@opengsd/gsd-core 1.7.0-rc.6 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +14 -0
- package/README.md +2 -0
- package/agents/gsd-debug-session-manager.md +42 -4
- package/agents/gsd-debugger.md +87 -29
- package/agents/gsd-executor.md +31 -3
- package/agents/gsd-planner.md +29 -36
- package/agents/gsd-security-auditor.md +13 -15
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +1157 -84
- package/commands/gsd/ai-integration-phase.md +1 -1
- package/commands/gsd/mempalace-capture.md +31 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/plan-phase.md +5 -3
- package/commands/gsd/plan-review-convergence.md +3 -2
- package/commands/gsd/surface.md +6 -6
- package/gsd-core/bin/gsd-tools.cjs +1866 -2434
- package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
- package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
- package/gsd-core/bin/lib/api-coverage.cjs +341 -49
- package/gsd-core/bin/lib/audit.cjs +7 -6
- package/gsd-core/bin/lib/broken-windows.cjs +716 -0
- package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
- package/gsd-core/bin/lib/capability-registry.cjs +157 -88
- package/gsd-core/bin/lib/capability-writer.cjs +6 -1
- package/gsd-core/bin/lib/check-command-router.cjs +129 -26
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
- package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
- package/gsd-core/bin/lib/clock.cjs +19 -0
- package/gsd-core/bin/lib/command-aliases.cjs +14 -0
- package/gsd-core/bin/lib/commands.cjs +129 -13
- package/gsd-core/bin/lib/config-loader.cjs +20 -4
- package/gsd-core/bin/lib/config.cjs +81 -18
- package/gsd-core/bin/lib/core-utils.cjs +14 -3
- package/gsd-core/bin/lib/decisions.cjs +32 -8
- package/gsd-core/bin/lib/docs.cjs +6 -0
- package/gsd-core/bin/lib/drift.cjs +4 -4
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
- package/gsd-core/bin/lib/frontmatter.cjs +22 -0
- package/gsd-core/bin/lib/gap-checker.cjs +17 -2
- package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
- package/gsd-core/bin/lib/init.cjs +138 -60
- package/gsd-core/bin/lib/install-engine.cjs +301 -25
- package/gsd-core/bin/lib/install-profiles.cjs +239 -1
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
- package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
- package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
- package/gsd-core/bin/lib/markdown-table.cjs +698 -0
- package/gsd-core/bin/lib/milestone.cjs +463 -43
- package/gsd-core/bin/lib/model-catalog.cjs +19 -4
- package/gsd-core/bin/lib/model-resolver.cjs +189 -7
- package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
- package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
- package/gsd-core/bin/lib/phase-id.cjs +26 -4
- package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
- package/gsd-core/bin/lib/phase-locator.cjs +23 -2
- package/gsd-core/bin/lib/phase.cjs +636 -72
- package/gsd-core/bin/lib/plan-scan.cjs +73 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
- package/gsd-core/bin/lib/roadmap.cjs +113 -52
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
- package/gsd-core/bin/lib/schema-detect.cjs +2 -1
- package/gsd-core/bin/lib/security.cjs +1 -1
- package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
- package/gsd-core/bin/lib/smart-entry.cjs +73 -7
- package/gsd-core/bin/lib/state-document.cjs +7 -4
- package/gsd-core/bin/lib/state-transition.cjs +122 -46
- package/gsd-core/bin/lib/state.cjs +456 -137
- package/gsd-core/bin/lib/surface.cjs +53 -11
- package/gsd-core/bin/lib/template.cjs +2 -1
- package/gsd-core/bin/lib/uat.cjs +474 -13
- package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
- package/gsd-core/bin/lib/validate.cjs +12 -8
- package/gsd-core/bin/lib/verification.cjs +112 -17
- package/gsd-core/bin/lib/verify.cjs +224 -25
- package/gsd-core/bin/lib/workstream.cjs +3 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
- package/gsd-core/bin/lib/write-set.cjs +38 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
- package/gsd-core/references/api-coverage.md +37 -7
- package/gsd-core/references/checkpoints.md +13 -1
- package/gsd-core/references/common-bug-patterns.md +13 -0
- package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
- package/gsd-core/references/debugger-fix-acceptance.md +157 -0
- package/gsd-core/references/debugger-philosophy.md +1 -0
- package/gsd-core/references/debugger-prevention.md +98 -0
- package/gsd-core/references/debugger-rca-branching.md +98 -0
- package/gsd-core/references/debugger-repro-hardening.md +130 -0
- package/gsd-core/references/debugger-sbfl.md +110 -0
- package/gsd-core/references/debugger-semantic-recall.md +81 -0
- package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
- package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
- package/gsd-core/references/execute-phase-response-language.md +7 -0
- package/gsd-core/references/planner-antipatterns.md +6 -0
- package/gsd-core/references/planner-mvp-mode.md +12 -13
- package/gsd-core/references/planner-preconditions.md +156 -0
- package/gsd-core/references/planner-reversibility.md +132 -0
- package/gsd-core/references/reviewer-instances.md +9 -7
- package/gsd-core/references/skeleton-template.md +1 -1
- package/gsd-core/references/thinking-models-planning.md +3 -1
- package/gsd-core/templates/DEBUG.md +5 -3
- package/gsd-core/workflows/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +4 -2
- package/gsd-core/workflows/add-todo.md +32 -1
- package/gsd-core/workflows/ai-integration-phase.md +4 -2
- package/gsd-core/workflows/audit-fix.md +2 -2
- package/gsd-core/workflows/check-todos.md +3 -1
- package/gsd-core/workflows/cleanup.md +7 -1
- package/gsd-core/workflows/code-review.md +17 -5
- package/gsd-core/workflows/complete-milestone.md +3 -0
- package/gsd-core/workflows/debug.md +27 -5
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- package/gsd-core/workflows/discovery-phase.md +7 -0
- package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
- package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
- package/gsd-core/workflows/do.md +7 -1
- package/gsd-core/workflows/docs-update.md +1 -0
- package/gsd-core/workflows/eval-review.md +3 -0
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
- package/gsd-core/workflows/execute-phase.md +30 -37
- package/gsd-core/workflows/execute-plan.md +15 -4
- package/gsd-core/workflows/fast.md +8 -22
- package/gsd-core/workflows/graduation.md +3 -0
- package/gsd-core/workflows/health.md +7 -1
- package/gsd-core/workflows/help/modes/full.md +6 -2
- package/gsd-core/workflows/import.md +8 -2
- package/gsd-core/workflows/inbox.md +7 -0
- package/gsd-core/workflows/ingest-docs.md +15 -10
- package/gsd-core/workflows/manager.md +3 -1
- package/gsd-core/workflows/map-codebase.md +4 -4
- package/gsd-core/workflows/mvp-phase.md +3 -0
- package/gsd-core/workflows/new-milestone.md +69 -21
- package/gsd-core/workflows/new-project.md +17 -15
- package/gsd-core/workflows/new-workspace.md +3 -1
- package/gsd-core/workflows/onboard.md +3 -0
- package/gsd-core/workflows/plan-phase.md +14 -5
- package/gsd-core/workflows/plan-review-convergence.md +48 -3
- package/gsd-core/workflows/plant-seed.md +3 -0
- package/gsd-core/workflows/profile-user.md +7 -1
- package/gsd-core/workflows/progress.md +33 -5
- package/gsd-core/workflows/quick.md +21 -7
- package/gsd-core/workflows/remove-workspace.md +3 -0
- package/gsd-core/workflows/review.md +123 -68
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +4 -1
- package/gsd-core/workflows/settings-integrations.md +3 -0
- package/gsd-core/workflows/settings.md +3 -0
- package/gsd-core/workflows/ship.md +58 -5
- package/gsd-core/workflows/sketch.md +3 -0
- package/gsd-core/workflows/smart-entry.md +3 -0
- package/gsd-core/workflows/spec-phase.md +1 -1
- package/gsd-core/workflows/spike.md +7 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +3 -1
- package/gsd-core/workflows/ui-review.md +3 -0
- package/gsd-core/workflows/undo.md +7 -0
- package/gsd-core/workflows/update.md +2 -0
- package/gsd-core/workflows/validate-phase.md +3 -0
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/gsd-core/workflows/verify-work.md +7 -3
- package/hooks/dist/gsd-context-monitor.js +27 -9
- package/hooks/dist/gsd-statusline.js +252 -17
- package/hooks/gsd-context-monitor.js +27 -9
- package/hooks/gsd-statusline.js +252 -17
- package/package.json +8 -4
- package/pi/gsd.cjs +8 -2
- package/scripts/changeset/lint.cjs +1 -0
- package/scripts/changeset/parse.cjs +26 -0
- package/scripts/check-glossary-refs.cjs +220 -0
- package/scripts/ci-rebase-check.cjs +48 -4
- package/scripts/ci-test-scope.cjs +39 -1
- package/scripts/gen-adr-index.cjs +526 -0
- package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
- package/scripts/gen-install-tree-fixtures.cjs +75 -0
- package/scripts/gen-test-timings.cjs +201 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-portable-timeout.cjs +140 -0
- package/scripts/lint-table-schema-drift.cjs +157 -0
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/release-tarball-smoke.cjs +18 -11
- package/scripts/run-tests.cjs +420 -58
- package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +31 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-plan-phase/SKILL.md +5 -3
- package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
- package/skills/gsd-surface/SKILL.md +6 -6
- package/vscode/package.json +1 -1
|
@@ -38,7 +38,7 @@ const phaseLocatorMod = require("./phase-locator.cjs");
|
|
|
38
38
|
const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
|
|
39
39
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
|
|
40
40
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
41
|
-
const { stripShippedMilestones, extractCurrentMilestone, getMilestonePhaseFilter } = roadmapParserMod;
|
|
41
|
+
const { stripShippedMilestones, extractCurrentMilestone, getMilestonePhaseFilter, currentMilestoneRawRanges, withPhaseSection } = roadmapParserMod;
|
|
42
42
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
|
43
43
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
44
44
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
@@ -49,6 +49,8 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
|
|
|
49
49
|
const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
|
|
50
50
|
const clock_cjs_1 = require("./clock.cjs");
|
|
51
51
|
const state_transition_cjs_1 = require("./state-transition.cjs");
|
|
52
|
+
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
53
|
+
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
52
54
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
|
|
53
55
|
const uatPredicate = require("./uat-predicate.cjs");
|
|
54
56
|
const { evaluateUatPassed } = uatPredicate;
|
|
@@ -67,6 +69,56 @@ const looksLikePlanFile = (f) => /\.md$/i.test(f) &&
|
|
|
67
69
|
/PLAN/i.test(f) &&
|
|
68
70
|
!PLAN_OUTLINE_RE.test(f) &&
|
|
69
71
|
!PLAN_PRE_BOUNCE_RE.test(f);
|
|
72
|
+
/**
|
|
73
|
+
* Scope an `updateTableCell` call to the `## Traceability` (or
|
|
74
|
+
* `## Traceability Status`) heading's own section — up to the next H1/H2
|
|
75
|
+
* heading — instead of handing it the WHOLE REQUIREMENTS.md content.
|
|
76
|
+
*
|
|
77
|
+
* F1 (#2245 review, BLOCKER): `updateTableCell` binds to the FIRST GFM table
|
|
78
|
+
* found in whatever text it is given. The shipped requirements template
|
|
79
|
+
* (gsd-core/templates/requirements.md) puts an `## Out of Scope` table
|
|
80
|
+
* (`| Feature | Reason |`, no `Status` column) BEFORE `## Traceability` — so
|
|
81
|
+
* an unscoped whole-file call targets the Out-of-Scope table instead, fails
|
|
82
|
+
* with `{ok:false, reason:'unknown column: Status'}`, and the real
|
|
83
|
+
* Traceability row is never flipped, while the checkbox surface still flips
|
|
84
|
+
* and the command reports success (the #2140 silent-divergence class one
|
|
85
|
+
* level deeper). Mirrors `editProgressHeadingSlice` below, which scopes
|
|
86
|
+
* `## Progress` writes to that heading's own slice for the same reason.
|
|
87
|
+
*
|
|
88
|
+
* Falls back to running `updateTableCell` against the whole `text` when no
|
|
89
|
+
* `## Traceability` heading exists — matching the previous (unscoped)
|
|
90
|
+
* behaviour for a REQUIREMENTS.md whose traceability table sits under some
|
|
91
|
+
* other heading, or with no heading at all (never worse than before this fix).
|
|
92
|
+
*/
|
|
93
|
+
function updateTraceabilityCell(text, match, column, newValue) {
|
|
94
|
+
const headingMatch = text.match(/^##[ \t]+Traceability(?:[ \t]+Status)?\b/im);
|
|
95
|
+
if (!headingMatch || headingMatch.index === undefined) {
|
|
96
|
+
return (0, markdown_table_cjs_1.updateTableCell)(text, match, column, newValue);
|
|
97
|
+
}
|
|
98
|
+
const headingOffset = headingMatch.index;
|
|
99
|
+
const before = text.slice(0, headingOffset);
|
|
100
|
+
const fromHeading = text.slice(headingOffset);
|
|
101
|
+
const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
|
|
102
|
+
const scoped = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
|
|
103
|
+
const after = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
|
|
104
|
+
const result = (0, markdown_table_cjs_1.updateTableCell)(scoped, match, column, newValue);
|
|
105
|
+
if (!result.ok)
|
|
106
|
+
return result;
|
|
107
|
+
return { ok: true, value: before + result.value + after };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Extract the MAJOR version segment from a version-ish string: "v1", "v1.3",
|
|
111
|
+
* "V1.0", and "1.0" all yield "1"; "v2" yields "2". Used (#2334 BLOCKER fix)
|
|
112
|
+
* to compare a `## v<N> ...` REQUIREMENTS.md heading against the current
|
|
113
|
+
* milestone's version at MAJOR-version granularity only — "v1" heading vs
|
|
114
|
+
* milestone "v1.3" is the SAME major version and must not be treated as a
|
|
115
|
+
* version mismatch. Returns null when `raw` has no leading digit run (not a
|
|
116
|
+
* version-shaped string), which the caller treats as "cannot resolve".
|
|
117
|
+
*/
|
|
118
|
+
function extractMajorVersion(raw) {
|
|
119
|
+
const m = raw.trim().match(/^v?(\d+)/i);
|
|
120
|
+
return m ? m[1] : null;
|
|
121
|
+
}
|
|
70
122
|
function describeNonCanonicalPlans(dirFiles, matchedFiles) {
|
|
71
123
|
const matched = new Set(matchedFiles);
|
|
72
124
|
const offenders = dirFiles.filter((f) => looksLikePlanFile(f) && !matched.has(f));
|
|
@@ -88,8 +140,13 @@ function extractCanonicalPlanId(filename) {
|
|
|
88
140
|
// or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
|
|
89
141
|
// so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
|
|
90
142
|
const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
|
|
143
|
+
// #2232: the PAIRED plan component is a zero-padded continuation segment
|
|
144
|
+
// (exactly 2 digits), so a ≥3-digit slug word (a year) is not paired into a
|
|
145
|
+
// bogus "14-2026" id. The leading phase component keeps tokenRe's unbounded
|
|
146
|
+
// \d{2,} — phase numbers ≥100 are legitimate; only continuations are capped.
|
|
147
|
+
const planTokenRe = new RegExp(`^(?:${phaseIdMod.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?|\\d[A-Z])(?:\\.\\d+)*$`, 'i');
|
|
91
148
|
const phaseIdx = parts.findIndex((p) => tokenRe.test(p));
|
|
92
|
-
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length &&
|
|
149
|
+
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && planTokenRe.test(parts[phaseIdx + 1])) {
|
|
93
150
|
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
|
|
94
151
|
}
|
|
95
152
|
return base;
|
|
@@ -313,9 +370,21 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
313
370
|
.filter((e) => e.isDirectory())
|
|
314
371
|
.map((e) => e.name)
|
|
315
372
|
.sort((a, b) => comparePhaseNum(a, b));
|
|
316
|
-
|
|
317
|
-
|
|
373
|
+
// #2237: fail loud when multiple directories match the same bare phase
|
|
374
|
+
// number — prevents cross-project file writes when unrelated projects
|
|
375
|
+
// share a .planning/phases/ tree.
|
|
376
|
+
const matches = dirs.filter((d) => phaseTokenMatches(d, normalized));
|
|
377
|
+
if (matches.length === 0)
|
|
318
378
|
continue;
|
|
379
|
+
if (matches.length > 1) {
|
|
380
|
+
output({
|
|
381
|
+
...notFound,
|
|
382
|
+
ambiguous_matches: matches,
|
|
383
|
+
warning: `Phase ${normalized} is ambiguous: ${matches.length} directories match (${matches.map(m => `"${m}"`).join(', ')}). Set a distinct project_code in .planning/config.json to scope resolution.`,
|
|
384
|
+
}, raw, '');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const match = matches[0];
|
|
319
388
|
const dirMatch = match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
|
|
320
389
|
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
|
321
390
|
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
|
@@ -552,6 +621,29 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
552
621
|
result['warnings'] = warnings;
|
|
553
622
|
output(result, raw);
|
|
554
623
|
}
|
|
624
|
+
// #2390 — phase.add title-shape heuristic. A description at or under this many
|
|
625
|
+
// characters, and with no sentence-ending punctuation followed by more text,
|
|
626
|
+
// reads as a short Title. Anything longer or multi-sentence reads as a Goal,
|
|
627
|
+
// not a Title. phase.add still writes the phase verbatim (it never mangles
|
|
628
|
+
// ROADMAP.md), but when the description looks goal-shaped the JSON result
|
|
629
|
+
// gains a `warning` key naming the gap, so the caller — or the orchestrating
|
|
630
|
+
// add-phase workflow — can split title vs. goal instead of the whole paragraph
|
|
631
|
+
// landing silently in the `### Phase N:` header.
|
|
632
|
+
const PHASE_ADD_TITLE_MAX_LEN = 80;
|
|
633
|
+
const PHASE_ADD_MULTI_SENTENCE_RE = /[.!?]['")\]]?\s+\S/;
|
|
634
|
+
function describeGoalShapedTitle(description) {
|
|
635
|
+
const trimmed = description.trim();
|
|
636
|
+
const tooLong = trimmed.length > PHASE_ADD_TITLE_MAX_LEN;
|
|
637
|
+
const multiSentence = PHASE_ADD_MULTI_SENTENCE_RE.test(trimmed);
|
|
638
|
+
if (!tooLong && !multiSentence)
|
|
639
|
+
return null;
|
|
640
|
+
const reasons = [
|
|
641
|
+
tooLong ? `${trimmed.length} chars (over the ${PHASE_ADD_TITLE_MAX_LEN}-char title threshold)` : null,
|
|
642
|
+
multiSentence ? 'multiple sentences' : null,
|
|
643
|
+
].filter(Boolean).join(', ');
|
|
644
|
+
return (`description looks goal-shaped, not title-shaped (${reasons}). It was written verbatim ` +
|
|
645
|
+
`as the phase title; consider a short title with the detail moved to **Goal:**.`);
|
|
646
|
+
}
|
|
555
647
|
function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
556
648
|
if (!description) {
|
|
557
649
|
error('description required for phase add');
|
|
@@ -639,6 +731,7 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
639
731
|
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent);
|
|
640
732
|
return { newPhaseId: _newPhaseId, dirName: _dirName };
|
|
641
733
|
});
|
|
734
|
+
const titleWarning = describeGoalShapedTitle(description);
|
|
642
735
|
const result = {
|
|
643
736
|
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
|
|
644
737
|
padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
|
|
@@ -647,7 +740,9 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
647
740
|
directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
|
|
648
741
|
naming_mode: config.phase_naming,
|
|
649
742
|
};
|
|
650
|
-
|
|
743
|
+
if (titleWarning)
|
|
744
|
+
result['warning'] = titleWarning;
|
|
745
|
+
output(result, raw, result['padded']);
|
|
651
746
|
}
|
|
652
747
|
function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
653
748
|
if (!Array.isArray(descriptions) || descriptions.length === 0) {
|
|
@@ -760,8 +855,27 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
760
855
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
761
856
|
const normalizedBase = normalizePhaseName(afterPhase);
|
|
762
857
|
const decimalSet = new Set();
|
|
763
|
-
|
|
764
|
-
|
|
858
|
+
// #2245 audit: existsSync-guarded, mirroring cmdPhaseNextDecimal's identical
|
|
859
|
+
// scan above — a missing phasesDir (no decimal sub-phases yet) is the
|
|
860
|
+
// expected, silent case (empty decimalSet). A readdirSync failure once the
|
|
861
|
+
// dir is confirmed to EXIST is a genuine anomaly; swallowing it used to let
|
|
862
|
+
// `phase insert` proceed with an incomplete decimalSet and risk writing a
|
|
863
|
+
// decimal phase number that collides with an existing on-disk directory
|
|
864
|
+
// the scan simply never saw — surfaced loud instead, like the sibling.
|
|
865
|
+
if (node_fs_1.default.existsSync(phasesDir)) {
|
|
866
|
+
// Initialized (not just declared) so TS's definite-assignment check is
|
|
867
|
+
// satisfied without relying on control-flow narrowing through error()'s
|
|
868
|
+
// `never` return, which TS does not propagate through a destructured
|
|
869
|
+
// module-property function reference — error() still halts the process
|
|
870
|
+
// before `dirs` below is ever computed from this placeholder value.
|
|
871
|
+
let entries = [];
|
|
872
|
+
try {
|
|
873
|
+
entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
874
|
+
}
|
|
875
|
+
catch (e) {
|
|
876
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
877
|
+
error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
|
|
878
|
+
}
|
|
765
879
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
766
880
|
const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`);
|
|
767
881
|
for (const dir of dirs) {
|
|
@@ -770,9 +884,6 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
770
884
|
decimalSet.add(parseInt(dm[1], 10));
|
|
771
885
|
}
|
|
772
886
|
}
|
|
773
|
-
catch {
|
|
774
|
-
/* intentionally empty */
|
|
775
|
-
}
|
|
776
887
|
const rmPhasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
|
|
777
888
|
let rmMatch;
|
|
778
889
|
while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
|
|
@@ -940,19 +1051,186 @@ function decrementRoadmapPaddedPhaseNumber(raw, removedInt) {
|
|
|
940
1051
|
return raw;
|
|
941
1052
|
return String(num - 1).padStart(raw.length, '0');
|
|
942
1053
|
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Return the RAW text of the `dataRowIndex`-th data row line (0-based, in
|
|
1056
|
+
* file order — header and delimiter rows excluded) of the FIRST GFM table
|
|
1057
|
+
* found in `sectionText`, or `null` when the table or that row doesn't exist.
|
|
1058
|
+
*
|
|
1059
|
+
* F8 (#2245 review, nit) support helper: addresses a table row by its
|
|
1060
|
+
* STRUCTURAL position rather than by matching its (possibly non-unique)
|
|
1061
|
+
* trimmed cell content — see the Progress-ordinal renumber's padding-recovery
|
|
1062
|
+
* use below for why content-matching is unsafe here (two rows with identical
|
|
1063
|
+
* trimmed Phase text, or a row whose already-rewritten new value coincides
|
|
1064
|
+
* with another row's pre-edit text, would otherwise resolve to the wrong line).
|
|
1065
|
+
*/
|
|
1066
|
+
function findDataRowLine(sectionText, dataRowIndex) {
|
|
1067
|
+
const lines = sectionText.split(/\r?\n/);
|
|
1068
|
+
let headerIdx = -1;
|
|
1069
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1070
|
+
const trimmed = lines[i].trim();
|
|
1071
|
+
if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
|
|
1072
|
+
headerIdx = i;
|
|
1073
|
+
break;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
if (headerIdx === -1)
|
|
1077
|
+
return null;
|
|
1078
|
+
let seen = -1;
|
|
1079
|
+
for (let i = headerIdx + 2; i < lines.length; i++) {
|
|
1080
|
+
if (!lines[i].trim().startsWith('|'))
|
|
1081
|
+
break;
|
|
1082
|
+
seen += 1;
|
|
1083
|
+
if (seen === dataRowIndex)
|
|
1084
|
+
return lines[i];
|
|
1085
|
+
}
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
943
1088
|
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
|
|
944
1089
|
withPlanningLock(cwd, () => {
|
|
945
1090
|
let content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
946
1091
|
const escaped = escapeRegex(targetPhase);
|
|
947
|
-
|
|
1092
|
+
// SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
|
|
1093
|
+
// detail section INCLUDING its own heading line. Migrated onto deleteSection
|
|
1094
|
+
// (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
|
|
1095
|
+
// tokenizeHeadings + this predicate, then splices out the range from that
|
|
1096
|
+
// heading's own start through the next heading of the SAME-OR-HIGHER level —
|
|
1097
|
+
// whatever that heading's text is. This fixes a data-loss bug in the prior
|
|
1098
|
+
// hand-rolled regex, whose lookahead only recognised ANOTHER "Phase N:"
|
|
1099
|
+
// heading as a stop boundary: removing the LAST phase in a roadmap left no
|
|
1100
|
+
// such heading to stop at, so the lazy `[\s\S]*?` scan ran to EOF and swept
|
|
1101
|
+
// away everything after it — including a trailing `## Progress` heading and
|
|
1102
|
+
// its tracking table.
|
|
1103
|
+
const phaseHeadingRe = new RegExp(`^Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
|
|
1104
|
+
content = (0, markdown_sectionizer_cjs_1.deleteSection)(content, (h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text));
|
|
948
1105
|
content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '');
|
|
949
|
-
|
|
1106
|
+
// ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
|
|
1107
|
+
// for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
|
|
1108
|
+
// of updateTableCell). Scoped to the `## Progress` section — mirroring
|
|
1109
|
+
// deriveProgressFromRoadmap's read-side scoping (phase-lifecycle.cts) —
|
|
1110
|
+
// so a same-numbered row in an earlier, unrelated table (e.g. a
|
|
1111
|
+
// `| Phase | Requirements | Count |` table preceding `## Progress`,
|
|
1112
|
+
// #2012) is never touched. Matches the row by its FIRST cell only: for an
|
|
1113
|
+
// integer removal, a zero-pad-insensitive leading-integer comparison
|
|
1114
|
+
// (`01.`, `1.`, `1 `, bare `1` all match phase 1; a decimal sub-phase
|
|
1115
|
+
// cell like `2.5` never matches an integer removal); for a decimal
|
|
1116
|
+
// removal, the exact decimal token. This replaces the prior regex's
|
|
1117
|
+
// `\.?\s` requirement, which silently left a COMPACT unpadded row (e.g.
|
|
1118
|
+
// `|2|0/2|Planned|-|`) undeleted — its closing `|` follows the digit with
|
|
1119
|
+
// no whitespace to match (#2245 audit) — and which was also unscoped to
|
|
1120
|
+
// any particular table.
|
|
1121
|
+
const progressHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
|
|
1122
|
+
if (progressHeadingMatch && progressHeadingMatch.index !== undefined) {
|
|
1123
|
+
const headingOffset = progressHeadingMatch.index;
|
|
1124
|
+
const before = content.slice(0, headingOffset);
|
|
1125
|
+
const fromHeading = content.slice(headingOffset);
|
|
1126
|
+
const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
|
|
1127
|
+
const progressSection = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
|
|
1128
|
+
const rest = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
|
|
1129
|
+
const matchRemovedProgressRow = (row) => {
|
|
1130
|
+
const firstCellRaw = (Object.values(row)[0] ?? '').trim();
|
|
1131
|
+
if (isDecimal) {
|
|
1132
|
+
return new RegExp(`^${escaped}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
|
|
1133
|
+
}
|
|
1134
|
+
const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
|
|
1135
|
+
if (!leadingMatch || leadingMatch[2])
|
|
1136
|
+
return false;
|
|
1137
|
+
return parseInt(leadingMatch[1], 10) === removedInt;
|
|
1138
|
+
};
|
|
1139
|
+
const deleteResult = (0, markdown_table_cjs_1.deleteTableRow)(progressSection, matchRemovedProgressRow);
|
|
1140
|
+
if (deleteResult.ok) {
|
|
1141
|
+
content = before + deleteResult.value + rest;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
950
1144
|
if (!isDecimal) {
|
|
951
1145
|
// #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
|
|
952
1146
|
// is re-emitted verbatim — a tagged later phase still gets renumbered.
|
|
953
1147
|
content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]{0,200}\))?\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
|
|
954
1148
|
content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
|
|
955
|
-
|
|
1149
|
+
// ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
|
|
1150
|
+
// updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
|
|
1151
|
+
// directly above). The prior whole-document regex
|
|
1152
|
+
// `/(\|\s*)(\d+)(\.\s)/g` rewrote ANY `| N. ` cell anywhere in the
|
|
1153
|
+
// file — including a same-shaped cell in an UNRELATED, earlier table
|
|
1154
|
+
// (e.g. a `| Phase | Requirements | Count |` table, or a decoy table,
|
|
1155
|
+
// preceding `## Progress`; #2245-class scoping defect, same family as
|
|
1156
|
+
// the row-delete fix above). Scoped here to the `## Progress` section
|
|
1157
|
+
// only, mirroring that same section-slice-then-splice-back pattern.
|
|
1158
|
+
//
|
|
1159
|
+
// Loops because updateTableCell only rewrites the FIRST matching row
|
|
1160
|
+
// per call. `processedOrdinalRows` tracks by row INDEX (stable across
|
|
1161
|
+
// iterations — this only edits cell content, it never inserts/deletes
|
|
1162
|
+
// rows) so an already-decremented row's new value — which may still
|
|
1163
|
+
// numerically exceed `removedInt` — is never re-selected and
|
|
1164
|
+
// decremented a second time (matching on the row's CURRENT value alone,
|
|
1165
|
+
// without this guard, would keep re-firing on each pass).
|
|
1166
|
+
//
|
|
1167
|
+
// `phaseCellShapeRe` is the exact digit+dot-space shape the old regex
|
|
1168
|
+
// required: a decimal sub-phase ordinal like `2.5` (no whitespace
|
|
1169
|
+
// between the dot and the next character) never matches it, so it is
|
|
1170
|
+
// left untouched — identical decimal-safety to the prior behaviour.
|
|
1171
|
+
//
|
|
1172
|
+
// updateTableCell hands the callback the TRIMMED, UNESCAPED cell value
|
|
1173
|
+
// only, so the row's original leading/trailing alignment padding is
|
|
1174
|
+
// recovered by a narrow, anchored lookup within that row's OWN raw
|
|
1175
|
+
// line — addressed by ROW INDEX (`matchedRowIndex`, via
|
|
1176
|
+
// `findDataRowLine`), not by searching the whole section for content
|
|
1177
|
+
// matching the trimmed value (F8 #2245 review: two rows with identical
|
|
1178
|
+
// trimmed Phase text, or a row whose already-rewritten new value
|
|
1179
|
+
// coincides with another row's pre-edit text, would otherwise resolve
|
|
1180
|
+
// to the WRONG row's padding — the first/leftmost content match found).
|
|
1181
|
+
// The lookup searches for `escapeCell(current)` (F3 #2245 review: the
|
|
1182
|
+
// ESCAPED form, e.g. `Foo \| Bar`) — the raw line always carries the
|
|
1183
|
+
// escaped form, so searching for the unescaped `current` would
|
|
1184
|
+
// silently fail to find an escaped-pipe cell's own line — preserving
|
|
1185
|
+
// every other byte of the row (ADR-2143 §7 byte-parity) while only the
|
|
1186
|
+
// digits actually change.
|
|
1187
|
+
const ordinalHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
|
|
1188
|
+
if (ordinalHeadingMatch && ordinalHeadingMatch.index !== undefined) {
|
|
1189
|
+
const ordinalHeadingOffset = ordinalHeadingMatch.index;
|
|
1190
|
+
const ordinalBefore = content.slice(0, ordinalHeadingOffset);
|
|
1191
|
+
const ordinalFromHeading = content.slice(ordinalHeadingOffset);
|
|
1192
|
+
const ordinalNextHeadingOffset = ordinalFromHeading.search(/\n#{1,2}[ \t]/);
|
|
1193
|
+
let ordinalSection = ordinalNextHeadingOffset >= 0
|
|
1194
|
+
? ordinalFromHeading.slice(0, ordinalNextHeadingOffset)
|
|
1195
|
+
: ordinalFromHeading;
|
|
1196
|
+
const ordinalRest = ordinalNextHeadingOffset >= 0 ? ordinalFromHeading.slice(ordinalNextHeadingOffset) : '';
|
|
1197
|
+
const phaseCellShapeRe = /^(\d+)(\.\s)/;
|
|
1198
|
+
const processedOrdinalRows = new Set();
|
|
1199
|
+
let matchedRowIndex = null;
|
|
1200
|
+
for (;;) {
|
|
1201
|
+
matchedRowIndex = null;
|
|
1202
|
+
const cellResult = (0, markdown_table_cjs_1.updateTableCell)(ordinalSection, (row, index) => {
|
|
1203
|
+
if (processedOrdinalRows.has(index))
|
|
1204
|
+
return false;
|
|
1205
|
+
const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
|
|
1206
|
+
if (!m)
|
|
1207
|
+
return false;
|
|
1208
|
+
const num = parseInt(m[1], 10);
|
|
1209
|
+
if (!Number.isInteger(num) || num <= removedInt || num === 999)
|
|
1210
|
+
return false;
|
|
1211
|
+
processedOrdinalRows.add(index);
|
|
1212
|
+
matchedRowIndex = index;
|
|
1213
|
+
return true;
|
|
1214
|
+
}, 'Phase', (current) => {
|
|
1215
|
+
const m = phaseCellShapeRe.exec(current);
|
|
1216
|
+
if (!m)
|
|
1217
|
+
return current;
|
|
1218
|
+
const decremented = decrementRoadmapPhaseNumber(m[1], removedInt);
|
|
1219
|
+
const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
|
|
1220
|
+
const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
|
|
1221
|
+
const padMatch = targetLine
|
|
1222
|
+
? new RegExp(`^[ \\t]*\\|(\\s*)${escapeRegex((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
|
|
1223
|
+
: null;
|
|
1224
|
+
const leadPad = padMatch ? padMatch[1] : ' ';
|
|
1225
|
+
const trailPad = padMatch ? padMatch[2] : ' ';
|
|
1226
|
+
return `${leadPad}${(0, markdown_table_cjs_1.escapeCell)(newContent)}${trailPad}`;
|
|
1227
|
+
});
|
|
1228
|
+
if (!cellResult.ok)
|
|
1229
|
+
break;
|
|
1230
|
+
ordinalSection = cellResult.value;
|
|
1231
|
+
}
|
|
1232
|
+
content = ordinalBefore + ordinalSection + ordinalRest;
|
|
1233
|
+
}
|
|
956
1234
|
content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g, (_match, phaseNum, planNum) => `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`);
|
|
957
1235
|
content = content.replace(/(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
|
|
958
1236
|
content = content.replace(/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
|
|
@@ -990,8 +1268,18 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
990
1268
|
renamedDirs = renamed.renamedDirs;
|
|
991
1269
|
renamedFiles = renamed.renamedFiles;
|
|
992
1270
|
}
|
|
993
|
-
catch {
|
|
994
|
-
|
|
1271
|
+
catch (e) {
|
|
1272
|
+
// #2245 audit (was ERROR-HIDING): renameDecimalPhases/renameIntegerPhases
|
|
1273
|
+
// rename subsequent phase directories ON DISK one at a time — a mid-loop
|
|
1274
|
+
// failure leaves SOME directories already renumbered and others not, with
|
|
1275
|
+
// no way to recover which (the callee's own renamedDirs/renamedFiles never
|
|
1276
|
+
// reach this scope when it throws). Silently swallowing this and falling
|
|
1277
|
+
// through to updateRoadmapAfterPhaseRemoval below used to rewrite
|
|
1278
|
+
// ROADMAP.md's phase numbers assuming the ENTIRE renumbering succeeded,
|
|
1279
|
+
// permanently desyncing ROADMAP.md from the actual (partially-renamed)
|
|
1280
|
+
// on-disk directory names. Surface loud instead of compounding it.
|
|
1281
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1282
|
+
error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
|
|
995
1283
|
}
|
|
996
1284
|
updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
|
|
997
1285
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
@@ -1085,7 +1373,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1085
1373
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
1086
1374
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
1087
1375
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
1088
|
-
const today = clock_cjs_1.realClock.
|
|
1376
|
+
const today = clock_cjs_1.realClock.localToday();
|
|
1089
1377
|
const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
|
|
1090
1378
|
if (!phaseInfoRaw) {
|
|
1091
1379
|
error(`Phase ${phaseNum} not found`);
|
|
@@ -1130,7 +1418,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1130
1418
|
}
|
|
1131
1419
|
}
|
|
1132
1420
|
catch {
|
|
1133
|
-
/*
|
|
1421
|
+
/* best-effort (#2245 audit): this is an ADVISORY pre-scan of UAT/
|
|
1422
|
+
* VERIFICATION files for `warnings` in the phase-complete output — the
|
|
1423
|
+
* actual completion GATE is readVerificationStatus below (a separate
|
|
1424
|
+
* mechanism). A readdirSync/readFileSync failure here just means fewer
|
|
1425
|
+
* warnings are surfaced this run, not a blocked or corrupted completion. */
|
|
1134
1426
|
}
|
|
1135
1427
|
let nextPhaseNum = null;
|
|
1136
1428
|
let nextPhaseName = null;
|
|
@@ -1153,50 +1445,143 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1153
1445
|
// so completing an already-checked phase (idempotent re-run) checked the
|
|
1154
1446
|
// wrong phase's box. Mirrors the tight pattern used by phase-insert
|
|
1155
1447
|
// (`]\\s*(?:\\*\\*)?Phase`).
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1448
|
+
// #2067/#2200: line-anchored (^, optional leading indent) so an
|
|
1449
|
+
// inline / backticked prose literal cannot match. Milestone-scoped below
|
|
1450
|
+
// (mutateMilestonePhase) so a Backlog entry or a same-numbered shipped-
|
|
1451
|
+
// milestone phase cannot be flipped either.
|
|
1452
|
+
// ADR-2143 §4 note / #2245 audit: this is the phase-LIST checkbox — it
|
|
1453
|
+
// lives in the milestone's `- [ ] Phase N: …` checklist, OUTSIDE any
|
|
1454
|
+
// `### Phase N` detail section, so there is no section for
|
|
1455
|
+
// withPhaseSection to bind to. Migrated onto the sectionizer's
|
|
1456
|
+
// `updateBullet` bullet-write seam: the pattern itself is unchanged,
|
|
1457
|
+
// only the "find the right line, splice it back" plumbing moved off a
|
|
1458
|
+
// whole-slice `.replace()` onto the seam. Applied per single physical
|
|
1459
|
+
// line by updateBullet, so the pattern no longer needs the `m` flag
|
|
1460
|
+
// (it never sees more than one line at a time); see
|
|
1461
|
+
// planCountBodyPattern below for the sites that were migrated onto
|
|
1462
|
+
// withPhaseSection instead.
|
|
1463
|
+
//
|
|
1464
|
+
// #2245 review Fix 6: this is behaviour-preserving for GSD-GENERATED
|
|
1465
|
+
// inputs (the only shape ROADMAP.md ever actually has), NOT byte-parity
|
|
1466
|
+
// across every conceivable input. `updateBullet` is fence-aware — a
|
|
1467
|
+
// checkbox-shaped line inside a fenced (``` / ~~~) code block is never
|
|
1468
|
+
// offered to `match`/`transform` — whereas the retired whole-slice
|
|
1469
|
+
// `.replace()` had no such fence tracking and would have flipped a
|
|
1470
|
+
// bullet-shaped line inside a fence too. That divergence has no live
|
|
1471
|
+
// bug because a GSD-authored ROADMAP.md milestone checklist never puts
|
|
1472
|
+
// its own `- [ ] Phase N: …` entries inside a fenced code block, but it
|
|
1473
|
+
// is a real (and correct) behavioural difference on pathological input.
|
|
1474
|
+
const checkboxPattern = new RegExp(`^[ \\t]*(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
|
|
1475
|
+
// Progress table row: update Plans Complete/Status/Completed columns BY
|
|
1476
|
+
// COLUMN NAME (handles 4- or 5-column RoadmapProgress tables) via the
|
|
1477
|
+
// markdown-table seam (ADR-2143 §7) — supersedes the prior ordinal
|
|
1478
|
+
// cells[]-index regex. Applied inside mutateMilestonePhase below (per
|
|
1479
|
+
// milestone window), further scoped to the ## Progress heading within
|
|
1480
|
+
// that window so the row lookup doesn't bind to an earlier table (e.g.
|
|
1481
|
+
// | Phase | Requirements | Count |) whose rows also start with the
|
|
1482
|
+
// phase number (#2012).
|
|
1483
|
+
// #2245 Blocker 4: optional dot must be followed by whitespace-or-end,
|
|
1484
|
+
// not dot-OR-whitespace-OR-end as alternatives — the prior form let a
|
|
1485
|
+
// bare "." satisfy the whole lookahead, so completing phase "2"
|
|
1486
|
+
// over-matched a decimal sub-phase row like "2.5 Extra". Matches "2",
|
|
1487
|
+
// "2.", "2 Alpha"; rejects "2.5 Extra".
|
|
1488
|
+
const phaseCellRe = new RegExp(`^${phaseEscaped}\\.?(?:\\s|$)`, 'i');
|
|
1489
|
+
const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
|
|
1490
|
+
const dateShape = /^\d{4}-\d{2}-\d{2}$/;
|
|
1491
|
+
/**
|
|
1492
|
+
* Within `text` (already scoped to one milestone window by the
|
|
1493
|
+
* caller), scope further to the `## Progress` heading section (up to
|
|
1494
|
+
* the next `#`/`##` heading) when present, run `edit` against just
|
|
1495
|
+
* that slice, and splice the result back — falling back to the whole
|
|
1496
|
+
* `text` when no `## Progress` heading exists (mirrors phase-
|
|
1497
|
+
* lifecycle.cjs's deriveProgressFromRoadmap read-side scoping).
|
|
1498
|
+
*/
|
|
1499
|
+
const editProgressHeadingSlice = (text, edit) => {
|
|
1500
|
+
const progressMatch = text.match(/^##[ \t]+Progress\b/im);
|
|
1501
|
+
if (!progressMatch || progressMatch.index === undefined) {
|
|
1502
|
+
return edit(text);
|
|
1178
1503
|
}
|
|
1179
|
-
|
|
1504
|
+
const headingOffset = progressMatch.index;
|
|
1505
|
+
const beforeHeading = text.slice(0, headingOffset);
|
|
1506
|
+
const fromHeading = text.slice(headingOffset);
|
|
1507
|
+
const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
|
|
1508
|
+
const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
|
|
1509
|
+
const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
|
|
1510
|
+
return beforeHeading + edit(scoped) + after;
|
|
1180
1511
|
};
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1512
|
+
// ADR-2143 §4: the plan-count write is now routed through
|
|
1513
|
+
// withPhaseSection (see mutateMilestonePhase below), which hands this
|
|
1514
|
+
// pattern ONLY phase N's own detail-section body — so the pattern no
|
|
1515
|
+
// longer needs its own `#{2,4}\s*Phase\s+N` anchor + skip-ahead-past-
|
|
1516
|
+
// interior-headings lookahead; the section boundary itself confines
|
|
1517
|
+
// the match (the #2067/#2200 boundary-crossing class is now
|
|
1518
|
+
// structurally impossible for this site rather than regex-enforced).
|
|
1519
|
+
const planCountBodyPattern = /(\*\*Plans:\*\*\s*)[^\n]+/i;
|
|
1520
|
+
const phaseInfoSummaries = phaseInfo['summaries'];
|
|
1521
|
+
// #2200: apply the phase-checkbox flip, the plan-count write, and the
|
|
1522
|
+
// per-plan checkbox flips ONLY within the current milestone's region(s)
|
|
1523
|
+
// (primary section + optional Phase Details section). A bullet/heading in
|
|
1524
|
+
// a shipped milestone, a Backlog section, or a backticked prose literal is
|
|
1525
|
+
// outside the window and stays untouched. With no versioned active
|
|
1526
|
+
// milestone, fall back to whole-content mutation (prior behaviour).
|
|
1527
|
+
const mutateMilestonePhase = (slice) => {
|
|
1528
|
+
let s = slice;
|
|
1529
|
+
s = (0, markdown_sectionizer_cjs_1.updateBullet)(s, (_bulletText, rawLine) => checkboxPattern.test(rawLine), (rawLine) => rawLine.replace(checkboxPattern, `$1x$2 (completed ${today})`));
|
|
1530
|
+
s = editProgressHeadingSlice(s, (scoped) => {
|
|
1531
|
+
let text = scoped;
|
|
1532
|
+
const plansResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
|
|
1533
|
+
if (plansResult.ok)
|
|
1534
|
+
text = plansResult.value;
|
|
1535
|
+
const statusResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Status', ' Complete ');
|
|
1536
|
+
if (statusResult.ok)
|
|
1537
|
+
text = statusResult.value;
|
|
1538
|
+
// Preserve only a valid ISO date (#1161: idempotent; self-heal
|
|
1539
|
+
// garbage). Ragged-tolerant (#2245 Blocker 2): decide via the
|
|
1540
|
+
// CURRENT Completed cell inside a single updateTableCell callback
|
|
1541
|
+
// (its own tolerant row scan) rather than gating on
|
|
1542
|
+
// findTableWithColumns (which requires the WHOLE table to parse —
|
|
1543
|
+
// a ragged SIBLING row elsewhere used to silently no-op this
|
|
1544
|
+
// row's date stamp too).
|
|
1545
|
+
const completedResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Completed', (current) => dateShape.test(current.trim()) ? current : ` ${today} `);
|
|
1546
|
+
if (completedResult.ok)
|
|
1547
|
+
text = completedResult.value;
|
|
1548
|
+
return text;
|
|
1549
|
+
});
|
|
1550
|
+
// ADR-2143 §4: the plan-count write and the per-plan checkbox flips
|
|
1551
|
+
// are both scoped to phase N's OWN detail section via
|
|
1552
|
+
// withPhaseSection — the edit callback below only ever sees that
|
|
1553
|
+
// section's body, so neither regex can escape into a sibling
|
|
1554
|
+
// phase's section, a shipped milestone, or a Backlog entry.
|
|
1555
|
+
s = withPhaseSection(s, phaseNum, (body) => {
|
|
1556
|
+
let b = body.replace(planCountBodyPattern, `$1${summaryCount}/${planCount} plans complete`);
|
|
1557
|
+
for (const summaryFile of phaseInfoSummaries) {
|
|
1558
|
+
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
|
1559
|
+
if (!planId)
|
|
1560
|
+
continue;
|
|
1561
|
+
const planEscaped = escapeRegex(planId);
|
|
1562
|
+
const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
|
|
1563
|
+
b = b.replace(planCheckboxPattern, '$1x$2');
|
|
1564
|
+
}
|
|
1565
|
+
return b;
|
|
1566
|
+
});
|
|
1567
|
+
return s;
|
|
1568
|
+
};
|
|
1569
|
+
const milestoneRanges = currentMilestoneRawRanges(roadmapContent, cwd);
|
|
1570
|
+
if (milestoneRanges) {
|
|
1571
|
+
// Splice later windows first so an earlier window's offsets are not
|
|
1572
|
+
// shifted by a length-changing mutation in a later window.
|
|
1573
|
+
const windows = [milestoneRanges.details, milestoneRanges.primary]
|
|
1574
|
+
.filter((w) => w !== null)
|
|
1575
|
+
.sort((a, b) => b.start - a.start);
|
|
1576
|
+
for (const w of windows) {
|
|
1577
|
+
roadmapContent =
|
|
1578
|
+
roadmapContent.slice(0, w.start)
|
|
1579
|
+
+ mutateMilestonePhase(roadmapContent.slice(w.start, w.end))
|
|
1580
|
+
+ roadmapContent.slice(w.end);
|
|
1581
|
+
}
|
|
1186
1582
|
}
|
|
1187
1583
|
else {
|
|
1188
|
-
roadmapContent = roadmapContent
|
|
1189
|
-
}
|
|
1190
|
-
const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, 'i');
|
|
1191
|
-
roadmapContent = roadmapContent.replace(planCountPattern, `$1${summaryCount}/${planCount} plans complete`);
|
|
1192
|
-
const phaseInfoSummaries = phaseInfo['summaries'];
|
|
1193
|
-
for (const summaryFile of phaseInfoSummaries) {
|
|
1194
|
-
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
|
1195
|
-
if (!planId)
|
|
1196
|
-
continue;
|
|
1197
|
-
const planEscaped = escapeRegex(planId);
|
|
1198
|
-
const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
|
|
1199
|
-
roadmapContent = (roadmapContent).replace(planCheckboxPattern, '$1x$2');
|
|
1584
|
+
roadmapContent = mutateMilestonePhase(roadmapContent);
|
|
1200
1585
|
}
|
|
1201
1586
|
writes.push({
|
|
1202
1587
|
filePath: roadmapPath,
|
|
@@ -1212,22 +1597,74 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1212
1597
|
const reqMatch = sectionText.match(/\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]+)/i);
|
|
1213
1598
|
const originalReqContent = node_fs_1.default.readFileSync(reqPath, 'utf-8');
|
|
1214
1599
|
let reqContent = originalReqContent;
|
|
1600
|
+
// #2316: `citedReqIds` — the REQ-IDs ROADMAP's own **Requirements:**
|
|
1601
|
+
// line for this phase actually cites — is hoisted out of the
|
|
1602
|
+
// `if (reqMatch)` block (previously scoped only inside it) so the
|
|
1603
|
+
// ghost-ID cross-check below (~#2316-1) can consult it. `TBD` is the
|
|
1604
|
+
// literal placeholder `phase.add`/`-batch`/`-insert` seed
|
|
1605
|
+
// (`**Requirements**: TBD`, src/phase.cts:833,920,1078) — never a
|
|
1606
|
+
// real REQ-ID, so it is filtered out wherever a cited-ID list feeds
|
|
1607
|
+
// a warning (#2316-7 boundary).
|
|
1608
|
+
const isPlaceholderReqId = (id) => id.toUpperCase() === 'TBD';
|
|
1609
|
+
let citedReqIds = [];
|
|
1610
|
+
// #2316-1: Traceability-row writes that matched NO row (ghost or
|
|
1611
|
+
// otherwise) — the `if (reqUpdate.ok)` below previously had no
|
|
1612
|
+
// `else`, discarding this fact silently instead of surfacing it.
|
|
1613
|
+
const traceabilityWriteMisses = [];
|
|
1215
1614
|
if (reqMatch) {
|
|
1216
|
-
|
|
1615
|
+
// #2334 HIGH 3: filter the tokenized capture to the REQ-ID SHAPE —
|
|
1616
|
+
// the SAME shape bodyReqIds (`\*\*([A-Z][A-Z0-9]*-\d+)\*\*`, below)
|
|
1617
|
+
// and tableReqIds (`([A-Z][A-Z0-9]*-\d+)`, below) already require —
|
|
1618
|
+
// so the ghost-ID / unregistered comparisons stay shape-symmetric.
|
|
1619
|
+
// Without this, `[^\n]+` split on `[,\s]+` turned EVERY word after
|
|
1620
|
+
// the ID list into a "cited REQ-ID": the shipped
|
|
1621
|
+
// `templates/roadmap.md:32` line
|
|
1622
|
+
// `**Requirements**: [REQ-01, REQ-02] <!-- brackets optional, ... -->`
|
|
1623
|
+
// warned to register `<!--`, `brackets`, `optional`, `-->`, etc., and
|
|
1624
|
+
// `**Requirements:** None` warned to register the literal word
|
|
1625
|
+
// `None`. This subsumes the `TBD` placeholder special-case (`TBD`
|
|
1626
|
+
// does not match the REQ-ID shape either); `isPlaceholderReqId` is
|
|
1627
|
+
// kept below as a defensive no-op for any caller that still hands
|
|
1628
|
+
// it a raw token.
|
|
1629
|
+
const REQ_ID_SHAPE_RE = /^[A-Z][A-Z0-9]*-\d+$/i;
|
|
1630
|
+
citedReqIds = reqMatch[1]
|
|
1217
1631
|
.replace(/[\[\]]/g, '')
|
|
1218
1632
|
.split(/[,\s]+/)
|
|
1219
1633
|
.map((r) => r.trim())
|
|
1220
|
-
.filter(Boolean)
|
|
1221
|
-
|
|
1634
|
+
.filter(Boolean)
|
|
1635
|
+
.filter((r) => REQ_ID_SHAPE_RE.test(r));
|
|
1636
|
+
for (const reqId of citedReqIds) {
|
|
1222
1637
|
const reqEscaped = escapeRegex(reqId);
|
|
1223
1638
|
reqContent = reqContent.replace(new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'), '$1x$2');
|
|
1224
|
-
|
|
1639
|
+
// Traceability row: | <REQ-ID> | Phase N | Pending|In Progress | ->
|
|
1640
|
+
// ... Complete | via the markdown-table seam (ADR-2143 §7). Match the
|
|
1641
|
+
// row by its FIRST cell's value (the requirement-ID column) regardless
|
|
1642
|
+
// of that column's HEADER name — real tables head it `REQ-ID`, others
|
|
1643
|
+
// `Requirement` (#2769/#2203); this mirrors the prior regex's first-cell
|
|
1644
|
+
// `\|\s*<id>\s*\|` anchor, not a by-name lookup. Object.values(row) is in
|
|
1645
|
+
// header order, so [0] is the first column. Case-insensitive.
|
|
1646
|
+
const reqRowMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === reqId.toLowerCase();
|
|
1647
|
+
// Ragged-tolerant (#2245 Blocker 2): drive the write purely off
|
|
1648
|
+
// updateTableCell's own tolerant row scan — a DIFFERENT
|
|
1649
|
+
// requirement's row elsewhere in the same table having a
|
|
1650
|
+
// mismatched cell count must never silently no-op THIS
|
|
1651
|
+
// requirement's write. The "only flip Pending/In Progress ->
|
|
1652
|
+
// Complete" gate is folded into the newValue callback so one
|
|
1653
|
+
// updateTableCell call both probes and writes.
|
|
1654
|
+
const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => /^(?:pending|in progress)$/i.test(current.trim()) ? ' Complete ' : current);
|
|
1655
|
+
if (reqUpdate.ok) {
|
|
1656
|
+
reqContent = reqUpdate.value;
|
|
1657
|
+
}
|
|
1658
|
+
else if (!isPlaceholderReqId(reqId)) {
|
|
1659
|
+
traceabilityWriteMisses.push(reqId);
|
|
1660
|
+
}
|
|
1225
1661
|
}
|
|
1226
1662
|
}
|
|
1227
1663
|
// #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
|
|
1228
1664
|
// Requirements under headings whose text contains "deferred", "backlog",
|
|
1229
|
-
// "future", or
|
|
1230
|
-
// and must not be flagged as missing from the
|
|
1665
|
+
// "future", or an OFF-milestone `v<N>` (case-insensitive) are explicitly
|
|
1666
|
+
// out of current scope and must not be flagged as missing from the
|
|
1667
|
+
// Traceability table.
|
|
1231
1668
|
//
|
|
1232
1669
|
// Strategy: walk lines, track heading depth, and toggle a "deferred" flag
|
|
1233
1670
|
// when a heading matching the pattern is encountered. A sub-heading (higher
|
|
@@ -1235,7 +1672,47 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1235
1672
|
// opens a same-or-shallower heading that does NOT match the pattern.
|
|
1236
1673
|
// Lines inside fenced code blocks (``` or ~~~) are treated as content, not
|
|
1237
1674
|
// headings, to avoid false deferred-section detection from code examples.
|
|
1238
|
-
|
|
1675
|
+
//
|
|
1676
|
+
// #2334 BLOCKER fix (regresses closed bug #1159 against GSD's OWN
|
|
1677
|
+
// shipped template): #2316-4a dropped the bare `v\d+` alternative
|
|
1678
|
+
// entirely to stop it over-matching an ACTIVE heading like "## v1
|
|
1679
|
+
// Requirements" — but the shipped `templates/requirements.md:35`
|
|
1680
|
+
// scaffold ships `## v2 Requirements` / "Deferred to future release"
|
|
1681
|
+
// as its ONLY deferred marker, and `v\d+` was the ONLY alternative
|
|
1682
|
+
// that ever matched a bare version heading (the deferred-ness lives
|
|
1683
|
+
// in body prose, not the heading text). Dropping it regressed #1159
|
|
1684
|
+
// for every project scaffolded from the shipped template.
|
|
1685
|
+
//
|
|
1686
|
+
// Fix: make the `v<N>` alternative MILESTONE-AWARE instead of
|
|
1687
|
+
// deleting it. A `## v<N> ...` heading is deferred ONLY when `<N>`
|
|
1688
|
+
// (MAJOR version only — "v1" vs milestone "v1.3" is the SAME major
|
|
1689
|
+
// version) does not match the CURRENT milestone's major version,
|
|
1690
|
+
// resolved via `stateExtractField` against STATE.md's `milestone:`
|
|
1691
|
+
// frontmatter field (the same seam `getMilestoneInfo`/state.cts's
|
|
1692
|
+
// frontmatter builder already use — no bespoke frontmatter parsing).
|
|
1693
|
+
// "## v1 Requirements" while the milestone is v1.x is the ACTIVE
|
|
1694
|
+
// milestone's own section (#2316's original ask) and must NOT be
|
|
1695
|
+
// swallowed; "## v2 Requirements" while the milestone is v1.x is a
|
|
1696
|
+
// genuinely future milestone (#1159's ask, and the literal shipped-
|
|
1697
|
+
// template shape) and MUST stay suppressed. `deferred`/`backlog`/
|
|
1698
|
+
// `future` are unaffected by milestone resolution — a genuinely
|
|
1699
|
+
// deferred heading always spells one of those words too (see
|
|
1700
|
+
// #2316-5 regression guard: "## Deferred v2 Requirements", "##
|
|
1701
|
+
// Future Backlog", "## Deferred", "## Backlog", "## Future").
|
|
1702
|
+
//
|
|
1703
|
+
// Fail-safe: when the milestone version cannot be resolved at all
|
|
1704
|
+
// (no STATE.md, or no `milestone:` field), fall back to the OLD
|
|
1705
|
+
// pre-#2316-4a behavior and treat every `v\d+` heading as deferred.
|
|
1706
|
+
// A false "deferred" here only ever SUPPRESSES a warning — strictly
|
|
1707
|
+
// safer than spamming a warning on every v\d+-headed scaffold when
|
|
1708
|
+
// we cannot tell whether it names the active milestone.
|
|
1709
|
+
const DEFERRED_KEYWORD_RE = /\b(?:deferred|backlog|future)\b/i;
|
|
1710
|
+
const HEADING_VERSION_RE = /\bv(\d+)(?:\.\d+)*\b/i;
|
|
1711
|
+
const stateRawForMilestone = node_fs_1.default.existsSync(statePath) ? node_fs_1.default.readFileSync(statePath, 'utf-8') : null;
|
|
1712
|
+
const currentMilestoneRaw = stateRawForMilestone
|
|
1713
|
+
? stateExtractField(stateRawForMilestone, 'milestone')
|
|
1714
|
+
: null;
|
|
1715
|
+
const currentMilestoneMajor = currentMilestoneRaw ? extractMajorVersion(currentMilestoneRaw) : null;
|
|
1239
1716
|
const bodyReqIds = [];
|
|
1240
1717
|
// deferredDepth: the heading level that opened the current deferred block,
|
|
1241
1718
|
// or 0 when we are in an active section.
|
|
@@ -1259,11 +1736,21 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1259
1736
|
}
|
|
1260
1737
|
// Heading at same level or shallower than current deferred opener,
|
|
1261
1738
|
// or no active deferred block yet.
|
|
1262
|
-
if (
|
|
1739
|
+
if (DEFERRED_KEYWORD_RE.test(text)) {
|
|
1263
1740
|
deferredDepth = depth; // enter a deferred block
|
|
1264
1741
|
}
|
|
1265
1742
|
else {
|
|
1266
|
-
|
|
1743
|
+
const versionMatch = text.match(HEADING_VERSION_RE);
|
|
1744
|
+
if (versionMatch) {
|
|
1745
|
+
const headingMajor = versionMatch[1];
|
|
1746
|
+
deferredDepth =
|
|
1747
|
+
currentMilestoneMajor === null || headingMajor !== currentMilestoneMajor
|
|
1748
|
+
? depth // unresolved milestone (fail-safe) or off-milestone version -> deferred
|
|
1749
|
+
: 0; // same major version as the current milestone -> active
|
|
1750
|
+
}
|
|
1751
|
+
else {
|
|
1752
|
+
deferredDepth = 0; // back in an active section
|
|
1753
|
+
}
|
|
1267
1754
|
}
|
|
1268
1755
|
continue;
|
|
1269
1756
|
}
|
|
@@ -1283,7 +1770,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1283
1770
|
? reqContent.slice(traceabilityHeadingMatch.index)
|
|
1284
1771
|
: '';
|
|
1285
1772
|
const tableReqIds = new Set();
|
|
1286
|
-
|
|
1773
|
+
// #2203: match REQ-IDs in any pipe-delimited cell (not just the first
|
|
1774
|
+
// column) so a traceability table that leads with a status column (e.g.
|
|
1775
|
+
// | ☐ | REQ-01 | …) is parsed correctly instead of reporting every row
|
|
1776
|
+
// as missing.
|
|
1777
|
+
const tableRowPat = /\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/g;
|
|
1287
1778
|
let tableMatch;
|
|
1288
1779
|
while ((tableMatch = tableRowPat.exec(traceabilitySection)) !== null) {
|
|
1289
1780
|
tableReqIds.add(tableMatch[1]);
|
|
@@ -1292,8 +1783,68 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1292
1783
|
if (unregistered.length > 0) {
|
|
1293
1784
|
warnings.push(`REQUIREMENTS.md: ${unregistered.length} REQ-ID(s) found in body but missing from Traceability table: ${unregistered.join(', ')} — add them manually to keep traceability in sync`);
|
|
1294
1785
|
}
|
|
1786
|
+
// #2316-1: ghost REQ-IDs — cited by ROADMAP's own **Requirements:**
|
|
1787
|
+
// line for this phase, but registered NOWHERE in REQUIREMENTS.md
|
|
1788
|
+
// (neither its body nor its Traceability table). The `unregistered`
|
|
1789
|
+
// check above only ever compares REQUIREMENTS.md's own body against
|
|
1790
|
+
// its own Traceability table; it never consults `citedReqIds`, so an
|
|
1791
|
+
// ID that ROADMAP cites but REQUIREMENTS.md never defines at all was
|
|
1792
|
+
// previously invisible to every guard. `TBD` (the phase.add/-batch/
|
|
1793
|
+
// -insert placeholder) is excluded — see #2316-7 boundary.
|
|
1794
|
+
//
|
|
1795
|
+
// #2334 HIGH 2: classify "ghost" by PROBING THE ACTUAL WRITE
|
|
1796
|
+
// SURFACES this same function just wrote to (:1947 checkbox,
|
|
1797
|
+
// :1967 Traceability row) — case-insensitively — mirroring
|
|
1798
|
+
// milestone.cts's `notFound`/`hasRow`/`doneCheckbox` classification
|
|
1799
|
+
// (src/milestone.cts:117-141,209-215), instead of set-differencing
|
|
1800
|
+
// `bodyReqIds` (deferred-filtered, case-sensitive, bold-only) and
|
|
1801
|
+
// `tableReqIds` (case-sensitive) against `citedReqIds`. Those two
|
|
1802
|
+
// indexes can disagree with the writes: an ID under a `##
|
|
1803
|
+
// Deferred` heading gets its checkbox ticked by the write loop
|
|
1804
|
+
// above but is deliberately EXCLUDED from `bodyReqIds` by the
|
|
1805
|
+
// deferred-heading filter (#1159), so the old set-diff reported it
|
|
1806
|
+
// as an unregistered ghost in the SAME response that just ticked
|
|
1807
|
+
// its checkbox; a case-mismatched citation (`known-01` vs
|
|
1808
|
+
// `**KNOWN-01**`) lands its write via the writes' case-insensitive
|
|
1809
|
+
// regexes but failed the old set-diff's case-SENSITIVE
|
|
1810
|
+
// `Array.includes`/`Set.has`. An ID whose checkbox OR Traceability
|
|
1811
|
+
// row actually matched is registered — not a ghost — regardless of
|
|
1812
|
+
// which section (deferred or not) it lives under.
|
|
1813
|
+
const reqIsRegisteredAnywhere = (id) => {
|
|
1814
|
+
const reqEscaped = escapeRegex(id);
|
|
1815
|
+
// Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
|
|
1816
|
+
// insensitive: existence check, not the write's space-only match.
|
|
1817
|
+
if (new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
|
|
1818
|
+
return true;
|
|
1819
|
+
}
|
|
1820
|
+
// Surface 2 — Traceability row exists at all (any Status value),
|
|
1821
|
+
// via the SAME no-op-probe-through-updateTraceabilityCell
|
|
1822
|
+
// technique milestone.cts's `hasRow` uses (:210-214): a case-
|
|
1823
|
+
// insensitive first-cell match, regardless of current Status.
|
|
1824
|
+
const rowProbeMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === id.toLowerCase();
|
|
1825
|
+
return updateTraceabilityCell(reqContent, rowProbeMatch, 'Status', (current) => current).ok;
|
|
1826
|
+
};
|
|
1827
|
+
const ghostReqIds = citedReqIds.filter((id) => !isPlaceholderReqId(id) && !reqIsRegisteredAnywhere(id));
|
|
1828
|
+
if (ghostReqIds.length > 0) {
|
|
1829
|
+
warnings.push(`ROADMAP Phase ${phaseNum} cites REQ-ID(s) not registered anywhere in REQUIREMENTS.md (neither body nor Traceability table): ${ghostReqIds.join(', ')} — add them to REQUIREMENTS.md or correct the ROADMAP citation`);
|
|
1830
|
+
}
|
|
1831
|
+
// #2316-1 cont.: a cited ID whose Traceability-row write matched no
|
|
1832
|
+
// row for a reason OTHER than being a ghost (e.g. a malformed table)
|
|
1833
|
+
// still deserves a warning instead of a silent discard — but skip
|
|
1834
|
+
// IDs already reported above as ghosts to avoid a duplicate message
|
|
1835
|
+
// for the same root cause.
|
|
1836
|
+
const traceabilityWriteFailures = traceabilityWriteMisses.filter((id) => !ghostReqIds.includes(id));
|
|
1837
|
+
if (traceabilityWriteFailures.length > 0) {
|
|
1838
|
+
warnings.push(`REQUIREMENTS.md: Traceability row write skipped for REQ-ID(s) cited by ROADMAP (no matching row found): ${traceabilityWriteFailures.join(', ')}`);
|
|
1839
|
+
}
|
|
1295
1840
|
writes.push({ filePath: reqPath, before: originalReqContent, after: reqContent });
|
|
1296
|
-
|
|
1841
|
+
// #2316-3: `requirements_updated` must reflect whether REQUIREMENTS.md
|
|
1842
|
+
// content actually CHANGED, not merely that the file existed in the
|
|
1843
|
+
// transaction — mirrors the `writes.push({filePath,before,after})`
|
|
1844
|
+
// diff-tracking pattern used for the ROADMAP write above. A phase
|
|
1845
|
+
// whose citations match nothing (ghost REQ-IDs only) must report
|
|
1846
|
+
// `false`, not a bare "the file was present" `true`.
|
|
1847
|
+
requirementsUpdated = reqContent !== originalReqContent;
|
|
1297
1848
|
}
|
|
1298
1849
|
}
|
|
1299
1850
|
try {
|
|
@@ -1319,7 +1870,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1319
1870
|
}
|
|
1320
1871
|
}
|
|
1321
1872
|
catch {
|
|
1322
|
-
/*
|
|
1873
|
+
/* best-effort (#2245 audit): stage 1 of a deliberate 3-stage
|
|
1874
|
+
* cascading fallback for locating the next phase (disk dirs → roadmap
|
|
1875
|
+
* headings/checkboxes → lowest-outstanding-checkbox override, #2028
|
|
1876
|
+
* below). A disk-scan failure here is indistinguishable from "found
|
|
1877
|
+
* nothing on disk" and correctly falls through to stage 2, which
|
|
1878
|
+
* derives the same information independently from ROADMAP.md content
|
|
1879
|
+
* — not a silent data-loss path. */
|
|
1323
1880
|
}
|
|
1324
1881
|
if (isLastPhase && roadmapContent !== null) {
|
|
1325
1882
|
try {
|
|
@@ -1355,10 +1912,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1355
1912
|
}
|
|
1356
1913
|
}
|
|
1357
1914
|
catch {
|
|
1358
|
-
/*
|
|
1915
|
+
/* best-effort (#2245 audit): stage 2 of the next-phase cascade
|
|
1916
|
+
* (see stage 1's comment above) — a failure here just leaves
|
|
1917
|
+
* isLastPhase as stage 1 left it; stage 3 (#2028) below runs next
|
|
1918
|
+
* regardless and provides a further, independent override. */
|
|
1359
1919
|
}
|
|
1360
1920
|
}
|
|
1361
|
-
// #2028: don't stamp "
|
|
1921
|
+
// #2028: don't stamp "All phases complete" when a LOWER-numbered phase is
|
|
1362
1922
|
// still outstanding. The two blocks above only clear isLastPhase when a
|
|
1363
1923
|
// HIGHER-numbered phase exists, so completing the numerically-highest phase
|
|
1364
1924
|
// out of order (e.g. Phase 10 before Phase 9) wrongly read as milestone-end.
|
|
@@ -1396,7 +1956,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1396
1956
|
}
|
|
1397
1957
|
}
|
|
1398
1958
|
catch {
|
|
1399
|
-
/*
|
|
1959
|
+
/* best-effort (#2245 audit): stage 3 (#2028) of the next-phase
|
|
1960
|
+
* cascade — a failure here simply leaves isLastPhase/nextPhaseNum
|
|
1961
|
+
* as stages 1-2 already determined them; this stage only ever
|
|
1962
|
+
* overrides toward "not last" when it finds a genuinely lower
|
|
1963
|
+
* outstanding phase, never the reverse. */
|
|
1400
1964
|
}
|
|
1401
1965
|
}
|
|
1402
1966
|
if (node_fs_1.default.existsSync(statePath)) {
|