#!/usr/bin/env node // tools/scripts/lint-no-req-placeholders.mjs // [impl->REQ-HYG-02] // Source: 07-CONTEXT.md D-19/D-20 — drift-lock against REQ-…-XX placeholder regression in docs/, .planning/, packages/, apps/, tools/. // // Greps the canonical scan roots (mirrors traceable-reqs.toml [scan].roots) for // placeholder REQ-IDs and fails on any hit outside the ALLOWLIST. The allowlist // excludes files that DOCUMENT the regex itself (this script, REQUIREMENTS.md // examples, milestone retrospective notes, phase planning context) — those // must be free to quote the placeholder pattern without tripping the lint. // // Usage: node tools/scripts/lint-no-req-placeholders.mjs // Exit: 0 clean, 1 violation, 2 internal error. import { execSync } from 'node:child_process'; // CONTEXT.md D-20 placeholder regex. Note: `REQ-X[^-A-Z0-9]` catches bare // `REQ-X` followed by a non-ID character (e.g. `REQ-X.` or `REQ-X `) but does // NOT match well-formed IDs like `REQ-XYZ-01`. The trailing-`-XX` alternations // catch the documented placeholder shapes. const BAD_PATTERN = 'REQ-SRV-XX|REQ-X[^-A-Z0-9]|REQ-MAP-XX|REQ-HYG-XX|REQ-[A-Z]+-XX'; // Mirror of traceable-reqs.toml [scan].roots as of Phase 7. Manifest drift // becomes visible in code review. const SCAN_PATHS = ['docs/', '.planning/', 'packages/', 'apps/', 'tools/']; // Path-prefix allowlist. An entry ending in `/` matches all descendants. // PITFALL 2 (07-PATTERNS.md): this allowlist MUST be present in the FIRST commit. const ALLOWLIST = [ '.planning/REQUIREMENTS.md', '.planning/STATE.md', '.planning/PROJECT.md', '.planning/ROADMAP.md', '.planning/research/v1.1/', '.planning/phases/07-workflow-smoke-convention-locks/', '.planning/milestones/', '.planning/MILESTONES.md', 'tools/scripts/lint-no-req-placeholders.mjs', 'traceable-reqs.toml', ]; function isExecError(e) { return typeof e === 'object' && e !== null && 'status' in e; } function isAllowed(path) { for (const entry of ALLOWLIST) { if (entry.endsWith('/')) { if (path.startsWith(entry)) return true; } else if (path === entry) { return true; } } return false; } try { const cmd = `git grep -nE "${BAD_PATTERN}" -- ${SCAN_PATHS.join(' ')}`; const out = execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }); const lines = out.split('\n').filter((l) => l.length > 0); const violations = []; for (const line of lines) { // Format: :: const firstColon = line.indexOf(':'); if (firstColon < 0) continue; const path = line.slice(0, firstColon); if (!isAllowed(path)) violations.push(line); } if (violations.length > 0) { for (const v of violations) process.stderr.write(v + '\n'); process.stderr.write( `lint-no-req-placeholders: ${violations.length} violation(s)\n`, ); process.exit(1); } console.log('lint-no-req-placeholders: OK (no REQ-*-XX placeholders outside allowlist)'); process.exit(0); } catch (e) { if (isExecError(e) && e.status === 1) { // git-grep exit 1 = no matches found = success console.log('lint-no-req-placeholders: OK (no matches in scan roots)'); process.exit(0); } process.stderr.write('lint-no-req-placeholders: internal error\n'); if (isExecError(e) && e.stderr) process.stderr.write(String(e.stderr)); if (e instanceof Error) process.stderr.write('\n' + e.message + '\n'); process.exit(2); }