diff --git a/src/planning-snapshot.cts b/src/planning-snapshot.cts index 836d6812c..f7a5bba11 100644 --- a/src/planning-snapshot.cts +++ b/src/planning-snapshot.cts @@ -54,6 +54,10 @@ const { checkAgentsInstalled } = agentInstallCheckMod; // eslint-disable-next-line @typescript-eslint/no-require-imports -- worktree-safety.cjs is an export= CommonJS module import worktreeSafetyMod = require('./worktree-safety.cjs'); const { inspectWorktreeHealth } = worktreeSafetyMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseIdMod = require('./phase-id.cjs'); +const { PHASE_NUMBER_TOKEN_SOURCE, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod; +import { buildRoadmapPhaseVariants } from './validate.cjs'; // ─── worstScope — the one new piece of coordination logic ─────────────────── @@ -107,6 +111,30 @@ interface PlanningSnapshot { config: { value: Record | null; scope: Scope; exists: boolean }; agentInstall: { value: ReturnType; scope: Scope }; worktreeHealth: { value: ReturnType['findings']; scope: Scope }; + // ─── Phase 11 (#3309) "Rule table organization" additions ───────────────── + // The design doc's own "Rule table organization" table and prose disagree + // on the count: the table lists EIGHT rows (through `planningRootFiles`, + // W019) but the prose says "7 more fields" / "14 fields after this batch". + // This implementation follows the table (and the task brief, which + // separately enumerates all eight) — every field a reused owner or a + // small, relocated (not new-algorithm) derivation. `PlanningSnapshot` + // therefore totals 15 fields after this batch, not 14; flagged here rather + // than silently reconciled, since correcting the design doc's prose is + // outside this diff's scope. + projectSections: { value: string[] | null; scope: Scope; exists: boolean }; + statePhaseTokens: { value: string[]; scope: Scope }; + stateStatus: { value: string | null; scope: Scope }; + roadmapDeclaredPhases: { value: { phaseId: string; milestone: string | null }[]; scope: Scope }; + roadmapPhaseCheckboxes: { value: Record; scope: Scope }; + researchValidationStatus: { + value: { dir: string; hasValidationArchitecture: boolean; hasValidationMd: boolean }[]; + scope: Scope; + }; + milestoneArchiveStatus: { + value: { archivedVersions: string[]; documentedVersions: string[] }; + scope: Scope; + }; + planningRootFiles: { value: string[]; scope: Scope }; } /** @@ -131,10 +159,27 @@ function buildPhaseSnapshot(phasesDir: string, dir: string): PhaseSnapshot { }; } +interface StateFields { + currentPhaseLabel: { value: string | null; scope: Scope }; + statePhaseTokens: { value: string[]; scope: Scope }; + stateStatus: { value: string | null; scope: 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). + * Resolve every STATE.md-sourced field in one place: `currentPhaseLabel` (the + * raw `Phase:` field under `## Current Position`, e.g. `"3 of 8 (User + * Auth)"`, not a normalized phase-directory id — see the design doc's Known + * limits), `statePhaseTokens` (Phase 11, #3309 — every phase-number-shaped + * token found anywhere in STATE.md's raw text, backs W002), and `stateStatus` + * (Phase 11, #3309 — the `status`/`Status` field, backs W011). + * + * Phase 10 shipped `currentPhaseLabel` as its own single-purpose reader + * (`buildCurrentPhaseLabel(statePath)`); this phase folds two more STATE.md + * derivations in rather than reading and parsing the same file three times + * per `buildPlanningSnapshot` call — the read, `extractFrontmatter`, and + * `stripFrontmatter` are genuinely shared inputs for all three, and sharing + * them means `warnUnusableInput(STATE_UNREADABLE)` also stays a single call + * site instead of a risk of tripling on one degraded read. * * This module performs the one STATE.md read no §7 owner does, mirroring * every existing STATE.md caller (`cmdStateSnapshot`, `cmdStatePrune`): @@ -144,30 +189,63 @@ function buildPhaseSnapshot(phasesDir: string, dir: string): PhaseSnapshot { * 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. + * corruption — `warnUnusableInput(STATE_UNREADABLE)` fires exactly once, + * and all three fields degrade to their UNREADABLE non-answer together. * - 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. + * - `currentPhaseLabel`/`stateStatus` both live under `## Current Position` + * (`gsd-core/templates/state.md`) and both use `stateFieldValue` + * (`state-document.cts:296`) the exact way `smart-entry.cts:448`/ + * `state.cts:1561,3273` already call it for `'status'`/`'Status'` — so a + * missing `## Current Position` section degrades BOTH to `TRUNCATED` with + * a whole-body fallback, together. + * - `statePhaseTokens` scans the WHOLE document (`verify.cts`'s exact + * `PHASE_NUMBER_TOKEN_SOURCE` regex, relocated verbatim from + * `verify.cts:1731-1735`), not just the Current Position section, so it is + * NOT degraded to `TRUNCATED` by a missing section header — it stays + * `COMPLETE` whenever the file itself was read successfully. */ -function buildCurrentPhaseLabel(statePath: string): { value: string | null; scope: Scope } { +function buildStateFields(statePath: string): StateFields { let content: string | null; try { content = platformReadSync(statePath); } catch { warnUnusableInput({ reason: UNUSABLE_REASON.STATE_UNREADABLE, source: statePath }); - return { value: null, scope: SCOPE.UNREADABLE }; + return { + currentPhaseLabel: { value: null, scope: SCOPE.UNREADABLE }, + statePhaseTokens: { value: [], scope: SCOPE.UNREADABLE }, + stateStatus: { value: null, scope: SCOPE.UNREADABLE }, + }; } if (content === null) { - return { value: null, scope: SCOPE.UNREADABLE }; + return { + currentPhaseLabel: { value: null, scope: SCOPE.UNREADABLE }, + statePhaseTokens: { value: [], scope: SCOPE.UNREADABLE }, + stateStatus: { 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, + const currentPositionScope = section === null ? SCOPE.TRUNCATED : SCOPE.COMPLETE; + + const currentPhaseLabel = stateFieldValue(frontmatter, section ?? body, null, 'Phase', { + scope: currentPositionScope, }); + const stateStatus = stateFieldValue(frontmatter, section ?? body, 'status', 'Status', { + scope: currentPositionScope, + }); + const statePhaseTokens = { + value: [...content.matchAll(new RegExp(`[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g'))].map( + (m) => m[1], + ), + scope: SCOPE.COMPLETE, + }; + + return { currentPhaseLabel, statePhaseTokens, stateStatus }; } /** @@ -262,6 +340,273 @@ function buildWorktreeHealthField(cwd: string): { value: ReturnType m[1].trim()); + return { value, scope: SCOPE.COMPLETE, exists: true }; +} + +/** + * Resolve `roadmapDeclaredPhases` — every phase id ROADMAP.md declares + * (heading-style AND checklist-style, not filtered to disk presence), each + * paired with the milestone-version section it was found under (`null` when + * found outside any versioned section). Backs W006/W007 (declared-phase + * half) and W021(2288)/W026(2392) (milestone-attribution half). + * + * The declared-phase-id half reuses `buildRoadmapPhaseVariants` + * (`validate.cts:136`, already imported by `verify.cts:12` — genuine existing + * reuse). The milestone-attribution half relocates + * `checkMilestonePrefixMismatches`'s `sectionRx`-based section walk + * (`verify.cts:1429-1459`, local/unexported there), generalized from "record + * only the mismatches" to "record every attribution" — this field exposes + * the parsed fact; the future W021/W026 rules make the mismatch judgment. + */ +function buildRoadmapDeclaredPhasesField( + roadmapPath: string, +): { value: { phaseId: string; milestone: string | null }[]; scope: Scope } { + if (!fs.existsSync(roadmapPath)) { + return { value: [], scope: SCOPE.UNREADABLE }; + } + let content: string; + try { + content = fs.readFileSync(roadmapPath, 'utf-8'); + } catch { + return { value: [], scope: SCOPE.UNREADABLE }; + } + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + + const milestoneByPhase = new Map(); + const sectionRx = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim; + const sections: { version: string; start: number; end: number }[] = []; + let sm: RegExpExecArray | null; + while ((sm = sectionRx.exec(content)) !== null) { + if (sections.length > 0) sections[sections.length - 1].end = sm.index; + sections.push({ version: `v${sm[1]}`, start: sm.index, end: content.length }); + } + const phaseRx = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; + for (const section of sections) { + const sectionContent = content.slice(section.start, section.end); + phaseRx.lastIndex = 0; + let pm: RegExpExecArray | null; + while ((pm = phaseRx.exec(sectionContent)) !== null) { + if (!milestoneByPhase.has(pm[1])) milestoneByPhase.set(pm[1], section.version); + } + } + + const value = [...roadmapPhases].map((phaseId) => ({ + phaseId, + milestone: milestoneByPhase.get(phaseId) ?? null, + })); + return { value, scope: SCOPE.COMPLETE }; +} + +/** + * Resolve `roadmapPhaseCheckboxes` — parsed `[x]`/`[ ]` checkbox state per + * phase from ROADMAP.md's progress-table region, keyed by phase id. Backs + * W011. + * + * Relocates and generalizes `verify.cts`'s W011 block (`verify.cts:2104- + * 2134`): that call site builds ONE hardcoded `phaseCheckboxRe` testing a + * single target phase id (STATE's current phase) for a `[x]` match. This + * builder is the same regex shape, generalized to CAPTURE both the check + * character and the phase id instead of interpolating one fixed target, so + * every declared checkbox is recorded, not just one. + * + * NOT a re-derivation of `isPhaseComplete` (`verification.cts:557`, ADR-3180 + * §7.4, disk-strict): that owner explicitly refuses to consult the ROADMAP + * checkbox at all when DECIDING phase completion (`verification.cts:536- + * 537`). This field only exposes what the checkbox literally says, for a + * diagnostic (W011) whose entire purpose is flagging when the two DISAGREE — + * reading the data is not re-litigating who is authoritative. + */ +function buildRoadmapPhaseCheckboxesField( + roadmapPath: string, +): { value: Record; scope: Scope } { + if (!fs.existsSync(roadmapPath)) { + return { value: {}, scope: SCOPE.UNREADABLE }; + } + let content: string; + try { + content = fs.readFileSync(roadmapPath, 'utf-8'); + } catch { + return { value: {}, scope: SCOPE.UNREADABLE }; + } + + const checkboxRe = new RegExp( + `-\\s*\\[([xX ])\\].*?Phase\\s+0*(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, + 'gi', + ); + const value: Record = {}; + let m: RegExpExecArray | null; + while ((m = checkboxRe.exec(content)) !== null) { + value[m[2]] = m[1].toLowerCase() === 'x'; + } + return { value, scope: SCOPE.COMPLETE }; +} + +/** + * Resolve `researchValidationStatus` — per phase directory, whether its + * `*-RESEARCH.md` contains the literal heading `## Validation Architecture`, + * and whether a `*-VALIDATION.md` file exists in the same directory. Backs + * W009. + * + * Relocates the file-naming convention `verify.cts:1967-1990` (W009) uses to + * find "the" RESEARCH.md / VALIDATION.md in a phase dir: a flat, + * non-recursive `readdirSync` of the phase dir, then the first entry whose + * name ends `-RESEARCH.md` / any entry ending `-VALIDATION.md`. Computed for + * EVERY phase dir unconditionally (verify.cts's W009 only reads RESEARCH.md + * when `hasResearch && !hasValidation`; this field exposes both booleans + * regardless, so the future W009 rule does its own `hasResearch && + * hasValidationArchitecture && !hasValidationMd` check against parsed data, + * not raw text). + * + * `scope` mirrors `phaseDirs.scope` (the caller-supplied enumeration): a + * per-directory read failure degrades that single entry's booleans to + * `false` and is silently skipped, mirroring `verify.cts`'s own + * `catch { intentionally empty }` around this exact read — this is a + * deliberate fail-open match to the pre-migration behavior, not a scope + * degradation, since the original never surfaced these failures either. + */ +function buildResearchValidationStatusField( + phasesDir: string, + phaseDirNames: string[], + enumerationScope: Scope, +): { + value: { dir: string; hasValidationArchitecture: boolean; hasValidationMd: boolean }[]; + scope: Scope; +} { + const value = phaseDirNames.map((dir) => { + const fullPhaseDir = path.join(phasesDir, dir); + let files: string[]; + try { + files = fs.readdirSync(fullPhaseDir); + } catch { + return { dir, hasValidationArchitecture: false, hasValidationMd: false }; + } + const researchFile = files.find((f) => f.endsWith('-RESEARCH.md')); + const hasValidationMd = files.some((f) => f.endsWith('-VALIDATION.md')); + let hasValidationArchitecture = false; + if (researchFile) { + try { + const researchContent = fs.readFileSync(path.join(fullPhaseDir, researchFile), 'utf-8'); + hasValidationArchitecture = researchContent.includes('## Validation Architecture'); + } catch { + /* intentionally empty — mirrors verify.cts:1986-1988's own silent skip */ + } + } + return { dir, hasValidationArchitecture, hasValidationMd }; + }); + return { value, scope: enumerationScope }; +} + +/** + * Resolve `milestoneArchiveStatus` — `archivedVersions` (versions with a + * `milestones/-ROADMAP.md` snapshot file present) and `documentedVersions` + * (`## ` headings already present in MILESTONES.md). Backs W018. + * + * Relocates `verify.cts:2301-2335` (W018)'s directory-scan glob + * (`^(v\d+\.\d+(?:\.\d+)?)-ROADMAP\.md$` against a flat, non-recursive + * `readdirSync` of `.planning/milestones/`) and its MILESTONES.md + * heading-membership check, generalized from "is THIS archived version's + * heading present" to "list every `## ` heading MILESTONES.md has." + * + * Confirmed NOT a fit for `listArchiveVersionDirs` + * (`phase-locator.cts:127`): that function scans `milestones/*-phases/` + * DIRECTORIES, a different target than this field's `milestones/*-ROADMAP.md` + * FILES — reusing it here would silently answer the wrong question. + * + * Root-scoped (`planningRoot(cwd)`), matching `verify.cts`'s own + * `rootBase`-based `milestonesPath`/`milestonesArchiveDir`. + */ +function buildMilestoneArchiveStatusField( + cwd: string, +): { value: { archivedVersions: string[]; documentedVersions: string[] }; scope: Scope } { + const rootBase = planningRoot(cwd); + const milestonesArchiveDir = path.join(rootBase, 'milestones'); + const milestonesPath = path.join(rootBase, 'MILESTONES.md'); + + let archivedVersions: string[] = []; + let scope: Scope = SCOPE.COMPLETE; + if (fs.existsSync(milestonesArchiveDir)) { + try { + const archiveFiles = fs.readdirSync(milestonesArchiveDir); + archivedVersions = archiveFiles + .map((f) => f.match(/^(v\d+\.\d+(?:\.\d+)?)-ROADMAP\.md$/)) + .filter((m): m is RegExpMatchArray => m !== null) + .map((m) => m[1]); + } catch { + scope = SCOPE.UNREADABLE; + } + } + + let documentedVersions: string[] = []; + if (fs.existsSync(milestonesPath)) { + try { + const registryContent = fs.readFileSync(milestonesPath, 'utf-8'); + documentedVersions = [...registryContent.matchAll(/^##\s+(v\d+\.\d+(?:\.\d+)?)/gm)].map( + (m) => m[1], + ); + } catch { + scope = worstScope(scope, SCOPE.UNREADABLE); + } + } + + return { value: { archivedVersions, documentedVersions }, scope }; +} + +/** + * Resolve `planningRootFiles` — plain listing of file (not directory) names + * directly under `.planning/` root. Backs W019. + * + * Pairs with the existing exported `isCanonicalPlanningFile` predicate + * (`artifacts.cts:43`) — but per the design doc, that predicate is called by + * the future W019 RULE per filename, not by this builder; this field only + * needs to BE the raw filename list. + */ +function buildPlanningRootFilesField(cwd: string): { value: string[]; scope: Scope } { + try { + const entries = fs.readdirSync(planningRoot(cwd), { withFileTypes: true }); + return { value: entries.filter((e) => e.isFile()).map((e) => e.name), scope: SCOPE.COMPLETE }; + } catch { + return { value: [], scope: SCOPE.UNREADABLE }; + } +} + /** * Build the full `.planning/` projection for `cwd`. Composes the six §7 * owners named in the design doc's "Owners consumed" table, plus (Phase 11, @@ -276,6 +621,7 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { const phaseDirs = listMilestonePhaseDirs(paths.phases, { cwd }); const phasesValue = phaseDirs.value.map((dir) => buildPhaseSnapshot(paths.phases, dir)); + const stateFields = buildStateFields(paths.state); return { milestone, @@ -284,10 +630,18 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { value: phasesValue, scope: worstScope(phaseDirs.scope, ...phasesValue.map((p) => p.scope)), }, - currentPhaseLabel: buildCurrentPhaseLabel(paths.state), + currentPhaseLabel: stateFields.currentPhaseLabel, config: buildConfigField(cwd), agentInstall: buildAgentInstallField(cwd), worktreeHealth: buildWorktreeHealthField(cwd), + projectSections: buildProjectSectionsField(cwd), + statePhaseTokens: stateFields.statePhaseTokens, + stateStatus: stateFields.stateStatus, + roadmapDeclaredPhases: buildRoadmapDeclaredPhasesField(paths.roadmap), + roadmapPhaseCheckboxes: buildRoadmapPhaseCheckboxesField(paths.roadmap), + researchValidationStatus: buildResearchValidationStatusField(paths.phases, phaseDirs.value, phaseDirs.scope), + milestoneArchiveStatus: buildMilestoneArchiveStatusField(cwd), + planningRootFiles: buildPlanningRootFilesField(cwd), }; } diff --git a/src/unusable-input.cts b/src/unusable-input.cts index f91bb2244..268542aa6 100644 --- a/src/unusable-input.cts +++ b/src/unusable-input.cts @@ -71,6 +71,13 @@ const UNUSABLE_REASON = Object.freeze({ * is corruption. (#3309, eighth #1879 site — planning-snapshot's config field) */ CONFIG_UNREADABLE: 'config_unreadable', + /** + * A PROJECT.md exists but could not be read (EACCES/EIO/…). Distinct from a project that has + * not run any project-writing command yet: absence returns the same non-answer, silently — + * only an exists-but-unreadable PROJECT.md is corruption. (#3309, ninth #1879 site — + * planning-snapshot's projectSections field) + */ + PROJECT_UNREADABLE: 'project_unreadable', } as const); type UnusableReason = (typeof UNUSABLE_REASON)[keyof typeof UNUSABLE_REASON]; @@ -87,6 +94,8 @@ const REASON_PROSE: Readonly> = Object.freeze({ 'STATE.md exists but could not be read; the current-phase label fell back to unavailable', [UNUSABLE_REASON.CONFIG_UNREADABLE]: 'config.json exists but could not be read or parsed; the config field fell back to unavailable', + [UNUSABLE_REASON.PROJECT_UNREADABLE]: + 'PROJECT.md exists but could not be read; the projectSections field fell back to unavailable', }); // ─── Dedup state ────────────────────────────────────────────────────────────── diff --git a/tests/planning-snapshot.test.cjs b/tests/planning-snapshot.test.cjs index 83d5eedd8..ab9f9549b 100644 --- a/tests/planning-snapshot.test.cjs +++ b/tests/planning-snapshot.test.cjs @@ -688,3 +688,363 @@ describe('Phase-10 fields unchanged by the Phase-11 extension (matrix row 8)', ( assert.ok('worktreeHealth' in snap); }); }); + +// ═════════════════════════════════════════════════════════════════════════ +// Phase 11 (#3309) — "Rule table organization" batch, 7 more fields +// +// Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md +// ("Rule table organization" table) +// +// Each field relocates (not reinvents) an existing verify.cts derivation — +// see the JSDoc above each builder in src/planning-snapshot.cts for the +// exact source lines. Fixture helpers below mirror the existing +// writeRoadmap/writeState/writeFile idiom. +// ═════════════════════════════════════════════════════════════════════════ + +function writeProject(cwd, content) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'PROJECT.md'), content); +} + +function writeMilestoneArchiveRoadmap(cwd, version, content) { + const archiveDir = path.join(planningDirOf(cwd), 'milestones'); + fs.mkdirSync(archiveDir, { recursive: true }); + fs.writeFileSync(path.join(archiveDir, `${version}-ROADMAP.md`), content); +} + +function writeMilestonesRegistry(cwd, content) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'MILESTONES.md'), content); +} + +describe('projectSections field (Phase 11, #3309)', () => { + test('happy: returns every ## heading actually present, unfiltered against any required list', (t) => { + const cwd = createTempDir('gsd-3309-ps1-'); + t.after(() => cleanup(cwd)); + writeProject(cwd, [ + '# My Project', + '', + '## What This Is', + '', + 'text', + '', + '## Custom Section', + '', + '### Not a top-level heading', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.projectSections, { + value: ['What This Is', 'Custom Section'], + scope: SCOPE.COMPLETE, + exists: true, + }); + }); + + test('absence: no PROJECT.md is a real non-answer, not corruption', (t) => { + const cwd = createTempDir('gsd-3309-ps2-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.deepStrictEqual(snap.projectSections, { value: null, scope: SCOPE.UNREADABLE, exists: false }); + assert.strictEqual(emitted, 0); + }); + + test('hostile: present-but-unreadable PROJECT.md degrades without throwing, emits PROJECT_UNREADABLE exactly once', (t) => { + const cwd = createTempDir('gsd-3309-ps3-'); + t.after(() => cleanup(cwd)); + makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'PROJECT.md')); + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.deepStrictEqual(snap.projectSections, { value: null, scope: SCOPE.UNREADABLE, exists: true }); + assert.strictEqual(emitted, 1, 'present-but-unreadable PROJECT.md is corruption — exactly one PROJECT_UNREADABLE diagnostic'); + }); +}); + +describe('statePhaseTokens field (Phase 11, #3309)', () => { + test('happy: every phase-number-shaped token anywhere in STATE.md text, in appearance order', (t) => { + const cwd = createTempDir('gsd-3309-spt1-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + // The `Phase:`-field syntax ("Phase: 3 of 8") does NOT match this regex — + // it requires `[Pp]hase\s+` (whitespace, not a colon, right + // after "Phase"), exactly like verify.cts's own W002 relocation target. + // Only prose-style "Phase N" references match, e.g. bracketed decision + // annotations and free-text mentions. + appendToState(cwd, [ + '', + '## Current Position', + '', + 'Phase: 3 of 8 (User Auth)', + '', + '### Decisions', + '- [Phase 5]: revisit after Phase 2 wraps', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.statePhaseTokens, { value: ['5', '2'], scope: SCOPE.COMPLETE }); + }); + + test('absence: no STATE.md yields an empty token list, non-answer scope, no diagnostic', (t) => { + const cwd = createTempDir('gsd-3309-spt2-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.deepStrictEqual(snap.statePhaseTokens, { value: [], scope: SCOPE.UNREADABLE }); + assert.strictEqual(emitted, 0); + }); + + test('hostile: unreadable-but-present STATE.md degrades statePhaseTokens together with currentPhaseLabel from ONE diagnostic', (t) => { + const cwd = createTempDir('gsd-3309-spt3-'); + t.after(() => cleanup(cwd)); + makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'STATE.md')); + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.deepStrictEqual(snap.statePhaseTokens, { value: [], scope: SCOPE.UNREADABLE }); + assert.deepStrictEqual(snap.currentPhaseLabel, { value: null, scope: SCOPE.UNREADABLE }); + assert.strictEqual(emitted, 1, 'the shared STATE.md read must not double-emit across fields'); + }); +}); + +describe('stateStatus field (Phase 11, #3309)', () => { + test('happy: Status field under Current Position is extracted verbatim, mirroring currentPhaseLabel', (t) => { + const cwd = createTempDir('gsd-3309-ss1-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, [ + '', + '## Current Position', + '', + 'Phase: 3 of 8 (User Auth)', + 'Status: In progress', + '', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.stateStatus, { value: 'In progress', scope: SCOPE.COMPLETE }); + }); + + test('boundary: missing Current Position section still resolves status from frontmatter, scope TRUNCATED', (t) => { + const cwd = createTempDir('gsd-3309-ss2-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { status: 'planning' }); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.stateStatus.value, 'planning'); + assert.strictEqual(snap.stateStatus.scope, SCOPE.TRUNCATED); + }); + + test('absence: no STATE.md yields a non-answer, no diagnostic', (t) => { + const cwd = createTempDir('gsd-3309-ss3-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.deepStrictEqual(snap.stateStatus, { value: null, scope: SCOPE.UNREADABLE }); + assert.strictEqual(emitted, 0); + }); +}); + +describe('roadmapDeclaredPhases field (Phase 11, #3309)', () => { + test('happy: every declared phase id paired with the milestone section it was found under', (t) => { + const cwd = createTempDir('gsd-3309-rdp1-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.roadmapDeclaredPhases, { + value: [ + { phaseId: '1', milestone: 'v1.0' }, + { phaseId: '2', milestone: 'v1.0' }, + ], + scope: SCOPE.COMPLETE, + }); + }); + + test('boundary: a phase declared before any version heading gets milestone: null', (t) => { + const cwd = createTempDir('gsd-3309-rdp2-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['### Phase 9: Prelude', '', '## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + const prelude = snap.roadmapDeclaredPhases.value.find((p) => p.phaseId === '9'); + const foo = snap.roadmapDeclaredPhases.value.find((p) => p.phaseId === '1'); + assert.deepStrictEqual(prelude, { phaseId: '9', milestone: null }); + assert.deepStrictEqual(foo, { phaseId: '1', milestone: 'v1.0' }); + }); + + test('absence: no ROADMAP.md is a non-answer', (t) => { + const cwd = createTempDir('gsd-3309-rdp3-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.roadmapDeclaredPhases, { value: [], scope: SCOPE.UNREADABLE }); + }); + + test('hostile: unreadable ROADMAP.md degrades to an empty list, scope UNREADABLE', (t) => { + const cwd = createTempDir('gsd-3309-rdp4-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'ROADMAP.md')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.roadmapDeclaredPhases, { value: [], scope: SCOPE.UNREADABLE }); + }); +}); + +describe('roadmapPhaseCheckboxes field (Phase 11, #3309)', () => { + test('happy: [x]/[ ] checkbox state parsed per phase id', (t) => { + const cwd = createTempDir('gsd-3309-rpc1-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## Progress', '', '- [x] Phase 1: Foo', '- [ ] Phase 2: Bar'].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.roadmapPhaseCheckboxes, { value: { '1': true, '2': false }, scope: SCOPE.COMPLETE }); + }); + + test('boundary: no checklist lines present is a real empty answer, not a non-answer', (t) => { + const cwd = createTempDir('gsd-3309-rpc2-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.roadmapPhaseCheckboxes, { value: {}, scope: SCOPE.COMPLETE }); + }); + + test('hostile: unreadable ROADMAP.md degrades to an empty map, scope UNREADABLE', (t) => { + const cwd = createTempDir('gsd-3309-rpc3-'); + t.after(() => cleanup(cwd)); + makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'ROADMAP.md')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.roadmapPhaseCheckboxes, { value: {}, scope: SCOPE.UNREADABLE }); + }); +}); + +describe('researchValidationStatus field (Phase 11, #3309)', () => { + test('happy: RESEARCH.md carries the Validation Architecture heading and a VALIDATION.md exists', (t) => { + const cwd = createTempDir('gsd-3309-rvs1-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + writeFile(cwd, '.planning/phases/01-foo/01-RESEARCH.md', '# Research\n\n## Validation Architecture\n\ntext\n'); + writeFile(cwd, '.planning/phases/01-foo/01-VALIDATION.md', '# Validation\n'); + + const snap = buildPlanningSnapshot(cwd); + const entry = snap.researchValidationStatus.value.find((r) => r.dir === '01-foo'); + assert.deepStrictEqual(entry, { dir: '01-foo', hasValidationArchitecture: true, hasValidationMd: true }); + assert.strictEqual(snap.researchValidationStatus.scope, SCOPE.COMPLETE); + }); + + test('negative: RESEARCH.md without the heading and no VALIDATION.md reports both false', (t) => { + const cwd = createTempDir('gsd-3309-rvs2-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + writeFile(cwd, '.planning/phases/01-foo/01-RESEARCH.md', '# Research\n\nno special section\n'); + + const snap = buildPlanningSnapshot(cwd); + const entry = snap.researchValidationStatus.value.find((r) => r.dir === '01-foo'); + assert.deepStrictEqual(entry, { dir: '01-foo', hasValidationArchitecture: false, hasValidationMd: false }); + }); + + test('hostile: an unreadable phase directory degrades that entry to false/false without throwing', (t) => { + const cwd = createTempDir('gsd-3309-rvs3-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + const phaseDir = path.join(planningDirOf(cwd), 'phases', '01-foo'); + fs.mkdirSync(phaseDir, { recursive: true }); + injectPhaseDirFault(t, phaseDir); + + const snap = buildPlanningSnapshot(cwd); + const entry = snap.researchValidationStatus.value.find((r) => r.dir === '01-foo'); + assert.deepStrictEqual(entry, { dir: '01-foo', hasValidationArchitecture: false, hasValidationMd: false }); + }); +}); + +describe('milestoneArchiveStatus field (Phase 11, #3309)', () => { + test('happy: archived ROADMAP snapshot present and its version documented in MILESTONES.md', (t) => { + const cwd = createTempDir('gsd-3309-mas1-'); + t.after(() => cleanup(cwd)); + writeMilestoneArchiveRoadmap(cwd, 'v1.0', '# v1.0 archive\n'); + writeMilestonesRegistry(cwd, '## v1.0\n\nShipped.\n'); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.milestoneArchiveStatus, { + value: { archivedVersions: ['v1.0'], documentedVersions: ['v1.0'] }, + scope: SCOPE.COMPLETE, + }); + }); + + test('negative: no milestones/ dir and no MILESTONES.md is a real empty answer, not a non-answer', (t) => { + const cwd = createTempDir('gsd-3309-mas2-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.milestoneArchiveStatus, { + value: { archivedVersions: [], documentedVersions: [] }, + scope: SCOPE.COMPLETE, + }); + }); + + test('boundary: an archived version missing from the registry is reported, not silently dropped', (t) => { + const cwd = createTempDir('gsd-3309-mas3-'); + t.after(() => cleanup(cwd)); + writeMilestoneArchiveRoadmap(cwd, 'v1.0', '# v1.0 archive\n'); + // No MILESTONES.md at all. + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.milestoneArchiveStatus.value.archivedVersions, ['v1.0']); + assert.deepStrictEqual(snap.milestoneArchiveStatus.value.documentedVersions, []); + }); + + test('hostile: an unreadable milestones/ dir degrades to scope UNREADABLE without throwing', (t) => { + const cwd = createTempDir('gsd-3309-mas4-'); + t.after(() => cleanup(cwd)); + // Directory-vs-file swap: milestones/ is a regular FILE, so + // fs.existsSync is true but readdirSync throws ENOTDIR. + makeDirUnreadableAsFile(path.join(planningDirOf(cwd), 'milestones')); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.milestoneArchiveStatus.scope, SCOPE.UNREADABLE); + }); +}); + +describe('planningRootFiles field (Phase 11, #3309)', () => { + test('happy: lists files (not directories) directly under .planning/ root', (t) => { + const cwd = createTempDir('gsd-3309-prf1-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', ''].join('\n')); + writeFile(cwd, '.planning/NOTES.md', 'stray file\n'); + fs.mkdirSync(path.join(planningDirOf(cwd), 'phases'), { recursive: true }); // a directory — must be excluded + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.planningRootFiles.value.slice().sort(), ['NOTES.md', 'ROADMAP.md', 'STATE.md']); + assert.strictEqual(snap.planningRootFiles.scope, SCOPE.COMPLETE); + }); + + test('absence: no .planning/ directory at all degrades to an empty list, scope UNREADABLE', (t) => { + const cwd = createTempDir('gsd-3309-prf2-'); + t.after(() => cleanup(cwd)); + // .planning/ deliberately never created. + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.planningRootFiles, { value: [], scope: SCOPE.UNREADABLE }); + }); + + test('hostile: an unreadable .planning/ root degrades without throwing', (t) => { + const cwd = createTempDir('gsd-3309-prf3-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + injectPhaseDirFault(t, planningDirOf(cwd)); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.planningRootFiles, { value: [], scope: SCOPE.UNREADABLE }); + }); +}); diff --git a/tests/unusable-input.test.cjs b/tests/unusable-input.test.cjs index 8452197ab..84b9a3328 100644 --- a/tests/unusable-input.test.cjs +++ b/tests/unusable-input.test.cjs @@ -72,7 +72,7 @@ describe('UNUSABLE_REASON', () => { // (enum + call site + this assertion) instead of a silent widening. assert.deepStrictEqual( Object.keys(UNUSABLE_REASON).sort(), - ['CONFIG_UNREADABLE', 'FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'ROADMAP_UNREADABLE', 'STATE_UNREADABLE'], + ['CONFIG_UNREADABLE', 'FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'PROJECT_UNREADABLE', 'ROADMAP_UNREADABLE', 'STATE_UNREADABLE'], ); assert.strictEqual(UNUSABLE_REASON.FRONTMATTER_UNTERMINATED, 'frontmatter_unterminated'); }); @@ -181,6 +181,53 @@ describe('CONFIG_UNREADABLE', () => { }); }); +// ─── PROJECT_UNREADABLE: a PROJECT.md that exists but could not be read ────── + +describe('PROJECT_UNREADABLE', () => { + test('a genuinely unreadable PROJECT.md produces exactly one diagnostic', () => { + _resetUnusableInputWarningsForTests(); + const emitted = emissionsDuring(() => { + const wrote = warnUnusableInput({ + reason: UNUSABLE_REASON.PROJECT_UNREADABLE, + source: '/u/project-unreadable.md', + }); + assert.strictEqual(wrote, true); + }); + assert.strictEqual(emitted, 1); + }); + + test('the same PROJECT.md path reported twice yields one diagnostic', () => { + _resetUnusableInputWarningsForTests(); + const source = '/u/project-unreadable-dedup/PROJECT.md'; + const emitted = emissionsDuring(() => { + const first = warnUnusableInput({ reason: UNUSABLE_REASON.PROJECT_UNREADABLE, source }); + const repeat = warnUnusableInput({ reason: UNUSABLE_REASON.PROJECT_UNREADABLE, source }); + assert.strictEqual(first, true); + assert.strictEqual(repeat, false, 'same (path, cause) must dedup'); + }); + assert.strictEqual(emitted, 1); + }); + + test('two different PROJECT.md paths are never suppressed as one', () => { + _resetUnusableInputWarningsForTests(); + const emitted = emissionsDuring(() => { + warnUnusableInput({ + reason: UNUSABLE_REASON.PROJECT_UNREADABLE, + source: '/u/project-unreadable-a/PROJECT.md', + }); + warnUnusableInput({ + reason: UNUSABLE_REASON.PROJECT_UNREADABLE, + source: '/u/project-unreadable-b/PROJECT.md', + }); + }); + assert.strictEqual(emitted, 2, 'keying too coarsely would hide a real second fault'); + }); + + test('the reason value is the frozen string "project_unreadable"', () => { + assert.strictEqual(UNUSABLE_REASON.PROJECT_UNREADABLE, 'project_unreadable'); + }); +}); + // ─── The discriminator: truncated vs. everything that merely looks like it ─── describe('extractFrontmatter — flags a genuinely truncated frontmatter', () => {