Files
msd-core/src/core-utils.cts
Tom Boucher 77dcdda534 enhance(#4014): an unreadable directory must not report as an empty one (#4163)
* test(#4014): add failing-first coverage for unreadable-vs-empty directory scope (epic #3473 B4)

* fix(#4014): an unreadable directory must not report as an empty one (epic #3473 B4)

* test(#4014): update hardcoded generateSlugInternal closing-brace line after import shift

src/core-utils.cts's new #4014 import block shifted every subsequent line by
6, moving generateSlugInternal's real closing brace from line 193 to 199.
tests/slug-derivation-drift-guard.test.cjs's MAJOR-1 fixture hardcodes that
line number to plant a synthetic violation immediately after the function's
real body; the guard script itself locates the boundary dynamically via
brace-matching and needed no change.

* docs(#4014): document the unreadable-directory scope signal and add changeset

* docs(#4014): backfill changeset PR number to #4163

* test(#4014): kill pre-existing core-utils.cjs mutation-score gap, unrelated to this issue's diff

---------

Co-authored-by: sim <sim@local>
2026-09-02 08:12:57 -04:00

556 lines
27 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)
*
* #3883 (ADR-3473 §8.3): two of this module's cyclic partners require
* generateSlugInternal, the canonical slug formula:
* - phase-id.cjs requires this module directly.
* - planning-workspace.cjs is a cyclic partner via a longer path:
* core-utils.cjs -> planning-workspace.cjs -> active-workstream-store.cjs
* -> workstream-name-policy.cjs -> core-utils.cjs.
* Both are genuine circular requires. They are safe ONLY because every side
* accesses the other's exports lazily, through the live module-namespace
* object (`phaseIdModule.foo(...)` / `planningWorkspace.foo(...)`) inside a
* function body, never via a top-level destructure — a top-level
* `const { foo } = require(...)` copies the binding at import time and would
* silently capture `undefined` whichever module loses the load-order race.
* This is an absolute rule with no exception in this file: every cyclic
* partner's export is accessed through its module-namespace object, never
* destructured at the top level.
*/
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');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import shellCommandProjection = require('./shell-command-projection.cjs');
// planning-scope.cjs is a leaf module (no imports of its own — see its file
// header), so importing it here directly cannot introduce a cycle.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
// ─── Line-ending normalization ─────────────────────────────────────────────────
/**
* Normalize every line ending in `content` to a bare `\n`, ONCE — the shared
* seam every document-READ boundary in this codebase should route through
* (#3707-CR follow-up MAJOR).
*
* CommonMark treats a lone CR (no paired LF) as a line ending — such a
* document RENDERS as separate lines to a human reader — but a parser that
* splits/tokenizes/scans on `\n` alone treats a lone-CR-separated document as
* ONE unbroken line, hiding every row boundary in it. `src/uat.cts` originally
* carried this exact fix as a PRIVATE, unexported function applied inside two
* of its own parse functions (`parseUatItemsWithStats`, `parseCurrentTest`) —
* which is why two OTHER read sites in the same module (`cmdAuditUat`'s
* VERIFICATION and deferred-items.md ingresses) were missed: normalizing
* per-parser means every new parser must remember to call it. Promoted here,
* to the shared leaf module every document consumer can reach without a new
* dependency edge, so normalization can be applied at the READ boundary
* instead — every current and future parser fed from a boundary that calls
* this gets normalized text by construction.
*
* `/\r\n?/g` is deliberately ONE alternation, not two separate replaces: a
* two-pass `replace(/\r\n/g,'\n').replace(/\r/g,'\n')` is equivalent here
* because the first pass already consumes every CRLF pair before the second
* pass ever runs, but a single regex avoids relying on pass ORDER and matches
* greedily left-to-right in one scan, so a CRLF is always consumed as ONE
* unit (never left as a stray trailing `\r` after the `\n` half is matched
* first) and a lone CR — including one immediately followed by nothing, i.e.
* at EOF, or by another lone CR — is still replaced.
*
* This is deliberately NOT a length-preserving transform (CRLF, two UTF-16
* units, becomes LF, one), so any offsets a caller computes must be compared
* only against THIS normalized string, never against the original raw text.
*
* U+2028 LINE SEPARATOR / U+2029 PARAGRAPH SEPARATOR (#3078-CR) are
* DELIBERATELY NOT folded here, unlike `\r`/`\r\n`. Folding is unnecessary:
* `String.prototype.split('\n')` never treats U+2028/U+2029 as a delimiter,
* so an exotic separator can never manufacture a fake line start for a
* consumer that scans lines produced by `split('\n')`, rather than anchoring
* a multiline (`/m`) regex directly over unsplit text. Only the latter
* pattern is vulnerable to the ECMA-262 LineTerminator set including these
* two code points. This module performs no line-anchored matching of its
* own; a caller that scans lines should split first and match per-line
* rather than anchor `/m` over unsplit text — this comment makes no claim
* about whether any particular caller currently does so.
*/
function normalizeLineEndings(content: string): string {
return content.replace(/\r\n?/g, '\n');
}
// ─── 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(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, '');
// #3170: anchor to a summary-shaped heading (Summary / Overview /
// Accomplishments) so an incidental first heading (a rule list, task
// breakdown, deviation note) does not contribute its first bold run as the
// deliverable one-liner. Iterate headings in document order and extract from
// the first summary-shaped one that has a bold run; fall back to null (not the
// wrong text) when no such heading exists.
const headingRe = /^#+\s*([^\n]*)\n+\*\*([^*\n]+)\*\*([^\n]*)/gm;
let match: RegExpExecArray | null;
while ((match = headingRe.exec(body)) !== null) {
if (!/summary|overview|accomplish/i.test(match[1])) continue;
const boldInner = match[2].trim();
const afterBold = match[3];
if (/:\s*$/.test(boldInner)) {
const prose = afterBold.trim();
if (prose.length > 0) return prose;
} else if (boldInner.length > 0) {
return boldInner;
}
}
return 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;
}
}
/**
* #3883 (ADR-3473 §8.3 remediation): `maxLen` lets a caller state its own
* truncation contract instead of being forced into this function's
* historical 60-char cap. Some call sites truncated at 60 before the #3883
* consolidation (commands.cts:cmdGenerateSlug) and some never truncated at
* all (phase-id.cts toDir/getPhaseDirFromPhaseId, the init.cts/phase-locator
* phase_slug sites, workstream-name-policy.cts toWorkstreamSlug) — collapsing
* every caller onto a single hard-coded 60 introduced two identity
* collisions (distinct >60-char names/phase-slugs truncating to the same
* value) that did not exist pre-migration. `maxLen: 60` remains the default
* so untouched callers keep prior behavior; pass `null` for no truncation.
*/
function generateSlugInternal(text: string | null | undefined, maxLen: number | null = 60): 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.
const collapsed = transliterateForSlug(text).replace(/[^a-z0-9]+/g, '-');
const truncated = maxLen === null ? collapsed : collapsed.substring(0, maxLen);
return truncated.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: Scope;
}
// Minimal shape this module needs from plan-scan.cjs's scanPhasePlans result.
interface PlanScanResultShape {
planFiles: string[];
summaryFiles: string[];
scope: Scope;
}
/**
* 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.
*
* #3511 BLOCKER-2: the raw listing is scoped through `scopeToPhase` (keyed on
* `path.basename(phaseDir)`) before any of the four artifact predicates run,
* so a stray cross-phase file (e.g. `04-VERIFICATION.md` sitting inside phase
* 03's directory) cannot flip `hasResearch`/`hasContext`/`hasVerification`/
* `hasReviews` true for a phase it does not belong to — the same membership
* rule every other aggregate phase-directory scan (`uat.cts`, `audit.cts`,
* `phase.cts`, `state.cts`) already routes through. `hasContext` is scoped by
* passing the already-scoped array into `findContextMdIn` at this call site
* only — `findContextMdIn` itself is unchanged and its other 4 call sites
* (roadmap.cts, gap-checker.cts, init.cts) are unaffected.
*
* Degrades on an unreadable directory instead of throwing: empty arrays,
* every flag false, scope UNREADABLE (mirroring scanPhasePlans's own
* degrade path).
*
* #4014 (epic #3473 B4-unreadable): this function's OWN readdirSync used to
* catch its own failure and then return `scope: scan.scope` — the scope of
* the UNRELATED, already-successful scanPhasePlans call — silently dropping
* that THIS read failed. The own-readdirSync is now routed through
* findContextMdIn's directory-string form (which never throws and reports
* its own SCOPE.UNREADABLE), and the two independently-scoped answers are
* combined into the worse of the two below — never letting a successful
* scan.scope mask this function's own failed read.
*
* The combination is written out as a two-value comparison rather than
* calling planning-snapshot.cts's `worstScope` directly: that module
* requires core-utils.cjs (for `findOrphanSummaries`) at its own top level,
* so importing it back here would create a tight, direct
* core-utils.cjs <-> planning-snapshot.cjs cycle — a materially different
* (and riskier) shape than this file's existing, documented lazy-access
* cyclic partners (planning-workspace.cjs, phase-id.cjs), which
* planning-snapshot.cts sits *above* in the dependency graph, not beside.
* `findContextMdIn`'s directory-string form can only ever report
* SCOPE.COMPLETE or SCOPE.UNREADABLE (a single readdirSync is binary — see
* its own doc comment), so per planning-snapshot.cts's SCOPE_SEVERITY
* ordering (UNREADABLE is maximal), `worstScope(scan.scope, ownScope)` is
* exactly `ownScope === UNREADABLE ? UNREADABLE : scan.scope` — the
* expression below is that identity, not a re-derivation of the ordering.
*/
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);
const { files, scope: ownScope } = planningWorkspace.findContextMdIn(phaseDir);
const scope: Scope = ownScope === SCOPE.UNREADABLE ? SCOPE.UNREADABLE : scan.scope;
if (ownScope === SCOPE.UNREADABLE) {
return {
plans: scan.planFiles,
summaries: scan.summaryFiles,
hasResearch: false,
hasContext: false,
hasVerification: false,
hasReviews: false,
scope,
};
}
const scopedFiles = phaseIdModule.scopeToPhase(files, path.basename(phaseDir));
return {
plans: scan.planFiles,
summaries: scan.summaryFiles,
hasResearch: scopedFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
hasContext: planningWorkspace.findContextMdIn(scopedFiles) !== null,
hasVerification: scopedFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
hasReviews: scopedFiles.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
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) => phaseIdModule.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,
normalizeLineEndings,
detectSubRepos,
extractOneLinerFromBody,
pathExistsInternal,
generateSlugInternal,
transliterateForSlug,
getPhaseFileStats,
readSubdirectories,
timeAgo,
extractCanonicalPlanId,
countMatchedSummaries,
findUnsummarizedPlans,
findOrphanSummaries,
};