/** * cli:compute-page-diff — disk-drift.ts (Phase 3a orchestrator guard) * * `compute-page-diff` stays a PURE spec-diff CLI (AC-F4.1-011). This module is * the orchestrator-side guard that closes the **silent-skip class**: a pagespec * whose hash is unchanged can still have a drifted `.tsx` on disk — deleted, * hand-edited into an error state, broken i18n / import. Left alone, an empty * spec-diff would skip Phase 3a and let a broken page through, exactly the * failure the backend `pre_entry_coverage_check` closes for Phases 0-2 * (`references/gates.md`). * * The orchestrator (model-driven, no `orchestrator.ts`) executes this contract * by running `compute-page-diff` then `validate-page` on each `unchanged` page. * These pure helpers are the tested reference of that union — the file * existence + `validate-page` err-count are INJECTED, so the union logic is * unit-testable without a real web project. */ export interface DiskDriftDeps { /** True when the page's generated `.tsx` exists on disk. */ exists: (key: string) => boolean /** Count of `err`-severity violations `validate-page` reports for the page. */ errCount: (key: string) => number } /** * Given the `unchanged` set from `compute-page-diff`, return the subset whose * `.tsx` is **missing or invalid** on disk. Only `unchanged` pages are checked: * `added` / `modified` already regenerate, and `removed` pages no longer exist. * * Note: `validate-page` itself returns an `err` for a missing file, so the * `exists` check is belt-and-suspenders — it lets a caller short-circuit * `validate-page` for pages it already knows are gone. */ export function computeDiskDrift(unchanged: string[], deps: DiskDriftDeps): string[] { const drift: string[] = [] for (const key of unchanged) { if (!deps.exists(key)) { drift.push(key) // missing continue } if (deps.errCount(key) > 0) drift.push(key) // invalid on disk } return drift } /** * Final Phase 3a regeneration set = spec-diff (`added ∪ modified`) ∪ disk-drift. * Disk-drift keys outside the spec-diff are folded in; the result is sorted and * de-duplicated. Phase 3a is skipped **only** when this is empty — i.e. spec * AND disk are both clean. */ export function selectPagesToRegenerate(specToRegenerate: string[], diskDrift: string[]): string[] { return [...new Set([...specToRegenerate, ...diskDrift])].sort() }