@opengsd/gsd-core 1.7.0-rc.6 → 1.7.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/agents/gsd-executor.md +2 -1
- package/agents/gsd-security-auditor.md +13 -15
- package/bin/install.js +5 -4
- package/commands/gsd/mempalace-capture.md +27 -1
- package/commands/gsd/surface.md +6 -6
- package/gsd-core/bin/gsd-tools.cjs +63 -2
- package/gsd-core/bin/lib/api-coverage.cjs +3 -4
- package/gsd-core/bin/lib/audit.cjs +7 -6
- package/gsd-core/bin/lib/capability-registry.cjs +57 -57
- package/gsd-core/bin/lib/check-command-router.cjs +1 -1
- package/gsd-core/bin/lib/clock.cjs +19 -0
- package/gsd-core/bin/lib/commands.cjs +48 -9
- package/gsd-core/bin/lib/config-loader.cjs +6 -2
- package/gsd-core/bin/lib/config.cjs +12 -0
- package/gsd-core/bin/lib/core-utils.cjs +8 -2
- package/gsd-core/bin/lib/drift.cjs +4 -4
- package/gsd-core/bin/lib/frontmatter.cjs +22 -0
- package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
- package/gsd-core/bin/lib/init.cjs +27 -13
- package/gsd-core/bin/lib/install-engine.cjs +3 -2
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
- package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
- package/gsd-core/bin/lib/markdown-table.cjs +698 -0
- package/gsd-core/bin/lib/milestone.cjs +217 -31
- package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
- 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 +436 -61
- package/gsd-core/bin/lib/plan-scan.cjs +3 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
- package/gsd-core/bin/lib/roadmap.cjs +100 -49
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -6
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +2 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +25 -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 +4 -3
- package/gsd-core/bin/lib/state-transition.cjs +100 -45
- package/gsd-core/bin/lib/state.cjs +391 -126
- package/gsd-core/bin/lib/surface.cjs +2 -2
- package/gsd-core/bin/lib/template.cjs +2 -1
- package/gsd-core/bin/lib/uat.cjs +54 -8
- package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
- package/gsd-core/bin/lib/verify.cjs +4 -3
- 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 +2 -0
- package/gsd-core/references/checkpoints.md +12 -0
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/debug.md +2 -0
- package/gsd-core/workflows/execute-phase.md +5 -3
- package/gsd-core/workflows/fast.md +8 -22
- package/gsd-core/workflows/progress.md +2 -2
- package/gsd-core/workflows/quick.md +2 -0
- package/gsd-core/workflows/review.md +42 -3
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/ship.md +8 -2
- package/gsd-core/workflows/spec-phase.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/hooks/dist/gsd-statusline.js +164 -14
- package/hooks/gsd-statusline.js +164 -14
- package/package.json +4 -2
- package/scripts/ci-test-scope.cjs +39 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
- package/scripts/gen-install-tree-fixtures.cjs +75 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-table-schema-drift.cjs +157 -0
- package/skills/gsd-mempalace-capture/SKILL.md +27 -1
- 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,43 @@ 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
|
+
}
|
|
70
109
|
function describeNonCanonicalPlans(dirFiles, matchedFiles) {
|
|
71
110
|
const matched = new Set(matchedFiles);
|
|
72
111
|
const offenders = dirFiles.filter((f) => looksLikePlanFile(f) && !matched.has(f));
|
|
@@ -313,9 +352,21 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
313
352
|
.filter((e) => e.isDirectory())
|
|
314
353
|
.map((e) => e.name)
|
|
315
354
|
.sort((a, b) => comparePhaseNum(a, b));
|
|
316
|
-
|
|
317
|
-
|
|
355
|
+
// #2237: fail loud when multiple directories match the same bare phase
|
|
356
|
+
// number — prevents cross-project file writes when unrelated projects
|
|
357
|
+
// share a .planning/phases/ tree.
|
|
358
|
+
const matches = dirs.filter((d) => phaseTokenMatches(d, normalized));
|
|
359
|
+
if (matches.length === 0)
|
|
318
360
|
continue;
|
|
361
|
+
if (matches.length > 1) {
|
|
362
|
+
output({
|
|
363
|
+
...notFound,
|
|
364
|
+
ambiguous_matches: matches,
|
|
365
|
+
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.`,
|
|
366
|
+
}, raw, '');
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const match = matches[0];
|
|
319
370
|
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
371
|
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
|
321
372
|
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
|
@@ -760,8 +811,27 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
760
811
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
761
812
|
const normalizedBase = normalizePhaseName(afterPhase);
|
|
762
813
|
const decimalSet = new Set();
|
|
763
|
-
|
|
764
|
-
|
|
814
|
+
// #2245 audit: existsSync-guarded, mirroring cmdPhaseNextDecimal's identical
|
|
815
|
+
// scan above — a missing phasesDir (no decimal sub-phases yet) is the
|
|
816
|
+
// expected, silent case (empty decimalSet). A readdirSync failure once the
|
|
817
|
+
// dir is confirmed to EXIST is a genuine anomaly; swallowing it used to let
|
|
818
|
+
// `phase insert` proceed with an incomplete decimalSet and risk writing a
|
|
819
|
+
// decimal phase number that collides with an existing on-disk directory
|
|
820
|
+
// the scan simply never saw — surfaced loud instead, like the sibling.
|
|
821
|
+
if (node_fs_1.default.existsSync(phasesDir)) {
|
|
822
|
+
// Initialized (not just declared) so TS's definite-assignment check is
|
|
823
|
+
// satisfied without relying on control-flow narrowing through error()'s
|
|
824
|
+
// `never` return, which TS does not propagate through a destructured
|
|
825
|
+
// module-property function reference — error() still halts the process
|
|
826
|
+
// before `dirs` below is ever computed from this placeholder value.
|
|
827
|
+
let entries = [];
|
|
828
|
+
try {
|
|
829
|
+
entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
830
|
+
}
|
|
831
|
+
catch (e) {
|
|
832
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
833
|
+
error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
|
|
834
|
+
}
|
|
765
835
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
766
836
|
const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`);
|
|
767
837
|
for (const dir of dirs) {
|
|
@@ -770,9 +840,6 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
770
840
|
decimalSet.add(parseInt(dm[1], 10));
|
|
771
841
|
}
|
|
772
842
|
}
|
|
773
|
-
catch {
|
|
774
|
-
/* intentionally empty */
|
|
775
|
-
}
|
|
776
843
|
const rmPhasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
|
|
777
844
|
let rmMatch;
|
|
778
845
|
while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
|
|
@@ -940,19 +1007,186 @@ function decrementRoadmapPaddedPhaseNumber(raw, removedInt) {
|
|
|
940
1007
|
return raw;
|
|
941
1008
|
return String(num - 1).padStart(raw.length, '0');
|
|
942
1009
|
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Return the RAW text of the `dataRowIndex`-th data row line (0-based, in
|
|
1012
|
+
* file order — header and delimiter rows excluded) of the FIRST GFM table
|
|
1013
|
+
* found in `sectionText`, or `null` when the table or that row doesn't exist.
|
|
1014
|
+
*
|
|
1015
|
+
* F8 (#2245 review, nit) support helper: addresses a table row by its
|
|
1016
|
+
* STRUCTURAL position rather than by matching its (possibly non-unique)
|
|
1017
|
+
* trimmed cell content — see the Progress-ordinal renumber's padding-recovery
|
|
1018
|
+
* use below for why content-matching is unsafe here (two rows with identical
|
|
1019
|
+
* trimmed Phase text, or a row whose already-rewritten new value coincides
|
|
1020
|
+
* with another row's pre-edit text, would otherwise resolve to the wrong line).
|
|
1021
|
+
*/
|
|
1022
|
+
function findDataRowLine(sectionText, dataRowIndex) {
|
|
1023
|
+
const lines = sectionText.split(/\r?\n/);
|
|
1024
|
+
let headerIdx = -1;
|
|
1025
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1026
|
+
const trimmed = lines[i].trim();
|
|
1027
|
+
if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
|
|
1028
|
+
headerIdx = i;
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (headerIdx === -1)
|
|
1033
|
+
return null;
|
|
1034
|
+
let seen = -1;
|
|
1035
|
+
for (let i = headerIdx + 2; i < lines.length; i++) {
|
|
1036
|
+
if (!lines[i].trim().startsWith('|'))
|
|
1037
|
+
break;
|
|
1038
|
+
seen += 1;
|
|
1039
|
+
if (seen === dataRowIndex)
|
|
1040
|
+
return lines[i];
|
|
1041
|
+
}
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
943
1044
|
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
|
|
944
1045
|
withPlanningLock(cwd, () => {
|
|
945
1046
|
let content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
946
1047
|
const escaped = escapeRegex(targetPhase);
|
|
947
|
-
|
|
1048
|
+
// SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
|
|
1049
|
+
// detail section INCLUDING its own heading line. Migrated onto deleteSection
|
|
1050
|
+
// (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
|
|
1051
|
+
// tokenizeHeadings + this predicate, then splices out the range from that
|
|
1052
|
+
// heading's own start through the next heading of the SAME-OR-HIGHER level —
|
|
1053
|
+
// whatever that heading's text is. This fixes a data-loss bug in the prior
|
|
1054
|
+
// hand-rolled regex, whose lookahead only recognised ANOTHER "Phase N:"
|
|
1055
|
+
// heading as a stop boundary: removing the LAST phase in a roadmap left no
|
|
1056
|
+
// such heading to stop at, so the lazy `[\s\S]*?` scan ran to EOF and swept
|
|
1057
|
+
// away everything after it — including a trailing `## Progress` heading and
|
|
1058
|
+
// its tracking table.
|
|
1059
|
+
const phaseHeadingRe = new RegExp(`^Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
|
|
1060
|
+
content = (0, markdown_sectionizer_cjs_1.deleteSection)(content, (h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text));
|
|
948
1061
|
content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '');
|
|
949
|
-
|
|
1062
|
+
// ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
|
|
1063
|
+
// for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
|
|
1064
|
+
// of updateTableCell). Scoped to the `## Progress` section — mirroring
|
|
1065
|
+
// deriveProgressFromRoadmap's read-side scoping (phase-lifecycle.cts) —
|
|
1066
|
+
// so a same-numbered row in an earlier, unrelated table (e.g. a
|
|
1067
|
+
// `| Phase | Requirements | Count |` table preceding `## Progress`,
|
|
1068
|
+
// #2012) is never touched. Matches the row by its FIRST cell only: for an
|
|
1069
|
+
// integer removal, a zero-pad-insensitive leading-integer comparison
|
|
1070
|
+
// (`01.`, `1.`, `1 `, bare `1` all match phase 1; a decimal sub-phase
|
|
1071
|
+
// cell like `2.5` never matches an integer removal); for a decimal
|
|
1072
|
+
// removal, the exact decimal token. This replaces the prior regex's
|
|
1073
|
+
// `\.?\s` requirement, which silently left a COMPACT unpadded row (e.g.
|
|
1074
|
+
// `|2|0/2|Planned|-|`) undeleted — its closing `|` follows the digit with
|
|
1075
|
+
// no whitespace to match (#2245 audit) — and which was also unscoped to
|
|
1076
|
+
// any particular table.
|
|
1077
|
+
const progressHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
|
|
1078
|
+
if (progressHeadingMatch && progressHeadingMatch.index !== undefined) {
|
|
1079
|
+
const headingOffset = progressHeadingMatch.index;
|
|
1080
|
+
const before = content.slice(0, headingOffset);
|
|
1081
|
+
const fromHeading = content.slice(headingOffset);
|
|
1082
|
+
const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
|
|
1083
|
+
const progressSection = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
|
|
1084
|
+
const rest = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
|
|
1085
|
+
const matchRemovedProgressRow = (row) => {
|
|
1086
|
+
const firstCellRaw = (Object.values(row)[0] ?? '').trim();
|
|
1087
|
+
if (isDecimal) {
|
|
1088
|
+
return new RegExp(`^${escaped}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
|
|
1089
|
+
}
|
|
1090
|
+
const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
|
|
1091
|
+
if (!leadingMatch || leadingMatch[2])
|
|
1092
|
+
return false;
|
|
1093
|
+
return parseInt(leadingMatch[1], 10) === removedInt;
|
|
1094
|
+
};
|
|
1095
|
+
const deleteResult = (0, markdown_table_cjs_1.deleteTableRow)(progressSection, matchRemovedProgressRow);
|
|
1096
|
+
if (deleteResult.ok) {
|
|
1097
|
+
content = before + deleteResult.value + rest;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
950
1100
|
if (!isDecimal) {
|
|
951
1101
|
// #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
|
|
952
1102
|
// is re-emitted verbatim — a tagged later phase still gets renumbered.
|
|
953
1103
|
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
1104
|
content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
|
|
955
|
-
|
|
1105
|
+
// ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
|
|
1106
|
+
// updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
|
|
1107
|
+
// directly above). The prior whole-document regex
|
|
1108
|
+
// `/(\|\s*)(\d+)(\.\s)/g` rewrote ANY `| N. ` cell anywhere in the
|
|
1109
|
+
// file — including a same-shaped cell in an UNRELATED, earlier table
|
|
1110
|
+
// (e.g. a `| Phase | Requirements | Count |` table, or a decoy table,
|
|
1111
|
+
// preceding `## Progress`; #2245-class scoping defect, same family as
|
|
1112
|
+
// the row-delete fix above). Scoped here to the `## Progress` section
|
|
1113
|
+
// only, mirroring that same section-slice-then-splice-back pattern.
|
|
1114
|
+
//
|
|
1115
|
+
// Loops because updateTableCell only rewrites the FIRST matching row
|
|
1116
|
+
// per call. `processedOrdinalRows` tracks by row INDEX (stable across
|
|
1117
|
+
// iterations — this only edits cell content, it never inserts/deletes
|
|
1118
|
+
// rows) so an already-decremented row's new value — which may still
|
|
1119
|
+
// numerically exceed `removedInt` — is never re-selected and
|
|
1120
|
+
// decremented a second time (matching on the row's CURRENT value alone,
|
|
1121
|
+
// without this guard, would keep re-firing on each pass).
|
|
1122
|
+
//
|
|
1123
|
+
// `phaseCellShapeRe` is the exact digit+dot-space shape the old regex
|
|
1124
|
+
// required: a decimal sub-phase ordinal like `2.5` (no whitespace
|
|
1125
|
+
// between the dot and the next character) never matches it, so it is
|
|
1126
|
+
// left untouched — identical decimal-safety to the prior behaviour.
|
|
1127
|
+
//
|
|
1128
|
+
// updateTableCell hands the callback the TRIMMED, UNESCAPED cell value
|
|
1129
|
+
// only, so the row's original leading/trailing alignment padding is
|
|
1130
|
+
// recovered by a narrow, anchored lookup within that row's OWN raw
|
|
1131
|
+
// line — addressed by ROW INDEX (`matchedRowIndex`, via
|
|
1132
|
+
// `findDataRowLine`), not by searching the whole section for content
|
|
1133
|
+
// matching the trimmed value (F8 #2245 review: two rows with identical
|
|
1134
|
+
// trimmed Phase text, or a row whose already-rewritten new value
|
|
1135
|
+
// coincides with another row's pre-edit text, would otherwise resolve
|
|
1136
|
+
// to the WRONG row's padding — the first/leftmost content match found).
|
|
1137
|
+
// The lookup searches for `escapeCell(current)` (F3 #2245 review: the
|
|
1138
|
+
// ESCAPED form, e.g. `Foo \| Bar`) — the raw line always carries the
|
|
1139
|
+
// escaped form, so searching for the unescaped `current` would
|
|
1140
|
+
// silently fail to find an escaped-pipe cell's own line — preserving
|
|
1141
|
+
// every other byte of the row (ADR-2143 §7 byte-parity) while only the
|
|
1142
|
+
// digits actually change.
|
|
1143
|
+
const ordinalHeadingMatch = content.match(/^##[ \t]+Progress\b/im);
|
|
1144
|
+
if (ordinalHeadingMatch && ordinalHeadingMatch.index !== undefined) {
|
|
1145
|
+
const ordinalHeadingOffset = ordinalHeadingMatch.index;
|
|
1146
|
+
const ordinalBefore = content.slice(0, ordinalHeadingOffset);
|
|
1147
|
+
const ordinalFromHeading = content.slice(ordinalHeadingOffset);
|
|
1148
|
+
const ordinalNextHeadingOffset = ordinalFromHeading.search(/\n#{1,2}[ \t]/);
|
|
1149
|
+
let ordinalSection = ordinalNextHeadingOffset >= 0
|
|
1150
|
+
? ordinalFromHeading.slice(0, ordinalNextHeadingOffset)
|
|
1151
|
+
: ordinalFromHeading;
|
|
1152
|
+
const ordinalRest = ordinalNextHeadingOffset >= 0 ? ordinalFromHeading.slice(ordinalNextHeadingOffset) : '';
|
|
1153
|
+
const phaseCellShapeRe = /^(\d+)(\.\s)/;
|
|
1154
|
+
const processedOrdinalRows = new Set();
|
|
1155
|
+
let matchedRowIndex = null;
|
|
1156
|
+
for (;;) {
|
|
1157
|
+
matchedRowIndex = null;
|
|
1158
|
+
const cellResult = (0, markdown_table_cjs_1.updateTableCell)(ordinalSection, (row, index) => {
|
|
1159
|
+
if (processedOrdinalRows.has(index))
|
|
1160
|
+
return false;
|
|
1161
|
+
const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
|
|
1162
|
+
if (!m)
|
|
1163
|
+
return false;
|
|
1164
|
+
const num = parseInt(m[1], 10);
|
|
1165
|
+
if (!Number.isInteger(num) || num <= removedInt || num === 999)
|
|
1166
|
+
return false;
|
|
1167
|
+
processedOrdinalRows.add(index);
|
|
1168
|
+
matchedRowIndex = index;
|
|
1169
|
+
return true;
|
|
1170
|
+
}, 'Phase', (current) => {
|
|
1171
|
+
const m = phaseCellShapeRe.exec(current);
|
|
1172
|
+
if (!m)
|
|
1173
|
+
return current;
|
|
1174
|
+
const decremented = decrementRoadmapPhaseNumber(m[1], removedInt);
|
|
1175
|
+
const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
|
|
1176
|
+
const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
|
|
1177
|
+
const padMatch = targetLine
|
|
1178
|
+
? new RegExp(`^[ \\t]*\\|(\\s*)${escapeRegex((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
|
|
1179
|
+
: null;
|
|
1180
|
+
const leadPad = padMatch ? padMatch[1] : ' ';
|
|
1181
|
+
const trailPad = padMatch ? padMatch[2] : ' ';
|
|
1182
|
+
return `${leadPad}${(0, markdown_table_cjs_1.escapeCell)(newContent)}${trailPad}`;
|
|
1183
|
+
});
|
|
1184
|
+
if (!cellResult.ok)
|
|
1185
|
+
break;
|
|
1186
|
+
ordinalSection = cellResult.value;
|
|
1187
|
+
}
|
|
1188
|
+
content = ordinalBefore + ordinalSection + ordinalRest;
|
|
1189
|
+
}
|
|
956
1190
|
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
1191
|
content = content.replace(/(\*\*Depends on\*\*\s*:\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
|
|
958
1192
|
content = content.replace(/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
|
|
@@ -990,8 +1224,18 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
990
1224
|
renamedDirs = renamed.renamedDirs;
|
|
991
1225
|
renamedFiles = renamed.renamedFiles;
|
|
992
1226
|
}
|
|
993
|
-
catch {
|
|
994
|
-
|
|
1227
|
+
catch (e) {
|
|
1228
|
+
// #2245 audit (was ERROR-HIDING): renameDecimalPhases/renameIntegerPhases
|
|
1229
|
+
// rename subsequent phase directories ON DISK one at a time — a mid-loop
|
|
1230
|
+
// failure leaves SOME directories already renumbered and others not, with
|
|
1231
|
+
// no way to recover which (the callee's own renamedDirs/renamedFiles never
|
|
1232
|
+
// reach this scope when it throws). Silently swallowing this and falling
|
|
1233
|
+
// through to updateRoadmapAfterPhaseRemoval below used to rewrite
|
|
1234
|
+
// ROADMAP.md's phase numbers assuming the ENTIRE renumbering succeeded,
|
|
1235
|
+
// permanently desyncing ROADMAP.md from the actual (partially-renamed)
|
|
1236
|
+
// on-disk directory names. Surface loud instead of compounding it.
|
|
1237
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1238
|
+
error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
|
|
995
1239
|
}
|
|
996
1240
|
updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
|
|
997
1241
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
@@ -1085,7 +1329,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1085
1329
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
1086
1330
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
1087
1331
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
1088
|
-
const today = clock_cjs_1.realClock.
|
|
1332
|
+
const today = clock_cjs_1.realClock.localToday();
|
|
1089
1333
|
const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
|
|
1090
1334
|
if (!phaseInfoRaw) {
|
|
1091
1335
|
error(`Phase ${phaseNum} not found`);
|
|
@@ -1130,7 +1374,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1130
1374
|
}
|
|
1131
1375
|
}
|
|
1132
1376
|
catch {
|
|
1133
|
-
/*
|
|
1377
|
+
/* best-effort (#2245 audit): this is an ADVISORY pre-scan of UAT/
|
|
1378
|
+
* VERIFICATION files for `warnings` in the phase-complete output — the
|
|
1379
|
+
* actual completion GATE is readVerificationStatus below (a separate
|
|
1380
|
+
* mechanism). A readdirSync/readFileSync failure here just means fewer
|
|
1381
|
+
* warnings are surfaced this run, not a blocked or corrupted completion. */
|
|
1134
1382
|
}
|
|
1135
1383
|
let nextPhaseNum = null;
|
|
1136
1384
|
let nextPhaseName = null;
|
|
@@ -1153,50 +1401,143 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1153
1401
|
// so completing an already-checked phase (idempotent re-run) checked the
|
|
1154
1402
|
// wrong phase's box. Mirrors the tight pattern used by phase-insert
|
|
1155
1403
|
// (`]\\s*(?:\\*\\*)?Phase`).
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1404
|
+
// #2067/#2200: line-anchored (^, optional leading indent) so an
|
|
1405
|
+
// inline / backticked prose literal cannot match. Milestone-scoped below
|
|
1406
|
+
// (mutateMilestonePhase) so a Backlog entry or a same-numbered shipped-
|
|
1407
|
+
// milestone phase cannot be flipped either.
|
|
1408
|
+
// ADR-2143 §4 note / #2245 audit: this is the phase-LIST checkbox — it
|
|
1409
|
+
// lives in the milestone's `- [ ] Phase N: …` checklist, OUTSIDE any
|
|
1410
|
+
// `### Phase N` detail section, so there is no section for
|
|
1411
|
+
// withPhaseSection to bind to. Migrated onto the sectionizer's
|
|
1412
|
+
// `updateBullet` bullet-write seam: the pattern itself is unchanged,
|
|
1413
|
+
// only the "find the right line, splice it back" plumbing moved off a
|
|
1414
|
+
// whole-slice `.replace()` onto the seam. Applied per single physical
|
|
1415
|
+
// line by updateBullet, so the pattern no longer needs the `m` flag
|
|
1416
|
+
// (it never sees more than one line at a time); see
|
|
1417
|
+
// planCountBodyPattern below for the sites that were migrated onto
|
|
1418
|
+
// withPhaseSection instead.
|
|
1419
|
+
//
|
|
1420
|
+
// #2245 review Fix 6: this is behaviour-preserving for GSD-GENERATED
|
|
1421
|
+
// inputs (the only shape ROADMAP.md ever actually has), NOT byte-parity
|
|
1422
|
+
// across every conceivable input. `updateBullet` is fence-aware — a
|
|
1423
|
+
// checkbox-shaped line inside a fenced (``` / ~~~) code block is never
|
|
1424
|
+
// offered to `match`/`transform` — whereas the retired whole-slice
|
|
1425
|
+
// `.replace()` had no such fence tracking and would have flipped a
|
|
1426
|
+
// bullet-shaped line inside a fence too. That divergence has no live
|
|
1427
|
+
// bug because a GSD-authored ROADMAP.md milestone checklist never puts
|
|
1428
|
+
// its own `- [ ] Phase N: …` entries inside a fenced code block, but it
|
|
1429
|
+
// is a real (and correct) behavioural difference on pathological input.
|
|
1430
|
+
const checkboxPattern = new RegExp(`^[ \\t]*(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
|
|
1431
|
+
// Progress table row: update Plans Complete/Status/Completed columns BY
|
|
1432
|
+
// COLUMN NAME (handles 4- or 5-column RoadmapProgress tables) via the
|
|
1433
|
+
// markdown-table seam (ADR-2143 §7) — supersedes the prior ordinal
|
|
1434
|
+
// cells[]-index regex. Applied inside mutateMilestonePhase below (per
|
|
1435
|
+
// milestone window), further scoped to the ## Progress heading within
|
|
1436
|
+
// that window so the row lookup doesn't bind to an earlier table (e.g.
|
|
1437
|
+
// | Phase | Requirements | Count |) whose rows also start with the
|
|
1438
|
+
// phase number (#2012).
|
|
1439
|
+
// #2245 Blocker 4: optional dot must be followed by whitespace-or-end,
|
|
1440
|
+
// not dot-OR-whitespace-OR-end as alternatives — the prior form let a
|
|
1441
|
+
// bare "." satisfy the whole lookahead, so completing phase "2"
|
|
1442
|
+
// over-matched a decimal sub-phase row like "2.5 Extra". Matches "2",
|
|
1443
|
+
// "2.", "2 Alpha"; rejects "2.5 Extra".
|
|
1444
|
+
const phaseCellRe = new RegExp(`^${phaseEscaped}\\.?(?:\\s|$)`, 'i');
|
|
1445
|
+
const rowMatch = (row) => phaseCellRe.test((row['Phase'] ?? '').trim());
|
|
1446
|
+
const dateShape = /^\d{4}-\d{2}-\d{2}$/;
|
|
1447
|
+
/**
|
|
1448
|
+
* Within `text` (already scoped to one milestone window by the
|
|
1449
|
+
* caller), scope further to the `## Progress` heading section (up to
|
|
1450
|
+
* the next `#`/`##` heading) when present, run `edit` against just
|
|
1451
|
+
* that slice, and splice the result back — falling back to the whole
|
|
1452
|
+
* `text` when no `## Progress` heading exists (mirrors phase-
|
|
1453
|
+
* lifecycle.cjs's deriveProgressFromRoadmap read-side scoping).
|
|
1454
|
+
*/
|
|
1455
|
+
const editProgressHeadingSlice = (text, edit) => {
|
|
1456
|
+
const progressMatch = text.match(/^##[ \t]+Progress\b/im);
|
|
1457
|
+
if (!progressMatch || progressMatch.index === undefined) {
|
|
1458
|
+
return edit(text);
|
|
1178
1459
|
}
|
|
1179
|
-
|
|
1460
|
+
const headingOffset = progressMatch.index;
|
|
1461
|
+
const beforeHeading = text.slice(0, headingOffset);
|
|
1462
|
+
const fromHeading = text.slice(headingOffset);
|
|
1463
|
+
const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
|
|
1464
|
+
const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
|
|
1465
|
+
const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
|
|
1466
|
+
return beforeHeading + edit(scoped) + after;
|
|
1180
1467
|
};
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1468
|
+
// ADR-2143 §4: the plan-count write is now routed through
|
|
1469
|
+
// withPhaseSection (see mutateMilestonePhase below), which hands this
|
|
1470
|
+
// pattern ONLY phase N's own detail-section body — so the pattern no
|
|
1471
|
+
// longer needs its own `#{2,4}\s*Phase\s+N` anchor + skip-ahead-past-
|
|
1472
|
+
// interior-headings lookahead; the section boundary itself confines
|
|
1473
|
+
// the match (the #2067/#2200 boundary-crossing class is now
|
|
1474
|
+
// structurally impossible for this site rather than regex-enforced).
|
|
1475
|
+
const planCountBodyPattern = /(\*\*Plans:\*\*\s*)[^\n]+/i;
|
|
1476
|
+
const phaseInfoSummaries = phaseInfo['summaries'];
|
|
1477
|
+
// #2200: apply the phase-checkbox flip, the plan-count write, and the
|
|
1478
|
+
// per-plan checkbox flips ONLY within the current milestone's region(s)
|
|
1479
|
+
// (primary section + optional Phase Details section). A bullet/heading in
|
|
1480
|
+
// a shipped milestone, a Backlog section, or a backticked prose literal is
|
|
1481
|
+
// outside the window and stays untouched. With no versioned active
|
|
1482
|
+
// milestone, fall back to whole-content mutation (prior behaviour).
|
|
1483
|
+
const mutateMilestonePhase = (slice) => {
|
|
1484
|
+
let s = slice;
|
|
1485
|
+
s = (0, markdown_sectionizer_cjs_1.updateBullet)(s, (_bulletText, rawLine) => checkboxPattern.test(rawLine), (rawLine) => rawLine.replace(checkboxPattern, `$1x$2 (completed ${today})`));
|
|
1486
|
+
s = editProgressHeadingSlice(s, (scoped) => {
|
|
1487
|
+
let text = scoped;
|
|
1488
|
+
const plansResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
|
|
1489
|
+
if (plansResult.ok)
|
|
1490
|
+
text = plansResult.value;
|
|
1491
|
+
const statusResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Status', ' Complete ');
|
|
1492
|
+
if (statusResult.ok)
|
|
1493
|
+
text = statusResult.value;
|
|
1494
|
+
// Preserve only a valid ISO date (#1161: idempotent; self-heal
|
|
1495
|
+
// garbage). Ragged-tolerant (#2245 Blocker 2): decide via the
|
|
1496
|
+
// CURRENT Completed cell inside a single updateTableCell callback
|
|
1497
|
+
// (its own tolerant row scan) rather than gating on
|
|
1498
|
+
// findTableWithColumns (which requires the WHOLE table to parse —
|
|
1499
|
+
// a ragged SIBLING row elsewhere used to silently no-op this
|
|
1500
|
+
// row's date stamp too).
|
|
1501
|
+
const completedResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Completed', (current) => dateShape.test(current.trim()) ? current : ` ${today} `);
|
|
1502
|
+
if (completedResult.ok)
|
|
1503
|
+
text = completedResult.value;
|
|
1504
|
+
return text;
|
|
1505
|
+
});
|
|
1506
|
+
// ADR-2143 §4: the plan-count write and the per-plan checkbox flips
|
|
1507
|
+
// are both scoped to phase N's OWN detail section via
|
|
1508
|
+
// withPhaseSection — the edit callback below only ever sees that
|
|
1509
|
+
// section's body, so neither regex can escape into a sibling
|
|
1510
|
+
// phase's section, a shipped milestone, or a Backlog entry.
|
|
1511
|
+
s = withPhaseSection(s, phaseNum, (body) => {
|
|
1512
|
+
let b = body.replace(planCountBodyPattern, `$1${summaryCount}/${planCount} plans complete`);
|
|
1513
|
+
for (const summaryFile of phaseInfoSummaries) {
|
|
1514
|
+
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
|
1515
|
+
if (!planId)
|
|
1516
|
+
continue;
|
|
1517
|
+
const planEscaped = escapeRegex(planId);
|
|
1518
|
+
const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
|
|
1519
|
+
b = b.replace(planCheckboxPattern, '$1x$2');
|
|
1520
|
+
}
|
|
1521
|
+
return b;
|
|
1522
|
+
});
|
|
1523
|
+
return s;
|
|
1524
|
+
};
|
|
1525
|
+
const milestoneRanges = currentMilestoneRawRanges(roadmapContent, cwd);
|
|
1526
|
+
if (milestoneRanges) {
|
|
1527
|
+
// Splice later windows first so an earlier window's offsets are not
|
|
1528
|
+
// shifted by a length-changing mutation in a later window.
|
|
1529
|
+
const windows = [milestoneRanges.details, milestoneRanges.primary]
|
|
1530
|
+
.filter((w) => w !== null)
|
|
1531
|
+
.sort((a, b) => b.start - a.start);
|
|
1532
|
+
for (const w of windows) {
|
|
1533
|
+
roadmapContent =
|
|
1534
|
+
roadmapContent.slice(0, w.start)
|
|
1535
|
+
+ mutateMilestonePhase(roadmapContent.slice(w.start, w.end))
|
|
1536
|
+
+ roadmapContent.slice(w.end);
|
|
1537
|
+
}
|
|
1186
1538
|
}
|
|
1187
1539
|
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');
|
|
1540
|
+
roadmapContent = mutateMilestonePhase(roadmapContent);
|
|
1200
1541
|
}
|
|
1201
1542
|
writes.push({
|
|
1202
1543
|
filePath: roadmapPath,
|
|
@@ -1221,7 +1562,24 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1221
1562
|
for (const reqId of reqIds) {
|
|
1222
1563
|
const reqEscaped = escapeRegex(reqId);
|
|
1223
1564
|
reqContent = reqContent.replace(new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'), '$1x$2');
|
|
1224
|
-
|
|
1565
|
+
// Traceability row: | <REQ-ID> | Phase N | Pending|In Progress | ->
|
|
1566
|
+
// ... Complete | via the markdown-table seam (ADR-2143 §7). Match the
|
|
1567
|
+
// row by its FIRST cell's value (the requirement-ID column) regardless
|
|
1568
|
+
// of that column's HEADER name — real tables head it `REQ-ID`, others
|
|
1569
|
+
// `Requirement` (#2769/#2203); this mirrors the prior regex's first-cell
|
|
1570
|
+
// `\|\s*<id>\s*\|` anchor, not a by-name lookup. Object.values(row) is in
|
|
1571
|
+
// header order, so [0] is the first column. Case-insensitive.
|
|
1572
|
+
const reqRowMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === reqId.toLowerCase();
|
|
1573
|
+
// Ragged-tolerant (#2245 Blocker 2): drive the write purely off
|
|
1574
|
+
// updateTableCell's own tolerant row scan — a DIFFERENT
|
|
1575
|
+
// requirement's row elsewhere in the same table having a
|
|
1576
|
+
// mismatched cell count must never silently no-op THIS
|
|
1577
|
+
// requirement's write. The "only flip Pending/In Progress ->
|
|
1578
|
+
// Complete" gate is folded into the newValue callback so one
|
|
1579
|
+
// updateTableCell call both probes and writes.
|
|
1580
|
+
const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => /^(?:pending|in progress)$/i.test(current.trim()) ? ' Complete ' : current);
|
|
1581
|
+
if (reqUpdate.ok)
|
|
1582
|
+
reqContent = reqUpdate.value;
|
|
1225
1583
|
}
|
|
1226
1584
|
}
|
|
1227
1585
|
// #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
|
|
@@ -1283,7 +1641,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1283
1641
|
? reqContent.slice(traceabilityHeadingMatch.index)
|
|
1284
1642
|
: '';
|
|
1285
1643
|
const tableReqIds = new Set();
|
|
1286
|
-
|
|
1644
|
+
// #2203: match REQ-IDs in any pipe-delimited cell (not just the first
|
|
1645
|
+
// column) so a traceability table that leads with a status column (e.g.
|
|
1646
|
+
// | ☐ | REQ-01 | …) is parsed correctly instead of reporting every row
|
|
1647
|
+
// as missing.
|
|
1648
|
+
const tableRowPat = /\|\s*([A-Z][A-Z0-9]*-\d+)\s*\|/g;
|
|
1287
1649
|
let tableMatch;
|
|
1288
1650
|
while ((tableMatch = tableRowPat.exec(traceabilitySection)) !== null) {
|
|
1289
1651
|
tableReqIds.add(tableMatch[1]);
|
|
@@ -1319,7 +1681,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1319
1681
|
}
|
|
1320
1682
|
}
|
|
1321
1683
|
catch {
|
|
1322
|
-
/*
|
|
1684
|
+
/* best-effort (#2245 audit): stage 1 of a deliberate 3-stage
|
|
1685
|
+
* cascading fallback for locating the next phase (disk dirs → roadmap
|
|
1686
|
+
* headings/checkboxes → lowest-outstanding-checkbox override, #2028
|
|
1687
|
+
* below). A disk-scan failure here is indistinguishable from "found
|
|
1688
|
+
* nothing on disk" and correctly falls through to stage 2, which
|
|
1689
|
+
* derives the same information independently from ROADMAP.md content
|
|
1690
|
+
* — not a silent data-loss path. */
|
|
1323
1691
|
}
|
|
1324
1692
|
if (isLastPhase && roadmapContent !== null) {
|
|
1325
1693
|
try {
|
|
@@ -1355,10 +1723,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1355
1723
|
}
|
|
1356
1724
|
}
|
|
1357
1725
|
catch {
|
|
1358
|
-
/*
|
|
1726
|
+
/* best-effort (#2245 audit): stage 2 of the next-phase cascade
|
|
1727
|
+
* (see stage 1's comment above) — a failure here just leaves
|
|
1728
|
+
* isLastPhase as stage 1 left it; stage 3 (#2028) below runs next
|
|
1729
|
+
* regardless and provides a further, independent override. */
|
|
1359
1730
|
}
|
|
1360
1731
|
}
|
|
1361
|
-
// #2028: don't stamp "
|
|
1732
|
+
// #2028: don't stamp "All phases complete" when a LOWER-numbered phase is
|
|
1362
1733
|
// still outstanding. The two blocks above only clear isLastPhase when a
|
|
1363
1734
|
// HIGHER-numbered phase exists, so completing the numerically-highest phase
|
|
1364
1735
|
// out of order (e.g. Phase 10 before Phase 9) wrongly read as milestone-end.
|
|
@@ -1396,7 +1767,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1396
1767
|
}
|
|
1397
1768
|
}
|
|
1398
1769
|
catch {
|
|
1399
|
-
/*
|
|
1770
|
+
/* best-effort (#2245 audit): stage 3 (#2028) of the next-phase
|
|
1771
|
+
* cascade — a failure here simply leaves isLastPhase/nextPhaseNum
|
|
1772
|
+
* as stages 1-2 already determined them; this stage only ever
|
|
1773
|
+
* overrides toward "not last" when it finds a genuinely lower
|
|
1774
|
+
* outstanding phase, never the reverse. */
|
|
1400
1775
|
}
|
|
1401
1776
|
}
|
|
1402
1777
|
if (node_fs_1.default.existsSync(statePath)) {
|