#!/usr/bin/env node 'use strict'; /** * Registry-completeness guard for the `.planning/`-root artifact registry * (epic #3180, ADR-3180 §8.4 deliverable C, Phase 12 #3310). * * `src/artifacts.cts`'s `isCanonicalPlanningFile` enumerates every file name * gsd workflows officially write at the `.planning/` ROOT (used today by * `validate.health`'s W019 to flag unrecognized files). Nothing previously * checked the OTHER direction: that every actual writer of a `.planning/` * root file is itself represented in that registry. This guard closes that * gap statically — it does not run any code, it scans `src/*.cts` for a * write whose target file name can be determined AT READ TIME and checks * that name against the real (compiled) `isCanonicalPlanningFile`. * * ## What counts as a checkable write * * A call to `platformWriteSync(` or `fs.writeFileSync(` whose first argument * resolves — through same-file, single-hop static tracing only — to a * LITERAL `.md`/`.json` file name joined onto an UNAMBIGUOUS `.planning/` * root expression. Three source shapes are recognized, all real patterns * found in this codebase (`src/roadmap.cts`, `src/state.cts`, * `src/config.cts`, `src/milestone.cts`, `src/health-diagnostic.cts`): * * 1. `path.join(, 'Literal.md')` — inline, or assigned first to a * `const`/`let` binding or an object-literal property * (`key: path.join(, 'Literal.md')`) that is later passed to the * write call by name. Object-literal properties are traced by NAME * only (no cross-function data-flow) — this matches the actual * `RepairPaths`-style convention this repo already uses * (`src/health-diagnostic.cts:209-218`), where a destructured * parameter reuses the same identifier the property was defined with. * 2. `planningPaths(cwd).` for `` in `state`, `roadmap`, * `project`, `config`, `requirements` — the `PlanningPaths` interface's * own file-valued properties (`src/planning-workspace.cts`), which are * already a full path, not a directory to join further. * 3. A `` in both forms above means EXACTLY `planningRoot(cwd)`, * `planningDir(cwd)` (no second argument), or `planningPaths(cwd)` * (no second argument) `.planning` — deliberately excluding any call * that passes a workstream/project argument (`planningDir(cwd, ws)`), * because that form can resolve UNDER `.planning/workstreams//` * instead of the `.planning/` root, and this guard cannot tell * statically whether `ws` is truthy at runtime. Per this guard's own * design brief: "false negatives are safer than false positives" — an * ambiguous root expression is silently skipped, never reported either * way. * * Anything else — a template-literal or otherwise runtime-computed target, * a multi-segment join landing under `phases/`, `milestones/`, or * `workstreams/`, a path built through an intermediate helper this guard * does not recognize (e.g. `path.dirname(x)`, a ternary, a `.gsd/` * fallback) — is silently skipped. This guard reports VIOLATIONS only; a * skipped write is never counted as a pass either. See the module docblock * above `src/artifacts.cts` for the registry's own stated scope * (".planning/ root level" only) — this guard shares that scope. * * ## Caveat: same-file, name-based tracing (not a real data-flow analysis) * * Both the `const`/`let` and object-literal-property forms are tracked in a * single flat, WHOLE-FILE, name -> file-name map (no per-function scoping). * If the same identifier were reused in one file for two unrelated purposes * — one a real `.planning/`-root join, the other something else entirely — * this guard could mis-resolve the second one. No such collision exists in * `src/*.cts` today (verified during implementation); this is a known, * accepted heuristic limit, matching this guard's explicitly simpler * (non-function-scoped) design versus its sibling * `lint-planning-snapshot-bypass-drift.cjs`. * * ## No ratchet / no baseline * * Unlike its `lint-*-drift.cjs` siblings, this is a bare pass/fail check, * not a shrinking-debt baseline: a ground-truth sweep of this codebase * found every real `.planning/`-root writer already registered, so there is * no inherited debt to grandfather. Any violation this guard reports is a * genuine, actionable regression. * * Tree-walk / root-confinement / symlink / sanitizer machinery is shared * via `scripts/lib/drift-scan.cjs`, exactly like every sibling guard. */ const fs = require('node:fs'); const path = require('node:path'); const driftScan = require('./lib/drift-scan.cjs'); const { sanitizeForReport, scanTree } = driftScan; const REPO_ROOT = path.join(__dirname, '..'); const COMPILED_MODULE_REL = path.join('gsd-core', 'bin', 'lib', 'artifacts.cjs'); const COMPILED_MODULE_PATH = path.join(REPO_ROOT, COMPILED_MODULE_REL); // Authored TypeScript source only — mirrors every sibling drift guard. const SCAN_DIRS = ['src']; const SCAN_EXT = new Set(['.cts']); // `PlanningPaths` (src/planning-workspace.cts) properties that are // themselves a FULL FILE path (not a directory) at the `.planning/` root, // mapped to the literal file name they resolve to. `planning`/`phases`/ // `debug` are deliberately absent — those are directories, not files. const PLANNING_PATHS_FILE_PROPS = new Map([ ['state', 'STATE.md'], ['roadmap', 'ROADMAP.md'], ['project', 'PROJECT.md'], ['config', 'config.json'], ['requirements', 'REQUIREMENTS.md'], ]); // The three unambiguous `.planning/`-root expressions this guard recognizes // as the first argument of `path.join(...)`. Each takes ONLY `cwd` — a call // carrying a workstream/project argument is excluded (see module docblock). const ROOT_CALL_SRC = String.raw`planningRoot\(cwd\)|planningDir\(cwd\)|planningPaths\(cwd\)\.planning`; // A quoted literal file name ending in `.md` or `.json` — single-quoted or // double-quoted, as two separate alternatives (groups: single-quoted // filename, double-quoted filename) rather than a `(['"])...\1` // backreference: this fragment is spliced into several different larger // regexes below at different capture-group OFFSETS, so a fixed // backreference number (`\1`) would silently point at whichever group // happens to be first in THAT particular composed regex, not necessarily // this fragment's own quote group. Every call site reads // `matched[i] ?? matched[i + 1]` for the two alternative filename groups // this fragment always contributes, in order. Deliberately excludes // backticks: a template literal is a runtime-computed target by definition // and must never match here. const LITERAL_FILENAME_SRC = String.raw`(?:'([^'\\]+\.(?:md|json))'|"([^"\\]+\.(?:md|json))")`; // `const X = ;` / `let X = ;` — binds X to an unambiguous root // expression, so a later `path.join(X, 'Literal.md')` can resolve through // it (mirrors `src/config.cts`'s `planningBase` / `src/health-diagnostic // .cts`'s `rootBase`/`wsBase`). const ROOT_VAR_ASSIGN_RE = new RegExp(String.raw`\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:${ROOT_CALL_SRC})\s*;`); // A "binding" — either `const X = ...` / `let X = ...`, or an object-literal // property `X: ...` — shared by both join-tracing regexes below so a // destructured-later property (the `RepairPaths` convention) traces the // same way a local variable does. Group 1 is the const/let name, group 2 is // the property name; callers use whichever is non-undefined. const BINDING_PREFIX_SRC = String.raw`(?:(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=|([A-Za-z_$][\w$]*)\s*:)`; // ` = path.join(, 'Literal.md')` — the root expression is // spelled out inline (not through an intermediate variable). const JOIN_FROM_ROOT_CALL_RE = new RegExp( String.raw`${BINDING_PREFIX_SRC}\s*path\.join\(\s*(?:${ROOT_CALL_SRC})\s*,\s*${LITERAL_FILENAME_SRC}\s*\)`, ); // ` = path.join(IDENT, 'Literal.md')` — the root expression was // already bound to IDENT by ROOT_VAR_ASSIGN_RE elsewhere in the file. const JOIN_FROM_IDENT_RE = new RegExp( String.raw`${BINDING_PREFIX_SRC}\s*path\.join\(\s*([A-Za-z_$][\w$]*)\s*,\s*${LITERAL_FILENAME_SRC}\s*\)`, ); // ` = planningPaths(cwd).` — PLANNING_PATHS_FILE_PROPS below // maps to its file name. const PLANNING_PATHS_PROP_RE = new RegExp( String.raw`${BINDING_PREFIX_SRC}\s*planningPaths\(cwd\)\.([A-Za-z_$][\w$]*)\b`, ); // The write calls this guard checks the first argument of. const WRITE_CALL_RE = /\b(?:platformWriteSync|fs\.writeFileSync)\(/g; // A bare identifier, or an inline `path.join(, 'Literal.md')` / // `planningPaths(cwd).` expression, as the resolved first-argument // text of a write call. const INLINE_JOIN_ROOT_RE = new RegExp(String.raw`^path\.join\(\s*(?:${ROOT_CALL_SRC})\s*,\s*${LITERAL_FILENAME_SRC}\s*\)$`); const INLINE_PLANNING_PATHS_PROP_RE = /^planningPaths\(cwd\)\.([A-Za-z_$][\w$]*)$/; const BARE_IDENT_RE = /^[A-Za-z_$][\w$]*$/; /** * Strip `//` line comments and `/* ... *\/` block comments from `line`, * preserving the CONTENTS of single/double/backtick-quoted strings verbatim * (so a filename literal or an identifier that happens to sit inside a * string is never mistaken for code, but a `//`/`/*` inside a string never * truncates the line either). No cross-line state: a template literal or * block comment that spans multiple lines is left as-is on each line it * touches — every real call/assignment this guard matches is single-line in * `src/*.cts` today, so cross-line tracking would add complexity with no * observed benefit (see this guard's "simpler than its sibling" design * note). */ function stripLineComment(line) { let out = ''; let i = 0; while (i < line.length) { const ch = line[i]; if (ch === '/' && line[i + 1] === '/') break; if (ch === '/' && line[i + 1] === '*') { const close = line.indexOf('*/', i + 2); if (close === -1) { i = line.length; break; } i = close + 2; continue; } if (ch === "'" || ch === '"' || ch === '`') { const quote = ch; const start = i; let j = i + 1; while (j < line.length) { if (line[j] === '\\') { j += 2; continue; } if (line[j] === quote) { j++; break; } j++; } out += line.slice(start, j); i = j; continue; } out += ch; i++; } return out; } /** * Scan forward from `openParenIdx` (the index of a call's opening `(`) and * return the TEXT of its first argument — up to the first top-level comma, * or the call's own closing paren if it has only one argument — respecting * nested parens and quoted strings so an inner `path.join(a, 'b.md')` * comma never terminates early. Returns null if the call does not close on * this line (a genuinely multi-line call is out of this guard's scope — see * module docblock). */ function extractFirstArg(line, openParenIdx) { let depth = 1; let i = openParenIdx + 1; const start = i; while (i < line.length) { const ch = line[i]; if (ch === "'" || ch === '"' || ch === '`') { const quote = ch; i++; while (i < line.length) { if (line[i] === '\\') { i += 2; continue; } if (line[i] === quote) { i++; break; } i++; } continue; } if (ch === '(') { depth++; i++; continue; } if (ch === ')') { if (depth === 1) return line.slice(start, i).trim(); depth--; i++; continue; } if (ch === ',' && depth === 1) return line.slice(start, i).trim(); i++; } return null; // unterminated on this line — skip (see docblock) } /** * Pure: scan `text` (one `src/*.cts` file's contents) for every write call * this guard can statically resolve to a literal `.planning/`-root file * name. Returns EVERY resolved candidate (canonical or not) — filtering to * violations only happens in `findArtifactWriterDrift` — so tests and * callers can tell "not checked" (candidate absent) apart from "checked and * passed" (candidate present, canonical). */ function scanFileForCandidates(text, relPath) { const file = relPath.replace(/\\/g, '/'); const originalLines = text.split('\n'); const lines = originalLines.map(stripLineComment); // Pass 1: build the whole-file name -> file-name maps (see module // docblock for the "flat, same-file, name-based" tracing this performs). const rootVars = new Set(); const filenameVars = new Map(); for (const line of lines) { const rootMatch = ROOT_VAR_ASSIGN_RE.exec(line); if (rootMatch) rootVars.add(rootMatch[1]); } for (const line of lines) { const m1 = JOIN_FROM_ROOT_CALL_RE.exec(line); if (m1) { const name = m1[1] || m1[2]; filenameVars.set(name, m1[3] || m1[4]); continue; } const propMatch = PLANNING_PATHS_PROP_RE.exec(line); if (propMatch) { const name = propMatch[1] || propMatch[2]; const prop = propMatch[3]; if (PLANNING_PATHS_FILE_PROPS.has(prop)) filenameVars.set(name, PLANNING_PATHS_FILE_PROPS.get(prop)); continue; } const m2 = JOIN_FROM_IDENT_RE.exec(line); if (m2) { const name = m2[1] || m2[2]; const sourceIdent = m2[3]; if (rootVars.has(sourceIdent)) filenameVars.set(name, m2[4] || m2[5]); } } // Pass 2: resolve every write call's first argument. const out = []; for (let li = 0; li < lines.length; li++) { const line = lines[li]; WRITE_CALL_RE.lastIndex = 0; let callMatch; while ((callMatch = WRITE_CALL_RE.exec(line)) !== null) { const openParenIdx = callMatch.index + callMatch[0].length - 1; const argText = extractFirstArg(line, openParenIdx); if (argText === null) continue; let filename = null; if (BARE_IDENT_RE.test(argText)) { if (filenameVars.has(argText)) filename = filenameVars.get(argText); } else { const inlineJoin = INLINE_JOIN_ROOT_RE.exec(argText); if (inlineJoin) { filename = inlineJoin[1] || inlineJoin[2]; } else { const inlineProp = INLINE_PLANNING_PATHS_PROP_RE.exec(argText); if (inlineProp && PLANNING_PATHS_FILE_PROPS.has(inlineProp[1])) filename = PLANNING_PATHS_FILE_PROPS.get(inlineProp[1]); } } if (filename !== null) { out.push({ file, line: li + 1, filename, text: originalLines[li].trim() }); } } } return out; } /** * Pure: `scanFileForCandidates` filtered to violations — a resolved * candidate whose file name `isCanonicalPlanningFile` rejects. * `isCanonical` defaults to the REAL, compiled function (loaded lazily so a * missing `npm run build:lib` only errors when this guard actually runs, * not merely on `require`) but is overridable for tests that want to * exercise the filter without a build. */ function findArtifactWriterDrift(text, relPath, isCanonical) { const check = isCanonical || loadIsCanonicalPlanningFile(); return scanFileForCandidates(text, relPath).filter((c) => !check(c.filename)); } let _isCanonicalPlanningFile = null; function loadIsCanonicalPlanningFile() { if (_isCanonicalPlanningFile) return _isCanonicalPlanningFile; if (!fs.existsSync(COMPILED_MODULE_PATH)) { throw new Error( `lint-planning-artifact-writer-drift: compiled artifact not found at ${COMPILED_MODULE_REL}.\n` + 'Run `npm run build:lib` first.', ); } const mod = require(COMPILED_MODULE_PATH); if (typeof mod.isCanonicalPlanningFile !== 'function') { throw new Error(`lint-planning-artifact-writer-drift: ${COMPILED_MODULE_REL} does not export isCanonicalPlanningFile()`); } _isCanonicalPlanningFile = mod.isCanonicalPlanningFile; return _isCanonicalPlanningFile; } /** Scan the authored source tree and return every writer-registry violation. */ function scanRepo(root) { const isCanonical = loadIsCanonicalPlanningFile(); return scanTree({ root, scanDirs: SCAN_DIRS, scanExt: SCAN_EXT, onFile(rel, text) { return findArtifactWriterDrift(text, rel, isCanonical); }, }); } function main() { const violations = scanRepo(REPO_ROOT); if (violations.length === 0) { process.stdout.write('ok planning-artifact-writer: every statically-resolvable .planning/-root write is a registered canonical artifact\n'); return; } process.stderr.write('planning-artifact-writer: unregistered .planning/-root artifact write(s) found.\n'); process.stderr.write('Every write of a literal .planning/-root file name must be reflected in the registry\n'); process.stderr.write("(src/artifacts.cts's isCanonicalPlanningFile, consumed by validate.health's W019):\n"); for (const v of violations) { process.stderr.write( ` ${sanitizeForReport(v.file)}:${v.line} '${sanitizeForReport(v.filename)}' ${sanitizeForReport(v.text)}\n` + ` remedy: add '${sanitizeForReport(v.filename)}' to CANONICAL_EXACT in src/artifacts.cts, ` + 'or a CANONICAL_PATTERNS regex if it is version-stamped\n', ); } process.exitCode = 1; } if (require.main === module) main(); module.exports = { scanFileForCandidates, findArtifactWriterDrift, scanRepo, stripLineComment, extractFirstArg, PLANNING_PATHS_FILE_PROPS, SCAN_DIRS, SCAN_EXT, COMPILED_MODULE_PATH, COMPILED_MODULE_REL, };