Merge pull request #3402 from open-gsd/refactor/3308-planning-snapshot-parsed-projection

This commit is contained in:
Tom Boucher
2026-08-13 00:12:48 -04:00
committed by GitHub
16 changed files with 1670 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
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)

1
.gitignore vendored
View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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',

View File

@@ -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",

View File

@@ -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
}
]
}

View File

@@ -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<repo-relative file, Set<function name>>`) 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,
};

182
src/planning-snapshot.cts Normal file
View File

@@ -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, number> = {
[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<typeof getMilestoneInfo>;
phaseDirs: ReturnType<typeof listMilestonePhaseDirs>;
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,
};

View File

@@ -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<Record<UnusableReason, string>> = 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 ──────────────────────────────────────────────────────────────

View File

@@ -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');

View File

@@ -0,0 +1,211 @@
/**
* 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');
// 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
// (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_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_POSIX, 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_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...
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_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
// 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_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
// 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, []);
});
});

View File

@@ -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 },
);
});
});

View File

@@ -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', () => {