import { lstat, readFile } from "node:fs/promises"; import path from "node:path"; import { parseQuixosLockDocument, type QuixosLockDiagnostic, type QuixosLockParseResult } from "./parser.js"; import type { LockedResource, QuixosLockDocument, QuixosRepositoryLock } from "./types.js"; export type QuixosLockSourceReader = (relativePath: string) => Promise; const diagnostic = (code: string, message: string, fileName: string, path?: string): QuixosLockDiagnostic => ({ phase: "resolution", code, message, fileName, line: 1, column: 0, path, }); export const resolveQuixosLock = async ( rootSource: string, readSource: QuixosLockSourceReader, rootFileName = "quixos.lock", ): Promise => { const root = parseQuixosLockDocument(rootSource, rootFileName); if (!root.ok) return root; if (root.document.kind !== "root") { return { ok: false, diagnostics: [ diagnostic("expected-root-lock", "The entrypoint must be a root Quixos lock, not a fragment", rootFileName), ], }; } const diagnostics: QuixosLockDiagnostic[] = []; const resources: LockedResource[] = []; const sourceFiles = [rootFileName]; const visited = new Set(); const active: string[] = []; const addResources = (document: QuixosLockDocument, fileName: string) => { for (const resource of document.resources) { const duplicate = resources.find((entry) => entry.kind === resource.kind && entry.binding === resource.binding); if (duplicate) { diagnostics.push( diagnostic( "duplicate-resource-binding", `Duplicate ${resource.kind} binding ${resource.binding} across imported lock files`, fileName, `${resource.kind}.${resource.binding}`, ), ); } else { resources.push(resource); } } }; const visit = async (importPath: string) => { if (active.includes(importPath)) { diagnostics.push( diagnostic("import-cycle", `Lock import cycle: ${[...active, importPath].join(" -> ")}`, importPath), ); return; } if (visited.has(importPath)) return; active.push(importPath); let source: string; try { source = await readSource(importPath); } catch (cause) { diagnostics.push( diagnostic( "import-read-failed", `Could not read lock import ${importPath}: ${cause instanceof Error ? cause.message : String(cause)}`, importPath, ), ); active.pop(); return; } const parsed = parseQuixosLockDocument(source, importPath); if (!parsed.ok) { diagnostics.push(...parsed.diagnostics); active.pop(); return; } if (parsed.document.kind !== "fragment") { diagnostics.push( diagnostic( "imported-root-lock", `Imported file ${importPath} must begin with "quixos-lock fragment"`, importPath, ), ); active.pop(); return; } visited.add(importPath); sourceFiles.push(importPath); addResources(parsed.document, importPath); for (const nested of parsed.document.imports) await visit(nested); active.pop(); }; addResources(root.document, rootFileName); for (const importPath of root.document.imports) await visit(importPath); if (diagnostics.length) return { ok: false, diagnostics }; const lock: QuixosRepositoryLock = { formatVersion: 1, quixos: root.document.quixos, resources, sourceFiles, }; return { ok: true, lock, diagnostics: [] }; }; export const loadQuixosLock = async (fileName: string): Promise => { const absoluteRoot = path.resolve(fileName); const repositoryRoot = path.dirname(absoluteRoot); const rootName = path.basename(absoluteRoot); const rootSource = await readFile(absoluteRoot, "utf8"); return await resolveQuixosLock( rootSource, async (relativePath) => { let absoluteImport = repositoryRoot; const segments = relativePath.split("/"); for (const [index, segment] of segments.entries()) { absoluteImport = path.join(absoluteImport, segment); const metadata = await lstat(absoluteImport); if (metadata.isSymbolicLink()) { throw new Error("imports must not traverse symbolic links"); } const final = index === segments.length - 1; if ((!final && !metadata.isDirectory()) || (final && !metadata.isFile())) { throw new Error("imports must be ordinary files beneath ordinary directories"); } } return await readFile(absoluteImport, "utf8"); }, rootName, ); };