diff --git a/flake.nix b/flake.nix index efac4b1..54daab3 100644 --- a/flake.nix +++ b/flake.nix @@ -80,6 +80,9 @@ mkdir -p "$out" install -Dm644 nix/checked-package.nix "$out/share/checked-package.nix" install -Dm644 nix/checked-artifacts.nix "$out/share/checked-artifacts.nix" + install -Dm644 nix/package-context.nix "$out/share/package-context.nix" + install -Dm644 nix/package-context.mjs "$out/share/package-context.mjs" + install -Dm644 nix/check-package.mjs "$out/share/check-package.mjs" install -Dm644 nix/check-execution.mjs "$out/share/check-execution.mjs" substitute nix/checked-candidate.nix "$out/share/checked-candidate.nix" \ --replace-fail '@nixpkgs@' '${pkgs.path}' diff --git a/nix/check-package.mjs b/nix/check-package.mjs new file mode 100644 index 0000000..efeb326 --- /dev/null +++ b/nix/check-package.mjs @@ -0,0 +1,41 @@ +// An independent package check witnesses its contract and bytes, without +// inventing a workspace or granting installation/activation authority. +import { readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; +const [compilerPath, schemaPath, checksPath, output] = process.argv.slice(2); +const compiler = await import(pathToFileURL(compilerPath)); +const schema = JSON.parse(await readFile(schemaPath, "utf8")); +const checks = JSON.parse(await readFile(checksPath, "utf8")); +const generator = createHash("sha256") + .update(await readFile(compilerPath)) + .digest("hex"); +const evidence = []; +for (const check of checks) { + const pkg = schema.packages.find((pkg) => pkg.revisionId === check.packageRevisionId); + if (!pkg) throw Error("Unknown checked package"); + const contract = compiler.packageBuildContract(schema, pkg.revisionId); + const sourceDigest = compiler.hexDigest({ revision: pkg.revisionId, source: pkg.source }); + const manifest = compiler.parsePackageArtifacts( + JSON.parse(await readFile(join(check.artifactPath, "share/quixos/package-artifacts.json"), "utf8")), + ); + compiler.checkPackageCoverage( + { ...contract.definition.package, bindingDigest: contract.digest, sourceDigest }, + manifest, + ); + const receipt = JSON.parse(await readFile(join(check.artifactPath, "share/quixos/build-receipt.json"), "utf8")); + const expected = compiler.expectedPackageReceipt(contract, manifest, sourceDigest, generator); + if (compiler.hexDigest(receipt) !== compiler.hexDigest(expected.receipt)) + throw Error("Invalid package build receipt"); + const read = await compiler.artifactFileReader({ [pkg.revisionId]: check.artifactPath }); + await compiler.checkArtifactFiles(manifest, read); + for (const [file, bytes] of expected.sources) + if (!Buffer.from(bytes).equals(await read(pkg.revisionId, file, bytes.length))) + throw Error("Modified generated binding " + file); + evidence.push({ ...check, contract: contract.digest, sourceDigest, receipt }); +} +await writeFile( + join(output, "package-checks.json"), + JSON.stringify({ codec: "camino-package-checks-v1", generator, packages: evidence }), +); diff --git a/nix/checked-artifacts.nix b/nix/checked-artifacts.nix index 0505663..cc7d7c1 100644 --- a/nix/checked-artifacts.nix +++ b/nix/checked-artifacts.nix @@ -24,18 +24,26 @@ let package = builtins.getFlake ( "path:" + builtins.unsafeDiscardStringContext (toString packageSource) ); + pkgs = import package.inputs.nixpkgs { inherit system; }; + buildContext = import ./package-context.nix { + inherit pkgs packageRevisionId; + protocol = builtins.storePath generator; + plan = builtins.path { + path = /. + plan; + name = "candidate-execution-plan.json"; + }; + }; checked = package.quixosPackages.${system}.checkedArtifacts or (throw "Package ${packageRevisionId} lacks checkedArtifacts for the portable execution world."); in -checked { - inherit packageRevisionId; - plan = builtins.path { - path = /. + plan; - name = "candidate-execution-plan.json"; - }; - generator = builtins.storePath generator; - core = builtins.storePath core; - sharedRuntime = builtins.storePath sharedRuntime; - typescript = builtins.storePath typescript; -} +checked ( + buildContext + // { + inherit packageRevisionId; + generator = builtins.storePath generator; + core = builtins.storePath core; + sharedRuntime = builtins.storePath sharedRuntime; + typescript = builtins.storePath typescript; + } +) diff --git a/nix/checked-candidate.nix b/nix/checked-candidate.nix index 9533963..2cebdd7 100644 --- a/nix/checked-candidate.nix +++ b/nix/checked-candidate.nix @@ -20,26 +20,45 @@ let ref = "refs/tags/quixos-reachability/${source.commit}"; shallow = true; }; - load = - ancestors: source: - if builtins.elem (sourceKey source) ancestors then - throw "Source dependency cycle at ${sourceKey source}" - else if builtins.length ancestors >= 100 then - throw "Source dependency depth exceeds 100" - else - let - directory = fetch source; - lockFile = pkgs.runCommand "qx-source-lock.json" { } '' - ${protocol}/bin/quixos-lock-check ${directory}/quixos.lock > "$out" - ''; - lock = builtins.fromJSON (builtins.readFile lockFile); + # Memoize by exact source identity. A diamond-shaped dependency graph must + # not recursively evaluate every path through the same imported subtree. + loadSource = + source: + let + directory = fetch source; + lockFile = pkgs.runCommand "qx-source-lock.json" { } '' + ${protocol}/bin/quixos-lock-check ${directory}/quixos.lock > "$out" + ''; + lock = builtins.fromJSON (builtins.readFile lockFile); + in + source // { inherit directory lock; }; + sourceEntry = source: { + key = sourceKey source; + value = loadSource source; + }; + sourceGraph = builtins.genericClosure { + startSet = [ (sourceEntry { inherit kind repository commit; }) ]; + operator = + entry: + map ( + resource: sourceEntry (resource.source // { inherit (resource) kind; }) + ) entry.value.lock.resources; + }; + nodeTable = builtins.listToAttrs ( + map (entry: { + name = entry.key; + value = entry.value // { children = map ( - entry: - load (ancestors ++ [ (sourceKey source) ]) (entry.source // { inherit (entry) kind binding; }) - ) lock.resources; - in - source // { inherit directory children lock; }; - root = load [ ] { inherit kind repository commit; }; + resource: + nodeTable.${sourceKey (resource.source // { inherit (resource) kind; })} + // { + inherit (resource) binding; + } + ) entry.value.lock.resources; + }; + }) sourceGraph + ); + root = nodeTable.${sourceKey { inherit kind repository commit; }}; configuration = if builtins.pathExists (root.directory + "/quixos.check.json") then builtins.fromJSON (builtins.readFile (root.directory + "/quixos.check.json")) @@ -50,54 +69,61 @@ let "git+${root.lock.quixos.repository}?rev=${root.lock.quixos.commit}&ref=refs/tags/quixos-reachability/${root.lock.quixos.commit}" ); runtimePackages = runtime.packages.${system}; - flatten = node: [ node ] ++ pkgs.lib.concatMap flatten node.children; - capabilityFlatten = - node: - if node.kind == "build" then - [ ] - else - [ node ] ++ pkgs.lib.concatMap capabilityFlatten node.children; - nodes = builtins.attrValues ( - builtins.listToAttrs ( - map (node: { - name = sourceKey node; - value = node; - }) (flatten root) - ) - ); + resourceClosure = + includeBuild: node: + map (entry: entry.value) ( + builtins.genericClosure { + startSet = pkgs.lib.optional (includeBuild || node.kind != "build") { + key = sourceKey node; + value = node; + }; + operator = + entry: + map (child: { + key = sourceKey child; + value = child; + }) (builtins.filter (child: includeBuild || child.kind != "build") entry.value.children); + } + ); + capabilityFlatten = resourceClosure false; # Frontends are an explicit build choice. The capability assembler itself # neither discovers React contracts nor loads executable package code. - queryFrontends = pkgs.lib.unique ( - pkgs.lib.concatMap ( - node: - let - config = node.directory + "/quixos.check.json"; - in - if builtins.pathExists config then - (builtins.fromJSON (builtins.readFile config)).queryFrontends or [ ] - else - [ ] - ) (capabilityFlatten root) - ); + queryFrontends = + node: + pkgs.lib.unique ( + pkgs.lib.concatMap ( + node: + let + config = node.directory + "/quixos.check.json"; + in + if builtins.pathExists config then + (builtins.fromJSON (builtins.readFile config)).queryFrontends or [ ] + else + [ ] + ) (capabilityFlatten node) + ); queryGeneratorFlags = - if queryFrontends == [ ] then + node: + if queryFrontends node == [ ] then "" - else if queryFrontends == [ "react" ] then + else if queryFrontends node == [ "react" ] then "--query-generator ${protocol}/libexec/quixos-protocol/react-query-generator.mjs" else - throw "Unsupported query frontend selection: ${builtins.toJSON queryFrontends}"; - snapshots = pkgs.writeText "qx-nix-source-graph.json" ( - builtins.toJSON { - resources = map (node: { - inherit (node) - kind - repository - commit - directory - ; - }) (builtins.filter (node: node.kind != "workspace") nodes); - } - ); + throw "Unsupported query frontend selection: ${builtins.toJSON (queryFrontends node)}"; + snapshots = + node: + pkgs.writeText "qx-nix-source-graph.json" ( + builtins.toJSON { + resources = map (node: { + inherit (node) + kind + repository + commit + directory + ; + }) (builtins.filter (entry: entry.kind != "workspace") (resourceClosure true node)); + } + ); compile = node: pkgs.runCommand "qx-${node.kind}-contract" { } '' @@ -106,19 +132,22 @@ let if node.kind == "workspace" then '' ${protocol}/bin/quixos-workspace-compile --root ${node.directory} \ - ${queryGeneratorFlags} \ + ${queryGeneratorFlags node} \ --source-root-commit ${pkgs.lib.escapeShellArg node.commit} \ - --checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} \ + --checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots node} \ --graph-out "$out/graph.json" --schemas-out "$out/package-schemas.json" \ - ${pkgs.lib.optionalString execution ''--execution-plan-out "$out/execution-plan.json"''} > "$out/candidate.json" + ${ + pkgs.lib.optionalString (execution && kind == "workspace") + ''--execution-plan-out "$out/execution-plan.json" --build-contexts-out "$out/build-contexts.json"'' + } > "$out/candidate.json" '' else '' ${protocol}/bin/quixos-resource-compile --root ${node.directory} \ - ${queryGeneratorFlags} \ + ${queryGeneratorFlags node} \ --kind ${node.kind} --repository ${pkgs.lib.escapeShellArg node.repository} \ --commit ${pkgs.lib.escapeShellArg node.commit} \ - --checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} --snapshot-only true \ + --checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots node} --snapshot-only true \ --graph-out "$out/graph.json" --schema-out "$out/bindings.json" > "$out/candidate.json" '' } @@ -137,7 +166,7 @@ let }) (builtins.filter (dependency: dependency.kind == "build") node.children) ); reactContracts = - node: + node: moduleInterfaces: let interfaces = builtins.attrValues ( builtins.listToAttrs ( @@ -159,6 +188,17 @@ let in if build == null then [ ] + else if + !(builtins.any ( + entry: + let + resource = builtins.fromJSON (builtins.readFile "${compile dependency}/candidate.json"); + in + entry.revisionId == resource.revision.revisionId + || (entry.definitionId or null) == resource.revision.revisionId + ) moduleInterfaces) + then + [ ] else [ (build { @@ -174,15 +214,26 @@ let node: let compiled = compile node; - candidate = builtins.fromJSON (builtins.readFile "${compiled}/candidate.json"); - componentEntries = - if execution then - (pkgs.lib.findFirst ( - entry: entry.revision == candidate.revision.revisionId - ) { } (builtins.fromJSON (builtins.readFile "${contract}/execution-plan.json")).definition.packages) - .components or [ ] + candidate = + if execution && kind == "workspace" then + { + revision = + pkgs.lib.findFirst + (pkg: pkg.source.repository == node.repository && pkg.source.commit == node.commit) + (throw "Source package missing from workspace compilation: ${node.repository}") + (builtins.fromJSON (builtins.readFile "${contract}/execution-plan.json")) + .definition.bindingSchema.packages; + } else - [ ]; + builtins.fromJSON (builtins.readFile "${compiled}/candidate.json"); + buildContext = import ./package-context.nix { + inherit pkgs protocol; + contexts = if kind == "workspace" then "${contract}/build-contexts.json" else null; + plan = "${compiled}/bindings.json"; + packageRevisionId = candidate.revision.revisionId; + }; + buildContract = builtins.fromJSON (builtins.readFile buildContext.buildContract); + componentEntries = if execution then buildContract.definition.package.components or [ ] else [ ]; package = builtins.getFlake ( "git+${node.repository}?rev=${node.commit}&ref=refs/tags/quixos-reachability/${node.commit}" ); @@ -195,8 +246,8 @@ let or (throw "Package ${node.repository} lacks checkedServer; use the supported package scaffold."); artifact = checked ( if execution then - { - plan = "${contract}/execution-plan.json"; + buildContext + // { generator = protocol; core = runtimePackages.camino-replica-core; sharedRuntime = runtimePackages.camino-shared-runtime; @@ -208,7 +259,7 @@ let react = { sdk = runtimePackages.camino-react; runtime = runtimePackages.camino-react-runtime; - contracts = reactContracts node; + contracts = reactContracts node buildContract.definition.moduleInterfaces; }; } else @@ -236,16 +287,18 @@ let [ ] else map checkPackage ( - builtins.filter (node: node.kind == "package") ( - builtins.attrValues ( - builtins.listToAttrs ( - map (node: { - name = sourceKey node; - value = node; - }) (capabilityFlatten root) + builtins.filter + (node: node.kind == "package" && (kind == "workspace" || sourceKey node == sourceKey root)) + ( + builtins.attrValues ( + builtins.listToAttrs ( + map (node: { + name = sourceKey node; + value = node; + }) (capabilityFlatten root) + ) ) ) - ) ); manifest = pkgs.writeText "qx-candidate-checks.json" (builtins.toJSON checks); bindingOptions = pkgs.writeText "qx-typescript-options.json" ( @@ -254,8 +307,6 @@ let ) ); in -assert pkgs.lib.assertMsg (!execution || kind == "workspace") - "Execution packages must be checked in their complete workspace; run qx-workspace check from the workspace root."; pkgs.runCommand (if contractOnly then "qx-contract" else "qx-checked-candidate") { } '' mkdir -p "$out" cp ${ @@ -264,7 +315,7 @@ pkgs.runCommand (if contractOnly then "qx-contract" else "qx-checked-candidate") cp ${contract}/candidate.json "$out/candidate.json" cp ${contract}/graph.json "$out/graph.json" cp ${manifest} "$out/checks.json" - ${pkgs.lib.optionalString execution '' + ${pkgs.lib.optionalString (execution && kind == "workspace") '' ${ if builtins.pathExists "${root.directory}/quixos.entry.json" then '' @@ -291,7 +342,12 @@ pkgs.runCommand (if contractOnly then "qx-contract" else "qx-checked-candidate") ${pkgs.lib.optionalString ( kind != "workspace" ) ''cp ${contract}/bindings.json "$out/bindings.json"''} - ${pkgs.lib.optionalString (kind == "package") '' + ${pkgs.lib.optionalString (execution && kind == "package" && !contractOnly) '' + ${pkgs.nodejs_24}/bin/node ${./check-package.mjs} \ + ${protocol}/libexec/quixos-protocol/execution-world.mjs ${contract}/bindings.json \ + "$out/checks.json" "$out" + ''} + ${pkgs.lib.optionalString (kind == "package" && !execution) '' ${protocol}/bin/quixos-codegen-ts ${contract}/bindings.json \ ${pkgs.lib.escapeShellArg (builtins.fromJSON (builtins.readFile "${contract}/candidate.json")).revision.revisionId} \ "$out/bindings.ts" ${bindingOptions} diff --git a/nix/package-context.mjs b/nix/package-context.mjs new file mode 100644 index 0000000..20ba028 --- /dev/null +++ b/nix/package-context.mjs @@ -0,0 +1,15 @@ +import fs from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +const compiler = await import(pathToFileURL(process.argv[2])); +const input = JSON.parse(await fs.readFile(process.argv[3], "utf8")); +const revision = process.argv[4]; +const schema = input.definition?.bindingSchema ?? input; +if (input.definition && compiler.hexDigest(input.definition) !== input.digest) throw Error("Invalid execution plan"); +const pkg = schema.packages.find((entry) => entry.revisionId === revision); +if (!pkg) throw Error("Unknown build package " + revision); +process.stdout.write( + JSON.stringify({ + contract: compiler.packageBuildContract(schema, revision), + sourceDigest: compiler.hexDigest({ revision, source: pkg.source }), + }), +); diff --git a/nix/package-context.nix b/nix/package-context.nix new file mode 100644 index 0000000..7317379 --- /dev/null +++ b/nix/package-context.nix @@ -0,0 +1,30 @@ +# Workspace composition may reevaluate this projection after any graph edit. +# Only the canonical, source-free value becomes a package compilation input. +{ + pkgs, + protocol, + plan ? null, + contexts ? null, + packageRevisionId, +}: +let + projected = pkgs.runCommand "camino-package-context.json" { } '' + ${pkgs.nodejs_24}/bin/node ${./package-context.mjs} \ + ${protocol}/libexec/quixos-protocol/execution-world.mjs ${plan} \ + ${pkgs.lib.escapeShellArg packageRevisionId} > "$out" + ''; + context = + if contexts == null then + builtins.fromJSON (builtins.readFile projected) + else + (builtins.fromJSON (builtins.readFile contexts)).${packageRevisionId}; +in +{ + # This is typed semantic data (nominal IDs, types and generated source), never + # Nix artifact paths. Remove the projection derivation's string context, not + # any code/toolchain dependency. Those are separate explicit helper arguments. + buildContract = builtins.toFile "camino-package-contract.json" ( + builtins.unsafeDiscardStringContext (builtins.toJSON context.contract) + ); + sourceDigest = builtins.unsafeDiscardStringContext context.sourceDigest; +} diff --git a/src/bindings/index.ts b/src/bindings/index.ts index e65b3c0..23b4a6d 100644 --- a/src/bindings/index.ts +++ b/src/bindings/index.ts @@ -9,6 +9,14 @@ import type { CompiledCapabilityResourceRepository } from "../capability-languag import { genericImplementationType } from "./generics.js"; import { capabilityId } from "../capability-model/types.js"; +/** Source-free package compilation inputs. Nominal references do not import + * implementations or concrete workspace conformances. */ +export type BindingContractSchema = { + format: "quixos-bindings"; + version: 1; + interfaces: Omit[]; + packages: Pick[]; +}; /** Portable generator input. New backends consume this instead of the parser or TS runtime. */ export type BindingSchema = { format: "quixos-bindings"; diff --git a/src/bindings/portable.ts b/src/bindings/portable.ts index 7efc839..fbaff4c 100644 --- a/src/bindings/portable.ts +++ b/src/bindings/portable.ts @@ -1,5 +1,5 @@ import { portableReflectionType } from "../execution-world/reflection.js"; -import type { BindingSchema } from "./index.js"; +import type { BindingContractSchema } from "./index.js"; import type { ValueType, ObjectExpectation, @@ -121,7 +121,7 @@ interface Method { explicitInput?: boolean; } function methods( - schema: BindingSchema, + schema: BindingContractSchema, entry: PackageExport, port: DependencyPort, target: string, @@ -247,21 +247,21 @@ export interface PortableBindingOptions { } /** Generate one target's handlers. The same source signatures are used in browser/server builds. */ export function generatePortableTypeScriptBindings( - schema: BindingSchema, + schema: BindingContractSchema, revision: string, options: PortableBindingOptions, ): string { return generateBindings(schema, revision, options, false); } export function generateRemoteTypeScriptBindings( - schema: BindingSchema, + schema: BindingContractSchema, revision: string, options: { runtimeModule?: string } = {}, ): string { return generateBindings(schema, revision, { ...options, target: "server" }, true); } function generateBindings( - schema: BindingSchema, + schema: BindingContractSchema, revision: string, options: PortableBindingOptions, remote: boolean, diff --git a/src/capability-language/execution-scaffold.ts b/src/capability-language/execution-scaffold.ts index 45dd196..e690ac8 100644 --- a/src/capability-language/execution-scaffold.ts +++ b/src/capability-language/execution-scaffold.ts @@ -80,9 +80,8 @@ export async function executionScaffold( devShells.default = (import (helpers + "/quixos-package-helpers.nix")).mkCaminoYarnShell { inherit pkgs; }; quixosPackages.checkedArtifacts = context: let - plan = builtins.fromJSON (builtins.readFile context.plan); - package = pkgs.lib.findFirst (entry: entry.revision == context.packageRevisionId) - (throw "Package missing from checked plan") plan.definition.packages; + contract = builtins.fromJSON (builtins.readFile context.buildContract); + package = contract.definition.package; portable = builtins.filter (entry: entry.execution.kind != "remote") package.exports; remote = builtins.filter (entry: entry.execution.kind == "remote") package.exports; modules = import ./components.nix; @@ -107,7 +106,7 @@ in camino.assembleCaminoPackage { ); create( "README.md", - `# ${spec.name}\n\nThis execution package exports checked browser/server modules and a process service.\nUse scaffold function with an explicit execution contract to add a typed handler.\nSnapshot/transaction targets choose browser.ts and/or server.ts; remote handlers\nuse remote.ts. Shared helpers are ordinary imports. Generated bindings are build\noutputs, not authored source. The coordinator supplies the checked workspace plan\nand runtime dependencies to checkedArtifacts.\n\nRun qx-workspace check from the workspace root to compile exact source and build\nchecked artifacts. Activate the resulting report separately through the workspace\nhost. A source-only structural check is not runtime qualification.\n`, + `# ${spec.name}\n\nThis execution package exports checked browser/server modules and a process service.\nUse scaffold function with an explicit execution contract to add a typed handler.\nSnapshot/transaction targets choose browser.ts and/or server.ts; remote handlers\nuse remote.ts. Shared helpers are ordinary imports. Generated bindings are build\noutputs, not authored source. The Nix composition supplies the canonical package contract\nand explicit runtime dependencies to checkedArtifacts.\n\nRun qx-workspace check from this package to build it independently, or from a\nworkspace to also check installed bindings. Activate the resulting report separately through the workspace\nhost. A source-only structural check is not runtime qualification.\n`, ); } else if (command === "function") { if (!spec.declaration) diff --git a/src/capability-language/structural-plan.ts b/src/capability-language/structural-plan.ts index fe5cfae..6b03c5f 100644 --- a/src/capability-language/structural-plan.ts +++ b/src/capability-language/structural-plan.ts @@ -168,8 +168,8 @@ export const planStructure = async (rootPath: string, request: StructuralRequest }); if (compiled.resource.kind === "package") { const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8")); - // Execution bindings require the complete workspace plan and are owned - // by checkedArtifacts, not a package-only structural preview. + // Execution bindings are immutable build outputs owned by checkedArtifacts, + // not source edits produced by a structural preview. const artifacts = configuration.backend === "execution" ? [] diff --git a/src/capability-language/workspace-cli.ts b/src/capability-language/workspace-cli.ts index ffe57e4..18b2ec5 100644 --- a/src/capability-language/workspace-cli.ts +++ b/src/capability-language/workspace-cli.ts @@ -5,7 +5,7 @@ import process from "node:process"; import { compileWorkspaceRepository } from "./assembly.js"; import { createGitCapabilityResolver } from "./git-resolver.js"; import { loadQueryGenerator } from "./query-generator.js"; -import { compileExecutionPlan } from "../execution-world/index.js"; +import { compileExecutionPlan, packageBuildContexts } from "../execution-world/index.js"; import { bindingSchema, specializeBindingSchema } from "../bindings/index.js"; import { planEvolution, @@ -17,7 +17,7 @@ import { const usage = `usage: quixos-workspace-compile --root DIRECTORY --checkout-root DIRECTORY [--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID] [--source-root-commit GIT_REV] [--baseline PLAN_JSON] [--evolution-out PATH] - [--reviews REVIEW_JSON] [--schemas-out PATH] [--execution-plan-out PATH] + [--reviews REVIEW_JSON] [--schemas-out PATH] [--execution-plan-out PATH] [--build-contexts-out PATH] [--query-generator BUILD_MODULE_PATH] Resolves a workspace's recursive resource-lock graph, clones every exact @@ -41,6 +41,7 @@ const parseArgs = (args: string[]) => { graphOut: values.get("--graph-out"), schemasOut: values.get("--schemas-out"), executionPlanOut: values.get("--execution-plan-out"), + buildContextsOut: values.get("--build-contexts-out"), snapshotMap: values.get("--snapshot-map"), workspaceId: values.get("--workspace-id"), workspaceRevisionId: values.get("--workspace-revision-id"), @@ -108,11 +109,11 @@ const main = async () => { ); } const candidate = { ...assembled.workspace, executionContracts: runtimeContracts(assembled.workspace) }; - if (options.executionPlanOut) { - await writeFile( - options.executionPlanOut, - `${JSON.stringify(compileExecutionPlan(assembled.workspace), null, 2)}\n`, - ); + if (options.executionPlanOut || options.buildContextsOut) { + const plan = compileExecutionPlan(assembled.workspace); + if (options.executionPlanOut) await writeFile(options.executionPlanOut, `${JSON.stringify(plan, null, 2)}\n`); + if (options.buildContextsOut) + await writeFile(options.buildContextsOut, JSON.stringify(packageBuildContexts(plan.definition.bindingSchema))); } if (options.schemasOut) { const schemas: Record = {}; diff --git a/src/execution-world/artifacts.ts b/src/execution-world/artifacts.ts index 9a99f21..e5fbcea 100644 --- a/src/execution-world/artifacts.ts +++ b/src/execution-world/artifacts.ts @@ -20,15 +20,19 @@ export interface ArtifactImplementation { files: ArtifactFile[]; contentDigest: string; } -export interface PackageArtifacts { +export interface ArtifactGroup { schemaVersion: 1; packageRevisionId: string; bindingDigest: string; - sourceDigest: string; versions: ExecutionPlan["definition"]["versions"]; implementations: ArtifactImplementation[]; components?: ComponentArtifact[]; } +/** Installation evidence binds a reusable group to the selected exact source. + * Compilation receipts never claim that a cache hit was built at a new pin. */ +export interface PackageArtifacts extends ArtifactGroup { + sourceDigest: string; +} export const componentSharedModules = [ "react", "react-dom", @@ -199,11 +203,11 @@ function parseComponent(value: unknown, index: number): ComponentArtifact { if (componentArtifactDigest(body) !== contentDigest) fail(path, "Component metadata digest mismatch"); return component; } -export function parsePackageArtifacts(value: unknown): PackageArtifacts { +export function parseArtifactGroup(value: unknown): ArtifactGroup { const record = object(value, "manifest"); keys( record, - ["schemaVersion", "packageRevisionId", "bindingDigest", "sourceDigest", "versions", "implementations"], + ["schemaVersion", "packageRevisionId", "bindingDigest", "versions", "implementations"], ["components"], "manifest", ); @@ -266,13 +270,19 @@ export function parsePackageArtifacts(value: unknown): PackageArtifacts { schemaVersion: 1, packageRevisionId: text(record.packageRevisionId, "packageRevisionId"), bindingDigest: digest(record.bindingDigest, "bindingDigest"), - sourceDigest: digest(record.sourceDigest, "sourceDigest"), versions: versions as unknown as PackageArtifacts["versions"], implementations, ...(components ? { components } : {}), }; } -function checkCoverage(expected: PlanPackage, manifest: PackageArtifacts): void { +export function parsePackageArtifacts(value: unknown): PackageArtifacts { + const { sourceDigest, ...group } = object(value, "manifest"); + return { ...parseArtifactGroup(group), sourceDigest: digest(sourceDigest, "sourceDigest") }; +} +export function checkPackageCoverage( + expected: Pick, + manifest: PackageArtifacts, +): void { for (const module of expected.modules ?? []) if (!expected.components?.some((component) => component.id === module.id)) fail(module.id, `No artifact consumer for module contract ${module.contract} and its subject`); @@ -315,6 +325,21 @@ function checkCoverage(expected: PlanPackage, manifest: PackageArtifacts): void } } export type ArtifactReader = (packageRevisionId: string, path: string, maxBytes: number) => Promise; +/** Shared by standalone package checks and complete world installation. */ +export async function checkArtifactFiles(manifest: ArtifactGroup, read: ArtifactReader): Promise { + const files = new Map(); + for (const artifact of [...manifest.implementations, ...(manifest.components ?? [])]) + for (const file of artifact.files) { + const existing = files.get(file.path); + if (existing && canonicalJson(existing) !== canonicalJson(file)) fail(file.path, "Conflicting file descriptors"); + files.set(file.path, file); + } + for (const file of files.values()) { + const bytes = await read(manifest.packageRevisionId, file.path, file.bytes); + if (bytes.byteLength !== file.bytes || createHash("sha256").update(bytes).digest("hex") !== file.digest) + fail(file.path, "Artifact bytes do not match the checked content digest"); + } +} /** Caller supplies a reader confined to the resolved immutable package output, including symlink checks. */ export async function checkExecutionWorld( plan: ExecutionPlan, @@ -335,17 +360,11 @@ export async function checkExecutionWorld( const manifest = manifests.find((m) => m.packageRevisionId === expected.revision) ?? fail(expected.revision, "Missing package artifact"); - checkCoverage(expected, manifest); + checkPackageCoverage(expected, manifest); if (canonicalJson(manifest.versions) !== canonicalJson(plan.definition.versions)) fail(expected.revision, "Incompatible runtime codecs"); - const files = new Map(); + await checkArtifactFiles(manifest, read); for (const implementation of manifest.implementations) { - for (const file of implementation.files) { - const existing = files.get(file.path); - if (existing && canonicalJson(existing) !== canonicalJson(file)) - fail(file.path, "Conflicting file descriptors"); - files.set(file.path, file); - } artifacts.push({ binding: JSON.stringify([expected.revision, implementation.id]), target: implementation.target, @@ -353,23 +372,12 @@ export async function checkExecutionWorld( }); } for (const component of manifest.components ?? []) { - for (const file of component.files) { - const existing = files.get(file.path); - if (existing && canonicalJson(existing) !== canonicalJson(file)) - fail(file.path, "Conflicting file descriptors"); - files.set(file.path, file); - } artifacts.push({ binding: JSON.stringify([expected.revision, "component", component.id]), target: "browser-renderer", digest: component.contentDigest, }); } - for (const file of files.values()) { - const bytes = await read(expected.revision, file.path, file.bytes); - if (bytes.byteLength !== file.bytes || createHash("sha256").update(bytes).digest("hex") !== file.digest) - fail(file.path, "Artifact bytes do not match the checked content digest"); - } } return { codec: "camino-world-v1", diff --git a/src/execution-world/build-contract.ts b/src/execution-world/build-contract.ts new file mode 100644 index 0000000..a656e7f --- /dev/null +++ b/src/execution-world/build-contract.ts @@ -0,0 +1,224 @@ +import type { BindingSchema, BindingContractSchema } from "../bindings/index.js"; +import type { InterfaceRevision, PackageRevision } from "../capability-model/types.js"; +import { canonicalJson } from "../capability-model/evolution.js"; +import { moduleContract } from "../capability-model/modules.js"; +import { generatePackageComponentBindings, publicQueryContract, type PublicQueryContract } from "./client-contracts.js"; +import { moduleTypeContext } from "./module-contracts.js"; +import { executionVersions, hexDigest, ExecutionPlanError, type PlanPackage } from "./plan.js"; +import { preparationArtifact, type ReactPreparationArtifact } from "../react-build/declarations.js"; + +/** Inputs to compilation, independent of source pins and installed providers. + * Declared ports select interface contracts; the React consumer projects its + * imported interfaces, public queries and module inputs. Ordinary code + * and richer language type companions remain separate, explicit Nix inputs. */ +export interface PackageBuildContract { + definition: { + codec: "camino-package-build-v1"; + package: Omit; + versions: typeof executionVersions; + bindings: BindingContractSchema; + queries: PublicQueryContract[]; + component?: string; + moduleInterfaces: { revisionId: string; definitionId?: string; members: string[] }[]; + diagnostics: string[]; + }; + digest: string; +} + +const normalize = (value: T): T => JSON.parse(canonicalJson(value)) as T; +const indexSchema = (schema: BindingSchema) => ({ + interfaces: new Map( + [...schema.interfaces, ...(schema.interfaceTemplates ?? [])].map((entry) => [entry.revisionId, entry]), + ), + packages: new Map(schema.packages.map((entry) => [entry.revisionId, entry])), +}); +export function packageBuildContexts( + schema: BindingSchema, +): Record { + const index = indexSchema(schema); + return Object.fromEntries( + schema.packages.map((pkg) => [ + pkg.revisionId, + { + contract: projectPackageContract(schema, pkg.revisionId, index), + sourceDigest: hexDigest({ revision: pkg.revisionId, source: pkg.source }), + }, + ]), + ); +} +export function packageBuildContract(schema: BindingSchema, revision: string): PackageBuildContract { + return projectPackageContract(schema, revision, indexSchema(schema)); +} +function projectPackageContract( + schema: BindingSchema, + revision: string, + index: ReturnType, +): PackageBuildContract { + const declared = index.packages.get(revision); + if (!declared) throw new ExecutionPlanError("UNKNOWN_BUILD_PACKAGE", revision, "Missing package declaration"); + const pkg = normalize(declared); + const interfaces = (ids: Iterable) => + [...ids].map((id) => { + const iface = index.interfaces.get(id); + if (!iface) throw new ExecutionPlanError("UNKNOWN_BUILD_INTERFACE", id, "Missing declared interface"); + return normalize(iface); + }); + const modules = [...(pkg.modules ?? [])] + .sort((a, b) => a.id.localeCompare(b.id)) + .map((module) => { + const contract = moduleContract(interfaces([module.interfaceRevisionId]), module); + if (!contract) throw new ExecutionPlanError("UNKNOWN_MODULE_CONTRACT", module.id, "Missing module contract"); + return { + ...module, + contract: contract.contract, + ...(contract.subject ? { subject: contract.subject } : {}), + ...moduleTypeContext(index.interfaces.get(module.interfaceRevisionId)!), + }; + }); + const components = modules.flatMap((module) => + ["org.quixos.react.component/1", "org.quixos.react.component/2", "org.quixos.react.subject-only/1"].includes( + module.contract, + ) && module.subject?.kind === "interface" + ? [ + { + id: module.id, + displayName: module.displayName, + subject: module.subject.interfaceRevisionId, + runtime: "react" as const, + }, + ] + : [], + ); + const shape: PackageBuildContract["definition"]["package"] = { + revision, + exports: [...pkg.exports] + .sort((a, b) => a.id.localeCompare(b.id)) + .map((entry) => ({ id: entry.id, execution: entry.execution ?? { kind: "remote" } })), + ...(modules.length ? { modules } : {}), + ...(components.length ? { components } : {}), + ...(pkg.controllers?.length ? { controllers: [...pkg.controllers].sort((a, b) => a.id.localeCompare(b.id)) } : {}), + }; + const portInterfaces = new Set( + pkg.exports.flatMap((entry) => + entry.dependencyPorts.flatMap((port) => + port.requirement.kind === "interface" ? [port.requirement.interfaceRevisionId] : [], + ), + ), + ); + const bindings: BindingContractSchema = { + format: "quixos-bindings", + version: 1, + packages: [ + { + revisionId: pkg.revisionId, + exports: [...pkg.exports].sort((a, b) => a.id.localeCompare(b.id)), + ...(pkg.genericExports ? { genericExports: pkg.genericExports } : {}), + }, + ], + interfaces: interfaces(portInterfaces) + .map(({ source, ...contract }) => contract) + .sort((a, b) => a.revisionId.localeCompare(b.revisionId)), + }; + const preparation = pkg.buildArtifacts?.[preparationArtifact] as ReactPreparationArtifact | undefined; + const diagnostics = components.flatMap((component) => { + const input = preparation?.modules.find((entry) => entry.module === component.id); + return input && pkg.queryCompositions?.some((entry) => entry.id === input.composition) + ? [] + : [`${component.id}: declare checked inputs in quixos.react.json`]; + }); + // Only interfaces whose modules are exposed by these bindings need language + // type companions. Reference types themselves do not recursively import the + // referenced object's implementation or its conformance inventory. + const used = new Set(Object.values(pkg.clientInterfaces ?? {})); + for (const module of modules) used.add(module.interfaceRevisionId); + for (const component of components) used.add(component.subject); + for (const entry of preparation?.modules ?? []) + for (const child of entry.children) if (child.role) used.add(child.role.interface); + const moduleInterfaces = interfaces(used) + .flatMap((iface: InterfaceRevision) => { + const members = iface.members + .filter((member) => member.kind === "module") + .map((member) => member.id) + .sort(); + return members.length + ? [ + { + revisionId: iface.revisionId, + ...(iface.application ? { definitionId: iface.application.definitionId } : {}), + members, + }, + ] + : []; + }) + .sort((a, b) => a.revisionId.localeCompare(b.revisionId)); + for (const module of modules) + for (const argument of index.interfaces.get(module.interfaceRevisionId)?.application?.arguments ?? []) + if (argument.kind === "object" && argument.target.kind === "interface") + used.add(argument.target.interfaceRevisionId); + const providers = new Set(Object.values(pkg.clientPackages ?? {})); + for (const query of pkg.queries ?? []) + for (const imported of query.importedFragments ?? []) providers.add(imported.packageRevisionId); + providers.delete(pkg.revisionId); + const componentSchema: BindingSchema = { + format: "quixos-bindings", + version: 1, + interfaces: interfaces(used), + packages: [ + pkg, + ...[...providers].map((id) => { + const provider = index.packages.get(id); + if (!provider) throw new ExecutionPlanError("UNKNOWN_BUILD_PROVIDER", id, "Missing declared package provider"); + return normalize(provider); + }), + ], + }; + const definition: PackageBuildContract["definition"] = { + codec: "camino-package-build-v1", + package: shape, + versions: { ...executionVersions }, + bindings, + queries: [ + ...new Map( + pkg.exports.flatMap((entry) => + entry.dependencyPorts.flatMap((port) => { + if (port.requirement.kind !== "query") return []; + const requirement = port.requirement; + const provider = index.packages.get(requirement.packageRevisionId); + if (!provider) + throw new ExecutionPlanError( + "UNKNOWN_QUERY_PROVIDER", + requirement.packageRevisionId, + "Missing declared query provider", + ); + return [ + [ + JSON.stringify([requirement.packageRevisionId, requirement.queryId]), + publicQueryContract(provider, requirement.queryId), + ] as const, + ]; + }), + ), + ).entries(), + ] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, query]) => query), + ...(components.length && !diagnostics.length + ? { component: generatePackageComponentBindings(componentSchema, shape) } + : {}), + moduleInterfaces, + diagnostics, + }; + const normalized = JSON.parse(canonicalJson(definition)) as typeof definition; + return { definition: normalized, digest: hexDigest(normalized) }; +} + +export function checkPackageBuildContract(contract: PackageBuildContract): void { + if (contract.definition.codec !== "camino-package-build-v1" || hexDigest(contract.definition) !== contract.digest) + throw new ExecutionPlanError("INVALID_BUILD_CONTRACT", contract.digest, "Package contract digest mismatch"); + if (contract.definition.diagnostics.length) + throw new ExecutionPlanError( + "INCOMPLETE_BUILD_CONTRACT", + contract.definition.package.revision, + contract.definition.diagnostics.join("; "), + ); +} diff --git a/src/execution-world/client-contracts.ts b/src/execution-world/client-contracts.ts index b9740c8..a258e4e 100644 --- a/src/execution-world/client-contracts.ts +++ b/src/execution-world/client-contracts.ts @@ -7,6 +7,9 @@ import type { import { canonicalJson } from "../capability-model/evolution.js"; import { portableValueSchema, tsType } from "../bindings/portable.js"; import { type ExecutionPlan, resolvePlannedOperation, hexDigest, ExecutionPlanError } from "./plan.js"; +import type { BindingSchema } from "../bindings/index.js"; +import type { PackageBuildContract } from "./build-contract.js"; +import { preparationArtifact, type ReactPreparationArtifact } from "../react-build/declarations.js"; import { moduleTypeContext } from "./module-contracts.js"; /** Public shape only: no slot inventory, implementation binding, or workspace identity. */ @@ -145,6 +148,13 @@ export function generatePackageQueryClientBindings( runtimeModule = "@quixos/camino-replica-core", packages: readonly PackageRevision[] = [pkg], ): string { + const internal = + (pkg.buildArtifacts?.[preparationArtifact] as ReactPreparationArtifact | undefined)?.internalQueries ?? []; + pkg = { + ...pkg, + queries: pkg.queries?.filter((query) => !internal.includes(query.id)), + checkedQueries: pkg.checkedQueries?.filter((query) => !internal.includes(query.declaration.id)), + }; const queries = (pkg.checkedQueries ?? []) .filter((query) => !query.declaration.fragment) .map( @@ -206,12 +216,21 @@ export function generatePackageQueryClientBindings( /** Receipt-covered bindings expose the package's imports, component subjects and * declared queries, never the workspace's complete atom/interface inventory. */ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevision: string): string { - const pkg = plan.definition.workspace.packageImports.find((pkg) => pkg.revisionId === packageRevision); - const components = plan.definition.packages.find((pkg) => pkg.revision === packageRevision)?.components; - const modules = plan.definition.packages.find((pkg) => pkg.revision === packageRevision)?.modules ?? []; + const shape = plan.definition.packages.find((pkg) => pkg.revision === packageRevision); + if (!shape) throw new ExecutionPlanError("NO_COMPONENT_CONTRACT", packageRevision, "Unknown package"); + return generatePackageComponentBindings(plan.definition.bindingSchema, shape); +} +export function generatePackageComponentBindings( + schema: BindingSchema, + shape: PackageBuildContract["definition"]["package"], +): string { + const packageRevision = shape.revision; + const pkg = schema.packages.find((pkg) => pkg.revisionId === packageRevision); + const components = shape.components; + const modules = shape.modules ?? []; if (!pkg || !components?.length) throw new ExecutionPlanError("NO_COMPONENT_CONTRACT", packageRevision, "Package declares no component entries"); - const byId = new Map(plan.definition.workspace.interfaceImports.map((iface) => [iface.revisionId as string, iface])); + const byId = new Map(schema.interfaces.map((iface) => [iface.revisionId as string, iface])); const find = (id: string) => { const contract = byId.get(id); if (!contract || contract.template) @@ -254,11 +273,7 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi .filter((line) => !line.startsWith("import ")) .join("\n") .replace("export const interfaces =", "export const subjects ="); - const queries = generatePackageQueryClientBindings( - pkg, - "@quixos/camino-react", - plan.definition.workspace.packageImports, - ) + const queries = generatePackageQueryClientBindings(pkg, "@quixos/camino-react", schema.packages) .split("\n") .filter((line) => !line.startsWith("import ")) .join("\n"); @@ -270,7 +285,7 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi queries + `\nexport const providers = {${Object.entries(pkg.clientPackages ?? {}) .map(([alias, id]) => { - const imported = plan.definition.workspace.packageImports.find((entry) => entry.revisionId === id); + const imported = schema.packages.find((entry) => entry.revisionId === id); if (!imported) throw new ExecutionPlanError("UNINSTALLED_PACKAGE", id, "Missing imported module provider"); return `${JSON.stringify(alias)}:{${(imported.modules ?? []).map((module) => `${JSON.stringify(module.displayName)}:${JSON.stringify({ packageRevision: id, id: module.id })}`).join(",")}}`; }) @@ -304,7 +319,7 @@ export function generateComponentClientBindings(plan: ExecutionPlan, packageRevi const handle = `PresentationHandle<${JSON.stringify(child.subject)}, ${extra}>`; return `readonly ${JSON.stringify(child.name)}: ${child.each ? `readonly ${handle}[]` : child.with || child.when ? `${handle} | null` : handle}`; }); - const input = `${tsType(composition.output)} & {readonly children: {${children.join(";")}}}`; + const input = `${tsType(preparation.input ?? composition.output)} & {readonly children: {${children.join(";")}}}`; return `${JSON.stringify(component.displayName)}: ComponentType & ExtraModuleProps<${JSON.stringify(key)}>>;`; }) .join("\n") + diff --git a/src/execution-world/index.ts b/src/execution-world/index.ts index b3aa839..e393d65 100644 --- a/src/execution-world/index.ts +++ b/src/execution-world/index.ts @@ -22,3 +22,5 @@ export { reviewSemanticMajors } from "../capability-model/evolution.js"; export * from "./public-reflection.js"; export * from "./graph-execution.js"; + +export * from "./build-contract.js"; diff --git a/src/execution-world/plan.ts b/src/execution-world/plan.ts index 3a47547..da65c4c 100644 --- a/src/execution-world/plan.ts +++ b/src/execution-world/plan.ts @@ -11,13 +11,12 @@ import type { OperationId, } from "../capability-model/types.js"; import { validateWorkspaceRevision } from "../capability-model/validation.js"; -import { moduleContract } from "../capability-model/modules.js"; import { canonicalJson, contentDigest } from "../capability-model/evolution.js"; import { resolveForwardedBinding } from "../capability-model/forwarding.js"; import { linkQueries, type LinkedQuery } from "../query/link.js"; import { type BindingSchema } from "../bindings/index.js"; import { unsupportedQueryOperator } from "./query-operators.js"; -import { moduleTypeContext } from "./module-contracts.js"; +import { packageBuildContexts } from "./build-contract.js"; export class ExecutionPlanError extends Error { constructor( @@ -78,30 +77,6 @@ export interface PlanPackage { exports: { id: string; execution: ExecutionContract }[]; } -/** React consumes these module contracts; other contracts remain metadata until - * an artifact consumer exists. This does not make modules callable operations. */ -function componentDeclarations(workspace: WorkspaceRevision, pkg: WorkspaceRevision["packageImports"][number]) { - return [ - ...(pkg.modules ?? []).flatMap((module) => { - const contract = moduleContract(workspace.interfaceImports, module)!; - if ( - !["org.quixos.react.component/1", "org.quixos.react.component/2", "org.quixos.react.subject-only/1"].includes( - contract.contract, - ) || - contract.subject?.kind !== "interface" - ) - return []; - return [ - { - id: module.id, - displayName: module.displayName, - subject: contract.subject.interfaceRevisionId, - runtime: "react" as const, - }, - ]; - }), - ]; -} export interface ExecutionPlanDefinition { presentations?: NonNullable; codec: "camino-execution-plan-v1"; @@ -543,37 +518,21 @@ export function compileExecutionPlan( packages: sorted(workspace.packageImports, (p) => p.revisionId), }; const normalizedSchema = JSON.parse(canonicalJson(schema)) as BindingSchema; + const buildContexts = packageBuildContexts(normalizedSchema); const definition: ExecutionPlanDefinition = { codec: "camino-execution-plan-v1", workspace, bindingSchema: normalizedSchema, ...(workspace.presentations?.length ? { presentations: sorted(workspace.presentations, (p) => p.id) } : {}), - packages: sorted(workspace.packageImports, (p) => p.revisionId).map((p) => ({ - revision: p.revisionId, - source: p.source, - sourceDigest: hexDigest({ revision: p.revisionId, source: p.source }), - bindingDigest: hexDigest(normalizedSchema), - ...(p.modules?.length - ? { - modules: sorted(p.modules, (module) => module.id).map((module) => { - const contract = moduleContract(workspace.interfaceImports, module)!; - return { - ...module, - contract: contract.contract, - ...(contract.subject ? { subject: contract.subject } : {}), - ...moduleTypeContext( - workspace.interfaceImports.find((iface) => iface.revisionId === module.interfaceRevisionId)!, - ), - }; - }), - } - : {}), - ...(p.controllers?.length ? { controllers: sorted(p.controllers, (controller) => controller.id) } : {}), - ...(componentDeclarations(workspace, p).length - ? { components: sorted(componentDeclarations(workspace, p), (component) => component.id) } - : {}), - exports: sorted(p.exports, (e) => e.id).map((e) => ({ id: e.id, execution: e.execution ?? { kind: "remote" } })), - })), + packages: sorted(workspace.packageImports, (p) => p.revisionId).map((p) => { + const contract = buildContexts[p.revisionId]!.contract; + return { + ...contract.definition.package, + source: p.source, + sourceDigest: hexDigest({ revision: p.revisionId, source: p.source }), + bindingDigest: contract.digest, + }; + }), owners: sorted(workspace.conformances, (c) => c.id ?? "").map((c) => { if (!c.id) throw new ExecutionPlanError( diff --git a/src/execution-world/receipts.ts b/src/execution-world/receipts.ts index a69def6..27f76d5 100644 --- a/src/execution-world/receipts.ts +++ b/src/execution-world/receipts.ts @@ -1,19 +1,23 @@ import { createHash } from "node:crypto"; import { generatePortableTypeScriptBindings, generateRemoteTypeScriptBindings } from "../bindings/portable.js"; import { type ExecutionPlan, ExecutionPlanError, hexDigest } from "./plan.js"; -import { type ArtifactReader, type PackageArtifacts } from "./artifacts.js"; +import { type ArtifactReader, type PackageArtifacts, type ArtifactGroup } from "./artifacts.js"; import { loadArtifactWorld, type LoadedArtifactWorld } from "./files.js"; -import { generateComponentClientBindings, componentAssetDeclarations } from "./client-contracts.js"; +import { componentAssetDeclarations } from "./client-contracts.js"; -export interface ArtifactReceipt { +import { checkPackageBuildContract, packageBuildContract, type PackageBuildContract } from "./build-contract.js"; + +export interface GroupReceipt { schemaVersion: 1; packageRevisionId: string; bindingDigest: string; - sourceDigest: string; manifestDigest: string; generatorDigest: string; generated: { target: string; path: string; digest: string; bytes: number }[]; } +export interface ArtifactReceipt extends GroupReceipt { + sourceDigest: string; +} const hashBytes = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); /** Nominal identities never become filesystem paths. */ export const generatedBindingPath = (target: string) => `share/quixos/generated/target-${hexDigest(target)}.ts`; @@ -24,20 +28,42 @@ export function expectedArtifactReceipt( manifest: PackageArtifacts, generatorDigest: string, ): { receipt: ArtifactReceipt; sources: Map } { - if (!/^[0-9a-f]{64}$/.test(generatorDigest)) throw new Error("Invalid trusted generator digest"); const pkg = plan.definition.packages.find((p) => p.revision === manifest.packageRevisionId); - const schemaPackage = plan.definition.bindingSchema.packages.find((p) => p.revisionId === manifest.packageRevisionId); + if (!pkg || manifest.sourceDigest !== pkg.sourceDigest) + throw new ExecutionPlanError("INVALID_RECEIPT", manifest.packageRevisionId, "Receipt package/source mismatch"); + return expectedPackageReceipt( + packageBuildContract(plan.definition.bindingSchema, pkg.revision), + manifest, + pkg.sourceDigest, + generatorDigest, + ); +} +export function expectedPackageReceipt( + contract: PackageBuildContract, + manifest: PackageArtifacts, + sourceDigest: string, + generatorDigest: string, +): { receipt: ArtifactReceipt; sources: Map } { + if (manifest.sourceDigest !== sourceDigest) + throw new ExecutionPlanError("INVALID_RECEIPT", manifest.packageRevisionId, "Receipt source mismatch"); + const result = expectedGroupReceipt(contract, manifest, generatorDigest); + return { ...result, receipt: { ...result.receipt, sourceDigest } }; +} +export function expectedGroupReceipt( + contract: PackageBuildContract, + manifest: ArtifactGroup, + generatorDigest: string, +): { receipt: GroupReceipt; sources: Map } { + checkPackageBuildContract(contract); + if (!/^[0-9a-f]{64}$/.test(generatorDigest)) throw new Error("Invalid trusted generator digest"); + const pkg = contract.definition.package; + const schemaPackage = contract.definition.bindings.packages.find((p) => p.revisionId === pkg.revision)!; if ( - !pkg || - !schemaPackage || - manifest.bindingDigest !== pkg.bindingDigest || - manifest.sourceDigest !== pkg.sourceDigest + manifest.packageRevisionId !== pkg.revision || + manifest.bindingDigest !== contract.digest || + hexDigest(manifest.versions) !== hexDigest(contract.definition.versions) ) - throw new ExecutionPlanError( - "INVALID_RECEIPT", - manifest.packageRevisionId, - "Receipt package/schema/source mismatch", - ); + throw new ExecutionPlanError("INVALID_RECEIPT", manifest.packageRevisionId, "Receipt package/contract mismatch"); // A process service may implement transactions in any language. Only the // checked JS module path promises generated TypeScript adapter evidence. const available = new Set( @@ -60,14 +86,14 @@ export function expectedArtifactReceipt( const generated = targets.map((target) => { const path = generatedBindingPath(target); const bytes = new TextEncoder().encode( - generatePortableTypeScriptBindings(plan.definition.bindingSchema, pkg.revision, { target }), + generatePortableTypeScriptBindings(contract.definition.bindings, pkg.revision, { target }), ); sources.set(path, bytes); return { target, path, digest: hashBytes(bytes), bytes: bytes.length }; }); if (manifest.components?.length) { for (const [path, source] of [ - ["share/quixos/generated/client.ts", generateComponentClientBindings(plan, pkg.revision)], + ["share/quixos/generated/client.ts", contract.definition.component!], ["share/quixos/generated/component-assets.d.ts", componentAssetDeclarations()], ] as const) { const bytes = new TextEncoder().encode(source); @@ -88,7 +114,7 @@ export function expectedArtifactReceipt( ) { const path = "share/quixos/generated/remote.ts"; const bytes = new TextEncoder().encode( - generateRemoteTypeScriptBindings(plan.definition.bindingSchema, pkg.revision), + generateRemoteTypeScriptBindings(contract.definition.bindings, pkg.revision), ); sources.set(path, bytes); generated.push({ target: "server", path, digest: hashBytes(bytes), bytes: bytes.length }); @@ -97,8 +123,7 @@ export function expectedArtifactReceipt( receipt: { schemaVersion: 1, packageRevisionId: pkg.revision, - bindingDigest: pkg.bindingDigest, - sourceDigest: pkg.sourceDigest, + bindingDigest: contract.digest, manifestDigest: hexDigest(manifest), generatorDigest, generated, diff --git a/src/react-build/declarations.ts b/src/react-build/declarations.ts index 2a7c042..429d5f4 100644 --- a/src/react-build/declarations.ts +++ b/src/react-build/declarations.ts @@ -17,9 +17,13 @@ export interface ReactChildDeclaration { export interface ReactModulePreparation { module: string; composition: string; + /** Public input type before workspace query lowering adds private projections. */ + input?: ValueType; children: ReactChildDeclaration[]; } export interface ReactPreparationArtifact { + /** Frontend queries belong to installed preparation, not the authored client API. */ + internalQueries?: string[]; modules: ReactModulePreparation[]; } diff --git a/src/react-build/expansion.ts b/src/react-build/expansion.ts index 177d8ba..4776f4c 100644 --- a/src/react-build/expansion.ts +++ b/src/react-build/expansion.ts @@ -61,6 +61,8 @@ export async function expandPresentationQueries(workspace: WorkspaceRevision): P allowances.push(allowance); } declaration = { ...declaration, views: [...views.values()], allowances }; + const artifact = pkg.buildArtifacts?.[preparationArtifact] as ReactPreparationArtifact | undefined; + if (artifact) artifact.internalQueries = [...new Set([...(artifact.internalQueries ?? []), declaration.id])].sort(); const checked = await compileQuery(declaration, workspace.interfaceImports, async () => document, resolver); pkg.queries = [...(pkg.queries ?? []).filter((entry) => entry.id !== declaration.id), declaration]; pkg.checkedQueries = [ diff --git a/src/react-build/package-queries.ts b/src/react-build/package-queries.ts index 3442230..b9dd3f6 100644 --- a/src/react-build/package-queries.ts +++ b/src/react-build/package-queries.ts @@ -20,6 +20,10 @@ export const generatePackageQueries: PackageQueryGenerator = async (input) => { const declarations = reactModules.length ? presentationDeclarations(await input.readSource("quixos.react.json"), pkg, input.interfaces) : undefined; + if (declarations) + declarations.artifact.internalQueries = (pkg.queries ?? []) + .filter((query) => !before.has(query.id)) + .map((query) => query.id); return { queries: (pkg.queries ?? []).filter((query) => !before.has(query.id)), documents, diff --git a/src/react-build/workspace.ts b/src/react-build/workspace.ts index f0baba0..a413d57 100644 --- a/src/react-build/workspace.ts +++ b/src/react-build/workspace.ts @@ -24,6 +24,7 @@ export async function completeWorkspace(workspace: WorkspaceRevision): Promise entry.id === parent.preparation.composition); if (!composition || composition.output.kind !== "record") throw Error(`Missing checked inputs for ${parent.module.displayName}`); + parent.preparation.input = structuredClone(composition.output); const outputs = new Map(Object.entries(composition.output.fields)); const dependencies: string[] = []; const children = parent.preparation.children.map((child) => { diff --git a/test/build-contract.test.ts b/test/build-contract.test.ts new file mode 100644 index 0000000..4dd9b22 --- /dev/null +++ b/test/build-contract.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { compileCapabilityResourceSource } from "../src/capability-language/index.js"; +import { packageBuildContract } from "../src/execution-world/build-contract.js"; +import { generatePortableTypeScriptBindings } from "../src/bindings/portable.js"; +import type { BindingSchema } from "../src/bindings/index.js"; +import type { InterfaceRevision } from "../src/capability-model/types.js"; + +function fixture() { + const source = { repository: "https://example.test/source", commit: "1".repeat(40) }; + const interfaces = new Map(); + for (const name of ["Value", "Unrelated"]) { + const result = compileCapabilityResourceSource( + `interface ${name} id "${name}" revision "${name}@1" { + value text id "text" : string { get id "text:get" execution snapshot; } + }`, + { source }, + ); + assert.ok(result.ok && result.resource.kind === "interface", JSON.stringify(result.diagnostics)); + interfaces.set(name, result.resource.revision); + } + const result = compileCapabilityResourceSource( + `import interface Value; + package Adapter id "adapter" revision "adapter@1" { + operation fetchText id "read" : unit -> string mode call execution snapshot targets [server] + receiver any requires { interface data id "value" : Value using [text.get]; }; + }`, + { source, environment: { interfaces, interfaceClosure: [...interfaces.values()] } }, + ); + assert.ok(result.ok && result.resource.kind === "package", JSON.stringify(result.diagnostics)); + return { + format: "quixos-bindings", + version: 1, + interfaces: [...interfaces.values()], + packages: [result.resource.revision], + } satisfies BindingSchema; +} + +test("package builds ignore exact pins and unrelated declarations, and preserve generated port types", () => { + const schema = fixture(); + const original = packageBuildContract(schema, "adapter@1"); + const changed = structuredClone(schema); + changed.interfaces.forEach((iface) => { + iface.source.commit = "2".repeat(40); + }); + changed.packages[0]!.source.commit = "3".repeat(40); + changed.interfaces.find((iface) => iface.displayName === "Unrelated")!.members = []; + assert.deepEqual(packageBuildContract(changed, "adapter@1"), original); + assert.equal(JSON.stringify(original).includes("https://example.test/source"), false); + assert.equal( + generatePortableTypeScriptBindings(original.definition.bindings, "adapter@1", { target: "server" }), + generatePortableTypeScriptBindings(schema, "adapter@1", { target: "server" }), + ); +}); + +test("changing a consumed interface or an owned port contract invalidates the build", () => { + const schema = fixture(); + const original = packageBuildContract(schema, "adapter@1"); + const changed = structuredClone(schema); + changed.interfaces[0]!.members[0]!.operations[0]!.outputType = { kind: "scalar", name: "bool" }; + assert.notEqual(packageBuildContract(changed, "adapter@1").digest, original.digest); + const own = structuredClone(schema); + own.packages[0]!.exports[0]!.outputType = { kind: "scalar", name: "bool" }; + assert.notEqual(packageBuildContract(own, "adapter@1").digest, original.digest); +}); + +test("a nominal reference does not import the referenced interface's entire dependency graph", () => { + const schema = fixture(); + schema.packages[0]!.exports[0]!.inputType = { + kind: "object-ref", + expectation: { kind: "interface", interfaceRevisionId: schema.interfaces[1]!.revisionId }, + }; + const original = packageBuildContract(schema, "adapter@1"); + schema.interfaces[1]!.members = []; + assert.deepEqual(packageBuildContract(schema, "adapter@1"), original); + schema.packages[0]!.exports[0]!.inputType = { kind: "scalar", name: "string" }; + assert.notEqual(packageBuildContract(schema, "adapter@1").digest, original.digest); +}); diff --git a/test/emit-managed.ts b/test/emit-managed.ts index 731551f..95ef2c7 100644 --- a/test/emit-managed.ts +++ b/test/emit-managed.ts @@ -10,6 +10,9 @@ import { generatePackageQueryClientBindings, } from "../src/execution-world/client-contracts.js"; import { compileQueryProgram } from "../src/execution-world/queries.js"; +import { presentationDeclarations, preparationArtifact } from "../src/react-build/declarations.js"; +import { compileQueryComposition } from "../src/query/composition.js"; +import type { PackageRevision } from "../src/capability-model/types.js"; const source = { repository: "https://example.test/device-owner.git", commit: "a".repeat(40) }; const factory = compileCapabilityResourceSource( `external atom Entry id "atom:entry"; @@ -51,6 +54,17 @@ const view = compileCapabilityResourceSource( ); assert.ok(view.ok && view.resource.kind === "interface", JSON.stringify(view.diagnostics)); const viewInterface = view.resource.revision; +function declareInputs(pkg: PackageRevision) { + const declarations = presentationDeclarations( + JSON.stringify({ + modules: Object.fromEntries((pkg.modules ?? []).map((module) => [module.displayName, { inputs: [] }])), + }), + pkg, + [dataInterface, viewInterface], + ); + pkg.buildArtifacts = { [preparationArtifact]: declarations.artifact }; + pkg.queryCompositions = declarations.compositions.map((entry) => compileQueryComposition(entry, new Map())); +} const ui = compileCapabilityResourceSource( 'import interface Data; import interface View; package UI id "package:ui" revision "package:ui@1" { module Card id "component:card" implements View.render; }', { @@ -65,6 +79,7 @@ const ui = compileCapabilityResourceSource( }, ); assert.ok(ui.ok && ui.resource.kind === "package", JSON.stringify(ui.diagnostics)); +declareInputs(ui.resource.revision); for (const variant of ["base", "added", "gateway", "gateway-next", "session"]) { const session = variant === "session", gateway = session || variant.startsWith("gateway"), @@ -107,6 +122,7 @@ package Owner id "package:owner" revision "package:owner@1" { }, ); assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics)); + if (session) declareInputs(pkg.resource.revision); if (session) pkg.resource.revision.checkedQueries = await Promise.all( pkg.resource.revision.queries!.map((query) =>