/** * cli:compute-page-diff — scan-pagespecs.ts * * Reads every `/pagespecs/*.md`, extracts the fenced ```json * machine block, and computes a canonical content hash per page. Unlike * `scaffold-screen-controller/parse-pagespec.ts` (which filters by section + * validates against PageSpecSchema), this scanner is NON-filtering and * schema-agnostic: a diff only needs the page identity + a stable hash of the * raw spec, so it keeps every JSON-valid pagespec in the module. * * Pure read-only. Malformed files (no fenced block / invalid JSON) produce a * warning and are skipped — a single bad pagespec never aborts the diff. */ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs' import { join, basename } from 'node:path' import { canonicalHash } from '../../../lib/canonical-hash.js' export const SNAPSHOT_FILENAME = '.run-snapshot.json' export interface ScannedPage { /** Page identity = pagespec filename without `.md` (convention `.`). */ key: string /** Canonical sha256 of the parsed fenced JSON. */ hash: string } export interface ScanResult { pages: ScannedPage[] warnings: string[] } export interface RunSnapshot { lastRun?: string /** Flat `key → hash` map. */ pages: Record } /** Extract the FIRST fenced ```json block. Identical regex to parse-pagespec. */ export function extractFencedJson(md: string): string | null { const m = md.match(/```json\s*\r?\n([\s\S]*?)\r?\n```/) return m ? m[1] : null } /** Page identity from the pagespec filename (`TypeAffaire.list.md` → `TypeAffaire.list`). */ export function pageKeyFromFilename(filename: string): string { return basename(filename, '.md') } /** * Scan `/pagespecs/*.md` → `{ key, hash }[]` sorted by key. * Returns an empty result (no warning) when the `pagespecs/` dir is absent. */ export function scanPagespecs(moduleRoot: string): ScanResult { const dir = join(moduleRoot, 'pagespecs') const out: ScanResult = { pages: [], warnings: [] } if (!existsSync(dir)) return out let entries: string[] try { entries = readdirSync(dir) } catch (err) { out.warnings.push(`cannot read ${dir}: ${(err as Error).message}`) return out } for (const name of entries) { if (!name.endsWith('.md')) continue const full = join(dir, name) try { if (!statSync(full).isFile()) continue } catch { continue } let content: string try { content = readFileSync(full, 'utf8') } catch (err) { out.warnings.push(`${name}: cannot read file: ${(err as Error).message}`) continue } const json = extractFencedJson(content) if (json === null) { out.warnings.push(`${name}: no fenced \`\`\`json block found — skipped`) continue } let parsed: unknown try { parsed = JSON.parse(json) } catch (err) { out.warnings.push(`${name}: invalid JSON: ${(err as Error).message} — skipped`) continue } out.pages.push({ key: pageKeyFromFilename(name), hash: canonicalHash(parsed) }) } out.pages.sort((a, b) => a.key.localeCompare(b.key)) return out } /** * Load `/.run-snapshot.json`. Returns an empty snapshot (and * `found: false`) when the file is absent OR malformed — both cases mean * "treat every current page as new", which the diff renders as `added`. */ export function loadSnapshot(moduleRoot: string): { snapshot: RunSnapshot; found: boolean } { const path = join(moduleRoot, SNAPSHOT_FILENAME) if (!existsSync(path)) return { snapshot: { pages: {} }, found: false } try { const raw = JSON.parse(readFileSync(path, 'utf8')) as Partial const pages = raw.pages && typeof raw.pages === 'object' ? raw.pages : {} return { snapshot: { pages, lastRun: raw.lastRun }, found: true } } catch { return { snapshot: { pages: {} }, found: false } } }