399 lines
17 KiB
JavaScript
399 lines
17 KiB
JavaScript
#!/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(<ROOT>, 'Literal.md')` — inline, or assigned first to a
|
|
* `const`/`let` binding or an object-literal property
|
|
* (`key: path.join(<ROOT>, '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).<prop>` for `<prop>` 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 `<ROOT>` 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/<ws>/`
|
|
* 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 = <ROOT>;` / `let X = <ROOT>;` — 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*:)`;
|
|
|
|
// `<binding> = path.join(<ROOT>, '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*\)`,
|
|
);
|
|
|
|
// `<binding> = 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*\)`,
|
|
);
|
|
|
|
// `<binding> = planningPaths(cwd).<prop>` — PLANNING_PATHS_FILE_PROPS below
|
|
// maps <prop> 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(<ROOT>, 'Literal.md')` /
|
|
// `planningPaths(cwd).<prop>` 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,
|
|
};
|