From 2538fd6344280c70ecf0c2adc812cb6fa2df4fae Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 12 Aug 2026 21:43:39 -0400 Subject: [PATCH] =?UTF-8?q?refactor(#3308):=20add=20planning-snapshot.cts?= =?UTF-8?q?=20parsed=20projection=20per=20ADR-3180=20=C2=A78.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10 of epic #3180. src/planning-snapshot.cts is a new parsed projection of .planning/, composed exclusively from the already- consolidated §7 owners (getMilestoneInfo, listMilestonePhaseDirs, isPhaseComplete, scanPhasePlans, stateFieldValue, planningPaths) plus the frozen SCOPE enum. No new semantic derivation is introduced beyond worstScope, a pure combinator folding several independently-scoped owner answers into one composite signal. Adds STATE_UNREADABLE to src/unusable-input.cts's UNUSABLE_REASON (seventh #1879 site) for STATE.md exists-but-unreadable, distinct from absent. Adds scripts/lint-planning-snapshot-bypass-drift.cjs, a ratcheted drift guard (ADR-3180 Decision 4(e)) scoped to DIAGNOSTIC_RULE_FUNCTIONS (currently cmdValidateHealth in src/verify.cts only) preventing new raw .planning/ reads from bypassing the snapshot, while acknowledging cmdValidateHealth's existing 15 raw-read sites as debt owned by Phase 11 (#3309). Six-gate .cts ripple: .gitignore, eslint.config.mjs, docs/INVENTORY.md + manifest regen, CONTEXT.md glossary entry. Breaking changes: none. This phase adds the subject only; Phase 11 migrates cmdValidateHealth onto it. --- .gitignore | 1 + CONTEXT.md | 3 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 1 + eslint.config.mjs | 1 + package.json | 2 +- .../planning-snapshot-bypass-baseline.json | 110 ++++ .../lint-planning-snapshot-bypass-drift.cjs | 544 ++++++++++++++++++ src/planning-snapshot.cts | 182 ++++++ src/unusable-input.cts | 9 + 10 files changed, 853 insertions(+), 1 deletion(-) create mode 100644 scripts/baselines/planning-snapshot-bypass-baseline.json create mode 100644 scripts/lint-planning-snapshot-bypass-drift.cjs create mode 100644 src/planning-snapshot.cts diff --git a/.gitignore b/.gitignore index 80d1e85a9..8780e681a 100644 --- a/.gitignore +++ b/.gitignore @@ -195,6 +195,7 @@ build/ /gsd-core/bin/lib/worktree-safety.cjs /gsd-core/bin/lib/planning-workspace.cjs /gsd-core/bin/lib/planning-scope.cjs +/gsd-core/bin/lib/planning-snapshot.cjs /gsd-core/bin/lib/command-roster.cjs /gsd-core/bin/lib/runtime-artifact-conversion.cjs /gsd-core/bin/lib/runtime-artifact-layout.cjs diff --git a/CONTEXT.md b/CONTEXT.md index f96415a51..8b1b00750 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -103,6 +103,9 @@ Module owning legacy-key normalization, defaults merge, and explicit on-disk mig ### Planning Scope Module Leaf module owning the frozen `SCOPE` discriminator (`COMPLETE` / `TRUNCATED` / `UNSCOPED` / `UNREADABLE`) that every consolidated `.planning/` semantic derivation returns alongside its payload, per ADR-3180 Decision 2. It exists to make one distinction representable: `COMPLETE` with zero items is a REAL answer (a phase genuinely has no plans; a milestone genuinely has no phases yet), while the other three with zero items are NON-answers — the derivation could not see all of its input. Before it, those two cases were output-identical, which is the failure class epic #3180 removes: a truncated milestone window returned `phase_count: 0` with no error, indistinguishable from a freshly-declared milestone. It is a frozen enum rather than a message string because `CONTRIBUTING.md` bans raw-text matching on outputs and requires a typed IR, so callers branch on `result.scope === SCOPE.TRUNCATED`. Pure and import-free — the bottom of the dependency graph, so any consumer can depend on it without a cycle (mirrors the Phase Id Module's leaf position). Source of truth: `gsd-core/bin/lib/planning-scope.cjs` (generated from `src/planning-scope.cts`). The contract is PROVISIONAL: #3183 is its first real implementation, and ADR-3180 requires the ADR be amended before Phase 2 rather than the contract worked around, if it does not fit. +### Planning Snapshot Module +Module owning the parsed projection of `.planning/` that a diagnostic rule may read, per ADR-3180 §8.1 (Decision 8, Phase 10, #3308). `buildPlanningSnapshot(cwd) → PlanningSnapshot` is composed EXCLUSIVELY from the already-consolidated §7 owners — `getMilestoneInfo` (Roadmap Parser Module), `listMilestonePhaseDirs` (Phase Locator Module), `isPhaseComplete` (Verification Module), `scanPhasePlans` (Plan Scan Module), `stateFieldValue`/`stateCurrentPositionSlice` (STATE.md Document Module), `planningPaths` (Planning Workspace Module) — and introduces no new semantic derivation of its own. `PlanningSnapshot` exposes `milestone`/`phaseDirs`/`phases`/`currentPhaseLabel`, each a `{value, scope}` pair per the Planning Scope Module's frozen `SCOPE` enum; `phases` additionally carries a `PhaseSnapshot[]` (`dir`, `complete`, `verificationStatus`, `planCount`, `summaryCount`, `scope`). The one new piece of logic this module adds is `worstScope(...scopes) → Scope`, a pure severity-ordered combinator (`UNREADABLE` > `UNSCOPED` > `TRUNCATED` > `COMPLETE`) that folds several independently-scoped owner answers about the same phase directory into one composite signal — NOT a re-derivation of any owner (each owner's own algorithm is untouched; only their already-computed `scope` verdicts are combined), but new coordination logic no single owner has the visibility to express. Every exposed field carries PARSED values only, never raw document text — this is structural, not advisory: a diagnostic rule given only the parsed value cannot re-derive a field's location the way `#3162`'s three inert `Current Phase` literal-search predicates did. Read failures on STATE.md (exists-but-unreadable, distinct from absent) are reported via the Unusable Input Diagnostic Module's `warnUnusableInput(UNUSABLE_REASON.STATE_UNREADABLE)`. Guarded by `scripts/lint-planning-snapshot-bypass-drift.cjs` (ratcheted per Decision 4(e), scoped to `DIAGNOSTIC_RULE_FUNCTIONS` — currently `cmdValidateHealth` in `src/verify.cts` only, acknowledging its existing raw `.planning/` reads as debt owned by Phase 11, #3309, which migrates it onto this snapshot). Source of truth: `gsd-core/bin/lib/planning-snapshot.cjs` (generated from `src/planning-snapshot.cts`). Design: `.gsd/phase/refactor-3308-planning-snapshot-parsed-projection/40-design.md`. + ### Planning Workspace Module Module owning `.planning` path resolution, active workstream pointer policy (`session-scoped > shared`), pointer self-heal behavior, and planning lock semantics for workstream-aware execution. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 4c61c8c2c..ca01f0bdb 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -423,6 +423,7 @@ "plan-drift-guard.cjs", "plan-scan.cjs", "planning-scope.cjs", + "planning-snapshot.cjs", "planning-workspace.cjs", "probe-core.cjs", "profile-output.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 4cd9beba3..11933ed18 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -530,6 +530,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `plan-dependency-graph.cjs` | Shared halt-propagation over a plan's `depends_on` DAG — the single topological-order + halt-propagation engine used by both `phase.cjs`'s wave-grouping and `phase-locator.cjs`'s phase-location primitive, so the two can never diverge on which plans a halted plan blocks (#2830) | | `plan-scan.cjs` | Canonical phase-plan scanner for detecting plan and summary files in flat and nested layouts (k014) | | `planning-scope.cjs` | Frozen `SCOPE` discriminator (`COMPLETE`/`TRUNCATED`/`UNSCOPED`/`UNREADABLE`) distinguishing a genuinely-empty derivation from one computed over a truncated or unscoped input, so callers can branch on the difference instead of reading a plausible zero (ADR-3180) | +| `planning-snapshot.cjs` | Parsed projection of `.planning/` composed exclusively from the ADR-3180 §7 owners (milestone identity, phase enumeration, phase completion, plan/summary counting, STATE.md current-phase) — exposes only scope-carrying parsed values, never raw document text, so a diagnostic rule cannot re-derive a field's location (ADR-3180 §8.1) | | `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) | | `project-root.cjs` | Resolves a project root from a starting directory using four heuristics (own `.planning/` guard, `sub_repos` config, `multiRepo` flag, `.git` heuristic) | | `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | diff --git a/eslint.config.mjs b/eslint.config.mjs index 742925c69..b76354c7c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -134,6 +134,7 @@ export default tseslint.config( 'gsd-core/bin/lib/model-catalog.cjs', 'gsd-core/bin/lib/configuration.cjs', 'gsd-core/bin/lib/state-document.cjs', + 'gsd-core/bin/lib/planning-snapshot.cjs', 'gsd-core/bin/lib/shell-command-projection.cjs', 'gsd-core/bin/lib/security.cjs', 'gsd-core/bin/lib/command-aliases.cjs', diff --git a/package.json b/package.json index c5f3efad2..0963f9798 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs", "lint:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs", "lint:removed-but-needed": "node scripts/lint-removed-but-needed.cjs", - "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs", + "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs", diff --git a/scripts/baselines/planning-snapshot-bypass-baseline.json b/scripts/baselines/planning-snapshot-bypass-baseline.json new file mode 100644 index 000000000..87eee1c6d --- /dev/null +++ b/scripts/baselines/planning-snapshot-bypass-baseline.json @@ -0,0 +1,110 @@ +{ + "$comment": "ADR-3180 §8.1 rule 2 ratchet, owned by Phase 11 (#3309). See scripts/lint-planning-snapshot-bypass-drift.cjs. SHRINK-ONLY: entries are removed as cmdValidateHealth migrates onto src/planning-snapshot.cts; new or changed entries fail lint:ci. `count` is the number of byte-identical (file, text) occurrences acknowledged at this site — a run producing fewer fails as a partial migration, more fails as an unacknowledged new copy.", + "entries": [ + { + "file": "src/verify.cts", + "text": ".readdirSync(phasesDir, { withFileTypes: true })", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "? fs.readFileSync(milestonesPath, 'utf-8')", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 2 + }, + { + "file": "src/verify.cts", + "text": "const archiveFiles = fs.readdirSync(milestonesArchiveDir);", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const configRaw = fs.readFileSync(configPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 4 + }, + { + "file": "src/verify.cts", + "text": "const content = fs.readFileSync(projectPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const entries = fs.readdirSync(rootBase, { withFileTypes: true });", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const rawCfg = fs.readFileSync(configPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const researchContent = fs.readFileSync(", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const roadmapContentFull = fs.readFileSync(roadmapPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const roadmapContentRaw = fs.readFileSync(roadmapPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 2 + }, + { + "file": "src/verify.cts", + "text": "const stateContent = fs.readFileSync(statePath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 2 + }, + { + "file": "src/verify.cts", + "text": "const stateRaw = fs.readFileSync(statePath, 'utf-8');", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + }, + { + "file": "src/verify.cts", + "text": "phaseDirFiles.set(e.name, fs.readdirSync(path.join(phasesDir, e.name)));", + "derivation": "planning-snapshot-bypass", + "owner_issue": "#3309", + "count": 1 + } + ] +} diff --git a/scripts/lint-planning-snapshot-bypass-drift.cjs b/scripts/lint-planning-snapshot-bypass-drift.cjs new file mode 100644 index 000000000..619282bf0 --- /dev/null +++ b/scripts/lint-planning-snapshot-bypass-drift.cjs @@ -0,0 +1,544 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Anti-divergence drift guard for the DIAGNOSTIC-RULE raw-`.planning/`-read + * bypass of the planning snapshot single owner (epic #3180, ADR-3180 + * "Planning Semantic Model Single Owner", §8.1 rule 2). + * + * ADR-3180 §8.1 rule 2: a diagnostic rule may see only PARSED values coming + * off `src/planning-snapshot.cts`, never raw `.planning/` document text — + * every diagnostic rule is expected to consume the snapshot's already-parsed + * fields (via the ADR-3180 §7 owner functions: `getMilestoneInfo`, + * `listMilestonePhaseDirs`, `isPhaseComplete`, `scanPhasePlans`, + * `stateFieldValue`, etc.), not re-derive its own view of the filesystem with + * `platformReadSync(`/`readFileSync(`/`readdirSync(`. + * + * `cmdValidateHealth` (`src/verify.cts`) is the one diagnostic-rule-shaped + * function in the repo today that has NOT yet been migrated onto the + * snapshot — it predates ADR-3180 and still does its own raw reads for + * roughly thirty W0xx/W1xx diagnostic codes. Migrating it is Phase 11 + * (#3309), not this phase (#3308) — this guard's job is only to make that + * acknowledged debt VISIBLE and SHRINK-ONLY via a ratchet baseline, exactly + * like `scripts/lint-planning-prompt-drift.cjs` and + * `scripts/lint-state-field-drift.cjs` do for their own re-derivations, so it + * cannot silently grow while Phase 11 is pending. + * + * FUNCTION-SCOPED, not whole-file or whole-repo. `DIAGNOSTIC_RULE_FUNCTIONS` + * (a `Map>`) names the exact functions + * this rule applies to — the semantic inverse of + * `lint-completion-ratio-drift.cjs`'s `FUNCTION_SCOPED_EXEMPTIONS` (which + * names functions a rule does NOT apply to), but the identical data shape and + * lookup pattern. A raw-read primitive anywhere OUTSIDE a registered function + * — including elsewhere in the very same file — is not this derivation and is + * never flagged; `src/verify.cts` itself is full of legitimate raw reads + * outside `cmdValidateHealth` (health-check plumbing, non-diagnostic-rule + * helpers) that this guard must not see. + * + * Line-to-enclosing-function attribution is a TRIMMED copy of + * `lint-state-field-drift.cjs`'s `buildFunctionInfo` — the same + * comment/string-stripping tokenizer (`scanCode`) feeding the same + * brace-depth function-frame stack, producing the same `innermostAt[line] -> + * function name | null` attribution — with that guard's LADDER WINDOW + * co-occurrence logic dropped entirely: this guard only ever needs "which + * function encloses this line", never a multi-line pattern within one + * function body. + * + * RATCHET, not an allowlist — mirrors `lint-planning-prompt-drift.cjs`'s + * `diffAgainstBaseline`/`writeBaseline`/`dedupeViolationsForBaseline`/ + * `sortEntries` machinery verbatim (baseline path, owner issue, and + * `derivation` label are the only differences): a violation whose (file, + * text) pair is already RECORDED in the baseline is KNOWN and never fails; an + * unrecorded pair is FRESH and fails; a recorded pair that no longer fires is + * STALE and ALSO fails, forcing `--update` (run by a maintainer, expected to + * run to completion — zero remaining entries — once #3309 lands) to prune it. + * + * Tree-walk / root-confinement / symlink / sanitizer machinery is shared via + * `scripts/lib/drift-scan.cjs`, exactly like every sibling guard. + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const driftScan = require('./lib/drift-scan.cjs'); +const { sanitizeForReport, scanTree } = driftScan; + +// Authored TypeScript source only (the generated bin/lib/*.cjs mirror it). +const SCAN_DIRS = ['src']; +const SCAN_EXT = new Set(['.cts']); + +const BASELINE_REL_PATH = path.join('scripts', 'baselines', 'planning-snapshot-bypass-baseline.json'); + +// ADR-3180 §8.1 rule 2's acknowledged debt is owned by Phase 11 (#3309, "give +// cmdValidateHealth the snapshot"), NOT the epic (#3180) itself and NOT +// Phase 8 (#3218, the sibling prompt-layer guard's owner issue — a different +// derivation entirely). +const RATCHET_OWNER_ISSUE = '#3309'; + +// Per ADR-3180 §8.1 rule 2: function-scoped registry of exactly which +// diagnostic-rule-shaped functions this guard applies to. Not a bare file +// allowlist (ADR-3180 Decision 4(a) forbids that) — every other function in +// `src/verify.cts`, and every function in every other file, is scanned like +// normal code and simply never matches because it is not in this Map. +const DIAGNOSTIC_RULE_FUNCTIONS = new Map([[path.join('src', 'verify.cts'), new Set(['cmdValidateHealth'])]]); + +// `scanTree` builds its repo-relative path via `path.relative()`, which uses +// NATIVE separators: on Windows that is `src\verify.cts`, while +// `DIAGNOSTIC_RULE_FUNCTIONS` above and the committed baseline both store +// POSIX paths (`src/verify.cts`). Normalized UNCONDITIONALLY — never gated on +// `process.platform` — so the POSIX path is never the only tested case (see +// `lint-planning-prompt-drift.cjs`'s `toPosixRel` for the full rationale; +// applied here at the same single seam: `findSnapshotBypassDrift` is the only +// place a repo-relative path enters this guard's violation objects). +function toPosixRel(relPath) { + return relPath.replace(/\\/g, '/'); +} + +// Looks up `DIAGNOSTIC_RULE_FUNCTIONS` by POSIX-normalizing BOTH the incoming +// `relPath` and each registered key before comparing, so a native-separator +// caller (Windows) and a POSIX-separator caller (every other platform, and +// every test in this repo) resolve to the same registered `Set` — see +// `toPosixRel` above. +function lookupRegisteredFunctions(relPath) { + const posix = toPosixRel(relPath); + for (const [key, fns] of DIAGNOSTIC_RULE_FUNCTIONS) { + if (toPosixRel(key) === posix) return fns; + } + return null; +} + +// A raw `.planning/` filesystem read primitive. ADR-3180 §7 owner functions +// (`getMilestoneInfo`, `listMilestonePhaseDirs`, `isPhaseComplete`, +// `scanPhasePlans`, `stateFieldValue`, ...) never match this — none of their +// names or call shapes contain `platformReadSync(`, `readFileSync(`, or +// `readdirSync(`, so a registered function that has already been migrated +// onto the snapshot correctly stops producing violations without needing any +// separate allowlist of "safe" calls. +const RAW_READ_RE = /platformReadSync\(|readFileSync\(|readdirSync\(/; + +// `function NAME(` — top-level or nested, matches the DECLARATION line +// itself (mirrors `lint-state-field-drift.cjs`'s `FUNCTION_DECL_RE`). +const FUNCTION_DECL_RE = /\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/; + +// `const NAME = (...): ReturnType => {` — an arrow function assigned to a +// const, whose own line already carries `=>\s*\{` (mirrors +// `lint-state-field-drift.cjs`'s `ARROW_CONST_RE`). +const ARROW_CONST_RE = /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*\([^)]*\)\s*(?::\s*[^=]+)?=>\s*\{/; + +/** + * Strip line-comments, block comments, and the CONTENTS of string/template + * literals (excluded from brace-depth counting so a brace inside a string + * never desyncs `depth`) while keeping quoted text verbatim in `detect` (so + * declaration/call regexes can still see identifiers that happen to sit + * inside a template literal's interpolation-free text). Trimmed, unchanged + * copy of `lint-state-field-drift.cjs`'s `scanCode` — this guard needs the + * identical comment/string-safety, not a domain-specific variant. + * Returns `{ detect, braces }`, one string per input line. + */ +function scanCode(lines) { + const detect = new Array(lines.length); + const braces = new Array(lines.length); + let inBlockComment = false; + let inTemplate = false; + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + let outDetect = ''; + let outBraces = ''; + let i = 0; + if (inTemplate) { + const start = i; + while (i < line.length) { + if (line[i] === '\\') { + i += 2; + continue; + } + if (line[i] === '`') { + i++; + inTemplate = false; + break; + } + i++; + } + outDetect += line.slice(start, i); + if (inTemplate) { + detect[li] = outDetect; + braces[li] = ''; + continue; + } + } + while (i < line.length) { + if (inBlockComment) { + const close = line.indexOf('*/', i); + if (close === -1) { + i = line.length; + break; + } + i = close + 2; + inBlockComment = false; + continue; + } + const ch = line[i]; + if (ch === '/' && line[i + 1] === '/') { + i = line.length; + break; + } + if (ch === '/' && line[i + 1] === '*') { + inBlockComment = true; + i += 2; + continue; + } + if (ch === "'" || ch === '"') { + const quote = ch; + const start = i; + let j = i + 1; + while (j < line.length) { + if (line[j] === '\\') { + j += 2; + continue; + } + if (line[j] === quote) { + j++; + break; + } + j++; + } + outDetect += line.slice(start, j); + i = j; + continue; + } + if (ch === '`') { + const start = i; + let j = i + 1; + let closed = false; + while (j < line.length) { + if (line[j] === '\\') { + j += 2; + continue; + } + if (line[j] === '`') { + j++; + closed = true; + break; + } + j++; + } + if (!closed) { + outDetect += line.slice(start); + inTemplate = true; + i = line.length; + break; + } + outDetect += line.slice(start, j); + i = j; + continue; + } + outDetect += ch; + outBraces += ch; + i++; + } + detect[li] = outDetect; + braces[li] = outBraces; + } + return { detect, braces }; +} + +/** + * Trimmed copy of `lint-state-field-drift.cjs`'s `buildFunctionInfo`: a + * single pass over `lines` maintaining a brace-depth stack of open named + * function frames, producing `innermostAt[lineIndex] -> function name | + * null` — which function frame is innermost at each source line. The LADDER + * WINDOW co-occurrence tracking from the sibling guard is dropped entirely; + * this guard needs only line-to-enclosing-function attribution. + */ +function buildFunctionInfo(lines) { + const { detect, braces } = scanCode(lines); + const innermostAt = new Array(lines.length).fill(null); + const stack = []; // { name, openDepth } + let depth = 0; + let pendingDeclName = null; + for (let i = 0; i < lines.length; i++) { + const detectCode = detect[i]; + const braceCode = braces[i]; + + let immediateName = null; + if (detectCode.trim()) { + const arrowMatch = ARROW_CONST_RE.exec(detectCode); + if (arrowMatch) { + immediateName = arrowMatch[1]; + } else { + const declMatch = FUNCTION_DECL_RE.exec(detectCode); + if (declMatch) pendingDeclName = declMatch[1]; + } + } + + const opens = (braceCode.match(/\{/g) || []).length; + const closes = (braceCode.match(/\}/g) || []).length; + depth += opens - closes; + + if (immediateName) stack.push({ name: immediateName, openDepth: depth }); + + if (pendingDeclName) { + if (opens > 0) { + stack.push({ name: pendingDeclName, openDepth: depth }); + pendingDeclName = null; + } else if (detectCode.includes(';')) { + pendingDeclName = null; + } + } + + while (stack.length > 0 && depth < stack[stack.length - 1].openDepth) stack.pop(); + + innermostAt[i] = stack.length > 0 ? stack[stack.length - 1].name : null; + } + return { innermostAt }; +} + +/** + * Pure: find every raw `.planning/`-read line inside a + * `DIAGNOSTIC_RULE_FUNCTIONS`-registered function in `text`. `relPath` is the + * repo-relative path (native separators or POSIX, either is accepted) — used + * both as the (POSIX-normalized) `file` on every result and to look up the + * registered function set for this file. A file with no registered entry + * short-circuits to `[]` immediately, before any line is scanned. + * Returns [{ file, line, found, text }] — `file` is always POSIX-separated, + * `text` is the TRIMMED source line, the same value the baseline keys on. + */ +function findSnapshotBypassDrift(text, relPath) { + const registeredFns = lookupRegisteredFunctions(relPath); + if (!registeredFns) return []; + + const file = toPosixRel(relPath); + const lines = text.split('\n'); + const { innermostAt } = buildFunctionInfo(lines); + const out = []; + for (let i = 0; i < lines.length; i++) { + const fn = innermostAt[i]; + if (!fn || !registeredFns.has(fn)) continue; + const line = lines[i]; + const match = RAW_READ_RE.exec(line); + if (!match) continue; + out.push({ file, line: i + 1, found: match[0], text: line.trim() }); + } + return out; +} + +/** + * Scan the authored source tree and return every registered-function raw-read + * bypass, each annotated with the (POSIX-normalized) repo-relative file path. + */ +function scanRepo(root) { + return scanTree({ + root, + scanDirs: SCAN_DIRS, + scanExt: SCAN_EXT, + onFile(rel, text) { + return findSnapshotBypassDrift(text, rel); + }, + }); +} + +/** + * Read and parse the ratchet baseline. Returns `{ entries, errors }` — + * mirrors `lint-planning-prompt-drift.cjs`'s `loadBaseline` verbatim, adapted + * to this guard's baseline path. + */ +function loadBaseline(root) { + const baselinePath = path.join(root, BASELINE_REL_PATH); + if (!fs.existsSync(baselinePath)) { + return { entries: [], errors: [`${BASELINE_REL_PATH} is missing — run \`node scripts/lint-planning-snapshot-bypass-drift.cjs --update\` to generate it`] }; + } + const raw = fs.readFileSync(baselinePath, 'utf8'); + if (raw.trim() === '') { + return { entries: [], errors: [`${BASELINE_REL_PATH} is present but empty`] }; + } + let doc; + try { + doc = JSON.parse(raw); + } catch (err) { + return { entries: [], errors: [`${BASELINE_REL_PATH} is not valid JSON: ${err.message}`] }; + } + if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) { + return { entries: [], errors: [`${BASELINE_REL_PATH} must be a JSON object, got ${Array.isArray(doc) ? 'array' : typeof doc}`] }; + } + if (!Array.isArray(doc.entries)) { + return { entries: [], errors: [`${BASELINE_REL_PATH}: "entries" must be an array, got ${JSON.stringify(doc.entries)}`] }; + } + const errors = []; + const entries = []; + doc.entries.forEach((entry, i) => { + const where = `${BASELINE_REL_PATH}.entries[${i}]`; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + errors.push(`${where} must be an object, got ${JSON.stringify(entry)}`); + return; + } + if (typeof entry.file !== 'string' || entry.file === '') { + errors.push(`${where}.file must be a non-empty string, got ${JSON.stringify(entry.file)}`); + return; + } + if (typeof entry.text !== 'string' || entry.text === '') { + errors.push(`${where}.text must be a non-empty string, got ${JSON.stringify(entry.text)}`); + return; + } + if (entry.count !== undefined && !(Number.isInteger(entry.count) && entry.count >= 1)) { + errors.push(`${where}.count must be a positive integer when present, got ${JSON.stringify(entry.count)}`); + return; + } + entries.push(entry); + }); + return { entries, errors }; +} + +/** + * Diff scanned `violations` against baseline `entries`, matched by the pair + * (`file`, TRIMMED `text`), count-aware — mirrors + * `lint-planning-prompt-drift.cjs`'s `diffAgainstBaseline` verbatim. See that + * module's header for the full "COUNT, not duplicate rows" rationale. + */ +function diffAgainstBaseline(violations, baseline) { + const key = (file, text) => `${file} ${text}`; + + const actualByKey = new Map(); + for (const v of violations) { + const k = key(v.file, v.text); + let vs = actualByKey.get(k); + if (!vs) { vs = []; actualByKey.set(k, vs); } + vs.push(v); + } + + const knownKeys = new Set(baseline.map((e) => key(e.file, e.text))); + + const fresh = []; + const stale = []; + + for (const [k, vs] of actualByKey) { + if (!knownKeys.has(k)) fresh.push(...vs); + } + + for (const entry of baseline) { + const k = key(entry.file, entry.text); + const expected = entry.count ?? 1; + const vs = actualByKey.get(k) || []; + const actual = vs.length; + if (actual < expected) { + stale.push({ ...entry, count: expected, actualCount: actual }); + } else if (actual > expected) { + fresh.push(...vs.slice(expected)); + } + } + + return { fresh, stale }; +} + +/** Stable sort: by `file`, then by `text`. */ +function sortEntries(entries) { + return [...entries].sort((a, b) => { + if (a.file !== b.file) return a.file < b.file ? -1 : 1; + if (a.text !== b.text) return a.text < b.text ? -1 : 1; + return 0; + }); +} + +/** + * Collapse `violations` into one baseline row per distinct (file, text) pair, + * carrying a `count` of how many occurrences that pair has in THIS run. Pure; + * no I/O. + */ +function dedupeViolationsForBaseline(violations) { + const order = []; + const byKey = new Map(); + for (const v of violations) { + const k = `${v.file} ${v.text}`; + let entry = byKey.get(k); + if (!entry) { + entry = { file: v.file, text: v.text, derivation: 'planning-snapshot-bypass', owner_issue: RATCHET_OWNER_ISSUE, count: 0 }; + byKey.set(k, entry); + order.push(entry); + } + entry.count += 1; + } + return order; +} + +function writeBaseline(root, violations) { + const entries = sortEntries(dedupeViolationsForBaseline(violations)); + const doc = { + $comment: + 'ADR-3180 §8.1 rule 2 ratchet, owned by Phase 11 (#3309). See scripts/lint-planning-snapshot-bypass-drift.cjs. ' + + 'SHRINK-ONLY: entries are removed as cmdValidateHealth migrates onto src/planning-snapshot.cts; new or ' + + 'changed entries fail lint:ci. `count` is the number of byte-identical (file, text) occurrences ' + + 'acknowledged at this site — a run producing fewer fails as a partial migration, more fails as an ' + + 'unacknowledged new copy.', + entries, + }; + const baselinePath = path.join(root, BASELINE_REL_PATH); + fs.mkdirSync(path.dirname(baselinePath), { recursive: true }); + fs.writeFileSync(baselinePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8'); + return entries; +} + +function main() { + const root = path.join(__dirname, '..'); + const update = process.argv.includes('--update'); + const violations = scanRepo(root); + + if (update) { + const entries = writeBaseline(root, violations); + process.stdout.write(`ok planning-snapshot-bypass: baseline regenerated with ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}\n`); + return; + } + + const { entries: baseline, errors } = loadBaseline(root); + if (errors.length > 0) { + process.stderr.write('planning-snapshot-bypass: baseline load error(s):\n'); + for (const e of errors) process.stderr.write(` ${e}\n`); + process.exitCode = 1; + return; + } + + const { fresh, stale } = diffAgainstBaseline(violations, baseline); + + if (fresh.length === 0 && stale.length === 0) { + process.stdout.write(`ok planning-snapshot-bypass: no unacknowledged raw .planning/ reads in registered diagnostic-rule functions (${baseline.length} known)\n`); + return; + } + + if (fresh.length > 0) { + process.stderr.write('planning-snapshot-bypass: NEW raw .planning/ read(s) found inside a DIAGNOSTIC_RULE_FUNCTIONS-registered function.\n'); + process.stderr.write('Route the read through src/planning-snapshot.cts (ADR-3180 §7 owner functions: getMilestoneInfo,\n'); + process.stderr.write('listMilestonePhaseDirs, isPhaseComplete, scanPhasePlans, stateFieldValue, ...) instead of calling\n'); + process.stderr.write(`platformReadSync(/readFileSync(/readdirSync( directly, or add an acknowledged entry to ${BASELINE_REL_PATH}\n`); + process.stderr.write('via --update:\n'); + for (const v of fresh) { + process.stderr.write(` ${sanitizeForReport(v.file)}:${v.line} ${sanitizeForReport(v.found)} ${sanitizeForReport(v.text)}\n`); + } + } + + if (stale.length > 0) { + process.stderr.write('\nplanning-snapshot-bypass: STALE baseline entr' + (stale.length === 1 ? 'y' : 'ies') + " (fully migrated, or a PARTIAL migration — fewer occurrences found than acknowledged; delete or re-record the row):\n"); + for (const e of stale) { + process.stderr.write(` ${sanitizeForReport(e.file)} ${sanitizeForReport(e.text)} (found ${e.actualCount}/${e.count} acknowledged occurrence${e.count === 1 ? '' : 's'})\n`); + } + process.stderr.write(`\n remedy: node scripts/lint-planning-snapshot-bypass-drift.cjs --update\n`); + } + + process.exitCode = 1; +} + +if (require.main === module) main(); + +module.exports = { + findSnapshotBypassDrift, + scanRepo, + toPosixRel, + loadBaseline, + diffAgainstBaseline, + writeBaseline, + dedupeViolationsForBaseline, + sortEntries, + buildFunctionInfo, + DIAGNOSTIC_RULE_FUNCTIONS, + RAW_READ_RE, + SCAN_DIRS, + SCAN_EXT, + BASELINE_REL_PATH, + RATCHET_OWNER_ISSUE, +}; diff --git a/src/planning-snapshot.cts b/src/planning-snapshot.cts new file mode 100644 index 000000000..b1fcfd7dc --- /dev/null +++ b/src/planning-snapshot.cts @@ -0,0 +1,182 @@ +/** + * Planning Snapshot — a parsed projection of `.planning/` (Phase 10, #3308, + * ADR-3180 §8.1). + * + * Composed EXCLUSIVELY from the already-consolidated §7 owners + * (`getMilestoneInfo`, `listMilestonePhaseDirs`, `isPhaseComplete`, + * `scanPhasePlans`, `stateFieldValue`, `planningPaths`) plus the frozen + * `SCOPE` enum. This module introduces no new semantic derivation — it + * introduces exactly one new thing: `worstScope`, a way to combine several + * independently-scoped owner answers into one composite record without + * letting a caller treat a non-answer as data. + * + * `buildPlanningSnapshot(cwd)` is the sole export consumers reach for; + * `worstScope` is exported alongside it for direct unit coverage. + * + * Design: .gsd/phase/refactor-3308-planning-snapshot-parsed-projection/40-design.md + * + * ADR-457 build-at-publish: source in src/planning-snapshot.cts, compiled to + * gsd-core/bin/lib/planning-snapshot.cjs (gitignored). + */ + +import path from 'node:path'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import roadmapParserMod = require('./roadmap-parser.cjs'); +const { getMilestoneInfo } = roadmapParserMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseLocatorMod = require('./phase-locator.cjs'); +const { listMilestonePhaseDirs } = phaseLocatorMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import verificationMod = require('./verification.cjs'); +const { isPhaseComplete } = verificationMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import scanPhasePlans = require('./plan-scan.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningWorkspace = require('./planning-workspace.cjs'); +const { planningPaths } = planningWorkspace; +import { platformReadSync } from './shell-command-projection.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import frontmatterMod = require('./frontmatter.cjs'); +const { extractFrontmatter, stripFrontmatter } = frontmatterMod; +import { stateFieldValue, stateCurrentPositionSlice } from './state-document.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import unusableInputMod = require('./unusable-input.cjs'); +const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningScopeMod = require('./planning-scope.cjs'); +const { SCOPE } = planningScopeMod; +type Scope = planningScopeMod.Scope; + +// ─── worstScope — the one new piece of coordination logic ─────────────────── + +/** + * Severity ordering (`UNREADABLE` worst, `COMPLETE` best) is a genuine design + * choice, not inherited from anywhere — see the design doc's "Scope + * combination" section. `TRUNCATED` vs `UNSCOPED` are not ranked against each + * other by any upstream decision; this ordering exists only so a future + * diagnostic rule can name which failure was worse when several compound. + */ +const SCOPE_SEVERITY: Record = { + [SCOPE.COMPLETE]: 0, + [SCOPE.TRUNCATED]: 1, + [SCOPE.UNSCOPED]: 2, + [SCOPE.UNREADABLE]: 3, +}; + +/** + * Combine several independently-scoped owner answers into the single worst + * (most severe) `Scope` among them. Pure, no I/O. Not a re-derivation of any + * §7 owner — it folds together already-final `scope` outputs, which is new + * coordination logic no single owner has visibility to express itself. + */ +function worstScope(...scopes: Scope[]): Scope { + return scopes.reduce((worst, s) => (SCOPE_SEVERITY[s] > SCOPE_SEVERITY[worst] ? s : worst)); +} + +// ─── Snapshot shape ─────────────────────────────────────────────────────────── + +interface PhaseSnapshot { + dir: string; + complete: boolean; + verificationStatus: string; + planCount: number; + summaryCount: number; + scope: Scope; +} + +interface PlanningSnapshot { + milestone: ReturnType; + phaseDirs: ReturnType; + phases: { value: PhaseSnapshot[]; scope: Scope }; + currentPhaseLabel: { value: string | null; scope: Scope }; +} + +/** + * Build one `PhaseSnapshot` for a single already-enumerated phase directory + * name. `isPhaseComplete` and `scanPhasePlans` each perform their own raw + * `readdirSync` against `fullPhaseDir` and can independently degrade — see + * the design doc's "Scope combination" section for why the two are genuinely + * uncorrelated (isPhaseComplete's readability check never re-derives or + * requires scanPhasePlans, and vice versa). + */ +function buildPhaseSnapshot(phasesDir: string, dir: string): PhaseSnapshot { + const fullPhaseDir = path.join(phasesDir, dir); + const completionResult = isPhaseComplete(fullPhaseDir); + const scanResult = scanPhasePlans(fullPhaseDir); + return { + dir, + complete: completionResult.value.complete, + verificationStatus: completionResult.value.verification.status, + planCount: scanResult.planCount, + summaryCount: scanResult.summaryCount, + scope: worstScope(completionResult.scope, scanResult.scope), + }; +} + +/** + * Resolve `currentPhaseLabel` — the raw `Phase:` field STATE.md records under + * `## Current Position` (e.g. `"3 of 8 (User Auth)"`), not a normalized + * phase-directory id (see the design doc's Known limits). + * + * This module performs the one STATE.md read no §7 owner does, mirroring + * every existing STATE.md caller (`cmdStateSnapshot`, `cmdStatePrune`): + * `platformReadSync` + `extractFrontmatter` + `stripFrontmatter`. + * + * - STATE.md absent (ENOENT, `platformReadSync` returns `null`) is a real + * non-answer, NOT corruption — a project that never ran `state.init` + * legitimately has no STATE.md yet. `warnUnusableInput` is NOT called. + * - STATE.md present but unreadable (any other read error, e.g. EISDIR) is + * corruption — `warnUnusableInput(STATE_UNREADABLE)` fires exactly once. + * - An unterminated frontmatter fence is reported by `extractFrontmatter` + * itself (`FRONTMATTER_UNTERMINATED`) — this function does not duplicate + * that diagnostic; it still attempts a body-only field read on whatever + * `stripFrontmatter` leaves behind. + */ +function buildCurrentPhaseLabel(statePath: string): { value: string | null; scope: Scope } { + let content: string | null; + try { + content = platformReadSync(statePath); + } catch { + warnUnusableInput({ reason: UNUSABLE_REASON.STATE_UNREADABLE, source: statePath }); + return { value: null, scope: SCOPE.UNREADABLE }; + } + if (content === null) { + return { value: null, scope: SCOPE.UNREADABLE }; + } + + const frontmatter = extractFrontmatter(content, statePath); + const body = stripFrontmatter(content); + const section = stateCurrentPositionSlice(body); + return stateFieldValue(frontmatter, section ?? body, null, 'Phase', { + scope: section === null ? SCOPE.TRUNCATED : SCOPE.COMPLETE, + }); +} + +/** + * Build the full `.planning/` projection for `cwd`. Composes exactly the six + * §7 owners named in the design doc's "Owners consumed" table — no + * re-derivation, no new semantic answer. See the design doc for the + * behavior table and rejected alternatives. + */ +function buildPlanningSnapshot(cwd: string): PlanningSnapshot { + const paths = planningPaths(cwd); + const milestone = getMilestoneInfo(cwd); + const phaseDirs = listMilestonePhaseDirs(paths.phases, { cwd }); + + const phasesValue = phaseDirs.value.map((dir) => buildPhaseSnapshot(paths.phases, dir)); + + return { + milestone, + phaseDirs, + phases: { + value: phasesValue, + scope: worstScope(phaseDirs.scope, ...phasesValue.map((p) => p.scope)), + }, + currentPhaseLabel: buildCurrentPhaseLabel(paths.state), + }; +} + +export = { + buildPlanningSnapshot, + worstScope, +}; diff --git a/src/unusable-input.cts b/src/unusable-input.cts index 83206d385..cd51e1880 100644 --- a/src/unusable-input.cts +++ b/src/unusable-input.cts @@ -57,6 +57,13 @@ const UNUSABLE_REASON = Object.freeze({ * silent degradation is visible. (#3099, sixth #1879 site) */ LAST_ACTIVITY_UNPARSEABLE: 'last_activity_unparseable', + /** + * A STATE.md exists but could not be read (EACCES/EIO/…). Distinct from a project that has + * not run `state.init` yet: absence returns the same non-answer, silently — only an + * exists-but-unreadable STATE.md is corruption. (#3308, seventh #1879 site — + * planning-snapshot's current-phase field) + */ + STATE_UNREADABLE: 'state_unreadable', } as const); type UnusableReason = (typeof UNUSABLE_REASON)[keyof typeof UNUSABLE_REASON]; @@ -69,6 +76,8 @@ const REASON_PROSE: Readonly> = Object.freeze({ 'ROADMAP.md exists but could not be read; phase and milestone lookups fell back to defaults', [UNUSABLE_REASON.LAST_ACTIVITY_UNPARSEABLE]: 'last_activity in STATE.md is present but unparseable as a date; stale_activity fell back to false (idle-stranded suppressed)', + [UNUSABLE_REASON.STATE_UNREADABLE]: + 'STATE.md exists but could not be read; the current-phase label fell back to unavailable', }); // ─── Dedup state ──────────────────────────────────────────────────────────────