A phase whose slug's first word is a bare single digit (dir
`46-6-rs-pipeline-orchestrator`, roadmap phase "6 Rs Pipeline Orchestrator" →
slug `6-rs-…`) had its phase token over-collected as `46-6` instead of `46`, so
`gsd-tools` phase-by-number lookups (init.plan-phase, init.phase-op, and
downstream execute/verify/ship) resolved phase_dir=null / has_context=false.
Root cause: an over-broad "looks numeric" test (`/^\d/`, `\d+(?:-\d+)*`)
classified token segments and could not distinguish a legitimate zero-padded
sub-phase segment (`01`, `02`) from a single-digit slug word (`6`). Zero-padded
phase/sub-phase segments are always ≥2 digits, so requiring ≥2 digits is the
structural distinguisher. Applied consistently across every same-class
implementation the triage identified (fixing extractPhaseToken alone leaves the
health-check and milestone-filter subsystems exposed):
- src/phase-id.cts extractPhaseToken — a pure-numeric leading segment continues
only with ≥2-digit segments; a letter-prefixed milestone id (`M1`) still
admits its single-digit sub-phase (`M1-2`).
- src/validate.cts PHASE_TOKEN_FROM_DIR_RE (W005/W006/W007 health checks) and
canonicalPlanStem (I001 plan/summary pairing).
- src/roadmap-parser.cts isDirInMilestone numericRe (getMilestonePhaseFilter —
highest blast radius: a false negative silently excludes a phase from the
milestone).
- src/core-utils.cts + its verbatim duplicate src/phase.cts extractCanonicalPlanId.
Regression tests added across tests/{phase-id,health-validation,core-utils,
roadmap-parser}.test.cjs using the shared `46-6-rs-…` fixture, asserting the
token resolves to `46` (not `46-6`) while legit multi-segment tokens
(`01-02`, `02-03-04`, `M1-2`, `68-01`, `02-01`) are unchanged. The
roadmap-parser test exercises the real getMilestonePhaseFilter end-to-end.
The residual ≥2-digit-leading-slug case (e.g. `NN-2024-roadmap`) stays out of
scope — subsumed by the #612 / #565 phase-ID convention work, per the issue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
245 lines
9.7 KiB
TypeScript
245 lines
9.7 KiB
TypeScript
/**
|
|
* Core Utilities — Shared low-level utility primitives
|
|
*
|
|
* ADR-857 rollout phase 2c: extracted from core.cts (issue #877).
|
|
* Owns POSIX path normalization, sub-repo/subdirectory scanning,
|
|
* phase file stats, slug/one-liner/plan-id helpers, and time-ago.
|
|
* Behaviour is preserved byte-for-behaviour from the prior location;
|
|
* only the module boundary moved. core.cjs re-exports every public symbol
|
|
* here under its own `export =` object so existing consumers are unaffected.
|
|
*
|
|
* New imports should pull core-utils helpers from core-utils.cjs directly.
|
|
*
|
|
* Dependencies (leaf modules only — no core.cjs, no loadConfig):
|
|
* - node:fs / node:path (stdlib)
|
|
* - ./phase-id.cjs (comparePhaseNum, used by readSubdirectories)
|
|
* - ./planning-workspace.cjs (findContextMdIn, used by getPhaseFileStats)
|
|
*/
|
|
|
|
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 { comparePhaseNum } = phaseIdModule;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import planningWorkspace = require('./planning-workspace.cjs');
|
|
const { findContextMdIn } = planningWorkspace;
|
|
|
|
// ─── Path helpers ────────────────────────────────────────────────────────────
|
|
|
|
/** Normalize a relative path to always use forward slashes (cross-platform). */
|
|
function toPosixPath(p: string): string {
|
|
return p.split(path.sep).join('/');
|
|
}
|
|
|
|
/**
|
|
* Scan immediate child directories for separate git repos.
|
|
* Returns a sorted array of directory names that have their own `.git`.
|
|
* Excludes hidden directories and node_modules.
|
|
*/
|
|
function detectSubRepos(cwd: string): string[] {
|
|
const results: string[] = [];
|
|
try {
|
|
const entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
const gitPath = path.join(cwd, entry.name, '.git');
|
|
try {
|
|
if (fs.existsSync(gitPath)) {
|
|
results.push(entry.name);
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
} catch { /* ignore */ }
|
|
return results.sort();
|
|
}
|
|
|
|
// ─── Summary body helpers ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract a one-liner from the summary body when it's not in frontmatter.
|
|
*/
|
|
function extractOneLinerFromBody(content: string | null | undefined): string | null {
|
|
if (!content) return null;
|
|
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
const body = normalized.replace(/^---\n[\s\S]*?\n---\n*/, '');
|
|
const match = body.match(/^#[^\n]*\n+\*\*([^*\n]+)\*\*([^\n]*)/m);
|
|
if (!match) return null;
|
|
const boldInner = match[1].trim();
|
|
const afterBold = match[2];
|
|
if (/:\s*$/.test(boldInner)) {
|
|
const prose = afterBold.trim();
|
|
return prose.length > 0 ? prose : null;
|
|
}
|
|
return boldInner.length > 0 ? boldInner : null;
|
|
}
|
|
|
|
// ─── Misc utilities ───────────────────────────────────────────────────────────
|
|
|
|
function pathExistsInternal(cwd: string, targetPath: string): boolean {
|
|
const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
|
|
try {
|
|
fs.statSync(fullPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function generateSlugInternal(text: string | null | undefined): string | null {
|
|
if (!text) return null;
|
|
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60);
|
|
}
|
|
|
|
// ─── Phase file helpers ──────────────────────────────────────────────────────
|
|
|
|
/** Filter a file list to just PLAN.md / *-PLAN.md entries. */
|
|
function filterPlanFiles(files: string[]): string[] {
|
|
return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
}
|
|
|
|
/** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */
|
|
function filterSummaryFiles(files: string[]): string[] {
|
|
return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
}
|
|
|
|
interface PhaseFileStats {
|
|
plans: string[];
|
|
summaries: string[];
|
|
hasResearch: boolean;
|
|
hasContext: boolean;
|
|
hasVerification: boolean;
|
|
hasReviews: boolean;
|
|
}
|
|
|
|
/**
|
|
* Read a phase directory and return counts/flags for common file types.
|
|
*/
|
|
function getPhaseFileStats(phaseDir: string): PhaseFileStats {
|
|
const files = fs.readdirSync(phaseDir);
|
|
return {
|
|
plans: filterPlanFiles(files),
|
|
summaries: filterSummaryFiles(files),
|
|
hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
|
|
hasContext: findContextMdIn(files) !== null,
|
|
hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
|
|
hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Read immediate child directories from a path.
|
|
* Returns [] if the path doesn't exist or can't be read.
|
|
* Pass sort=true to apply comparePhaseNum ordering.
|
|
*/
|
|
function readSubdirectories(dirPath: string, sort = false): string[] {
|
|
try {
|
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format a Date as a fuzzy relative time string (e.g. "5 minutes ago").
|
|
*/
|
|
function timeAgo(date: Date): string {
|
|
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
if (seconds < 5) return 'just now';
|
|
if (seconds < 60) return `${seconds} seconds ago`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes === 1) return '1 minute ago';
|
|
if (minutes < 60) return `${minutes} minutes ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours === 1) return '1 hour ago';
|
|
if (hours < 24) return `${hours} hours ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days === 1) return '1 day ago';
|
|
if (days < 30) return `${days} days ago`;
|
|
const months = Math.floor(days / 30);
|
|
if (months === 1) return '1 month ago';
|
|
if (months < 12) return `${months} months ago`;
|
|
const years = Math.floor(days / 365);
|
|
if (years === 1) return '1 year ago';
|
|
return `${years} years ago`;
|
|
}
|
|
|
|
// ─── Plan ID helpers ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract the canonical plan ID from a filename.
|
|
* Private to the core cluster — exported so core.cjs:searchPhaseInDir can
|
|
* import it from this leaf without circular dependency, but NOT re-exported
|
|
* from core.cjs's public `export =` block.
|
|
*/
|
|
function extractCanonicalPlanId(filename: string): string {
|
|
const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');
|
|
const parts = base.split('-').filter(Boolean);
|
|
// #2043: a phase/plan token component is either a zero-padded number (≥2 digits)
|
|
// or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
|
|
// so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
|
|
const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
|
|
const phaseIdx = parts.findIndex(p => tokenRe.test(p));
|
|
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
|
|
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
/**
|
|
* Count summaries that correspond to a real plan (#1988).
|
|
*
|
|
* A summary counts toward phase completion iff it pairs with an existing plan
|
|
* file. This excludes stray non-plan summaries — e.g. `30-FIX-CR02-SUMMARY.md`,
|
|
* `30-GAPCLOSURE-SUMMARY.md` — that inflate the raw `*-SUMMARY.md` count and
|
|
* silently flip a phase to Complete when plans are actually missing summaries.
|
|
*
|
|
* Pairing is layout-agnostic. For each plan, up to three candidate summary
|
|
* filenames are generated and any match suffices:
|
|
* 1. marker swap `PLAN`→`SUMMARY` on the basename — root padded
|
|
* (`30-01-PLAN.md`↔`30-01-SUMMARY.md`), nested (`PLAN-01.md`↔
|
|
* `SUMMARY-01.md`, incl. a `plans/` prefix), and bare (`PLAN.md`↔
|
|
* `SUMMARY.md`);
|
|
* 2. `<stem>-SUMMARY.md` — bare (`PLAN.md`↔`PLAN-SUMMARY.md`) and legacy
|
|
* (`14-PLAN-01.md`↔`14-PLAN-01-SUMMARY.md`);
|
|
* 3. extended `<n>-PLAN-<m>…`→`<n>-<m>-SUMMARY.md`
|
|
* (`3-PLAN-01-setup.md`↔`3-01-SUMMARY.md`).
|
|
* The swap is applied to the basename only so a lowercase `plans/` dir prefix
|
|
* isn't corrupted to `SUMMARYs/…`.
|
|
*/
|
|
function countMatchedSummaries(planFiles: string[], summaryFiles: string[]): number {
|
|
const summarySet = new Set(summaryFiles);
|
|
let matched = 0;
|
|
for (const plan of planFiles) {
|
|
const slashIdx = plan.lastIndexOf('/');
|
|
const dir = slashIdx >= 0 ? plan.slice(0, slashIdx + 1) : '';
|
|
const base = (dir ? plan.slice(dir.length) : plan).replace(/\.md$/i, '');
|
|
const candidates: string[] = [
|
|
dir + base.replace(/PLAN/i, 'SUMMARY') + '.md',
|
|
dir + base + '-SUMMARY.md',
|
|
];
|
|
const extended = base.match(/^(\d+)-PLAN-(\d+)/i);
|
|
if (extended) candidates.push(dir + extended[1] + '-' + extended[2] + '-SUMMARY.md');
|
|
if (candidates.some((c) => summarySet.has(c))) matched++;
|
|
}
|
|
return matched;
|
|
}
|
|
|
|
export = {
|
|
toPosixPath,
|
|
detectSubRepos,
|
|
extractOneLinerFromBody,
|
|
pathExistsInternal,
|
|
generateSlugInternal,
|
|
filterPlanFiles,
|
|
filterSummaryFiles,
|
|
getPhaseFileStats,
|
|
readSubdirectories,
|
|
timeAgo,
|
|
extractCanonicalPlanId,
|
|
countMatchedSummaries,
|
|
};
|