From d1b659703e66c8a9f0173565ad8ffcd8f3bfe42d Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 12 Aug 2026 21:43:25 -0400 Subject: [PATCH 1/7] test(#3308): failing-first tests for planning-snapshot parsed projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-3180 epic #3180 Phase 10 (§8.1): tests for the not-yet-existing src/planning-snapshot.cts (buildPlanningSnapshot, worstScope), the not-yet-existing scripts/lint-planning-snapshot-bypass-drift.cjs guard, and the new STATE_UNREADABLE reason on tests/unusable-input.test.cjs's already-shipped UNUSABLE_REASON enum. RED by construction: the modules under test do not exist yet. --- tests/planning-snapshot-bypass-drift.test.cjs | 203 +++++++ tests/planning-snapshot.test.cjs | 497 ++++++++++++++++++ tests/unusable-input.test.cjs | 49 +- 3 files changed, 748 insertions(+), 1 deletion(-) create mode 100644 tests/planning-snapshot-bypass-drift.test.cjs create mode 100644 tests/planning-snapshot.test.cjs diff --git a/tests/planning-snapshot-bypass-drift.test.cjs b/tests/planning-snapshot-bypass-drift.test.cjs new file mode 100644 index 000000000..1fff7fe2b --- /dev/null +++ b/tests/planning-snapshot-bypass-drift.test.cjs @@ -0,0 +1,203 @@ +/** + * Tests for the planning-snapshot bypass drift guard (Phase 10, issue #3308, + * ADR-3180 §8.1 rule 2) — `scripts/lint-planning-snapshot-bypass-drift.cjs`. + * + * ADR-3180 §8.1 rule 2: a diagnostic rule may see only PARSED values from + * `src/planning-snapshot.cts`, never raw `.planning/` document text. + * `cmdValidateHealth` (`src/verify.cts`) is the one diagnostic-rule-shaped + * function in the repo today that has NOT yet been migrated onto the + * snapshot (Phase 11, issue #3309) — this guard tracks that acknowledged + * debt via a shrink-only ratchet baseline, keyed function-scoped through + * `DIAGNOSTIC_RULE_FUNCTIONS` (mirroring + * `lint-completion-ratio-drift.cjs`'s `FUNCTION_SCOPED_EXEMPTIONS`, inverted: + * this registry names which functions the rule APPLIES TO, not which are + * exempt from it). + * + * NOTE: `scripts/lint-planning-snapshot-bypass-drift.cjs` does not exist yet + * (Phase 10 is TDD — this test file is written first and is expected to fail + * with a require/MODULE_NOT_FOUND error until the guard script lands). This + * mirrors `tests/planning-prompt-drift.test.cjs`'s structure and asserts + * only the guard's PURE functions, driven with in-memory strings/objects — + * no shelling out to the CLI. + */ + +'use strict'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const drift = require('../scripts/lint-planning-snapshot-bypass-drift.cjs'); +const { + findSnapshotBypassDrift, + diffAgainstBaseline, + writeBaseline, + dedupeViolationsForBaseline, + sortEntries, + DIAGNOSTIC_RULE_FUNCTIONS, +} = drift; +const { createTempDir, cleanup } = require('./helpers.cjs'); +const fs = require('node:fs'); +const path = require('node:path'); + +const REGISTERED_FILE = path.join('src', 'verify.cts'); +const REGISTERED_FN = 'cmdValidateHealth'; + +// A minimal fixture carrying one registered diagnostic-rule-shaped function +// (containing a raw-read primitive) and one unregistered function (carrying +// the identical raw-read line) — proves detection is function-SCOPED, not +// whole-file. +function fixtureSource() { + return [ + 'function cmdValidateHealth(cwd) {', + ' const raw = platformReadSync(x);', + ' return raw;', + '}', + '', + 'function cmdUnrelated(cwd) {', + ' const raw = platformReadSync(x);', + ' return raw;', + '}', + '', + ].join('\n'); +} + +// ─── G1/G3: registered-function raw-read line, baseline mechanics ──────── + +describe('findSnapshotBypassDrift — G1/G3: registered function raw-read detection', () => { + test('a raw-read line inside a DIAGNOSTIC_RULE_FUNCTIONS-registered function is detected', () => { + const out = findSnapshotBypassDrift(fixtureSource(), REGISTERED_FILE); + // Exactly ONE violation: the registered cmdValidateHealth copy, not the + // unregistered cmdUnrelated copy (see G2 below for the direct negative). + assert.strictEqual(out.length, 1); + assert.strictEqual(out[0].line, 2); + assert.strictEqual(out[0].found, 'platformReadSync('); + assert.strictEqual(out[0].text, 'const raw = platformReadSync(x);'); + assert.strictEqual(out[0].file, REGISTERED_FILE); + }); + + test('G1: a registered-function violation present in the baseline is KNOWN — neither fresh nor stale', () => { + const violations = findSnapshotBypassDrift(fixtureSource(), REGISTERED_FILE); + const baseline = [{ file: REGISTERED_FILE, text: 'const raw = platformReadSync(x);' }]; + const { fresh, stale } = diffAgainstBaseline(violations, baseline); + assert.deepStrictEqual(fresh, []); + assert.deepStrictEqual(stale, []); + }); + + test('G3: a registered-function violation NOT in the baseline is reported under fresh', () => { + const violations = findSnapshotBypassDrift(fixtureSource(), REGISTERED_FILE); + const { fresh, stale } = diffAgainstBaseline(violations, []); + assert.strictEqual(fresh.length, 1); + assert.strictEqual(fresh[0].text, 'const raw = platformReadSync(x);'); + assert.deepStrictEqual(stale, []); + }); +}); + +// ─── G2: function-scoped, not whole-file ────────────────────────────────── + +describe('findSnapshotBypassDrift — G2: function-scoped detection (not whole-file)', () => { + test('the identical raw-read line inside an UNREGISTERED function produces no violation for that line', () => { + const out = findSnapshotBypassDrift(fixtureSource(), REGISTERED_FILE); + const unregisteredLines = out.filter((v) => v.line === 7); + assert.deepStrictEqual(unregisteredLines, [], 'cmdUnrelated (line 7) must not be flagged — it is not in DIAGNOSTIC_RULE_FUNCTIONS'); + }); + + test('a registered function name in a file NOT listed in DIAGNOSTIC_RULE_FUNCTIONS is never flagged', () => { + const out = findSnapshotBypassDrift(fixtureSource(), path.join('src', 'other-file.cts')); + assert.deepStrictEqual(out, []); + }); + + test('DIAGNOSTIC_RULE_FUNCTIONS registers exactly src/verify.cts -> cmdValidateHealth today', () => { + assert.ok(DIAGNOSTIC_RULE_FUNCTIONS.has(REGISTERED_FILE)); + assert.ok(DIAGNOSTIC_RULE_FUNCTIONS.get(REGISTERED_FILE).has(REGISTERED_FN)); + }); +}); + +// ─── G4: stale ratchet entries ───────────────────────────────────────────── + +describe('diffAgainstBaseline — G4: stale entries', () => { + test('a baseline entry whose (file, text) pair no longer appears in a fresh scan is reported under stale', () => { + const baseline = [{ file: REGISTERED_FILE, text: 'const raw = platformReadSync(oldSite);' }]; + const violations = findSnapshotBypassDrift(fixtureSource(), REGISTERED_FILE); + const { fresh, stale } = diffAgainstBaseline(violations, baseline); + // The baseline's own (unmatched) entry is stale... + assert.strictEqual(stale.length, 1); + assert.strictEqual(stale[0].text, 'const raw = platformReadSync(oldSite);'); + // ...and the fixture's real, unacknowledged violation is separately fresh. + assert.strictEqual(fresh.length, 1); + assert.strictEqual(fresh[0].text, 'const raw = platformReadSync(x);'); + }); +}); + +// ─── G5: writeBaseline / dedupeViolationsForBaseline regeneration ──────── + +describe('writeBaseline / dedupeViolationsForBaseline — G5: regeneration matches a fresh detection pass', () => { + test('dedupeViolationsForBaseline collapses violations into sorted, deduped baseline rows matching the detection pass', () => { + const violations = findSnapshotBypassDrift(fixtureSource(), REGISTERED_FILE); + const deduped = dedupeViolationsForBaseline(violations); + const sorted = sortEntries(deduped); + assert.strictEqual(sorted.length, 1); + assert.strictEqual(sorted[0].file, REGISTERED_FILE); + assert.strictEqual(sorted[0].text, 'const raw = platformReadSync(x);'); + assert.strictEqual(sorted[0].count, 1); + // A freshly-written baseline must itself be immediately KNOWN (not + // fresh/stale) against the SAME detection pass — the round-trip + // invariant a ratchet baseline exists to guarantee. + const { fresh, stale } = diffAgainstBaseline(violations, sorted); + assert.deepStrictEqual(fresh, []); + assert.deepStrictEqual(stale, []); + }); + + test('writeBaseline persists entries to disk that exactly match a fresh scanRepo/detection pass', (t) => { + const root = createTempDir('gsd-planning-snapshot-bypass-drift-'); + t.after(() => cleanup(root)); + const violations = findSnapshotBypassDrift(fixtureSource(), REGISTERED_FILE); + writeBaseline(root, violations); + const writtenPath = path.join(root, 'scripts', 'baselines', 'planning-snapshot-bypass-baseline.json'); + const written = JSON.parse(fs.readFileSync(writtenPath, 'utf8')); + assert.strictEqual(written.entries.length, 1); + assert.strictEqual(written.entries[0].file, REGISTERED_FILE); + assert.strictEqual(written.entries[0].text, 'const raw = platformReadSync(x);'); + assert.strictEqual(written.entries[0].count, 1); + // The written baseline immediately reconciles with the pass that produced + // it — no fresh, no stale. + const { fresh, stale } = diffAgainstBaseline(violations, written.entries); + assert.deepStrictEqual(fresh, []); + assert.deepStrictEqual(stale, []); + // The baseline names its owner issue (#3309, Phase 11) and ADR §8.1, not + // #3218 (the sibling prompt-drift guard's owner issue). + assert.match(written.$comment, /#3309/); + assert.doesNotMatch(written.$comment, /#3218/); + assert.strictEqual(written.entries[0].owner_issue, '#3309'); + }); +}); + +// ─── G6: owner functions (ADR-3180 §7) are never flagged ───────────────── + +describe('findSnapshotBypassDrift — G6: ADR-3180 §7 owner-function calls never match the raw-read primitive regex', () => { + test('a call to an ADR-3180 §7 owner (getMilestoneInfo) inside a registered function is never flagged', () => { + const source = [ + 'function cmdValidateHealth(cwd) {', + ' const info = getMilestoneInfo(cwd);', + ' return info;', + '}', + '', + ].join('\n'); + const out = findSnapshotBypassDrift(source, REGISTERED_FILE); + assert.deepStrictEqual(out, [], 'getMilestoneInfo(cwd) must not match platformReadSync(/readFileSync(/readdirSync('); + }); + + test('other ADR-3180 §7 owner calls (listMilestonePhaseDirs, isPhaseComplete, scanPhasePlans, stateFieldValue) also never match', () => { + const source = [ + 'function cmdValidateHealth(cwd) {', + ' const dirs = listMilestonePhaseDirs(phasesDir, opts);', + ' const done = isPhaseComplete(phaseDir, deps);', + ' const scan = scanPhasePlans(phaseDir);', + " const label = stateFieldValue(fm, body, null, 'Phase', opts);", + ' return { dirs, done, scan, label };', + '}', + '', + ].join('\n'); + const out = findSnapshotBypassDrift(source, REGISTERED_FILE); + assert.deepStrictEqual(out, []); + }); +}); diff --git a/tests/planning-snapshot.test.cjs b/tests/planning-snapshot.test.cjs new file mode 100644 index 000000000..604fdcaef --- /dev/null +++ b/tests/planning-snapshot.test.cjs @@ -0,0 +1,497 @@ +'use strict'; + +/** + * Tests for `src/planning-snapshot.cts` (Phase 10, #3308, ADR-3180 §8.1). + * + * Design: .gsd/phase/refactor-3308-planning-snapshot-parsed-projection/40-design.md + * Test matrix: .gsd/phase/refactor-3308-planning-snapshot-parsed-projection/50-test-matrix.md + * + * TDD RED: `src/planning-snapshot.cts` does not exist yet — this file's + * `require('../gsd-core/bin/lib/planning-snapshot.cjs')` throws + * MODULE_NOT_FOUND until a companion implementation phase adds it. That is + * the intended starting state. + * + * Fixture provenance (CONTRIBUTING.md / repo rule): every case calls the REAL + * compiled owners (`getMilestoneInfo`, `listMilestonePhaseDirs`, + * `isPhaseComplete`, `scanPhasePlans`, `stateFieldValue`, `planningPaths`) + * against real temp `.planning/` trees — no hand-built in-memory mocks of any + * owner. Fixture helpers mirror `tests/completion-ratio-scope-withholding.test.cjs` + * verbatim (`writeRoadmap`/`writeState`/`writeFile`, and the directory-vs-file + * swap technique for IO-failure rows), and the readdirSync fault-injection + * helper mirrors `tests/verify.test.cjs`'s `injectMilestonesFault` (`t.mock`, + * auto-restored — never `chmod 0o000`, which root bypasses in CI/Docker). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const fc = require('fast-check'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// `export =` shape TBD by the implementing phase — this module is "the sole +// export consumers reach for" per the design doc, with `worstScope` also +// exported for direct unit testing per this phase's brief. Support either a +// callable-with-properties export (mirrors plan-scan.cjs) or a plain object +// export (mirrors verification.cjs) so this file does not lock in a shape the +// design doc leaves to the implementer. +const planningSnapshotLib = require('../gsd-core/bin/lib/planning-snapshot.cjs'); +const buildPlanningSnapshot = planningSnapshotLib.buildPlanningSnapshot ?? planningSnapshotLib; +const { worstScope } = planningSnapshotLib; + +const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs'); +const { _unusableInputEmissionCountForTests } = require('../gsd-core/bin/lib/unusable-input.cjs'); + +// ─── Fixture helpers (mirrors tests/completion-ratio-scope-withholding.test.cjs) ─ + +function planningDirOf(cwd) { + return path.join(cwd, '.planning'); +} + +function writeRoadmap(cwd, content) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content); +} + +function writeState(cwd, fields) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + const lines = ['---']; + for (const [k, v] of Object.entries(fields)) lines.push(`${k}: ${v}`); + lines.push('---', ''); + fs.writeFileSync(path.join(planningDirOf(cwd), 'STATE.md'), lines.join('\n')); +} + +function writeFile(cwd, relPath, content) { + const full = path.join(cwd, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); +} + +// Appends raw text to an already-written STATE.md (writeState above writes +// only the frontmatter block) — used by the currentPhaseLabel rows to add a +// body carrying `## Current Position` / a bare `Phase:` line. +function appendToState(cwd, body) { + const statePath = path.join(planningDirOf(cwd), 'STATE.md'); + fs.writeFileSync(statePath, fs.readFileSync(statePath, 'utf-8') + body); +} + +// Makes a FILE unreadable-as-a-file: a DIRECTORY node where callers expect a +// regular file (ROADMAP.md / STATE.md). `platformReadSync` -> `fs.readFileSync` +// throws EISDIR on it, deterministically and cross-platform — no chmod. +function makeFileUnreadableAsDir(fullPath) { + fs.mkdirSync(fullPath, { recursive: true }); +} + +// Makes a DIRECTORY unreadable-as-a-directory: a REGULAR FILE where callers +// expect a directory (a phase's nested `plans/` subdirectory). `readdirSync` +// throws ENOTDIR on it, deterministically and cross-platform — no chmod. +// (This is the inverse of `makePhasesDirUnreadable` in the sibling test file.) +function makeDirUnreadableAsFile(fullPath) { + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, 'not a directory\n'); +} + +// A matched plan/summary pair plus a passing `*-VERIFICATION.md` — +// `isPhaseComplete` requires `verification.status === 'passed'` for +// `complete: true`, which plan/summary pairing alone does not establish. +function makeCompletePhaseDir(cwd, relPhaseDir) { + writeFile(cwd, `${relPhaseDir}/01-01-PLAN.md`, '# Plan\n'); + writeFile(cwd, `${relPhaseDir}/01-01-SUMMARY.md`, '# Summary\n'); + writeFile(cwd, `${relPhaseDir}/01-VERIFICATION.md`, '---\nstatus: passed\n---\n'); +} + +function buildHealthyTwoPhaseFixture(cwd) { + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, [ + '## v1.0 Current 🚧', + '', + '### Phase 1: Foo', + '', + '### Phase 2: Bar', + ].join('\n')); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); + makeCompletePhaseDir(cwd, '.planning/phases/02-bar'); +} + +// ─── fs fault injection (mirrors tests/verify.test.cjs's injectMilestonesFault) ─ + +function fsError(code, targetPath) { + const err = new Error(`${code}: operation failed, scandir '${targetPath}'`); + err.code = code; + err.syscall = 'scandir'; + err.path = targetPath; + return err; +} + +// Scoped readdirSync fault: throws ONLY for the exact `targetPhaseDir` path. +// Every other path (crucially, the phases/ enumeration read that must still +// list this directory's NAME) passes through to the real implementation. +// `t.mock` auto-restores after the test. +function injectPhaseDirFault(t, targetPhaseDir) { + const original = fs.readdirSync; + t.mock.method(fs, 'readdirSync', function (p, ...rest) { + if (p === targetPhaseDir) throw fsError('EACCES', targetPhaseDir); + return original.call(this, p, ...rest); + }); +} + +// Measure NEW unusable-input diagnostics emitted while `fn` runs, with +// stderr stubbed to keep the suite's own output clean (mirrors +// tests/unusable-input.test.cjs's emissionsDuring). +function emissionsDuring(fn) { + const before = _unusableInputEmissionCountForTests(); + const original = process.stderr.write; + process.stderr.write = () => true; + let result; + try { + result = fn(); + } finally { + process.stderr.write = original; + } + return [result, _unusableInputEmissionCountForTests() - before]; +} + +// ═════════════════════════════════════════════════════════════════════════ +// Happy path — rows 1-4 +// ═════════════════════════════════════════════════════════════════════════ + +describe('happy path', () => { + test('builds a fully-COMPLETE snapshot for a healthy two-phase milestone', (t) => { + const cwd = createTempDir('gsd-3308-h1-'); + t.after(() => cleanup(cwd)); + buildHealthyTwoPhaseFixture(cwd); + + const snap = buildPlanningSnapshot(cwd); + + assert.strictEqual(snap.milestone.scope, SCOPE.COMPLETE); + assert.strictEqual(snap.phaseDirs.scope, SCOPE.COMPLETE); + assert.strictEqual(snap.phases.scope, SCOPE.COMPLETE); + assert.strictEqual(snap.phases.value.length, 2); + for (const p of snap.phases.value) { + assert.strictEqual(p.complete, true); + assert.strictEqual(p.scope, SCOPE.COMPLETE); + assert.strictEqual(p.verificationStatus, 'passed'); + assert.strictEqual(p.planCount, 1); + assert.strictEqual(p.summaryCount, 1); + } + }); + + test('zero phase directories is a COMPLETE empty array, not a non-answer', (t) => { + const cwd = createTempDir('gsd-3308-h2-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', ''].join('\n')); + fs.mkdirSync(path.join(planningDirOf(cwd), 'phases'), { recursive: true }); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.phaseDirs, { value: [], scope: SCOPE.COMPLETE }); + assert.deepStrictEqual(snap.phases, { value: [], scope: SCOPE.COMPLETE }); + }); + + test('single phase directory produces a one-element phases array', (t) => { + const cwd = createTempDir('gsd-3308-h3-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.phaseDirs.value.length, 1); + assert.strictEqual(snap.phases.value.length, 1); + assert.strictEqual(snap.phases.value[0].dir, '01-foo'); + }); + + test('three phase directories each carry independent completion', (t) => { + const cwd = createTempDir('gsd-3308-h4-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, [ + '## v1.0 Current 🚧', '', + '### Phase 1: Foo', '', + '### Phase 2: Bar', '', + '### Phase 3: Baz', + ].join('\n')); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); // complete + writeFile(cwd, '.planning/phases/02-bar/02-01-PLAN.md', '# Plan\n'); // no summary/verification + writeFile(cwd, '.planning/phases/03-baz/03-01-PLAN.md', '# Plan\n'); + writeFile(cwd, '.planning/phases/03-baz/03-01-SUMMARY.md', '# Summary\n'); + writeFile(cwd, '.planning/phases/03-baz/03-VERIFICATION.md', '---\nstatus: gaps_found\n---\n'); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.phases.value.length, 3); + const foo = snap.phases.value.find((p) => p.dir === '01-foo'); + const bar = snap.phases.value.find((p) => p.dir === '02-bar'); + const baz = snap.phases.value.find((p) => p.dir === '03-baz'); + + assert.strictEqual(foo.complete, true); + assert.strictEqual(bar.complete, false); + assert.strictEqual(bar.verificationStatus, 'missing'); + assert.strictEqual(baz.complete, false); + assert.strictEqual(baz.verificationStatus, 'gaps_found'); + }); + + test('Phase field under Current Position is extracted verbatim as currentPhaseLabel', (t) => { + const cwd = createTempDir('gsd-3308-h12-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, ['', '## Current Position', '', 'Phase: 3 of 8 (User Auth)', ''].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.currentPhaseLabel, { value: '3 of 8 (User Auth)', scope: SCOPE.COMPLETE }); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════ +// Negative space — rows 5, 9, 11, 14 +// ═════════════════════════════════════════════════════════════════════════ + +describe('negative space', () => { + test('absent ROADMAP.md yields a non-COMPLETE milestone but does not crash phase enumeration', (t) => { + const cwd = createTempDir('gsd-3308-n5-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n'); + writeFile(cwd, '.planning/phases/02-bar/02-01-PLAN.md', '# Plan\n'); + + const snap = buildPlanningSnapshot(cwd); + assert.notStrictEqual(snap.milestone.scope, SCOPE.COMPLETE); + assert.strictEqual(snap.milestone.value, null); + assert.ok(Array.isArray(snap.phaseDirs.value), 'phase enumeration must not throw'); + assert.deepStrictEqual(snap.phaseDirs.value.slice().sort(), ['01-foo', '02-bar']); + }); + + test('absent STATE.md is a non-answer but not an unusable-input diagnostic', (t) => { + const cwd = createTempDir('gsd-3308-n9-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + // No STATE.md at all. + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.deepStrictEqual(snap.currentPhaseLabel, { value: null, scope: SCOPE.UNREADABLE }); + assert.strictEqual(emitted, 0, 'a project that never ran state.init is not corruption'); + }); + + test('missing Current Position section yields TRUNCATED scope with a whole-body fallback value', (t) => { + const cwd = createTempDir('gsd-3308-n11-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, ['', '## Notes', '', 'Phase: 3 of 8 (User Auth)', ''].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.currentPhaseLabel.scope, SCOPE.TRUNCATED); + assert.strictEqual(snap.currentPhaseLabel.value, '3 of 8 (User Auth)'); + }); + + test('a truncated milestone window still forwards its over-inclusive phase set, not an empty one', (t) => { + const cwd = createTempDir('gsd-3308-n14-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v2.0' }); + writeRoadmap(cwd, [ + '## v1.0 Planned', '', + '### Phase 1: Foo', '', + '## v2.0 Current 🚧', + ].join('\n')); + writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n'); + writeFile(cwd, '.planning/phases/02-bar/02-01-PLAN.md', '# Plan\n'); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.phaseDirs.scope, SCOPE.TRUNCATED); + assert.deepStrictEqual(snap.phaseDirs.value.slice().sort(), ['01-foo', '02-bar']); + }); + + test('a legitimately-missing verification file is not conflated with an unreadable phase directory', (t) => { + const cwd = createTempDir('gsd-3308-ns18-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n')); + // 01-foo: real, readable directory with no *-VERIFICATION.md at all — + // a well-formed 'missing' answer, scope COMPLETE. + writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n'); + // 02-bar: real directory on disk, but faulted below — a non-answer, + // scope UNREADABLE, even though readVerificationStatus's own no-throw + // fail-open contract still reports 'missing' for the same reason. + const barDir = path.join(planningDirOf(cwd), 'phases', '02-bar'); + fs.mkdirSync(barDir, { recursive: true }); + injectPhaseDirFault(t, barDir); + + const snap = buildPlanningSnapshot(cwd); + const foo = snap.phases.value.find((p) => p.dir === '01-foo'); + const bar = snap.phases.value.find((p) => p.dir === '02-bar'); + + assert.strictEqual(foo.complete, false); + assert.strictEqual(foo.verificationStatus, 'missing'); + assert.strictEqual(foo.scope, SCOPE.COMPLETE, 'a genuinely missing verification file is a real answer'); + + assert.strictEqual(bar.complete, false); + assert.strictEqual(bar.verificationStatus, 'missing'); + assert.strictEqual(bar.scope, SCOPE.UNREADABLE, 'an unreadable directory is a non-answer, not a real one'); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════ +// Hostile input — rows 6, 7, 8, 10, 13 +// ═════════════════════════════════════════════════════════════════════════ + +describe('hostile input', () => { + test('unreadable ROADMAP.md reports milestone scope UNREADABLE', (t) => { + const cwd = createTempDir('gsd-3308-h6-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'ROADMAP.md')); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.milestone.scope, SCOPE.UNREADABLE); + }); + + test('an unreadable nested plans dir taints the phase record to TRUNCATED via worstScope, not COMPLETE', (t) => { + const cwd = createTempDir('gsd-3308-h7-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); + // Swap the nested plans/ subdirectory for a regular file: readdirSync + // throws ENOTDIR inside scanPhasePlans, independent of isPhaseComplete's + // own (unaffected) readdirSync(phaseDir) call on the same phase. + makeDirUnreadableAsFile(path.join(planningDirOf(cwd), 'phases', '01-foo', 'plans')); + + const snap = buildPlanningSnapshot(cwd); + const phase = snap.phases.value.find((p) => p.dir === '01-foo'); + assert.ok(phase, 'expected phase 01-foo in the snapshot'); + assert.strictEqual(phase.complete, true, 'isPhaseComplete alone still reads COMPLETE'); + assert.strictEqual(phase.scope, SCOPE.TRUNCATED, 'worstScope must promote the record to TRUNCATED'); + }); + + test('an unreadable phase directory reports scope UNREADABLE from both owners combined', (t) => { + const cwd = createTempDir('gsd-3308-h8-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + const phaseDir = path.join(planningDirOf(cwd), 'phases', '01-foo'); + fs.mkdirSync(phaseDir, { recursive: true }); + injectPhaseDirFault(t, phaseDir); + + const snap = buildPlanningSnapshot(cwd); + const phase = snap.phases.value.find((p) => p.dir === '01-foo'); + assert.ok(phase, 'the phase must still be enumerated — listMilestonePhaseDirs reads the phases/ dir, not this one'); + assert.strictEqual(phase.scope, SCOPE.UNREADABLE, 'worst of two independently-UNREADABLE owner calls'); + }); + + test('unreadable-but-present STATE.md emits exactly one STATE_UNREADABLE diagnostic', (t) => { + const cwd = createTempDir('gsd-3308-h10-'); + t.after(() => cleanup(cwd)); + makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'STATE.md')); + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.deepStrictEqual(snap.currentPhaseLabel, { value: null, scope: SCOPE.UNREADABLE }); + assert.strictEqual(emitted, 1); + }); + + test('unterminated frontmatter does not double-emit and still yields a body-only phase label', (t) => { + const cwd = createTempDir('gsd-3308-h13-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + // Opens `---` and never closes it. Every non-empty line in the tail is + // frontmatter-shaped (>= 2 keys), so extractFrontmatter's own + // FRONTMATTER_UNTERMINATED diagnostic fires exactly once. stripFrontmatter + // is then a no-op (no closing fence to strip), so `body` is the full raw + // content — the bare `Phase:` line inside it is still reachable via + // stateExtractField's plain line-start pattern. + fs.writeFileSync( + path.join(planningDirOf(cwd), 'STATE.md'), + ['---', 'gsd_state_version: 1', 'Phase: 3 of 8 (User Auth)', ''].join('\n'), + ); + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.strictEqual(emitted, 1, 'exactly one diagnostic reaches the operator, from extractFrontmatter itself'); + assert.strictEqual(snap.currentPhaseLabel.value, '3 of 8 (User Auth)'); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════ +// Independence — rows 15, 16, 17, 19 +// ═════════════════════════════════════════════════════════════════════════ + +describe('independence', () => { + test('phases-level scope is the worst of every individual PhaseSnapshot scope, not the first or last', (t) => { + const cwd = createTempDir('gsd-3308-i15-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n')); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); + makeCompletePhaseDir(cwd, '.planning/phases/02-bar'); + makeDirUnreadableAsFile(path.join(planningDirOf(cwd), 'phases', '02-bar', 'plans')); + + const snap = buildPlanningSnapshot(cwd); + const foo = snap.phases.value.find((p) => p.dir === '01-foo'); + const bar = snap.phases.value.find((p) => p.dir === '02-bar'); + assert.strictEqual(foo.scope, SCOPE.COMPLETE); + assert.strictEqual(bar.scope, SCOPE.TRUNCATED); + assert.strictEqual(snap.phases.scope, SCOPE.TRUNCATED, 'array-level scope must be the worst-of, not first/last-wins'); + }); + + test('a truncated phase-enumeration window taints phases.scope even when every phase itself reads COMPLETE', (t) => { + const cwd = createTempDir('gsd-3308-i16-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v2.0' }); + writeRoadmap(cwd, [ + '## v1.0 Planned', '', + '### Phase 1: Foo', '', + '## v2.0 Current 🚧', + ].join('\n')); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.phaseDirs.scope, SCOPE.TRUNCATED); + const foo = snap.phases.value.find((p) => p.dir === '01-foo'); + assert.strictEqual(foo.scope, SCOPE.COMPLETE, 'the individual phase read cleanly'); + assert.strictEqual(snap.phases.scope, SCOPE.TRUNCATED, 'the array-level scope must still fold in phaseDirs.scope'); + }); + + test('two calls against unchanged disk state produce identical snapshots', (t) => { + const cwd = createTempDir('gsd-3308-i19-'); + t.after(() => cleanup(cwd)); + buildHealthyTwoPhaseFixture(cwd); + + const first = buildPlanningSnapshot(cwd); + const second = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(first, second, 'buildPlanningSnapshot must be pure w.r.t. disk state — no hidden mutation/caching'); + }); +}); + +// ═════════════════════════════════════════════════════════════════════════ +// worstScope — pure unit coverage, row 17 (independence / boundary) +// ═════════════════════════════════════════════════════════════════════════ + +describe('worstScope — pure unit coverage', () => { + const SCOPES = [SCOPE.COMPLETE, SCOPE.TRUNCATED, SCOPE.UNSCOPED, SCOPE.UNREADABLE]; + + test('COMPLETE only when every input is COMPLETE', () => { + assert.strictEqual(worstScope(SCOPE.COMPLETE), SCOPE.COMPLETE); + assert.strictEqual(worstScope(SCOPE.COMPLETE, SCOPE.COMPLETE), SCOPE.COMPLETE); + assert.strictEqual(worstScope(SCOPE.COMPLETE, SCOPE.COMPLETE, SCOPE.COMPLETE), SCOPE.COMPLETE); + for (const bad of [SCOPE.TRUNCATED, SCOPE.UNSCOPED, SCOPE.UNREADABLE]) { + assert.notStrictEqual(worstScope(SCOPE.COMPLETE, bad), SCOPE.COMPLETE); + assert.notStrictEqual(worstScope(bad, SCOPE.COMPLETE), SCOPE.COMPLETE); + } + }); + + test('UNREADABLE wins over every other combination', () => { + for (const other of SCOPES) { + assert.strictEqual(worstScope(SCOPE.UNREADABLE, other), SCOPE.UNREADABLE); + assert.strictEqual(worstScope(other, SCOPE.UNREADABLE), SCOPE.UNREADABLE); + } + assert.strictEqual(worstScope(SCOPE.COMPLETE, SCOPE.TRUNCATED, SCOPE.UNSCOPED, SCOPE.UNREADABLE), SCOPE.UNREADABLE); + }); + + test('worstScope picks the most severe of any scope combination, order-independent', () => { + // Seeded explicitly (no unseeded fc.assert — the Wave-3 defect this + // directive's own text names, PR #3335). + fc.assert( + fc.property(fc.constantFrom(...SCOPES), fc.constantFrom(...SCOPES), (a, b) => { + assert.strictEqual(worstScope(a, b), worstScope(b, a)); + }), + { seed: 20261012 }, + ); + }); +}); diff --git a/tests/unusable-input.test.cjs b/tests/unusable-input.test.cjs index 1d9f8549b..043bf7f14 100644 --- a/tests/unusable-input.test.cjs +++ b/tests/unusable-input.test.cjs @@ -72,7 +72,7 @@ describe('UNUSABLE_REASON', () => { // (enum + call site + this assertion) instead of a silent widening. assert.deepStrictEqual( Object.keys(UNUSABLE_REASON).sort(), - ['FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'ROADMAP_UNREADABLE'], + ['FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'ROADMAP_UNREADABLE', 'STATE_UNREADABLE'], ); assert.strictEqual(UNUSABLE_REASON.FRONTMATTER_UNTERMINATED, 'frontmatter_unterminated'); }); @@ -87,6 +87,53 @@ describe('UNUSABLE_REASON', () => { }); }); +// ─── STATE_UNREADABLE: a STATE.md that exists but could not be read ───────── + +describe('STATE_UNREADABLE', () => { + test('a genuinely unreadable STATE.md produces exactly one diagnostic', () => { + _resetUnusableInputWarningsForTests(); + const emitted = emissionsDuring(() => { + const wrote = warnUnusableInput({ + reason: UNUSABLE_REASON.STATE_UNREADABLE, + source: '/u/state-unreadable.md', + }); + assert.strictEqual(wrote, true); + }); + assert.strictEqual(emitted, 1); + }); + + test('the same STATE.md path reported twice yields one diagnostic', () => { + _resetUnusableInputWarningsForTests(); + const source = '/u/state-unreadable-dedup/STATE.md'; + const emitted = emissionsDuring(() => { + const first = warnUnusableInput({ reason: UNUSABLE_REASON.STATE_UNREADABLE, source }); + const repeat = warnUnusableInput({ reason: UNUSABLE_REASON.STATE_UNREADABLE, source }); + assert.strictEqual(first, true); + assert.strictEqual(repeat, false, 'same (path, cause) must dedup'); + }); + assert.strictEqual(emitted, 1); + }); + + test('two different STATE.md paths are never suppressed as one', () => { + _resetUnusableInputWarningsForTests(); + const emitted = emissionsDuring(() => { + warnUnusableInput({ + reason: UNUSABLE_REASON.STATE_UNREADABLE, + source: '/u/state-unreadable-a/STATE.md', + }); + warnUnusableInput({ + reason: UNUSABLE_REASON.STATE_UNREADABLE, + source: '/u/state-unreadable-b/STATE.md', + }); + }); + assert.strictEqual(emitted, 2, 'keying too coarsely would hide a real second fault'); + }); + + test('the reason value is the frozen string "state_unreadable"', () => { + assert.strictEqual(UNUSABLE_REASON.STATE_UNREADABLE, 'state_unreadable'); + }); +}); + // ─── The discriminator: truncated vs. everything that merely looks like it ─── describe('extractFrontmatter — flags a genuinely truncated frontmatter', () => { From 2538fd6344280c70ecf0c2adc812cb6fa2df4fae Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 12 Aug 2026 21:43:39 -0400 Subject: [PATCH 2/7] =?UTF-8?q?refactor(#3308):=20add=20planning-snapshot.?= =?UTF-8?q?cts=20parsed=20projection=20per=20ADR-3180=20=C2=A78.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10 of epic #3180. src/planning-snapshot.cts is a new parsed projection of .planning/, composed exclusively from the already- consolidated §7 owners (getMilestoneInfo, listMilestonePhaseDirs, isPhaseComplete, scanPhasePlans, stateFieldValue, planningPaths) plus the frozen SCOPE enum. No new semantic derivation is introduced beyond worstScope, a pure combinator folding several independently-scoped owner answers into one composite signal. Adds STATE_UNREADABLE to src/unusable-input.cts's UNUSABLE_REASON (seventh #1879 site) for STATE.md exists-but-unreadable, distinct from absent. Adds scripts/lint-planning-snapshot-bypass-drift.cjs, a ratcheted drift guard (ADR-3180 Decision 4(e)) scoped to DIAGNOSTIC_RULE_FUNCTIONS (currently cmdValidateHealth in src/verify.cts only) preventing new raw .planning/ reads from bypassing the snapshot, while acknowledging cmdValidateHealth's existing 15 raw-read sites as debt owned by Phase 11 (#3309). Six-gate .cts ripple: .gitignore, eslint.config.mjs, docs/INVENTORY.md + manifest regen, CONTEXT.md glossary entry. Breaking changes: none. This phase adds the subject only; Phase 11 migrates cmdValidateHealth onto it. --- .gitignore | 1 + CONTEXT.md | 3 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 1 + eslint.config.mjs | 1 + package.json | 2 +- .../planning-snapshot-bypass-baseline.json | 110 ++++ .../lint-planning-snapshot-bypass-drift.cjs | 544 ++++++++++++++++++ src/planning-snapshot.cts | 182 ++++++ src/unusable-input.cts | 9 + 10 files changed, 853 insertions(+), 1 deletion(-) create mode 100644 scripts/baselines/planning-snapshot-bypass-baseline.json create mode 100644 scripts/lint-planning-snapshot-bypass-drift.cjs create mode 100644 src/planning-snapshot.cts diff --git a/.gitignore b/.gitignore index 80d1e85a9..8780e681a 100644 --- a/.gitignore +++ b/.gitignore @@ -195,6 +195,7 @@ build/ /gsd-core/bin/lib/worktree-safety.cjs /gsd-core/bin/lib/planning-workspace.cjs /gsd-core/bin/lib/planning-scope.cjs +/gsd-core/bin/lib/planning-snapshot.cjs /gsd-core/bin/lib/command-roster.cjs /gsd-core/bin/lib/runtime-artifact-conversion.cjs /gsd-core/bin/lib/runtime-artifact-layout.cjs diff --git a/CONTEXT.md b/CONTEXT.md index f96415a51..8b1b00750 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -103,6 +103,9 @@ Module owning legacy-key normalization, defaults merge, and explicit on-disk mig ### Planning Scope Module Leaf module owning the frozen `SCOPE` discriminator (`COMPLETE` / `TRUNCATED` / `UNSCOPED` / `UNREADABLE`) that every consolidated `.planning/` semantic derivation returns alongside its payload, per ADR-3180 Decision 2. It exists to make one distinction representable: `COMPLETE` with zero items is a REAL answer (a phase genuinely has no plans; a milestone genuinely has no phases yet), while the other three with zero items are NON-answers — the derivation could not see all of its input. Before it, those two cases were output-identical, which is the failure class epic #3180 removes: a truncated milestone window returned `phase_count: 0` with no error, indistinguishable from a freshly-declared milestone. It is a frozen enum rather than a message string because `CONTRIBUTING.md` bans raw-text matching on outputs and requires a typed IR, so callers branch on `result.scope === SCOPE.TRUNCATED`. Pure and import-free — the bottom of the dependency graph, so any consumer can depend on it without a cycle (mirrors the Phase Id Module's leaf position). Source of truth: `gsd-core/bin/lib/planning-scope.cjs` (generated from `src/planning-scope.cts`). The contract is PROVISIONAL: #3183 is its first real implementation, and ADR-3180 requires the ADR be amended before Phase 2 rather than the contract worked around, if it does not fit. +### Planning Snapshot Module +Module owning the parsed projection of `.planning/` that a diagnostic rule may read, per ADR-3180 §8.1 (Decision 8, Phase 10, #3308). `buildPlanningSnapshot(cwd) → PlanningSnapshot` is composed EXCLUSIVELY from the already-consolidated §7 owners — `getMilestoneInfo` (Roadmap Parser Module), `listMilestonePhaseDirs` (Phase Locator Module), `isPhaseComplete` (Verification Module), `scanPhasePlans` (Plan Scan Module), `stateFieldValue`/`stateCurrentPositionSlice` (STATE.md Document Module), `planningPaths` (Planning Workspace Module) — and introduces no new semantic derivation of its own. `PlanningSnapshot` exposes `milestone`/`phaseDirs`/`phases`/`currentPhaseLabel`, each a `{value, scope}` pair per the Planning Scope Module's frozen `SCOPE` enum; `phases` additionally carries a `PhaseSnapshot[]` (`dir`, `complete`, `verificationStatus`, `planCount`, `summaryCount`, `scope`). The one new piece of logic this module adds is `worstScope(...scopes) → Scope`, a pure severity-ordered combinator (`UNREADABLE` > `UNSCOPED` > `TRUNCATED` > `COMPLETE`) that folds several independently-scoped owner answers about the same phase directory into one composite signal — NOT a re-derivation of any owner (each owner's own algorithm is untouched; only their already-computed `scope` verdicts are combined), but new coordination logic no single owner has the visibility to express. Every exposed field carries PARSED values only, never raw document text — this is structural, not advisory: a diagnostic rule given only the parsed value cannot re-derive a field's location the way `#3162`'s three inert `Current Phase` literal-search predicates did. Read failures on STATE.md (exists-but-unreadable, distinct from absent) are reported via the Unusable Input Diagnostic Module's `warnUnusableInput(UNUSABLE_REASON.STATE_UNREADABLE)`. Guarded by `scripts/lint-planning-snapshot-bypass-drift.cjs` (ratcheted per Decision 4(e), scoped to `DIAGNOSTIC_RULE_FUNCTIONS` — currently `cmdValidateHealth` in `src/verify.cts` only, acknowledging its existing raw `.planning/` reads as debt owned by Phase 11, #3309, which migrates it onto this snapshot). Source of truth: `gsd-core/bin/lib/planning-snapshot.cjs` (generated from `src/planning-snapshot.cts`). Design: `.gsd/phase/refactor-3308-planning-snapshot-parsed-projection/40-design.md`. + ### Planning Workspace Module Module owning `.planning` path resolution, active workstream pointer policy (`session-scoped > shared`), pointer self-heal behavior, and planning lock semantics for workstream-aware execution. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 4c61c8c2c..ca01f0bdb 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -423,6 +423,7 @@ "plan-drift-guard.cjs", "plan-scan.cjs", "planning-scope.cjs", + "planning-snapshot.cjs", "planning-workspace.cjs", "probe-core.cjs", "profile-output.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 4cd9beba3..11933ed18 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -530,6 +530,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `plan-dependency-graph.cjs` | Shared halt-propagation over a plan's `depends_on` DAG — the single topological-order + halt-propagation engine used by both `phase.cjs`'s wave-grouping and `phase-locator.cjs`'s phase-location primitive, so the two can never diverge on which plans a halted plan blocks (#2830) | | `plan-scan.cjs` | Canonical phase-plan scanner for detecting plan and summary files in flat and nested layouts (k014) | | `planning-scope.cjs` | Frozen `SCOPE` discriminator (`COMPLETE`/`TRUNCATED`/`UNSCOPED`/`UNREADABLE`) distinguishing a genuinely-empty derivation from one computed over a truncated or unscoped input, so callers can branch on the difference instead of reading a plausible zero (ADR-3180) | +| `planning-snapshot.cjs` | Parsed projection of `.planning/` composed exclusively from the ADR-3180 §7 owners (milestone identity, phase enumeration, phase completion, plan/summary counting, STATE.md current-phase) — exposes only scope-carrying parsed values, never raw document text, so a diagnostic rule cannot re-derive a field's location (ADR-3180 §8.1) | | `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) | | `project-root.cjs` | Resolves a project root from a starting directory using four heuristics (own `.planning/` guard, `sub_repos` config, `multiRepo` flag, `.git` heuristic) | | `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | diff --git a/eslint.config.mjs b/eslint.config.mjs index 742925c69..b76354c7c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -134,6 +134,7 @@ export default tseslint.config( 'gsd-core/bin/lib/model-catalog.cjs', 'gsd-core/bin/lib/configuration.cjs', 'gsd-core/bin/lib/state-document.cjs', + 'gsd-core/bin/lib/planning-snapshot.cjs', 'gsd-core/bin/lib/shell-command-projection.cjs', 'gsd-core/bin/lib/security.cjs', 'gsd-core/bin/lib/command-aliases.cjs', diff --git a/package.json b/package.json index c5f3efad2..0963f9798 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs", "lint:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs", "lint:removed-but-needed": "node scripts/lint-removed-but-needed.cjs", - "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs", + "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs", diff --git a/scripts/baselines/planning-snapshot-bypass-baseline.json b/scripts/baselines/planning-snapshot-bypass-baseline.json new file mode 100644 index 000000000..87eee1c6d --- /dev/null +++ b/scripts/baselines/planning-snapshot-bypass-baseline.json @@ -0,0 +1,110 @@ +{ + "$comment": "ADR-3180 §8.1 rule 2 ratchet, owned by Phase 11 (#3309). See scripts/lint-planning-snapshot-bypass-drift.cjs. SHRINK-ONLY: entries are removed as cmdValidateHealth migrates onto src/planning-snapshot.cts; new or changed entries fail lint:ci. `count` is the number of byte-identical (file, text) occurrences acknowledged at this site — a run producing fewer fails as a partial migration, more fails as an unacknowledged new copy.", + "entries": [ + { + "file": "src/verify.cts", + "text": ".readdirSync(phasesDir, { withFileTypes: true })", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "? fs.readFileSync(milestonesPath, 'utf-8')", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 2 + }, + { + "file": "src/verify.cts", + "text": "const archiveFiles = fs.readdirSync(milestonesArchiveDir);", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const configRaw = fs.readFileSync(configPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 4 + }, + { + "file": "src/verify.cts", + "text": "const content = fs.readFileSync(projectPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const entries = fs.readdirSync(rootBase, { withFileTypes: true });", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const rawCfg = fs.readFileSync(configPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const researchContent = fs.readFileSync(", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const roadmapContentFull = fs.readFileSync(roadmapPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const roadmapContentRaw = fs.readFileSync(roadmapPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 2 + }, + { + "file": "src/verify.cts", + "text": "const stateContent = fs.readFileSync(statePath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 2 + }, + { + "file": "src/verify.cts", + "text": "const stateRaw = fs.readFileSync(statePath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "phaseDirFiles.set(e.name, fs.readdirSync(path.join(phasesDir, e.name)));", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + } + ] +} diff --git a/scripts/lint-planning-snapshot-bypass-drift.cjs b/scripts/lint-planning-snapshot-bypass-drift.cjs new file mode 100644 index 000000000..619282bf0 --- /dev/null +++ b/scripts/lint-planning-snapshot-bypass-drift.cjs @@ -0,0 +1,544 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Anti-divergence drift guard for the DIAGNOSTIC-RULE raw-`.planning/`-read + * bypass of the planning snapshot single owner (epic #3180, ADR-3180 + * "Planning Semantic Model Single Owner", §8.1 rule 2). + * + * ADR-3180 §8.1 rule 2: a diagnostic rule may see only PARSED values coming + * off `src/planning-snapshot.cts`, never raw `.planning/` document text — + * every diagnostic rule is expected to consume the snapshot's already-parsed + * fields (via the ADR-3180 §7 owner functions: `getMilestoneInfo`, + * `listMilestonePhaseDirs`, `isPhaseComplete`, `scanPhasePlans`, + * `stateFieldValue`, etc.), not re-derive its own view of the filesystem with + * `platformReadSync(`/`readFileSync(`/`readdirSync(`. + * + * `cmdValidateHealth` (`src/verify.cts`) is the one diagnostic-rule-shaped + * function in the repo today that has NOT yet been migrated onto the + * snapshot — it predates ADR-3180 and still does its own raw reads for + * roughly thirty W0xx/W1xx diagnostic codes. Migrating it is Phase 11 + * (#3309), not this phase (#3308) — this guard's job is only to make that + * acknowledged debt VISIBLE and SHRINK-ONLY via a ratchet baseline, exactly + * like `scripts/lint-planning-prompt-drift.cjs` and + * `scripts/lint-state-field-drift.cjs` do for their own re-derivations, so it + * cannot silently grow while Phase 11 is pending. + * + * FUNCTION-SCOPED, not whole-file or whole-repo. `DIAGNOSTIC_RULE_FUNCTIONS` + * (a `Map>`) names the exact functions + * this rule applies to — the semantic inverse of + * `lint-completion-ratio-drift.cjs`'s `FUNCTION_SCOPED_EXEMPTIONS` (which + * names functions a rule does NOT apply to), but the identical data shape and + * lookup pattern. A raw-read primitive anywhere OUTSIDE a registered function + * — including elsewhere in the very same file — is not this derivation and is + * never flagged; `src/verify.cts` itself is full of legitimate raw reads + * outside `cmdValidateHealth` (health-check plumbing, non-diagnostic-rule + * helpers) that this guard must not see. + * + * Line-to-enclosing-function attribution is a TRIMMED copy of + * `lint-state-field-drift.cjs`'s `buildFunctionInfo` — the same + * comment/string-stripping tokenizer (`scanCode`) feeding the same + * brace-depth function-frame stack, producing the same `innermostAt[line] -> + * function name | null` attribution — with that guard's LADDER WINDOW + * co-occurrence logic dropped entirely: this guard only ever needs "which + * function encloses this line", never a multi-line pattern within one + * function body. + * + * RATCHET, not an allowlist — mirrors `lint-planning-prompt-drift.cjs`'s + * `diffAgainstBaseline`/`writeBaseline`/`dedupeViolationsForBaseline`/ + * `sortEntries` machinery verbatim (baseline path, owner issue, and + * `derivation` label are the only differences): a violation whose (file, + * text) pair is already RECORDED in the baseline is KNOWN and never fails; an + * unrecorded pair is FRESH and fails; a recorded pair that no longer fires is + * STALE and ALSO fails, forcing `--update` (run by a maintainer, expected to + * run to completion — zero remaining entries — once #3309 lands) to prune it. + * + * 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; + +// Authored TypeScript source only (the generated bin/lib/*.cjs mirror it). +const SCAN_DIRS = ['src']; +const SCAN_EXT = new Set(['.cts']); + +const BASELINE_REL_PATH = path.join('scripts', 'baselines', 'planning-snapshot-bypass-baseline.json'); + +// ADR-3180 §8.1 rule 2's acknowledged debt is owned by Phase 11 (#3309, "give +// cmdValidateHealth the snapshot"), NOT the epic (#3180) itself and NOT +// Phase 8 (#3218, the sibling prompt-layer guard's owner issue — a different +// derivation entirely). +const RATCHET_OWNER_ISSUE = '#3309'; + +// Per ADR-3180 §8.1 rule 2: function-scoped registry of exactly which +// diagnostic-rule-shaped functions this guard applies to. Not a bare file +// allowlist (ADR-3180 Decision 4(a) forbids that) — every other function in +// `src/verify.cts`, and every function in every other file, is scanned like +// normal code and simply never matches because it is not in this Map. +const DIAGNOSTIC_RULE_FUNCTIONS = new Map([[path.join('src', 'verify.cts'), new Set(['cmdValidateHealth'])]]); + +// `scanTree` builds its repo-relative path via `path.relative()`, which uses +// NATIVE separators: on Windows that is `src\verify.cts`, while +// `DIAGNOSTIC_RULE_FUNCTIONS` above and the committed baseline both store +// POSIX paths (`src/verify.cts`). Normalized UNCONDITIONALLY — never gated on +// `process.platform` — so the POSIX path is never the only tested case (see +// `lint-planning-prompt-drift.cjs`'s `toPosixRel` for the full rationale; +// applied here at the same single seam: `findSnapshotBypassDrift` is the only +// place a repo-relative path enters this guard's violation objects). +function toPosixRel(relPath) { + return relPath.replace(/\\/g, '/'); +} + +// Looks up `DIAGNOSTIC_RULE_FUNCTIONS` by POSIX-normalizing BOTH the incoming +// `relPath` and each registered key before comparing, so a native-separator +// caller (Windows) and a POSIX-separator caller (every other platform, and +// every test in this repo) resolve to the same registered `Set` — see +// `toPosixRel` above. +function lookupRegisteredFunctions(relPath) { + const posix = toPosixRel(relPath); + for (const [key, fns] of DIAGNOSTIC_RULE_FUNCTIONS) { + if (toPosixRel(key) === posix) return fns; + } + return null; +} + +// A raw `.planning/` filesystem read primitive. ADR-3180 §7 owner functions +// (`getMilestoneInfo`, `listMilestonePhaseDirs`, `isPhaseComplete`, +// `scanPhasePlans`, `stateFieldValue`, ...) never match this — none of their +// names or call shapes contain `platformReadSync(`, `readFileSync(`, or +// `readdirSync(`, so a registered function that has already been migrated +// onto the snapshot correctly stops producing violations without needing any +// separate allowlist of "safe" calls. +const RAW_READ_RE = /platformReadSync\(|readFileSync\(|readdirSync\(/; + +// `function NAME(` — top-level or nested, matches the DECLARATION line +// itself (mirrors `lint-state-field-drift.cjs`'s `FUNCTION_DECL_RE`). +const FUNCTION_DECL_RE = /\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/; + +// `const NAME = (...): ReturnType => {` — an arrow function assigned to a +// const, whose own line already carries `=>\s*\{` (mirrors +// `lint-state-field-drift.cjs`'s `ARROW_CONST_RE`). +const ARROW_CONST_RE = /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*\([^)]*\)\s*(?::\s*[^=]+)?=>\s*\{/; + +/** + * Strip line-comments, block comments, and the CONTENTS of string/template + * literals (excluded from brace-depth counting so a brace inside a string + * never desyncs `depth`) while keeping quoted text verbatim in `detect` (so + * declaration/call regexes can still see identifiers that happen to sit + * inside a template literal's interpolation-free text). Trimmed, unchanged + * copy of `lint-state-field-drift.cjs`'s `scanCode` — this guard needs the + * identical comment/string-safety, not a domain-specific variant. + * Returns `{ detect, braces }`, one string per input line. + */ +function scanCode(lines) { + const detect = new Array(lines.length); + const braces = new Array(lines.length); + let inBlockComment = false; + let inTemplate = false; + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + let outDetect = ''; + let outBraces = ''; + let i = 0; + if (inTemplate) { + const start = i; + while (i < line.length) { + if (line[i] === '\\') { + i += 2; + continue; + } + if (line[i] === '`') { + i++; + inTemplate = false; + break; + } + i++; + } + outDetect += line.slice(start, i); + if (inTemplate) { + detect[li] = outDetect; + braces[li] = ''; + continue; + } + } + while (i < line.length) { + if (inBlockComment) { + const close = line.indexOf('*/', i); + if (close === -1) { + i = line.length; + break; + } + i = close + 2; + inBlockComment = false; + continue; + } + const ch = line[i]; + if (ch === '/' && line[i + 1] === '/') { + i = line.length; + break; + } + if (ch === '/' && line[i + 1] === '*') { + inBlockComment = true; + i += 2; + continue; + } + if (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++; + } + outDetect += line.slice(start, j); + i = j; + continue; + } + if (ch === '`') { + const start = i; + let j = i + 1; + let closed = false; + while (j < line.length) { + if (line[j] === '\\') { + j += 2; + continue; + } + if (line[j] === '`') { + j++; + closed = true; + break; + } + j++; + } + if (!closed) { + outDetect += line.slice(start); + inTemplate = true; + i = line.length; + break; + } + outDetect += line.slice(start, j); + i = j; + continue; + } + outDetect += ch; + outBraces += ch; + i++; + } + detect[li] = outDetect; + braces[li] = outBraces; + } + return { detect, braces }; +} + +/** + * Trimmed copy of `lint-state-field-drift.cjs`'s `buildFunctionInfo`: a + * single pass over `lines` maintaining a brace-depth stack of open named + * function frames, producing `innermostAt[lineIndex] -> function name | + * null` — which function frame is innermost at each source line. The LADDER + * WINDOW co-occurrence tracking from the sibling guard is dropped entirely; + * this guard needs only line-to-enclosing-function attribution. + */ +function buildFunctionInfo(lines) { + const { detect, braces } = scanCode(lines); + const innermostAt = new Array(lines.length).fill(null); + const stack = []; // { name, openDepth } + let depth = 0; + let pendingDeclName = null; + for (let i = 0; i < lines.length; i++) { + const detectCode = detect[i]; + const braceCode = braces[i]; + + let immediateName = null; + if (detectCode.trim()) { + const arrowMatch = ARROW_CONST_RE.exec(detectCode); + if (arrowMatch) { + immediateName = arrowMatch[1]; + } else { + const declMatch = FUNCTION_DECL_RE.exec(detectCode); + if (declMatch) pendingDeclName = declMatch[1]; + } + } + + const opens = (braceCode.match(/\{/g) || []).length; + const closes = (braceCode.match(/\}/g) || []).length; + depth += opens - closes; + + if (immediateName) stack.push({ name: immediateName, openDepth: depth }); + + if (pendingDeclName) { + if (opens > 0) { + stack.push({ name: pendingDeclName, openDepth: depth }); + pendingDeclName = null; + } else if (detectCode.includes(';')) { + pendingDeclName = null; + } + } + + while (stack.length > 0 && depth < stack[stack.length - 1].openDepth) stack.pop(); + + innermostAt[i] = stack.length > 0 ? stack[stack.length - 1].name : null; + } + return { innermostAt }; +} + +/** + * Pure: find every raw `.planning/`-read line inside a + * `DIAGNOSTIC_RULE_FUNCTIONS`-registered function in `text`. `relPath` is the + * repo-relative path (native separators or POSIX, either is accepted) — used + * both as the (POSIX-normalized) `file` on every result and to look up the + * registered function set for this file. A file with no registered entry + * short-circuits to `[]` immediately, before any line is scanned. + * Returns [{ file, line, found, text }] — `file` is always POSIX-separated, + * `text` is the TRIMMED source line, the same value the baseline keys on. + */ +function findSnapshotBypassDrift(text, relPath) { + const registeredFns = lookupRegisteredFunctions(relPath); + if (!registeredFns) return []; + + const file = toPosixRel(relPath); + const lines = text.split('\n'); + const { innermostAt } = buildFunctionInfo(lines); + const out = []; + for (let i = 0; i < lines.length; i++) { + const fn = innermostAt[i]; + if (!fn || !registeredFns.has(fn)) continue; + const line = lines[i]; + const match = RAW_READ_RE.exec(line); + if (!match) continue; + out.push({ file, line: i + 1, found: match[0], text: line.trim() }); + } + return out; +} + +/** + * Scan the authored source tree and return every registered-function raw-read + * bypass, each annotated with the (POSIX-normalized) repo-relative file path. + */ +function scanRepo(root) { + return scanTree({ + root, + scanDirs: SCAN_DIRS, + scanExt: SCAN_EXT, + onFile(rel, text) { + return findSnapshotBypassDrift(text, rel); + }, + }); +} + +/** + * Read and parse the ratchet baseline. Returns `{ entries, errors }` — + * mirrors `lint-planning-prompt-drift.cjs`'s `loadBaseline` verbatim, adapted + * to this guard's baseline path. + */ +function loadBaseline(root) { + const baselinePath = path.join(root, BASELINE_REL_PATH); + if (!fs.existsSync(baselinePath)) { + return { entries: [], errors: [`${BASELINE_REL_PATH} is missing — run \`node scripts/lint-planning-snapshot-bypass-drift.cjs --update\` to generate it`] }; + } + const raw = fs.readFileSync(baselinePath, 'utf8'); + if (raw.trim() === '') { + return { entries: [], errors: [`${BASELINE_REL_PATH} is present but empty`] }; + } + let doc; + try { + doc = JSON.parse(raw); + } catch (err) { + return { entries: [], errors: [`${BASELINE_REL_PATH} is not valid JSON: ${err.message}`] }; + } + if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) { + return { entries: [], errors: [`${BASELINE_REL_PATH} must be a JSON object, got ${Array.isArray(doc) ? 'array' : typeof doc}`] }; + } + if (!Array.isArray(doc.entries)) { + return { entries: [], errors: [`${BASELINE_REL_PATH}: "entries" must be an array, got ${JSON.stringify(doc.entries)}`] }; + } + const errors = []; + const entries = []; + doc.entries.forEach((entry, i) => { + const where = `${BASELINE_REL_PATH}.entries[${i}]`; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + errors.push(`${where} must be an object, got ${JSON.stringify(entry)}`); + return; + } + if (typeof entry.file !== 'string' || entry.file === '') { + errors.push(`${where}.file must be a non-empty string, got ${JSON.stringify(entry.file)}`); + return; + } + if (typeof entry.text !== 'string' || entry.text === '') { + errors.push(`${where}.text must be a non-empty string, got ${JSON.stringify(entry.text)}`); + return; + } + if (entry.count !== undefined && !(Number.isInteger(entry.count) && entry.count >= 1)) { + errors.push(`${where}.count must be a positive integer when present, got ${JSON.stringify(entry.count)}`); + return; + } + entries.push(entry); + }); + return { entries, errors }; +} + +/** + * Diff scanned `violations` against baseline `entries`, matched by the pair + * (`file`, TRIMMED `text`), count-aware — mirrors + * `lint-planning-prompt-drift.cjs`'s `diffAgainstBaseline` verbatim. See that + * module's header for the full "COUNT, not duplicate rows" rationale. + */ +function diffAgainstBaseline(violations, baseline) { + const key = (file, text) => `${file} ${text}`; + + const actualByKey = new Map(); + for (const v of violations) { + const k = key(v.file, v.text); + let vs = actualByKey.get(k); + if (!vs) { vs = []; actualByKey.set(k, vs); } + vs.push(v); + } + + const knownKeys = new Set(baseline.map((e) => key(e.file, e.text))); + + const fresh = []; + const stale = []; + + for (const [k, vs] of actualByKey) { + if (!knownKeys.has(k)) fresh.push(...vs); + } + + for (const entry of baseline) { + const k = key(entry.file, entry.text); + const expected = entry.count ?? 1; + const vs = actualByKey.get(k) || []; + const actual = vs.length; + if (actual < expected) { + stale.push({ ...entry, count: expected, actualCount: actual }); + } else if (actual > expected) { + fresh.push(...vs.slice(expected)); + } + } + + return { fresh, stale }; +} + +/** Stable sort: by `file`, then by `text`. */ +function sortEntries(entries) { + return [...entries].sort((a, b) => { + if (a.file !== b.file) return a.file < b.file ? -1 : 1; + if (a.text !== b.text) return a.text < b.text ? -1 : 1; + return 0; + }); +} + +/** + * Collapse `violations` into one baseline row per distinct (file, text) pair, + * carrying a `count` of how many occurrences that pair has in THIS run. Pure; + * no I/O. + */ +function dedupeViolationsForBaseline(violations) { + const order = []; + const byKey = new Map(); + for (const v of violations) { + const k = `${v.file} ${v.text}`; + let entry = byKey.get(k); + if (!entry) { + entry = { file: v.file, text: v.text, derivation: 'planning-snapshot-bypass', owner_issue: RATCHET_OWNER_ISSUE, count: 0 }; + byKey.set(k, entry); + order.push(entry); + } + entry.count += 1; + } + return order; +} + +function writeBaseline(root, violations) { + const entries = sortEntries(dedupeViolationsForBaseline(violations)); + const doc = { + $comment: + 'ADR-3180 §8.1 rule 2 ratchet, owned by Phase 11 (#3309). See scripts/lint-planning-snapshot-bypass-drift.cjs. ' + + 'SHRINK-ONLY: entries are removed as cmdValidateHealth migrates onto src/planning-snapshot.cts; new or ' + + 'changed entries fail lint:ci. `count` is the number of byte-identical (file, text) occurrences ' + + 'acknowledged at this site — a run producing fewer fails as a partial migration, more fails as an ' + + 'unacknowledged new copy.', + entries, + }; + const baselinePath = path.join(root, BASELINE_REL_PATH); + fs.mkdirSync(path.dirname(baselinePath), { recursive: true }); + fs.writeFileSync(baselinePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8'); + return entries; +} + +function main() { + const root = path.join(__dirname, '..'); + const update = process.argv.includes('--update'); + const violations = scanRepo(root); + + if (update) { + const entries = writeBaseline(root, violations); + process.stdout.write(`ok planning-snapshot-bypass: baseline regenerated with ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}\n`); + return; + } + + const { entries: baseline, errors } = loadBaseline(root); + if (errors.length > 0) { + process.stderr.write('planning-snapshot-bypass: baseline load error(s):\n'); + for (const e of errors) process.stderr.write(` ${e}\n`); + process.exitCode = 1; + return; + } + + const { fresh, stale } = diffAgainstBaseline(violations, baseline); + + if (fresh.length === 0 && stale.length === 0) { + process.stdout.write(`ok planning-snapshot-bypass: no unacknowledged raw .planning/ reads in registered diagnostic-rule functions (${baseline.length} known)\n`); + return; + } + + if (fresh.length > 0) { + process.stderr.write('planning-snapshot-bypass: NEW raw .planning/ read(s) found inside a DIAGNOSTIC_RULE_FUNCTIONS-registered function.\n'); + process.stderr.write('Route the read through src/planning-snapshot.cts (ADR-3180 §7 owner functions: getMilestoneInfo,\n'); + process.stderr.write('listMilestonePhaseDirs, isPhaseComplete, scanPhasePlans, stateFieldValue, ...) instead of calling\n'); + process.stderr.write(`platformReadSync(/readFileSync(/readdirSync( directly, or add an acknowledged entry to ${BASELINE_REL_PATH}\n`); + process.stderr.write('via --update:\n'); + for (const v of fresh) { + process.stderr.write(` ${sanitizeForReport(v.file)}:${v.line} ${sanitizeForReport(v.found)} ${sanitizeForReport(v.text)}\n`); + } + } + + if (stale.length > 0) { + process.stderr.write('\nplanning-snapshot-bypass: STALE baseline entr' + (stale.length === 1 ? 'y' : 'ies') + " (fully migrated, or a PARTIAL migration — fewer occurrences found than acknowledged; delete or re-record the row):\n"); + for (const e of stale) { + process.stderr.write(` ${sanitizeForReport(e.file)} ${sanitizeForReport(e.text)} (found ${e.actualCount}/${e.count} acknowledged occurrence${e.count === 1 ? '' : 's'})\n`); + } + process.stderr.write(`\n remedy: node scripts/lint-planning-snapshot-bypass-drift.cjs --update\n`); + } + + process.exitCode = 1; +} + +if (require.main === module) main(); + +module.exports = { + findSnapshotBypassDrift, + scanRepo, + toPosixRel, + loadBaseline, + diffAgainstBaseline, + writeBaseline, + dedupeViolationsForBaseline, + sortEntries, + buildFunctionInfo, + DIAGNOSTIC_RULE_FUNCTIONS, + RAW_READ_RE, + SCAN_DIRS, + SCAN_EXT, + BASELINE_REL_PATH, + RATCHET_OWNER_ISSUE, +}; diff --git a/src/planning-snapshot.cts b/src/planning-snapshot.cts new file mode 100644 index 000000000..b1fcfd7dc --- /dev/null +++ b/src/planning-snapshot.cts @@ -0,0 +1,182 @@ +/** + * Planning Snapshot — a parsed projection of `.planning/` (Phase 10, #3308, + * ADR-3180 §8.1). + * + * Composed EXCLUSIVELY from the already-consolidated §7 owners + * (`getMilestoneInfo`, `listMilestonePhaseDirs`, `isPhaseComplete`, + * `scanPhasePlans`, `stateFieldValue`, `planningPaths`) plus the frozen + * `SCOPE` enum. This module introduces no new semantic derivation — it + * introduces exactly one new thing: `worstScope`, a way to combine several + * independently-scoped owner answers into one composite record without + * letting a caller treat a non-answer as data. + * + * `buildPlanningSnapshot(cwd)` is the sole export consumers reach for; + * `worstScope` is exported alongside it for direct unit coverage. + * + * Design: .gsd/phase/refactor-3308-planning-snapshot-parsed-projection/40-design.md + * + * ADR-457 build-at-publish: source in src/planning-snapshot.cts, compiled to + * gsd-core/bin/lib/planning-snapshot.cjs (gitignored). + */ + +import path from 'node:path'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import roadmapParserMod = require('./roadmap-parser.cjs'); +const { getMilestoneInfo } = roadmapParserMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseLocatorMod = require('./phase-locator.cjs'); +const { listMilestonePhaseDirs } = phaseLocatorMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import verificationMod = require('./verification.cjs'); +const { isPhaseComplete } = verificationMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import scanPhasePlans = require('./plan-scan.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningWorkspace = require('./planning-workspace.cjs'); +const { planningPaths } = planningWorkspace; +import { platformReadSync } from './shell-command-projection.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import frontmatterMod = require('./frontmatter.cjs'); +const { extractFrontmatter, stripFrontmatter } = frontmatterMod; +import { stateFieldValue, stateCurrentPositionSlice } from './state-document.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import unusableInputMod = require('./unusable-input.cjs'); +const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningScopeMod = require('./planning-scope.cjs'); +const { SCOPE } = planningScopeMod; +type Scope = planningScopeMod.Scope; + +// ─── worstScope — the one new piece of coordination logic ─────────────────── + +/** + * Severity ordering (`UNREADABLE` worst, `COMPLETE` best) is a genuine design + * choice, not inherited from anywhere — see the design doc's "Scope + * combination" section. `TRUNCATED` vs `UNSCOPED` are not ranked against each + * other by any upstream decision; this ordering exists only so a future + * diagnostic rule can name which failure was worse when several compound. + */ +const SCOPE_SEVERITY: Record = { + [SCOPE.COMPLETE]: 0, + [SCOPE.TRUNCATED]: 1, + [SCOPE.UNSCOPED]: 2, + [SCOPE.UNREADABLE]: 3, +}; + +/** + * Combine several independently-scoped owner answers into the single worst + * (most severe) `Scope` among them. Pure, no I/O. Not a re-derivation of any + * §7 owner — it folds together already-final `scope` outputs, which is new + * coordination logic no single owner has visibility to express itself. + */ +function worstScope(...scopes: Scope[]): Scope { + return scopes.reduce((worst, s) => (SCOPE_SEVERITY[s] > SCOPE_SEVERITY[worst] ? s : worst)); +} + +// ─── Snapshot shape ─────────────────────────────────────────────────────────── + +interface PhaseSnapshot { + dir: string; + complete: boolean; + verificationStatus: string; + planCount: number; + summaryCount: number; + scope: Scope; +} + +interface PlanningSnapshot { + milestone: ReturnType; + phaseDirs: ReturnType; + phases: { value: PhaseSnapshot[]; scope: Scope }; + currentPhaseLabel: { value: string | null; scope: Scope }; +} + +/** + * Build one `PhaseSnapshot` for a single already-enumerated phase directory + * name. `isPhaseComplete` and `scanPhasePlans` each perform their own raw + * `readdirSync` against `fullPhaseDir` and can independently degrade — see + * the design doc's "Scope combination" section for why the two are genuinely + * uncorrelated (isPhaseComplete's readability check never re-derives or + * requires scanPhasePlans, and vice versa). + */ +function buildPhaseSnapshot(phasesDir: string, dir: string): PhaseSnapshot { + const fullPhaseDir = path.join(phasesDir, dir); + const completionResult = isPhaseComplete(fullPhaseDir); + const scanResult = scanPhasePlans(fullPhaseDir); + return { + dir, + complete: completionResult.value.complete, + verificationStatus: completionResult.value.verification.status, + planCount: scanResult.planCount, + summaryCount: scanResult.summaryCount, + scope: worstScope(completionResult.scope, scanResult.scope), + }; +} + +/** + * Resolve `currentPhaseLabel` — the raw `Phase:` field STATE.md records under + * `## Current Position` (e.g. `"3 of 8 (User Auth)"`), not a normalized + * phase-directory id (see the design doc's Known limits). + * + * This module performs the one STATE.md read no §7 owner does, mirroring + * every existing STATE.md caller (`cmdStateSnapshot`, `cmdStatePrune`): + * `platformReadSync` + `extractFrontmatter` + `stripFrontmatter`. + * + * - STATE.md absent (ENOENT, `platformReadSync` returns `null`) is a real + * non-answer, NOT corruption — a project that never ran `state.init` + * legitimately has no STATE.md yet. `warnUnusableInput` is NOT called. + * - STATE.md present but unreadable (any other read error, e.g. EISDIR) is + * corruption — `warnUnusableInput(STATE_UNREADABLE)` fires exactly once. + * - An unterminated frontmatter fence is reported by `extractFrontmatter` + * itself (`FRONTMATTER_UNTERMINATED`) — this function does not duplicate + * that diagnostic; it still attempts a body-only field read on whatever + * `stripFrontmatter` leaves behind. + */ +function buildCurrentPhaseLabel(statePath: string): { value: string | null; scope: Scope } { + let content: string | null; + try { + content = platformReadSync(statePath); + } catch { + warnUnusableInput({ reason: UNUSABLE_REASON.STATE_UNREADABLE, source: statePath }); + return { value: null, scope: SCOPE.UNREADABLE }; + } + if (content === null) { + return { value: null, scope: SCOPE.UNREADABLE }; + } + + const frontmatter = extractFrontmatter(content, statePath); + const body = stripFrontmatter(content); + const section = stateCurrentPositionSlice(body); + return stateFieldValue(frontmatter, section ?? body, null, 'Phase', { + scope: section === null ? SCOPE.TRUNCATED : SCOPE.COMPLETE, + }); +} + +/** + * Build the full `.planning/` projection for `cwd`. Composes exactly the six + * §7 owners named in the design doc's "Owners consumed" table — no + * re-derivation, no new semantic answer. See the design doc for the + * behavior table and rejected alternatives. + */ +function buildPlanningSnapshot(cwd: string): PlanningSnapshot { + const paths = planningPaths(cwd); + const milestone = getMilestoneInfo(cwd); + const phaseDirs = listMilestonePhaseDirs(paths.phases, { cwd }); + + const phasesValue = phaseDirs.value.map((dir) => buildPhaseSnapshot(paths.phases, dir)); + + return { + milestone, + phaseDirs, + phases: { + value: phasesValue, + scope: worstScope(phaseDirs.scope, ...phasesValue.map((p) => p.scope)), + }, + currentPhaseLabel: buildCurrentPhaseLabel(paths.state), + }; +} + +export = { + buildPlanningSnapshot, + worstScope, +}; diff --git a/src/unusable-input.cts b/src/unusable-input.cts index 83206d385..cd51e1880 100644 --- a/src/unusable-input.cts +++ b/src/unusable-input.cts @@ -57,6 +57,13 @@ const UNUSABLE_REASON = Object.freeze({ * silent degradation is visible. (#3099, sixth #1879 site) */ LAST_ACTIVITY_UNPARSEABLE: 'last_activity_unparseable', + /** + * A STATE.md exists but could not be read (EACCES/EIO/…). Distinct from a project that has + * not run `state.init` yet: absence returns the same non-answer, silently — only an + * exists-but-unreadable STATE.md is corruption. (#3308, seventh #1879 site — + * planning-snapshot's current-phase field) + */ + STATE_UNREADABLE: 'state_unreadable', } as const); type UnusableReason = (typeof UNUSABLE_REASON)[keyof typeof UNUSABLE_REASON]; @@ -69,6 +76,8 @@ const REASON_PROSE: Readonly> = Object.freeze({ 'ROADMAP.md exists but could not be read; phase and milestone lookups fell back to defaults', [UNUSABLE_REASON.LAST_ACTIVITY_UNPARSEABLE]: 'last_activity in STATE.md is present but unparseable as a date; stale_activity fell back to false (idle-stranded suppressed)', + [UNUSABLE_REASON.STATE_UNREADABLE]: + 'STATE.md exists but could not be read; the current-phase label fell back to unavailable', }); // ─── Dedup state ────────────────────────────────────────────────────────────── From 97876a77a42c8809054e2170bd6d954e62b03bd3 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 12 Aug 2026 21:52:50 -0400 Subject: [PATCH 3/7] =?UTF-8?q?docs(#3308):=20ADR-3180=20=C2=A78.1=20Enfor?= =?UTF-8?q?ced,=20Amendment=209=20=E2=80=94=20Phase=2010=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates docs/adr/3180-planning-semantic-model-single-owner.md to reflect Phase 10 shipping: §8.1 status Required -> Enforced (Phase 10, #3308), guard-roster row contract only -> enforced, phase-index table issue/status backfilled, and a new Amendment 9 recording the guard's real baseline (15 distinct raw-read sites, 21 total acknowledged occurrences in cmdValidateHealth) against the issue's own vaguer estimate, per Amendment 4a's standing "N found by the guard, never per the epic" rule. Also records the intended reading of an absent STATE.md as UNREADABLE-without-diagnostic, symmetric with every other §7 owner's absence-vs-corruption distinction. --- ...80-planning-semantic-model-single-owner.md | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/adr/3180-planning-semantic-model-single-owner.md b/docs/adr/3180-planning-semantic-model-single-owner.md index 26e3d19ca..8b6f7728a 100644 --- a/docs/adr/3180-planning-semantic-model-single-owner.md +++ b/docs/adr/3180-planning-semantic-model-single-owner.md @@ -900,7 +900,7 @@ Two rows join the roster (declared here rather than inserted above): | Derivation | Owner | Guard | Scan surface | Status | |---|---|---|---|---| -| Diagnostic subject (8.1) | `planning-snapshot.cts` (Phase 10) | `lint-planning-snapshot-bypass-drift.cjs` | `src/` | contract only | +| Diagnostic subject (8.1) | `planning-snapshot.cts` (Phase 10) | `lint-planning-snapshot-bypass-drift.cjs` | `src/` | enforced | | Planning-artifact registration (8.4) | `artifacts.cts` | `lint-planning-artifact-writer-drift.cjs` (Phase 12) | `src/` | contract only | **The second row is a different shape, recorded as such rather than filed under a contract it does @@ -969,7 +969,7 @@ is a further amendment to this ADR. Decision 8 **consumes** §7.1–7.7 and does of them — in particular §7.7 already governs `state validate`'s unconditional `{valid: true}`, and Decision 8 does not re-decide it. -#### 8.1 The subject a rule may read — *Required — Phase 10* +#### 8.1 The subject a rule may read — *Enforced (Phase 10, #3308)* **Question.** What may a diagnostic rule look at? @@ -1107,7 +1107,7 @@ apply. | Phase | Issue | Deliverable | Status | |---|---|---|---| | 9 | #3287 | this design lock (Decision 8) | docs-only | -| 10 | to file | `src/planning-snapshot.cts` (8.1) + `lint-planning-snapshot-bypass-drift.cjs`, ratcheted | ready — Phase 5 merged | +| 10 | #3308 | `src/planning-snapshot.cts` (8.1) + `lint-planning-snapshot-bypass-drift.cjs`, ratcheted | PR pending (Amendment 9) | | 11 | to file | `src/health-diagnostic.cts` (8.2/8.3/8.5), `validate.health` migrated, W021/W017 second subjects take new codes, `health.md` tables generated | follows Phase 10 | | 12 | to file | `validate.consistency` + `state.validate` onto the envelope (8.4); `lint-planning-artifact-writer-drift.cjs` | follows Phase 11 | @@ -1314,3 +1314,49 @@ narrowly. `.changeset/bold-otters-scope.md` is updated to disclose this write-path change alongside the two Amendment 7 already recorded. + +### Amendment 9 — Phase 10 (#3308) validation: the guard's real baseline, not the issue's estimate + +Phase 10 (`src/planning-snapshot.cts`, PR pending) shipped the diagnostic subject §8.1 specifies: +`buildPlanningSnapshot(cwd)`, a parsed projection of `.planning/` composed **exclusively** from the +already-consolidated §7 owners — `getMilestoneInfo`, `listMilestonePhaseDirs`, `isPhaseComplete`, +`scanPhasePlans`, `stateFieldValue`, `planningPaths`. It introduces exactly one new piece of +coordination logic: `worstScope(...scopes)`, a severity-ordered combinator (`COMPLETE` best, +`UNREADABLE` worst) folding several independently-scoped owner answers into one composite `Scope` +per phase record. This is not a re-derivation of any owner — each input `scope` is already that +owner's final verdict; `worstScope` only picks the worst of several finals, which is new coordination +no single §7 owner has visibility to express on its own. + +**The guard's real baseline, per Amendment 4a's standing rule ("N found by the guard, never N per +the epic").** The ratcheted guard `scripts/lint-planning-snapshot-bypass-drift.cjs`, scoped to +`DIAGNOSTIC_RULE_FUNCTIONS = {src/verify.cts: {cmdValidateHealth}}`, found **15 distinct (file, text) +raw-read sites, 21 total acknowledged occurrences** inside `cmdValidateHealth` +(`scripts/baselines/planning-snapshot-bypass-baseline.json`). Contrast this against the epic's own +code-COUNT estimate: `cmdValidateHealth` is described, both in the issue and in this ADR's own +Amendment 6 (§ *Why the diagnostic layer is the same failure class*), as emitting "30+" diagnostic +codes through one nested `addIssue` closure — a figure about how many **codes** the function emits, +not how many **raw-read call sites** produce them. The two are different measures, exactly as +Amendments 2/3/4/7 found for their own derivations: a code-count estimate is not a call-site count, +and the whole-repo, function-scoped guard is what makes the real number visible instead of assumed. +The gap runs the expected direction — several codes share a read (`configRaw`'s +`fs.readFileSync(configPath, 'utf-8')` alone accounts for 4 of the 21 occurrences) — so 15 sites +covering 21 occurrences behind 30+ codes is consistent with, not contradictory to, the epic's figure. + +**The contract held on the first pass.** No amendment to §8.1 rules 1–4 was needed. Rule 2 — parsed +values only, never raw text — is what the guard now mechanically enforces going forward for any +**new** diagnostic-rule-shaped code: an unrecorded raw-read site inside a `DIAGNOSTIC_RULE_FUNCTIONS` +entry fails lint immediately. `cmdValidateHealth`'s existing 15 sites are ratcheted debt explicitly +owned by Phase 11 (#3309), not silently left unwatched — the baseline can only shrink, and a site that +stops firing without being pruned from the baseline also fails, per Decision 4(e)'s invariants. + +**One open judgment call, surfaced for a maintainer's eyes rather than silently resolved — not a +defect, per this ADR's own "written rule, not silent implementation choice" philosophy.** §8.1 rule 4's +text ("Read failures are reported via `warnUnusableInput`... and the field's `scope` is `UNREADABLE`") +could read as implying every `UNREADABLE` scope correlates with a reported diagnostic. Phase 10's +`currentPhaseLabel` field (`buildCurrentPhaseLabel`, `src/planning-snapshot.cts`) treats a genuinely +**absent** STATE.md as `UNREADABLE` too, but does **not** call `warnUnusableInput` for that case — only +an actual read error (e.g. EISDIR) fires it. This mirrors §7's own absence-vs-corruption distinction +elsewhere in this ADR (e.g. the `unusable-input.cts` glossary entry's `#1881` note on ROADMAP.md): a +project that never ran `state.init` legitimately has no STATE.md yet, and that is a non-answer, not +corruption. Recorded as the intended reading rather than a gap, since it is symmetric with how every +other §7 owner already treats absence vs. unreadable. From 21c46ecf5230d7079cce0d172bcd35664588836c Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 12 Aug 2026 22:05:05 -0400 Subject: [PATCH 4/7] fix: scope safe.directory ownership bypass to the real-repo-root git ls-files call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while running gsd-test for #3308: tests/commit-files-pathspec.test.cjs's repo-wide `--files` scan runs `git ls-files -z -- *.md` directly against the checked-out repo root (not a createTempGitProject() fixture, unlike every other gitOrThrow call in this file). Inside a container-provisioned test runner the checkout's on-disk owner can legitimately differ from the running UID, tripping git's CVE-2022-24765 dubious-ownership guard and failing the scan closed (exitCode 128) rather than reporting a real file-list result — reproduced on gsd-test's linux-node22 and linux-node24 lanes. Adds `-c safe.directory=*` to that ONE invocation only, so the bypass is scoped to this call rather than a global `git config` write that would leak into every other git call in the process. No source behavior changed; test-infrastructure resilience only. --- tests/commit-files-pathspec.test.cjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/commit-files-pathspec.test.cjs b/tests/commit-files-pathspec.test.cjs index 7b6125ed4..c72388301 100644 --- a/tests/commit-files-pathspec.test.cjs +++ b/tests/commit-files-pathspec.test.cjs @@ -2813,7 +2813,13 @@ describe('workflow call sites declare --files (#2269)', () => { // Routed through tests/helpers/git-fixture.cjs rather than a bare spawn per // #3144 — local/no-unbounded-spawn fails an unbounded spawnSync in tests, // and this file's allowlist entry was retired when that migration landed. - const trackedMd = gitOrThrow(['ls-files', '-z', '--', '*.md'], { + // -c safe.directory=* is scoped to THIS invocation only (never a global + // `git config` write): unlike every other gitOrThrow call in this file, + // which targets a createTempGitProject() fixture it owns, this one runs + // against the real checked-out repoRoot, whose ownership can legitimately + // differ from the running UID inside a container-provisioned test runner + // (git's CVE-2022-24765 dubious-ownership guard would otherwise fire). + const trackedMd = gitOrThrow(['-c', 'safe.directory=*', 'ls-files', '-z', '--', '*.md'], { cwd: repoRoot, timeoutMs: GIT_TIMEOUT_MS, }).split('\0').filter(Boolean); assert.ok(trackedMd.length > 0, 'git ls-files reported no .md files at all — the walk is broken'); From 9ce44efe85952cba026fd23262832727a892c52b Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 12 Aug 2026 22:25:00 -0400 Subject: [PATCH 5/7] docs(#3308): add changeset for planning-snapshot parsed projection --- .changeset/tidy-bears-wave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tidy-bears-wave.md diff --git a/.changeset/tidy-bears-wave.md b/.changeset/tidy-bears-wave.md new file mode 100644 index 000000000..31f7f8e69 --- /dev/null +++ b/.changeset/tidy-bears-wave.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 0 +--- +**Diagnostic rules for `.planning/` health checks now have a single parsed subject to read from** — `src/planning-snapshot.cts` composes the already-consolidated milestone, phase, and plan derivations into one scope-carrying projection, so a rule can no longer re-derive a field's location from raw document text the way three now-inert `validate health` predicates once did (#3162). No command output changes yet — `validate health` migrates onto it in a follow-up phase. (#3308) From 1f5fc2461082ace7880bab5d0e8e9783f163d6c1 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 12 Aug 2026 22:28:03 -0400 Subject: [PATCH 6/7] chore(#3308): backfill changeset PR number (#3402) --- .changeset/tidy-bears-wave.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tidy-bears-wave.md b/.changeset/tidy-bears-wave.md index 31f7f8e69..96ff2e7fe 100644 --- a/.changeset/tidy-bears-wave.md +++ b/.changeset/tidy-bears-wave.md @@ -1,5 +1,5 @@ --- type: Added -pr: 0 +pr: 3402 --- **Diagnostic rules for `.planning/` health checks now have a single parsed subject to read from** — `src/planning-snapshot.cts` composes the already-consolidated milestone, phase, and plan derivations into one scope-carrying projection, so a rule can no longer re-derive a field's location from raw document text the way three now-inert `validate health` predicates once did (#3162). No command output changes yet — `validate health` migrates onto it in a follow-up phase. (#3308) From 2bd1d123693611288e5d360cfea0408d985df5ff Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 12 Aug 2026 22:54:30 -0400 Subject: [PATCH 7/7] fix(#3308): compare guard-produced file fields against POSIX-normalized paths in Windows CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3402's real Windows CI (windows-latest node22/24) caught what gsd-test's Linux-only lanes structurally cannot: tests/planning-snapshot-bypass-drift.test.cjs compared the guard's own POSIX-normalized output (findSnapshotBypassDrift's `file` field, dedupeViolationsForBaseline/sortEntries entries, a written baseline read back from disk) against REGISTERED_FILE, which is built via path.join('src', 'verify.cts') and is therefore backslash-separated on Windows. The guard always normalizes its OUTPUT to POSIX via toPosixRel regardless of the input separator form, so the comparison only ever coincidentally passed on POSIX. Adds REGISTERED_FILE_POSIX for every assertion against a guard-PRODUCED value (including baseline fixtures fed into diffAgainstBaseline, which are matched by exact string key against the guard's normalized output). REGISTERED_FILE itself is unchanged and still used, correctly, everywhere it is the relPath INPUT to findSnapshotBypassDrift or a DIAGNOSTIC_RULE_FUNCTIONS Map-key lookup — both need the platform-native form to match the guard's own Map key, which is also path.join-constructed. No behavior change on POSIX (both constants are byte-identical there). --- tests/planning-snapshot-bypass-drift.test.cjs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/planning-snapshot-bypass-drift.test.cjs b/tests/planning-snapshot-bypass-drift.test.cjs index 1fff7fe2b..140e82345 100644 --- a/tests/planning-snapshot-bypass-drift.test.cjs +++ b/tests/planning-snapshot-bypass-drift.test.cjs @@ -40,6 +40,14 @@ const fs = require('node:fs'); const path = require('node:path'); const REGISTERED_FILE = path.join('src', 'verify.cts'); +// The guard's OWN output (found/file fields, baseline entries) is always +// POSIX-normalized via toPosixRel, regardless of the separator form its +// `relPath` input used — comparing against the platform-native +// REGISTERED_FILE is correct for the guard's INPUT (the DIAGNOSTIC_RULE_FUNCTIONS +// Map key is also path.join-constructed, so an exact Map.has() lookup needs +// this form) but wrong for anything the guard PRODUCED, which this constant +// is for. +const REGISTERED_FILE_POSIX = REGISTERED_FILE.replace(/\\/g, '/'); const REGISTERED_FN = 'cmdValidateHealth'; // A minimal fixture carrying one registered diagnostic-rule-shaped function @@ -72,12 +80,12 @@ describe('findSnapshotBypassDrift — G1/G3: registered function raw-read detect assert.strictEqual(out[0].line, 2); assert.strictEqual(out[0].found, 'platformReadSync('); assert.strictEqual(out[0].text, 'const raw = platformReadSync(x);'); - assert.strictEqual(out[0].file, REGISTERED_FILE); + assert.strictEqual(out[0].file, REGISTERED_FILE_POSIX); }); test('G1: a registered-function violation present in the baseline is KNOWN — neither fresh nor stale', () => { const violations = findSnapshotBypassDrift(fixtureSource(), REGISTERED_FILE); - const baseline = [{ file: REGISTERED_FILE, text: 'const raw = platformReadSync(x);' }]; + const baseline = [{ file: REGISTERED_FILE_POSIX, text: 'const raw = platformReadSync(x);' }]; const { fresh, stale } = diffAgainstBaseline(violations, baseline); assert.deepStrictEqual(fresh, []); assert.deepStrictEqual(stale, []); @@ -116,7 +124,7 @@ describe('findSnapshotBypassDrift — G2: function-scoped detection (not whole-f describe('diffAgainstBaseline — G4: stale entries', () => { test('a baseline entry whose (file, text) pair no longer appears in a fresh scan is reported under stale', () => { - const baseline = [{ file: REGISTERED_FILE, text: 'const raw = platformReadSync(oldSite);' }]; + const baseline = [{ file: REGISTERED_FILE_POSIX, text: 'const raw = platformReadSync(oldSite);' }]; const violations = findSnapshotBypassDrift(fixtureSource(), REGISTERED_FILE); const { fresh, stale } = diffAgainstBaseline(violations, baseline); // The baseline's own (unmatched) entry is stale... @@ -136,7 +144,7 @@ describe('writeBaseline / dedupeViolationsForBaseline — G5: regeneration match const deduped = dedupeViolationsForBaseline(violations); const sorted = sortEntries(deduped); assert.strictEqual(sorted.length, 1); - assert.strictEqual(sorted[0].file, REGISTERED_FILE); + assert.strictEqual(sorted[0].file, REGISTERED_FILE_POSIX); assert.strictEqual(sorted[0].text, 'const raw = platformReadSync(x);'); assert.strictEqual(sorted[0].count, 1); // A freshly-written baseline must itself be immediately KNOWN (not @@ -155,7 +163,7 @@ describe('writeBaseline / dedupeViolationsForBaseline — G5: regeneration match const writtenPath = path.join(root, 'scripts', 'baselines', 'planning-snapshot-bypass-baseline.json'); const written = JSON.parse(fs.readFileSync(writtenPath, 'utf8')); assert.strictEqual(written.entries.length, 1); - assert.strictEqual(written.entries[0].file, REGISTERED_FILE); + assert.strictEqual(written.entries[0].file, REGISTERED_FILE_POSIX); assert.strictEqual(written.entries[0].text, 'const raw = platformReadSync(x);'); assert.strictEqual(written.entries[0].count, 1); // The written baseline immediately reconciles with the pass that produced