All files / scripts lint-prompts.js

0% Statements 0/79
0% Branches 0/36
0% Functions 0/5
0% Lines 0/78

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202                                                                                                                                                                                                                                                                                                                                                                                                                   
#!/usr/bin/env node
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
 
/**
 * Agentic-workflow prompt linter.
 *
 * Enforces the single-PR rule and forbidden-phrase list across every
 * `.github/workflows/news-*.md` gh-aw workflow.
 *
 * Exceptions:
 *   - `news-translate.md` uses a legitimate multi-call flush pattern and is
 *     fully exempt from all three rules.
 *
 * Rules (applied per workflow file):
 *   1. `safeoutputs___create_pull_request` appears AT MOST ONCE.
 *   2. No forbidden phrases (case-insensitive): "checkpoint pr", "checkpoint-pr",
 *      "keep-alive", "keepalive", "keep alive", "heartbeat",
 *      "progressive safe output".
 *   3. No `safeoutputs___push_repo_memory` references.
 *   4. Analysis-awareness: news-*.md must either directly reference
 *      `analysis/methodologies/ai-driven-analysis-guide.md` and
 *      `03-analysis-completeness-gate.md`, OR import
 *      `.github/agents/news-generation.agent.md` (which provides both).
 *      news-translate.md is exempt.
 *
 * Usage:
 *   node scripts/lint-prompts.js
 *   node scripts/lint-prompts.js --workflows-dir .github/workflows
 *
 * Exit 0 if clean; non-zero with violations listed on stderr otherwise.
 */
 
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
 
const ROOT = process.cwd();
 
const DEFAULT_DIR = path.join('.github', 'workflows');
 
const ARGS = process.argv.slice(2);
const dirIdx = ARGS.indexOf('--workflows-dir');
const WORKFLOWS_DIR = dirIdx !== -1 && ARGS[dirIdx + 1]
  ? ARGS[dirIdx + 1]
  : DEFAULT_DIR;
 
const ABS_DIR = path.resolve(ROOT, WORKFLOWS_DIR);
 
const EXEMPT_FROM_SINGLE_PR = new Set(['news-translate.md']);
const EXEMPT_FROM_PUSH_MEMORY = new Set(['news-translate.md']);
// news-translate legitimately describes its own multi-call flush cadence, so it
// is allowed to reference "checkpoint", "keep-alive", "heartbeat" etc. in
// documentation form. The single-PR rule does not apply to it.
const EXEMPT_FROM_PHRASE_CHECK = new Set(['news-translate.md']);
// news-translate does not need the analysis chain (it is a translation-only
// flush pattern with no Stage B/C/D analysis).
const EXEMPT_FROM_ANALYSIS_AWARENESS = new Set(['news-translate.md']);
 
// Workflows either reference these anchors directly, OR import the shared
// news-generation agent which brings them in transitively.
const ANALYSIS_ANCHOR_GUIDE = 'analysis/methodologies/ai-driven-analysis-guide.md';
const ANALYSIS_ANCHOR_GATE = '03-analysis-completeness-gate.md';
const NEWS_GENERATION_IMPORT = '.github/agents/news-generation.agent.md';
 
const FORBIDDEN_PHRASES = [
  /\bcheckpoint\s+pr\b/i,
  /\bcheckpoint-pr\b/i,
  /\bkeep-alive\b/i,
  /\bkeepalive\b/i,
  /\bkeep\s+alive\b/i,
  /\bheartbeat\b/i,
  /\bprogressive\s+safe\s+output\b/i,
];
 
function collectWorkflowFiles(dir) {
  if (!fs.existsSync(dir)) {
    return [];
  }
  return fs
    .readdirSync(dir)
    .filter((name) => name.startsWith('news-') && name.endsWith('.md'))
    .sort();
}
 
function countOccurrences(text, needle) {
  if (!needle) return 0;
  let count = 0;
  let idx = text.indexOf(needle);
  while (idx !== -1) {
    count += 1;
    idx = text.indexOf(needle, idx + needle.length);
  }
  return count;
}
 
function lintFile(filePath, fileName) {
  const content = fs.readFileSync(filePath, 'utf8');
  const violations = [];
 
  // Rule 1: at most one safeoutputs___create_pull_request reference.
  if (!EXEMPT_FROM_SINGLE_PR.has(fileName)) {
    const prCount = countOccurrences(content, 'safeoutputs___create_pull_request');
    if (prCount > 1) {
      violations.push(
        `references 'safeoutputs___create_pull_request' ${prCount} times — must appear at most once (single-PR rule). See .github/prompts/06-pr-and-safe-outputs.md`,
      );
    }
  }
 
  // Rule 2: forbidden phrases.
  if (!EXEMPT_FROM_PHRASE_CHECK.has(fileName)) {
    for (const pattern of FORBIDDEN_PHRASES) {
      const match = content.match(pattern);
      if (match) {
        // Only record the first hit per pattern to keep output concise.
        const lineNumber = content.slice(0, match.index).split('\n').length;
        violations.push(
          `line ${lineNumber}: forbidden phrase ${JSON.stringify(match[0])} matched by ${pattern.toString()} — banned by single-PR rule. See .github/prompts/06-pr-and-safe-outputs.md`,
        );
      }
    }
  }
 
  // Rule 3: no safeoutputs___push_repo_memory outside news-translate.md.
  if (!EXEMPT_FROM_PUSH_MEMORY.has(fileName)) {
    if (content.includes('safeoutputs___push_repo_memory')) {
      const idx = content.indexOf('safeoutputs___push_repo_memory');
      const lineNumber = content.slice(0, idx).split('\n').length;
      violations.push(
        `line ${lineNumber}: 'safeoutputs___push_repo_memory' is banned (heartbeat pattern). Remove the reference.`,
      );
    }
  }
 
  // Rule 4: analysis-awareness.
  // Each news-*.md (except news-translate) must either directly reference the
  // analysis anchors or import the shared news-generation agent which carries
  // them transitively. This prevents a workflow from drifting out of the
  // Data → Analysis Artifacts → Completeness Gate → Article → PR chain.
  if (!EXEMPT_FROM_ANALYSIS_AWARENESS.has(fileName)) {
    const importsNewsGen = content.includes(NEWS_GENERATION_IMPORT);
    const refsGuide = content.includes(ANALYSIS_ANCHOR_GUIDE);
    const refsGate = content.includes(ANALYSIS_ANCHOR_GATE);
    if (!importsNewsGen) {
      if (!refsGuide) {
        violations.push(
          `missing analysis-awareness anchor: must either import '${NEWS_GENERATION_IMPORT}' or reference '${ANALYSIS_ANCHOR_GUIDE}'. See .github/prompts/README.md § Analysis Artifact Integration.`,
        );
      }
      if (!refsGate) {
        violations.push(
          `missing completeness-gate anchor: must either import '${NEWS_GENERATION_IMPORT}' or reference '${ANALYSIS_ANCHOR_GATE}'. See .github/prompts/README.md § Analysis Artifact Integration.`,
        );
      }
    }
  }
 
  return violations;
}
 
function main() {
  const files = collectWorkflowFiles(ABS_DIR);
  if (files.length === 0) {
    console.error(`lint-prompts: no news-*.md workflows found in ${ABS_DIR}`);
    process.exit(2);
  }
 
  let totalViolations = 0;
  let filesWithViolations = 0;
  const report = [];
  for (const fileName of files) {
    const filePath = path.join(ABS_DIR, fileName);
    const violations = lintFile(filePath, fileName);
    if (violations.length > 0) {
      totalViolations += violations.length;
      filesWithViolations += 1;
      report.push(`\n❌ ${fileName}`);
      for (const v of violations) {
        report.push(`   - ${v}`);
      }
    }
  }
 
  if (totalViolations === 0) {
    console.log(`lint-prompts: ✅ ${files.length} workflow(s) checked, 0 violations`);
    process.exit(0);
  }
 
  console.error(
    `lint-prompts: ❌ ${totalViolations} violation(s) across ${filesWithViolations} file(s)`,
  );
  for (const line of report) {
    console.error(line);
  }
  console.error('');
  console.error('Fix: see .github/prompts/06-pr-and-safe-outputs.md — the single-PR rule.');
  process.exit(1);
}
 
main();