* refactor(#3216): widen milestone-window guard to literal-## matchers The guard keyed only on the `#{N,M}` quantifier plus a literal version or phase-lookahead token. getMilestoneInfo hand-rolls its milestone-heading match with a literal `^##`/`## ` and an interpolated ${escapedVer}, so it satisfied neither token and the guard reported a clean zero on a file carrying live re-derivations (#3171, #3197) — a zero it did not earn. Widen token (a) to a literal 2-6 `#` run, admitted ONLY inside a heading-MATCHER literal (a regex literal, or a string/template handed to new RegExp) so a heading-BUILDING template is not mistaken for a re-derivation. Widen token (b) with the grouped `v(\d+(?:\.\d+)+)` shape and an interpolated version placeholder. Ships BEFORE the consolidation per ADR-3180 s7.2: a guard widened afterwards measures an already-cleaned surface. It is expected to be RED until the consolidation lands. * test(#3216): failing-first milestone-identity single-owner suite 63 tests across two files, from the matrix in .gsd/phase/. Section H of milestone-window-single-owner.test.cjs covers the 21 input classes of the design's behavior table plus its negative space; milestone-window-drift-guard covers the widened tokens and proves the exemption is function-scoped, not file-scoped. Copy count is 3 found by the guard, not 1 per the epic (ADR-3180 Amendment 3's standing rule, holding for the fourth consecutive phase): both getMilestoneInfo sites plus cmdRoadmapAnalyze's milestone enumeration at roadmap.cts:454, which carries the same #3171 truncation and #3197 phase-heading confusion. Expected RED until the consolidation lands. * refactor(#3216): bind milestone identity to the canonical locator getMilestoneInfo hand-rolled two milestone-heading regexes inside the owner's own file. Both were wrong, differently: the STATE-version site's ^## anchor is level-blind so [^\n]* absorbs a third #, and the fallback site had no anchor at all, so '## ' matched from the second # of '###'. Against '### Phase 7: Close v3.3 gaps' the fallback returned {v3.3, gaps} (#3197). Both captured names with [^\n(], truncating at a parenthetical (#3171). Bind both to the canonical grammar. locateMilestoneHeadings becomes a version-filtered view over one shared source, and a new version-agnostic listMilestoneHeadings enumerates milestone headings for callers that need all of them. getMilestoneInfo returns ScopedResult<MilestoneInfo|null>; the {v1.0,'milestone'} default, which was output-identical to a real v1.0 project, is deleted. The #2245 never-throws invariant is preserved. Copy count: 3 found by the guard, not 1 per the epic. The third was cmdRoadmapAnalyze's own milestone enumeration (roadmap.cts:454), carrying both defects in the implementation the epic blessed. buildStateFrontmatter and archivePhaseDirectories branch on scope: the first writes null rather than a fabricated identity, the second falls through to its dated-label fallback. A fabricated v3.3 passes ARCHIVE_VERSION_LABEL_RE, so it would otherwise misfile phase history. Also fixes an unsafe cast in init.cts that masked these type errors across five call sites, which would have shipped undefined milestone fields under green tsc. * fix(#3216): restore the #1761 unbounded guard and bullet precedence Review and the first full-matrix run surfaced five real defects in the consolidation, all fixed here rather than by relaxing the tests that caught them: - buildStateFrontmatter gated its isMilestoneBoundedInRoadmap check on the scope-gated milestone value, which is null on any non-COMPLETE scope, so the #1761 unbounded guard was silently skipped and state json reported a percent it must omit. It now gates on the STATE-asserted version, independent of identity scope. - The rewrite lost #2135's precedence: the name-bearing progress-marker bullet is consulted before the heading again. - A single-segment version (v3, no dot) did not resolve; the name-extraction fallback now accepts it. - A version carrying regex metacharacters, or a $& / $1 replacement pattern, is matched literally. - listMilestoneHeadings' heading field trimmed, so a CRLF roadmap no longer leaks a trailing carriage return into roadmap analyze's output. Also emits milestone_version / milestone_name / current_milestone as explicit null rather than omitting the key, so the prompt layer cannot render a bare placeholder, and corrects an init.cts comment plus a cast left inconsistent. * test(#3216): update milestone-identity expectations to the scoped contract getMilestoneInfo returns ScopedResult<MilestoneInfo|null> and the {v1.0,'milestone'} default is deleted, so the suites asserting the old shape assert removed behavior. Updated rather than weakened: every touched call site now asserts the scope explicitly against the frozen SCOPE enum. roadmap-parser.test.cjs: 20 expectations moved to {value,scope}. The #1881 unreadable-vs-absent diagnostic assertions are untouched and still prove their original point — only the return shape moved. One pre-existing assert.ok(info) is now a specific UNSCOPED assertion, so that case is stronger than before. new-milestone-clear-phases.test.cjs: the test asserting phases clear archives under the v1.0 default now asserts the dated archived-<YYYYMMDD> fallback, which is the deliberate consequence of deleting that default. Two of this branch's own tests were also corrected after they drove the implementation the wrong way: the parity test compared raw heading text and so pushed a stray ## prefix into roadmap analyze's public output, and the hostile metacharacter row demanded a pathological version resolve, which pushed a widening of the ADR-locked \b boundary. Both now assert what the contract actually requires. * docs(#3216): document milestone identity and correct the CONTEXT.md entry ADR-3180 s7.2 moves to Enforced and gains two rules that were unstated: the name derives from the heading's own version token and drops a trailing status marker, and a free-form legacy ROADMAP with no version anywhere is UNSCOPED with no identity rather than a defaulted v1.0 (decided by the maintainer before implementation, per s7's own rule that an unstated behavior is not decided). Amendment 4 records Phase 6's validation, including that the copy count was a lower bound for the fourth consecutive phase. CONTEXT.md's Roadmap Parser entry described locateMilestoneHeadings as boundary-matched with (?![\w.-]) — the alternative Amendment 2 tried and REVERTED. The code uses \b and says so, and the ADR agrees; the revert updated code and ADR and missed CONTEXT.md, which is the epic's own fixed-on-one-copy failure class in the docs layer, on a file that is itself a PR gate. * fix(#3216): persist the real version on a truncated identity buildStateFrontmatter wrote null for BOTH milestone and milestone_name on any non-COMPLETE scope, discarding a real version. ADR-3180 s7.2 rule 6: a version known with no resolvable name is TRUNCATED carrying {version, name: null} — 'the version is a real answer, the name is a non-answer, and collapsing the two is the failure this contract exists to prevent.' The two fields are now gated by what is actually known: the version whenever one exists (COMPLETE or TRUNCATED), the name only on COMPLETE. Never fabricated. Caught by this phase's own Decision 4(c) consumer-output test, which is the argument for asserting at the consumer rather than the owner — the owner was correct throughout; only the consumer collapsed its answer. * refactor(#3216): extract helpers and make cmdCommit's scope gate explicit From the two-axis code review: - init.cts repeated the identical getMilestoneInfo cast at five sites with copy-pasted comments — duplication inside a PR whose thesis is that duplicates get deleted. Extracted milestoneRecord(cwd); the one site-specific comment is kept, the four generic copies removed. - getMilestoneInfo hand-built its { value, scope } literal at ten return points; a local scoped() constructor now does it once. Every per-branch rationale comment is preserved and no returned value or scope changed. - cmdCommit gated the milestone branch name on plain truthiness, which is also true for TRUNCATED, so an unresolved identity drove branch creation incidentally rather than deliberately. It now gates on the SCOPE enum, accepting COMPLETE or TRUNCATED because both carry a real version, and the comment records why that differs from archivePhaseDirectories — which demands COMPLETE because it uses the value as a filesystem path component. * test(#3216): cover the bare-version-in-prose truncated path The spec review found the bareVersionMatch path — no STATE version, no milestone heading, a version token only in prose — returning TRUNCATED with no test exercising that exact shape, violating Decision 4's boundary-coverage requirement. * docs(#3216): record the missed Tier-2 surfaces and rule 5's corollary Decision 3 requires an explicit call-out for EVERY Tier-2 change, and Amendment 4's first draft named eight surfaces while the change touched thirteen. Adds cmdCommit's branch-name construction and the four init JSON bundles, an incomplete list being the same defect in miniature that this epic removes. s7.2 rule 5 gains a corollary separating two cases the original wording ran together: no version token ANYWHERE is UNSCOPED, while a bare version token in prose or a non-milestone heading is weak but real evidence and yields TRUNCATED under rule 6. * chore(#3216): set changeset fragment pr to 3226 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -29,6 +29,9 @@ const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phas
|
||||
import roadmapParserMod = require('./roadmap-parser.cjs');
|
||||
const { extractCurrentMilestone, stripShippedMilestones: _stripShippedMilestones, getMilestoneInfo, getRoadmapPhaseInternal } = 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 modelResolverMod = require('./model-resolver.cjs');
|
||||
const { resolveModelInternal, resolveModelForTier, resolveProviderEscalation, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, resolveGranularityInternal, assertValidGranularityOverride } = modelResolverMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -864,7 +867,19 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
}
|
||||
}
|
||||
} else if (branchingStrategy === 'milestone') {
|
||||
const milestone = getMilestoneInfo(cwd);
|
||||
const milestoneInfo = getMilestoneInfo(cwd);
|
||||
// #3216 review Finding 3: explicit scope gate instead of plain truthiness.
|
||||
// COMPLETE and TRUNCATED both carry a real `version` (ADR-3180 §7.2 rule
|
||||
// 6 — TRUNCATED means the version resolved but the milestone's NAME did
|
||||
// not), so a TRUNCATED identity is acceptable here: `milestone.version`
|
||||
// only feeds a BRANCH NAME, and `generateSlugInternal(null) || 'milestone'`
|
||||
// already degrades the missing name to the literal "milestone" slug on
|
||||
// purpose. This differs from `archivePhaseDirectories` (milestone.cts),
|
||||
// which uses the same value as a DIRECTORY NAME and therefore demands
|
||||
// COMPLETE only — a real-but-unnamed version is not safe enough there.
|
||||
const milestone = milestoneInfo.scope === SCOPE.COMPLETE || milestoneInfo.scope === SCOPE.TRUNCATED
|
||||
? milestoneInfo.value
|
||||
: null;
|
||||
if (milestone && milestone.version) {
|
||||
branchName = (config['milestone_branch_template'] as string)
|
||||
.replace('{milestone}', milestone.version)
|
||||
@@ -1561,7 +1576,7 @@ async function cmdWebsearch(query: string | undefined, options: WebsearchOptions
|
||||
|
||||
function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
const phasesDir = planningPaths(cwd).phases;
|
||||
const milestone = getMilestoneInfo(cwd);
|
||||
const milestone = getMilestoneInfo(cwd).value;
|
||||
|
||||
const phases: PhaseProgress[] = [];
|
||||
let totalPlans = 0;
|
||||
@@ -1603,7 +1618,7 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean
|
||||
const barWidth = 10;
|
||||
const filled = Math.round((percent / 100) * barWidth);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
let out = `# ${milestone.version} ${milestone.name}\n\n`;
|
||||
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n\n`;
|
||||
out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n`;
|
||||
out += `| Phase | Name | Plans | Status |\n`;
|
||||
out += `|-------|------|-------|--------|\n`;
|
||||
@@ -1620,8 +1635,8 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean
|
||||
} else {
|
||||
// JSON format
|
||||
output({
|
||||
milestone_version: milestone.version,
|
||||
milestone_name: milestone.name,
|
||||
milestone_version: milestone?.version ?? null,
|
||||
milestone_name: milestone?.name ?? null,
|
||||
phases,
|
||||
total_plans: totalPlans,
|
||||
total_summaries: totalSummaries,
|
||||
@@ -1865,7 +1880,7 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
const roadmapPath = planningPaths(cwd).roadmap;
|
||||
const reqPath = planningPaths(cwd).requirements;
|
||||
const statePath = planningPaths(cwd).state;
|
||||
const milestone = getMilestoneInfo(cwd);
|
||||
const milestone = getMilestoneInfo(cwd).value;
|
||||
|
||||
// Phase & plan stats (reuse progress pattern)
|
||||
const phasesByNumber = new Map<string, {
|
||||
@@ -1994,8 +2009,8 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
}
|
||||
|
||||
const result = {
|
||||
milestone_version: milestone.version,
|
||||
milestone_name: milestone.name,
|
||||
milestone_version: milestone?.version ?? null,
|
||||
milestone_name: milestone?.name ?? null,
|
||||
phases,
|
||||
phases_completed: completedPhases,
|
||||
phases_total: phases.length,
|
||||
@@ -2017,7 +2032,7 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
const barWidth = 10;
|
||||
const filled = Math.round((percent / 100) * barWidth);
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
||||
let out = `# ${milestone.version} ${milestone.name} — Statistics\n\n`;
|
||||
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
|
||||
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases (${percent}%)\n`;
|
||||
if (totalPlans > 0) {
|
||||
out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`;
|
||||
|
||||
74
src/init.cts
74
src/init.cts
@@ -813,6 +813,17 @@ function buildSectionManifestField(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #3216 review Finding 1: `getMilestoneInfo(cwd).value` unwrap-and-cast was
|
||||
* repeated identically (comment included) at five init call sites — factored
|
||||
* out once so the cast and its `?? {}` "no milestone resolved" fallback live
|
||||
* in exactly one place. Behavior-preserving: same call, same fallback, same
|
||||
* cast, for every caller.
|
||||
*/
|
||||
function milestoneRecord(cwd: string): Record<string, unknown> {
|
||||
return (getMilestoneInfo(cwd).value ?? {}) as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function cmdInitExecutePhase(
|
||||
cwd: string,
|
||||
phase: string,
|
||||
@@ -825,7 +836,16 @@ function cmdInitExecutePhase(
|
||||
|
||||
const config = loadConfig(cwd);
|
||||
let phaseInfo = guardedFindPhase(cwd, phase, config.project_code);
|
||||
const milestone = getMilestoneInfo(cwd) as unknown as Record<string, unknown>;
|
||||
// #3216: getMilestoneInfo now returns a ScopedResult — `.value` carries the
|
||||
// MilestoneInfo (or null on any non-COMPLETE scope). NOT display-only: when
|
||||
// `branching_strategy === 'milestone'`, `milestone['version']`/`['name']`
|
||||
// below feed `branch_name` construction (see the milestone_branch_template
|
||||
// branch below), so an unresolved milestone changes the constructed branch
|
||||
// name, not merely what gets printed. bracket-access below naturally reads
|
||||
// `undefined` when unresolved; the `milestone_version`/`milestone_name`
|
||||
// output fields below coerce that to an explicit `null` (#3216 review
|
||||
// Finding 2) so the key is never silently omitted from the JSON bundle.
|
||||
const milestone = milestoneRecord(cwd);
|
||||
|
||||
const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
|
||||
phaseInfo = applyRoadmapFallback(phaseInfo, roadmapPhase, (rp) => {
|
||||
@@ -905,16 +925,16 @@ function cmdInitExecutePhase(
|
||||
.replace('{slug}', (phaseInfo['phase_slug'] as string) || 'phase')
|
||||
: config.branching_strategy === 'milestone'
|
||||
? (config.milestone_branch_template as string)
|
||||
.replace('{milestone}', milestone['version'] as string)
|
||||
.replace('{milestone}', (milestone['version'] as string | undefined) ?? '')
|
||||
.replace(
|
||||
'{slug}',
|
||||
generateSlugInternal(milestone['name'] as string) || 'milestone',
|
||||
generateSlugInternal(milestone['name'] as string | undefined) || 'milestone',
|
||||
)
|
||||
: null,
|
||||
|
||||
milestone_version: milestone['version'],
|
||||
milestone_name: milestone['name'],
|
||||
milestone_slug: generateSlugInternal(milestone['name'] as string),
|
||||
milestone_version: milestone['version'] ?? null,
|
||||
milestone_name: milestone['name'] ?? null,
|
||||
milestone_slug: generateSlugInternal(milestone['name'] as string | undefined),
|
||||
|
||||
state_exists: fs.existsSync(path.join(planningDir(cwd), 'STATE.md')),
|
||||
roadmap_exists: fs.existsSync(path.join(planningDir(cwd), 'ROADMAP.md')),
|
||||
@@ -1208,7 +1228,7 @@ function cmdInitNewProject(cwd: string, raw: boolean, options: Record<string, un
|
||||
|
||||
function cmdInitNewMilestone(cwd: string, raw: boolean, options: Record<string, unknown> = {}): void {
|
||||
const config = loadConfig(cwd);
|
||||
const milestone = getMilestoneInfo(cwd) as unknown as Record<string, unknown>;
|
||||
const milestone = milestoneRecord(cwd);
|
||||
const latestCompleted = getLatestCompletedMilestone(cwd);
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
// #3185 (ADR-3180 Decision 1): "how many phase directories belong to the
|
||||
@@ -1227,8 +1247,12 @@ function cmdInitNewMilestone(cwd: string, raw: boolean, options: Record<string,
|
||||
commit_docs: config.commit_docs,
|
||||
research_enabled: wf['research'],
|
||||
|
||||
current_milestone: milestone['version'],
|
||||
current_milestone_name: milestone['name'],
|
||||
// #3216 review Finding 2: `?? null` so an unresolved milestone still emits
|
||||
// the key with an explicit `null` rather than letting JSON.stringify drop
|
||||
// it — an omitted key reaches the prompt layer's `{current_milestone}`
|
||||
// placeholder as literal, un-substituted text.
|
||||
current_milestone: milestone['version'] ?? null,
|
||||
current_milestone_name: milestone['name'] ?? null,
|
||||
latest_completed_milestone: latestCompleted?.version || null,
|
||||
latest_completed_milestone_name: latestCompleted?.name || null,
|
||||
phase_dir_count: phaseDirCount,
|
||||
@@ -1988,7 +2012,7 @@ function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void
|
||||
|
||||
function cmdInitMilestoneOp(cwd: string, raw: boolean): void {
|
||||
const config = loadConfig(cwd);
|
||||
const milestone = getMilestoneInfo(cwd) as unknown as Record<string, unknown>;
|
||||
const milestone = milestoneRecord(cwd);
|
||||
|
||||
let phaseCount = 0;
|
||||
let completedPhases = 0;
|
||||
@@ -2076,9 +2100,13 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void {
|
||||
const result: Record<string, unknown> = {
|
||||
commit_docs: config.commit_docs,
|
||||
|
||||
milestone_version: milestone['version'],
|
||||
milestone_name: milestone['name'],
|
||||
milestone_slug: generateSlugInternal(milestone['name'] as string),
|
||||
// #3216 review Finding 2: `?? null` so an unresolved milestone still emits
|
||||
// the key with an explicit `null` rather than letting JSON.stringify drop
|
||||
// it — an omitted key reaches the prompt layer's `{milestone_version}`
|
||||
// placeholder as literal, un-substituted text.
|
||||
milestone_version: milestone['version'] ?? null,
|
||||
milestone_name: milestone['name'] ?? null,
|
||||
milestone_slug: generateSlugInternal(milestone['name'] as string | undefined),
|
||||
|
||||
phase_count: phaseCount,
|
||||
completed_phases: completedPhases,
|
||||
@@ -2134,7 +2162,7 @@ function cmdInitMapCodebase(cwd: string, raw: boolean): void {
|
||||
|
||||
function cmdInitManager(cwd: string, raw: boolean): void {
|
||||
const config = loadConfig(cwd);
|
||||
const milestone = getMilestoneInfo(cwd) as unknown as Record<string, unknown>;
|
||||
const milestone = milestoneRecord(cwd);
|
||||
const _slashRuntime = resolveRuntime(cwd);
|
||||
|
||||
const paths = planningPaths(cwd);
|
||||
@@ -2476,8 +2504,12 @@ function cmdInitManager(cwd: string, raw: boolean): void {
|
||||
};
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
milestone_version: milestone['version'],
|
||||
milestone_name: milestone['name'],
|
||||
// #3216 review Finding 2: `?? null` so an unresolved milestone still emits
|
||||
// the key with an explicit `null` rather than letting JSON.stringify drop
|
||||
// it — an omitted key reaches the prompt layer's `{milestone_version}`
|
||||
// placeholder as literal, un-substituted text.
|
||||
milestone_version: milestone['version'] ?? null,
|
||||
milestone_name: milestone['name'] ?? null,
|
||||
phases,
|
||||
phase_count: phases.length,
|
||||
completed_count: completedCount,
|
||||
@@ -2751,7 +2783,7 @@ function cmdInitProgress(cwd: string, raw: boolean, options: Record<string, unkn
|
||||
/* intentionally empty */
|
||||
}
|
||||
const config = loadConfig(cwd);
|
||||
const milestone = getMilestoneInfo(cwd) as unknown as Record<string, unknown>;
|
||||
const milestone = milestoneRecord(cwd);
|
||||
const _slashRuntime = resolveRuntime(cwd);
|
||||
|
||||
// #1912: fail safe in workstream mode with no active workstream. With no active
|
||||
@@ -2937,8 +2969,12 @@ function cmdInitProgress(cwd: string, raw: boolean, options: Record<string, unkn
|
||||
|
||||
commit_docs: config.commit_docs,
|
||||
|
||||
milestone_version: milestone['version'],
|
||||
milestone_name: milestone['name'],
|
||||
// #3216 review Finding 2: `?? null` so an unresolved milestone still emits
|
||||
// the key with an explicit `null` rather than letting JSON.stringify drop
|
||||
// it — an omitted key reaches the prompt layer's `{milestone_version}`
|
||||
// placeholder as literal, un-substituted text.
|
||||
milestone_version: milestone['version'] ?? null,
|
||||
milestone_name: milestone['name'] ?? null,
|
||||
|
||||
phases,
|
||||
phase_count: phases.length,
|
||||
|
||||
@@ -1047,7 +1047,13 @@ function archivePhaseDirectories(cwd: string, phasesDir: string, dirs: ReadonlyA
|
||||
let archiveVersion: string | null = safeOverride;
|
||||
if (!archiveVersion) {
|
||||
try {
|
||||
const liveVersion = getMilestoneInfo(cwd).version ?? null;
|
||||
// #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
|
||||
|
||||
@@ -129,6 +129,70 @@ function computeMilestoneSectionEnd(content: string, headingText: string, headin
|
||||
return content.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* #3216 (epic #3180 §7.2 Scope amendment): the version-AGNOSTIC sibling of
|
||||
* `locateMilestoneHeadings` — enumerates EVERY milestone heading in document
|
||||
* order, carrying its own version token, curated name, and shipped/closed
|
||||
* status. Consolidates the THIRD independent re-derivation the widened guard
|
||||
* found at `roadmap.cts:454` (`cmdRoadmapAnalyze`'s inline
|
||||
* `/##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi`), which truncated names at a
|
||||
* parenthetical and had no phase-heading exclusion.
|
||||
*
|
||||
* `MILESTONE_HEADING_LINE_SOURCE` immediately below is the ONE textual
|
||||
* expression of the grammar `^#{1,3}\s+(?!Phase\s+\S)` in this file;
|
||||
* `locateMilestoneHeadings` builds its own pattern from the SAME constant
|
||||
* instead of re-typing the pattern text, so it is a version-FILTERED VIEW
|
||||
* over this function's grammar, never a second expression of it.
|
||||
*
|
||||
* Name extraction follows the pinned rule (ADR-3180 §7.2 amendment, "Name
|
||||
* extraction — pinned rule" via `extractMilestoneHeadingName`): strip
|
||||
* everything through the heading's OWN version token — not necessarily one a
|
||||
* caller is separately asking about — then ONE leading delimiter and
|
||||
* surrounding whitespace via the shared `stripLeadingDelimiter`. `(` is an
|
||||
* ordinary name character and is never a terminator (#3171). `getMilestoneInfo`
|
||||
* shares this same extraction so the parenthetical rule has exactly one
|
||||
* implementation.
|
||||
*/
|
||||
function listMilestoneHeadings(content: string): Array<{ heading: string; version: string; name: string | null; closed: boolean }> {
|
||||
const pattern = new RegExp(MILESTONE_HEADING_LINE_SOURCE, 'gmi');
|
||||
const out: Array<{ heading: string; version: string; name: string | null; closed: boolean }> = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pattern.exec(content)) !== null) {
|
||||
// #3216 review (Finding 4): the shared grammar's `[^\n]*` captures a
|
||||
// trailing `\r` on a CRLF-encoded ROADMAP (the inline `cmdRoadmapAnalyze`
|
||||
// regex this replaced called `.trim()`; this did not). `.trim()` here
|
||||
// matches that prior behavior. `version` (digits/dots/letters only, via
|
||||
// `extractMilestoneHeadingName`'s regex) and `name` (already run through
|
||||
// `stripLeadingDelimiter`, which ends in `.trim()`) cannot carry a
|
||||
// trailing `\r`, so only `heading` needs the fix.
|
||||
//
|
||||
// `heading` carries the heading text WITHOUT the leading `#{1,3}` run and
|
||||
// its following whitespace — matching the inline `cmdRoadmapAnalyze`
|
||||
// regex this function replaced (`/##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi`,
|
||||
// whose capture group 1 begins AFTER `##\s*`). `locateMilestoneHeadings`
|
||||
// legitimately returns a DIFFERENT representation (`m[1]`, `#`s included)
|
||||
// — the two owners agree on WHICH milestone headings are selected, not on
|
||||
// raw heading text.
|
||||
const heading = m[0].replace(/^#{1,3}\s+/, '').trim();
|
||||
const extracted = extractMilestoneHeadingName(heading);
|
||||
if (extracted === null) continue; // no version token on this heading — not a milestone heading
|
||||
out.push({
|
||||
heading,
|
||||
version: extracted.version,
|
||||
name: extracted.name,
|
||||
closed: isClosedMilestoneHeading(heading),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// #3216: the ONE textual expression of "level-bounded (h1-h3), phase-excluded
|
||||
// heading line" in this file. `listMilestoneHeadings` and
|
||||
// `locateMilestoneHeadings` both build their pattern from this constant
|
||||
// rather than typing `^#{1,3}\s+(?!Phase\s+\S)` a second time — the exact
|
||||
// duplication class ADR-3180 §7.2's widened guard exists to catch.
|
||||
const MILESTONE_HEADING_LINE_SOURCE = '^#{1,3}\\s+(?!Phase\\s+\\S)[^\\n]*';
|
||||
|
||||
/**
|
||||
* #3184: the sole milestone-heading locator. Boundary-matched on the version
|
||||
* token with `\b`, NOT the stricter `(?![\w.-])`: this function keeps `\b`
|
||||
@@ -142,17 +206,26 @@ function computeMilestoneSectionEnd(content: string, headingText: string, headin
|
||||
* selection. `extractCurrentMilestoneScoped`, `currentMilestoneRawRanges`,
|
||||
* and `getMilestonePhaseFilter`'s versionOverride branch all consume this
|
||||
* instead of re-deriving their own heading-location regex.
|
||||
*
|
||||
* #3216: rewritten as a version-FILTERED VIEW over `MILESTONE_HEADING_LINE_SOURCE`
|
||||
* — the SAME grammar `listMilestoneHeadings` enumerates — rather than a
|
||||
* second expression of it. The returned `RegExpExecArray[]` contract
|
||||
* (`m[0] === m[1]`, `m.index` at the heading's start) is byte-for-byte
|
||||
* unchanged, so its 4 existing callers are unaffected.
|
||||
*/
|
||||
function locateMilestoneHeadings(content: string, version: string): RegExpExecArray[] {
|
||||
const escapedVersion = escapeRegex(version);
|
||||
const pattern = new RegExp(
|
||||
`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
|
||||
'gmi',
|
||||
);
|
||||
// ADR-3180 §7.1 locks this boundary as `\b`, not the stricter
|
||||
// `(?![\w.-])` — Amendment 2 tried the stricter boundary and reverted it.
|
||||
// `\b` alone is what preserves the #730 sub-milestone selection this
|
||||
// function owns: `v2.0` still matches inside `v2.0.1`, `v8.0` still
|
||||
// matches `## v8.0-B …`.
|
||||
const boundary = new RegExp(`${escapedVersion}\\b`, 'i');
|
||||
const pattern = new RegExp(`(${MILESTONE_HEADING_LINE_SOURCE})`, 'gmi');
|
||||
const matches: RegExpExecArray[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pattern.exec(content)) !== null) {
|
||||
matches.push(m);
|
||||
if (boundary.test(m[1])) matches.push(m);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -715,7 +788,7 @@ function reportUnreadableRoadmap(err: unknown, roadmapPath: string): void {
|
||||
|
||||
interface MilestoneInfo {
|
||||
version: string;
|
||||
name: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -731,7 +804,94 @@ function stripLeadingDelimiter(s: string): string {
|
||||
return s.replace(/^[\s—–:-]+/, '').trim();
|
||||
}
|
||||
|
||||
function getMilestoneInfo(cwd: string): MilestoneInfo {
|
||||
/**
|
||||
* #3216 (ADR-3180 §7.2's "Name extraction — pinned rule"): the sole "milestone
|
||||
* heading text → version + curated name" rule. Strips everything through the
|
||||
* heading's OWN version token — NOT necessarily a version a caller is
|
||||
* separately asking about (a `v2.0` STATE selecting a `## v2.0.1 — Portability`
|
||||
* heading yields the name `Portability`, never `.1 — Portability`) — then ONE
|
||||
* leading delimiter and surrounding whitespace via `stripLeadingDelimiter`.
|
||||
* `(` is an ordinary name character and is never a terminator (#3171). Shared
|
||||
* by `listMilestoneHeadings` and `getMilestoneInfo` so this rule has exactly
|
||||
* one implementation. Returns `null` when `headingText` carries no version
|
||||
* token at all (e.g. a non-milestone heading reached this by mistake).
|
||||
*
|
||||
* @param expectedVersion - When the caller already knows the exact version it
|
||||
* is looking for (the STATE-anchored `getMilestoneInfo` path, which located
|
||||
* this heading via `selectMilestoneHeading(roadmap, stateVersion)`), pass it
|
||||
* here so the "own version token" is found by anchoring to that KNOWN
|
||||
* literal (escaped, then extended by the same dash/dot continuation grammar
|
||||
* for the row-16/17 sub-milestone cases) instead of independently
|
||||
* re-deriving a version-shaped pattern from scratch. `listMilestoneHeadings`
|
||||
* (version-agnostic enumeration — no target version exists) omits this and
|
||||
* keeps the generic re-derivation. Anchoring on the known literal is what
|
||||
* makes a hostile STATE `milestone:` value (regex metacharacters, single-
|
||||
* segment `vN`, a literal `$&`/`$1`) resolve correctly: the generic pattern
|
||||
* only recognizes the real GSD version grammar and stops early on anything
|
||||
* outside it, leaving hostile characters in the extracted "name".
|
||||
*/
|
||||
function extractMilestoneHeadingName(
|
||||
headingText: string,
|
||||
expectedVersion?: string,
|
||||
): { version: string; name: string | null } | null {
|
||||
const versionMatch = expectedVersion
|
||||
// Anchor to the KNOWN literal version, then extend across any immediate
|
||||
// dash/dot continuation the heading's OWN token carries beyond it (e.g.
|
||||
// requested v8.0 -> heading's own v8.0-B; requested v2.0 -> v2.0.1).
|
||||
// `.match()` here — never `.replace()` — so a `$&`/`$1`-bearing version
|
||||
// is located as a literal substring and never interpreted as a
|
||||
// String.replace() substitution pattern.
|
||||
? headingText.match(new RegExp(`${escapeRegex(expectedVersion)}(?:[-.][A-Za-z0-9]+)*`, 'i'))
|
||||
// No known target: re-derive a version-shaped token generically. `v3` /
|
||||
// `v3.3` / `v3.3.3` must all resolve to themselves (§7.2), so the dotted
|
||||
// continuation is zero-or-more, not one-or-more.
|
||||
: headingText.match(/v\d+(?:\.\d+)*(?:[-.][A-Za-z0-9]+)*/i);
|
||||
if (!versionMatch) return null;
|
||||
const version = versionMatch[0];
|
||||
const afterVersion = headingText.slice((versionMatch.index ?? 0) + version.length);
|
||||
// Amendment (§7.2 pinned rule): after stripping the leading delimiter, also
|
||||
// strip a trailing run of status markers (✅ 📋 🚧) plus surrounding
|
||||
// whitespace — the marker is already carried structurally by `closed`, so
|
||||
// duplicating it inside `name` (e.g. "Old ✅") is redundant and wrong. Only
|
||||
// these three markers, only at the end; a marker inside a name is untouched.
|
||||
const name = stripLeadingDelimiter(afterVersion).replace(/\s*(?:[✅📋🚧]\s*)+$/, '') || null;
|
||||
return { version, name };
|
||||
}
|
||||
|
||||
/**
|
||||
* #3216 (epic #3180 §7.2, "Milestone identity"): which milestone is current,
|
||||
* and what is it called. Binds to the canonical `locateMilestoneHeadings` /
|
||||
* `listMilestoneHeadings` / `extractMilestoneHeadingName` owners and deletes
|
||||
* both hand-rolled heading regexes this function used to carry — the
|
||||
* level-blind STATE-version regex (#3197) and the unanchored fallback regex
|
||||
* (#3171) — so the class of defect they produced ("### Phase N: … v3.3 …"
|
||||
* read as milestone `v3.3`; a name truncated at `(`) is structurally
|
||||
* unrepresentable rather than merely fixed on this one copy.
|
||||
*
|
||||
* Never throws (#2245) — the outer try/catch returns `{value: null, scope:
|
||||
* UNREADABLE}` on any failure, preserving `state.cts:1663`'s "this wrapper
|
||||
* could never be triggered" invariant. Absence (ENOENT) is silent (#1881,
|
||||
* ADR-1411); a genuine read fault (e.g. EACCES) still reports via
|
||||
* `reportUnreadableRoadmap`, which discriminates on the errno exactly as
|
||||
* before.
|
||||
*
|
||||
* The `{version:'v1.0', name:'milestone'}` default this function used to
|
||||
* return on every unresolved path is DELETED per §7.2 rule 4 — it was
|
||||
* output-identical to a successful read of a genuine v1.0 project. Every
|
||||
* unresolved path now returns a `scope` other than `COMPLETE` instead.
|
||||
*/
|
||||
/**
|
||||
* #3216 review Finding 2: `getMilestoneInfo`'s `{ value, scope }` return shape
|
||||
* was hand-built as an inline object literal at every return point — factored
|
||||
* out once so the shape itself cannot drift between call sites. Purely a
|
||||
* literal-shape constructor: does not decide, validate, or alter any value or
|
||||
* scope — every per-branch rationale comment stays exactly where it was.
|
||||
*/
|
||||
function scoped(value: MilestoneInfo | null, scope: Scope): { value: MilestoneInfo | null; scope: Scope } {
|
||||
return { value, scope };
|
||||
}
|
||||
|
||||
function getMilestoneInfo(cwd?: string): { value: MilestoneInfo | null; scope: Scope } {
|
||||
// Declared here but RESOLVED INSIDE the try, so the catch can name the file without
|
||||
// moving planningDir() out of the protected region. planningDir throws a plain Error
|
||||
// for an invalid GSD_WORKSTREAM/GSD_PROJECT segment, and hoisting the call let that
|
||||
@@ -740,6 +900,8 @@ function getMilestoneInfo(cwd: string): MilestoneInfo {
|
||||
// skipped and the default is returned exactly as before.
|
||||
let roadmapPath: string | undefined;
|
||||
try {
|
||||
if (!cwd) return scoped(null, SCOPE.UNREADABLE);
|
||||
|
||||
roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
const roadmap = platformReadSync(roadmapPath);
|
||||
if (roadmap === null) throw new Error('missing');
|
||||
@@ -770,58 +932,98 @@ function getMilestoneInfo(cwd: string): MilestoneInfo {
|
||||
// (the active-milestone bullet). A `##` heading is often nameless
|
||||
// ("## vX.Y — Active Milestone") and, when unanchored, was matched
|
||||
// spuriously on a copy quoted inside backticks in this very bullet.
|
||||
// #3216 fix (progressMarkerBulletIsConsultedBeforeHeading): the version
|
||||
// is commonly wrapped in its OWN bold pair — `🚧 **v3.3** Name` — so a
|
||||
// trailing `\*?\*?` after the version (mirroring the leading one) is
|
||||
// required before the `\s+` that anchors the name capture; without it
|
||||
// the closing `**` sits between the version and the required whitespace
|
||||
// and the whole match fails, silently falling through to the heading.
|
||||
const listMatch = roadmap.match(
|
||||
new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i')
|
||||
new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\*?\\*?\\s+([^*\\n]+)`, 'i')
|
||||
);
|
||||
if (listMatch) {
|
||||
const name = stripLeadingDelimiter(listMatch[1]);
|
||||
if (name) return { version: stateVersion, name };
|
||||
if (name) return scoped({ version: stateVersion, name }, SCOPE.COMPLETE);
|
||||
}
|
||||
|
||||
// Fall back to the `##` heading — ANCHORED to line start (`^` + `m` flag)
|
||||
// so a heading quoted inside backticks or prose mid-line can no longer
|
||||
// match. Skip shipped (✅) headings.
|
||||
const headingMatch = roadmap.match(
|
||||
new RegExp(`^##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'im')
|
||||
);
|
||||
if (headingMatch && !headingMatch[0].includes('✅')) {
|
||||
// Strip a leading delimiter — `.trim()` removes whitespace, not the
|
||||
// em-dash/colon that conventionally separates version from name.
|
||||
const name = stripLeadingDelimiter(headingMatch[1]);
|
||||
if (name) return { version: stateVersion, name };
|
||||
// #3216: heading selection routes through the shared owner
|
||||
// (`selectMilestoneHeading` — locate → prefer-non-closed, mirroring
|
||||
// `sliceMilestoneWindow`), deleting the level-blind `^##…` regex (#3197)
|
||||
// and the unanchored `[:\s]+([^\n(]+)` name capture that truncated at a
|
||||
// parenthetical (#3171). A CLOSED/shipped heading is not "current" (row
|
||||
// 5) — it falls through to the TRUNCATED return below exactly as a
|
||||
// missing heading would.
|
||||
const selected = selectMilestoneHeading(roadmap, stateVersion);
|
||||
if (selected) {
|
||||
const headingText = selected[1].replace(/^#{1,3}\s+/, '');
|
||||
if (!isClosedMilestoneHeading(headingText)) {
|
||||
// #3216 fix: pass the KNOWN stateVersion so name extraction anchors
|
||||
// to it (see extractMilestoneHeadingName's `expectedVersion` doc) —
|
||||
// fixes single-segment versions (`v3`, no dot) and hostile STATE
|
||||
// values (regex metacharacters, literal `$&`/`$1`) that the generic
|
||||
// re-derivation used by listMilestoneHeadings cannot recognize.
|
||||
const extracted = extractMilestoneHeadingName(headingText, stateVersion);
|
||||
if (extracted && extracted.name) {
|
||||
return scoped({ version: stateVersion, name: extracted.name }, SCOPE.COMPLETE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { version: stateVersion, name: 'milestone' };
|
||||
// Version is known (STATE.md), but no name-bearing evidence resolved:
|
||||
// no 🚧 bullet, no usable heading (absent, phase-only-excluded, shipped,
|
||||
// or heading-but-nameless). §7.2 rule 4 — never fabricate a name.
|
||||
return scoped({ version: stateVersion, name: null }, SCOPE.TRUNCATED);
|
||||
}
|
||||
|
||||
// No STATE.md version. The 🚧 in-progress bullet is still consulted first
|
||||
// (unchanged from the pre-#3216 fallback).
|
||||
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
||||
if (inProgressMatch) {
|
||||
return {
|
||||
version: 'v' + inProgressMatch[1],
|
||||
name: inProgressMatch[2].trim(),
|
||||
};
|
||||
return scoped(
|
||||
{ version: 'v' + inProgressMatch[1], name: inProgressMatch[2].trim() },
|
||||
SCOPE.COMPLETE,
|
||||
);
|
||||
}
|
||||
|
||||
// #3216: enumerate every OPEN (non-shipped) milestone heading via the
|
||||
// shared owner and take the first in document order — deletes the
|
||||
// unanchored `/## (?!.*✅).*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/` fallback
|
||||
// regex (#3171/#3197), whose `## ` prefix matched starting at the SECOND
|
||||
// `#` of a `### Phase N: …` heading.
|
||||
const cleaned = stripShippedMilestones(roadmap);
|
||||
const headingMatch = cleaned.match(/## (?!.*✅).*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
|
||||
if (headingMatch) {
|
||||
return {
|
||||
version: 'v' + headingMatch[1],
|
||||
name: headingMatch[2].trim(),
|
||||
};
|
||||
const openHeadings = listMilestoneHeadings(cleaned).filter((h) => !h.closed);
|
||||
if (openHeadings.length > 0) {
|
||||
const first = openHeadings[0];
|
||||
if (first.name) {
|
||||
return scoped({ version: first.version, name: first.name }, SCOPE.COMPLETE);
|
||||
}
|
||||
return scoped({ version: first.version, name: null }, SCOPE.TRUNCATED);
|
||||
}
|
||||
const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
|
||||
return {
|
||||
version: versionMatch ? versionMatch[0] : 'v1.0',
|
||||
name: 'milestone',
|
||||
};
|
||||
|
||||
// No usable milestone heading anywhere. A version token mentioned ONLY
|
||||
// inside an excluded `### Phase N: … vX.Y …` heading is not evidence
|
||||
// (#3197) and must not be reported as if it were a real version — value
|
||||
// stays null, scope UNSCOPED. A version token mentioned OUTSIDE any Phase
|
||||
// heading (prose, a bullet, a non-milestone heading) is weak-but-real
|
||||
// evidence — version retained, name null, scope TRUNCATED.
|
||||
const withoutPhaseHeadingLines = cleaned.replace(/^#{1,4}\s*Phase\s+\S[^\n]*$/gim, '');
|
||||
const bareVersionMatch = withoutPhaseHeadingLines.match(/v\d+(?:\.\d+)+/i);
|
||||
if (bareVersionMatch) {
|
||||
return scoped({ version: bareVersionMatch[0], name: null }, SCOPE.TRUNCATED);
|
||||
}
|
||||
|
||||
// Free-form legacy ROADMAP with no version anywhere reachable, OR the
|
||||
// only version-bearing heading was a `### Phase N` heading. §7.1's
|
||||
// "free-form is COMPLETE" governs WINDOWING (whole document is the
|
||||
// window); identity has no version to report and must not invent one.
|
||||
return scoped(null, SCOPE.UNSCOPED);
|
||||
} catch (err) {
|
||||
// This function has no existsSync guard, so an absent ROADMAP arrives here too, as a
|
||||
// synthetic Error with no errno. Only a real read fault is reported; the populated
|
||||
// default is returned unchanged either way, and a plausible-looking default needs the
|
||||
// diagnostic more than an empty sentinel does, not less (ADR-1411).
|
||||
// synthetic Error with no errno. Only a real read fault is reported; `value: null` is
|
||||
// returned unchanged either way, and a plausible-looking default needs the diagnostic
|
||||
// more than an empty sentinel does, not less (ADR-1411).
|
||||
if (roadmapPath !== undefined) reportUnreadableRoadmap(err, roadmapPath);
|
||||
return { version: 'v1.0', name: 'milestone' };
|
||||
return scoped(null, SCOPE.UNREADABLE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1156,6 +1358,7 @@ export = {
|
||||
withPhaseSection,
|
||||
computeMilestoneSectionEnd,
|
||||
locateMilestoneHeadings,
|
||||
listMilestoneHeadings,
|
||||
selectMilestoneHeading,
|
||||
classifyMilestoneWindow,
|
||||
// #3184: the sole "give me this version's window" composition — see its
|
||||
|
||||
@@ -20,7 +20,7 @@ import phaseLocatorMod = require('./phase-locator.cjs');
|
||||
const { findPhaseInternal } = phaseLocatorMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import roadmapParserModule = require('./roadmap-parser.cjs');
|
||||
const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, replaceInCurrentMilestone } = roadmapParserModule;
|
||||
const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, replaceInCurrentMilestone, listMilestoneHeadings } = roadmapParserModule;
|
||||
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
|
||||
import { updateTableCell } from './markdown-table.cjs';
|
||||
import { clampPercent } from './phase-lifecycle.cjs';
|
||||
@@ -449,16 +449,14 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
|
||||
});
|
||||
}
|
||||
|
||||
// Extract milestone info
|
||||
const milestones: Array<{ heading: string; version: string }> = [];
|
||||
const milestonePattern = /##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi;
|
||||
let mMatch: RegExpExecArray | null;
|
||||
while ((mMatch = milestonePattern.exec(content)) !== null) {
|
||||
milestones.push({
|
||||
heading: mMatch[1].trim(),
|
||||
version: 'v' + mMatch[2],
|
||||
});
|
||||
}
|
||||
// Extract milestone info. #3216: routed through the canonical
|
||||
// `listMilestoneHeadings` owner (deleted the inline `##…` regex, which
|
||||
// truncated names at a parenthetical and had no phase-heading exclusion)
|
||||
// rather than re-deriving the enumeration here.
|
||||
const milestones: Array<{ heading: string; version: string }> = listMilestoneHeadings(content).map((m) => ({
|
||||
heading: m.heading,
|
||||
version: m.version,
|
||||
}));
|
||||
|
||||
// Find current and next phase
|
||||
const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null;
|
||||
|
||||
@@ -1659,14 +1659,46 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
|
||||
|
||||
let milestone: string | null = null;
|
||||
let milestoneName: string | null = null;
|
||||
// #1761 regression fix (#3216): the milestone STATE.md actually ASSERTS,
|
||||
// independent of whether getMilestoneInfo's identity scope is COMPLETE.
|
||||
// Needed below by the disk-scan block's `isMilestoneBoundedInRoadmap` guard
|
||||
// — that check answers "is the ASSERTED version bounded to a versioned
|
||||
// ROADMAP heading", a different question from "is the identity trustworthy
|
||||
// enough to persist" (`milestone` above). Conflating the two regressed
|
||||
// #1761: when a real STATE `milestone:` value has no matching ROADMAP
|
||||
// heading, `info.scope` is never COMPLETE (rightly — there's no curated
|
||||
// name to persist), but the version was still genuinely asserted and the
|
||||
// bounded check must still run on it, or the guard silently no-ops and
|
||||
// `state json` reports a conflated whole-document total_phases/percent.
|
||||
let assertedMilestoneVersion: string | null = null;
|
||||
if (cwd) {
|
||||
// DEAD catch removed (#2245 audit): getMilestoneInfo has its own outer
|
||||
// try/catch (roadmap-parser.cts) that already swallows every internal
|
||||
// failure and always returns a MilestoneInfo — it never throws, so this
|
||||
// failure and always returns a ScopedResult — it never throws, so this
|
||||
// wrapper could never be triggered.
|
||||
// #3216 (ADR-3180 §7.2 rule 6): this is the #3197 disk-write path. Rule 6
|
||||
// draws the line at the FIELD, not the scope as a whole — "a version known
|
||||
// but no name resolvable is TRUNCATED carrying {version, name: null} — the
|
||||
// version is a real answer, the name is a non-answer, and collapsing the
|
||||
// two is the failure this contract exists to prevent." So `milestone`
|
||||
// (the version) is written whenever COMPLETE or TRUNCATED — both carry a
|
||||
// genuine version per rule 6 — while `milestoneName` is written only on
|
||||
// COMPLETE, since TRUNCATED's name is by definition unresolved and must
|
||||
// never be fabricated. UNSCOPED/UNREADABLE have no real version either
|
||||
// way, so both stay null there. This mirrors cmdCommit (src/commands.cts),
|
||||
// which accepts COMPLETE or TRUNCATED for the same reason (the version is
|
||||
// real), and deliberately diverges from archivePhaseDirectories
|
||||
// (src/milestone.cts), which demands COMPLETE only because it uses the
|
||||
// value as a filesystem path component and a TRUNCATED version is not
|
||||
// safe to use there.
|
||||
const info = getMilestoneInfo(cwd);
|
||||
milestone = info.version;
|
||||
milestoneName = info.name;
|
||||
assertedMilestoneVersion = info.value ? info.value.version : null;
|
||||
if ((info.scope === SCOPE.COMPLETE || info.scope === SCOPE.TRUNCATED) && info.value) {
|
||||
milestone = info.value.version;
|
||||
}
|
||||
if (info.scope === SCOPE.COMPLETE && info.value) {
|
||||
milestoneName = info.value.name;
|
||||
}
|
||||
}
|
||||
|
||||
let totalPhases: number | null = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
|
||||
@@ -1783,13 +1815,22 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined, sto
|
||||
// phase-dir count only, and mark unbounded so percent is skipped
|
||||
// downstream (mirrors the sync write-path guard).
|
||||
let milestoneBounded = true;
|
||||
if (milestone && roadmapRaw !== null) {
|
||||
// #3216 fix (#1761 regression): use `assertedMilestoneVersion` —
|
||||
// the version STATE.md actually asserts — not the scope-gated
|
||||
// `milestone`. `milestone` is null on any non-COMPLETE identity
|
||||
// scope (deliberately, so a non-trustworthy identity never
|
||||
// persists), but a real asserted version with no matching
|
||||
// ROADMAP heading is EXACTLY the unbounded case this guard exists
|
||||
// to catch; gating on `milestone` skipped the guard entirely and
|
||||
// let the whole-document roadmapPhaseCount conflate sibling
|
||||
// milestones again.
|
||||
if (assertedMilestoneVersion && roadmapRaw !== null) {
|
||||
// #3184: routed through the single owner (roadmap-parser.cjs)
|
||||
// instead of a hand-rolled, unbounded-substring re-derivation —
|
||||
// the prior inline regex had no boundary assertion after the
|
||||
// version token, so `v2.0` matched inside `v2.0.1` (#2562-class
|
||||
// defect, design row 17).
|
||||
milestoneBounded = isMilestoneBoundedInRoadmap(roadmapRaw, String(milestone).trim());
|
||||
milestoneBounded = isMilestoneBoundedInRoadmap(roadmapRaw, String(assertedMilestoneVersion).trim());
|
||||
}
|
||||
// #2828: distinguish a FLAT unmilestoned roadmap (no milestone sectioning
|
||||
// at all — only Phase headings) from a MILESTONED-but-unbounded one
|
||||
|
||||
@@ -2320,7 +2320,7 @@ function cmdValidateHealth(
|
||||
fs.copyFileSync(statePath, backupPath);
|
||||
repairActions.push({ action: 'backupState', success: true, path: backupPath });
|
||||
}
|
||||
const milestone = getMilestoneInfo(cwd);
|
||||
const milestone = getMilestoneInfo(cwd).value;
|
||||
const projectRef = path
|
||||
.relative(cwd, path.join(rootBase, 'PROJECT.md'))
|
||||
.split(path.sep)
|
||||
@@ -2329,7 +2329,7 @@ function cmdValidateHealth(
|
||||
stateContent += `## Project Reference\n\n`;
|
||||
stateContent += `See: ${projectRef}\n\n`;
|
||||
stateContent += `## Position\n\n`;
|
||||
stateContent += `**Milestone:** ${milestone.version} ${milestone.name}\n`;
|
||||
stateContent += `**Milestone:** ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n`;
|
||||
stateContent += `**Current phase:** (determining...)\n`;
|
||||
stateContent += `**Status:** Resuming\n\n`;
|
||||
stateContent += `## Session Log\n\n`;
|
||||
|
||||
@@ -157,8 +157,8 @@ function cmdWorkstreamCreate(cwd: string, name: string | null | undefined, optio
|
||||
existingWsName = slugged;
|
||||
} else {
|
||||
try {
|
||||
const milestone = getMilestoneInfo(cwd);
|
||||
existingWsName = generateSlugInternal(milestone.name) || 'default';
|
||||
const milestone = getMilestoneInfo(cwd).value;
|
||||
existingWsName = generateSlugInternal(milestone?.name ?? null) || 'default';
|
||||
} catch {
|
||||
existingWsName = 'default';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user