Merge branch 'next' into fix/2137-derive-progress-header-driven
This commit is contained in:
@@ -694,6 +694,7 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
// Stage files
|
||||
const explicitFiles = files && files.length > 0;
|
||||
const filesToStage = explicitFiles ? files : ['.planning/'];
|
||||
const stagedPaths: string[] = [];
|
||||
for (const file of filesToStage) {
|
||||
const fullPath = path.join(cwd, file);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
@@ -708,12 +709,32 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
execGit(['rm', '--cached', '--ignore-unmatch', file], { cwd });
|
||||
} else {
|
||||
execGit(['add', file], { cwd });
|
||||
stagedPaths.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Commit (--no-verify skips pre-commit hooks, used by parallel executor agents)
|
||||
const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', sanitizedMessage as string];
|
||||
// Commit — when the caller declared a scope (--files), append a pathspec so
|
||||
// only the declared files land in the commit, not the entire index (#2112).
|
||||
// The pathspec uses stagedPaths (not filesToStage) so skipped missing files
|
||||
// are excluded — otherwise git would record them as deletions (#2014).
|
||||
// During a merge, git refuses partial commits — fall back to a bare commit.
|
||||
// --amend is left without a pathspec: amending with -- <paths> is a different
|
||||
// operation that rewrites the tip with only those paths.
|
||||
if (explicitFiles && stagedPaths.length === 0 && !amend) {
|
||||
const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
|
||||
output(result, raw, 'nothing');
|
||||
return;
|
||||
}
|
||||
const isMergeInProgress = execGit(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd }).exitCode === 0;
|
||||
const canScope = explicitFiles && stagedPaths.length > 0 && !amend
|
||||
&& !isMergeInProgress;
|
||||
const commitArgs = amend
|
||||
? ['commit', '--amend', '--no-edit']
|
||||
: ['commit', '-m', sanitizedMessage as string];
|
||||
if (noVerify) commitArgs.push('--no-verify');
|
||||
if (canScope) {
|
||||
commitArgs.push('--', ...stagedPaths);
|
||||
}
|
||||
const commitResult = execGit(commitArgs, { cwd });
|
||||
if (commitResult.exitCode !== 0) {
|
||||
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
|
||||
@@ -817,13 +838,22 @@ function cmdCommitToSubrepo(cwd: string, message: string | undefined, files: str
|
||||
const repoCwd = path.join(cwd, repo);
|
||||
|
||||
// Stage files (strip sub-repo prefix for paths relative to that repo)
|
||||
const stagedRelPaths: string[] = [];
|
||||
for (const file of repoFiles) {
|
||||
const relativePath = file.slice(repo.length + 1);
|
||||
execGit(['add', relativePath], { cwd: repoCwd });
|
||||
const addResult = execGit(['add', relativePath], { cwd: repoCwd });
|
||||
if (addResult.exitCode === 0) {
|
||||
stagedRelPaths.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Commit
|
||||
const commitResult = execGit(['commit', '-m', message as string], { cwd: repoCwd });
|
||||
// Commit — pathspec limits the commit to the staged files only (#2112)
|
||||
const isMergeInProgressSub = execGit(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd: repoCwd }).exitCode === 0;
|
||||
const canScopeSub = stagedRelPaths.length > 0 && !isMergeInProgressSub;
|
||||
const commitArgs = canScopeSub
|
||||
? ['commit', '-m', message as string, '--', ...stagedRelPaths]
|
||||
: ['commit', '-m', message as string];
|
||||
const commitResult = execGit(commitArgs, { cwd: repoCwd });
|
||||
if (commitResult.exitCode !== 0) {
|
||||
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
|
||||
repos[repo] = { committed: false, hash: null, files: repoFiles, reason: 'nothing_to_commit' };
|
||||
@@ -969,8 +999,16 @@ function cmdPrSubrepo(
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Commit
|
||||
const commitResult = execGit(['commit', '-m', commitMessage as string], { cwd: repoCwd });
|
||||
// 5. Commit — pathspec limits the commit to the staged files only (#2112).
|
||||
// changedFiles includes both old and new paths for renames so the full
|
||||
// rename is captured atomically (pathspec on newPath alone would leave the
|
||||
// deletion of oldPath stranded in the index).
|
||||
const isMergeInProgressPr = execGit(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd: repoCwd }).exitCode === 0;
|
||||
const canScopePr = changedFiles.length > 0 && !isMergeInProgressPr;
|
||||
const commitArgs = canScopePr
|
||||
? ['commit', '-m', commitMessage as string, '--', ...changedFiles]
|
||||
: ['commit', '-m', commitMessage as string];
|
||||
const commitResult = execGit(commitArgs, { cwd: repoCwd });
|
||||
if (commitResult.exitCode !== 0) {
|
||||
rollback();
|
||||
error(`Failed to commit in ${repo}: ${commitResult.stderr}`);
|
||||
|
||||
@@ -38,6 +38,7 @@ interface MilestoneCompleteOptions {
|
||||
name?: string;
|
||||
force?: boolean;
|
||||
archivePhases?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: boolean): void {
|
||||
@@ -161,8 +162,10 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
const today = realClock.localToday();
|
||||
const milestoneName = options.name || version;
|
||||
|
||||
// Ensure archive directory exists
|
||||
platformEnsureDir(archiveDir);
|
||||
// Ensure archive directory exists (skipped in dry-run — no mutations)
|
||||
if (!options.dryRun) {
|
||||
platformEnsureDir(archiveDir);
|
||||
}
|
||||
|
||||
// Scope stats and accomplishments to only the phases belonging to the
|
||||
// current milestone's ROADMAP. Uses the shared filter from roadmap-parser.cjs
|
||||
@@ -295,6 +298,47 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
/* intentionally empty */
|
||||
}
|
||||
|
||||
// #2118: --dry-run preview — compute what WOULD happen without mutating.
|
||||
// The stats above are read-only; all mutations start at the archive section below.
|
||||
if (options.dryRun) {
|
||||
const phaseDirsToArchive: string[] = [];
|
||||
if (options.archivePhases !== false) {
|
||||
try {
|
||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory() && isDirInMilestone(e.name)) {
|
||||
phaseDirsToArchive.push(e.name);
|
||||
}
|
||||
}
|
||||
} catch { /* phasesDir missing — nothing to archive */ }
|
||||
}
|
||||
const dryRunResult = {
|
||||
dry_run: true,
|
||||
version,
|
||||
name: milestoneName,
|
||||
stats: { phases: phaseCount, plans: totalPlans, tasks: totalTasks },
|
||||
accomplishments,
|
||||
would_archive: {
|
||||
roadmap: fs.existsSync(roadmapPath)
|
||||
? { source: path.relative(cwd, roadmapPath).split(path.sep).join('/'), target: path.relative(cwd, path.join(archiveDir, `${version}-ROADMAP.md`)).split(path.sep).join('/') }
|
||||
: null,
|
||||
requirements: fs.existsSync(reqPath)
|
||||
? { source: path.relative(cwd, reqPath).split(path.sep).join('/'), target: path.relative(cwd, path.join(archiveDir, `${version}-REQUIREMENTS.md`)).split(path.sep).join('/') }
|
||||
: null,
|
||||
audit: fs.existsSync(path.join(planningBase, `${version}-MILESTONE-AUDIT.md`))
|
||||
? { source: path.relative(cwd, path.join(planningBase, `${version}-MILESTONE-AUDIT.md`)).split(path.sep).join('/'), target: path.relative(cwd, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)).split(path.sep).join('/') }
|
||||
: null,
|
||||
phases: phaseDirsToArchive,
|
||||
},
|
||||
would_update: {
|
||||
milestones_md: path.relative(cwd, milestonesPath).split(path.sep).join('/'),
|
||||
state_md: fs.existsSync(statePath) ? path.relative(cwd, statePath).split(path.sep).join('/') : null,
|
||||
},
|
||||
};
|
||||
output(dryRunResult, raw);
|
||||
return;
|
||||
}
|
||||
|
||||
// Archive ROADMAP.md
|
||||
if (fs.existsSync(roadmapPath)) {
|
||||
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
|
||||
@@ -163,7 +163,28 @@ function routePhaseCommand({ phase, args, cwd, raw, error }: RoutePhaseCommandOp
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
complete: (_ctx: Record<string, unknown>): { ok: true; data: null } => {
|
||||
phase.cmdPhaseComplete(cwd, args[2], raw);
|
||||
// #2201: accept --phase N as well as the positional form (the state
|
||||
// family already accepts --phase). An unrecognized flag is a usage
|
||||
// error, not "Phase --phase not found".
|
||||
let phaseNum: string | null = null;
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
if (args[i] === '--phase') {
|
||||
phaseNum = args[++i];
|
||||
if (!phaseNum || phaseNum.startsWith('--'))
|
||||
return makeInvalidArgs('--phase', '--phase requires a value') as never;
|
||||
} else if (args[i].startsWith('--phase=')) {
|
||||
phaseNum = args[i].slice(8);
|
||||
} else if (args[i] === '--raw') {
|
||||
continue;
|
||||
} else if (args[i].startsWith('--')) {
|
||||
return makeInvalidArgs(args[i], `phase complete does not support ${args[i]}`) as never;
|
||||
} else {
|
||||
phaseNum = args[i];
|
||||
}
|
||||
}
|
||||
if (!phaseNum)
|
||||
return makeInvalidArgs('--phase', 'phase number required (positional or --phase N)') as never;
|
||||
phase.cmdPhaseComplete(cwd, phaseNum, raw);
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
'uat-passed': (_ctx: Record<string, unknown>): { ok: true; data: null } => {
|
||||
@@ -185,7 +206,26 @@ function routePhaseCommand({ phase, args, cwd, raw, error }: RoutePhaseCommandOp
|
||||
},
|
||||
// #1437 — list plan files for a phase
|
||||
'list-plans': (_ctx: Record<string, unknown>): { ok: true; data: null } => {
|
||||
phase.cmdPhaseListPlans(cwd, args[2], raw);
|
||||
// #2201: accept --phase N as well as positional.
|
||||
let phaseNum: string | null = null;
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
if (args[i] === '--phase') {
|
||||
phaseNum = args[++i];
|
||||
if (!phaseNum || phaseNum.startsWith('--'))
|
||||
return makeInvalidArgs('--phase', '--phase requires a value') as never;
|
||||
} else if (args[i].startsWith('--phase=')) {
|
||||
phaseNum = args[i].slice(8);
|
||||
} else if (args[i] === '--raw') {
|
||||
continue;
|
||||
} else if (args[i].startsWith('--')) {
|
||||
return makeInvalidArgs(args[i], `phase list-plans does not support ${args[i]}`) as never;
|
||||
} else {
|
||||
phaseNum = args[i];
|
||||
}
|
||||
}
|
||||
if (!phaseNum)
|
||||
return makeInvalidArgs('--phase', 'phase number required (positional or --phase N)') as never;
|
||||
phase.cmdPhaseListPlans(cwd, phaseNum, raw);
|
||||
return { ok: true as const, data: null };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@ import phaseLocatorMod = require('./phase-locator.cjs');
|
||||
const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
|
||||
import roadmapParserMod = require('./roadmap-parser.cjs');
|
||||
const { stripShippedMilestones, extractCurrentMilestone, getMilestonePhaseFilter } = roadmapParserMod;
|
||||
const { stripShippedMilestones, extractCurrentMilestone, getMilestonePhaseFilter, currentMilestoneRawRanges } = roadmapParserMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
||||
@@ -1475,13 +1475,13 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
// so completing an already-checked phase (idempotent re-run) checked the
|
||||
// wrong phase's box. Mirrors the tight pattern used by phase-insert
|
||||
// (`]\\s*(?:\\*\\*)?Phase`).
|
||||
// #2067/#2200: line-anchored (^ + m flag, optional leading indent) so an
|
||||
// inline / backticked prose literal cannot match. Milestone-scoped below
|
||||
// (mutateMilestonePhase) so a Backlog entry or a same-numbered shipped-
|
||||
// milestone phase cannot be flipped either.
|
||||
const checkboxPattern = new RegExp(
|
||||
`(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`,
|
||||
'i',
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(
|
||||
checkboxPattern,
|
||||
`$1x$2 (completed ${today})`,
|
||||
`^[ \\t]*(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`,
|
||||
'im',
|
||||
);
|
||||
|
||||
const tableRowPattern = new RegExp(
|
||||
@@ -1522,21 +1522,47 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
'i',
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(
|
||||
planCountPattern,
|
||||
`$1${summaryCount}/${planCount} plans complete`,
|
||||
);
|
||||
|
||||
const phaseInfoSummaries = phaseInfo['summaries'] as string[];
|
||||
for (const summaryFile of phaseInfoSummaries) {
|
||||
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
if (!planId) continue;
|
||||
const planEscaped = escapeRegex(planId);
|
||||
const planCheckboxPattern = new RegExp(
|
||||
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
|
||||
'i',
|
||||
);
|
||||
roadmapContent = (roadmapContent).replace(planCheckboxPattern, '$1x$2');
|
||||
|
||||
// #2200: apply the phase-checkbox flip, the plan-count write, and the
|
||||
// per-plan checkbox flips ONLY within the current milestone's region(s)
|
||||
// (primary section + optional Phase Details section). A bullet/heading in
|
||||
// a shipped milestone, a Backlog section, or a backticked prose literal is
|
||||
// outside the window and stays untouched. With no versioned active
|
||||
// milestone, fall back to whole-content mutation (prior behaviour).
|
||||
const mutateMilestonePhase = (slice: string): string => {
|
||||
let s = slice;
|
||||
s = s.replace(checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
s = s.replace(planCountPattern, `$1${summaryCount}/${planCount} plans complete`);
|
||||
for (const summaryFile of phaseInfoSummaries) {
|
||||
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
if (!planId) continue;
|
||||
const planEscaped = escapeRegex(planId);
|
||||
const planCheckboxPattern = new RegExp(
|
||||
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
|
||||
'i',
|
||||
);
|
||||
s = s.replace(planCheckboxPattern, '$1x$2');
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
const milestoneRanges = currentMilestoneRawRanges(roadmapContent, cwd);
|
||||
if (milestoneRanges) {
|
||||
// Splice later windows first so an earlier window's offsets are not
|
||||
// shifted by a length-changing mutation in a later window.
|
||||
const windows = [milestoneRanges.details, milestoneRanges.primary]
|
||||
.filter((w): w is { start: number; end: number } => w !== null)
|
||||
.sort((a, b) => b.start - a.start);
|
||||
for (const w of windows) {
|
||||
roadmapContent =
|
||||
roadmapContent.slice(0, w.start)
|
||||
+ mutateMilestonePhase(roadmapContent.slice(w.start, w.end))
|
||||
+ roadmapContent.slice(w.end);
|
||||
}
|
||||
} else {
|
||||
roadmapContent = mutateMilestonePhase(roadmapContent);
|
||||
}
|
||||
|
||||
writes.push({
|
||||
|
||||
@@ -202,6 +202,23 @@ function replaceInCurrentMilestone(content: string, pattern: RegExp, replacement
|
||||
|
||||
// ─── Roadmap phase lookup ─────────────────────────────────────────────────────
|
||||
|
||||
// #2199: a bullet/checkbox phase entry, e.g. `- [ ] **Phase 36 — Authentication**`
|
||||
// (the bundled roadmapper emits this in bullet-house-style ROADMAPs). The number
|
||||
// is captured in group 1, the name in group 2; the separator may be an em-dash,
|
||||
// en-dash, hyphen, or colon. Used as a fallback when no ATX heading matches, and
|
||||
// to count phases in a milestone that uses the bullet form.
|
||||
const BULLET_PHASE_LINE_PATTERN =
|
||||
/^\s*[-*]\s+(?:\[[ xX]\]\s+)?\*\*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*[—–:\-]\s*(.+?)\*\*/im;
|
||||
|
||||
/** Build a bullet-phase-line regex pinned to a specific phase number (#2199). */
|
||||
function bulletPhaseLineFor(phaseNum: unknown, phaseSource?: string): RegExp {
|
||||
const num = phaseSource ?? phaseMarkdownRegexSource(phaseNum);
|
||||
return new RegExp(
|
||||
`^\\s*[-*]\\s+(?:\\[[ xX]\\]\\s+)?\\*\\*Phase\\s+(${num})${OPTIONAL_PHASE_TAG_SOURCE}\\s*[—–:\\-]\\s*(.+?)\\*\\*`,
|
||||
'im',
|
||||
);
|
||||
}
|
||||
|
||||
interface RoadmapPhaseResult {
|
||||
found: boolean;
|
||||
phase_number: string;
|
||||
@@ -241,6 +258,22 @@ function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSour
|
||||
};
|
||||
}
|
||||
|
||||
function findRoadmapBulletPhaseInContent(content: string, phaseNum: unknown, phaseSource?: string): RoadmapPhaseResult | null {
|
||||
// #2199: bullet/checkbox entry fallback (`- [ ] **Phase N — name**`). Returns
|
||||
// the single bullet line as the section (no multi-line body) — used only as a
|
||||
// last resort, AFTER heading lookup on scoped + full content has failed, so a
|
||||
// heading with a Requirements/Goal section always wins.
|
||||
const bulletMatch = content.match(bulletPhaseLineFor(phaseNum, phaseSource));
|
||||
if (!bulletMatch) return null;
|
||||
return {
|
||||
found: true,
|
||||
phase_number: String(phaseNum),
|
||||
phase_name: bulletMatch[2].trim(),
|
||||
goal: null,
|
||||
section: bulletMatch[0].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null {
|
||||
if (!phaseNum) return null;
|
||||
const normalizedPhase = stripProjectCodePrefix(phaseNum);
|
||||
@@ -262,6 +295,17 @@ function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseRe
|
||||
if (fullResult) return fullResult;
|
||||
}
|
||||
|
||||
// #2199: no ATX heading matched on scoped or full content — fall back to a
|
||||
// bullet/checkbox entry (em-dash/en-dash/hyphen/colon separator). Last resort
|
||||
// so a bullet never pre-empts a heading that carries the Requirements section.
|
||||
for (const source of roadmapPhaseLookupSources(phaseNum)) {
|
||||
const scopedBullet = findRoadmapBulletPhaseInContent(content, phaseNum, source);
|
||||
if (scopedBullet) return scopedBullet;
|
||||
|
||||
const fullBullet = findRoadmapBulletPhaseInContent(fullContent, phaseNum, source);
|
||||
if (fullBullet) return fullBullet;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -458,6 +502,16 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
|
||||
// Exclude 999.x backlog phases from milestone phase set. Mirrors init.cts filter.
|
||||
if (pm && !/^999\b/.test(pm[1])) milestonePhaseNums.add(pm[1]);
|
||||
}
|
||||
// #2199: also count bullet/checkbox phase entries (`- [ ] **Phase N — name**`)
|
||||
// so a bullet-house-style ROADMAP populates the milestone phase set instead of
|
||||
// collapsing to a zero-count pass-all filter.
|
||||
{
|
||||
let bm: RegExpExecArray | null;
|
||||
const scanner = new RegExp(BULLET_PHASE_LINE_PATTERN.source, 'gim');
|
||||
while ((bm = scanner.exec(roadmap)) !== null) {
|
||||
if (!/^999\b/.test(bm[1])) milestonePhaseNums.add(bm[1]);
|
||||
}
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
if (milestonePhaseNums.size === 0) {
|
||||
@@ -502,6 +556,92 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
|
||||
return isDirInMilestone as MilestonePhaseFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2200: raw [start,end) offsets of the current milestone's region(s) in ROADMAP
|
||||
* content, for scoping write-path mutations (phase-checkbox flip, Plans-count
|
||||
* writer) so they cannot touch a backticked prose literal, a Backlog entry, or a
|
||||
* same-numbered phase in a shipped milestone.
|
||||
*
|
||||
* Mirrors the region selection in `extractCurrentMilestone` (version detection →
|
||||
* active heading → next milestone boundary → optional Phase Details section).
|
||||
* Returns null when there is no versioned active milestone; callers then fall
|
||||
* back to whole-content mutation (the prior behaviour).
|
||||
*
|
||||
* NOTE: keep the region logic here in sync with extractCurrentMilestone.
|
||||
*/
|
||||
function currentMilestoneRawRanges(
|
||||
content: string,
|
||||
cwd?: string,
|
||||
): { primary: { start: number; end: number }; details: { start: number; end: number } | null } | null {
|
||||
if (!cwd) return null;
|
||||
|
||||
let version: string | null = null;
|
||||
try {
|
||||
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
||||
const stateRaw = platformReadSync(statePath);
|
||||
if (stateRaw !== null) {
|
||||
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
|
||||
if (milestoneMatch) version = milestoneMatch[1].trim();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (!version) {
|
||||
const inProgressMatch = content.match(/(?:🚧|🔄)\s*\*\*v(\d+\.\d+)\s/);
|
||||
if (inProgressMatch) version = 'v' + inProgressMatch[1];
|
||||
}
|
||||
if (!version) return null;
|
||||
|
||||
const escapedVersion = escapeRegex(version);
|
||||
const sectionPattern = new RegExp(
|
||||
`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
|
||||
'gmi',
|
||||
);
|
||||
const headingMatches = [...content.matchAll(sectionPattern)];
|
||||
if (headingMatches.length === 0) return null;
|
||||
|
||||
const closedMarkerPattern = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
|
||||
const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
|
||||
const isClosed = (h: string) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
|
||||
const firstMatch = headingMatches[0];
|
||||
const selected = headingMatches.find((m) => !isClosed(m[1])) || firstMatch;
|
||||
const sectionStart = selected.index ?? 0;
|
||||
|
||||
const computeSectionEnd = (headingText: string, headingStart: number): number => {
|
||||
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
||||
const afterHeading = headingStart + headingText.length;
|
||||
for (const h of tokenizeHeadings(content)) {
|
||||
if (h.offset <= headingStart) continue;
|
||||
if (h.offset < afterHeading) continue;
|
||||
if (h.level > level) continue;
|
||||
if (/^Phase\s+\S/i.test(h.text)) continue;
|
||||
if (!/v\d+\.\d+|✅|📋|🚧/i.test(h.text)) continue;
|
||||
return h.offset;
|
||||
}
|
||||
return content.length;
|
||||
};
|
||||
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
|
||||
|
||||
const selectedVersionToken = selected[1].match(
|
||||
/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i,
|
||||
)?.[0];
|
||||
const detailsVersionBoundary = selectedVersionToken
|
||||
? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
|
||||
: null;
|
||||
const detailsMatch = headingMatches.find(
|
||||
(m) =>
|
||||
/\(Phase\s+Details\)/i.test(m[1]) &&
|
||||
!isClosed(m[1]) &&
|
||||
(!detailsVersionBoundary || detailsVersionBoundary.test(m[1])) &&
|
||||
(m.index ?? 0) >= sectionEnd,
|
||||
);
|
||||
let details: { start: number; end: number } | null = null;
|
||||
if (detailsMatch) {
|
||||
const detailsStart = detailsMatch.index ?? 0;
|
||||
details = { start: detailsStart, end: computeSectionEnd(detailsMatch[0], detailsStart) };
|
||||
}
|
||||
|
||||
return { primary: { start: sectionStart, end: sectionEnd }, details };
|
||||
}
|
||||
|
||||
export = {
|
||||
stripShippedMilestones,
|
||||
extractCurrentMilestone,
|
||||
@@ -509,4 +649,5 @@ export = {
|
||||
getRoadmapPhaseInternal,
|
||||
getMilestoneInfo,
|
||||
getMilestonePhaseFilter,
|
||||
currentMilestoneRawRanges,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user