/** * lib/generated-writes.ts — the ONE guarded write loop of the scaffolders. * * Two invariants every `writeFileSync(file.content)` loop must honour: * 1. A file whose head carries the `@customised` marker is NEVER overwritten * — it is the bespoke seam (architecture C), the developer owns it. Before * 2026-09-24 only the frontend page/layout/primitive writers honoured it: * scaffold-api-client and scaffold-extension-config overwrote files their * own SKILL told the developer to hand-edit, and no backend CLI checked at * all (audit B-CRIT-04, A-WARN-10). * 2. Every skip is RETURNED so the caller reports it (`data.skipped[]` + one * warning per file): a silent skip is indistinguishable from a write. * * Paths resolve through `safeJoinPath(outdir, rel)` (traversal-safe). The * write itself is unconditional otherwise (no content comparison): `filesCreated` * and mtimes keep their historical meaning. `dryRun` only classifies. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import { safeJoinPath } from './fs.js' import { isCustomised } from './guarded-rm.js' export interface GeneratedFileLike { /** Path relative to `outdir` (as the generators emit it). */ path: string content: string } export interface WriteGeneratedResult { /** Absolute paths written (or that would be, under dryRun), in input order. */ written: string[] /** Absolute paths kept because their head carries `@customised`. */ skipped: string[] } export function writeGeneratedFiles( files: readonly GeneratedFileLike[], opts: { outdir: string; dryRun?: boolean }, ): WriteGeneratedResult { const written: string[] = [] const skipped: string[] = [] for (const file of files) { const abs = safeJoinPath(opts.outdir, file.path) if (existsSync(abs)) { let existing: string | null = null try { existing = readFileSync(abs, 'utf-8') } catch { existing = null // unreadable (a directory?) — let the write surface the real error } if (existing !== null && isCustomised(existing)) { skipped.push(abs) continue } } if (!opts.dryRun) { mkdirSync(dirname(abs), { recursive: true }) writeFileSync(abs, file.content, 'utf-8') } written.push(abs) } return { written, skipped } } /** One envelope warning per skipped file — the caller spreads it into `warnings[]`. */ export function customisedWarnings(result: WriteGeneratedResult): string[] { return result.skipped.map( (p) => `${p} kept: marked @customised — not overwritten by this run (remove the marker to regenerate it).`, ) }