Format authored monorepo code with pinned language formatters

This commit is contained in:
Timothy J. Aveni
2026-09-15 15:41:24 -07:00
parent a174faea5c
commit 00fc2b9ff1
69 changed files with 5135 additions and 3306 deletions
+101 -91
View File
@@ -64,11 +64,9 @@ export type CompiledCapabilityResourceRepository = {
const sourceKey = (kind: LockedResource["kind"], source: GitSource) =>
`${kind}\0${source.repository}\0${source.commit.toLowerCase()}`;
const bindingKey = (kind: LockedResource["kind"], binding: string) =>
`${kind}\0${binding}`;
const bindingKey = (kind: LockedResource["kind"], binding: string) => `${kind}\0${binding}`;
const importsKey = (entry: CapabilityResourceImport | LockedResource) =>
bindingKey(entry.kind, entry.binding);
const importsKey = (entry: CapabilityResourceImport | LockedResource) => bindingKey(entry.kind, entry.binding);
const assertImportsMatchLock = (
label: string,
@@ -77,22 +75,23 @@ const assertImportsMatchLock = (
) => {
const authored = [...imports].map(importsKey).sort();
const locked = [...resources].map(importsKey).sort();
if (
authored.length !== locked.length ||
authored.some((entry, index) => entry !== locked[index])
) {
if (authored.length !== locked.length || authored.some((entry, index) => entry !== locked[index])) {
throw new Error(
`${label} imports do not match quixos.lock:\n` +
`authored: ${authored.join(", ") || "none"}\n` +
`locked: ${locked.join(", ") || "none"}`,
`authored: ${authored.join(", ") || "none"}\n` +
`locked: ${locked.join(", ") || "none"}`,
);
}
};
const exactRevisions = <Revision extends {
revisionId: string;
source: SourceRevision;
}>(revisions: readonly Revision[]) => {
const exactRevisions = <
Revision extends {
revisionId: string;
source: SourceRevision;
},
>(
revisions: readonly Revision[],
) => {
const seen = new Set<string>();
return revisions.filter((revision) => {
const key = `${revision.revisionId}\0${revision.source.repository}\0${revision.source.commit}`;
@@ -122,19 +121,20 @@ const diagnosticsMessage = (
message: string;
path?: string;
}[],
) => `${label} did not compile:\n${diagnostics.map((entry) => {
const location = entry.line > 0
? `${entry.fileName}:${entry.line}:${entry.column + 1}`
: `${entry.fileName}${entry.path ? `:${entry.path}` : ""}`;
return `${location}: ${entry.phase} ${entry.code}: ${entry.message}`;
}).join("\n")}`;
) =>
`${label} did not compile:\n${diagnostics
.map((entry) => {
const location =
entry.line > 0
? `${entry.fileName}:${entry.line}:${entry.column + 1}`
: `${entry.fileName}${entry.path ? `:${entry.path}` : ""}`;
return `${location}: ${entry.phase} ${entry.code}: ${entry.message}`;
})
.join("\n")}`;
const revisionFor = (node: ResolvedCapabilityResource) =>
node.resource.revision;
const revisionFor = (node: ResolvedCapabilityResource) => node.resource.revision;
const resourceClosure = (
roots: readonly ResolvedCapabilityResource[],
): ResolvedCapabilityResource[] => {
const resourceClosure = (roots: readonly ResolvedCapabilityResource[]): ResolvedCapabilityResource[] => {
const result: ResolvedCapabilityResource[] = [];
const seen = new Set<string>();
const visit = (node: ResolvedCapabilityResource) => {
@@ -151,25 +151,26 @@ const environmentFor = (
direct: readonly [LockedResource, ResolvedCapabilityResource][],
closure: readonly ResolvedCapabilityResource[],
): CapabilityImportEnvironment => ({
interfaces: new Map(direct.flatMap(([locked, node]) =>
node.resource.kind === "interface"
? [[locked.binding, node.resource.revision] as const]
: [])),
packages: new Map(direct.flatMap(([locked, node]) =>
node.resource.kind === "package"
? [[locked.binding, node.resource.revision] as const]
: [])),
interfaceClosure: exactRevisions(closure.flatMap((node) =>
node.resource.kind === "interface" ? [node.resource.revision] : [])),
packageClosure: exactRevisions(closure.flatMap((node) =>
node.resource.kind === "package" ? [node.resource.revision] : [])),
interfaces: new Map(
direct.flatMap(([locked, node]) =>
node.resource.kind === "interface" ? [[locked.binding, node.resource.revision] as const] : [],
),
),
packages: new Map(
direct.flatMap(([locked, node]) =>
node.resource.kind === "package" ? [[locked.binding, node.resource.revision] as const] : [],
),
),
interfaceClosure: exactRevisions(
closure.flatMap((node) => (node.resource.kind === "interface" ? [node.resource.revision] : [])),
),
packageClosure: exactRevisions(
closure.flatMap((node) => (node.resource.kind === "package" ? [node.resource.revision] : [])),
),
externalAtoms: exactAtoms(closure.flatMap((node) => node.resource.externalAtoms)),
});
const createResourceGraphResolver = (
quixosCommit: string,
resolveResource: CapabilityRepositoryResolver,
) => {
const createResourceGraphResolver = (quixosCommit: string, resolveResource: CapabilityRepositoryResolver) => {
const resolved = new Map<string, Promise<ResolvedCapabilityResource>>();
const active: string[] = [];
@@ -188,7 +189,7 @@ const createResourceGraphResolver = (
const pending = (async () => {
active.push(key);
try {
const snapshot = suppliedSnapshot ?? await resolveResource(locked.source, locked.kind);
const snapshot = suppliedSnapshot ?? (await resolveResource(locked.source, locked.kind));
const lockResult = await loadQuixosLock(path.join(snapshot.directory, "quixos.lock"));
if (!lockResult.ok) {
throw new Error(diagnosticsMessage(`${locked.kind} ${locked.binding} lock`, lockResult.diagnostics));
@@ -196,22 +197,20 @@ const createResourceGraphResolver = (
if (lockResult.lock.quixos.policy) {
throw new Error(
`${locked.kind} ${locked.binding} lock declares workspace Quixos policy ` +
`${lockResult.lock.quixos.policy}; resource locks may only declare their exact authored-against commit`,
`${lockResult.lock.quixos.policy}; resource locks may only declare their exact authored-against commit`,
);
}
if (lockResult.lock.quixos.commit.toLowerCase() !== quixosCommit.toLowerCase()) {
throw new Error(
`${locked.kind} ${locked.binding} selects Quixos ${lockResult.lock.quixos.commit}, ` +
`but the repository graph selects ${quixosCommit}`,
`but the repository graph selects ${quixosCommit}`,
);
}
const directPairs: Array<[LockedResource, ResolvedCapabilityResource]> = [];
for (const dependency of lockResult.lock.resources) {
directPairs.push([dependency, await visit(dependency)]);
}
const dependencyClosure = resourceClosure(
directPairs.map(([, node]) => node),
);
const dependencyClosure = resourceClosure(directPairs.map(([, node]) => node));
const environment = environmentFor(directPairs, dependencyClosure);
const manifestName = locked.kind === "interface" ? "interface.qx" : "package.qx";
const manifestPath = path.join(snapshot.directory, manifestName);
@@ -225,22 +224,28 @@ const createResourceGraphResolver = (
throw new Error(diagnosticsMessage(`${locked.kind} ${locked.binding}`, compiled.diagnostics));
}
if (compiled.resource.kind !== locked.kind) {
throw new Error(
`${manifestPath} declares ${compiled.resource.kind}, not ${locked.kind}`,
);
throw new Error(`${manifestPath} declares ${compiled.resource.kind}, not ${locked.kind}`);
}
assertImportsMatchLock(manifestPath, compiled.resource.imports, lockResult.lock.resources);
if (compiled.resource.kind === "package") {
let catalogText: string | undefined;
try { catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8"); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
try {
catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if (catalogText !== undefined) {
const catalog = validateMigrationCatalog(JSON.parse(catalogText), new Set(compiled.resource.revision.exports.map((entry) => entry.id)));
const catalog = validateMigrationCatalog(
JSON.parse(catalogText),
new Set(compiled.resource.revision.exports.map((entry) => entry.id)),
);
const root = await realpath(snapshot.directory);
for (const migration of catalog.migrations) {
const implementation = await realpath(path.join(root, migration.implementation.file));
if (!implementation.startsWith(`${root}${path.sep}`)) throw new Error("Migration implementation escapes its package");
if (contentDigest(await readFile(implementation, "utf8")) !== migration.implementation.digest) throw new Error(`Migration implementation digest mismatch: ${migration.id}`);
if (!implementation.startsWith(`${root}${path.sep}`))
throw new Error("Migration implementation escapes its package");
if (contentDigest(await readFile(implementation, "utf8")) !== migration.implementation.digest)
throw new Error(`Migration implementation digest mismatch: ${migration.id}`);
}
compiled.resource.revision.migrationCatalog = catalog;
}
@@ -252,10 +257,9 @@ const createResourceGraphResolver = (
directory: snapshot.directory,
lock: lockResult.lock,
resource: compiled.resource,
dependencies: new Map(directPairs.map(([dependency, node]) => [
bindingKey(dependency.kind, dependency.binding),
node,
])),
dependencies: new Map(
directPairs.map(([dependency, node]) => [bindingKey(dependency.kind, dependency.binding), node]),
),
};
} finally {
active.pop();
@@ -281,15 +285,15 @@ export const compileCapabilityResourceRepository = async (options: {
if (!lockResult.ok) {
throw new Error(diagnosticsMessage("Resource lock", lockResult.diagnostics));
}
const resolver = createResourceGraphResolver(
lockResult.lock.quixos.commit,
options.resolveResource,
const resolver = createResourceGraphResolver(lockResult.lock.quixos.commit, options.resolveResource);
const root = await resolver.visit(
{
kind: options.kind,
binding: "<root>",
source: options.source,
},
{ directory: options.rootDirectory },
);
const root = await resolver.visit({
kind: options.kind,
binding: "<root>",
source: options.source,
}, { directory: options.rootDirectory });
return {
resource: root.resource,
lock: root.lock,
@@ -307,9 +311,7 @@ export const compileWorkspaceRepository = async (options: {
/** An editor's proposed source snapshot; locks and dependency revisions remain exact. */
readSource?: (name: string) => Promise<string>;
}): Promise<CompiledWorkspaceRepository> => {
const rootLockResult = await loadQuixosLock(
path.join(options.rootDirectory, "quixos.lock"),
);
const rootLockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock"));
if (!rootLockResult.ok) {
throw new Error(diagnosticsMessage("Workspace lock", rootLockResult.diagnostics));
}
@@ -323,17 +325,25 @@ export const compileWorkspaceRepository = async (options: {
const nodes = await resolver.nodes();
const environment = environmentFor(directPairs, nodes);
const workspacePath = path.join(options.rootDirectory, "workspace.qx");
const sources = options.readSource ? await resolveQxSources(options.readSource) : await loadQxSources(options.rootDirectory);
const sources = options.readSource
? await resolveQxSources(options.readSource)
: await loadQxSources(options.rootDirectory);
const compiled = compileCapabilitySource(sources.source, workspacePath, environment);
if (!compiled.ok) {
throw new Error(diagnosticsMessage("Workspace", compiled.diagnostics.map((diagnostic) =>
diagnostic.line > 0 ? { ...diagnostic, ...sources.originalPosition(diagnostic.line, diagnostic.column) } : diagnostic)));
throw new Error(
diagnosticsMessage(
"Workspace",
compiled.diagnostics.map((diagnostic) =>
diagnostic.line > 0
? { ...diagnostic, ...sources.originalPosition(diagnostic.line, diagnostic.column) }
: diagnostic,
),
),
);
}
assertImportsMatchLock(workspacePath, compiled.imports, rootLock.resources);
const availableInterfaceIds = new Set(
nodes.flatMap((node) => node.resource.kind === "interface"
? [node.resource.revision.revisionId]
: []),
nodes.flatMap((node) => (node.resource.kind === "interface" ? [node.resource.revision.revisionId] : [])),
);
const availableAtomIds = new Set(compiled.workspace.atoms.map((atom) => atom.id));
for (const node of nodes) {
@@ -341,7 +351,7 @@ export const compileWorkspaceRepository = async (options: {
if (!availableInterfaceIds.has(requirement.revisionId)) {
throw new Error(
`${node.kind} ${node.resource.revision.displayName} requires external interface ` +
`${requirement.binding} (${requirement.revisionId}), but the workspace resource graph does not provide it`,
`${requirement.binding} (${requirement.revisionId}), but the workspace resource graph does not provide it`,
);
}
}
@@ -349,28 +359,31 @@ export const compileWorkspaceRepository = async (options: {
if (!availableAtomIds.has(atom.id)) {
throw new Error(
`${node.kind} ${node.resource.revision.displayName} requires external atom ` +
`${atom.displayName} (${atom.id}), but the workspace does not define it`,
`${atom.displayName} (${atom.id}), but the workspace does not define it`,
);
}
}
}
const workspace: WorkspaceRevision = {
...compiled.workspace,
...(options.workspaceId
? { workspaceId: capabilityId.workspace(options.workspaceId) }
: {}),
...(options.workspaceId ? { workspaceId: capabilityId.workspace(options.workspaceId) } : {}),
...(options.workspaceRevisionId
? { id: capabilityId.workspaceRevision(options.workspaceRevisionId) }
: options.sourceRootCommit ? { id: capabilityId.workspaceRevision(`workspace-revision:${options.workspaceId ?? compiled.workspace.workspaceId}:${options.sourceRootCommit}`) } : {}),
...(options.sourceRootCommit
? { sourceRootCommit: options.sourceRootCommit }
: {}),
: options.sourceRootCommit
? {
id: capabilityId.workspaceRevision(
`workspace-revision:${options.workspaceId ?? compiled.workspace.workspaceId}:${options.sourceRootCommit}`,
),
}
: {}),
...(options.sourceRootCommit ? { sourceRootCommit: options.sourceRootCommit } : {}),
};
const checked = compileWorkspaceRevision(workspace);
if (!checked.ok) {
throw new Error(
`Instantiated workspace did not compile:\n${checked.issues.map((entry) =>
`${entry.path}: ${entry.message}`).join("\n")}`,
`Instantiated workspace did not compile:\n${checked.issues
.map((entry) => `${entry.path}: ${entry.message}`)
.join("\n")}`,
);
}
return {
@@ -378,9 +391,6 @@ export const compileWorkspaceRepository = async (options: {
plan: checked.plan,
lock: rootLock,
resources: nodes,
directResources: new Map(directPairs.map(([locked, node]) => [
bindingKey(locked.kind, locked.binding),
node,
])),
directResources: new Map(directPairs.map(([locked, node]) => [bindingKey(locked.kind, locked.binding), node])),
};
};
+89 -27
View File
@@ -1,5 +1,5 @@
import fs from "node:fs/promises";
import {appendFileSync} from "node:fs";
import { appendFileSync } from "node:fs";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { authoringContext } from "./authoring-context.js";
@@ -10,21 +10,45 @@ import { buildImmutableCandidate, checkerIdentity } from "./checked-build.js";
import { planEvolution, type WorkspaceRevision, type EvolutionReview } from "../capability-model/index.js";
export const checkRecordName = (directory: string) => createHash("sha256").update(directory).digest("hex") + ".json";
export async function checkAuthoring(start: string, output: string, options: { baseline?: string; reviews?: string; contractOnly?: boolean } = {}) {
export async function checkAuthoring(
start: string,
output: string,
options: { baseline?: string; reviews?: string; contractOnly?: boolean } = {},
) {
const started = performance.now();
const timings: Record<string, number> = {};
const context = await authoringContext(start);
const location = await fs.realpath(start);
const directory = location === context.workbench ? "root" : path.relative(context.workbench, location);
const resource = context.resources.find(entry => entry.directory === directory);
const resource = context.resources.find((entry) => entry.directory === directory);
if (!resource) throw new Error("Run check from a registered repository root or the workbench");
await fs.mkdir(output, { mode: 0o700 });
const report: { directory: string; checker: string; candidateOnly: true; activationEvidence: false; commit?: string; artifactPath?: string; blockers: string[]; phase: string; output: string; compilation?: "passed"; activationReadiness?: "preserve" | "migration-required" | "blocked"; migrationRequired?: string[] } = {
directory, checker: checkerIdentity(), candidateOnly: true, activationEvidence: false, blockers: [], phase: "convergence", output,
const report: {
directory: string;
checker: string;
candidateOnly: true;
activationEvidence: false;
commit?: string;
artifactPath?: string;
blockers: string[];
phase: string;
output: string;
compilation?: "passed";
activationReadiness?: "preserve" | "migration-required" | "blocked";
migrationRequired?: string[];
} = {
directory,
checker: checkerIdentity(),
candidateOnly: true,
activationEvidence: false,
blockers: [],
phase: "convergence",
output,
};
const progress = async (running = true) => {
const file = path.join(output, "report.json"), temp = `${file}.tmp`;
await fs.writeFile(temp, JSON.stringify({...report, timings, running}, null, 2));
const file = path.join(output, "report.json"),
temp = `${file}.tmp`;
await fs.writeFile(temp, JSON.stringify({ ...report, timings, running }, null, 2));
await fs.rename(temp, file);
};
await progress();
@@ -33,31 +57,43 @@ export async function checkAuthoring(start: string, output: string, options: { b
// Serialize only source capture, not the potentially slow Nix build.
// Repository-scoped agents can check separate immutable candidates in parallel.
const capture = promisify(callback)("quixos-qx", ["converge", context.workbench, directory], {
maxBuffer: 4 * 1024 * 1024, env: {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_TRACE_CAPTURE: "1"},
maxBuffer: 4 * 1024 * 1024,
env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_TRACE_CAPTURE: "1" },
});
console.error(`Capture details: ${path.join(output, "capture.log")}`);
capture.child.stderr?.on("data", chunk => appendFileSync(path.join(output, "capture.log"), chunk));
const captured = await capture.catch(error => {
if (typeof error.stdout === "string" && error.stdout.trim().startsWith("{")) return {stdout: error.stdout};
capture.child.stderr?.on("data", (chunk) => appendFileSync(path.join(output, "capture.log"), chunk));
const captured = await capture.catch((error) => {
if (typeof error.stdout === "string" && error.stdout.trim().startsWith("{")) return { stdout: error.stdout };
throw error;
});
const converged = JSON.parse(captured.stdout) as Awaited<ReturnType<typeof convergeAuthoring>>;
timings.captureMs = Math.round(performance.now() - started);
console.error(`[${new Date().toISOString()}] Check: source captured in ${(timings.captureMs / 1000).toFixed(1)}s`);
if (!converged.candidate) {
report.phase = converged.worklist.find(entry => entry.phase !== "dependency")?.phase ?? "convergence";
throw new Error(converged.worklist.map(entry => `${entry.directory} [${entry.phase}]: ${entry.message}`).join("\n"));
report.phase = converged.worklist.find((entry) => entry.phase !== "dependency")?.phase ?? "convergence";
throw new Error(
converged.worklist.map((entry) => `${entry.directory} [${entry.phase}]: ${entry.message}`).join("\n"),
);
}
report.commit = converged.candidate.commit;
report.phase = "verification";
await progress();
const buildStarted = performance.now();
console.error(`[${new Date().toISOString()}] Check: immutable Nix ${options.contractOnly ? "contract" : "verification"}; build output: ${path.join(output, "nix.log")}`);
console.error(
`[${new Date().toISOString()}] Check: immutable Nix ${options.contractOnly ? "contract" : "verification"}; build output: ${path.join(output, "nix.log")}`,
);
try {
report.artifactPath = await buildImmutableCandidate(converged.candidate, resource.kind, path.join(output, "nix.log"), options.contractOnly);
report.artifactPath = await buildImmutableCandidate(
converged.candidate,
resource.kind,
path.join(output, "nix.log"),
options.contractOnly,
);
} finally {
timings.immutableCheckMs = Math.round(performance.now() - buildStarted);
console.error(`[${new Date().toISOString()}] Check: immutable phase ended after ${(timings.immutableCheckMs / 1000).toFixed(1)}s`);
console.error(
`[${new Date().toISOString()}] Check: immutable phase ended after ${(timings.immutableCheckMs / 1000).toFixed(1)}s`,
);
}
const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8");
await fs.writeFile(path.join(output, "candidate.json"), candidateText);
@@ -69,36 +105,62 @@ export async function checkAuthoring(start: string, output: string, options: { b
if (!baseline) {
try {
const host = JSON.parse(await fs.readFile("/etc/quixos/workspace-source.json", "utf8"));
if (await fs.realpath(host.workbenchRoot) === context.workbench) baseline = JSON.parse(await fs.readFile(path.join(host.runtimeClosureRoot, "manifest.json"), "utf8")).workspacePlanPath;
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
if ((await fs.realpath(host.workbenchRoot)) === context.workbench)
baseline = JSON.parse(
await fs.readFile(path.join(host.runtimeClosureRoot, "manifest.json"), "utf8"),
).workspacePlanPath;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
const before = baseline ? JSON.parse(await fs.readFile(baseline, "utf8")) as WorkspaceRevision : null;
const reviews = options.reviews ? JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[] : [];
const before = baseline ? (JSON.parse(await fs.readFile(baseline, "utf8")) as WorkspaceRevision) : null;
const reviews = options.reviews
? (JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[])
: [];
const evolution = planEvolution(before, JSON.parse(candidateText), { reviews });
await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2));
report.blockers.push(...evolution.blockers);
report.migrationRequired = evolution.migrationRequired;
report.activationReadiness = evolution.blockers.length ? "blocked" : evolution.migrationRequired.length ? "migration-required" : "preserve";
if (evolution.migrationRequired.length) report.blockers.push(`Explicit migration required for: ${evolution.migrationRequired.join(", ")}. Compilation passed; supply a migration path before cutover.`);
report.activationReadiness = evolution.blockers.length
? "blocked"
: evolution.migrationRequired.length
? "migration-required"
: "preserve";
if (evolution.migrationRequired.length)
report.blockers.push(
`Explicit migration required for: ${evolution.migrationRequired.join(", ")}. Compilation passed; supply a migration path before cutover.`,
);
}
if (!report.blockers.length) report.phase = options.contractOnly ? "contract-only" : "checked";
} catch (error) { report.blockers.push(String(error instanceof Error ? error.message : error)); }
} catch (error) {
report.blockers.push(String(error instanceof Error ? error.message : error));
}
timings.totalMs = Math.round(performance.now() - started);
Object.assign(report, {timings});
Object.assign(report, { timings });
await progress(false);
if (options.contractOnly) return report;
const records = path.join(context.workbench, ".quixos/checks");
await fs.mkdir(records, { recursive: true });
const remember = async (value: typeof report) => {
const filename = path.join(records, checkRecordName(value.directory)), temporary = `${filename}.${randomUUID()}.tmp`;
const filename = path.join(records, checkRecordName(value.directory)),
temporary = `${filename}.${randomUUID()}.tmp`;
await fs.writeFile(temporary, JSON.stringify(value, null, 2), { flag: "wx", mode: 0o600 });
await fs.rename(temporary, filename);
};
if (report.artifactPath) {
const graph = JSON.parse(await fs.readFile(path.join(report.artifactPath, "graph.json"), "utf8"));
for (const checked of graph.resources) {
const managed = context.resources.find(entry => entry.kind === checked.kind && entry.source?.repository === checked.source.repository);
if (managed && managed.directory !== directory) await remember({...report, directory: managed.directory, commit: checked.source.commit, blockers: [], phase: "checked"});
const managed = context.resources.find(
(entry) => entry.kind === checked.kind && entry.source?.repository === checked.source.repository,
);
if (managed && managed.directory !== directory)
await remember({
...report,
directory: managed.directory,
commit: checked.source.commit,
blockers: [],
phase: "checked",
});
}
}
await remember(report);
+34 -13
View File
@@ -14,33 +14,54 @@ export async function authoringContext(start: string) {
let workbench = await realpath(start);
for (;;) {
let text: string | undefined;
try { text = await readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
try {
text = await readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if (text !== undefined) {
const graph = JSON.parse(text) as { resources: AuthoringResource[] };
if (!Array.isArray(graph.resources)) throw new Error("Managed resource inventory is malformed");
const resources: AuthoringResource[] = [{ kind: "workspace", directory: "root" }];
const identities = new Set<string>(), directories = new Set<string>(["root"]);
const identities = new Set<string>(),
directories = new Set<string>(["root"]);
for (const entry of graph.resources) {
// Compiler graphs carry resolved paths; the authoring API presents
// stable workbench-relative names and validates containment here.
if (typeof entry.directory === "string" && path.isAbsolute(entry.directory)) entry.directory = path.relative(workbench, entry.directory);
if (!["interface", "package"].includes(entry.kind) || !entry.source ||
!/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(entry.directory)) {
if (typeof entry.directory === "string" && path.isAbsolute(entry.directory))
entry.directory = path.relative(workbench, entry.directory);
if (
!["interface", "package"].includes(entry.kind) ||
!entry.source ||
!/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(entry.directory)
) {
throw new Error("Invalid managed resource registration");
}
const identity = `${entry.kind}\0${entry.resourceId ?? entry.source.repository}`;
if (identities.has(identity) || directories.has(entry.directory)) {
throw new Error(`Multiple editable selections for ${entry.resourceId ?? entry.source.repository}`);
}
identities.add(identity); directories.add(entry.directory);
resources.push({kind: entry.kind, directory: entry.directory, resourceId: entry.resourceId, source: entry.source});
identities.add(identity);
directories.add(entry.directory);
resources.push({
kind: entry.kind,
directory: entry.directory,
resourceId: entry.resourceId,
source: entry.source,
});
}
return { workbench, resources, async baseline() {
const result = await loadQuixosLock(path.join(workbench, "root/quixos.lock"));
if (!result.ok) throw new Error(`Workspace source baseline is invalid: ${result.diagnostics.map(d => d.message).join("; ")}`);
return result.lock.quixos;
} };
return {
workbench,
resources,
async baseline() {
const result = await loadQuixosLock(path.join(workbench, "root/quixos.lock"));
if (!result.ok)
throw new Error(
`Workspace source baseline is invalid: ${result.diagnostics.map((d) => d.message).join("; ")}`,
);
return result.lock.quixos;
},
};
}
const parent = path.dirname(workbench);
if (parent === workbench) throw new Error("Not in a managed workbench; select one with --workbench DIRECTORY");
+125 -50
View File
@@ -5,7 +5,13 @@ import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import { authoringContext } from "./authoring-context.js";
import { snapshotCommit } from "./checked-build.js";
import { loadQuixosLock, parseQuixosLockDocument, formatQuixosLockDocument, retentionTagForCommit, type GitSource } from "../resource-lock/index.js";
import {
loadQuixosLock,
parseQuixosLockDocument,
formatQuixosLockDocument,
retentionTagForCommit,
type GitSource,
} from "../resource-lock/index.js";
const execFile = promisify(callback);
const command = async (cwd: string, executable: string, args: string[]) => {
@@ -13,15 +19,26 @@ const command = async (cwd: string, executable: string, args: string[]) => {
// Do not log arguments: transports may contain credentials. Source identities
// remain in the normal checked result, not in this timing channel.
const label = `${path.basename(cwd)} ${executable} ${args[0]}`;
try { return (await execFile(executable, args, {
cwd, maxBuffer: 4 * 1024 * 1024,
env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0", GIT_TERMINAL_PROMPT: "0" },
})).stdout.trim(); }
finally { if (process.env.QUIXOS_TRACE_CAPTURE === "1") console.error(`[${new Date().toISOString()}] Capture: ${label}: ${Math.round(performance.now() - start)}ms`); }
try {
return (
await execFile(executable, args, {
cwd,
maxBuffer: 4 * 1024 * 1024,
env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0", GIT_TERMINAL_PROMPT: "0" },
})
).stdout.trim();
} finally {
if (process.env.QUIXOS_TRACE_CAPTURE === "1")
console.error(`[${new Date().toISOString()}] Capture: ${label}: ${Math.round(performance.now() - start)}ms`);
}
};
const identity = (kind: string, repository: string) => `${kind}\0${repository}`;
export type AuthoringBlocker = { directory: string; phase: "resolution" | "dependency" | "source" | "publication" | "concurrent-edit"; message: string };
export type AuthoringBlocker = {
directory: string;
phase: "resolution" | "dependency" | "source" | "publication" | "concurrent-edit";
message: string;
};
/** Source retention only. Neither successful convergence nor an empty source
* worklist grants typechecking, semantic review or activation approval.
@@ -37,94 +54,136 @@ export async function convergeAuthoring(start: string, target = "root") {
const root = path.join(context.workbench, entry.directory);
let repository = entry.source?.repository;
try {
if (await realpath(root) !== root) throw new Error(`Managed checkout crosses a symlink: ${entry.directory}`);
if ((await realpath(root)) !== root) throw new Error(`Managed checkout crosses a symlink: ${entry.directory}`);
// Transport rewrites must not become committed source identities.
const origin = await command(root, "git", ["config", "--get", "remote.origin.url"]);
if (repository && origin !== repository) throw new Error(`Origin differs from registered source for ${entry.directory}`);
if (repository && origin !== repository)
throw new Error(`Origin differs from registered source for ${entry.directory}`);
repository ??= origin;
} catch (error) {
blockers.push({directory: entry.directory, phase: "source", message: String(error).slice(0, 2000)});
blockers.push({ directory: entry.directory, phase: "source", message: String(error).slice(0, 2000) });
if (!repository) throw error; // The root has no separate registered source.
}
const key = identity(entry.kind, repository);
if (selected.has(key)) throw new Error(`More than one editable checkout for ${repository}`);
selected.set(key, entry.directory);
nodes.set(entry.directory, { ...entry, source: { resolver: "git", repository, commit: entry.source?.commit ?? "" }, dependencies: [] });
nodes.set(entry.directory, {
...entry,
source: { resolver: "git", repository, commit: entry.source?.commit ?? "" },
dependencies: [],
});
}
for (const node of nodes.values()) {
try {
const lock = await loadQuixosLock(path.join(context.workbench, node.directory, "quixos.lock"));
if (!lock.ok) throw new Error(lock.diagnostics.map(d => `${d.fileName}: ${d.message}`).join("\n"));
node.dependencies = [...new Set(lock.lock.resources.flatMap(entry => {
const directory = selected.get(identity(entry.kind, entry.source.repository));
return directory ? [directory] : [];
}))];
} catch (error) { blockers.push({ directory: node.directory, phase: "resolution", message: String(error) }); }
if (!lock.ok) throw new Error(lock.diagnostics.map((d) => `${d.fileName}: ${d.message}`).join("\n"));
node.dependencies = [
...new Set(
lock.lock.resources.flatMap((entry) => {
const directory = selected.get(identity(entry.kind, entry.source.repository));
return directory ? [directory] : [];
}),
),
];
} catch (error) {
blockers.push({ directory: node.directory, phase: "resolution", message: String(error) });
}
}
const complete = new Map<string, GitSource>(), active = new Set<string>();
const complete = new Map<string, GitSource>(),
active = new Set<string>();
const visited = new Set<string>();
if (!nodes.has(target)) throw new Error(`Not a registered repository: ${target}`);
const visit = async (directory: string): Promise<boolean> => {
visited.add(directory);
if (complete.has(directory)) return true;
if (blockers.some(entry => entry.directory === directory)) return false;
if (active.has(directory)) { blockers.push({ directory, phase: "dependency", message: `Source dependency cycle: ${[...active, directory].join(" -> ")}` }); return false; }
if (blockers.some((entry) => entry.directory === directory)) return false;
if (active.has(directory)) {
blockers.push({
directory,
phase: "dependency",
message: `Source dependency cycle: ${[...active, directory].join(" -> ")}`,
});
return false;
}
active.add(directory);
const node = nodes.get(directory)!;
for (const dependency of node.dependencies) if (!await visit(dependency)) {
blockers.push({ directory, phase: "dependency", message: `Waiting for ${dependency}` }); active.delete(directory); return false;
}
for (const dependency of node.dependencies)
if (!(await visit(dependency))) {
blockers.push({ directory, phase: "dependency", message: `Waiting for ${dependency}` });
active.delete(directory);
return false;
}
const root = path.join(context.workbench, directory);
let phase: AuthoringBlocker["phase"] = "source";
try {
const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
if (!lock.ok) throw new Error("Lock changed during convergence; retry after joining writers");
for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) {
const filename = path.join(root, file), before = await readFile(filename, "utf8");
const filename = path.join(root, file),
before = await readFile(filename, "utf8");
const parsed = parseQuixosLockDocument(before, file);
if (!parsed.ok) throw new Error(`Invalid lock ${file}`);
let changed = false;
for (const dependency of parsed.document.resources) {
const target = selected.get(identity(dependency.kind, dependency.source.repository));
const source = target ? complete.get(target) : undefined;
if (target && !source) throw new Error(`Dependencies changed during convergence (${dependency.binding}); join writers and retry`);
if (source && source.commit !== dependency.source.commit) { dependency.source = source; changed = true; }
if (target && !source)
throw new Error(`Dependencies changed during convergence (${dependency.binding}); join writers and retry`);
if (source && source.commit !== dependency.source.commit) {
dependency.source = source;
changed = true;
}
}
if (changed) {
const temporary = `${filename}.${randomUUID()}.tmp`;
try {
await writeFile(temporary, formatQuixosLockDocument(parsed.document), { flag: "wx" });
if (await readFile(filename, "utf8") !== before) throw new Error(`Concurrent edit to ${file}; retry after joining writers`);
if ((await readFile(filename, "utf8")) !== before)
throw new Error(`Concurrent edit to ${file}; retry after joining writers`);
await rename(temporary, filename);
} finally { await rm(temporary, { force: true }); }
} finally {
await rm(temporary, { force: true });
}
}
}
const snapshotStarted = performance.now();
const commit = await snapshotCommit(root);
if (process.env.QUIXOS_TRACE_CAPTURE === "1") console.error(`[${new Date().toISOString()}] Capture: ${directory} snapshot: ${Math.round(performance.now() - snapshotStarted)}ms`);
if (process.env.QUIXOS_TRACE_CAPTURE === "1")
console.error(
`[${new Date().toISOString()}] Capture: ${directory} snapshot: ${Math.round(performance.now() - snapshotStarted)}ms`,
);
phase = "publication";
const ref = retentionTagForCommit(commit);
const remote = await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref]);
if (remote && remote.split(/\s+/)[0] !== commit) throw new Error(`Conflicting immutable retention ref ${ref}`);
if (!remote) await command(root, "git", ["push", node.source.repository, `${commit}:${ref}`]);
if ((await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref])).split(/\s+/)[0] !== commit) throw new Error("Published source retention was not observed");
if ((await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref])).split(/\s+/)[0] !== commit)
throw new Error("Published source retention was not observed");
complete.set(directory, { ...node.source, commit });
} catch (error) { blockers.push({ directory, phase, message: String(error).slice(0, 4000) }); }
} catch (error) {
blockers.push({ directory, phase, message: String(error).slice(0, 4000) });
}
active.delete(directory);
return complete.has(directory);
};
// Include newly created, not-yet-imported resources, then the root.
if (target === "root") for (const directory of [...nodes.keys()].filter(d => d !== "root")) await visit(directory);
if (target === "root") for (const directory of [...nodes.keys()].filter((d) => d !== "root")) await visit(directory);
await visit(target);
for (let index = blockers.length - 1; index >= 0; index--) if (!visited.has(blockers[index].directory)) blockers.splice(index, 1);
for (let index = blockers.length - 1; index >= 0; index--)
if (!visited.has(blockers[index].directory)) blockers.splice(index, 1);
for (const [directory, source] of complete) {
try { if (await snapshotCommit(path.join(context.workbench, directory)) !== source.commit) throw new Error("Source advanced while converging; join writers and retry"); }
catch (error) { blockers.push({ directory, phase: "concurrent-edit", message: String(error) }); }
try {
if ((await snapshotCommit(path.join(context.workbench, directory))) !== source.commit)
throw new Error("Source advanced while converging; join writers and retry");
} catch (error) {
blockers.push({ directory, phase: "concurrent-edit", message: String(error) });
}
}
// Persist successful selections even if another repository is still broken.
// Recovery must not depend on all parents succeeding in the same invocation.
const graph = JSON.parse(graphBefore);
for (const resource of graph.resources) resource.directory = path.relative(context.workbench, path.resolve(context.workbench, resource.directory));
for (const resource of graph.resources)
resource.directory = path.relative(context.workbench, path.resolve(context.workbench, resource.directory));
const replacements = new Map<string, string>();
for (const resource of graph.resources) {
const source = complete.get(resource.directory);
@@ -132,30 +191,36 @@ export async function convergeAuthoring(start: string, target = "root") {
const key = `${resource.kind}\0${source.repository}\0${source.commit}`;
replacements.set(resource.key, key);
if (resource.source.commit !== source.commit) delete resource.revisionId;
resource.source = source; resource.key = key;
resource.source = source;
resource.key = key;
}
for (const resource of graph.resources) for (const dependency of resource.dependencies ?? []) {
dependency.resourceKey = replacements.get(dependency.resourceKey) ?? dependency.resourceKey;
}
for (const direct of graph.directResources ?? []) direct.resourceKey = replacements.get(direct.resourceKey) ?? direct.resourceKey;
for (const resource of graph.resources)
for (const dependency of resource.dependencies ?? []) {
dependency.resourceKey = replacements.get(dependency.resourceKey) ?? dependency.resourceKey;
}
for (const direct of graph.directResources ?? [])
direct.resourceKey = replacements.get(direct.resourceKey) ?? direct.resourceKey;
// Inventory is a projection of actual locks, including newly added/removed
// imports. Never require a successful parent compilation to repair it.
for (const [directory] of complete) {
const lock = await loadQuixosLock(path.join(context.workbench, directory, "quixos.lock"));
if (!lock.ok) continue;
const dependencies = lock.lock.resources.map(dependency => ({
const dependencies = lock.lock.resources.map((dependency) => ({
binding: `${dependency.kind}\0${dependency.binding}`,
resourceKey: `${dependency.kind}\0${dependency.source.repository}\0${dependency.source.commit}`,
}));
if (directory === "root") {
graph.quixos = lock.lock.quixos;
graph.directResources = lock.lock.resources.map((dependency, index) => ({
kind: dependency.kind, binding: dependency.binding, resourceKey: dependencies[index].resourceKey,
kind: dependency.kind,
binding: dependency.binding,
resourceKey: dependencies[index].resourceKey,
...(selected.has(identity(dependency.kind, dependency.source.repository))
? {directory: selected.get(identity(dependency.kind, dependency.source.repository))} : {}),
? { directory: selected.get(identity(dependency.kind, dependency.source.repository)) }
: {}),
}));
} else {
const resource = graph.resources.find((entry: {directory: string}) => entry.directory === directory);
const resource = graph.resources.find((entry: { directory: string }) => entry.directory === directory);
if (resource) resource.dependencies = dependencies;
}
}
@@ -164,12 +229,22 @@ export async function convergeAuthoring(start: string, target = "root") {
const temporary = `${graphFile}.${randomUUID()}.tmp`;
try {
await writeFile(temporary, graphAfter, { flag: "wx", mode: 0o600 });
if (await readFile(graphFile, "utf8") !== graphBefore) throw new Error("Managed inventory changed during convergence; source is retained, retry after joining writers");
if ((await readFile(graphFile, "utf8")) !== graphBefore)
throw new Error(
"Managed inventory changed during convergence; source is retained, retry after joining writers",
);
await rename(temporary, graphFile);
} finally { await rm(temporary, { force: true }); }
} finally {
await rm(temporary, { force: true });
}
}
return { workbench: context.workbench, converged: blockers.length === 0,
candidate: blockers.length ? null : complete.get(target) ?? null,
return {
workbench: context.workbench,
converged: blockers.length === 0,
candidate: blockers.length ? null : (complete.get(target) ?? null),
retained: [...complete].map(([directory, source]) => ({ directory, source })),
worklist: blockers, verificationEvidence: false, activated: false };
worklist: blockers,
verificationEvidence: false,
activated: false,
};
}
+61 -24
View File
@@ -5,48 +5,74 @@ import path from "node:path";
import { parseQx, walkSyntax } from "./source.js";
import { authoringContext } from "./authoring-context.js";
import { readQxSource } from "./source-loader.js";
import {loadQuixosLock} from "../resource-lock/index.js";
import { loadQuixosLock } from "../resource-lock/index.js";
const execFile = promisify(callback);
const git = async (root: string, args: string[]) => (await execFile("git", ["-C", root, ...args], {
maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
})).stdout;
const git = async (root: string, args: string[]) =>
(
await execFile("git", ["-C", root, ...args], {
maxBuffer: 8 * 1024 * 1024,
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
})
).stdout;
const message = (error: unknown) => String(error instanceof Error ? error.message : error).slice(0, 2000);
/** Syntax-only contract inspection is deliberately NOT verification evidence.
* Each file can recover independently; current valid files always win. */
export async function inspectAuthoringRepository(root: string, historyLimit = 100) {
const names = (await git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]))
.split("\0").filter(name => name.endsWith(".qx"));
if (names.length > 128) throw new Error("Repository inspection exceeds 128 QX files; split the resource into smaller repositories");
.split("\0")
.filter((name) => name.endsWith(".qx"));
if (names.length > 128)
throw new Error("Repository inspection exceeds 128 QX files; split the resource into smaller repositories");
const files = [];
for (const name of [...new Set(names)].sort()) {
let source = "", errors: unknown[] = [], revision: string | null = null;
let source = "",
errors: unknown[] = [],
revision: string | null = null;
try {
source = await readQxSource(root, name);
if (source.length > 262144) throw new Error(`Inspection file exceeds 256 KiB: ${name}`);
errors = parseQx(source, name).diagnostics;
} catch (error) { errors = [{ message: message(error) }]; }
} catch (error) {
errors = [{ message: message(error) }];
}
const currentErrors = errors;
if (errors.length) {
// Git can traverse jj's immutable commit DAG without mutating/snapshotting @.
const head = await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"],
{ cwd: root, env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1" } }).then(r => r.stdout.trim(), () => "HEAD");
const head = await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], {
cwd: root,
env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1" },
}).then(
(r) => r.stdout.trim(),
() => "HEAD",
);
const commits = await git(root, ["rev-list", `--max-count=${historyLimit}`, head, "--", name]).catch(() => "");
for (const commit of commits.trim().split("\n").filter(Boolean)) {
const historical = await git(root, ["show", `${commit}:${name}`]).catch(() => null);
if (historical === null || historical.length > 262144) continue;
if (!parseQx(historical, name).diagnostics.length) {
source = historical; revision = commit; errors = []; break;
source = historical;
revision = commit;
errors = [];
break;
}
}
}
const syntax = errors.length ? null : parseQx(source, name);
files.push({ file: name, status: errors.length ? "unavailable" : revision ? "historical" : "current",
revision, currentErrors: currentErrors.slice(0, 20), omittedErrors: Math.max(0, currentErrors.length - 20),
declarations: syntax ? [...walkSyntax(syntax.root)]
.filter(node => /^(?:interface|package|atom|state|edge|method|function|event|conformance)\w*Decl$/.test(node.kind))
.map(node => ({ kind: node.kind, source: source.slice(node.start, node.end) })) : [],
files.push({
file: name,
status: errors.length ? "unavailable" : revision ? "historical" : "current",
revision,
currentErrors: currentErrors.slice(0, 20),
omittedErrors: Math.max(0, currentErrors.length - 20),
declarations: syntax
? [...walkSyntax(syntax.root)]
.filter((node) =>
/^(?:interface|package|atom|state|edge|method|function|event|conformance)\w*Decl$/.test(node.kind),
)
.map((node) => ({ kind: node.kind, source: source.slice(node.start, node.end) }))
: [],
});
}
return { verificationEvidence: false as const, resolutionChecked: false as const, files };
@@ -56,21 +82,32 @@ export async function inspectWorkbench(start: string, selector?: string) {
const context = await authoringContext(start);
if (!selector || selector === ".") {
const relative = path.relative(context.workbench, await realpath(start));
selector = context.resources.find(entry => relative === entry.directory || relative.startsWith(entry.directory + path.sep))?.directory ?? "root";
selector =
context.resources.find((entry) => relative === entry.directory || relative.startsWith(entry.directory + path.sep))
?.directory ?? "root";
}
const lock = await loadQuixosLock(path.join(context.workbench, "root/quixos.lock"));
const aliases = lock.ok ? lock.lock.resources.filter(entry => entry.binding === selector) : [];
const selected = context.resources.filter(entry => !selector || selector === entry.directory ||
selector === entry.resourceId || selector === path.basename(entry.directory) || aliases.some(alias => alias.kind === entry.kind && alias.source.repository === entry.source?.repository));
const aliases = lock.ok ? lock.lock.resources.filter((entry) => entry.binding === selector) : [];
const selected = context.resources.filter(
(entry) =>
!selector ||
selector === entry.directory ||
selector === entry.resourceId ||
selector === path.basename(entry.directory) ||
aliases.some((alias) => alias.kind === entry.kind && alias.source.repository === entry.source?.repository),
);
if (!selected.length) throw new Error(`No registered resource matches ${selector}`);
if (selector && selected.length > 1) throw new Error(`Ambiguous resource ${selector}; use its resource ID or directory`);
if (selector && selected.length > 1)
throw new Error(`Ambiguous resource ${selector}; use its resource ID or directory`);
const resources = [];
for (const entry of selected) {
try {
const root = path.join(context.workbench, entry.directory);
if (await realpath(root) !== root) throw new Error("Managed checkout crosses a symlink");
resources.push({ ...entry, ...await inspectAuthoringRepository(root) });
} catch (error) { resources.push({ ...entry, error: message(error) }); }
if ((await realpath(root)) !== root) throw new Error("Managed checkout crosses a symlink");
resources.push({ ...entry, ...(await inspectAuthoringRepository(root)) });
} catch (error) {
resources.push({ ...entry, error: message(error) });
}
}
return { workbench: context.workbench, verificationEvidence: false, resources };
}
+85 -26
View File
@@ -11,50 +11,109 @@ import { checkerIdentity } from "./checked-build.js";
const execFile = promisify(callback);
export async function authoringWorklist(start: string) {
const context = await authoringContext(start);
const entries: {directory: string; resourceId?: string; phase: string; message: string; next: string}[] = [];
const entries: { directory: string; resourceId?: string; phase: string; message: string; next: string }[] = [];
const dependencies = new Map<string, string[]>();
for (const resource of context.resources) {
const root = path.join(context.workbench, resource.directory);
const add = (phase: string, message: string) => entries.push({directory: resource.directory, resourceId: resource.resourceId, phase, message,
next: phase === "syntax" ? `qx-workspace inspect ${resource.directory}` : phase === "evolution" ? "Inspect evolution.json in the check output; resolve its named migration/review requirements before cutover" : `cd ${resource.directory} && qx-workspace check`});
const add = (phase: string, message: string) =>
entries.push({
directory: resource.directory,
resourceId: resource.resourceId,
phase,
message,
next:
phase === "syntax"
? `qx-workspace inspect ${resource.directory}`
: phase === "evolution"
? "Inspect evolution.json in the check output; resolve its named migration/review requirements before cutover"
: `cd ${resource.directory} && qx-workspace check`,
});
try {
if (await fs.realpath(root) !== root) throw new Error("Registered checkout crosses a symlink");
if ((await fs.realpath(root)) !== root) throw new Error("Registered checkout crosses a symlink");
const inspected = await inspectAuthoringRepository(root);
for (const file of inspected.files) if (file.currentErrors.length) add("syntax", `${file.file}: ${JSON.stringify(file.currentErrors)}${file.status === "historical" ? `; historical contract available at ${file.revision}` : ""}`);
for (const file of inspected.files)
if (file.currentErrors.length)
add(
"syntax",
`${file.file}: ${JSON.stringify(file.currentErrors)}${file.status === "historical" ? `; historical contract available at ${file.revision}` : ""}`,
);
const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
if (!lock.ok) add("resolution", lock.diagnostics.map(entry => `${entry.fileName}: ${entry.message}`).join("\n"));
else dependencies.set(resource.directory, lock.lock.resources.flatMap(dependency => {
const selected = context.resources.find(entry => entry.kind === dependency.kind && entry.source?.repository === dependency.source.repository);
if (selected?.source && selected.source.commit !== dependency.source.commit) add("propagation", `Dependency ${dependency.binding} has advanced; check will repin it automatically`);
return selected ? [selected.directory] : [];
}));
if (!lock.ok)
add("resolution", lock.diagnostics.map((entry) => `${entry.fileName}: ${entry.message}`).join("\n"));
else
dependencies.set(
resource.directory,
lock.lock.resources.flatMap((dependency) => {
const selected = context.resources.find(
(entry) => entry.kind === dependency.kind && entry.source?.repository === dependency.source.repository,
);
if (selected?.source && selected.source.commit !== dependency.source.commit)
add("propagation", `Dependency ${dependency.binding} has advanced; check will repin it automatically`);
return selected ? [selected.directory] : [];
}),
);
let record;
try { record = JSON.parse(await fs.readFile(path.join(context.workbench, ".quixos/checks", checkRecordName(resource.directory)), "utf8")); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
try {
record = JSON.parse(
await fs.readFile(
path.join(context.workbench, ".quixos/checks", checkRecordName(resource.directory)),
"utf8",
),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if (!record?.commit) {
if (record?.blockers?.length) add(record.phase, record.blockers.join("\n"));
else add("unchecked", "No immutable candidate check recorded yet");
continue;
}
if (record.checker !== checkerIdentity()) add("unchecked", "The installed checker changed since the last check");
const env = {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"};
const commit = (await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], {cwd: root, env})).stdout.trim();
const dirty = await execFile("git", ["diff", "--quiet", "--no-ext-diff", record.commit, "--"], {cwd: root}).then(() => false, () => true);
const untracked = (await execFile("git", ["ls-files", "--others", "--exclude-standard"], {cwd: root})).stdout;
if (commit !== record.commit || dirty || untracked) add("unchecked", `Edits are newer than the last check (${record.commit.slice(0, 12)})`);
const env = { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1" };
const commit = (
await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], {
cwd: root,
env,
})
).stdout.trim();
const dirty = await execFile("git", ["diff", "--quiet", "--no-ext-diff", record.commit, "--"], {
cwd: root,
}).then(
() => false,
() => true,
);
const untracked = (await execFile("git", ["ls-files", "--others", "--exclude-standard"], { cwd: root })).stdout;
if (commit !== record.commit || dirty || untracked)
add("unchecked", `Edits are newer than the last check (${record.commit.slice(0, 12)})`);
else if (record.blockers?.length) add(record.phase, record.blockers.join("\n"));
} catch (error) { add("inspection", String(error).slice(0, 3000)); }
} catch (error) {
add("inspection", String(error).slice(0, 3000));
}
}
// Fixed-point propagation, independent of registration order.
const blocked = new Set(entries.map(entry => entry.directory));
const blocked = new Set(entries.map((entry) => entry.directory));
let changed = true;
while (changed) {
changed = false;
for (const [directory, required] of dependencies) if (!blocked.has(directory)) {
const waiting = required.filter(dependency => blocked.has(dependency));
if (waiting.length) { blocked.add(directory); changed = true; entries.push({directory, phase: "dependency", message: `Waiting for ${waiting.join(", ")}`, next: "Resolve the named repositories, then rerun check"}); }
}
for (const [directory, required] of dependencies)
if (!blocked.has(directory)) {
const waiting = required.filter((dependency) => blocked.has(dependency));
if (waiting.length) {
blocked.add(directory);
changed = true;
entries.push({
directory,
phase: "dependency",
message: `Waiting for ${waiting.join(", ")}`,
next: "Resolve the named repositories, then rerun check",
});
}
}
}
return { workbench: context.workbench, verificationEvidence: false, worklist: entries,
note: "Derived authoring guidance, not activation approval. Independent repositories can be delegated separately; join writers before a root check." };
return {
workbench: context.workbench,
verificationEvidence: false,
worklist: entries,
note: "Derived authoring guidance, not activation approval. Independent repositories can be delegated separately; join writers before a root check.",
};
}
+155 -47
View File
@@ -6,34 +6,51 @@ import { promisify } from "node:util";
import { createHash } from "node:crypto";
import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js";
import { createGitCapabilityResolver } from "./git-resolver.js";
import { contentDigest, planEvolution, type EvolutionReview, type WorkspaceRevision } from "../capability-model/index.js";
import {
contentDigest,
planEvolution,
type EvolutionReview,
type WorkspaceRevision,
} from "../capability-model/index.js";
import { bindingSchema } from "../bindings/index.js";
import {snapshotCommit, checkoutCommit, buildCheckedPackage} from "./checked-build.js";
import { snapshotCommit, checkoutCommit, buildCheckedPackage } from "./checked-build.js";
const execFile = promisify(execFileCallback);
const bytesDigest = (value: Uint8Array) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
export const localResourceSnapshots = async (root: string, filename?: string): Promise<{resources: {kind: string; repository: string; commit: string; directory: string}[]}> => {
export const localResourceSnapshots = async (
root: string,
filename?: string,
): Promise<{ resources: { kind: string; repository: string; commit: string; directory: string }[] }> => {
if (filename) {
const document = JSON.parse(await fs.readFile(filename, "utf8"));
return {resources: document.resources.map((entry: {directory: string}) => ({...entry, directory: path.resolve(path.dirname(filename), entry.directory)}))};
return {
resources: document.resources.map((entry: { directory: string }) => ({
...entry,
directory: path.resolve(path.dirname(filename), entry.directory),
})),
};
}
let directory = await fs.realpath(root);
for (;;) {
let graphText: string | undefined;
try {graphText = await fs.readFile(path.join(directory, ".quixos/resource-graph.json"), "utf8");}
catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;}
try {
graphText = await fs.readFile(path.join(directory, ".quixos/resource-graph.json"), "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if (graphText !== undefined) {
const graph = JSON.parse(graphText);
const resources = [];
for (const entry of graph.resources) {
const location = await fs.realpath(path.resolve(directory, entry.directory));
if (!location.startsWith(`${directory}/resources/`)) throw new Error("Workbench resource escapes managed directory");
resources.push({kind: entry.kind, ...entry.source, directory: location});
if (!location.startsWith(`${directory}/resources/`))
throw new Error("Workbench resource escapes managed directory");
resources.push({ kind: entry.kind, ...entry.source, directory: location });
}
return {resources};
return { resources };
}
const parent = path.dirname(directory);
if (parent === directory) return {resources: []};
if (parent === directory) return { resources: [] };
directory = parent;
}
};
@@ -41,18 +58,33 @@ export const localResourceSnapshots = async (root: string, filename?: string): P
/** Copy actual authoring files without snapshotting jj or creating a Git commit. */
export const snapshotRepository = async (source: string, destination: string) => {
const root = await fs.realpath(source);
const files = async () => (await execFile("git", ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], { maxBuffer: 16 * 1024 * 1024 })).stdout.split("\0").filter(Boolean).sort();
const files = async () =>
(
await execFile("git", ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
maxBuffer: 16 * 1024 * 1024,
})
).stdout
.split("\0")
.filter(Boolean)
.sort();
const names = [...new Set(await files())];
if (names.length > 50_000) throw new Error("Candidate source exceeds 50000 files");
const contents: {name: string; digest: string; mode: number}[] = [];
const contents: { name: string; digest: string; mode: number }[] = [];
let bytes = 0;
await fs.mkdir(destination, { recursive: true, mode: 0o700 });
for (const name of names) {
if (path.isAbsolute(name) || name.split(/[\\/]/).some((part) => part === ".." || part === ".git" || part === ".jj")) throw new Error("Invalid candidate source path");
if (path.isAbsolute(name) || name.split(/[\\/]/).some((part) => part === ".." || part === ".git" || part === ".jj"))
throw new Error("Invalid candidate source path");
const file = path.join(root, name);
let metadata;
try { metadata = await fs.lstat(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
if (!metadata.isFile() || metadata.isSymbolicLink() || !(await fs.realpath(file)).startsWith(`${root}${path.sep}`)) throw new Error(`Candidate source must be a regular file: ${name}`);
try {
metadata = await fs.lstat(file);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
throw error;
}
if (!metadata.isFile() || metadata.isSymbolicLink() || !(await fs.realpath(file)).startsWith(`${root}${path.sep}`))
throw new Error(`Candidate source must be a regular file: ${name}`);
const data = await fs.readFile(file);
bytes += data.length;
if (bytes > 128 * 1024 * 1024) throw new Error("Candidate source exceeds 128 MiB");
@@ -60,28 +92,38 @@ export const snapshotRepository = async (source: string, destination: string) =>
await fs.mkdir(path.dirname(path.join(destination, name)), { recursive: true });
await fs.writeFile(path.join(destination, name), data, { flag: "wx", mode: metadata.mode & 0o777 });
}
if (JSON.stringify([...new Set(await files())]) !== JSON.stringify(names)) throw new Error("Source files changed during candidate snapshot");
for (const entry of contents) if (bytesDigest(await fs.readFile(path.join(root, entry.name))) !== entry.digest) throw new Error(`Source changed during candidate snapshot: ${entry.name}`);
if (JSON.stringify([...new Set(await files())]) !== JSON.stringify(names))
throw new Error("Source files changed during candidate snapshot");
for (const entry of contents)
if (bytesDigest(await fs.readFile(path.join(root, entry.name))) !== entry.digest)
throw new Error(`Source changed during candidate snapshot: ${entry.name}`);
return { source: root, directory: destination, treeDigest: contentDigest(contents), files: contents };
};
// Local mirrors accelerate resolution, but only their committed locked trees
// may stand in for published dependencies. Never relabel dirty files as a pin.
async function committedResolver(root: string, temporary: string, filename?: string, publishedOnly = false) {
const map = publishedOnly ? {resources: []} : await localResourceSnapshots(root, filename);
const map = publishedOnly ? { resources: [] } : await localResourceSnapshots(root, filename);
const resources = [];
for (const [index, entry] of map.resources.entries()) {
const directory = path.join(temporary, `dependency-${index}`);
await checkoutCommit(entry.directory, entry.commit, directory);
resources.push({...entry, directory});
resources.push({ ...entry, directory });
}
const snapshotMap = path.join(temporary, "snapshots.json");
await fs.writeFile(snapshotMap, JSON.stringify({resources}));
return createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resolved"), snapshotMap});
await fs.writeFile(snapshotMap, JSON.stringify({ resources }));
return createGitCapabilityResolver({ checkoutRoot: path.join(temporary, "resolved"), snapshotMap });
}
export const checkResourceCandidate = async (options: {root: string; output: string; kind: "package" | "interface"; source: {repository: string; commit: string}; snapshotMap?: string; publishedOnly?: boolean}) => {
await fs.mkdir(options.output, {mode: 0o700});
export const checkResourceCandidate = async (options: {
root: string;
output: string;
kind: "package" | "interface";
source: { repository: string; commit: string };
snapshotMap?: string;
publishedOnly?: boolean;
}) => {
await fs.mkdir(options.output, { mode: 0o700 });
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-resource-check-"));
const blockers: string[] = [];
let treeDigest: string | undefined, commit: string | undefined, artifactPath: string | undefined;
@@ -90,62 +132,128 @@ export const checkResourceCandidate = async (options: {root: string; output: str
treeDigest = (await snapshotRepository(options.root, path.join(temporary, "observed"))).treeDigest;
const root = path.join(temporary, "source");
await checkoutCommit(options.root, commit, root);
const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap, options.publishedOnly);
const compiled = await compileCapabilityResourceRepository({rootDirectory: root, kind: options.kind, source: {resolver: "git", repository: options.source.repository, commit}, resolveResource});
const resolveResource = await committedResolver(
options.root,
temporary,
options.snapshotMap,
options.publishedOnly,
);
const compiled = await compileCapabilityResourceRepository({
rootDirectory: root,
kind: options.kind,
source: { resolver: "git", repository: options.source.repository, commit },
resolveResource,
});
if (compiled.resource.kind === "package") {
const schema = path.join(temporary, "bindings.json");
await fs.writeFile(schema, JSON.stringify(bindingSchema(compiled)));
artifactPath = await buildCheckedPackage(root, schema, compiled.resource.revision.revisionId);
}
if (await snapshotCommit(options.root) !== commit) throw new Error("Source changed during verification; run the check again");
if ((await snapshotCommit(options.root)) !== commit)
throw new Error("Source changed during verification; run the check again");
await fs.writeFile(path.join(options.output, "candidate.json"), JSON.stringify(compiled.resource, null, 2));
} catch (error) {
blockers.push(error instanceof Error ? error.message : String(error));
} finally {await fs.rm(temporary, {recursive: true, force: true});}
const result = {candidateOnly: true, activationEvidence: false, commit, treeDigest, artifactPath, blockers,
note: "Checked immutable candidate; cutover independently checks current migration/review requirements. No publication or activation performed."};
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
const result = {
candidateOnly: true,
activationEvidence: false,
commit,
treeDigest,
artifactPath,
blockers,
note: "Checked immutable candidate; cutover independently checks current migration/review requirements. No publication or activation performed.",
};
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
return result;
};
export const checkWorkspaceCandidate = async (options: {root: string; output: string; snapshotMap?: string; baseline?: string; reviews?: string}) => {
await fs.mkdir(options.output, {mode: 0o700});
export const checkWorkspaceCandidate = async (options: {
root: string;
output: string;
snapshotMap?: string;
baseline?: string;
reviews?: string;
}) => {
await fs.mkdir(options.output, { mode: 0o700 });
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-workspace-check-"));
const blockers: string[] = [], checks: {packageRevisionId: string; artifactPath: string}[] = [];
const blockers: string[] = [],
checks: { packageRevisionId: string; artifactPath: string }[] = [];
let commit: string | undefined;
try {
commit = await snapshotCommit(options.root);
for (const entry of (await localResourceSnapshots(options.root, options.snapshotMap)).resources) {
const current = await snapshotCommit(entry.directory);
const tree = async (revision: string) => (await execFile("git", ["rev-parse", `${revision}^{tree}`], {cwd: entry.directory})).stdout.trim();
if (await tree(current) !== await tree(entry.commit)) throw new Error(`Edited resource is not in the root's locked candidate: ${entry.directory}. Check that resource, then run qx-workspace resource upgrade --publish to propagate its revision.`);
const tree = async (revision: string) =>
(await execFile("git", ["rev-parse", `${revision}^{tree}`], { cwd: entry.directory })).stdout.trim();
if ((await tree(current)) !== (await tree(entry.commit)))
throw new Error(
`Edited resource is not in the root's locked candidate: ${entry.directory}. Check that resource, then run qx-workspace resource upgrade --publish to propagate its revision.`,
);
}
const root = path.join(temporary, "source");
await checkoutCommit(options.root, commit, root);
const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap);
const compiled = await compileWorkspaceRepository({rootDirectory: root, sourceRootCommit: commit, resolveResource});
const baseline = options.baseline ? JSON.parse(await fs.readFile(options.baseline, "utf8")) as WorkspaceRevision : null;
const reviews = options.reviews ? JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[] : [];
const evolution = planEvolution(baseline, compiled.workspace, {reviews});
const compiled = await compileWorkspaceRepository({
rootDirectory: root,
sourceRootCommit: commit,
resolveResource,
});
const baseline = options.baseline
? (JSON.parse(await fs.readFile(options.baseline, "utf8")) as WorkspaceRevision)
: null;
const reviews = options.reviews
? (JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[])
: [];
const evolution = planEvolution(baseline, compiled.workspace, { reviews });
blockers.push(...evolution.blockers);
for (const resource of compiled.resources.filter(entry => entry.kind === "package")) {
for (const resource of compiled.resources.filter((entry) => entry.kind === "package")) {
// Per-package recursive schema, identical to host activation, not unrelated
// workspace declarations that would unnecessarily invalidate build caches.
const candidate = await compileCapabilityResourceRepository({rootDirectory: resource.directory, kind: "package", source: resource.source, resolveResource});
const candidate = await compileCapabilityResourceRepository({
rootDirectory: resource.directory,
kind: "package",
source: resource.source,
resolveResource,
});
const schema = path.join(temporary, "bindings.json");
await fs.writeFile(schema, JSON.stringify(bindingSchema(candidate)));
const artifactPath = await buildCheckedPackage(resource.directory, schema, candidate.resource.revision.revisionId);
checks.push({packageRevisionId: candidate.resource.revision.revisionId, artifactPath});
const artifactPath = await buildCheckedPackage(
resource.directory,
schema,
candidate.resource.revision.revisionId,
);
checks.push({ packageRevisionId: candidate.resource.revision.revisionId, artifactPath });
}
if (await snapshotCommit(options.root) !== commit) throw new Error("Source changed during verification; run the check again");
const result = {schemaVersion: 1, candidateOnly: true, activationEvidence: false, commit, evolution, checks, blockers,
note: "Checks the committed root and its exact locked dependencies. Resource edits must be verified and repinned before they enter this candidate. No publication or activation performed."};
if ((await snapshotCommit(options.root)) !== commit)
throw new Error("Source changed during verification; run the check again");
const result = {
schemaVersion: 1,
candidateOnly: true,
activationEvidence: false,
commit,
evolution,
checks,
blockers,
note: "Checks the committed root and its exact locked dependencies. Resource edits must be verified and repinned before they enter this candidate. No publication or activation performed.",
};
await fs.writeFile(path.join(options.output, "candidate.json"), JSON.stringify(compiled.workspace, null, 2));
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
return result;
} catch (error) {
const result = {schemaVersion: 1, candidateOnly: true, activationEvidence: false, commit, checks, blockers: [...blockers, error instanceof Error ? error.message : String(error)]};
const result = {
schemaVersion: 1,
candidateOnly: true,
activationEvidence: false,
commit,
checks,
blockers: [...blockers, error instanceof Error ? error.message : String(error)],
};
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
return result;
} finally {await fs.rm(temporary, {recursive: true, force: true});}
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
};
+132 -43
View File
@@ -1,38 +1,52 @@
import fs from "node:fs/promises";
import path from "node:path";
import {execFile as callback, spawn} from "node:child_process";
import { execFile as callback, spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import {promisify} from "node:util";
import {fileURLToPath} from "node:url";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const execFile = promisify(callback);
const environment = () => ({...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", GIT_TERMINAL_PROMPT: "0"});
export const checkerIdentity = () => process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const environment = () => ({ ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", GIT_TERMINAL_PROMPT: "0" });
export const checkerIdentity = () =>
process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
/** Checking snapshots jj, but never publishes or activates the working copy. */
export async function snapshotCommit(root: string): Promise<string> {
const run = async (...args: string[]) => (await execFile("jj", args, {cwd: root, env: environment()})).stdout.trim();
const run = async (...args: string[]) =>
(await execFile("jj", args, { cwd: root, env: environment() })).stdout.trim();
await run("status");
// jj resolve --list exits 1 on a clean revision. Query structured revision
// metadata instead of depending on diagnostic wording or swallowing errors.
if (await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "conflict") !== "false") throw new Error("Resolve source conflicts before verification");
if ((await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "conflict")) !== "false")
throw new Error("Resolve source conflicts before verification");
const commit = await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id");
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Verification requires an exact jj commit");
await execFile("git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"], {cwd: root, env: environment()});
const {stdout} = await execFile("git", ["ls-files", "--others", "--exclude-standard", "-z"], {cwd: root});
await execFile("git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"], {
cwd: root,
env: environment(),
});
const { stdout } = await execFile("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root });
if (stdout) throw new Error("Source contains files not captured by jj; inspect jj tracking before verification");
return commit;
}
/** Use Git's actual committed tree, never dirty overlays labelled as old pins. */
export async function checkoutCommit(root: string, commit: string, destination: string) {
await fs.mkdir(destination, {recursive: true});
const archive = await execFile("git", ["archive", "--format=tar", commit], {cwd: root, encoding: "buffer", maxBuffer: 128 * 1024 * 1024});
await fs.mkdir(destination, { recursive: true });
const archive = await execFile("git", ["archive", "--format=tar", commit], {
cwd: root,
encoding: "buffer",
maxBuffer: 128 * 1024 * 1024,
});
await new Promise<void>((resolve, reject) => {
const child = spawn("tar", ["-xf", "-", "-C", destination], {stdio: ["pipe", "ignore", "pipe"]});
const child = spawn("tar", ["-xf", "-", "-C", destination], { stdio: ["pipe", "ignore", "pipe"] });
let error = "";
child.stderr.on("data", chunk => {error += chunk;});
child.stderr.on("data", (chunk) => {
error += chunk;
});
child.on("error", reject);
child.on("close", code => code === 0 ? resolve() : reject(new Error(`Cannot extract committed source: ${error}`)));
child.on("close", (code) =>
code === 0 ? resolve() : reject(new Error(`Cannot extract committed source: ${error}`)),
);
child.stdin.on("error", reject);
child.stdin.end(archive.stdout);
});
@@ -43,21 +57,47 @@ export async function checkoutCommit(root: string, commit: string, destination:
* not a certificate authored or approved by the workspace agent.
*/
export async function buildCheckedPackage(source: string, schema: string, packageRevisionId: string): Promise<string> {
const generator = process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Run verification with the installed Quixos tooling (its exact Nix checker is required)");
const generator =
process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
if (!/^\/nix\/store\/[^/]+$/.test(generator))
throw new Error("Run verification with the installed Quixos tooling (its exact Nix checker is required)");
const builder = path.join(generator, "share/checked-package.nix");
await fs.access(builder);
return await new Promise((resolve, reject) => {
const child = spawn("nix", ["build", "--impure", "--file", builder,
"--argstr", "source", source, "--argstr", "schema", schema,
"--argstr", "generator", generator, "--argstr", "packageRevisionId", packageRevisionId,
"--no-link", "--print-out-paths", "-L"], {env: environment(), stdio: ["ignore", "pipe", "inherit"]});
const child = spawn(
"nix",
[
"build",
"--impure",
"--file",
builder,
"--argstr",
"source",
source,
"--argstr",
"schema",
schema,
"--argstr",
"generator",
generator,
"--argstr",
"packageRevisionId",
packageRevisionId,
"--no-link",
"--print-out-paths",
"-L",
],
{ env: environment(), stdio: ["ignore", "pipe", "inherit"] },
);
let output = "";
child.stdout.on("data", chunk => {output += chunk;});
child.stdout.on("data", (chunk) => {
output += chunk;
});
child.on("error", reject);
child.on("close", code => {
child.on("close", (code) => {
const artifact = output.trim();
if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(artifact)) reject(new Error(`Checked Nix build failed (${code}); see build diagnostics above`));
if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(artifact))
reject(new Error(`Checked Nix build failed (${code}); see build diagnostics above`));
else resolve(artifact);
});
});
@@ -65,31 +105,80 @@ export async function buildCheckedPackage(source: string, schema: string, packag
/** Exact remote source DAG; the Nix checker owns schema construction and all
* nested fetches. Keep build noise in a named log, with a bounded failure tail. */
export async function buildImmutableCandidate(source: { repository: string; commit: string }, kind: "workspace" | "interface" | "package", logFile: string, contractOnly = false): Promise<string> {
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(source.commit)) throw new Error("An immutable candidate requires an exact commit");
export async function buildImmutableCandidate(
source: { repository: string; commit: string },
kind: "workspace" | "interface" | "package",
logFile: string,
contractOnly = false,
): Promise<string> {
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(source.commit))
throw new Error("An immutable candidate requires an exact commit");
const url = new URL(source.repository);
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("Candidate origin must be credential-free HTTPS");
const generator = process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash)
throw new Error("Candidate origin must be credential-free HTTPS");
const generator =
process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Use the installed Quixos checker");
const builder = path.join(generator, "share/checked-candidate.nix");
await fs.access(builder);
const log = createWriteStream(logFile, { flags: "wx", mode: 0o600 });
await new Promise<void>((resolve, reject) => { log.once("open", () => resolve()); log.once("error", reject); });
await new Promise<void>((resolve, reject) => {
log.once("open", () => resolve());
log.once("error", reject);
});
return await new Promise((resolve, reject) => {
let output = "", tail = "", failure: Error | undefined;
const child = spawn("nix", ["build", "--impure", "--file", builder,
"--argstr", "repository", source.repository, "--argstr", "commit", source.commit,
"--argstr", "kind", kind, "--argstr", "generator", generator,
"--arg", "contractOnly", contractOnly ? "true" : "false",
"--no-link", "--print-out-paths", "-L"], { env: environment(), stdio: ["ignore", "pipe", "pipe"] });
log.on("error", error => { failure = error; child.kill(); });
child.stdout.on("data", chunk => { output += chunk; });
child.stderr.on("data", chunk => { log.write(chunk); tail = (tail + String(chunk)).slice(-6000); });
child.on("error", error => { failure = error; });
child.on("close", code => log.end(() => {
if (failure) reject(failure);
else if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(output.trim())) reject(new Error(`Candidate Nix check failed (${code}). Full log: ${logFile}\n${tail}`));
else resolve(output.trim());
}));
let output = "",
tail = "",
failure: Error | undefined;
const child = spawn(
"nix",
[
"build",
"--impure",
"--file",
builder,
"--argstr",
"repository",
source.repository,
"--argstr",
"commit",
source.commit,
"--argstr",
"kind",
kind,
"--argstr",
"generator",
generator,
"--arg",
"contractOnly",
contractOnly ? "true" : "false",
"--no-link",
"--print-out-paths",
"-L",
],
{ env: environment(), stdio: ["ignore", "pipe", "pipe"] },
);
log.on("error", (error) => {
failure = error;
child.kill();
});
child.stdout.on("data", (chunk) => {
output += chunk;
});
child.stderr.on("data", (chunk) => {
log.write(chunk);
tail = (tail + String(chunk)).slice(-6000);
});
child.on("error", (error) => {
failure = error;
});
child.on("close", (code) =>
log.end(() => {
if (failure) reject(failure);
else if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(output.trim()))
reject(new Error(`Candidate Nix check failed (${code}). Full log: ${logFile}\n${tail}`));
else resolve(output.trim());
}),
);
});
}
+31 -37
View File
@@ -35,9 +35,9 @@ const parseArgs = (args: string[]) => {
else positional.push(argument);
}
if (
positional.length !== 1
|| Boolean(workspaceId) !== Boolean(workspaceRevisionId)
|| (resource && Boolean(workspaceId || workspaceRevisionId || sourceRootCommit))
positional.length !== 1 ||
Boolean(workspaceId) !== Boolean(workspaceRevisionId) ||
(resource && Boolean(workspaceId || workspaceRevisionId || sourceRootCommit))
) {
throw new Error(usage);
}
@@ -50,14 +50,7 @@ const main = async () => {
process.stdout.write(`${usage}\n`);
return;
}
const {
checkOnly,
resource,
workspaceId,
workspaceRevisionId,
sourceRootCommit,
fileName,
} = parseArgs(args);
const { checkOnly, resource, workspaceId, workspaceRevisionId, sourceRootCommit, fileName } = parseArgs(args);
const source =
fileName === "-"
? await new Promise<string>((resolve, reject) => {
@@ -67,33 +60,33 @@ const main = async () => {
process.stdin.on("error", reject);
})
: await readFile(fileName, "utf8");
const reportDiagnostics = (diagnostics: readonly {
fileName: string;
line: number;
column: number;
phase: string;
code: string;
message: string;
path?: string;
}[]) => {
const reportDiagnostics = (
diagnostics: readonly {
fileName: string;
line: number;
column: number;
phase: string;
code: string;
message: string;
path?: string;
}[],
) => {
for (const diagnostic of diagnostics) {
const location = diagnostic.line
? `${diagnostic.fileName}:${diagnostic.line}:${diagnostic.column + 1}`
: `${diagnostic.fileName}${diagnostic.path ? `:${diagnostic.path}` : ""}`;
process.stderr.write(
`${location}: ${diagnostic.phase} ${diagnostic.code}: ${diagnostic.message}\n`,
);
process.stderr.write(`${location}: ${diagnostic.phase} ${diagnostic.code}: ${diagnostic.message}\n`);
}
process.exitCode = 1;
};
if (resource) {
const result = compileCapabilityResourceSource(source, {
source: {
repository: "https://compiler.invalid/resource.git",
commit: "0000000000000000000000000000000000000000",
},
fileName,
});
source: {
repository: "https://compiler.invalid/resource.git",
commit: "0000000000000000000000000000000000000000",
},
fileName,
});
if (!result.ok) {
reportDiagnostics(result.diagnostics);
return;
@@ -106,14 +99,15 @@ const main = async () => {
reportDiagnostics(result.diagnostics);
return;
}
const workspace = workspaceId && workspaceRevisionId
? {
...result.workspace,
workspaceId: capabilityId.workspace(workspaceId),
id: capabilityId.workspaceRevision(workspaceRevisionId),
...(sourceRootCommit ? { sourceRootCommit } : {}),
}
: { ...result.workspace, ...(sourceRootCommit ? { sourceRootCommit } : {}) };
const workspace =
workspaceId && workspaceRevisionId
? {
...result.workspace,
workspaceId: capabilityId.workspace(workspaceId),
id: capabilityId.workspaceRevision(workspaceRevisionId),
...(sourceRootCommit ? { sourceRootCommit } : {}),
}
: { ...result.workspace, ...(sourceRootCommit ? { sourceRootCommit } : {}) };
const instantiated = compileWorkspaceRevision(workspace);
if (!instantiated.ok) {
throw new Error(instantiated.issues.map((issue) => `${issue.path}: ${issue.message}`).join("\n"));
+41 -8
View File
@@ -3,19 +3,52 @@ import { spawn } from "node:child_process";
/** Kernel-owned lock: a crashed coordinator cannot leave a stale ownership file.
* The persistent file is just an inode; EOF releases the helper's lock. */
export async function withFileLock<T>(filename: string, work: () => Promise<T>): Promise<T> {
const child = spawn("flock", ["--exclusive", "--timeout", "120", "--conflict-exit-code", "75", filename,
process.execPath, "-e", 'process.stdout.write("locked\\n"); process.stdin.resume();'], {stdio: ["pipe", "pipe", "pipe"]});
const child = spawn(
"flock",
[
"--exclusive",
"--timeout",
"120",
"--conflict-exit-code",
"75",
filename,
process.execPath,
"-e",
'process.stdout.write("locked\\n"); process.stdin.resume();',
],
{ stdio: ["pipe", "pipe", "pipe"] },
);
let diagnostics = "";
child.stdin.on("error", () => { /* acquisition/exit handling reports helper failure */ });
child.stderr.on("data", chunk => { diagnostics = (diagnostics + String(chunk)).slice(-2000); });
const closed = new Promise<void>((resolve) => { child.once("close", () => resolve()); });
child.stdin.on("error", () => {
/* acquisition/exit handling reports helper failure */
});
child.stderr.on("data", (chunk) => {
diagnostics = (diagnostics + String(chunk)).slice(-2000);
});
const closed = new Promise<void>((resolve) => {
child.once("close", () => resolve());
});
try {
await new Promise<void>((resolve, reject) => {
let output = "";
child.once("error", reject);
child.once("exit", code => reject(new Error(code === 75 ? "Timed out after 120 seconds waiting for another authoring command; inspect that command before retrying" : `Cannot acquire authoring lock: ${diagnostics}`)));
child.stdout.on("data", chunk => { output += chunk; if (output.includes("locked\n")) resolve(); });
child.once("exit", (code) =>
reject(
new Error(
code === 75
? "Timed out after 120 seconds waiting for another authoring command; inspect that command before retrying"
: `Cannot acquire authoring lock: ${diagnostics}`,
),
),
);
child.stdout.on("data", (chunk) => {
output += chunk;
if (output.includes("locked\n")) resolve();
});
});
return await work();
} finally { child.stdin.end(); await closed; }
} finally {
child.stdin.end();
await closed;
}
}
+40 -25
View File
@@ -57,24 +57,34 @@ export const createGitCapabilityResolver = async (options: {
const existing = checkouts.get(key);
if (existing) return await existing;
const pending = (async () => {
const directory = path.join(
checkoutRoot,
checkoutName(kind, source.repository, source.commit),
);
const directory = path.join(checkoutRoot, checkoutName(kind, source.repository, source.commit));
const verify = async (checkout: string) => {
const { stdout } = await execFile("git", ["-C", checkout, "rev-parse", "HEAD"]);
if (stdout.trim().toLowerCase() !== source.commit.toLowerCase()) {
throw new Error(`Locked commit mismatch for ${source.repository}: wanted ${source.commit}, fetched ${stdout.trim()}`);
throw new Error(
`Locked commit mismatch for ${source.repository}: wanted ${source.commit}, fetched ${stdout.trim()}`,
);
}
const { stdout: changes } = await execFile("git", ["-C", checkout, "status", "--porcelain", "--untracked-files=all"]);
const { stdout: changes } = await execFile("git", [
"-C",
checkout,
"status",
"--porcelain",
"--untracked-files=all",
]);
if (changes.trim()) throw new Error(`Dependency checkout was modified: ${checkout}`);
};
// Only complete, checked clones become visible under the deterministic name.
// Concurrent resolvers may fetch independently, but cannot observe a partial clone.
if (await stat(directory).then(() => true, (error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return false;
throw error;
})) {
if (
await stat(directory).then(
() => true,
(error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return false;
throw error;
},
)
) {
await verify(directory);
return { directory };
}
@@ -82,20 +92,21 @@ export const createGitCapabilityResolver = async (options: {
const checkout = path.join(staging, "checkout");
try {
await execFile("git", [
"-c",
"advice.detachedHead=false",
"clone",
"--depth",
"1",
"--single-branch",
"--branch",
`quixos-reachability/${source.commit.toLowerCase()}`,
source.repository,
checkout,
]);
"-c",
"advice.detachedHead=false",
"clone",
"--depth",
"1",
"--single-branch",
"--branch",
`quixos-reachability/${source.commit.toLowerCase()}`,
source.repository,
checkout,
]);
await verify(checkout);
try { await rename(checkout, directory); }
catch (error) {
try {
await rename(checkout, directory);
} catch (error) {
if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
await verify(directory);
}
@@ -105,7 +116,11 @@ export const createGitCapabilityResolver = async (options: {
return { directory };
})();
checkouts.set(key, pending);
try { return await pending; }
catch (error) { checkouts.delete(key); throw error; }
try {
return await pending;
} catch (error) {
checkouts.delete(key);
throw error;
}
};
};
+35 -10
View File
@@ -1,25 +1,50 @@
import {parse} from "@babel/parser";
type Node = {type: string; start: number; end: number; [key: string]: unknown};
const node = (value: unknown): value is Node => !!value && typeof value === "object" && typeof (value as Node).type === "string";
import { parse } from "@babel/parser";
type Node = { type: string; start: number; end: number; [key: string]: unknown };
const node = (value: unknown): value is Node =>
!!value && typeof value === "object" && typeof (value as Node).type === "string";
/** One requested insertion into ordinary authored TypeScript, not regeneration.
* Ambiguous/custom wiring is left alone with an actionable error. */
export function addImplementation(text: string, factory: "createRuntime" | "serveMigration", key: string, importPath: string, migrationOnly = false): string {
const ast = parse(text, {sourceType: "module", plugins: ["typescript"]});
export function addImplementation(
text: string,
factory: "createRuntime" | "serveMigration",
key: string,
importPath: string,
migrationOnly = false,
): string {
const ast = parse(text, { sourceType: "module", plugins: ["typescript"] });
const objects: Node[] = [];
const names = new Set<string>();
const visit = (value: unknown) => {
if (Array.isArray(value)) { value.forEach(visit); return; }
if (Array.isArray(value)) {
value.forEach(visit);
return;
}
if (!node(value)) return;
if (value.type === "Identifier") names.add(String(value.name));
if (value.type === "CallExpression" && node(value.callee) && value.callee.type === "Identifier" && value.callee.name === factory &&
Array.isArray(value.arguments) && value.arguments.length === 1 && node(value.arguments[0]) && value.arguments[0].type === "ObjectExpression") objects.push(value.arguments[0]);
if (
value.type === "CallExpression" &&
node(value.callee) &&
value.callee.type === "Identifier" &&
value.callee.name === factory &&
Array.isArray(value.arguments) &&
value.arguments.length === 1 &&
node(value.arguments[0]) &&
value.arguments[0].type === "ObjectExpression"
)
objects.push(value.arguments[0]);
Object.values(value).forEach(visit);
};
visit(ast);
if (objects.length !== 1) throw new Error(`Cannot safely add implementation: expected one ${factory}({...}) literal. Wire the handler in your authored server code instead.`);
if (objects.length !== 1)
throw new Error(
`Cannot safely add implementation: expected one ${factory}({...}) literal. Wire the handler in your authored server code instead.`,
);
const object = objects[0];
if ((object.properties as Node[]).some(p => node(p.key) && !p.computed && (p.key.name === key || p.key.value === key))) throw new Error(`Implementation already exists for ${key}`);
if (
(object.properties as Node[]).some((p) => node(p.key) && !p.computed && (p.key.name === key || p.key.value === key))
)
throw new Error(`Implementation already exists for ${key}`);
let alias = "qxImplementation";
for (let index = 1; names.has(alias); index++) alias = `qxImplementation${index}`;
const value = migrationOnly ? 'async () => { throw new Error("Migration-only export"); }' : alias;
+6 -1
View File
@@ -10,7 +10,12 @@ for await (const chunk of process.stdin) {
}
const document = JSON.parse(input);
if (!Array.isArray(document) && document?.operation === "instantiate-workspace") {
if (typeof document.source !== "string" || document.source.length > 262144 || typeof document.workspaceId !== "string") throw new Error("Invalid template identity request");
if (
typeof document.source !== "string" ||
document.source.length > 262144 ||
typeof document.workspaceId !== "string"
)
throw new Error("Invalid template identity request");
process.stdout.write(JSON.stringify({ source: instantiateWorkspaceIdentity(document.source, document.workspaceId) }));
process.exit(0);
}
+10 -6
View File
@@ -1,8 +1,8 @@
import fs from "node:fs/promises";
import path from "node:path";
import {contentDigest} from "../capability-model/evolution.js";
import {validateMigrationCatalog, type MigrationCatalog} from "../capability-model/migrations.js";
import {applyStructure, planStructure} from "./structural-plan.js";
import { contentDigest } from "../capability-model/evolution.js";
import { validateMigrationCatalog, type MigrationCatalog } from "../capability-model/migrations.js";
import { applyStructure, planStructure } from "./structural-plan.js";
/** Explicitly acknowledge edited migration code. This does not change retained
* contracts or grant activation approval; the immutable checker verifies it. */
@@ -17,7 +17,11 @@ export async function sealMigrations(directory: string) {
migration.implementation.digest = contentDigest(await fs.readFile(implementation, "utf8"));
}
validateMigrationCatalog(catalog);
return applyStructure(await planStructure(root, {kind: "package", validation: "syntax", files: [
{file, expected: before, replace: JSON.stringify(catalog, null, 2) + "\n"},
]}));
return applyStructure(
await planStructure(root, {
kind: "package",
validation: "syntax",
files: [{ file, expected: before, replace: JSON.stringify(catalog, null, 2) + "\n" }],
}),
);
}
File diff suppressed because it is too large Load Diff
+373 -150
View File
@@ -1,100 +1,185 @@
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import {randomUUID} from "node:crypto";
import {execFile as callback} from "node:child_process";
import {promisify} from "node:util";
import {loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js";
import {contentDigest} from "../capability-model/evolution.js";
import {planStructure, applyStructure, type StructuralRequest} from "./structural-plan.js";
import {snapshotRepository} from "./candidate-check.js";
import {compileWorkspaceRepository, compileCapabilityResourceRepository, type ResolvedCapabilityResource} from "./assembly.js";
import {createGitCapabilityResolver} from "./git-resolver.js";
import {snapshotCommit, buildImmutableCandidate} from "./checked-build.js";
import {planEvolution, type WorkspaceRevision, type EvolutionReview} from "../capability-model/index.js";
import {withFileLock} from "./file-lock.js";
import { randomUUID } from "node:crypto";
import { execFile as callback } from "node:child_process";
import { promisify } from "node:util";
import { loadQuixosLock, parseQuixosLockDocument } from "../resource-lock/index.js";
import { contentDigest } from "../capability-model/evolution.js";
import { planStructure, applyStructure, type StructuralRequest } from "./structural-plan.js";
import { snapshotRepository } from "./candidate-check.js";
import {
compileWorkspaceRepository,
compileCapabilityResourceRepository,
type ResolvedCapabilityResource,
} from "./assembly.js";
import { createGitCapabilityResolver } from "./git-resolver.js";
import { snapshotCommit, buildImmutableCandidate } from "./checked-build.js";
import { planEvolution, type WorkspaceRevision, type EvolutionReview } from "../capability-model/index.js";
import { withFileLock } from "./file-lock.js";
const execFile = promisify(callback);
type Source = {repository: string; commit: string};
export type UpgradeNode = {kind: "workspace" | "package" | "interface"; directory: string; source: Source};
export type UpgradeSpec = {nodes: UpgradeNode[]; quixos?: Source; baseline?: string; reviews?: string; bootstrap?: boolean};
type NodePlan = UpgradeNode & {treeDigest: string; dependencies: string[]; lockFiles: string[]};
export type UpgradePlan = {schemaVersion: 1; workbench: string; spec: UpgradeSpec; nodes: NodePlan[]; digest: string};
type Step = {directory: string; phase: "editing" | "prepared" | "refactor" | "checked" | "publishing" | "published"; commit?: string; treeDigest?: string; structuralJournal?: string; structuralPlan?: Awaited<ReturnType<typeof planStructure>>};
type Journal = {schemaVersion: 1; plan: UpgradePlan; steps: Step[]};
const sourceKey = (node: {kind: string; source: Source}) => JSON.stringify([node.kind, node.source.repository, node.source.commit]);
const command = async (cwd: string, tool: string, args: string[]) => (await execFile(tool, args, {cwd, maxBuffer: 16 * 1024 * 1024, env: {...process.env, GIT_TERMINAL_PROMPT: "0", QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0"}})).stdout.trim();
type Source = { repository: string; commit: string };
export type UpgradeNode = { kind: "workspace" | "package" | "interface"; directory: string; source: Source };
export type UpgradeSpec = {
nodes: UpgradeNode[];
quixos?: Source;
baseline?: string;
reviews?: string;
bootstrap?: boolean;
};
type NodePlan = UpgradeNode & { treeDigest: string; dependencies: string[]; lockFiles: string[] };
export type UpgradePlan = { schemaVersion: 1; workbench: string; spec: UpgradeSpec; nodes: NodePlan[]; digest: string };
type Step = {
directory: string;
phase: "editing" | "prepared" | "refactor" | "checked" | "publishing" | "published";
commit?: string;
treeDigest?: string;
structuralJournal?: string;
structuralPlan?: Awaited<ReturnType<typeof planStructure>>;
};
type Journal = { schemaVersion: 1; plan: UpgradePlan; steps: Step[] };
const sourceKey = (node: { kind: string; source: Source }) =>
JSON.stringify([node.kind, node.source.repository, node.source.commit]);
const command = async (cwd: string, tool: string, args: string[]) =>
(
await execFile(tool, args, {
cwd,
maxBuffer: 16 * 1024 * 1024,
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0" },
})
).stdout.trim();
const validSource = (value: Source) => {
const url = new URL(value.repository);
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value.commit)) throw new Error("Upgrade sources must be exact credential-free HTTPS revisions");
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.search ||
url.hash ||
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value.commit)
)
throw new Error("Upgrade sources must be exact credential-free HTTPS revisions");
};
const location = async (root: string, directory: string) => {
if (directory !== "root" && !/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(directory)) throw new Error("Upgrade target must be a managed root/resource repository");
if (directory !== "root" && !/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(directory))
throw new Error("Upgrade target must be a managed root/resource repository");
const resolved = await fs.realpath(path.join(root, directory));
if (resolved !== path.join(root, directory)) throw new Error("Upgrade target crosses a symlink");
return resolved;
};
const treeDigest = async (root: string) => {
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-upgrade-tree-"));
try {return (await snapshotRepository(root, temporary)).treeDigest;} finally {await fs.rm(temporary, {recursive: true, force: true});}
try {
return (await snapshotRepository(root, temporary)).treeDigest;
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
};
const writeJournal = async (filename: string, journal: unknown) => {
const temp = `${filename}.${randomUUID()}.tmp`;
const handle = await fs.open(temp, "wx", 0o600);
try {await handle.writeFile(JSON.stringify(journal, null, 2)); await handle.sync();} finally {await handle.close();}
try {
await handle.writeFile(JSON.stringify(journal, null, 2));
await handle.sync();
} finally {
await handle.close();
}
await fs.rename(temp, filename);
const directory = await fs.open(path.dirname(filename), "r");
try {await directory.sync();} finally {await directory.close();}
try {
await directory.sync();
} finally {
await directory.close();
}
};
export const discoverUpgradeSpec = async (workbench: string): Promise<UpgradeSpec> => {
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
const root = await location(workbench, "root");
const nodes: UpgradeNode[] = [{kind: "workspace", directory: "root", source: {
repository: await command(root, "git", ["config", "--get", "remote.origin.url"]),
commit: await command(root, "jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"]),
}}, ...graph.resources.map((entry: {kind: "interface" | "package"; directory: string; source: Source}) => ({kind: entry.kind, source: entry.source,
directory: path.relative(workbench, path.resolve(workbench, entry.directory))}))];
const nodes: UpgradeNode[] = [
{
kind: "workspace",
directory: "root",
source: {
repository: await command(root, "git", ["config", "--get", "remote.origin.url"]),
commit: await command(root, "jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"]),
},
},
...graph.resources.map((entry: { kind: "interface" | "package"; directory: string; source: Source }) => ({
kind: entry.kind,
source: entry.source,
directory: path.relative(workbench, path.resolve(workbench, entry.directory)),
})),
];
let baseline: string | undefined;
try {
const host = JSON.parse(await fs.readFile("/etc/quixos/workspace-source.json", "utf8"));
if (await fs.realpath(host.workbenchRoot) === await fs.realpath(workbench)) baseline = JSON.parse(await fs.readFile(path.join(host.runtimeClosureRoot, "manifest.json"), "utf8")).workspacePlanPath;
} catch (error) {if (!["ENOENT", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;}
return {nodes, baseline};
if ((await fs.realpath(host.workbenchRoot)) === (await fs.realpath(workbench)))
baseline = JSON.parse(
await fs.readFile(path.join(host.runtimeClosureRoot, "manifest.json"), "utf8"),
).workspacePlanPath;
} catch (error) {
if (!["ENOENT", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
}
return { nodes, baseline };
};
/** Read-only source plan. Repositories are selected explicitly, including any
* parallel versions of the same resource; no guesses at a floating 'latest'. */
export const planPinUpgrades = async (workbenchPath: string, spec: UpgradeSpec): Promise<UpgradePlan> => {
const workbench = await fs.realpath(workbenchPath);
if (!Array.isArray(spec.nodes) || !spec.nodes.length || spec.nodes.length > 100 || spec.nodes.filter((node) => node.kind === "workspace").length !== 1) throw new Error("Upgrade graph requires one workspace and at most 100 repositories");
if (
!Array.isArray(spec.nodes) ||
!spec.nodes.length ||
spec.nodes.length > 100 ||
spec.nodes.filter((node) => node.kind === "workspace").length !== 1
)
throw new Error("Upgrade graph requires one workspace and at most 100 repositories");
if (spec.quixos) validSource(spec.quixos);
const keys = new Map<string, string>();
for (const node of spec.nodes) {
validSource(node.source);
if (!["workspace", "package", "interface"].includes(node.kind) || keys.has(sourceKey(node))) throw new Error("Duplicate/invalid upgrade resource identity");
if (!["workspace", "package", "interface"].includes(node.kind) || keys.has(sourceKey(node)))
throw new Error("Duplicate/invalid upgrade resource identity");
keys.set(sourceKey(node), node.directory);
}
if (new Set(spec.nodes.map((node) => node.directory)).size !== spec.nodes.length) throw new Error("Upgrade directories must be distinct");
if (new Set(spec.nodes.map((node) => node.directory)).size !== spec.nodes.length)
throw new Error("Upgrade directories must be distinct");
const nodes: NodePlan[] = [];
for (const node of spec.nodes) {
const root = await location(workbench, node.directory);
if (await command(root, "git", ["config", "--get", "remote.origin.url"]) !== node.source.repository) throw new Error(`Upgrade origin differs from selected source: ${node.directory}`);
if ((await command(root, "git", ["config", "--get", "remote.origin.url"])) !== node.source.repository)
throw new Error(`Upgrade origin differs from selected source: ${node.directory}`);
const loaded = await loadQuixosLock(path.join(root, "quixos.lock"));
if (!loaded.ok) throw new Error(`Invalid lock in ${node.directory}: ${loaded.diagnostics.map((entry) => entry.message).join("; ")}`);
const dependencies = loaded.lock.resources.map((resource) => keys.get(sourceKey(resource))).filter((value): value is string => Boolean(value));
nodes.push({...node, treeDigest: await treeDigest(root), dependencies: [...new Set(dependencies)], lockFiles: loaded.lock.sourceFiles ?? ["quixos.lock"]});
if (!loaded.ok)
throw new Error(
`Invalid lock in ${node.directory}: ${loaded.diagnostics.map((entry) => entry.message).join("; ")}`,
);
const dependencies = loaded.lock.resources
.map((resource) => keys.get(sourceKey(resource)))
.filter((value): value is string => Boolean(value));
nodes.push({
...node,
treeDigest: await treeDigest(root),
dependencies: [...new Set(dependencies)],
lockFiles: loaded.lock.sourceFiles ?? ["quixos.lock"],
});
}
const ordered: NodePlan[] = [], remaining = [...nodes];
const ordered: NodePlan[] = [],
remaining = [...nodes];
while (remaining.length) {
const index = remaining.findIndex((node) => node.dependencies.every((dependency) => ordered.some((entry) => entry.directory === dependency)));
const index = remaining.findIndex((node) =>
node.dependencies.every((dependency) => ordered.some((entry) => entry.directory === dependency)),
);
if (index < 0) throw new Error("Cyclic source publication graph");
ordered.push(remaining.splice(index, 1)[0]);
}
const workspace = ordered.find((node) => node.kind === "workspace")!;
// Even unreferenced new resources are published before the root.
ordered.splice(ordered.indexOf(workspace), 1); ordered.push(workspace);
const plan = {schemaVersion: 1 as const, workbench, spec, nodes: ordered};
return {...plan, digest: contentDigest(plan)};
ordered.splice(ordered.indexOf(workspace), 1);
ordered.push(workspace);
const plan = { schemaVersion: 1 as const, workbench, spec, nodes: ordered };
return { ...plan, digest: contentDigest(plan) };
};
export type UpgradeEffects = {
@@ -104,21 +189,27 @@ export type UpgradeEffects = {
};
const effects: UpgradeEffects = {
async check(node, root, output, spec) {
if (node.kind === "workspace" && !spec.baseline && !spec.bootstrap) throw new Error("Upgrading a workspace requires its checked active baseline for major-review checks (or explicit bootstrap:true for a new workspace)");
if (node.kind === "workspace" && !spec.baseline && !spec.bootstrap)
throw new Error(
"Upgrading a workspace requires its checked active baseline for major-review checks (or explicit bootstrap:true for a new workspace)",
);
// Explicit baseline upgrades use the same immutable Nix checker. Retaining
// an unverified source is safe and must precede a remote flake fetch.
await fs.mkdir(output);
const commit = await snapshotCommit(root);
await effects.publish(root, commit);
const artifact = await buildImmutableCandidate({...node.source, commit}, node.kind, path.join(output, "nix.log"));
const artifact = await buildImmutableCandidate({ ...node.source, commit }, node.kind, path.join(output, "nix.log"));
const candidate = await fs.readFile(path.join(artifact, "candidate.json"), "utf8");
await fs.writeFile(path.join(output, "candidate.json"), candidate);
if (node.kind === "workspace") {
const baseline = spec.baseline ? JSON.parse(await fs.readFile(spec.baseline, "utf8")) as WorkspaceRevision : null;
const reviews = spec.reviews ? JSON.parse(await fs.readFile(spec.reviews, "utf8")) as EvolutionReview[] : [];
const evolution = planEvolution(baseline, JSON.parse(candidate), {reviews});
const baseline = spec.baseline
? (JSON.parse(await fs.readFile(spec.baseline, "utf8")) as WorkspaceRevision)
: null;
const reviews = spec.reviews ? (JSON.parse(await fs.readFile(spec.reviews, "utf8")) as EvolutionReview[]) : [];
const evolution = planEvolution(baseline, JSON.parse(candidate), { reviews });
await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2));
if (evolution.blockers.length) throw new Error(`Refactor required in ${node.directory}: ${evolution.blockers.join("; ")}`);
if (evolution.blockers.length)
throw new Error(`Refactor required in ${node.directory}: ${evolution.blockers.join("; ")}`);
}
},
async snapshot(root) {
@@ -126,7 +217,10 @@ const effects: UpgradeEffects = {
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Publication did not resolve an exact commit");
await command(root, "git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"]);
const tracked = new Set((await command(root, "git", ["ls-tree", "-r", "--name-only", "-z", commit])).split("\0"));
for (const file of (await command(root, "git", ["ls-files", "--others", "--exclude-standard", "-z"])).split("\0").filter(Boolean)) if (!tracked.has(file)) throw new Error(`Uncaptured source file ${file}`);
for (const file of (await command(root, "git", ["ls-files", "--others", "--exclude-standard", "-z"]))
.split("\0")
.filter(Boolean))
if (!tracked.has(file)) throw new Error(`Uncaptured source file ${file}`);
return commit;
},
async publish(root, commit) {
@@ -134,121 +228,250 @@ const effects: UpgradeEffects = {
const remote = await command(root, "git", ["ls-remote", "--refs", "origin", ref]);
if (remote && remote !== `${commit}\t${ref}`) throw new Error("Immutable publication ref conflict");
if (!remote) await command(root, "git", ["push", "origin", `${commit}:${ref}`]);
if (await command(root, "git", ["ls-remote", "--refs", "origin", ref]) !== `${commit}\t${ref}`) throw new Error("Publication response uncertain; retry the same journal");
if ((await command(root, "git", ["ls-remote", "--refs", "origin", ref])) !== `${commit}\t${ref}`)
throw new Error("Publication response uncertain; retry the same journal");
},
};
/** Explicit --publish only. Append-only remote retention; never moves the
* workspace branch, activates code, or rolls back previously published nodes. */
export const applyPinUpgrades = async (plan: UpgradePlan, journalId?: string, implementation: UpgradeEffects = effects, options: {acceptEdits?: boolean} = {}) => {
if (implementation === effects && !plan.spec.baseline && !plan.spec.bootstrap) throw new Error("Publication requires an active checked baseline or explicit bootstrap:true");
const {digest, ...body} = plan;
export const applyPinUpgrades = async (
plan: UpgradePlan,
journalId?: string,
implementation: UpgradeEffects = effects,
options: { acceptEdits?: boolean } = {},
) => {
if (implementation === effects && !plan.spec.baseline && !plan.spec.bootstrap)
throw new Error("Publication requires an active checked baseline or explicit bootstrap:true");
const { digest, ...body } = plan;
if (contentDigest(body) !== digest) throw new Error("Upgrade plan digest mismatch");
const directory = path.join(plan.workbench, ".quixos", "upgrades");
await fs.mkdir(directory, {recursive: true, mode: 0o700});
if (await fs.realpath(directory) !== directory) throw new Error("Upgrade journals must not cross symlinks");
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
if ((await fs.realpath(directory)) !== directory) throw new Error("Upgrade journals must not cross symlinks");
const id = journalId ?? randomUUID();
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid upgrade journal ID");
const filename = path.join(directory, `${id}.json`);
return withFileLock(path.join(directory, "writer.lock"), async () => {
try {
const journal: Journal = journalId ? JSON.parse(await fs.readFile(filename, "utf8")) : {schemaVersion: 1, plan, steps: []};
if (journal.plan.digest !== plan.digest) throw new Error("Upgrade journal belongs to another plan");
if (!journalId) await writeJournal(filename, journal);
for (const node of plan.nodes) {
const root = await location(plan.workbench, node.directory);
if (await command(root, "git", ["config", "--get", "remote.origin.url"]) !== node.source.repository) throw new Error("Upgrade remote changed after planning");
let step = journal.steps.find((entry) => entry.directory === node.directory);
if (step?.phase === "published") continue;
if (!step) {
if (await treeDigest(root) !== node.treeDigest) throw new Error(`Stale upgrade plan: ${node.directory}`);
const files: StructuralRequest["files"] = [];
for (const file of node.lockFiles) {
const parsed = parseQuixosLockDocument(await fs.readFile(path.join(root, file), "utf8"));
if (!parsed.ok) throw new Error("Invalid lock during upgrade");
const edits = [];
for (const resource of parsed.document.resources) {
const dependency = plan.nodes.find((entry) => sourceKey(entry) === sourceKey(resource));
const published = dependency && journal.steps.find((entry) => entry.directory === dependency.directory && entry.phase === "published");
if (published?.commit && published.commit !== resource.source.commit) edits.push({operation: "dependency" as const, kind: resource.kind, name: resource.binding, source: {...resource.source, commit: published.commit}});
try {
const journal: Journal = journalId
? JSON.parse(await fs.readFile(filename, "utf8"))
: { schemaVersion: 1, plan, steps: [] };
if (journal.plan.digest !== plan.digest) throw new Error("Upgrade journal belongs to another plan");
if (!journalId) await writeJournal(filename, journal);
for (const node of plan.nodes) {
const root = await location(plan.workbench, node.directory);
if ((await command(root, "git", ["config", "--get", "remote.origin.url"])) !== node.source.repository)
throw new Error("Upgrade remote changed after planning");
let step = journal.steps.find((entry) => entry.directory === node.directory);
if (step?.phase === "published") continue;
if (!step) {
if ((await treeDigest(root)) !== node.treeDigest) throw new Error(`Stale upgrade plan: ${node.directory}`);
const files: StructuralRequest["files"] = [];
for (const file of node.lockFiles) {
const parsed = parseQuixosLockDocument(await fs.readFile(path.join(root, file), "utf8"));
if (!parsed.ok) throw new Error("Invalid lock during upgrade");
const edits = [];
for (const resource of parsed.document.resources) {
const dependency = plan.nodes.find((entry) => sourceKey(entry) === sourceKey(resource));
const published =
dependency &&
journal.steps.find((entry) => entry.directory === dependency.directory && entry.phase === "published");
if (published?.commit && published.commit !== resource.source.commit)
edits.push({
operation: "dependency" as const,
kind: resource.kind,
name: resource.binding,
source: { ...resource.source, commit: published.commit },
});
}
if (parsed.document.kind === "root" && plan.spec.quixos)
edits.push({ operation: "quixos-pin" as const, source: plan.spec.quixos });
if (edits.length) files.push({ file, edits });
}
if (parsed.document.kind === "root" && plan.spec.quixos) edits.push({operation: "quixos-pin" as const, source: plan.spec.quixos});
if (edits.length) files.push({file, edits});
const structuralPlan = files.length
? await planStructure(
root,
{ kind: node.kind, source: node.source, files },
process.env.QUIXOS_SNAPSHOT_MAP,
)
: undefined;
step = {
directory: node.directory,
phase: "editing",
treeDigest: node.treeDigest,
structuralPlan,
structuralJournal: structuralPlan ? randomUUID() : undefined,
};
journal.steps.push(step);
await writeJournal(filename, journal);
}
const structuralPlan = files.length ? await planStructure(root, {kind: node.kind, source: node.source, files}, process.env.QUIXOS_SNAPSHOT_MAP) : undefined;
step = {directory: node.directory, phase: "editing", treeDigest: node.treeDigest, structuralPlan, structuralJournal: structuralPlan ? randomUUID() : undefined};
journal.steps.push(step); await writeJournal(filename, journal);
}
if (step.phase === "editing") {
if (step.structuralPlan) await applyStructure(step.structuralPlan, step.structuralJournal);
else if (await treeDigest(root) !== step.treeDigest) throw new Error("Source changed before upgrade editing");
step.treeDigest = await treeDigest(root);
step.phase = "prepared";
if (step.phase === "editing") {
if (step.structuralPlan) await applyStructure(step.structuralPlan, step.structuralJournal);
else if ((await treeDigest(root)) !== step.treeDigest)
throw new Error("Source changed before upgrade editing");
step.treeDigest = await treeDigest(root);
step.phase = "prepared";
await writeJournal(filename, journal);
}
if (step.phase === "refactor") {
const current = await treeDigest(root);
if (current !== step.treeDigest && !options.acceptEdits)
throw new Error("Refactored source requires --accept-edits when resuming");
step.treeDigest = current;
step.phase = "prepared";
await writeJournal(filename, journal);
}
if ((await treeDigest(root)) !== step.treeDigest)
throw new Error(`Source changed during upgrade: ${node.directory}; inspect ${filename}`);
if (step.phase === "prepared") {
try {
await implementation.check(
node,
root,
path.join(directory, `${id}-${node.directory.replaceAll("/", "-")}-${randomUUID()}`),
plan.spec,
);
} catch (error) {
step.phase = "refactor";
await writeJournal(filename, journal);
throw error;
}
if ((await treeDigest(root)) !== step.treeDigest) throw new Error("Source changed while checking");
step.phase = "checked";
await writeJournal(filename, journal);
}
if (step.phase === "checked") {
step.commit = await implementation.snapshot(root);
if ((await treeDigest(root)) !== step.treeDigest)
throw new Error("Publication snapshot changed checked files");
step.phase = "publishing";
await writeJournal(filename, journal);
}
await implementation.publish(root, step.commit!);
step.phase = "published";
await writeJournal(filename, journal);
}
if (step.phase === "refactor") {
const current = await treeDigest(root);
if (current !== step.treeDigest && !options.acceptEdits) throw new Error("Refactored source requires --accept-edits when resuming");
step.treeDigest = current; step.phase = "prepared"; await writeJournal(filename, journal);
// Keep subsequent automatic upgrades associated with the newly published
// identities, without renaming repositories or changing any selected branch.
// Explicit-spec callers without a managed graph retain the journal as their
// source of revisions instead.
const graphFile = path.join(plan.workbench, ".quixos/resource-graph.json");
let graphText: string | undefined;
try {
graphText = await fs.readFile(graphFile, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if (await treeDigest(root) !== step.treeDigest) throw new Error(`Source changed during upgrade: ${node.directory}; inspect ${filename}`);
if (step.phase === "prepared") {
try {await implementation.check(node, root, path.join(directory, `${id}-${node.directory.replaceAll("/", "-")}-${randomUUID()}`), plan.spec);}
catch (error) {step.phase = "refactor"; await writeJournal(filename, journal); throw error;}
if (await treeDigest(root) !== step.treeDigest) throw new Error("Source changed while checking");
step.phase = "checked"; await writeJournal(filename, journal);
if (graphText !== undefined) {
const previous = JSON.parse(graphText);
const snapshots = await Promise.all(
previous.resources.map(async (entry: { kind: string; source: Source; directory: string }) => ({
kind: entry.kind,
...entry.source,
directory: await location(
plan.workbench,
path.relative(plan.workbench, path.resolve(plan.workbench, entry.directory)),
),
})),
);
for (const node of plan.nodes.filter((entry) => entry.kind !== "workspace")) {
const step = journal.steps.find((entry) => entry.directory === node.directory)!;
if ((await treeDigest(await location(plan.workbench, node.directory))) !== step.treeDigest)
throw new Error("Published source changed before workbench graph refresh");
snapshots.push({
kind: node.kind,
repository: node.source.repository,
commit: step.commit,
directory: path.join(plan.workbench, node.directory),
});
}
const unique = [
...new Map(
snapshots.map((entry: { kind: string; repository: string; commit: string }) => [
JSON.stringify([entry.kind, entry.repository, entry.commit]),
entry,
]),
).values(),
];
const snapshotMap = path.join(directory, `${id}-published-snapshots.json`);
await fs.writeFile(snapshotMap, JSON.stringify({ resources: unique }));
const resolveResource = await createGitCapabilityResolver({
checkoutRoot: path.join(directory, `${id}-graph-resources`),
snapshotMap,
snapshotOnly: true,
});
const rootNode = plan.nodes.find((node) => node.kind === "workspace")!;
if (
(await treeDigest(await location(plan.workbench, rootNode.directory))) !==
journal.steps.find((step) => step.directory === rootNode.directory)!.treeDigest
)
throw new Error("Root source changed before workbench graph refresh");
const compiled = await compileWorkspaceRepository({
rootDirectory: await location(plan.workbench, rootNode.directory),
resolveResource,
});
// Managed repositories need not currently be reachable from the workspace.
// Keep them discoverable/checkpointed until explicitly removed by the user.
const resources = new Map<string, ResolvedCapabilityResource>(
compiled.resources.map((node) => [node.key, node]),
);
for (const node of plan.nodes.filter((entry) => entry.kind !== "workspace")) {
const step = journal.steps.find((entry) => entry.directory === node.directory)!;
const source = { resolver: "git" as const, repository: node.source.repository, commit: step.commit! };
const key = `${node.kind}\0${source.repository}\0${source.commit}`;
if (resources.has(key)) continue;
const directory = await location(plan.workbench, node.directory);
const standalone = await compileCapabilityResourceRepository({
rootDirectory: directory,
kind: node.kind as "package" | "interface",
source,
resolveResource,
});
for (const dependency of standalone.resources) resources.set(dependency.key, dependency);
resources.set(key, {
key,
kind: node.kind as "package" | "interface",
source,
directory,
lock: standalone.lock,
resource: standalone.resource,
dependencies: standalone.directResources,
});
}
await writeJournal(graphFile, {
formatVersion: 1,
quixos: compiled.lock.quixos,
directResources: [...compiled.directResources.entries()].map(([bindingKey, node]) => {
const [kind, binding] = bindingKey.split("\0");
return { kind, binding, resourceKey: node.key, directory: node.directory };
}),
resources: [...resources.values()].map((node) => ({
key: node.key,
kind: node.kind,
source: node.source,
directory: node.directory,
resourceId:
node.resource.kind === "interface"
? node.resource.revision.interfaceId
: node.resource.revision.packageId,
revisionId: node.resource.revision.revisionId,
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
binding,
resourceKey: dependency.key,
})),
})),
});
}
if (step.phase === "checked") {
step.commit = await implementation.snapshot(root);
if (await treeDigest(root) !== step.treeDigest) throw new Error("Publication snapshot changed checked files");
step.phase = "publishing"; await writeJournal(filename, journal);
}
await implementation.publish(root, step.commit!);
step.phase = "published"; await writeJournal(filename, journal);
}
// Keep subsequent automatic upgrades associated with the newly published
// identities, without renaming repositories or changing any selected branch.
// Explicit-spec callers without a managed graph retain the journal as their
// source of revisions instead.
const graphFile = path.join(plan.workbench, ".quixos/resource-graph.json");
let graphText: string | undefined;
try {graphText = await fs.readFile(graphFile, "utf8");} catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;}
if (graphText !== undefined) {
const previous = JSON.parse(graphText);
const snapshots = await Promise.all(previous.resources.map(async (entry: {kind: string; source: Source; directory: string}) => ({kind: entry.kind, ...entry.source, directory: await location(plan.workbench, path.relative(plan.workbench, path.resolve(plan.workbench, entry.directory)))})));
for (const node of plan.nodes.filter((entry) => entry.kind !== "workspace")) {
const step = journal.steps.find((entry) => entry.directory === node.directory)!;
if (await treeDigest(await location(plan.workbench, node.directory)) !== step.treeDigest) throw new Error("Published source changed before workbench graph refresh");
snapshots.push({kind: node.kind, repository: node.source.repository, commit: step.commit, directory: path.join(plan.workbench, node.directory)});
}
const unique = [...new Map(snapshots.map((entry: {kind: string; repository: string; commit: string}) => [JSON.stringify([entry.kind, entry.repository, entry.commit]), entry])).values()];
const snapshotMap = path.join(directory, `${id}-published-snapshots.json`);
await fs.writeFile(snapshotMap, JSON.stringify({resources: unique}));
const resolveResource = await createGitCapabilityResolver({checkoutRoot: path.join(directory, `${id}-graph-resources`), snapshotMap, snapshotOnly: true});
const rootNode = plan.nodes.find((node) => node.kind === "workspace")!;
if (await treeDigest(await location(plan.workbench, rootNode.directory)) !== journal.steps.find((step) => step.directory === rootNode.directory)!.treeDigest) throw new Error("Root source changed before workbench graph refresh");
const compiled = await compileWorkspaceRepository({rootDirectory: await location(plan.workbench, rootNode.directory), resolveResource});
// Managed repositories need not currently be reachable from the workspace.
// Keep them discoverable/checkpointed until explicitly removed by the user.
const resources = new Map<string, ResolvedCapabilityResource>(compiled.resources.map((node) => [node.key, node]));
for (const node of plan.nodes.filter((entry) => entry.kind !== "workspace")) {
const step = journal.steps.find((entry) => entry.directory === node.directory)!;
const source = {resolver: "git" as const, repository: node.source.repository, commit: step.commit!};
const key = `${node.kind}\0${source.repository}\0${source.commit}`;
if (resources.has(key)) continue;
const directory = await location(plan.workbench, node.directory);
const standalone = await compileCapabilityResourceRepository({rootDirectory: directory, kind: node.kind as "package" | "interface", source, resolveResource});
for (const dependency of standalone.resources) resources.set(dependency.key, dependency);
resources.set(key, {key, kind: node.kind as "package" | "interface", source, directory, lock: standalone.lock, resource: standalone.resource, dependencies: standalone.directResources});
}
await writeJournal(graphFile, {formatVersion: 1, quixos: compiled.lock.quixos,
directResources: [...compiled.directResources.entries()].map(([bindingKey, node]) => {const [kind, binding] = bindingKey.split("\0"); return {kind, binding, resourceKey: node.key, directory: node.directory};}),
resources: [...resources.values()].map((node) => ({key: node.key, kind: node.kind, source: node.source, directory: node.directory,
resourceId: node.resource.kind === "interface" ? node.resource.revision.interfaceId : node.resource.revision.packageId,
revisionId: node.resource.revision.revisionId, dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({binding, resourceKey: dependency.key}))})),
return {
id,
journal: filename,
revisions: journal.steps.map((step) => ({ directory: step.directory, commit: step.commit })),
activated: false,
};
} catch (error) {
throw new Error(`${error instanceof Error ? error.message : String(error)}; upgrade journal ${filename}`, {
cause: error,
});
}
return {id, journal: filename, revisions: journal.steps.map((step) => ({directory: step.directory, commit: step.commit})), activated: false};
} catch (error) {throw new Error(`${error instanceof Error ? error.message : String(error)}; upgrade journal ${filename}`, {cause: error});}
});
};
+42 -21
View File
@@ -3,10 +3,7 @@
import { writeFile } from "node:fs/promises";
import process from "node:process";
import { bindingSchema } from "../bindings/index.js";
import {
compileCapabilityResourceRepository,
type ResolvedCapabilityResource,
} from "./assembly.js";
import { compileCapabilityResourceRepository, type ResolvedCapabilityResource } from "./assembly.js";
import { createGitCapabilityResolver } from "./git-resolver.js";
const usage = `usage: quixos-resource-compile --root DIRECTORY --kind interface|package
@@ -22,7 +19,21 @@ const parseArgs = (args: string[]) => {
const key = args[index];
const value = args[index + 1];
if (!key?.startsWith("--") || !value) throw new Error(usage);
if (!["--root", "--kind", "--repository", "--commit", "--checkout-root", "--snapshot-map", "--snapshot-only", "--graph-out", "--schema-out"].includes(key) || values.has(key)) throw new Error(usage);
if (
![
"--root",
"--kind",
"--repository",
"--commit",
"--checkout-root",
"--snapshot-map",
"--snapshot-only",
"--graph-out",
"--schema-out",
].includes(key) ||
values.has(key)
)
throw new Error(usage);
values.set(key, value);
}
const rootDirectory = values.get("--root");
@@ -30,14 +41,14 @@ const parseArgs = (args: string[]) => {
const repository = values.get("--repository");
const commit = values.get("--commit")?.toLowerCase();
const checkoutRoot = values.get("--checkout-root");
if (!rootDirectory || !repository || !commit || !checkoutRoot ||
(kind !== "interface" && kind !== "package")) {
if (!rootDirectory || !repository || !commit || !checkoutRoot || (kind !== "interface" && kind !== "package")) {
throw new Error(usage);
}
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
throw new Error("--commit must be a full Git object ID");
}
if (values.has("--snapshot-only") && values.get("--snapshot-only") !== "true") throw new Error("--snapshot-only accepts true");
if (values.has("--snapshot-only") && values.get("--snapshot-only") !== "true")
throw new Error("--snapshot-only accepts true");
return {
rootDirectory,
kind,
@@ -56,9 +67,8 @@ const graphEntry = (node: ResolvedCapabilityResource) => ({
kind: node.kind,
source: node.source,
directory: node.directory,
resourceId: node.resource.kind === "interface"
? node.resource.revision.interfaceId
: node.resource.revision.packageId,
resourceId:
node.resource.kind === "interface" ? node.resource.revision.interfaceId : node.resource.revision.packageId,
revisionId: node.resource.revision.revisionId,
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
binding,
@@ -88,21 +98,32 @@ const main = async () => {
resolveResource,
});
if (options.graphOut) {
await writeFile(options.graphOut, `${JSON.stringify({
formatVersion: 1,
quixos: compiled.lock.quixos,
root: graphEntry(compiled.resources.find((node) =>
node.source.repository === options.repository &&
node.source.commit === options.commit &&
node.kind === options.kind)!),
resources: compiled.resources.map(graphEntry),
}, null, 2)}\n`);
await writeFile(
options.graphOut,
`${JSON.stringify(
{
formatVersion: 1,
quixos: compiled.lock.quixos,
root: graphEntry(
compiled.resources.find(
(node) =>
node.source.repository === options.repository &&
node.source.commit === options.commit &&
node.kind === options.kind,
)!,
),
resources: compiled.resources.map(graphEntry),
},
null,
2,
)}\n`,
);
}
if (options.schemaOut) await writeFile(options.schemaOut, `${JSON.stringify(bindingSchema(compiled), null, 2)}\n`);
process.stdout.write(`${JSON.stringify(compiled.resource, null, 2)}\n`);
};
main().catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
process.exitCode = 1;
});
+310 -86
View File
@@ -1,33 +1,57 @@
import fs from "node:fs/promises";
import path from "node:path";
import {contentDigest} from "../capability-model/evolution.js";
import {validateMigrationCatalog, type MigrationCatalog, type MigrationDeclaration} from "../capability-model/migrations.js";
import {formatQuixosLock, type GitSource} from "../resource-lock/index.js";
import {parseQx, walkSyntax} from "./source.js";
import type {StructuralRequest} from "./structural-plan.js";
import {addImplementation} from "./implementation-edit.js";
import {reactPlatformTypes} from "../bindings/react-platform.js";
import { contentDigest } from "../capability-model/evolution.js";
import {
validateMigrationCatalog,
type MigrationCatalog,
type MigrationDeclaration,
} from "../capability-model/migrations.js";
import { formatQuixosLock, type GitSource } from "../resource-lock/index.js";
import { parseQx, walkSyntax } from "./source.js";
import type { StructuralRequest } from "./structural-plan.js";
import { addImplementation } from "./implementation-edit.js";
import { reactPlatformTypes } from "../bindings/react-platform.js";
type Source = {repository: string; commit: string};
type PackageEditModel = {generatedBy: "qx-scaffold-v1"; name: string; id: string; revision: string; exports: {name: string; id: string; file: string; migration?: boolean}[]};
type Source = { repository: string; commit: string };
type PackageEditModel = {
generatedBy: "qx-scaffold-v1";
name: string;
id: string;
revision: string;
exports: { name: string; id: string; file: string; migration?: boolean }[];
};
export type ScaffoldRecipe = {
template?: "typescript" | "typescript-react";
source: Source; directory?: string; name?: string; id?: string; revision?: string;
source: Source;
directory?: string;
name?: string;
id?: string;
revision?: string;
declaration?: string;
/** Initial authored files for a composed recipe; only used for a new package. */
initialFiles?: Record<string, string>;
tools?: {quixos: Source; protocol: Source; helpers: Source; sdk: Source};
tools?: { quixos: Source; protocol: Source; helpers: Source; sdk: Source };
nixifyPluginUrl?: string;
migration?: Omit<MigrationDeclaration, "implementation"> & {contracts: Record<string, unknown>};
migration?: Omit<MigrationDeclaration, "implementation"> & { contracts: Record<string, unknown> };
};
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
const source = (value: Source): GitSource => {
if (!value || typeof value.repository !== "string" || typeof value.commit !== "string") throw new Error("Scaffold requires an exact source identity; use qx-workspace from a registered repository");
if (!value || typeof value.repository !== "string" || typeof value.commit !== "string")
throw new Error("Scaffold requires an exact source identity; use qx-workspace from a registered repository");
const url = new URL(value.repository);
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit)) throw new Error("Scaffolds require credential-free HTTPS sources and full exact commits");
return {resolver: "git", ...value};
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.search ||
url.hash ||
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit)
)
throw new Error("Scaffolds require credential-free HTTPS sources and full exact commits");
return { resolver: "git", ...value };
};
const nixSource = (value: Source) => `git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`;
const nixSource = (value: Source) =>
`git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`;
const nixString = (value: string) => JSON.stringify(value).replaceAll("${", "\\${");
const safeName = (name: string | undefined): string => {
if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("Scaffold requires a simple authored name");
@@ -35,7 +59,8 @@ const safeName = (name: string | undefined): string => {
};
const ownedJson = async <T>(root: string, file: string): Promise<T> => {
const target = path.join(root, file);
if (!(await fs.realpath(target)).startsWith(`${await fs.realpath(root)}/`)) throw new Error("Scaffold input escapes repository");
if (!(await fs.realpath(target)).startsWith(`${await fs.realpath(root)}/`))
throw new Error("Scaffold input escapes repository");
const value = JSON.parse(await fs.readFile(target, "utf8"));
if (value.generatedBy !== "qx-scaffold-v1") throw new Error(`Not scaffold-owned: ${file}`);
return value;
@@ -43,45 +68,133 @@ const ownedJson = async <T>(root: string, file: string): Promise<T> => {
/** Recipes describe structural edits; planStructure owns validation/journaling.
* Implementation files are created once; later edits preserve authored wiring. */
export const scaffoldRecipe = async (root: string, command: "package" | "function" | "migration" | "refresh", spec: ScaffoldRecipe): Promise<StructuralRequest> => {
if (command === "refresh") throw new Error("Scaffold refresh has been removed. Edit declarations and typed server wiring directly, then run qx-workspace check. Use scaffold function to add a declaration and handler together.");
export const scaffoldRecipe = async (
root: string,
command: "package" | "function" | "migration" | "refresh",
spec: ScaffoldRecipe,
): Promise<StructuralRequest> => {
if (command === "refresh")
throw new Error(
"Scaffold refresh has been removed. Edit declarations and typed server wiring directly, then run qx-workspace check. Use scaffold function to add a declaration and handler together.",
);
source(spec.source);
if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory)) throw new Error("Scaffold directory must be contained");
if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory))
throw new Error("Scaffold directory must be contained");
const prefix = spec.directory ? `${spec.directory}/` : "";
const files: StructuralRequest["files"] = [];
const create = (file: string, content: string) => files.push({file: prefix + file, create: content});
const generated = (file: string, content: string) => files.push({file: prefix + file, generated: content});
const create = (file: string, content: string) => files.push({ file: prefix + file, create: content });
const generated = (file: string, content: string) => files.push({ file: prefix + file, generated: content });
let packageModel: PackageEditModel;
let catalog: MigrationCatalog & {generatedBy: "qx-scaffold-v1"};
let catalog: MigrationCatalog & { generatedBy: "qx-scaffold-v1" };
if (command === "package") {
const name = safeName(spec.name);
if (spec.template && !["typescript", "typescript-react"].includes(spec.template)) throw new Error("Unknown package template");
if (spec.template && !["typescript", "typescript-react"].includes(spec.template))
throw new Error("Unknown package template");
const react = spec.template === "typescript-react";
if (!spec.id || !spec.revision || !spec.tools) throw new Error("Package scaffold requires id, revision, and exact quixos/protocol/helpers/sdk tool sources");
if (!spec.id || !spec.revision || !spec.tools)
throw new Error("Package scaffold requires id, revision, and exact quixos/protocol/helpers/sdk tool sources");
Object.values(spec.tools).forEach(source);
packageModel = {generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: []};
catalog = {generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: []};
if (react) packageModel.exports.push({name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts"});
create("package.qx", `package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n${react ? ` function sourceGet id ${JSON.stringify(`export:${name}:source`)} : unit -> string;\n` : ""}}\n`);
create("quixos.lock", formatQuixosLock({formatVersion: 1, quixos: source(spec.tools.quixos), resources: []}));
create("package.json", json({name: `@quixos/${name.toLowerCase()}`, version: "0.1.0", private: true, type: "module", packageManager: "yarn@4.18.0",
scripts: {build: "tsc -p tsconfig.json", typecheck: "tsc --noEmit"}, dependencies: {"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`},
devDependencies: {"@types/node": "^24", typescript: "^7.0.2", ...(react ? {react: "^18.3.1", "@types/react": "^18.3.12", esbuild: "^0.25.12"} : {})}}));
create("tsconfig.json", json({compilerOptions: {target: "ES2023", module: "NodeNext", moduleResolution: "NodeNext", strict: true, types: ["node", ...(react ? ["react"] : [])], outDir: "dist", rootDir: "src", skipLibCheck: true, ...(react ? {jsx: "react-jsx", esModuleInterop: true} : {})}, include: ["src/**/*.ts", "src/**/*.tsx"]}));
packageModel = { generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: [] };
catalog = { generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: [] };
if (react)
packageModel.exports.push({ name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts" });
create(
"package.qx",
`package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n${react ? ` function sourceGet id ${JSON.stringify(`export:${name}:source`)} : unit -> string;\n` : ""}}\n`,
);
create("quixos.lock", formatQuixosLock({ formatVersion: 1, quixos: source(spec.tools.quixos), resources: [] }));
create(
"package.json",
json({
name: `@quixos/${name.toLowerCase()}`,
version: "0.1.0",
private: true,
type: "module",
packageManager: "yarn@4.18.0",
scripts: { build: "tsc -p tsconfig.json", typecheck: "tsc --noEmit" },
dependencies: {
"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`,
},
devDependencies: {
"@types/node": "^24",
typescript: "^7.0.2",
...(react ? { react: "^18.3.1", "@types/react": "^18.3.12", esbuild: "^0.25.12" } : {}),
},
}),
);
create(
"tsconfig.json",
json({
compilerOptions: {
target: "ES2023",
module: "NodeNext",
moduleResolution: "NodeNext",
strict: true,
types: ["node", ...(react ? ["react"] : [])],
outDir: "dist",
rootDir: "src",
skipLibCheck: true,
...(react ? { jsx: "react-jsx", esModuleInterop: true } : {}),
},
include: ["src/**/*.ts", "src/**/*.tsx"],
}),
);
if (react) {
create("src/component.tsx", `// Props are opaque at the platform boundary until capability generics exist.\nexport default function Component(_props: {camino: unknown; render: unknown; dispatch: (action: unknown) => void}) {\n return <section><h1>${name}</h1><p>Edit this component, then run qx-workspace check.</p></section>;\n}\n`);
create("src/impl/sourceGet.ts", `import componentSource from "../component.js?browser-source";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => componentSource;\n`);
create("src/browser-assets.d.ts", `declare module "*?browser-source" { const source: string; export default source; }\ndeclare module "*.css" {}\n`);
create(
"src/component.tsx",
`// Props are opaque at the platform boundary until capability generics exist.\nexport default function Component(_props: {camino: unknown; render: unknown; dispatch: (action: unknown) => void}) {\n return <section><h1>${name}</h1><p>Edit this component, then run qx-workspace check.</p></section>;\n}\n`,
);
create(
"src/impl/sourceGet.ts",
`import componentSource from "../component.js?browser-source";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => componentSource;\n`,
);
create(
"src/browser-assets.d.ts",
`declare module "*?browser-source" { const source: string; export default source; }\ndeclare module "*.css" {}\n`,
);
create("src/gen/web-studio-react-runtime.d.ts", reactPlatformTypes);
}
create(".gitignore", "node_modules/\ndist/\n.quixos/\nresult\n.yarn/install-state.gz\n");
create(".yarnrc.yml", `nodeLinker: node-modules\nenableScripts: true\nnpmMinimalAgeGate: 0\napprovedGitRepositories:\n - ${JSON.stringify(spec.tools.sdk.repository)}\nsupportedArchitectures:\n os: [current, linux]\n cpu: [current, x64, arm64]\n libc: [current, glibc]\n`);
const nixifyPluginUrl = spec.nixifyPluginUrl ?? "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js";
create(
".yarnrc.yml",
`nodeLinker: node-modules\nenableScripts: true\nnpmMinimalAgeGate: 0\napprovedGitRepositories:\n - ${JSON.stringify(spec.tools.sdk.repository)}\nsupportedArchitectures:\n os: [current, linux]\n cpu: [current, x64, arm64]\n libc: [current, glibc]\n`,
);
const nixifyPluginUrl =
spec.nixifyPluginUrl ??
"https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js";
const plugin = new URL(nixifyPluginUrl);
if (plugin.protocol !== "https:" || plugin.username || plugin.password || plugin.search || plugin.hash || !/\/commit\/[a-f0-9]{40,64}\//.test(plugin.pathname)) throw new Error("Nixify plugin must have an exact credential-free HTTPS commit URL");
generated("quixos.toolchain.json", json({generatedBy: "qx-scaffold-v1", nixifyPluginUrl}));
create("quixos.check.json", json({backend: "typescript", bindingOutput: "src/gen/qx.ts", ...(react ? {options: {messages: {"org.quixos.web-studio.ReactProps": {module: "@quixos/camino-package-runtime", export: "opaqueReactPropsBinding"}}}} : {})}));
create("flake.nix", `{
if (
plugin.protocol !== "https:" ||
plugin.username ||
plugin.password ||
plugin.search ||
plugin.hash ||
!/\/commit\/[a-f0-9]{40,64}\//.test(plugin.pathname)
)
throw new Error("Nixify plugin must have an exact credential-free HTTPS commit URL");
generated("quixos.toolchain.json", json({ generatedBy: "qx-scaffold-v1", nixifyPluginUrl }));
create(
"quixos.check.json",
json({
backend: "typescript",
bindingOutput: "src/gen/qx.ts",
...(react
? {
options: {
messages: {
"org.quixos.web-studio.ReactProps": {
module: "@quixos/camino-package-runtime",
export: "opaqueReactPropsBinding",
},
},
},
}
: {}),
}),
);
create(
"flake.nix",
`{
inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))};
inputs.nixpkgs.follows = "protocol/nixpkgs";
inputs.flake-utils.follows = "protocol/flake-utils";
@@ -93,54 +206,101 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
migrationEntrypoint = "dist/migrate.js";
installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; };
};
}\n`);
}\n`,
);
} else {
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
// Derive the edit model from authored declarations; it is never persisted.
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
const syntax = parseQx(authored);
if (syntax.diagnostics.length) throw new Error("Cannot scaffold into an invalid package.qx; fix the reported syntax first");
const declaration = [...walkSyntax(syntax.root)].find(node => node.kind === "packageResourceDecl");
if (syntax.diagnostics.length)
throw new Error("Cannot scaffold into an invalid package.qx; fix the reported syntax first");
const declaration = [...walkSyntax(syntax.root)].find((node) => node.kind === "packageResourceDecl");
if (!declaration) throw new Error("Expected a package declaration");
const text = (node: typeof declaration) => authored.slice(node.start, node.end);
const literals = declaration.children.filter(node => node.kind === "stringLiteral");
packageModel = {generatedBy: "qx-scaffold-v1", name: text(declaration.children.find(node => node.kind === "identifier")!),
id: JSON.parse(text(literals[0])), revision: JSON.parse(text(literals[1])), exports: []};
const literals = declaration.children.filter((node) => node.kind === "stringLiteral");
packageModel = {
generatedBy: "qx-scaffold-v1",
name: text(declaration.children.find((node) => node.kind === "identifier")!),
id: JSON.parse(text(literals[0])),
revision: JSON.parse(text(literals[1])),
exports: [],
};
for (const node of walkSyntax(declaration)) {
if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)) continue;
const name = safeName(text(node.children.find(child => child.kind === "identifier")!));
const id = JSON.parse(text(node.children.find(child => child.kind === "stringLiteral")!));
if (packageModel.exports.some(entry => entry.id === id || entry.name === name)) throw new Error("Duplicate package export name or ID");
const migration = catalog.migrations.find(entry => entry.implementation.exportId === id);
packageModel.exports.push({name, id, file: migration?.implementation.file ?? `src/impl/${name}.ts`, ...(migration ? {migration: true} : {})});
if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind))
continue;
const name = safeName(text(node.children.find((child) => child.kind === "identifier")!));
const id = JSON.parse(text(node.children.find((child) => child.kind === "stringLiteral")!));
if (packageModel.exports.some((entry) => entry.id === id || entry.name === name))
throw new Error("Duplicate package export name or ID");
const migration = catalog.migrations.find((entry) => entry.implementation.exportId === id);
packageModel.exports.push({
name,
id,
file: migration?.implementation.file ?? `src/impl/${name}.ts`,
...(migration ? { migration: true } : {}),
});
}
{
const name = safeName(spec.name);
if (!spec.id || packageModel.exports.some((entry) => entry.id === spec.id || entry.name === name)) throw new Error("New export requires a unique name and ID");
if (!spec.id || packageModel.exports.some((entry) => entry.id === spec.id || entry.name === name))
throw new Error("New export requires a unique name and ID");
const declaration = spec.declaration ?? `function ${name} id ${JSON.stringify(spec.id)} : unit -> unit;`;
const parsed = parseQx(`package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`);
const exports = [...walkSyntax(parsed.root)].filter((node) => ["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind));
if (parsed.diagnostics.length || exports.length !== 1) throw new Error("Expected one valid package export declaration");
const exports = [...walkSyntax(parsed.root)].filter((node) =>
["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind),
);
if (parsed.diagnostics.length || exports.length !== 1)
throw new Error("Expected one valid package export declaration");
const wrapped = `package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`;
const node = exports[0];
const derived = [...walkSyntax(node)].some((entry) => entry.kind === "eventClause");
if (command === "migration" && (node.kind !== "packageFunctionExport" || spec.declaration)) throw new Error("Migration exports use the scaffold's unit function declaration and dedicated migration entrypoint");
if (wrapped.slice(node.children.find((child) => child.kind === "identifier")!.start, node.children.find((child) => child.kind === "identifier")!.end) !== name
|| JSON.parse(wrapped.slice(node.children.find((child) => child.kind === "stringLiteral")!.start, node.children.find((child) => child.kind === "stringLiteral")!.end)) !== spec.id) throw new Error("Declaration name/ID must match its registration");
files.push({file: prefix + "package.qx", edits: [{operation: "append", parent: {kind: "packageResourceDecl", id: packageModel.id}, source: declaration}]});
if (command === "migration" && (node.kind !== "packageFunctionExport" || spec.declaration))
throw new Error(
"Migration exports use the scaffold's unit function declaration and dedicated migration entrypoint",
);
if (
wrapped.slice(
node.children.find((child) => child.kind === "identifier")!.start,
node.children.find((child) => child.kind === "identifier")!.end,
) !== name ||
JSON.parse(
wrapped.slice(
node.children.find((child) => child.kind === "stringLiteral")!.start,
node.children.find((child) => child.kind === "stringLiteral")!.end,
),
) !== spec.id
)
throw new Error("Declaration name/ID must match its registration");
files.push({
file: prefix + "package.qx",
edits: [
{ operation: "append", parent: { kind: "packageResourceDecl", id: packageModel.id }, source: declaration },
],
});
const file = `src/${command === "migration" ? "migrations" : "impl"}/${name}.ts`;
const implementation = command === "migration" ? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise<void> => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\n`
: `import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`;
const implementation =
command === "migration"
? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise<void> => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\n`
: `import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`;
create(file, implementation);
packageModel.exports.push({name, id: spec.id, file, ...(command === "migration" ? {migration: true} : {})});
packageModel.exports.push({ name, id: spec.id, file, ...(command === "migration" ? { migration: true } : {}) });
if (command === "migration") {
if (!spec.migration) throw new Error("Migration scaffold requires retained contracts and an explicit transition");
const {contracts, ...transition} = spec.migration;
if (!spec.migration)
throw new Error("Migration scaffold requires retained contracts and an explicit transition");
const { contracts, ...transition } = spec.migration;
for (const [digest, contract] of Object.entries(contracts)) {
if (contentDigest(contract) !== digest || (catalog.contracts[digest] && contentDigest(catalog.contracts[digest]) !== digest)) throw new Error("Retained migration contract mismatch");
if (
contentDigest(contract) !== digest ||
(catalog.contracts[digest] && contentDigest(catalog.contracts[digest]) !== digest)
)
throw new Error("Retained migration contract mismatch");
catalog.contracts[digest] = contract;
}
catalog.migrations.push({...transition, implementation: {exportId: spec.id, file, digest: contentDigest(implementation)}});
catalog.migrations.push({
...transition,
implementation: { exportId: spec.id, file, digest: contentDigest(implementation) },
});
}
}
}
@@ -150,44 +310,108 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
const entry = packageModel.exports.at(-1)!;
const file = prefix + "src/server.ts";
const before = await fs.readFile(path.join(root, file), "utf8");
files.push({file, expected: before, replace: addImplementation(before, "createRuntime", entry.name, `./${entry.file.slice(4, -3)}.js`, !!entry.migration)});
files.push({
file,
expected: before,
replace: addImplementation(
before,
"createRuntime",
entry.name,
`./${entry.file.slice(4, -3)}.js`,
!!entry.migration,
),
});
if (entry.migration) {
const file = prefix + "src/migrate.ts";
const before = await fs.readFile(path.join(root, file), "utf8");
files.push({file, expected: before, replace: addImplementation(before, "serveMigration", entry.id, `./${entry.file.slice(4, -3)}.js`)});
files.push({
file,
expected: before,
replace: addImplementation(before, "serveMigration", entry.id, `./${entry.file.slice(4, -3)}.js`),
});
}
} else {
create("src/server.ts", `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` + packageModel.exports.filter((entry) => !entry.migration).map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") +
`servePackageRuntime(createRuntime({\n` + packageModel.exports.map((entry) => ` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${packageModel.exports.filter((value) => !value.migration).indexOf(entry)}`},`).join("\n") + `\n}));\n`);
const migrations = packageModel.exports.filter((entry) => entry.migration);
create("src/migrate.ts", `import {serveMigration} from "@quixos/camino-package-runtime";\n` + migrations.map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") + `await serveMigration({${migrations.map((entry, index) => `${JSON.stringify(entry.id)}: impl${index}`).join(", ")}});\n`);
create(
"src/server.ts",
`import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` +
packageModel.exports
.filter((entry) => !entry.migration)
.map(
(entry, index) =>
`import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`,
)
.join("") +
`servePackageRuntime(createRuntime({\n` +
packageModel.exports
.map(
(entry) =>
` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${packageModel.exports.filter((value) => !value.migration).indexOf(entry)}`},`,
)
.join("\n") +
`\n}));\n`,
);
const migrations = packageModel.exports.filter((entry) => entry.migration);
create(
"src/migrate.ts",
`import {serveMigration} from "@quixos/camino-package-runtime";\n` +
migrations
.map(
(entry, index) =>
`import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`,
)
.join("") +
`await serveMigration({${migrations.map((entry, index) => `${JSON.stringify(entry.id)}: impl${index}`).join(", ")}});\n`,
);
}
generated("descriptor.quixos-package.txtpb", `# Generated by qx-scaffold-v1\npackage_id: ${JSON.stringify(packageModel.id)}\npackage_revision_id: ${JSON.stringify(packageModel.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` + packageModel.exports.map((entry) => `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`).join(""));
generated(
"descriptor.quixos-package.txtpb",
`# Generated by qx-scaffold-v1\npackage_id: ${JSON.stringify(packageModel.id)}\npackage_revision_id: ${JSON.stringify(packageModel.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` +
packageModel.exports
.map(
(entry) =>
`exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`,
)
.join(""),
);
if (spec.initialFiles) {
if (command !== "package") throw new Error("initialFiles is only valid when creating a package");
const authored = spec.initialFiles["package.qx"];
if (authored !== undefined) {
const parsed = parseQx(authored);
const declaration = [...walkSyntax(parsed.root)].find(n => n.kind === "packageResourceDecl");
const literals = declaration?.children.filter(n => n.kind === "stringLiteral") ?? [];
const identifier = declaration?.children.find(n => n.kind === "identifier");
if (parsed.diagnostics.length || literals.length !== 2 || !identifier || authored.slice(identifier.start, identifier.end) !== spec.name ||
JSON.parse(authored.slice(literals[0].start, literals[0].end)) !== spec.id || JSON.parse(authored.slice(literals[1].start, literals[1].end)) !== spec.revision)
const declaration = [...walkSyntax(parsed.root)].find((n) => n.kind === "packageResourceDecl");
const literals = declaration?.children.filter((n) => n.kind === "stringLiteral") ?? [];
const identifier = declaration?.children.find((n) => n.kind === "identifier");
if (
parsed.diagnostics.length ||
literals.length !== 2 ||
!identifier ||
authored.slice(identifier.start, identifier.end) !== spec.name ||
JSON.parse(authored.slice(literals[0].start, literals[0].end)) !== spec.id ||
JSON.parse(authored.slice(literals[1].start, literals[1].end)) !== spec.revision
)
throw new Error("Initial package declaration must match the provisioned name, ID and revision");
}
for (const [file, content] of Object.entries(spec.initialFiles)) {
if (typeof content !== "string" || ["quixos.lock", "flake.nix", "quixos.toolchain.json", "quixos.check.json", "package.json"].includes(file)) throw new Error(`Not an initial authored file: ${file}`);
const existing = files.findIndex(entry => entry.file === prefix + file);
if (
typeof content !== "string" ||
["quixos.lock", "flake.nix", "quixos.toolchain.json", "quixos.check.json", "package.json"].includes(file)
)
throw new Error(`Not an initial authored file: ${file}`);
const existing = files.findIndex((entry) => entry.file === prefix + file);
if (existing >= 0) files.splice(existing, 1);
create(file, content);
}
// A composed React recipe supplies its own modules and complete server.
if (reactRecipe(spec)) {
for (const file of ["src/component.tsx", "src/impl/sourceGet.ts", "descriptor.quixos-package.txtpb"])
if (!(file in spec.initialFiles)) {const index = files.findIndex(entry => entry.file === prefix + file); if (index >= 0) files.splice(index, 1);}
if (!(file in spec.initialFiles)) {
const index = files.findIndex((entry) => entry.file === prefix + file);
if (index >= 0) files.splice(index, 1);
}
}
}
return {kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files};
return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files };
};
const reactRecipe = (spec: ScaffoldRecipe) => spec.template === "typescript-react" && spec.initialFiles?.["package.qx"] && spec.initialFiles?.["src/server.ts"];
const reactRecipe = (spec: ScaffoldRecipe) =>
spec.template === "typescript-react" && spec.initialFiles?.["package.qx"] && spec.initialFiles?.["src/server.ts"];
+22 -6
View File
@@ -19,7 +19,11 @@ export const planAtomScaffold = (workspace: string, name: string, id: string) =>
/** Validate an in-memory proposal before creating files. Never replace a fragment. */
export const scaffoldAtom = async (options: {
root: string; name: string; id: string; write: boolean; resolveResource: CapabilityRepositoryResolver;
root: string;
name: string;
id: string;
write: boolean;
resolveResource: CapabilityRepositoryResolver;
}) => {
const before = await readQxSource(options.root, "workspace.qx");
const plan = planAtomScaffold(before, options.name, options.id);
@@ -31,14 +35,17 @@ export const scaffoldAtom = async (options: {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const observed = new Map<string, string>();
await compileWorkspaceRepository({ rootDirectory: options.root, resolveResource: options.resolveResource,
await compileWorkspaceRepository({
rootDirectory: options.root,
resolveResource: options.resolveResource,
readSource: async (name) => {
if (name === "workspace.qx") return plan.workspace;
if (name === plan.fileName) return plan.fragment;
const text = await readQxSource(options.root, name);
observed.set(name, text);
return text;
} });
},
});
if (!options.write) return plan;
const workspacePath = path.join(options.root, "workspace.qx");
const temporary = path.join(options.root, `.qx-scaffold-${randomUUID()}.tmp`);
@@ -47,13 +54,22 @@ export const scaffoldAtom = async (options: {
try {
const fragment = await open(fragmentPath, "wx");
createdFragment = true;
try { await fragment.writeFile(plan.fragment); } finally { await fragment.close(); }
try {
await fragment.writeFile(plan.fragment);
} finally {
await fragment.close();
}
const file = await open(temporary, "wx", (await lstat(workspacePath)).mode);
createdTemporary = true;
try { await file.writeFile(plan.workspace); } finally { await file.close(); }
try {
await file.writeFile(plan.workspace);
} finally {
await file.close();
}
observed.set("workspace.qx", before);
for (const [name, text] of observed) {
if (await readQxSource(options.root, name) !== text) throw new Error(`QX source changed during scaffolding: ${name}`);
if ((await readQxSource(options.root, name)) !== text)
throw new Error(`QX source changed during scaffolding: ${name}`);
}
await rename(temporary, workspacePath);
createdTemporary = false;
+13 -9
View File
@@ -16,9 +16,7 @@ export const readQxSource = async (root: string, name: string) => {
};
/** Local imports share one workspace scope; paths are always repository-relative. */
export const resolveQxSources = async (
read: (name: string) => Promise<string>, entry = "workspace.qx",
) => {
export const resolveQxSources = async (read: (name: string) => Promise<string>, entry = "workspace.qx") => {
const files = new Map<string, string>();
const active: string[] = [];
const visited = new Set<string>();
@@ -34,8 +32,8 @@ export const resolveQxSources = async (
if (visited.has(name)) return;
const text = await read(name);
const syntax = parseQx(text, name);
if (syntax.diagnostics.length) throw new Error(syntax.diagnostics.map((d) =>
`${name}:${d.line}:${d.column + 1}: ${d.message}`).join("\n"));
if (syntax.diagnostics.length)
throw new Error(syntax.diagnostics.map((d) => `${name}:${d.line}:${d.column + 1}: ${d.message}`).join("\n"));
const declaration = syntax.root.children[0]!;
if (declaration.kind !== (root ? "workspaceDecl" : "fragmentDecl"))
throw new Error(`${name}: expected ${root ? "workspace" : "fragment"} document`);
@@ -60,16 +58,22 @@ export const resolveQxSources = async (
};
await visit(entry, true);
return {
source, sourceFiles: [...files.keys()], files,
source,
sourceFiles: [...files.keys()],
files,
originalPosition(line: number, column: number) {
const lines = source.split("\n");
const offset = lines.slice(0, line - 1).reduce((sum, value) => sum + value.length + 1, 0) +
const offset =
lines.slice(0, line - 1).reduce((sum, value) => sum + value.length + 1, 0) +
[...(lines[line - 1] ?? "")].slice(0, column).join("").length;
const segment = segments.find((entry) => entry.start <= offset && entry.end > offset);
if (!segment) return { fileName: entry, line, column };
const prefix = files.get(segment.fileName)!.slice(0, segment.sourceStart + offset - segment.start);
return { fileName: segment.fileName, line: prefix.split("\n").length,
column: prefix.length - prefix.lastIndexOf("\n") - 1 };
return {
fileName: segment.fileName,
line: prefix.split("\n").length,
column: prefix.length - prefix.lastIndexOf("\n") - 1,
};
},
};
};
+46 -18
View File
@@ -17,20 +17,26 @@ export const parseQx = (source: string, fileName = "<memory>") => {
kind: QuixosCapabilityParser.ruleNames[context.ruleIndex]!,
start: offset(context.start?.start ?? 0),
end: offset((context.stop?.stop ?? -1) + 1),
children: context.children.flatMap((child) => child instanceof ParserRuleContext ? [node(child)] : []),
children: context.children.flatMap((child) => (child instanceof ParserRuleContext ? [node(child)] : [])),
});
return {
source, fileName,
source,
fileName,
root: node(parsed.tree),
diagnostics: parsed.diagnostics.map((diagnostic) => {
const line = source.split("\n")[diagnostic.line - 1] ?? "";
return { ...diagnostic, column: [...line].slice(0, diagnostic.column).join("").length };
}),
tokens: parsed.tokens.getTokens().filter((token) => token.type !== -1).map((token) => ({
kind: QuixosCapabilityParser.symbolicNames[token.type] ?? "token",
start: offset(token.start), end: offset(token.stop + 1),
text: token.text ?? "", trivia: token.channel !== 0,
})),
tokens: parsed.tokens
.getTokens()
.filter((token) => token.type !== -1)
.map((token) => ({
kind: QuixosCapabilityParser.symbolicNames[token.type] ?? "token",
start: offset(token.start),
end: offset(token.stop + 1),
text: token.text ?? "",
trivia: token.channel !== 0,
})),
};
};
@@ -41,8 +47,14 @@ export const applySourceEdits = (source: string, edits: readonly SourceEdit[]) =
let previousStart = -1;
let result = "";
for (const edit of sorted) {
if (!Number.isInteger(edit.start) || !Number.isInteger(edit.end) ||
edit.start < end || edit.start === previousStart || edit.end < edit.start || edit.end > source.length) {
if (
!Number.isInteger(edit.start) ||
!Number.isInteger(edit.end) ||
edit.start < end ||
edit.start === previousStart ||
edit.end < edit.start ||
edit.end > source.length
) {
throw new Error("Invalid or overlapping source edits");
}
result += source.slice(end, edit.start) + edit.text;
@@ -68,14 +80,27 @@ export const lintQx = (source: string, fileName = "<memory>") => {
const importPath: string = JSON.parse(source.slice(literal.start, literal.end));
let message: string | undefined;
let code = "invalid-source-import";
try { validateQxImportPath(importPath); } catch (error) { message = (error as Error).message; }
if (!message && seen.has(importPath)) { code = "duplicate-source-import"; message = `Repeated local import ${importPath}`; }
try {
validateQxImportPath(importPath);
} catch (error) {
message = (error as Error).message;
}
if (!message && seen.has(importPath)) {
code = "duplicate-source-import";
message = `Repeated local import ${importPath}`;
}
seen.add(importPath);
if (message) {
const prefix = source.slice(0, node.start);
diagnostics.push({ phase: "syntax", code, message, fileName,
line: prefix.split("\n").length, column: prefix.length - prefix.lastIndexOf("\n") - 1,
severity: code === "duplicate-source-import" ? "warning" : "error" });
diagnostics.push({
phase: "syntax",
code,
message,
fileName,
line: prefix.split("\n").length,
column: prefix.length - prefix.lastIndexOf("\n") - 1,
severity: code === "duplicate-source-import" ? "warning" : "error",
});
}
}
return diagnostics;
@@ -117,12 +142,15 @@ export const addWorkspaceImport = (source: string, importPath: string) => {
}
const brace = syntax.tokens.find((token) => token.kind === "LBRACE")!;
const newline = source.includes("\r\n") ? "\r\n" : "\n";
return applySourceEdits(source, [{ start: brace.end, end: brace.end,
text: `${newline} import ${JSON.stringify(importPath)};` }]);
return applySourceEdits(source, [
{ start: brace.end, end: brace.end, text: `${newline} import ${JSON.stringify(importPath)};` },
]);
};
export const validateQxImportPath = (value: string) => {
if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.qx$/.test(value) ||
value.split("/").some((part) => part === "." || part === ".."))
if (
!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.qx$/.test(value) ||
value.split("/").some((part) => part === "." || part === "..")
)
throw new Error(`Invalid repository-relative QX import path: ${value}`);
};
+150 -43
View File
@@ -12,23 +12,46 @@ export type StructuralEdit =
| { operation: "import"; kind: "interface" | "package"; name: string }
| { operation: "semantic-major"; target: StructuralSelector; major: number }
| { operation: "conformance-id"; target: StructuralSelector; id: string }
| { operation: "quixos-pin"; source: {repository: string; commit: string} }
| { operation: "dependency"; kind: "interface" | "package"; name: string; source: {repository: string; commit: string} | null };
| { operation: "quixos-pin"; source: { repository: string; commit: string } }
| {
operation: "dependency";
kind: "interface" | "package";
name: string;
source: { repository: string; commit: string } | null;
};
// Deliberately exclude valueType/identifier/stringLiteral: callers operate on
// declaration structure, not arbitrary token offsets or lockfile text patches.
const selectable = new Set(["workspaceDecl", "fragmentDecl", "interfaceResourceDecl", "packageResourceDecl", "atomDecl",
"valueMember", "relationshipMember", "operationMember", "packageOperationExport", "packageFunctionExport", "packageConstructorExport",
"conformanceDecl", "stateDecl", "edgeDecl", "constructorBindingDecl", "resourceImportDecl", "sourceImportDecl", "operationBindingDecl"]);
const selectable = new Set([
"workspaceDecl",
"fragmentDecl",
"interfaceResourceDecl",
"packageResourceDecl",
"atomDecl",
"valueMember",
"relationshipMember",
"operationMember",
"packageOperationExport",
"packageFunctionExport",
"packageConstructorExport",
"conformanceDecl",
"stateDecl",
"edgeDecl",
"constructorBindingDecl",
"resourceImportDecl",
"sourceImportDecl",
"operationBindingDecl",
]);
/** Bind only the template root identity; schema/atom identities are reusable.
* The revision's real identity is derived from its containing commit at compile time. */
export const instantiateWorkspaceIdentity = (source: string, workspaceId: string): string => {
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(workspaceId)) throw new Error("Workspace identity must be a UUID");
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(workspaceId))
throw new Error("Workspace identity must be a UUID");
const syntax = parseQx(source);
if (syntax.diagnostics.length) throw new Error("Cannot instantiate a malformed template workspace");
const declaration = syntax.root.children.find(node => node.kind === "workspaceDecl");
const literals = declaration?.children.filter(node => node.kind === "stringLiteral");
const declaration = syntax.root.children.find((node) => node.kind === "workspaceDecl");
const literals = declaration?.children.filter((node) => node.kind === "stringLiteral");
if (!literals || literals.length !== 3) throw new Error("Template must contain a workspace declaration");
return applySourceEdits(source, [
{ ...literals[0], text: JSON.stringify(workspaceId) },
@@ -36,25 +59,48 @@ export const instantiateWorkspaceIdentity = (source: string, workspaceId: string
]);
};
const select = (source: string, selector: StructuralSelector): {node: SyntaxNode; syntax: ReturnType<typeof parseQx>} => {
const select = (
source: string,
selector: StructuralSelector,
): { node: SyntaxNode; syntax: ReturnType<typeof parseQx> } => {
if (!selectable.has(selector.kind)) throw new Error(`Unsupported structural selector ${selector.kind}`);
const syntax = parseQx(source);
if (syntax.diagnostics.length) throw new Error("Cannot scaffold syntactically invalid QX");
const matches = [...walkSyntax(syntax.root)].filter((node) => {
if (node.kind !== selector.kind) return false;
if (selector.name && !node.children.some((child) => child.kind === "identifier" && source.slice(child.start, child.end) === selector.name)) return false;
if (selector.names && JSON.stringify(node.children.filter((child) => child.kind === "identifier").map((child) => source.slice(child.start, child.end))) !== JSON.stringify(selector.names)) return false;
if (
selector.name &&
!node.children.some(
(child) => child.kind === "identifier" && source.slice(child.start, child.end) === selector.name,
)
)
return false;
if (
selector.names &&
JSON.stringify(
node.children
.filter((child) => child.kind === "identifier")
.map((child) => source.slice(child.start, child.end)),
) !== JSON.stringify(selector.names)
)
return false;
if (selector.id) {
// Only an explicit ID field counts, not a coincidentally equal revision,
// default value, nested declaration, or comment.
const tokens = syntax.tokens.filter((token) => !token.trivia && token.start >= node.start && token.end <= node.end);
const literal = node.children.find((child) => child.kind === "stringLiteral" && tokens.some((token, index) => token.start === child.start && tokens[index - 1]?.kind === "ID"));
const tokens = syntax.tokens.filter(
(token) => !token.trivia && token.start >= node.start && token.end <= node.end,
);
const literal = node.children.find(
(child) =>
child.kind === "stringLiteral" &&
tokens.some((token, index) => token.start === child.start && tokens[index - 1]?.kind === "ID"),
);
if (!literal || JSON.parse(source.slice(literal.start, literal.end)) !== selector.id) return false;
}
return true;
});
if (matches.length !== 1) throw new Error(`Structural selector must resolve exactly once (found ${matches.length})`);
return {node: matches[0], syntax};
return { node: matches[0], syntax };
};
/** Comment-preserving structural edits; every result is parsed before returning. */
@@ -69,87 +115,148 @@ export const editStructure = (source: string, edit: StructuralEdit): string => {
const offsets = [0];
for (const character of source) offsets.push(offsets[offsets.length - 1] + character.length);
const result = applySourceEdits(source, [
{start: offsets[literals[0].start!.start], end: offsets[literals[0].stop!.stop + 1], text: JSON.stringify(edit.source.repository)},
{start: offsets[literals[literals.length - 1].start!.start], end: offsets[literals[literals.length - 1].stop!.stop + 1], text: JSON.stringify(edit.source.commit)},
{
start: offsets[literals[0].start!.start],
end: offsets[literals[0].stop!.stop + 1],
text: JSON.stringify(edit.source.repository),
},
{
start: offsets[literals[literals.length - 1].start!.start],
end: offsets[literals[literals.length - 1].stop!.stop + 1],
text: JSON.stringify(edit.source.commit),
},
]);
const checked = parseQuixosLockDocument(result);
if (!checked.ok) throw new Error(`Invalid Quixos pin: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
if (!checked.ok)
throw new Error(`Invalid Quixos pin: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
return result;
}
if (edit.operation === "import") {
if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name)) throw new Error("Invalid resource import");
if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name))
throw new Error("Invalid resource import");
const syntax = parseQx(source);
if (syntax.diagnostics.length) throw new Error("Cannot scaffold invalid QX");
const text = `import ${edit.kind} ${edit.name};`;
if ([...walkSyntax(syntax.root)].some((entry) => entry.kind === "resourceImportDecl" && source.slice(entry.start, entry.end).replace(/\s+/g, " ") === text)) return source;
if (
[...walkSyntax(syntax.root)].some(
(entry) =>
entry.kind === "resourceImportDecl" && source.slice(entry.start, entry.end).replace(/\s+/g, " ") === text,
)
)
return source;
const root = syntax.root.children[0];
const position = ["workspaceDecl", "fragmentDecl"].includes(root.kind) ? syntax.tokens.find((token) => token.kind === "LBRACE")!.end : root.start;
const result = applySourceEdits(source, [{start: position, end: position, text: `\n${text}\n`}]);
const position = ["workspaceDecl", "fragmentDecl"].includes(root.kind)
? syntax.tokens.find((token) => token.kind === "LBRACE")!.end
: root.start;
const result = applySourceEdits(source, [{ start: position, end: position, text: `\n${text}\n` }]);
if (parseQx(result).diagnostics.length) throw new Error("Invalid resource import position");
return result;
}
if (edit.operation === "dependency") {
const parsed = parseQuixosLockDocument(source);
if (!parsed.ok) throw new Error("Cannot scaffold an invalid lockfile");
if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name)) throw new Error("Invalid dependency selector");
if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name))
throw new Error("Invalid dependency selector");
const parser = new QuixosLockParser(new CommonTokenStream(new QuixosLockLexer(CharStream.fromString(source))));
const tree = parser.document();
const offsets = [0];
for (const character of source) offsets.push(offsets[offsets.length - 1] + character.length);
const entries = tree.resourceEntry().filter((entry) => entry.resourceKind().getText() === edit.kind && entry.identifier().getText() === edit.name);
const entries = tree
.resourceEntry()
.filter((entry) => entry.resourceKind().getText() === edit.kind && entry.identifier().getText() === edit.name);
if (entries.length > 1) throw new Error("Ambiguous dependency selector");
const entry = entries[0];
const replacement = edit.source ? `${edit.kind} ${edit.name} source {\n repository ${JSON.stringify(edit.source.repository)};\n commit ${JSON.stringify(edit.source.commit)};\n}` : "";
const replacement = edit.source
? `${edit.kind} ${edit.name} source {\n repository ${JSON.stringify(edit.source.repository)};\n commit ${JSON.stringify(edit.source.commit)};\n}`
: "";
if (!entry && !edit.source) throw new Error("Cannot remove an absent dependency");
const start = entry ? offsets[entry.start!.start] : offsets[tree.RBRACE().symbol.start];
const end = entry ? offsets[entry.stop!.stop + 1] : start;
const result = applySourceEdits(source, [{start, end, text: entry ? replacement : `${replacement}\n`}]);
const result = applySourceEdits(source, [{ start, end, text: entry ? replacement : `${replacement}\n` }]);
const checked = parseQuixosLockDocument(result);
if (!checked.ok) throw new Error(`Invalid dependency change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
if (!checked.ok)
throw new Error(`Invalid dependency change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
return result;
}
const {node, syntax} = select(source, edit.operation === "append" ? edit.parent : edit.target);
const { node, syntax } = select(source, edit.operation === "append" ? edit.parent : edit.target);
let result: string;
if (edit.operation === "semantic-major") {
if (!["conformanceDecl", "packageResourceDecl"].includes(node.kind) || !Number.isSafeInteger(edit.major) || edit.major < 1) throw new Error("Semantic major requires a package/conformance and positive integer");
if (
!["conformanceDecl", "packageResourceDecl"].includes(node.kind) ||
!Number.isSafeInteger(edit.major) ||
edit.major < 1
)
throw new Error("Semantic major requires a package/conformance and positive integer");
const tokens = syntax.tokens.filter((token) => !token.trivia && token.start >= node.start && token.end <= node.end);
const marker = tokens.findIndex((token) => token.kind === "SEMANTIC_MAJOR");
const value = marker < 0 ? undefined : tokens[marker + 1];
const brace = tokens.find((token) => token.kind === "LBRACE")!;
result = applySourceEdits(source, [{start: value?.start ?? brace.start, end: value?.end ?? brace.start, text: value ? String(edit.major) : `semantic-major ${edit.major} `}]);
result = applySourceEdits(source, [
{
start: value?.start ?? brace.start,
end: value?.end ?? brace.start,
text: value ? String(edit.major) : `semantic-major ${edit.major} `,
},
]);
} else if (edit.operation === "conformance-id") {
if (node.kind !== "conformanceDecl" || !edit.id) throw new Error("Identity enrollment requires a conformance and stable ID");
if (node.kind !== "conformanceDecl" || !edit.id)
throw new Error("Identity enrollment requires a conformance and stable ID");
const existing = node.children.find((entry) => entry.kind === "stringLiteral");
if (existing) {
if (JSON.parse(source.slice(existing.start, existing.end)) !== edit.id) throw new Error("Cannot change an enrolled conformance identity; create a new conformance explicitly");
if (JSON.parse(source.slice(existing.start, existing.end)) !== edit.id)
throw new Error("Cannot change an enrolled conformance identity; create a new conformance explicitly");
return source;
}
const identifiers = node.children.filter((entry) => entry.kind === "identifier");
const position = identifiers[identifiers.length - 1].end;
result = applySourceEdits(source, [{start: position, end: position, text: ` id ${JSON.stringify(edit.id)}`}]);
result = applySourceEdits(source, [{ start: position, end: position, text: ` id ${JSON.stringify(edit.id)}` }]);
} else if (edit.operation === "append") {
const closing = syntax.tokens.find((token) => token.kind === "RBRACE" && token.end === node.end);
if (!closing) throw new Error("Append requires a declaration with a body");
result = applySourceEdits(source, [{start: closing.start, end: closing.start, text: `\n${edit.source}\n`}]);
result = applySourceEdits(source, [{ start: closing.start, end: closing.start, text: `\n${edit.source}\n` }]);
} else {
// A resource parser node includes imports/external declarations preceding
// its header. Replacing the declaration must not delete that preamble.
const identifier = node.children.find(child => child.kind === "identifier");
const header = ["packageResourceDecl", "interfaceResourceDecl"].includes(node.kind) && identifier
? syntax.tokens.filter(token => token.start >= node.start && token.end <= identifier.start &&
token.kind === (node.kind === "packageResourceDecl" ? "PACKAGE" : "INTERFACE")).at(-1)?.start : undefined;
const wrapper = edit.operation === "remove" && ["stateDecl", "edgeDecl"].includes(node.kind)
? [...walkSyntax(syntax.root)].filter((entry) => ["conformanceItem", "sharedAttachmentDecl"].includes(entry.kind) && entry.start <= node.start && entry.end >= node.end).sort((a, b) => (a.end - a.start) - (b.end - b.start))[0]
: undefined;
result = applySourceEdits(source, [{start: wrapper?.start ?? header ?? node.start, end: wrapper?.end ?? node.end, text: edit.operation === "replace" ? edit.source : ""}]);
const identifier = node.children.find((child) => child.kind === "identifier");
const header =
["packageResourceDecl", "interfaceResourceDecl"].includes(node.kind) && identifier
? syntax.tokens
.filter(
(token) =>
token.start >= node.start &&
token.end <= identifier.start &&
token.kind === (node.kind === "packageResourceDecl" ? "PACKAGE" : "INTERFACE"),
)
.at(-1)?.start
: undefined;
const wrapper =
edit.operation === "remove" && ["stateDecl", "edgeDecl"].includes(node.kind)
? [...walkSyntax(syntax.root)]
.filter(
(entry) =>
["conformanceItem", "sharedAttachmentDecl"].includes(entry.kind) &&
entry.start <= node.start &&
entry.end >= node.end,
)
.sort((a, b) => a.end - a.start - (b.end - b.start))[0]
: undefined;
result = applySourceEdits(source, [
{
start: wrapper?.start ?? header ?? node.start,
end: wrapper?.end ?? node.end,
text: edit.operation === "replace" ? edit.source : "",
},
]);
}
const checked = parseQx(result);
if (checked.diagnostics.length) throw new Error(`Invalid structural change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
if (checked.diagnostics.length)
throw new Error(`Invalid structural change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
return result;
};
export const scaffoldResourceSource = (kind: "interface" | "package", name: string, id: string, revision: string) => {
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || !id || !revision) throw new Error("Resource scaffold requires a PascalCase name and explicit identities");
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || !id || !revision)
throw new Error("Resource scaffold requires a PascalCase name and explicit identities");
const source = `${kind} ${name} id ${JSON.stringify(id)} revision ${JSON.stringify(revision)} {\n}\n`;
if (parseQx(source).diagnostics.length) throw new Error("Invalid resource scaffold");
return source;
+207 -79
View File
@@ -7,125 +7,220 @@ 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 { 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};
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})[];
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[]};
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|lock|ts|tsx|css|mjs|json|nix|txtpb))$/.test(file)
|| file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))) throw new Error(`Unsafe scaffold path ${file}`);
if (
!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|lock|ts|tsx|css|mjs|json|nix|txtpb))$/.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}`);
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; }
} 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; });
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");
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(); }
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(); }
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");
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 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");
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");
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}`);
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");
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");
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});
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}`);
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});
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");
} 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}); }
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. */
@@ -134,33 +229,47 @@ const replayStructure = async (rootPath: string, id: string) => {
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");
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 (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};
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;
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(); }
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}`);
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(); }
try {
await directory.sync();
} finally {
await directory.close();
}
}
journal.phase = "complete";
await durableJson(journalPath, journal);
return {id, journalPath, phase: journal.phase};
return { id, journalPath, phase: journal.phase };
};
const withStructureLock = async <T>(root: string, work: () => Promise<T>) => {
@@ -172,22 +281,41 @@ 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}`);
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);
} 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);
});
});
+297 -109
View File
@@ -2,21 +2,21 @@
import { readFile, writeFile, mkdtemp, rm, realpath } from "node:fs/promises";
import { parseQx, formatQx, lintQx } from "./source.js";
import { scaffoldAtom } from "./scaffold.js";
import {loadQxSources} from "./source-loader.js";
import { loadQxSources } from "./source-loader.js";
import { createGitCapabilityResolver } from "./git-resolver.js";
import { planEvolution } from "../capability-model/index.js";
import { snapshotRepository } from "./candidate-check.js";
import {planPinUpgrades, applyPinUpgrades, discoverUpgradeSpec, type UpgradeSpec} from "./pin-upgrades.js";
import { planPinUpgrades, applyPinUpgrades, discoverUpgradeSpec, type UpgradeSpec } from "./pin-upgrades.js";
import path from "node:path";
import os from "node:os";
import {spawnSync} from "node:child_process";
import { spawnSync } from "node:child_process";
import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js";
import {scaffoldRecipe, type ScaffoldRecipe} from "./scaffold-recipes.js";
import {checkBundleSources} from "../bindings/bundle-policy.js";
import {sealMigrations} from "./migration-seal.js";
import {generatePackageDescriptor} from "../bindings/index.js";
import {buildCheckedPackage, buildImmutableCandidate, snapshotCommit} from "./checked-build.js";
import {formatQuixosLock, loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js";
import { scaffoldRecipe, type ScaffoldRecipe } from "./scaffold-recipes.js";
import { checkBundleSources } from "../bindings/bundle-policy.js";
import { sealMigrations } from "./migration-seal.js";
import { generatePackageDescriptor } from "../bindings/index.js";
import { buildCheckedPackage, buildImmutableCandidate, snapshotCommit } from "./checked-build.js";
import { formatQuixosLock, loadQuixosLock, parseQuixosLockDocument } from "../resource-lock/index.js";
import { walkSyntax } from "./source.js";
import { inspectWorkbench } from "./authoring-inspect.js";
import { authoringContext } from "./authoring-context.js";
@@ -25,18 +25,30 @@ import { checkAuthoring } from "./authoring-check.js";
import { authoringWorklist } from "./authoring-worklist.js";
const authorSource = async (root: string) => {
const result = spawnSync("git", ["config", "--get", "remote.origin.url"], {cwd: root, encoding: "utf8"});
const result = spawnSync("git", ["config", "--get", "remote.origin.url"], { cwd: root, encoding: "utf8" });
if (result.error || result.status !== 0) throw new Error("Managed resource has no origin");
return {repository: result.stdout.trim(), commit: await snapshotCommit(root)};
return { repository: result.stdout.trim(), commit: await snapshotCommit(root) };
};
const readSpec = async (value: string) => {
if (value === "-") { let input = ""; for await (const chunk of process.stdin) { input += chunk; if (input.length > 1024 * 1024) throw new Error("Scaffold specification exceeds 1 MiB"); } return JSON.parse(input); }
if (value === "-") {
let input = "";
for await (const chunk of process.stdin) {
input += chunk;
if (input.length > 1024 * 1024) throw new Error("Scaffold specification exceeds 1 MiB");
}
return JSON.parse(input);
}
return JSON.parse(value.trimStart().startsWith("{") ? value : await readFile(value, "utf8"));
};
const planSummary = (plan: Awaited<ReturnType<typeof planStructure>>) => ({
root: plan.root, validation: plan.validation,
changes: plan.changes.map(change => ({file: change.file, beforeBytes: change.before?.length ?? 0, afterBytes: change.after?.length ?? 0})),
root: plan.root,
validation: plan.validation,
changes: plan.changes.map((change) => ({
file: change.file,
beforeBytes: change.before?.length ?? 0,
afterBytes: change.after?.length ?? 0,
})),
note: "Structural plan only, not implementation verification. Run qx-workspace check while iterating.",
});
@@ -47,43 +59,62 @@ const main = async () => {
const sources = JSON.parse(args[0]);
if (!Array.isArray(sources) || sources.length > 100) throw new Error("Expected at most 100 scaffold sources");
for (const entry of sources) {
if (!entry || typeof entry.file !== "string" || typeof entry.source !== "string" || entry.source.length > 1024*1024) throw new Error("Invalid scaffold source");
if (
!entry ||
typeof entry.file !== "string" ||
typeof entry.source !== "string" ||
entry.source.length > 1024 * 1024
)
throw new Error("Invalid scaffold source");
const diagnostics = parseQx(entry.source, entry.file).diagnostics;
if (diagnostics.length) throw new Error(diagnostics.map(d => `${entry.file}:${d.line}:${d.column + 1}: ${d.message}`).join("\n"));
if (diagnostics.length)
throw new Error(diagnostics.map((d) => `${entry.file}:${d.line}:${d.column + 1}: ${d.message}`).join("\n"));
}
process.stdout.write('{"syntaxValid":true,"verificationEvidence":false}\n');
return;
}
if (command === "scaffold-placement-binding") {
if (args.length !== 1) throw new Error("scaffold-placement-binding ROOT");
const {source} = await loadQxSources(args[0]);
const { source } = await loadQxSources(args[0]);
const syntax = parseQx(source);
const text = (n: {start: number; end: number}) => source.slice(n.start, n.end);
const text = (n: { start: number; end: number }) => source.slice(n.start, n.end);
const matches: string[] = [];
for (const edge of walkSyntax(syntax.root)) {
if (edge.kind !== "edgeDecl") continue;
for (const endpoint of edge.children.filter(n => n.kind === "edgeEndpoint")) {
const target = endpoint.children.find(n => n.kind === "targetConstraint");
if (!target || !syntax.tokens.some(t => t.start >= target.start && t.end <= target.end && t.kind === "INTERFACE") ||
!target.children.some(n => n.kind === "identifier" && text(n) === "WebStudioPlaceable")) continue;
const edgeName = text(edge.children.find(n => n.kind === "identifier")!);
const projection = text(endpoint.children.find(n => n.kind === "identifier")!);
for (const endpoint of edge.children.filter((n) => n.kind === "edgeEndpoint")) {
const target = endpoint.children.find((n) => n.kind === "targetConstraint");
if (
!target ||
!syntax.tokens.some((t) => t.start >= target.start && t.end <= target.end && t.kind === "INTERFACE") ||
!target.children.some((n) => n.kind === "identifier" && text(n) === "WebStudioPlaceable")
)
continue;
const edgeName = text(edge.children.find((n) => n.kind === "identifier")!);
const projection = text(endpoint.children.find((n) => n.kind === "identifier")!);
matches.push(`bind placements.resolve to edge ${edgeName}.${projection}.resolve;`);
}
}
if (matches.length !== 1) throw new Error(`Expected one canvas placement edge for WebStudioPlaceable; found ${matches.length}. Configure the workspace canvas before adding a bundle.`);
process.stdout.write(JSON.stringify({binding: matches[0]}) + "\n");
if (matches.length !== 1)
throw new Error(
`Expected one canvas placement edge for WebStudioPlaceable; found ${matches.length}. Configure the workspace canvas before adding a bundle.`,
);
process.stdout.write(JSON.stringify({ binding: matches[0] }) + "\n");
return;
}
if (command === "package-identity") {
if (args.length !== 1) throw new Error("usage: quixos-qx package-identity PACKAGE_QX");
const authored = await readFile(args[0], "utf8");
const syntax = parseQx(authored);
const declarations = [...walkSyntax(syntax.root)].filter(node => node.kind === "packageResourceDecl");
if (syntax.diagnostics.length || declarations.length !== 1) throw new Error("Expected one valid package declaration");
const literals = declarations[0].children.filter(node => node.kind === "stringLiteral");
process.stdout.write(JSON.stringify({id: JSON.parse(authored.slice(literals[0].start, literals[0].end)),
revision: JSON.parse(authored.slice(literals[1].start, literals[1].end))}) + "\n");
const declarations = [...walkSyntax(syntax.root)].filter((node) => node.kind === "packageResourceDecl");
if (syntax.diagnostics.length || declarations.length !== 1)
throw new Error("Expected one valid package declaration");
const literals = declarations[0].children.filter((node) => node.kind === "stringLiteral");
process.stdout.write(
JSON.stringify({
id: JSON.parse(authored.slice(literals[0].start, literals[0].end)),
revision: JSON.parse(authored.slice(literals[1].start, literals[1].end)),
}) + "\n",
);
return;
}
if (command === "package-descriptor") {
@@ -108,29 +139,50 @@ const main = async () => {
}
if (["author-check", "author-contract"].includes(command) && !args.includes("--help")) {
const [root, output, ...flags] = args;
if (!root || !output) throw new Error("usage: quixos-qx author-check ROOT OUTPUT [--baseline FILE] [--reviews FILE]");
const options: {baseline?: string; reviews?: string} = {};
if (!root || !output)
throw new Error("usage: quixos-qx author-check ROOT OUTPUT [--baseline FILE] [--reviews FILE]");
const options: { baseline?: string; reviews?: string } = {};
for (let index = 0; index < flags.length; index += 2) {
if (!flags[index + 1]) throw new Error("Missing check option value");
if (flags[index] === "--baseline") options.baseline = flags[index + 1];
else if (flags[index] === "--reviews") options.reviews = flags[index + 1];
else throw new Error(`Unknown check option ${flags[index]}`);
}
const result = await checkAuthoring(root, output, {...options, contractOnly: command === "author-contract"});
const result = await checkAuthoring(root, output, { ...options, contractOnly: command === "author-contract" });
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
if (result.blockers.length) process.exitCode = 1;
return;
}
if (["converge", "_converge"].includes(command) && !args.includes("--help")) {
if (!args.length || args.length > 2) throw new Error("usage: quixos-qx converge WORKBENCH [REGISTERED_DIRECTORY] (join package writers first)");
if (!args.length || args.length > 2)
throw new Error("usage: quixos-qx converge WORKBENCH [REGISTERED_DIRECTORY] (join package writers first)");
const context = await authoringContext(args[0]);
if (command === "converge") {
console.error(`[${new Date().toISOString()}] Capture: waiting for coordinator lock (120s limit)`);
const result = spawnSync("flock", ["--exclusive", "--timeout", "120", "--conflict-exit-code", "75", path.join(context.workbench, ".quixos/converge.lock"),
process.execPath, process.argv[1], "_converge", context.workbench, ...(args[1] ? [args[1]] : [])], {stdio: "inherit"});
const result = spawnSync(
"flock",
[
"--exclusive",
"--timeout",
"120",
"--conflict-exit-code",
"75",
path.join(context.workbench, ".quixos/converge.lock"),
process.execPath,
process.argv[1],
"_converge",
context.workbench,
...(args[1] ? [args[1]] : []),
],
{ stdio: "inherit" },
);
if (result.error) throw result.error;
if (result.status === 75) process.stderr.write("Timed out after 120 seconds waiting for source capture; inspect the active coordinator. No build lock is held.\n");
process.exitCode = result.status ?? 1; return;
if (result.status === 75)
process.stderr.write(
"Timed out after 120 seconds waiting for source capture; inspect the active coordinator. No build lock is held.\n",
);
process.exitCode = result.status ?? 1;
return;
}
console.error(`[${new Date().toISOString()}] Capture: coordinator lock acquired`);
const result = await convergeAuthoring(context.workbench, args[1]);
@@ -140,18 +192,24 @@ const main = async () => {
}
if (command === "check-committed" && !args.includes("--help")) {
const [kind, repository, commit, log, ...extra] = args;
if (!["workspace", "interface", "package"].includes(kind) || !log || extra.length) throw new Error("usage: quixos-qx check-committed workspace|interface|package REPOSITORY COMMIT LOG_FILE");
process.stdout.write(`${await buildImmutableCandidate({repository, commit}, kind as "workspace" | "interface" | "package", log)}\n`);
if (!["workspace", "interface", "package"].includes(kind) || !log || extra.length)
throw new Error("usage: quixos-qx check-committed workspace|interface|package REPOSITORY COMMIT LOG_FILE");
process.stdout.write(
`${await buildImmutableCandidate({ repository, commit }, kind as "workspace" | "interface" | "package", log)}\n`,
);
return;
}
if (!command || command === "--help" || args.includes("--help")) {
process.stdout.write("quixos-qx: author-check, author-contract, converge, worklist, inspect, resources, check-committed, source-baseline, scaffold-package, scaffold-interface, scaffold-function, scaffold-dependency, scaffold-structure, scaffold-resume, pin-upgrade, parse, lint, format\n" +
"inspect WORKBENCH [RESOURCE] shows provisional contracts, with explicit historical fallback; never verification evidence.\n" +
"resources WORKBENCH lists registered editable repositories. Use qx-workspace for the workspace authoring workflow.\n");
process.stdout.write(
"quixos-qx: author-check, author-contract, converge, worklist, inspect, resources, check-committed, source-baseline, scaffold-package, scaffold-interface, scaffold-function, scaffold-dependency, scaffold-structure, scaffold-resume, pin-upgrade, parse, lint, format\n" +
"inspect WORKBENCH [RESOURCE] shows provisional contracts, with explicit historical fallback; never verification evidence.\n" +
"resources WORKBENCH lists registered editable repositories. Use qx-workspace for the workspace authoring workflow.\n",
);
return;
}
if (command === "inspect" || command === "resources") {
if (!args[0] || args.length > (command === "inspect" ? 2 : 1)) throw new Error(`usage: quixos-qx ${command} WORKBENCH${command === "inspect" ? " [RESOURCE]" : ""}`);
if (!args[0] || args.length > (command === "inspect" ? 2 : 1))
throw new Error(`usage: quixos-qx ${command} WORKBENCH${command === "inspect" ? " [RESOURCE]" : ""}`);
const result = command === "inspect" ? await inspectWorkbench(args[0], args[1]) : await authoringContext(args[0]);
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
@@ -165,27 +223,56 @@ const main = async () => {
const [root, kind, name, ...remaining] = args;
let repository: string | undefined, commit: string | undefined;
const flags = [...remaining];
if (flags.length && !flags[0].startsWith("--")) { repository = flags.shift(); commit = flags.shift(); }
else if (root && ["interface", "package"].includes(kind) && name) {
if (flags.length && !flags[0].startsWith("--")) {
repository = flags.shift();
commit = flags.shift();
} else if (root && ["interface", "package"].includes(kind) && name) {
const context = await authoringContext(root);
const alias = await realpath(path.join(context.workbench, `${kind}s`, name)).catch(() => null);
const matches = context.resources.filter(entry => entry.kind === kind && (entry.resourceId === name || path.basename(entry.directory) === name || path.join(context.workbench, entry.directory) === alias));
if (matches.length !== 1 || !matches[0].source) throw new Error(`Select exactly one registered ${kind} with qx-workspace resources; no match for ${name}`);
const matches = context.resources.filter(
(entry) =>
entry.kind === kind &&
(entry.resourceId === name ||
path.basename(entry.directory) === name ||
path.join(context.workbench, entry.directory) === alias),
);
if (matches.length !== 1 || !matches[0].source)
throw new Error(`Select exactly one registered ${kind} with qx-workspace resources; no match for ${name}`);
const selected = matches[0];
let source = selected.source!;
if (flags.includes("--write")) {
const retained = spawnSync("quixos-qx", ["converge", context.workbench, selected.directory], {encoding: "utf8", env: {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"}});
if (retained.error || retained.status !== 0) throw new Error(`Dependency source needs attention: ${retained.error?.message ?? retained.stdout ?? retained.stderr}`);
const retained = spawnSync("quixos-qx", ["converge", context.workbench, selected.directory], {
encoding: "utf8",
env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1" },
});
if (retained.error || retained.status !== 0)
throw new Error(
`Dependency source needs attention: ${retained.error?.message ?? retained.stdout ?? retained.stderr}`,
);
source = JSON.parse(retained.stdout).candidate;
}
repository = source.repository; commit = source.commit;
repository = source.repository;
commit = source.commit;
}
if (!root || !["interface", "package"].includes(kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name ?? "") || !repository || !commit || flags.some(flag => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-dependency ROOT interface|package NAME REPOSITORY COMMIT [--write]");
if (
!root ||
!["interface", "package"].includes(kind) ||
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name ?? "") ||
!repository ||
!commit ||
flags.some((flag) => flag !== "--write")
)
throw new Error("usage: quixos-qx scaffold-dependency ROOT interface|package NAME REPOSITORY COMMIT [--write]");
const resourceKind = kind as "package" | "interface";
let entrypoint: "workspace" | "package" | "interface" | undefined;
for (const candidate of ["workspace", "package", "interface"] as const) {
try {await readFile(path.join(root, `${candidate}.qx`)); if (entrypoint) throw new Error("Ambiguous repository entrypoint"); entrypoint = candidate;}
catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;}
try {
await readFile(path.join(root, `${candidate}.qx`));
if (entrypoint) throw new Error("Ambiguous repository entrypoint");
entrypoint = candidate;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
if (!entrypoint) throw new Error("No QX repository entrypoint");
const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
@@ -193,45 +280,84 @@ const main = async () => {
let target = "quixos.lock";
for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) {
const parsed = parseQuixosLockDocument(await readFile(path.join(root, file), "utf8"));
if (parsed.ok && parsed.document.resources.some(entry => entry.kind === kind && entry.binding === name)) target = file;
if (parsed.ok && parsed.document.resources.some((entry) => entry.kind === kind && entry.binding === name))
target = file;
}
const request: StructuralRequest = {kind: entrypoint, source: await authorSource(root), validation: "syntax", files: [
{file: `${entrypoint}.qx`, edits: [{operation: "import", kind: resourceKind, name}]},
{file: target, edits: [{operation: "dependency", kind: resourceKind, name, source: {repository, commit}}]},
]};
const request: StructuralRequest = {
kind: entrypoint,
source: await authorSource(root),
validation: "syntax",
files: [
{ file: `${entrypoint}.qx`, edits: [{ operation: "import", kind: resourceKind, name }] },
{
file: target,
edits: [{ operation: "dependency", kind: resourceKind, name, source: { repository, commit } }],
},
],
};
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
process.stdout.write(`${JSON.stringify({...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`);
process.stdout.write(
`${JSON.stringify({ ...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined }, null, 2)}\n`,
);
return;
}
if (command === "scaffold-interface") {
const [root, specFile, ...flags] = args;
if (!root || !specFile || flags.some(flag => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-interface ROOT SPEC_JSON [--write]");
const spec = await readSpec(specFile) as ScaffoldRecipe;
if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id || !spec.revision || !spec.tools?.quixos) throw new Error("Interface scaffold requires name, id, revision and Quixos toolchain source");
const request: StructuralRequest = {kind: "interface", source: spec.source, files: [
{file: "interface.qx", create: spec.declaration ?? `interface ${spec.name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`},
{file: "quixos.lock", create: formatQuixosLock({formatVersion: 1, quixos: {resolver: "git", ...spec.tools.quixos}, resources: []})},
{file: ".gitignore", create: ".quixos/\n"},
]};
if (!root || !specFile || flags.some((flag) => flag !== "--write"))
throw new Error("usage: quixos-qx scaffold-interface ROOT SPEC_JSON [--write]");
const spec = (await readSpec(specFile)) as ScaffoldRecipe;
if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id || !spec.revision || !spec.tools?.quixos)
throw new Error("Interface scaffold requires name, id, revision and Quixos toolchain source");
const request: StructuralRequest = {
kind: "interface",
source: spec.source,
files: [
{
file: "interface.qx",
create:
spec.declaration ??
`interface ${spec.name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`,
},
{
file: "quixos.lock",
create: formatQuixosLock({
formatVersion: 1,
quixos: { resolver: "git", ...spec.tools.quixos },
resources: [],
}),
},
{ file: ".gitignore", create: ".quixos/\n" },
],
};
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
process.stdout.write(`${JSON.stringify({...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`);
process.stdout.write(
`${JSON.stringify({ ...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined }, null, 2)}\n`,
);
return;
}
if (command === "build-package") {
if (args.length !== 3) throw new Error("usage: quixos-qx build-package COMMITTED_SOURCE SCHEMA PACKAGE_REVISION_ID");
if (args.length !== 3)
throw new Error("usage: quixos-qx build-package COMMITTED_SOURCE SCHEMA PACKAGE_REVISION_ID");
process.stdout.write(`${await buildCheckedPackage(args[0], args[1], args[2])}\n`);
return;
}
if (command === "source-digest") {
if (!args[0] || args.length !== 1) throw new Error("usage: quixos-qx source-digest ROOT");
const temporary = await mkdtemp(path.join(os.tmpdir(), "qx-source-digest-"));
try {process.stdout.write(`${(await snapshotRepository(args[0], temporary)).treeDigest}\n`);} finally {await rm(temporary, {recursive: true, force: true});}
try {
process.stdout.write(`${(await snapshotRepository(args[0], temporary)).treeDigest}\n`);
} finally {
await rm(temporary, { recursive: true, force: true });
}
return;
}
if (command === "pin-upgrade") {
const [workbench, specFile, ...flags] = args;
if (!workbench || !specFile) throw new Error("usage: quixos-qx pin-upgrade WORKBENCH SPEC_JSON [--publish] [--resume UUID]");
let publish = false, acceptEdits = false, resume: string | undefined;
if (!workbench || !specFile)
throw new Error("usage: quixos-qx pin-upgrade WORKBENCH SPEC_JSON [--publish] [--resume UUID]");
let publish = false,
acceptEdits = false,
resume: string | undefined;
for (let index = 0; index < flags.length; index++) {
if (flags[index] === "--publish") publish = true;
else if (flags[index] === "--accept-edits") acceptEdits = true;
@@ -239,66 +365,112 @@ const main = async () => {
else throw new Error(`Unknown pin-upgrade option ${flags[index]}`);
}
if (acceptEdits && !resume) throw new Error("--accept-edits requires an existing refactor journal (--resume)");
const plan = resume ? JSON.parse(await readFile(path.join(workbench, ".quixos/upgrades", `${resume}.json`), "utf8")).plan
: await planPinUpgrades(workbench, specFile === "auto" ? await discoverUpgradeSpec(workbench) : JSON.parse(await readFile(specFile, "utf8")) as UpgradeSpec);
if (resume && path.resolve(workbench) !== plan.workbench) throw new Error("Upgrade journal belongs to another workbench");
process.stdout.write(`${JSON.stringify(publish ? await applyPinUpgrades(plan, resume, undefined, {acceptEdits}) : plan, null, 2)}\n`);
const plan = resume
? JSON.parse(await readFile(path.join(workbench, ".quixos/upgrades", `${resume}.json`), "utf8")).plan
: await planPinUpgrades(
workbench,
specFile === "auto"
? await discoverUpgradeSpec(workbench)
: (JSON.parse(await readFile(specFile, "utf8")) as UpgradeSpec),
);
if (resume && path.resolve(workbench) !== plan.workbench)
throw new Error("Upgrade journal belongs to another workbench");
process.stdout.write(
`${JSON.stringify(publish ? await applyPinUpgrades(plan, resume, undefined, { acceptEdits }) : plan, null, 2)}\n`,
);
return;
}
if (command === "scaffold-refresh") throw new Error("Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependency installation uses scaffold install.");
if (command === "scaffold-refresh")
throw new Error(
"Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependency installation uses scaffold install.",
);
if (["scaffold-package", "scaffold-function", "scaffold-migration", "scaffold-install"].includes(command)) {
const [root, specFile, ...flags] = args;
let spec: ScaffoldRecipe;
if (command === "scaffold-function" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(specFile ?? "")) {
const authored = parseQx(await readFile(path.join(root, "package.qx"), "utf8"));
const declarationNode = [...walkSyntax(authored.root)].find(node => node.kind === "packageResourceDecl");
const nameNode = declarationNode?.children.find(node => node.kind === "identifier");
const declarationNode = [...walkSyntax(authored.root)].find((node) => node.kind === "packageResourceDecl");
const nameNode = declarationNode?.children.find((node) => node.kind === "identifier");
if (!nameNode) throw new Error("Expected a package declaration");
const name = authored.source.slice(nameNode.start, nameNode.end);
spec = {source: await authorSource(root), name: specFile, id: `export:${name}:${specFile}`};
spec = { source: await authorSource(root), name: specFile, id: `export:${name}:${specFile}` };
const declaration = flags.indexOf("--declaration");
if (declaration >= 0) {
if (!flags[declaration + 1]) throw new Error("--declaration requires a QX declaration file");
spec.declaration = await readFile(flags[declaration + 1], "utf8");
const parsed = parseQx(`package Draft id "package:draft" revision "package:draft@1" { ${spec.declaration} }`);
if (parsed.diagnostics.length) throw new Error(parsed.diagnostics.map(d => d.message).join("\n"));
const exported = [...walkSyntax(parsed.root)].filter(node => ["packageOperationExport", "packageFunctionExport", "packageConstructorExport"].includes(node.kind));
if (exported.length !== 1) throw new Error("--declaration must contain exactly one function, operation or constructor export");
const literal = exported[0].children.find(node => node.kind === "stringLiteral");
if (parsed.diagnostics.length) throw new Error(parsed.diagnostics.map((d) => d.message).join("\n"));
const exported = [...walkSyntax(parsed.root)].filter((node) =>
["packageOperationExport", "packageFunctionExport", "packageConstructorExport"].includes(node.kind),
);
if (exported.length !== 1)
throw new Error("--declaration must contain exactly one function, operation or constructor export");
const literal = exported[0].children.find((node) => node.kind === "stringLiteral");
if (!literal) throw new Error("Declaration requires an authored export ID");
spec.id = JSON.parse(parsed.source.slice(literal.start, literal.end));
flags.splice(declaration, 2);
}
} else spec = await readSpec(specFile) as ScaffoldRecipe;
if (!root || !specFile || flags.some((flag) => !["--write", "--install"].includes(flag)) || (flags.includes("--install") && !flags.includes("--write"))) throw new Error("usage: quixos-qx scaffold-package|function|migration|refresh ROOT SPEC_JSON [--write [--install]]");
const plan = command === "scaffold-install" ? undefined : await planStructure(root,
await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration", spec), process.env.QUIXOS_SNAPSHOT_MAP);
} else spec = (await readSpec(specFile)) as ScaffoldRecipe;
if (
!root ||
!specFile ||
flags.some((flag) => !["--write", "--install"].includes(flag)) ||
(flags.includes("--install") && !flags.includes("--write"))
)
throw new Error(
"usage: quixos-qx scaffold-package|function|migration|refresh ROOT SPEC_JSON [--write [--install]]",
);
const plan =
command === "scaffold-install"
? undefined
: await planStructure(
root,
await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration", spec),
process.env.QUIXOS_SNAPSHOT_MAP,
);
const applied = plan && flags.includes("--write") ? await applyStructure(plan) : undefined;
if (flags.includes("--install")) {
const cwd = path.resolve(root, spec.directory ?? "");
const toolchain = JSON.parse(await readFile(path.join(cwd, "quixos.toolchain.json"), "utf8"));
if (toolchain.generatedBy !== "qx-scaffold-v1" || typeof toolchain.nixifyPluginUrl !== "string") throw new Error("Missing scaffold toolchain");
for (const [executable, args] of [["corepack", ["yarn", "plugin", "import", toolchain.nixifyPluginUrl]], ["corepack", ["yarn", "config", "set", "generateDefaultNix", "false"]], ["corepack", ["yarn", "config", "set", "individualNixPackaging", "true"]], ["corepack", ["yarn", "install"]]] as const) {
const result = spawnSync(executable, [...args], {cwd, stdio: ["inherit", 2, 2]});
if (result.error || result.status !== 0) throw new Error(`Scaffold files retained; ${executable} ${args.join(" ")} failed: ${result.error?.message ?? result.status}`);
if (toolchain.generatedBy !== "qx-scaffold-v1" || typeof toolchain.nixifyPluginUrl !== "string")
throw new Error("Missing scaffold toolchain");
for (const [executable, args] of [
["corepack", ["yarn", "plugin", "import", toolchain.nixifyPluginUrl]],
["corepack", ["yarn", "config", "set", "generateDefaultNix", "false"]],
["corepack", ["yarn", "config", "set", "individualNixPackaging", "true"]],
["corepack", ["yarn", "install"]],
] as const) {
const result = spawnSync(executable, [...args], { cwd, stdio: ["inherit", 2, 2] });
if (result.error || result.status !== 0)
throw new Error(
`Scaffold files retained; ${executable} ${args.join(" ")} failed: ${result.error?.message ?? result.status}`,
);
}
try {
await readFile(path.join(cwd, "yarn-project.nix"));
} catch {
throw new Error(
"Nixify did not generate yarn-project.nix. It skips repositories under the OS temporary directory; use an ordinary workspace checkout and retry installation.",
);
}
try {await readFile(path.join(cwd, "yarn-project.nix"));}
catch {throw new Error("Nixify did not generate yarn-project.nix. It skips repositories under the OS temporary directory; use an ordinary workspace checkout and retry installation.");}
await snapshotCommit(cwd);
const locked = spawnSync("nix", ["flake", "lock"], {cwd, stdio: ["inherit", 2, 2]});
const locked = spawnSync("nix", ["flake", "lock"], { cwd, stdio: ["inherit", 2, 2] });
if (locked.error || locked.status !== 0) throw new Error("Scaffold files retained; nix flake lock failed");
}
process.stdout.write(`${JSON.stringify({...plan ? planSummary(plan) : {installed: true}, applied}, null, 2)}\n`);
process.stdout.write(
`${JSON.stringify({ ...(plan ? planSummary(plan) : { installed: true }), applied }, null, 2)}\n`,
);
return;
}
if (command === "scaffold-structure") {
const [root, spec, ...flags] = args;
if (!root || !spec || flags.some((flag) => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-structure ROOT SPEC_JSON [--write]");
const request = await readSpec(spec) as StructuralRequest;
if (!root || !spec || flags.some((flag) => flag !== "--write"))
throw new Error("usage: quixos-qx scaffold-structure ROOT SPEC_JSON [--write]");
const request = (await readSpec(spec)) as StructuralRequest;
if (request.kind !== "workspace" && !request.source) request.source = await authorSource(root);
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
const applied = flags.includes("--write") ? await applyStructure(plan) : undefined;
process.stdout.write(`${JSON.stringify({...planSummary(plan), applied}, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ ...planSummary(plan), applied }, null, 2)}\n`);
return;
}
if (command === "scaffold-resume") {
@@ -307,10 +479,14 @@ const main = async () => {
process.stdout.write(`${JSON.stringify(await resumeStructure(root, id), null, 2)}\n`);
return;
}
if (["check", "check-resource"].includes(command)) throw new Error("Use qx-workspace check in the registered repository; handwritten source/snapshot-map candidates are no longer an authoring check path");
if (["check", "check-resource"].includes(command))
throw new Error(
"Use qx-workspace check in the registered repository; handwritten source/snapshot-map candidates are no longer an authoring check path",
);
if (command === "evolution") {
const [baseline, candidate, reviews, ...extra] = args;
if (!baseline || !candidate || extra.length) throw new Error("usage: quixos-qx evolution BASELINE_JSON CANDIDATE_JSON [REVIEWS_JSON]");
if (!baseline || !candidate || extra.length)
throw new Error("usage: quixos-qx evolution BASELINE_JSON CANDIDATE_JSON [REVIEWS_JSON]");
const before = baseline === "none" ? null : JSON.parse(await readFile(baseline, "utf8"));
const after = JSON.parse(await readFile(candidate, "utf8"));
const decisions = reviews ? JSON.parse(await readFile(reviews, "utf8")) : [];
@@ -320,23 +496,35 @@ const main = async () => {
}
if (command === "scaffold-atom") {
const [root, name, id, ...flags] = args;
if (!root || !name || !id || flags.some((flag) => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-atom ROOT NAME ID [--write]");
if (!root || !name || !id || flags.some((flag) => flag !== "--write"))
throw new Error("usage: quixos-qx scaffold-atom ROOT NAME ID [--write]");
const resolveResource = await createGitCapabilityResolver({ checkoutRoot: `${root}/.quixos/resource-checkouts` });
const plan = await scaffoldAtom({ root, name, id, write: flags.includes("--write"), resolveResource });
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
return;
}
const [file, flag, ...rest] = args;
if (!file || rest.length || (flag && flag !== "--write") || !["parse", "lint", "format"].includes(command ?? "") ||
(flag && command !== "format")) throw new Error("usage: quixos-qx parse|lint|format FILE [--write (format only)]");
if (
!file ||
rest.length ||
(flag && flag !== "--write") ||
!["parse", "lint", "format"].includes(command ?? "") ||
(flag && command !== "format")
)
throw new Error("usage: quixos-qx parse|lint|format FILE [--write (format only)]");
const source = await readFile(file, "utf8");
if (command === "format") {
const formatted = formatQx(source);
if (flag) await writeFile(file, formatted); else process.stdout.write(formatted);
if (flag) await writeFile(file, formatted);
else process.stdout.write(formatted);
} else {
const result = command === "parse" ? parseQx(source, file) : lintQx(source, file);
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
if (Array.isArray(result) ? result.some((entry) => entry.severity === "error") : result.diagnostics.length) process.exitCode = 1;
if (Array.isArray(result) ? result.some((entry) => entry.severity === "error") : result.diagnostics.length)
process.exitCode = 1;
}
};
main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
+45 -27
View File
@@ -4,7 +4,12 @@ import { readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import { compileWorkspaceRepository } from "./assembly.js";
import { createGitCapabilityResolver } from "./git-resolver.js";
import { planEvolution, runtimeContracts, type EvolutionReview, type WorkspaceRevision } from "../capability-model/index.js";
import {
planEvolution,
runtimeContracts,
type EvolutionReview,
type WorkspaceRevision,
} from "../capability-model/index.js";
const usage = `usage: quixos-workspace-compile --root DIRECTORY --checkout-root DIRECTORY
[--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID]
@@ -59,40 +64,53 @@ const main = async () => {
});
if (options.graphOut) {
await writeFile(options.graphOut, `${JSON.stringify({
formatVersion: 1,
quixos: assembled.lock.quixos,
directResources: [...assembled.directResources.entries()].map(([bindingKey, node]) => {
const [kind, binding] = bindingKey.split("\0");
return { kind, binding, resourceKey: node.key, directory: node.directory };
}),
resources: assembled.resources.map((node) => ({
key: node.key,
kind: node.kind,
source: node.source,
directory: node.directory,
resourceId: node.resource.kind === "interface"
? node.resource.revision.interfaceId
: node.resource.revision.packageId,
revisionId: node.resource.revision.revisionId,
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
binding,
resourceKey: dependency.key,
})),
})),
}, null, 2)}\n`);
await writeFile(
options.graphOut,
`${JSON.stringify(
{
formatVersion: 1,
quixos: assembled.lock.quixos,
directResources: [...assembled.directResources.entries()].map(([bindingKey, node]) => {
const [kind, binding] = bindingKey.split("\0");
return { kind, binding, resourceKey: node.key, directory: node.directory };
}),
resources: assembled.resources.map((node) => ({
key: node.key,
kind: node.kind,
source: node.source,
directory: node.directory,
resourceId:
node.resource.kind === "interface"
? node.resource.revision.interfaceId
: node.resource.revision.packageId,
revisionId: node.resource.revision.revisionId,
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
binding,
resourceKey: dependency.key,
})),
})),
},
null,
2,
)}\n`,
);
}
const candidate = { ...assembled.workspace, executionContracts: runtimeContracts(assembled.workspace) };
if (options.evolutionOut) {
const baseline = options.baseline ? JSON.parse(await readFile(options.baseline, "utf8")) as WorkspaceRevision : null;
const reviews = options.reviews ? JSON.parse(await readFile(options.reviews, "utf8")) as EvolutionReview[] : [];
const baseline = options.baseline
? (JSON.parse(await readFile(options.baseline, "utf8")) as WorkspaceRevision)
: null;
const reviews = options.reviews ? (JSON.parse(await readFile(options.reviews, "utf8")) as EvolutionReview[]) : [];
if (!Array.isArray(reviews)) throw new Error("Review file must contain an array");
await writeFile(options.evolutionOut, `${JSON.stringify(planEvolution(baseline, candidate, { reviews }), null, 2)}\n`);
await writeFile(
options.evolutionOut,
`${JSON.stringify(planEvolution(baseline, candidate, { reviews }), null, 2)}\n`,
);
}
process.stdout.write(`${JSON.stringify(candidate, null, 2)}\n`);
};
main().catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
process.exitCode = 1;
});