enhance(#3180): the sibling validators share one envelope and one owner — Phase 12 (#3407)

This commit is contained in:
Tom Boucher
2026-08-13 11:31:16 -04:00
committed by GitHub
parent bf05479928
commit 6dbc124018
27 changed files with 2212 additions and 354 deletions

View File

@@ -35,6 +35,15 @@
* explicit and auditable instead of relying on a coincidental title
* match, and the PASS output now reports exempted codes SEPARATELY from
* genuinely fixture-covered ones rather than folding them together.
* 3. (Phase 12, #3310 — S0NN pass) `cmdStateValidate` (src/state.cts) emits
* 7 coded diagnostics (S001-S007) built inline via a local
* `stateDiagnostic()` helper — NOT `Rule`-table entries, so they are not
* read from the compiled module the way RULES/CONSISTENCY_RULES are.
* This third pass hardcodes that code list (`STATE_VALIDATE_CODES`,
* below) and applies the SAME §8.5 fixture-proof check against
* `tests/state.test.cjs`, reported in its own PASS/FAIL section —
* mirroring the C0NN pass's structure, just with a hardcoded list
* instead of a `Rule[]` array as the code source.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
* ("The lint guard (§8.2 1:1 invariant + §8.5 fixture proof)").
@@ -64,6 +73,24 @@ const COMPILED_MODULE_PATH = path.join(REPO_ROOT, COMPILED_MODULE_REL);
const TEST_GROUP_DIR = path.join(REPO_ROOT, 'tests', 'health-diagnostic-rules');
const SKELETON_TEST_FILE = path.join(REPO_ROOT, 'tests', 'health-diagnostic.test.cjs');
// Phase 12 (#3310, ADR-3180 §8.4) — the C0NN namespace's own fixture-proof
// test file. §8.5 extends to `CONSISTENCY_RULES`'s NEW codes only (C001-C004
// — W006/W007 are already fixture-proofed above, against the SAME `Rule`
// objects; re-checking them here would be redundant, not additional
// coverage).
const CONSISTENCY_TEST_FILE = path.join(REPO_ROOT, 'tests', 'health-diagnostic-rules', 'consistency.test.cjs');
const CONSISTENCY_CODE_PREFIX_RE = /^C\d{3}$/;
// Phase 12 (#3310, ADR-3180 §8.5 extension) — the S0NN namespace's own
// fixture-proof pass. These 7 codes are NOT collected in any exported
// `Rule[]` array: `cmdStateValidate` (src/state.cts) builds `Diagnostic[]`
// directly via a local `stateDiagnostic()` helper, not via the rule-table
// evaluator (out of scope for the RULES/CONSISTENCY_RULES-keyed passes
// above). The list is therefore hardcoded here instead of read from the
// compiled module.
const STATE_VALIDATE_TEST_FILE = path.join(REPO_ROOT, 'tests', 'state.test.cjs');
const STATE_VALIDATE_CODES = ['S001', 'S002', 'S003', 'S004', 'S005', 'S006', 'S007'];
// Matches `describe(`/`test(`/`it(` calls whose first argument is a string
// literal, capturing that literal as the block's title. Line/regex-based
// (not full AST) per this repo's existing lint-guard house style
@@ -221,13 +248,38 @@ function formatRepoRelative(absPath) {
}
function main() {
const { RULES, SEVERITY } = loadCompiledModule();
const { RULES, CONSISTENCY_RULES, SEVERITY } = loadCompiledModule();
const { duplicates, badSeverities } = checkOneToOneInvariant(RULES, SEVERITY);
const testFiles = findHealthDiagnosticTestFiles(REPO_ROOT);
const { uncovered, exempted } = checkFixtureProofInvariant(RULES, testFiles);
// ─── C0NN check pass (Phase 12, #3310) — separate from the W/E/I pass
// above, own reporting section, does NOT re-check W006/W007's fixture
// proof (already covered above against the same `Rule` objects).
const consistencyNewRules = CONSISTENCY_RULES.filter((r) => CONSISTENCY_CODE_PREFIX_RE.test(r.code));
const { duplicates: consistencyDuplicates, badSeverities: consistencyBadSeverities } = checkOneToOneInvariant(
consistencyNewRules,
SEVERITY,
);
const consistencyTestFiles = fs.existsSync(CONSISTENCY_TEST_FILE) ? [CONSISTENCY_TEST_FILE] : [];
const { uncovered: consistencyUncovered } = checkFixtureProofInvariant(
consistencyNewRules,
consistencyTestFiles,
new Map(),
);
// ─── S0NN check pass (Phase 12, #3310) — separate from the W/E/I and C0NN
// passes above, own reporting section. Hardcoded code list (see the
// STATE_VALIDATE_CODES comment above) rather than read from a Rule[] array.
const stateValidateTestFiles = fs.existsSync(STATE_VALIDATE_TEST_FILE) ? [STATE_VALIDATE_TEST_FILE] : [];
const { uncovered: stateValidateUncovered } = checkFixtureProofInvariant(
STATE_VALIDATE_CODES.map((code) => ({ code })),
stateValidateTestFiles,
new Map(),
);
const problems = [];
if (duplicates.length > 0) {
@@ -263,6 +315,50 @@ function main() {
);
}
if (consistencyDuplicates.length > 0) {
const list = consistencyDuplicates.map((d) => ` ${d.code} (${d.count} occurrences)`).join('\n');
problems.push(
`§8.2 rule 1 violated (C0NN namespace): ${consistencyDuplicates.length} duplicated rule code(s) in ` +
`CONSISTENCY_RULES (${COMPILED_MODULE_REL}):\n${list}\n` +
' remedy: codes are append-only and 1:1 with a single Rule — rename or remove the duplicate.',
);
}
if (consistencyBadSeverities.length > 0) {
const list = consistencyBadSeverities
.map((b) => ` ${b.code}: severity=${JSON.stringify(b.severity)}`)
.join('\n');
problems.push(
`§8.2 rule 3 violated (C0NN namespace): ${consistencyBadSeverities.length} rule(s) with a severity not ` +
`in SEVERITY's values:\n${list}\n` +
' remedy: severity is a property of the RULE — set it to SEVERITY.ERROR/WARNING/INFO.',
);
}
if (consistencyUncovered.length > 0) {
problems.push(
`§8.5 violated (C0NN namespace): ${consistencyUncovered.length} rule code(s) with no describe()/test() ` +
`block naming them (a comment or bare string mention does not count):\n ${consistencyUncovered.join(', ')}\n\n` +
` Searched: ${formatRepoRelative(CONSISTENCY_TEST_FILE)}` +
(consistencyTestFiles.length === 0 ? ' (file not found)' : '') +
'\n\n remedy: add or extend a describe()/test() title in tests/health-diagnostic-rules/consistency.test.cjs ' +
`so the block title names the code verbatim (e.g. describe('${consistencyUncovered[0]} — ...', () => { ... })), ` +
'and drive the rule to fire against a real fixture built via createTempDir() + buildPlanningSnapshot().',
);
}
if (stateValidateUncovered.length > 0) {
problems.push(
`§8.5 violated (S0NN namespace): ${stateValidateUncovered.length} code(s) with no describe()/test() ` +
`block naming them (a comment or bare string mention does not count):\n ${stateValidateUncovered.join(', ')}\n\n` +
` Searched: ${formatRepoRelative(STATE_VALIDATE_TEST_FILE)}` +
(stateValidateTestFiles.length === 0 ? ' (file not found)' : '') +
'\n\n remedy: add or extend a describe()/test() title in tests/state.test.cjs ' +
`so the block title names the code verbatim (e.g. test('${stateValidateUncovered[0]}: ...', () => { ... })), ` +
'mirroring the existing `#3310 state validate — S0NN coded diagnostics` describe block.',
);
}
if (problems.length > 0) {
throw new ExitError(1, `${problems.join('\n\n')}\n`);
}
@@ -277,6 +373,16 @@ function main() {
`real fixture, ${exempted.length} exempted across ${testFiles.length} test file(s).` +
(exempted.length > 0 ? `\n Exempted: ${exemptedDetail}` : ''),
);
console.log(
`lint-health-diagnostic-rule-table: PASS (C0NN namespace) — ${consistencyNewRules.length} new rule code(s) ` +
`(${consistencyNewRules.map((r) => r.code).join(', ')}), all fixture-covered in ` +
`${formatRepoRelative(CONSISTENCY_TEST_FILE)}.`,
);
console.log(
`lint-health-diagnostic-rule-table: PASS (S0NN namespace) — ${STATE_VALIDATE_CODES.length} code(s) ` +
`(${STATE_VALIDATE_CODES.join(', ')}), all fixture-covered in ` +
`${formatRepoRelative(STATE_VALIDATE_TEST_FILE)}.`,
);
}
runMain(main);
@@ -292,4 +398,7 @@ module.exports = {
COMPILED_MODULE_PATH,
TEST_GROUP_DIR,
SKELETON_TEST_FILE,
CONSISTENCY_TEST_FILE,
STATE_VALIDATE_TEST_FILE,
STATE_VALIDATE_CODES,
};

View File

@@ -67,10 +67,6 @@
* see the PHYSICAL set so a heading already scoped by
* `extractCurrentMilestoneScoped` can find its directory; filtering it
* through the owner would scope the same set twice.
* - `src/verify.cts` `collectDiskPhases`: a DRIFT DIAGNOSTIC comparing
* what is on disk against what the ROADMAP declares. It wants the
* physical set by definition — scoping it would make the diagnostic
* unable to see the very drift it exists to report.
* - `src/verify.cts` `cmdValidateHealth`: a project-wide HEALTH-CHECK sweep
* (config drift, phase-directory naming, duplicate-directory collisions,
* unsummarized-plan detection) — same "sweep everything, report gaps"
@@ -274,7 +270,7 @@ const OWNER_FILES = new Set([
// See the header comment for the full written reason behind each entry.
const FUNCTION_SCOPED_EXEMPTIONS = new Map([
[path.join('src', 'roadmap.cts'), new Set(['cmdRoadmapAnalyze'])],
[path.join('src', 'verify.cts'), new Set(['collectDiskPhases', 'cmdValidateHealth', 'cmdVerifySchemaDrift'])],
[path.join('src', 'verify.cts'), new Set(['cmdValidateHealth', 'cmdVerifySchemaDrift'])],
[path.join('src', 'init.cts'), new Set(['detectHasPriorPhases', 'detectUiPhaseActive', 'cmdInitMilestoneOp'])],
[path.join('src', 'milestone.cts'), new Set(['archivePhaseDirectories', 'cmdMilestoneComplete', 'cmdPhasesClear'])],
[path.join('src', 'phase.cts'), new Set(['cmdPhasesList', 'cmdPhaseNextDecimal', 'cmdPhasePlanIndex', 'cmdPhaseInsert', 'renameDecimalPhases', 'renameIntegerPhases'])],

View File

@@ -151,13 +151,15 @@ const CORE_UTILS_EXEMPT_FUNCTIONS = new Set([
// #2296/#2070/#2838) byte-for-behaviour rather than the phase-scoped
// plan-scan owner's root+nested rule — "rescue every summary before a
// merge blows it away" is not a live-plan/completion count.
// - verify.cts cmdValidateConsistency: the strict `-(\d{2})-PLAN\.md$`
// match extracts a zero-padded SEQUENCE NUMBER from filenames the owner
// (`allPlanFiles`) already classified as plans — it does not re-derive
// "is this a plan", it answers a different, narrower question (does the
// canonical 2-digit numbering sequence have a gap) that the owner's
// boolean plan/summary classification cannot answer. See the extended
// inline comment at that call site for the full Question 1/2/3 split.
// - planning-snapshot.cts buildPerPhasePlanScanFields (Phase 12, #3310,
// ADR-3180 §8.4): a strict `-(\d{2})-PLAN\.md$` match extracts a
// zero-padded SEQUENCE NUMBER from filenames the owner (`allPlanFiles`)
// already classified as plans, into the `perPhasePlanNumbering`
// `PlanningSnapshot` field so `validate.consistency`'s C002 rule can read
// it via the shared snapshot instead of re-scanning disk. It does not
// re-derive "is this a plan" — it answers a different, narrower question
// (does the canonical 2-digit numbering sequence have a gap) that the
// owner's boolean plan/summary classification cannot answer.
const FUNCTION_SCOPED_EXEMPTIONS = new Map([
[CORE_UTILS_FILE, CORE_UTILS_EXEMPT_FUNCTIONS],
[path.join('src', 'audit.cts'), new Set(['scanQuickTasks'])],
@@ -165,7 +167,7 @@ const FUNCTION_SCOPED_EXEMPTIONS = new Map([
[path.join('src', 'estimate-cli.cts'), new Set(['collectCalibrationSamples'])],
[path.join('src', 'roadmap.cts'), new Set(['cmdRoadmapAnnotateDependencies'])],
[path.join('src', 'worktree-safety.cts'), new Set(['defaultFindSummaryFiles'])],
[path.join('src', 'verify.cts'), new Set(['cmdValidateConsistency'])],
[path.join('src', 'planning-snapshot.cts'), new Set(['buildPerPhasePlanScanFields'])],
]);
// Optional `export ` modifier: `collectCalibrationSamples` (estimate-cli.cts)

View File

@@ -0,0 +1,398 @@
#!/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,
};