import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import { execFile as execFileCallback } from "node:child_process"; 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 { bindingSchema } from "../bindings/index.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 }[] }> => { 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), })), }; } 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; } 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 }); } return { resources }; } const parent = path.dirname(directory); if (parent === directory) return { resources: [] }; directory = parent; } }; /** 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 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 }[] = []; 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"); 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}`); const data = await fs.readFile(file); bytes += data.length; if (bytes > 128 * 1024 * 1024) throw new Error("Candidate source exceeds 128 MiB"); contents.push({ name, digest: bytesDigest(data), mode: metadata.mode & 0o777 }); 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}`); 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 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 }); } const snapshotMap = path.join(temporary, "snapshots.json"); 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 }); 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; try { commit = await snapshotCommit(options.root); 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, }); 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"); 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.", }; 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 }); const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-workspace-check-")); 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 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 }); blockers.push(...evolution.blockers); 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 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 }); } 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)], }; 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 }); } };