@opengsd/gsd-core 1.19.0 → 1.20.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-code-fixer.compact.md +5 -5
- package/agents/gsd-code-fixer.md +9 -4
- package/agents/gsd-code-reviewer.compact.md +49 -8
- package/agents/gsd-code-reviewer.md +53 -14
- package/agents/gsd-executor.md +8 -4
- package/commands/gsd/update.md +5 -2
- package/gsd-core/bin/gsd-tools.cjs +73 -23
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
- package/gsd-core/bin/lib/capability-registry.cjs +284 -93
- package/gsd-core/bin/lib/capability-validator.cjs +15 -0
- package/gsd-core/bin/lib/commands.cjs +27 -17
- package/gsd-core/bin/lib/config-loader.cjs +3 -3
- package/gsd-core/bin/lib/core-utils.cjs +11 -0
- package/gsd-core/bin/lib/frontmatter.cjs +21 -3
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
- package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
- package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
- package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
- package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
- package/gsd-core/bin/lib/init.cjs +180 -67
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
- package/gsd-core/bin/lib/markdown-table.cjs +23 -0
- package/gsd-core/bin/lib/mcp-server.cjs +8 -5
- package/gsd-core/bin/lib/milestone.cjs +18 -50
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
- package/gsd-core/bin/lib/phase-id.cjs +609 -35
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
- package/gsd-core/bin/lib/phase-locator.cjs +328 -19
- package/gsd-core/bin/lib/phase.cjs +2990 -666
- package/gsd-core/bin/lib/planning-document.cjs +26 -5
- package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
- package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
- package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
- package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +730 -144
- package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
- package/gsd-core/bin/lib/roadmap.cjs +358 -106
- package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
- package/gsd-core/bin/lib/smart-entry.cjs +22 -11
- package/gsd-core/bin/lib/state-contract.cjs +11 -7
- package/gsd-core/bin/lib/state-document.cjs +332 -23
- package/gsd-core/bin/lib/state-transition.cjs +39 -10
- package/gsd-core/bin/lib/state.cjs +498 -179
- package/gsd-core/bin/lib/template.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +3 -2
- package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
- package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
- package/gsd-core/bin/lib/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
- package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
- package/gsd-core/references/cicd-review-patterns.md +45 -0
- package/gsd-core/references/necessity-review.md +48 -0
- package/gsd-core/workflows/code-review-fix.md +46 -5
- package/gsd-core/workflows/code-review.md +3 -0
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
- package/gsd-core/workflows/plan-phase.md +1 -1
- package/gsd-core/workflows/pr-branch.md +2 -0
- package/gsd-core/workflows/reapply-patches.md +104 -0
- package/gsd-core/workflows/review.md +54 -141
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +57 -9
- package/gsd-core/workflows/ship.md +12 -0
- package/hooks/dist/gsd-graphify-update.sh +6 -0
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/gsd-worktree-path-guard.js +4 -4
- package/hooks/dist/lib/git-probe.js +16 -0
- package/hooks/gsd-graphify-update.sh +6 -0
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-windsurf-pre-write.js +4 -4
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/gsd-worktree-path-guard.js +4 -4
- package/hooks/lib/git-probe.js +16 -0
- package/package.json +3 -3
- package/pi/package-agents/gsd-code-fixer.md +9 -4
- package/pi/package-agents/gsd-code-reviewer.md +53 -14
- package/pi/package-agents/gsd-executor.md +8 -4
- package/pi/package-agents/manifest.json +3 -3
- package/pi/package-skills/gsd-update/SKILL.md +4 -1
- package/scripts/ci-next-health.cjs +246 -23
- package/scripts/docs-guard-registry.cjs +2 -0
- package/scripts/gen-features.cjs +14 -2
- package/scripts/lib/drift-scan.cjs +27 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +6 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
- package/scripts/lint-phase-enumeration-drift.cjs +8 -4
- package/scripts/lint-roadmap-progress-drift.cjs +421 -0
- package/scripts/mutation-matrix.cjs +23 -1
- package/scripts/sync-next-version.cjs +38 -6
- package/skills/gsd-update/SKILL.md +5 -2
- package/vscode/package.json +1 -1
|
@@ -13,25 +13,29 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
13
13
|
const node_path_1 = __importDefault(require("node:path"));
|
|
14
14
|
const clock_cjs_1 = require("./clock.cjs");
|
|
15
15
|
const pattern_cjs_1 = require("./pattern.cjs");
|
|
16
|
+
const phase_entry_grammar_cjs_1 = require("./phase-entry-grammar.cjs");
|
|
16
17
|
const text_lines_cjs_1 = require("./text-lines.cjs");
|
|
17
18
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
18
19
|
const ioMod = require("./io.cjs");
|
|
19
20
|
const { output, error, formatDiagnosticToken, declineNoOp } = ioMod;
|
|
20
21
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
22
|
const phaseIdMod = require("./phase-id.cjs");
|
|
22
|
-
const {
|
|
23
|
+
const { phaseMarkdownRegexSource, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE, roadmapPhaseLookupSources, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, isSentinelPhaseId, scopeToPhase, bracketQualifiedKey, foldBracketId } = phaseIdMod;
|
|
23
24
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
24
25
|
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
25
|
-
const { findPhaseInternal, listMilestonePhaseDirs, listAllPhaseDirs } = phaseLocatorMod;
|
|
26
|
+
const { phaseDirectoryOptions, findPhaseInternal, listMilestonePhaseDirs, listAllPhaseDirs, resolvePhaseDirectoryLookup, matchPhaseDirsForLookup, } = phaseLocatorMod;
|
|
26
27
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
27
28
|
const planningScopeMod = require("./planning-scope.cjs");
|
|
28
29
|
const { SCOPE } = planningScopeMod;
|
|
29
30
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
30
31
|
const roadmapParserModule = require("./roadmap-parser.cjs");
|
|
31
|
-
const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped,
|
|
32
|
+
const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, checklistBulletExtent, phaseChecklistBullet: phaseChecklistBulletFor, withPhaseSection, listMilestoneHeadings, scanMilestonePhaseIds, collectTablePhaseRows, hasPhaseListingTableHeader } = roadmapParserModule;
|
|
33
|
+
/** Every phase-list entry line, any phase: the rows between two of them belong to the first. */
|
|
34
|
+
const ANY_PHASE_ENTRY_LINE = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)('Phase[ \\t][^\\r\\n]*$', 'gi');
|
|
32
35
|
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
33
36
|
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
34
37
|
const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
|
|
38
|
+
const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
|
|
35
39
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
36
40
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
37
41
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
@@ -154,7 +158,7 @@ function searchPhaseInContent(content, escapedPhase, phaseNum, convention) {
|
|
|
154
158
|
// Fallback: check if phase exists in summary list but missing detail section
|
|
155
159
|
// A BARE `Phase\s+` at base — takes the label-only baseline, so a bracket
|
|
156
160
|
// repo gains the bracket-ID form and nothing else.
|
|
157
|
-
const checklistPattern =
|
|
161
|
+
const checklistPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^*]+?)${phase_entry_grammar_cjs_1.PHASE_ENTRY_BOLD_CLOSE_SRC}`, 'i', { bold: 'required' });
|
|
158
162
|
const checklistMatch = content.match(checklistPattern);
|
|
159
163
|
if (checklistMatch) {
|
|
160
164
|
return {
|
|
@@ -354,7 +358,7 @@ const occurrenceKey = (num, bracketId) => {
|
|
|
354
358
|
* `cmdRoadmapAnalyze`'s former inline loop so the fallback re-runs the EXACT
|
|
355
359
|
* same enrichment, not a second derivation.
|
|
356
360
|
*/
|
|
357
|
-
function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
|
|
361
|
+
function collectAnalyzePhases(cwd, content, phasesDir, phaseDirNames, convention, directoryContext) {
|
|
358
362
|
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
|
|
359
363
|
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
360
364
|
// #612: CAPTURING intro under the bracket convention — group 1 is the
|
|
@@ -375,7 +379,7 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
|
|
|
375
379
|
// (src/markdown-sectionizer.cts:453) so a legitimately-indented heading that
|
|
376
380
|
// matched before this fix still matches after it.
|
|
377
381
|
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
|
|
378
|
-
const phasePattern = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, true)}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gim');
|
|
382
|
+
const phasePattern = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, convention, true)}(${directoryContext.phaseNaming === 'custom' && convention !== 'bracket' ? phaseIdMod.phaseScanTokenSource(directoryContext) : '[A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*'})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gim');
|
|
379
383
|
// The capturing intro inserts the bracket id at group 1 only under the
|
|
380
384
|
// bracket convention; the token and name shift by the same offset.
|
|
381
385
|
const G = convention === 'bracket' ? 1 : 0;
|
|
@@ -413,7 +417,7 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
|
|
|
413
417
|
const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
|
414
418
|
const depends_on = dependsMatch ? dependsMatch[1].trim() : null;
|
|
415
419
|
// Check completion on disk
|
|
416
|
-
const
|
|
420
|
+
const lookup = resolvePhaseDirectoryLookup(cwd, phaseNum, undefined, directoryContext);
|
|
417
421
|
let diskStatus = phase_status_cjs_1.DISK_STATUS.NO_DIRECTORY;
|
|
418
422
|
let planCount = 0;
|
|
419
423
|
let summaryCount = 0;
|
|
@@ -447,9 +451,9 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
|
|
|
447
451
|
// That is verbatim the asymmetry the note above the W026 rule says this PR
|
|
448
452
|
// closed — the directory read widens with the heading read, or every bracket
|
|
449
453
|
// phase resolves to nothing.
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
const dirMatch =
|
|
454
|
+
// The locator adapter applies the canonical bracket spelling first and the
|
|
455
|
+
// migration-window legacy spelling only when the canonical pass misses.
|
|
456
|
+
const dirMatch = matchPhaseDirsForLookup(phaseDirNames, lookup, undefined, directoryContext).matches[0];
|
|
453
457
|
if (dirMatch) {
|
|
454
458
|
const counts = countPhasePlansAndSummaries(node_path_1.default.join(phasesDir, dirMatch), convention);
|
|
455
459
|
planCount = counts.planCount;
|
|
@@ -494,9 +498,9 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
|
|
|
494
498
|
// checkbox — no passing `*-VERIFICATION.md`, plans outstanding — now
|
|
495
499
|
// reports incomplete; this is the deliberate Tier-2 break (ADR-3180 §7.4
|
|
496
500
|
// Decision 3).
|
|
497
|
-
const checkboxPattern =
|
|
501
|
+
const checkboxPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention)}${phaseMarkdownRegexSource(phaseNum, directoryContext)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'im', { state: 'captured' });
|
|
498
502
|
const checkboxMatch = content.match(checkboxPattern);
|
|
499
|
-
const roadmapComplete = checkboxMatch ? checkboxMatch[1]
|
|
503
|
+
const roadmapComplete = checkboxMatch ? (0, phase_entry_grammar_cjs_1.isBoxCharChecked)(checkboxMatch[1]) : false;
|
|
500
504
|
const headingPhase = {
|
|
501
505
|
number: phaseNum,
|
|
502
506
|
name: phaseName,
|
|
@@ -528,7 +532,8 @@ function collectAnalyzePhases(content, phasesDir, phaseDirNames, convention) {
|
|
|
528
532
|
detailKeys.add(occurrenceKey(tr.id));
|
|
529
533
|
if (seen.has(stripPadA(tr.id)))
|
|
530
534
|
continue;
|
|
531
|
-
const
|
|
535
|
+
const tableLookup = resolvePhaseDirectoryLookup(cwd, tr.id, undefined, directoryContext);
|
|
536
|
+
const dirMatchA = matchPhaseDirsForLookup(phaseDirNames, tableLookup, undefined, directoryContext).matches[0];
|
|
532
537
|
let tPlanCount = 0;
|
|
533
538
|
let tSummaryCount = 0;
|
|
534
539
|
let tHasContext = false;
|
|
@@ -588,6 +593,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
588
593
|
const phasesDir = planningPaths(cwd).phases;
|
|
589
594
|
// #612: resolve once per command and thread the same reading through both
|
|
590
595
|
// the scoped scan and any fallback scan.
|
|
596
|
+
const directoryContext = phaseDirectoryOptions(cwd, undefined, rawContent);
|
|
591
597
|
const convention = resolvePhaseIdConvention(cwd);
|
|
592
598
|
const G = convention === 'bracket' ? 1 : 0;
|
|
593
599
|
// Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
|
|
@@ -605,7 +611,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
605
611
|
// Scan the scoped milestone window for phase-detail headings and enrich each
|
|
606
612
|
// with its on-disk status. Extracted into `collectAnalyzePhases` (#3165) so
|
|
607
613
|
// the SAME enrichment re-runs on the fallback below — not a second copy.
|
|
608
|
-
let collected = collectAnalyzePhases(content, phasesDir, _phaseDirNames, convention);
|
|
614
|
+
let collected = collectAnalyzePhases(cwd, content, phasesDir, _phaseDirNames, convention, directoryContext);
|
|
609
615
|
let phases = collected.phases;
|
|
610
616
|
let detailKeys = collected.detailKeys;
|
|
611
617
|
// #5118: carried from every phaseStatus read this command makes.
|
|
@@ -631,7 +637,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
631
637
|
// populated, flagged result.
|
|
632
638
|
if (phases.length === 0 && scope !== SCOPE.COMPLETE && _phaseDirNames.length > 0) {
|
|
633
639
|
const fallbackContent = stripShippedMilestones(rawContent);
|
|
634
|
-
const fallbackCollection = collectAnalyzePhases(fallbackContent, phasesDir, _phaseDirNames, convention);
|
|
640
|
+
const fallbackCollection = collectAnalyzePhases(cwd, fallbackContent, phasesDir, _phaseDirNames, convention, directoryContext);
|
|
635
641
|
if (fallbackCollection.phases.length > 0) {
|
|
636
642
|
collected = fallbackCollection;
|
|
637
643
|
phases = collected.phases;
|
|
@@ -664,7 +670,8 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
664
670
|
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
|
|
665
671
|
// #3036: widen to accept non-numeric-leading ids (same widening as the detail-heading pattern above).
|
|
666
672
|
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
|
|
667
|
-
const
|
|
673
|
+
const customIds = directoryContext.phaseNaming === 'custom' && convention !== 'bracket';
|
|
674
|
+
const checklistPattern = new RegExp(checklistEntrySrc(convention, true, customIds), 'gim');
|
|
668
675
|
// #2761 M1: an OCCURRENCE list keyed by `occurrenceKey`, not a token->bracket
|
|
669
676
|
// map. The map was first-wins on the bare token, so of two checklist entries
|
|
670
677
|
// sharing a token across brackets the FIRST one's bracket id classified BOTH:
|
|
@@ -687,7 +694,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
687
694
|
// `checklistPattern` — adding one would shift every downstream `1 + G` /
|
|
688
695
|
// `G` index this loop and `missingDetails` below already depend on.
|
|
689
696
|
// Reading it off the full match text instead is index-safe.
|
|
690
|
-
checklistOccurrences.push({ token, bracketId, checked:
|
|
697
|
+
checklistOccurrences.push({ token, bracketId, checked: (0, phase_entry_grammar_cjs_1.isPhaseEntryChecked)(checklistMatch[0]) });
|
|
691
698
|
}
|
|
692
699
|
// #4899: `collectAnalyzePhases` only recognises `### Phase N:` headings and
|
|
693
700
|
// progress-table rows — the shape `templates/roadmap.md` itself emits
|
|
@@ -724,8 +731,8 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
724
731
|
for (const occ of checklistOccurrences) {
|
|
725
732
|
if (isSentinelPhase(occ.token, occ.bracketId))
|
|
726
733
|
continue;
|
|
727
|
-
const
|
|
728
|
-
const dirMatch =
|
|
734
|
+
const checklistLookup = resolvePhaseDirectoryLookup(cwd, occ.token, undefined, directoryContext);
|
|
735
|
+
const dirMatch = matchPhaseDirsForLookup(_phaseDirNames, checklistLookup, undefined, directoryContext).matches[0];
|
|
729
736
|
let diskStatus = 'no_directory';
|
|
730
737
|
let planCount = 0;
|
|
731
738
|
let summaryCount = 0;
|
|
@@ -829,6 +836,54 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
829
836
|
plan_count: p.plan_count,
|
|
830
837
|
summary_count: p.summary_count,
|
|
831
838
|
}));
|
|
839
|
+
// #5221: ship's completeness gate. Phase number (zero-padding folded per segment, as ship.md's `n`)
|
|
840
|
+
// -> true only when EVERY record of it reads complete: each checklist entry (any bracket — a shared
|
|
841
|
+
// token fails closed) and the phase-table Status cell. Absent = no record. The table is read from the
|
|
842
|
+
// exact slice `update-plan-progress` writes (editProgressTableSlice), so reader and writer agree.
|
|
843
|
+
const roadmapRecord = {};
|
|
844
|
+
const fold = (token, done) => {
|
|
845
|
+
const k = foldPhaseId(token);
|
|
846
|
+
roadmapRecord[k] = (roadmapRecord[k] ?? true) && done;
|
|
847
|
+
};
|
|
848
|
+
for (const o of checklistOccurrences)
|
|
849
|
+
if (!isSentinelPhase(o.token, o.bracketId))
|
|
850
|
+
fold(o.token, o.checked);
|
|
851
|
+
// phase_naming: custom ids (`- [ ] **Phase AUTH: …**`, as phase add writes them) are records too.
|
|
852
|
+
const isCustomId = (t) => customIds && !/^[A-Za-z]?\d/.test(t);
|
|
853
|
+
if (customIds) {
|
|
854
|
+
for (const m of effectiveContent.matchAll(new RegExp(checklistEntrySrc(convention, false, true), 'gim'))) {
|
|
855
|
+
if (isCustomId(m[1]))
|
|
856
|
+
fold(m[1], (0, phase_entry_grammar_cjs_1.isPhaseEntryChecked)(m[0]));
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
// A phase table that does not parse (e.g. a ragged row) makes the record unknown: null, so ship stops.
|
|
860
|
+
let tableUnreadable = false;
|
|
861
|
+
const archivedForRecord = roadmapParserModule.maskArchivedDetails(rawContent, cwd);
|
|
862
|
+
editProgressTableSlice(archivedForRecord.visible, (scoped) => {
|
|
863
|
+
// Same table the writer picks (findPhaseTableHeader); a slice without a Plans column keeps the whole-slice Phase + Status scan.
|
|
864
|
+
const planned = findPhaseTableHeader(scoped);
|
|
865
|
+
const header = planned !== -1 ? planned : scoped.search(/^[ \t]*(?=.*\|[ \t]*Phase[ \t]*\|)(?=.*\|[ \t]*Status[ \t]*\|)\|.*$/m);
|
|
866
|
+
if (header === -1)
|
|
867
|
+
return scoped;
|
|
868
|
+
const table = (0, markdown_table_cjs_1.parseMarkdownTable)(scoped.slice(header));
|
|
869
|
+
// A phase table whose Phase/Status headers are not recognised (other case or wording) is unknown: null, so ship stops.
|
|
870
|
+
if (!table.ok || !table.value.columns.includes('Phase') || !table.value.columns.includes('Status')) {
|
|
871
|
+
tableUnreadable = true;
|
|
872
|
+
return scoped;
|
|
873
|
+
}
|
|
874
|
+
const wantMilestone = table.value.columns.includes('Milestone')
|
|
875
|
+
? (0, roadmap_progress_cjs_1.normalizeMilestoneToken)(roadmapParserModule.getMilestoneInfo(cwd).value?.version)
|
|
876
|
+
: null;
|
|
877
|
+
for (const r of table.value.rows) {
|
|
878
|
+
if (wantMilestone !== null && (wantMilestone === '' || (0, roadmap_progress_cjs_1.normalizeMilestoneToken)(r['Milestone']) !== wantMilestone))
|
|
879
|
+
continue;
|
|
880
|
+
const cell = (r['Phase'] ?? '').trim();
|
|
881
|
+
const token = cell.match(/^[A-Za-z]?\d\S*?(?=\.?(?:\s|$))/)?.[0] ?? cell.match(new RegExp(`^${CUSTOM_PHASE_ID_SRC}?(?=\\.?(?:\\s|$))`))?.[0];
|
|
882
|
+
if (token && (/^[A-Za-z]?\d/.test(token) ? !isSentinelPhase(token) : isCustomId(token)))
|
|
883
|
+
fold(token, (0, phase_status_cjs_1.parseRoadmapStatusCell)(r['Status']) === phase_status_cjs_1.WIRE_STATUS.COMPLETE);
|
|
884
|
+
}
|
|
885
|
+
return scoped;
|
|
886
|
+
}, archivedForRecord.activeRange);
|
|
832
887
|
const result = {
|
|
833
888
|
milestones,
|
|
834
889
|
phases,
|
|
@@ -859,6 +914,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
|
|
|
859
914
|
// #4757: phases whose ROADMAP checkbox disagrees with `disk_status`
|
|
860
915
|
// (`roadmap_complete` vs `disk_status === "complete"`); always an array.
|
|
861
916
|
checkbox_conflict: checkboxConflict,
|
|
917
|
+
roadmap_record: tableUnreadable ? null : roadmapRecord,
|
|
862
918
|
missing_phase_details: missingDetails.length > 0 ? missingDetails : null,
|
|
863
919
|
// #3184/#3165: distinguishes a genuinely empty milestone (`scope:
|
|
864
920
|
// "complete"`, `phase_count: 0`) from a window that could not be fully
|
|
@@ -932,14 +988,57 @@ function cmdRoadmapMilestoneScope(cwd, raw) {
|
|
|
932
988
|
output({ scope, phases, phase_count: phases.length }, raw, undefined);
|
|
933
989
|
}
|
|
934
990
|
// ─── cmdRoadmapUpdatePlanProgress ─────────────────────────────────────────────
|
|
991
|
+
/** #5221: phase-id fold (leading zeros stripped per segment) keying roadmap_record; ship.md's jq `def n` folds identically (pinned by a test). */
|
|
992
|
+
function foldPhaseId(token) {
|
|
993
|
+
return token.split('.').map((s) => s.replace(/^0+(?=\d)/, '')).join('.');
|
|
994
|
+
}
|
|
995
|
+
/** #5221: whether offset `idx` of `text` sits inside a bare <details> block (an open-attribute wrapper is the active milestone, not shipped) that has not closed. */
|
|
996
|
+
function insideOpenDetails(text, idx) {
|
|
997
|
+
const prior = text.slice(0, idx).toLowerCase();
|
|
998
|
+
return prior.lastIndexOf('<details>') > prior.lastIndexOf('</details>');
|
|
999
|
+
}
|
|
1000
|
+
/** #5221: a `phase_naming: custom` id (`Phase AUTH:`). */
|
|
1001
|
+
const CUSTOM_PHASE_ID_SRC = '[A-Za-z][\\w-]{0,63}';
|
|
1002
|
+
/**
|
|
1003
|
+
* #5221: source of a `- [ ] **Phase N:` checklist entry, the one shape `roadmap analyze` reads and
|
|
1004
|
+
* phase add/insert anchor on. Token is the last group; `custom` also admits a custom id before `:`.
|
|
1005
|
+
*/
|
|
1006
|
+
function checklistEntrySrc(convention, capturing = false, custom = false) {
|
|
1007
|
+
// phase-id-owner: uses [.-] (dot-or-dash) separator variant, not the canonical dot-only token; swapping to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
|
|
1008
|
+
const numeric = '[A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*';
|
|
1009
|
+
// numeric must end at a word edge, else `P1-setup` matches as `P1` before the custom alternative is tried.
|
|
1010
|
+
const token = custom ? `(?:${numeric}(?![\\w-])|${CUSTOM_PHASE_ID_SRC}(?=:))` : numeric;
|
|
1011
|
+
return `${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)({ bold: 'required' })}${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, capturing)}(${token})`;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* #5221: offset of the phase table's header row — the first table row whose cells
|
|
1015
|
+
* name `Phase` and `Plans Complete` (or `Plans`), compared case-insensitively. The one
|
|
1016
|
+
* predicate `editFromPhaseTable` (writer) and `roadmap analyze`'s `roadmap_record` (reader)
|
|
1017
|
+
* both pick the table by, so they never disagree on which table is the phase table. -1: none.
|
|
1018
|
+
*/
|
|
1019
|
+
function findPhaseTableHeader(text) {
|
|
1020
|
+
for (const m of text.matchAll(/^[ \t]*\|.*$/gm)) {
|
|
1021
|
+
const cells = m[0].split('|').map((c) => c.trim().toLowerCase());
|
|
1022
|
+
if (cells.includes('phase') && (cells.includes('plans complete') || cells.includes('plans')))
|
|
1023
|
+
return m.index;
|
|
1024
|
+
}
|
|
1025
|
+
return -1;
|
|
1026
|
+
}
|
|
1027
|
+
function editFromPhaseTable(scoped, edit) {
|
|
1028
|
+
// #5221: Status/Completed bind to the first table carrying that column; start
|
|
1029
|
+
// at the phase table so an earlier versions table is never the target. A Progress
|
|
1030
|
+
// slice with no Plans column at all keeps the whole-slice scan.
|
|
1031
|
+
const at = Math.max(findPhaseTableHeader(scoped), 0);
|
|
1032
|
+
return scoped.slice(0, at) + edit(scoped.slice(at));
|
|
1033
|
+
}
|
|
935
1034
|
/**
|
|
936
1035
|
* Scope a ROADMAP.md content string down to its "Progress table" writable
|
|
937
1036
|
* slice, run `edit` against just that slice, then splice the result back into
|
|
938
1037
|
* the original content (ADR-2143 §7). Layered scoping:
|
|
939
|
-
* 1. Milestone scope —
|
|
940
|
-
* (
|
|
941
|
-
* an archived milestone is never
|
|
942
|
-
* 2. Heading scope — within
|
|
1038
|
+
* 1. Milestone scope — the caller has already hidden archived `<details>`
|
|
1039
|
+
* blocks (`maskArchivedDetails`, #5553), so a same-numbered phase row in
|
|
1040
|
+
* an archived milestone is never seen here.
|
|
1041
|
+
* 2. Heading scope — within the visible text, the `## Progress` heading
|
|
943
1042
|
* section (up to the next `#`/`##` heading) when present, else the whole
|
|
944
1043
|
* milestone slice (mirrors phase-lifecycle.cjs's `deriveProgressFromRoadmap`
|
|
945
1044
|
* read-side scoping, #2012 decoy avoidance — a differently-headed table
|
|
@@ -948,29 +1047,19 @@ function cmdRoadmapMilestoneScope(cwd, raw) {
|
|
|
948
1047
|
* found within the scoped slice) simply returns its input unchanged, mirroring
|
|
949
1048
|
* the prior regex `.replace()`'s no-match-is-a-no-op semantics.
|
|
950
1049
|
*/
|
|
951
|
-
function editProgressTableSlice(content, edit) {
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
const progressMatch = milestoneSlice.match(/^##[ \t]+Progress\b/im);
|
|
957
|
-
if (!progressMatch || progressMatch.index === undefined) {
|
|
958
|
-
return before + edit(milestoneSlice);
|
|
959
|
-
}
|
|
960
|
-
const headingOffset = progressMatch.index;
|
|
961
|
-
const beforeHeading = milestoneSlice.slice(0, headingOffset);
|
|
962
|
-
const fromHeading = milestoneSlice.slice(headingOffset);
|
|
963
|
-
const nextHeading = fromHeading.search(/\n#{1,2}[ \t]/);
|
|
964
|
-
const scoped = nextHeading >= 0 ? fromHeading.slice(0, nextHeading) : fromHeading;
|
|
965
|
-
const after = nextHeading >= 0 ? fromHeading.slice(nextHeading) : '';
|
|
966
|
-
return before + beforeHeading + edit(scoped) + after;
|
|
1050
|
+
function editProgressTableSlice(content, edit, active = null) {
|
|
1051
|
+
// #5762: the Progress section is the owner's (`editRoadmapProgress`, the write side of the
|
|
1052
|
+
// rule every reader uses; the first one inside the active milestone's range wins, #5553).
|
|
1053
|
+
// A headingless document is edited whole, from the phase table's header.
|
|
1054
|
+
return (0, roadmap_progress_cjs_1.editRoadmapProgress)(content, (scoped) => editFromPhaseTable(scoped, edit), { active });
|
|
967
1055
|
}
|
|
968
1056
|
function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
969
1057
|
if (!phaseNum) {
|
|
970
1058
|
error('phase number required for roadmap update-plan-progress');
|
|
971
1059
|
}
|
|
972
1060
|
const roadmapPath = planningPaths(cwd).roadmap;
|
|
973
|
-
const
|
|
1061
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
1062
|
+
const phaseInfo = findPhaseInternal(cwd, phaseNum, undefined, directoryContext);
|
|
974
1063
|
if (!phaseInfo) {
|
|
975
1064
|
error(`Phase ${phaseNum} not found`);
|
|
976
1065
|
}
|
|
@@ -1075,6 +1164,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1075
1164
|
// refusal below declines with the analyzer's own `missing_phase_details`
|
|
1076
1165
|
// vocabulary and leaves ROADMAP.md byte-identical.
|
|
1077
1166
|
let missingPhaseDetails = false;
|
|
1167
|
+
let archiveRestoreFailed = false;
|
|
1168
|
+
let archiveAmbiguous = false;
|
|
1078
1169
|
const warnings = [];
|
|
1079
1170
|
withPlanningLock(cwd, () => {
|
|
1080
1171
|
// #3957 (B9.4): captured BEFORE any transform runs, so the write/report
|
|
@@ -1082,9 +1173,14 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1082
1173
|
// anything — not just that they ran. Every transform below still runs
|
|
1083
1174
|
// unconditionally exactly as before; only the final write-and-report
|
|
1084
1175
|
// step becomes conditional on `roadmapContent !== originalContent`.
|
|
1085
|
-
const
|
|
1176
|
+
const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
1177
|
+
// #5553: every transform below runs on the text with archived `<details>`
|
|
1178
|
+
// blocks hidden, so none can reach an archive; restored before the write.
|
|
1179
|
+
const archived = roadmapParserModule.maskArchivedDetails(rawContent, cwd);
|
|
1180
|
+
const originalContent = archived.visible;
|
|
1086
1181
|
let roadmapContent = originalContent;
|
|
1087
|
-
|
|
1182
|
+
// #5186: a custom ID such as SETUP-2 is literal, not numeric phase 2.
|
|
1183
|
+
const phasePattern = phaseMarkdownRegexSource(phaseNum, directoryContext);
|
|
1088
1184
|
// #4247: ONE local source for the ATX phase-heading anchor that every
|
|
1089
1185
|
// section-scoped writer below (`planSectionPattern`,
|
|
1090
1186
|
// `insertRowsPatternA|B`) starts with — extracted so the target-detection
|
|
@@ -1101,16 +1197,12 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1101
1197
|
// phase-id-owner: deliberately unmigrated (#4984 revert) — see comment above.
|
|
1102
1198
|
const phaseHeadingAnchor = `#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`;
|
|
1103
1199
|
// #4247: target detection runs against the ORIGINAL content's active
|
|
1104
|
-
// (
|
|
1200
|
+
// (archive-masked) text — the same milestone scoping every writer
|
|
1105
1201
|
// below applies — so the gate asks "does the file carry a writable phase
|
|
1106
1202
|
// representation" rather than "did some regex fire mid-transform".
|
|
1107
|
-
const gateDetailsClose = originalContent.lastIndexOf('</details>');
|
|
1108
|
-
const gateActiveRegion = gateDetailsClose === -1
|
|
1109
|
-
? originalContent
|
|
1110
|
-
: originalContent.slice(gateDetailsClose + '</details>'.length);
|
|
1111
1203
|
// Heading target: the exact grammar `planSectionPattern` /
|
|
1112
1204
|
// `insertRowsPatternA|B` anchor on (an ATX phase heading for this phase).
|
|
1113
|
-
const headingTargetFound = new RegExp(phaseHeadingAnchor, 'i').test(
|
|
1205
|
+
const headingTargetFound = new RegExp(phaseHeadingAnchor, 'i').test(originalContent);
|
|
1114
1206
|
// #5545: the phase-checklist bullet grammar, defined ONCE for the writer
|
|
1115
1207
|
// (an unchecked bullet to tick) and the gate probe (`anyState`).
|
|
1116
1208
|
// Line-anchored, with only an optional `**` before the label and horizontal
|
|
@@ -1121,16 +1213,18 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1121
1213
|
// same narrowing #4965 made for `roadmap analyze`'s checklist reader.
|
|
1122
1214
|
// The lookahead only PEEKS (its `\s` may see a newline but consumes none):
|
|
1123
1215
|
// the label must be followed by `:`, whitespace or the end of the line, so
|
|
1124
|
-
// `Phase 2.1` / `Phase 20` are not `Phase 2`. Capture group 1 is
|
|
1125
|
-
//
|
|
1126
|
-
|
|
1216
|
+
// `Phase 2.1` / `Phase 20` are not `Phase 2`. Capture group 1 is everything through
|
|
1217
|
+
// the opening `[`, group 2 the rest of the line from the closing `]`. The grammar (marker,
|
|
1218
|
+
// box, bold token) is the phase-entry grammar's, shared with every reader
|
|
1219
|
+
// and writer through roadmap-parser's `phaseChecklistBullet` (#5764).
|
|
1220
|
+
const phaseChecklistBullet = (anyState) => phaseChecklistBulletFor(phasePattern, anyState);
|
|
1127
1221
|
// Checklist target: when the phase is complete, its own checklist bullet
|
|
1128
1222
|
// (`- [ ] **Phase N: …**`) IS a writable phase row — the completion
|
|
1129
1223
|
// checkbox stamp below updates it. Same grammar as that writer, widened
|
|
1130
1224
|
// one notch to `[ x]` so an ALREADY-checked bullet still counts as a
|
|
1131
1225
|
// found target: an idempotent re-run then takes the honest
|
|
1132
1226
|
// "no changes were needed" decline instead of this refusal.
|
|
1133
|
-
const checklistTargetFound = isComplete && phaseChecklistBullet(true).test(
|
|
1227
|
+
const checklistTargetFound = isComplete && phaseChecklistBullet(true).test(originalContent);
|
|
1134
1228
|
// Table-row target: set by the row-scoped cell updates below.
|
|
1135
1229
|
let tableRowFound = false;
|
|
1136
1230
|
// Progress table row: update Plans Complete/Status/Completed columns BY
|
|
@@ -1203,14 +1297,14 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1203
1297
|
tableRowFound = true;
|
|
1204
1298
|
}
|
|
1205
1299
|
return text;
|
|
1206
|
-
});
|
|
1300
|
+
}, archived.activeRange);
|
|
1207
1301
|
// #4947: a word-prefixed Phase cell (`Phase 68`) is deliberately not a
|
|
1208
1302
|
// writable table target (#4247 — the read side ignores it too), but when the
|
|
1209
1303
|
// phase's heading section is still written the command reports
|
|
1210
1304
|
// `updated: true` and the unwritten Status cell looked like a silent bug.
|
|
1211
1305
|
// The row stays byte-identical; the output now says so.
|
|
1212
1306
|
const wordPrefixedCellRe = new RegExp(`^Phase\\s+${phasePattern}\\.?(?:\\s|$)`, 'i');
|
|
1213
|
-
const progressTable = tableRowFound ? null : (0, markdown_table_cjs_1.findTableWithColumns)(
|
|
1307
|
+
const progressTable = tableRowFound ? null : (0, markdown_table_cjs_1.findTableWithColumns)(originalContent, ['Phase', 'Status']);
|
|
1214
1308
|
if (progressTable && progressTable.rows.some((row) => wordPrefixedCellRe.test((row['Phase'] ?? '').trim()))) {
|
|
1215
1309
|
warnings.push(`Progress table row for phase ${formatDiagnosticToken(String(phaseNum))} was not updated: its Phase cell is word-prefixed ("Phase N"), and only a bare phase number (N or N.) is recognized. Edit that row by hand or rewrite the cell as the bare number.`);
|
|
1216
1310
|
}
|
|
@@ -1332,7 +1426,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1332
1426
|
: `; ${stillPending.length} ${m[2]}plan${stillPending.length === 1 ? '' : 's'} pending (${stillPending.join(', ')})`;
|
|
1333
1427
|
return tail.slice(0, m.index) + replacement + tail.slice(m.index + m[0].length);
|
|
1334
1428
|
};
|
|
1335
|
-
roadmapContent =
|
|
1429
|
+
roadmapContent = roadmapContent.replace(planSectionPattern, (sectionText) => {
|
|
1336
1430
|
const parsed = (0, planning_document_cjs_1.parsePlanningDoc)(sectionText, 'ROADMAP.md');
|
|
1337
1431
|
if (!parsed.ok) {
|
|
1338
1432
|
// Unreadable section (e.g. an unterminated frontmatter fence) —
|
|
@@ -1412,7 +1506,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1412
1506
|
});
|
|
1413
1507
|
// If complete: check checkbox
|
|
1414
1508
|
if (isComplete) {
|
|
1415
|
-
roadmapContent =
|
|
1509
|
+
roadmapContent = roadmapContent.replace(phaseChecklistBullet(false), `$1x$2 (completed ${today})`);
|
|
1416
1510
|
}
|
|
1417
1511
|
// Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md", "- [ ] 50-01:", or "- [ ] **50-01**")
|
|
1418
1512
|
// #4741: tick only plans the phase's own COUNT still counts. `plans` is the
|
|
@@ -1429,19 +1523,98 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1429
1523
|
.map((summaryFile) => ({ summaryFile, planId: summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '') }))
|
|
1430
1524
|
.filter(({ summaryFile, planId }) => planId !== ''
|
|
1431
1525
|
&& coreUtils.summaryPairsWithAnyPlan(phaseInfo.plans, summaryFile));
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1438
|
-
|
|
1526
|
+
// #5483: a plan's row is ticked only in THIS phase's own scopes (its detail
|
|
1527
|
+
// section, then the rows under its checklist bullet, as `phase complete` does).
|
|
1528
|
+
// A scope that was located but holds no row ticks nothing, so a sibling phase's
|
|
1529
|
+
// same-named row is never touched; only a phase with neither scope falls back
|
|
1530
|
+
// to the first row outside every phase bullet's rows. Archived blocks are
|
|
1531
|
+
// already hidden by maskArchivedDetails (#5553), which owns which regions are editable.
|
|
1532
|
+
const editPhaseScopes = (text, edit, levelBounded = false) => {
|
|
1533
|
+
let out = withPhaseSection(text, phaseNum, edit, levelBounded);
|
|
1534
|
+
const bullet = phaseChecklistBullet(true).exec(out);
|
|
1535
|
+
if (bullet) {
|
|
1536
|
+
const { start, end } = checklistBulletExtent(out, bullet);
|
|
1537
|
+
out = out.slice(0, start) + edit(out.slice(start, end)) + out.slice(end);
|
|
1538
|
+
}
|
|
1539
|
+
return out;
|
|
1540
|
+
};
|
|
1541
|
+
// Read-only view of the scopes `editPhaseScopes` edits: the non-empty text of each.
|
|
1542
|
+
const readPhaseScopes = (text, levelBounded = false) => {
|
|
1543
|
+
const scopes = [];
|
|
1544
|
+
editPhaseScopes(text, (scope) => { if (scope.trim() !== '')
|
|
1545
|
+
scopes.push(scope); return scope; }, levelBounded);
|
|
1546
|
+
return scopes;
|
|
1547
|
+
};
|
|
1548
|
+
// Decided once on the pre-insertion text: insertion only adds rows under an existing section.
|
|
1549
|
+
const phaseScopes = readPhaseScopes(roadmapContent, true);
|
|
1550
|
+
const located = phaseScopes.length > 0;
|
|
1551
|
+
// Same-length copy of `text` with every phase's detail section and checklist-bullet rows blanked: what is
|
|
1552
|
+
// left is "unowned" text (e.g. rows under a non-phase heading inside the active milestone), which belongs to no sibling phase.
|
|
1553
|
+
const unownedText = (text) => {
|
|
1554
|
+
const spans = [];
|
|
1555
|
+
const heads = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(text);
|
|
1556
|
+
heads.forEach((h, i) => {
|
|
1557
|
+
if (/^Phase\b/i.test(h.text.replace(/^\[[^\]]*\]\s*/, '')))
|
|
1558
|
+
spans.push([h.offset, heads[i + 1]?.offset ?? text.length]);
|
|
1559
|
+
});
|
|
1560
|
+
for (const b of text.matchAll(ANY_PHASE_ENTRY_LINE))
|
|
1561
|
+
spans.push([b.index, checklistBulletExtent(text, b).end]);
|
|
1562
|
+
let out = text;
|
|
1563
|
+
for (const [a, z] of spans)
|
|
1564
|
+
out = out.slice(0, a) + out.slice(a, z).replace(/[^\r\n]/g, ' ') + out.slice(z);
|
|
1565
|
+
return out;
|
|
1566
|
+
};
|
|
1567
|
+
const tickUnowned = (text, planId) => {
|
|
1568
|
+
const blank = unownedText(text);
|
|
1569
|
+
const ticked = coreUtils.tickAnchoredPlanRow(blank, planId);
|
|
1570
|
+
if (ticked === blank)
|
|
1571
|
+
return text;
|
|
1572
|
+
let out = '';
|
|
1573
|
+
for (let i = 0; i < text.length; i++)
|
|
1574
|
+
out += ticked[i] !== blank[i] ? ticked[i] : text[i];
|
|
1575
|
+
return out;
|
|
1576
|
+
};
|
|
1577
|
+
const fallbackTicked = new Set();
|
|
1578
|
+
const tickPlanRow = (text, planId) => {
|
|
1579
|
+
// levelBounded: a row under the phase's own `#### Plans` sub-heading ticks too.
|
|
1580
|
+
const out = editPhaseScopes(text, (scope) => coreUtils.tickAnchoredPlanRow(scope, planId), true);
|
|
1581
|
+
if (out !== text)
|
|
1582
|
+
return out;
|
|
1583
|
+
// A located scope without the row: a row no sibling phase owns (under a non-phase heading) is still this phase's.
|
|
1584
|
+
if (located)
|
|
1585
|
+
return tickUnowned(text, planId);
|
|
1586
|
+
if (fallbackTicked.has(planId))
|
|
1587
|
+
return out;
|
|
1588
|
+
// First row outside every phase bullet's rows: one nested under a sibling's bullet is that sibling's.
|
|
1589
|
+
let ticked = text;
|
|
1590
|
+
let from = 0;
|
|
1591
|
+
for (const b of text.matchAll(ANY_PHASE_ENTRY_LINE)) {
|
|
1592
|
+
const { start, end } = checklistBulletExtent(text, b);
|
|
1593
|
+
const seg = text.slice(from, start);
|
|
1594
|
+
const t = coreUtils.tickAnchoredPlanRow(seg, planId);
|
|
1595
|
+
if (t !== seg) {
|
|
1596
|
+
ticked = text.slice(0, from) + t + text.slice(start);
|
|
1597
|
+
break;
|
|
1598
|
+
}
|
|
1599
|
+
from = end;
|
|
1600
|
+
}
|
|
1601
|
+
if (ticked === text) {
|
|
1602
|
+
const tail = coreUtils.tickAnchoredPlanRow(text.slice(from), planId);
|
|
1603
|
+
if (tail !== text.slice(from))
|
|
1604
|
+
ticked = text.slice(0, from) + tail;
|
|
1605
|
+
}
|
|
1606
|
+
if (ticked !== text)
|
|
1607
|
+
fallbackTicked.add(planId);
|
|
1608
|
+
return ticked;
|
|
1609
|
+
};
|
|
1610
|
+
const tickPlanRows = (text) => tickableSummaries.reduce((acc, { planId }) => tickPlanRow(acc, planId), text);
|
|
1611
|
+
roadmapContent = tickPlanRows(roadmapContent);
|
|
1612
|
+
// Detection reads presentRows (the archive-masked text plus rows hidden in
|
|
1613
|
+
// collapsed blocks of the active range); insertion edits the masked text only.
|
|
1614
|
+
// A plan row that exists only in an archived <details> block is therefore
|
|
1615
|
+
// not counted as "already present" in the active milestone section.
|
|
1439
1616
|
// (Finding 1 code-review: detection was previously running against the full
|
|
1440
1617
|
// roadmapContent, causing archived rows to suppress active-section inserts.)
|
|
1441
|
-
const lastDetailsClose = roadmapContent.lastIndexOf('</details>');
|
|
1442
|
-
const activeRegion = lastDetailsClose === -1
|
|
1443
|
-
? roadmapContent
|
|
1444
|
-
: roadmapContent.slice(lastDetailsClose + '</details>'.length);
|
|
1445
1618
|
// Compute which plan files are MISSING a checkbox row in the ACTIVE region.
|
|
1446
1619
|
// This handles three cases:
|
|
1447
1620
|
// (a) Fresh template — no rows at all: all plans are missing.
|
|
@@ -1461,12 +1634,20 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1461
1634
|
// up to the SAME boundary the tick loop uses (`planRowPattern`, #5542), so
|
|
1462
1635
|
// `5-011` never satisfies `5-01` and a dotted sub-id (`5-01.5`) is a real
|
|
1463
1636
|
// distinct plan whose row does not count as 5-01's presence.
|
|
1637
|
+
// Rows inside a collapsed block of the active range are hidden from edits but still present.
|
|
1638
|
+
// #5483: presence is judged in THIS phase's own scopes (plus rows hidden in collapsed active blocks), so a
|
|
1639
|
+
// sibling phase's same-named un-prefixed row never makes a plan look present; a phase with no scope reads the whole text.
|
|
1640
|
+
const presentRows = located
|
|
1641
|
+
? `${readPhaseScopes(roadmapContent, true).join('\n')}\n${archived.hiddenInActive}`
|
|
1642
|
+
: `${roadmapContent}\n${archived.hiddenInActive}`;
|
|
1643
|
+
const unownedNow = located ? unownedText(roadmapContent) : '';
|
|
1644
|
+
const hasUnownedAny = (planFile, stem) => new RegExp(`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${(0, pattern_cjs_1.escapeRegex)(planFile)}`, 'i').test(unownedNow) || coreUtils.planRowPattern(stem, { anyState: true }).test(unownedNow);
|
|
1464
1645
|
const missingPlans = phaseInfo.plans.filter((planFile) => {
|
|
1465
1646
|
const planEscaped = (0, pattern_cjs_1.escapeRegex)(planFile);
|
|
1466
|
-
if (new RegExp(`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${planEscaped}`, 'i').test(
|
|
1647
|
+
if (new RegExp(`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${planEscaped}`, 'i').test(presentRows))
|
|
1467
1648
|
return false;
|
|
1468
1649
|
const stem = planFile.replace(/-PLAN\.md$/i, '');
|
|
1469
|
-
return !coreUtils.planRowPattern(stem, { anyState: true }).test(
|
|
1650
|
+
return !coreUtils.planRowPattern(stem, { anyState: true }).test(presentRows) && !(located && hasUnownedAny(planFile, stem));
|
|
1470
1651
|
});
|
|
1471
1652
|
if (missingPlans.length > 0) {
|
|
1472
1653
|
// Insert missing plan checklist rows (#1163). We prefer to anchor to the
|
|
@@ -1488,32 +1669,16 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1488
1669
|
const sortedMissing = [...missingPlans].sort();
|
|
1489
1670
|
const newRows = sortedMissing.map(p => `- [ ] ${p}`).join('\n');
|
|
1490
1671
|
const inserter = (match) => `${match}\n${newRows}`;
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
// Note: lastDetailsClose was computed above (shared with detection).
|
|
1496
|
-
const scopedReplace = (src, pat) => src.replace(pat, inserter);
|
|
1497
|
-
let withRows;
|
|
1498
|
-
if (lastDetailsClose === -1) {
|
|
1499
|
-
// activeRegion === roadmapContent when there are no </details> blocks.
|
|
1500
|
-
const regionA = scopedReplace(activeRegion, insertRowsPatternA);
|
|
1501
|
-
withRows = regionA !== activeRegion ? regionA : scopedReplace(activeRegion, insertRowsPatternB);
|
|
1502
|
-
}
|
|
1503
|
-
else {
|
|
1504
|
-
const beforeDetails = roadmapContent.slice(0, lastDetailsClose + '</details>'.length);
|
|
1505
|
-
const regionA = scopedReplace(activeRegion, insertRowsPatternA);
|
|
1506
|
-
const afterWithRows = regionA !== activeRegion ? regionA : scopedReplace(activeRegion, insertRowsPatternB);
|
|
1507
|
-
withRows = beforeDetails + afterWithRows;
|
|
1508
|
-
}
|
|
1672
|
+
// Archived `<details>` blocks are already hidden (maskArchivedDetails), so
|
|
1673
|
+
// archived duplicate phase headings cannot receive rows.
|
|
1674
|
+
const regionA = roadmapContent.replace(insertRowsPatternA, inserter);
|
|
1675
|
+
const withRows = regionA !== roadmapContent ? regionA : roadmapContent.replace(insertRowsPatternB, inserter);
|
|
1509
1676
|
if (withRows !== roadmapContent) {
|
|
1510
1677
|
roadmapContent = withRows;
|
|
1511
1678
|
// Mark any newly-inserted rows that already have summaries as complete
|
|
1512
1679
|
// (#4741: same superseded-filtered tick list as the loop above — a
|
|
1513
1680
|
// pre-existing superseded row must stay unchecked on this path too).
|
|
1514
|
-
|
|
1515
|
-
roadmapContent = roadmapContent.replace(coreUtils.planRowPattern(planId), '$1x$2');
|
|
1516
|
-
}
|
|
1681
|
+
roadmapContent = tickPlanRows(roadmapContent);
|
|
1517
1682
|
}
|
|
1518
1683
|
}
|
|
1519
1684
|
// #3957 (B9.4): write and report an update only when the transforms
|
|
@@ -1532,8 +1697,16 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1532
1697
|
// left byte-identical, and the caller gets a typed
|
|
1533
1698
|
// `missing_phase_details` decline instead of a false green.
|
|
1534
1699
|
const phaseRepresentationFound = tableRowFound || headingTargetFound || checklistTargetFound;
|
|
1535
|
-
|
|
1536
|
-
|
|
1700
|
+
// Unpaired <details> tags make the archive extents a guess: refuse rather than edit inconsistently.
|
|
1701
|
+
const finalContent = archived.ambiguous ? null : archived.restore(roadmapContent);
|
|
1702
|
+
if (archived.ambiguous) {
|
|
1703
|
+
archiveAmbiguous = true;
|
|
1704
|
+
}
|
|
1705
|
+
else if (finalContent === null) {
|
|
1706
|
+
archiveRestoreFailed = true;
|
|
1707
|
+
}
|
|
1708
|
+
else if (phaseRepresentationFound && finalContent !== rawContent) {
|
|
1709
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, finalContent);
|
|
1537
1710
|
updated = true;
|
|
1538
1711
|
}
|
|
1539
1712
|
if (!phaseRepresentationFound) {
|
|
@@ -1551,6 +1724,14 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1551
1724
|
if (updated) {
|
|
1552
1725
|
output(warnings.length > 0 ? { updated: true, ...computed, warnings } : { updated: true, ...computed }, raw, `${summaryCount}/${planCount} ${status}`);
|
|
1553
1726
|
}
|
|
1727
|
+
else if (archiveAmbiguous) {
|
|
1728
|
+
declineNoOp(raw, 'updated', 'archive_structure_ambiguous', 'roadmap update-plan-progress skipped — ROADMAP.md has an unpaired <details> tag that hides where an archived block ends. ROADMAP.md was left unchanged.', computed);
|
|
1729
|
+
}
|
|
1730
|
+
else if (archiveRestoreFailed) {
|
|
1731
|
+
// An edit dropped an archive placeholder (#5553): writing would lose that
|
|
1732
|
+
// archive, so refuse loudly instead of reporting "no changes were needed".
|
|
1733
|
+
declineNoOp(raw, 'updated', 'archive_placeholder_lost', 'roadmap update-plan-progress skipped — an edit would have dropped an archived ROADMAP block. ROADMAP.md was left unchanged.', computed);
|
|
1734
|
+
}
|
|
1554
1735
|
else if (missingPhaseDetails) {
|
|
1555
1736
|
// #4247: honest refusal — the reason names the real condition (the
|
|
1556
1737
|
// analyzer's `missing_phase_details` vocabulary), never "already
|
|
@@ -1563,6 +1744,23 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1563
1744
|
}
|
|
1564
1745
|
}
|
|
1565
1746
|
// ─── cmdRoadmapAnnotateDependencies ───────────────────────────────────────────
|
|
1747
|
+
/** #5321: tags a generated constraints heading so re-runs can tell it from a pre-#5321 one. */
|
|
1748
|
+
const GENERATED_MARK = ' <!-- gsd:generated -->';
|
|
1749
|
+
const BULLET_RE = /^\s*-\s+/;
|
|
1750
|
+
/**
|
|
1751
|
+
* #5321: first checklist-shaped row left after the matched Plans block, scanning the rest of the
|
|
1752
|
+
* Plans region — it ends at a heading or an unindented non-list line, so a later section's
|
|
1753
|
+
* checklist is never mistaken for a stranded plan row. Indented detail lines are skipped, not final.
|
|
1754
|
+
*/
|
|
1755
|
+
function findStrandedRow(rest) {
|
|
1756
|
+
for (const l of rest) {
|
|
1757
|
+
if (/^[ \t]*[-*+][ \t]+\[[ \t]*[xX ]?[ \t]*\]/.test(l))
|
|
1758
|
+
return l;
|
|
1759
|
+
if (/^#{1,6}\s/.test(l) || /^\S/.test(l) && !/^(?:[-*+]\s|\*\*Wave\s+\d+\*\*)/i.test(l))
|
|
1760
|
+
return undefined;
|
|
1761
|
+
}
|
|
1762
|
+
return undefined;
|
|
1763
|
+
}
|
|
1566
1764
|
/**
|
|
1567
1765
|
* Annotate the ROADMAP.md plan list for a phase with wave dependency notes
|
|
1568
1766
|
* and a cross-cutting constraints subsection derived from PLAN frontmatter.
|
|
@@ -1573,8 +1771,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
|
|
1573
1771
|
* Cross-cutting constraints: must_haves.truths strings that appear in 2+ plans
|
|
1574
1772
|
* are surfaced in a "Cross-cutting constraints" subsection below the plan list.
|
|
1575
1773
|
*
|
|
1576
|
-
*
|
|
1577
|
-
*
|
|
1774
|
+
* Re-running regenerates the annotation from current PLAN frontmatter (replans
|
|
1775
|
+
* update waves/constraints, #5321); idempotent: an unchanged result writes nothing.
|
|
1578
1776
|
*/
|
|
1579
1777
|
function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
|
|
1580
1778
|
if (!phaseNum) {
|
|
@@ -1585,7 +1783,8 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
|
|
|
1585
1783
|
declineNoOp(raw, 'updated', 'ROADMAP.md not found', 'roadmap annotate-dependencies skipped — ROADMAP.md not found.');
|
|
1586
1784
|
return;
|
|
1587
1785
|
}
|
|
1588
|
-
const
|
|
1786
|
+
const directoryContext = phaseDirectoryOptions(cwd);
|
|
1787
|
+
const phaseInfo = findPhaseInternal(cwd, phaseNum, undefined, directoryContext);
|
|
1589
1788
|
// #3957 (B9.1): distinguish "phase does not resolve at all" from "phase
|
|
1590
1789
|
// resolves but has zero plans" — previously both collapsed into the same
|
|
1591
1790
|
// 'no plans found for phase' reason, which is simply false for the first
|
|
@@ -1655,6 +1854,9 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
|
|
|
1655
1854
|
.map(v => v.text);
|
|
1656
1855
|
// Patch ROADMAP.md
|
|
1657
1856
|
let updated = false;
|
|
1857
|
+
let skipped;
|
|
1858
|
+
let staleLegacy = [];
|
|
1859
|
+
let droppedGenerated = [];
|
|
1658
1860
|
withPlanningLock(cwd, () => {
|
|
1659
1861
|
const content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
1660
1862
|
// #3413: preserve the file's own EOL style when the checklist block below
|
|
@@ -1665,7 +1867,7 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
|
|
|
1665
1867
|
// Find the phase section.
|
|
1666
1868
|
// #3537: padding-tolerant fragment so the caller's resolved padded id
|
|
1667
1869
|
// matches un-padded ROADMAP headings.
|
|
1668
|
-
const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
|
|
1870
|
+
const phaseEscaped = phaseMarkdownRegexSource(phaseNum, directoryContext);
|
|
1669
1871
|
// #4906 Phase 5 (#4984): NOT migrated onto the heading-baseline selector —
|
|
1670
1872
|
// this site was never one of the five ADR-4910 §8 call sites this phase
|
|
1671
1873
|
// owns (init.cts/milestone.cts's buildPhaseHeadingScanRegex sites); a
|
|
@@ -1684,10 +1886,6 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
|
|
|
1684
1886
|
const nextPhaseOffset = restAfterHeader.slice(1).search(/\n#{2,4}\s+Phase\s+\d/i);
|
|
1685
1887
|
const phaseEnd = nextPhaseOffset >= 0 ? phaseStart + 1 + nextPhaseOffset : content.length;
|
|
1686
1888
|
const phaseSection = content.slice(phaseStart, phaseEnd);
|
|
1687
|
-
// Idempotency: skip if annotation markers already present
|
|
1688
|
-
if (/\*\*Wave\s+\d+/i.test(phaseSection) ||
|
|
1689
|
-
/\*\*Cross-cutting constraints:\*\*/i.test(phaseSection))
|
|
1690
|
-
return;
|
|
1691
1889
|
// Find the Plans: section within the phase section.
|
|
1692
1890
|
// #3691 Bug 1: `Plans:\s*\n` required no text after the colon, missing variants like
|
|
1693
1891
|
// `Plans: 3 plans across 2 waves\n` or `**Plans:** 3 plans\n` (bold-wrapped).
|
|
@@ -1707,15 +1905,32 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
|
|
|
1707
1905
|
// it accepts the first complete parse and stops, which rules out the #2128-class
|
|
1708
1906
|
// ambiguous-boundary blowup despite the nested-quantifier shape. Non-global match on
|
|
1709
1907
|
// already phase-sliced content, not the whole file.
|
|
1710
|
-
//
|
|
1711
|
-
|
|
1908
|
+
// #5321: re-runs rebuild a prior annotation: group 2 swallows generated `**Wave N**` headers
|
|
1909
|
+
// only (user prose kept); group 3 the exact constraints header + bullets, never read as plans.
|
|
1910
|
+
// Block ends in one blank line (group 3 eats it) so later user bullets survive; legacy blocks
|
|
1911
|
+
// (no marker) keep bullets that are not current shared truths; the exact heading is reserved.
|
|
1912
|
+
// Blank lines inside the list are crossed only before a plan-id row, so a hand-written bullet after a blank line stays out.
|
|
1913
|
+
// Outer `+` has no trailing constraint to force re-partitioning; alternatives start with distinct literals; measured linear to 10.9MB.
|
|
1914
|
+
const plansBlockMatch = phaseSection.match(new RegExp(String.raw `(?:^|\r?\n)(\*{0,2}Plans\*{0,2}:[^\r\n]*\r?\n)((?:[ \t]*-\s*\[[ x]\][^\r\n]*\r?\n?|(?:[ \t]*\r?\n)+(?=[ \t]*-\s*\[[ x]\]\s*[\w.-]+?(?:-PLAN\.md|\.md|:|\s—))|\s*\*\*Wave\s+\d+\*\*(?: \*\(blocked on Wave \d+ completion\)\*)?[ \t]*(?:\r?\n|$)(?=\s*-\s*\[[ x]\]))+)(\s*\*\*Cross-cutting constraints:\*\*(${(0, pattern_cjs_1.escapeRegex)(GENERATED_MARK)})?[ \t]*(?:\r?\n|$)(?:[ \t]*-[ \t]+[^\r\n]*\r?\n?)*(?:[ \t]*\r?\n)?)?`, 'i'));
|
|
1712
1915
|
if (!plansBlockMatch)
|
|
1713
1916
|
return;
|
|
1714
1917
|
const plansHeader = plansBlockMatch[1];
|
|
1715
1918
|
const existingList = plansBlockMatch[2];
|
|
1716
|
-
const listLines = (0, text_lines_cjs_1.splitLines)(existingList).filter(l => /^\s*-\s*\[
|
|
1919
|
+
const listLines = (0, text_lines_cjs_1.splitLines)(existingList).filter(l => /^\s*-\s*\[[ x]\]/i.test(l));
|
|
1717
1920
|
if (listLines.length === 0)
|
|
1718
1921
|
return;
|
|
1922
|
+
const oldBlock = plansBlockMatch[3] ? (0, text_lines_cjs_1.splitLines)(plansBlockMatch[3]) : [];
|
|
1923
|
+
// #5321: a checklist row group 2 rejects (`- [ ]`, `* [ ]`), or an empty generated wave
|
|
1924
|
+
// header, splits the list; rewriting only the prefix would orphan the rest — skip loudly.
|
|
1925
|
+
const stranded = plansBlockMatch[3] ? undefined : findStrandedRow((0, text_lines_cjs_1.splitLines)(phaseSection.slice(plansBlockMatch.index + plansBlockMatch[0].length)));
|
|
1926
|
+
const warn = (head, tail) => {
|
|
1927
|
+
process.stderr.write(`[gsd-tools] WARNING: roadmap annotate-dependencies ${head}${formatDiagnosticToken(String(phaseNum))}${tail}`);
|
|
1928
|
+
};
|
|
1929
|
+
if (stranded) {
|
|
1930
|
+
warn(`skipped — unrecognised checklist row ${formatDiagnosticToken(stranded.trim())} splits the Plans list of phase `, '.\n');
|
|
1931
|
+
skipped = 'unrecognised_checklist_row';
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1719
1934
|
// #314 perf: build a first-wins Map so per-line lookup is O(1) instead of O(plans).
|
|
1720
1935
|
// First-wins mirrors .find() semantics: if the same planId appears more than once
|
|
1721
1936
|
// in planData, the earlier entry wins — identical to what .find() returned before.
|
|
@@ -1761,11 +1976,38 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
|
|
|
1761
1976
|
// Append cross-cutting constraints subsection if any found
|
|
1762
1977
|
if (crossCuttingTruths.length > 0) {
|
|
1763
1978
|
annotatedLines.push('');
|
|
1764
|
-
annotatedLines.push(
|
|
1979
|
+
annotatedLines.push(`**Cross-cutting constraints:**${GENERATED_MARK}`);
|
|
1765
1980
|
for (const t of crossCuttingTruths) {
|
|
1766
1981
|
annotatedLines.push(`- ${t}`);
|
|
1767
1982
|
}
|
|
1983
|
+
annotatedLines.push(''); // #5321: delimiter so user bullets after the block survive re-runs
|
|
1984
|
+
}
|
|
1985
|
+
// #5321: bullets of the old constraints block that equal ANY current plan truth were generated
|
|
1986
|
+
// from one (rebuilt above, or no longer shared): drop them. Any other bullet is a user bullet or
|
|
1987
|
+
// a reworded/removed constraint; both look alike. Without the generated marker (pre-#5321) keep
|
|
1988
|
+
// and report it; under the marker the block is machine-owned, so drop it but warn.
|
|
1989
|
+
if (plansBlockMatch[3]) {
|
|
1990
|
+
const unknown = oldBlock
|
|
1991
|
+
.filter(l => BULLET_RE.test(l) && !truthCounts.has(l.replace(BULLET_RE, '').trim().toLowerCase()));
|
|
1992
|
+
const texts = unknown.map(l => l.replace(BULLET_RE, '').trim());
|
|
1993
|
+
const names = texts.map(formatDiagnosticToken).join('; ');
|
|
1994
|
+
if (unknown.length > 0 && plansBlockMatch[4]) {
|
|
1995
|
+
droppedGenerated = texts;
|
|
1996
|
+
warn(`dropped ${unknown.length} bullet(s) under the generated `
|
|
1997
|
+
+ `constraints heading that match no current plan truth in phase `, `: ${names} — put your own bullets after a blank line.\n`);
|
|
1998
|
+
}
|
|
1999
|
+
else if (unknown.length > 0) {
|
|
2000
|
+
if (crossCuttingTruths.length === 0)
|
|
2001
|
+
annotatedLines.push('');
|
|
2002
|
+
annotatedLines.push(...unknown);
|
|
2003
|
+
staleLegacy = texts;
|
|
2004
|
+
warn(`kept ${unknown.length} bullet(s) under a pre-#5321 `
|
|
2005
|
+
+ `constraints block that match no current plan truth in phase `, `: ${names} — delete any that are stale.\n`);
|
|
2006
|
+
}
|
|
1768
2007
|
}
|
|
2008
|
+
// The old block's trailing blank line separated it from user content below: keep it when the block is dropped.
|
|
2009
|
+
if (oldBlock.length > 2 && !oldBlock[oldBlock.length - 2].trim() && annotatedLines[annotatedLines.length - 1] !== '')
|
|
2010
|
+
annotatedLines.push('');
|
|
1769
2011
|
const newListBlock = (0, text_lines_cjs_1.joinLines)(annotatedLines, eol) + eol;
|
|
1770
2012
|
// #1103: when `(?:^|\r?\n)` consumed a leading terminator (mid-string
|
|
1771
2013
|
// match), re-emit it verbatim so the line preceding the Plans: header is
|
|
@@ -1783,7 +2025,8 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
|
|
|
1783
2025
|
// never pattern-interpreted.
|
|
1784
2026
|
const newPhaseSection = phaseSection.replace(plansBlockMatch[0], () => leadingNewline + plansHeader + newListBlock);
|
|
1785
2027
|
const nextContent = content.slice(0, phaseStart) + newPhaseSection + content.slice(phaseEnd);
|
|
1786
|
-
|
|
2028
|
+
// #5321: compare post-normalize (platformWriteSync collapses blank runs), not raw strings.
|
|
2029
|
+
if (!(0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, content, nextContent))
|
|
1787
2030
|
return;
|
|
1788
2031
|
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, nextContent);
|
|
1789
2032
|
updated = true;
|
|
@@ -1793,7 +2036,10 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
|
|
|
1793
2036
|
phase: phaseNum,
|
|
1794
2037
|
waves: waves.length,
|
|
1795
2038
|
cross_cutting_constraints: crossCuttingTruths.length,
|
|
1796
|
-
|
|
2039
|
+
...(skipped && { skipped }),
|
|
2040
|
+
...(staleLegacy.length > 0 && { stale_legacy_constraints: staleLegacy }),
|
|
2041
|
+
...(droppedGenerated.length > 0 && { dropped_generated_constraints: droppedGenerated }),
|
|
2042
|
+
}, raw, updated ? `annotated ${waves.length} wave(s), ${crossCuttingTruths.length} constraint(s)` : skipped ? `skipped (${skipped})` : 'skipped (already up to date or no plan list)');
|
|
1797
2043
|
}
|
|
1798
2044
|
module.exports = {
|
|
1799
2045
|
cmdRoadmapGetPhase,
|
|
@@ -1803,4 +2049,10 @@ module.exports = {
|
|
|
1803
2049
|
cmdRoadmapUpdatePlanProgress,
|
|
1804
2050
|
cmdRoadmapAnnotateDependencies,
|
|
1805
2051
|
buildPhaseHeadingRegex,
|
|
2052
|
+
editFromPhaseTable,
|
|
2053
|
+
findPhaseTableHeader,
|
|
2054
|
+
editProgressTableSlice,
|
|
2055
|
+
foldPhaseId,
|
|
2056
|
+
insideOpenDetails,
|
|
2057
|
+
checklistEntrySrc,
|
|
1806
2058
|
};
|