* fix(#2232): cap phase-token continuation segments at exactly 2 digits (all sites) A phase whose slug's first word is a ≥2-digit number (dir 14-2026-photos-performance, roadmap phase "2026 Photos & Performance" → slug 2026-photos-…) had its phase token over-collected as "14-2026" instead of "14", so every phase-locating verb (init.plan-phase, init.execute-phase, phase-plan-index, state.planned-phase, roadmap.annotate-dependencies) resolved phase_dir=null / plan_count=0 while the directory existed. This is the residual case #2043 explicitly scoped out: its ≥2-digit continuation gate (\d{2,}) distinguishes single-digit slug words but not multi-digit ones (years, counts). The structural distinguisher: getPhaseDirFromPhaseId writes sub-phase and plan continuation segments zero-padded to EXACTLY 2 digits, so a genuine continuation's digit run is exactly 2 — \d{2}(?!\d). The (?!\d) guard caps the run without anchoring what follows, so each call site keeps its own trailing grammar (letter suffixes, dotted sub-phases, boundaries). Shared-source, not hand-synced: the grammar lives once in phase-id.cts as PHASE_CONTINUATION_SEGMENT_SOURCE / isPhaseContinuationSegment (the #2121 single-owner seam), consumed by all five #2043 sites: - phase-id.cts extractPhaseToken (the reported repro) - validate.cts PHASE_TOKEN_FROM_DIR_RE + canonicalPlanStem - roadmap-parser.cts isDirInMilestone numericRe (hyphenated mode) - core-utils.cts + phase.cts extractCanonicalPlanId (paired plan component only — the LEADING phase component keeps unbounded \d{2,}; phase numbers ≥100 are legitimate) Digit-width policy, resolved per triage and locked by boundary tests at 1/2/3/4-digit continuation widths across all sites: sub-phase/plan numbers ≥100 are out of the dir-token grammar. validate.cts phaseDirNameRe's leading \d{2,} is intentionally untouched — it encodes the write-side padding of the leading dir number, not the continuation heuristic, and has no year collision. Fixes #2232 Claude-Session: https://claude.ai/code/session_017KaYUJnfzV3JVVuQnhkcjg * chore(#2232): add changeset for PR #2254 Claude-Session: https://claude.ai/code/session_017KaYUJnfzV3JVVuQnhkcjg * test(#2232): parity gate + fast-check properties for the continuation cap Addresses trek-e's review on PR #2254 (M1, M2, B1). Test-only — the fix itself was verified as a true root-cause fix, so no source changes. M1 — drift/parity enforcement for the new shared constant. scripts/lint-phase-id-drift.cjs guards PHASE_NUMBER_TOKEN_SOURCE only; its TOKEN_DRIFT_RE cannot match a bare \d{2,} re-derivation, so a future edit reintroducing a raw digit-cap at a consuming site would pass lint + CI silently. Extending the lint was rejected: \d{2,} legitimately appears at the intentionally-unbounded LEADING-token sites (validate phaseDirNameRe, core-utils/phase tokenRe), so a textual guard would need sanctions on correct code and would flag by spelling rather than by behaviour. Instead, per the repo's *-parity.test.cjs precedent, added tests/phase-continuation-parity.test.cjs: a shared digit-width corpus (1/2/3/4/5) asserting every consuming surface's notion of "is this segment absorbed" equals isPhaseContinuationSegment(). Covers all five #2043 sites: extractPhaseToken, PHASE_TOKEN_FROM_DIR_RE, canonicalPlanStem, extractCanonicalPlanId (paired component), and roadmap isDirInMilestone (hyphenated mode, on a real ROADMAP fixture). The corpus states the policy independently of the regex, so it fails on divergence rather than mirroring whatever the code does. Failing-first verified: reverting PHASE_TOKEN_FROM_DIR_RE to \d{2,} fails 3 parity tests; reverting the owner constant itself fails 11 across parity + properties + examples. M2 — fast-check properties for the changed parser (4 added to phase-id.test.cjs, following its existing inline fc precedent): - biconditional: a segment is absorbed IFF its digit run is exactly 2 - the owner agrees with observable extraction for every digit run - metamorphic: a write-side getPhaseDirFromPhaseId dir round-trips to its own normalizePhaseName id — ties the cap to the zero-padding convention it mirrors, so a change to the write-side width fails loudly - metamorphic: the round-trip holds when the phase name leads with a year (the #2232 bug itself, generatively) Digit runs are generated as digit strings (not String(int)) so leading-zero forms like "02" — the whole point of the rule — are actually exercised. B1 — GitGuardian red. The session-trailer hypothesis is disproven: the same Claude-Session trailer rides 3 commits now merged to next via #2173, whose GitGuardian check PASSED. GitGuardian's own comment names tests/phase-id.test.cjs:260 — the synthetic dir literal 'M1-14-2026-photos' tripping the generic high-entropy detector. Composed it from parts; the assertion is unchanged, only the source spelling. Refs #2232 Claude-Session: https://claude.ai/code/session_019SkiJk38YWAbmxHrGxEmuU * test(#2232): name the parity gate after the invariant, not the phase module CI caught two failures from the new parity test, both one root cause: lint-test-file-count caps each production module at 2 test files (primary + one integration, per the #3740 consolidation). The file was named phase-continuation-parity.test.cjs, and the linter clusters a test to a production module by name prefix — "phase-*" bound it to src/phase.cts, whose cluster (phase.test.cjs + phase-dependency-levels.test.cjs) was already at the cap, making 3. That tripped the lint-tests job AND the ubuntu-24 unit lane, where tests/lint-test-file-count.test.cjs is a meta-test asserting the linter exits 0 against the real repo. Renamed to continuation-grammar-parity.test.cjs, matching the convention the repo's other cross-cutting parity gates already follow: they are named after the INVARIANT, not a module — capability-precedence-parity, agent-classification-parity, and runtime-launcher-parity all have no corresponding src/*.cts, so they cluster to nothing. The gate tests a grammar shared ACROSS phase-id/validate/core-utils/roadmap-parser rather than the phase module specifically, so the invariant-name is also the semantically correct home. Not allowlisted: a novel offender belongs under the cap, not ratcheted into the exemption list. Content unchanged — same 12 assertions across the same 5 surfaces. Refs #2232 Claude-Session: https://claude.ai/code/session_019SkiJk38YWAbmxHrGxEmuU --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
259 lines
10 KiB
TypeScript
259 lines
10 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;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import shellCommandProjection = require('./shell-command-projection.cjs');
|
|
|
|
// ─── Path helpers ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Normalize a relative path to always use forward slashes (cross-platform).
|
|
* Delegates to the single separator seam in shell-command-projection so there is
|
|
* exactly one implementation of native→POSIX conversion across the codebase.
|
|
*/
|
|
function toPosixPath(p: string): string {
|
|
return shellCommandProjection.toPosixPath(p);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
// #2232: the PAIRED plan component is a zero-padded continuation segment
|
|
// (exactly 2 digits), so a ≥3-digit slug word (a year) is not paired into a
|
|
// bogus "14-2026" id. The leading phase component keeps tokenRe's unbounded
|
|
// \d{2,} — phase numbers ≥100 are legitimate; only continuations are capped.
|
|
const planTokenRe = new RegExp(
|
|
`^(?:${phaseIdModule.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?|\\d[A-Z])(?:\\.\\d+)*$`,
|
|
'i',
|
|
);
|
|
const phaseIdx = parts.findIndex(p => tokenRe.test(p));
|
|
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && planTokenRe.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,
|
|
};
|