Files
msd-core/src/core-utils.cts
Cody Anderson 612fcb00f7 fix(#2232): cap phase-token continuation segments at exactly 2 digits (all sites) (#2254)
* 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>
2026-07-15 15:33:58 -04:00

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,
};