import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import { randomUUID } from "node:crypto"; import { contentDigest } from "../capability-model/evolution.js"; import { editStructure, type StructuralEdit } from "./structural-edits.js"; import { snapshotRepository, localResourceSnapshots } from "./candidate-check.js"; import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js"; import { createGitCapabilityResolver } from "./git-resolver.js"; import { bindingSchema, generateTypeScriptBindings } from "../bindings/index.js"; import { parseQx } from "./source.js"; import { parseQuixosLockDocument } from "../resource-lock/index.js"; import { withFileLock } from "./file-lock.js"; export type StructuralRequest = { kind: "workspace" | "interface" | "package"; source?: { repository: string; commit: string }; resourceRoot?: string; validation?: "syntax" | "resource-graph"; files: ( | { file: string; edits: StructuralEdit[] } | { file: string; create: string } | { file: string; generated: string } | { file: string; expected: string; replace: string } )[]; }; type Change = { file: string; before: string | null; after: string; mode: number }; type Journal = { schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[] }; const safeFile = (file: string) => { if ( !/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|graphql|lock|ts|tsx|css|mjs|json|nix|txtpb|md))$/.test( file, ) || file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part)) ) throw new Error(`Unsafe scaffold path ${file}`); }; const read = async (root: string, file: string): Promise => { safeFile(file); const target = path.join(root, file); try { const metadata = await fs.lstat(target); if (!metadata.isFile() || metadata.isSymbolicLink() || !(await fs.realpath(target)).startsWith(`${root}/`)) throw new Error(`Scaffold target is not a contained regular file: ${file}`); return await fs.readFile(target, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } }; const containedParent = async (root: string, file: string) => { let current = root; for (const part of file.split("/").slice(0, -1)) { current = path.join(current, part); await fs.mkdir(current).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; }); const metadata = await fs.lstat(current); if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("Scaffold parent must be a real directory"); } }; const durableJson = async (file: string, value: unknown) => { const temporary = `${file}.${randomUUID()}.tmp`; const handle = await fs.open(temporary, "wx", 0o600); try { await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`); await handle.sync(); } finally { await handle.close(); } await fs.rename(temporary, file); const directory = await fs.open(path.dirname(file), "r"); try { await directory.sync(); } finally { await directory.close(); } }; /** Validate the entire edited resource graph in a private snapshot before writes. */ export const planStructure = async (rootPath: string, request: StructuralRequest, snapshotMap?: string) => { if (request.validation && !["syntax", "resource-graph"].includes(request.validation)) throw new Error("Unknown structural validation mode"); const root = await fs.realpath(rootPath); const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-structure-")); try { const snapshot = await snapshotRepository(root, path.join(temporary, "source")); const observed = await Promise.all( snapshot.files.map(async ({ name }) => ({ file: name, digest: contentDigest(await fs.readFile(path.join(snapshot.directory, name), "utf8")), })), ); const changes: Change[] = []; if (!Array.isArray(request.files) || !request.files.length || request.files.length > 100) throw new Error("Structural plan requires 1–100 files"); for (const input of request.files) { safeFile(input.file); if (changes.some((entry) => entry.file === input.file)) throw new Error("Repeated structural file target"); const before = await read(root, input.file); let after: string; if ("create" in input) { if (before !== null || typeof input.create !== "string") throw new Error("Scaffold creation cannot replace an existing file"); after = input.create; } else if ("replace" in input) { if (before !== input.expected || typeof input.replace !== "string") throw new Error(`Stale imperative edit: ${input.file}`); after = input.replace; } else if ("generated" in input) { const generated = (text: string) => text.startsWith("// Generated by qx-scaffold-v1\n") || text.startsWith("# Generated by qx-scaffold-v1\n") || (() => { try { return JSON.parse(text).generatedBy === "qx-scaffold-v1"; } catch { return false; } })(); if ( typeof input.generated !== "string" || !generated(input.generated) || (before !== null && !generated(before)) ) throw new Error("Only scaffold-owned generated files may be regenerated"); after = input.generated; } else { if (before === null || !Array.isArray(input.edits)) throw new Error("Structural edit requires an existing source"); after = input.edits.reduce(editStructure, before); } if (Buffer.byteLength(after) > 1024 * 1024) throw new Error("Scaffold file exceeds 1 MiB"); const mode = before === null ? 0o644 : (await fs.stat(path.join(root, input.file))).mode & 0o777; changes.push({ file: input.file, before, after, mode }); await containedParent(snapshot.directory, input.file); await fs.writeFile(path.join(snapshot.directory, input.file), after); } if (request.validation === "syntax") { for (const change of changes) { if (change.file.endsWith(".qx") && parseQx(change.after, change.file).diagnostics.length) throw new Error(`Invalid QX syntax in ${change.file}`); if (change.file.endsWith(".lock") && !parseQuixosLockDocument(change.after, change.file).ok) throw new Error(`Invalid lock syntax in ${change.file}`); } } else { const localMap = path.join(temporary, "local-resources.json"); await fs.writeFile(localMap, JSON.stringify(await localResourceSnapshots(root, snapshotMap))); const resolveResource = await createGitCapabilityResolver({ checkoutRoot: path.join(temporary, "resources"), snapshotMap: localMap, }); if ( request.resourceRoot && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(request.resourceRoot) ) throw new Error("Resource root must be a contained relative directory"); const resourceRoot = path.join(snapshot.directory, request.resourceRoot ?? ""); if (request.kind === "workspace") await compileWorkspaceRepository({ rootDirectory: resourceRoot, resolveResource }); else if (["package", "interface"].includes(request.kind) && request.source) { const compiled = await compileCapabilityResourceRepository({ rootDirectory: resourceRoot, kind: request.kind as "package" | "interface", source: { resolver: "git", ...request.source }, resolveResource, }); if (compiled.resource.kind === "package") { const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8")); const artifacts = [ { file: configuration.bindingOutput as string, after: generateTypeScriptBindings( bindingSchema(compiled), compiled.resource.revision.revisionId, configuration.options, ), }, ]; for (const artifact of artifacts) { const file = request.resourceRoot ? `${request.resourceRoot}/${artifact.file}` : artifact.file; safeFile(file); if (Buffer.byteLength(artifact.after) > 1024 * 1024) throw new Error("Generated scaffold file exceeds 1 MiB"); const before = await read(root, file); if ( before !== null && !before.startsWith("// Generated by quixos-codegen-ts.") && (() => { try { return JSON.parse(before).generatedBy !== "qx-scaffold-v1"; } catch { return true; } })() ) throw new Error(`Refusing to overwrite hand-authored generated artifact ${file}`); const previous = changes.find((entry) => entry.file === file); if (previous) previous.after = artifact.after; else changes.push({ file, before, after: artifact.after, mode: 0o644 }); } } } else throw new Error("Resource plans require kind and exact authored source identity"); } if (changes.length > 100) throw new Error("Structural plan including generated artifacts exceeds 100 files"); // Validation may fetch dependencies; reject edits made while it was running. for (const entry of changes) if ((await read(root, entry.file)) !== entry.before) throw new Error(`Source changed while planning: ${entry.file}`); for (const entry of observed) if (contentDigest(await fs.readFile(path.join(root, entry.file), "utf8")) !== entry.digest) throw new Error(`Validation input changed while planning: ${entry.file}`); return { root, changes, observed, digest: contentDigest(changes), validation: request.validation ?? ("resource-graph" as const), }; } finally { await fs.rm(temporary, { recursive: true, force: true }); } }; /** Replay only exact before/after states. A crash never loses the original text. */ const replayStructure = async (rootPath: string, id: string) => { const root = await fs.realpath(rootPath); if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid scaffold journal ID"); const journalPath = path.join(root, ".quixos", "scaffolds", `${id}.json`); const journal = JSON.parse(await fs.readFile(journalPath, "utf8")) as Journal; if (journal.schemaVersion !== 1 || journal.root !== root || journal.id !== id) throw new Error("Scaffold journal identity mismatch"); for (const entry of journal.changes) { const current = await read(root, entry.file); if (current !== entry.before && current !== entry.after) throw new Error( `Scaffold conflicts with newer edits: ${entry.file}; original text is retained in ${journalPath}`, ); } if (journal.phase === "complete") return { id, journalPath, phase: journal.phase }; for (const entry of journal.changes) { if ((await read(root, entry.file)) === entry.after) continue; await containedParent(root, entry.file); const target = path.join(root, entry.file); const temporary = `${target}.qx-${randomUUID()}.tmp`; const handle = await fs.open(temporary, "wx", entry.mode); try { await handle.writeFile(entry.after); await handle.sync(); } finally { await handle.close(); } if (entry.before === null) { // link is atomic and fails if another author created the destination. await fs.link(temporary, target); await fs.unlink(temporary); } else { if ((await read(root, entry.file)) !== entry.before) throw new Error(`Source changed during scaffold: ${entry.file}`); await fs.rename(temporary, target); } const directory = await fs.open(path.dirname(target), "r"); try { await directory.sync(); } finally { await directory.close(); } } journal.phase = "complete"; await durableJson(journalPath, journal); return { id, journalPath, phase: journal.phase }; }; const withStructureLock = async (root: string, work: () => Promise) => { await containedParent(root, ".quixos/scaffolds/placeholder.json"); const lock = path.join(root, ".quixos", "scaffolds", "writer.lock"); return withFileLock(lock, work); }; export const resumeStructure = async (rootPath: string, id: string) => { const root = await fs.realpath(rootPath); return withStructureLock(root, () => replayStructure(root, id)); }; export const applyStructure = async (plan: Awaited>, id: string = randomUUID()) => withStructureLock(plan.root, async () => { if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid scaffold journal ID"); const directory = path.join(plan.root, ".quixos", "scaffolds"); try { const existing = JSON.parse(await fs.readFile(path.join(directory, `${id}.json`), "utf8")) as Journal; if (existing.root !== plan.root || contentDigest(existing.changes) !== plan.digest) throw new Error("Scaffold journal identity conflict"); for (const entry of plan.observed) if ( !plan.changes.some((change) => change.file === entry.file) && contentDigest(await fs.readFile(path.join(plan.root, entry.file), "utf8")) !== entry.digest ) throw new Error(`Stale scaffold validation input: ${entry.file}`); return replayStructure(plan.root, id); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } // An unfinished journal must be recovered before another structural mutation. for (const file of await fs.readdir(directory)) if (file.endsWith(".json")) { const prior = JSON.parse(await fs.readFile(path.join(directory, file), "utf8")) as Journal; if (prior.phase !== "complete") throw new Error(`Unfinished scaffold ${prior.id}; resume it first`); } for (const entry of plan.changes) if ((await read(plan.root, entry.file)) !== entry.before) throw new Error(`Stale scaffold plan: ${entry.file}`); for (const entry of plan.observed) if (contentDigest(await fs.readFile(path.join(plan.root, entry.file), "utf8")) !== entry.digest) throw new Error(`Stale scaffold validation input: ${entry.file}`); await durableJson(path.join(directory, `${id}.json`), { schemaVersion: 1, id, root: plan.root, phase: "prepared", changes: plan.changes, } satisfies Journal); return replayStructure(plan.root, id); });