diff --git a/.quixos-subtree-source.json b/.quixos-subtree-source.json index aa03317..364bea9 100644 --- a/.quixos-subtree-source.json +++ b/.quixos-subtree-source.json @@ -1,7 +1,7 @@ { "version": 1, "sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos", - "sourceCommit": "54e443af5dd963f1fa6f83f062f4943b46ad2535", + "sourceCommit": "e5aec2bb26110ed474929f353ee51ff46d4f20d6", "sourcePath": "quixos-instance/quixos-nix-helpers", "exportName": "quixos-nix-helpers", "mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git" diff --git a/PORTABLE_ARTIFACTS.md b/PORTABLE_ARTIFACTS.md index 0f4618a..b285675 100644 --- a/PORTABLE_ARTIFACTS.md +++ b/PORTABLE_ARTIFACTS.md @@ -8,9 +8,9 @@ receipts. It has no migration step or package-defined shell hooks. ```nix # Inside a per-system flake output: quixosPackages.checkedArtifacts = - { plan, generator, core, sharedRuntime, typescript, packageRevisionId }: + { buildContract, sourceDigest, generator, core, sharedRuntime, typescript, packageRevisionId, ... }: helpers.mkCaminoArtifacts { - inherit pkgs plan core sharedRuntime typescript packageRevisionId; + inherit pkgs buildContract sourceDigest core sharedRuntime typescript packageRevisionId; protocol = generator; src = ./.; portable = { @@ -22,6 +22,25 @@ quixosPackages.checkedArtifacts = }; ``` +`buildContract` is a canonical, source-free capability slice. `sourceDigest` +records the exact selected package source in the final installation manifest; +it does not enter the reusable compilation group. Ordinary source dependencies, +SDKs and React props companions remain explicit Nix inputs. Raw QX and resource +lock files enter through the checked contract and evaluated recipe, and are +excluded from the implementation source tree. The `plan` argument on the low-level +helper is a composition convenience for fixtures; it is projected before building. +Package recipes receive the contract directly and can be checked without a workspace. + +Inside `checkedArtifacts = context: ...`, the package helpers expose +`camino.packageSet { inherit nixpkgs system context; }` as the usual package set +selection. It shares the workspace composition's evaluated Nixpkgs when the +source content, system, and default import arguments match. Pass ordinary Nixpkgs +arguments such as `config` or `overlays` to use a separate import. A direct +`import nixpkgs { inherit system; }` also works, with additional evaluation cost. +The implementation groups depend on the complete execution compiler bundle and +JavaScript build drivers by content; unrelated checker CLI and publication +metadata changes do not enter those inputs. + The handler entry exports `registry = createRegistry(handlers)` from the generated bindings. One registry contains many exports. The builder always supplies a server implementation for the generated process service. A service-only package instead diff --git a/artifacts.nix b/artifacts.nix index 8e2542a..f5ca613 100644 --- a/artifacts.nix +++ b/artifacts.nix @@ -2,7 +2,25 @@ { pkgs, src, - plan, + plan ? null, + buildContract ? + (import ./package-context.nix { + inherit + pkgs + protocol + plan + packageRevisionId + ; + }).buildContract, + sourceDigest ? + (import ./package-context.nix { + inherit + pkgs + protocol + plan + packageRevisionId + ; + }).sourceDigest, protocol, core, sharedRuntime, @@ -16,19 +34,25 @@ group ? false, }: let - compiler = "${protocol}/libexec/quixos-protocol/execution-world.mjs"; + buildTools = import ./build-tools.nix; + # The standalone bundle is the complete code dependency of this driver. + # Changes to unrelated checker CLIs must not invalidate every package group. + compiler = builtins.path { + path = "${protocol}/libexec/quixos-protocol/execution-world.mjs"; + name = "execution-world.mjs"; + }; config = pkgs.writeText "camino-artifact-build.json" ( builtins.toJSON { inherit - plan + buildContract compiler packageRevisionId core sharedRuntime nodeModules service - group ; + group = true; components = if components == null then null @@ -39,7 +63,10 @@ let bindingOutput = components.bindingOutput or "src/gen/client.ts"; contracts = components.contracts or [ ]; }; - source = src; + source = import ./implementation-source.nix { + inherit (pkgs) lib; + inherit src; + }; portable = if portable == null then null @@ -71,7 +98,33 @@ let serviceAdapter = ./portable-service.mjs; } ); + compiled = + pkgs.runCommand "camino-compiled-artifacts" + { + passthru.buildConfig = config; + QUIXOS_TRACE_PROFILE = "1"; + } + '' + ${pkgs.nodejs_24}/bin/node ${buildTools}/build-artifacts.mjs ${config} + ''; in -pkgs.runCommand "camino-checked-artifacts" { passthru.buildConfig = config; } '' - ${pkgs.nodejs_24}/bin/node ${./.}/build-artifacts.mjs ${config} -'' +if group then + compiled +else + (import ./assemble-package.nix { + inherit pkgs; + artifacts = [ compiled ]; + context = { + inherit + buildContract + sourceDigest + packageRevisionId + sharedRuntime + ; + generator = protocol; + }; + }) + // { + caminoGroup = compiled; + buildConfig = config; + } diff --git a/assemble-package.mjs b/assemble-package.mjs index 56e226e..04a99c7 100644 --- a/assemble-package.mjs +++ b/assemble-package.mjs @@ -6,9 +6,10 @@ import { buildService } from "./build-service.mjs"; const config = JSON.parse(await fs.readFile(process.argv[2], "utf8")); const compiler = await import(pathToFileURL(config.compiler)); -const plan = JSON.parse(await fs.readFile(config.plan, "utf8")); -const pkg = plan.definition.packages.find((pkg) => pkg.revision === config.packageRevisionId); -if (!pkg || compiler.hexDigest(plan.definition) !== plan.digest) throw Error("Invalid package assembly context"); +const contract = JSON.parse(await fs.readFile(config.buildContract, "utf8")); +compiler.checkPackageBuildContract(contract); +const pkg = contract.definition.package; +if (pkg.revision !== config.packageRevisionId) throw Error("Invalid package assembly context"); const output = process.env.out; if (!output) throw Error("Package assembly requires an output directory"); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); @@ -23,18 +24,17 @@ const recordFile = async (name, mediaType) => { }; await fs.mkdir(path.join(output, "share/quixos/generated"), { recursive: true }); for (const root of config.groups) { - const manifest = compiler.parsePackageArtifacts( + const manifest = compiler.parseArtifactGroup( JSON.parse(await fs.readFile(path.join(root, "share/quixos/package-artifacts.json"), "utf8")), ); if ( manifest.packageRevisionId !== pkg.revision || - manifest.bindingDigest !== pkg.bindingDigest || - manifest.sourceDigest !== pkg.sourceDigest || - JSON.stringify(manifest.versions) !== JSON.stringify(plan.definition.versions) + manifest.bindingDigest !== contract.digest || + JSON.stringify(manifest.versions) !== JSON.stringify(contract.definition.versions) ) throw Error("Artifact group belongs to a different checked package"); const receipt = JSON.parse(await fs.readFile(path.join(root, "share/quixos/build-receipt.json"), "utf8")); - const expected = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); + const expected = compiler.expectedGroupReceipt(contract, manifest, generatorDigest); if (JSON.stringify(receipt) !== JSON.stringify(expected.receipt)) throw Error("Artifact group has an invalid build receipt"); const read = await compiler.artifactFileReader({ [pkg.revision]: root }); @@ -80,24 +80,18 @@ if (!implementations.some((entry) => entry.compatibility === "quixos-service-v1" const manifest = compiler.parsePackageArtifacts({ schemaVersion: 1, packageRevisionId: pkg.revision, - bindingDigest: pkg.bindingDigest, - sourceDigest: pkg.sourceDigest, - versions: plan.definition.versions, + bindingDigest: contract.digest, + sourceDigest: config.sourceDigest, + versions: contract.definition.versions, implementations, ...(components.length ? { components } : {}), }); -const { receipt, sources } = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); +const { receipt, sources } = compiler.expectedPackageReceipt(contract, manifest, config.sourceDigest, generatorDigest); for (const [name, bytes] of sources) { const actual = await fs.readFile(path.join(output, name)); if (!Buffer.from(bytes).equals(actual)) throw Error("Assembly lacks checked generated source " + name); } await fs.writeFile(path.join(output, "share/quixos/package-artifacts.json"), JSON.stringify(manifest, null, 2)); await fs.writeFile(path.join(output, "share/quixos/build-receipt.json"), JSON.stringify(receipt, null, 2)); -// Check package coverage with the same public validator used at installation. -const packagePlan = { definition: { ...plan.definition, packages: [pkg] }, digest: "" }; -packagePlan.digest = compiler.hexDigest(packagePlan.definition); -await compiler.checkExecutionWorld( - packagePlan, - [manifest], - await compiler.artifactFileReader({ [pkg.revision]: output }), -); +// Source selection is attested here; compilation groups retain their own receipt. +compiler.checkPackageCoverage({ ...pkg, bindingDigest: contract.digest, sourceDigest: config.sourceDigest }, manifest); diff --git a/assemble-package.nix b/assemble-package.nix index 8550622..f4f4835 100644 --- a/assemble-package.nix +++ b/assemble-package.nix @@ -4,12 +4,22 @@ artifacts, }: let + buildTools = import ./build-tools.nix; groups = map (artifact: artifact.caminoGroup or artifact) artifacts; + compiler = builtins.path { + path = "${context.generator}/libexec/quixos-protocol/execution-world.mjs"; + name = "execution-world.mjs"; + }; config = pkgs.writeText "camino-package-assembly.json" ( builtins.toJSON { inherit groups; - inherit (context) plan packageRevisionId sharedRuntime; - compiler = "${context.generator}/libexec/quixos-protocol/execution-world.mjs"; + inherit (context) + buildContract + sourceDigest + packageRevisionId + sharedRuntime + ; + inherit compiler; node = "${pkgs.nodejs_24}/bin/node"; esbuild = "${pkgs.esbuild}/bin/esbuild"; shell = "${pkgs.runtimeShell}"; @@ -25,5 +35,5 @@ pkgs.runCommand "camino-package" }; } '' - ${pkgs.nodejs_24}/bin/node ${./.}/assemble-package.mjs ${config} + ${pkgs.nodejs_24}/bin/node ${buildTools}/assemble-package.mjs ${config} '' diff --git a/build-artifacts.mjs b/build-artifacts.mjs index 8bf757f..040e3b3 100644 --- a/build-artifacts.mjs +++ b/build-artifacts.mjs @@ -1,4 +1,5 @@ import { installDependencies } from "./install-dependencies.mjs"; +import { measureBuild } from "./build-profile.mjs"; import { buildService } from "./build-service.mjs"; import { buildComponents } from "./build-components.mjs"; /** Trusted Nix build driver. No package-defined build hooks run between generation and witnessing. */ @@ -10,9 +11,10 @@ import { execFileSync } from "node:child_process"; import { pathToFileURL } from "node:url"; const config = JSON.parse(await fs.readFile(process.argv[2], "utf8")); const compiler = await import(pathToFileURL(config.compiler)); -const plan = JSON.parse(await fs.readFile(config.plan, "utf8")); -const pkg = plan.definition.packages.find((p) => p.revision === config.packageRevisionId); -if (!pkg || compiler.hexDigest(plan.definition) !== plan.digest) throw Error("Invalid checked execution plan/package"); +const contract = JSON.parse(await fs.readFile(config.buildContract, "utf8")); +compiler.checkPackageBuildContract(contract); +const pkg = contract.definition.package; +if (pkg.revision !== config.packageRevisionId) throw Error("Invalid build contract package"); const output = process.env.out; if (!output) throw Error("Missing Nix output"); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); @@ -55,6 +57,7 @@ const targets = portable ? [...new Set([...portable.targets, "server"])] : []; const builds = targets.map((target) => ({ target, mode: target, settings: portable })); if (config.remote) builds.push({ target: "server", mode: "remote", settings: config.remote }); for (const { target, mode, settings } of builds) { + const executionTarget = target === "browser" ? "target:org.quixos.execution.javascript@1" : "server"; if (!["browser", "server"].includes(target)) throw Error(`Unsupported JS build target ${target}`); const work = path.resolve(`work-${mode}`); await fs.cp(config.source, work, { recursive: true, dereference: false }); @@ -85,53 +88,65 @@ for (const { target, mode, settings } of builds) { const bindingPath = relative(settings.bindingOutput); const source = mode === "remote" - ? compiler.generateRemoteTypeScriptBindings(plan.definition.bindingSchema, pkg.revision) - : compiler.generatePortableTypeScriptBindings(plan.definition.bindingSchema, pkg.revision, { target }); + ? compiler.generateRemoteTypeScriptBindings(contract.definition.bindings, pkg.revision) + : compiler.generatePortableTypeScriptBindings(contract.definition.bindings, pkg.revision, { + target: executionTarget, + }); await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true }); await fs.writeFile(path.join(work, bindingPath), source); generated.set(mode, { file: path.join(work, bindingPath), source }); - await fs.writeFile(path.join(output, "share/quixos/generated", `${mode}.ts`), source); + await fs.writeFile( + path.join( + output, + mode === "remote" ? "share/quixos/generated/remote.ts" : compiler.generatedBindingPath(executionTarget), + ), + source, + ); const entry = relative(settings.entries?.[target] ?? settings.entry); - execFileSync( - config.node, - [ - config.tsc, - "--noEmit", - "--strict", - "--skipLibCheck", - "false", - "--target", - "es2024", - "--lib", - "es2024,dom,esnext.disposable", - "--module", - "nodenext", - "--moduleResolution", - "nodenext", - entry, - ], - { cwd: work, stdio: "inherit" }, + measureBuild("portable-typecheck", { package: pkg.revision, target, mode }, () => + execFileSync( + config.node, + [ + config.tsc, + "--noEmit", + "--strict", + "--skipLibCheck", + "false", + "--target", + "es2024", + "--lib", + "es2024,dom,esnext.disposable", + "--module", + "nodenext", + "--moduleResolution", + "nodenext", + entry, + ], + { cwd: work, stdio: "inherit" }, + ), ); const bundlePath = mode === "remote" ? "share/quixos/remote/server/module.mjs" : `share/quixos/portable/${target}/module.mjs`; await fs.mkdir(path.dirname(path.join(output, bundlePath)), { recursive: true }); const coreImport = target === "browser" ? coreURL : coreServer; const metadata = path.join(work, "bundle-meta.json"); - execFileSync( - config.esbuild, - [ - entry, - "--bundle", - `--platform=${target === "browser" ? "browser" : "node"}`, - "--format=esm", - "--minify-whitespace", - "--target=es2024", - `--outfile=${path.join(output, bundlePath)}`, - `--metafile=${metadata}`, - `--alias:@quixos/camino-replica-core=${coreImport}`, - `--external:${coreImport}`, - ], - { cwd: work, stdio: "inherit" }, + measureBuild("portable-bundle", { package: pkg.revision, target, mode }, () => + execFileSync( + config.esbuild, + [ + entry, + "--bundle", + `--platform=${target === "browser" ? "browser" : "node"}`, + "--format=esm", + "--minify-whitespace", + "--target=es2024", + `--outfile=${path.join(output, bundlePath)}`, + `--metafile=${metadata}`, + `--alias:@quixos/camino-replica-core=${coreImport}`, + `--external:${coreImport}`, + ], + { cwd: work, stdio: "inherit" }, + ), ); const meta = JSON.parse(await fs.readFile(metadata, "utf8")); const built = Object.values(meta.outputs).find((o) => o.entryPoint); @@ -152,12 +167,12 @@ for (const { target, mode, settings } of builds) { .filter((e) => mode === "remote" ? e.execution.kind === "remote" - : e.execution.kind !== "remote" && e.execution.targets.includes(target), + : e.execution.kind !== "remote" && e.execution.targets.includes(executionTarget), ) .map((e) => e.id); const body = { id: `${mode}-module`, - target, + target: executionTarget, compatibility: "quixos-js-module-v1", path: bundlePath, ports: "quixos-transaction-ports-v1", @@ -170,22 +185,21 @@ for (const { target, mode, settings } of builds) { const components = config.group && !config.components ? [] - : await buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }); + : await buildComponents({ config, compiler, contract, pkg, output, recordFile, generated }); if (!config.group || config.service.entry) await buildService({ config, compiler, pkg, output, coreServer, sharedFiles, implementations, recordFile }); -const manifest = compiler.parsePackageArtifacts({ +const manifest = compiler.parseArtifactGroup({ schemaVersion: 1, packageRevisionId: pkg.revision, - bindingDigest: pkg.bindingDigest, - sourceDigest: pkg.sourceDigest, - versions: plan.definition.versions, + bindingDigest: contract.digest, + versions: contract.definition.versions, implementations, ...(components.length ? { components } : {}), }); for (const { file, source } of generated.values()) if ((await fs.readFile(file, "utf8")) !== source) throw Error("Generated bindings changed during build"); -const { receipt, sources } = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); +const { receipt, sources } = compiler.expectedGroupReceipt(contract, manifest, generatorDigest); for (const [file, bytes] of sources) if (!Buffer.from(bytes).equals(await fs.readFile(path.join(output, file)))) throw Error("Receipt generation differs from compiled bindings"); diff --git a/build-components.mjs b/build-components.mjs index ba34594..535594f 100644 --- a/build-components.mjs +++ b/build-components.mjs @@ -1,10 +1,13 @@ import { installDependencies } from "./install-dependencies.mjs"; +import { checkComponentTypes } from "./component-types.mjs"; +import { measureBuild } from "./build-profile.mjs"; import fs from "node:fs/promises"; import path from "node:path"; import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); -export async function buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }) { +export async function buildComponents({ config, compiler, contract, pkg, output, recordFile, generated }) { const settings = config.components; if (!settings) { if (pkg.components?.length) throw Error("Declared components require checked component build settings"); @@ -100,12 +103,14 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec await fs.writeFile( shim, `export * from ${JSON.stringify(url)};` + - (name === "@quixos/camino-react" ? "" : `export {default} from ${JSON.stringify(url)};`), + (["@quixos/camino-react", "@quixos/camino-react/ui"].includes(name) + ? "" + : `export {default} from ${JSON.stringify(url)};`), ); sharedAliases.push("--alias:" + name + "=" + shim, "--external:" + url); } const bindingPath = compiler.artifactPath(settings.bindingOutput), - bindings = compiler.generateComponentClientBindings(plan, pkg.revision), + bindings = contract.definition.component, assets = compiler.componentAssetDeclarations(); await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true }); await fs.writeFile(path.join(work, bindingPath), bindings); @@ -123,10 +128,9 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec for (const root of settings.contracts ?? []) { const companion = JSON.parse(await fs.readFile(path.join(root, "react-contracts.json"), "utf8")); if (companion.schemaVersion !== 1) throw Error("Unsupported React props companion"); - const contracts = plan.definition.workspace.interfaceImports.filter( + const contracts = contract.definition.moduleInterfaces.filter( (iface) => - iface.revisionId === companion.interfaceRevisionId || - iface.application?.definitionId === companion.interfaceRevisionId, + iface.revisionId === companion.interfaceRevisionId || iface.definitionId === companion.interfaceRevisionId, ); if (!contracts.length) continue; for (const [memberId, entry] of Object.entries(companion.members)) { @@ -136,12 +140,7 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec `import type {${entry.export} as ${alias}} from ${JSON.stringify(path.join(root, "types", compiler.artifactPath(entry.path)))};`, ); for (const iface of contracts) { - if ( - !(iface.template?.members ?? iface.members).some( - (member) => member.kind === "module" && member.id === memberId, - ) - ) - throw Error("Props companion names an unknown module member"); + if (!iface.members.includes(memberId)) throw Error("Props companion names an unknown module member"); const key = JSON.stringify([iface.revisionId, memberId]); if (seenProps.has(key)) throw Error("Duplicate React props companion for " + key); seenProps.add(key); @@ -155,46 +154,41 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec propsImports.join("\n") + `\nexport {};\ndeclare module ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))} { interface ModuleProps {${propsMembers.join("\n")}} }\n`, ); - const components = []; + const witnesses = []; for (const [id, entry] of entries) { const declaration = declarations.find((component) => component.id === id), source = compiler.artifactPath(entry.entry); if (!/^[$A-Z_a-z][$\w]*$/.test(entry.export)) throw Error("Invalid component export name"); - const key = digest(Buffer.from(id)).slice(0, 24), - directory = "share/quixos/components/" + key; + const key = digest(Buffer.from(id)).slice(0, 24); const witness = path.join(work, "component-check-" + key + ".tsx"); const module = pkg.modules.find((module) => module.id === id); await fs.writeFile( witness, `import type {Modules} from ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))};import {${entry.export} as Component} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};const checked:Modules[${JSON.stringify(declaration.displayName)}]=Component;export default checked;\n` + (module.contract === "org.quixos.react.subject-only/1" - ? `import type {ComponentProps} from "@quixos/camino-react";declare const subject:ComponentProps<${JSON.stringify(declaration.subject)}>['subject'];const mount = ;\n` + ? `import type {ComponentProps} from "react";declare const props:Pick,"subject"|"input">;const mount = ;\n` : ""), ); - execFileSync( - config.node, - [ - config.tsc, - "--noEmit", - "--strict", - "--skipLibCheck", - "false", - "--target", - "es2024", - "--lib", - "es2024,dom,dom.iterable,esnext.disposable", - "--module", - "nodenext", - "--moduleResolution", - "nodenext", - "--jsx", - "react-jsx", - path.basename(witness), - assetPath, - propsPath, - ], - { cwd: work, stdio: "inherit" }, - ); + witnesses.push(path.basename(witness)); + } + checkComponentTypes({ + node: config.node, + tsc: config.tsc, + work, + witnesses, + assets: assetPath, + props: propsPath, + packageRevision: pkg.revision, + }); + const components = []; + const { checkControls } = await import(pathToFileURL(path.join(settings.sdk, "lint-controls.mjs"))); + const { default: ts } = await import(pathToFileURL(path.resolve(path.dirname(config.tsc), "../lib/typescript.js"))); + const checkedControls = new Set(); + for (const [id, entry] of entries) { + const declaration = declarations.find((component) => component.id === id), + source = compiler.artifactPath(entry.entry), + key = digest(Buffer.from(id)).slice(0, 24), + directory = "share/quixos/components/" + key; const metadata = path.join(work, "component-meta-" + key + ".json"); const facade = "component-entry-" + key + ".ts"; await fs.writeFile( @@ -202,27 +196,35 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec `export {${entry.export}} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};\n`, ); await fs.mkdir(path.join(output, directory), { recursive: true }); - execFileSync( - config.esbuild, - [ - facade, - "--bundle", - "--platform=browser", - "--format=esm", - "--target=es2024", - "--jsx=automatic", - '--define:process.env.NODE_ENV="production"', - "--metafile=" + metadata, - "--outfile=" + path.join(output, directory, "module.mjs"), - ...sharedAliases, - ...["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"].map( - (extension) => "--loader:." + extension + "=dataurl", - ), - ], - { cwd: work, stdio: "inherit" }, + measureBuild("component-bundle", { package: pkg.revision, component: id }, () => + execFileSync( + config.esbuild, + [ + facade, + "--bundle", + "--platform=browser", + "--format=esm", + "--target=es2024", + "--jsx=automatic", + '--define:process.env.NODE_ENV="production"', + "--metafile=" + metadata, + "--outfile=" + path.join(output, directory, "module.mjs"), + ...sharedAliases, + ...["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"].map( + (extension) => "--loader:." + extension + "=dataurl", + ), + ], + { cwd: work, stdio: "inherit" }, + ), ); const meta = JSON.parse(await fs.readFile(metadata, "utf8")), outputs = Object.entries(meta.outputs); + for (const file of Object.keys(meta.inputs)) { + if (!/\.[cm]?[jt]sx?$/.test(file) || file.includes("node_modules/") || checkedControls.has(file)) continue; + checkedControls.add(file); + const errors = checkControls(ts, await fs.readFile(path.resolve(work, file), "utf8"), file); + if (errors.length) throw Error(errors.join("\n")); + } const main = outputs.find(([file]) => file.endsWith("/module.mjs"))?.[1]; if (!main?.exports.includes(entry.export)) throw Error("Component bundle lacks declared export"); if ( diff --git a/build-profile.mjs b/build-profile.mjs new file mode 100644 index 0000000..4391102 --- /dev/null +++ b/build-profile.mjs @@ -0,0 +1,12 @@ +/** Timing is log data, never part of an artifact or its witness. */ +export function measureBuild(phase, details, action) { + const started = performance.now(); + try { + return action(); + } finally { + if (process.env.QUIXOS_TRACE_PROFILE === "1") + console.error( + `[qx-build-profile] ${JSON.stringify({ phase, ms: Math.round(performance.now() - started), ...details })}`, + ); + } +} diff --git a/build-react-contracts.mjs b/build-react-contracts.mjs index 0081175..e4d6736 100644 --- a/build-react-contracts.mjs +++ b/build-react-contracts.mjs @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { execFileSync } from "node:child_process"; +import { installDependencies } from "./install-dependencies.mjs"; const config = JSON.parse(await fs.readFile(process.argv[2], "utf8")); const resource = JSON.parse(await fs.readFile(config.interface, "utf8")); if (resource.kind !== "interface") throw Error("React props belong to an interface resource"); @@ -29,14 +30,24 @@ const metadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error) throw error; }); await fs.writeFile(packageFile, JSON.stringify({ ...metadata, type: "module" })); -if (config.dependencies) await fs.symlink(config.dependencies, path.join(work, "node_modules")); +const modules = path.join(output, "node_modules"); +await fs.mkdir(modules, { recursive: true }); +await installDependencies({ + destination: modules, + application: config.dependencies, + selected: config.reactSdk ? { "@quixos/camino-react": config.reactSdk } : {}, + reserved: ["@quixos/camino-react"], +}); +await fs.symlink(modules, path.join(work, "node_modules")); const members = Object.create(null); let index = 0; for (const [id, entry] of Object.entries(config.members)) { const member = (iface.template?.members ?? iface.members).find((member) => member.id === id); if ( member?.kind !== "module" || - !["org.quixos.react.component/1", "org.quixos.react.subject-only/1"].includes(member.contract) + !["org.quixos.react.component/1", "org.quixos.react.component/2", "org.quixos.react.subject-only/1"].includes( + member.contract, + ) ) throw Error("Props entry is not a React module member: " + id); if ( @@ -52,7 +63,7 @@ for (const [id, entry] of Object.entries(config.members)) { `import type {${entry.export} as Props} from ${typeImport}; type Assert = T; type ObjectProps = Assert; -type ReservedSubject = Assert<"subject" extends keyof Props ? false : true>; +type ReservedRendererProps = Assert extends never ? true : false>; ${member.contract === "org.quixos.react.subject-only/1" ? "type SubjectOnly = Assert<{} extends Props ? true : false>;" : ""} export type {Props};\n`, ); @@ -69,6 +80,8 @@ execFileSync( "--strict", "--target", "es2024", + "--lib", + "es2024,dom,dom.iterable,esnext.disposable", "--module", "nodenext", "--moduleResolution", @@ -88,7 +101,6 @@ for (const file of declarations) { await fs.copyFile(path.join(work, file), path.join(output, "types", file)); } await fs.writeFile(path.join(output, "package.json"), JSON.stringify({ type: "module" })); -if (config.dependencies) await fs.symlink(config.dependencies, path.join(output, "node_modules")); await fs.writeFile( path.join(output, "react-contracts.json"), JSON.stringify({ schemaVersion: 1, interfaceRevisionId: iface.revisionId, members }, null, 2), diff --git a/build-tools.nix b/build-tools.nix new file mode 100644 index 0000000..c80795d --- /dev/null +++ b/build-tools.nix @@ -0,0 +1,13 @@ +# Standalone build drivers import sibling JavaScript modules. Their code is an +# input; Nix helper APIs and test sources do not belong to that dependency. +builtins.path { + path = ./.; + name = "camino-build-tools"; + filter = + filename: type: + let + name = builtins.baseNameOf filename; + in + type == "directory" + || (builtins.match ".*\\.mjs" name != null && builtins.match ".*\\.test\\.mjs" name == null); +} diff --git a/component-type-batch.mjs b/component-type-batch.mjs new file mode 100644 index 0000000..4ce801a --- /dev/null +++ b/component-type-batch.mjs @@ -0,0 +1,49 @@ +import path from "node:path"; +import { createRequire } from "node:module"; + +const [tsc, count, ...args] = process.argv.slice(2); +const ts = createRequire(import.meta.url)(path.resolve(path.dirname(tsc), "../lib/typescript.js")); +const parsed = ts.parseCommandLine(args); +const witnesses = parsed.fileNames.slice(0, Number(count)); +const shared = parsed.fileNames.slice(Number(count)); +const host = ts.createCompilerHost(parsed.options, true); +// These programs only discover source closures; they do not bind or check. +// Sharing immutable parsed source files avoids reading/parsing the same libs +// for every witness. Only the final combined program performs typechecking. +const sources = new Map(); +const getSourceFile = host.getSourceFile; +host.getSourceFile = (filename, ...options) => { + if (!sources.has(filename)) sources.set(filename, getSourceFile(filename, ...options)); + return sources.get(filename); +}; +for (const method of ["fileExists", "directoryExists", "getDirectories", "readFile", "realpath"]) { + if (!host[method]) continue; + const original = host[method], + cache = new Map(); + host[method] = (filename) => { + if (!cache.has(filename)) cache.set(filename, original(filename)); + return cache.get(filename); + }; +} +const closures = witnesses.map( + (witness) => new Set(ts.createProgram([witness, ...shared], parsed.options, host).getSourceFiles()), +); +const common = new Set([...closures[0]].filter((source) => closures.every((closure) => closure.has(source)))); +const ambient = (node) => + ts.isModuleDeclaration(node) || ts.isNamespaceExportDeclaration(node) || ts.forEachChild(node, ambient); +// A file that is visible to just some witnesses must not contribute globals +// or ambient module declarations to the others. Otherwise a combined success +// could hide an independent failure. Script files are conservatively unsafe. +if ( + closures.some((closure) => + [...closure].some((source) => !common.has(source) && (!ts.isExternalModule(source) || ambient(source))), + ) +) + process.exit(2); + +const program = ts.createProgram(parsed.fileNames, parsed.options, host); +const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)]; +if (diagnostics.length) { + console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, host)); + process.exit(1); +} diff --git a/component-types.mjs b/component-types.mjs new file mode 100644 index 0000000..8da07ce --- /dev/null +++ b/component-types.mjs @@ -0,0 +1,51 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { measureBuild } from "./build-profile.mjs"; + +// Component witnesses share bindings and usually the same source graph. +// Check a bounded batch once when distinct source closures cannot supply +// ambient declarations to one another. Otherwise use the original checks. +export function checkComponentTypes({ node, tsc, work, witnesses, assets, props, packageRevision }) { + const checkTypes = (files, combined = false) => + execFileSync( + node, + [ + ...(combined ? [fileURLToPath(new URL("./component-type-batch.mjs", import.meta.url))] : []), + tsc, + ...(combined ? [String(files.length)] : []), + "--noEmit", + "--strict", + "--skipLibCheck", + "false", + "--target", + "es2024", + "--lib", + "es2024,dom,dom.iterable,esnext.disposable", + "--module", + "nodenext", + "--moduleResolution", + "nodenext", + "--jsx", + "react-jsx", + ...files, + assets, + props, + ], + { cwd: work, stdio: combined ? "pipe" : "inherit" }, + ); + measureBuild("component-typecheck", { package: packageRevision, components: witnesses.length }, () => { + for (let start = 0; start < witnesses.length; start += 1000) { + const batch = witnesses.slice(start, start + 1000); + if (batch.length === 1) checkTypes(batch); + else { + try { + checkTypes(batch, true); + } catch (error) { + if (!(error.status > 0) && error.code !== "ENOBUFS") throw error; + console.error("Component batch requires independent witness checks"); + for (const witness of batch) checkTypes([witness]); + } + } + } + }); +} diff --git a/component-types.test.mjs b/component-types.test.mjs new file mode 100644 index 0000000..3bfce73 --- /dev/null +++ b/component-types.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { checkComponentTypes } from "./component-types.mjs"; + +const tsc = process.env.QUIXOS_TYPESCRIPT; +if (!tsc) throw Error("Pass the selected TypeScript compiler path"); +async function fixture(context, left, right) { + const work = await mkdtemp(path.join(os.tmpdir(), "component-types-")); + context.after(() => rm(work, { recursive: true, force: true })); + await Promise.all( + Object.entries({ "left.ts": left, "right.ts": right, "assets.d.ts": "", "props.d.ts": "" }).map(([file, source]) => + writeFile(path.join(work, file), source), + ), + ); + return { + node: process.execPath, + tsc, + work, + witnesses: ["left.ts", "right.ts"], + assets: "assets.d.ts", + props: "props.d.ts", + packageRevision: "test", + }; +} +test("shared component typechecking accepts all valid witnesses", async (context) => { + checkComponentTypes(await fixture(context, "export const left: string = 'ok';", "export const right: number = 1;")); +}); +test("independently valid ambient declarations use the full individual check fallback", async (context) => { + checkComponentTypes( + await fixture( + context, + "export {}; declare global { interface Application { value: string; } }", + "export {}; declare global { interface Application { value: number; } }", + ), + ); +}); +test("a type error in any witness still rejects the artifact", async (context) => { + const input = await fixture(context, "export const left: string = 'ok';", "export const right: string = 1;"); + assert.throws(() => checkComponentTypes(input)); +}); +test("one witness's globals cannot make an independently invalid witness pass", async (context) => { + const input = await fixture( + context, + "export {}; declare global { interface Application { value: string; } }", + "export const right: Application = {value: 'ok'};", + ); + assert.throws(() => checkComponentTypes(input)); +}); +test("an ambient module from another source closure cannot resolve a missing import", async (context) => { + const input = await fixture( + context, + "declare module 'missing-component-package' { export const value: string; }", + "import {value} from 'missing-component-package'; export const right = value;", + ); + assert.throws(() => checkComponentTypes(input)); +}); diff --git a/implementation-source.nix b/implementation-source.nix new file mode 100644 index 0000000..672db0a --- /dev/null +++ b/implementation-source.nix @@ -0,0 +1,30 @@ +# QX and recursive pins enter through the checked contract and build recipe. +# The compiler receives authored implementation files, not resource provenance. +{ lib, src }: +builtins.path { + path = src; + name = "camino-implementation-source"; + filter = + path: type: + let + name = builtins.baseNameOf path; + in + if type == "directory" then + !(builtins.elem name [ + ".git" + ".jj" + "node_modules" + ]) + else + !(builtins.elem name [ + "flake.nix" + "flake.lock" + "quixos.lock" + "quixos.check.json" + "quixos.react.json" + "yarn-project.nix" + "components.nix" + "contracts.nix" + ]) + && !(lib.hasSuffix ".qx" name); +} diff --git a/package-context.nix b/package-context.nix new file mode 100644 index 0000000..36310f3 --- /dev/null +++ b/package-context.nix @@ -0,0 +1,2 @@ +# The selected protocol owns semantic projection; builders receive its result. +args@{ protocol, ... }: import "${protocol}/share/package-context.nix" args diff --git a/package-set.test.nix b/package-set.test.nix new file mode 100644 index 0000000..034e57b --- /dev/null +++ b/package-set.test.nix @@ -0,0 +1,57 @@ +{ + pkgs, + nixpkgs, + system, +}: +let + packageSet = (import ./quixos-package-helpers.nix).packageSet; + shared = { + marker = "shared"; + }; + environment = { + inherit system; + nixpkgsSource = toString nixpkgs; + pkgs = shared; + narHash = nixpkgs.narHash; + }; + standard = { + inherit nixpkgs system; + context.buildEnvironment = environment; + }; + fallback = packageSet { inherit nixpkgs system; }; +in +assert packageSet standard == shared; +assert + packageSet ( + standard + // { + context.buildEnvironment = environment // { + nixpkgsSource = "same-content-under-another-name"; + }; + } + ) == shared; +assert + (packageSet ( + standard + // { + context.buildEnvironment = environment // { + system = "another-system"; + }; + } + )).path == fallback.path; +assert + (packageSet ( + standard + // { + context.buildEnvironment = environment // { + nixpkgsSource = "another-source"; + narHash = "another-hash"; + }; + } + )).path == fallback.path; +assert + (packageSet (standard // { overlays = [ (_: _: { marker = "overlay"; }) ]; })).marker == "overlay"; +assert (packageSet (standard // { config.allowBroken = true; })).config.allowBroken; +pkgs.runCommand "camino-package-set-check" { } '' + touch "$out" +'' diff --git a/quixos-package-helpers.nix b/quixos-package-helpers.nix index 356a7d4..8f3ecef 100644 --- a/quixos-package-helpers.nix +++ b/quixos-package-helpers.nix @@ -847,6 +847,40 @@ let in { mkCaminoReactPackage = import ./react-package.nix; + # Share the composition's package set only for the same exact Nixpkgs source, + # system and default import arguments. Config/overlay/cross-build choices use + # ordinary Nixpkgs evaluation. No package implementation is substituted. + packageSet = + args@{ + nixpkgs, + system, + context ? { }, + ... + }: + let + authoredArgs = builtins.removeAttrs args [ + "nixpkgs" + "context" + ]; + importArgs = { + config = { }; + overlays = [ ]; + } + // authoredArgs; + environment = context.buildEnvironment or null; + in + if + builtins.attrNames authoredArgs == [ "system" ] + && environment != null + && environment.system == system + && ( + environment.nixpkgsSource == toString nixpkgs + || ((nixpkgs.narHash or null) != null && nixpkgs.narHash == (environment.narHash or null)) + ) + then + environment.pkgs + else + import nixpkgs importArgs; mkCaminoReactContracts = import ./react-contracts.nix; mkCaminoYarnDependencies = import ./yarn-dependencies.nix; mkCaminoYarnShell = import ./yarn-shell.nix; diff --git a/react-contracts.nix b/react-contracts.nix index acd5b9b..fcc1e40 100644 --- a/react-contracts.nix +++ b/react-contracts.nix @@ -6,16 +6,33 @@ dependencies ? null, }: let + buildTools = import ./build-tools.nix; config = pkgs.writeText "camino-react-contracts.json" ( builtins.toJSON { - source = src; - interface = context.interface; + source = import ./implementation-source.nix { + inherit (pkgs) lib; + inherit src; + }; + interface = builtins.toFile "camino-interface-contract.json" ( + builtins.unsafeDiscardStringContext ( + builtins.toJSON ( + let + resource = builtins.fromJSON (builtins.readFile context.interface); + in + { + kind = resource.kind; + revision = builtins.removeAttrs resource.revision [ "source" ]; + } + ) + ) + ); inherit members dependencies; + reactSdk = context.reactSdk or null; node = "${pkgs.nodejs_24}/bin/node"; tsc = "${context.typescript}/node_modules/typescript/bin/tsc"; } ); in pkgs.runCommand "camino-react-contracts" { } '' - ${pkgs.nodejs_24}/bin/node ${./.}/build-react-contracts.mjs ${config} + ${pkgs.nodejs_24}/bin/node ${buildTools}/build-react-contracts.mjs ${config} '' diff --git a/react-package.nix b/react-package.nix index e76ad74..4a99309 100644 --- a/react-package.nix +++ b/react-package.nix @@ -10,7 +10,7 @@ let group = import ./artifacts.nix { inherit pkgs src; inherit (context) - plan + buildContract core sharedRuntime typescript diff --git a/typescript-package.nix b/typescript-package.nix index 950dcbb..09fcaae 100644 --- a/typescript-package.nix +++ b/typescript-package.nix @@ -15,7 +15,7 @@ let group = import ./artifacts.nix { inherit pkgs src; inherit (context) - plan + buildContract core sharedRuntime typescript diff --git a/typescript-service.nix b/typescript-service.nix index 61ceea6..587b60a 100644 --- a/typescript-service.nix +++ b/typescript-service.nix @@ -11,7 +11,7 @@ let group = import ./artifacts.nix { inherit pkgs src service; inherit (context) - plan + buildContract core sharedRuntime typescript