import { readFileSync } from 'node:fs'; /** * Framework path-ownership manifest (#791). * * The updater must operate from an explicit framework-owned path manifest and * NEVER write outside it. This module is the TypeScript reader for the shared * SSOT manifest (`packages/mosaic/framework/framework-manifest.txt`) that the * bash installer also consumes. Keeping both paths on one data file is what * closes the two-copies-drift failure class (see #631 → #791). * * Everything here is pure (parse + resolve + plan) so the ownership guarantee * is unit- and property-testable without touching the filesystem. */ export type Ownership = 'framework' | 'operator'; /** * Thrown when the manifest is missing, empty, or malformed. A distinct type lets * callers (e.g. finalizeStage) tell a pre-sync validation abort — where NO files * were touched — apart from a generic mid-sync filesystem failure, and message * the user accurately (#791 blocker-C). */ export class ManifestError extends Error { constructor(message: string) { super(message); this.name = 'ManifestError'; } } export interface FrameworkManifest { /** Globs the updater MAY create/overwrite, and prune only when retired. */ readonly framework: readonly string[]; /** Globs the updater must NEVER write over or prune. Win over `framework`. */ readonly operator: readonly string[]; } type Section = 'framework' | 'operator' | null; /** * Parse the line-oriented manifest text. `#` comments and blank lines are * ignored; `[framework]` / `[operator]` headers switch the active section. * Lines before any header are rejected — the format must be explicit. */ export function parseManifest(text: string): FrameworkManifest { const framework: string[] = []; const operator: string[] = []; let section: Section = null; const lines = text.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const raw = lines[i] ?? ''; const line = raw.trim(); if (line === '' || line.startsWith('#')) continue; if (line === '[framework]') { section = 'framework'; continue; } if (line === '[operator]') { section = 'operator'; continue; } if (line.startsWith('[')) { throw new ManifestError(`Unknown manifest section header on line ${i + 1}: ${line}`); } if (section === null) { throw new ManifestError( `Manifest entry before any [section] header on line ${i + 1}: ${line}`, ); } (section === 'framework' ? framework : operator).push(line); } // Fail CLOSED on an empty or comment-only manifest. A manifest with zero // framework-owned globs would make resolveOwnership() return `operator` for // every path: an upgrade would prune nothing and refresh nothing — a silent // no-op indistinguishable from success. Refuse loudly instead, mirroring the // bash reader's `manifest_load` guard so both halves reject it identically (#791 B2). if (framework.length === 0) { throw new ManifestError( 'Framework manifest defines no [framework] paths — refusing to proceed (empty or malformed manifest).', ); } // Fail CLOSED on framework entries that normalize to nothing usable. A manifest // like `[framework]\n/` or `[framework]\n./` passes the length check above but // every entry normalizes to an empty (or bare-dot) glob that matches no real // path — so the compiled framework matcher is empty and every path resolves // `operator`: the same silent no-op as an empty manifest. Require at least one // entry with a real, non-dot character (the bash reader applies the identical // `[^/.]` test, so both halves reject these inputs together — #791 blocker-B). if (!framework.some(isUsableFrameworkGlob)) { throw new ManifestError( 'Framework manifest defines no usable [framework] paths (every entry is empty or a bare dot segment) — refusing to proceed (malformed manifest).', ); } return { framework, operator }; } /** * A framework glob is usable only if, once normalized, it still contains a * character other than `/` or `.` — i.e. it names a real path segment or a * wildcard. `''`, `/`, `./`, `.`, `..` are all unusable (they compile to a glob * that matches nothing). Kept byte-compatible with the bash `[[ =~ [^/.] ]]` * test so TS and bash accept/reject exactly the same manifests. */ function isUsableFrameworkGlob(glob: string): boolean { return /[^/.]/.test(normalizeRel(glob)); } /** Read and parse the manifest from a framework root directory. */ export function loadManifest(frameworkRoot: string): FrameworkManifest { const file = `${frameworkRoot}/framework-manifest.txt`; let text: string; try { text = readFileSync(file, 'utf-8'); } catch (err) { // A missing/unreadable manifest must fail closed with a clear message, not a // raw ENOENT that a caller might mistake for an empty result set (#791 B2/B3). throw new ManifestError( `Cannot read framework manifest at ${file}: ${(err as Error).message} — refusing to sync (fail-closed).`, ); } return parseManifest(text); } /** * Match a mosaic-home-relative POSIX path against one glob. * * Supported: `**` (any depth, including zero segments) and `*` (any run of * characters within a single segment, not crossing `/`). A glob with no * wildcard matches either the exact path OR any path beneath it (so a bare * directory entry like `memory` covers `memory/notes.md`). */ export function matchGlob(glob: string, relPath: string): boolean { const path = normalizeRel(relPath); const pattern = normalizeRel(glob); if (pattern === '') return false; if (!pattern.includes('*')) { // Exact file, or any descendant of a bare directory prefix. return path === pattern || path.startsWith(`${pattern}/`); } const re = new RegExp(`^${globToRegExpBody(pattern)}$`); return re.test(path); } /** True if the path matches any glob in the list. */ export function matchesAny(globs: readonly string[], relPath: string): boolean { return globs.some((g) => matchGlob(g, relPath)); } /** * Resolve ownership of a mosaic-home-relative path (deny-wins / fail-safe): * operator globs win, then framework globs, else operator by default. */ export function resolveOwnership(manifest: FrameworkManifest, relPath: string): Ownership { if (matchesAny(manifest.operator, relPath)) return 'operator'; if (matchesAny(manifest.framework, relPath)) return 'framework'; return 'operator'; } /** * The set of `[framework]` subtree roots that pruning is allowed to descend * into (glob entries of the form `dir/**`). Single-file framework entries * (e.g. `CONSTITUTION.md`) are reconcile-managed and never pruned. */ export function frameworkSubtreeRoots(manifest: FrameworkManifest): string[] { const roots: string[] = []; for (const g of manifest.framework) { if (g.endsWith('/**')) roots.push(g.slice(0, -3)); } return roots; } export interface PrunePlanInput { readonly manifest: FrameworkManifest; /** Mosaic-home-relative paths currently present in the target. */ readonly targetPaths: readonly string[]; /** Mosaic-home-relative paths the framework currently ships (source). */ readonly sourcePaths: readonly string[]; } /** * Pure prune planner — the testable seam of the #791 fix. * * Returns the delete-set: target paths that are framework-owned, live inside a * shipped framework subtree, and are absent from the current source (retired * framework files). By construction the result never contains an operator-owned * or unknown path: those either resolve to `operator` or fall outside every * framework subtree root, so they are structurally unreachable by pruning. */ export function planPrune(input: PrunePlanInput): string[] { const { manifest, targetPaths, sourcePaths } = input; const source = new Set(sourcePaths.map(normalizeRel)); const roots = frameworkSubtreeRoots(manifest); const deleteSet: string[] = []; for (const raw of targetPaths) { const path = normalizeRel(raw); if (source.has(path)) continue; // still shipped — keep if (resolveOwnership(manifest, path) !== 'framework') continue; // operator/unknown — never prune if (!roots.some((root) => path === root || path.startsWith(`${root}/`))) continue; // outside shipped subtrees deleteSet.push(path); } return deleteSet; } function normalizeRel(p: string): string { return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, ''); } /** Translate a glob body (already normalized) into a RegExp source fragment. */ function globToRegExpBody(pattern: string): string { let out = ''; for (let i = 0; i < pattern.length; i++) { const c = pattern[i]; if (c === undefined) continue; if (c === '*') { if (pattern[i + 1] === '*') { // `**` — any depth. `a/**` must also match the bare root `a`, so when a // literal `/` was just emitted, make it optional along with the rest. i++; let trailingSlash = false; if (pattern[i + 1] === '/') { i++; trailingSlash = true; } if (out.endsWith('/')) { out = `${out.slice(0, -1)}(?:/.*)?`; } else if (trailingSlash) { out += '(?:.*/)?'; } else { out += '.*'; } } else { out += '[^/]*'; } } else { out += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); } } return out; }