import { readFile, realpath } from "node:fs/promises"; import path from "node:path"; import { loadQxSources, resolveQxSources } from "./source-loader.js"; import { capabilityId, compileWorkspaceRevision, validateMigrationCatalog, contentDigest, type AtomDefinition, type CompiledWorkspaceRevision, type InterfaceRevision, type PackageRevision, type SourceRevision, type WorkspaceRevision, } from "../capability-model/index.js"; import { loadQuixosLock, type GitSource, type LockedResource, type QuixosRepositoryLock, } from "../resource-lock/index.js"; import { compileCapabilityResourceSource, compileCapabilitySource, type CapabilityImportEnvironment, type CapabilityResource, type CapabilityResourceImport, } from "./parser.js"; export type CapabilityRepositorySnapshot = { directory: string; }; export type CapabilityRepositoryResolver = ( source: GitSource, kind: LockedResource["kind"], ) => Promise; export type ResolvedCapabilityResource = { key: string; kind: LockedResource["kind"]; source: GitSource; directory: string; lock: QuixosRepositoryLock; resource: CapabilityResource; dependencies: ReadonlyMap; }; export type CompiledWorkspaceRepository = { workspace: WorkspaceRevision; plan: CompiledWorkspaceRevision; lock: QuixosRepositoryLock; resources: ResolvedCapabilityResource[]; directResources: ReadonlyMap; }; export type CompiledCapabilityResourceRepository = { resource: CapabilityResource; lock: QuixosRepositoryLock; resources: ResolvedCapabilityResource[]; directResources: ReadonlyMap; }; 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 importsKey = (entry: CapabilityResourceImport | LockedResource) => bindingKey(entry.kind, entry.binding); const assertImportsMatchLock = ( label: string, imports: readonly CapabilityResourceImport[], resources: readonly LockedResource[], ) => { const authored = [...imports].map(importsKey).sort(); const locked = [...resources].map(importsKey).sort(); 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"}`, ); } }; const exactRevisions = (revisions: readonly Revision[]) => { const seen = new Set(); return revisions.filter((revision) => { const key = `${revision.revisionId}\0${revision.source.repository}\0${revision.source.commit}`; if (seen.has(key)) return false; seen.add(key); return true; }); }; const exactAtoms = (atoms: readonly AtomDefinition[]) => { const seen = new Set(); return atoms.filter((atom) => { if (seen.has(atom.id)) return false; seen.add(atom.id); return true; }); }; const diagnosticsMessage = ( label: string, diagnostics: readonly { fileName: string; line: number; column: number; phase: string; code: string; 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")}`; const revisionFor = (node: ResolvedCapabilityResource) => node.resource.revision; const resourceClosure = ( roots: readonly ResolvedCapabilityResource[], ): ResolvedCapabilityResource[] => { const result: ResolvedCapabilityResource[] = []; const seen = new Set(); const visit = (node: ResolvedCapabilityResource) => { if (seen.has(node.key)) return; seen.add(node.key); for (const dependency of node.dependencies.values()) visit(dependency); result.push(node); }; for (const root of roots) visit(root); return result; }; 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] : [])), externalAtoms: exactAtoms(closure.flatMap((node) => node.resource.externalAtoms)), }); const createResourceGraphResolver = ( quixosCommit: string, resolveResource: CapabilityRepositoryResolver, ) => { const resolved = new Map>(); const active: string[] = []; const visit = async ( locked: LockedResource, suppliedSnapshot?: CapabilityRepositorySnapshot, ): Promise => { const key = sourceKey(locked.kind, locked.source); const cached = resolved.get(key); if (cached) { if (active.includes(key)) { throw new Error(`Resource dependency cycle: ${[...active, key].join(" -> ")}`); } return await cached; } const pending = (async () => { active.push(key); try { 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)); } 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`, ); } 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}`, ); } 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 environment = environmentFor(directPairs, dependencyClosure); const manifestName = locked.kind === "interface" ? "interface.qx" : "package.qx"; const manifestPath = path.join(snapshot.directory, manifestName); const sourceText = await readFile(manifestPath, "utf8"); const compiled = compileCapabilityResourceSource(sourceText, { source: locked.source, fileName: manifestPath, environment, }); if (!compiled.ok) { 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}`, ); } 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; } if (catalogText !== undefined) { 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}`); } compiled.resource.revision.migrationCatalog = catalog; } } return { key, kind: locked.kind, source: locked.source, directory: snapshot.directory, lock: lockResult.lock, resource: compiled.resource, dependencies: new Map(directPairs.map(([dependency, node]) => [ bindingKey(dependency.kind, dependency.binding), node, ])), }; } finally { active.pop(); } })(); resolved.set(key, pending); return await pending; }; return { visit, nodes: async () => await Promise.all(resolved.values()), }; }; export const compileCapabilityResourceRepository = async (options: { rootDirectory: string; kind: LockedResource["kind"]; source: GitSource; resolveResource: CapabilityRepositoryResolver; }): Promise => { const lockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock")); if (!lockResult.ok) { throw new Error(diagnosticsMessage("Resource lock", lockResult.diagnostics)); } const resolver = createResourceGraphResolver( lockResult.lock.quixos.commit, options.resolveResource, ); const root = await resolver.visit({ kind: options.kind, binding: "", source: options.source, }, { directory: options.rootDirectory }); return { resource: root.resource, lock: root.lock, resources: await resolver.nodes(), directResources: root.dependencies, }; }; export const compileWorkspaceRepository = async (options: { rootDirectory: string; resolveResource: CapabilityRepositoryResolver; workspaceId?: string; workspaceRevisionId?: string; sourceRootCommit?: string; /** An editor's proposed source snapshot; locks and dependency revisions remain exact. */ readSource?: (name: string) => Promise; }): Promise => { const rootLockResult = await loadQuixosLock( path.join(options.rootDirectory, "quixos.lock"), ); if (!rootLockResult.ok) { throw new Error(diagnosticsMessage("Workspace lock", rootLockResult.diagnostics)); } const rootLock = rootLockResult.lock; const resolver = createResourceGraphResolver(rootLock.quixos.commit, options.resolveResource); const directPairs: Array<[LockedResource, ResolvedCapabilityResource]> = []; for (const locked of rootLock.resources) { directPairs.push([locked, await resolver.visit(locked)]); } 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 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))); } assertImportsMatchLock(workspacePath, compiled.imports, rootLock.resources); const availableInterfaceIds = new Set( 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) { for (const requirement of node.resource.externalInterfaces) { 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`, ); } } for (const atom of node.resource.externalAtoms) { 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`, ); } } } const workspace: WorkspaceRevision = { ...compiled.workspace, ...(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 } : {}), }; 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")}`, ); } return { workspace, plan: checked.plan, lock: rootLock, resources: nodes, directResources: new Map(directPairs.map(([locked, node]) => [ bindingKey(locked.kind, locked.binding), node, ])), }; };