* refactor(#3471): one enforcement point for the empty case, and reports that match the disk Implements ADR-3408 section 8.5 and section 8.4's residue (folded in when Phase 3 closed as subsumed). Four items, and two findings the design did not predict. FINDING 1 — the guards could not simply be deleted, as the design instructed. state sync and REGENERATE_STATE never run applyStatePreservation at all, so those six conditions were their ONLY empty-field fallback. A baseline probe on the unedited tree confirmed unconditional deletion drops current_phase, current_phase_name, current_plan, stopped_at and paused_at from a blank-body STATE.md on state sync — breaking the byte-identical requirement section 8.3 grants those two sanctioned-permanent exceptions. They are now GATED, not deleted: on for the exceptions, off for the write seam, where an empty derived value finally reaches the executor unmolested. FINDING 2, the more serious one — there was a FOURTH encoding of this policy. The pre-existing #2202 unknown-key carry-forward loop independently restored the same six fields whenever derivedFm lacked the key, completely neutralizing the fix. It is named nowhere in the ADR, the design, or three prior phases. It was found only because a probe that should have passed did not: the first attempt reported divergedFields: [] and silently restored both fields, reproducing the exact bug this phase exists to close. That is worth stating plainly. This epic's thesis is 'policy declared in one table, enforcement hand-rolled per call site.' The final phase found one more call site than anyone had counted — which is the fourth consecutive time a copy count in this epic proved to be a lower bound. Also: divergedFields could only observe fields the executor actively RESTORED, by diffing postFm. A discard-to-empty is absent both before and after, so it was invisible. A second pass now reports it, which is what makes section 8.5's 'preservation is visible' true for the delete-the-body-line case rather than aspirational. cmdPhaseComplete now reports what it preserved — #3374 was filed against that command and its complaint was warnings: [], silence. cmdStateJson's private third copy of the guards is routed onto the executor's preserve-when-unchanged rule. A read is definitionally not a write, so the #1230 delta is 'unchanged' and curated wins over a stale annotation. shouldPreserveExistingProgress is a different rule and is untouched. Report reconciliation is ONE shared helper across seven commands, not five copies of fix(#3351)'s block. Five copies of a reconciliation is precisely the shape this epic removes, and introducing it in the final phase would have been a poor joke. Both untraced commands were traced rather than assumed: cmdStatePlannedPhase matched cmdStateBeginPhase exactly; cmdStateCompletePhase turned out to be a different legacy hand-rolled path reporting a mix of field names AND a section name, where the naive helper would have dropped 'Current Position' as a false negative every time. * test(#3471): characterization coverage for one enforcement point and reconciled reports Matrix sections A-E, asserted at the consumer's output per ADR-3180 Decision 4(b)/(c) — this phase owes Decision 5's outcome metric, the one the drift guard's zero may never be reported without. Three walls matter more than the new coverage: A2 is SIX separately named tests, one per gated guard, not one parameterised assertion over a list. A list is trivially shortened later; six named tests are not, and six guards is exactly where a field gets silently dropped. A6 pins what Phases 1-3 already fixed — non-empty stale body, delta unchanged, losing to fresher curated frontmatter, with the divergence reported. If A6 reddens, this phase broke the thing the epic was for. D1/D2 pin state sync byte-identical. The implementation had to GATE the six guards rather than delete them precisely because state sync has no executor, and a baseline probe showed unconditional deletion drops five fields. Nothing else in the suite would notice that regression. E6 covers #3345's direction — a field preservation restored that the intent never named IS reported. Nothing has ever tested that direction. Assertions were empirically verified against the compiled lib and the real CLI before being written, since the suite cannot be executed locally. That caught two type bugs in the draft: fm.current_phase after a quoted-YAML round-trip is the string '5', not the number 5. E5 is recorded as structurally unreachable rather than weakened or faked. Those four commands report body Title-Case labels, which cannot string-collide with a frontmatter snake_case key the way cmdStatePatch's arbitrary field names can — which is why fix(#3351) targeted only cmdStatePatch. Testing it directly would need reconcileReportedFields exported from private scope; the helper is exercised through E6 and all seven commands instead. * docs(#3471): amend ADR-3408 section 8.5 — a fourth enforcement point, and guards that could not be deleted Amendment 3. The contract held; two of section 8.5's own statements did not. It said the six empty-only guards are DELETED. They cannot be. writeStateMd is the sole path for both section 8.3 sanctioned-permanent exceptions and never runs applyStatePreservation, so those guards were their only empty-field fallback. A baseline probe on the unedited tree confirmed unconditional deletion drops five fields from a blank-body STATE.md on state sync, breaking the byte-identical guarantee section 8.3 grants it. They are gated instead. It also mis-located cmdStateJson's guards, describing them as living in syncStateFrontmatter. They were a separate private copy on the read path with no delta check at all, so a stale body annotation always beat fresher curated frontmatter in state.json — #3395's shape entirely outside the write seam. THE FINDING: a fourth enforcement point nobody had counted. The pre-existing #2202 unknown-key carry-forward loop independently restored the same six fields, silently neutralizing the fix. It is named nowhere in this ADR, in the phase design, or in three prior phases, and was found only because a probe that should have passed did not. Fourth consecutive time a copy count in this epic proved a lower bound: 2 write-seam bypasses became 4, three preservation encodings became four, and the estimate was wrong every time. ADR-3180's standing rule has earned itself in every phase — read the code, not the write-up. Records the Row 2 decision (a discard-to-empty wins per the delta rule and is reported, not silent — the sharpest Hyrum exposure in the epic), section 8.4's residue landing as ONE shared reconcileReportedFields across seven commands rather than five copies, and the parity assertion added because FRONTMATTER_KEY_TO_BODY_LABEL was itself a second table that failed silently — this epic's shape in miniature, in its final phase. * fix(#3471): repair four regressions the checkpoint caught Checkpoint returned 16 failures of 34389: six real regressions in pre-existing tests, plus seven of my own test bugs. My hypothesis was wrong and is recorded as such. I predicted the #2202 carry-forward skip was the cause, reasoning it had removed a load-bearing fallback the way the six guards nearly were. It was not implicated in any of the six. Three unrelated causes: #2111 — current_phase came back undefined from milestone complete, which is the epic's own defect class reintroduced by its final phase. Root cause is Row 2 working exactly as designed: milestoneCompleteCore rewrites the body Phase: line to a closure message, so current_phase's #1230 delta reads CHANGED and the new rule correctly discards the curated value. The transition never declared any intent to touch that field. Fixed by re-asserting current_phase and current_phase_name through authoritativeFm — the existing #2736 mechanism beginPhaseCore and completePhaseCore already use — rather than by weakening Row 2, which A5 pins. That interaction is worth naming: a rule that keys on 'did this write change the body source' will fire on a transition that moves the body line for an entirely unrelated reason. The design did not anticipate it. #1264 / #3242 / the state.patch progress report — reconcileReportedFields folded EVERY divergedFields entry into updated, including preserve-always progress restores no caller asked about. Now scoped to preserve-when-unchanged rows only. #1162 / case-insensitive table fields — valueOf checked frontmatter before body, so a lowercase table field name exact-matched the lowercase frontmatter key sync always derives, comparing stale pre-sync body text against a post-sync frontmatter enum. Flipped to body-first. That last one is the SAME lesson as Phase 2's patchCore, recurring in a different function two phases later: in this model the body is authoritative and frontmatter is the projection, so a name that could mean either resolves body-first. Twice now. Test bugs: a stray unused parameter shifted every argument at six call sites, so body arrived undefined; and A4 compared nested progress scalars against numbers when extractFrontmatter returns raw YAML strings. The string-vs-number YAML round-trip has now been caught three times in this phase alone. * test(#3471): one helper for the progress coercion that bit four times A2f failed on the string-vs-number YAML round-trip: extractFrontmatter returns nested progress scalars as raw YAML strings, so a comparison against numeric literals can never pass. This is the FOURTH time this exact class has been caught in this phase — twice during test authoring, once as A4 in the previous checkpoint, now as A2f. Patching it a fourth time by hand would guarantee a fifth. Added numericProgress() with a comment saying why it exists, and routed every progress-reading assertion in the #3471 block through it. Swept the block: C3 needed no change, because cmdStateJson's output already runs through normalizeProgressNumbers. Deliberately NOT shared with frontmatter.test.cjs's readPersistedProgress: that one is path-based and re-reads from disk, while these assert on an in-memory string that is never written. Sharing would have meant either a disk round-trip these tests do not do, or duplicating half the helper — so the coercion pattern is mirrored locally and the reason recorded, rather than manufacturing a dependency to satisfy the letter of consolidation. * chore(#3471): backfill pr number in changeset fragment --------- Co-authored-by: sim <sim@local>
1192 lines
60 KiB
TypeScript
1192 lines
60 KiB
TypeScript
/**
|
|
* Milestone — Milestone and requirements lifecycle operations.
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/milestone.cjs collapsed to
|
|
* a TypeScript source of truth, compiled by tsc to a gitignored .cjs at the same
|
|
* require() path. Behaviour preserved byte-for-behaviour; only types are added.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
|
import planningWorkspace = require('./planning-workspace.cjs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
import frontmatterMod = require('./frontmatter.cjs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module
|
|
import stateMod = require('./state.cjs');
|
|
import { platformWriteSync, platformReadSync, platformEnsureDir, execGit, retryRenameSync } from './shell-command-projection.cjs';
|
|
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
|
import { realClock } from './clock.cjs';
|
|
import { transitionCore } from './state-transition.cjs';
|
|
import { writeSetComplete } from './write-set.cjs';
|
|
import type { WriteSet } from './write-set.cjs';
|
|
import { updateTableCell } from './markdown-table.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import ioMod = require('./io.cjs');
|
|
const { output, error } = ioMod;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import phaseIdMod = require('./phase-id.cjs');
|
|
const { normalizePhaseName, matchPhaseDirs, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId } = phaseIdMod;
|
|
import { escapeRegex } from './pattern.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import roadmapParserMod = require('./roadmap-parser.cjs');
|
|
const {
|
|
getMilestonePhaseFilter,
|
|
extractCurrentMilestone,
|
|
getMilestoneInfo,
|
|
sliceMilestoneWindow,
|
|
} = roadmapParserMod;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import planningScopeMod = require('./planning-scope.cjs');
|
|
const { SCOPE } = planningScopeMod;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import coreUtilsMod = require('./core-utils.cjs');
|
|
const { extractOneLinerFromBody, countMatchedSummaries } = coreUtilsMod;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
|
import planScanMod = require('./plan-scan.cjs');
|
|
const { scanPhasePlans } = planScanMod;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
|
|
import phaseLocatorMod = require('./phase-locator.cjs');
|
|
const { listMilestonePhaseDirs } = phaseLocatorMod;
|
|
const { planningPaths } = planningWorkspace;
|
|
const { extractFrontmatter } = frontmatterMod;
|
|
// ADR-3408 §8.3 / #3469: `writeStateMd` gets sync and NO preservation — the
|
|
// same #3374-shaped exposure the milestone-complete write used to carry (a
|
|
// stale body value silently clobbering fresher frontmatter, with no
|
|
// divergence signal). Routed through the single write-seam composition
|
|
// (`syncAndPreserveStateMd`) instead, under `withStateLock` — see
|
|
// `cmdMilestoneComplete`'s own STATE.md-update block for the full rationale.
|
|
const { syncAndPreserveStateMd, withStateLock } = stateMod;
|
|
|
|
// #2288 security: a milestone version label becomes a filesystem directory
|
|
// component (`milestones/<label>-phases/`) into which phase directories are
|
|
// MOVED. Any label used as a path segment must be a safe version token —
|
|
// letters/digits/'.'/'-'/'_', leading alphanumeric, no path separators and no
|
|
// `..` (the leading-alphanumeric anchor rejects a bare `..`). This gates both
|
|
// the caller-supplied `--archive-version` override and the STATE.md-derived
|
|
// live-read value, so a crafted value cannot escape `.planning/milestones/`.
|
|
const ARCHIVE_VERSION_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
|
|
interface MilestoneCompleteOptions {
|
|
name?: string;
|
|
force?: boolean;
|
|
archivePhases?: boolean;
|
|
dryRun?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Scope an `updateTableCell` call to the `## Traceability` (or
|
|
* `## Traceability Status`) heading's own section — up to the next H1/H2
|
|
* heading — instead of handing it the WHOLE REQUIREMENTS.md content.
|
|
*
|
|
* F1 (#2245 review, BLOCKER): `updateTableCell` binds to the FIRST GFM table
|
|
* found in whatever text it is given. The shipped requirements template
|
|
* (gsd-core/templates/requirements.md) puts an `## Out of Scope` table
|
|
* (`| Feature | Reason |`, no `Status` column) BEFORE `## Traceability` — so
|
|
* an unscoped whole-file call targets the Out-of-Scope table instead, fails
|
|
* with `{ok:false, reason:'unknown column: Status'}`, and the real
|
|
* Traceability row is never flipped, while the checkbox surface still flips
|
|
* and the command reports success (the #2140 silent-divergence class one
|
|
* level deeper). Mirrors phase.cts's `editProgressHeadingSlice` scoping of
|
|
* `## Progress` writes to that heading's own slice.
|
|
*
|
|
* Falls back to running `updateTableCell` against the whole `text` when no
|
|
* `## Traceability` heading exists — matching the previous (unscoped)
|
|
* behaviour for a REQUIREMENTS.md whose traceability table sits under some
|
|
* other heading, or with no heading at all (never worse than before this fix).
|
|
*/
|
|
function updateTraceabilityCell(
|
|
text: string,
|
|
match: (row: Record<string, string>, index: number) => boolean,
|
|
column: string,
|
|
newValue: string | ((current: string) => string),
|
|
): ReturnType<typeof updateTableCell> {
|
|
const headingMatch = text.match(/^##[ \t]+Traceability(?:[ \t]+Status)?\b/im);
|
|
if (!headingMatch || headingMatch.index === undefined) {
|
|
return updateTableCell(text, match, column, newValue);
|
|
}
|
|
const headingOffset = headingMatch.index;
|
|
const before = text.slice(0, headingOffset);
|
|
const fromHeading = text.slice(headingOffset);
|
|
const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
|
|
const scoped = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
|
|
const after = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
|
|
|
|
const result = updateTableCell(scoped, match, column, newValue);
|
|
if (!result.ok) return result;
|
|
return { ok: true, value: before + result.value + after };
|
|
}
|
|
|
|
function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: boolean): void {
|
|
if (!reqIdsRaw || reqIdsRaw.length === 0) {
|
|
error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02');
|
|
}
|
|
|
|
// Accept comma-separated, space-separated, or bracket-wrapped: [REQ-01, REQ-02]
|
|
const reqIds = reqIdsRaw
|
|
.join(' ')
|
|
.replace(/[\[\]]/g, '')
|
|
.split(/[,\s]+/)
|
|
.map((r) => r.trim())
|
|
.filter(Boolean);
|
|
|
|
if (reqIds.length === 0) {
|
|
error('no valid requirement IDs found');
|
|
}
|
|
|
|
const reqPath = planningPaths(cwd).requirements;
|
|
if (!fs.existsSync(reqPath)) {
|
|
output({ updated: false, reason: 'REQUIREMENTS.md not found', ids: reqIds }, raw, 'no requirements file');
|
|
return;
|
|
}
|
|
|
|
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
|
const updated: string[] = [];
|
|
const alreadyComplete: string[] = [];
|
|
const notFound: string[] = [];
|
|
// #2140: IDs reconciled on the checkbox surface only — a traceability table
|
|
// exists but has no row for the ID. Without this bucket the payload for a
|
|
// partial reconcile is byte-identical to a full one, and audit-milestone (which
|
|
// reads the table) still sees Pending while the CLI reported success.
|
|
const tableUnmatched: string[] = [];
|
|
|
|
// A traceability table is present if the file has a requirement-ID column
|
|
// header: "Requirement", "Requirement ID", or "REQ-ID" (#2769/#2203) — kept
|
|
// in sync with the positional first-cell rowMatch/hasRow below so a
|
|
// REQ-ID-headed table (the real-world format) participates in the
|
|
// write-set and the #2140 drift check below, not just the "Requirement"
|
|
// case. A REQUIREMENTS.md with no such table is legitimate (mid-roadmap),
|
|
// so a missing row only counts as drift when a table actually exists.
|
|
const hasTable = /^\|\s*(?:Requirement(?:\s*ID)?|REQ[-\s]?ID)\s*\|/im.test(reqContent);
|
|
|
|
// ADR-2143 §6 per-surface write-set, tracked PER requirement ID: a
|
|
// multi-ID batch must not OR one ID's surface outcome into another's —
|
|
// that is the exact #2140 class one level up (an ID whose traceability
|
|
// row is absent/unmatched must not have its partial write masked by a
|
|
// different ID in the same invocation that fully reconciled). Reported
|
|
// additively as `write_set` below — it does not change the existing
|
|
// marked_complete/already_complete/not_found/table_unmatched/updated
|
|
// computation, which stays byte-for-behaviour identical (#2140's tactical
|
|
// fix already surfaces the checkbox-only-partial-write case via
|
|
// table_unmatched; this only adds the structured ADR-2143 shape on top).
|
|
const writeSet: WriteSet = [];
|
|
|
|
for (const reqId of reqIds) {
|
|
const reqEscaped = escapeRegex(reqId);
|
|
|
|
// Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
|
// Use replace() + compare to avoid the test()+replace() global regex
|
|
// lastIndex bug where test() advances state and replace() misses matches.
|
|
// (#2788 defect 2: the flip is CONDITIONAL — when a traceability row EXISTS
|
|
// for this ID but its Status write is rejected, the checkbox must NOT flip,
|
|
// so the two surfaces cannot silently diverge. The row-write outcome below
|
|
// gates whether the flip is kept.)
|
|
const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
|
|
const beforeCheckbox = reqContent;
|
|
const afterCheckbox = reqContent.replace(checkboxPattern, '$1x$2');
|
|
const checkboxFlipped = afterCheckbox !== beforeCheckbox;
|
|
if (checkboxFlipped) reqContent = afterCheckbox;
|
|
|
|
// Surface 2 — the traceability row: | <REQ-ID> | Phase N | Pending | → ... Complete |
|
|
// via the markdown-table seam (ADR-2143 §7) — supersedes the prior ordinal
|
|
// regex. Match the row by its FIRST cell's value (the requirement-ID column)
|
|
// regardless of that column's HEADER name — real tables head it `REQ-ID`,
|
|
// others `Requirement` (#2769/#2203); this mirrors the prior regex's first-cell
|
|
// `\|\s*<id>\s*\|` anchor. Object.values(row) is in header order so [0] is the
|
|
// first column. Case-insensitive (mirrors the prior regex's 'i' flag).
|
|
const rowMatch = (row: Record<string, string>): boolean =>
|
|
(Object.values(row)[0] ?? '').trim().toLowerCase() === reqId.toLowerCase();
|
|
// Ragged-tolerant (#2245 Blocker 2): drive the write purely off
|
|
// updateTableCell's own tolerant row scan — a DIFFERENT requirement's row
|
|
// elsewhere in the same table having a mismatched cell count must never
|
|
// silently no-op THIS requirement's write. The "only flip Pending ->
|
|
// Complete" gate is folded into the newValue callback so one
|
|
// updateTableCell call both probes the current value and writes.
|
|
let tableHit = false;
|
|
const tableUpdate = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
|
|
// #2788: accept `Gaps Found` as a forward input too — `revert-phase` (the
|
|
// documented gaps_found response) leaves a row stranded at Gaps Found with
|
|
// no inverse; a genuinely-satisfied requirement must be able to reach
|
|
// Complete again via mark-complete, or the milestone is blocked forever.
|
|
if (/^(pending|gaps found)$/i.test(current.trim())) {
|
|
tableHit = true;
|
|
return ' Complete ';
|
|
}
|
|
return current;
|
|
});
|
|
if (tableUpdate.ok) {
|
|
reqContent = tableUpdate.value;
|
|
}
|
|
|
|
// #2788 defect 2: if a row EXISTS for this ID but its Status write was
|
|
// rejected (e.g. the row reads `Blocked`, which mark-complete does not
|
|
// accept), roll the checkbox back so the checkbox and the row cannot
|
|
// silently diverge. The checkbox and the row are two representations of the
|
|
// same fact; flipping one while the other rejects the write is the lie.
|
|
let checkboxHit = checkboxFlipped;
|
|
const rowExistsProbe = tableUpdate; // ok === a row matched (probes existence)
|
|
if (checkboxFlipped && rowExistsProbe.ok && !tableHit) {
|
|
reqContent = beforeCheckbox;
|
|
checkboxHit = false;
|
|
}
|
|
|
|
// ADR-2143 §6 per-ID write-set entries: this ID's checkbox surface is
|
|
// always tracked; the traceability surface is tracked only when the file
|
|
// has a traceability table at all (same `hasTable` gate the existing
|
|
// required-surface logic below uses) — omitted entirely, not a false
|
|
// `applied:false`, when no table is required of this file.
|
|
writeSet.push({ requirement: reqId, surface: 'checkbox', applied: checkboxHit });
|
|
if (hasTable) {
|
|
writeSet.push({ requirement: reqId, surface: 'traceability', applied: tableHit });
|
|
}
|
|
|
|
// Coverage of the traceability surface for this ID (computed after any flip).
|
|
// hasRow keys on the ID's FIRST cell (the requirement-ID column, by position —
|
|
// see rowMatch above) so a bare mention of the ID in a non-traceability table
|
|
// does not masquerade as a real row.
|
|
// Ragged-tolerant (#2245 Blocker 2): same reasoning as the write above — a
|
|
// sibling row's raggedness must not blind this classification to a row
|
|
// that genuinely exists. Probe via a no-op updateTableCell write (its own
|
|
// tolerant scan) instead of findTableWithColumns (whole-table parse gate).
|
|
let currentStatusCell = '';
|
|
const statusProbe = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
|
|
currentStatusCell = current;
|
|
return current;
|
|
});
|
|
const hasRow = statusProbe.ok;
|
|
const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent);
|
|
const doneTable = Boolean(hasRow && /^complete$/i.test(currentStatusCell.trim()));
|
|
|
|
// #2788 defect 2: when a traceability table exists AND this ID has a row in
|
|
// it, `updated`/`marked_complete` must reflect the ROW moving, not a
|
|
// checkbox-only flip. Otherwise (`table_unmatched` — no row for this ID, or
|
|
// no table at all) the checkbox flip is a legitimate partial reconcile / the
|
|
// sole completion surface, so the #2140 OR semantics are preserved.
|
|
const rowExists = hasTable && hasRow;
|
|
const idUpdated = rowExists ? tableHit : (checkboxHit || tableHit);
|
|
if (idUpdated) {
|
|
updated.push(reqId);
|
|
} else if (doneTable || (doneCheckbox && !hasTable)) {
|
|
// Fully reconciled: the table row is Complete, OR the checkbox is done and
|
|
// there is no table to reconcile against. (A [x] checkbox with a Pending or
|
|
// absent row is NOT fully reconciled when a table exists — #2140.)
|
|
alreadyComplete.push(reqId);
|
|
} else if (!doneCheckbox && !doneTable) {
|
|
notFound.push(reqId);
|
|
}
|
|
// else: doneCheckbox && hasTable && !doneTable — partially reconciled. It is
|
|
// neither updated, already_complete, nor not_found; the table_unmatched bucket
|
|
// below carries the truthful partial-reconcile signal.
|
|
|
|
// Surface traceability drift: checkbox reconciled (this run or before) but the
|
|
// table has no row for this ID. This is what makes a partial reconcile
|
|
// distinguishable from a full one (#2140).
|
|
if (hasTable && doneCheckbox && !hasRow) {
|
|
tableUnmatched.push(reqId);
|
|
}
|
|
}
|
|
|
|
if (updated.length > 0) {
|
|
platformWriteSync(reqPath, reqContent);
|
|
}
|
|
|
|
// ADR-2143 §6: `writeSet` above already carries one WriteOutcome per
|
|
// (requirement, surface) this invocation could have written to — per ID,
|
|
// not ORed across the batch. `write_set` and `write_set_complete` are
|
|
// additive: they do not replace or gate `updated` / `marked_complete` /
|
|
// `already_complete` / `not_found` / `table_unmatched`, which remain
|
|
// computed exactly as before (see #2140 note above — that fix already
|
|
// surfaces a checkbox-only partial write via `table_unmatched`;
|
|
// `write_set_complete` is a structured, ADR-2143-shaped read of the SAME
|
|
// per-surface, per-ID facts, `false` if ANY id's ANY required surface did
|
|
// not apply, since `writeSetComplete` requires EVERY entry to have
|
|
// applied, never an OR across surfaces OR across IDs).
|
|
output(
|
|
{
|
|
updated: updated.length > 0,
|
|
marked_complete: updated,
|
|
already_complete: alreadyComplete,
|
|
not_found: notFound,
|
|
table_unmatched: tableUnmatched,
|
|
total: reqIds.length,
|
|
write_set: writeSet,
|
|
write_set_complete: writeSetComplete(writeSet),
|
|
},
|
|
raw,
|
|
`${updated.length}/${reqIds.length} requirements marked complete`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* #2388: a requirement ID shared by more than one plan in a phase must not be
|
|
* handed to `cmdRequirementsMarkComplete` until every plan declaring it has
|
|
* finished (i.e. has a `*-SUMMARY.md`) — otherwise the ID reads `Complete` in
|
|
* REQUIREMENTS.md ~20 minutes before its sibling plans even run, and long
|
|
* before `verify_phase_goal` has a chance to catch a gap.
|
|
*
|
|
* Pure read-only gate: scans sibling `*-PLAN.md` files in the SAME phase
|
|
* directory as `planPath` (excluding `planPath` itself) and, for each
|
|
* candidate ID, blocks it only when a sibling plan ALSO declares that ID in
|
|
* its own `requirements:` frontmatter AND that sibling has no matching
|
|
* `*-SUMMARY.md` yet. An ID no sibling declares is never blocked — a
|
|
* single-plan (non-shared) ID is always `ready`, preserving immediate
|
|
* marking with no added latency (acceptance criterion 4). Does not read or
|
|
* write REQUIREMENTS.md itself; callers pass the `ready` subset on to
|
|
* `cmdRequirementsMarkComplete` (whose own flip semantics are untouched).
|
|
*/
|
|
function cmdRequirementsReadyIds(cwd: string, args: string[], raw: boolean): void {
|
|
const planPathArg = args[0];
|
|
if (!planPathArg) {
|
|
error('plan path required. Usage: requirements ready-ids <plan-path> REQ-01,REQ-02');
|
|
}
|
|
|
|
const reqIds = args
|
|
.slice(1)
|
|
.join(' ')
|
|
.replace(/[\[\]]/g, '')
|
|
.split(/[,\s]+/)
|
|
.map((r) => r.trim())
|
|
.filter(Boolean);
|
|
|
|
if (reqIds.length === 0) {
|
|
output({ ready: [], blocked: [], total: 0 }, raw, 'no requirement IDs provided');
|
|
return;
|
|
}
|
|
|
|
const planAbsPath = path.resolve(cwd, planPathArg);
|
|
// #3183: `planPathArg` may point at a root plan (`<phaseDir>/<n>-PLAN.md`)
|
|
// or a nested plan (`<phaseDir>/plans/PLAN-<n>.md`, #3139 layout) —
|
|
// scanPhasePlans always operates on the PHASE dir, so a nested plan needs
|
|
// one extra `dirname` to reach it, and its planFiles-relative identity
|
|
// carries the `plans/` prefix scanPhasePlans itself applies.
|
|
const isNestedPlanPath = path.basename(path.dirname(planAbsPath)) === 'plans';
|
|
const phaseDir = isNestedPlanPath ? path.dirname(path.dirname(planAbsPath)) : path.dirname(planAbsPath);
|
|
const currentRelative = isNestedPlanPath ? `plans/${path.basename(planAbsPath)}` : path.basename(planAbsPath);
|
|
|
|
// #3183: canonical plan/summary sets (root+nested, superseded-excluded)
|
|
// from the single owner, rather than a root-only hand-rolled readdirSync
|
|
// filter — a superseded sibling that still declares reqId with no SUMMARY
|
|
// used to block the ID forever (false-block); it is now excluded upstream.
|
|
const phaseScan = scanPhasePlans(phaseDir);
|
|
const siblingPlanFiles = phaseScan.planFiles.filter((f) => f !== currentRelative);
|
|
|
|
const parseFrontmatterReqIds = (content: string, sourcePath?: string): string[] => {
|
|
const fm = extractFrontmatter(content, sourcePath);
|
|
const fmReq = fm.requirements;
|
|
if (Array.isArray(fmReq)) return fmReq.map((r) => String(r).trim()).filter(Boolean);
|
|
if (typeof fmReq === 'string') {
|
|
return fmReq
|
|
.replace(/[\[\]]/g, '')
|
|
.split(/[,\s]+/)
|
|
.map((r) => r.trim())
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const ready: string[] = [];
|
|
const blocked: string[] = [];
|
|
|
|
for (const reqId of reqIds) {
|
|
let blockedBySibling = false;
|
|
|
|
for (const siblingFile of siblingPlanFiles) {
|
|
const siblingPath = path.join(phaseDir, siblingFile);
|
|
let siblingContent: string;
|
|
try {
|
|
siblingContent = fs.readFileSync(siblingPath, 'utf-8');
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const siblingReqIds = parseFrontmatterReqIds(siblingContent, siblingPath);
|
|
const siblingDeclaresId = siblingReqIds.some((id) => id.toLowerCase() === reqId.toLowerCase());
|
|
if (!siblingDeclaresId) continue;
|
|
|
|
// Sibling declares the SAME ID — it must have finished (produced a
|
|
// SUMMARY) before this ID is ready to mark Complete. Canonical pairing
|
|
// via countMatchedSummaries (root+nested, all three naming forms)
|
|
// instead of a bespoke -PLAN.md→-SUMMARY.md regex swap.
|
|
const siblingHasSummary = countMatchedSummaries([siblingFile], phaseScan.summaryFiles) > 0;
|
|
if (!siblingHasSummary) {
|
|
blockedBySibling = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (blockedBySibling) blocked.push(reqId);
|
|
else ready.push(reqId);
|
|
}
|
|
|
|
output(
|
|
{ ready, blocked, total: reqIds.length },
|
|
raw,
|
|
`${ready.length}/${reqIds.length} requirement(s) ready to mark complete`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* #2388: revert this phase's own requirement IDs out of `Complete` when
|
|
* `verify_phase_goal` returns `gaps_found` — a gap verdict must not leave a
|
|
* premature `Complete` (from a shared ID's first-declaring plan, or from any
|
|
* other early write) sitting in REQUIREMENTS.md indefinitely.
|
|
*
|
|
* Mirrors `cmdRequirementsMarkComplete`'s two write surfaces in reverse:
|
|
* checkbox `[x]` -> `[ ]`, and traceability Status `Complete` -> `Gaps
|
|
* Found`. Phase-scoping is the CALLER's responsibility — this function only
|
|
* ever touches the exact IDs it is given, so a caller passing just this
|
|
* phase's own `phase_req_ids` never touches another phase's `Complete` row.
|
|
* Never call this on the pass path; it is `gaps_found`-only.
|
|
*/
|
|
function cmdRequirementsRevertPhase(cwd: string, reqIdsRaw: string[], raw: boolean): void {
|
|
const reqIds = (reqIdsRaw || [])
|
|
.join(' ')
|
|
.replace(/[\[\]]/g, '')
|
|
.split(/[,\s]+/)
|
|
.map((r) => r.trim())
|
|
.filter(Boolean);
|
|
|
|
if (reqIds.length === 0) {
|
|
output({ reverted: [], unchanged: [], total: 0 }, raw, 'no requirement IDs provided');
|
|
return;
|
|
}
|
|
|
|
const reqPath = planningPaths(cwd).requirements;
|
|
if (!fs.existsSync(reqPath)) {
|
|
output(
|
|
{ reverted: [], unchanged: reqIds, total: reqIds.length, reason: 'REQUIREMENTS.md not found' },
|
|
raw,
|
|
'no requirements file',
|
|
);
|
|
return;
|
|
}
|
|
|
|
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
|
const reverted: string[] = [];
|
|
const unchanged: string[] = [];
|
|
|
|
for (const reqId of reqIds) {
|
|
const reqEscaped = escapeRegex(reqId);
|
|
let idReverted = false;
|
|
|
|
// Surface 1 — checkbox: - [x] **REQ-ID** -> - [ ] **REQ-ID**
|
|
const checkboxPattern = new RegExp(`(-\\s*\\[)x(\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
|
|
const afterCheckbox = reqContent.replace(checkboxPattern, '$1 $2');
|
|
if (afterCheckbox !== reqContent) {
|
|
reqContent = afterCheckbox;
|
|
idReverted = true;
|
|
}
|
|
|
|
// Surface 2 — traceability row: Status Complete -> Gaps Found. Only
|
|
// flips a row currently reading Complete (mirrors mark-complete's own
|
|
// "only flip Pending -> Complete" gate, in reverse).
|
|
const rowMatch = (row: Record<string, string>): boolean =>
|
|
(Object.values(row)[0] ?? '').trim().toLowerCase() === reqId.toLowerCase();
|
|
let tableHit = false;
|
|
const tableUpdate = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
|
|
if (/^complete$/i.test(current.trim())) {
|
|
tableHit = true;
|
|
return ' Gaps Found ';
|
|
}
|
|
return current;
|
|
});
|
|
if (tableUpdate.ok) {
|
|
reqContent = tableUpdate.value;
|
|
if (tableHit) idReverted = true;
|
|
}
|
|
|
|
if (idReverted) reverted.push(reqId);
|
|
else unchanged.push(reqId);
|
|
}
|
|
|
|
if (reverted.length > 0) {
|
|
platformWriteSync(reqPath, reqContent);
|
|
}
|
|
|
|
output(
|
|
{ reverted, unchanged, total: reqIds.length },
|
|
raw,
|
|
`${reverted.length}/${reqIds.length} requirement(s) reverted from Complete`,
|
|
);
|
|
}
|
|
|
|
function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCompleteOptions, raw: boolean): void {
|
|
if (!version) {
|
|
error('version required for milestone complete (e.g., v1.0)');
|
|
}
|
|
// #2288 security: `version` is a CLI positional that is interpolated into
|
|
// multiple filesystem sinks below — `path.join(archiveDir, `${version}-ROADMAP.md`)`,
|
|
// `${version}-REQUIREMENTS.md`, `${version}-MILESTONE-AUDIT.md`, and the
|
|
// `${version}-phases` archive directory that phase dirs are MOVED into. Reject
|
|
// path separators / `..` here (same guard as `--archive-version`) so a crafted
|
|
// version cannot write or relocate content outside `.planning/milestones/`.
|
|
if (!ARCHIVE_VERSION_LABEL_RE.test(version)) {
|
|
error(`milestone complete: version "${version}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
|
|
}
|
|
|
|
const roadmapPath = planningPaths(cwd).roadmap;
|
|
const reqPath = planningPaths(cwd).requirements;
|
|
const statePath = planningPaths(cwd).state;
|
|
// #1911: derive the archive base from the workstream-aware planning root so
|
|
// `milestone complete --ws` archives into the workstream, not root. planningPaths(cwd).planning
|
|
// resolves to the workstream base when GSD_WORKSTREAM is set and to root .planning otherwise
|
|
// (flat mode is a no-op).
|
|
const planningBase = planningPaths(cwd).planning;
|
|
const milestonesPath = path.join(planningBase, 'MILESTONES.md');
|
|
const archiveDir = path.join(planningBase, 'milestones');
|
|
const phasesDir = planningPaths(cwd).phases;
|
|
const today = realClock.localToday();
|
|
const milestoneName = options.name || version;
|
|
// ADR-3408 §8.5 / #3469: "liberal but visible" — when the write-seam
|
|
// composition's preservation stage restores a curated frontmatter value
|
|
// over a disagreeing freshly-derived one, that divergence is surfaced
|
|
// here rather than silently absorbed (the direct answer to #3374's
|
|
// `warnings: []`). Structured (field + reason), not prose, so a caller can
|
|
// assert on the value rather than regex a rendered message.
|
|
//
|
|
// Named `preservation_warnings`, NOT `warnings`: `cmdPhaseComplete` already
|
|
// exposes a sibling field called `warnings` typed as prose `string[]`. Reusing
|
|
// that name here for a structured `{field, reason}[]` shape would be the
|
|
// "Generative Fix Divergence" anti-pattern — two sibling state commands
|
|
// sharing one field name with different element types. `warnings` stays
|
|
// one meaning (prose) repo-wide; this is a distinct, machine-assertable
|
|
// signal for ADR-3408 §8.5's "preservation is visible" rule. Check
|
|
// `preservation_warnings.length` rather than a companion `has_warnings`
|
|
// flag — that flag existed only to mirror `cmdPhaseComplete`'s channel,
|
|
// which this field intentionally does not claim to be.
|
|
const preservationWarnings: Array<{ field: string; reason: string }> = [];
|
|
|
|
// Scope stats and accomplishments to only the phases belonging to the
|
|
// current milestone's ROADMAP. Uses the shared filter from roadmap-parser.cjs
|
|
// (same logic used by cmdPhasesList and other callers).
|
|
// #3184 review finding: this scope computation + refusal MUST run BEFORE
|
|
// `platformEnsureDir(archiveDir)` below — a refused run (scope not COMPLETE,
|
|
// no --force) must be a true no-op on disk, and creating the archive
|
|
// directory first left an empty directory behind even on refusal.
|
|
const isDirInMilestone = getMilestonePhaseFilter(cwd, version);
|
|
if (isDirInMilestone.missingExplicitVersion) {
|
|
error(`no phases found for milestone ${version} in ROADMAP.md`);
|
|
}
|
|
// #3184/#3166: `milestone complete` is the ONE-WAY-DOOR consumer of the
|
|
// milestone window (ROADMAP/REQUIREMENTS archived, phase directories
|
|
// MOVED). #3166 is specifically the TRUNCATED case: the milestone's
|
|
// heading IS found but its section closes before the phase region, and the
|
|
// phase filter degrades to pass-all (see getMilestonePhaseFilter above) —
|
|
// silently archiving every phase directory on disk. UNREADABLE (no
|
|
// ROADMAP.md at all) and UNSCOPED (no section for this version) are
|
|
// pre-existing, legitimately-handled states — `missingExplicitVersion`
|
|
// above already errors where that matters, and a missing ROADMAP.md has
|
|
// its own documented graceful path — so only TRUNCATED is refused here.
|
|
// The read-path consumers keep the pass-all degrade for every scope
|
|
// (ADR-3180 Decision 3's Rejected section: deny-all there would trade one
|
|
// silent wrong answer for another); this write path refuses on TRUNCATED
|
|
// alone, positioned before `platformEnsureDir` so a refusal stays a no-op
|
|
// on disk.
|
|
if (isDirInMilestone.scope === SCOPE.TRUNCATED && !options.force) {
|
|
error(
|
|
`Cannot mark milestone complete: the ROADMAP window for "${version}" is truncated ` +
|
|
`(the milestone heading was found but its section ends before reaching any phase ` +
|
|
`entries, even though the ROADMAP has phase entries elsewhere), so phase scoping ` +
|
|
`cannot be trusted for this destructive operation. Re-run with --force to override.`,
|
|
);
|
|
}
|
|
|
|
// Guard: prevent marking complete when ROADMAP still lists phases that have
|
|
// no directory on disk (disk_status: no_directory). This catches the case
|
|
// where the active milestone was erroneously marked complete before phases
|
|
// were even started. The scan scopes the ROADMAP via the `version` argument
|
|
// (getMilestonePhaseFilter / extractCurrentMilestone above) and runs whenever
|
|
// --force is absent — a fresh project with no `### Phase N:` headings in the
|
|
// scoped slice yields an empty `noDirectoryPhases` and the guard is a no-op,
|
|
// so no STATE match is required to avoid false positives.
|
|
// Pass --force to override this guard.
|
|
//
|
|
// #2946: the scan used to be nested inside `if (stateVersion && stateVersion
|
|
// === version)`, which silently disarmed the guard whenever STATE.md's
|
|
// `milestone:` field was desynced or absent — functionally an implicit
|
|
// --force on a one-way-door operation (ROADMAP/REQUIREMENTS archived, phase
|
|
// directories MOVED). The STATE field is not the source of truth for which
|
|
// phases belong to this milestone; the ROADMAP scoping is. The scan now runs
|
|
// unconditionally, and a present-but-mismatched STATE field emits a WARNING
|
|
// so the suspicious condition is visible rather than silent.
|
|
if (!options.force) {
|
|
try {
|
|
// Read STATE.md's milestone field only to detect a suspicious mismatch;
|
|
// it no longer gates the scan. (#2946)
|
|
let stateVersion: string | null = null;
|
|
try {
|
|
const stateRaw = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : null;
|
|
if (stateRaw) {
|
|
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
|
|
if (milestoneMatch) stateVersion = milestoneMatch[1].trim();
|
|
}
|
|
} catch {
|
|
/* skip — stateVersion stays null, scan still runs */
|
|
}
|
|
if (stateVersion !== null && stateVersion !== version) {
|
|
// #2946: emit a WARNING so the suspicious STATE mismatch is visible
|
|
// rather than silently disarming the guard. Plain-text diagnostic on
|
|
// stderr, matching the existing [gsd-tools] WARNING convention
|
|
// (state.cts). A missing STATE.md `milestone:` field is not warned
|
|
// here — the scan still runs, and "no milestone declared" is a normal
|
|
// state for a fresh project, not a suspicious drift.
|
|
//
|
|
// `stateVersion` comes from a user-controlled file (STATE.md) and is
|
|
// not validated like the CLI `version` arg (ARCHIVE_VERSION_LABEL_RE).
|
|
// Sanitize before interpolating into stderr so ANSI escapes / control
|
|
// chars / secret-looking strings cannot be echoed verbatim into a CI
|
|
// log or terminal (CONTRIBUTING.md security: secret-looking values in
|
|
// stderr). `version` is already constrained to [A-Za-z0-9._-].
|
|
const safeStateVersion = stateVersion.replace(/[\x00-\x1f\x7f]/g, '?').slice(0, 80);
|
|
process.stderr.write(
|
|
`[gsd-tools] WARNING: STATE.md milestone: "${safeStateVersion}" ≠ requested "${version}" — ` +
|
|
`running the unstarted-phase guard against the ROADMAP scoped for "${version}" anyway.\n`,
|
|
);
|
|
}
|
|
|
|
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
// #3184/#2946: scope the unstarted-phase guard to the same `version`
|
|
// window `getMilestonePhaseFilter` used above, NOT to
|
|
// extractCurrentMilestone's own STATE.md-derived window — those two
|
|
// can disagree (that disagreement is exactly what the WARNING above
|
|
// detects), and scoping this guard to the wrong window under-detects
|
|
// unstarted phases on the destructive completion path. Calls the same
|
|
// sliceMilestoneWindow owner getMilestonePhaseFilter's versionOverride
|
|
// branch calls (a prior pass here re-composed locate+select+section-end
|
|
// locally, which review caught as a second, disagreeing derivation of
|
|
// the same window — ADR-3180 Decision 4(c)); falls back to
|
|
// extractCurrentMilestone's whole-document result only for the
|
|
// free-form (no versioned milestones anywhere) shape, where both
|
|
// windows converge to the same value regardless of which version drove
|
|
// the lookup.
|
|
const scopedContent = sliceMilestoneWindow(roadmapContent, version) ?? extractCurrentMilestone(roadmapContent, cwd);
|
|
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
|
|
const noDirectoryPhases: string[] = [];
|
|
let pm: RegExpExecArray | null;
|
|
const phaseDirEntries = ((): string[] => {
|
|
try {
|
|
return fs
|
|
.readdirSync(phasesDir, { withFileTypes: true })
|
|
.filter((e) => e.isDirectory())
|
|
.map((e) => e.name);
|
|
} catch {
|
|
return [];
|
|
}
|
|
})();
|
|
while ((pm = phasePattern.exec(scopedContent)) !== null) {
|
|
const phaseNum = pm[1];
|
|
// Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not
|
|
// real phases — they legitimately have no directory and must not block
|
|
// milestone completion. Mirrors the engine-wide sentinel convention
|
|
// (phase-id getMilestoneFromPhaseId, roadmap-command-router SENTINELS,
|
|
// the #1445 /^999/ progress filters). (#1580)
|
|
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this local check already covered both 0 and 999; now delegates to the single canonical owner.
|
|
if (isSentinelPhaseId(phaseNum)) continue;
|
|
const normalized = normalizePhaseName(phaseNum);
|
|
// A phase has disk_status: 'no_directory' when no phase directory
|
|
// with a matching token exists on disk. Use the same matchPhaseDirs
|
|
// owner that roadmap.analyze uses to avoid false positives on decimal
|
|
// (2.1) and letter-suffix (12A) phase IDs. (#2528)
|
|
const hasDirectory = matchPhaseDirs(phaseDirEntries, normalized).matches.length > 0;
|
|
if (!hasDirectory) {
|
|
noDirectoryPhases.push(phaseNum);
|
|
}
|
|
}
|
|
if (noDirectoryPhases.length > 0) {
|
|
error(
|
|
`Cannot mark milestone complete: ROADMAP lists ${noDirectoryPhases.length} unstarted phase(s) ` +
|
|
`(e.g. Phase ${noDirectoryPhases[0]}). Re-run with --force to override.`,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
// If the error came from our guard, re-throw it; otherwise skip silently.
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
if (message && message.startsWith('Cannot mark milestone complete:')) throw e;
|
|
// Phase scan failed (e.g. ROADMAP unreadable) — allow completion to proceed.
|
|
}
|
|
}
|
|
|
|
// Gather stats from phases (scoped to current milestone only)
|
|
let phaseCount = 0;
|
|
let totalPlans = 0;
|
|
let totalTasks = 0;
|
|
const accomplishments: string[] = [];
|
|
|
|
try {
|
|
// #3185 (ADR-3180 Decision 1): "which phase directories belong to the
|
|
// CURRENT milestone" — routed through the canonical owner (with the
|
|
// explicit `version` this command already resolved) instead of a
|
|
// hand-rolled readdirSync + isDirInMilestone filter, which also never
|
|
// excluded sentinels, unlike the owner.
|
|
const dirs = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: version }).value;
|
|
|
|
for (const dir of dirs) {
|
|
phaseCount++;
|
|
// #3183: canonical plan/summary sets (root+nested, superseded-excluded)
|
|
// from the single owner, rather than a root-only hand-rolled readdirSync
|
|
// filter.
|
|
const phaseScan = scanPhasePlans(path.join(phasesDir, dir));
|
|
const summaries = phaseScan.summaryFiles;
|
|
totalPlans += phaseScan.planCount;
|
|
|
|
// Extract one-liners from summaries
|
|
for (const s of summaries) {
|
|
try {
|
|
const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
|
|
const fm = extractFrontmatter(content, path.join(phasesDir, dir, s));
|
|
const rawOneLiner = fm['one-liner'];
|
|
const oneLiner = (typeof rawOneLiner === 'string' ? rawOneLiner : '') || extractOneLinerFromBody(content);
|
|
if (oneLiner) {
|
|
accomplishments.push(oneLiner);
|
|
}
|
|
// Count tasks: prefer **Tasks:** N from Performance section,
|
|
// then <task XML tags, then ## Task N markdown headers
|
|
const tasksFieldMatch = content.match(/\*\*Tasks:\*\*\s*(\d+)/);
|
|
if (tasksFieldMatch) {
|
|
totalTasks += parseInt(tasksFieldMatch[1], 10);
|
|
} else {
|
|
const xmlTaskMatches = content.match(/<task[\s>]/gi) || [];
|
|
const mdTaskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
|
|
totalTasks += xmlTaskMatches.length || mdTaskMatches.length;
|
|
}
|
|
} catch {
|
|
/* best-effort (#2245 audit): one unreadable/malformed SUMMARY.md
|
|
* must not abort the accomplishments/task-count roll-up for every
|
|
* OTHER summary across every OTHER phase — it's simply excluded
|
|
* from the milestone's shipped-summary text. */
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* best-effort (#2245 audit): mirrors the phaseDirEntries IIFE a few
|
|
* lines below this function (same phasesDir, same "try readdirSync,
|
|
* tolerate ENOENT" pattern) — phasesDir may legitimately not exist yet
|
|
* (e.g. milestone being force-completed before any phase directories
|
|
* were created). Degrades stats to phaseCount/totalPlans/totalTasks=0,
|
|
* accomplishments=[] rather than crash `milestone complete`. */
|
|
}
|
|
|
|
// #2118: --dry-run preview — compute what WOULD happen without mutating.
|
|
// The stats above are read-only; all mutations start at the archive section below.
|
|
if (options.dryRun) {
|
|
const phaseDirsToArchive: string[] = [];
|
|
if (options.archivePhases !== false) {
|
|
// #3185 (ADR-3180 Decision 1): same routed derivation as the stats loop
|
|
// above — the dry-run preview must list exactly what the real archive
|
|
// pass below would move.
|
|
phaseDirsToArchive.push(...listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: version }).value);
|
|
}
|
|
const dryRunResult = {
|
|
dry_run: true,
|
|
version,
|
|
name: milestoneName,
|
|
stats: { phases: phaseCount, plans: totalPlans, tasks: totalTasks },
|
|
accomplishments,
|
|
would_archive: {
|
|
roadmap: fs.existsSync(roadmapPath)
|
|
? { source: path.relative(cwd, roadmapPath).split(path.sep).join('/'), target: path.relative(cwd, path.join(archiveDir, `${version}-ROADMAP.md`)).split(path.sep).join('/') }
|
|
: null,
|
|
requirements: fs.existsSync(reqPath)
|
|
? { source: path.relative(cwd, reqPath).split(path.sep).join('/'), target: path.relative(cwd, path.join(archiveDir, `${version}-REQUIREMENTS.md`)).split(path.sep).join('/') }
|
|
: null,
|
|
audit: fs.existsSync(path.join(planningBase, `${version}-MILESTONE-AUDIT.md`))
|
|
? { source: path.relative(cwd, path.join(planningBase, `${version}-MILESTONE-AUDIT.md`)).split(path.sep).join('/'), target: path.relative(cwd, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)).split(path.sep).join('/') }
|
|
: null,
|
|
phases: phaseDirsToArchive,
|
|
},
|
|
would_update: {
|
|
milestones_md: path.relative(cwd, milestonesPath).split(path.sep).join('/'),
|
|
state_md: fs.existsSync(statePath) ? path.relative(cwd, statePath).split(path.sep).join('/') : null,
|
|
},
|
|
};
|
|
output(dryRunResult, raw);
|
|
return;
|
|
}
|
|
|
|
// Ensure archive directory exists. Deliberately placed AFTER the dry-run
|
|
// early return and every refusal/guard above (missingExplicitVersion, the
|
|
// scope refusal, the unstarted-phase guard) — #3184 review finding: this
|
|
// used to run before those checks, so a refused run still left an empty
|
|
// archive directory behind. Reaching this point means the run is
|
|
// committed to mutating.
|
|
platformEnsureDir(archiveDir);
|
|
|
|
// Archive ROADMAP.md
|
|
if (fs.existsSync(roadmapPath)) {
|
|
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
platformWriteSync(path.join(archiveDir, `${version}-ROADMAP.md`), roadmapContent);
|
|
}
|
|
|
|
// Archive REQUIREMENTS.md
|
|
if (fs.existsSync(reqPath)) {
|
|
const reqContent = fs.readFileSync(reqPath, 'utf-8');
|
|
// Derive the display path from the same source the writer uses (reqPath), so a
|
|
// workstream archive header points at `.planning/workstreams/<ws>/REQUIREMENTS.md`
|
|
// instead of the hardcoded root path (#1993). Root case is byte-identical.
|
|
// Normalize to POSIX separators so the header is cross-platform (Windows
|
|
// path.relative yields backslashes; the original literal was forward-slash).
|
|
const reqDisplay = path.relative(cwd, reqPath).split(path.sep).join('/');
|
|
const archiveHeader = `# Requirements Archive: ${version} ${milestoneName}\n\n**Archived:** ${today}\n**Status:** SHIPPED\n\nFor current requirements, see \`${reqDisplay}\`.\n\n---\n\n`;
|
|
platformWriteSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent);
|
|
}
|
|
|
|
// Archive audit file if exists
|
|
const auditFile = path.join(planningBase, `${version}-MILESTONE-AUDIT.md`);
|
|
if (fs.existsSync(auditFile)) {
|
|
retryRenameSync(auditFile, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
|
|
}
|
|
|
|
// Create/append MILESTONES.md entry
|
|
const accomplishmentsList = accomplishments.map((a) => `- ${a}`).join('\n');
|
|
const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
|
|
|
|
if (fs.existsSync(milestonesPath)) {
|
|
const existing = fs.readFileSync(milestonesPath, 'utf-8');
|
|
if (!existing.trim()) {
|
|
// Empty file — treat like new
|
|
platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
|
|
} else {
|
|
// Insert after the header line(s) for reverse chronological order (newest first)
|
|
// #3415: empirically verified linear-time up to 5MB adversarial input (worst-case
|
|
// no-newline-at-all forcing full [^\r\n]* backtrack: 0.11ms@10KB -> 6.9ms@5MB).
|
|
// Non-global, `^`-anchored (no /m) so this is a single match attempt at position 0
|
|
// only — never rescanned at every offset — with no nested repeated group, so it
|
|
// cannot exhibit the #2128-class catastrophic backtracking.
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- single ^-anchored non-global attempt at pos 0, measured linear to 5MB, no nested quantifier
|
|
const headerMatch = existing.match(/^(#{1,3}\s+[^\r\n]*\r?\n(?:\r?\n)?)/);
|
|
if (headerMatch) {
|
|
const header = headerMatch[1];
|
|
const rest = existing.slice(header.length);
|
|
platformWriteSync(milestonesPath, header + milestoneEntry + rest);
|
|
} else {
|
|
// No recognizable header — prepend the entry
|
|
platformWriteSync(milestonesPath, milestoneEntry + existing);
|
|
}
|
|
}
|
|
} else {
|
|
platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
|
|
}
|
|
|
|
// Update STATE.md — keep frontmatter/body semantically aligned after closure.
|
|
// ADR-1769 Phase 5: dispatches to the STATE.md Transition Module. The closure
|
|
// write (Status, Last Activity, Last Activity Description, Current Position
|
|
// reset, Operator Next Steps reset) is the pure `milestoneCompleteCore` in
|
|
// src/state-transition.cts, backed by the field-classification table. The
|
|
// runtime-specific next-milestone slash command is resolved here and injected
|
|
// via the intent so the core stays pure.
|
|
//
|
|
// ADR-3408 §8.3 / #3469: this used to write via `writeStateMd`, which gets
|
|
// sync and NO preservation — the identical shape #3374 reported for
|
|
// `phase.complete` (a stale body value silently clobbering fresher
|
|
// frontmatter). Routed through the single write-seam composition
|
|
// (`syncAndPreserveStateMd`) instead, under the same lock discipline
|
|
// `cmdPhaseComplete`'s atomic-commit adapter already uses: `withStateLock`
|
|
// wraps read + transform + sync + preserve + write so the read this
|
|
// transaction bases its transform on cannot be raced by a concurrent
|
|
// writer (closing a pre-existing TOCTOU gap `writeStateMd`'s own internal
|
|
// lock never covered, since the read used to happen before any lock was
|
|
// taken). `resync: true` mirrors `cmdPhaseComplete`'s posture (progress
|
|
// recomputed from disk; only the preserve-when-unchanged deltas apply) —
|
|
// milestone completion is the same kind of lifecycle transition.
|
|
if (fs.existsSync(statePath)) {
|
|
withStateLock(statePath, () => {
|
|
const originalStateContent = platformReadSync(statePath) || '';
|
|
const result = transitionCore(
|
|
originalStateContent,
|
|
{
|
|
kind: 'milestoneComplete',
|
|
version,
|
|
nextMilestoneCommand: formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string,
|
|
},
|
|
{ clock: realClock, sourcePath: statePath },
|
|
);
|
|
const divergedFields: string[] = [];
|
|
// #2111 (found by #3471 review): `milestoneCompleteCore` never declares
|
|
// `current_phase`/`current_phase_name` among the fields it touches — but
|
|
// its ## Current Position reset REWRITES the `Phase:` prose line to a
|
|
// closure message ("Milestone vX.Y complete"), which is not a number.
|
|
// That is an unavoidable side effect of the wholesale section reset
|
|
// `resetSectionVerbatim` performs, not an intent to change the phase.
|
|
// Downstream, `current_phase`/`current_phase_name` are
|
|
// `preserve-when-unchanged` rows: the #1230 delta heuristic sees the
|
|
// body source go from a real value to unparseable and — correctly, per
|
|
// ADR-3408 §8.5 Row 2 — lets the derived (empty) value win, discarding
|
|
// the curated phase entirely. §8.5 Row 2 governs a genuine mid-write
|
|
// body edit (e.g. `state.patch` deleting the Phase line); milestone
|
|
// closure is a different shape — the transition never intended to
|
|
// touch these fields at all. Re-assert them via `authoritativeFm` (the
|
|
// same #2736 intent-first mechanism `beginPhaseCore`/`completePhaseCore`
|
|
// already use to freeze a field the transition resolved out-of-band),
|
|
// so the closure-message side effect cannot clobber the last real
|
|
// phase. Scoped to non-empty strings only, mirroring #2736's own guard.
|
|
const authoritativeFm: Record<string, unknown> = {};
|
|
const preFm = extractFrontmatter(originalStateContent, statePath) as Record<string, unknown>;
|
|
const preCurrentPhase = preFm['current_phase'];
|
|
const preCurrentPhaseName = preFm['current_phase_name'];
|
|
if (typeof preCurrentPhase === 'string' && preCurrentPhase.trim().length > 0) {
|
|
authoritativeFm['current_phase'] = preCurrentPhase;
|
|
}
|
|
if (typeof preCurrentPhaseName === 'string' && preCurrentPhaseName.trim().length > 0) {
|
|
authoritativeFm['current_phase_name'] = preCurrentPhaseName;
|
|
}
|
|
const finalContent = syncAndPreserveStateMd(
|
|
originalStateContent,
|
|
result.content,
|
|
statePath,
|
|
cwd,
|
|
true,
|
|
Object.keys(authoritativeFm).length > 0 ? authoritativeFm : undefined,
|
|
undefined,
|
|
divergedFields,
|
|
);
|
|
platformWriteSync(statePath, finalContent);
|
|
for (const field of divergedFields) {
|
|
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
|
|
}
|
|
// The authoritativeFm re-assert above (unlike a delta-based restore) is
|
|
// invisible to `divergedFields` — #2736's re-assert runs after that
|
|
// diff — so surface it explicitly here for "liberal but visible".
|
|
for (const field of Object.keys(authoritativeFm)) {
|
|
if (!divergedFields.includes(field)) {
|
|
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Archive phase directories if requested
|
|
let phasesArchived = false;
|
|
// #1871: archive phase dirs by default on milestone complete (opt out via --no-archive-phases).
|
|
if (options.archivePhases !== false) {
|
|
// #2245 audit (was ERROR-HIDING): retryRenameSync moves one phase dir at a
|
|
// time — a mid-loop failure (e.g. the Nth rename) used to leave
|
|
// `phasesArchived` at its `false` default even though the first N-1 dirs
|
|
// had ALREADY been moved to phaseArchiveDir on disk, silently
|
|
// under-reporting a real partial archive in the JSON result. archivedCount
|
|
// is now computed in a `finally` so it reflects whatever succeeded before
|
|
// any failure, instead of being lost with the swallowed exception.
|
|
let archivedCount = 0;
|
|
try {
|
|
const phaseArchiveDir = path.join(archiveDir, `${version}-phases`);
|
|
platformEnsureDir(phaseArchiveDir);
|
|
|
|
// #3185 (ADR-3180 Decision 1): same routed derivation as the stats
|
|
// loop above — only the CURRENT milestone's phase directories move,
|
|
// never a sentinel or an out-of-window directory left for a later
|
|
// milestone.
|
|
const phaseDirNames = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: version }).value;
|
|
for (const dir of phaseDirNames) {
|
|
retryRenameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir));
|
|
archivedCount++;
|
|
}
|
|
} catch {
|
|
/* best-effort: phasesDir may not exist yet, or the archive rename loop
|
|
* failed partway — phasesArchived below still reflects whatever
|
|
* archivedCount succeeded before the failure. */
|
|
} finally {
|
|
phasesArchived = archivedCount > 0;
|
|
}
|
|
}
|
|
|
|
const result = {
|
|
version,
|
|
name: milestoneName,
|
|
date: today,
|
|
phases: phaseCount,
|
|
plans: totalPlans,
|
|
tasks: totalTasks,
|
|
accomplishments,
|
|
archived: {
|
|
roadmap: fs.existsSync(path.join(archiveDir, `${version}-ROADMAP.md`)),
|
|
requirements: fs.existsSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`)),
|
|
audit: fs.existsSync(path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
|
|
phases: phasesArchived,
|
|
},
|
|
milestones_updated: true,
|
|
state_updated: fs.existsSync(statePath),
|
|
preservation_warnings: preservationWarnings,
|
|
};
|
|
|
|
output(result, raw);
|
|
}
|
|
|
|
function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
|
|
const phasesDir = planningPaths(cwd).phases;
|
|
const confirm = Array.isArray(args) && args.includes('--confirm');
|
|
// --force bypasses the uncommitted-changes guard. Only use when the caller
|
|
// has already archived or explicitly accepts loss of uncommitted work. (#1447)
|
|
const force = Array.isArray(args) && args.includes('--force');
|
|
// #2288: explicit outgoing-version override for the archive destination.
|
|
// new-milestone.md runs `state.milestone-switch` BEFORE `phases.clear --confirm`,
|
|
// so a live read of STATE.md would already report the NEW milestone version by
|
|
// the time we get here. Callers that know the outgoing version pass it explicitly;
|
|
// absent an override, archivePhaseDirectories falls back to the live read.
|
|
const avIndex = Array.isArray(args) ? args.indexOf('--archive-version') : -1;
|
|
let archiveVersionOverride: string | null = null;
|
|
if (avIndex !== -1) {
|
|
const rawArchiveVersion = args[avIndex + 1];
|
|
// Missing / flag-shaped value: fail loud instead of silently dropping the
|
|
// override. A truncated invocation (e.g. a broken template substitution
|
|
// leaving `--archive-version` with no value) must NOT fall through to the
|
|
// live read — that silently re-files the archive under the new milestone,
|
|
// the exact #2288 bug this flag exists to prevent.
|
|
if (typeof rawArchiveVersion !== 'string' || rawArchiveVersion.startsWith('--') || rawArchiveVersion.trim() === '') {
|
|
error('--archive-version requires a value (a milestone version token, e.g. v1.0)');
|
|
}
|
|
const trimmed = rawArchiveVersion.trim();
|
|
// #2288 security: reject path separators / `..` so a crafted value cannot
|
|
// relocate phase history outside `.planning/milestones/` (phase dirs are
|
|
// MOVED into the archive dir — a traversal is data loss, not just an odd name).
|
|
if (!ARCHIVE_VERSION_LABEL_RE.test(trimmed)) {
|
|
error(`--archive-version "${trimmed}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
|
|
}
|
|
archiveVersionOverride = trimmed;
|
|
}
|
|
let cleared = 0;
|
|
|
|
if (fs.existsSync(phasesDir)) {
|
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
// #3185 (ADR-3180 Decision 1): this carried the FIFTH copy of the
|
|
// sentinel rule and its THIRD regex variant — `/^999(?:\.|$)/` — which
|
|
// excluded 999 but NOT 0. Because this is the DESTRUCTIVE path, that
|
|
// divergence meant a `0-*` directory `roadmap analyze` preserves as a
|
|
// sentinel was DELETED here. Routed through the canonical predicate so
|
|
// every reader of "is this a sentinel phase" agrees by construction.
|
|
const dirs = entries.filter((e) => e.isDirectory() && !isSentinelPhaseId(e.name));
|
|
|
|
if (dirs.length > 0 && !confirm) {
|
|
error(
|
|
`phases clear would delete ${dirs.length} phase director${dirs.length === 1 ? 'y' : 'ies'}. ` +
|
|
`Pass --confirm to proceed.`,
|
|
);
|
|
}
|
|
|
|
// Guard (#1447): refuse to hard-delete phase directories that contain
|
|
// uncommitted changes. This prevents data loss when `new-milestone` runs
|
|
// `phases.clear --confirm` before the operator has archived or committed
|
|
// phase work from the outgoing milestone.
|
|
// Use `--force` to bypass this guard only when you have verified that
|
|
// archive or commit of the outgoing phases is already done.
|
|
if (dirs.length > 0 && !force) {
|
|
// Compute the path relative to cwd for git status
|
|
let relPhasesDir: string;
|
|
try {
|
|
relPhasesDir = path.relative(cwd, phasesDir);
|
|
} catch {
|
|
relPhasesDir = phasesDir;
|
|
}
|
|
|
|
let gitStatusOutput = '';
|
|
try {
|
|
const gitResult = execGit(['status', '--porcelain', relPhasesDir], { cwd, timeout: 10_000 });
|
|
if (gitResult.exitCode === 0) {
|
|
gitStatusOutput = gitResult.stdout ?? '';
|
|
}
|
|
// If git is not available or this is not a git repo, skip the guard
|
|
// (gitResult.exitCode non-zero → not a git repo → no uncommitted changes to protect).
|
|
} catch {
|
|
// git unavailable — skip guard
|
|
}
|
|
|
|
const uncommittedLines = gitStatusOutput
|
|
.split('\n')
|
|
.filter((line) => line.trim().length > 0);
|
|
if (uncommittedLines.length > 0) {
|
|
error(
|
|
`phases clear aborted: ${uncommittedLines.length} uncommitted change${uncommittedLines.length === 1 ? '' : 's'} detected in phase directories. ` +
|
|
`Archive or commit outgoing phase work before running this command, ` +
|
|
`or pass --force to skip this check and permanently delete the phase directories. (#1447)`,
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
// #1871: archive phase directories instead of destroying them (shared helper).
|
|
// #2288: thread the explicit --archive-version override (if any) through.
|
|
cleared = archivePhaseDirectories(cwd, phasesDir, dirs, archiveVersionOverride).archived;
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
error('Failed to clear phases directory: ' + message);
|
|
}
|
|
}
|
|
|
|
output({ cleared }, raw, `${cleared} phase director${cleared === 1 ? 'y' : 'ies'} cleared`);
|
|
}
|
|
|
|
/**
|
|
* #1871: move each non-999 phase directory under `phasesDir` into
|
|
* `milestones/<version>-phases/` (collision-safe). Shared by `phases clear`
|
|
* (archive-then-remove) and the internal milestone.complete phase archival so
|
|
* phase history survives a milestone switch instead of being hard-deleted.
|
|
*
|
|
* Archive-version precedence (#2288): an explicit `archiveVersionOverride` wins
|
|
* first, then a live `getMilestoneInfo(cwd)` read (which itself defaults to a
|
|
* version like `v1.0` when ROADMAP/STATE is absent), and only a dated fallback
|
|
* label if no safe version label is resolvable at all. The override is validated
|
|
* by the caller (`cmdPhasesClear`); the live-read value is re-validated here
|
|
* (defense in depth) because `getMilestoneInfo` derives it from STATE.md's
|
|
* unvalidated `milestone:` field. The override exists because `new-milestone.md`
|
|
* runs `state.milestone-switch` BEFORE `phases.clear --confirm` — by the time
|
|
* this runs, a live read of STATE.md would already report the NEW milestone
|
|
* version, so phase history from the OLD milestone would be misfiled under the
|
|
* new version's archive directory. Callers that know the outgoing version must
|
|
* pass it explicitly.
|
|
*/
|
|
function archivePhaseDirectories(cwd: string, phasesDir: string, dirs: ReadonlyArray<{ name: string }>, archiveVersionOverride: string | null = null): { archiveDir: string; archived: number } {
|
|
// Self-protecting (#2288 security defense in depth): the sole current caller
|
|
// (`cmdPhasesClear`) already validates the override, but re-test it here so a
|
|
// future caller cannot reopen the path-traversal sink at line ~742. An override
|
|
// that fails the safe-label check is discarded (falls through to the live read
|
|
// / dated label) rather than reaching `path.join` unvalidated.
|
|
const safeOverride = archiveVersionOverride && ARCHIVE_VERSION_LABEL_RE.test(archiveVersionOverride.trim())
|
|
? archiveVersionOverride.trim()
|
|
: null;
|
|
let archiveVersion: string | null = safeOverride;
|
|
if (!archiveVersion) {
|
|
try {
|
|
// #3216 (ADR-3180 §7.2 Decision): getMilestoneInfo's version becomes a
|
|
// DIRECTORY NAME below — only a COMPLETE scope's identity is trustworthy
|
|
// enough to act on destructively. On any other scope, treat the version
|
|
// as unavailable so control falls through to the dated-label fallback,
|
|
// same as an unreadable ROADMAP/STATE.
|
|
const info = getMilestoneInfo(cwd);
|
|
const liveVersion = info.scope === SCOPE.COMPLETE ? (info.value?.version ?? null) : null;
|
|
// Defense in depth (#2288 security): getMilestoneInfo reads STATE.md's
|
|
// `milestone:` field, which is unvalidated file content. Only accept it
|
|
// as a path component if it is a safe version label; a crafted value
|
|
// (path separators / `..`) falls through to the dated label below rather
|
|
// than escaping `.planning/milestones/`.
|
|
archiveVersion = liveVersion && ARCHIVE_VERSION_LABEL_RE.test(liveVersion) ? liveVersion : null;
|
|
} catch {
|
|
/* ROADMAP/STATE unreadable — fall back to a dated label */
|
|
}
|
|
}
|
|
if (!archiveVersion) {
|
|
archiveVersion = `archived-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 8)}`;
|
|
}
|
|
const archivePhasesDir = path.join(planningPaths(cwd).planning, 'milestones', `${archiveVersion}-phases`);
|
|
platformEnsureDir(archivePhasesDir);
|
|
let archived = 0;
|
|
for (const entry of dirs) {
|
|
const src = path.join(phasesDir, entry.name);
|
|
// Collision-safe: if a same-named archive entry exists (re-run), suffix it.
|
|
let dest = path.join(archivePhasesDir, entry.name);
|
|
let n = 1;
|
|
while (fs.existsSync(dest)) {
|
|
dest = path.join(archivePhasesDir, `${entry.name}.${n++}`);
|
|
}
|
|
retryRenameSync(src, dest);
|
|
archived++;
|
|
}
|
|
return { archiveDir: archivePhasesDir, archived };
|
|
}
|
|
|
|
export = {
|
|
cmdRequirementsMarkComplete,
|
|
cmdRequirementsReadyIds,
|
|
cmdRequirementsRevertPhase,
|
|
cmdMilestoneComplete,
|
|
cmdPhasesClear,
|
|
};
|