refactor(#3308): add planning-snapshot.cts parsed projection per ADR-3180 §8.1
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.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -195,6 +195,7 @@ build/
|
|||||||
/gsd-core/bin/lib/worktree-safety.cjs
|
/gsd-core/bin/lib/worktree-safety.cjs
|
||||||
/gsd-core/bin/lib/planning-workspace.cjs
|
/gsd-core/bin/lib/planning-workspace.cjs
|
||||||
/gsd-core/bin/lib/planning-scope.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/command-roster.cjs
|
||||||
/gsd-core/bin/lib/runtime-artifact-conversion.cjs
|
/gsd-core/bin/lib/runtime-artifact-conversion.cjs
|
||||||
/gsd-core/bin/lib/runtime-artifact-layout.cjs
|
/gsd-core/bin/lib/runtime-artifact-layout.cjs
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ Module owning legacy-key normalization, defaults merge, and explicit on-disk mig
|
|||||||
### Planning Scope Module
|
### 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.
|
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
|
### 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.
|
Module owning `.planning` path resolution, active workstream pointer policy (`session-scoped > shared`), pointer self-heal behavior, and planning lock semantics for workstream-aware execution.
|
||||||
|
|
||||||
|
|||||||
@@ -423,6 +423,7 @@
|
|||||||
"plan-drift-guard.cjs",
|
"plan-drift-guard.cjs",
|
||||||
"plan-scan.cjs",
|
"plan-scan.cjs",
|
||||||
"planning-scope.cjs",
|
"planning-scope.cjs",
|
||||||
|
"planning-snapshot.cjs",
|
||||||
"planning-workspace.cjs",
|
"planning-workspace.cjs",
|
||||||
"probe-core.cjs",
|
"probe-core.cjs",
|
||||||
"profile-output.cjs",
|
"profile-output.cjs",
|
||||||
|
|||||||
@@ -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-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) |
|
| `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-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) |
|
| `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) |
|
| `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 |
|
| `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation |
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export default tseslint.config(
|
|||||||
'gsd-core/bin/lib/model-catalog.cjs',
|
'gsd-core/bin/lib/model-catalog.cjs',
|
||||||
'gsd-core/bin/lib/configuration.cjs',
|
'gsd-core/bin/lib/configuration.cjs',
|
||||||
'gsd-core/bin/lib/state-document.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/shell-command-projection.cjs',
|
||||||
'gsd-core/bin/lib/security.cjs',
|
'gsd-core/bin/lib/security.cjs',
|
||||||
'gsd-core/bin/lib/command-aliases.cjs',
|
'gsd-core/bin/lib/command-aliases.cjs',
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
"lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs",
|
"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:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs",
|
||||||
"lint:removed-but-needed": "node scripts/lint-removed-but-needed.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:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
|
||||||
"lint:regression-names": "node scripts/lint-regression-test-names.cjs",
|
"lint:regression-names": "node scripts/lint-regression-test-names.cjs",
|
||||||
"lint:descriptions": "node scripts/lint-descriptions.cjs",
|
"lint:descriptions": "node scripts/lint-descriptions.cjs",
|
||||||
|
|||||||
110
scripts/baselines/planning-snapshot-bypass-baseline.json
Normal file
110
scripts/baselines/planning-snapshot-bypass-baseline.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
544
scripts/lint-planning-snapshot-bypass-drift.cjs
Normal file
544
scripts/lint-planning-snapshot-bypass-drift.cjs
Normal 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
182
src/planning-snapshot.cts
Normal 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,
|
||||||
|
};
|
||||||
@@ -57,6 +57,13 @@ const UNUSABLE_REASON = Object.freeze({
|
|||||||
* silent degradation is visible. (#3099, sixth #1879 site)
|
* silent degradation is visible. (#3099, sixth #1879 site)
|
||||||
*/
|
*/
|
||||||
LAST_ACTIVITY_UNPARSEABLE: 'last_activity_unparseable',
|
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);
|
} as const);
|
||||||
|
|
||||||
type UnusableReason = (typeof UNUSABLE_REASON)[keyof typeof UNUSABLE_REASON];
|
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',
|
'ROADMAP.md exists but could not be read; phase and milestone lookups fell back to defaults',
|
||||||
[UNUSABLE_REASON.LAST_ACTIVITY_UNPARSEABLE]:
|
[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)',
|
'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 ──────────────────────────────────────────────────────────────
|
// ─── Dedup state ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user