Migrate cmdStateSync, cmdStatePrune, cmdStateUpdate (state.cts) onto the STATE.md Transition Module substrate and close the maintenance bug pair #1760/#1761 (ADR-1769, epic #1769). Completes the substrate: all 10 lifecycle/maintenance transitions now route through transitionCore. - Add {kind: 'sync'|'prune'|'update'} to StateTransitionIntent with syncCore, pruneCore, updateCore in src/state-transition.cts. - updateCore: body-only single-field update (strip/reassemble), mirroring the pre-migration cmdStateUpdate contract. - pruneCore: pure content→content section pruning (Decisions / Recently Completed / resolved Blockers / Performance Metrics rows at or below cutoff), byte-identical tokenizeHeadings splicing. Adapter owns currentPhase, dry-run, and STATE-ARCHIVE.md. - syncCore: body writes (Total Plans in Phase, Progress bar, Last Activity) given disk-derived numbers. Adapter owns the disk scan + roadmap scope. - #1760: cmdStatePrune now derives currentPhase from 'Current Phase' OR 'Phase' (the canonical template emits 'Phase: X of Y'), so prune engages on template-conformant STATE.md instead of bailing 'Only 0 phases'. - #1761: cmdStateSync skips the Progress write (percent=null) when a milestone version is set in frontmatter but the ROADMAP has no versioned heading for it (milestone cannot be bounded). Projects without a milestone version are unaffected. Leaves progress untouched rather than silently writing fallback- derived wrong values. - Regression: bug-1760 (prune engages on template field) + bug-1761 (sync leaves progress untouched when unbounded). ADR-1769 marked Accepted. All 82 transition + 515 regression tests pass. Closes #1793
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# ADR-1769: STATE.md Transition Module — intent-based transitions over scattered RMW callbacks
|
||||
|
||||
- **Status:** Proposed (Phase 0); **Accepted** at Phase 7 closeout
|
||||
- **Date:** 2026-06-27 (Phase 0)
|
||||
- **Status:** Accepted (Phase 7 closeout — all 10 lifecycle/maintenance transitions migrated; bug cluster #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 retired)
|
||||
- **Date:** 2026-06-27 (Phase 0); Phase 7 closeout 2026-06-27
|
||||
- **Issue:** [#1769](https://github.com/open-gsd/gsd-core/issues/1769) — epic
|
||||
- **Supersedes:** the policy portions of `syncStateFrontmatter` (`src/state.cts:1667–1743`),
|
||||
`readModifyWriteStateMd`'s post-sync preservation block (`src/state.cts:2008–2119`), and
|
||||
@@ -214,4 +214,4 @@ alongside, leave callbacks — parallel worlds don't converge (ADR-857's failure
|
||||
| 4 | `plannedPhase` + `milestoneSwitch` | #1786 | — |
|
||||
| 5 | `milestoneComplete` + `milestone.cts:352` | #1789 | — |
|
||||
| 6 | `patch` | #1791 | #1743, #1695 |
|
||||
| 7 | `sync`, `prune`, `update` | TBD | #1760, #1761 |
|
||||
| 7 | `sync`, `prune`, `update` | #1793 | #1760, #1761 |
|
||||
|
||||
@@ -127,6 +127,12 @@ function transitionCore(content, intent, deps) {
|
||||
return milestoneCompleteCore(content, intent, deps);
|
||||
case 'patch':
|
||||
return patchCore(content, intent);
|
||||
case 'update':
|
||||
return updateCore(content, intent);
|
||||
case 'prune':
|
||||
return pruneCore(content, intent);
|
||||
case 'sync':
|
||||
return syncCore(content, intent, deps);
|
||||
}
|
||||
}
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -941,3 +947,199 @@ function patchCore(content, intent) {
|
||||
}
|
||||
return { content: result, updated, data: { updated, failed } };
|
||||
}
|
||||
// ----------------------------------------------------------------------------
|
||||
// update — intent implementation (Phase 7)
|
||||
// ----------------------------------------------------------------------------
|
||||
/**
|
||||
* Apply an `update` transition to STATE.md content.
|
||||
*
|
||||
* Migrates `cmdStateUpdate` (state.cts) onto the substrate. A single-field
|
||||
* body-only update (the field is replaced in the body; frontmatter is preserved
|
||||
* as-is and re-synced by the adapter's `readModifyWriteStateMd` post-sync).
|
||||
* Mirrors the pre-migration body-strip/reassemble contract.
|
||||
*/
|
||||
function updateCore(content, intent) {
|
||||
const existingFm = extractFrontmatter(content);
|
||||
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
||||
const body = stripFrontmatter(content);
|
||||
const result = (0, state_document_cjs_1.stateReplaceField)(body, intent.field, intent.value);
|
||||
if (result === null) {
|
||||
return { content, updated: [], data: { updated: false } };
|
||||
}
|
||||
const reassembled = hasFrontmatter
|
||||
? `---\n${reconstructFrontmatter(existingFm)}\n---\n\n${result}`
|
||||
: result;
|
||||
return { content: reassembled, updated: [intent.field], data: { updated: true } };
|
||||
}
|
||||
// Stop predicate for prune section slicing: a level-2 OR level-3 heading ends
|
||||
// the section (mirrors state.cts STOP_H2_H3 — Decisions / Recently Completed /
|
||||
// Blockers / Performance Metrics live at H2 or H3).
|
||||
const STOP_H2_H3 = (lv) => lv === 2 || lv === 3;
|
||||
/**
|
||||
* Apply a `prune` transition to STATE.md content.
|
||||
*
|
||||
* Migrates the section-pruning half of `cmdStatePrune` (state.cts) onto the
|
||||
* substrate. Pure `content → {content, archivedSections}` given a cutoff phase:
|
||||
* archives Decisions / Recently Completed / resolved Blockers / Performance
|
||||
* Metrics table rows whose phase number is <= cutoff. ADR-1372 T6
|
||||
* tokenizeHeadings + untrimmed-span splicing, byte-identical to the pre-migration
|
||||
* `prunePass`.
|
||||
*
|
||||
* The adapter owns currentPhase derivation (with the #1760 `Phase` / `Current
|
||||
* Phase` fallback), keepRecent/dryRun, and STATE-ARCHIVE.md writes.
|
||||
*/
|
||||
function pruneCore(content, intent) {
|
||||
const cutoff = intent.cutoff;
|
||||
const sections = [];
|
||||
let c = content;
|
||||
// Helper: locate a heading matching pred, extract untrimmed body [bs, se),
|
||||
// apply transform, splice back. All prune sections stop at level 2 or 3.
|
||||
const pruneSectionSpan = (pred, transform, sectionName) => {
|
||||
const hs = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(c);
|
||||
const i = hs.findIndex((h) => pred(h.level, h.text));
|
||||
if (i === -1)
|
||||
return;
|
||||
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: sectionName, count: archive.length, lines: archive });
|
||||
c = c.slice(0, bs) + keep.join('\n') + c.slice(se);
|
||||
}
|
||||
};
|
||||
pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^(?:Decisions|Decisions Made|Accumulated.*Decisions)$/i.test(text), (body) => {
|
||||
const keep = [], archive = [];
|
||||
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);
|
||||
}
|
||||
}
|
||||
return { keep, archive };
|
||||
}, 'Decisions');
|
||||
pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^recently\s+completed$/i.test(text), (body) => {
|
||||
const keep = [], archive = [];
|
||||
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');
|
||||
pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^(?:Blockers|Blockers\/Concerns|Blockers\s*&\s*Concerns)$/i.test(text), (body) => {
|
||||
const keep = [], archive = [];
|
||||
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);
|
||||
}
|
||||
}
|
||||
return { keep, archive };
|
||||
}, 'Blockers (resolved)');
|
||||
pruneSectionSpan((lv, text) => (lv === 2 || lv === 3) && /^performance\s+metrics$/i.test(text), (body) => {
|
||||
const keep = [], archive = [];
|
||||
for (const line of body.split('\n')) {
|
||||
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 {
|
||||
keep.push(line);
|
||||
}
|
||||
}
|
||||
return { keep, archive };
|
||||
}, 'Performance Metrics');
|
||||
const totalPruned = sections.reduce((sum, s) => sum + s.count, 0);
|
||||
return {
|
||||
content: c,
|
||||
updated: totalPruned > 0 ? ['pruned'] : [],
|
||||
data: { archivedSections: sections, totalPruned },
|
||||
};
|
||||
}
|
||||
// ----------------------------------------------------------------------------
|
||||
// sync — intent implementation (Phase 7)
|
||||
// ----------------------------------------------------------------------------
|
||||
/**
|
||||
* Apply a `sync` transition to STATE.md content.
|
||||
*
|
||||
* Migrates the body-write half of `cmdStateSync` (state.cts) onto the substrate.
|
||||
* Updates Total Plans in Phase, the Progress bar, and Last Activity from
|
||||
* disk-derived numbers (injected via the intent). Returns the per-field change
|
||||
* log via `data.changes` so the adapter can build the CLI output.
|
||||
*
|
||||
* #1761: when the current milestone cannot be bounded to a versioned phase set,
|
||||
* the adapter passes `percent: null` and this core leaves Progress untouched
|
||||
* (rather than silently writing fallback-derived wrong values).
|
||||
*/
|
||||
function syncCore(content, intent, deps) {
|
||||
const today = deps.clock.today();
|
||||
const changes = [];
|
||||
let modified = content;
|
||||
const updated = [];
|
||||
if (intent.totalPlansInPhase !== null) {
|
||||
const currentPlansField = (0, state_document_cjs_1.stateExtractField)(modified, 'Total Plans in Phase');
|
||||
if (currentPlansField && parseInt(currentPlansField, 10) !== intent.totalPlansInPhase) {
|
||||
changes.push(`Total Plans in Phase: ${currentPlansField} -> ${intent.totalPlansInPhase}`);
|
||||
const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Total Plans in Phase', String(intent.totalPlansInPhase));
|
||||
if (result) {
|
||||
modified = result;
|
||||
updated.push('Total Plans in Phase');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (intent.percent !== null) {
|
||||
const currentProgress = (0, state_document_cjs_1.stateExtractField)(modified, 'Progress');
|
||||
if (currentProgress) {
|
||||
const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
|
||||
if (currentPercent !== intent.percent) {
|
||||
const barWidth = 10;
|
||||
const filled = Math.round((intent.percent / 100) * barWidth);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
const progressStr = `[${bar}] ${intent.percent}%`;
|
||||
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
|
||||
const result = (0, state_document_cjs_1.stateReplaceField)(modified, 'Progress', progressStr);
|
||||
if (result) {
|
||||
modified = result;
|
||||
updated.push('Progress');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const lastActivityResult = (0, state_document_cjs_1.stateReplaceField)(modified, 'Last Activity', today);
|
||||
if (lastActivityResult) {
|
||||
const oldActivity = (0, state_document_cjs_1.stateExtractField)(modified, 'Last Activity');
|
||||
if (oldActivity !== today) {
|
||||
changes.push(`Last Activity: ${oldActivity} -> ${today}`);
|
||||
updated.push('Last Activity');
|
||||
}
|
||||
modified = lastActivityResult;
|
||||
}
|
||||
return { content: modified, updated, data: { changes } };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"bug-170-workflow-fallback-install-hint.test.cjs",
|
||||
"bug-1736-local-install-commands.test.cjs",
|
||||
"bug-1754-js-hook-guard.test.cjs",
|
||||
"bug-1760-state-prune-noop-template-field.test.cjs",
|
||||
"bug-1761-state-sync-wrong-progress.test.cjs",
|
||||
"bug-1817-sh-hook-guard.test.cjs",
|
||||
"bug-1818-unknown-flags.test.cjs",
|
||||
"bug-1826-phases-clear-confirm.test.cjs",
|
||||
|
||||
@@ -100,6 +100,8 @@
|
||||
"state": {
|
||||
"files": [
|
||||
"bug-1695-state-patch-clobbers-phase-name.test.cjs",
|
||||
"bug-1760-state-prune-noop-template-field.test.cjs",
|
||||
"bug-1761-state-sync-wrong-progress.test.cjs",
|
||||
"bug-21-state-md-template-frontmatter.test.cjs",
|
||||
"bug-2630-state-frontmatter-milestone-switch.test.cjs",
|
||||
"bug-3127-state-begin-phase-idempotent.test.cjs",
|
||||
|
||||
@@ -180,8 +180,21 @@ export type StateTransitionIntent =
|
||||
/** Resolved runtime slash command for the Operator Next Steps hint (e.g. '/gsd:new-milestone'). */
|
||||
nextMilestoneCommand: string;
|
||||
}
|
||||
| { kind: 'patch'; patches: Record<string, string> };
|
||||
// Phase 7 adds the remaining intent kinds to this discriminated union.
|
||||
| { kind: 'patch'; patches: Record<string, string> }
|
||||
| { kind: 'update'; field: string; value: string }
|
||||
| {
|
||||
kind: 'prune';
|
||||
/** Phases at or below this number are archived (currentPhase - keepRecent). */
|
||||
cutoff: number;
|
||||
}
|
||||
| {
|
||||
kind: 'sync';
|
||||
/** Disk-derived plan count for the highest incomplete phase, or null. */
|
||||
totalPlansInPhase: number | null;
|
||||
/** Recomputed progress percent (0-100), or null when it must be left untouched (#1761). */
|
||||
percent: number | null;
|
||||
};
|
||||
// Phase 7 closes out the discriminated union (all 10 lifecycle/maintenance intents).
|
||||
|
||||
export type StateTransitionResult = {
|
||||
content: string;
|
||||
@@ -224,6 +237,12 @@ export function transitionCore(
|
||||
return milestoneCompleteCore(content, intent, deps);
|
||||
case 'patch':
|
||||
return patchCore(content, intent);
|
||||
case 'update':
|
||||
return updateCore(content, intent);
|
||||
case 'prune':
|
||||
return pruneCore(content, intent);
|
||||
case 'sync':
|
||||
return syncCore(content, intent, deps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1174,3 +1193,223 @@ function patchCore(
|
||||
|
||||
return { content: result, updated, data: { updated, failed } };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// update — intent implementation (Phase 7)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply an `update` transition to STATE.md content.
|
||||
*
|
||||
* Migrates `cmdStateUpdate` (state.cts) onto the substrate. A single-field
|
||||
* body-only update (the field is replaced in the body; frontmatter is preserved
|
||||
* as-is and re-synced by the adapter's `readModifyWriteStateMd` post-sync).
|
||||
* Mirrors the pre-migration body-strip/reassemble contract.
|
||||
*/
|
||||
function updateCore(
|
||||
content: string,
|
||||
intent: { kind: 'update'; field: string; value: string },
|
||||
): StateTransitionResult {
|
||||
const existingFm = extractFrontmatter(content) as Record<string, unknown>;
|
||||
const hasFrontmatter = Object.keys(existingFm).length > 0;
|
||||
const body = stripFrontmatter(content);
|
||||
const result = stateReplaceField(body, intent.field, intent.value);
|
||||
if (result === null) {
|
||||
return { content, updated: [], data: { updated: false } };
|
||||
}
|
||||
const reassembled = hasFrontmatter
|
||||
? `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${result}`
|
||||
: result;
|
||||
return { content: reassembled, updated: [intent.field], data: { updated: true } };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// prune — intent implementation (Phase 7)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type PrunedSection = { section: string; count: number; lines: string[] };
|
||||
|
||||
// Stop predicate for prune section slicing: a level-2 OR level-3 heading ends
|
||||
// the section (mirrors state.cts STOP_H2_H3 — Decisions / Recently Completed /
|
||||
// Blockers / Performance Metrics live at H2 or H3).
|
||||
const STOP_H2_H3 = (lv: number): boolean => lv === 2 || lv === 3;
|
||||
|
||||
/**
|
||||
* Apply a `prune` transition to STATE.md content.
|
||||
*
|
||||
* Migrates the section-pruning half of `cmdStatePrune` (state.cts) onto the
|
||||
* substrate. Pure `content → {content, archivedSections}` given a cutoff phase:
|
||||
* archives Decisions / Recently Completed / resolved Blockers / Performance
|
||||
* Metrics table rows whose phase number is <= cutoff. ADR-1372 T6
|
||||
* tokenizeHeadings + untrimmed-span splicing, byte-identical to the pre-migration
|
||||
* `prunePass`.
|
||||
*
|
||||
* The adapter owns currentPhase derivation (with the #1760 `Phase` / `Current
|
||||
* Phase` fallback), keepRecent/dryRun, and STATE-ARCHIVE.md writes.
|
||||
*/
|
||||
function pruneCore(
|
||||
content: string,
|
||||
intent: { kind: 'prune'; cutoff: number },
|
||||
): StateTransitionResult {
|
||||
const cutoff = intent.cutoff;
|
||||
const sections: PrunedSection[] = [];
|
||||
let c = content;
|
||||
|
||||
// Helper: locate a heading matching pred, extract untrimmed body [bs, se),
|
||||
// apply transform, splice back. All prune sections stop at level 2 or 3.
|
||||
const pruneSectionSpan = (
|
||||
pred: (lv: number, text: string) => boolean,
|
||||
transform: (body: string) => { keep: string[]; archive: string[] },
|
||||
sectionName: string,
|
||||
): void => {
|
||||
const hs = tokenizeHeadings(c);
|
||||
const i = hs.findIndex((h) => pred(h.level, h.text));
|
||||
if (i === -1) return;
|
||||
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: sectionName, count: archive.length, lines: archive });
|
||||
c = c.slice(0, bs) + keep.join('\n') + c.slice(se);
|
||||
}
|
||||
};
|
||||
|
||||
pruneSectionSpan(
|
||||
(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); }
|
||||
}
|
||||
return { keep, archive };
|
||||
},
|
||||
'Decisions',
|
||||
);
|
||||
|
||||
pruneSectionSpan(
|
||||
(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',
|
||||
);
|
||||
|
||||
pruneSectionSpan(
|
||||
(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); }
|
||||
}
|
||||
return { keep, archive };
|
||||
},
|
||||
'Blockers (resolved)',
|
||||
);
|
||||
|
||||
pruneSectionSpan(
|
||||
(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')) {
|
||||
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 {
|
||||
keep.push(line);
|
||||
}
|
||||
}
|
||||
return { keep, archive };
|
||||
},
|
||||
'Performance Metrics',
|
||||
);
|
||||
|
||||
const totalPruned = sections.reduce((sum, s) => sum + s.count, 0);
|
||||
return {
|
||||
content: c,
|
||||
updated: totalPruned > 0 ? ['pruned'] : [],
|
||||
data: { archivedSections: sections, totalPruned },
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// sync — intent implementation (Phase 7)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply a `sync` transition to STATE.md content.
|
||||
*
|
||||
* Migrates the body-write half of `cmdStateSync` (state.cts) onto the substrate.
|
||||
* Updates Total Plans in Phase, the Progress bar, and Last Activity from
|
||||
* disk-derived numbers (injected via the intent). Returns the per-field change
|
||||
* log via `data.changes` so the adapter can build the CLI output.
|
||||
*
|
||||
* #1761: when the current milestone cannot be bounded to a versioned phase set,
|
||||
* the adapter passes `percent: null` and this core leaves Progress untouched
|
||||
* (rather than silently writing fallback-derived wrong values).
|
||||
*/
|
||||
function syncCore(
|
||||
content: string,
|
||||
intent: { kind: 'sync'; totalPlansInPhase: number | null; percent: number | null },
|
||||
deps: StateTransitionDeps,
|
||||
): StateTransitionResult {
|
||||
const today = deps.clock.today();
|
||||
const changes: string[] = [];
|
||||
let modified = content;
|
||||
const updated: string[] = [];
|
||||
|
||||
if (intent.totalPlansInPhase !== null) {
|
||||
const currentPlansField = stateExtractField(modified, 'Total Plans in Phase');
|
||||
if (currentPlansField && parseInt(currentPlansField, 10) !== intent.totalPlansInPhase) {
|
||||
changes.push(`Total Plans in Phase: ${currentPlansField} -> ${intent.totalPlansInPhase}`);
|
||||
const result = stateReplaceField(modified, 'Total Plans in Phase', String(intent.totalPlansInPhase));
|
||||
if (result) { modified = result; updated.push('Total Plans in Phase'); }
|
||||
}
|
||||
}
|
||||
|
||||
if (intent.percent !== null) {
|
||||
const currentProgress = stateExtractField(modified, 'Progress');
|
||||
if (currentProgress) {
|
||||
const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
|
||||
if (currentPercent !== intent.percent) {
|
||||
const barWidth = 10;
|
||||
const filled = Math.round((intent.percent / 100) * barWidth);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
const progressStr = `[${bar}] ${intent.percent}%`;
|
||||
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
|
||||
const result = stateReplaceField(modified, 'Progress', progressStr);
|
||||
if (result) { modified = result; updated.push('Progress'); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lastActivityResult = stateReplaceField(modified, 'Last Activity', today);
|
||||
if (lastActivityResult) {
|
||||
const oldActivity = stateExtractField(modified, 'Last Activity');
|
||||
if (oldActivity !== today) {
|
||||
changes.push(`Last Activity: ${oldActivity} -> ${today}`);
|
||||
updated.push('Last Activity');
|
||||
}
|
||||
modified = lastActivityResult;
|
||||
}
|
||||
|
||||
return { content: modified, updated, data: { changes } };
|
||||
}
|
||||
|
||||
220
src/state.cts
220
src/state.cts
@@ -435,20 +435,20 @@ function cmdStateUpdate(cwd: string, field: string | undefined, value: string |
|
||||
try {
|
||||
let updated = false;
|
||||
const shouldResync = shouldResyncStateProgress([field as string]);
|
||||
// ADR-1769 Phase 7: dispatches to the STATE.md Transition Module. The
|
||||
// body-strip/reassemble single-field update is the pure `updateCore` in
|
||||
// src/state-transition.cts. readModifyWriteStateMd still owns the lock, the
|
||||
// #1230/#1264/#1695 post-sync preservation, and the no-op write guard.
|
||||
// Preserve curated progress for body-only updates, but allow fields that
|
||||
// directly project into progress.* frontmatter to rebuild after mutation.
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
const body = stripFrontmatter(content);
|
||||
const result = stateReplaceField(body, field as string, value as string);
|
||||
if (result) {
|
||||
updated = true;
|
||||
const existingFm = extractFrontmatter(content) as Record<string, unknown>;
|
||||
if (Object.keys(existingFm).length > 0) {
|
||||
return `---\n${reconstructFrontmatter(existingFm as unknown as Frontmatter)}\n---\n\n${result}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return content;
|
||||
const result = transitionCore(
|
||||
content,
|
||||
{ kind: 'update', field: field as string, value: value as string },
|
||||
{ clock: realClock, progressProvider: () => null },
|
||||
);
|
||||
updated = (result.data as { updated: boolean } | undefined)?.updated === true;
|
||||
return result.content;
|
||||
}, cwd, { resync: shouldResync });
|
||||
if (updated) {
|
||||
output({ updated: true }, false, undefined);
|
||||
@@ -2362,7 +2362,7 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
|
||||
const content = fs.readFileSync(statePath, 'utf-8');
|
||||
const changes: string[] = [];
|
||||
let modified = content;
|
||||
const today = realClock.today();
|
||||
|
||||
|
||||
const phasesDir = planningPaths(cwd).phases;
|
||||
if (!fs.existsSync(phasesDir)) {
|
||||
@@ -2375,10 +2375,12 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
|
||||
// exactly as buildStateFrontmatter does — otherwise `state sync --verify`
|
||||
// would keep re-deriving the inflated denominator and report "no drift".
|
||||
let syncRoadmapScope: string | null = null;
|
||||
let syncRoadmapRaw: string | null = null;
|
||||
let syncRetiredPhaseNums = new Set<string>();
|
||||
try {
|
||||
const roadmapRaw = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md'));
|
||||
if (roadmapRaw !== null) {
|
||||
syncRoadmapRaw = roadmapRaw;
|
||||
syncRoadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
syncRetiredPhaseNums = extractRetiredPhaseNumbers(syncRoadmapScope);
|
||||
}
|
||||
@@ -2456,46 +2458,36 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Sync Total Plans in Phase
|
||||
if (highestIncompletePhase) {
|
||||
const currentPlansField = stateExtractField(modified, 'Total Plans in Phase');
|
||||
if (currentPlansField && parseInt(currentPlansField, 10) !== highestIncompletePhaseplanCount) {
|
||||
changes.push(`Total Plans in Phase: ${currentPlansField} -> ${highestIncompletePhaseplanCount}`);
|
||||
const result = stateReplaceField(modified, 'Total Plans in Phase', String(highestIncompletePhaseplanCount));
|
||||
if (result) modified = result;
|
||||
}
|
||||
// ADR-1769 Phase 7: the body writes (Total Plans in Phase, Progress bar, Last
|
||||
// Activity) are the pure `syncCore` in src/state-transition.cts.
|
||||
// #1761: when a milestone version is set in frontmatter but the ROADMAP has no
|
||||
// versioned heading for it, the milestone cannot be bounded to a versioned phase
|
||||
// set — leave Progress untouched (percent=null) rather than silently writing
|
||||
// fallback-derived wrong values. Projects without a milestone version (the common
|
||||
// sync-test shape) are unaffected: the gate only fires when a version is asserted.
|
||||
const fmVersion = (extractFrontmatter(content) as Record<string, unknown>).milestone;
|
||||
const versionStr = typeof fmVersion === 'string' && fmVersion.trim() ? fmVersion.trim() : null;
|
||||
let milestoneBounded = true;
|
||||
if (versionStr !== null && syncRoadmapRaw !== null) {
|
||||
const versionedHeading = new RegExp(`^#{1,3}\\s+(?!Phase\\s+\\S).*${escapeRegex(versionStr)}`, 'mi');
|
||||
milestoneBounded = versionedHeading.test(syncRoadmapRaw);
|
||||
}
|
||||
|
||||
// Sync Progress — use shared helper so formula stays in one place (#3242 Bug B).
|
||||
// computeProgressPercent applies min(plan_fraction, phase_fraction) so unrealised
|
||||
// ROADMAP phases cap the reported percent rather than allowing a false 100%.
|
||||
const percent = (() => {
|
||||
let percent: number | null = null;
|
||||
if (!milestoneBounded) {
|
||||
changes.push(`Progress: skipped — milestone ${versionStr} cannot be bounded to a versioned ROADMAP phase set (#1761)`);
|
||||
} else {
|
||||
const p = computeProgressPercent(totalDiskSummaries, totalDiskPlans, diskCompletedPhases, syncTotalPhases);
|
||||
return p !== null ? p : 0;
|
||||
})();
|
||||
const currentProgress = stateExtractField(modified, 'Progress');
|
||||
if (currentProgress) {
|
||||
const currentPercent = parseInt(currentProgress.replace(/[^\d]/g, ''), 10);
|
||||
if (currentPercent !== percent) {
|
||||
const barWidth = 10;
|
||||
const filled = Math.round(percent / 100 * barWidth);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
const progressStr = `[${bar}] ${percent}%`;
|
||||
changes.push(`Progress: ${currentProgress} -> ${progressStr}`);
|
||||
const result = stateReplaceField(modified, 'Progress', progressStr);
|
||||
if (result) modified = result;
|
||||
}
|
||||
percent = p !== null ? p : 0;
|
||||
}
|
||||
|
||||
// Sync Last Activity
|
||||
const result = stateReplaceField(modified, 'Last Activity', today);
|
||||
if (result) {
|
||||
const oldActivity = stateExtractField(modified, 'Last Activity');
|
||||
if (oldActivity !== today) {
|
||||
changes.push(`Last Activity: ${oldActivity} -> ${today}`);
|
||||
}
|
||||
modified = result;
|
||||
}
|
||||
const syncResult = transitionCore(
|
||||
modified,
|
||||
{ kind: 'sync', totalPlansInPhase: highestIncompletePhase ? highestIncompletePhaseplanCount : null, percent },
|
||||
{ clock: realClock, progressProvider: () => null },
|
||||
);
|
||||
modified = syncResult.content;
|
||||
const coreChanges = (syncResult.data as { changes?: string[] } | undefined)?.changes ?? [];
|
||||
changes.push(...coreChanges);
|
||||
|
||||
if (verify) {
|
||||
output({ synced: false, changes, dry_run: true }, raw, undefined);
|
||||
@@ -2526,8 +2518,13 @@ function cmdStatePrune(cwd: string, options: StatePruneOptions, raw: boolean): v
|
||||
|
||||
const keepRecent = parseInt(String(options.keepRecent), 10) || 3;
|
||||
const dryRun = !!options.dryRun;
|
||||
const currentPhaseRaw = stateExtractField(fs.readFileSync(statePath, 'utf-8'), 'Current Phase');
|
||||
const currentPhase = parseInt(currentPhaseRaw as string, 10) || 0;
|
||||
// #1760: the canonical STATE.md template emits `Phase: [X] of [Y]`, not
|
||||
// `Current Phase:`. Read both (mirroring buildStateFrontmatter /
|
||||
// resolvePhaseIdForCompletePhase) so prune engages on template-conformant
|
||||
// STATE.md instead of bailing with "Only 0 phases — nothing to prune".
|
||||
const rawState = fs.readFileSync(statePath, 'utf-8');
|
||||
const currentPhaseRaw = stateExtractField(rawState, 'Current Phase') || stateExtractField(rawState, 'Phase');
|
||||
const currentPhase = parseInt(String(currentPhaseRaw), 10) || 0;
|
||||
const cutoff = currentPhase - keepRecent;
|
||||
|
||||
if (cutoff <= 0) {
|
||||
@@ -2538,119 +2535,22 @@ function cmdStatePrune(cwd: string, options: StatePruneOptions, raw: boolean): v
|
||||
const archivePath = path.join(path.dirname(statePath), 'STATE-ARCHIVE.md');
|
||||
const archived: PrunedSection[] = [];
|
||||
|
||||
// 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[] = [];
|
||||
|
||||
// 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: sectionName, count: archive.length, lines: archive });
|
||||
return c.slice(0, bs) + keep.join('\n') + c.slice(se);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// 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); }
|
||||
}
|
||||
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
|
||||
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); }
|
||||
}
|
||||
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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
return { keep, archive };
|
||||
},
|
||||
'Performance Metrics',
|
||||
);
|
||||
|
||||
return { newContent: content, archivedSections: sections };
|
||||
}
|
||||
// ADR-1769 Phase 7: the section-pruning is the pure `pruneCore` in
|
||||
// src/state-transition.cts (byte-identical tokenizeHeadings section splicing).
|
||||
// This adapter owns currentPhase derivation (#1760 `Phase`/`Current Phase`
|
||||
// fallback above), dry-run, and STATE-ARCHIVE.md writes.
|
||||
const runPruneCore = (content: string): { newContent: string; archivedSections: PrunedSection[] } => {
|
||||
const result = transitionCore(content, { kind: 'prune', cutoff }, { clock: realClock, progressProvider: () => null });
|
||||
return {
|
||||
newContent: result.content,
|
||||
archivedSections: ((result.data as { archivedSections?: PrunedSection[] } | undefined)?.archivedSections) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
if (dryRun) {
|
||||
// Dry-run: compute what would be pruned without writing anything
|
||||
const content = fs.readFileSync(statePath, 'utf-8');
|
||||
const result = prunePass(content);
|
||||
const result = runPruneCore(content);
|
||||
const totalPruned = result.archivedSections.reduce((sum, s) => sum + s.count, 0);
|
||||
emit({
|
||||
pruned: false,
|
||||
@@ -2665,7 +2565,7 @@ function cmdStatePrune(cwd: string, options: StatePruneOptions, raw: boolean): v
|
||||
}
|
||||
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
const result = prunePass(content);
|
||||
const result = runPruneCore(content);
|
||||
archived.push(...result.archivedSections);
|
||||
return result.newContent;
|
||||
}, cwd);
|
||||
|
||||
80
tests/bug-1760-state-prune-noop-template-field.test.cjs
Normal file
80
tests/bug-1760-state-prune-noop-template-field.test.cjs
Normal file
@@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
// Regression test for issue #1760 — `state prune` no-ops on template-conformant
|
||||
// STATE.md because it reads `Current Phase` only, never the `Phase: X of Y` line
|
||||
// the canonical template emits.
|
||||
//
|
||||
// ADR-1769 Phase 7 fix: derive the current phase with a `Phase` / `Current Phase`
|
||||
// fallback (mirroring buildStateFrontmatter), so prune engages on template STATE.md.
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
function writeStateMd(tmpDir, content) {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
||||
}
|
||||
function readStateMd(tmpDir) {
|
||||
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
}
|
||||
|
||||
describe('#1760: state prune engages on template-conformant STATE.md (Phase: X of Y)', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('prune engages when STATE.md uses "Phase: N of M" (no Current Phase field)', () => {
|
||||
// Template-conformant STATE.md: Current Position uses `Phase: 10 of 15`, and
|
||||
// there is NO `**Current Phase:**` body field. Pre-fix this made prune bail
|
||||
// with "Only 0 phases — nothing to prune".
|
||||
writeStateMd(tmpDir, [
|
||||
'# Session State',
|
||||
'',
|
||||
'## Current Position',
|
||||
'',
|
||||
'Phase: 10 of 15',
|
||||
'Plan: 2 of 4',
|
||||
'Status: Executing Phase 10',
|
||||
'',
|
||||
'## Decisions',
|
||||
'',
|
||||
'- [Phase 1]: Old decision',
|
||||
'- [Phase 3]: Older decision',
|
||||
'- [Phase 9]: Recent decision',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
|
||||
// Pre-fix: { pruned: false, reason: 'Only 0 phases — nothing to prune...' }.
|
||||
// Post-fix: prune engages and reports a real cutoff_phase (10 - 3 = 7).
|
||||
assert.strictEqual(out.pruned, false, 'dry-run must report pruned:false');
|
||||
assert.ok(out.reason === undefined || !/Only 0 phases/i.test(String(out.reason)),
|
||||
`prune must not bail with "Only 0 phases" on template STATE.md; got reason=${JSON.stringify(out.reason)}`);
|
||||
assert.strictEqual(out.cutoff_phase, 7,
|
||||
`cutoff_phase must be 7 (current 10 - keep-recent 3); got ${JSON.stringify(out.cutoff_phase)}`);
|
||||
});
|
||||
|
||||
test('prune still engages when "Current Phase:" IS present (no regression)', () => {
|
||||
writeStateMd(tmpDir, [
|
||||
'# Session State',
|
||||
'',
|
||||
'**Current Phase:** 10',
|
||||
'',
|
||||
'## Decisions',
|
||||
'',
|
||||
'- [Phase 1]: Old decision',
|
||||
'- [Phase 9]: Recent decision',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const result = runGsdTools('state prune --keep-recent 3 --dry-run', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const out = JSON.parse(result.output);
|
||||
assert.strictEqual(out.cutoff_phase, 7);
|
||||
});
|
||||
});
|
||||
89
tests/bug-1761-state-sync-wrong-progress.test.cjs
Normal file
89
tests/bug-1761-state-sync-wrong-progress.test.cjs
Normal file
@@ -0,0 +1,89 @@
|
||||
'use strict';
|
||||
// Regression test for issue #1761 — `state sync` silently writes wrong progress
|
||||
// when ROADMAP lacks versioned milestone headings.
|
||||
//
|
||||
// ADR-1769 Phase 7 fix: when getMilestonePhaseFilter().missingExplicitVersion is
|
||||
// true (the current milestone cannot be bounded to a versioned phase set), the
|
||||
// sync transition leaves Progress untouched (percent=null) rather than silently
|
||||
// computing/writing values off a fallback milestone.
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
function buildStateWithProgress({ percent = 50 } = {}) {
|
||||
const barWidth = 10;
|
||||
const filled = Math.round((percent / 100) * barWidth);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
return [
|
||||
'---',
|
||||
'gsd_state_version: 1.0',
|
||||
'milestone: v1.0',
|
||||
'milestone_name: Test',
|
||||
'current_phase: "3"',
|
||||
'status: executing',
|
||||
'progress:',
|
||||
' total_phases: 10',
|
||||
' completed_phases: 5',
|
||||
` percent: ${percent}`,
|
||||
'---',
|
||||
'',
|
||||
'# GSD State',
|
||||
'',
|
||||
'**Current Phase:** 3',
|
||||
'**Total Plans in Phase:** 4',
|
||||
'**Current Plan:** 2',
|
||||
'**Status:** Executing Phase 3',
|
||||
'**Last Activity:** 2026-06-20',
|
||||
`**Progress:** [${bar}] ${percent}%`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ROADMAP with an UNVERSIONED milestone heading (no vX.Y) — the #1761 trigger.
|
||||
function buildUnversionedRoadmap(numPhases) {
|
||||
const lines = ['# ROADMAP', '', '## Milestone 1: Test Milestone', ''];
|
||||
for (let i = 1; i <= numPhases; i++) {
|
||||
lines.push(`### Phase ${i}: phase-${i}`);
|
||||
lines.push('');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function readBodyProgress(statePath) {
|
||||
const m = fs.readFileSync(statePath, 'utf-8').match(/\*\*Progress:\*\*\s*(.*)/);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
describe('#1761: state sync leaves Progress untouched when milestone is unbounded', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('sync does NOT rewrite the Progress bar when ROADMAP lacks a versioned milestone heading', () => {
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
fs.writeFileSync(statePath, buildStateWithProgress({ percent: 50 }));
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), buildUnversionedRoadmap(10));
|
||||
|
||||
// Seed disk: 2 of 10 phases fully summarized → if sync naively recomputes,
|
||||
// it would write ~20%, clobbering the curated 50% (#1761).
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
const dir = path.join(phasesDir, String(i).padStart(2, '0'));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '01-PLAN.md'), '# Plan\n');
|
||||
fs.writeFileSync(path.join(dir, '01-SUMMARY.md'), '# Summary\n');
|
||||
}
|
||||
|
||||
const before = readBodyProgress(statePath);
|
||||
const result = runGsdTools('state sync', tmpDir);
|
||||
assert.ok(result.success, `state sync failed: ${result.error}`);
|
||||
const after = readBodyProgress(statePath);
|
||||
|
||||
assert.strictEqual(after, before,
|
||||
`Progress must be left untouched when the milestone is unbounded; before=${JSON.stringify(before)} after=${JSON.stringify(after)} (#1761)`);
|
||||
});
|
||||
});
|
||||
@@ -1085,3 +1085,128 @@ describe('ADR-1769 Phase 6: patch transition — field updates', () => {
|
||||
assert.deepStrictEqual(result.data && result.data.updated, ['stopped_at']);
|
||||
});
|
||||
});
|
||||
|
||||
// ADR-1769 Phase 7: update, prune, sync
|
||||
|
||||
describe('ADR-1769 Phase 7: update transition — single body field', () => {
|
||||
const deps = { clock: fixedClock, progressProvider: noProgress };
|
||||
|
||||
test('replaces a body field and reports updated:true', () => {
|
||||
const input = '# Project State\n\n**Status:** Planning\n**Current Plan:** 2\n';
|
||||
const result = transitionCore(input, { kind: 'update', field: 'Current Plan', value: '3' }, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Current Plan'), '3');
|
||||
assert.strictEqual(result.data && result.data.updated, true);
|
||||
});
|
||||
|
||||
test('reports updated:false when the field is absent', () => {
|
||||
const input = '# Project State\n\n**Status:** Planning\n';
|
||||
const result = transitionCore(input, { kind: 'update', field: 'Nonexistent', value: 'x' }, deps);
|
||||
assert.strictEqual(result.content, input);
|
||||
assert.strictEqual(result.data && result.data.updated, false);
|
||||
});
|
||||
|
||||
test('preserves frontmatter across the body update', () => {
|
||||
const input = ['---', 'status: planning', '---', '', '# State', '', '**Status:** Planning', ''].join('\n');
|
||||
const result = transitionCore(input, { kind: 'update', field: 'Status', value: 'Paused' }, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Status'), 'Paused');
|
||||
assert.ok(/^---\r?\n[\s\S]*?\r?\n---/.test(result.content));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADR-1769 Phase 7: prune transition — section pruning', () => {
|
||||
const deps = { clock: fixedClock, progressProvider: noProgress };
|
||||
|
||||
test('archives Decisions entries at or below the cutoff phase', () => {
|
||||
const input = [
|
||||
'# Session State',
|
||||
'',
|
||||
'## Decisions',
|
||||
'',
|
||||
'- [Phase 1]: Old',
|
||||
'- [Phase 3]: Older',
|
||||
'- [Phase 9]: Recent',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(input, { kind: 'prune', cutoff: 7 }, deps);
|
||||
const archived = (result.data && result.data.archivedSections) || [];
|
||||
assert.strictEqual(result.content.includes('[Phase 1]: Old'), false);
|
||||
assert.strictEqual(result.content.includes('[Phase 3]: Older'), false);
|
||||
assert.ok(result.content.includes('[Phase 9]: Recent'));
|
||||
const decisions = archived.find((s) => s.section === 'Decisions');
|
||||
assert.ok(decisions, 'Decisions archive entry must exist');
|
||||
assert.strictEqual(decisions.count, 2);
|
||||
});
|
||||
|
||||
test('archives Performance Metrics table rows at or below the cutoff', () => {
|
||||
const input = [
|
||||
'# State',
|
||||
'',
|
||||
'## Performance Metrics',
|
||||
'',
|
||||
'| Phase | Plans | Total | Avg/Plan |',
|
||||
'| --- | --- | --- | --- |',
|
||||
'| 1 | 4 | 8 | 2 |',
|
||||
'| 9 | 2 | 4 | 2 |',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(input, { kind: 'prune', cutoff: 7 }, deps);
|
||||
assert.ok(result.content.includes('| 9 | 2 | 4 | 2 |'), 'phase-9 row must remain');
|
||||
assert.strictEqual(result.content.includes('| 1 | 4 | 8 | 2 |'), false, 'phase-1 row must be archived');
|
||||
assert.ok(result.content.includes('| Phase | Plans |'), 'header row preserved');
|
||||
});
|
||||
|
||||
test('no-op when nothing is old enough (totalPruned === 0)', () => {
|
||||
const input = '# State\n\n## Decisions\n\n- [Phase 9]: Recent\n';
|
||||
const result = transitionCore(input, { kind: 'prune', cutoff: 7 }, deps);
|
||||
assert.strictEqual(result.content, input);
|
||||
assert.strictEqual((result.data && result.data.totalPruned) || 0, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADR-1769 Phase 7: sync transition — body writes + #1761', () => {
|
||||
const deps = { clock: fixedClock, progressProvider: noProgress };
|
||||
|
||||
test('updates Total Plans in Phase + Progress bar + Last Activity when bounded', () => {
|
||||
const input = [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Total Plans in Phase:** 2',
|
||||
'**Last Activity:** 2026-06-20',
|
||||
'**Progress:** [████░░░░░░] 40%',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(
|
||||
input,
|
||||
{ kind: 'sync', totalPlansInPhase: 5, percent: 60 },
|
||||
deps,
|
||||
);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '5');
|
||||
assert.strictEqual(stateExtractField(result.content, 'Last Activity'), '2026-06-27');
|
||||
assert.ok(/\[██████░░░░\] 60%/.test(result.content), 'Progress bar must be 60%');
|
||||
});
|
||||
|
||||
test('#1761: leaves Progress untouched when percent is null (milestone unbounded)', () => {
|
||||
const input = [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Total Plans in Phase:** 2',
|
||||
'**Last Activity:** 2026-06-20',
|
||||
'**Progress:** [█████░░░░░] 50%',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = transitionCore(
|
||||
input,
|
||||
{ kind: 'sync', totalPlansInPhase: 5, percent: null },
|
||||
deps,
|
||||
);
|
||||
// Total Plans + Last Activity still advance; Progress bar is left untouched.
|
||||
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '5');
|
||||
assert.ok(/\[█████░░░░░\] 50%/.test(result.content), 'Progress bar must be unchanged when percent is null');
|
||||
});
|
||||
|
||||
test('skips Total Plans write when totalPlansInPhase is null', () => {
|
||||
const input = '# Project State\n\n**Total Plans in Phase:** 2\n**Progress:** 40%\n';
|
||||
const result = transitionCore(input, { kind: 'sync', totalPlansInPhase: null, percent: null }, deps);
|
||||
assert.strictEqual(stateExtractField(result.content, 'Total Plans in Phase'), '2');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user