This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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'])],
|
||||
|
||||
@@ -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)
|
||||
|
||||
398
scripts/lint-planning-artifact-writer-drift.cjs
Normal file
398
scripts/lint-planning-artifact-writer-drift.cjs
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user