Merge branch 'next' into kimi-runtime-support
This commit is contained in:
784
src/core.cts
784
src/core.cts
@@ -9,7 +9,16 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execGit, platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
import { execGit, platformWriteSync, platformReadSync } from './shell-command-projection.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import ioModule = require('./io.cjs');
|
||||
const { output, error, ERROR_REASON, setJsonErrorMode, getJsonErrorMode, GSD_TEMP_DIR, reapStaleTempFiles } = ioModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdModule = require('./phase-id.cjs');
|
||||
const { escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum, extractPhaseToken, phaseTokenMatches } = phaseIdModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import roadmapParserModule = require('./roadmap-parser.cjs');
|
||||
const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter } = roadmapParserModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import modelProfiles = require('./model-profiles.cjs');
|
||||
const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES: _VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles;
|
||||
@@ -76,151 +85,6 @@ function detectSubRepos(cwd: string): string[] {
|
||||
|
||||
// findProjectRoot is now re-exported from the generated CJS module above.
|
||||
|
||||
// ─── Output helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dedicated GSD temp directory: path.join(os.tmpdir(), 'gsd').
|
||||
* Created on first use. Keeps GSD temp files isolated from the system
|
||||
* temp directory so reap scans only GSD files (#1975).
|
||||
*/
|
||||
const GSD_TEMP_DIR = path.join(os.tmpdir(), 'gsd');
|
||||
|
||||
function ensureGsdTempDir(): void {
|
||||
platformEnsureDir(GSD_TEMP_DIR);
|
||||
}
|
||||
|
||||
interface ReapOptions {
|
||||
maxAgeMs?: number;
|
||||
dirsOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes).
|
||||
* Runs opportunistically before each new temp file write to prevent unbounded accumulation.
|
||||
* @param prefix - filename prefix to match (e.g., 'gsd-')
|
||||
* @param opts
|
||||
* @param opts.maxAgeMs - max age in ms before removal (default: 5 min)
|
||||
* @param opts.dirsOnly - if true, only remove directories (default: false)
|
||||
*/
|
||||
function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false }: ReapOptions = {}): void {
|
||||
try {
|
||||
ensureGsdTempDir();
|
||||
const now = Date.now();
|
||||
const entries = fs.readdirSync(GSD_TEMP_DIR);
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith(prefix)) continue;
|
||||
const fullPath = path.join(GSD_TEMP_DIR, entry);
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (now - stat.mtimeMs > maxAgeMs) {
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||
} else if (!dirsOnly) {
|
||||
fs.unlinkSync(fullPath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File may have been removed between readdir and stat — ignore
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — don't let cleanup failures break output
|
||||
}
|
||||
}
|
||||
|
||||
function output(result: unknown, raw: boolean, rawValue?: unknown): void {
|
||||
let data: string;
|
||||
if (raw && rawValue !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
data = String(rawValue);
|
||||
} else {
|
||||
const json = JSON.stringify(result, null, 2);
|
||||
// Large payloads exceed Claude Code's Bash tool buffer (~50KB).
|
||||
// Write to tmpfile and output the path prefixed with @file: so callers can detect it.
|
||||
if (json.length > 50000) {
|
||||
reapStaleTempFiles();
|
||||
ensureGsdTempDir();
|
||||
const tmpPath = path.join(GSD_TEMP_DIR, `gsd-${Date.now()}.json`);
|
||||
platformWriteSync(tmpPath, json);
|
||||
data = '@file:' + tmpPath;
|
||||
} else {
|
||||
data = json;
|
||||
}
|
||||
}
|
||||
// process.stdout.write() is async when stdout is a pipe — process.exit()
|
||||
// can tear down the process before the reader consumes the buffer.
|
||||
// fs.writeSync(1, ...) blocks until the kernel accepts the bytes, and
|
||||
// skipping process.exit() lets the event loop drain naturally.
|
||||
fs.writeSync(1, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen enum of typed reason codes used by error() for structured errors.
|
||||
* Each subcommand contributes its own codes; the enum exists so tests can
|
||||
* assert against typed values instead of grepping stderr (#2974).
|
||||
*
|
||||
* Adding a new code:
|
||||
* - Pick a snake_case lowercase value (the JSON wire form)
|
||||
* - Group by subsystem prefix (CONFIG_*, SDK_*, etc)
|
||||
* - Pass it to error(msg, ERROR_REASON.NEW_CODE) at the call site
|
||||
*/
|
||||
const ERROR_REASON = Object.freeze({
|
||||
// config-get / config-set
|
||||
CONFIG_KEY_NOT_FOUND: 'config_key_not_found',
|
||||
CONFIG_NO_FILE: 'config_no_file',
|
||||
CONFIG_PARSE_FAILED: 'config_parse_failed',
|
||||
CONFIG_INVALID_KEY: 'config_invalid_key',
|
||||
// SDK / gsd-tools dispatch
|
||||
SDK_FAIL_FAST: 'sdk_fail_fast',
|
||||
SDK_UNKNOWN_COMMAND: 'sdk_unknown_command',
|
||||
SDK_MISSING_ARG: 'sdk_missing_arg',
|
||||
// workflow / phase
|
||||
PHASE_NOT_FOUND: 'phase_not_found',
|
||||
SUMMARY_NO_PLANNING: 'summary_no_planning',
|
||||
// graphify
|
||||
GRAPHIFY_NO_GRAPH: 'graphify_no_graph',
|
||||
GRAPHIFY_INVALID_QUERY: 'graphify_invalid_query',
|
||||
// hooks
|
||||
HOOKS_OPT_OUT: 'hooks_opt_out',
|
||||
// security-scan
|
||||
SECURITY_SCAN_FAILED: 'security_scan_failed',
|
||||
// generic
|
||||
USAGE: 'usage',
|
||||
UNKNOWN: 'unknown',
|
||||
});
|
||||
|
||||
type ErrorReasonValue = typeof ERROR_REASON[keyof typeof ERROR_REASON];
|
||||
|
||||
/**
|
||||
* Process-level flag: when true, error() emits structured JSON to stderr
|
||||
* instead of plain "Error: <message>" text. Set by gsd-tools.cjs when the
|
||||
* CLI is invoked with `--json-errors`. Tests opt in to typed-IR error
|
||||
* assertions by passing that flag and parsing the JSON.
|
||||
*
|
||||
* Default off so existing callers and human operators keep their plain-text
|
||||
* diagnostics. The structured form is opt-in for tooling and tests (#2974).
|
||||
*/
|
||||
let _jsonErrorMode = false;
|
||||
function setJsonErrorMode(v: unknown): void { _jsonErrorMode = !!v; }
|
||||
function getJsonErrorMode(): boolean { return _jsonErrorMode; }
|
||||
|
||||
/**
|
||||
* Emit an error and exit. When the second argument is provided it must be
|
||||
* a value from ERROR_REASON; tests can assert on `result.reason`. When the
|
||||
* process is in JSON-error mode, stderr receives `{ ok: false, reason,
|
||||
* message }` so callers can parse it; otherwise stderr keeps the plain
|
||||
* text form for human operators.
|
||||
*/
|
||||
function error(message: string, reason: ErrorReasonValue = ERROR_REASON.UNKNOWN): never {
|
||||
if (_jsonErrorMode) {
|
||||
const payload = JSON.stringify({ ok: false, reason, message }) + '\n';
|
||||
fs.writeSync(2, payload);
|
||||
} else {
|
||||
fs.writeSync(2, 'Error: ' + message + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ─── File & Config utilities ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -660,197 +524,11 @@ function pruneOrphanedWorktrees(repoRoot: string): string[] {
|
||||
|
||||
// ─── Planning workspace (pathing + active workstream + lock) moved to planning-workspace.cjs ───
|
||||
|
||||
// ─── Phase utilities ──────────────────────────────────────────────────────────
|
||||
|
||||
function escapeRegex(value: unknown): string {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function normalizePhaseName(phase: unknown): string {
|
||||
const str = String(phase);
|
||||
// Strip optional project_code prefix (e.g., 'CK-01' → '01')
|
||||
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
|
||||
// Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
|
||||
const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
||||
if (milestoneMatch) {
|
||||
const major = milestoneMatch[1].padStart(2, '0');
|
||||
const subSegments = milestoneMatch[2].slice(1).split('-').map(s => s.padStart(2, '0'));
|
||||
const suffix = milestoneMatch[3] || '';
|
||||
return `${major}-${subSegments.join('-')}${suffix}`;
|
||||
}
|
||||
// Standard numeric phases: 1, 01, 12A, 12.1
|
||||
const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
||||
if (match) {
|
||||
const padded = match[1].padStart(2, '0');
|
||||
// Preserve original case of letter suffix (#1962).
|
||||
const letter = match[2] || '';
|
||||
const decimal = match[3] || '';
|
||||
return padded + letter + decimal;
|
||||
}
|
||||
// Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
|
||||
return str;
|
||||
}
|
||||
|
||||
function getMilestoneFromPhaseId(phaseId: unknown): string | null {
|
||||
const str = String(phaseId);
|
||||
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const m = stripped.match(/^0*(\d+)-\d/);
|
||||
if (!m) return null;
|
||||
const major = parseInt(m[1], 10);
|
||||
if (major === 0 || major === 999) return null;
|
||||
return `v${major}.0`;
|
||||
}
|
||||
|
||||
function getPhaseDirFromPhaseId(phaseId: unknown, phaseName: string | null | undefined, projectCode: string | null | undefined): string | null {
|
||||
const str = String(phaseId);
|
||||
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/);
|
||||
if (!m) return null;
|
||||
const milestone = String(parseInt(m[1], 10)).padStart(2, '0');
|
||||
const subParts = m[2].split('-').map(p => String(parseInt(p, 10)).padStart(2, '0'));
|
||||
const sub = subParts.join('-');
|
||||
const slug = phaseName
|
||||
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
: '';
|
||||
const parts = [milestone, sub, slug].filter(Boolean);
|
||||
const base = parts.join('-');
|
||||
return projectCode ? `${projectCode}-${base}` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a regex source fragment matching a phase number against ROADMAP/STATE
|
||||
* prose regardless of zero-padding on either side.
|
||||
*/
|
||||
function phaseMarkdownRegexSource(phaseNum: unknown): string {
|
||||
const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
|
||||
// Milestone-prefixed IDs: M-NN or M-N-N (deep).
|
||||
const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
|
||||
if (milestoneSegments && milestoneSegments[2]) {
|
||||
const majorUnpadded = milestoneSegments[1].replace(/^0+/, '') || '0';
|
||||
const subParts = milestoneSegments[2].slice(1).split('-');
|
||||
const subFragments = subParts.map(s => {
|
||||
const unpadded = s.replace(/^0+/, '') || '0';
|
||||
return `0*${escapeRegex(unpadded)}`;
|
||||
});
|
||||
const suffix = milestoneSegments[3] || '';
|
||||
const suffixFragment = suffix ? escapeRegex(suffix) : '';
|
||||
return `0*${escapeRegex(majorUnpadded)}-${subFragments.join('-')}${suffixFragment}`;
|
||||
}
|
||||
|
||||
// Plain numeric phase: 1, 01, 12A, 12.1
|
||||
const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i);
|
||||
if (!match) return escapeRegex(phaseNum);
|
||||
|
||||
const integer = match[1].replace(/^0+/, '') || '0';
|
||||
const letter = match[2] ? escapeRegex(match[2]) : '';
|
||||
const decimal = match[3] ? escapeRegex(match[3]) : '';
|
||||
return `0*${escapeRegex(integer)}${letter}${decimal}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* #3599: when the caller passed a project-code-prefixed ID like `PROJ-42`,
|
||||
* return the exact-escaped form.
|
||||
*/
|
||||
function phaseMarkdownRegexSourceExact(phaseNum: unknown): string | null {
|
||||
const raw = String(phaseNum);
|
||||
if (!/^[A-Z]{1,6}-(?=\d)/i.test(raw)) return null;
|
||||
return escapeRegex(raw);
|
||||
}
|
||||
|
||||
function comparePhaseNum(a: unknown, b: unknown): number {
|
||||
// Strip optional project_code prefix before comparing
|
||||
const sa = String(a).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const sb = String(b).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
|
||||
const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
||||
const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
||||
|
||||
if (milestoneA && milestoneB) {
|
||||
const segsA = [parseInt(milestoneA[1], 10), ...milestoneA[2].slice(1).split('-').map(s => parseInt(s, 10))];
|
||||
const segsB = [parseInt(milestoneB[1], 10), ...milestoneB[2].slice(1).split('-').map(s => parseInt(s, 10))];
|
||||
const maxSegs = Math.max(segsA.length, segsB.length);
|
||||
for (let i = 0; i < maxSegs; i++) {
|
||||
const av = segsA[i] !== undefined ? segsA[i] : 0;
|
||||
const bv = segsB[i] !== undefined ? segsB[i] : 0;
|
||||
if (av !== bv) return av - bv;
|
||||
}
|
||||
const sufA = milestoneA[3] || '';
|
||||
const sufB = milestoneB[3] || '';
|
||||
if (sufA !== sufB) return sufA < sufB ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (milestoneA || milestoneB) return String(a).localeCompare(String(b));
|
||||
|
||||
const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
||||
const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
||||
if (!pa || !pb) return String(a).localeCompare(String(b));
|
||||
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
|
||||
if (intDiff !== 0) return intDiff;
|
||||
const la = (pa[2] || '').toUpperCase();
|
||||
const lb = (pb[2] || '').toUpperCase();
|
||||
if (la !== lb) {
|
||||
if (!la) return -1;
|
||||
if (!lb) return 1;
|
||||
return la < lb ? -1 : 1;
|
||||
}
|
||||
const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
|
||||
const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
|
||||
const maxLen = Math.max(aDecParts.length, bDecParts.length);
|
||||
if (aDecParts.length === 0 && bDecParts.length > 0) return -1;
|
||||
if (bDecParts.length === 0 && aDecParts.length > 0) return 1;
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0;
|
||||
const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0;
|
||||
if (av !== bv) return av - bv;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the phase token from a directory name.
|
||||
*/
|
||||
function extractPhaseToken(dirName: string): string {
|
||||
const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i);
|
||||
let prefix = '';
|
||||
let rest = dirName;
|
||||
if (codePrefixMatch) {
|
||||
prefix = codePrefixMatch[1] + '-';
|
||||
rest = codePrefixMatch[2];
|
||||
}
|
||||
|
||||
const segments = rest.split('-');
|
||||
const tokenSegments: string[] = [];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
if (/^\d/.test(seg)) {
|
||||
tokenSegments.push(seg);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenSegments.length === 0) {
|
||||
return dirName;
|
||||
}
|
||||
|
||||
return prefix + tokenSegments.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory name's phase token matches the normalized phase exactly.
|
||||
*/
|
||||
function phaseTokenMatches(dirName: string, normalized: string): boolean {
|
||||
const token = extractPhaseToken(dirName);
|
||||
if (token.toUpperCase() === normalized.toUpperCase()) return true;
|
||||
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
if (stripped !== dirName) {
|
||||
const strippedToken = extractPhaseToken(stripped);
|
||||
if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// ─── Phase utilities (pure helpers re-exported from phase-id.cjs) ─────────────
|
||||
// escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId,
|
||||
// phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum,
|
||||
// extractPhaseToken, phaseTokenMatches
|
||||
// — all imported via `phaseIdModule` above; internal callers use the destructured bindings.
|
||||
|
||||
function extractCanonicalPlanId(filename: string): string {
|
||||
const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');
|
||||
@@ -1004,235 +682,10 @@ function getArchivedPhaseDirs(cwd: string): ArchivedPhaseDir[] {
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip shipped milestone content wrapped in <details> blocks.
|
||||
*/
|
||||
function stripShippedMilestones(content: string): string {
|
||||
return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the current milestone section from ROADMAP.md by positive lookup.
|
||||
*/
|
||||
function extractCurrentMilestone(content: string, cwd?: string): string {
|
||||
if (!cwd) return stripShippedMilestones(content);
|
||||
|
||||
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 stripShippedMilestones(content);
|
||||
|
||||
const escapedVersion = escapeRegex(version);
|
||||
const sectionPattern = new RegExp(
|
||||
`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
|
||||
'gmi'
|
||||
);
|
||||
const summaryPattern = new RegExp(
|
||||
`<summary[^>]*>([^<]*${escapedVersion}[^<]*)<\\/summary>`,
|
||||
'i'
|
||||
);
|
||||
const headingMatches = [...content.matchAll(sectionPattern)];
|
||||
|
||||
if (headingMatches.length === 0) {
|
||||
const summaryMatch = content.match(summaryPattern);
|
||||
if (summaryMatch) {
|
||||
const summaryIdx = content.indexOf(summaryMatch[0]);
|
||||
const beforeSummary = content.slice(0, summaryIdx);
|
||||
const detailsOpenIdx = beforeSummary.lastIndexOf('<details');
|
||||
if (detailsOpenIdx !== -1) {
|
||||
const afterDetails = content.slice(detailsOpenIdx);
|
||||
const closingMatch = afterDetails.match(/<\/details>/i);
|
||||
const detailsEnd = closingMatch
|
||||
? detailsOpenIdx + (closingMatch.index ?? 0) + '</details>'.length
|
||||
: content.length;
|
||||
const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|<details/im;
|
||||
const firstMilestoneMatch = content.match(anyMilestoneOrDetails);
|
||||
const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index! : detailsOpenIdx;
|
||||
const preamble = content.slice(0, preambleCutoff)
|
||||
.replace(/<details>[\s\S]*?<\/details>/gi, '')
|
||||
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
|
||||
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
|
||||
return preamble + content.slice(detailsOpenIdx, detailsEnd);
|
||||
}
|
||||
}
|
||||
return stripShippedMilestones(content);
|
||||
}
|
||||
|
||||
const allMatches = headingMatches;
|
||||
|
||||
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 = allMatches[0];
|
||||
const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch;
|
||||
|
||||
const sectionStart = selected.index;
|
||||
|
||||
const computeSectionEnd = (headingText: string, headingStart: number): number => {
|
||||
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
||||
const rest = content.slice(headingStart + headingText.length);
|
||||
const stopPattern = new RegExp(
|
||||
`^#{1,${level}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
|
||||
'i',
|
||||
);
|
||||
let end = content.length;
|
||||
let fc: string | null = null;
|
||||
let fl = 0;
|
||||
let off = 0;
|
||||
for (const line of rest.split('\n')) {
|
||||
const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
|
||||
if (fm) {
|
||||
const ch = fm[1][0];
|
||||
const ln = fm[1].length;
|
||||
const trailing = fm[2] || '';
|
||||
if (!fc) {
|
||||
fc = ch;
|
||||
fl = ln;
|
||||
} else if (ch === fc && ln >= fl && /^\s*$/.test(trailing)) {
|
||||
fc = null;
|
||||
fl = 0;
|
||||
}
|
||||
} else if (!fc && stopPattern.test(line)) {
|
||||
end = headingStart + headingText.length + off;
|
||||
break;
|
||||
}
|
||||
off += line.length + 1;
|
||||
}
|
||||
return end;
|
||||
};
|
||||
|
||||
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
|
||||
|
||||
const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
|
||||
const firstMilestoneMatch = content.match(anyMilestonePattern);
|
||||
const preambleCutoff = firstMilestoneMatch
|
||||
? firstMilestoneMatch.index!
|
||||
: firstMatch.index;
|
||||
const beforeMilestones = content.slice(0, preambleCutoff);
|
||||
const currentSection = content.slice(sectionStart, sectionEnd);
|
||||
|
||||
// Multi-milestone roadmaps split each added milestone across two version-bearing
|
||||
// headings: a `## Phases` checklist subsection (early) and a dedicated
|
||||
// `## Milestone … (Phase Details)` section (late) holding the `### Phase N:`
|
||||
// detail headers. The scope window above stops at the next version-bearing
|
||||
// heading — the current milestone's OWN Phase Details heading — leaving those
|
||||
// detail headers outside `currentSection`. Append that section so phase
|
||||
// resolution and counting see the current milestone's phases. Anchor the lookup
|
||||
// to the SELECTED heading's specific version token (boundary-aware, so a
|
||||
// `v3.0` state does not match a `v3.0-A` sub-milestone) so sibling milestones
|
||||
// that share a version prefix do not cross-pollinate. (#730)
|
||||
const selectedVersionToken = selected[1].match(
|
||||
/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i,
|
||||
)?.[0];
|
||||
const detailsVersionBoundary = selectedVersionToken
|
||||
? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
|
||||
: null;
|
||||
let detailsSection = '';
|
||||
const detailsMatch = allMatches.find(
|
||||
(m) =>
|
||||
/\(Phase\s+Details\)/i.test(m[1]) &&
|
||||
!isClosed(m[1]) &&
|
||||
(!detailsVersionBoundary || detailsVersionBoundary.test(m[1])) &&
|
||||
(m.index ?? 0) >= sectionEnd,
|
||||
);
|
||||
if (detailsMatch) {
|
||||
const detailsStart = detailsMatch.index ?? 0;
|
||||
detailsSection = content.slice(
|
||||
detailsStart,
|
||||
computeSectionEnd(detailsMatch[0], detailsStart),
|
||||
);
|
||||
}
|
||||
|
||||
const preamble = beforeMilestones
|
||||
.replace(/<details>[\s\S]*?<\/details>/gi, '')
|
||||
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
|
||||
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
|
||||
|
||||
return detailsSection
|
||||
? preamble + currentSection + '\n' + detailsSection
|
||||
: preamble + currentSection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a pattern only in the current milestone section of ROADMAP.md.
|
||||
*/
|
||||
function replaceInCurrentMilestone(content: string, pattern: RegExp, replacement: string): string {
|
||||
const lastDetailsClose = content.lastIndexOf('</details>');
|
||||
if (lastDetailsClose === -1) {
|
||||
return content.replace(pattern, replacement);
|
||||
}
|
||||
const offset = lastDetailsClose + '</details>'.length;
|
||||
const before = content.slice(0, offset);
|
||||
const after = content.slice(offset);
|
||||
return before + after.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
// ─── Roadmap & model utilities ────────────────────────────────────────────────
|
||||
|
||||
interface RoadmapPhaseResult {
|
||||
found: boolean;
|
||||
phase_number: string;
|
||||
phase_name: string;
|
||||
goal: string | null;
|
||||
section: string;
|
||||
}
|
||||
|
||||
function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null {
|
||||
if (!phaseNum) return null;
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
if (!fs.existsSync(roadmapPath)) return null;
|
||||
|
||||
try {
|
||||
const roadmapRaw = platformReadSync(roadmapPath);
|
||||
if (roadmapRaw === null) throw new Error('missing');
|
||||
const content = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
const phasePattern = new RegExp(
|
||||
`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`,
|
||||
'i'
|
||||
);
|
||||
const headerMatch = content.match(phasePattern);
|
||||
if (!headerMatch) return null;
|
||||
|
||||
const phaseName = headerMatch[1].trim();
|
||||
const headerIndex = headerMatch.index!;
|
||||
const restOfContent = content.slice(headerIndex);
|
||||
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
|
||||
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index! : content.length;
|
||||
const section = content.slice(headerIndex, sectionEnd).trim();
|
||||
|
||||
const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
|
||||
const goal = goalMatch ? goalMatch[1].trim() : null;
|
||||
|
||||
return {
|
||||
found: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
phase_number: String(phaseNum),
|
||||
phase_name: phaseName,
|
||||
goal,
|
||||
section,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// ─── Roadmap milestone scoping (re-exported from roadmap-parser.cjs) ──────────
|
||||
// stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone,
|
||||
// getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter
|
||||
// — all imported via `roadmapParserModule` above; internal callers use the destructured bindings.
|
||||
|
||||
// ─── Agent installation validation (#1371) ───────────────────────────────────
|
||||
|
||||
@@ -1932,203 +1385,8 @@ function generateSlugInternal(text: string | null | undefined): string | null {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60);
|
||||
}
|
||||
|
||||
interface MilestoneInfo {
|
||||
version: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function getMilestoneInfo(cwd: string): MilestoneInfo {
|
||||
try {
|
||||
const roadmap = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md'));
|
||||
if (roadmap === null) throw new Error('missing');
|
||||
|
||||
let stateVersion: string | null = null;
|
||||
if (cwd) {
|
||||
try {
|
||||
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
||||
const stateRaw = platformReadSync(statePath);
|
||||
if (stateRaw !== null) {
|
||||
const m = stateRaw.match(/^milestone:\s*(.+)/m);
|
||||
if (m) stateVersion = m[1].trim();
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
|
||||
if (stateVersion) {
|
||||
const escapedVer = escapeRegex(stateVersion);
|
||||
const headingMatch = roadmap.match(
|
||||
new RegExp(`##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'i')
|
||||
);
|
||||
if (headingMatch) {
|
||||
if (!headingMatch[0].includes('✅')) {
|
||||
return { version: stateVersion, name: headingMatch[1].trim() };
|
||||
}
|
||||
} else {
|
||||
const listMatch = roadmap.match(
|
||||
new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i')
|
||||
);
|
||||
if (listMatch) {
|
||||
return { version: stateVersion, name: listMatch[1].trim() };
|
||||
}
|
||||
return { version: stateVersion, name: 'milestone' };
|
||||
}
|
||||
}
|
||||
|
||||
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
||||
if (inProgressMatch) {
|
||||
return {
|
||||
version: 'v' + inProgressMatch[1],
|
||||
name: inProgressMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const cleaned = stripShippedMilestones(roadmap);
|
||||
const headingMatch = cleaned.match(/## (?!.*✅).*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
|
||||
if (headingMatch) {
|
||||
return {
|
||||
version: 'v' + headingMatch[1],
|
||||
name: headingMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
|
||||
return {
|
||||
version: versionMatch ? versionMatch[0] : 'v1.0',
|
||||
name: 'milestone',
|
||||
};
|
||||
} catch {
|
||||
return { version: 'v1.0', name: 'milestone' };
|
||||
}
|
||||
}
|
||||
|
||||
type MilestonePhaseFilter = ((dirName: string) => boolean) & {
|
||||
phaseCount: number;
|
||||
missingExplicitVersion: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a filter function that checks whether a phase directory belongs
|
||||
* to the current milestone based on ROADMAP.md phase headings.
|
||||
*/
|
||||
function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null): MilestonePhaseFilter {
|
||||
const milestonePhaseNums = new Set<string>();
|
||||
let missingExplicitVersion = false;
|
||||
try {
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
const roadmapContent = platformReadSync(roadmapPath);
|
||||
if (roadmapContent === null) throw new Error('missing');
|
||||
let roadmap = extractCurrentMilestone(roadmapContent, cwd);
|
||||
|
||||
const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
|
||||
const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
|
||||
if (!hasVersionedMilestonesGlobal && hasPhaseHeadings) {
|
||||
console.warn(
|
||||
'[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' +
|
||||
'Set phase_id_convention in config.json to suppress this warning.'
|
||||
);
|
||||
}
|
||||
|
||||
if (versionOverride) {
|
||||
const escapedVersion = escapeRegex(versionOverride);
|
||||
const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}[^\\n]*)`, 'mi');
|
||||
let sectionMatch = roadmapContent.match(sectionPattern);
|
||||
|
||||
if (!sectionMatch) {
|
||||
const summaryPat = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i');
|
||||
const summaryHit = roadmapContent.match(summaryPat);
|
||||
if (summaryHit) {
|
||||
const beforeSummary = roadmapContent.slice(0, summaryHit.index);
|
||||
const detailsIdx = beforeSummary.lastIndexOf('<details');
|
||||
if (detailsIdx !== -1) {
|
||||
sectionMatch = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sectionMatch) {
|
||||
const hasVersionedMilestones = /^#{1,3}\s+(?!Phase\s+\S).*v\d+\.\d+/mi.test(roadmapContent);
|
||||
const versionInSummary = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i').test(roadmapContent);
|
||||
if (hasVersionedMilestones && !versionInSummary) {
|
||||
roadmap = '';
|
||||
missingExplicitVersion = true;
|
||||
}
|
||||
} else {
|
||||
const sectionStart = sectionMatch.index!;
|
||||
const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
||||
const restContent = roadmapContent.slice(sectionStart + sectionMatch[0].length);
|
||||
const nextMilestonePattern = new RegExp(`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i');
|
||||
|
||||
let sectionEnd = roadmapContent.length;
|
||||
let fenceChar: string | null = null;
|
||||
let fenceLen = 0;
|
||||
let charOffset = 0;
|
||||
for (const line of restContent.split('\n')) {
|
||||
const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
|
||||
if (fenceMatch) {
|
||||
const char = fenceMatch[1][0];
|
||||
const len = fenceMatch[1].length;
|
||||
const trailing = fenceMatch[2] || '';
|
||||
if (!fenceChar) {
|
||||
fenceChar = char;
|
||||
fenceLen = len;
|
||||
} else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) {
|
||||
fenceChar = null;
|
||||
fenceLen = 0;
|
||||
}
|
||||
} else if (!fenceChar && nextMilestonePattern.test(line)) {
|
||||
sectionEnd = sectionStart + sectionMatch[0].length + charOffset;
|
||||
break;
|
||||
}
|
||||
charOffset += line.length + 1;
|
||||
}
|
||||
|
||||
const currentSection = roadmapContent.slice(sectionStart, sectionEnd);
|
||||
roadmap = currentSection;
|
||||
}
|
||||
}
|
||||
|
||||
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(roadmap)) !== null) {
|
||||
milestonePhaseNums.add(m[1]);
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
if (milestonePhaseNums.size === 0) {
|
||||
const passAll = (() => true) as unknown as MilestonePhaseFilter;
|
||||
passAll.phaseCount = 0;
|
||||
passAll.missingExplicitVersion = missingExplicitVersion;
|
||||
return passAll;
|
||||
}
|
||||
|
||||
const normalized = new Set(
|
||||
[...milestonePhaseNums].map(n => n.split('-').map(seg => (seg.replace(/^0+(?=\d)/, '') || '0')).join('-').toLowerCase())
|
||||
);
|
||||
|
||||
function normalizePhaseIdSegments(id: string): string {
|
||||
return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
|
||||
}
|
||||
|
||||
const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
|
||||
const numericRe = roadmapUsesHyphenedIds
|
||||
? /^0*(\d+(?:-0*\d+)*[A-Za-z]?(?:\.\d+)*)/
|
||||
: /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
|
||||
|
||||
function isDirInMilestone(dirName: string): boolean {
|
||||
const m2 = dirName.match(numericRe);
|
||||
if (m2 && normalized.has(normalizePhaseIdSegments(m2[1]).toLowerCase())) return true;
|
||||
const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
|
||||
if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
|
||||
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
if (stripped !== dirName) {
|
||||
const sm = stripped.match(numericRe);
|
||||
if (sm && normalized.has(normalizePhaseIdSegments(sm[1]).toLowerCase())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
(isDirInMilestone as MilestonePhaseFilter).phaseCount = milestonePhaseNums.size;
|
||||
(isDirInMilestone as MilestonePhaseFilter).missingExplicitVersion = missingExplicitVersion;
|
||||
return isDirInMilestone as MilestonePhaseFilter;
|
||||
}
|
||||
// MilestoneInfo, MilestonePhaseFilter, getMilestoneInfo, getMilestonePhaseFilter
|
||||
// — all re-exported from roadmap-parser.cjs via roadmapParserModule above.
|
||||
|
||||
// ─── Phase file helpers ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
174
src/io.cts
Normal file
174
src/io.cts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* CLI I/O primitives — output(), error(), ERROR_REASON, JSON-error mode,
|
||||
* and the temp-file helpers that output() depends on.
|
||||
*
|
||||
* Extracted from core.cts (ADR-857 rollout phase 1 / issue #859).
|
||||
* The hand-written bodies are preserved byte-for-behaviour; only the module
|
||||
* boundary moved. core.cts re-exports every symbol here under its own
|
||||
* `export =` object so existing consumers are unaffected.
|
||||
*
|
||||
* New imports should pull I/O primitives from io.cjs directly.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
|
||||
// ─── Temp-file helpers (needed by output()) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dedicated GSD temp directory: path.join(os.tmpdir(), 'gsd').
|
||||
* Created on first use. Keeps GSD temp files isolated from the system
|
||||
* temp directory so reap scans only GSD files (#1975).
|
||||
*/
|
||||
const GSD_TEMP_DIR = path.join(os.tmpdir(), 'gsd');
|
||||
|
||||
function ensureGsdTempDir(): void {
|
||||
platformEnsureDir(GSD_TEMP_DIR);
|
||||
}
|
||||
|
||||
interface ReapOptions {
|
||||
maxAgeMs?: number;
|
||||
dirsOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes).
|
||||
* Runs opportunistically before each new temp file write to prevent unbounded accumulation.
|
||||
* @param prefix - filename prefix to match (e.g., 'gsd-')
|
||||
* @param opts
|
||||
* @param opts.maxAgeMs - max age in ms before removal (default: 5 min)
|
||||
* @param opts.dirsOnly - if true, only remove directories (default: false)
|
||||
*/
|
||||
function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false }: ReapOptions = {}): void {
|
||||
try {
|
||||
ensureGsdTempDir();
|
||||
const now = Date.now();
|
||||
const entries = fs.readdirSync(GSD_TEMP_DIR);
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith(prefix)) continue;
|
||||
const fullPath = path.join(GSD_TEMP_DIR, entry);
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (now - stat.mtimeMs > maxAgeMs) {
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||
} else if (!dirsOnly) {
|
||||
fs.unlinkSync(fullPath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File may have been removed between readdir and stat — ignore
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — don't let cleanup failures break output
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Output helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function output(result: unknown, raw: boolean, rawValue?: unknown): void {
|
||||
let data: string;
|
||||
if (raw && rawValue !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
data = String(rawValue);
|
||||
} else {
|
||||
const json = JSON.stringify(result, null, 2);
|
||||
// Large payloads exceed Claude Code's Bash tool buffer (~50KB).
|
||||
// Write to tmpfile and output the path prefixed with @file: so callers can detect it.
|
||||
if (json.length > 50000) {
|
||||
reapStaleTempFiles();
|
||||
ensureGsdTempDir();
|
||||
const tmpPath = path.join(GSD_TEMP_DIR, `gsd-${Date.now()}.json`);
|
||||
platformWriteSync(tmpPath, json);
|
||||
data = '@file:' + tmpPath;
|
||||
} else {
|
||||
data = json;
|
||||
}
|
||||
}
|
||||
// process.stdout.write() is async when stdout is a pipe — process.exit()
|
||||
// can tear down the process before the reader consumes the buffer.
|
||||
// fs.writeSync(1, ...) blocks until the kernel accepts the bytes, and
|
||||
// skipping process.exit() lets the event loop drain naturally.
|
||||
fs.writeSync(1, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen enum of typed reason codes used by error() for structured errors.
|
||||
* Each subcommand contributes its own codes; the enum exists so tests can
|
||||
* assert against typed values instead of grepping stderr (#2974).
|
||||
*
|
||||
* Adding a new code:
|
||||
* - Pick a snake_case lowercase value (the JSON wire form)
|
||||
* - Group by subsystem prefix (CONFIG_*, SDK_*, etc)
|
||||
* - Pass it to error(msg, ERROR_REASON.NEW_CODE) at the call site
|
||||
*/
|
||||
const ERROR_REASON = Object.freeze({
|
||||
// config-get / config-set
|
||||
CONFIG_KEY_NOT_FOUND: 'config_key_not_found',
|
||||
CONFIG_NO_FILE: 'config_no_file',
|
||||
CONFIG_PARSE_FAILED: 'config_parse_failed',
|
||||
CONFIG_INVALID_KEY: 'config_invalid_key',
|
||||
// SDK / gsd-tools dispatch
|
||||
SDK_FAIL_FAST: 'sdk_fail_fast',
|
||||
SDK_UNKNOWN_COMMAND: 'sdk_unknown_command',
|
||||
SDK_MISSING_ARG: 'sdk_missing_arg',
|
||||
// workflow / phase
|
||||
PHASE_NOT_FOUND: 'phase_not_found',
|
||||
SUMMARY_NO_PLANNING: 'summary_no_planning',
|
||||
// graphify
|
||||
GRAPHIFY_NO_GRAPH: 'graphify_no_graph',
|
||||
GRAPHIFY_INVALID_QUERY: 'graphify_invalid_query',
|
||||
// hooks
|
||||
HOOKS_OPT_OUT: 'hooks_opt_out',
|
||||
// security-scan
|
||||
SECURITY_SCAN_FAILED: 'security_scan_failed',
|
||||
// generic
|
||||
USAGE: 'usage',
|
||||
UNKNOWN: 'unknown',
|
||||
});
|
||||
|
||||
type ErrorReasonValue = typeof ERROR_REASON[keyof typeof ERROR_REASON];
|
||||
|
||||
/**
|
||||
* Process-level flag: when true, error() emits structured JSON to stderr
|
||||
* instead of plain "Error: <message>" text. Set by gsd-tools.cjs when the
|
||||
* CLI is invoked with `--json-errors`. Tests opt in to typed-IR error
|
||||
* assertions by passing that flag and parsing the JSON.
|
||||
*
|
||||
* Default off so existing callers and human operators keep their plain-text
|
||||
* diagnostics. The structured form is opt-in for tooling and tests (#2974).
|
||||
*/
|
||||
let _jsonErrorMode = false;
|
||||
function setJsonErrorMode(v: unknown): void { _jsonErrorMode = !!v; }
|
||||
function getJsonErrorMode(): boolean { return _jsonErrorMode; }
|
||||
|
||||
/**
|
||||
* Emit an error and exit. When the second argument is provided it must be
|
||||
* a value from ERROR_REASON; tests can assert on `result.reason`. When the
|
||||
* process is in JSON-error mode, stderr receives `{ ok: false, reason,
|
||||
* message }` so callers can parse it; otherwise stderr keeps the plain
|
||||
* text form for human operators.
|
||||
*/
|
||||
function error(message: string, reason: ErrorReasonValue = ERROR_REASON.UNKNOWN): never {
|
||||
if (_jsonErrorMode) {
|
||||
const payload = JSON.stringify({ ok: false, reason, message }) + '\n';
|
||||
fs.writeSync(2, payload);
|
||||
} else {
|
||||
fs.writeSync(2, 'Error: ' + message + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export = {
|
||||
GSD_TEMP_DIR,
|
||||
ensureGsdTempDir,
|
||||
reapStaleTempFiles,
|
||||
output,
|
||||
ERROR_REASON,
|
||||
setJsonErrorMode,
|
||||
getJsonErrorMode,
|
||||
error,
|
||||
};
|
||||
217
src/phase-id.cts
Normal file
217
src/phase-id.cts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Pure phase-id parsing/matching helpers — normalize, token match,
|
||||
* milestone/phase-dir id parsing, phase-markdown regex builders.
|
||||
*
|
||||
* Extracted from core.cts (ADR-857 rollout phase 2a / issue #865).
|
||||
* The hand-written bodies are preserved byte-for-behaviour; only the module
|
||||
* boundary moved. core.cts re-exports every symbol here under its own
|
||||
* `export =` object so existing consumers are unaffected.
|
||||
*
|
||||
* New imports should pull phase-id helpers from phase-id.cjs directly.
|
||||
*
|
||||
* Dependencies: none (pure string/regex, no Node built-ins required).
|
||||
*/
|
||||
|
||||
// ─── Phase-id helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function escapeRegex(value: unknown): string {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function normalizePhaseName(phase: unknown): string {
|
||||
const str = String(phase);
|
||||
// Strip optional project_code prefix (e.g., 'CK-01' → '01')
|
||||
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
|
||||
// Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
|
||||
const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
||||
if (milestoneMatch) {
|
||||
const major = milestoneMatch[1].padStart(2, '0');
|
||||
const subSegments = milestoneMatch[2].slice(1).split('-').map(s => s.padStart(2, '0'));
|
||||
const suffix = milestoneMatch[3] || '';
|
||||
return `${major}-${subSegments.join('-')}${suffix}`;
|
||||
}
|
||||
// Standard numeric phases: 1, 01, 12A, 12.1
|
||||
const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
||||
if (match) {
|
||||
const padded = match[1].padStart(2, '0');
|
||||
// Preserve original case of letter suffix (#1962).
|
||||
const letter = match[2] || '';
|
||||
const decimal = match[3] || '';
|
||||
return padded + letter + decimal;
|
||||
}
|
||||
// Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
|
||||
return str;
|
||||
}
|
||||
|
||||
function getMilestoneFromPhaseId(phaseId: unknown): string | null {
|
||||
const str = String(phaseId);
|
||||
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const m = stripped.match(/^0*(\d+)-\d/);
|
||||
if (!m) return null;
|
||||
const major = parseInt(m[1], 10);
|
||||
if (major === 0 || major === 999) return null;
|
||||
return `v${major}.0`;
|
||||
}
|
||||
|
||||
function getPhaseDirFromPhaseId(phaseId: unknown, phaseName: string | null | undefined, projectCode: string | null | undefined): string | null {
|
||||
const str = String(phaseId);
|
||||
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/);
|
||||
if (!m) return null;
|
||||
const milestone = String(parseInt(m[1], 10)).padStart(2, '0');
|
||||
const subParts = m[2].split('-').map(p => String(parseInt(p, 10)).padStart(2, '0'));
|
||||
const sub = subParts.join('-');
|
||||
const slug = phaseName
|
||||
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
: '';
|
||||
const parts = [milestone, sub, slug].filter(Boolean);
|
||||
const base = parts.join('-');
|
||||
return projectCode ? `${projectCode}-${base}` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a regex source fragment matching a phase number against ROADMAP/STATE
|
||||
* prose regardless of zero-padding on either side.
|
||||
*/
|
||||
function phaseMarkdownRegexSource(phaseNum: unknown): string {
|
||||
const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
|
||||
// Milestone-prefixed IDs: M-NN or M-N-N (deep).
|
||||
const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
|
||||
if (milestoneSegments && milestoneSegments[2]) {
|
||||
const majorUnpadded = milestoneSegments[1].replace(/^0+/, '') || '0';
|
||||
const subParts = milestoneSegments[2].slice(1).split('-');
|
||||
const subFragments = subParts.map(s => {
|
||||
const unpadded = s.replace(/^0+/, '') || '0';
|
||||
return `0*${escapeRegex(unpadded)}`;
|
||||
});
|
||||
const suffix = milestoneSegments[3] || '';
|
||||
const suffixFragment = suffix ? escapeRegex(suffix) : '';
|
||||
return `0*${escapeRegex(majorUnpadded)}-${subFragments.join('-')}${suffixFragment}`;
|
||||
}
|
||||
|
||||
// Plain numeric phase: 1, 01, 12A, 12.1
|
||||
const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i);
|
||||
if (!match) return escapeRegex(phaseNum);
|
||||
|
||||
const integer = match[1].replace(/^0+/, '') || '0';
|
||||
const letter = match[2] ? escapeRegex(match[2]) : '';
|
||||
const decimal = match[3] ? escapeRegex(match[3]) : '';
|
||||
return `0*${escapeRegex(integer)}${letter}${decimal}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* #3599: when the caller passed a project-code-prefixed ID like `PROJ-42`,
|
||||
* return the exact-escaped form.
|
||||
*/
|
||||
function phaseMarkdownRegexSourceExact(phaseNum: unknown): string | null {
|
||||
const raw = String(phaseNum);
|
||||
if (!/^[A-Z]{1,6}-(?=\d)/i.test(raw)) return null;
|
||||
return escapeRegex(raw);
|
||||
}
|
||||
|
||||
function comparePhaseNum(a: unknown, b: unknown): number {
|
||||
// Strip optional project_code prefix before comparing
|
||||
const sa = String(a).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const sb = String(b).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
|
||||
const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
||||
const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
||||
|
||||
if (milestoneA && milestoneB) {
|
||||
const segsA = [parseInt(milestoneA[1], 10), ...milestoneA[2].slice(1).split('-').map(s => parseInt(s, 10))];
|
||||
const segsB = [parseInt(milestoneB[1], 10), ...milestoneB[2].slice(1).split('-').map(s => parseInt(s, 10))];
|
||||
const maxSegs = Math.max(segsA.length, segsB.length);
|
||||
for (let i = 0; i < maxSegs; i++) {
|
||||
const av = segsA[i] !== undefined ? segsA[i] : 0;
|
||||
const bv = segsB[i] !== undefined ? segsB[i] : 0;
|
||||
if (av !== bv) return av - bv;
|
||||
}
|
||||
const sufA = milestoneA[3] || '';
|
||||
const sufB = milestoneB[3] || '';
|
||||
if (sufA !== sufB) return sufA < sufB ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (milestoneA || milestoneB) return String(a).localeCompare(String(b));
|
||||
|
||||
const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
||||
const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
||||
if (!pa || !pb) return String(a).localeCompare(String(b));
|
||||
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
|
||||
if (intDiff !== 0) return intDiff;
|
||||
const la = (pa[2] || '').toUpperCase();
|
||||
const lb = (pb[2] || '').toUpperCase();
|
||||
if (la !== lb) {
|
||||
if (!la) return -1;
|
||||
if (!lb) return 1;
|
||||
return la < lb ? -1 : 1;
|
||||
}
|
||||
const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
|
||||
const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
|
||||
const maxLen = Math.max(aDecParts.length, bDecParts.length);
|
||||
if (aDecParts.length === 0 && bDecParts.length > 0) return -1;
|
||||
if (bDecParts.length === 0 && aDecParts.length > 0) return 1;
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0;
|
||||
const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0;
|
||||
if (av !== bv) return av - bv;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the phase token from a directory name.
|
||||
*/
|
||||
function extractPhaseToken(dirName: string): string {
|
||||
const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i);
|
||||
let prefix = '';
|
||||
let rest = dirName;
|
||||
if (codePrefixMatch) {
|
||||
prefix = codePrefixMatch[1] + '-';
|
||||
rest = codePrefixMatch[2];
|
||||
}
|
||||
|
||||
const segments = rest.split('-');
|
||||
const tokenSegments: string[] = [];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
if (/^\d/.test(seg)) {
|
||||
tokenSegments.push(seg);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenSegments.length === 0) {
|
||||
return dirName;
|
||||
}
|
||||
|
||||
return prefix + tokenSegments.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory name's phase token matches the normalized phase exactly.
|
||||
*/
|
||||
function phaseTokenMatches(dirName: string, normalized: string): boolean {
|
||||
const token = extractPhaseToken(dirName);
|
||||
if (token.toUpperCase() === normalized.toUpperCase()) return true;
|
||||
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
if (stripped !== dirName) {
|
||||
const strippedToken = extractPhaseToken(stripped);
|
||||
if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export = {
|
||||
escapeRegex,
|
||||
normalizePhaseName,
|
||||
getMilestoneFromPhaseId,
|
||||
getPhaseDirFromPhaseId,
|
||||
phaseMarkdownRegexSource,
|
||||
phaseMarkdownRegexSourceExact,
|
||||
comparePhaseNum,
|
||||
extractPhaseToken,
|
||||
phaseTokenMatches,
|
||||
};
|
||||
@@ -17,8 +17,8 @@ import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import readline from 'node:readline';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { output, error, reapStaleTempFiles } = core;
|
||||
import ioModule = require('./io.cjs');
|
||||
const { output, error, reapStaleTempFiles } = ioModule;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
469
src/roadmap-parser.cts
Normal file
469
src/roadmap-parser.cts
Normal file
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* Roadmap Parser — ROADMAP.md parsing helpers
|
||||
*
|
||||
* ADR-857 rollout phase 2b: extracted from core.cts (issue #870).
|
||||
* Owns shipped-milestone slicing, current-milestone extraction,
|
||||
* milestone/phase lookups, and milestone-phase filtering.
|
||||
* Behaviour is preserved byte-for-behaviour from the prior location;
|
||||
* only the module boundary moved. core.cjs re-exports every symbol here
|
||||
* under its own `export =` object so existing consumers are unaffected.
|
||||
*
|
||||
* New imports should pull roadmap-parser helpers from roadmap-parser.cjs directly.
|
||||
*
|
||||
* Dependencies (leaf modules only — no core.cjs, no loadConfig):
|
||||
* - node:fs / node:path (stdlib)
|
||||
* - ./phase-id.cjs (escapeRegex, phaseMarkdownRegexSource)
|
||||
* - ./planning-workspace.cjs (planningDir)
|
||||
* - ./shell-command-projection.cjs (platformReadSync)
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdModule = require('./phase-id.cjs');
|
||||
const { escapeRegex, phaseMarkdownRegexSource } = phaseIdModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir } = planningWorkspace;
|
||||
import { platformReadSync } from './shell-command-projection.cjs';
|
||||
|
||||
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip shipped milestone content wrapped in <details> blocks.
|
||||
*/
|
||||
function stripShippedMilestones(content: string): string {
|
||||
return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the current milestone section from ROADMAP.md by positive lookup.
|
||||
*/
|
||||
function extractCurrentMilestone(content: string, cwd?: string): string {
|
||||
if (!cwd) return stripShippedMilestones(content);
|
||||
|
||||
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 stripShippedMilestones(content);
|
||||
|
||||
const escapedVersion = escapeRegex(version);
|
||||
const sectionPattern = new RegExp(
|
||||
`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
|
||||
'gmi'
|
||||
);
|
||||
const summaryPattern = new RegExp(
|
||||
`<summary[^>]*>([^<]*${escapedVersion}[^<]*)<\\/summary>`,
|
||||
'i'
|
||||
);
|
||||
const headingMatches = [...content.matchAll(sectionPattern)];
|
||||
|
||||
if (headingMatches.length === 0) {
|
||||
const summaryMatch = content.match(summaryPattern);
|
||||
if (summaryMatch) {
|
||||
const summaryIdx = content.indexOf(summaryMatch[0]);
|
||||
const beforeSummary = content.slice(0, summaryIdx);
|
||||
const detailsOpenIdx = beforeSummary.lastIndexOf('<details');
|
||||
if (detailsOpenIdx !== -1) {
|
||||
const afterDetails = content.slice(detailsOpenIdx);
|
||||
const closingMatch = afterDetails.match(/<\/details>/i);
|
||||
const detailsEnd = closingMatch
|
||||
? detailsOpenIdx + (closingMatch.index ?? 0) + '</details>'.length
|
||||
: content.length;
|
||||
const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|<details/im;
|
||||
const firstMilestoneMatch = content.match(anyMilestoneOrDetails);
|
||||
const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index! : detailsOpenIdx;
|
||||
const preamble = content.slice(0, preambleCutoff)
|
||||
.replace(/<details>[\s\S]*?<\/details>/gi, '')
|
||||
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
|
||||
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
|
||||
return preamble + content.slice(detailsOpenIdx, detailsEnd);
|
||||
}
|
||||
}
|
||||
return stripShippedMilestones(content);
|
||||
}
|
||||
|
||||
const allMatches = headingMatches;
|
||||
|
||||
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 = allMatches[0];
|
||||
const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch;
|
||||
|
||||
const sectionStart = selected.index;
|
||||
|
||||
const computeSectionEnd = (headingText: string, headingStart: number): number => {
|
||||
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
||||
const rest = content.slice(headingStart + headingText.length);
|
||||
const stopPattern = new RegExp(
|
||||
`^#{1,${level}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
|
||||
'i',
|
||||
);
|
||||
let end = content.length;
|
||||
let fc: string | null = null;
|
||||
let fl = 0;
|
||||
let off = 0;
|
||||
for (const line of rest.split('\n')) {
|
||||
const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
|
||||
if (fm) {
|
||||
const ch = fm[1][0];
|
||||
const ln = fm[1].length;
|
||||
const trailing = fm[2] || '';
|
||||
if (!fc) {
|
||||
fc = ch;
|
||||
fl = ln;
|
||||
} else if (ch === fc && ln >= fl && /^\s*$/.test(trailing)) {
|
||||
fc = null;
|
||||
fl = 0;
|
||||
}
|
||||
} else if (!fc && stopPattern.test(line)) {
|
||||
end = headingStart + headingText.length + off;
|
||||
break;
|
||||
}
|
||||
off += line.length + 1;
|
||||
}
|
||||
return end;
|
||||
};
|
||||
|
||||
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
|
||||
|
||||
const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
|
||||
const firstMilestoneMatch = content.match(anyMilestonePattern);
|
||||
const preambleCutoff = firstMilestoneMatch
|
||||
? firstMilestoneMatch.index!
|
||||
: firstMatch.index;
|
||||
const beforeMilestones = content.slice(0, preambleCutoff);
|
||||
const currentSection = content.slice(sectionStart, sectionEnd);
|
||||
|
||||
// Multi-milestone roadmaps split each added milestone across two version-bearing
|
||||
// headings: a `## Phases` checklist subsection (early) and a dedicated
|
||||
// `## Milestone … (Phase Details)` section (late) holding the `### Phase N:`
|
||||
// detail headers. The scope window above stops at the next version-bearing
|
||||
// heading — the current milestone's OWN Phase Details heading — leaving those
|
||||
// detail headers outside `currentSection`. Append that section so phase
|
||||
// resolution and counting see the current milestone's phases. Anchor the lookup
|
||||
// to the SELECTED heading's specific version token (boundary-aware, so a
|
||||
// `v3.0` state does not match a `v3.0-A` sub-milestone) so sibling milestones
|
||||
// that share a version prefix do not cross-pollinate. (#730)
|
||||
const selectedVersionToken = selected[1].match(
|
||||
/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i,
|
||||
)?.[0];
|
||||
const detailsVersionBoundary = selectedVersionToken
|
||||
? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
|
||||
: null;
|
||||
let detailsSection = '';
|
||||
const detailsMatch = allMatches.find(
|
||||
(m) =>
|
||||
/\(Phase\s+Details\)/i.test(m[1]) &&
|
||||
!isClosed(m[1]) &&
|
||||
(!detailsVersionBoundary || detailsVersionBoundary.test(m[1])) &&
|
||||
(m.index ?? 0) >= sectionEnd,
|
||||
);
|
||||
if (detailsMatch) {
|
||||
const detailsStart = detailsMatch.index ?? 0;
|
||||
detailsSection = content.slice(
|
||||
detailsStart,
|
||||
computeSectionEnd(detailsMatch[0], detailsStart),
|
||||
);
|
||||
}
|
||||
|
||||
const preamble = beforeMilestones
|
||||
.replace(/<details>[\s\S]*?<\/details>/gi, '')
|
||||
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
|
||||
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
|
||||
|
||||
return detailsSection
|
||||
? preamble + currentSection + '\n' + detailsSection
|
||||
: preamble + currentSection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a pattern only in the current milestone section of ROADMAP.md.
|
||||
*/
|
||||
function replaceInCurrentMilestone(content: string, pattern: RegExp, replacement: string): string {
|
||||
const lastDetailsClose = content.lastIndexOf('</details>');
|
||||
if (lastDetailsClose === -1) {
|
||||
return content.replace(pattern, replacement);
|
||||
}
|
||||
const offset = lastDetailsClose + '</details>'.length;
|
||||
const before = content.slice(0, offset);
|
||||
const after = content.slice(offset);
|
||||
return before + after.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
// ─── Roadmap phase lookup ─────────────────────────────────────────────────────
|
||||
|
||||
interface RoadmapPhaseResult {
|
||||
found: boolean;
|
||||
phase_number: string;
|
||||
phase_name: string;
|
||||
goal: string | null;
|
||||
section: string;
|
||||
}
|
||||
|
||||
function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null {
|
||||
if (!phaseNum) return null;
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
if (!fs.existsSync(roadmapPath)) return null;
|
||||
|
||||
try {
|
||||
const roadmapRaw = platformReadSync(roadmapPath);
|
||||
if (roadmapRaw === null) throw new Error('missing');
|
||||
const content = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
const phasePattern = new RegExp(
|
||||
`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`,
|
||||
'i'
|
||||
);
|
||||
const headerMatch = content.match(phasePattern);
|
||||
if (!headerMatch) return null;
|
||||
|
||||
const phaseName = headerMatch[1].trim();
|
||||
const headerIndex = headerMatch.index!;
|
||||
const restOfContent = content.slice(headerIndex);
|
||||
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
|
||||
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index! : content.length;
|
||||
const section = content.slice(headerIndex, sectionEnd).trim();
|
||||
|
||||
const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
|
||||
const goal = goalMatch ? goalMatch[1].trim() : null;
|
||||
|
||||
return {
|
||||
found: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
phase_number: String(phaseNum),
|
||||
phase_name: phaseName,
|
||||
goal,
|
||||
section,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Milestone info lookup ────────────────────────────────────────────────────
|
||||
|
||||
interface MilestoneInfo {
|
||||
version: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function getMilestoneInfo(cwd: string): MilestoneInfo {
|
||||
try {
|
||||
const roadmap = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md'));
|
||||
if (roadmap === null) throw new Error('missing');
|
||||
|
||||
let stateVersion: string | null = null;
|
||||
if (cwd) {
|
||||
try {
|
||||
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
||||
const stateRaw = platformReadSync(statePath);
|
||||
if (stateRaw !== null) {
|
||||
const m = stateRaw.match(/^milestone:\s*(.+)/m);
|
||||
if (m) stateVersion = m[1].trim();
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
|
||||
if (stateVersion) {
|
||||
const escapedVer = escapeRegex(stateVersion);
|
||||
const headingMatch = roadmap.match(
|
||||
new RegExp(`##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'i')
|
||||
);
|
||||
if (headingMatch) {
|
||||
if (!headingMatch[0].includes('✅')) {
|
||||
return { version: stateVersion, name: headingMatch[1].trim() };
|
||||
}
|
||||
} else {
|
||||
const listMatch = roadmap.match(
|
||||
new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i')
|
||||
);
|
||||
if (listMatch) {
|
||||
return { version: stateVersion, name: listMatch[1].trim() };
|
||||
}
|
||||
return { version: stateVersion, name: 'milestone' };
|
||||
}
|
||||
}
|
||||
|
||||
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
||||
if (inProgressMatch) {
|
||||
return {
|
||||
version: 'v' + inProgressMatch[1],
|
||||
name: inProgressMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const cleaned = stripShippedMilestones(roadmap);
|
||||
const headingMatch = cleaned.match(/## (?!.*✅).*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
|
||||
if (headingMatch) {
|
||||
return {
|
||||
version: 'v' + headingMatch[1],
|
||||
name: headingMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
|
||||
return {
|
||||
version: versionMatch ? versionMatch[0] : 'v1.0',
|
||||
name: 'milestone',
|
||||
};
|
||||
} catch {
|
||||
return { version: 'v1.0', name: 'milestone' };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Milestone phase filter ───────────────────────────────────────────────────
|
||||
|
||||
type MilestonePhaseFilter = ((dirName: string) => boolean) & {
|
||||
phaseCount: number;
|
||||
missingExplicitVersion: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a filter function that checks whether a phase directory belongs
|
||||
* to the current milestone based on ROADMAP.md phase headings.
|
||||
*/
|
||||
function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null): MilestonePhaseFilter {
|
||||
const milestonePhaseNums = new Set<string>();
|
||||
let missingExplicitVersion = false;
|
||||
try {
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
const roadmapContent = platformReadSync(roadmapPath);
|
||||
if (roadmapContent === null) throw new Error('missing');
|
||||
let roadmap = extractCurrentMilestone(roadmapContent, cwd);
|
||||
|
||||
const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
|
||||
const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
|
||||
if (!hasVersionedMilestonesGlobal && hasPhaseHeadings) {
|
||||
console.warn(
|
||||
'[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' +
|
||||
'Set phase_id_convention in config.json to suppress this warning.'
|
||||
);
|
||||
}
|
||||
|
||||
if (versionOverride) {
|
||||
const escapedVersion = escapeRegex(versionOverride);
|
||||
const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}[^\\n]*)`, 'mi');
|
||||
let sectionMatch = roadmapContent.match(sectionPattern);
|
||||
|
||||
if (!sectionMatch) {
|
||||
const summaryPat = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i');
|
||||
const summaryHit = roadmapContent.match(summaryPat);
|
||||
if (summaryHit) {
|
||||
const beforeSummary = roadmapContent.slice(0, summaryHit.index);
|
||||
const detailsIdx = beforeSummary.lastIndexOf('<details');
|
||||
if (detailsIdx !== -1) {
|
||||
sectionMatch = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sectionMatch) {
|
||||
const hasVersionedMilestones = /^#{1,3}\s+(?!Phase\s+\S).*v\d+\.\d+/mi.test(roadmapContent);
|
||||
const versionInSummary = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i').test(roadmapContent);
|
||||
if (hasVersionedMilestones && !versionInSummary) {
|
||||
roadmap = '';
|
||||
missingExplicitVersion = true;
|
||||
}
|
||||
} else {
|
||||
const sectionStart = sectionMatch.index!;
|
||||
const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
||||
const restContent = roadmapContent.slice(sectionStart + sectionMatch[0].length);
|
||||
const nextMilestonePattern = new RegExp(`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i');
|
||||
|
||||
let sectionEnd = roadmapContent.length;
|
||||
let fenceChar: string | null = null;
|
||||
let fenceLen = 0;
|
||||
let charOffset = 0;
|
||||
for (const line of restContent.split('\n')) {
|
||||
const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
|
||||
if (fenceMatch) {
|
||||
const char = fenceMatch[1][0];
|
||||
const len = fenceMatch[1].length;
|
||||
const trailing = fenceMatch[2] || '';
|
||||
if (!fenceChar) {
|
||||
fenceChar = char;
|
||||
fenceLen = len;
|
||||
} else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) {
|
||||
fenceChar = null;
|
||||
fenceLen = 0;
|
||||
}
|
||||
} else if (!fenceChar && nextMilestonePattern.test(line)) {
|
||||
sectionEnd = sectionStart + sectionMatch[0].length + charOffset;
|
||||
break;
|
||||
}
|
||||
charOffset += line.length + 1;
|
||||
}
|
||||
|
||||
const currentSection = roadmapContent.slice(sectionStart, sectionEnd);
|
||||
roadmap = currentSection;
|
||||
}
|
||||
}
|
||||
|
||||
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(roadmap)) !== null) {
|
||||
milestonePhaseNums.add(m[1]);
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
if (milestonePhaseNums.size === 0) {
|
||||
const passAll = (() => true) as unknown as MilestonePhaseFilter;
|
||||
passAll.phaseCount = 0;
|
||||
passAll.missingExplicitVersion = missingExplicitVersion;
|
||||
return passAll;
|
||||
}
|
||||
|
||||
const normalized = new Set(
|
||||
[...milestonePhaseNums].map(n => n.split('-').map(seg => (seg.replace(/^0+(?=\d)/, '') || '0')).join('-').toLowerCase())
|
||||
);
|
||||
|
||||
function normalizePhaseIdSegments(id: string): string {
|
||||
return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
|
||||
}
|
||||
|
||||
const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
|
||||
const numericRe = roadmapUsesHyphenedIds
|
||||
? /^0*(\d+(?:-0*\d+)*[A-Za-z]?(?:\.\d+)*)/
|
||||
: /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
|
||||
|
||||
function isDirInMilestone(dirName: string): boolean {
|
||||
const m2 = dirName.match(numericRe);
|
||||
if (m2 && normalized.has(normalizePhaseIdSegments(m2[1]).toLowerCase())) return true;
|
||||
const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
|
||||
if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
|
||||
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
if (stripped !== dirName) {
|
||||
const sm = stripped.match(numericRe);
|
||||
if (sm && normalized.has(normalizePhaseIdSegments(sm[1]).toLowerCase())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
(isDirInMilestone as MilestonePhaseFilter).phaseCount = milestonePhaseNums.size;
|
||||
(isDirInMilestone as MilestonePhaseFilter).missingExplicitVersion = missingExplicitVersion;
|
||||
return isDirInMilestone as MilestonePhaseFilter;
|
||||
}
|
||||
|
||||
export = {
|
||||
stripShippedMilestones,
|
||||
extractCurrentMilestone,
|
||||
replaceInCurrentMilestone,
|
||||
getRoadmapPhaseInternal,
|
||||
getMilestoneInfo,
|
||||
getMilestonePhaseFilter,
|
||||
};
|
||||
@@ -10,7 +10,10 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, phaseTokenMatches } = core;
|
||||
const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, output, error, findPhaseInternal, phaseTokenMatches } = core;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import roadmapParserModule = require('./roadmap-parser.cjs');
|
||||
const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = roadmapParserModule;
|
||||
import { platformWriteSync } from './shell-command-projection.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
|
||||
Reference in New Issue
Block a user