Files
msd-core/src/core-utils.cts
Tom Boucher 343835facc refactor(#3183): route live-plan counting through scanPhasePlans (#3199)
* refactor(#3183): route live-plan counting through scanPhasePlans

scanPhasePlans becomes the sole owner of the live-plan derivation. Twenty-one
independent re-derivations across seven modules now route through it, and
scripts/lint-plan-count-drift.cjs reports zero, scanning the whole repo rather
than an allowlist (ADR-3180 Decision 4a).

The epic scoped this at three copies. A whole-repo guard found twenty-six sites
across nine files, so Phase 1 absorbs every live-plan re-derivation and Phase 3
narrows to window plus sentinel enumeration.

Two sites are exempt with a documented reason rather than a bare allowlist:
audit.cts scans one quick task's own directory for a single completion record,
and gsd2-import.cts reads a foreign GSD-2 tasks/ layout during a one-time
import. Neither is a phase directory.

scanPhasePlans gains allPlanFiles (pre-supersession) alongside planFiles so one
owner answers both questions: verify.cts's numbering-gap check wants every plan
on disk, its pairing check wants the live set. Both fields are additive.

Highest-severity fix: cmdPhasePlanIndex, which feeds execute-phase wave
scheduling, was scheduling status:superseded plans into waves and reporting zero
plans for the post-#3139 nested layout.

filterPlanFiles and filterSummaryFiles are deleted; getPhaseFileStats orphaned
them and only their own tests still called them.

New leaf module src/planning-scope.cts carries the frozen SCOPE discriminator,
with its six-gate ripple closed: gitignore, inventory manifest, INVENTORY.md and
the CONTEXT.md glossary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* docs(#3183): amend ADR-3180 for the Phase 1/3 boundary re-slice

The contract held; the phase boundary did not. The whole-repo drift guard found
26 re-derivations across 9 files against the epic's estimate of 3, and
cmdProgressRender re-derives both enumeration and plan counting on adjacent
lines, so DW4 was unsatisfiable within Phase 1's original file scope.

Records the amended scope, scanPhasePlans's new allPlanFiles field,
findOrphanSummaries, the two documented exemptions, the re-derived Tier-2
table, and the describeNonCanonicalPlans trap for later phases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* fix(#3183): complete the canonical pairing rule and gate the naming diagnostic

The remote runner went red with 13 deterministic failures on both lanes,
and they were right: replacing verify.cts's canonicalPlanStem pairing with
summaryCandidates dropped a case the bespoke rule covered. A plan carrying a
descriptive slug after its id (68-01-scaffolding-PLAN.md) pairs with its
canonical-stem summary (68-01-SUMMARY.md), and summaryCandidates generated no
such candidate, so the plan read unsummarized.

The fix is to complete the one rule rather than restore a second:
summaryCandidates gains a canonical-id candidate, narrowed to fire only when an
id pair was actually extracted. countMatchedSummaries, findUnsummarizedPlans
and findOrphanSummaries all inherit it. The two-plans-one-summary collision
behaviour of the original rule is preserved deliberately and documented in
place.

Second defect, independently root-caused while verifying: routing the #2893
naming diagnostic through scanPhasePlans exposed it to the loose /PLAN/i
fallback, which is correct for counting and wrong for a naming check — a
non-canonically-named file was accepted as a valid plan and the diagnostic
went silent. cmdPhasesList, cmdFindPhase and cmdPhasePlanIndex now intersect
with a strict isCanonicalPlanFile predicate before reporting names.

Same class as the describeNonCanonicalPlans trap already recorded in ADR-3180:
a question about file naming wants the physical, strictly-matched set; only a
question about outstanding work wants the live set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* chore(#3183): register planning-scope.cjs in the eslint migration list

tests/repo-invariants.test.cjs asserts every bin/lib/*.cjs is linted xor
ignored per its ADR-457 migration state. The new planning-scope module closed
five of the six .cts ripple gates - gitignore, inventory manifest, INVENTORY.md
and the CONTEXT.md glossary - but not eslint, because that one is enforced by a
test rather than by lint:ci, so the local pipeline stayed green while it was
missing.

Generated from src/planning-scope.cts, so the .cjs is ignored and the .cts is
linted, matching every other migrated module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* fix(#3183): replace the plan-count drift detector with a literal tokenizer

CodeQL reported 4 high-severity js/redos alerts on REGEX_LITERAL_MD_RE, the
backtracking regex that finds "a regex literal mentioning PLAN/SUMMARY and an
escaped \.md". Five review rounds found it had two defects, not one:

  - EXPONENTIAL, then CUBIC. Its "any char" atom `(?:\\.|[^/\r\n])` let a `\.`
    pair be consumed either as one escape or as two class characters, which is
    exponential backtracking: 27,464ms on `"/\.mdplan" + "\.".repeat(28) + "X"`.
    Excluding `\` from the class killed that but left a cubic path — 23ms at
    N=200, 172ms at N=400, 1362ms at N=800 on `"/" + "PLAN\.md".repeat(N)` with
    no closing `/`. This guard is the last stage of `npm run lint:ci`, which CI
    runs on fork pull requests, so a crafted src/*.cts could stall the job.
  - A DETECTION HOLE. A character class holding a bare, unescaped `/` — e.g.
    `/SUMMARY[^/]*\.md$/`, an ordinary path-excluding filter — terminated the
    literal at that `/`, so the scan never reached `\.md` and the guard missed
    it entirely. (Classes holding an ESCAPED `\/` were already matched; the
    tests cover those separately as parity, not as regressions.)

Both defects have one root cause: regex-literal grammar — `\x` escapes, and
`/` inside `[...]` not terminating — is not expressible in a backtracking
regex. So the detector is now a tokenizer, not a regex.

readRegexLiteralAt reads the literal at a given `/` in a single left-to-right
pass with no backtracking, treating escapes as two-character units and
suppressing the `/` terminator inside a character class. findRegexLiteralMdMatch
restarts it at every `/` on the line, preserving the old "find anywhere"
behaviour; MAX_REGEX_LITERAL_LEN (400) bounds each read — including the
trailing-flag scan — which keeps the whole-line cost linear.

Results: cubic shape flat at 0.06-0.39ms out to N=3200 (25KB), exponential
shape 0.01ms at 28 reps and 0.00ms at 64, and the bare-`/` class shapes are now
caught. Differential against the old regex over 28,474 lines (those matching
FILENAME_TEST_RE but not PLAN_SUMMARY_LITERAL_RE, across src/tests/scripts/
gsd-core/bin/eslint-rules, excluding 265 lines with >6 backslashes on which the
old regex hangs): 6 differences, all the tokenizer returning the fuller or
newly-correct literal, 0 old-only misses. The `\.md` token stays
case-insensitive, matching the `/i` the old regex carried.

Also closes three holes in the same new file:

  - walk() tested entry.isFile(), false for a symlink, so a symlinked
    src/*.cts was silently unscanned — an evasion of a guard whose stated
    principle (ADR-3180 Decision 4a) is whole-repo discovery with no allowlist.
    It now resolves symlinks, but confined: file links must resolve inside the
    repo root, directory links inside the scanned dir itself. Every sibling
    drift guard in scripts/ uses the Dirent classification and never follows
    links, so following them unconfined would have made this the only linter
    able to read outside the tree — on fork PRs an arbitrary out-of-repo read
    whose matched fragments reach a public CI log. The narrower directory rule
    additionally stops `src/up -> ..` from sweeping the whole repo, and the
    skip list is now checked against resolved paths so `src/g -> ../.git`
    cannot reach .git/** or node_modules/**. Real paths are de-duplicated and
    files reported canonically, so a symlink alias cannot shift which
    FUNCTION_SCOPED_EXEMPTIONS key applies.
  - Both the reported fragment and the reported FILE PATH are attacker-
    controlled source text written straight to a CI log, and git permits
    control bytes in a filename. Both are now escaped — C0/C1/DEL plus the
    bidi and zero-width controls — so a crafted literal or filename cannot
    recolour the log, overwrite a line with CR, or fabricate a line that looks
    like this guard's own success output.

Regression coverage in tests/plan-count-single-owner.test.cjs: a child-process
probe over both pathological shapes (catastrophic backtracking is synchronous
and would freeze the suite rather than fail one test), the bare-`/` class
shapes verified to fail against the parent-commit blob, root-confinement tests
covering the outside-file, outside-directory, cycle, broken-link and duplicate
cases, direct isInsideRoot coverage including the sibling-prefix case that a
bare startsWith would let through, sanitizeForReport coverage, and
limit-1/limit/limit+1 coverage of MAX_REGEX_LITERAL_LEN derived from the
exported constant. The earlier structural assertion was dropped — it checked
for the substring `[^/`, which respelling the class as `[^\r\n/]` defeats
while staying exponential.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* chore(#3183): backfill changeset PR number

Restores b77931869, which a force-push during the ReDoS remediation dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:20:55 -04:00

423 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
// #2849: strip leading/trailing hyphens AFTER truncation, not only before.
// .substring(0, 60) can land on a separator, re-introducing a trailing hyphen
// the strip step exists to prevent. Truncation cannot add a leading hyphen, so
// running the full ^-+|-+$ pass last is equivalent for leading hyphens and
// fixes the trailing-hyphen-after-truncation case.
return transliterateForSlug(text).replace(/[^a-z0-9]+/g, '-').substring(0, 60).replace(/^-+|-+$/g, '');
}
// ─── Transliteration (#2848) ─────────────────────────────────────────────────
//
// Non-Latin titles used to reduce to an empty slug: the `[^a-z0-9]+` strip
// removed every character of an all-Cyrillic title and the hyphen cleanup left
// "". Callers then created unnamed phase directories (`01-`) and empty
// `milestone_slug` init JSON. The fix transliterates Cyrillic to ASCII BEFORE
// the existing ASCII filter, so a non-Latin title yields a usable ASCII slug
// while Latin-script text (which hits zero map entries) is byte-for-byte
// unchanged — the negative control is satisfied by construction.
//
// Multi-letter mappings (ж→zh, ч→ch, ш→sh, щ→sch, ю→yu, я→ya) are applied as a
// single pass; soft/hard signs (ъ, ь) drop to nothing rather than a hyphen.
// Scope is Cyrillic (Russian + the reported Ukrainian/Belarusian extras
// і ї є ґ ў) per the issue's confirmed-working patch. CJK and other
// non-transliterated scripts keep the existing strip-to-ASCII behavior.
const CYRILLIC_TRANSLITERATION: Readonly<Record<string, string>> = {
// multi-letter first (longest-match-safe within a single pass via ordered keys)
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh',
з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o',
п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts',
ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu',
я: 'ya',
// Ukrainian / Belarusian extras reported in #2848
є: 'ye', і: 'i', ї: 'yi', ґ: 'g', ў: 'u',
};
const CYRILLIC_TRANSLITERATION_KEYS = Object.keys(CYRILLIC_TRANSLITERATION);
/**
* Lowercase + transliterate Cyrillic characters to ASCII. The output still
* contains non-ASCII for scripts outside the map (CJK, etc.) — the caller's
* existing `[^a-z0-9]+` filter handles those. Latin-script input is returned
* lowercased with no other change.
*
* Shared by `generateSlugInternal` (core-utils) and `slugify` (gsd2-import) so
* the transliteration step is not duplicated across the two slug helpers (#2848
* explicitly requires both be fixed).
*/
function transliterateForSlug(text: string): string {
const lowered = text.toLowerCase();
let out = '';
for (const ch of lowered) {
out += CYRILLIC_TRANSLITERATION_KEYS.includes(ch)
? CYRILLIC_TRANSLITERATION[ch]
: ch;
}
return out;
}
// ─── Phase file helpers ──────────────────────────────────────────────────────
interface PhaseFileStats {
plans: string[];
summaries: string[];
hasResearch: boolean;
hasContext: boolean;
hasVerification: boolean;
hasReviews: boolean;
scope: string;
}
// Minimal shape this module needs from plan-scan.cjs's scanPhasePlans result.
interface PlanScanResultShape {
planFiles: string[];
summaryFiles: string[];
scope: string;
}
/**
* Read a phase directory and return counts/flags for common file types.
*
* #3183 (ADR-3180 Decision 2): `plans`/`summaries` are derived from the
* canonical `scanPhasePlans` rather than a local re-derivation, so this
* primitive can no longer diverge from the single owner of live-plan
* counting. `scanPhasePlans`
* lives in plan-scan.cjs, which itself imports `countMatchedSummaries` from
* THIS module — a top-level import here would be circular, so the require
* is deferred (lazy, inside the function body) to break the cycle at load
* time. This mirrors the lazy-require seam already used elsewhere in this
* repo (see src/audit-command-router.cts) for the same "module A needs
* module B which needs module A" shape.
*
* `hasResearch`/`hasContext`/`hasVerification`/`hasReviews` stay on the raw
* `readdirSync` listing — they are not plan-scan concerns.
*
* Degrades on an unreadable directory instead of throwing: empty arrays,
* every flag false, scope UNREADABLE (mirroring scanPhasePlans's own
* degrade path).
*/
function getPhaseFileStats(phaseDir: string): PhaseFileStats {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const scanPhasePlans: (dir: string) => PlanScanResultShape = require('./plan-scan.cjs');
const scan = scanPhasePlans(phaseDir);
let files: string[];
try {
files = fs.readdirSync(phaseDir);
} catch {
return {
plans: scan.planFiles,
summaries: scan.summaryFiles,
hasResearch: false,
hasContext: false,
hasVerification: false,
hasReviews: false,
scope: scan.scope,
};
}
return {
plans: scan.planFiles,
summaries: scan.summaryFiles,
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'),
scope: scan.scope,
};
}
/**
* 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) {
if (summaryCandidates(plan).some((c) => summarySet.has(c))) matched++;
}
return matched;
}
/**
* The candidate `*-SUMMARY.md` filenames a single plan's completion record
* could take, per the three naming conventions documented above
* `countMatchedSummaries`. Extracted so `findUnsummarizedPlans` can reuse the
* exact same matching rule without duplicating it (a divergence between the
* count and the list would let a plan be counted as matched while still
* appearing in the unsummarized set, or vice versa).
*/
function summaryCandidates(plan: string): string[] {
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');
// #3183: canonical-id form. Restores the coverage of the pre-migration
// bespoke I001 rule (verify.cts, pre-#3183, via validate.cjs's now-unused
// `canonicalPlanStem` — behaviourally identical to `extractCanonicalPlanId`,
// confirmed empirically), which matched a plan carrying a descriptive slug
// after its <phase>-<plan> id — e.g. `68-01-scaffolding-PLAN.md` — against
// a summary named only by the bare id — `68-01-SUMMARY.md`. None of the
// three candidates above produce that filename.
//
// Narrowed to the case `extractCanonicalPlanId` actually extracted an
// <id>-<id> pair (its result differs from the plan's own PLAN-stripped
// base). When no pair is found it falls back to returning that same base
// unchanged, which would otherwise push a redundant candidate identical to
// the `<stem>-SUMMARY.md` form above (e.g. `setup-PLAN.md` -> canonical
// 'setup' -> 'setup-SUMMARY.md', already candidate #2) rather than the
// original rule's actual behavior of matching only real id pairs.
//
// Collision, matching the original rule byte-for-behaviour: two plans that
// share the same <phase>-<plan> id but differ only in their descriptive
// slug (`68-01-alpha-PLAN.md` + `68-01-beta-PLAN.md`) both generate the
// SAME candidate `68-01-SUMMARY.md` and therefore BOTH read as summarized
// off one shared summary file. This is not a new regression: the
// pre-migration bespoke rule collapsed the same way (it populated one
// `summaryBases` Set keyed by canonical stem, so any plan whose canonical
// stem hit the set counted as matched, with no cardinality check against
// how many plans shared that stem).
const planStem = base.replace(/-PLAN$/i, '');
const canonicalId = extractCanonicalPlanId(base + '.md');
if (canonicalId !== planStem) candidates.push(dir + canonicalId + '-SUMMARY.md');
return candidates;
}
/**
* #2648: the plan files in `planFiles` that have NO matching completion record
* in `summaryFiles`, using the identical matching rule as `countMatchedSummaries`
* (so the count and the named list can never disagree). Callers that must NAME
* the missing plans — e.g. phase.complete's fail-closed coverage gate, which
* refuses completion when any non-retired plan lacks a SUMMARY — need the list,
* not just the count. `planFiles` is expected to be already superseded-filtered
* (the caller passes `scanPhasePlans(...).planFiles`, which drops
* `status: superseded` plans), so a deliberately-retired plan never appears
* here and never blocks completion.
*/
function findUnsummarizedPlans(planFiles: string[], summaryFiles: string[]): string[] {
const summarySet = new Set(summaryFiles);
return planFiles.filter((plan) => !summaryCandidates(plan).some((c) => summarySet.has(c)));
}
/**
* #3183: the mirror image of `findUnsummarizedPlans` — the summary files in
* `summaryFiles` that do NOT pair with ANY plan in `planFiles`, using the
* identical `summaryCandidates` matching rule as `countMatchedSummaries` /
* `findUnsummarizedPlans`. Callers that must name orphaned summaries (a
* stray non-plan summary, or a summary whose plan was renamed/removed) need
* this instead of a bespoke exact-suffix Set-diff, which cannot recognize
* the nested or extended naming forms `summaryCandidates` already handles —
* a divergence that produced false "orphan summary" warnings.
*/
function findOrphanSummaries(planFiles: string[], summaryFiles: string[]): string[] {
const claimed = new Set<string>();
for (const plan of planFiles) {
for (const candidate of summaryCandidates(plan)) claimed.add(candidate);
}
return summaryFiles.filter((s) => !claimed.has(s));
}
export = {
toPosixPath,
detectSubRepos,
extractOneLinerFromBody,
pathExistsInternal,
generateSlugInternal,
transliterateForSlug,
getPhaseFileStats,
readSubdirectories,
timeAgo,
extractCanonicalPlanId,
countMatchedSummaries,
findUnsummarizedPlans,
findOrphanSummaries,
};