From ce8bcb1b9550947ab22019ba5b61f7704746e5a9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 17 Jun 2026 17:56:17 -0400 Subject: [PATCH] refactor(#1398): migrate state.cts section-collects onto markdown-sectionizer seam, byte-identical STATE.md (epic #1372 T6) (#1399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(#1398): migrate state.cts section-collect regexes onto markdown-sectionizer seam (epic #1372 T6) Replace ≈18 hand-rolled `/(heading)([\s\S]*?)(?=stop)/` regex splices in state.cts with direct `tokenizeHeadings` calls that compute the exact [bodyStart, stopOffset) span, preserving byte-identical STATE.md output. Non-migratable site left in place: `cmdStateRecordMetric`'s metricsPattern captures table-header rows in group 1 — not a standard heading+body shape. Write orchestration (readModifyWriteStateMd / syncStateFrontmatter / shouldPreserveExistingProgress / #952 no-op guard) is UNTOUCHED. Verification: t6-headtohead.cjs head-to-head harness runs 25 ops across 6 STATE.md fixture variants (inline, trailing-blanks, CRLF, no-frontmatter, nested-acc, post-milestone) against origin/next and reports 0 diffs. No-op guard confirmed: record-session on recorded:false leaves STATE.md byte-identical. All 150 state tests and 62 milestone/forensics tests pass. Co-Authored-By: Claude Sonnet 4.6 * test(#1398): capture T6 state-section-splice characterization; drop throwaway harness Remove scripts/t6-headtohead.cjs (committed throwaway HEAD-vs-origin/next byte-compare harness). Capture its coverage as 23 behavioral characterization tests appended to tests/state.test.cjs, exercising all migrated cmdState* write-ops across 7 fixture variants (inline, trailing-blanks, CRLF, no-frontmatter, nested-acc, no-current-pos, post-milestone). Includes the #952 no-op guard (recorded:false + byte-unchanged assertion) and CRLF/trailing-blanks edge-case coverage. Also removes the dead spliceStateSection helper (defined but never called) that was surfacing as an @typescript-eslint/no-unused-vars warning. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- src/state.cts | 502 +++++++++++++++++---------- tests/state.test.cjs | 791 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1111 insertions(+), 182 deletions(-) diff --git a/src/state.cts b/src/state.cts index a950540b0..fc856e377 100644 --- a/src/state.cts +++ b/src/state.cts @@ -41,6 +41,7 @@ import { KNOWN_STATUS_PATTERNS, stateReplaceFieldIfTemplate, } from './state-document.cjs'; +import { tokenizeHeadings } from './markdown-sectionizer.cjs'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -154,6 +155,16 @@ process.on('exit', () => { // Hoisted to module scope — compiled once, not per call (#320). Stateless (/i, used with .match). const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i; +// ─── ADR-1372 T6: seam-based section splice helper ─────────────────────────── + +// Shared stop predicates corresponding to the regex lookaheads used in state.cts: +// STOP_H2_PLUS : (?=\n##|$) — stops at any heading with level ≥ 2 +// STOP_H2_H3 : (?=\n###?|\n##[^#]|$) — stops at level 2 or 3 +// STOP_H2_ONLY : (?=\n##[^#]|$) — stops at level 2 only +const STOP_H2_PLUS = (lv: number): boolean => lv >= 2; +const STOP_H2_H3 = (lv: number): boolean => lv === 2 || lv === 3; +const STOP_H2_ONLY = (lv: number): boolean => lv === 2; + function cmdStateLoad(cwd: string, raw: boolean): void { const config = loadConfig(cwd); const planDir = planningPaths(cwd).planning; @@ -368,11 +379,26 @@ function stateReplaceFieldWithFallback(content: string, primary: string, fallbac * Fixes #1365: advance-plan could not update Status/Last activity after begin-phase. */ function updateCurrentPositionFields(content: string, fields: { status?: string; lastActivity?: string; plan?: string }): string { - const posPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; - const posMatch = content.match(posPattern); - if (!posMatch) return content; + // ADR-1372 T6: locate ## Current Position using tokenizeHeadings, extract the + // untrimmed body span, apply field edits, then splice the modified body back in. + // Stop predicate mirrors (?=\n##|$): any heading with level ≥ 2. + const headings = tokenizeHeadings(content); + const posIdx = headings.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text)); + if (posIdx === -1) return content; - let posBody = posMatch[2]; + const posHeading = headings[posIdx]; + const lines = content.split('\n'); + const posHeadingLine = lines[posHeading.line - 1]; + const posBodyStart = posHeading.offset + posHeadingLine.length + 1; + let posBodyEnd = content.length; + for (let j = posIdx + 1; j < headings.length; j++) { + if (STOP_H2_PLUS(headings[j].level)) { + posBodyEnd = headings[j].offset - 1; + break; + } + } + + let posBody = content.slice(posBodyStart, posBodyEnd); const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status']; const lastActivityDefaults = KNOWN_TEMPLATE_DEFAULTS['Last Activity']; @@ -442,7 +468,8 @@ function updateCurrentPositionFields(content: string, fields: { status?: string; } } - return content.replace(posPattern, () => `${posMatch[1]}${posBody}`); + // Splice the modified body back in place of the original untrimmed span. + return content.slice(0, posBodyStart) + posBody + content.slice(posBodyEnd); } function cmdStateAdvancePlan(cwd: string, raw: boolean): void { @@ -656,17 +683,32 @@ function cmdStateAddDecision(cwd: string, options: StateAddDecisionOptions, raw: let created = false; readModifyWriteStateMd(statePath, (content) => { - // Find Decisions section (various heading patterns) - const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; - const match = content.match(sectionPattern); + // ADR-1372 T6: find Decisions section via tokenizeHeadings; stop at level 2 or 3. + // Mirrors /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i + const decisionsPred = (lv: number, text: string): boolean => + (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text); + const sectionBody = (() => { + const hs = tokenizeHeadings(content); + const i = hs.findIndex(h => decisionsPred(h.level, h.text)); + if (i === -1) return null; + const h = hs[i]; + const ls = content.split('\n'); + const hl = ls[h.line - 1]; + const bs = h.offset + hl.length + 1; + let se = content.length; + for (let j = i + 1; j < hs.length; j++) { + if (STOP_H2_H3(hs[j].level)) { se = hs[j].offset - 1; break; } + } + return { bodyStart: bs, bodyEnd: se, body: content.slice(bs, se) }; + })(); - if (match) { - let sectionBody = match[2]; + if (sectionBody !== null) { + let newBody = sectionBody.body; // Remove placeholders - sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, ''); - sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; + newBody = newBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, ''); + newBody = newBody.trimEnd() + '\n' + entry + '\n'; _added = true; - return content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`); + return content.slice(0, sectionBody.bodyStart) + newBody + content.slice(sectionBody.bodyEnd); } // Section absent — DWIM: auto-create canonical ## Decisions scaffold, @@ -709,15 +751,31 @@ function cmdStateAddBlocker(cwd: string, text: string | StateAddBlockerOptions, let created = false; readModifyWriteStateMd(statePath, (content) => { - const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; - const match = content.match(sectionPattern); + // ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3. + // Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i + const blockersPred = (lv: number, text: string): boolean => + (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(text); + const sectionSpan = (() => { + const hs = tokenizeHeadings(content); + const i = hs.findIndex(h => blockersPred(h.level, h.text)); + if (i === -1) return null; + const h = hs[i]; + const ls = content.split('\n'); + const hl = ls[h.line - 1]; + const bs = h.offset + hl.length + 1; + let se = content.length; + for (let j = i + 1; j < hs.length; j++) { + if (STOP_H2_H3(hs[j].level)) { se = hs[j].offset - 1; break; } + } + return { bodyStart: bs, bodyEnd: se, body: content.slice(bs, se) }; + })(); - if (match) { - let sectionBody = match[2]; + if (sectionSpan !== null) { + let sectionBody = sectionSpan.body; sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, ''); sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; _added = true; - return content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`); + return content.slice(0, sectionSpan.bodyStart) + sectionBody + content.slice(sectionSpan.bodyEnd); } // Section absent — DWIM: auto-create canonical ### Blockers scaffold. @@ -777,20 +835,45 @@ function cmdStateAddRoadmapEvolution(cwd: string, options: StateAddRoadmapEvolut // Section boundaries mirror the sibling handlers (add-decision/add-blocker): // a trailing CR on a CRLF STATE.md is absorbed by the lazy body and trimmed, // so following sections are preserved without data loss (see the CRLF test). + // + // ADR-1372 T6: accPattern and subPattern migrated to tokenizeHeadings. + // accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i + // → stop at level 2 only (STOP_H2_ONLY) + // subPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?|$)/i + // → applied to accBody; stop at level 2 or 3 (STOP_H2_H3) readModifyWriteStateMd(statePath, (content) => { - const accPattern = /(##\s*Accumulated Context\s*\n)([\s\S]*?)(?=\n##[^#]|$)/i; - const accMatch = content.match(accPattern); + // Locate ## Accumulated Context and extract its untrimmed body span. + const accHs = tokenizeHeadings(content); + const accIdx = accHs.findIndex(h => h.level === 2 && /^accumulated\s+context$/i.test(h.text)); + + if (accIdx !== -1) { + const accH = accHs[accIdx]; + const contentLines = content.split('\n'); + const accHL = contentLines[accH.line - 1]; + const accBodyStart = accH.offset + accHL.length + 1; + let accBodyEnd = content.length; + for (let j = accIdx + 1; j < accHs.length; j++) { + if (STOP_H2_ONLY(accHs[j].level)) { accBodyEnd = accHs[j].offset - 1; break; } + } + const accBody = content.slice(accBodyStart, accBodyEnd); - if (accMatch) { - const accHeader = accMatch[1]; - const accBody = accMatch[2]; // Find `### Roadmap Evolution` WITHIN the Accumulated Context body only. - // Bounded by the next h3/h2 or the end of the section body. - const subPattern = /(###\s*Roadmap Evolution\s*\n)([\s\S]*?)(?=\n###?|$)/i; - const subMatch = accBody.match(subPattern); + // tokenizeHeadings is applied to accBody to scope the search. + // Stop predicate mirrors (?=\n###?|$): level 2 or 3. + const subHs = tokenizeHeadings(accBody); + const subIdx = subHs.findIndex(h => h.level === 3 && /^roadmap\s+evolution$/i.test(h.text)); + + if (subIdx !== -1) { + const subH = subHs[subIdx]; + const accLines = accBody.split('\n'); + const subHL = accLines[subH.line - 1]; + const subBodyStart = subH.offset + subHL.length + 1; + let subBodyEnd = accBody.length; + for (let j = subIdx + 1; j < subHs.length; j++) { + if (STOP_H2_H3(subHs[j].level)) { subBodyEnd = subHs[j].offset - 1; break; } + } + let subBody = accBody.slice(subBodyStart, subBodyEnd); - if (subMatch) { - let subBody = subMatch[2]; // Dedupe: exact (trimmed) line already present is a no-op replay. if (subBody.split('\n').some((line) => line.trim() === entry.trim())) { duplicate = true; @@ -798,15 +881,16 @@ function cmdStateAddRoadmapEvolution(cwd: string, options: StateAddRoadmapEvolut } subBody = subBody.replace(/None yet\.?\s*\n?/gi, ''); subBody = subBody.trimEnd() + '\n' + entry + '\n'; - const newAccBody = accBody.replace(subPattern, (_m, header: string) => `${header}${subBody}`); - return content.replace(accPattern, () => `${accHeader}${newAccBody}`); + // Splice subBody into accBody, then splice newAccBody into content. + const newAccBody = accBody.slice(0, subBodyStart) + subBody + accBody.slice(subBodyEnd); + return content.slice(0, accBodyStart) + newAccBody + content.slice(accBodyEnd); } // Subsection missing — append it at the end of the Accumulated Context body. subsectionCreated = true; const trimmedAcc = accBody.trimEnd(); const block = `${trimmedAcc ? `${trimmedAcc}\n\n` : ''}### Roadmap Evolution\n\n${entry}\n`; - return content.replace(accPattern, () => `${accHeader}${block}`); + return content.slice(0, accBodyStart) + block + content.slice(accBodyEnd); } // No `## Accumulated Context` — DWIM: create both at end of file. @@ -843,27 +927,35 @@ function cmdStateResolveBlocker(cwd: string, text: string, raw: boolean): void { let resolved = false; readModifyWriteStateMd(statePath, (content) => { - const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; - const match = content.match(sectionPattern); + // ADR-1372 T6: find Blockers/Concerns section via tokenizeHeadings; stop at level 2 or 3. + // Mirrors /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i + const hs = tokenizeHeadings(content); + const i = hs.findIndex(h => (h.level === 2 || h.level === 3) && /^(?:Blockers|Blockers\/Concerns|Concerns)$/i.test(h.text)); + if (i === -1) return content; - if (match) { - const sectionBody = match[2]; - const lines = sectionBody.split('\n'); - const filtered = lines.filter(line => { - if (!line.startsWith('- ')) return true; - return !line.toLowerCase().includes(text.toLowerCase()); - }); - - let newBody = filtered.join('\n'); - // If section is now empty, add placeholder - if (!newBody.trim() || !newBody.includes('- ')) { - newBody = 'None\n'; - } - - resolved = true; - return content.replace(sectionPattern, (_match, header: string) => `${header}${newBody}`); + const h = hs[i]; + const ls = content.split('\n'); + const hl = ls[h.line - 1]; + const bs = h.offset + hl.length + 1; + let se = content.length; + for (let j = i + 1; j < hs.length; j++) { + if (STOP_H2_H3(hs[j].level)) { se = hs[j].offset - 1; break; } } - return content; + const sectionBody = content.slice(bs, se); + const lines = sectionBody.split('\n'); + const filtered = lines.filter(line => { + if (!line.startsWith('- ')) return true; + return !line.toLowerCase().includes(text.toLowerCase()); + }); + + let newBody = filtered.join('\n'); + // If section is now empty, add placeholder + if (!newBody.trim() || !newBody.includes('- ')) { + newBody = 'None\n'; + } + + resolved = true; + return content.slice(0, bs) + newBody + content.slice(se); }, cwd); if (resolved) { @@ -1880,11 +1972,20 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName } // Update ## Current Position section (#1104, #1365) - const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; - const positionMatch = body.match(positionPattern); - if (positionMatch) { - const header = positionMatch[1]; - let posBody = positionMatch[2]; + // ADR-1372 T6: positionPattern → tokenizeHeadings + spliceStateSection. + // Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; stop at level ≥ 2. + const posHs = tokenizeHeadings(body); + const posIdx = posHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text)); + if (posIdx !== -1) { + const posH = posHs[posIdx]; + const bodyLines = body.split('\n'); + const posHL = bodyLines[posH.line - 1]; + const posBodyStart = posH.offset + posHL.length + 1; + let posBodyEnd = body.length; + for (let j = posIdx + 1; j < posHs.length; j++) { + if (STOP_H2_PLUS(posHs[j].level)) { posBodyEnd = posHs[j].offset - 1; break; } + } + let posBody = body.slice(posBodyStart, posBodyEnd); // Update or insert Phase line const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`; @@ -1934,21 +2035,29 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName if (replaced !== null) posBody = replaced; } - body = body.replace(positionPattern, () => `${header}${posBody}`); + body = body.slice(0, posBodyStart) + posBody + body.slice(posBodyEnd); updated.push('Current Position'); } } else { // Resume path: only update Last activity timestamp in Current Position // (do not touch Plan:, stopped_at, progress.percent, or plan counter) - const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; - const positionMatch = body.match(positionPattern); - if (positionMatch) { - const header = positionMatch[1]; - let posBody = positionMatch[2]; + // ADR-1372 T6: positionPattern → tokenizeHeadings; stop at level ≥ 2. + const posHsR = tokenizeHeadings(body); + const posIdxR = posHsR.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text)); + if (posIdxR !== -1) { + const posHR = posHsR[posIdxR]; + const bodyLinesR = body.split('\n'); + const posHLR = bodyLinesR[posHR.line - 1]; + const posBodyStartR = posHR.offset + posHLR.length + 1; + let posBodyEndR = body.length; + for (let j = posIdxR + 1; j < posHsR.length; j++) { + if (STOP_H2_PLUS(posHsR[j].level)) { posBodyEndR = posHsR[j].offset - 1; break; } + } + let posBody = body.slice(posBodyStartR, posBodyEndR); const resumeActivity = `Last activity: ${today} — Phase ${phaseNumber} execution resumed (wave continue)`; if (/^Last activity:/im.test(posBody)) { posBody = posBody.replace(/^Last activity:.*$/im, resumeActivity); - body = body.replace(positionPattern, () => `${header}${posBody}`); + body = body.slice(0, posBodyStartR) + posBody + body.slice(posBodyEndR); updated.push('Last activity (resume)'); } else { // Pipe-table format in Current Position (#1255) @@ -1956,7 +2065,7 @@ function cmdStateBeginPhase(cwd: string, phaseNumber: string | number, phaseName ?? stateReplaceField(posBody, 'Last activity', resumeActivity); if (replaced !== null) { posBody = replaced; - body = body.replace(positionPattern, () => `${header}${posBody}`); + body = body.slice(0, posBodyStartR) + posBody + body.slice(posBodyEndR); updated.push('Last activity (resume)'); } } @@ -2146,15 +2255,26 @@ function cmdStateMilestoneSwitch(cwd: string, version: string | undefined, name: const existingFm = extractFrontmatter(content) as Record; const body = stripFrontmatter(content); - const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; + // ADR-1372 T6: positionPattern → tokenizeHeadings + spliceStateSection. + // Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; stop at level ≥ 2. const resetPositionBody = `\nPhase: Not started (defining requirements)\n` + `Plan: —\n` + `Status: Defining requirements\n` + `Last activity: ${today} — Milestone ${version} started\n\n`; let newBody: string; - if (positionPattern.test(body)) { - newBody = body.replace(positionPattern, (_m, header: string) => `${header}${resetPositionBody}`); + const msPosHs = tokenizeHeadings(body); + const msPosIdx = msPosHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text)); + if (msPosIdx !== -1) { + const msPosH = msPosHs[msPosIdx]; + const msBodyLines = body.split('\n'); + const msPosHL = msBodyLines[msPosH.line - 1]; + const msPosBodyStart = msPosH.offset + msPosHL.length + 1; + let msPosBodyEnd = body.length; + for (let j = msPosIdx + 1; j < msPosHs.length; j++) { + if (STOP_H2_PLUS(msPosHs[j].level)) { msPosBodyEnd = msPosHs[j].offset - 1; break; } + } + newBody = body.slice(0, msPosBodyStart) + resetPositionBody + body.slice(msPosBodyEnd); } else { const preface = body.trim().length > 0 ? body : '# Project State\n'; newBody = `${preface.trimEnd()}\n\n## Current Position\n${resetPositionBody}`; @@ -2434,102 +2554,109 @@ function cmdStatePrune(cwd: string, options: StatePruneOptions, raw: boolean): v // Shared pruning logic applied to both dry-run and real passes. // Returns { newContent, archivedSections }. + // ADR-1372 T6: all four inline section-collect regexes replaced with + // tokenizeHeadings + untrimmed-span splicing for byte-identical writes. function prunePass(content: string): { newContent: string; archivedSections: PrunedSection[] } { const sections: PrunedSection[] = []; - // Prune Decisions section: entries like "- [Phase N]: ..." - const decisionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; - const decMatch = content.match(decisionPattern); - if (decMatch) { - const lines = decMatch[2].split('\n'); - const keep: string[] = []; - const archive: string[] = []; - for (const line of lines) { - const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i); - if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { - archive.push(line); - } else { - keep.push(line); - } + // Helper: locate a heading matching pred, extract untrimmed body [bs, se), + // apply transform, and splice back. Returns updated content. + // All prune-section patterns stop at level 2 or 3 (STOP_H2_H3). + function pruneSectionSpan( + c: string, + pred: (lv: number, text: string) => boolean, + transform: (body: string) => { keep: string[]; archive: string[] }, + sectionName: string, + ): string { + const hs = tokenizeHeadings(c); + const i = hs.findIndex(h => pred(h.level, h.text)); + if (i === -1) return c; + const h = hs[i]; + const ls = c.split('\n'); + const hl = ls[h.line - 1]; + const bs = h.offset + hl.length + 1; + let se = c.length; + for (let j = i + 1; j < hs.length; j++) { + if (STOP_H2_H3(hs[j].level)) { se = hs[j].offset - 1; break; } } + const body = c.slice(bs, se); + const { keep, archive } = transform(body); if (archive.length > 0) { - sections.push({ section: 'Decisions', count: archive.length, lines: archive }); - content = content.replace(decisionPattern, (_m, header: string) => `${header}${keep.join('\n')}`); + sections.push({ section: sectionName, count: archive.length, lines: archive }); + return c.slice(0, bs) + keep.join('\n') + c.slice(se); } + return c; } - // Prune Recently Completed section: entries mentioning phase numbers - const recentPattern = /(###?\s*Recently Completed\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; - const recMatch = content.match(recentPattern); - if (recMatch) { - const lines = recMatch[2].split('\n'); - const keep: string[] = []; - const archive: string[] = []; - for (const line of lines) { - const phaseMatch = line.match(/Phase\s+(\d+)/i); - if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { - archive.push(line); - } else { - keep.push(line); + // Prune Decisions section: entries like "- [Phase N]: ..." + content = pruneSectionSpan( + content, + (lv, text) => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text), + (body) => { + const keep: string[] = [], archive: string[] = []; + for (const line of body.split('\n')) { + const phaseMatch = line.match(/^\s*-\s*\[Phase\s+(\d+)/i); + if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); } } - } - if (archive.length > 0) { - sections.push({ section: 'Recently Completed', count: archive.length, lines: archive }); - content = content.replace(recentPattern, (_m, header: string) => `${header}${keep.join('\n')}`); - } - } + return { keep, archive }; + }, + 'Decisions', + ); + + // Prune Recently Completed section: entries mentioning phase numbers + content = pruneSectionSpan( + content, + (lv, text) => (lv === 2 || lv === 3) && /^recently\s+completed$/i.test(text), + (body) => { + const keep: string[] = [], archive: string[] = []; + for (const line of body.split('\n')) { + const phaseMatch = line.match(/Phase\s+(\d+)/i); + if (phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); } + } + return { keep, archive }; + }, + 'Recently Completed', + ); // Prune resolved blockers: lines marked as resolved (strikethrough ~~text~~ // or "[RESOLVED]" prefix) with a phase reference older than cutoff - const blockersPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; - const blockersMatch = content.match(blockersPattern); - if (blockersMatch) { - const lines = blockersMatch[2].split('\n'); - const keep: string[] = []; - const archive: string[] = []; - for (const line of lines) { - const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line); - const phaseMatch = line.match(/Phase\s+(\d+)/i); - if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { - archive.push(line); - } else { - keep.push(line); + content = pruneSectionSpan( + content, + (lv, text) => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)$/i.test(text), + (body) => { + const keep: string[] = [], archive: string[] = []; + for (const line of body.split('\n')) { + const isResolved = /~~.*~~|\[RESOLVED\]/i.test(line); + const phaseMatch = line.match(/Phase\s+(\d+)/i); + if (isResolved && phaseMatch && parseInt(phaseMatch[1], 10) <= cutoff) { archive.push(line); } else { keep.push(line); } } - } - if (archive.length > 0) { - sections.push({ section: 'Blockers (resolved)', count: archive.length, lines: archive }); - content = content.replace(blockersPattern, (_m, header: string) => `${header}${keep.join('\n')}`); - } - } + return { keep, archive }; + }, + 'Blockers (resolved)', + ); // Prune Performance Metrics table rows: keep only rows for phases > cutoff. // Preserves header rows (| Phase | ... and |---|...) and any prose around the table. - const metricsPattern = /(###?\s*Performance Metrics\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; - const metricsMatch = content.match(metricsPattern); - if (metricsMatch) { - const sectionLines = metricsMatch[2].split('\n'); - const keep: string[] = []; - const archive: string[] = []; - for (const line of sectionLines) { - // Table data row: starts with | followed by a number (phase) - const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/); - if (tableRowMatch) { - const rowPhase = parseInt(tableRowMatch[1], 10); - if (rowPhase <= cutoff) { - archive.push(line); + content = pruneSectionSpan( + content, + (lv, text) => (lv === 2 || lv === 3) && /^performance\s+metrics$/i.test(text), + (body) => { + const keep: string[] = [], archive: string[] = []; + for (const line of body.split('\n')) { + // Table data row: starts with | followed by a number (phase) + const tableRowMatch = line.match(/^\|\s*(\d+)\s*\|/); + if (tableRowMatch) { + const rowPhase = parseInt(tableRowMatch[1], 10); + if (rowPhase <= cutoff) { archive.push(line); } else { keep.push(line); } } else { + // Header row, separator row, or prose — always keep keep.push(line); } - } else { - // Header row, separator row, or prose — always keep - keep.push(line); } - } - if (archive.length > 0) { - sections.push({ section: 'Performance Metrics', count: archive.length, lines: archive }); - content = content.replace(metricsPattern, (_m, header: string) => `${header}${keep.join('\n')}`); - } - } + return { keep, archive }; + }, + 'Performance Metrics', + ); return { newContent: content, archivedSections: sections }; } @@ -2664,48 +2791,59 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string if (result) { body = result; updated.push('Last Activity Description'); } // Update ## Current Position section - const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; - const positionMatch = body.match(positionPattern); - if (positionMatch) { - const header = positionMatch[1]; - let posBody = positionMatch[2]; + // ADR-1372 T6: positionPattern → tokenizeHeadings; stop at level ≥ 2. + // Mirrors /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i + { + const cpHs = tokenizeHeadings(body); + const cpIdx = cpHs.findIndex(h => h.level === 2 && /^current\s+position$/i.test(h.text)); + if (cpIdx !== -1) { + const cpH = cpHs[cpIdx]; + const cpBodyLines = body.split('\n'); + const cpHL = cpBodyLines[cpH.line - 1]; + const cpBodyStart = cpH.offset + cpHL.length + 1; + let cpBodyEnd = body.length; + for (let j = cpIdx + 1; j < cpHs.length; j++) { + if (STOP_H2_PLUS(cpHs[j].level)) { cpBodyEnd = cpHs[j].offset - 1; break; } + } + let posBody = body.slice(cpBodyStart, cpBodyEnd); - // Update Phase line to show COMPLETE - const newPhase = `Phase: ${currentPhase} — COMPLETE`; - if (/^Phase:/m.test(posBody)) { - posBody = posBody.replace(/^Phase:.*$/m, newPhase); - } else { - // Pipe-table format in Current Position (#1255) - // Value cell must be bare (no "Phase:" label prefix) — the column header already provides the label. - const replaced = stateReplaceField(posBody, 'Phase', `${currentPhase} — COMPLETE`); - if (replaced !== null) posBody = replaced; + // Update Phase line to show COMPLETE + const newPhase = `Phase: ${currentPhase} — COMPLETE`; + if (/^Phase:/m.test(posBody)) { + posBody = posBody.replace(/^Phase:.*$/m, newPhase); + } else { + // Pipe-table format in Current Position (#1255) + // Value cell must be bare (no "Phase:" label prefix) — the column header already provides the label. + const replaced = stateReplaceField(posBody, 'Phase', `${currentPhase} — COMPLETE`); + if (replaced !== null) posBody = replaced; + } + + // Update Status line if present + const newStatus = `Status: Phase ${currentPhase} complete`; + if (/^Status:/m.test(posBody)) { + posBody = posBody.replace(/^Status:.*$/m, newStatus); + } else { + // Pipe-table format in Current Position (#1255) + const replaced = stateReplaceField(posBody, 'Status', `Phase ${currentPhase} complete`); + if (replaced !== null) posBody = replaced; + } + + // Update Last activity line if present + const newActivity = `Last activity: ${today} — Phase ${currentPhase} marked complete`; + if (/^Last activity:/im.test(posBody)) { + posBody = posBody.replace(/^Last activity:.*$/im, newActivity); + } else { + // Pipe-table format in Current Position (#1255) + // Value must match the inline branch (date + narrative), not bare date. + const activityValue = `${today} — Phase ${currentPhase} marked complete`; + const replaced = stateReplaceField(posBody, 'Last Activity', activityValue) + ?? stateReplaceField(posBody, 'Last activity', activityValue); + if (replaced !== null) posBody = replaced; + } + + body = body.slice(0, cpBodyStart) + posBody + body.slice(cpBodyEnd); + updated.push('Current Position'); } - - // Update Status line if present - const newStatus = `Status: Phase ${currentPhase} complete`; - if (/^Status:/m.test(posBody)) { - posBody = posBody.replace(/^Status:.*$/m, newStatus); - } else { - // Pipe-table format in Current Position (#1255) - const replaced = stateReplaceField(posBody, 'Status', `Phase ${currentPhase} complete`); - if (replaced !== null) posBody = replaced; - } - - // Update Last activity line if present - const newActivity = `Last activity: ${today} — Phase ${currentPhase} marked complete`; - if (/^Last activity:/im.test(posBody)) { - posBody = posBody.replace(/^Last activity:.*$/im, newActivity); - } else { - // Pipe-table format in Current Position (#1255) - // Value must match the inline branch (date + narrative), not bare date. - const activityValue = `${today} — Phase ${currentPhase} marked complete`; - const replaced = stateReplaceField(posBody, 'Last Activity', activityValue) - ?? stateReplaceField(posBody, 'Last activity', activityValue); - if (replaced !== null) posBody = replaced; - } - - body = body.replace(positionPattern, () => `${header}${posBody}`); - updated.push('Current Position'); } return reassemble(body); diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 8306e8c3d..c1167d360 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -4166,3 +4166,794 @@ describe('#1257 — planned-phase and begin-phase pipe-table regressions', () => } }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// T6 section-splice characterization tests (ADR-1372 / #1398) +// +// Covers the migrated cmdState* write-ops across a matrix of fixture variants: +// inline (standard frontmatter + inline section text) +// trailing-blanks (sections with extra blank lines) +// CRLF (Windows line endings) +// no-frontmatter (bare body only) +// nested-acc (Accumulated Context with Session Notes subsection) +// no-current-pos (absent Current Position section) +// post-milestone (fresh milestone, prior progress=100%) +// ───────────────────────────────────────────────────────────────────────────── + +describe('T6 section-splice characterization — record-session', () => { + // Fixtures used across these tests + const STATE_WITH_SESSION = [ + '---', + "gsd_state_version: '1.0'", + 'milestone: v1.0', + 'milestone_name: TestMilestone', + 'status: executing', + "last_updated: '2026-01-01T00:00:00.000Z'", + "last_activity: '2026-01-01'", + '---', + '', + '# Project State', + '', + '## Session', + '', + '**Last session:** 2026-01-01T00:00:00.000Z', + '**Stopped at:** None', + '**Resume file:** None', + '', + ].join('\n'); + + const STATE_NO_SESSION_LABELS = [ + '# Project State', + '', + '**Current focus:** Phase 2', + '', + '## Current Position', + '', + 'Phase: 2', + 'Plan: 1 of 4', + 'Status: Executing Phase 2', + 'Last Activity: 2026-01-01', + '', + '## Decisions Made', + '', + '- [Phase 1]: Chose PostgreSQL', + '', + ].join('\n'); + + test('record-session no-op: no session fields → recorded:false, STATE.md byte-unchanged', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_SESSION_LABELS); + const result = runGsdTools(['state', 'record-session'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.recorded, false, 'recorded must be false when no session fields exist'); + // milestone_name must NOT be trampled (#952 no-op guard) + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.strictEqual(after, STATE_NO_SESSION_LABELS, 'STATE.md must be byte-unchanged on no-op'); + } finally { + cleanup(d); + } + }); + + test('record-session --stopped-at updates Stopped at field in Session section', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_WITH_SESSION); + const result = runGsdTools(['state', 'record-session', '--stopped-at', '14.3'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.recorded, true, 'recorded must be true when session fields found'); + assert.ok(Array.isArray(output.updated), 'updated must be an array'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(after.includes('**Stopped at:** 14.3'), 'Stopped at field must be updated to 14.3'); + // milestone_name must be preserved (not trampled) + assert.ok(after.includes('milestone_name: TestMilestone'), 'milestone_name must be preserved'); + } finally { + cleanup(d); + } + }); + + test('record-session --resume-file updates Resume file field in Session section', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_WITH_SESSION); + const result = runGsdTools(['state', 'record-session', '--resume-file', 'plan-3.md'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.recorded, true, 'recorded must be true'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(after.includes('**Resume file:** plan-3.md'), 'Resume file field must be updated to plan-3.md'); + } finally { + cleanup(d); + } + }); +}); + +describe('T6 section-splice characterization — add-decision', () => { + const STATE_INLINE = [ + '---', + "gsd_state_version: '1.0'", + 'milestone: v1.0', + 'milestone_name: TestMilestone', + 'status: executing', + '---', + '', + '# Project State', + '', + '## Decisions Made', + '', + '- [Phase 1]: Use Node.js for tooling', + '', + '### Blockers', + '', + 'None yet.', + '', + ].join('\n'); + + const STATE_TRAILING_BLANKS = [ + '---', + "gsd_state_version: '1.0'", + 'status: planning', + '---', + '', + '# Project State', + '', + '## Current Position', + '', + 'Phase: 1', + '', + 'Status: Executing Phase 1', + 'Last Activity: 2026-01-01', + '', + '', + '## Decisions Made', + '', + '- [Phase 1]: First decision', + '', + '', + '### Blockers', + '', + '- Bug in auth service', + '', + ].join('\n'); + + const STATE_NO_DECISIONS = [ + '---', + "gsd_state_version: '1.0'", + 'status: planning', + '---', + '', + '# Project State', + '', + '### Blockers', + '', + 'None.', + '', + ].join('\n'); + + test('add-decision appends to existing Decisions Made section (inline fixture)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE); + const result = runGsdTools(['state', 'add-decision', '--phase', '2', '--summary', 'Use Docker for builds'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.added, true, 'added must be true'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(after.includes('- [Phase 2]: Use Docker for builds'), 'new decision entry must be present'); + assert.ok(after.includes('- [Phase 1]: Use Node.js for tooling'), 'existing decision must be preserved'); + } finally { + cleanup(d); + } + }); + + test('add-decision appends to Decisions Made section with trailing blank lines (trailing-blanks fixture)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_TRAILING_BLANKS); + const result = runGsdTools(['state', 'add-decision', '--phase', '3', '--summary', 'Add monitoring'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.added, true, 'added must be true'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(after.includes('- [Phase 3]: Add monitoring'), 'new decision must be present'); + assert.ok(after.includes('- [Phase 1]: First decision'), 'original decision must be preserved'); + } finally { + cleanup(d); + } + }); + + test('add-decision creates Decisions section when absent (DWIM)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_DECISIONS); + const result = runGsdTools(['state', 'add-decision', '--phase', '1', '--summary', 'Use Node.js'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.added, true, 'added must be true'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(after.includes('- [Phase 1]: Use Node.js'), 'decision must be present even when section was absent'); + } finally { + cleanup(d); + } + }); +}); + +describe('T6 section-splice characterization — add-blocker', () => { + const STATE_INLINE_WITH_BLOCKERS = [ + '---', + "gsd_state_version: '1.0'", + 'status: executing', + '---', + '', + '# Project State', + '', + '## Decisions Made', + '', + '- [Phase 1]: Use Node.js for tooling', + '', + '### Blockers', + '', + 'None yet.', + '', + ].join('\n'); + + const STATE_CRLF = '---\r\ngsd_state_version: 1.0\r\nstatus: executing\r\n---\r\n\r\n# Project State\r\n\r\n## Current Position\r\n\r\nStatus: Executing Phase 2\r\nLast Activity: 2026-01-01\r\n\r\n### Blockers\r\n\r\nNone.\r\n'; + + const STATE_NO_BLOCKERS_SECTION = [ + '# Project State', + '', + '## Current Position', + '', + 'Phase: 2', + '', + ].join('\n'); + + test('add-blocker appends to existing Blockers section (inline fixture)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_WITH_BLOCKERS); + const result = runGsdTools(['state', 'add-blocker', '--text', 'Flaky CI on Windows'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.added, true, 'added must be true'); + assert.strictEqual(output.blocker, 'Flaky CI on Windows', 'blocker text must match'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(after.includes('- Flaky CI on Windows'), 'blocker entry must be present'); + } finally { + cleanup(d); + } + }); + + test('add-blocker appends to existing Blockers section (CRLF fixture)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_CRLF); + const result = runGsdTools(['state', 'add-blocker', '--text', 'NFS mount issue'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.added, true, 'added must be true'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(after.includes('NFS mount issue'), 'blocker entry must be present in CRLF file'); + } finally { + cleanup(d); + } + }); + + test('add-blocker creates Blockers section when absent (DWIM)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_BLOCKERS_SECTION); + const result = runGsdTools(['state', 'add-blocker', '--text', 'Build pipeline broken'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.added, true, 'added must be true'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(after.includes('Build pipeline broken'), 'blocker must be present even when section was absent'); + } finally { + cleanup(d); + } + }); +}); + +describe('T6 section-splice characterization — resolve-blocker', () => { + const STATE_WITH_BLOCKERS = [ + '---', + "gsd_state_version: '1.0'", + 'status: planning', + '---', + '', + '# Project State', + '', + '## Decisions Made', + '', + '- [Phase 1]: First decision', + '', + '', + '### Blockers', + '', + '- Bug in auth service', + '- Another blocker', + '', + '### Recently Completed', + '', + '- Phase 1 Plan 1', + '', + ].join('\n'); + + test('resolve-blocker removes target blocker, preserves others (trailing-blanks fixture)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_WITH_BLOCKERS); + const result = runGsdTools(['state', 'resolve-blocker', '--text', 'Bug in auth service'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.resolved, true, 'resolved must be true'); + assert.strictEqual(output.blocker, 'Bug in auth service', 'resolved blocker text must match'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(!after.includes('- Bug in auth service'), 'resolved blocker must be removed'); + assert.ok(after.includes('Another blocker'), 'unrelated blocker must be preserved'); + } finally { + cleanup(d); + } + }); +}); + +describe('T6 section-splice characterization — add-roadmap-evolution', () => { + const STATE_NESTED_ACC = [ + '---', + "gsd_state_version: '1.0'", + 'status: executing', + '---', + '', + '# Project State', + '', + '## Current Position', + '', + 'Phase: 3', + 'Status: Executing Phase 3', + '', + '## Decisions Made', + '', + '- [Phase 1]: Use TypeScript', + '- [Phase 2]: Use Jest', + '', + '### Blockers', + '', + 'None.', + '', + '## Accumulated Context', + '', + 'Some context text here.', + '', + '### Roadmap Evolution', + '', + '- Phase 1 added: Initial planning', + '- Phase 2 changed: Scope updated', + '', + '### Session Notes', + '', + 'Some notes.', + '', + '## Session', + '', + '**Last session:** 2026-01-01T00:00:00.000Z', + '**Stopped at:** None', + '**Resume file:** None', + '', + ].join('\n'); + + const STATE_INLINE_WITH_ROAD_EVO = [ + '---', + "gsd_state_version: '1.0'", + 'status: executing', + '---', + '', + '# Project State', + '', + '## Accumulated Context', + '', + '### Roadmap Evolution', + '', + 'None yet.', + '', + ].join('\n'); + + const STATE_NO_ACC_SECTION = [ + '---', + "gsd_state_version: '1.0'", + 'status: planning', + '---', + '', + '# Project State', + '', + '### Blockers', + '', + 'None.', + '', + ].join('\n'); + + const STATE_CRLF_ROAD = '---\r\ngsd_state_version: 1.0\r\nstatus: executing\r\n---\r\n\r\n# Project State\r\n\r\n## Accumulated Context\r\n\r\n### Roadmap Evolution\r\n\r\n- Phase 1 added: Initial migration\r\n'; + + const STATE_POST_MILESTONE = [ + '---', + "gsd_state_version: '1.0'", + 'milestone: v2.0', + 'milestone_name: NextMilestone', + 'status: planning', + 'progress:', + ' total_phases: 4', + ' completed_phases: 4', + ' total_plans: 12', + ' completed_plans: 12', + ' percent: 100', + '---', + '', + '# Project State', + '', + '## Current Position', + '', + 'Phase: Not started (defining requirements)', + 'Plan: —', + 'Status: Defining requirements', + 'Last activity: 2026-01-15 — Milestone v2.0 started', + '', + '## Accumulated Context', + '', + '### Roadmap Evolution', + '', + '- Phase 1 complete after Phase 1: Migration done', + '', + ].join('\n'); + + test('add-roadmap-evolution appends to existing Roadmap Evolution subsection (nested-acc fixture)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NESTED_ACC); + const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '4', '--action', 'added', '--note', 'New API endpoint'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.added, true, 'added must be true'); + assert.ok(output.entry.includes('Phase 4 added'), 'entry must reference phase 4 added'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(after.includes('- Phase 4 added: New API endpoint'), 'new entry must be present'); + assert.ok(after.includes('- Phase 1 added: Initial planning'), 'existing entries must be preserved'); + assert.ok(after.includes('- Phase 2 changed: Scope updated'), 'second existing entry must be preserved'); + // Session Notes subsection must be preserved (not consumed by splice) + assert.ok(after.includes('### Session Notes'), 'Session Notes subsection must be preserved'); + } finally { + cleanup(d); + } + }); + + test('add-roadmap-evolution creates Roadmap Evolution subsection when absent but acc section present', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_WITH_ROAD_EVO); + const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '3', '--action', 'changed', '--note', 'Scope updated significantly'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.added, true, 'added must be true'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(after.includes('- Phase 3 changed: Scope updated significantly'), 'new entry must be present'); + } finally { + cleanup(d); + } + }); + + test('add-roadmap-evolution creates Accumulated Context and subsection when both absent (DWIM)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_ACC_SECTION); + const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '1', '--action', 'added', '--note', 'Initial setup'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.added, true, 'added must be true'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(after.includes('- Phase 1 added: Initial setup'), 'new entry must be present'); + assert.ok(after.includes('### Roadmap Evolution'), 'Roadmap Evolution subsection must be created'); + } finally { + cleanup(d); + } + }); + + test('add-roadmap-evolution appends to Roadmap Evolution in CRLF file', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_CRLF_ROAD); + const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '2', '--action', 'changed', '--note', 'CRLF test case'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.added, true, 'added must be true'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(after.includes('- Phase 2 changed: CRLF test case'), 'new CRLF entry must be present'); + assert.ok(after.includes('- Phase 1 added: Initial migration'), 'existing CRLF entry must be preserved'); + } finally { + cleanup(d); + } + }); + + test('add-roadmap-evolution appends to Roadmap Evolution in post-milestone STATE.md', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_POST_MILESTONE); + const result = runGsdTools(['state', 'add-roadmap-evolution', '--phase', '2', '--action', 'added', '--note', 'New phase inserted'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.added, true, 'added must be true'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(after.includes('- Phase 2 added: New phase inserted'), 'new entry must be present'); + assert.ok(after.includes('- Phase 1 complete after Phase 1: Migration done'), 'prior entry must be preserved'); + // Frontmatter milestone_name must NOT be trampled + assert.ok(after.includes('milestone_name: NextMilestone'), 'milestone_name must be preserved'); + } finally { + cleanup(d); + } + }); +}); + +describe('T6 section-splice characterization — begin-phase', () => { + const STATE_INLINE_POS = [ + '---', + "gsd_state_version: '1.0'", + 'milestone: v1.0', + 'milestone_name: TestMilestone', + 'status: executing', + '---', + '', + '# Project State', + '', + '## Current Position', + '', + 'Phase: 1 (Setup)', + 'Plan: 2 of 3', + 'Status: Executing Phase 1', + 'Last Activity: 2026-01-01', + 'Last activity: 2026-01-01', + '', + ].join('\n'); + + const STATE_NO_FRONTMATTER_POS = [ + '# Project State', + '', + '## Current Position', + '', + 'Phase: 2', + 'Plan: 1 of 4', + 'Status: Executing Phase 2', + 'Last Activity: 2026-01-01', + '', + ].join('\n'); + + const STATE_NO_CURRENT_POS = [ + '---', + "gsd_state_version: '1.0'", + 'status: planning', + '---', + '', + '# Project State', + '', + '## Decisions Made', + '', + '- [Phase 1]: First decision', + '', + '### Blockers', + '', + 'None.', + '', + ].join('\n'); + + test('begin-phase updates Current Position and status (inline fixture)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_POS); + const result = runGsdTools(['state', 'begin-phase', '--phase', '2', '--name', 'Build', '--plans', '4'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.ok(Array.isArray(output.updated), 'updated must be an array'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + // Phase line must reflect new phase + assert.ok(/Phase:\s+2/.test(after), 'Phase line must reference phase 2'); + // Plan counter must reset to 1 of 4 + assert.ok(/Plan:\s+1 of 4/.test(after), 'Plan line must be reset to 1 of 4'); + // Frontmatter status must be executing + assert.ok(/^status:\s+executing/m.test(after), 'frontmatter status must be executing'); + } finally { + cleanup(d); + } + }); + + test('begin-phase updates Current Position without frontmatter (no-frontmatter fixture)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_FRONTMATTER_POS); + const result = runGsdTools(['state', 'begin-phase', '--phase', '3', '--plans', '2'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.ok(Array.isArray(output.updated), 'updated must be an array'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(/Phase:\s+3/.test(after), 'Phase line must reference phase 3'); + } finally { + cleanup(d); + } + }); + + test('begin-phase handles absent Current Position section gracefully', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_CURRENT_POS); + const result = runGsdTools(['state', 'begin-phase', '--phase', '1', '--name', 'Setup', '--plans', '3'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + // Expect phase, phase_name and plan_count in response even if fields weren't updated + assert.strictEqual(output.phase, '1', 'phase must be reported in response'); + assert.strictEqual(output.plan_count, 3, 'plan_count must be reported in response'); + } finally { + cleanup(d); + } + }); +}); + +describe('T6 section-splice characterization — complete-phase', () => { + const STATE_INLINE_EXEC = [ + '---', + "gsd_state_version: '1.0'", + 'status: executing', + '---', + '', + '# Project State', + '', + '## Current Position', + '', + 'Phase: 1 (Setup)', + 'Plan: 2 of 3', + 'Status: Executing Phase 1', + 'Last Activity: 2026-01-01', + '', + ].join('\n'); + + const STATE_TRAILING_BLANKS_EXEC = [ + '---', + "gsd_state_version: '1.0'", + 'status: planning', + '---', + '', + '# Project State', + '', + '## Current Position', + '', + 'Phase: 1', + '', + 'Status: Executing Phase 1', + 'Last Activity: 2026-01-01', + '', + '', + '## Decisions Made', + '', + '- [Phase 1]: First decision', + '', + ].join('\n'); + + const STATE_CRLF_EXEC = '---\r\ngsd_state_version: 1.0\r\nstatus: executing\r\n---\r\n\r\n# Project State\r\n\r\n## Current Position\r\n\r\nStatus: Executing Phase 2\r\nLast Activity: 2026-01-01\r\n'; + + test('complete-phase marks current phase complete and sets frontmatter status (inline fixture)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_EXEC); + const result = runGsdTools(['state', 'complete-phase'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.ok(Array.isArray(output.updated), 'updated must be an array'); + assert.ok(output.updated.includes('Status'), 'Status must be in updated list'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(/^status:\s+completed/m.test(after), 'frontmatter status must be completed'); + assert.ok(/Status:\s+Phase\s+1\s+complete/i.test(after), 'body Status field must reflect phase complete'); + } finally { + cleanup(d); + } + }); + + test('complete-phase works correctly with trailing blank lines in Current Position (trailing-blanks fixture)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_TRAILING_BLANKS_EXEC); + const result = runGsdTools(['state', 'complete-phase'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.ok(Array.isArray(output.updated), 'updated must be an array'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(/^status:\s+completed/m.test(after), 'frontmatter status must be completed'); + } finally { + cleanup(d); + } + }); + + test('complete-phase works correctly on CRLF STATE.md', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_CRLF_EXEC); + const result = runGsdTools(['state', 'complete-phase', '--phase', '2'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.ok(Array.isArray(output.updated), 'updated must be an array'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(/^status:\s+completed/m.test(after), 'frontmatter status must be completed after CRLF complete-phase'); + } finally { + cleanup(d); + } + }); +}); + +describe('T6 section-splice characterization — milestone-switch', () => { + const STATE_INLINE_MS = [ + '---', + "gsd_state_version: '1.0'", + 'milestone: v1.0', + 'milestone_name: OldMilestone', + 'status: executing', + '---', + '', + '# Project State', + '', + '## Current Position', + '', + 'Phase: 1 (Setup)', + 'Plan: 1 of 3', + 'Status: Executing Phase 1', + '', + ].join('\n'); + + const STATE_NO_POSITION_MS = [ + '---', + "gsd_state_version: '1.0'", + 'milestone: v1.0', + 'milestone_name: OldMilestone', + 'status: planning', + '---', + '', + '# Project State', + '', + '## Decisions Made', + '', + '- [Phase 1]: First decision', + '', + '### Blockers', + '', + 'None.', + '', + ].join('\n'); + + test('milestone-switch updates milestone and milestone_name in frontmatter (position present)', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_INLINE_MS); + const result = runGsdTools(['state', 'milestone-switch', '--milestone', 'v2.0', '--name', 'NextMilestone'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.switched, true, 'switched must be true'); + assert.strictEqual(output.version, 'v2.0', 'version must be v2.0'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(/^milestone:\s+v2\.0/m.test(after), 'frontmatter milestone must be v2.0'); + assert.ok(/^milestone_name:\s+NextMilestone/m.test(after), 'frontmatter milestone_name must be NextMilestone'); + assert.ok(/^status:\s+planning/m.test(after), 'frontmatter status must be reset to planning on milestone switch'); + } finally { + cleanup(d); + } + }); + + test('milestone-switch updates frontmatter when Current Position is absent', () => { + const d = createTempProject(); + try { + fs.writeFileSync(path.join(d, '.planning', 'STATE.md'), STATE_NO_POSITION_MS); + const result = runGsdTools(['state', 'milestone-switch', '--milestone', 'v3.0'], d); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.switched, true, 'switched must be true'); + const after = fs.readFileSync(path.join(d, '.planning', 'STATE.md'), 'utf-8'); + assert.ok(/^milestone:\s+v3\.0/m.test(after), 'frontmatter milestone must be v3.0'); + } finally { + cleanup(d); + } + }); +});