Project source-free capability contracts and keep exact source provenance in package assembly. Reuse compiled groups across unrelated graph and dependency implementation edits. Keep React public inputs separate from workspace query lowering and retain explicit type-companion dependencies. Allow independent execution-package checks, scope resource compilation to declared dependencies, and memoize source closure traversal. Document generic-specialization limits and measured Reader rebuild behavior. Verified protocol, artifact, package, React, and managed activation checks; retained-Git independent/workspace artifact equality and incompatible type dependency rejection; nix fmt -- --check and lint. One-off Reader measurements recompiled 1 of 13 groups for a child edit and 0 for a workspace QX default edit.
327 lines
15 KiB
TypeScript
327 lines
15 KiB
TypeScript
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" | "build";
|
||
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|\.yarn\/plugins\/[A-Za-z0-9_-][A-Za-z0-9_.-]*\.cjs|[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<string | null> => {
|
||
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 (path.basename(change.file) === "quixos.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", "build"].includes(request.kind) && request.source) {
|
||
const compiled = await compileCapabilityResourceRepository({
|
||
rootDirectory: resourceRoot,
|
||
kind: request.kind as "package" | "interface" | "build",
|
||
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"));
|
||
// Execution bindings are immutable build outputs owned by checkedArtifacts,
|
||
// not source edits produced by a structural preview.
|
||
const artifacts =
|
||
configuration.backend === "execution"
|
||
? []
|
||
: [
|
||
{
|
||
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 <T>(root: string, work: () => Promise<T>) => {
|
||
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<ReturnType<typeof planStructure>>, 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);
|
||
});
|