From 00fc2b9ff12f96becd02163e91cbf58320886e15 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Tue, 15 Sep 2026 15:23:24 -0700 Subject: [PATCH] Format authored monorepo code with pinned language formatters --- flake.nix | 229 ++-- nix/checked-candidate.nix | 159 ++- nix/checked-package.nix | 32 +- proto/camino/api.proto | 76 +- proto/quixos/orch.proto | 28 +- proto/quixos/runtime.proto | 4 +- src/bindings/bundle-policy.ts | 33 +- src/bindings/cli.ts | 11 +- src/bindings/client.ts | 68 +- src/bindings/index.ts | 216 +++- src/capability-language/assembly.ts | 192 ++-- src/capability-language/authoring-check.ts | 116 +- src/capability-language/authoring-context.ts | 47 +- src/capability-language/authoring-converge.ts | 175 ++- src/capability-language/authoring-inspect.ts | 85 +- src/capability-language/authoring-worklist.ts | 111 +- src/capability-language/candidate-check.ts | 202 +++- src/capability-language/checked-build.ts | 175 ++- src/capability-language/cli.ts | 68 +- src/capability-language/file-lock.ts | 49 +- src/capability-language/git-resolver.ts | 65 +- .../implementation-edit.ts | 45 +- src/capability-language/inspect-cli.ts | 7 +- src/capability-language/migration-seal.ts | 16 +- src/capability-language/parser.ts | 998 ++++++------------ src/capability-language/pin-upgrades.ts | 523 ++++++--- src/capability-language/resource-cli.ts | 63 +- src/capability-language/scaffold-recipes.ts | 396 +++++-- src/capability-language/scaffold.ts | 28 +- src/capability-language/source-loader.ts | 22 +- src/capability-language/source.ts | 64 +- src/capability-language/structural-edits.ts | 193 +++- src/capability-language/structural-plan.ts | 286 +++-- src/capability-language/tool-cli.ts | 406 +++++-- src/capability-language/workspace-cli.ts | 72 +- src/capability-model/evolution.ts | 338 ++++-- src/capability-model/migrations.ts | 116 +- src/capability-model/types.ts | 56 +- src/capability-model/validation.ts | 598 ++++------- src/descriptor-check.ts | 10 +- src/descriptor.ts | 20 +- src/resource-lock/loader.ts | 110 +- src/resource-lock/parser.ts | 173 ++- src/resource-lock/types.ts | 25 +- test/authoring-converge.test.ts | 62 +- test/authoring-inspect.test.ts | 9 +- test/authoring-worklist.test.ts | 90 +- test/bindings.test.ts | 24 +- test/bundle-policy.test.ts | 27 +- test/candidate-check.test.ts | 17 +- test/capability-assembly.test.ts | 94 +- test/capability-cli.test.ts | 13 +- test/capability-language.test.ts | 219 ++-- test/capability-model.test.ts | 148 +-- test/evolution.test.ts | 72 +- test/file-lock.test.ts | 32 +- test/fixtures/capability-model.ts | 153 +-- test/fixtures/todo.capabilities.qx | 4 +- test/fixtures/todo.package.qx | 10 +- test/fixtures/web-studio.capabilities.qx | 2 +- test/git-resolver.test.ts | 2 +- test/migrations.test.ts | 69 +- test/nix-candidate.test.ts | 91 +- test/pin-upgrades.test.ts | 207 ++-- test/qx-source.test.ts | 65 +- test/resource-lock.test.ts | 91 +- test/scaffold-recipes.test.ts | 115 +- test/structural-edits.test.ts | 85 +- test/structural-plan.test.ts | 34 +- 69 files changed, 5135 insertions(+), 3306 deletions(-) diff --git a/flake.nix b/flake.nix index 0477270..7a86d3d 100644 --- a/flake.nix +++ b/flake.nix @@ -6,126 +6,140 @@ flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils, ... }: - flake-utils.lib.eachDefaultSystem (system: + outputs = + { + self, + nixpkgs, + flake-utils, + ... + }: + flake-utils.lib.eachDefaultSystem ( + system: let pkgs = import nixpkgs { inherit system; }; nodejs = pkgs.nodejs_24; quixos-protocol = (pkgs.callPackage ./yarn-project.nix { inherit nodejs; }) { src = pkgs.lib.cleanSource ./.; overrideAttrs = old: { - nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ - pkgs.diffutils - pkgs.esbuild - pkgs.protobuf - pkgs.git - pkgs.jujutsu - pkgs.gnutar - pkgs.util-linux - ]; - buildPhase = '' - runHook preBuild - mkdir -p "$TMPDIR/generated-before" - cp --recursive src/gen "$TMPDIR/generated-before/proto" - cp --recursive src/capability-language/generated "$TMPDIR/generated-before/capability" - cp --recursive src/resource-lock/generated "$TMPDIR/generated-before/lock" - export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$PWD/proto''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}" - patchShebangs node_modules/.bin node_modules/@bufbuild/protoc-gen-es/bin - yarn build - esbuild dist/src/bindings/client.js --bundle --platform=node --target=node24 --format=esm --outfile=client-codegen.mjs - diff --recursive --unified "$TMPDIR/generated-before/proto" src/gen - diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated - diff --recursive --unified "$TMPDIR/generated-before/lock" src/resource-lock/generated - esbuild dist/src/descriptor-check.js \ - --bundle --platform=node --target=node24 --format=esm \ - --outfile=quixos-descriptor-check.mjs - esbuild dist/src/capability-language/cli.js \ - --bundle --platform=node --target=node24 --format=esm \ - --outfile=quixos-capability-compile.mjs - esbuild dist/src/capability-language/workspace-cli.js \ - --bundle --platform=node --target=node24 --format=esm \ - --outfile=quixos-workspace-compile.mjs - esbuild dist/src/capability-language/resource-cli.js \ - --bundle --platform=node --target=node24 --format=esm \ - --outfile=quixos-resource-compile.mjs - esbuild dist/src/resource-lock/cli.js \ - --bundle --platform=node --target=node24 --format=esm \ - --outfile=quixos-lock-check.mjs - esbuild dist/src/bindings/cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-codegen-ts.mjs - esbuild dist/src/bindings/react-platform.js --bundle --platform=node --target=node24 --format=esm --outfile=react-platform.mjs - esbuild dist/src/capability-language/tool-cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-qx.mjs - runHook postBuild - ''; - doCheck = true; - checkPhase = '' - runHook preCheck - yarn test - runHook postCheck - ''; - installPhase = '' - runHook preInstall - mkdir -p "$out" - install -Dm644 nix/checked-package.nix "$out/share/checked-package.nix" - substitute nix/checked-candidate.nix "$out/share/checked-candidate.nix" \ - --replace-fail '@nixpkgs@' '${pkgs.path}' - cp --reflink=auto --recursive grammar "$out/grammar" - cp --reflink=auto --recursive proto "$out/proto" - cp --reflink=auto --recursive dist "$out/dist" - cp package.json "$out/package.json" - mkdir -p "$out/bin" - for tool in quixos-codegen-ts quixos-qx; do - printf '#!/bin/sh\nexec ${pkgs.nodejs_24}/bin/node "%s/libexec/quixos-protocol/%s.mjs" "$@"\n' "$out" "$tool" > "$out/bin/$tool" - chmod +x "$out/bin/$tool" - done - cat > "$out/bin/quixos-descriptor-check" < "$out/bin/quixos-capability-compile" < "$out/bin/quixos-workspace-compile" < "$out/bin/quixos-resource-compile" < "$out/bin/quixos-lock-check" < "$out/bin/$tool" + chmod +x "$out/bin/$tool" + done + cat > "$out/bin/quixos-descriptor-check" < "$out/bin/quixos-capability-compile" < "$out/bin/quixos-workspace-compile" < "$out/bin/quixos-resource-compile" < "$out/bin/quixos-lock-check" <= 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); - children = map (entry: load (ancestors ++ [ (sourceKey source) ]) (entry.source // { inherit (entry) kind; })) lock.resources; - in source // { inherit directory children; }; + 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); + children = map ( + entry: load (ancestors ++ [ (sourceKey source) ]) (entry.source // { inherit (entry) kind; }) + ) lock.resources; + in + source // { inherit directory children; }; root = load [ ] { inherit kind repository commit; }; flatten = node: [ node ] ++ pkgs.lib.concatMap flatten node.children; - nodes = builtins.attrValues (builtins.listToAttrs (map (node: { - name = sourceKey node; value = node; - }) (flatten root))); - 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); - }); - compile = node: pkgs.runCommand "qx-${node.kind}-contract" { } '' - mkdir -p "$out" - ${if node.kind == "workspace" then '' - ${protocol}/bin/quixos-workspace-compile --root ${node.directory} \ - --source-root-commit ${pkgs.lib.escapeShellArg node.commit} \ - --checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} \ - --graph-out "$out/graph.json" > "$out/candidate.json" - '' else '' - ${protocol}/bin/quixos-resource-compile --root ${node.directory} \ - --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 \ - --graph-out "$out/graph.json" --schema-out "$out/bindings.json" > "$out/candidate.json" - ''} - ''; + nodes = builtins.attrValues ( + builtins.listToAttrs ( + map (node: { + name = sourceKey node; + value = node; + }) (flatten root) + ) + ); + 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); + } + ); + compile = + node: + pkgs.runCommand "qx-${node.kind}-contract" { } '' + mkdir -p "$out" + ${ + if node.kind == "workspace" then + '' + ${protocol}/bin/quixos-workspace-compile --root ${node.directory} \ + --source-root-commit ${pkgs.lib.escapeShellArg node.commit} \ + --checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} \ + --graph-out "$out/graph.json" > "$out/candidate.json" + '' + else + '' + ${protocol}/bin/quixos-resource-compile --root ${node.directory} \ + --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 \ + --graph-out "$out/graph.json" --schema-out "$out/bindings.json" > "$out/candidate.json" + '' + } + ''; contract = compile root; - checkPackage = node: let - compiled = compile node; - candidate = builtins.fromJSON (builtins.readFile "${compiled}/candidate.json"); - package = builtins.getFlake ("git+${node.repository}?rev=${node.commit}&ref=refs/tags/quixos-reachability/${node.commit}"); - checked = package.quixosPackages.${system}.checkedServer or - (throw "Package ${node.repository} lacks checkedServer; use the supported package scaffold."); - artifact = checked { - schema = "${compiled}/bindings.json"; - generator = protocol; + checkPackage = + node: + let + compiled = compile node; + candidate = builtins.fromJSON (builtins.readFile "${compiled}/candidate.json"); + package = builtins.getFlake ( + "git+${node.repository}?rev=${node.commit}&ref=refs/tags/quixos-reachability/${node.commit}" + ); + checked = + package.quixosPackages.${system}.checkedServer + or (throw "Package ${node.repository} lacks checkedServer; use the supported package scaffold."); + artifact = checked { + schema = "${compiled}/bindings.json"; + generator = protocol; + packageRevisionId = candidate.revision.revisionId; + }; + in + { packageRevisionId = candidate.revision.revisionId; + artifactPath = artifact; }; - in { packageRevisionId = candidate.revision.revisionId; artifactPath = artifact; }; - checks = if contractOnly then [ ] else map checkPackage (builtins.filter (node: node.kind == "package") nodes); + checks = + if contractOnly then + [ ] + else + map checkPackage (builtins.filter (node: node.kind == "package") nodes); manifest = pkgs.writeText "qx-candidate-checks.json" (builtins.toJSON checks); - bindingOptions = pkgs.writeText "qx-typescript-options.json" (builtins.toJSON - ((builtins.fromJSON (builtins.readFile "${root.directory}/quixos.check.json")).options or { })); -in pkgs.runCommand (if contractOnly then "qx-contract" else "qx-checked-candidate") { } '' + bindingOptions = pkgs.writeText "qx-typescript-options.json" ( + builtins.toJSON ( + (builtins.fromJSON (builtins.readFile "${root.directory}/quixos.check.json")).options or { } + ) + ); +in +pkgs.runCommand (if contractOnly then "qx-contract" else "qx-checked-candidate") { } '' mkdir -p "$out" cp ${contract}/candidate.json "$out/candidate.json" cp ${contract}/graph.json "$out/graph.json" cp ${manifest} "$out/checks.json" - ${pkgs.lib.optionalString (kind != "workspace") ''cp ${contract}/bindings.json "$out/bindings.json"''} + ${pkgs.lib.optionalString ( + kind != "workspace" + ) ''cp ${contract}/bindings.json "$out/bindings.json"''} ${pkgs.lib.optionalString (kind == "package") '' ${protocol}/bin/quixos-codegen-ts ${contract}/bindings.json \ ${pkgs.lib.escapeShellArg (builtins.fromJSON (builtins.readFile "${contract}/candidate.json")).revision.revisionId} \ diff --git a/nix/checked-package.nix b/nix/checked-package.nix index e6a79bc..39b16f8 100644 --- a/nix/checked-package.nix +++ b/nix/checked-package.nix @@ -1,17 +1,35 @@ # One derivation path for provisional checking and activation. The source and # schema come from exact committed inputs resolved by the Quixos compiler. -{ source, schema, generator, packageRevisionId, system ? builtins.currentSystem }: +{ + source, + schema, + generator, + packageRevisionId, + system ? builtins.currentSystem, +}: let packageSource = builtins.path { path = /. + source; name = "quixos-package-source"; - filter = path: _: let name = baseNameOf path; in name != ".git" && name != ".jj"; + filter = + path: _: + let + name = baseNameOf path; + in + name != ".git" && name != ".jj"; }; - package = builtins.getFlake ("path:" + builtins.unsafeDiscardStringContext (toString packageSource)); - checked = package.quixosPackages.${system}.checkedServer or - (throw "Package ${packageRevisionId} lacks checkedServer; use the supported package scaffold."); -in checked { + package = builtins.getFlake ( + "path:" + builtins.unsafeDiscardStringContext (toString packageSource) + ); + checked = + package.quixosPackages.${system}.checkedServer + or (throw "Package ${packageRevisionId} lacks checkedServer; use the supported package scaffold."); +in +checked { inherit packageRevisionId; - schema = builtins.path { path = /. + schema; name = "candidate-package-bindings.json"; }; + schema = builtins.path { + path = /. + schema; + name = "candidate-package-bindings.json"; + }; generator = builtins.storePath generator; } diff --git a/proto/camino/api.proto b/proto/camino/api.proto index 9f82e11..f277459 100644 --- a/proto/camino/api.proto +++ b/proto/camino/api.proto @@ -22,9 +22,15 @@ service CaminoService { } message NullValue {} -message ObjectValue { map fields = 1; } -message ListValue { repeated Value values = 1; } -message RefValue { string object_id = 1; } +message ObjectValue { + map fields = 1; +} +message ListValue { + repeated Value values = 1; +} +message RefValue { + string object_id = 1; +} message CrdtValue { string type = 1; string encoding = 2; @@ -43,7 +49,9 @@ message StateValueSource { CrdtValue crdt_snapshot = 6; } -message ValueSource { StateValueSource state = 1; } +message ValueSource { + StateValueSource state = 1; +} message Value { oneof kind { @@ -61,26 +69,44 @@ message Value { ValueSource source = 11; } -message InstallPersistencePlanRequest { PersistencePlan plan = 1; } -message InstallPersistencePlanResponse { PersistencePlan plan = 1; } +message InstallPersistencePlanRequest { + PersistencePlan plan = 1; +} +message InstallPersistencePlanResponse { + PersistencePlan plan = 1; +} message GetPersistencePlanRequest {} -message GetPersistencePlanResponse { PersistencePlan plan = 1; } +message GetPersistencePlanResponse { + PersistencePlan plan = 1; +} message CreateObjectRequest { string atom_id = 1; map initial_state = 2; } -message CreateObjectResponse { CaminoObject object = 1; } -message GetObjectRequest { string object_id = 1; } -message GetObjectResponse { CaminoObject object = 1; } -message ListObjectsRequest { string atom_id = 1; } -message ListObjectsResponse { repeated CaminoObject objects = 1; } +message CreateObjectResponse { + CaminoObject object = 1; +} +message GetObjectRequest { + string object_id = 1; +} +message GetObjectResponse { + CaminoObject object = 1; +} +message ListObjectsRequest { + string atom_id = 1; +} +message ListObjectsResponse { + repeated CaminoObject objects = 1; +} message ReadStateRequest { string object_id = 1; string slot_id = 2; } -message ReadStateResponse { Value value = 1; } +message ReadStateResponse { + Value value = 1; +} message WriteStateRequest { string object_id = 1; string slot_id = 2; @@ -100,15 +126,23 @@ message ConnectEdgeRequest { optional int32 ordinal = 5; optional int32 target_ordinal = 6; } -message ConnectEdgeResponse { CaminoEdge edge = 1; } +message ConnectEdgeResponse { + CaminoEdge edge = 1; +} message ResolveEdgeRequest { string object_id = 1; string edge_type_id = 2; string projection_id = 3; } -message ResolveEdgeResponse { repeated CaminoEdge edges = 1; } -message DisconnectEdgeRequest { string edge_id = 1; } -message DisconnectEdgeResponse { string edge_id = 1; } +message ResolveEdgeResponse { + repeated CaminoEdge edges = 1; +} +message DisconnectEdgeRequest { + string edge_id = 1; +} +message DisconnectEdgeResponse { + string edge_id = 1; +} message CollectionEntry { // Existing entry identity to preserve; empty allocates a new canonical edge. @@ -128,8 +162,12 @@ message ReplaceCollectionRequest { repeated CollectionEntry entries = 5; } -message ListOpsRequest { string object_id = 1; } -message ListOpsResponse { repeated CaminoOp ops = 1; } +message ListOpsRequest { + string object_id = 1; +} +message ListOpsResponse { + repeated CaminoOp ops = 1; +} message WatchObjectRequest { string object_id = 1; string after_op_id = 2; diff --git a/proto/quixos/orch.proto b/proto/quixos/orch.proto index 7ce44cf..bf306c2 100644 --- a/proto/quixos/orch.proto +++ b/proto/quixos/orch.proto @@ -11,7 +11,8 @@ service OrchestratorRuntime { rpc InvokeCapability(InvokeCapabilityRequest) returns (InvokeCapabilityResponse); rpc WatchCapability(WatchCapabilityRequest) returns (stream WatchCapabilityEvent); rpc ConstructObject(ConstructObjectRequest) returns (ConstructObjectResponse); - rpc ResolveOrConstructRelatedObject(ResolveOrConstructRelatedObjectRequest) returns (ResolveOrConstructRelatedObjectResponse); + rpc ResolveOrConstructRelatedObject(ResolveOrConstructRelatedObjectRequest) + returns (ResolveOrConstructRelatedObjectResponse); rpc GetWorkspace(GetWorkspaceRequest) returns (GetWorkspaceResponse); rpc ListPackageDescriptors(ListPackageDescriptorsRequest) returns (ListPackageDescriptorsResponse); rpc ListPackageRuntimes(ListPackageRuntimesRequest) returns (ListPackageRuntimesResponse); @@ -23,7 +24,9 @@ message ConstructObjectRequest { string atom_id = 1; map input = 2; } -message ConstructObjectResponse { camino.CaminoObject object = 1; } +message ConstructObjectResponse { + camino.CaminoObject object = 1; +} message ResolveOrConstructRelatedObjectRequest { string object_id = 1; @@ -89,12 +92,23 @@ message ConstructorInputContract { } message ListActivationsRequest {} message ListPackageDescriptorsRequest {} -message ListPackageDescriptorsResponse { repeated quixos.PackageDescriptor descriptors = 1; } +message ListPackageDescriptorsResponse { + repeated quixos.PackageDescriptor descriptors = 1; +} message ListPackageRuntimesRequest {} -message ListPackageRuntimesResponse { repeated PackageRuntimeStatus runtimes = 1; } -message ListActivationsResponse { repeated Activation activations = 1; } -message CloseActivationRequest { string activation_id = 1; string reason = 2; } -message CloseActivationResponse { Activation activation = 1; } +message ListPackageRuntimesResponse { + repeated PackageRuntimeStatus runtimes = 1; +} +message ListActivationsResponse { + repeated Activation activations = 1; +} +message CloseActivationRequest { + string activation_id = 1; + string reason = 2; +} +message CloseActivationResponse { + Activation activation = 1; +} message Activation { string activation_id = 1; diff --git a/proto/quixos/runtime.proto b/proto/quixos/runtime.proto index 3672cb0..ba1ca50 100644 --- a/proto/quixos/runtime.proto +++ b/proto/quixos/runtime.proto @@ -35,7 +35,9 @@ message InvocationContext { // Host-selected owner; packages must not invent workspace-local ownership. string owner_conformance_id = 6; } -message InvocationControlRequest { string invocation_id = 1; } +message InvocationControlRequest { + string invocation_id = 1; +} message InvocationStatus { string invocation_id = 1; // unknown, running, cancellation-requested, completed, failed diff --git a/src/bindings/bundle-policy.ts b/src/bindings/bundle-policy.ts index c63e82f..8221a4d 100644 --- a/src/bindings/bundle-policy.ts +++ b/src/bindings/bundle-policy.ts @@ -1,25 +1,35 @@ -import {parse} from "@babel/parser"; +import { parse } from "@babel/parser"; import fs from "node:fs/promises"; import path from "node:path"; /** Enforce the authored bundled-code contract, not a security sandbox. */ export function bundlePolicyErrors(source: string, filename: string): string[] { - const ast = parse(source, {sourceType: "module", plugins: ["typescript", "jsx"]}); + const ast = parse(source, { sourceType: "module", plugins: ["typescript", "jsx"] }); const errors: string[] = []; const visit = (value: unknown) => { - if (Array.isArray(value)) {value.forEach(visit); return;} + if (Array.isArray(value)) { + value.forEach(visit); + return; + } if (!value || typeof value !== "object") return; const n = value as Record; if (typeof n.type !== "string") return; const fail = (message: string) => errors.push(`${filename}:${n.loc?.start.line ?? 1}: ${message}`); - if (n.type === "MetaProperty" && n.meta.name === "import") fail("Bundled package code cannot use import.meta; import packaged assets statically"); - if (n.type === "Identifier" && ["__dirname", "__filename"].includes(n.name)) fail("Bundled package code cannot depend on module filesystem locations"); + if (n.type === "MetaProperty" && n.meta.name === "import") + fail("Bundled package code cannot use import.meta; import packaged assets statically"); + if (n.type === "Identifier" && ["__dirname", "__filename"].includes(n.name)) + fail("Bundled package code cannot depend on module filesystem locations"); if (["CallExpression", "NewExpression"].includes(n.type)) { - if (n.callee?.type === "Identifier" && ["eval", "Function"].includes(n.callee.name)) fail("Dynamic code generation is unsupported in bundled package code"); - if ((n.callee?.type === "Import" || (n.callee?.type === "Identifier" && n.callee.name === "require")) && - (n.arguments.length !== 1 || n.arguments[0].type !== "StringLiteral")) fail("Module imports must have a static string specifier"); + if (n.callee?.type === "Identifier" && ["eval", "Function"].includes(n.callee.name)) + fail("Dynamic code generation is unsupported in bundled package code"); + if ( + (n.callee?.type === "Import" || (n.callee?.type === "Identifier" && n.callee.name === "require")) && + (n.arguments.length !== 1 || n.arguments[0].type !== "StringLiteral") + ) + fail("Module imports must have a static string specifier"); } - if (n.type === "ImportExpression" && n.source.type !== "StringLiteral") fail("Module imports must have a static string specifier"); + if (n.type === "ImportExpression" && n.source.type !== "StringLiteral") + fail("Module imports must have a static string specifier"); Object.values(n).forEach(visit); }; visit(ast); @@ -28,12 +38,13 @@ export function bundlePolicyErrors(source: string, filename: string): string[] { export async function checkBundleSources(directory: string): Promise { const errors: string[] = []; async function walk(current: string) { - for (const entry of await fs.readdir(current, {withFileTypes: true})) { + for (const entry of await fs.readdir(current, { withFileTypes: true })) { if (["gen", "node_modules"].includes(entry.name)) continue; const file = path.join(current, entry.name); if (entry.isSymbolicLink()) throw new Error(`Authored source symlinks are unsupported: ${file}`); if (entry.isDirectory()) await walk(file); - else if (/\.[cm]?[jt]sx?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) errors.push(...bundlePolicyErrors(await fs.readFile(file, "utf8"), file)); + else if (/\.[cm]?[jt]sx?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) + errors.push(...bundlePolicyErrors(await fs.readFile(file, "utf8"), file)); } } await walk(directory); diff --git a/src/bindings/cli.ts b/src/bindings/cli.ts index 21f34fc..df81397 100644 --- a/src/bindings/cli.ts +++ b/src/bindings/cli.ts @@ -2,12 +2,12 @@ import { readFile, writeFile } from "node:fs/promises"; import { generateTypeScriptBindings } from "./index.js"; import path from "node:path"; -import {reactPlatformTypes} from "./react-platform.js"; +import { reactPlatformTypes } from "./react-platform.js"; const main = async () => { const [schema, revision, output, options, ...rest] = process.argv.slice(2); - if (!schema || !revision || !output || rest.length) throw new Error( - "usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]"); + if (!schema || !revision || !output || rest.length) + throw new Error("usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]"); const config = options ? JSON.parse(await readFile(options, "utf8")) : {}; const generated = generateTypeScriptBindings(JSON.parse(await readFile(schema, "utf8")), revision, config); await writeFile(output, generated); @@ -15,4 +15,7 @@ const main = async () => { await writeFile(path.join(path.dirname(output), "web-studio-react-runtime.d.ts"), reactPlatformTypes); } }; -main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; }); +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/src/bindings/client.ts b/src/bindings/client.ts index 26ae37e..af56ad3 100644 --- a/src/bindings/client.ts +++ b/src/bindings/client.ts @@ -1,16 +1,33 @@ -import type {InterfaceRevision, ValueType} from "../capability-model/types.js"; +import type { InterfaceRevision, ValueType } from "../capability-model/types.js"; /** Host clients have no package receiver, but must use the same checked * interface signatures and argument framing as generated package ports. */ export const generateClientContracts = (interfaces: InterfaceRevision[], messages: Record) => { const type = (value: ValueType): string => { switch (value.kind) { - case "builtin": return value.name === "unit" ? "undefined" : "string"; - case "scalar": return ({bool: "boolean", string: "string", bytes: "Uint8Array", int32: "number", uint32: "number", double: "number", int64: "bigint", uint64: "bigint"})[value.name]; - case "object-ref": return `{readonly $quixosRef: string}`; - case "optional": return `(${type(value.value)} | null)`; - case "list": return `Array<${type(value.value)}>`; - case "record": return `{${Object.entries(value.fields).map(([name, field]) => `${JSON.stringify(name)}${field.kind === "optional" ? "?" : ""}: ${type(field)}`).join("; ")}}`; + case "builtin": + return value.name === "unit" ? "undefined" : "string"; + case "scalar": + return { + bool: "boolean", + string: "string", + bytes: "Uint8Array", + int32: "number", + uint32: "number", + double: "number", + int64: "bigint", + uint64: "bigint", + }[value.name]; + case "object-ref": + return `{readonly $quixosRef: string}`; + case "optional": + return `(${type(value.value)} | null)`; + case "list": + return `Array<${type(value.value)}>`; + case "record": + return `{${Object.entries(value.fields) + .map(([name, field]) => `${JSON.stringify(name)}${field.kind === "optional" ? "?" : ""}: ${type(field)}`) + .join("; ")}}`; case "message": { const binding = messages[value.descriptorId]; if (!binding) throw new Error(`Missing host message type ${value.descriptorId}`); @@ -18,12 +35,33 @@ export const generateClientContracts = (interfaces: InterfaceRevision[], message } } }; - const operations = interfaces.flatMap(iface => iface.members.flatMap(member => member.operations - .filter(operation => operation.mode === "call").map(operation => ({...operation, interfaceRevisionId: iface.revisionId})))); - return `// Generated from checked QX interfaces. Regenerate with scripts/generate-platform-contracts.mjs.\n` + - `export type PlatformInputs = {\n${operations.map(operation => ` ${JSON.stringify(operation.id)}: ${type(operation.inputType)};`).join("\n")}\n};\n` + - `export const platformOperations = ${JSON.stringify(Object.fromEntries(operations.map(operation => [operation.id, { - interfaceRevisionId: operation.interfaceRevisionId, - input: operation.inputType.kind === "builtin" && operation.inputType.name === "unit" ? "unit" : ["record", "message"].includes(operation.inputType.kind) ? "fields" : "value", - }])), null, 2)} as const;\n`; + const operations = interfaces.flatMap((iface) => + iface.members.flatMap((member) => + member.operations + .filter((operation) => operation.mode === "call") + .map((operation) => ({ ...operation, interfaceRevisionId: iface.revisionId })), + ), + ); + return ( + `// Generated from checked QX interfaces. Regenerate with scripts/generate-platform-contracts.mjs.\n` + + `export type PlatformInputs = {\n${operations.map((operation) => ` ${JSON.stringify(operation.id)}: ${type(operation.inputType)};`).join("\n")}\n};\n` + + `export const platformOperations = ${JSON.stringify( + Object.fromEntries( + operations.map((operation) => [ + operation.id, + { + interfaceRevisionId: operation.interfaceRevisionId, + input: + operation.inputType.kind === "builtin" && operation.inputType.name === "unit" + ? "unit" + : ["record", "message"].includes(operation.inputType.kind) + ? "fields" + : "value", + }, + ]), + ), + null, + 2, + )} as const;\n` + ); }; diff --git a/src/bindings/index.ts b/src/bindings/index.ts index 0eda305..ec9f78d 100644 --- a/src/bindings/index.ts +++ b/src/bindings/index.ts @@ -9,9 +9,12 @@ export type BindingSchema = { packages: PackageRevision[]; }; export const bindingSchema = (compiled: CompiledCapabilityResourceRepository): BindingSchema => ({ - format: "quixos-bindings", version: 1, - interfaces: compiled.resources.flatMap((node) => node.resource.kind === "interface" ? [node.resource.revision] : []), - packages: compiled.resources.flatMap((node) => node.resource.kind === "package" ? [node.resource.revision] : []), + format: "quixos-bindings", + version: 1, + interfaces: compiled.resources.flatMap((node) => + node.resource.kind === "interface" ? [node.resource.revision] : [], + ), + packages: compiled.resources.flatMap((node) => (node.resource.kind === "package" ? [node.resource.revision] : [])), }); export type TypeScriptBindingOptions = { runtimeModule?: string; @@ -19,33 +22,61 @@ export type TypeScriptBindingOptions = { messages?: Record; }; export function generatePackageDescriptor(schema: BindingSchema, revisionId: string): string { - const pkg = schema.packages.find(entry => entry.revisionId === revisionId); + const pkg = schema.packages.find((entry) => entry.revisionId === revisionId); if (!pkg) throw new Error(`Unknown package revision ${revisionId}`); - return `# Generated from the checked package contract\npackage_id: ${JSON.stringify(pkg.packageId)}\npackage_revision_id: ${JSON.stringify(pkg.revisionId)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` + - pkg.exports.map(entry => `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.displayName)} }\n`).join(""); + return ( + `# Generated from the checked package contract\npackage_id: ${JSON.stringify(pkg.packageId)}\npackage_revision_id: ${JSON.stringify(pkg.revisionId)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` + + pkg.exports + .map( + (entry) => + `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.displayName)} }\n`, + ) + .join("") + ); } const q = JSON.stringify; -const object = (entries: [string, string][]) => `{ ${entries.map(([key, value]) => `${q(key)}: ${value}`).join("; ")} }`; +const object = (entries: [string, string][]) => + `{ ${entries.map(([key, value]) => `${q(key)}: ${value}`).join("; ")} }`; const unit = (type: ValueType) => type.kind === "builtin" && type.name === "unit"; export const generateTypeScriptBindings = ( - schema: BindingSchema, packageRevisionId: string, options: TypeScriptBindingOptions = {}, + schema: BindingSchema, + packageRevisionId: string, + options: TypeScriptBindingOptions = {}, ) => { - if (schema.format !== "quixos-bindings" || schema.version !== 1) throw new Error("Unsupported binding schema version"); + if (schema.format !== "quixos-bindings" || schema.version !== 1) + throw new Error("Unsupported binding schema version"); const pkg = schema.packages.find((entry) => entry.revisionId === packageRevisionId); if (!pkg) throw new Error(`Unknown package revision ${packageRevisionId}`); const messages = new Map(); const type = (value: ValueType): string => { switch (value.kind) { - case "builtin": return value.name === "unit" ? "null" : "QxWatchHandle"; - case "scalar": return ({ bool: "boolean", bytes: "Uint8Array", string: "string", int64: "bigint", uint64: "bigint", - double: "number", int32: "number", uint32: "number" })[value.name]; - case "object-ref": return `QxObjectRef<${q(value.expectation.kind === "atom" ? `atom:${value.expectation.atomId}` : `interface:${value.expectation.interfaceRevisionId}`)}>`; - case "optional": return `(${type(value.value)} | null)`; - case "list": return `Array<${type(value.value)}>`; - case "record": return `{ ${Object.entries(value.fields).map(([name, field]) => `${q(name)}: ${type(field)}`).join("; ")} }`; + case "builtin": + return value.name === "unit" ? "null" : "QxWatchHandle"; + case "scalar": + return { + bool: "boolean", + bytes: "Uint8Array", + string: "string", + int64: "bigint", + uint64: "bigint", + double: "number", + int32: "number", + uint32: "number", + }[value.name]; + case "object-ref": + return `QxObjectRef<${q(value.expectation.kind === "atom" ? `atom:${value.expectation.atomId}` : `interface:${value.expectation.interfaceRevisionId}`)}>`; + case "optional": + return `(${type(value.value)} | null)`; + case "list": + return `Array<${type(value.value)}>`; + case "record": + return `{ ${Object.entries(value.fields) + .map(([name, field]) => `${q(name)}: ${type(field)}`) + .join("; ")} }`; case "message": { - if (!options.messages?.[value.descriptorId]) throw new Error(`Missing TypeScript message binding for ${value.descriptorId}`); + if (!options.messages?.[value.descriptorId]) + throw new Error(`Missing TypeScript message binding for ${value.descriptorId}`); if (!messages.has(value.descriptorId)) messages.set(value.descriptorId, `message${messages.size}`); return `BindingValue`; } @@ -53,7 +84,7 @@ export const generateTypeScriptBindings = ( }; const ref = (target: { kind: "atom"; atomId: string } | { kind: "interface"; interfaceRevisionId: string }) => `QxObjectRef<${q(target.kind === "atom" ? `atom:${target.atomId}` : `interface:${target.interfaceRevisionId}`)}>`; - const params = (input: ValueType) => unit(input) ? "" : `input: ${type(input)}`; + const params = (input: ValueType) => (unit(input) ? "" : `input: ${type(input)}`); const port = (entry: DependencyPort): { type: string; spec: unknown } => { const requirement = entry.requirement; switch (requirement.kind) { @@ -69,36 +100,76 @@ export const generateTypeScriptBindings = ( case "edge": { const methods = requirement.primitives.map((primitive): [string, string] => { if (primitive === "resolve") return [primitive, `() => Promise>`]; - if (primitive === "connect" || primitive === "disconnect") return [primitive, `(target: ${ref(requirement.target)}) => Promise`]; + if (primitive === "connect" || primitive === "disconnect") + return [primitive, `(target: ${ref(requirement.target)}) => Promise`]; throw new Error(`Edge primitive ${primitive} is not supported by the TypeScript runtime binding yet`); }); - if (requirement.primitives.includes("resolve")) methods.push(["collection", `() => Promise>`]); - if (["resolve", "connect", "disconnect"].every((primitive) => requirement.primitives.includes(primitive as "resolve"))) methods.push(["replace", `(entries: RelationshipEntry<${ref(requirement.target)}>[], expectedRevision: bigint) => Promise>`]); + if (requirement.primitives.includes("resolve")) + methods.push(["collection", `() => Promise>`]); + if ( + ["resolve", "connect", "disconnect"].every((primitive) => + requirement.primitives.includes(primitive as "resolve"), + ) + ) + methods.push([ + "replace", + `(entries: RelationshipEntry<${ref(requirement.target)}>[], expectedRevision: bigint) => Promise>`, + ]); return { type: object(methods), spec: { kind: "edge", id: entry.id, primitives: requirement.primitives } }; } case "interface": { - const contract = schema.interfaces.find((candidate) => candidate.revisionId === requirement.interfaceRevisionId); + const contract = schema.interfaces.find( + (candidate) => candidate.revisionId === requirement.interfaceRevisionId, + ); if (!contract) throw new Error(`Missing imported interface contract ${requirement.interfaceRevisionId}`); // Streaming ports need a future streaming ABI; ordinary calls are fully typed today. - const operations = contract.members.flatMap((member) => member.operations.filter((operation) => operation.mode === "call") - .map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` }))); - return { type: object([ - ["objectId", ref({ kind: "interface", interfaceRevisionId: requirement.interfaceRevisionId })], - ["live", object(operations.map(operation => [operation.name, `(${params(operation.inputType)}) => Promise`]))], - ...operations.map((operation): [string, string] => [operation.name, `(${params(operation.inputType)}) => Promise<${type(operation.outputType)}>`]), - ]), - spec: { kind: "interface", id: entry.id, operations: Object.fromEntries(operations.map(({name, id, inputType, outputType}) => - [name, {id, inputType, outputType}])) } }; + const operations = contract.members.flatMap((member) => + member.operations + .filter((operation) => operation.mode === "call") + .map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` })), + ); + return { + type: object([ + ["objectId", ref({ kind: "interface", interfaceRevisionId: requirement.interfaceRevisionId })], + [ + "live", + object( + operations.map((operation) => [ + operation.name, + `(${params(operation.inputType)}) => Promise`, + ]), + ), + ], + ...operations.map((operation): [string, string] => [ + operation.name, + `(${params(operation.inputType)}) => Promise<${type(operation.outputType)}>`, + ]), + ]), + spec: { + kind: "interface", + id: entry.id, + operations: Object.fromEntries( + operations.map(({ name, id, inputType, outputType }) => [name, { id, inputType, outputType }]), + ), + }, + }; } case "constructor": { const input = requirement.inputType; - if (!input) throw new Error(`Constructor port ${entry.id} needs an explicit input contract: add 'input TYPE' after ${requirement.atomId} in QX`); - return { type: object([["construct", `(${params(input)}) => Promise<${ref({ kind: "atom", atomId: requirement.atomId })}>`]]), - spec: { kind: "constructor", id: entry.id, inputType: input } }; + if (!input) + throw new Error( + `Constructor port ${entry.id} needs an explicit input contract: add 'input TYPE' after ${requirement.atomId} in QX`, + ); + return { + type: object([ + ["construct", `(${params(input)}) => Promise<${ref({ kind: "atom", atomId: requirement.atomId })}>`], + ]), + spec: { kind: "constructor", id: entry.id, inputType: input }, + }; } } }; - const exports = [...pkg.exports].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0); + const exports = [...pkg.exports].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); const names = new Set(); const specs: Record = {}; const contexts: [string, string][] = []; @@ -107,38 +178,75 @@ export const generateTypeScriptBindings = ( if (names.has(entry.displayName)) throw new Error(`Duplicate export name ${entry.displayName}`); names.add(entry.displayName); const ports = entry.dependencyPorts.map((dependency) => ({ name: dependency.displayName, ...port(dependency) })); - if (new Set(ports.map((p) => p.name)).size !== ports.length) throw new Error(`Duplicate dependency name in ${entry.displayName}`); - const receiver = entry.kind === "constructor" ? ref({ kind: "atom", atomId: entry.constructsAtom }) : - entry.kind === "operation" && entry.receiverRequirement.kind === "exact-atom" ? - ref({ kind: "atom", atomId: entry.receiverRequirement.atomId }) : - entry.kind === "operation" && entry.receiverRequirement.kind === "all-interfaces" ? - `QxObjectRef<${entry.receiverRequirement.interfaceRevisionIds.map((id) => q(`interface:${id}`)).join(" | ") || "never"}>` : "QxObjectRef"; - const contextShape = object([["objectId", receiver], ["input", type(entry.inputType)], - ["ports", object(ports.map((port) => [port.name, port.type]))]]); - contexts.push([entry.displayName, `${contextShape} & QxContextLifecycle<${contextShape} & {signal?: AbortSignal}>`]); + if (new Set(ports.map((p) => p.name)).size !== ports.length) + throw new Error(`Duplicate dependency name in ${entry.displayName}`); + const receiver = + entry.kind === "constructor" + ? ref({ kind: "atom", atomId: entry.constructsAtom }) + : entry.kind === "operation" && entry.receiverRequirement.kind === "exact-atom" + ? ref({ kind: "atom", atomId: entry.receiverRequirement.atomId }) + : entry.kind === "operation" && entry.receiverRequirement.kind === "all-interfaces" + ? `QxObjectRef<${entry.receiverRequirement.interfaceRevisionIds.map((id) => q(`interface:${id}`)).join(" | ") || "never"}>` + : "QxObjectRef"; + const contextShape = object([ + ["objectId", receiver], + ["input", type(entry.inputType)], + ["ports", object(ports.map((port) => [port.name, port.type]))], + ]); + contexts.push([ + entry.displayName, + `${contextShape} & QxContextLifecycle<${contextShape} & {signal?: AbortSignal}>`, + ]); const event = entry.kind === "operation" ? entry.eventType : undefined; const contextType = `Contexts[${q(entry.displayName)}]`; const outputType = type(event ?? entry.outputType); // Watch-start handlers produce events through the runtime's derived stream protocol. - handlers.push([entry.displayName, event ? `QxDerived<${contextType}, ${outputType}>` : - `QxHandler<${contextType}, ${outputType}>${entry.kind === "operation" && entry.mode === "call" ? ` | QxDerived<${contextType}, ${outputType}>` : ""}`]); - specs[entry.displayName] = { inputType: entry.inputType, outputType: entry.outputType, - ...(event ? { eventType: event } : {}), ports: Object.fromEntries(ports.map((port) => [port.name, port.spec])) }; + handlers.push([ + entry.displayName, + event + ? `QxDerived<${contextType}, ${outputType}>` + : `QxHandler<${contextType}, ${outputType}>${entry.kind === "operation" && entry.mode === "call" ? ` | QxDerived<${contextType}, ${outputType}>` : ""}`, + ]); + specs[entry.displayName] = { + inputType: entry.inputType, + outputType: entry.outputType, + ...(event ? { eventType: event } : {}), + ports: Object.fromEntries(ports.map((port) => [port.name, port.spec])), + }; } const imports = [...messages].map(([id, alias]) => { const binding = options.messages![id]!; - if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(binding.export)) throw new Error(`Invalid message binding export ${binding.export}`); + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(binding.export)) + throw new Error(`Invalid message binding export ${binding.export}`); return `import { ${binding.export} as ${alias} } from ${q(binding.module)};`; }); const signatures = `${object(contexts)} ${object(handlers)}`; - const typeImports = ["BindingValue", "QxObjectRef", "QxWatchHandle", "QxHandler", "QxDerived", "QxContextLifecycle", "QxLiveValue", "RelationshipCollection", "RelationshipEntry"].filter((name) => new RegExp(`\\b${name}\\b`).test(signatures)); - return `// Generated by quixos-codegen-ts. Do not edit. Binding ABI version 1.\n` + + const typeImports = [ + "BindingValue", + "QxObjectRef", + "QxWatchHandle", + "QxHandler", + "QxDerived", + "QxContextLifecycle", + "QxLiveValue", + "RelationshipCollection", + "RelationshipEntry", + ].filter((name) => new RegExp(`\\b${name}\\b`).test(signatures)); + return ( + `// Generated by quixos-codegen-ts. Do not edit. Binding ABI version 1.\n` + `import { ${exports.length ? "bindQxHandler, " : ""}${[...typeImports, "QxHandlerSpec", "QxMessages"].map((name) => `type ${name}`).join(", ")} } from ${q(options.runtimeModule ?? "@quixos/camino-package-runtime")};\n` + - imports.join("\n") + `\nexport const packageRevisionId = ${q(pkg.revisionId)};\n` + + imports.join("\n") + + `\nexport const packageRevisionId = ${q(pkg.revisionId)};\n` + `export type Contexts = ${object(contexts)};\nexport type Implementation = ${object(handlers)};\n` + `const messages = { ${[...messages].map(([id, alias]) => `${q(id)}: ${alias}`).join(", ")} } satisfies QxMessages;\n` + `const specs = ${JSON.stringify(specs, null, 2)} satisfies Record;\n` + `export const createRuntime = (implementation: Implementation) => ({\n packageRevisionId,\n exports: {\n` + - exports.map((entry) => ` ${q(entry.id)}: bindQxHandler(specs[${q(entry.displayName)}], implementation[${q(entry.displayName)}], messages),`).join("\n") + - `\n },\n});\n`; + exports + .map( + (entry) => + ` ${q(entry.id)}: bindQxHandler(specs[${q(entry.displayName)}], implementation[${q(entry.displayName)}], messages),`, + ) + .join("\n") + + `\n },\n});\n` + ); }; diff --git a/src/capability-language/assembly.ts b/src/capability-language/assembly.ts index ecaf2e3..8a8fd91 100644 --- a/src/capability-language/assembly.ts +++ b/src/capability-language/assembly.ts @@ -64,11 +64,9 @@ export type CompiledCapabilityResourceRepository = { const sourceKey = (kind: LockedResource["kind"], source: GitSource) => `${kind}\0${source.repository}\0${source.commit.toLowerCase()}`; -const bindingKey = (kind: LockedResource["kind"], binding: string) => - `${kind}\0${binding}`; +const bindingKey = (kind: LockedResource["kind"], binding: string) => `${kind}\0${binding}`; -const importsKey = (entry: CapabilityResourceImport | LockedResource) => - bindingKey(entry.kind, entry.binding); +const importsKey = (entry: CapabilityResourceImport | LockedResource) => bindingKey(entry.kind, entry.binding); const assertImportsMatchLock = ( label: string, @@ -77,22 +75,23 @@ const assertImportsMatchLock = ( ) => { const authored = [...imports].map(importsKey).sort(); const locked = [...resources].map(importsKey).sort(); - if ( - authored.length !== locked.length || - authored.some((entry, index) => entry !== locked[index]) - ) { + if (authored.length !== locked.length || authored.some((entry, index) => entry !== locked[index])) { throw new Error( `${label} imports do not match quixos.lock:\n` + - `authored: ${authored.join(", ") || "none"}\n` + - `locked: ${locked.join(", ") || "none"}`, + `authored: ${authored.join(", ") || "none"}\n` + + `locked: ${locked.join(", ") || "none"}`, ); } }; -const exactRevisions = (revisions: readonly Revision[]) => { +const exactRevisions = < + Revision extends { + revisionId: string; + source: SourceRevision; + }, +>( + revisions: readonly Revision[], +) => { const seen = new Set(); return revisions.filter((revision) => { const key = `${revision.revisionId}\0${revision.source.repository}\0${revision.source.commit}`; @@ -122,19 +121,20 @@ const diagnosticsMessage = ( message: string; path?: string; }[], -) => `${label} did not compile:\n${diagnostics.map((entry) => { - const location = entry.line > 0 - ? `${entry.fileName}:${entry.line}:${entry.column + 1}` - : `${entry.fileName}${entry.path ? `:${entry.path}` : ""}`; - return `${location}: ${entry.phase} ${entry.code}: ${entry.message}`; -}).join("\n")}`; +) => + `${label} did not compile:\n${diagnostics + .map((entry) => { + const location = + entry.line > 0 + ? `${entry.fileName}:${entry.line}:${entry.column + 1}` + : `${entry.fileName}${entry.path ? `:${entry.path}` : ""}`; + return `${location}: ${entry.phase} ${entry.code}: ${entry.message}`; + }) + .join("\n")}`; -const revisionFor = (node: ResolvedCapabilityResource) => - node.resource.revision; +const revisionFor = (node: ResolvedCapabilityResource) => node.resource.revision; -const resourceClosure = ( - roots: readonly ResolvedCapabilityResource[], -): ResolvedCapabilityResource[] => { +const resourceClosure = (roots: readonly ResolvedCapabilityResource[]): ResolvedCapabilityResource[] => { const result: ResolvedCapabilityResource[] = []; const seen = new Set(); const visit = (node: ResolvedCapabilityResource) => { @@ -151,25 +151,26 @@ const environmentFor = ( direct: readonly [LockedResource, ResolvedCapabilityResource][], closure: readonly ResolvedCapabilityResource[], ): CapabilityImportEnvironment => ({ - interfaces: new Map(direct.flatMap(([locked, node]) => - node.resource.kind === "interface" - ? [[locked.binding, node.resource.revision] as const] - : [])), - packages: new Map(direct.flatMap(([locked, node]) => - node.resource.kind === "package" - ? [[locked.binding, node.resource.revision] as const] - : [])), - interfaceClosure: exactRevisions(closure.flatMap((node) => - node.resource.kind === "interface" ? [node.resource.revision] : [])), - packageClosure: exactRevisions(closure.flatMap((node) => - node.resource.kind === "package" ? [node.resource.revision] : [])), + interfaces: new Map( + direct.flatMap(([locked, node]) => + node.resource.kind === "interface" ? [[locked.binding, node.resource.revision] as const] : [], + ), + ), + packages: new Map( + direct.flatMap(([locked, node]) => + node.resource.kind === "package" ? [[locked.binding, node.resource.revision] as const] : [], + ), + ), + interfaceClosure: exactRevisions( + closure.flatMap((node) => (node.resource.kind === "interface" ? [node.resource.revision] : [])), + ), + packageClosure: exactRevisions( + closure.flatMap((node) => (node.resource.kind === "package" ? [node.resource.revision] : [])), + ), externalAtoms: exactAtoms(closure.flatMap((node) => node.resource.externalAtoms)), }); -const createResourceGraphResolver = ( - quixosCommit: string, - resolveResource: CapabilityRepositoryResolver, -) => { +const createResourceGraphResolver = (quixosCommit: string, resolveResource: CapabilityRepositoryResolver) => { const resolved = new Map>(); const active: string[] = []; @@ -188,7 +189,7 @@ const createResourceGraphResolver = ( const pending = (async () => { active.push(key); try { - const snapshot = suppliedSnapshot ?? await resolveResource(locked.source, locked.kind); + const snapshot = suppliedSnapshot ?? (await resolveResource(locked.source, locked.kind)); const lockResult = await loadQuixosLock(path.join(snapshot.directory, "quixos.lock")); if (!lockResult.ok) { throw new Error(diagnosticsMessage(`${locked.kind} ${locked.binding} lock`, lockResult.diagnostics)); @@ -196,22 +197,20 @@ const createResourceGraphResolver = ( if (lockResult.lock.quixos.policy) { throw new Error( `${locked.kind} ${locked.binding} lock declares workspace Quixos policy ` + - `${lockResult.lock.quixos.policy}; resource locks may only declare their exact authored-against commit`, + `${lockResult.lock.quixos.policy}; resource locks may only declare their exact authored-against commit`, ); } if (lockResult.lock.quixos.commit.toLowerCase() !== quixosCommit.toLowerCase()) { throw new Error( `${locked.kind} ${locked.binding} selects Quixos ${lockResult.lock.quixos.commit}, ` + - `but the repository graph selects ${quixosCommit}`, + `but the repository graph selects ${quixosCommit}`, ); } const directPairs: Array<[LockedResource, ResolvedCapabilityResource]> = []; for (const dependency of lockResult.lock.resources) { directPairs.push([dependency, await visit(dependency)]); } - const dependencyClosure = resourceClosure( - directPairs.map(([, node]) => node), - ); + const dependencyClosure = resourceClosure(directPairs.map(([, node]) => node)); const environment = environmentFor(directPairs, dependencyClosure); const manifestName = locked.kind === "interface" ? "interface.qx" : "package.qx"; const manifestPath = path.join(snapshot.directory, manifestName); @@ -225,22 +224,28 @@ const createResourceGraphResolver = ( throw new Error(diagnosticsMessage(`${locked.kind} ${locked.binding}`, compiled.diagnostics)); } if (compiled.resource.kind !== locked.kind) { - throw new Error( - `${manifestPath} declares ${compiled.resource.kind}, not ${locked.kind}`, - ); + throw new Error(`${manifestPath} declares ${compiled.resource.kind}, not ${locked.kind}`); } assertImportsMatchLock(manifestPath, compiled.resource.imports, lockResult.lock.resources); if (compiled.resource.kind === "package") { let catalogText: string | undefined; - try { catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8"); } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + try { + catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } if (catalogText !== undefined) { - const catalog = validateMigrationCatalog(JSON.parse(catalogText), new Set(compiled.resource.revision.exports.map((entry) => entry.id))); + const catalog = validateMigrationCatalog( + JSON.parse(catalogText), + new Set(compiled.resource.revision.exports.map((entry) => entry.id)), + ); const root = await realpath(snapshot.directory); for (const migration of catalog.migrations) { const implementation = await realpath(path.join(root, migration.implementation.file)); - if (!implementation.startsWith(`${root}${path.sep}`)) throw new Error("Migration implementation escapes its package"); - if (contentDigest(await readFile(implementation, "utf8")) !== migration.implementation.digest) throw new Error(`Migration implementation digest mismatch: ${migration.id}`); + if (!implementation.startsWith(`${root}${path.sep}`)) + throw new Error("Migration implementation escapes its package"); + if (contentDigest(await readFile(implementation, "utf8")) !== migration.implementation.digest) + throw new Error(`Migration implementation digest mismatch: ${migration.id}`); } compiled.resource.revision.migrationCatalog = catalog; } @@ -252,10 +257,9 @@ const createResourceGraphResolver = ( directory: snapshot.directory, lock: lockResult.lock, resource: compiled.resource, - dependencies: new Map(directPairs.map(([dependency, node]) => [ - bindingKey(dependency.kind, dependency.binding), - node, - ])), + dependencies: new Map( + directPairs.map(([dependency, node]) => [bindingKey(dependency.kind, dependency.binding), node]), + ), }; } finally { active.pop(); @@ -281,15 +285,15 @@ export const compileCapabilityResourceRepository = async (options: { if (!lockResult.ok) { throw new Error(diagnosticsMessage("Resource lock", lockResult.diagnostics)); } - const resolver = createResourceGraphResolver( - lockResult.lock.quixos.commit, - options.resolveResource, + const resolver = createResourceGraphResolver(lockResult.lock.quixos.commit, options.resolveResource); + const root = await resolver.visit( + { + kind: options.kind, + binding: "", + source: options.source, + }, + { directory: options.rootDirectory }, ); - const root = await resolver.visit({ - kind: options.kind, - binding: "", - source: options.source, - }, { directory: options.rootDirectory }); return { resource: root.resource, lock: root.lock, @@ -307,9 +311,7 @@ export const compileWorkspaceRepository = async (options: { /** An editor's proposed source snapshot; locks and dependency revisions remain exact. */ readSource?: (name: string) => Promise; }): Promise => { - const rootLockResult = await loadQuixosLock( - path.join(options.rootDirectory, "quixos.lock"), - ); + const rootLockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock")); if (!rootLockResult.ok) { throw new Error(diagnosticsMessage("Workspace lock", rootLockResult.diagnostics)); } @@ -323,17 +325,25 @@ export const compileWorkspaceRepository = async (options: { const nodes = await resolver.nodes(); const environment = environmentFor(directPairs, nodes); const workspacePath = path.join(options.rootDirectory, "workspace.qx"); - const sources = options.readSource ? await resolveQxSources(options.readSource) : await loadQxSources(options.rootDirectory); + const sources = options.readSource + ? await resolveQxSources(options.readSource) + : await loadQxSources(options.rootDirectory); const compiled = compileCapabilitySource(sources.source, workspacePath, environment); if (!compiled.ok) { - throw new Error(diagnosticsMessage("Workspace", compiled.diagnostics.map((diagnostic) => - diagnostic.line > 0 ? { ...diagnostic, ...sources.originalPosition(diagnostic.line, diagnostic.column) } : diagnostic))); + throw new Error( + diagnosticsMessage( + "Workspace", + compiled.diagnostics.map((diagnostic) => + diagnostic.line > 0 + ? { ...diagnostic, ...sources.originalPosition(diagnostic.line, diagnostic.column) } + : diagnostic, + ), + ), + ); } assertImportsMatchLock(workspacePath, compiled.imports, rootLock.resources); const availableInterfaceIds = new Set( - nodes.flatMap((node) => node.resource.kind === "interface" - ? [node.resource.revision.revisionId] - : []), + nodes.flatMap((node) => (node.resource.kind === "interface" ? [node.resource.revision.revisionId] : [])), ); const availableAtomIds = new Set(compiled.workspace.atoms.map((atom) => atom.id)); for (const node of nodes) { @@ -341,7 +351,7 @@ export const compileWorkspaceRepository = async (options: { if (!availableInterfaceIds.has(requirement.revisionId)) { throw new Error( `${node.kind} ${node.resource.revision.displayName} requires external interface ` + - `${requirement.binding} (${requirement.revisionId}), but the workspace resource graph does not provide it`, + `${requirement.binding} (${requirement.revisionId}), but the workspace resource graph does not provide it`, ); } } @@ -349,28 +359,31 @@ export const compileWorkspaceRepository = async (options: { if (!availableAtomIds.has(atom.id)) { throw new Error( `${node.kind} ${node.resource.revision.displayName} requires external atom ` + - `${atom.displayName} (${atom.id}), but the workspace does not define it`, + `${atom.displayName} (${atom.id}), but the workspace does not define it`, ); } } } const workspace: WorkspaceRevision = { ...compiled.workspace, - ...(options.workspaceId - ? { workspaceId: capabilityId.workspace(options.workspaceId) } - : {}), + ...(options.workspaceId ? { workspaceId: capabilityId.workspace(options.workspaceId) } : {}), ...(options.workspaceRevisionId ? { id: capabilityId.workspaceRevision(options.workspaceRevisionId) } - : options.sourceRootCommit ? { id: capabilityId.workspaceRevision(`workspace-revision:${options.workspaceId ?? compiled.workspace.workspaceId}:${options.sourceRootCommit}`) } : {}), - ...(options.sourceRootCommit - ? { sourceRootCommit: options.sourceRootCommit } - : {}), + : options.sourceRootCommit + ? { + id: capabilityId.workspaceRevision( + `workspace-revision:${options.workspaceId ?? compiled.workspace.workspaceId}:${options.sourceRootCommit}`, + ), + } + : {}), + ...(options.sourceRootCommit ? { sourceRootCommit: options.sourceRootCommit } : {}), }; const checked = compileWorkspaceRevision(workspace); if (!checked.ok) { throw new Error( - `Instantiated workspace did not compile:\n${checked.issues.map((entry) => - `${entry.path}: ${entry.message}`).join("\n")}`, + `Instantiated workspace did not compile:\n${checked.issues + .map((entry) => `${entry.path}: ${entry.message}`) + .join("\n")}`, ); } return { @@ -378,9 +391,6 @@ export const compileWorkspaceRepository = async (options: { plan: checked.plan, lock: rootLock, resources: nodes, - directResources: new Map(directPairs.map(([locked, node]) => [ - bindingKey(locked.kind, locked.binding), - node, - ])), + directResources: new Map(directPairs.map(([locked, node]) => [bindingKey(locked.kind, locked.binding), node])), }; }; diff --git a/src/capability-language/authoring-check.ts b/src/capability-language/authoring-check.ts index 023929e..eb0a94a 100644 --- a/src/capability-language/authoring-check.ts +++ b/src/capability-language/authoring-check.ts @@ -1,5 +1,5 @@ import fs from "node:fs/promises"; -import {appendFileSync} from "node:fs"; +import { appendFileSync } from "node:fs"; import path from "node:path"; import { createHash, randomUUID } from "node:crypto"; import { authoringContext } from "./authoring-context.js"; @@ -10,21 +10,45 @@ import { buildImmutableCandidate, checkerIdentity } from "./checked-build.js"; import { planEvolution, type WorkspaceRevision, type EvolutionReview } from "../capability-model/index.js"; export const checkRecordName = (directory: string) => createHash("sha256").update(directory).digest("hex") + ".json"; -export async function checkAuthoring(start: string, output: string, options: { baseline?: string; reviews?: string; contractOnly?: boolean } = {}) { +export async function checkAuthoring( + start: string, + output: string, + options: { baseline?: string; reviews?: string; contractOnly?: boolean } = {}, +) { const started = performance.now(); const timings: Record = {}; const context = await authoringContext(start); const location = await fs.realpath(start); const directory = location === context.workbench ? "root" : path.relative(context.workbench, location); - const resource = context.resources.find(entry => entry.directory === directory); + const resource = context.resources.find((entry) => entry.directory === directory); if (!resource) throw new Error("Run check from a registered repository root or the workbench"); await fs.mkdir(output, { mode: 0o700 }); - const report: { directory: string; checker: string; candidateOnly: true; activationEvidence: false; commit?: string; artifactPath?: string; blockers: string[]; phase: string; output: string; compilation?: "passed"; activationReadiness?: "preserve" | "migration-required" | "blocked"; migrationRequired?: string[] } = { - directory, checker: checkerIdentity(), candidateOnly: true, activationEvidence: false, blockers: [], phase: "convergence", output, + const report: { + directory: string; + checker: string; + candidateOnly: true; + activationEvidence: false; + commit?: string; + artifactPath?: string; + blockers: string[]; + phase: string; + output: string; + compilation?: "passed"; + activationReadiness?: "preserve" | "migration-required" | "blocked"; + migrationRequired?: string[]; + } = { + directory, + checker: checkerIdentity(), + candidateOnly: true, + activationEvidence: false, + blockers: [], + phase: "convergence", + output, }; const progress = async (running = true) => { - const file = path.join(output, "report.json"), temp = `${file}.tmp`; - await fs.writeFile(temp, JSON.stringify({...report, timings, running}, null, 2)); + const file = path.join(output, "report.json"), + temp = `${file}.tmp`; + await fs.writeFile(temp, JSON.stringify({ ...report, timings, running }, null, 2)); await fs.rename(temp, file); }; await progress(); @@ -33,31 +57,43 @@ export async function checkAuthoring(start: string, output: string, options: { b // Serialize only source capture, not the potentially slow Nix build. // Repository-scoped agents can check separate immutable candidates in parallel. const capture = promisify(callback)("quixos-qx", ["converge", context.workbench, directory], { - maxBuffer: 4 * 1024 * 1024, env: {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_TRACE_CAPTURE: "1"}, + maxBuffer: 4 * 1024 * 1024, + env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_TRACE_CAPTURE: "1" }, }); console.error(`Capture details: ${path.join(output, "capture.log")}`); - capture.child.stderr?.on("data", chunk => appendFileSync(path.join(output, "capture.log"), chunk)); - const captured = await capture.catch(error => { - if (typeof error.stdout === "string" && error.stdout.trim().startsWith("{")) return {stdout: error.stdout}; + capture.child.stderr?.on("data", (chunk) => appendFileSync(path.join(output, "capture.log"), chunk)); + const captured = await capture.catch((error) => { + if (typeof error.stdout === "string" && error.stdout.trim().startsWith("{")) return { stdout: error.stdout }; throw error; }); const converged = JSON.parse(captured.stdout) as Awaited>; timings.captureMs = Math.round(performance.now() - started); console.error(`[${new Date().toISOString()}] Check: source captured in ${(timings.captureMs / 1000).toFixed(1)}s`); if (!converged.candidate) { - report.phase = converged.worklist.find(entry => entry.phase !== "dependency")?.phase ?? "convergence"; - throw new Error(converged.worklist.map(entry => `${entry.directory} [${entry.phase}]: ${entry.message}`).join("\n")); + report.phase = converged.worklist.find((entry) => entry.phase !== "dependency")?.phase ?? "convergence"; + throw new Error( + converged.worklist.map((entry) => `${entry.directory} [${entry.phase}]: ${entry.message}`).join("\n"), + ); } report.commit = converged.candidate.commit; report.phase = "verification"; await progress(); const buildStarted = performance.now(); - console.error(`[${new Date().toISOString()}] Check: immutable Nix ${options.contractOnly ? "contract" : "verification"}; build output: ${path.join(output, "nix.log")}`); + console.error( + `[${new Date().toISOString()}] Check: immutable Nix ${options.contractOnly ? "contract" : "verification"}; build output: ${path.join(output, "nix.log")}`, + ); try { - report.artifactPath = await buildImmutableCandidate(converged.candidate, resource.kind, path.join(output, "nix.log"), options.contractOnly); + report.artifactPath = await buildImmutableCandidate( + converged.candidate, + resource.kind, + path.join(output, "nix.log"), + options.contractOnly, + ); } finally { timings.immutableCheckMs = Math.round(performance.now() - buildStarted); - console.error(`[${new Date().toISOString()}] Check: immutable phase ended after ${(timings.immutableCheckMs / 1000).toFixed(1)}s`); + console.error( + `[${new Date().toISOString()}] Check: immutable phase ended after ${(timings.immutableCheckMs / 1000).toFixed(1)}s`, + ); } const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8"); await fs.writeFile(path.join(output, "candidate.json"), candidateText); @@ -69,36 +105,62 @@ export async function checkAuthoring(start: string, output: string, options: { b if (!baseline) { try { const host = JSON.parse(await fs.readFile("/etc/quixos/workspace-source.json", "utf8")); - if (await fs.realpath(host.workbenchRoot) === context.workbench) baseline = JSON.parse(await fs.readFile(path.join(host.runtimeClosureRoot, "manifest.json"), "utf8")).workspacePlanPath; - } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if ((await fs.realpath(host.workbenchRoot)) === context.workbench) + baseline = JSON.parse( + await fs.readFile(path.join(host.runtimeClosureRoot, "manifest.json"), "utf8"), + ).workspacePlanPath; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } } - const before = baseline ? JSON.parse(await fs.readFile(baseline, "utf8")) as WorkspaceRevision : null; - const reviews = options.reviews ? JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[] : []; + const before = baseline ? (JSON.parse(await fs.readFile(baseline, "utf8")) as WorkspaceRevision) : null; + const reviews = options.reviews + ? (JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[]) + : []; const evolution = planEvolution(before, JSON.parse(candidateText), { reviews }); await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2)); report.blockers.push(...evolution.blockers); report.migrationRequired = evolution.migrationRequired; - report.activationReadiness = evolution.blockers.length ? "blocked" : evolution.migrationRequired.length ? "migration-required" : "preserve"; - if (evolution.migrationRequired.length) report.blockers.push(`Explicit migration required for: ${evolution.migrationRequired.join(", ")}. Compilation passed; supply a migration path before cutover.`); + report.activationReadiness = evolution.blockers.length + ? "blocked" + : evolution.migrationRequired.length + ? "migration-required" + : "preserve"; + if (evolution.migrationRequired.length) + report.blockers.push( + `Explicit migration required for: ${evolution.migrationRequired.join(", ")}. Compilation passed; supply a migration path before cutover.`, + ); } if (!report.blockers.length) report.phase = options.contractOnly ? "contract-only" : "checked"; - } catch (error) { report.blockers.push(String(error instanceof Error ? error.message : error)); } + } catch (error) { + report.blockers.push(String(error instanceof Error ? error.message : error)); + } timings.totalMs = Math.round(performance.now() - started); - Object.assign(report, {timings}); + Object.assign(report, { timings }); await progress(false); if (options.contractOnly) return report; const records = path.join(context.workbench, ".quixos/checks"); await fs.mkdir(records, { recursive: true }); const remember = async (value: typeof report) => { - const filename = path.join(records, checkRecordName(value.directory)), temporary = `${filename}.${randomUUID()}.tmp`; + const filename = path.join(records, checkRecordName(value.directory)), + temporary = `${filename}.${randomUUID()}.tmp`; await fs.writeFile(temporary, JSON.stringify(value, null, 2), { flag: "wx", mode: 0o600 }); await fs.rename(temporary, filename); }; if (report.artifactPath) { const graph = JSON.parse(await fs.readFile(path.join(report.artifactPath, "graph.json"), "utf8")); for (const checked of graph.resources) { - const managed = context.resources.find(entry => entry.kind === checked.kind && entry.source?.repository === checked.source.repository); - if (managed && managed.directory !== directory) await remember({...report, directory: managed.directory, commit: checked.source.commit, blockers: [], phase: "checked"}); + const managed = context.resources.find( + (entry) => entry.kind === checked.kind && entry.source?.repository === checked.source.repository, + ); + if (managed && managed.directory !== directory) + await remember({ + ...report, + directory: managed.directory, + commit: checked.source.commit, + blockers: [], + phase: "checked", + }); } } await remember(report); diff --git a/src/capability-language/authoring-context.ts b/src/capability-language/authoring-context.ts index 09d7dfd..f91a318 100644 --- a/src/capability-language/authoring-context.ts +++ b/src/capability-language/authoring-context.ts @@ -14,33 +14,54 @@ export async function authoringContext(start: string) { let workbench = await realpath(start); for (;;) { let text: string | undefined; - try { text = await readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"); } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + try { + text = await readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } if (text !== undefined) { const graph = JSON.parse(text) as { resources: AuthoringResource[] }; if (!Array.isArray(graph.resources)) throw new Error("Managed resource inventory is malformed"); const resources: AuthoringResource[] = [{ kind: "workspace", directory: "root" }]; - const identities = new Set(), directories = new Set(["root"]); + const identities = new Set(), + directories = new Set(["root"]); for (const entry of graph.resources) { // Compiler graphs carry resolved paths; the authoring API presents // stable workbench-relative names and validates containment here. - if (typeof entry.directory === "string" && path.isAbsolute(entry.directory)) entry.directory = path.relative(workbench, entry.directory); - if (!["interface", "package"].includes(entry.kind) || !entry.source || - !/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(entry.directory)) { + if (typeof entry.directory === "string" && path.isAbsolute(entry.directory)) + entry.directory = path.relative(workbench, entry.directory); + if ( + !["interface", "package"].includes(entry.kind) || + !entry.source || + !/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(entry.directory) + ) { throw new Error("Invalid managed resource registration"); } const identity = `${entry.kind}\0${entry.resourceId ?? entry.source.repository}`; if (identities.has(identity) || directories.has(entry.directory)) { throw new Error(`Multiple editable selections for ${entry.resourceId ?? entry.source.repository}`); } - identities.add(identity); directories.add(entry.directory); - resources.push({kind: entry.kind, directory: entry.directory, resourceId: entry.resourceId, source: entry.source}); + identities.add(identity); + directories.add(entry.directory); + resources.push({ + kind: entry.kind, + directory: entry.directory, + resourceId: entry.resourceId, + source: entry.source, + }); } - return { workbench, resources, async baseline() { - const result = await loadQuixosLock(path.join(workbench, "root/quixos.lock")); - if (!result.ok) throw new Error(`Workspace source baseline is invalid: ${result.diagnostics.map(d => d.message).join("; ")}`); - return result.lock.quixos; - } }; + return { + workbench, + resources, + async baseline() { + const result = await loadQuixosLock(path.join(workbench, "root/quixos.lock")); + if (!result.ok) + throw new Error( + `Workspace source baseline is invalid: ${result.diagnostics.map((d) => d.message).join("; ")}`, + ); + return result.lock.quixos; + }, + }; } const parent = path.dirname(workbench); if (parent === workbench) throw new Error("Not in a managed workbench; select one with --workbench DIRECTORY"); diff --git a/src/capability-language/authoring-converge.ts b/src/capability-language/authoring-converge.ts index 200fd32..c410152 100644 --- a/src/capability-language/authoring-converge.ts +++ b/src/capability-language/authoring-converge.ts @@ -5,7 +5,13 @@ import { promisify } from "node:util"; import { randomUUID } from "node:crypto"; import { authoringContext } from "./authoring-context.js"; import { snapshotCommit } from "./checked-build.js"; -import { loadQuixosLock, parseQuixosLockDocument, formatQuixosLockDocument, retentionTagForCommit, type GitSource } from "../resource-lock/index.js"; +import { + loadQuixosLock, + parseQuixosLockDocument, + formatQuixosLockDocument, + retentionTagForCommit, + type GitSource, +} from "../resource-lock/index.js"; const execFile = promisify(callback); const command = async (cwd: string, executable: string, args: string[]) => { @@ -13,15 +19,26 @@ const command = async (cwd: string, executable: string, args: string[]) => { // Do not log arguments: transports may contain credentials. Source identities // remain in the normal checked result, not in this timing channel. const label = `${path.basename(cwd)} ${executable} ${args[0]}`; - try { return (await execFile(executable, args, { - cwd, maxBuffer: 4 * 1024 * 1024, - env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0", GIT_TERMINAL_PROMPT: "0" }, - })).stdout.trim(); } - finally { if (process.env.QUIXOS_TRACE_CAPTURE === "1") console.error(`[${new Date().toISOString()}] Capture: ${label}: ${Math.round(performance.now() - start)}ms`); } + try { + return ( + await execFile(executable, args, { + cwd, + maxBuffer: 4 * 1024 * 1024, + env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0", GIT_TERMINAL_PROMPT: "0" }, + }) + ).stdout.trim(); + } finally { + if (process.env.QUIXOS_TRACE_CAPTURE === "1") + console.error(`[${new Date().toISOString()}] Capture: ${label}: ${Math.round(performance.now() - start)}ms`); + } }; const identity = (kind: string, repository: string) => `${kind}\0${repository}`; -export type AuthoringBlocker = { directory: string; phase: "resolution" | "dependency" | "source" | "publication" | "concurrent-edit"; message: string }; +export type AuthoringBlocker = { + directory: string; + phase: "resolution" | "dependency" | "source" | "publication" | "concurrent-edit"; + message: string; +}; /** Source retention only. Neither successful convergence nor an empty source * worklist grants typechecking, semantic review or activation approval. @@ -37,94 +54,136 @@ export async function convergeAuthoring(start: string, target = "root") { const root = path.join(context.workbench, entry.directory); let repository = entry.source?.repository; try { - if (await realpath(root) !== root) throw new Error(`Managed checkout crosses a symlink: ${entry.directory}`); + if ((await realpath(root)) !== root) throw new Error(`Managed checkout crosses a symlink: ${entry.directory}`); // Transport rewrites must not become committed source identities. const origin = await command(root, "git", ["config", "--get", "remote.origin.url"]); - if (repository && origin !== repository) throw new Error(`Origin differs from registered source for ${entry.directory}`); + if (repository && origin !== repository) + throw new Error(`Origin differs from registered source for ${entry.directory}`); repository ??= origin; } catch (error) { - blockers.push({directory: entry.directory, phase: "source", message: String(error).slice(0, 2000)}); + blockers.push({ directory: entry.directory, phase: "source", message: String(error).slice(0, 2000) }); if (!repository) throw error; // The root has no separate registered source. } const key = identity(entry.kind, repository); if (selected.has(key)) throw new Error(`More than one editable checkout for ${repository}`); selected.set(key, entry.directory); - nodes.set(entry.directory, { ...entry, source: { resolver: "git", repository, commit: entry.source?.commit ?? "" }, dependencies: [] }); + nodes.set(entry.directory, { + ...entry, + source: { resolver: "git", repository, commit: entry.source?.commit ?? "" }, + dependencies: [], + }); } for (const node of nodes.values()) { try { const lock = await loadQuixosLock(path.join(context.workbench, node.directory, "quixos.lock")); - if (!lock.ok) throw new Error(lock.diagnostics.map(d => `${d.fileName}: ${d.message}`).join("\n")); - node.dependencies = [...new Set(lock.lock.resources.flatMap(entry => { - const directory = selected.get(identity(entry.kind, entry.source.repository)); - return directory ? [directory] : []; - }))]; - } catch (error) { blockers.push({ directory: node.directory, phase: "resolution", message: String(error) }); } + if (!lock.ok) throw new Error(lock.diagnostics.map((d) => `${d.fileName}: ${d.message}`).join("\n")); + node.dependencies = [ + ...new Set( + lock.lock.resources.flatMap((entry) => { + const directory = selected.get(identity(entry.kind, entry.source.repository)); + return directory ? [directory] : []; + }), + ), + ]; + } catch (error) { + blockers.push({ directory: node.directory, phase: "resolution", message: String(error) }); + } } - const complete = new Map(), active = new Set(); + const complete = new Map(), + active = new Set(); const visited = new Set(); if (!nodes.has(target)) throw new Error(`Not a registered repository: ${target}`); const visit = async (directory: string): Promise => { visited.add(directory); if (complete.has(directory)) return true; - if (blockers.some(entry => entry.directory === directory)) return false; - if (active.has(directory)) { blockers.push({ directory, phase: "dependency", message: `Source dependency cycle: ${[...active, directory].join(" -> ")}` }); return false; } + if (blockers.some((entry) => entry.directory === directory)) return false; + if (active.has(directory)) { + blockers.push({ + directory, + phase: "dependency", + message: `Source dependency cycle: ${[...active, directory].join(" -> ")}`, + }); + return false; + } active.add(directory); const node = nodes.get(directory)!; - for (const dependency of node.dependencies) if (!await visit(dependency)) { - blockers.push({ directory, phase: "dependency", message: `Waiting for ${dependency}` }); active.delete(directory); return false; - } + for (const dependency of node.dependencies) + if (!(await visit(dependency))) { + blockers.push({ directory, phase: "dependency", message: `Waiting for ${dependency}` }); + active.delete(directory); + return false; + } const root = path.join(context.workbench, directory); let phase: AuthoringBlocker["phase"] = "source"; try { const lock = await loadQuixosLock(path.join(root, "quixos.lock")); if (!lock.ok) throw new Error("Lock changed during convergence; retry after joining writers"); for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) { - const filename = path.join(root, file), before = await readFile(filename, "utf8"); + const filename = path.join(root, file), + before = await readFile(filename, "utf8"); const parsed = parseQuixosLockDocument(before, file); if (!parsed.ok) throw new Error(`Invalid lock ${file}`); let changed = false; for (const dependency of parsed.document.resources) { const target = selected.get(identity(dependency.kind, dependency.source.repository)); const source = target ? complete.get(target) : undefined; - if (target && !source) throw new Error(`Dependencies changed during convergence (${dependency.binding}); join writers and retry`); - if (source && source.commit !== dependency.source.commit) { dependency.source = source; changed = true; } + if (target && !source) + throw new Error(`Dependencies changed during convergence (${dependency.binding}); join writers and retry`); + if (source && source.commit !== dependency.source.commit) { + dependency.source = source; + changed = true; + } } if (changed) { const temporary = `${filename}.${randomUUID()}.tmp`; try { await writeFile(temporary, formatQuixosLockDocument(parsed.document), { flag: "wx" }); - if (await readFile(filename, "utf8") !== before) throw new Error(`Concurrent edit to ${file}; retry after joining writers`); + if ((await readFile(filename, "utf8")) !== before) + throw new Error(`Concurrent edit to ${file}; retry after joining writers`); await rename(temporary, filename); - } finally { await rm(temporary, { force: true }); } + } finally { + await rm(temporary, { force: true }); + } } } const snapshotStarted = performance.now(); const commit = await snapshotCommit(root); - if (process.env.QUIXOS_TRACE_CAPTURE === "1") console.error(`[${new Date().toISOString()}] Capture: ${directory} snapshot: ${Math.round(performance.now() - snapshotStarted)}ms`); + if (process.env.QUIXOS_TRACE_CAPTURE === "1") + console.error( + `[${new Date().toISOString()}] Capture: ${directory} snapshot: ${Math.round(performance.now() - snapshotStarted)}ms`, + ); phase = "publication"; const ref = retentionTagForCommit(commit); const remote = await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref]); if (remote && remote.split(/\s+/)[0] !== commit) throw new Error(`Conflicting immutable retention ref ${ref}`); if (!remote) await command(root, "git", ["push", node.source.repository, `${commit}:${ref}`]); - if ((await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref])).split(/\s+/)[0] !== commit) throw new Error("Published source retention was not observed"); + if ((await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref])).split(/\s+/)[0] !== commit) + throw new Error("Published source retention was not observed"); complete.set(directory, { ...node.source, commit }); - } catch (error) { blockers.push({ directory, phase, message: String(error).slice(0, 4000) }); } + } catch (error) { + blockers.push({ directory, phase, message: String(error).slice(0, 4000) }); + } active.delete(directory); return complete.has(directory); }; // Include newly created, not-yet-imported resources, then the root. - if (target === "root") for (const directory of [...nodes.keys()].filter(d => d !== "root")) await visit(directory); + if (target === "root") for (const directory of [...nodes.keys()].filter((d) => d !== "root")) await visit(directory); await visit(target); - for (let index = blockers.length - 1; index >= 0; index--) if (!visited.has(blockers[index].directory)) blockers.splice(index, 1); + for (let index = blockers.length - 1; index >= 0; index--) + if (!visited.has(blockers[index].directory)) blockers.splice(index, 1); for (const [directory, source] of complete) { - try { if (await snapshotCommit(path.join(context.workbench, directory)) !== source.commit) throw new Error("Source advanced while converging; join writers and retry"); } - catch (error) { blockers.push({ directory, phase: "concurrent-edit", message: String(error) }); } + try { + if ((await snapshotCommit(path.join(context.workbench, directory))) !== source.commit) + throw new Error("Source advanced while converging; join writers and retry"); + } catch (error) { + blockers.push({ directory, phase: "concurrent-edit", message: String(error) }); + } } // Persist successful selections even if another repository is still broken. // Recovery must not depend on all parents succeeding in the same invocation. const graph = JSON.parse(graphBefore); - for (const resource of graph.resources) resource.directory = path.relative(context.workbench, path.resolve(context.workbench, resource.directory)); + for (const resource of graph.resources) + resource.directory = path.relative(context.workbench, path.resolve(context.workbench, resource.directory)); const replacements = new Map(); for (const resource of graph.resources) { const source = complete.get(resource.directory); @@ -132,30 +191,36 @@ export async function convergeAuthoring(start: string, target = "root") { const key = `${resource.kind}\0${source.repository}\0${source.commit}`; replacements.set(resource.key, key); if (resource.source.commit !== source.commit) delete resource.revisionId; - resource.source = source; resource.key = key; + resource.source = source; + resource.key = key; } - for (const resource of graph.resources) for (const dependency of resource.dependencies ?? []) { - dependency.resourceKey = replacements.get(dependency.resourceKey) ?? dependency.resourceKey; - } - for (const direct of graph.directResources ?? []) direct.resourceKey = replacements.get(direct.resourceKey) ?? direct.resourceKey; + for (const resource of graph.resources) + for (const dependency of resource.dependencies ?? []) { + dependency.resourceKey = replacements.get(dependency.resourceKey) ?? dependency.resourceKey; + } + for (const direct of graph.directResources ?? []) + direct.resourceKey = replacements.get(direct.resourceKey) ?? direct.resourceKey; // Inventory is a projection of actual locks, including newly added/removed // imports. Never require a successful parent compilation to repair it. for (const [directory] of complete) { const lock = await loadQuixosLock(path.join(context.workbench, directory, "quixos.lock")); if (!lock.ok) continue; - const dependencies = lock.lock.resources.map(dependency => ({ + const dependencies = lock.lock.resources.map((dependency) => ({ binding: `${dependency.kind}\0${dependency.binding}`, resourceKey: `${dependency.kind}\0${dependency.source.repository}\0${dependency.source.commit}`, })); if (directory === "root") { graph.quixos = lock.lock.quixos; graph.directResources = lock.lock.resources.map((dependency, index) => ({ - kind: dependency.kind, binding: dependency.binding, resourceKey: dependencies[index].resourceKey, + kind: dependency.kind, + binding: dependency.binding, + resourceKey: dependencies[index].resourceKey, ...(selected.has(identity(dependency.kind, dependency.source.repository)) - ? {directory: selected.get(identity(dependency.kind, dependency.source.repository))} : {}), + ? { directory: selected.get(identity(dependency.kind, dependency.source.repository)) } + : {}), })); } else { - const resource = graph.resources.find((entry: {directory: string}) => entry.directory === directory); + const resource = graph.resources.find((entry: { directory: string }) => entry.directory === directory); if (resource) resource.dependencies = dependencies; } } @@ -164,12 +229,22 @@ export async function convergeAuthoring(start: string, target = "root") { const temporary = `${graphFile}.${randomUUID()}.tmp`; try { await writeFile(temporary, graphAfter, { flag: "wx", mode: 0o600 }); - if (await readFile(graphFile, "utf8") !== graphBefore) throw new Error("Managed inventory changed during convergence; source is retained, retry after joining writers"); + if ((await readFile(graphFile, "utf8")) !== graphBefore) + throw new Error( + "Managed inventory changed during convergence; source is retained, retry after joining writers", + ); await rename(temporary, graphFile); - } finally { await rm(temporary, { force: true }); } + } finally { + await rm(temporary, { force: true }); + } } - return { workbench: context.workbench, converged: blockers.length === 0, - candidate: blockers.length ? null : complete.get(target) ?? null, + return { + workbench: context.workbench, + converged: blockers.length === 0, + candidate: blockers.length ? null : (complete.get(target) ?? null), retained: [...complete].map(([directory, source]) => ({ directory, source })), - worklist: blockers, verificationEvidence: false, activated: false }; + worklist: blockers, + verificationEvidence: false, + activated: false, + }; } diff --git a/src/capability-language/authoring-inspect.ts b/src/capability-language/authoring-inspect.ts index f0b0c9b..980bfc8 100644 --- a/src/capability-language/authoring-inspect.ts +++ b/src/capability-language/authoring-inspect.ts @@ -5,48 +5,74 @@ import path from "node:path"; import { parseQx, walkSyntax } from "./source.js"; import { authoringContext } from "./authoring-context.js"; import { readQxSource } from "./source-loader.js"; -import {loadQuixosLock} from "../resource-lock/index.js"; +import { loadQuixosLock } from "../resource-lock/index.js"; const execFile = promisify(callback); -const git = async (root: string, args: string[]) => (await execFile("git", ["-C", root, ...args], { - maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, -})).stdout; +const git = async (root: string, args: string[]) => + ( + await execFile("git", ["-C", root, ...args], { + maxBuffer: 8 * 1024 * 1024, + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + }) + ).stdout; const message = (error: unknown) => String(error instanceof Error ? error.message : error).slice(0, 2000); /** Syntax-only contract inspection is deliberately NOT verification evidence. * Each file can recover independently; current valid files always win. */ export async function inspectAuthoringRepository(root: string, historyLimit = 100) { const names = (await git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"])) - .split("\0").filter(name => name.endsWith(".qx")); - if (names.length > 128) throw new Error("Repository inspection exceeds 128 QX files; split the resource into smaller repositories"); + .split("\0") + .filter((name) => name.endsWith(".qx")); + if (names.length > 128) + throw new Error("Repository inspection exceeds 128 QX files; split the resource into smaller repositories"); const files = []; for (const name of [...new Set(names)].sort()) { - let source = "", errors: unknown[] = [], revision: string | null = null; + let source = "", + errors: unknown[] = [], + revision: string | null = null; try { source = await readQxSource(root, name); if (source.length > 262144) throw new Error(`Inspection file exceeds 256 KiB: ${name}`); errors = parseQx(source, name).diagnostics; - } catch (error) { errors = [{ message: message(error) }]; } + } catch (error) { + errors = [{ message: message(error) }]; + } const currentErrors = errors; if (errors.length) { // Git can traverse jj's immutable commit DAG without mutating/snapshotting @. - const head = await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], - { cwd: root, env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1" } }).then(r => r.stdout.trim(), () => "HEAD"); + const head = await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], { + cwd: root, + env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1" }, + }).then( + (r) => r.stdout.trim(), + () => "HEAD", + ); const commits = await git(root, ["rev-list", `--max-count=${historyLimit}`, head, "--", name]).catch(() => ""); for (const commit of commits.trim().split("\n").filter(Boolean)) { const historical = await git(root, ["show", `${commit}:${name}`]).catch(() => null); if (historical === null || historical.length > 262144) continue; if (!parseQx(historical, name).diagnostics.length) { - source = historical; revision = commit; errors = []; break; + source = historical; + revision = commit; + errors = []; + break; } } } const syntax = errors.length ? null : parseQx(source, name); - files.push({ file: name, status: errors.length ? "unavailable" : revision ? "historical" : "current", - revision, currentErrors: currentErrors.slice(0, 20), omittedErrors: Math.max(0, currentErrors.length - 20), - declarations: syntax ? [...walkSyntax(syntax.root)] - .filter(node => /^(?:interface|package|atom|state|edge|method|function|event|conformance)\w*Decl$/.test(node.kind)) - .map(node => ({ kind: node.kind, source: source.slice(node.start, node.end) })) : [], + files.push({ + file: name, + status: errors.length ? "unavailable" : revision ? "historical" : "current", + revision, + currentErrors: currentErrors.slice(0, 20), + omittedErrors: Math.max(0, currentErrors.length - 20), + declarations: syntax + ? [...walkSyntax(syntax.root)] + .filter((node) => + /^(?:interface|package|atom|state|edge|method|function|event|conformance)\w*Decl$/.test(node.kind), + ) + .map((node) => ({ kind: node.kind, source: source.slice(node.start, node.end) })) + : [], }); } return { verificationEvidence: false as const, resolutionChecked: false as const, files }; @@ -56,21 +82,32 @@ export async function inspectWorkbench(start: string, selector?: string) { const context = await authoringContext(start); if (!selector || selector === ".") { const relative = path.relative(context.workbench, await realpath(start)); - selector = context.resources.find(entry => relative === entry.directory || relative.startsWith(entry.directory + path.sep))?.directory ?? "root"; + selector = + context.resources.find((entry) => relative === entry.directory || relative.startsWith(entry.directory + path.sep)) + ?.directory ?? "root"; } const lock = await loadQuixosLock(path.join(context.workbench, "root/quixos.lock")); - const aliases = lock.ok ? lock.lock.resources.filter(entry => entry.binding === selector) : []; - const selected = context.resources.filter(entry => !selector || selector === entry.directory || - selector === entry.resourceId || selector === path.basename(entry.directory) || aliases.some(alias => alias.kind === entry.kind && alias.source.repository === entry.source?.repository)); + const aliases = lock.ok ? lock.lock.resources.filter((entry) => entry.binding === selector) : []; + const selected = context.resources.filter( + (entry) => + !selector || + selector === entry.directory || + selector === entry.resourceId || + selector === path.basename(entry.directory) || + aliases.some((alias) => alias.kind === entry.kind && alias.source.repository === entry.source?.repository), + ); if (!selected.length) throw new Error(`No registered resource matches ${selector}`); - if (selector && selected.length > 1) throw new Error(`Ambiguous resource ${selector}; use its resource ID or directory`); + if (selector && selected.length > 1) + throw new Error(`Ambiguous resource ${selector}; use its resource ID or directory`); const resources = []; for (const entry of selected) { try { const root = path.join(context.workbench, entry.directory); - if (await realpath(root) !== root) throw new Error("Managed checkout crosses a symlink"); - resources.push({ ...entry, ...await inspectAuthoringRepository(root) }); - } catch (error) { resources.push({ ...entry, error: message(error) }); } + if ((await realpath(root)) !== root) throw new Error("Managed checkout crosses a symlink"); + resources.push({ ...entry, ...(await inspectAuthoringRepository(root)) }); + } catch (error) { + resources.push({ ...entry, error: message(error) }); + } } return { workbench: context.workbench, verificationEvidence: false, resources }; } diff --git a/src/capability-language/authoring-worklist.ts b/src/capability-language/authoring-worklist.ts index 3805562..148087e 100644 --- a/src/capability-language/authoring-worklist.ts +++ b/src/capability-language/authoring-worklist.ts @@ -11,50 +11,109 @@ import { checkerIdentity } from "./checked-build.js"; const execFile = promisify(callback); export async function authoringWorklist(start: string) { const context = await authoringContext(start); - const entries: {directory: string; resourceId?: string; phase: string; message: string; next: string}[] = []; + const entries: { directory: string; resourceId?: string; phase: string; message: string; next: string }[] = []; const dependencies = new Map(); for (const resource of context.resources) { const root = path.join(context.workbench, resource.directory); - const add = (phase: string, message: string) => entries.push({directory: resource.directory, resourceId: resource.resourceId, phase, message, - next: phase === "syntax" ? `qx-workspace inspect ${resource.directory}` : phase === "evolution" ? "Inspect evolution.json in the check output; resolve its named migration/review requirements before cutover" : `cd ${resource.directory} && qx-workspace check`}); + const add = (phase: string, message: string) => + entries.push({ + directory: resource.directory, + resourceId: resource.resourceId, + phase, + message, + next: + phase === "syntax" + ? `qx-workspace inspect ${resource.directory}` + : phase === "evolution" + ? "Inspect evolution.json in the check output; resolve its named migration/review requirements before cutover" + : `cd ${resource.directory} && qx-workspace check`, + }); try { - if (await fs.realpath(root) !== root) throw new Error("Registered checkout crosses a symlink"); + if ((await fs.realpath(root)) !== root) throw new Error("Registered checkout crosses a symlink"); const inspected = await inspectAuthoringRepository(root); - for (const file of inspected.files) if (file.currentErrors.length) add("syntax", `${file.file}: ${JSON.stringify(file.currentErrors)}${file.status === "historical" ? `; historical contract available at ${file.revision}` : ""}`); + for (const file of inspected.files) + if (file.currentErrors.length) + add( + "syntax", + `${file.file}: ${JSON.stringify(file.currentErrors)}${file.status === "historical" ? `; historical contract available at ${file.revision}` : ""}`, + ); const lock = await loadQuixosLock(path.join(root, "quixos.lock")); - if (!lock.ok) add("resolution", lock.diagnostics.map(entry => `${entry.fileName}: ${entry.message}`).join("\n")); - else dependencies.set(resource.directory, lock.lock.resources.flatMap(dependency => { - const selected = context.resources.find(entry => entry.kind === dependency.kind && entry.source?.repository === dependency.source.repository); - if (selected?.source && selected.source.commit !== dependency.source.commit) add("propagation", `Dependency ${dependency.binding} has advanced; check will repin it automatically`); - return selected ? [selected.directory] : []; - })); + if (!lock.ok) + add("resolution", lock.diagnostics.map((entry) => `${entry.fileName}: ${entry.message}`).join("\n")); + else + dependencies.set( + resource.directory, + lock.lock.resources.flatMap((dependency) => { + const selected = context.resources.find( + (entry) => entry.kind === dependency.kind && entry.source?.repository === dependency.source.repository, + ); + if (selected?.source && selected.source.commit !== dependency.source.commit) + add("propagation", `Dependency ${dependency.binding} has advanced; check will repin it automatically`); + return selected ? [selected.directory] : []; + }), + ); let record; - try { record = JSON.parse(await fs.readFile(path.join(context.workbench, ".quixos/checks", checkRecordName(resource.directory)), "utf8")); } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + try { + record = JSON.parse( + await fs.readFile( + path.join(context.workbench, ".quixos/checks", checkRecordName(resource.directory)), + "utf8", + ), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } if (!record?.commit) { if (record?.blockers?.length) add(record.phase, record.blockers.join("\n")); else add("unchecked", "No immutable candidate check recorded yet"); continue; } if (record.checker !== checkerIdentity()) add("unchecked", "The installed checker changed since the last check"); - const env = {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"}; - const commit = (await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], {cwd: root, env})).stdout.trim(); - const dirty = await execFile("git", ["diff", "--quiet", "--no-ext-diff", record.commit, "--"], {cwd: root}).then(() => false, () => true); - const untracked = (await execFile("git", ["ls-files", "--others", "--exclude-standard"], {cwd: root})).stdout; - if (commit !== record.commit || dirty || untracked) add("unchecked", `Edits are newer than the last check (${record.commit.slice(0, 12)})`); + const env = { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1" }; + const commit = ( + await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], { + cwd: root, + env, + }) + ).stdout.trim(); + const dirty = await execFile("git", ["diff", "--quiet", "--no-ext-diff", record.commit, "--"], { + cwd: root, + }).then( + () => false, + () => true, + ); + const untracked = (await execFile("git", ["ls-files", "--others", "--exclude-standard"], { cwd: root })).stdout; + if (commit !== record.commit || dirty || untracked) + add("unchecked", `Edits are newer than the last check (${record.commit.slice(0, 12)})`); else if (record.blockers?.length) add(record.phase, record.blockers.join("\n")); - } catch (error) { add("inspection", String(error).slice(0, 3000)); } + } catch (error) { + add("inspection", String(error).slice(0, 3000)); + } } // Fixed-point propagation, independent of registration order. - const blocked = new Set(entries.map(entry => entry.directory)); + const blocked = new Set(entries.map((entry) => entry.directory)); let changed = true; while (changed) { changed = false; - for (const [directory, required] of dependencies) if (!blocked.has(directory)) { - const waiting = required.filter(dependency => blocked.has(dependency)); - if (waiting.length) { blocked.add(directory); changed = true; entries.push({directory, phase: "dependency", message: `Waiting for ${waiting.join(", ")}`, next: "Resolve the named repositories, then rerun check"}); } - } + for (const [directory, required] of dependencies) + if (!blocked.has(directory)) { + const waiting = required.filter((dependency) => blocked.has(dependency)); + if (waiting.length) { + blocked.add(directory); + changed = true; + entries.push({ + directory, + phase: "dependency", + message: `Waiting for ${waiting.join(", ")}`, + next: "Resolve the named repositories, then rerun check", + }); + } + } } - return { workbench: context.workbench, verificationEvidence: false, worklist: entries, - note: "Derived authoring guidance, not activation approval. Independent repositories can be delegated separately; join writers before a root check." }; + return { + workbench: context.workbench, + verificationEvidence: false, + worklist: entries, + note: "Derived authoring guidance, not activation approval. Independent repositories can be delegated separately; join writers before a root check.", + }; } diff --git a/src/capability-language/candidate-check.ts b/src/capability-language/candidate-check.ts index 8c77de8..6ccb5ed 100644 --- a/src/capability-language/candidate-check.ts +++ b/src/capability-language/candidate-check.ts @@ -6,34 +6,51 @@ import { promisify } from "node:util"; import { createHash } from "node:crypto"; import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js"; import { createGitCapabilityResolver } from "./git-resolver.js"; -import { contentDigest, planEvolution, type EvolutionReview, type WorkspaceRevision } from "../capability-model/index.js"; +import { + contentDigest, + planEvolution, + type EvolutionReview, + type WorkspaceRevision, +} from "../capability-model/index.js"; import { bindingSchema } from "../bindings/index.js"; -import {snapshotCommit, checkoutCommit, buildCheckedPackage} from "./checked-build.js"; +import { snapshotCommit, checkoutCommit, buildCheckedPackage } from "./checked-build.js"; const execFile = promisify(execFileCallback); const bytesDigest = (value: Uint8Array) => `sha256:${createHash("sha256").update(value).digest("hex")}`; -export const localResourceSnapshots = async (root: string, filename?: string): Promise<{resources: {kind: string; repository: string; commit: string; directory: string}[]}> => { +export const localResourceSnapshots = async ( + root: string, + filename?: string, +): Promise<{ resources: { kind: string; repository: string; commit: string; directory: string }[] }> => { if (filename) { const document = JSON.parse(await fs.readFile(filename, "utf8")); - return {resources: document.resources.map((entry: {directory: string}) => ({...entry, directory: path.resolve(path.dirname(filename), entry.directory)}))}; + return { + resources: document.resources.map((entry: { directory: string }) => ({ + ...entry, + directory: path.resolve(path.dirname(filename), entry.directory), + })), + }; } let directory = await fs.realpath(root); for (;;) { let graphText: string | undefined; - try {graphText = await fs.readFile(path.join(directory, ".quixos/resource-graph.json"), "utf8");} - catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} + try { + graphText = await fs.readFile(path.join(directory, ".quixos/resource-graph.json"), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } if (graphText !== undefined) { const graph = JSON.parse(graphText); const resources = []; for (const entry of graph.resources) { const location = await fs.realpath(path.resolve(directory, entry.directory)); - if (!location.startsWith(`${directory}/resources/`)) throw new Error("Workbench resource escapes managed directory"); - resources.push({kind: entry.kind, ...entry.source, directory: location}); + if (!location.startsWith(`${directory}/resources/`)) + throw new Error("Workbench resource escapes managed directory"); + resources.push({ kind: entry.kind, ...entry.source, directory: location }); } - return {resources}; + return { resources }; } const parent = path.dirname(directory); - if (parent === directory) return {resources: []}; + if (parent === directory) return { resources: [] }; directory = parent; } }; @@ -41,18 +58,33 @@ export const localResourceSnapshots = async (root: string, filename?: string): P /** Copy actual authoring files without snapshotting jj or creating a Git commit. */ export const snapshotRepository = async (source: string, destination: string) => { const root = await fs.realpath(source); - const files = async () => (await execFile("git", ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], { maxBuffer: 16 * 1024 * 1024 })).stdout.split("\0").filter(Boolean).sort(); + const files = async () => + ( + await execFile("git", ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], { + maxBuffer: 16 * 1024 * 1024, + }) + ).stdout + .split("\0") + .filter(Boolean) + .sort(); const names = [...new Set(await files())]; if (names.length > 50_000) throw new Error("Candidate source exceeds 50000 files"); - const contents: {name: string; digest: string; mode: number}[] = []; + const contents: { name: string; digest: string; mode: number }[] = []; let bytes = 0; await fs.mkdir(destination, { recursive: true, mode: 0o700 }); for (const name of names) { - if (path.isAbsolute(name) || name.split(/[\\/]/).some((part) => part === ".." || part === ".git" || part === ".jj")) throw new Error("Invalid candidate source path"); + if (path.isAbsolute(name) || name.split(/[\\/]/).some((part) => part === ".." || part === ".git" || part === ".jj")) + throw new Error("Invalid candidate source path"); const file = path.join(root, name); let metadata; - try { metadata = await fs.lstat(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; } - if (!metadata.isFile() || metadata.isSymbolicLink() || !(await fs.realpath(file)).startsWith(`${root}${path.sep}`)) throw new Error(`Candidate source must be a regular file: ${name}`); + try { + metadata = await fs.lstat(file); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + if (!metadata.isFile() || metadata.isSymbolicLink() || !(await fs.realpath(file)).startsWith(`${root}${path.sep}`)) + throw new Error(`Candidate source must be a regular file: ${name}`); const data = await fs.readFile(file); bytes += data.length; if (bytes > 128 * 1024 * 1024) throw new Error("Candidate source exceeds 128 MiB"); @@ -60,28 +92,38 @@ export const snapshotRepository = async (source: string, destination: string) => await fs.mkdir(path.dirname(path.join(destination, name)), { recursive: true }); await fs.writeFile(path.join(destination, name), data, { flag: "wx", mode: metadata.mode & 0o777 }); } - if (JSON.stringify([...new Set(await files())]) !== JSON.stringify(names)) throw new Error("Source files changed during candidate snapshot"); - for (const entry of contents) if (bytesDigest(await fs.readFile(path.join(root, entry.name))) !== entry.digest) throw new Error(`Source changed during candidate snapshot: ${entry.name}`); + if (JSON.stringify([...new Set(await files())]) !== JSON.stringify(names)) + throw new Error("Source files changed during candidate snapshot"); + for (const entry of contents) + if (bytesDigest(await fs.readFile(path.join(root, entry.name))) !== entry.digest) + throw new Error(`Source changed during candidate snapshot: ${entry.name}`); return { source: root, directory: destination, treeDigest: contentDigest(contents), files: contents }; }; // Local mirrors accelerate resolution, but only their committed locked trees // may stand in for published dependencies. Never relabel dirty files as a pin. async function committedResolver(root: string, temporary: string, filename?: string, publishedOnly = false) { - const map = publishedOnly ? {resources: []} : await localResourceSnapshots(root, filename); + const map = publishedOnly ? { resources: [] } : await localResourceSnapshots(root, filename); const resources = []; for (const [index, entry] of map.resources.entries()) { const directory = path.join(temporary, `dependency-${index}`); await checkoutCommit(entry.directory, entry.commit, directory); - resources.push({...entry, directory}); + resources.push({ ...entry, directory }); } const snapshotMap = path.join(temporary, "snapshots.json"); - await fs.writeFile(snapshotMap, JSON.stringify({resources})); - return createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resolved"), snapshotMap}); + await fs.writeFile(snapshotMap, JSON.stringify({ resources })); + return createGitCapabilityResolver({ checkoutRoot: path.join(temporary, "resolved"), snapshotMap }); } -export const checkResourceCandidate = async (options: {root: string; output: string; kind: "package" | "interface"; source: {repository: string; commit: string}; snapshotMap?: string; publishedOnly?: boolean}) => { - await fs.mkdir(options.output, {mode: 0o700}); +export const checkResourceCandidate = async (options: { + root: string; + output: string; + kind: "package" | "interface"; + source: { repository: string; commit: string }; + snapshotMap?: string; + publishedOnly?: boolean; +}) => { + await fs.mkdir(options.output, { mode: 0o700 }); const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-resource-check-")); const blockers: string[] = []; let treeDigest: string | undefined, commit: string | undefined, artifactPath: string | undefined; @@ -90,62 +132,128 @@ export const checkResourceCandidate = async (options: {root: string; output: str treeDigest = (await snapshotRepository(options.root, path.join(temporary, "observed"))).treeDigest; const root = path.join(temporary, "source"); await checkoutCommit(options.root, commit, root); - const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap, options.publishedOnly); - const compiled = await compileCapabilityResourceRepository({rootDirectory: root, kind: options.kind, source: {resolver: "git", repository: options.source.repository, commit}, resolveResource}); + const resolveResource = await committedResolver( + options.root, + temporary, + options.snapshotMap, + options.publishedOnly, + ); + const compiled = await compileCapabilityResourceRepository({ + rootDirectory: root, + kind: options.kind, + source: { resolver: "git", repository: options.source.repository, commit }, + resolveResource, + }); if (compiled.resource.kind === "package") { const schema = path.join(temporary, "bindings.json"); await fs.writeFile(schema, JSON.stringify(bindingSchema(compiled))); artifactPath = await buildCheckedPackage(root, schema, compiled.resource.revision.revisionId); } - if (await snapshotCommit(options.root) !== commit) throw new Error("Source changed during verification; run the check again"); + if ((await snapshotCommit(options.root)) !== commit) + throw new Error("Source changed during verification; run the check again"); await fs.writeFile(path.join(options.output, "candidate.json"), JSON.stringify(compiled.resource, null, 2)); } catch (error) { blockers.push(error instanceof Error ? error.message : String(error)); - } finally {await fs.rm(temporary, {recursive: true, force: true});} - const result = {candidateOnly: true, activationEvidence: false, commit, treeDigest, artifactPath, blockers, - note: "Checked immutable candidate; cutover independently checks current migration/review requirements. No publication or activation performed."}; + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } + const result = { + candidateOnly: true, + activationEvidence: false, + commit, + treeDigest, + artifactPath, + blockers, + note: "Checked immutable candidate; cutover independently checks current migration/review requirements. No publication or activation performed.", + }; await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2)); return result; }; -export const checkWorkspaceCandidate = async (options: {root: string; output: string; snapshotMap?: string; baseline?: string; reviews?: string}) => { - await fs.mkdir(options.output, {mode: 0o700}); +export const checkWorkspaceCandidate = async (options: { + root: string; + output: string; + snapshotMap?: string; + baseline?: string; + reviews?: string; +}) => { + await fs.mkdir(options.output, { mode: 0o700 }); const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-workspace-check-")); - const blockers: string[] = [], checks: {packageRevisionId: string; artifactPath: string}[] = []; + const blockers: string[] = [], + checks: { packageRevisionId: string; artifactPath: string }[] = []; let commit: string | undefined; try { commit = await snapshotCommit(options.root); for (const entry of (await localResourceSnapshots(options.root, options.snapshotMap)).resources) { const current = await snapshotCommit(entry.directory); - const tree = async (revision: string) => (await execFile("git", ["rev-parse", `${revision}^{tree}`], {cwd: entry.directory})).stdout.trim(); - if (await tree(current) !== await tree(entry.commit)) throw new Error(`Edited resource is not in the root's locked candidate: ${entry.directory}. Check that resource, then run qx-workspace resource upgrade --publish to propagate its revision.`); + const tree = async (revision: string) => + (await execFile("git", ["rev-parse", `${revision}^{tree}`], { cwd: entry.directory })).stdout.trim(); + if ((await tree(current)) !== (await tree(entry.commit))) + throw new Error( + `Edited resource is not in the root's locked candidate: ${entry.directory}. Check that resource, then run qx-workspace resource upgrade --publish to propagate its revision.`, + ); } const root = path.join(temporary, "source"); await checkoutCommit(options.root, commit, root); const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap); - const compiled = await compileWorkspaceRepository({rootDirectory: root, sourceRootCommit: commit, resolveResource}); - const baseline = options.baseline ? JSON.parse(await fs.readFile(options.baseline, "utf8")) as WorkspaceRevision : null; - const reviews = options.reviews ? JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[] : []; - const evolution = planEvolution(baseline, compiled.workspace, {reviews}); + const compiled = await compileWorkspaceRepository({ + rootDirectory: root, + sourceRootCommit: commit, + resolveResource, + }); + const baseline = options.baseline + ? (JSON.parse(await fs.readFile(options.baseline, "utf8")) as WorkspaceRevision) + : null; + const reviews = options.reviews + ? (JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[]) + : []; + const evolution = planEvolution(baseline, compiled.workspace, { reviews }); blockers.push(...evolution.blockers); - for (const resource of compiled.resources.filter(entry => entry.kind === "package")) { + for (const resource of compiled.resources.filter((entry) => entry.kind === "package")) { // Per-package recursive schema, identical to host activation, not unrelated // workspace declarations that would unnecessarily invalidate build caches. - const candidate = await compileCapabilityResourceRepository({rootDirectory: resource.directory, kind: "package", source: resource.source, resolveResource}); + const candidate = await compileCapabilityResourceRepository({ + rootDirectory: resource.directory, + kind: "package", + source: resource.source, + resolveResource, + }); const schema = path.join(temporary, "bindings.json"); await fs.writeFile(schema, JSON.stringify(bindingSchema(candidate))); - const artifactPath = await buildCheckedPackage(resource.directory, schema, candidate.resource.revision.revisionId); - checks.push({packageRevisionId: candidate.resource.revision.revisionId, artifactPath}); + const artifactPath = await buildCheckedPackage( + resource.directory, + schema, + candidate.resource.revision.revisionId, + ); + checks.push({ packageRevisionId: candidate.resource.revision.revisionId, artifactPath }); } - if (await snapshotCommit(options.root) !== commit) throw new Error("Source changed during verification; run the check again"); - const result = {schemaVersion: 1, candidateOnly: true, activationEvidence: false, commit, evolution, checks, blockers, - note: "Checks the committed root and its exact locked dependencies. Resource edits must be verified and repinned before they enter this candidate. No publication or activation performed."}; + if ((await snapshotCommit(options.root)) !== commit) + throw new Error("Source changed during verification; run the check again"); + const result = { + schemaVersion: 1, + candidateOnly: true, + activationEvidence: false, + commit, + evolution, + checks, + blockers, + note: "Checks the committed root and its exact locked dependencies. Resource edits must be verified and repinned before they enter this candidate. No publication or activation performed.", + }; await fs.writeFile(path.join(options.output, "candidate.json"), JSON.stringify(compiled.workspace, null, 2)); await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2)); return result; } catch (error) { - const result = {schemaVersion: 1, candidateOnly: true, activationEvidence: false, commit, checks, blockers: [...blockers, error instanceof Error ? error.message : String(error)]}; + const result = { + schemaVersion: 1, + candidateOnly: true, + activationEvidence: false, + commit, + checks, + blockers: [...blockers, error instanceof Error ? error.message : String(error)], + }; await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2)); return result; - } finally {await fs.rm(temporary, {recursive: true, force: true});} + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } }; diff --git a/src/capability-language/checked-build.ts b/src/capability-language/checked-build.ts index 2a3f63e..d08b50a 100644 --- a/src/capability-language/checked-build.ts +++ b/src/capability-language/checked-build.ts @@ -1,38 +1,52 @@ import fs from "node:fs/promises"; import path from "node:path"; -import {execFile as callback, spawn} from "node:child_process"; +import { execFile as callback, spawn } from "node:child_process"; import { createWriteStream } from "node:fs"; -import {promisify} from "node:util"; -import {fileURLToPath} from "node:url"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; const execFile = promisify(callback); -const environment = () => ({...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", GIT_TERMINAL_PROMPT: "0"}); -export const checkerIdentity = () => process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const environment = () => ({ ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", GIT_TERMINAL_PROMPT: "0" }); +export const checkerIdentity = () => + process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); /** Checking snapshots jj, but never publishes or activates the working copy. */ export async function snapshotCommit(root: string): Promise { - const run = async (...args: string[]) => (await execFile("jj", args, {cwd: root, env: environment()})).stdout.trim(); + const run = async (...args: string[]) => + (await execFile("jj", args, { cwd: root, env: environment() })).stdout.trim(); await run("status"); // jj resolve --list exits 1 on a clean revision. Query structured revision // metadata instead of depending on diagnostic wording or swallowing errors. - if (await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "conflict") !== "false") throw new Error("Resolve source conflicts before verification"); + if ((await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "conflict")) !== "false") + throw new Error("Resolve source conflicts before verification"); const commit = await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"); if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Verification requires an exact jj commit"); - await execFile("git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"], {cwd: root, env: environment()}); - const {stdout} = await execFile("git", ["ls-files", "--others", "--exclude-standard", "-z"], {cwd: root}); + await execFile("git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"], { + cwd: root, + env: environment(), + }); + const { stdout } = await execFile("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root }); if (stdout) throw new Error("Source contains files not captured by jj; inspect jj tracking before verification"); return commit; } /** Use Git's actual committed tree, never dirty overlays labelled as old pins. */ export async function checkoutCommit(root: string, commit: string, destination: string) { - await fs.mkdir(destination, {recursive: true}); - const archive = await execFile("git", ["archive", "--format=tar", commit], {cwd: root, encoding: "buffer", maxBuffer: 128 * 1024 * 1024}); + await fs.mkdir(destination, { recursive: true }); + const archive = await execFile("git", ["archive", "--format=tar", commit], { + cwd: root, + encoding: "buffer", + maxBuffer: 128 * 1024 * 1024, + }); await new Promise((resolve, reject) => { - const child = spawn("tar", ["-xf", "-", "-C", destination], {stdio: ["pipe", "ignore", "pipe"]}); + const child = spawn("tar", ["-xf", "-", "-C", destination], { stdio: ["pipe", "ignore", "pipe"] }); let error = ""; - child.stderr.on("data", chunk => {error += chunk;}); + child.stderr.on("data", (chunk) => { + error += chunk; + }); child.on("error", reject); - child.on("close", code => code === 0 ? resolve() : reject(new Error(`Cannot extract committed source: ${error}`))); + child.on("close", (code) => + code === 0 ? resolve() : reject(new Error(`Cannot extract committed source: ${error}`)), + ); child.stdin.on("error", reject); child.stdin.end(archive.stdout); }); @@ -43,21 +57,47 @@ export async function checkoutCommit(root: string, commit: string, destination: * not a certificate authored or approved by the workspace agent. */ export async function buildCheckedPackage(source: string, schema: string, packageRevisionId: string): Promise { - const generator = process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); - if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Run verification with the installed Quixos tooling (its exact Nix checker is required)"); + const generator = + process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + if (!/^\/nix\/store\/[^/]+$/.test(generator)) + throw new Error("Run verification with the installed Quixos tooling (its exact Nix checker is required)"); const builder = path.join(generator, "share/checked-package.nix"); await fs.access(builder); return await new Promise((resolve, reject) => { - const child = spawn("nix", ["build", "--impure", "--file", builder, - "--argstr", "source", source, "--argstr", "schema", schema, - "--argstr", "generator", generator, "--argstr", "packageRevisionId", packageRevisionId, - "--no-link", "--print-out-paths", "-L"], {env: environment(), stdio: ["ignore", "pipe", "inherit"]}); + const child = spawn( + "nix", + [ + "build", + "--impure", + "--file", + builder, + "--argstr", + "source", + source, + "--argstr", + "schema", + schema, + "--argstr", + "generator", + generator, + "--argstr", + "packageRevisionId", + packageRevisionId, + "--no-link", + "--print-out-paths", + "-L", + ], + { env: environment(), stdio: ["ignore", "pipe", "inherit"] }, + ); let output = ""; - child.stdout.on("data", chunk => {output += chunk;}); + child.stdout.on("data", (chunk) => { + output += chunk; + }); child.on("error", reject); - child.on("close", code => { + child.on("close", (code) => { const artifact = output.trim(); - if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(artifact)) reject(new Error(`Checked Nix build failed (${code}); see build diagnostics above`)); + if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(artifact)) + reject(new Error(`Checked Nix build failed (${code}); see build diagnostics above`)); else resolve(artifact); }); }); @@ -65,31 +105,80 @@ export async function buildCheckedPackage(source: string, schema: string, packag /** Exact remote source DAG; the Nix checker owns schema construction and all * nested fetches. Keep build noise in a named log, with a bounded failure tail. */ -export async function buildImmutableCandidate(source: { repository: string; commit: string }, kind: "workspace" | "interface" | "package", logFile: string, contractOnly = false): Promise { - if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(source.commit)) throw new Error("An immutable candidate requires an exact commit"); +export async function buildImmutableCandidate( + source: { repository: string; commit: string }, + kind: "workspace" | "interface" | "package", + logFile: string, + contractOnly = false, +): Promise { + if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(source.commit)) + throw new Error("An immutable candidate requires an exact commit"); const url = new URL(source.repository); - if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("Candidate origin must be credential-free HTTPS"); - const generator = process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) + throw new Error("Candidate origin must be credential-free HTTPS"); + const generator = + process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Use the installed Quixos checker"); const builder = path.join(generator, "share/checked-candidate.nix"); await fs.access(builder); const log = createWriteStream(logFile, { flags: "wx", mode: 0o600 }); - await new Promise((resolve, reject) => { log.once("open", () => resolve()); log.once("error", reject); }); + await new Promise((resolve, reject) => { + log.once("open", () => resolve()); + log.once("error", reject); + }); return await new Promise((resolve, reject) => { - let output = "", tail = "", failure: Error | undefined; - const child = spawn("nix", ["build", "--impure", "--file", builder, - "--argstr", "repository", source.repository, "--argstr", "commit", source.commit, - "--argstr", "kind", kind, "--argstr", "generator", generator, - "--arg", "contractOnly", contractOnly ? "true" : "false", - "--no-link", "--print-out-paths", "-L"], { env: environment(), stdio: ["ignore", "pipe", "pipe"] }); - log.on("error", error => { failure = error; child.kill(); }); - child.stdout.on("data", chunk => { output += chunk; }); - child.stderr.on("data", chunk => { log.write(chunk); tail = (tail + String(chunk)).slice(-6000); }); - child.on("error", error => { failure = error; }); - child.on("close", code => log.end(() => { - if (failure) reject(failure); - else if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(output.trim())) reject(new Error(`Candidate Nix check failed (${code}). Full log: ${logFile}\n${tail}`)); - else resolve(output.trim()); - })); + let output = "", + tail = "", + failure: Error | undefined; + const child = spawn( + "nix", + [ + "build", + "--impure", + "--file", + builder, + "--argstr", + "repository", + source.repository, + "--argstr", + "commit", + source.commit, + "--argstr", + "kind", + kind, + "--argstr", + "generator", + generator, + "--arg", + "contractOnly", + contractOnly ? "true" : "false", + "--no-link", + "--print-out-paths", + "-L", + ], + { env: environment(), stdio: ["ignore", "pipe", "pipe"] }, + ); + log.on("error", (error) => { + failure = error; + child.kill(); + }); + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + log.write(chunk); + tail = (tail + String(chunk)).slice(-6000); + }); + child.on("error", (error) => { + failure = error; + }); + child.on("close", (code) => + log.end(() => { + if (failure) reject(failure); + else if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(output.trim())) + reject(new Error(`Candidate Nix check failed (${code}). Full log: ${logFile}\n${tail}`)); + else resolve(output.trim()); + }), + ); }); } diff --git a/src/capability-language/cli.ts b/src/capability-language/cli.ts index 54bb9d7..bc879dc 100644 --- a/src/capability-language/cli.ts +++ b/src/capability-language/cli.ts @@ -35,9 +35,9 @@ const parseArgs = (args: string[]) => { else positional.push(argument); } if ( - positional.length !== 1 - || Boolean(workspaceId) !== Boolean(workspaceRevisionId) - || (resource && Boolean(workspaceId || workspaceRevisionId || sourceRootCommit)) + positional.length !== 1 || + Boolean(workspaceId) !== Boolean(workspaceRevisionId) || + (resource && Boolean(workspaceId || workspaceRevisionId || sourceRootCommit)) ) { throw new Error(usage); } @@ -50,14 +50,7 @@ const main = async () => { process.stdout.write(`${usage}\n`); return; } - const { - checkOnly, - resource, - workspaceId, - workspaceRevisionId, - sourceRootCommit, - fileName, - } = parseArgs(args); + const { checkOnly, resource, workspaceId, workspaceRevisionId, sourceRootCommit, fileName } = parseArgs(args); const source = fileName === "-" ? await new Promise((resolve, reject) => { @@ -67,33 +60,33 @@ const main = async () => { process.stdin.on("error", reject); }) : await readFile(fileName, "utf8"); - const reportDiagnostics = (diagnostics: readonly { - fileName: string; - line: number; - column: number; - phase: string; - code: string; - message: string; - path?: string; - }[]) => { + const reportDiagnostics = ( + diagnostics: readonly { + fileName: string; + line: number; + column: number; + phase: string; + code: string; + message: string; + path?: string; + }[], + ) => { for (const diagnostic of diagnostics) { const location = diagnostic.line ? `${diagnostic.fileName}:${diagnostic.line}:${diagnostic.column + 1}` : `${diagnostic.fileName}${diagnostic.path ? `:${diagnostic.path}` : ""}`; - process.stderr.write( - `${location}: ${diagnostic.phase} ${diagnostic.code}: ${diagnostic.message}\n`, - ); + process.stderr.write(`${location}: ${diagnostic.phase} ${diagnostic.code}: ${diagnostic.message}\n`); } process.exitCode = 1; }; if (resource) { const result = compileCapabilityResourceSource(source, { - source: { - repository: "https://compiler.invalid/resource.git", - commit: "0000000000000000000000000000000000000000", - }, - fileName, - }); + source: { + repository: "https://compiler.invalid/resource.git", + commit: "0000000000000000000000000000000000000000", + }, + fileName, + }); if (!result.ok) { reportDiagnostics(result.diagnostics); return; @@ -106,14 +99,15 @@ const main = async () => { reportDiagnostics(result.diagnostics); return; } - const workspace = workspaceId && workspaceRevisionId - ? { - ...result.workspace, - workspaceId: capabilityId.workspace(workspaceId), - id: capabilityId.workspaceRevision(workspaceRevisionId), - ...(sourceRootCommit ? { sourceRootCommit } : {}), - } - : { ...result.workspace, ...(sourceRootCommit ? { sourceRootCommit } : {}) }; + const workspace = + workspaceId && workspaceRevisionId + ? { + ...result.workspace, + workspaceId: capabilityId.workspace(workspaceId), + id: capabilityId.workspaceRevision(workspaceRevisionId), + ...(sourceRootCommit ? { sourceRootCommit } : {}), + } + : { ...result.workspace, ...(sourceRootCommit ? { sourceRootCommit } : {}) }; const instantiated = compileWorkspaceRevision(workspace); if (!instantiated.ok) { throw new Error(instantiated.issues.map((issue) => `${issue.path}: ${issue.message}`).join("\n")); diff --git a/src/capability-language/file-lock.ts b/src/capability-language/file-lock.ts index 2c41173..a1419ba 100644 --- a/src/capability-language/file-lock.ts +++ b/src/capability-language/file-lock.ts @@ -3,19 +3,52 @@ import { spawn } from "node:child_process"; /** Kernel-owned lock: a crashed coordinator cannot leave a stale ownership file. * The persistent file is just an inode; EOF releases the helper's lock. */ export async function withFileLock(filename: string, work: () => Promise): Promise { - const child = spawn("flock", ["--exclusive", "--timeout", "120", "--conflict-exit-code", "75", filename, - process.execPath, "-e", 'process.stdout.write("locked\\n"); process.stdin.resume();'], {stdio: ["pipe", "pipe", "pipe"]}); + const child = spawn( + "flock", + [ + "--exclusive", + "--timeout", + "120", + "--conflict-exit-code", + "75", + filename, + process.execPath, + "-e", + 'process.stdout.write("locked\\n"); process.stdin.resume();', + ], + { stdio: ["pipe", "pipe", "pipe"] }, + ); let diagnostics = ""; - child.stdin.on("error", () => { /* acquisition/exit handling reports helper failure */ }); - child.stderr.on("data", chunk => { diagnostics = (diagnostics + String(chunk)).slice(-2000); }); - const closed = new Promise((resolve) => { child.once("close", () => resolve()); }); + child.stdin.on("error", () => { + /* acquisition/exit handling reports helper failure */ + }); + child.stderr.on("data", (chunk) => { + diagnostics = (diagnostics + String(chunk)).slice(-2000); + }); + const closed = new Promise((resolve) => { + child.once("close", () => resolve()); + }); try { await new Promise((resolve, reject) => { let output = ""; child.once("error", reject); - child.once("exit", code => reject(new Error(code === 75 ? "Timed out after 120 seconds waiting for another authoring command; inspect that command before retrying" : `Cannot acquire authoring lock: ${diagnostics}`))); - child.stdout.on("data", chunk => { output += chunk; if (output.includes("locked\n")) resolve(); }); + child.once("exit", (code) => + reject( + new Error( + code === 75 + ? "Timed out after 120 seconds waiting for another authoring command; inspect that command before retrying" + : `Cannot acquire authoring lock: ${diagnostics}`, + ), + ), + ); + child.stdout.on("data", (chunk) => { + output += chunk; + if (output.includes("locked\n")) resolve(); + }); }); return await work(); - } finally { child.stdin.end(); await closed; } + } finally { + child.stdin.end(); + await closed; + } } diff --git a/src/capability-language/git-resolver.ts b/src/capability-language/git-resolver.ts index bbd2f27..c87dcfc 100644 --- a/src/capability-language/git-resolver.ts +++ b/src/capability-language/git-resolver.ts @@ -57,24 +57,34 @@ export const createGitCapabilityResolver = async (options: { const existing = checkouts.get(key); if (existing) return await existing; const pending = (async () => { - const directory = path.join( - checkoutRoot, - checkoutName(kind, source.repository, source.commit), - ); + const directory = path.join(checkoutRoot, checkoutName(kind, source.repository, source.commit)); const verify = async (checkout: string) => { const { stdout } = await execFile("git", ["-C", checkout, "rev-parse", "HEAD"]); if (stdout.trim().toLowerCase() !== source.commit.toLowerCase()) { - throw new Error(`Locked commit mismatch for ${source.repository}: wanted ${source.commit}, fetched ${stdout.trim()}`); + throw new Error( + `Locked commit mismatch for ${source.repository}: wanted ${source.commit}, fetched ${stdout.trim()}`, + ); } - const { stdout: changes } = await execFile("git", ["-C", checkout, "status", "--porcelain", "--untracked-files=all"]); + const { stdout: changes } = await execFile("git", [ + "-C", + checkout, + "status", + "--porcelain", + "--untracked-files=all", + ]); if (changes.trim()) throw new Error(`Dependency checkout was modified: ${checkout}`); }; // Only complete, checked clones become visible under the deterministic name. // Concurrent resolvers may fetch independently, but cannot observe a partial clone. - if (await stat(directory).then(() => true, (error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") return false; - throw error; - })) { + if ( + await stat(directory).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return false; + throw error; + }, + ) + ) { await verify(directory); return { directory }; } @@ -82,20 +92,21 @@ export const createGitCapabilityResolver = async (options: { const checkout = path.join(staging, "checkout"); try { await execFile("git", [ - "-c", - "advice.detachedHead=false", - "clone", - "--depth", - "1", - "--single-branch", - "--branch", - `quixos-reachability/${source.commit.toLowerCase()}`, - source.repository, - checkout, - ]); + "-c", + "advice.detachedHead=false", + "clone", + "--depth", + "1", + "--single-branch", + "--branch", + `quixos-reachability/${source.commit.toLowerCase()}`, + source.repository, + checkout, + ]); await verify(checkout); - try { await rename(checkout, directory); } - catch (error) { + try { + await rename(checkout, directory); + } catch (error) { if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; await verify(directory); } @@ -105,7 +116,11 @@ export const createGitCapabilityResolver = async (options: { return { directory }; })(); checkouts.set(key, pending); - try { return await pending; } - catch (error) { checkouts.delete(key); throw error; } + try { + return await pending; + } catch (error) { + checkouts.delete(key); + throw error; + } }; }; diff --git a/src/capability-language/implementation-edit.ts b/src/capability-language/implementation-edit.ts index abc9962..85ffb9b 100644 --- a/src/capability-language/implementation-edit.ts +++ b/src/capability-language/implementation-edit.ts @@ -1,25 +1,50 @@ -import {parse} from "@babel/parser"; -type Node = {type: string; start: number; end: number; [key: string]: unknown}; -const node = (value: unknown): value is Node => !!value && typeof value === "object" && typeof (value as Node).type === "string"; +import { parse } from "@babel/parser"; +type Node = { type: string; start: number; end: number; [key: string]: unknown }; +const node = (value: unknown): value is Node => + !!value && typeof value === "object" && typeof (value as Node).type === "string"; /** One requested insertion into ordinary authored TypeScript, not regeneration. * Ambiguous/custom wiring is left alone with an actionable error. */ -export function addImplementation(text: string, factory: "createRuntime" | "serveMigration", key: string, importPath: string, migrationOnly = false): string { - const ast = parse(text, {sourceType: "module", plugins: ["typescript"]}); +export function addImplementation( + text: string, + factory: "createRuntime" | "serveMigration", + key: string, + importPath: string, + migrationOnly = false, +): string { + const ast = parse(text, { sourceType: "module", plugins: ["typescript"] }); const objects: Node[] = []; const names = new Set(); const visit = (value: unknown) => { - if (Array.isArray(value)) { value.forEach(visit); return; } + if (Array.isArray(value)) { + value.forEach(visit); + return; + } if (!node(value)) return; if (value.type === "Identifier") names.add(String(value.name)); - if (value.type === "CallExpression" && node(value.callee) && value.callee.type === "Identifier" && value.callee.name === factory && - Array.isArray(value.arguments) && value.arguments.length === 1 && node(value.arguments[0]) && value.arguments[0].type === "ObjectExpression") objects.push(value.arguments[0]); + if ( + value.type === "CallExpression" && + node(value.callee) && + value.callee.type === "Identifier" && + value.callee.name === factory && + Array.isArray(value.arguments) && + value.arguments.length === 1 && + node(value.arguments[0]) && + value.arguments[0].type === "ObjectExpression" + ) + objects.push(value.arguments[0]); Object.values(value).forEach(visit); }; visit(ast); - if (objects.length !== 1) throw new Error(`Cannot safely add implementation: expected one ${factory}({...}) literal. Wire the handler in your authored server code instead.`); + if (objects.length !== 1) + throw new Error( + `Cannot safely add implementation: expected one ${factory}({...}) literal. Wire the handler in your authored server code instead.`, + ); const object = objects[0]; - if ((object.properties as Node[]).some(p => node(p.key) && !p.computed && (p.key.name === key || p.key.value === key))) throw new Error(`Implementation already exists for ${key}`); + if ( + (object.properties as Node[]).some((p) => node(p.key) && !p.computed && (p.key.name === key || p.key.value === key)) + ) + throw new Error(`Implementation already exists for ${key}`); let alias = "qxImplementation"; for (let index = 1; names.has(alias); index++) alias = `qxImplementation${index}`; const value = migrationOnly ? 'async () => { throw new Error("Migration-only export"); }' : alias; diff --git a/src/capability-language/inspect-cli.ts b/src/capability-language/inspect-cli.ts index e63e7ea..6db292c 100644 --- a/src/capability-language/inspect-cli.ts +++ b/src/capability-language/inspect-cli.ts @@ -10,7 +10,12 @@ for await (const chunk of process.stdin) { } const document = JSON.parse(input); if (!Array.isArray(document) && document?.operation === "instantiate-workspace") { - if (typeof document.source !== "string" || document.source.length > 262144 || typeof document.workspaceId !== "string") throw new Error("Invalid template identity request"); + if ( + typeof document.source !== "string" || + document.source.length > 262144 || + typeof document.workspaceId !== "string" + ) + throw new Error("Invalid template identity request"); process.stdout.write(JSON.stringify({ source: instantiateWorkspaceIdentity(document.source, document.workspaceId) })); process.exit(0); } diff --git a/src/capability-language/migration-seal.ts b/src/capability-language/migration-seal.ts index 0fa196e..583f090 100644 --- a/src/capability-language/migration-seal.ts +++ b/src/capability-language/migration-seal.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; -import {contentDigest} from "../capability-model/evolution.js"; -import {validateMigrationCatalog, type MigrationCatalog} from "../capability-model/migrations.js"; -import {applyStructure, planStructure} from "./structural-plan.js"; +import { contentDigest } from "../capability-model/evolution.js"; +import { validateMigrationCatalog, type MigrationCatalog } from "../capability-model/migrations.js"; +import { applyStructure, planStructure } from "./structural-plan.js"; /** Explicitly acknowledge edited migration code. This does not change retained * contracts or grant activation approval; the immutable checker verifies it. */ @@ -17,7 +17,11 @@ export async function sealMigrations(directory: string) { migration.implementation.digest = contentDigest(await fs.readFile(implementation, "utf8")); } validateMigrationCatalog(catalog); - return applyStructure(await planStructure(root, {kind: "package", validation: "syntax", files: [ - {file, expected: before, replace: JSON.stringify(catalog, null, 2) + "\n"}, - ]})); + return applyStructure( + await planStructure(root, { + kind: "package", + validation: "syntax", + files: [{ file, expected: before, replace: JSON.stringify(catalog, null, 2) + "\n" }], + }), + ); } diff --git a/src/capability-language/parser.ts b/src/capability-language/parser.ts index 883dd7c..d928e0b 100644 --- a/src/capability-language/parser.ts +++ b/src/capability-language/parser.ts @@ -39,7 +39,8 @@ import { type WorkspaceRevision, } from "../capability-model/index.js"; import { QuixosCapabilityLexer } from "./generated/QuixosCapabilityLexer.js"; -import { QuixosCapabilityParser, +import { + QuixosCapabilityParser, type AttachmentDeclContext, type ConformanceDeclContext, type DependencyBindingBlockContext, @@ -110,10 +111,7 @@ export type CapabilityResourceCompileResult = | { ok: true; resource: CapabilityResource; diagnostics: [] } | { ok: false; diagnostics: CapabilitySourceDiagnostic[] }; -export type CapabilitySourceDiagnosticPhase = - | "syntax" - | "lowering" - | "validation"; +export type CapabilitySourceDiagnosticPhase = "syntax" | "lowering" | "validation"; export interface CapabilitySourceDiagnostic { phase: CapabilitySourceDiagnosticPhase; @@ -206,12 +204,7 @@ const contextPosition = (context: ParserRuleContext) => ({ column: context.start?.column ?? 0, }); -const loweringIssue = ( - state: LoweringState, - context: ParserRuleContext, - code: string, - message: string, -) => { +const loweringIssue = (state: LoweringState, context: ParserRuleContext, code: string, message: string) => { state.diagnostics.push({ phase: "lowering", code, @@ -229,8 +222,7 @@ const text = (context: { getText(): string } | null): string => { }; const identifier = text; -const stringValue = (context: { getText(): string } | null) => - JSON.parse(text(context)) as string; +const stringValue = (context: { getText(): string } | null) => JSON.parse(text(context)) as string; const declareSymbol = ( state: LoweringState, @@ -241,12 +233,7 @@ const declareSymbol = ( kind: string, ) => { if (table.has(name)) { - loweringIssue( - state, - context, - "duplicate-symbol", - `Duplicate ${kind} authoring name ${name}`, - ); + loweringIssue(state, context, "duplicate-symbol", `Duplicate ${kind} authoring name ${name}`); return; } table.set(name, value); @@ -261,19 +248,12 @@ const requireSymbol = ( ): Value | undefined => { const value = table.get(name); if (value === undefined) { - loweringIssue( - state, - context, - "unknown-symbol", - `Unknown ${kind} ${name}`, - ); + loweringIssue(state, context, "unknown-symbol", `Unknown ${kind} ${name}`); } return value; }; -const lowerCardinality = ( - context: { getText(): string } | null, -): EdgeCardinality => text(context) as EdgeCardinality; +const lowerCardinality = (context: { getText(): string } | null): EdgeCardinality => text(context) as EdgeCardinality; const lowerConstraint = ( state: LoweringState, @@ -287,22 +267,11 @@ const lowerConstraint = ( const atomId = requireSymbol(state, state.atoms, name, context, "atom"); return atomId ? { kind: "atom", atomId } : undefined; } - const symbol = requireSymbol( - state, - state.interfaces, - name, - context, - "interface", - ); - return symbol - ? { kind: "interface", interfaceRevisionId: symbol.revisionId } - : undefined; + const symbol = requireSymbol(state, state.interfaces, name, context, "interface"); + return symbol ? { kind: "interface", interfaceRevisionId: symbol.revisionId } : undefined; }; -const lowerValueType = ( - state: LoweringState, - context: ValueTypeContext | null, -): ValueType => { +const lowerValueType = (state: LoweringState, context: ValueTypeContext | null): ValueType => { if (!context) { throw new Error("Missing value type after a successful parse"); } @@ -320,9 +289,12 @@ const lowerValueType = ( return valueType.message(stringValue(context.stringLiteral())); } if (context.RECORD()) { - const fields = context.recordField().map((field) => [identifier(field.identifier()), lowerValueType(state, field.valueType())] as const); - if (new Set(fields.map(([name]) => name)).size !== fields.length) loweringIssue(state, context, "invalid-type", "Duplicate record field"); - return {kind: "record", fields: Object.fromEntries(fields)}; + const fields = context + .recordField() + .map((field) => [identifier(field.identifier()), lowerValueType(state, field.valueType())] as const); + if (new Set(fields.map(([name]) => name)).size !== fields.length) + loweringIssue(state, context, "invalid-type", "Duplicate record field"); + return { kind: "record", fields: Object.fromEntries(fields) }; } if (context.ATOM_REF()) { const name = identifier(context.identifier()); @@ -331,16 +303,8 @@ const lowerValueType = ( } if (context.INTERFACE_REF()) { const name = identifier(context.identifier()); - const symbol = requireSymbol( - state, - state.interfaces, - name, - context, - "interface", - ); - return valueType.interfaceRef( - symbol?.revisionId ?? capabilityId.interfaceRevision(`unresolved:${name}`), - ); + const symbol = requireSymbol(state, state.interfaces, name, context, "interface"); + return valueType.interfaceRef(symbol?.revisionId ?? capabilityId.interfaceRevision(`unresolved:${name}`)); } if (context.OPTIONAL()) { return valueType.optional(lowerValueType(state, context.valueType())); @@ -352,11 +316,7 @@ const lowerValueType = ( return valueType.unit; }; -const valueOperation = ( - displayName: string, - id: InterfaceOperation["id"], - value: ValueType, -): InterfaceOperation => { +const valueOperation = (displayName: string, id: InterfaceOperation["id"], value: ValueType): InterfaceOperation => { switch (displayName) { case "get": return { @@ -403,9 +363,7 @@ const relationshipOperation = ( cardinality: EdgeCardinality, ): InterfaceOperation => { const targetType = - target.kind === "atom" - ? valueType.atomRef(target.atomId) - : valueType.interfaceRef(target.interfaceRevisionId); + target.kind === "atom" ? valueType.atomRef(target.atomId) : valueType.interfaceRef(target.interfaceRevisionId); const resolvedType = cardinalityValueType(target, cardinality); switch (displayName) { case "resolve": @@ -447,42 +405,23 @@ const relationshipOperation = ( } }; -const lowerValueMember = ( - state: LoweringState, - context: ValueMemberContext, -): InterfaceMember => { +const lowerValueMember = (state: LoweringState, context: ValueMemberContext): InterfaceMember => { const memberName = identifier(context.identifier()); const memberValueType = lowerValueType(state, context.valueType()); const operations: InterfaceOperation[] = []; for (const operation of context.valueMemberOperation()) { if (operation.GET()) { operations.push( - valueOperation( - "get", - capabilityId.operation(stringValue(operation.stringLiteral(0))), - memberValueType, - ), + valueOperation("get", capabilityId.operation(stringValue(operation.stringLiteral(0))), memberValueType), ); } else if (operation.SET()) { operations.push( - valueOperation( - "set", - capabilityId.operation(stringValue(operation.stringLiteral(0))), - memberValueType, - ), + valueOperation("set", capabilityId.operation(stringValue(operation.stringLiteral(0))), memberValueType), ); } else { operations.push( - valueOperation( - "watch-start", - capabilityId.operation(stringValue(operation.stringLiteral(0))), - memberValueType, - ), - valueOperation( - "watch-stop", - capabilityId.operation(stringValue(operation.stringLiteral(1))), - memberValueType, - ), + valueOperation("watch-start", capabilityId.operation(stringValue(operation.stringLiteral(0))), memberValueType), + valueOperation("watch-stop", capabilityId.operation(stringValue(operation.stringLiteral(1))), memberValueType), ); } } @@ -561,10 +500,7 @@ const lowerRelationshipMember = ( }; }; -const lowerOperationMember = ( - state: LoweringState, - context: OperationMemberContext, -): InterfaceMember => { +const lowerOperationMember = (state: LoweringState, context: OperationMemberContext): InterfaceMember => { const inputType = lowerValueType(state, context.valueType(0)); const outputType = lowerValueType(state, context.valueType(1)); return { @@ -573,13 +509,15 @@ const lowerOperationMember = ( displayName: identifier(context.identifier()), inputType, outputType, - operations: [{ - id: capabilityId.operation(stringValue(context.stringLiteral(1))), - displayName: "call", - inputType, - outputType, - mode: "call", - }], + operations: [ + { + id: capabilityId.operation(stringValue(context.stringLiteral(1))), + displayName: "call", + inputType, + outputType, + mode: "call", + }, + ], }; }; @@ -598,10 +536,7 @@ const lowerInterface = ( if (operation) { return [lowerOperationMember(state, operation)]; } - const relationship = lowerRelationshipMember( - state, - entry.relationshipMember()!, - ); + const relationship = lowerRelationshipMember(state, entry.relationshipMember()!); return relationship ? [relationship] : []; }); const symbol = state.interfaces.get(alias)!; @@ -612,9 +547,7 @@ const lowerInterface = ( member.displayName, { memberId: member.id, - operations: new Map( - member.operations.map((operation) => [operation.displayName, operation.id]), - ), + operations: new Map(member.operations.map((operation) => [operation.displayName, operation.id])), }, context, "interface member", @@ -629,10 +562,7 @@ const lowerInterface = ( }; }; -const lowerDependencyPort = ( - state: LoweringState, - context: DependencyPortContext, -): DependencyPort | undefined => { +const lowerDependencyPort = (state: LoweringState, context: DependencyPortContext): DependencyPort | undefined => { const name = identifier(context.identifier(0)); const id = capabilityId.dependencyPort(stringValue(context.stringLiteral())); if (context.STATE()) { @@ -640,16 +570,9 @@ const lowerDependencyPort = ( .primitiveList()! .primitive() .map((entry) => text(entry)); - const invalid = primitives.filter( - (entry) => !["read", "write", "watch-start", "watch-stop"].includes(entry), - ); + const invalid = primitives.filter((entry) => !["read", "write", "watch-start", "watch-stop"].includes(entry)); if (invalid.length > 0) { - loweringIssue( - state, - context, - "invalid-port-primitive", - `State port cannot request ${invalid.join(", ")}`, - ); + loweringIssue(state, context, "invalid-port-primitive", `State port cannot request ${invalid.join(", ")}`); } return { id, @@ -671,18 +594,10 @@ const lowerDependencyPort = ( .primitive() .map((entry) => text(entry)); const invalid = primitives.filter( - (entry) => - !["resolve", "connect", "disconnect", "watch-start", "watch-stop"].includes( - entry, - ), + (entry) => !["resolve", "connect", "disconnect", "watch-start", "watch-stop"].includes(entry), ); if (invalid.length > 0) { - loweringIssue( - state, - context, - "invalid-port-primitive", - `Edge port cannot request ${invalid.join(", ")}`, - ); + loweringIssue(state, context, "invalid-port-primitive", `Edge port cannot request ${invalid.join(", ")}`); } return { id, @@ -697,13 +612,7 @@ const lowerDependencyPort = ( } const targetName = identifier(context.identifier(1)); if (context.INTERFACE()) { - const target = requireSymbol( - state, - state.interfaces, - targetName, - context, - "interface", - ); + const target = requireSymbol(state, state.interfaces, targetName, context, "interface"); if (target && !target.contractAvailable) { loweringIssue( state, @@ -729,8 +638,11 @@ const lowerDependencyPort = ( ? { id, displayName: name, - requirement: { kind: "constructor", atomId, - ...(context.valueType() ? { inputType: lowerValueType(state, context.valueType()) } : {}) }, + requirement: { + kind: "constructor", + atomId, + ...(context.valueType() ? { inputType: lowerValueType(state, context.valueType()) } : {}), + }, } : undefined; }; @@ -743,19 +655,15 @@ const lowerDependencyPorts = ( if (!block) { return []; } - return block - .dependencyPort() - .flatMap((entry) => { - const port = lowerDependencyPort(state, entry); - return port ? [port] : []; - }); + return block.dependencyPort().flatMap((entry) => { + const port = lowerDependencyPort(state, entry); + return port ? [port] : []; + }); }; const lowerReceiver = ( state: LoweringState, - context: PackageOperationExportContext["receiverRequirement"] extends () => infer Result - ? Result - : never, + context: PackageOperationExportContext["receiverRequirement"] extends () => infer Result ? Result : never, ): PackageReceiverRequirement => { if (context.ANY()) { return { kind: "any-object" }; @@ -764,9 +672,7 @@ const lowerReceiver = ( const name = identifier(context.identifier()); return { kind: "exact-atom", - atomId: - requireSymbol(state, state.atoms, name, context, "atom") ?? - capabilityId.atom(`unresolved:${name}`), + atomId: requireSymbol(state, state.atoms, name, context, "atom") ?? capabilityId.atom(`unresolved:${name}`), }; } const list = context.identifierList(); @@ -792,14 +698,7 @@ const registerPackageExport = ( const packageSymbol = state.packages.get(packageAlias)!; const portSymbols = new Map(); for (const port of entry.dependencyPorts) { - declareSymbol( - state, - portSymbols, - port.displayName, - port.id, - context, - "dependency port", - ); + declareSymbol(state, portSymbols, port.displayName, port.id, context, "dependency port"); } declareSymbol( state, @@ -860,8 +759,7 @@ const lowerPackageConstructor = ( const alias = identifier(context.identifier(0)); const atomName = identifier(context.identifier(1)); const atomId = - requireSymbol(state, state.atoms, atomName, context, "atom") ?? - capabilityId.atom(`unresolved:${atomName}`); + requireSymbol(state, state.atoms, atomName, context, "atom") ?? capabilityId.atom(`unresolved:${atomName}`); const entry: PackageExport = { kind: "constructor", id: capabilityId.packageExport(stringValue(context.stringLiteral())), @@ -890,11 +788,7 @@ const lowerPackage = ( if (fn) { return lowerPackageFunction(state, alias, fn); } - return lowerPackageConstructor( - state, - alias, - exportContext.packageConstructorExport()!, - ); + return lowerPackageConstructor(state, alias, exportContext.packageConstructorExport()!); }); return { packageId: capabilityId.package(stringValue(context.stringLiteral(0))), @@ -906,14 +800,10 @@ const lowerPackage = ( }; }; -const lowerState = ( - state: LoweringState, - context: StateDeclContext, -): StateSlotDefinition => { +const lowerState = (state: LoweringState, context: StateDeclContext): StateSlotDefinition => { const atomName = identifier(context.identifier(1)); const atomId = - requireSymbol(state, state.atoms, atomName, context, "atom") ?? - capabilityId.atom(`unresolved:${atomName}`); + requireSymbol(state, state.atoms, atomName, context, "atom") ?? capabilityId.atom(`unresolved:${atomName}`); const policy = context.storagePolicy(); const defaultContext = context.jsonLiteral(); let defaultValue: unknown; @@ -936,38 +826,30 @@ const lowerState = ( }; }; -const lowerEdgeEndpoint = ( - state: LoweringState, - context: EdgeEndpointContext, -): EdgeEndpoint | undefined => { +const lowerEdgeEndpoint = (state: LoweringState, context: EdgeEndpointContext): EdgeEndpoint | undefined => { const constraint = lowerConstraint(state, context.targetConstraint()); return constraint ? { - projectionId: capabilityId.edgeProjection( - stringValue(context.stringLiteral(0)), - ), + projectionId: capabilityId.edgeProjection(stringValue(context.stringLiteral(0))), displayName: identifier(context.identifier()), constraint, cardinality: lowerCardinality(context.cardinality()), ordered: Boolean(context.ORDERED()), ...(context.ON_DELETE() ? { onDelete: stringValue(context.stringLiteral(1)) as EdgeEndpoint["onDelete"] } : {}), ...(context.RETAIN_OTHER() ? { retainOther: true } : {}), - ...(context.KEYED() ? {keyType: stringValue(context.stringLiteral(context.ON_DELETE() ? 2 : 1)) as EdgeEndpoint["keyType"]} : {}), - ...(context.PUBLIC_TRAVERSAL() ? {publicTraversal: true} : {}), + ...(context.KEYED() + ? { keyType: stringValue(context.stringLiteral(context.ON_DELETE() ? 2 : 1)) as EdgeEndpoint["keyType"] } + : {}), + ...(context.PUBLIC_TRAVERSAL() ? { publicTraversal: true } : {}), } : undefined; }; -const lowerEdge = ( - state: LoweringState, - context: EdgeDeclContext, -): EdgeDefinition | undefined => { - const endpoints = context - .edgeEndpoint() - .flatMap((entry) => { - const endpoint = lowerEdgeEndpoint(state, entry); - return endpoint ? [endpoint] : []; - }); +const lowerEdge = (state: LoweringState, context: EdgeDeclContext): EdgeDefinition | undefined => { + const endpoints = context.edgeEndpoint().flatMap((entry) => { + const endpoint = lowerEdgeEndpoint(state, entry); + return endpoint ? [endpoint] : []; + }); if (endpoints.length !== 2) { return undefined; } @@ -979,46 +861,21 @@ const lowerEdge = ( }; }; -const lowerAttachment = ( - state: LoweringState, - context: AttachmentDeclContext, -): PersistentAttachment | undefined => { +const lowerAttachment = (state: LoweringState, context: AttachmentDeclContext): PersistentAttachment | undefined => { const stateContext = context.stateDecl(); - return stateContext - ? lowerState(state, stateContext) - : lowerEdge(state, context.edgeDecl()!); + return stateContext ? lowerState(state, stateContext) : lowerEdge(state, context.edgeDecl()!); }; -const registerAttachment = ( - state: LoweringState, - context: AttachmentDeclContext, - attachment: PersistentAttachment, -) => { +const registerAttachment = (state: LoweringState, context: AttachmentDeclContext, attachment: PersistentAttachment) => { const stateContext = context.stateDecl(); - const name = identifier( - stateContext ? stateContext.identifier(0) : context.edgeDecl()!.identifier(), - ); + const name = identifier(stateContext ? stateContext.identifier(0) : context.edgeDecl()!.identifier()); const projections = new Map(); if (attachment.kind === "edge") { for (const endpoint of attachment.endpoints) { - declareSymbol( - state, - projections, - endpoint.displayName, - endpoint.projectionId, - context, - "edge projection", - ); + declareSymbol(state, projections, endpoint.displayName, endpoint.projectionId, context, "edge projection"); } } - declareSymbol( - state, - state.attachments, - name, - { attachment, projections }, - context, - "attachment", - ); + declareSymbol(state, state.attachments, name, { attachment, projections }, context, "attachment"); }; const findOperationId = ( @@ -1028,30 +885,12 @@ const findOperationId = ( operationName: string, context: ParserRuleContext, ) => { - const interfaceSymbol = requireSymbol( - state, - state.interfaces, - interfaceAlias, - context, - "interface", - ); + const interfaceSymbol = requireSymbol(state, state.interfaces, interfaceAlias, context, "interface"); const member = interfaceSymbol - ? requireSymbol( - state, - interfaceSymbol.members, - memberName, - context, - `member on ${interfaceAlias}`, - ) + ? requireSymbol(state, interfaceSymbol.members, memberName, context, `member on ${interfaceAlias}`) : undefined; return member - ? requireSymbol( - state, - member.operations, - operationName, - context, - `operation on ${interfaceAlias}.${memberName}`, - ) + ? requireSymbol(state, member.operations, operationName, context, `operation on ${interfaceAlias}.${memberName}`) : undefined; }; @@ -1065,27 +904,12 @@ const lowerBoundDependencies = ( } return context.dependencyBinding().flatMap((entry) => { const portName = identifier(entry.identifier(0)); - const portId = requireSymbol( - state, - exportSymbol.ports, - portName, - entry, - "dependency port", - ); + const portId = requireSymbol(state, exportSymbol.ports, portName, entry, "dependency port"); if (!portId) { return []; } - const traversal = ( - edgeName: string, - projectionName: string, - ): EdgeTraversal | undefined => { - const attachment = requireSymbol( - state, - state.attachments, - edgeName, - entry, - "attachment", - ); + const traversal = (edgeName: string, projectionName: string): EdgeTraversal | undefined => { + const attachment = requireSymbol(state, state.attachments, edgeName, entry, "attachment"); if (!attachment || attachment.attachment.kind !== "edge") { if (attachment) { loweringIssue(state, entry, "wrong-attachment-kind", `${edgeName} is state, not an edge`); @@ -1099,57 +923,33 @@ const lowerBoundDependencies = ( entry, `projection on ${edgeName}`, ); - return projectionId - ? { edgeTypeId: attachment.attachment.id, projectionId } - : undefined; + return projectionId ? { edgeTypeId: attachment.attachment.id, projectionId } : undefined; }; if (entry.STATE()) { const attachmentName = identifier(entry.identifier(1)); - const attachment = requireSymbol( - state, - state.attachments, - attachmentName, - entry, - "attachment", - ); + const attachment = requireSymbol(state, state.attachments, attachmentName, entry, "attachment"); if (!attachment || attachment.attachment.kind !== "state") { if (attachment) { - loweringIssue( - state, - entry, - "wrong-attachment-kind", - `${attachmentName} is an edge, not state`, - ); + loweringIssue(state, entry, "wrong-attachment-kind", `${attachmentName} is an edge, not state`); } return []; } - const via = entry.VIA() - ? traversal(identifier(entry.identifier(2)), identifier(entry.identifier(3))) - : undefined; + const via = entry.VIA() ? traversal(identifier(entry.identifier(2)), identifier(entry.identifier(3))) : undefined; if (entry.VIA() && !via) return []; - return [{ - portId, - binding: { kind: "state", slotId: attachment.attachment.id, ...(via ? { via } : {}) }, - }]; + return [ + { + portId, + binding: { kind: "state", slotId: attachment.attachment.id, ...(via ? { via } : {}) }, + }, + ]; } if (entry.EDGE(0) && !entry.INTERFACE()) { const attachmentName = identifier(entry.identifier(1)); const projectionName = identifier(entry.identifier(2)); - const attachment = requireSymbol( - state, - state.attachments, - attachmentName, - entry, - "attachment", - ); + const attachment = requireSymbol(state, state.attachments, attachmentName, entry, "attachment"); if (!attachment || attachment.attachment.kind !== "edge") { if (attachment) { - loweringIssue( - state, - entry, - "wrong-attachment-kind", - `${attachmentName} is state, not an edge`, - ); + loweringIssue(state, entry, "wrong-attachment-kind", `${attachmentName} is state, not an edge`); } return []; } @@ -1160,9 +960,7 @@ const lowerBoundDependencies = ( entry, `projection on ${attachmentName}`, ); - const via = entry.VIA() - ? traversal(identifier(entry.identifier(3)), identifier(entry.identifier(4))) - : undefined; + const via = entry.VIA() ? traversal(identifier(entry.identifier(3)), identifier(entry.identifier(4))) : undefined; if (entry.VIA() && !via) return []; return projectionId ? [ @@ -1180,16 +978,8 @@ const lowerBoundDependencies = ( } if (entry.INTERFACE()) { const interfaceName = identifier(entry.identifier(1)); - const interfaceSymbol = requireSymbol( - state, - state.interfaces, - interfaceName, - entry, - "interface", - ); - const via = entry.VIA() - ? traversal(identifier(entry.identifier(2)), identifier(entry.identifier(3))) - : undefined; + const interfaceSymbol = requireSymbol(state, state.interfaces, interfaceName, entry, "interface"); + const via = entry.VIA() ? traversal(identifier(entry.identifier(2)), identifier(entry.identifier(3))) : undefined; if (entry.VIA() && !via) return []; return interfaceSymbol ? [ @@ -1206,9 +996,7 @@ const lowerBoundDependencies = ( } const atomName = identifier(entry.identifier(1)); const atomId = requireSymbol(state, state.atoms, atomName, entry, "atom"); - return atomId - ? [{ portId, binding: { kind: "constructor", atomId } }] - : []; + return atomId ? [{ portId, binding: { kind: "constructor", atomId } }] : []; }); }; @@ -1237,15 +1025,10 @@ const lowerRelationshipMaterialization = ( if (edge && edge.attachment.kind !== "edge") { loweringIssue(state, context, "wrong-attachment-kind", `${edgeName} is state, not an edge`); } - const projectionId = edge?.attachment.kind === "edge" - ? requireSymbol( - state, - edge.projections, - identifier(context.identifier(3)), - context, - `projection on ${edgeName}`, - ) - : undefined; + const projectionId = + edge?.attachment.kind === "edge" + ? requireSymbol(state, edge.projections, identifier(context.identifier(3)), context, `projection on ${edgeName}`) + : undefined; return member && constructorAtom && edge?.attachment.kind === "edge" && projectionId ? { memberId: member.memberId, @@ -1264,149 +1047,99 @@ const lowerConformance = ( const atomName = identifier(context.identifier(0)); const interfaceName = identifier(context.identifier(1)); const atomId = requireSymbol(state, state.atoms, atomName, context, "atom"); - const interfaceSymbol = requireSymbol( - state, - state.interfaces, - interfaceName, - context, - "interface", - ); + const interfaceSymbol = requireSymbol(state, state.interfaces, interfaceName, context, "interface"); if (!atomId || !interfaceSymbol) { return undefined; } - const operationBindings = context.conformanceItem().flatMap< - WorkspaceRevision["conformances"][number]["operationBindings"][number] - >((item) => { - const bindingContext = item.operationBindingDecl(); - if (!bindingContext) { - return []; - } - const memberName = identifier( - bindingContext.memberOperationRef().identifier(), - ); - const operationName = text( - bindingContext.memberOperationRef()?.operationName() ?? null, - ); - const operationId = findOperationId( - state, - interfaceName, - memberName, - operationName, - bindingContext, - ); - if (!operationId) { - return []; - } - const provider = bindingContext.operationProvider(); - if (provider.STATE()) { - const attachmentName = identifier(provider.identifier(0)); - const attachment = requireSymbol( - state, - state.attachments, - attachmentName, - provider, - "attachment", - ); - if (!attachment || attachment.attachment.kind !== "state") { - if (attachment) { - loweringIssue( - state, - provider, - "wrong-attachment-kind", - `${attachmentName} is not state`, - ); - } + const operationBindings = context + .conformanceItem() + .flatMap((item) => { + const bindingContext = item.operationBindingDecl(); + if (!bindingContext) { return []; } - return [ - { - operationId, - binding: { - kind: "state" as const, - slotId: attachment.attachment.id, - primitive: text(provider.statePrimitive()) as StatePrimitive, + const memberName = identifier(bindingContext.memberOperationRef().identifier()); + const operationName = text(bindingContext.memberOperationRef()?.operationName() ?? null); + const operationId = findOperationId(state, interfaceName, memberName, operationName, bindingContext); + if (!operationId) { + return []; + } + const provider = bindingContext.operationProvider(); + if (provider.STATE()) { + const attachmentName = identifier(provider.identifier(0)); + const attachment = requireSymbol(state, state.attachments, attachmentName, provider, "attachment"); + if (!attachment || attachment.attachment.kind !== "state") { + if (attachment) { + loweringIssue(state, provider, "wrong-attachment-kind", `${attachmentName} is not state`); + } + return []; + } + return [ + { + operationId, + binding: { + kind: "state" as const, + slotId: attachment.attachment.id, + primitive: text(provider.statePrimitive()) as StatePrimitive, + }, }, - }, - ]; - } - if (provider.EDGE()) { - const edgeName = identifier(provider.identifier(0)); - const projectionName = identifier(provider.identifier(1)); - const attachment = requireSymbol( - state, - state.attachments, - edgeName, - provider, - "attachment", - ); - if (!attachment || attachment.attachment.kind !== "edge") { - if (attachment) { - loweringIssue( - state, - provider, - "wrong-attachment-kind", - `${edgeName} is not an edge`, - ); - } - return []; + ]; } - const projectionId = requireSymbol( - state, - attachment.projections, - projectionName, - provider, - `projection on ${edgeName}`, - ); - return projectionId + if (provider.EDGE()) { + const edgeName = identifier(provider.identifier(0)); + const projectionName = identifier(provider.identifier(1)); + const attachment = requireSymbol(state, state.attachments, edgeName, provider, "attachment"); + if (!attachment || attachment.attachment.kind !== "edge") { + if (attachment) { + loweringIssue(state, provider, "wrong-attachment-kind", `${edgeName} is not an edge`); + } + return []; + } + const projectionId = requireSymbol( + state, + attachment.projections, + projectionName, + provider, + `projection on ${edgeName}`, + ); + return projectionId + ? [ + { + operationId, + binding: { + kind: "edge" as const, + edgeTypeId: attachment.attachment.id, + projectionId, + primitive: text(provider.edgePrimitive()) as EdgePrimitive, + }, + }, + ] + : []; + } + const packageName = identifier(provider.identifier(0)); + const exportName = identifier(provider.identifier(1)); + const packageSymbol = requireSymbol(state, state.packages, packageName, provider, "package"); + const exportSymbol = packageSymbol + ? requireSymbol(state, packageSymbol.exports, exportName, provider, `export on ${packageName}`) + : undefined; + return packageSymbol && exportSymbol ? [ { operationId, binding: { - kind: "edge" as const, - edgeTypeId: attachment.attachment.id, - projectionId, - primitive: text(provider.edgePrimitive()) as EdgePrimitive, + kind: "package" as const, + packageRevisionId: packageSymbol.revisionId, + exportId: exportSymbol.exportId, + dependencies: lowerBoundDependencies( + state, + provider.dependencyBindingBlock() ?? undefined, + exportSymbol, + ), }, }, ] : []; - } - const packageName = identifier(provider.identifier(0)); - const exportName = identifier(provider.identifier(1)); - const packageSymbol = requireSymbol( - state, - state.packages, - packageName, - provider, - "package", - ); - const exportSymbol = packageSymbol - ? requireSymbol( - state, - packageSymbol.exports, - exportName, - provider, - `export on ${packageName}`, - ) - : undefined; - return packageSymbol && exportSymbol - ? [ - { - operationId, - binding: { - kind: "package" as const, - packageRevisionId: packageSymbol.revisionId, - exportId: exportSymbol.exportId, - dependencies: lowerBoundDependencies( - state, - provider.dependencyBindingBlock() ?? undefined, - exportSymbol, - ), - }, - }, - ] - : []; - }); + }); const relationshipMaterializations = context.conformanceItem().flatMap((item) => { const materialization = item.relationshipMaterializationDecl(); if (!materialization) return []; @@ -1427,35 +1160,31 @@ const lowerConformance = ( const interfaceSymbolFor = (revision: InterfaceRevision): InterfaceSymbol => ({ revisionId: revision.revisionId, contractAvailable: true, - members: new Map(revision.members.map((member) => [ - member.displayName, - { - memberId: member.id, - operations: new Map(member.operations.map((operation) => [ - operation.displayName, - operation.id, - ])), - }, - ])), + members: new Map( + revision.members.map((member) => [ + member.displayName, + { + memberId: member.id, + operations: new Map(member.operations.map((operation) => [operation.displayName, operation.id])), + }, + ]), + ), }); const packageSymbolFor = (revision: PackageRevision): PackageSymbol => ({ revisionId: revision.revisionId, - exports: new Map(revision.exports.map((entry) => [ - entry.displayName, - { - exportId: entry.id, - ports: new Map(entry.dependencyPorts.map((port) => [ - port.displayName, - port.id, - ])), - }, - ])), + exports: new Map( + revision.exports.map((entry) => [ + entry.displayName, + { + exportId: entry.id, + ports: new Map(entry.dependencyPorts.map((port) => [port.displayName, port.id])), + }, + ]), + ), }); -const resourceImport = ( - context: ResourceImportDeclContext, -): CapabilityResourceImport => ({ +const resourceImport = (context: ResourceImportDeclContext): CapabilityResourceImport => ({ kind: context.INTERFACE() ? "interface" : "package", binding: identifier(context.identifier()), }); @@ -1464,54 +1193,45 @@ const registerImports = ( state: LoweringState, contexts: readonly ResourceImportDeclContext[], environment: CapabilityImportEnvironment, -) => contexts.map((context) => { - const imported = resourceImport(context); - if (imported.kind === "interface") { - const revision = environment.interfaces?.get(imported.binding); - if (!revision) { - loweringIssue( - state, - context, - "missing-import", - `No resolved interface is available for lock binding ${imported.binding}`, - ); +) => + contexts.map((context) => { + const imported = resourceImport(context); + if (imported.kind === "interface") { + const revision = environment.interfaces?.get(imported.binding); + if (!revision) { + loweringIssue( + state, + context, + "missing-import", + `No resolved interface is available for lock binding ${imported.binding}`, + ); + } else { + declareSymbol(state, state.interfaces, imported.binding, interfaceSymbolFor(revision), context, "interface"); + } } else { - declareSymbol( - state, - state.interfaces, - imported.binding, - interfaceSymbolFor(revision), - context, - "interface", - ); + const revision = environment.packages?.get(imported.binding); + if (!revision) { + loweringIssue( + state, + context, + "missing-import", + `No resolved package is available for lock binding ${imported.binding}`, + ); + } else { + declareSymbol(state, state.packages, imported.binding, packageSymbolFor(revision), context, "package"); + } } - } else { - const revision = environment.packages?.get(imported.binding); - if (!revision) { - loweringIssue( - state, - context, - "missing-import", - `No resolved package is available for lock binding ${imported.binding}`, - ); - } else { - declareSymbol( - state, - state.packages, - imported.binding, - packageSymbolFor(revision), - context, - "package", - ); - } - } - return imported; -}); + return imported; + }); -const uniqueExactRevisions = (revisions: readonly Revision[]): Revision[] => { +const uniqueExactRevisions = < + Revision extends { + revisionId: string; + source: SourceRevision; + }, +>( + revisions: readonly Revision[], +): Revision[] => { const seen = new Set(); return revisions.filter((revision) => { const key = `${revision.revisionId}\0${revision.source.repository}\0${revision.source.commit}`; @@ -1569,9 +1289,7 @@ const lowerWorkspace = ( { id: atomId, displayName: identifier(atom.identifier()), - documentation: atom.DOC() - ? stringValue(atom.stringLiteral(1)) - : undefined, + documentation: atom.DOC() ? stringValue(atom.stringLiteral(1)) : undefined, }, ] : []; @@ -1588,10 +1306,7 @@ const lowerWorkspace = ( ]); const sharedAttachments: PersistentAttachment[] = []; - const privateAttachments = new Map< - ConformanceDeclContext, - PersistentAttachment[] - >(); + const privateAttachments = new Map(); for (const item of items) { const shared = item.sharedAttachmentDecl(); if (shared) { @@ -1625,11 +1340,7 @@ const lowerWorkspace = ( if (!context) { return []; } - const conformance = lowerConformance( - state, - context, - privateAttachments.get(context) ?? [], - ); + const conformance = lowerConformance(state, context, privateAttachments.get(context) ?? []); return conformance ? [conformance] : []; }); @@ -1642,21 +1353,9 @@ const lowerWorkspace = ( const packageName = identifier(constructor.identifier(1)); const exportName = identifier(constructor.identifier(2)); const atomId = requireSymbol(state, state.atoms, atomName, constructor, "atom"); - const packageSymbol = requireSymbol( - state, - state.packages, - packageName, - constructor, - "package", - ); + const packageSymbol = requireSymbol(state, state.packages, packageName, constructor, "package"); const exportSymbol = packageSymbol - ? requireSymbol( - state, - packageSymbol.exports, - exportName, - constructor, - `export on ${packageName}`, - ) + ? requireSymbol(state, packageSymbol.exports, exportName, constructor, `export on ${packageName}`) : undefined; return atomId && packageSymbol && exportSymbol ? [ @@ -1709,10 +1408,7 @@ export const parseDocument = ( return { tree, tokens, diagnostics }; }; -const newLoweringState = ( - fileName: string, - diagnostics: CapabilitySourceDiagnostic[], -): LoweringState => ({ +const newLoweringState = (fileName: string, diagnostics: CapabilitySourceDiagnostic[]): LoweringState => ({ fileName, diagnostics, atoms: new Map(), @@ -1726,57 +1422,50 @@ const validationDiagnostics = ( issues: ReturnType extends infer _Result ? Array<{ code: string; message: string; path: string }> : never, -): CapabilitySourceDiagnostic[] => issues.map((entry) => ({ - phase: "validation", - code: entry.code, - message: entry.message, - fileName, - line: 0, - column: 0, - path: entry.path, -})); +): CapabilitySourceDiagnostic[] => + issues.map((entry) => ({ + phase: "validation", + code: entry.code, + message: entry.message, + fileName, + line: 0, + column: 0, + path: entry.path, + })); -const externalAtomsFrom = ( - state: LoweringState, - contexts: readonly ExternalAtomDeclContext[], -): AtomDefinition[] => contexts.map((context) => { - const atom: AtomDefinition = { - id: capabilityId.atom(stringValue(context.stringLiteral())), - displayName: identifier(context.identifier()), - }; - declareSymbol( - state, - state.atoms, - atom.displayName, - atom.id, - context, - "external atom", - ); - return atom; -}); +const externalAtomsFrom = (state: LoweringState, contexts: readonly ExternalAtomDeclContext[]): AtomDefinition[] => + contexts.map((context) => { + const atom: AtomDefinition = { + id: capabilityId.atom(stringValue(context.stringLiteral())), + displayName: identifier(context.identifier()), + }; + declareSymbol(state, state.atoms, atom.displayName, atom.id, context, "external atom"); + return atom; + }); const externalInterfacesFrom = ( state: LoweringState, contexts: readonly ExternalInterfaceDeclContext[], -): CapabilityExternalInterface[] => contexts.map((context) => { - const requirement: CapabilityExternalInterface = { - binding: identifier(context.identifier()), - revisionId: capabilityId.interfaceRevision(stringValue(context.stringLiteral())), - }; - declareSymbol( - state, - state.interfaces, - requirement.binding, - { - revisionId: requirement.revisionId, - contractAvailable: false, - members: new Map(), - }, - context, - "external interface", - ); - return requirement; -}); +): CapabilityExternalInterface[] => + contexts.map((context) => { + const requirement: CapabilityExternalInterface = { + binding: identifier(context.identifier()), + revisionId: capabilityId.interfaceRevision(stringValue(context.stringLiteral())), + }; + declareSymbol( + state, + state.interfaces, + requirement.binding, + { + revisionId: requirement.revisionId, + contractAvailable: false, + members: new Map(), + }, + context, + "external interface", + ); + return requirement; + }); const resourcePreambleParts = (contexts: readonly ResourcePreambleContext[]) => ({ imports: contexts.flatMap((context) => context.resourceImportDecl() ?? []), @@ -1793,41 +1482,40 @@ const resourceValidationWorkspace = ( ...[...(environment.interfaces?.values() ?? [])].map((revision) => revision.revisionId), ...(resource.kind === "interface" ? [resource.revision.revisionId] : []), ]); - return ({ - id: capabilityId.workspaceRevision("workspace-revision:resource-validation"), - workspaceId: capabilityId.workspace("workspace:resource-validation"), - parentRevisionIds: [], - sourceRootCommit: resource.revision.source.commit, - atoms: uniqueAtoms([ - ...(environment.externalAtoms ?? []), - ...resource.externalAtoms, - ]), - sharedAttachments: [], - interfaceImports: uniqueExactRevisions([ - ...(environment.interfaceClosure ?? []), - ...[...(environment.interfaces?.values() ?? [])], - ...(resource.kind === "interface" ? [resource.revision] : []), - ...resource.externalInterfaces - .filter((requirement) => !resolvedInterfaceIds.has(requirement.revisionId)) - .map((requirement): InterfaceRevision => ({ - interfaceId: capabilityId.interface(`external:${requirement.revisionId}`), - revisionId: requirement.revisionId, - displayName: requirement.binding, - source: { - repository: `https://external.invalid/${encodeURIComponent(requirement.revisionId)}.git`, - commit: "0".repeat(40), - }, - members: [], - })), - ]), - packageImports: uniqueExactRevisions([ - ...(environment.packageClosure ?? []), - ...[...(environment.packages?.values() ?? [])], - ...(resource.kind === "package" ? [resource.revision] : []), - ]), - conformances: [], - constructors: [], - }); + return { + id: capabilityId.workspaceRevision("workspace-revision:resource-validation"), + workspaceId: capabilityId.workspace("workspace:resource-validation"), + parentRevisionIds: [], + sourceRootCommit: resource.revision.source.commit, + atoms: uniqueAtoms([...(environment.externalAtoms ?? []), ...resource.externalAtoms]), + sharedAttachments: [], + interfaceImports: uniqueExactRevisions([ + ...(environment.interfaceClosure ?? []), + ...[...(environment.interfaces?.values() ?? [])], + ...(resource.kind === "interface" ? [resource.revision] : []), + ...resource.externalInterfaces + .filter((requirement) => !resolvedInterfaceIds.has(requirement.revisionId)) + .map( + (requirement): InterfaceRevision => ({ + interfaceId: capabilityId.interface(`external:${requirement.revisionId}`), + revisionId: requirement.revisionId, + displayName: requirement.binding, + source: { + repository: `https://external.invalid/${encodeURIComponent(requirement.revisionId)}.git`, + commit: "0".repeat(40), + }, + members: [], + }), + ), + ]), + packageImports: uniqueExactRevisions([ + ...(environment.packageClosure ?? []), + ...[...(environment.packages?.values() ?? [])], + ...(resource.kind === "package" ? [resource.revision] : []), + ]), + conformances: [], + constructors: [], + }; }; export const compileCapabilityResourceSource = ( @@ -1850,21 +1538,21 @@ export const compileCapabilityResourceSource = ( if (!interfaceContext && !packageContext) { return { ok: false, - diagnostics: [{ - phase: "lowering", - code: "expected-resource", - message: "Expected a standalone interface or package resource document", - fileName, - line: 1, - column: 0, - }], + diagnostics: [ + { + phase: "lowering", + code: "expected-resource", + message: "Expected a standalone interface or package resource document", + fileName, + line: 1, + column: 0, + }, + ], }; } const state = newLoweringState(fileName, diagnostics); - const preamble = resourcePreambleParts( - (interfaceContext ?? packageContext)!.resourcePreamble(), - ); + const preamble = resourcePreambleParts((interfaceContext ?? packageContext)!.resourcePreamble()); const externalAtoms = externalAtomsFrom(state, preamble.atoms); const imports = registerImports(state, preamble.imports, environment); const externalInterfaces = externalInterfacesFrom(state, preamble.interfaces); @@ -1877,9 +1565,7 @@ export const compileCapabilityResourceSource = ( state.interfaces, alias, { - revisionId: capabilityId.interfaceRevision( - stringValue(interfaceContext.stringLiteral(1)), - ), + revisionId: capabilityId.interfaceRevision(stringValue(interfaceContext.stringLiteral(1))), contractAvailable: true, members: new Map(), }, @@ -1901,9 +1587,7 @@ export const compileCapabilityResourceSource = ( state.packages, alias, { - revisionId: capabilityId.packageRevision( - stringValue(context.stringLiteral(1)), - ), + revisionId: capabilityId.packageRevision(stringValue(context.stringLiteral(1))), exports: new Map(), }, context, @@ -1921,9 +1605,7 @@ export const compileCapabilityResourceSource = ( if (diagnostics.length > 0) { return { ok: false, diagnostics }; } - const compiled = compileWorkspaceRevision( - resourceValidationWorkspace(resource, environment), - ); + const compiled = compileWorkspaceRevision(resourceValidationWorkspace(resource, environment)); if (!compiled.ok) { return { ok: false, @@ -1946,21 +1628,23 @@ export const compileCapabilitySource = ( if (!workspaceContext) { return { ok: false, - diagnostics: [{ - phase: "lowering", - code: "expected-workspace", - message: "Expected a workspace document", - fileName, - line: 1, - column: 0, - }], + diagnostics: [ + { + phase: "lowering", + code: "expected-workspace", + message: "Expected a workspace document", + fileName, + line: 1, + column: 0, + }, + ], }; } const state = newLoweringState(fileName, diagnostics); for (const item of workspaceContext.workspaceItem()) { - if (item.sourceImportDecl()) loweringIssue(state, item, "unresolved-source-import", - "Local imports require the workspace repository compiler"); + if (item.sourceImportDecl()) + loweringIssue(state, item, "unresolved-source-import", "Local imports require the workspace repository compiler"); } if (diagnostics.length) return { ok: false, diagnostics }; const lowered = lowerWorkspace(state, workspaceContext, environment); diff --git a/src/capability-language/pin-upgrades.ts b/src/capability-language/pin-upgrades.ts index 927313b..5748d98 100644 --- a/src/capability-language/pin-upgrades.ts +++ b/src/capability-language/pin-upgrades.ts @@ -1,100 +1,185 @@ import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; -import {randomUUID} from "node:crypto"; -import {execFile as callback} from "node:child_process"; -import {promisify} from "node:util"; -import {loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js"; -import {contentDigest} from "../capability-model/evolution.js"; -import {planStructure, applyStructure, type StructuralRequest} from "./structural-plan.js"; -import {snapshotRepository} from "./candidate-check.js"; -import {compileWorkspaceRepository, compileCapabilityResourceRepository, type ResolvedCapabilityResource} from "./assembly.js"; -import {createGitCapabilityResolver} from "./git-resolver.js"; -import {snapshotCommit, buildImmutableCandidate} from "./checked-build.js"; -import {planEvolution, type WorkspaceRevision, type EvolutionReview} from "../capability-model/index.js"; -import {withFileLock} from "./file-lock.js"; +import { randomUUID } from "node:crypto"; +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { loadQuixosLock, parseQuixosLockDocument } from "../resource-lock/index.js"; +import { contentDigest } from "../capability-model/evolution.js"; +import { planStructure, applyStructure, type StructuralRequest } from "./structural-plan.js"; +import { snapshotRepository } from "./candidate-check.js"; +import { + compileWorkspaceRepository, + compileCapabilityResourceRepository, + type ResolvedCapabilityResource, +} from "./assembly.js"; +import { createGitCapabilityResolver } from "./git-resolver.js"; +import { snapshotCommit, buildImmutableCandidate } from "./checked-build.js"; +import { planEvolution, type WorkspaceRevision, type EvolutionReview } from "../capability-model/index.js"; +import { withFileLock } from "./file-lock.js"; const execFile = promisify(callback); -type Source = {repository: string; commit: string}; -export type UpgradeNode = {kind: "workspace" | "package" | "interface"; directory: string; source: Source}; -export type UpgradeSpec = {nodes: UpgradeNode[]; quixos?: Source; baseline?: string; reviews?: string; bootstrap?: boolean}; -type NodePlan = UpgradeNode & {treeDigest: string; dependencies: string[]; lockFiles: string[]}; -export type UpgradePlan = {schemaVersion: 1; workbench: string; spec: UpgradeSpec; nodes: NodePlan[]; digest: string}; -type Step = {directory: string; phase: "editing" | "prepared" | "refactor" | "checked" | "publishing" | "published"; commit?: string; treeDigest?: string; structuralJournal?: string; structuralPlan?: Awaited>}; -type Journal = {schemaVersion: 1; plan: UpgradePlan; steps: Step[]}; -const sourceKey = (node: {kind: string; source: Source}) => JSON.stringify([node.kind, node.source.repository, node.source.commit]); -const command = async (cwd: string, tool: string, args: string[]) => (await execFile(tool, args, {cwd, maxBuffer: 16 * 1024 * 1024, env: {...process.env, GIT_TERMINAL_PROMPT: "0", QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0"}})).stdout.trim(); +type Source = { repository: string; commit: string }; +export type UpgradeNode = { kind: "workspace" | "package" | "interface"; directory: string; source: Source }; +export type UpgradeSpec = { + nodes: UpgradeNode[]; + quixos?: Source; + baseline?: string; + reviews?: string; + bootstrap?: boolean; +}; +type NodePlan = UpgradeNode & { treeDigest: string; dependencies: string[]; lockFiles: string[] }; +export type UpgradePlan = { schemaVersion: 1; workbench: string; spec: UpgradeSpec; nodes: NodePlan[]; digest: string }; +type Step = { + directory: string; + phase: "editing" | "prepared" | "refactor" | "checked" | "publishing" | "published"; + commit?: string; + treeDigest?: string; + structuralJournal?: string; + structuralPlan?: Awaited>; +}; +type Journal = { schemaVersion: 1; plan: UpgradePlan; steps: Step[] }; +const sourceKey = (node: { kind: string; source: Source }) => + JSON.stringify([node.kind, node.source.repository, node.source.commit]); +const command = async (cwd: string, tool: string, args: string[]) => + ( + await execFile(tool, args, { + cwd, + maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, GIT_TERMINAL_PROMPT: "0", QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0" }, + }) + ).stdout.trim(); const validSource = (value: Source) => { const url = new URL(value.repository); - if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value.commit)) throw new Error("Upgrade sources must be exact credential-free HTTPS revisions"); + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.search || + url.hash || + !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value.commit) + ) + throw new Error("Upgrade sources must be exact credential-free HTTPS revisions"); }; const location = async (root: string, directory: string) => { - if (directory !== "root" && !/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(directory)) throw new Error("Upgrade target must be a managed root/resource repository"); + if (directory !== "root" && !/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(directory)) + throw new Error("Upgrade target must be a managed root/resource repository"); const resolved = await fs.realpath(path.join(root, directory)); if (resolved !== path.join(root, directory)) throw new Error("Upgrade target crosses a symlink"); return resolved; }; const treeDigest = async (root: string) => { const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-upgrade-tree-")); - try {return (await snapshotRepository(root, temporary)).treeDigest;} finally {await fs.rm(temporary, {recursive: true, force: true});} + try { + return (await snapshotRepository(root, temporary)).treeDigest; + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } }; const writeJournal = async (filename: string, journal: unknown) => { const temp = `${filename}.${randomUUID()}.tmp`; const handle = await fs.open(temp, "wx", 0o600); - try {await handle.writeFile(JSON.stringify(journal, null, 2)); await handle.sync();} finally {await handle.close();} + try { + await handle.writeFile(JSON.stringify(journal, null, 2)); + await handle.sync(); + } finally { + await handle.close(); + } await fs.rename(temp, filename); const directory = await fs.open(path.dirname(filename), "r"); - try {await directory.sync();} finally {await directory.close();} + try { + await directory.sync(); + } finally { + await directory.close(); + } }; export const discoverUpgradeSpec = async (workbench: string): Promise => { const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8")); const root = await location(workbench, "root"); - const nodes: UpgradeNode[] = [{kind: "workspace", directory: "root", source: { - repository: await command(root, "git", ["config", "--get", "remote.origin.url"]), - commit: await command(root, "jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"]), - }}, ...graph.resources.map((entry: {kind: "interface" | "package"; directory: string; source: Source}) => ({kind: entry.kind, source: entry.source, - directory: path.relative(workbench, path.resolve(workbench, entry.directory))}))]; + const nodes: UpgradeNode[] = [ + { + kind: "workspace", + directory: "root", + source: { + repository: await command(root, "git", ["config", "--get", "remote.origin.url"]), + commit: await command(root, "jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"]), + }, + }, + ...graph.resources.map((entry: { kind: "interface" | "package"; directory: string; source: Source }) => ({ + kind: entry.kind, + source: entry.source, + directory: path.relative(workbench, path.resolve(workbench, entry.directory)), + })), + ]; let baseline: string | undefined; try { const host = JSON.parse(await fs.readFile("/etc/quixos/workspace-source.json", "utf8")); - if (await fs.realpath(host.workbenchRoot) === await fs.realpath(workbench)) baseline = JSON.parse(await fs.readFile(path.join(host.runtimeClosureRoot, "manifest.json"), "utf8")).workspacePlanPath; - } catch (error) {if (!["ENOENT", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;} - return {nodes, baseline}; + if ((await fs.realpath(host.workbenchRoot)) === (await fs.realpath(workbench))) + baseline = JSON.parse( + await fs.readFile(path.join(host.runtimeClosureRoot, "manifest.json"), "utf8"), + ).workspacePlanPath; + } catch (error) { + if (!["ENOENT", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; + } + return { nodes, baseline }; }; /** Read-only source plan. Repositories are selected explicitly, including any * parallel versions of the same resource; no guesses at a floating 'latest'. */ export const planPinUpgrades = async (workbenchPath: string, spec: UpgradeSpec): Promise => { const workbench = await fs.realpath(workbenchPath); - if (!Array.isArray(spec.nodes) || !spec.nodes.length || spec.nodes.length > 100 || spec.nodes.filter((node) => node.kind === "workspace").length !== 1) throw new Error("Upgrade graph requires one workspace and at most 100 repositories"); + if ( + !Array.isArray(spec.nodes) || + !spec.nodes.length || + spec.nodes.length > 100 || + spec.nodes.filter((node) => node.kind === "workspace").length !== 1 + ) + throw new Error("Upgrade graph requires one workspace and at most 100 repositories"); if (spec.quixos) validSource(spec.quixos); const keys = new Map(); for (const node of spec.nodes) { validSource(node.source); - if (!["workspace", "package", "interface"].includes(node.kind) || keys.has(sourceKey(node))) throw new Error("Duplicate/invalid upgrade resource identity"); + if (!["workspace", "package", "interface"].includes(node.kind) || keys.has(sourceKey(node))) + throw new Error("Duplicate/invalid upgrade resource identity"); keys.set(sourceKey(node), node.directory); } - if (new Set(spec.nodes.map((node) => node.directory)).size !== spec.nodes.length) throw new Error("Upgrade directories must be distinct"); + if (new Set(spec.nodes.map((node) => node.directory)).size !== spec.nodes.length) + throw new Error("Upgrade directories must be distinct"); const nodes: NodePlan[] = []; for (const node of spec.nodes) { const root = await location(workbench, node.directory); - if (await command(root, "git", ["config", "--get", "remote.origin.url"]) !== node.source.repository) throw new Error(`Upgrade origin differs from selected source: ${node.directory}`); + if ((await command(root, "git", ["config", "--get", "remote.origin.url"])) !== node.source.repository) + throw new Error(`Upgrade origin differs from selected source: ${node.directory}`); const loaded = await loadQuixosLock(path.join(root, "quixos.lock")); - if (!loaded.ok) throw new Error(`Invalid lock in ${node.directory}: ${loaded.diagnostics.map((entry) => entry.message).join("; ")}`); - const dependencies = loaded.lock.resources.map((resource) => keys.get(sourceKey(resource))).filter((value): value is string => Boolean(value)); - nodes.push({...node, treeDigest: await treeDigest(root), dependencies: [...new Set(dependencies)], lockFiles: loaded.lock.sourceFiles ?? ["quixos.lock"]}); + if (!loaded.ok) + throw new Error( + `Invalid lock in ${node.directory}: ${loaded.diagnostics.map((entry) => entry.message).join("; ")}`, + ); + const dependencies = loaded.lock.resources + .map((resource) => keys.get(sourceKey(resource))) + .filter((value): value is string => Boolean(value)); + nodes.push({ + ...node, + treeDigest: await treeDigest(root), + dependencies: [...new Set(dependencies)], + lockFiles: loaded.lock.sourceFiles ?? ["quixos.lock"], + }); } - const ordered: NodePlan[] = [], remaining = [...nodes]; + const ordered: NodePlan[] = [], + remaining = [...nodes]; while (remaining.length) { - const index = remaining.findIndex((node) => node.dependencies.every((dependency) => ordered.some((entry) => entry.directory === dependency))); + const index = remaining.findIndex((node) => + node.dependencies.every((dependency) => ordered.some((entry) => entry.directory === dependency)), + ); if (index < 0) throw new Error("Cyclic source publication graph"); ordered.push(remaining.splice(index, 1)[0]); } const workspace = ordered.find((node) => node.kind === "workspace")!; // Even unreferenced new resources are published before the root. - ordered.splice(ordered.indexOf(workspace), 1); ordered.push(workspace); - const plan = {schemaVersion: 1 as const, workbench, spec, nodes: ordered}; - return {...plan, digest: contentDigest(plan)}; + ordered.splice(ordered.indexOf(workspace), 1); + ordered.push(workspace); + const plan = { schemaVersion: 1 as const, workbench, spec, nodes: ordered }; + return { ...plan, digest: contentDigest(plan) }; }; export type UpgradeEffects = { @@ -104,21 +189,27 @@ export type UpgradeEffects = { }; const effects: UpgradeEffects = { async check(node, root, output, spec) { - if (node.kind === "workspace" && !spec.baseline && !spec.bootstrap) throw new Error("Upgrading a workspace requires its checked active baseline for major-review checks (or explicit bootstrap:true for a new workspace)"); + if (node.kind === "workspace" && !spec.baseline && !spec.bootstrap) + throw new Error( + "Upgrading a workspace requires its checked active baseline for major-review checks (or explicit bootstrap:true for a new workspace)", + ); // Explicit baseline upgrades use the same immutable Nix checker. Retaining // an unverified source is safe and must precede a remote flake fetch. await fs.mkdir(output); const commit = await snapshotCommit(root); await effects.publish(root, commit); - const artifact = await buildImmutableCandidate({...node.source, commit}, node.kind, path.join(output, "nix.log")); + const artifact = await buildImmutableCandidate({ ...node.source, commit }, node.kind, path.join(output, "nix.log")); const candidate = await fs.readFile(path.join(artifact, "candidate.json"), "utf8"); await fs.writeFile(path.join(output, "candidate.json"), candidate); if (node.kind === "workspace") { - const baseline = spec.baseline ? JSON.parse(await fs.readFile(spec.baseline, "utf8")) as WorkspaceRevision : null; - const reviews = spec.reviews ? JSON.parse(await fs.readFile(spec.reviews, "utf8")) as EvolutionReview[] : []; - const evolution = planEvolution(baseline, JSON.parse(candidate), {reviews}); + const baseline = spec.baseline + ? (JSON.parse(await fs.readFile(spec.baseline, "utf8")) as WorkspaceRevision) + : null; + const reviews = spec.reviews ? (JSON.parse(await fs.readFile(spec.reviews, "utf8")) as EvolutionReview[]) : []; + const evolution = planEvolution(baseline, JSON.parse(candidate), { reviews }); await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2)); - if (evolution.blockers.length) throw new Error(`Refactor required in ${node.directory}: ${evolution.blockers.join("; ")}`); + if (evolution.blockers.length) + throw new Error(`Refactor required in ${node.directory}: ${evolution.blockers.join("; ")}`); } }, async snapshot(root) { @@ -126,7 +217,10 @@ const effects: UpgradeEffects = { if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Publication did not resolve an exact commit"); await command(root, "git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"]); const tracked = new Set((await command(root, "git", ["ls-tree", "-r", "--name-only", "-z", commit])).split("\0")); - for (const file of (await command(root, "git", ["ls-files", "--others", "--exclude-standard", "-z"])).split("\0").filter(Boolean)) if (!tracked.has(file)) throw new Error(`Uncaptured source file ${file}`); + for (const file of (await command(root, "git", ["ls-files", "--others", "--exclude-standard", "-z"])) + .split("\0") + .filter(Boolean)) + if (!tracked.has(file)) throw new Error(`Uncaptured source file ${file}`); return commit; }, async publish(root, commit) { @@ -134,121 +228,250 @@ const effects: UpgradeEffects = { const remote = await command(root, "git", ["ls-remote", "--refs", "origin", ref]); if (remote && remote !== `${commit}\t${ref}`) throw new Error("Immutable publication ref conflict"); if (!remote) await command(root, "git", ["push", "origin", `${commit}:${ref}`]); - if (await command(root, "git", ["ls-remote", "--refs", "origin", ref]) !== `${commit}\t${ref}`) throw new Error("Publication response uncertain; retry the same journal"); + if ((await command(root, "git", ["ls-remote", "--refs", "origin", ref])) !== `${commit}\t${ref}`) + throw new Error("Publication response uncertain; retry the same journal"); }, }; /** Explicit --publish only. Append-only remote retention; never moves the * workspace branch, activates code, or rolls back previously published nodes. */ -export const applyPinUpgrades = async (plan: UpgradePlan, journalId?: string, implementation: UpgradeEffects = effects, options: {acceptEdits?: boolean} = {}) => { - if (implementation === effects && !plan.spec.baseline && !plan.spec.bootstrap) throw new Error("Publication requires an active checked baseline or explicit bootstrap:true"); - const {digest, ...body} = plan; +export const applyPinUpgrades = async ( + plan: UpgradePlan, + journalId?: string, + implementation: UpgradeEffects = effects, + options: { acceptEdits?: boolean } = {}, +) => { + if (implementation === effects && !plan.spec.baseline && !plan.spec.bootstrap) + throw new Error("Publication requires an active checked baseline or explicit bootstrap:true"); + const { digest, ...body } = plan; if (contentDigest(body) !== digest) throw new Error("Upgrade plan digest mismatch"); const directory = path.join(plan.workbench, ".quixos", "upgrades"); - await fs.mkdir(directory, {recursive: true, mode: 0o700}); - if (await fs.realpath(directory) !== directory) throw new Error("Upgrade journals must not cross symlinks"); + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + if ((await fs.realpath(directory)) !== directory) throw new Error("Upgrade journals must not cross symlinks"); const id = journalId ?? randomUUID(); if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid upgrade journal ID"); const filename = path.join(directory, `${id}.json`); return withFileLock(path.join(directory, "writer.lock"), async () => { - try { - const journal: Journal = journalId ? JSON.parse(await fs.readFile(filename, "utf8")) : {schemaVersion: 1, plan, steps: []}; - if (journal.plan.digest !== plan.digest) throw new Error("Upgrade journal belongs to another plan"); - if (!journalId) await writeJournal(filename, journal); - for (const node of plan.nodes) { - const root = await location(plan.workbench, node.directory); - if (await command(root, "git", ["config", "--get", "remote.origin.url"]) !== node.source.repository) throw new Error("Upgrade remote changed after planning"); - let step = journal.steps.find((entry) => entry.directory === node.directory); - if (step?.phase === "published") continue; - if (!step) { - if (await treeDigest(root) !== node.treeDigest) throw new Error(`Stale upgrade plan: ${node.directory}`); - const files: StructuralRequest["files"] = []; - for (const file of node.lockFiles) { - const parsed = parseQuixosLockDocument(await fs.readFile(path.join(root, file), "utf8")); - if (!parsed.ok) throw new Error("Invalid lock during upgrade"); - const edits = []; - for (const resource of parsed.document.resources) { - const dependency = plan.nodes.find((entry) => sourceKey(entry) === sourceKey(resource)); - const published = dependency && journal.steps.find((entry) => entry.directory === dependency.directory && entry.phase === "published"); - if (published?.commit && published.commit !== resource.source.commit) edits.push({operation: "dependency" as const, kind: resource.kind, name: resource.binding, source: {...resource.source, commit: published.commit}}); + try { + const journal: Journal = journalId + ? JSON.parse(await fs.readFile(filename, "utf8")) + : { schemaVersion: 1, plan, steps: [] }; + if (journal.plan.digest !== plan.digest) throw new Error("Upgrade journal belongs to another plan"); + if (!journalId) await writeJournal(filename, journal); + for (const node of plan.nodes) { + const root = await location(plan.workbench, node.directory); + if ((await command(root, "git", ["config", "--get", "remote.origin.url"])) !== node.source.repository) + throw new Error("Upgrade remote changed after planning"); + let step = journal.steps.find((entry) => entry.directory === node.directory); + if (step?.phase === "published") continue; + if (!step) { + if ((await treeDigest(root)) !== node.treeDigest) throw new Error(`Stale upgrade plan: ${node.directory}`); + const files: StructuralRequest["files"] = []; + for (const file of node.lockFiles) { + const parsed = parseQuixosLockDocument(await fs.readFile(path.join(root, file), "utf8")); + if (!parsed.ok) throw new Error("Invalid lock during upgrade"); + const edits = []; + for (const resource of parsed.document.resources) { + const dependency = plan.nodes.find((entry) => sourceKey(entry) === sourceKey(resource)); + const published = + dependency && + journal.steps.find((entry) => entry.directory === dependency.directory && entry.phase === "published"); + if (published?.commit && published.commit !== resource.source.commit) + edits.push({ + operation: "dependency" as const, + kind: resource.kind, + name: resource.binding, + source: { ...resource.source, commit: published.commit }, + }); + } + if (parsed.document.kind === "root" && plan.spec.quixos) + edits.push({ operation: "quixos-pin" as const, source: plan.spec.quixos }); + if (edits.length) files.push({ file, edits }); } - if (parsed.document.kind === "root" && plan.spec.quixos) edits.push({operation: "quixos-pin" as const, source: plan.spec.quixos}); - if (edits.length) files.push({file, edits}); + const structuralPlan = files.length + ? await planStructure( + root, + { kind: node.kind, source: node.source, files }, + process.env.QUIXOS_SNAPSHOT_MAP, + ) + : undefined; + step = { + directory: node.directory, + phase: "editing", + treeDigest: node.treeDigest, + structuralPlan, + structuralJournal: structuralPlan ? randomUUID() : undefined, + }; + journal.steps.push(step); + await writeJournal(filename, journal); } - const structuralPlan = files.length ? await planStructure(root, {kind: node.kind, source: node.source, files}, process.env.QUIXOS_SNAPSHOT_MAP) : undefined; - step = {directory: node.directory, phase: "editing", treeDigest: node.treeDigest, structuralPlan, structuralJournal: structuralPlan ? randomUUID() : undefined}; - journal.steps.push(step); await writeJournal(filename, journal); - } - if (step.phase === "editing") { - if (step.structuralPlan) await applyStructure(step.structuralPlan, step.structuralJournal); - else if (await treeDigest(root) !== step.treeDigest) throw new Error("Source changed before upgrade editing"); - step.treeDigest = await treeDigest(root); - step.phase = "prepared"; + if (step.phase === "editing") { + if (step.structuralPlan) await applyStructure(step.structuralPlan, step.structuralJournal); + else if ((await treeDigest(root)) !== step.treeDigest) + throw new Error("Source changed before upgrade editing"); + step.treeDigest = await treeDigest(root); + step.phase = "prepared"; + await writeJournal(filename, journal); + } + if (step.phase === "refactor") { + const current = await treeDigest(root); + if (current !== step.treeDigest && !options.acceptEdits) + throw new Error("Refactored source requires --accept-edits when resuming"); + step.treeDigest = current; + step.phase = "prepared"; + await writeJournal(filename, journal); + } + if ((await treeDigest(root)) !== step.treeDigest) + throw new Error(`Source changed during upgrade: ${node.directory}; inspect ${filename}`); + if (step.phase === "prepared") { + try { + await implementation.check( + node, + root, + path.join(directory, `${id}-${node.directory.replaceAll("/", "-")}-${randomUUID()}`), + plan.spec, + ); + } catch (error) { + step.phase = "refactor"; + await writeJournal(filename, journal); + throw error; + } + if ((await treeDigest(root)) !== step.treeDigest) throw new Error("Source changed while checking"); + step.phase = "checked"; + await writeJournal(filename, journal); + } + if (step.phase === "checked") { + step.commit = await implementation.snapshot(root); + if ((await treeDigest(root)) !== step.treeDigest) + throw new Error("Publication snapshot changed checked files"); + step.phase = "publishing"; + await writeJournal(filename, journal); + } + await implementation.publish(root, step.commit!); + step.phase = "published"; await writeJournal(filename, journal); } - if (step.phase === "refactor") { - const current = await treeDigest(root); - if (current !== step.treeDigest && !options.acceptEdits) throw new Error("Refactored source requires --accept-edits when resuming"); - step.treeDigest = current; step.phase = "prepared"; await writeJournal(filename, journal); + // Keep subsequent automatic upgrades associated with the newly published + // identities, without renaming repositories or changing any selected branch. + // Explicit-spec callers without a managed graph retain the journal as their + // source of revisions instead. + const graphFile = path.join(plan.workbench, ".quixos/resource-graph.json"); + let graphText: string | undefined; + try { + graphText = await fs.readFile(graphFile, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } - if (await treeDigest(root) !== step.treeDigest) throw new Error(`Source changed during upgrade: ${node.directory}; inspect ${filename}`); - if (step.phase === "prepared") { - try {await implementation.check(node, root, path.join(directory, `${id}-${node.directory.replaceAll("/", "-")}-${randomUUID()}`), plan.spec);} - catch (error) {step.phase = "refactor"; await writeJournal(filename, journal); throw error;} - if (await treeDigest(root) !== step.treeDigest) throw new Error("Source changed while checking"); - step.phase = "checked"; await writeJournal(filename, journal); + if (graphText !== undefined) { + const previous = JSON.parse(graphText); + const snapshots = await Promise.all( + previous.resources.map(async (entry: { kind: string; source: Source; directory: string }) => ({ + kind: entry.kind, + ...entry.source, + directory: await location( + plan.workbench, + path.relative(plan.workbench, path.resolve(plan.workbench, entry.directory)), + ), + })), + ); + for (const node of plan.nodes.filter((entry) => entry.kind !== "workspace")) { + const step = journal.steps.find((entry) => entry.directory === node.directory)!; + if ((await treeDigest(await location(plan.workbench, node.directory))) !== step.treeDigest) + throw new Error("Published source changed before workbench graph refresh"); + snapshots.push({ + kind: node.kind, + repository: node.source.repository, + commit: step.commit, + directory: path.join(plan.workbench, node.directory), + }); + } + const unique = [ + ...new Map( + snapshots.map((entry: { kind: string; repository: string; commit: string }) => [ + JSON.stringify([entry.kind, entry.repository, entry.commit]), + entry, + ]), + ).values(), + ]; + const snapshotMap = path.join(directory, `${id}-published-snapshots.json`); + await fs.writeFile(snapshotMap, JSON.stringify({ resources: unique })); + const resolveResource = await createGitCapabilityResolver({ + checkoutRoot: path.join(directory, `${id}-graph-resources`), + snapshotMap, + snapshotOnly: true, + }); + const rootNode = plan.nodes.find((node) => node.kind === "workspace")!; + if ( + (await treeDigest(await location(plan.workbench, rootNode.directory))) !== + journal.steps.find((step) => step.directory === rootNode.directory)!.treeDigest + ) + throw new Error("Root source changed before workbench graph refresh"); + const compiled = await compileWorkspaceRepository({ + rootDirectory: await location(plan.workbench, rootNode.directory), + resolveResource, + }); + // Managed repositories need not currently be reachable from the workspace. + // Keep them discoverable/checkpointed until explicitly removed by the user. + const resources = new Map( + compiled.resources.map((node) => [node.key, node]), + ); + for (const node of plan.nodes.filter((entry) => entry.kind !== "workspace")) { + const step = journal.steps.find((entry) => entry.directory === node.directory)!; + const source = { resolver: "git" as const, repository: node.source.repository, commit: step.commit! }; + const key = `${node.kind}\0${source.repository}\0${source.commit}`; + if (resources.has(key)) continue; + const directory = await location(plan.workbench, node.directory); + const standalone = await compileCapabilityResourceRepository({ + rootDirectory: directory, + kind: node.kind as "package" | "interface", + source, + resolveResource, + }); + for (const dependency of standalone.resources) resources.set(dependency.key, dependency); + resources.set(key, { + key, + kind: node.kind as "package" | "interface", + source, + directory, + lock: standalone.lock, + resource: standalone.resource, + dependencies: standalone.directResources, + }); + } + await writeJournal(graphFile, { + formatVersion: 1, + quixos: compiled.lock.quixos, + directResources: [...compiled.directResources.entries()].map(([bindingKey, node]) => { + const [kind, binding] = bindingKey.split("\0"); + return { kind, binding, resourceKey: node.key, directory: node.directory }; + }), + resources: [...resources.values()].map((node) => ({ + key: node.key, + kind: node.kind, + source: node.source, + directory: node.directory, + resourceId: + node.resource.kind === "interface" + ? node.resource.revision.interfaceId + : node.resource.revision.packageId, + revisionId: node.resource.revision.revisionId, + dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({ + binding, + resourceKey: dependency.key, + })), + })), + }); } - if (step.phase === "checked") { - step.commit = await implementation.snapshot(root); - if (await treeDigest(root) !== step.treeDigest) throw new Error("Publication snapshot changed checked files"); - step.phase = "publishing"; await writeJournal(filename, journal); - } - await implementation.publish(root, step.commit!); - step.phase = "published"; await writeJournal(filename, journal); - } - // Keep subsequent automatic upgrades associated with the newly published - // identities, without renaming repositories or changing any selected branch. - // Explicit-spec callers without a managed graph retain the journal as their - // source of revisions instead. - const graphFile = path.join(plan.workbench, ".quixos/resource-graph.json"); - let graphText: string | undefined; - try {graphText = await fs.readFile(graphFile, "utf8");} catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} - if (graphText !== undefined) { - const previous = JSON.parse(graphText); - const snapshots = await Promise.all(previous.resources.map(async (entry: {kind: string; source: Source; directory: string}) => ({kind: entry.kind, ...entry.source, directory: await location(plan.workbench, path.relative(plan.workbench, path.resolve(plan.workbench, entry.directory)))}))); - for (const node of plan.nodes.filter((entry) => entry.kind !== "workspace")) { - const step = journal.steps.find((entry) => entry.directory === node.directory)!; - if (await treeDigest(await location(plan.workbench, node.directory)) !== step.treeDigest) throw new Error("Published source changed before workbench graph refresh"); - snapshots.push({kind: node.kind, repository: node.source.repository, commit: step.commit, directory: path.join(plan.workbench, node.directory)}); - } - const unique = [...new Map(snapshots.map((entry: {kind: string; repository: string; commit: string}) => [JSON.stringify([entry.kind, entry.repository, entry.commit]), entry])).values()]; - const snapshotMap = path.join(directory, `${id}-published-snapshots.json`); - await fs.writeFile(snapshotMap, JSON.stringify({resources: unique})); - const resolveResource = await createGitCapabilityResolver({checkoutRoot: path.join(directory, `${id}-graph-resources`), snapshotMap, snapshotOnly: true}); - const rootNode = plan.nodes.find((node) => node.kind === "workspace")!; - if (await treeDigest(await location(plan.workbench, rootNode.directory)) !== journal.steps.find((step) => step.directory === rootNode.directory)!.treeDigest) throw new Error("Root source changed before workbench graph refresh"); - const compiled = await compileWorkspaceRepository({rootDirectory: await location(plan.workbench, rootNode.directory), resolveResource}); - // Managed repositories need not currently be reachable from the workspace. - // Keep them discoverable/checkpointed until explicitly removed by the user. - const resources = new Map(compiled.resources.map((node) => [node.key, node])); - for (const node of plan.nodes.filter((entry) => entry.kind !== "workspace")) { - const step = journal.steps.find((entry) => entry.directory === node.directory)!; - const source = {resolver: "git" as const, repository: node.source.repository, commit: step.commit!}; - const key = `${node.kind}\0${source.repository}\0${source.commit}`; - if (resources.has(key)) continue; - const directory = await location(plan.workbench, node.directory); - const standalone = await compileCapabilityResourceRepository({rootDirectory: directory, kind: node.kind as "package" | "interface", source, resolveResource}); - for (const dependency of standalone.resources) resources.set(dependency.key, dependency); - resources.set(key, {key, kind: node.kind as "package" | "interface", source, directory, lock: standalone.lock, resource: standalone.resource, dependencies: standalone.directResources}); - } - await writeJournal(graphFile, {formatVersion: 1, quixos: compiled.lock.quixos, - directResources: [...compiled.directResources.entries()].map(([bindingKey, node]) => {const [kind, binding] = bindingKey.split("\0"); return {kind, binding, resourceKey: node.key, directory: node.directory};}), - resources: [...resources.values()].map((node) => ({key: node.key, kind: node.kind, source: node.source, directory: node.directory, - resourceId: node.resource.kind === "interface" ? node.resource.revision.interfaceId : node.resource.revision.packageId, - revisionId: node.resource.revision.revisionId, dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({binding, resourceKey: dependency.key}))})), + return { + id, + journal: filename, + revisions: journal.steps.map((step) => ({ directory: step.directory, commit: step.commit })), + activated: false, + }; + } catch (error) { + throw new Error(`${error instanceof Error ? error.message : String(error)}; upgrade journal ${filename}`, { + cause: error, }); } - return {id, journal: filename, revisions: journal.steps.map((step) => ({directory: step.directory, commit: step.commit})), activated: false}; - } catch (error) {throw new Error(`${error instanceof Error ? error.message : String(error)}; upgrade journal ${filename}`, {cause: error});} }); }; diff --git a/src/capability-language/resource-cli.ts b/src/capability-language/resource-cli.ts index 7ae8179..77c8de3 100644 --- a/src/capability-language/resource-cli.ts +++ b/src/capability-language/resource-cli.ts @@ -3,10 +3,7 @@ import { writeFile } from "node:fs/promises"; import process from "node:process"; import { bindingSchema } from "../bindings/index.js"; -import { - compileCapabilityResourceRepository, - type ResolvedCapabilityResource, -} from "./assembly.js"; +import { compileCapabilityResourceRepository, type ResolvedCapabilityResource } from "./assembly.js"; import { createGitCapabilityResolver } from "./git-resolver.js"; const usage = `usage: quixos-resource-compile --root DIRECTORY --kind interface|package @@ -22,7 +19,21 @@ const parseArgs = (args: string[]) => { const key = args[index]; const value = args[index + 1]; if (!key?.startsWith("--") || !value) throw new Error(usage); - if (!["--root", "--kind", "--repository", "--commit", "--checkout-root", "--snapshot-map", "--snapshot-only", "--graph-out", "--schema-out"].includes(key) || values.has(key)) throw new Error(usage); + if ( + ![ + "--root", + "--kind", + "--repository", + "--commit", + "--checkout-root", + "--snapshot-map", + "--snapshot-only", + "--graph-out", + "--schema-out", + ].includes(key) || + values.has(key) + ) + throw new Error(usage); values.set(key, value); } const rootDirectory = values.get("--root"); @@ -30,14 +41,14 @@ const parseArgs = (args: string[]) => { const repository = values.get("--repository"); const commit = values.get("--commit")?.toLowerCase(); const checkoutRoot = values.get("--checkout-root"); - if (!rootDirectory || !repository || !commit || !checkoutRoot || - (kind !== "interface" && kind !== "package")) { + if (!rootDirectory || !repository || !commit || !checkoutRoot || (kind !== "interface" && kind !== "package")) { throw new Error(usage); } if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) { throw new Error("--commit must be a full Git object ID"); } - if (values.has("--snapshot-only") && values.get("--snapshot-only") !== "true") throw new Error("--snapshot-only accepts true"); + if (values.has("--snapshot-only") && values.get("--snapshot-only") !== "true") + throw new Error("--snapshot-only accepts true"); return { rootDirectory, kind, @@ -56,9 +67,8 @@ const graphEntry = (node: ResolvedCapabilityResource) => ({ kind: node.kind, source: node.source, directory: node.directory, - resourceId: node.resource.kind === "interface" - ? node.resource.revision.interfaceId - : node.resource.revision.packageId, + resourceId: + node.resource.kind === "interface" ? node.resource.revision.interfaceId : node.resource.revision.packageId, revisionId: node.resource.revision.revisionId, dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({ binding, @@ -88,21 +98,32 @@ const main = async () => { resolveResource, }); if (options.graphOut) { - await writeFile(options.graphOut, `${JSON.stringify({ - formatVersion: 1, - quixos: compiled.lock.quixos, - root: graphEntry(compiled.resources.find((node) => - node.source.repository === options.repository && - node.source.commit === options.commit && - node.kind === options.kind)!), - resources: compiled.resources.map(graphEntry), - }, null, 2)}\n`); + await writeFile( + options.graphOut, + `${JSON.stringify( + { + formatVersion: 1, + quixos: compiled.lock.quixos, + root: graphEntry( + compiled.resources.find( + (node) => + node.source.repository === options.repository && + node.source.commit === options.commit && + node.kind === options.kind, + )!, + ), + resources: compiled.resources.map(graphEntry), + }, + null, + 2, + )}\n`, + ); } if (options.schemaOut) await writeFile(options.schemaOut, `${JSON.stringify(bindingSchema(compiled), null, 2)}\n`); process.stdout.write(`${JSON.stringify(compiled.resource, null, 2)}\n`); }; main().catch((error: unknown) => { - process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); process.exitCode = 1; }); diff --git a/src/capability-language/scaffold-recipes.ts b/src/capability-language/scaffold-recipes.ts index bbd0f2c..7a3dd20 100644 --- a/src/capability-language/scaffold-recipes.ts +++ b/src/capability-language/scaffold-recipes.ts @@ -1,33 +1,57 @@ import fs from "node:fs/promises"; import path from "node:path"; -import {contentDigest} from "../capability-model/evolution.js"; -import {validateMigrationCatalog, type MigrationCatalog, type MigrationDeclaration} from "../capability-model/migrations.js"; -import {formatQuixosLock, type GitSource} from "../resource-lock/index.js"; -import {parseQx, walkSyntax} from "./source.js"; -import type {StructuralRequest} from "./structural-plan.js"; -import {addImplementation} from "./implementation-edit.js"; -import {reactPlatformTypes} from "../bindings/react-platform.js"; +import { contentDigest } from "../capability-model/evolution.js"; +import { + validateMigrationCatalog, + type MigrationCatalog, + type MigrationDeclaration, +} from "../capability-model/migrations.js"; +import { formatQuixosLock, type GitSource } from "../resource-lock/index.js"; +import { parseQx, walkSyntax } from "./source.js"; +import type { StructuralRequest } from "./structural-plan.js"; +import { addImplementation } from "./implementation-edit.js"; +import { reactPlatformTypes } from "../bindings/react-platform.js"; -type Source = {repository: string; commit: string}; -type PackageEditModel = {generatedBy: "qx-scaffold-v1"; name: string; id: string; revision: string; exports: {name: string; id: string; file: string; migration?: boolean}[]}; +type Source = { repository: string; commit: string }; +type PackageEditModel = { + generatedBy: "qx-scaffold-v1"; + name: string; + id: string; + revision: string; + exports: { name: string; id: string; file: string; migration?: boolean }[]; +}; export type ScaffoldRecipe = { template?: "typescript" | "typescript-react"; - source: Source; directory?: string; name?: string; id?: string; revision?: string; + source: Source; + directory?: string; + name?: string; + id?: string; + revision?: string; declaration?: string; /** Initial authored files for a composed recipe; only used for a new package. */ initialFiles?: Record; - tools?: {quixos: Source; protocol: Source; helpers: Source; sdk: Source}; + tools?: { quixos: Source; protocol: Source; helpers: Source; sdk: Source }; nixifyPluginUrl?: string; - migration?: Omit & {contracts: Record}; + migration?: Omit & { contracts: Record }; }; const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; const source = (value: Source): GitSource => { - if (!value || typeof value.repository !== "string" || typeof value.commit !== "string") throw new Error("Scaffold requires an exact source identity; use qx-workspace from a registered repository"); + if (!value || typeof value.repository !== "string" || typeof value.commit !== "string") + throw new Error("Scaffold requires an exact source identity; use qx-workspace from a registered repository"); const url = new URL(value.repository); - if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit)) throw new Error("Scaffolds require credential-free HTTPS sources and full exact commits"); - return {resolver: "git", ...value}; + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.search || + url.hash || + !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit) + ) + throw new Error("Scaffolds require credential-free HTTPS sources and full exact commits"); + return { resolver: "git", ...value }; }; -const nixSource = (value: Source) => `git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`; +const nixSource = (value: Source) => + `git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`; const nixString = (value: string) => JSON.stringify(value).replaceAll("${", "\\${"); const safeName = (name: string | undefined): string => { if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("Scaffold requires a simple authored name"); @@ -35,7 +59,8 @@ const safeName = (name: string | undefined): string => { }; const ownedJson = async (root: string, file: string): Promise => { const target = path.join(root, file); - if (!(await fs.realpath(target)).startsWith(`${await fs.realpath(root)}/`)) throw new Error("Scaffold input escapes repository"); + if (!(await fs.realpath(target)).startsWith(`${await fs.realpath(root)}/`)) + throw new Error("Scaffold input escapes repository"); const value = JSON.parse(await fs.readFile(target, "utf8")); if (value.generatedBy !== "qx-scaffold-v1") throw new Error(`Not scaffold-owned: ${file}`); return value; @@ -43,45 +68,133 @@ const ownedJson = async (root: string, file: string): Promise => { /** Recipes describe structural edits; planStructure owns validation/journaling. * Implementation files are created once; later edits preserve authored wiring. */ -export const scaffoldRecipe = async (root: string, command: "package" | "function" | "migration" | "refresh", spec: ScaffoldRecipe): Promise => { - if (command === "refresh") throw new Error("Scaffold refresh has been removed. Edit declarations and typed server wiring directly, then run qx-workspace check. Use scaffold function to add a declaration and handler together."); +export const scaffoldRecipe = async ( + root: string, + command: "package" | "function" | "migration" | "refresh", + spec: ScaffoldRecipe, +): Promise => { + if (command === "refresh") + throw new Error( + "Scaffold refresh has been removed. Edit declarations and typed server wiring directly, then run qx-workspace check. Use scaffold function to add a declaration and handler together.", + ); source(spec.source); - if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory)) throw new Error("Scaffold directory must be contained"); + if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory)) + throw new Error("Scaffold directory must be contained"); const prefix = spec.directory ? `${spec.directory}/` : ""; const files: StructuralRequest["files"] = []; - const create = (file: string, content: string) => files.push({file: prefix + file, create: content}); - const generated = (file: string, content: string) => files.push({file: prefix + file, generated: content}); + const create = (file: string, content: string) => files.push({ file: prefix + file, create: content }); + const generated = (file: string, content: string) => files.push({ file: prefix + file, generated: content }); let packageModel: PackageEditModel; - let catalog: MigrationCatalog & {generatedBy: "qx-scaffold-v1"}; + let catalog: MigrationCatalog & { generatedBy: "qx-scaffold-v1" }; if (command === "package") { const name = safeName(spec.name); - if (spec.template && !["typescript", "typescript-react"].includes(spec.template)) throw new Error("Unknown package template"); + if (spec.template && !["typescript", "typescript-react"].includes(spec.template)) + throw new Error("Unknown package template"); const react = spec.template === "typescript-react"; - if (!spec.id || !spec.revision || !spec.tools) throw new Error("Package scaffold requires id, revision, and exact quixos/protocol/helpers/sdk tool sources"); + if (!spec.id || !spec.revision || !spec.tools) + throw new Error("Package scaffold requires id, revision, and exact quixos/protocol/helpers/sdk tool sources"); Object.values(spec.tools).forEach(source); - packageModel = {generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: []}; - catalog = {generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: []}; - if (react) packageModel.exports.push({name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts"}); - create("package.qx", `package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n${react ? ` function sourceGet id ${JSON.stringify(`export:${name}:source`)} : unit -> string;\n` : ""}}\n`); - create("quixos.lock", formatQuixosLock({formatVersion: 1, quixos: source(spec.tools.quixos), resources: []})); - create("package.json", json({name: `@quixos/${name.toLowerCase()}`, version: "0.1.0", private: true, type: "module", packageManager: "yarn@4.18.0", - scripts: {build: "tsc -p tsconfig.json", typecheck: "tsc --noEmit"}, dependencies: {"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`}, - devDependencies: {"@types/node": "^24", typescript: "^7.0.2", ...(react ? {react: "^18.3.1", "@types/react": "^18.3.12", esbuild: "^0.25.12"} : {})}})); - create("tsconfig.json", json({compilerOptions: {target: "ES2023", module: "NodeNext", moduleResolution: "NodeNext", strict: true, types: ["node", ...(react ? ["react"] : [])], outDir: "dist", rootDir: "src", skipLibCheck: true, ...(react ? {jsx: "react-jsx", esModuleInterop: true} : {})}, include: ["src/**/*.ts", "src/**/*.tsx"]})); + packageModel = { generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: [] }; + catalog = { generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: [] }; + if (react) + packageModel.exports.push({ name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts" }); + create( + "package.qx", + `package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n${react ? ` function sourceGet id ${JSON.stringify(`export:${name}:source`)} : unit -> string;\n` : ""}}\n`, + ); + create("quixos.lock", formatQuixosLock({ formatVersion: 1, quixos: source(spec.tools.quixos), resources: [] })); + create( + "package.json", + json({ + name: `@quixos/${name.toLowerCase()}`, + version: "0.1.0", + private: true, + type: "module", + packageManager: "yarn@4.18.0", + scripts: { build: "tsc -p tsconfig.json", typecheck: "tsc --noEmit" }, + dependencies: { + "@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`, + }, + devDependencies: { + "@types/node": "^24", + typescript: "^7.0.2", + ...(react ? { react: "^18.3.1", "@types/react": "^18.3.12", esbuild: "^0.25.12" } : {}), + }, + }), + ); + create( + "tsconfig.json", + json({ + compilerOptions: { + target: "ES2023", + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + types: ["node", ...(react ? ["react"] : [])], + outDir: "dist", + rootDir: "src", + skipLibCheck: true, + ...(react ? { jsx: "react-jsx", esModuleInterop: true } : {}), + }, + include: ["src/**/*.ts", "src/**/*.tsx"], + }), + ); if (react) { - create("src/component.tsx", `// Props are opaque at the platform boundary until capability generics exist.\nexport default function Component(_props: {camino: unknown; render: unknown; dispatch: (action: unknown) => void}) {\n return

${name}

Edit this component, then run qx-workspace check.

;\n}\n`); - create("src/impl/sourceGet.ts", `import componentSource from "../component.js?browser-source";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => componentSource;\n`); - create("src/browser-assets.d.ts", `declare module "*?browser-source" { const source: string; export default source; }\ndeclare module "*.css" {}\n`); + create( + "src/component.tsx", + `// Props are opaque at the platform boundary until capability generics exist.\nexport default function Component(_props: {camino: unknown; render: unknown; dispatch: (action: unknown) => void}) {\n return

${name}

Edit this component, then run qx-workspace check.

;\n}\n`, + ); + create( + "src/impl/sourceGet.ts", + `import componentSource from "../component.js?browser-source";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => componentSource;\n`, + ); + create( + "src/browser-assets.d.ts", + `declare module "*?browser-source" { const source: string; export default source; }\ndeclare module "*.css" {}\n`, + ); create("src/gen/web-studio-react-runtime.d.ts", reactPlatformTypes); } create(".gitignore", "node_modules/\ndist/\n.quixos/\nresult\n.yarn/install-state.gz\n"); - create(".yarnrc.yml", `nodeLinker: node-modules\nenableScripts: true\nnpmMinimalAgeGate: 0\napprovedGitRepositories:\n - ${JSON.stringify(spec.tools.sdk.repository)}\nsupportedArchitectures:\n os: [current, linux]\n cpu: [current, x64, arm64]\n libc: [current, glibc]\n`); - const nixifyPluginUrl = spec.nixifyPluginUrl ?? "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js"; + create( + ".yarnrc.yml", + `nodeLinker: node-modules\nenableScripts: true\nnpmMinimalAgeGate: 0\napprovedGitRepositories:\n - ${JSON.stringify(spec.tools.sdk.repository)}\nsupportedArchitectures:\n os: [current, linux]\n cpu: [current, x64, arm64]\n libc: [current, glibc]\n`, + ); + const nixifyPluginUrl = + spec.nixifyPluginUrl ?? + "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js"; const plugin = new URL(nixifyPluginUrl); - if (plugin.protocol !== "https:" || plugin.username || plugin.password || plugin.search || plugin.hash || !/\/commit\/[a-f0-9]{40,64}\//.test(plugin.pathname)) throw new Error("Nixify plugin must have an exact credential-free HTTPS commit URL"); - generated("quixos.toolchain.json", json({generatedBy: "qx-scaffold-v1", nixifyPluginUrl})); - create("quixos.check.json", json({backend: "typescript", bindingOutput: "src/gen/qx.ts", ...(react ? {options: {messages: {"org.quixos.web-studio.ReactProps": {module: "@quixos/camino-package-runtime", export: "opaqueReactPropsBinding"}}}} : {})})); - create("flake.nix", `{ + if ( + plugin.protocol !== "https:" || + plugin.username || + plugin.password || + plugin.search || + plugin.hash || + !/\/commit\/[a-f0-9]{40,64}\//.test(plugin.pathname) + ) + throw new Error("Nixify plugin must have an exact credential-free HTTPS commit URL"); + generated("quixos.toolchain.json", json({ generatedBy: "qx-scaffold-v1", nixifyPluginUrl })); + create( + "quixos.check.json", + json({ + backend: "typescript", + bindingOutput: "src/gen/qx.ts", + ...(react + ? { + options: { + messages: { + "org.quixos.web-studio.ReactProps": { + module: "@quixos/camino-package-runtime", + export: "opaqueReactPropsBinding", + }, + }, + }, + } + : {}), + }), + ); + create( + "flake.nix", + `{ inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))}; inputs.nixpkgs.follows = "protocol/nixpkgs"; inputs.flake-utils.follows = "protocol/flake-utils"; @@ -93,54 +206,101 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio migrationEntrypoint = "dist/migrate.js"; installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; }; }; -}\n`); +}\n`, + ); } else { catalog = await ownedJson(root, prefix + "quixos.migrations.json"); // Derive the edit model from authored declarations; it is never persisted. const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8"); const syntax = parseQx(authored); - if (syntax.diagnostics.length) throw new Error("Cannot scaffold into an invalid package.qx; fix the reported syntax first"); - const declaration = [...walkSyntax(syntax.root)].find(node => node.kind === "packageResourceDecl"); + if (syntax.diagnostics.length) + throw new Error("Cannot scaffold into an invalid package.qx; fix the reported syntax first"); + const declaration = [...walkSyntax(syntax.root)].find((node) => node.kind === "packageResourceDecl"); if (!declaration) throw new Error("Expected a package declaration"); const text = (node: typeof declaration) => authored.slice(node.start, node.end); - const literals = declaration.children.filter(node => node.kind === "stringLiteral"); - packageModel = {generatedBy: "qx-scaffold-v1", name: text(declaration.children.find(node => node.kind === "identifier")!), - id: JSON.parse(text(literals[0])), revision: JSON.parse(text(literals[1])), exports: []}; + const literals = declaration.children.filter((node) => node.kind === "stringLiteral"); + packageModel = { + generatedBy: "qx-scaffold-v1", + name: text(declaration.children.find((node) => node.kind === "identifier")!), + id: JSON.parse(text(literals[0])), + revision: JSON.parse(text(literals[1])), + exports: [], + }; for (const node of walkSyntax(declaration)) { - if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)) continue; - const name = safeName(text(node.children.find(child => child.kind === "identifier")!)); - const id = JSON.parse(text(node.children.find(child => child.kind === "stringLiteral")!)); - if (packageModel.exports.some(entry => entry.id === id || entry.name === name)) throw new Error("Duplicate package export name or ID"); - const migration = catalog.migrations.find(entry => entry.implementation.exportId === id); - packageModel.exports.push({name, id, file: migration?.implementation.file ?? `src/impl/${name}.ts`, ...(migration ? {migration: true} : {})}); + if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)) + continue; + const name = safeName(text(node.children.find((child) => child.kind === "identifier")!)); + const id = JSON.parse(text(node.children.find((child) => child.kind === "stringLiteral")!)); + if (packageModel.exports.some((entry) => entry.id === id || entry.name === name)) + throw new Error("Duplicate package export name or ID"); + const migration = catalog.migrations.find((entry) => entry.implementation.exportId === id); + packageModel.exports.push({ + name, + id, + file: migration?.implementation.file ?? `src/impl/${name}.ts`, + ...(migration ? { migration: true } : {}), + }); } { const name = safeName(spec.name); - if (!spec.id || packageModel.exports.some((entry) => entry.id === spec.id || entry.name === name)) throw new Error("New export requires a unique name and ID"); + if (!spec.id || packageModel.exports.some((entry) => entry.id === spec.id || entry.name === name)) + throw new Error("New export requires a unique name and ID"); const declaration = spec.declaration ?? `function ${name} id ${JSON.stringify(spec.id)} : unit -> unit;`; const parsed = parseQx(`package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`); - const exports = [...walkSyntax(parsed.root)].filter((node) => ["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)); - if (parsed.diagnostics.length || exports.length !== 1) throw new Error("Expected one valid package export declaration"); + const exports = [...walkSyntax(parsed.root)].filter((node) => + ["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind), + ); + if (parsed.diagnostics.length || exports.length !== 1) + throw new Error("Expected one valid package export declaration"); const wrapped = `package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`; const node = exports[0]; const derived = [...walkSyntax(node)].some((entry) => entry.kind === "eventClause"); - if (command === "migration" && (node.kind !== "packageFunctionExport" || spec.declaration)) throw new Error("Migration exports use the scaffold's unit function declaration and dedicated migration entrypoint"); - if (wrapped.slice(node.children.find((child) => child.kind === "identifier")!.start, node.children.find((child) => child.kind === "identifier")!.end) !== name - || JSON.parse(wrapped.slice(node.children.find((child) => child.kind === "stringLiteral")!.start, node.children.find((child) => child.kind === "stringLiteral")!.end)) !== spec.id) throw new Error("Declaration name/ID must match its registration"); - files.push({file: prefix + "package.qx", edits: [{operation: "append", parent: {kind: "packageResourceDecl", id: packageModel.id}, source: declaration}]}); + if (command === "migration" && (node.kind !== "packageFunctionExport" || spec.declaration)) + throw new Error( + "Migration exports use the scaffold's unit function declaration and dedicated migration entrypoint", + ); + if ( + wrapped.slice( + node.children.find((child) => child.kind === "identifier")!.start, + node.children.find((child) => child.kind === "identifier")!.end, + ) !== name || + JSON.parse( + wrapped.slice( + node.children.find((child) => child.kind === "stringLiteral")!.start, + node.children.find((child) => child.kind === "stringLiteral")!.end, + ), + ) !== spec.id + ) + throw new Error("Declaration name/ID must match its registration"); + files.push({ + file: prefix + "package.qx", + edits: [ + { operation: "append", parent: { kind: "packageResourceDecl", id: packageModel.id }, source: declaration }, + ], + }); const file = `src/${command === "migration" ? "migrations" : "impl"}/${name}.ts`; - const implementation = command === "migration" ? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\n` - : `import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`; + const implementation = + command === "migration" + ? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\n` + : `import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`; create(file, implementation); - packageModel.exports.push({name, id: spec.id, file, ...(command === "migration" ? {migration: true} : {})}); + packageModel.exports.push({ name, id: spec.id, file, ...(command === "migration" ? { migration: true } : {}) }); if (command === "migration") { - if (!spec.migration) throw new Error("Migration scaffold requires retained contracts and an explicit transition"); - const {contracts, ...transition} = spec.migration; + if (!spec.migration) + throw new Error("Migration scaffold requires retained contracts and an explicit transition"); + const { contracts, ...transition } = spec.migration; for (const [digest, contract] of Object.entries(contracts)) { - if (contentDigest(contract) !== digest || (catalog.contracts[digest] && contentDigest(catalog.contracts[digest]) !== digest)) throw new Error("Retained migration contract mismatch"); + if ( + contentDigest(contract) !== digest || + (catalog.contracts[digest] && contentDigest(catalog.contracts[digest]) !== digest) + ) + throw new Error("Retained migration contract mismatch"); catalog.contracts[digest] = contract; } - catalog.migrations.push({...transition, implementation: {exportId: spec.id, file, digest: contentDigest(implementation)}}); + catalog.migrations.push({ + ...transition, + implementation: { exportId: spec.id, file, digest: contentDigest(implementation) }, + }); } } } @@ -150,44 +310,108 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio const entry = packageModel.exports.at(-1)!; const file = prefix + "src/server.ts"; const before = await fs.readFile(path.join(root, file), "utf8"); - files.push({file, expected: before, replace: addImplementation(before, "createRuntime", entry.name, `./${entry.file.slice(4, -3)}.js`, !!entry.migration)}); + files.push({ + file, + expected: before, + replace: addImplementation( + before, + "createRuntime", + entry.name, + `./${entry.file.slice(4, -3)}.js`, + !!entry.migration, + ), + }); if (entry.migration) { const file = prefix + "src/migrate.ts"; const before = await fs.readFile(path.join(root, file), "utf8"); - files.push({file, expected: before, replace: addImplementation(before, "serveMigration", entry.id, `./${entry.file.slice(4, -3)}.js`)}); + files.push({ + file, + expected: before, + replace: addImplementation(before, "serveMigration", entry.id, `./${entry.file.slice(4, -3)}.js`), + }); } } else { - create("src/server.ts", `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` + packageModel.exports.filter((entry) => !entry.migration).map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") + - `servePackageRuntime(createRuntime({\n` + packageModel.exports.map((entry) => ` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${packageModel.exports.filter((value) => !value.migration).indexOf(entry)}`},`).join("\n") + `\n}));\n`); - const migrations = packageModel.exports.filter((entry) => entry.migration); - create("src/migrate.ts", `import {serveMigration} from "@quixos/camino-package-runtime";\n` + migrations.map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") + `await serveMigration({${migrations.map((entry, index) => `${JSON.stringify(entry.id)}: impl${index}`).join(", ")}});\n`); + create( + "src/server.ts", + `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` + + packageModel.exports + .filter((entry) => !entry.migration) + .map( + (entry, index) => + `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`, + ) + .join("") + + `servePackageRuntime(createRuntime({\n` + + packageModel.exports + .map( + (entry) => + ` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${packageModel.exports.filter((value) => !value.migration).indexOf(entry)}`},`, + ) + .join("\n") + + `\n}));\n`, + ); + const migrations = packageModel.exports.filter((entry) => entry.migration); + create( + "src/migrate.ts", + `import {serveMigration} from "@quixos/camino-package-runtime";\n` + + migrations + .map( + (entry, index) => + `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`, + ) + .join("") + + `await serveMigration({${migrations.map((entry, index) => `${JSON.stringify(entry.id)}: impl${index}`).join(", ")}});\n`, + ); } - generated("descriptor.quixos-package.txtpb", `# Generated by qx-scaffold-v1\npackage_id: ${JSON.stringify(packageModel.id)}\npackage_revision_id: ${JSON.stringify(packageModel.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` + packageModel.exports.map((entry) => `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`).join("")); + generated( + "descriptor.quixos-package.txtpb", + `# Generated by qx-scaffold-v1\npackage_id: ${JSON.stringify(packageModel.id)}\npackage_revision_id: ${JSON.stringify(packageModel.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` + + packageModel.exports + .map( + (entry) => + `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`, + ) + .join(""), + ); if (spec.initialFiles) { if (command !== "package") throw new Error("initialFiles is only valid when creating a package"); const authored = spec.initialFiles["package.qx"]; if (authored !== undefined) { const parsed = parseQx(authored); - const declaration = [...walkSyntax(parsed.root)].find(n => n.kind === "packageResourceDecl"); - const literals = declaration?.children.filter(n => n.kind === "stringLiteral") ?? []; - const identifier = declaration?.children.find(n => n.kind === "identifier"); - if (parsed.diagnostics.length || literals.length !== 2 || !identifier || authored.slice(identifier.start, identifier.end) !== spec.name || - JSON.parse(authored.slice(literals[0].start, literals[0].end)) !== spec.id || JSON.parse(authored.slice(literals[1].start, literals[1].end)) !== spec.revision) + const declaration = [...walkSyntax(parsed.root)].find((n) => n.kind === "packageResourceDecl"); + const literals = declaration?.children.filter((n) => n.kind === "stringLiteral") ?? []; + const identifier = declaration?.children.find((n) => n.kind === "identifier"); + if ( + parsed.diagnostics.length || + literals.length !== 2 || + !identifier || + authored.slice(identifier.start, identifier.end) !== spec.name || + JSON.parse(authored.slice(literals[0].start, literals[0].end)) !== spec.id || + JSON.parse(authored.slice(literals[1].start, literals[1].end)) !== spec.revision + ) throw new Error("Initial package declaration must match the provisioned name, ID and revision"); } for (const [file, content] of Object.entries(spec.initialFiles)) { - if (typeof content !== "string" || ["quixos.lock", "flake.nix", "quixos.toolchain.json", "quixos.check.json", "package.json"].includes(file)) throw new Error(`Not an initial authored file: ${file}`); - const existing = files.findIndex(entry => entry.file === prefix + file); + if ( + typeof content !== "string" || + ["quixos.lock", "flake.nix", "quixos.toolchain.json", "quixos.check.json", "package.json"].includes(file) + ) + throw new Error(`Not an initial authored file: ${file}`); + const existing = files.findIndex((entry) => entry.file === prefix + file); if (existing >= 0) files.splice(existing, 1); create(file, content); } // A composed React recipe supplies its own modules and complete server. if (reactRecipe(spec)) { for (const file of ["src/component.tsx", "src/impl/sourceGet.ts", "descriptor.quixos-package.txtpb"]) - if (!(file in spec.initialFiles)) {const index = files.findIndex(entry => entry.file === prefix + file); if (index >= 0) files.splice(index, 1);} + if (!(file in spec.initialFiles)) { + const index = files.findIndex((entry) => entry.file === prefix + file); + if (index >= 0) files.splice(index, 1); + } } } - return {kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files}; + return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files }; }; -const reactRecipe = (spec: ScaffoldRecipe) => spec.template === "typescript-react" && spec.initialFiles?.["package.qx"] && spec.initialFiles?.["src/server.ts"]; +const reactRecipe = (spec: ScaffoldRecipe) => + spec.template === "typescript-react" && spec.initialFiles?.["package.qx"] && spec.initialFiles?.["src/server.ts"]; diff --git a/src/capability-language/scaffold.ts b/src/capability-language/scaffold.ts index c9b4563..d5ef289 100644 --- a/src/capability-language/scaffold.ts +++ b/src/capability-language/scaffold.ts @@ -19,7 +19,11 @@ export const planAtomScaffold = (workspace: string, name: string, id: string) => /** Validate an in-memory proposal before creating files. Never replace a fragment. */ export const scaffoldAtom = async (options: { - root: string; name: string; id: string; write: boolean; resolveResource: CapabilityRepositoryResolver; + root: string; + name: string; + id: string; + write: boolean; + resolveResource: CapabilityRepositoryResolver; }) => { const before = await readQxSource(options.root, "workspace.qx"); const plan = planAtomScaffold(before, options.name, options.id); @@ -31,14 +35,17 @@ export const scaffoldAtom = async (options: { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } const observed = new Map(); - await compileWorkspaceRepository({ rootDirectory: options.root, resolveResource: options.resolveResource, + await compileWorkspaceRepository({ + rootDirectory: options.root, + resolveResource: options.resolveResource, readSource: async (name) => { if (name === "workspace.qx") return plan.workspace; if (name === plan.fileName) return plan.fragment; const text = await readQxSource(options.root, name); observed.set(name, text); return text; - } }); + }, + }); if (!options.write) return plan; const workspacePath = path.join(options.root, "workspace.qx"); const temporary = path.join(options.root, `.qx-scaffold-${randomUUID()}.tmp`); @@ -47,13 +54,22 @@ export const scaffoldAtom = async (options: { try { const fragment = await open(fragmentPath, "wx"); createdFragment = true; - try { await fragment.writeFile(plan.fragment); } finally { await fragment.close(); } + try { + await fragment.writeFile(plan.fragment); + } finally { + await fragment.close(); + } const file = await open(temporary, "wx", (await lstat(workspacePath)).mode); createdTemporary = true; - try { await file.writeFile(plan.workspace); } finally { await file.close(); } + try { + await file.writeFile(plan.workspace); + } finally { + await file.close(); + } observed.set("workspace.qx", before); for (const [name, text] of observed) { - if (await readQxSource(options.root, name) !== text) throw new Error(`QX source changed during scaffolding: ${name}`); + if ((await readQxSource(options.root, name)) !== text) + throw new Error(`QX source changed during scaffolding: ${name}`); } await rename(temporary, workspacePath); createdTemporary = false; diff --git a/src/capability-language/source-loader.ts b/src/capability-language/source-loader.ts index f8e981c..7ca00b1 100644 --- a/src/capability-language/source-loader.ts +++ b/src/capability-language/source-loader.ts @@ -16,9 +16,7 @@ export const readQxSource = async (root: string, name: string) => { }; /** Local imports share one workspace scope; paths are always repository-relative. */ -export const resolveQxSources = async ( - read: (name: string) => Promise, entry = "workspace.qx", -) => { +export const resolveQxSources = async (read: (name: string) => Promise, entry = "workspace.qx") => { const files = new Map(); const active: string[] = []; const visited = new Set(); @@ -34,8 +32,8 @@ export const resolveQxSources = async ( if (visited.has(name)) return; const text = await read(name); const syntax = parseQx(text, name); - if (syntax.diagnostics.length) throw new Error(syntax.diagnostics.map((d) => - `${name}:${d.line}:${d.column + 1}: ${d.message}`).join("\n")); + if (syntax.diagnostics.length) + throw new Error(syntax.diagnostics.map((d) => `${name}:${d.line}:${d.column + 1}: ${d.message}`).join("\n")); const declaration = syntax.root.children[0]!; if (declaration.kind !== (root ? "workspaceDecl" : "fragmentDecl")) throw new Error(`${name}: expected ${root ? "workspace" : "fragment"} document`); @@ -60,16 +58,22 @@ export const resolveQxSources = async ( }; await visit(entry, true); return { - source, sourceFiles: [...files.keys()], files, + source, + sourceFiles: [...files.keys()], + files, originalPosition(line: number, column: number) { const lines = source.split("\n"); - const offset = lines.slice(0, line - 1).reduce((sum, value) => sum + value.length + 1, 0) + + const offset = + lines.slice(0, line - 1).reduce((sum, value) => sum + value.length + 1, 0) + [...(lines[line - 1] ?? "")].slice(0, column).join("").length; const segment = segments.find((entry) => entry.start <= offset && entry.end > offset); if (!segment) return { fileName: entry, line, column }; const prefix = files.get(segment.fileName)!.slice(0, segment.sourceStart + offset - segment.start); - return { fileName: segment.fileName, line: prefix.split("\n").length, - column: prefix.length - prefix.lastIndexOf("\n") - 1 }; + return { + fileName: segment.fileName, + line: prefix.split("\n").length, + column: prefix.length - prefix.lastIndexOf("\n") - 1, + }; }, }; }; diff --git a/src/capability-language/source.ts b/src/capability-language/source.ts index 3f6a2a7..82e7e80 100644 --- a/src/capability-language/source.ts +++ b/src/capability-language/source.ts @@ -17,20 +17,26 @@ export const parseQx = (source: string, fileName = "") => { kind: QuixosCapabilityParser.ruleNames[context.ruleIndex]!, start: offset(context.start?.start ?? 0), end: offset((context.stop?.stop ?? -1) + 1), - children: context.children.flatMap((child) => child instanceof ParserRuleContext ? [node(child)] : []), + children: context.children.flatMap((child) => (child instanceof ParserRuleContext ? [node(child)] : [])), }); return { - source, fileName, + source, + fileName, root: node(parsed.tree), diagnostics: parsed.diagnostics.map((diagnostic) => { const line = source.split("\n")[diagnostic.line - 1] ?? ""; return { ...diagnostic, column: [...line].slice(0, diagnostic.column).join("").length }; }), - tokens: parsed.tokens.getTokens().filter((token) => token.type !== -1).map((token) => ({ - kind: QuixosCapabilityParser.symbolicNames[token.type] ?? "token", - start: offset(token.start), end: offset(token.stop + 1), - text: token.text ?? "", trivia: token.channel !== 0, - })), + tokens: parsed.tokens + .getTokens() + .filter((token) => token.type !== -1) + .map((token) => ({ + kind: QuixosCapabilityParser.symbolicNames[token.type] ?? "token", + start: offset(token.start), + end: offset(token.stop + 1), + text: token.text ?? "", + trivia: token.channel !== 0, + })), }; }; @@ -41,8 +47,14 @@ export const applySourceEdits = (source: string, edits: readonly SourceEdit[]) = let previousStart = -1; let result = ""; for (const edit of sorted) { - if (!Number.isInteger(edit.start) || !Number.isInteger(edit.end) || - edit.start < end || edit.start === previousStart || edit.end < edit.start || edit.end > source.length) { + if ( + !Number.isInteger(edit.start) || + !Number.isInteger(edit.end) || + edit.start < end || + edit.start === previousStart || + edit.end < edit.start || + edit.end > source.length + ) { throw new Error("Invalid or overlapping source edits"); } result += source.slice(end, edit.start) + edit.text; @@ -68,14 +80,27 @@ export const lintQx = (source: string, fileName = "") => { const importPath: string = JSON.parse(source.slice(literal.start, literal.end)); let message: string | undefined; let code = "invalid-source-import"; - try { validateQxImportPath(importPath); } catch (error) { message = (error as Error).message; } - if (!message && seen.has(importPath)) { code = "duplicate-source-import"; message = `Repeated local import ${importPath}`; } + try { + validateQxImportPath(importPath); + } catch (error) { + message = (error as Error).message; + } + if (!message && seen.has(importPath)) { + code = "duplicate-source-import"; + message = `Repeated local import ${importPath}`; + } seen.add(importPath); if (message) { const prefix = source.slice(0, node.start); - diagnostics.push({ phase: "syntax", code, message, fileName, - line: prefix.split("\n").length, column: prefix.length - prefix.lastIndexOf("\n") - 1, - severity: code === "duplicate-source-import" ? "warning" : "error" }); + diagnostics.push({ + phase: "syntax", + code, + message, + fileName, + line: prefix.split("\n").length, + column: prefix.length - prefix.lastIndexOf("\n") - 1, + severity: code === "duplicate-source-import" ? "warning" : "error", + }); } } return diagnostics; @@ -117,12 +142,15 @@ export const addWorkspaceImport = (source: string, importPath: string) => { } const brace = syntax.tokens.find((token) => token.kind === "LBRACE")!; const newline = source.includes("\r\n") ? "\r\n" : "\n"; - return applySourceEdits(source, [{ start: brace.end, end: brace.end, - text: `${newline} import ${JSON.stringify(importPath)};` }]); + return applySourceEdits(source, [ + { start: brace.end, end: brace.end, text: `${newline} import ${JSON.stringify(importPath)};` }, + ]); }; export const validateQxImportPath = (value: string) => { - if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.qx$/.test(value) || - value.split("/").some((part) => part === "." || part === "..")) + if ( + !/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.qx$/.test(value) || + value.split("/").some((part) => part === "." || part === "..") + ) throw new Error(`Invalid repository-relative QX import path: ${value}`); }; diff --git a/src/capability-language/structural-edits.ts b/src/capability-language/structural-edits.ts index 7ba8ac3..c73b1a7 100644 --- a/src/capability-language/structural-edits.ts +++ b/src/capability-language/structural-edits.ts @@ -12,23 +12,46 @@ export type StructuralEdit = | { operation: "import"; kind: "interface" | "package"; name: string } | { operation: "semantic-major"; target: StructuralSelector; major: number } | { operation: "conformance-id"; target: StructuralSelector; id: string } - | { operation: "quixos-pin"; source: {repository: string; commit: string} } - | { operation: "dependency"; kind: "interface" | "package"; name: string; source: {repository: string; commit: string} | null }; + | { operation: "quixos-pin"; source: { repository: string; commit: string } } + | { + operation: "dependency"; + kind: "interface" | "package"; + name: string; + source: { repository: string; commit: string } | null; + }; // Deliberately exclude valueType/identifier/stringLiteral: callers operate on // declaration structure, not arbitrary token offsets or lockfile text patches. -const selectable = new Set(["workspaceDecl", "fragmentDecl", "interfaceResourceDecl", "packageResourceDecl", "atomDecl", - "valueMember", "relationshipMember", "operationMember", "packageOperationExport", "packageFunctionExport", "packageConstructorExport", - "conformanceDecl", "stateDecl", "edgeDecl", "constructorBindingDecl", "resourceImportDecl", "sourceImportDecl", "operationBindingDecl"]); +const selectable = new Set([ + "workspaceDecl", + "fragmentDecl", + "interfaceResourceDecl", + "packageResourceDecl", + "atomDecl", + "valueMember", + "relationshipMember", + "operationMember", + "packageOperationExport", + "packageFunctionExport", + "packageConstructorExport", + "conformanceDecl", + "stateDecl", + "edgeDecl", + "constructorBindingDecl", + "resourceImportDecl", + "sourceImportDecl", + "operationBindingDecl", +]); /** Bind only the template root identity; schema/atom identities are reusable. * The revision's real identity is derived from its containing commit at compile time. */ export const instantiateWorkspaceIdentity = (source: string, workspaceId: string): string => { - if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(workspaceId)) throw new Error("Workspace identity must be a UUID"); + if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(workspaceId)) + throw new Error("Workspace identity must be a UUID"); const syntax = parseQx(source); if (syntax.diagnostics.length) throw new Error("Cannot instantiate a malformed template workspace"); - const declaration = syntax.root.children.find(node => node.kind === "workspaceDecl"); - const literals = declaration?.children.filter(node => node.kind === "stringLiteral"); + const declaration = syntax.root.children.find((node) => node.kind === "workspaceDecl"); + const literals = declaration?.children.filter((node) => node.kind === "stringLiteral"); if (!literals || literals.length !== 3) throw new Error("Template must contain a workspace declaration"); return applySourceEdits(source, [ { ...literals[0], text: JSON.stringify(workspaceId) }, @@ -36,25 +59,48 @@ export const instantiateWorkspaceIdentity = (source: string, workspaceId: string ]); }; -const select = (source: string, selector: StructuralSelector): {node: SyntaxNode; syntax: ReturnType} => { +const select = ( + source: string, + selector: StructuralSelector, +): { node: SyntaxNode; syntax: ReturnType } => { if (!selectable.has(selector.kind)) throw new Error(`Unsupported structural selector ${selector.kind}`); const syntax = parseQx(source); if (syntax.diagnostics.length) throw new Error("Cannot scaffold syntactically invalid QX"); const matches = [...walkSyntax(syntax.root)].filter((node) => { if (node.kind !== selector.kind) return false; - if (selector.name && !node.children.some((child) => child.kind === "identifier" && source.slice(child.start, child.end) === selector.name)) return false; - if (selector.names && JSON.stringify(node.children.filter((child) => child.kind === "identifier").map((child) => source.slice(child.start, child.end))) !== JSON.stringify(selector.names)) return false; + if ( + selector.name && + !node.children.some( + (child) => child.kind === "identifier" && source.slice(child.start, child.end) === selector.name, + ) + ) + return false; + if ( + selector.names && + JSON.stringify( + node.children + .filter((child) => child.kind === "identifier") + .map((child) => source.slice(child.start, child.end)), + ) !== JSON.stringify(selector.names) + ) + return false; if (selector.id) { // Only an explicit ID field counts, not a coincidentally equal revision, // default value, nested declaration, or comment. - const tokens = syntax.tokens.filter((token) => !token.trivia && token.start >= node.start && token.end <= node.end); - const literal = node.children.find((child) => child.kind === "stringLiteral" && tokens.some((token, index) => token.start === child.start && tokens[index - 1]?.kind === "ID")); + const tokens = syntax.tokens.filter( + (token) => !token.trivia && token.start >= node.start && token.end <= node.end, + ); + const literal = node.children.find( + (child) => + child.kind === "stringLiteral" && + tokens.some((token, index) => token.start === child.start && tokens[index - 1]?.kind === "ID"), + ); if (!literal || JSON.parse(source.slice(literal.start, literal.end)) !== selector.id) return false; } return true; }); if (matches.length !== 1) throw new Error(`Structural selector must resolve exactly once (found ${matches.length})`); - return {node: matches[0], syntax}; + return { node: matches[0], syntax }; }; /** Comment-preserving structural edits; every result is parsed before returning. */ @@ -69,87 +115,148 @@ export const editStructure = (source: string, edit: StructuralEdit): string => { const offsets = [0]; for (const character of source) offsets.push(offsets[offsets.length - 1] + character.length); const result = applySourceEdits(source, [ - {start: offsets[literals[0].start!.start], end: offsets[literals[0].stop!.stop + 1], text: JSON.stringify(edit.source.repository)}, - {start: offsets[literals[literals.length - 1].start!.start], end: offsets[literals[literals.length - 1].stop!.stop + 1], text: JSON.stringify(edit.source.commit)}, + { + start: offsets[literals[0].start!.start], + end: offsets[literals[0].stop!.stop + 1], + text: JSON.stringify(edit.source.repository), + }, + { + start: offsets[literals[literals.length - 1].start!.start], + end: offsets[literals[literals.length - 1].stop!.stop + 1], + text: JSON.stringify(edit.source.commit), + }, ]); const checked = parseQuixosLockDocument(result); - if (!checked.ok) throw new Error(`Invalid Quixos pin: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`); + if (!checked.ok) + throw new Error(`Invalid Quixos pin: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`); return result; } if (edit.operation === "import") { - if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name)) throw new Error("Invalid resource import"); + if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name)) + throw new Error("Invalid resource import"); const syntax = parseQx(source); if (syntax.diagnostics.length) throw new Error("Cannot scaffold invalid QX"); const text = `import ${edit.kind} ${edit.name};`; - if ([...walkSyntax(syntax.root)].some((entry) => entry.kind === "resourceImportDecl" && source.slice(entry.start, entry.end).replace(/\s+/g, " ") === text)) return source; + if ( + [...walkSyntax(syntax.root)].some( + (entry) => + entry.kind === "resourceImportDecl" && source.slice(entry.start, entry.end).replace(/\s+/g, " ") === text, + ) + ) + return source; const root = syntax.root.children[0]; - const position = ["workspaceDecl", "fragmentDecl"].includes(root.kind) ? syntax.tokens.find((token) => token.kind === "LBRACE")!.end : root.start; - const result = applySourceEdits(source, [{start: position, end: position, text: `\n${text}\n`}]); + const position = ["workspaceDecl", "fragmentDecl"].includes(root.kind) + ? syntax.tokens.find((token) => token.kind === "LBRACE")!.end + : root.start; + const result = applySourceEdits(source, [{ start: position, end: position, text: `\n${text}\n` }]); if (parseQx(result).diagnostics.length) throw new Error("Invalid resource import position"); return result; } if (edit.operation === "dependency") { const parsed = parseQuixosLockDocument(source); if (!parsed.ok) throw new Error("Cannot scaffold an invalid lockfile"); - if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name)) throw new Error("Invalid dependency selector"); + if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name)) + throw new Error("Invalid dependency selector"); const parser = new QuixosLockParser(new CommonTokenStream(new QuixosLockLexer(CharStream.fromString(source)))); const tree = parser.document(); const offsets = [0]; for (const character of source) offsets.push(offsets[offsets.length - 1] + character.length); - const entries = tree.resourceEntry().filter((entry) => entry.resourceKind().getText() === edit.kind && entry.identifier().getText() === edit.name); + const entries = tree + .resourceEntry() + .filter((entry) => entry.resourceKind().getText() === edit.kind && entry.identifier().getText() === edit.name); if (entries.length > 1) throw new Error("Ambiguous dependency selector"); const entry = entries[0]; - const replacement = edit.source ? `${edit.kind} ${edit.name} source {\n repository ${JSON.stringify(edit.source.repository)};\n commit ${JSON.stringify(edit.source.commit)};\n}` : ""; + const replacement = edit.source + ? `${edit.kind} ${edit.name} source {\n repository ${JSON.stringify(edit.source.repository)};\n commit ${JSON.stringify(edit.source.commit)};\n}` + : ""; if (!entry && !edit.source) throw new Error("Cannot remove an absent dependency"); const start = entry ? offsets[entry.start!.start] : offsets[tree.RBRACE().symbol.start]; const end = entry ? offsets[entry.stop!.stop + 1] : start; - const result = applySourceEdits(source, [{start, end, text: entry ? replacement : `${replacement}\n`}]); + const result = applySourceEdits(source, [{ start, end, text: entry ? replacement : `${replacement}\n` }]); const checked = parseQuixosLockDocument(result); - if (!checked.ok) throw new Error(`Invalid dependency change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`); + if (!checked.ok) + throw new Error(`Invalid dependency change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`); return result; } - const {node, syntax} = select(source, edit.operation === "append" ? edit.parent : edit.target); + const { node, syntax } = select(source, edit.operation === "append" ? edit.parent : edit.target); let result: string; if (edit.operation === "semantic-major") { - if (!["conformanceDecl", "packageResourceDecl"].includes(node.kind) || !Number.isSafeInteger(edit.major) || edit.major < 1) throw new Error("Semantic major requires a package/conformance and positive integer"); + if ( + !["conformanceDecl", "packageResourceDecl"].includes(node.kind) || + !Number.isSafeInteger(edit.major) || + edit.major < 1 + ) + throw new Error("Semantic major requires a package/conformance and positive integer"); const tokens = syntax.tokens.filter((token) => !token.trivia && token.start >= node.start && token.end <= node.end); const marker = tokens.findIndex((token) => token.kind === "SEMANTIC_MAJOR"); const value = marker < 0 ? undefined : tokens[marker + 1]; const brace = tokens.find((token) => token.kind === "LBRACE")!; - result = applySourceEdits(source, [{start: value?.start ?? brace.start, end: value?.end ?? brace.start, text: value ? String(edit.major) : `semantic-major ${edit.major} `}]); + result = applySourceEdits(source, [ + { + start: value?.start ?? brace.start, + end: value?.end ?? brace.start, + text: value ? String(edit.major) : `semantic-major ${edit.major} `, + }, + ]); } else if (edit.operation === "conformance-id") { - if (node.kind !== "conformanceDecl" || !edit.id) throw new Error("Identity enrollment requires a conformance and stable ID"); + if (node.kind !== "conformanceDecl" || !edit.id) + throw new Error("Identity enrollment requires a conformance and stable ID"); const existing = node.children.find((entry) => entry.kind === "stringLiteral"); if (existing) { - if (JSON.parse(source.slice(existing.start, existing.end)) !== edit.id) throw new Error("Cannot change an enrolled conformance identity; create a new conformance explicitly"); + if (JSON.parse(source.slice(existing.start, existing.end)) !== edit.id) + throw new Error("Cannot change an enrolled conformance identity; create a new conformance explicitly"); return source; } const identifiers = node.children.filter((entry) => entry.kind === "identifier"); const position = identifiers[identifiers.length - 1].end; - result = applySourceEdits(source, [{start: position, end: position, text: ` id ${JSON.stringify(edit.id)}`}]); + result = applySourceEdits(source, [{ start: position, end: position, text: ` id ${JSON.stringify(edit.id)}` }]); } else if (edit.operation === "append") { const closing = syntax.tokens.find((token) => token.kind === "RBRACE" && token.end === node.end); if (!closing) throw new Error("Append requires a declaration with a body"); - result = applySourceEdits(source, [{start: closing.start, end: closing.start, text: `\n${edit.source}\n`}]); + result = applySourceEdits(source, [{ start: closing.start, end: closing.start, text: `\n${edit.source}\n` }]); } else { // A resource parser node includes imports/external declarations preceding // its header. Replacing the declaration must not delete that preamble. - const identifier = node.children.find(child => child.kind === "identifier"); - const header = ["packageResourceDecl", "interfaceResourceDecl"].includes(node.kind) && identifier - ? syntax.tokens.filter(token => token.start >= node.start && token.end <= identifier.start && - token.kind === (node.kind === "packageResourceDecl" ? "PACKAGE" : "INTERFACE")).at(-1)?.start : undefined; - const wrapper = edit.operation === "remove" && ["stateDecl", "edgeDecl"].includes(node.kind) - ? [...walkSyntax(syntax.root)].filter((entry) => ["conformanceItem", "sharedAttachmentDecl"].includes(entry.kind) && entry.start <= node.start && entry.end >= node.end).sort((a, b) => (a.end - a.start) - (b.end - b.start))[0] - : undefined; - result = applySourceEdits(source, [{start: wrapper?.start ?? header ?? node.start, end: wrapper?.end ?? node.end, text: edit.operation === "replace" ? edit.source : ""}]); + const identifier = node.children.find((child) => child.kind === "identifier"); + const header = + ["packageResourceDecl", "interfaceResourceDecl"].includes(node.kind) && identifier + ? syntax.tokens + .filter( + (token) => + token.start >= node.start && + token.end <= identifier.start && + token.kind === (node.kind === "packageResourceDecl" ? "PACKAGE" : "INTERFACE"), + ) + .at(-1)?.start + : undefined; + const wrapper = + edit.operation === "remove" && ["stateDecl", "edgeDecl"].includes(node.kind) + ? [...walkSyntax(syntax.root)] + .filter( + (entry) => + ["conformanceItem", "sharedAttachmentDecl"].includes(entry.kind) && + entry.start <= node.start && + entry.end >= node.end, + ) + .sort((a, b) => a.end - a.start - (b.end - b.start))[0] + : undefined; + result = applySourceEdits(source, [ + { + start: wrapper?.start ?? header ?? node.start, + end: wrapper?.end ?? node.end, + text: edit.operation === "replace" ? edit.source : "", + }, + ]); } const checked = parseQx(result); - if (checked.diagnostics.length) throw new Error(`Invalid structural change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`); + if (checked.diagnostics.length) + throw new Error(`Invalid structural change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`); return result; }; export const scaffoldResourceSource = (kind: "interface" | "package", name: string, id: string, revision: string) => { - if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || !id || !revision) throw new Error("Resource scaffold requires a PascalCase name and explicit identities"); + if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || !id || !revision) + throw new Error("Resource scaffold requires a PascalCase name and explicit identities"); const source = `${kind} ${name} id ${JSON.stringify(id)} revision ${JSON.stringify(revision)} {\n}\n`; if (parseQx(source).diagnostics.length) throw new Error("Invalid resource scaffold"); return source; diff --git a/src/capability-language/structural-plan.ts b/src/capability-language/structural-plan.ts index d123309..e2ce3d3 100644 --- a/src/capability-language/structural-plan.ts +++ b/src/capability-language/structural-plan.ts @@ -7,125 +7,220 @@ import { editStructure, type StructuralEdit } from "./structural-edits.js"; import { snapshotRepository, localResourceSnapshots } from "./candidate-check.js"; import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js"; import { createGitCapabilityResolver } from "./git-resolver.js"; -import {bindingSchema, generateTypeScriptBindings} from "../bindings/index.js"; +import { bindingSchema, generateTypeScriptBindings } from "../bindings/index.js"; import { parseQx } from "./source.js"; import { parseQuixosLockDocument } from "../resource-lock/index.js"; import { withFileLock } from "./file-lock.js"; export type StructuralRequest = { kind: "workspace" | "interface" | "package"; - source?: {repository: string; commit: string}; + source?: { repository: string; commit: string }; resourceRoot?: string; validation?: "syntax" | "resource-graph"; - files: ({file: string; edits: StructuralEdit[]} | {file: string; create: string} | {file: string; generated: string} | {file: string; expected: string; replace: string})[]; + files: ( + | { file: string; edits: StructuralEdit[] } + | { file: string; create: string } + | { file: string; generated: string } + | { file: string; expected: string; replace: string } + )[]; }; -type Change = {file: string; before: string | null; after: string; mode: number}; -type Journal = {schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[]}; +type Change = { file: string; before: string | null; after: string; mode: number }; +type Journal = { schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[] }; const safeFile = (file: string) => { - if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|lock|ts|tsx|css|mjs|json|nix|txtpb))$/.test(file) - || file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))) throw new Error(`Unsafe scaffold path ${file}`); + if ( + !/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|lock|ts|tsx|css|mjs|json|nix|txtpb))$/.test( + file, + ) || + file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part)) + ) + throw new Error(`Unsafe scaffold path ${file}`); }; const read = async (root: string, file: string): Promise => { safeFile(file); const target = path.join(root, file); try { const metadata = await fs.lstat(target); - if (!metadata.isFile() || metadata.isSymbolicLink() || !(await fs.realpath(target)).startsWith(`${root}/`)) throw new Error(`Scaffold target is not a contained regular file: ${file}`); + if (!metadata.isFile() || metadata.isSymbolicLink() || !(await fs.realpath(target)).startsWith(`${root}/`)) + throw new Error(`Scaffold target is not a contained regular file: ${file}`); return await fs.readFile(target, "utf8"); - } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } }; const containedParent = async (root: string, file: string) => { let current = root; for (const part of file.split("/").slice(0, -1)) { current = path.join(current, part); - await fs.mkdir(current).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; }); + await fs.mkdir(current).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "EEXIST") throw error; + }); const metadata = await fs.lstat(current); - if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("Scaffold parent must be a real directory"); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) + throw new Error("Scaffold parent must be a real directory"); } }; const durableJson = async (file: string, value: unknown) => { const temporary = `${file}.${randomUUID()}.tmp`; const handle = await fs.open(temporary, "wx", 0o600); - try { await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`); await handle.sync(); } finally { await handle.close(); } + try { + await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`); + await handle.sync(); + } finally { + await handle.close(); + } await fs.rename(temporary, file); const directory = await fs.open(path.dirname(file), "r"); - try { await directory.sync(); } finally { await directory.close(); } + try { + await directory.sync(); + } finally { + await directory.close(); + } }; /** Validate the entire edited resource graph in a private snapshot before writes. */ export const planStructure = async (rootPath: string, request: StructuralRequest, snapshotMap?: string) => { - if (request.validation && !["syntax", "resource-graph"].includes(request.validation)) throw new Error("Unknown structural validation mode"); + if (request.validation && !["syntax", "resource-graph"].includes(request.validation)) + throw new Error("Unknown structural validation mode"); const root = await fs.realpath(rootPath); const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-structure-")); try { const snapshot = await snapshotRepository(root, path.join(temporary, "source")); - const observed = await Promise.all(snapshot.files.map(async ({name}) => ({file: name, digest: contentDigest(await fs.readFile(path.join(snapshot.directory, name), "utf8"))}))); + const observed = await Promise.all( + snapshot.files.map(async ({ name }) => ({ + file: name, + digest: contentDigest(await fs.readFile(path.join(snapshot.directory, name), "utf8")), + })), + ); const changes: Change[] = []; - if (!Array.isArray(request.files) || !request.files.length || request.files.length > 100) throw new Error("Structural plan requires 1–100 files"); + if (!Array.isArray(request.files) || !request.files.length || request.files.length > 100) + throw new Error("Structural plan requires 1–100 files"); for (const input of request.files) { safeFile(input.file); if (changes.some((entry) => entry.file === input.file)) throw new Error("Repeated structural file target"); const before = await read(root, input.file); let after: string; if ("create" in input) { - if (before !== null || typeof input.create !== "string") throw new Error("Scaffold creation cannot replace an existing file"); + if (before !== null || typeof input.create !== "string") + throw new Error("Scaffold creation cannot replace an existing file"); after = input.create; } else if ("replace" in input) { - if (before !== input.expected || typeof input.replace !== "string") throw new Error(`Stale imperative edit: ${input.file}`); + if (before !== input.expected || typeof input.replace !== "string") + throw new Error(`Stale imperative edit: ${input.file}`); after = input.replace; } else if ("generated" in input) { - const generated = (text: string) => text.startsWith("// Generated by qx-scaffold-v1\n") || text.startsWith("# Generated by qx-scaffold-v1\n") || (() => {try {return JSON.parse(text).generatedBy === "qx-scaffold-v1";} catch {return false;}})(); - if (typeof input.generated !== "string" || !generated(input.generated) || (before !== null && !generated(before))) throw new Error("Only scaffold-owned generated files may be regenerated"); + const generated = (text: string) => + text.startsWith("// Generated by qx-scaffold-v1\n") || + text.startsWith("# Generated by qx-scaffold-v1\n") || + (() => { + try { + return JSON.parse(text).generatedBy === "qx-scaffold-v1"; + } catch { + return false; + } + })(); + if ( + typeof input.generated !== "string" || + !generated(input.generated) || + (before !== null && !generated(before)) + ) + throw new Error("Only scaffold-owned generated files may be regenerated"); after = input.generated; } else { - if (before === null || !Array.isArray(input.edits)) throw new Error("Structural edit requires an existing source"); + if (before === null || !Array.isArray(input.edits)) + throw new Error("Structural edit requires an existing source"); after = input.edits.reduce(editStructure, before); } if (Buffer.byteLength(after) > 1024 * 1024) throw new Error("Scaffold file exceeds 1 MiB"); const mode = before === null ? 0o644 : (await fs.stat(path.join(root, input.file))).mode & 0o777; - changes.push({file: input.file, before, after, mode}); + changes.push({ file: input.file, before, after, mode }); await containedParent(snapshot.directory, input.file); await fs.writeFile(path.join(snapshot.directory, input.file), after); } if (request.validation === "syntax") { for (const change of changes) { - if (change.file.endsWith(".qx") && parseQx(change.after, change.file).diagnostics.length) throw new Error(`Invalid QX syntax in ${change.file}`); - if (change.file.endsWith(".lock") && !parseQuixosLockDocument(change.after, change.file).ok) throw new Error(`Invalid lock syntax in ${change.file}`); + if (change.file.endsWith(".qx") && parseQx(change.after, change.file).diagnostics.length) + throw new Error(`Invalid QX syntax in ${change.file}`); + if (change.file.endsWith(".lock") && !parseQuixosLockDocument(change.after, change.file).ok) + throw new Error(`Invalid lock syntax in ${change.file}`); } } else { - const localMap = path.join(temporary, "local-resources.json"); - await fs.writeFile(localMap, JSON.stringify(await localResourceSnapshots(root, snapshotMap))); - const resolveResource = await createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resources"), snapshotMap: localMap}); - if (request.resourceRoot && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(request.resourceRoot)) throw new Error("Resource root must be a contained relative directory"); - const resourceRoot = path.join(snapshot.directory, request.resourceRoot ?? ""); - if (request.kind === "workspace") await compileWorkspaceRepository({rootDirectory: resourceRoot, resolveResource}); - else if (["package", "interface"].includes(request.kind) && request.source) { - const compiled = await compileCapabilityResourceRepository({rootDirectory: resourceRoot, kind: request.kind as "package" | "interface", source: {resolver: "git", ...request.source}, resolveResource}); - if (compiled.resource.kind === "package") { - const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8")); - const artifacts = [ - {file: configuration.bindingOutput as string, after: generateTypeScriptBindings(bindingSchema(compiled), compiled.resource.revision.revisionId, configuration.options)}, - ]; - for (const artifact of artifacts) { - const file = request.resourceRoot ? `${request.resourceRoot}/${artifact.file}` : artifact.file; - safeFile(file); - if (Buffer.byteLength(artifact.after) > 1024 * 1024) throw new Error("Generated scaffold file exceeds 1 MiB"); - const before = await read(root, file); - if (before !== null && !before.startsWith("// Generated by quixos-codegen-ts.") && (() => {try {return JSON.parse(before).generatedBy !== "qx-scaffold-v1";} catch {return true;}})()) throw new Error(`Refusing to overwrite hand-authored generated artifact ${file}`); - const previous = changes.find((entry) => entry.file === file); - if (previous) previous.after = artifact.after; - else changes.push({file, before, after: artifact.after, mode: 0o644}); + const localMap = path.join(temporary, "local-resources.json"); + await fs.writeFile(localMap, JSON.stringify(await localResourceSnapshots(root, snapshotMap))); + const resolveResource = await createGitCapabilityResolver({ + checkoutRoot: path.join(temporary, "resources"), + snapshotMap: localMap, + }); + if ( + request.resourceRoot && + !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(request.resourceRoot) + ) + throw new Error("Resource root must be a contained relative directory"); + const resourceRoot = path.join(snapshot.directory, request.resourceRoot ?? ""); + if (request.kind === "workspace") + await compileWorkspaceRepository({ rootDirectory: resourceRoot, resolveResource }); + else if (["package", "interface"].includes(request.kind) && request.source) { + const compiled = await compileCapabilityResourceRepository({ + rootDirectory: resourceRoot, + kind: request.kind as "package" | "interface", + source: { resolver: "git", ...request.source }, + resolveResource, + }); + if (compiled.resource.kind === "package") { + const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8")); + const artifacts = [ + { + file: configuration.bindingOutput as string, + after: generateTypeScriptBindings( + bindingSchema(compiled), + compiled.resource.revision.revisionId, + configuration.options, + ), + }, + ]; + for (const artifact of artifacts) { + const file = request.resourceRoot ? `${request.resourceRoot}/${artifact.file}` : artifact.file; + safeFile(file); + if (Buffer.byteLength(artifact.after) > 1024 * 1024) + throw new Error("Generated scaffold file exceeds 1 MiB"); + const before = await read(root, file); + if ( + before !== null && + !before.startsWith("// Generated by quixos-codegen-ts.") && + (() => { + try { + return JSON.parse(before).generatedBy !== "qx-scaffold-v1"; + } catch { + return true; + } + })() + ) + throw new Error(`Refusing to overwrite hand-authored generated artifact ${file}`); + const previous = changes.find((entry) => entry.file === file); + if (previous) previous.after = artifact.after; + else changes.push({ file, before, after: artifact.after, mode: 0o644 }); + } } - } - } - else throw new Error("Resource plans require kind and exact authored source identity"); + } else throw new Error("Resource plans require kind and exact authored source identity"); } if (changes.length > 100) throw new Error("Structural plan including generated artifacts exceeds 100 files"); // Validation may fetch dependencies; reject edits made while it was running. - for (const entry of changes) if (await read(root, entry.file) !== entry.before) throw new Error(`Source changed while planning: ${entry.file}`); - for (const entry of observed) if (contentDigest(await fs.readFile(path.join(root, entry.file), "utf8")) !== entry.digest) throw new Error(`Validation input changed while planning: ${entry.file}`); - return {root, changes, observed, digest: contentDigest(changes), validation: request.validation ?? "resource-graph" as const}; - } finally { await fs.rm(temporary, {recursive: true, force: true}); } + for (const entry of changes) + if ((await read(root, entry.file)) !== entry.before) + throw new Error(`Source changed while planning: ${entry.file}`); + for (const entry of observed) + if (contentDigest(await fs.readFile(path.join(root, entry.file), "utf8")) !== entry.digest) + throw new Error(`Validation input changed while planning: ${entry.file}`); + return { + root, + changes, + observed, + digest: contentDigest(changes), + validation: request.validation ?? ("resource-graph" as const), + }; + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } }; /** Replay only exact before/after states. A crash never loses the original text. */ @@ -134,33 +229,47 @@ const replayStructure = async (rootPath: string, id: string) => { if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid scaffold journal ID"); const journalPath = path.join(root, ".quixos", "scaffolds", `${id}.json`); const journal = JSON.parse(await fs.readFile(journalPath, "utf8")) as Journal; - if (journal.schemaVersion !== 1 || journal.root !== root || journal.id !== id) throw new Error("Scaffold journal identity mismatch"); + if (journal.schemaVersion !== 1 || journal.root !== root || journal.id !== id) + throw new Error("Scaffold journal identity mismatch"); for (const entry of journal.changes) { const current = await read(root, entry.file); - if (current !== entry.before && current !== entry.after) throw new Error(`Scaffold conflicts with newer edits: ${entry.file}; original text is retained in ${journalPath}`); + if (current !== entry.before && current !== entry.after) + throw new Error( + `Scaffold conflicts with newer edits: ${entry.file}; original text is retained in ${journalPath}`, + ); } - if (journal.phase === "complete") return {id, journalPath, phase: journal.phase}; + if (journal.phase === "complete") return { id, journalPath, phase: journal.phase }; for (const entry of journal.changes) { - if (await read(root, entry.file) === entry.after) continue; + if ((await read(root, entry.file)) === entry.after) continue; await containedParent(root, entry.file); const target = path.join(root, entry.file); const temporary = `${target}.qx-${randomUUID()}.tmp`; const handle = await fs.open(temporary, "wx", entry.mode); - try { await handle.writeFile(entry.after); await handle.sync(); } finally { await handle.close(); } + try { + await handle.writeFile(entry.after); + await handle.sync(); + } finally { + await handle.close(); + } if (entry.before === null) { // link is atomic and fails if another author created the destination. await fs.link(temporary, target); await fs.unlink(temporary); } else { - if (await read(root, entry.file) !== entry.before) throw new Error(`Source changed during scaffold: ${entry.file}`); + if ((await read(root, entry.file)) !== entry.before) + throw new Error(`Source changed during scaffold: ${entry.file}`); await fs.rename(temporary, target); } const directory = await fs.open(path.dirname(target), "r"); - try { await directory.sync(); } finally { await directory.close(); } + try { + await directory.sync(); + } finally { + await directory.close(); + } } journal.phase = "complete"; await durableJson(journalPath, journal); - return {id, journalPath, phase: journal.phase}; + return { id, journalPath, phase: journal.phase }; }; const withStructureLock = async (root: string, work: () => Promise) => { @@ -172,22 +281,41 @@ export const resumeStructure = async (rootPath: string, id: string) => { const root = await fs.realpath(rootPath); return withStructureLock(root, () => replayStructure(root, id)); }; -export const applyStructure = async (plan: Awaited>, id: string = randomUUID()) => withStructureLock(plan.root, async () => { - if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid scaffold journal ID"); - const directory = path.join(plan.root, ".quixos", "scaffolds"); - try { - const existing = JSON.parse(await fs.readFile(path.join(directory, `${id}.json`), "utf8")) as Journal; - if (existing.root !== plan.root || contentDigest(existing.changes) !== plan.digest) throw new Error("Scaffold journal identity conflict"); - for (const entry of plan.observed) if (!plan.changes.some((change) => change.file === entry.file) && contentDigest(await fs.readFile(path.join(plan.root, entry.file), "utf8")) !== entry.digest) throw new Error(`Stale scaffold validation input: ${entry.file}`); +export const applyStructure = async (plan: Awaited>, id: string = randomUUID()) => + withStructureLock(plan.root, async () => { + if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid scaffold journal ID"); + const directory = path.join(plan.root, ".quixos", "scaffolds"); + try { + const existing = JSON.parse(await fs.readFile(path.join(directory, `${id}.json`), "utf8")) as Journal; + if (existing.root !== plan.root || contentDigest(existing.changes) !== plan.digest) + throw new Error("Scaffold journal identity conflict"); + for (const entry of plan.observed) + if ( + !plan.changes.some((change) => change.file === entry.file) && + contentDigest(await fs.readFile(path.join(plan.root, entry.file), "utf8")) !== entry.digest + ) + throw new Error(`Stale scaffold validation input: ${entry.file}`); + return replayStructure(plan.root, id); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + // An unfinished journal must be recovered before another structural mutation. + for (const file of await fs.readdir(directory)) + if (file.endsWith(".json")) { + const prior = JSON.parse(await fs.readFile(path.join(directory, file), "utf8")) as Journal; + if (prior.phase !== "complete") throw new Error(`Unfinished scaffold ${prior.id}; resume it first`); + } + for (const entry of plan.changes) + if ((await read(plan.root, entry.file)) !== entry.before) throw new Error(`Stale scaffold plan: ${entry.file}`); + for (const entry of plan.observed) + if (contentDigest(await fs.readFile(path.join(plan.root, entry.file), "utf8")) !== entry.digest) + throw new Error(`Stale scaffold validation input: ${entry.file}`); + await durableJson(path.join(directory, `${id}.json`), { + schemaVersion: 1, + id, + root: plan.root, + phase: "prepared", + changes: plan.changes, + } satisfies Journal); return replayStructure(plan.root, id); - } catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} - // An unfinished journal must be recovered before another structural mutation. - for (const file of await fs.readdir(directory)) if (file.endsWith(".json")) { - const prior = JSON.parse(await fs.readFile(path.join(directory, file), "utf8")) as Journal; - if (prior.phase !== "complete") throw new Error(`Unfinished scaffold ${prior.id}; resume it first`); - } - for (const entry of plan.changes) if (await read(plan.root, entry.file) !== entry.before) throw new Error(`Stale scaffold plan: ${entry.file}`); - for (const entry of plan.observed) if (contentDigest(await fs.readFile(path.join(plan.root, entry.file), "utf8")) !== entry.digest) throw new Error(`Stale scaffold validation input: ${entry.file}`); - await durableJson(path.join(directory, `${id}.json`), {schemaVersion: 1, id, root: plan.root, phase: "prepared", changes: plan.changes} satisfies Journal); - return replayStructure(plan.root, id); -}); + }); diff --git a/src/capability-language/tool-cli.ts b/src/capability-language/tool-cli.ts index 7a6cae1..feae14a 100644 --- a/src/capability-language/tool-cli.ts +++ b/src/capability-language/tool-cli.ts @@ -2,21 +2,21 @@ import { readFile, writeFile, mkdtemp, rm, realpath } from "node:fs/promises"; import { parseQx, formatQx, lintQx } from "./source.js"; import { scaffoldAtom } from "./scaffold.js"; -import {loadQxSources} from "./source-loader.js"; +import { loadQxSources } from "./source-loader.js"; import { createGitCapabilityResolver } from "./git-resolver.js"; import { planEvolution } from "../capability-model/index.js"; import { snapshotRepository } from "./candidate-check.js"; -import {planPinUpgrades, applyPinUpgrades, discoverUpgradeSpec, type UpgradeSpec} from "./pin-upgrades.js"; +import { planPinUpgrades, applyPinUpgrades, discoverUpgradeSpec, type UpgradeSpec } from "./pin-upgrades.js"; import path from "node:path"; import os from "node:os"; -import {spawnSync} from "node:child_process"; +import { spawnSync } from "node:child_process"; import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js"; -import {scaffoldRecipe, type ScaffoldRecipe} from "./scaffold-recipes.js"; -import {checkBundleSources} from "../bindings/bundle-policy.js"; -import {sealMigrations} from "./migration-seal.js"; -import {generatePackageDescriptor} from "../bindings/index.js"; -import {buildCheckedPackage, buildImmutableCandidate, snapshotCommit} from "./checked-build.js"; -import {formatQuixosLock, loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js"; +import { scaffoldRecipe, type ScaffoldRecipe } from "./scaffold-recipes.js"; +import { checkBundleSources } from "../bindings/bundle-policy.js"; +import { sealMigrations } from "./migration-seal.js"; +import { generatePackageDescriptor } from "../bindings/index.js"; +import { buildCheckedPackage, buildImmutableCandidate, snapshotCommit } from "./checked-build.js"; +import { formatQuixosLock, loadQuixosLock, parseQuixosLockDocument } from "../resource-lock/index.js"; import { walkSyntax } from "./source.js"; import { inspectWorkbench } from "./authoring-inspect.js"; import { authoringContext } from "./authoring-context.js"; @@ -25,18 +25,30 @@ import { checkAuthoring } from "./authoring-check.js"; import { authoringWorklist } from "./authoring-worklist.js"; const authorSource = async (root: string) => { - const result = spawnSync("git", ["config", "--get", "remote.origin.url"], {cwd: root, encoding: "utf8"}); + const result = spawnSync("git", ["config", "--get", "remote.origin.url"], { cwd: root, encoding: "utf8" }); if (result.error || result.status !== 0) throw new Error("Managed resource has no origin"); - return {repository: result.stdout.trim(), commit: await snapshotCommit(root)}; + return { repository: result.stdout.trim(), commit: await snapshotCommit(root) }; }; const readSpec = async (value: string) => { - if (value === "-") { let input = ""; for await (const chunk of process.stdin) { input += chunk; if (input.length > 1024 * 1024) throw new Error("Scaffold specification exceeds 1 MiB"); } return JSON.parse(input); } + if (value === "-") { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + if (input.length > 1024 * 1024) throw new Error("Scaffold specification exceeds 1 MiB"); + } + return JSON.parse(input); + } return JSON.parse(value.trimStart().startsWith("{") ? value : await readFile(value, "utf8")); }; const planSummary = (plan: Awaited>) => ({ - root: plan.root, validation: plan.validation, - changes: plan.changes.map(change => ({file: change.file, beforeBytes: change.before?.length ?? 0, afterBytes: change.after?.length ?? 0})), + root: plan.root, + validation: plan.validation, + changes: plan.changes.map((change) => ({ + file: change.file, + beforeBytes: change.before?.length ?? 0, + afterBytes: change.after?.length ?? 0, + })), note: "Structural plan only, not implementation verification. Run qx-workspace check while iterating.", }); @@ -47,43 +59,62 @@ const main = async () => { const sources = JSON.parse(args[0]); if (!Array.isArray(sources) || sources.length > 100) throw new Error("Expected at most 100 scaffold sources"); for (const entry of sources) { - if (!entry || typeof entry.file !== "string" || typeof entry.source !== "string" || entry.source.length > 1024*1024) throw new Error("Invalid scaffold source"); + if ( + !entry || + typeof entry.file !== "string" || + typeof entry.source !== "string" || + entry.source.length > 1024 * 1024 + ) + throw new Error("Invalid scaffold source"); const diagnostics = parseQx(entry.source, entry.file).diagnostics; - if (diagnostics.length) throw new Error(diagnostics.map(d => `${entry.file}:${d.line}:${d.column + 1}: ${d.message}`).join("\n")); + if (diagnostics.length) + throw new Error(diagnostics.map((d) => `${entry.file}:${d.line}:${d.column + 1}: ${d.message}`).join("\n")); } process.stdout.write('{"syntaxValid":true,"verificationEvidence":false}\n'); return; } if (command === "scaffold-placement-binding") { if (args.length !== 1) throw new Error("scaffold-placement-binding ROOT"); - const {source} = await loadQxSources(args[0]); + const { source } = await loadQxSources(args[0]); const syntax = parseQx(source); - const text = (n: {start: number; end: number}) => source.slice(n.start, n.end); + const text = (n: { start: number; end: number }) => source.slice(n.start, n.end); const matches: string[] = []; for (const edge of walkSyntax(syntax.root)) { if (edge.kind !== "edgeDecl") continue; - for (const endpoint of edge.children.filter(n => n.kind === "edgeEndpoint")) { - const target = endpoint.children.find(n => n.kind === "targetConstraint"); - if (!target || !syntax.tokens.some(t => t.start >= target.start && t.end <= target.end && t.kind === "INTERFACE") || - !target.children.some(n => n.kind === "identifier" && text(n) === "WebStudioPlaceable")) continue; - const edgeName = text(edge.children.find(n => n.kind === "identifier")!); - const projection = text(endpoint.children.find(n => n.kind === "identifier")!); + for (const endpoint of edge.children.filter((n) => n.kind === "edgeEndpoint")) { + const target = endpoint.children.find((n) => n.kind === "targetConstraint"); + if ( + !target || + !syntax.tokens.some((t) => t.start >= target.start && t.end <= target.end && t.kind === "INTERFACE") || + !target.children.some((n) => n.kind === "identifier" && text(n) === "WebStudioPlaceable") + ) + continue; + const edgeName = text(edge.children.find((n) => n.kind === "identifier")!); + const projection = text(endpoint.children.find((n) => n.kind === "identifier")!); matches.push(`bind placements.resolve to edge ${edgeName}.${projection}.resolve;`); } } - if (matches.length !== 1) throw new Error(`Expected one canvas placement edge for WebStudioPlaceable; found ${matches.length}. Configure the workspace canvas before adding a bundle.`); - process.stdout.write(JSON.stringify({binding: matches[0]}) + "\n"); + if (matches.length !== 1) + throw new Error( + `Expected one canvas placement edge for WebStudioPlaceable; found ${matches.length}. Configure the workspace canvas before adding a bundle.`, + ); + process.stdout.write(JSON.stringify({ binding: matches[0] }) + "\n"); return; } if (command === "package-identity") { if (args.length !== 1) throw new Error("usage: quixos-qx package-identity PACKAGE_QX"); const authored = await readFile(args[0], "utf8"); const syntax = parseQx(authored); - const declarations = [...walkSyntax(syntax.root)].filter(node => node.kind === "packageResourceDecl"); - if (syntax.diagnostics.length || declarations.length !== 1) throw new Error("Expected one valid package declaration"); - const literals = declarations[0].children.filter(node => node.kind === "stringLiteral"); - process.stdout.write(JSON.stringify({id: JSON.parse(authored.slice(literals[0].start, literals[0].end)), - revision: JSON.parse(authored.slice(literals[1].start, literals[1].end))}) + "\n"); + const declarations = [...walkSyntax(syntax.root)].filter((node) => node.kind === "packageResourceDecl"); + if (syntax.diagnostics.length || declarations.length !== 1) + throw new Error("Expected one valid package declaration"); + const literals = declarations[0].children.filter((node) => node.kind === "stringLiteral"); + process.stdout.write( + JSON.stringify({ + id: JSON.parse(authored.slice(literals[0].start, literals[0].end)), + revision: JSON.parse(authored.slice(literals[1].start, literals[1].end)), + }) + "\n", + ); return; } if (command === "package-descriptor") { @@ -108,29 +139,50 @@ const main = async () => { } if (["author-check", "author-contract"].includes(command) && !args.includes("--help")) { const [root, output, ...flags] = args; - if (!root || !output) throw new Error("usage: quixos-qx author-check ROOT OUTPUT [--baseline FILE] [--reviews FILE]"); - const options: {baseline?: string; reviews?: string} = {}; + if (!root || !output) + throw new Error("usage: quixos-qx author-check ROOT OUTPUT [--baseline FILE] [--reviews FILE]"); + const options: { baseline?: string; reviews?: string } = {}; for (let index = 0; index < flags.length; index += 2) { if (!flags[index + 1]) throw new Error("Missing check option value"); if (flags[index] === "--baseline") options.baseline = flags[index + 1]; else if (flags[index] === "--reviews") options.reviews = flags[index + 1]; else throw new Error(`Unknown check option ${flags[index]}`); } - const result = await checkAuthoring(root, output, {...options, contractOnly: command === "author-contract"}); + const result = await checkAuthoring(root, output, { ...options, contractOnly: command === "author-contract" }); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); if (result.blockers.length) process.exitCode = 1; return; } if (["converge", "_converge"].includes(command) && !args.includes("--help")) { - if (!args.length || args.length > 2) throw new Error("usage: quixos-qx converge WORKBENCH [REGISTERED_DIRECTORY] (join package writers first)"); + if (!args.length || args.length > 2) + throw new Error("usage: quixos-qx converge WORKBENCH [REGISTERED_DIRECTORY] (join package writers first)"); const context = await authoringContext(args[0]); if (command === "converge") { console.error(`[${new Date().toISOString()}] Capture: waiting for coordinator lock (120s limit)`); - const result = spawnSync("flock", ["--exclusive", "--timeout", "120", "--conflict-exit-code", "75", path.join(context.workbench, ".quixos/converge.lock"), - process.execPath, process.argv[1], "_converge", context.workbench, ...(args[1] ? [args[1]] : [])], {stdio: "inherit"}); + const result = spawnSync( + "flock", + [ + "--exclusive", + "--timeout", + "120", + "--conflict-exit-code", + "75", + path.join(context.workbench, ".quixos/converge.lock"), + process.execPath, + process.argv[1], + "_converge", + context.workbench, + ...(args[1] ? [args[1]] : []), + ], + { stdio: "inherit" }, + ); if (result.error) throw result.error; - if (result.status === 75) process.stderr.write("Timed out after 120 seconds waiting for source capture; inspect the active coordinator. No build lock is held.\n"); - process.exitCode = result.status ?? 1; return; + if (result.status === 75) + process.stderr.write( + "Timed out after 120 seconds waiting for source capture; inspect the active coordinator. No build lock is held.\n", + ); + process.exitCode = result.status ?? 1; + return; } console.error(`[${new Date().toISOString()}] Capture: coordinator lock acquired`); const result = await convergeAuthoring(context.workbench, args[1]); @@ -140,18 +192,24 @@ const main = async () => { } if (command === "check-committed" && !args.includes("--help")) { const [kind, repository, commit, log, ...extra] = args; - if (!["workspace", "interface", "package"].includes(kind) || !log || extra.length) throw new Error("usage: quixos-qx check-committed workspace|interface|package REPOSITORY COMMIT LOG_FILE"); - process.stdout.write(`${await buildImmutableCandidate({repository, commit}, kind as "workspace" | "interface" | "package", log)}\n`); + if (!["workspace", "interface", "package"].includes(kind) || !log || extra.length) + throw new Error("usage: quixos-qx check-committed workspace|interface|package REPOSITORY COMMIT LOG_FILE"); + process.stdout.write( + `${await buildImmutableCandidate({ repository, commit }, kind as "workspace" | "interface" | "package", log)}\n`, + ); return; } if (!command || command === "--help" || args.includes("--help")) { - process.stdout.write("quixos-qx: author-check, author-contract, converge, worklist, inspect, resources, check-committed, source-baseline, scaffold-package, scaffold-interface, scaffold-function, scaffold-dependency, scaffold-structure, scaffold-resume, pin-upgrade, parse, lint, format\n" + - "inspect WORKBENCH [RESOURCE] shows provisional contracts, with explicit historical fallback; never verification evidence.\n" + - "resources WORKBENCH lists registered editable repositories. Use qx-workspace for the workspace authoring workflow.\n"); + process.stdout.write( + "quixos-qx: author-check, author-contract, converge, worklist, inspect, resources, check-committed, source-baseline, scaffold-package, scaffold-interface, scaffold-function, scaffold-dependency, scaffold-structure, scaffold-resume, pin-upgrade, parse, lint, format\n" + + "inspect WORKBENCH [RESOURCE] shows provisional contracts, with explicit historical fallback; never verification evidence.\n" + + "resources WORKBENCH lists registered editable repositories. Use qx-workspace for the workspace authoring workflow.\n", + ); return; } if (command === "inspect" || command === "resources") { - if (!args[0] || args.length > (command === "inspect" ? 2 : 1)) throw new Error(`usage: quixos-qx ${command} WORKBENCH${command === "inspect" ? " [RESOURCE]" : ""}`); + if (!args[0] || args.length > (command === "inspect" ? 2 : 1)) + throw new Error(`usage: quixos-qx ${command} WORKBENCH${command === "inspect" ? " [RESOURCE]" : ""}`); const result = command === "inspect" ? await inspectWorkbench(args[0], args[1]) : await authoringContext(args[0]); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; @@ -165,27 +223,56 @@ const main = async () => { const [root, kind, name, ...remaining] = args; let repository: string | undefined, commit: string | undefined; const flags = [...remaining]; - if (flags.length && !flags[0].startsWith("--")) { repository = flags.shift(); commit = flags.shift(); } - else if (root && ["interface", "package"].includes(kind) && name) { + if (flags.length && !flags[0].startsWith("--")) { + repository = flags.shift(); + commit = flags.shift(); + } else if (root && ["interface", "package"].includes(kind) && name) { const context = await authoringContext(root); const alias = await realpath(path.join(context.workbench, `${kind}s`, name)).catch(() => null); - const matches = context.resources.filter(entry => entry.kind === kind && (entry.resourceId === name || path.basename(entry.directory) === name || path.join(context.workbench, entry.directory) === alias)); - if (matches.length !== 1 || !matches[0].source) throw new Error(`Select exactly one registered ${kind} with qx-workspace resources; no match for ${name}`); + const matches = context.resources.filter( + (entry) => + entry.kind === kind && + (entry.resourceId === name || + path.basename(entry.directory) === name || + path.join(context.workbench, entry.directory) === alias), + ); + if (matches.length !== 1 || !matches[0].source) + throw new Error(`Select exactly one registered ${kind} with qx-workspace resources; no match for ${name}`); const selected = matches[0]; let source = selected.source!; if (flags.includes("--write")) { - const retained = spawnSync("quixos-qx", ["converge", context.workbench, selected.directory], {encoding: "utf8", env: {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"}}); - if (retained.error || retained.status !== 0) throw new Error(`Dependency source needs attention: ${retained.error?.message ?? retained.stdout ?? retained.stderr}`); + const retained = spawnSync("quixos-qx", ["converge", context.workbench, selected.directory], { + encoding: "utf8", + env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1" }, + }); + if (retained.error || retained.status !== 0) + throw new Error( + `Dependency source needs attention: ${retained.error?.message ?? retained.stdout ?? retained.stderr}`, + ); source = JSON.parse(retained.stdout).candidate; } - repository = source.repository; commit = source.commit; + repository = source.repository; + commit = source.commit; } - if (!root || !["interface", "package"].includes(kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name ?? "") || !repository || !commit || flags.some(flag => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-dependency ROOT interface|package NAME REPOSITORY COMMIT [--write]"); + if ( + !root || + !["interface", "package"].includes(kind) || + !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name ?? "") || + !repository || + !commit || + flags.some((flag) => flag !== "--write") + ) + throw new Error("usage: quixos-qx scaffold-dependency ROOT interface|package NAME REPOSITORY COMMIT [--write]"); const resourceKind = kind as "package" | "interface"; let entrypoint: "workspace" | "package" | "interface" | undefined; for (const candidate of ["workspace", "package", "interface"] as const) { - try {await readFile(path.join(root, `${candidate}.qx`)); if (entrypoint) throw new Error("Ambiguous repository entrypoint"); entrypoint = candidate;} - catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} + try { + await readFile(path.join(root, `${candidate}.qx`)); + if (entrypoint) throw new Error("Ambiguous repository entrypoint"); + entrypoint = candidate; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } } if (!entrypoint) throw new Error("No QX repository entrypoint"); const lock = await loadQuixosLock(path.join(root, "quixos.lock")); @@ -193,45 +280,84 @@ const main = async () => { let target = "quixos.lock"; for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) { const parsed = parseQuixosLockDocument(await readFile(path.join(root, file), "utf8")); - if (parsed.ok && parsed.document.resources.some(entry => entry.kind === kind && entry.binding === name)) target = file; + if (parsed.ok && parsed.document.resources.some((entry) => entry.kind === kind && entry.binding === name)) + target = file; } - const request: StructuralRequest = {kind: entrypoint, source: await authorSource(root), validation: "syntax", files: [ - {file: `${entrypoint}.qx`, edits: [{operation: "import", kind: resourceKind, name}]}, - {file: target, edits: [{operation: "dependency", kind: resourceKind, name, source: {repository, commit}}]}, - ]}; + const request: StructuralRequest = { + kind: entrypoint, + source: await authorSource(root), + validation: "syntax", + files: [ + { file: `${entrypoint}.qx`, edits: [{ operation: "import", kind: resourceKind, name }] }, + { + file: target, + edits: [{ operation: "dependency", kind: resourceKind, name, source: { repository, commit } }], + }, + ], + }; const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP); - process.stdout.write(`${JSON.stringify({...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`); + process.stdout.write( + `${JSON.stringify({ ...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined }, null, 2)}\n`, + ); return; } if (command === "scaffold-interface") { const [root, specFile, ...flags] = args; - if (!root || !specFile || flags.some(flag => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-interface ROOT SPEC_JSON [--write]"); - const spec = await readSpec(specFile) as ScaffoldRecipe; - if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id || !spec.revision || !spec.tools?.quixos) throw new Error("Interface scaffold requires name, id, revision and Quixos toolchain source"); - const request: StructuralRequest = {kind: "interface", source: spec.source, files: [ - {file: "interface.qx", create: spec.declaration ?? `interface ${spec.name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`}, - {file: "quixos.lock", create: formatQuixosLock({formatVersion: 1, quixos: {resolver: "git", ...spec.tools.quixos}, resources: []})}, - {file: ".gitignore", create: ".quixos/\n"}, - ]}; + if (!root || !specFile || flags.some((flag) => flag !== "--write")) + throw new Error("usage: quixos-qx scaffold-interface ROOT SPEC_JSON [--write]"); + const spec = (await readSpec(specFile)) as ScaffoldRecipe; + if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id || !spec.revision || !spec.tools?.quixos) + throw new Error("Interface scaffold requires name, id, revision and Quixos toolchain source"); + const request: StructuralRequest = { + kind: "interface", + source: spec.source, + files: [ + { + file: "interface.qx", + create: + spec.declaration ?? + `interface ${spec.name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`, + }, + { + file: "quixos.lock", + create: formatQuixosLock({ + formatVersion: 1, + quixos: { resolver: "git", ...spec.tools.quixos }, + resources: [], + }), + }, + { file: ".gitignore", create: ".quixos/\n" }, + ], + }; const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP); - process.stdout.write(`${JSON.stringify({...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`); + process.stdout.write( + `${JSON.stringify({ ...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined }, null, 2)}\n`, + ); return; } if (command === "build-package") { - if (args.length !== 3) throw new Error("usage: quixos-qx build-package COMMITTED_SOURCE SCHEMA PACKAGE_REVISION_ID"); + if (args.length !== 3) + throw new Error("usage: quixos-qx build-package COMMITTED_SOURCE SCHEMA PACKAGE_REVISION_ID"); process.stdout.write(`${await buildCheckedPackage(args[0], args[1], args[2])}\n`); return; } if (command === "source-digest") { if (!args[0] || args.length !== 1) throw new Error("usage: quixos-qx source-digest ROOT"); const temporary = await mkdtemp(path.join(os.tmpdir(), "qx-source-digest-")); - try {process.stdout.write(`${(await snapshotRepository(args[0], temporary)).treeDigest}\n`);} finally {await rm(temporary, {recursive: true, force: true});} + try { + process.stdout.write(`${(await snapshotRepository(args[0], temporary)).treeDigest}\n`); + } finally { + await rm(temporary, { recursive: true, force: true }); + } return; } if (command === "pin-upgrade") { const [workbench, specFile, ...flags] = args; - if (!workbench || !specFile) throw new Error("usage: quixos-qx pin-upgrade WORKBENCH SPEC_JSON [--publish] [--resume UUID]"); - let publish = false, acceptEdits = false, resume: string | undefined; + if (!workbench || !specFile) + throw new Error("usage: quixos-qx pin-upgrade WORKBENCH SPEC_JSON [--publish] [--resume UUID]"); + let publish = false, + acceptEdits = false, + resume: string | undefined; for (let index = 0; index < flags.length; index++) { if (flags[index] === "--publish") publish = true; else if (flags[index] === "--accept-edits") acceptEdits = true; @@ -239,66 +365,112 @@ const main = async () => { else throw new Error(`Unknown pin-upgrade option ${flags[index]}`); } if (acceptEdits && !resume) throw new Error("--accept-edits requires an existing refactor journal (--resume)"); - const plan = resume ? JSON.parse(await readFile(path.join(workbench, ".quixos/upgrades", `${resume}.json`), "utf8")).plan - : await planPinUpgrades(workbench, specFile === "auto" ? await discoverUpgradeSpec(workbench) : JSON.parse(await readFile(specFile, "utf8")) as UpgradeSpec); - if (resume && path.resolve(workbench) !== plan.workbench) throw new Error("Upgrade journal belongs to another workbench"); - process.stdout.write(`${JSON.stringify(publish ? await applyPinUpgrades(plan, resume, undefined, {acceptEdits}) : plan, null, 2)}\n`); + const plan = resume + ? JSON.parse(await readFile(path.join(workbench, ".quixos/upgrades", `${resume}.json`), "utf8")).plan + : await planPinUpgrades( + workbench, + specFile === "auto" + ? await discoverUpgradeSpec(workbench) + : (JSON.parse(await readFile(specFile, "utf8")) as UpgradeSpec), + ); + if (resume && path.resolve(workbench) !== plan.workbench) + throw new Error("Upgrade journal belongs to another workbench"); + process.stdout.write( + `${JSON.stringify(publish ? await applyPinUpgrades(plan, resume, undefined, { acceptEdits }) : plan, null, 2)}\n`, + ); return; } - if (command === "scaffold-refresh") throw new Error("Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependency installation uses scaffold install."); + if (command === "scaffold-refresh") + throw new Error( + "Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependency installation uses scaffold install.", + ); if (["scaffold-package", "scaffold-function", "scaffold-migration", "scaffold-install"].includes(command)) { const [root, specFile, ...flags] = args; let spec: ScaffoldRecipe; if (command === "scaffold-function" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(specFile ?? "")) { const authored = parseQx(await readFile(path.join(root, "package.qx"), "utf8")); - const declarationNode = [...walkSyntax(authored.root)].find(node => node.kind === "packageResourceDecl"); - const nameNode = declarationNode?.children.find(node => node.kind === "identifier"); + const declarationNode = [...walkSyntax(authored.root)].find((node) => node.kind === "packageResourceDecl"); + const nameNode = declarationNode?.children.find((node) => node.kind === "identifier"); if (!nameNode) throw new Error("Expected a package declaration"); const name = authored.source.slice(nameNode.start, nameNode.end); - spec = {source: await authorSource(root), name: specFile, id: `export:${name}:${specFile}`}; + spec = { source: await authorSource(root), name: specFile, id: `export:${name}:${specFile}` }; const declaration = flags.indexOf("--declaration"); if (declaration >= 0) { if (!flags[declaration + 1]) throw new Error("--declaration requires a QX declaration file"); spec.declaration = await readFile(flags[declaration + 1], "utf8"); const parsed = parseQx(`package Draft id "package:draft" revision "package:draft@1" { ${spec.declaration} }`); - if (parsed.diagnostics.length) throw new Error(parsed.diagnostics.map(d => d.message).join("\n")); - const exported = [...walkSyntax(parsed.root)].filter(node => ["packageOperationExport", "packageFunctionExport", "packageConstructorExport"].includes(node.kind)); - if (exported.length !== 1) throw new Error("--declaration must contain exactly one function, operation or constructor export"); - const literal = exported[0].children.find(node => node.kind === "stringLiteral"); + if (parsed.diagnostics.length) throw new Error(parsed.diagnostics.map((d) => d.message).join("\n")); + const exported = [...walkSyntax(parsed.root)].filter((node) => + ["packageOperationExport", "packageFunctionExport", "packageConstructorExport"].includes(node.kind), + ); + if (exported.length !== 1) + throw new Error("--declaration must contain exactly one function, operation or constructor export"); + const literal = exported[0].children.find((node) => node.kind === "stringLiteral"); if (!literal) throw new Error("Declaration requires an authored export ID"); spec.id = JSON.parse(parsed.source.slice(literal.start, literal.end)); flags.splice(declaration, 2); } - } else spec = await readSpec(specFile) as ScaffoldRecipe; - if (!root || !specFile || flags.some((flag) => !["--write", "--install"].includes(flag)) || (flags.includes("--install") && !flags.includes("--write"))) throw new Error("usage: quixos-qx scaffold-package|function|migration|refresh ROOT SPEC_JSON [--write [--install]]"); - const plan = command === "scaffold-install" ? undefined : await planStructure(root, - await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration", spec), process.env.QUIXOS_SNAPSHOT_MAP); + } else spec = (await readSpec(specFile)) as ScaffoldRecipe; + if ( + !root || + !specFile || + flags.some((flag) => !["--write", "--install"].includes(flag)) || + (flags.includes("--install") && !flags.includes("--write")) + ) + throw new Error( + "usage: quixos-qx scaffold-package|function|migration|refresh ROOT SPEC_JSON [--write [--install]]", + ); + const plan = + command === "scaffold-install" + ? undefined + : await planStructure( + root, + await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration", spec), + process.env.QUIXOS_SNAPSHOT_MAP, + ); const applied = plan && flags.includes("--write") ? await applyStructure(plan) : undefined; if (flags.includes("--install")) { const cwd = path.resolve(root, spec.directory ?? ""); const toolchain = JSON.parse(await readFile(path.join(cwd, "quixos.toolchain.json"), "utf8")); - if (toolchain.generatedBy !== "qx-scaffold-v1" || typeof toolchain.nixifyPluginUrl !== "string") throw new Error("Missing scaffold toolchain"); - for (const [executable, args] of [["corepack", ["yarn", "plugin", "import", toolchain.nixifyPluginUrl]], ["corepack", ["yarn", "config", "set", "generateDefaultNix", "false"]], ["corepack", ["yarn", "config", "set", "individualNixPackaging", "true"]], ["corepack", ["yarn", "install"]]] as const) { - const result = spawnSync(executable, [...args], {cwd, stdio: ["inherit", 2, 2]}); - if (result.error || result.status !== 0) throw new Error(`Scaffold files retained; ${executable} ${args.join(" ")} failed: ${result.error?.message ?? result.status}`); + if (toolchain.generatedBy !== "qx-scaffold-v1" || typeof toolchain.nixifyPluginUrl !== "string") + throw new Error("Missing scaffold toolchain"); + for (const [executable, args] of [ + ["corepack", ["yarn", "plugin", "import", toolchain.nixifyPluginUrl]], + ["corepack", ["yarn", "config", "set", "generateDefaultNix", "false"]], + ["corepack", ["yarn", "config", "set", "individualNixPackaging", "true"]], + ["corepack", ["yarn", "install"]], + ] as const) { + const result = spawnSync(executable, [...args], { cwd, stdio: ["inherit", 2, 2] }); + if (result.error || result.status !== 0) + throw new Error( + `Scaffold files retained; ${executable} ${args.join(" ")} failed: ${result.error?.message ?? result.status}`, + ); + } + try { + await readFile(path.join(cwd, "yarn-project.nix")); + } catch { + throw new Error( + "Nixify did not generate yarn-project.nix. It skips repositories under the OS temporary directory; use an ordinary workspace checkout and retry installation.", + ); } - try {await readFile(path.join(cwd, "yarn-project.nix"));} - catch {throw new Error("Nixify did not generate yarn-project.nix. It skips repositories under the OS temporary directory; use an ordinary workspace checkout and retry installation.");} await snapshotCommit(cwd); - const locked = spawnSync("nix", ["flake", "lock"], {cwd, stdio: ["inherit", 2, 2]}); + const locked = spawnSync("nix", ["flake", "lock"], { cwd, stdio: ["inherit", 2, 2] }); if (locked.error || locked.status !== 0) throw new Error("Scaffold files retained; nix flake lock failed"); } - process.stdout.write(`${JSON.stringify({...plan ? planSummary(plan) : {installed: true}, applied}, null, 2)}\n`); + process.stdout.write( + `${JSON.stringify({ ...(plan ? planSummary(plan) : { installed: true }), applied }, null, 2)}\n`, + ); return; } if (command === "scaffold-structure") { const [root, spec, ...flags] = args; - if (!root || !spec || flags.some((flag) => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-structure ROOT SPEC_JSON [--write]"); - const request = await readSpec(spec) as StructuralRequest; + if (!root || !spec || flags.some((flag) => flag !== "--write")) + throw new Error("usage: quixos-qx scaffold-structure ROOT SPEC_JSON [--write]"); + const request = (await readSpec(spec)) as StructuralRequest; if (request.kind !== "workspace" && !request.source) request.source = await authorSource(root); const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP); const applied = flags.includes("--write") ? await applyStructure(plan) : undefined; - process.stdout.write(`${JSON.stringify({...planSummary(plan), applied}, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ...planSummary(plan), applied }, null, 2)}\n`); return; } if (command === "scaffold-resume") { @@ -307,10 +479,14 @@ const main = async () => { process.stdout.write(`${JSON.stringify(await resumeStructure(root, id), null, 2)}\n`); return; } - if (["check", "check-resource"].includes(command)) throw new Error("Use qx-workspace check in the registered repository; handwritten source/snapshot-map candidates are no longer an authoring check path"); + if (["check", "check-resource"].includes(command)) + throw new Error( + "Use qx-workspace check in the registered repository; handwritten source/snapshot-map candidates are no longer an authoring check path", + ); if (command === "evolution") { const [baseline, candidate, reviews, ...extra] = args; - if (!baseline || !candidate || extra.length) throw new Error("usage: quixos-qx evolution BASELINE_JSON CANDIDATE_JSON [REVIEWS_JSON]"); + if (!baseline || !candidate || extra.length) + throw new Error("usage: quixos-qx evolution BASELINE_JSON CANDIDATE_JSON [REVIEWS_JSON]"); const before = baseline === "none" ? null : JSON.parse(await readFile(baseline, "utf8")); const after = JSON.parse(await readFile(candidate, "utf8")); const decisions = reviews ? JSON.parse(await readFile(reviews, "utf8")) : []; @@ -320,23 +496,35 @@ const main = async () => { } if (command === "scaffold-atom") { const [root, name, id, ...flags] = args; - if (!root || !name || !id || flags.some((flag) => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-atom ROOT NAME ID [--write]"); + if (!root || !name || !id || flags.some((flag) => flag !== "--write")) + throw new Error("usage: quixos-qx scaffold-atom ROOT NAME ID [--write]"); const resolveResource = await createGitCapabilityResolver({ checkoutRoot: `${root}/.quixos/resource-checkouts` }); const plan = await scaffoldAtom({ root, name, id, write: flags.includes("--write"), resolveResource }); process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); return; } const [file, flag, ...rest] = args; - if (!file || rest.length || (flag && flag !== "--write") || !["parse", "lint", "format"].includes(command ?? "") || - (flag && command !== "format")) throw new Error("usage: quixos-qx parse|lint|format FILE [--write (format only)]"); + if ( + !file || + rest.length || + (flag && flag !== "--write") || + !["parse", "lint", "format"].includes(command ?? "") || + (flag && command !== "format") + ) + throw new Error("usage: quixos-qx parse|lint|format FILE [--write (format only)]"); const source = await readFile(file, "utf8"); if (command === "format") { const formatted = formatQx(source); - if (flag) await writeFile(file, formatted); else process.stdout.write(formatted); + if (flag) await writeFile(file, formatted); + else process.stdout.write(formatted); } else { const result = command === "parse" ? parseQx(source, file) : lintQx(source, file); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - if (Array.isArray(result) ? result.some((entry) => entry.severity === "error") : result.diagnostics.length) process.exitCode = 1; + if (Array.isArray(result) ? result.some((entry) => entry.severity === "error") : result.diagnostics.length) + process.exitCode = 1; } }; -main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; }); +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/src/capability-language/workspace-cli.ts b/src/capability-language/workspace-cli.ts index 75f69e7..bf870b8 100644 --- a/src/capability-language/workspace-cli.ts +++ b/src/capability-language/workspace-cli.ts @@ -4,7 +4,12 @@ import { readFile, writeFile } from "node:fs/promises"; import process from "node:process"; import { compileWorkspaceRepository } from "./assembly.js"; import { createGitCapabilityResolver } from "./git-resolver.js"; -import { planEvolution, runtimeContracts, type EvolutionReview, type WorkspaceRevision } from "../capability-model/index.js"; +import { + planEvolution, + runtimeContracts, + type EvolutionReview, + type WorkspaceRevision, +} from "../capability-model/index.js"; const usage = `usage: quixos-workspace-compile --root DIRECTORY --checkout-root DIRECTORY [--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID] @@ -59,40 +64,53 @@ const main = async () => { }); if (options.graphOut) { - await writeFile(options.graphOut, `${JSON.stringify({ - formatVersion: 1, - quixos: assembled.lock.quixos, - directResources: [...assembled.directResources.entries()].map(([bindingKey, node]) => { - const [kind, binding] = bindingKey.split("\0"); - return { kind, binding, resourceKey: node.key, directory: node.directory }; - }), - resources: assembled.resources.map((node) => ({ - key: node.key, - kind: node.kind, - source: node.source, - directory: node.directory, - resourceId: node.resource.kind === "interface" - ? node.resource.revision.interfaceId - : node.resource.revision.packageId, - revisionId: node.resource.revision.revisionId, - dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({ - binding, - resourceKey: dependency.key, - })), - })), - }, null, 2)}\n`); + await writeFile( + options.graphOut, + `${JSON.stringify( + { + formatVersion: 1, + quixos: assembled.lock.quixos, + directResources: [...assembled.directResources.entries()].map(([bindingKey, node]) => { + const [kind, binding] = bindingKey.split("\0"); + return { kind, binding, resourceKey: node.key, directory: node.directory }; + }), + resources: assembled.resources.map((node) => ({ + key: node.key, + kind: node.kind, + source: node.source, + directory: node.directory, + resourceId: + node.resource.kind === "interface" + ? node.resource.revision.interfaceId + : node.resource.revision.packageId, + revisionId: node.resource.revision.revisionId, + dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({ + binding, + resourceKey: dependency.key, + })), + })), + }, + null, + 2, + )}\n`, + ); } const candidate = { ...assembled.workspace, executionContracts: runtimeContracts(assembled.workspace) }; if (options.evolutionOut) { - const baseline = options.baseline ? JSON.parse(await readFile(options.baseline, "utf8")) as WorkspaceRevision : null; - const reviews = options.reviews ? JSON.parse(await readFile(options.reviews, "utf8")) as EvolutionReview[] : []; + const baseline = options.baseline + ? (JSON.parse(await readFile(options.baseline, "utf8")) as WorkspaceRevision) + : null; + const reviews = options.reviews ? (JSON.parse(await readFile(options.reviews, "utf8")) as EvolutionReview[]) : []; if (!Array.isArray(reviews)) throw new Error("Review file must contain an array"); - await writeFile(options.evolutionOut, `${JSON.stringify(planEvolution(baseline, candidate, { reviews }), null, 2)}\n`); + await writeFile( + options.evolutionOut, + `${JSON.stringify(planEvolution(baseline, candidate, { reviews }), null, 2)}\n`, + ); } process.stdout.write(`${JSON.stringify(candidate, null, 2)}\n`); }; main().catch((error: unknown) => { - process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); process.exitCode = 1; }); diff --git a/src/capability-model/evolution.ts b/src/capability-model/evolution.ts index 6014f9a..31873f8 100644 --- a/src/capability-model/evolution.ts +++ b/src/capability-model/evolution.ts @@ -3,50 +3,75 @@ import type { Binding, Conformance, DependencyBinding, PersistentAttachment, Wor import { validateWorkspaceRevision } from "./validation.js"; /** Content hashing is independent of JSON object insertion order, not array order. */ -const compareText = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0; +const compareText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); export const canonicalJson = (value: unknown): string => { if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; if (typeof value === "object" && value !== null) { - if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error("Expected a plain JSON object"); - return `{${Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => compareText(a, b)) - .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`; + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) + throw new Error("Expected a plain JSON object"); + return `{${Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .sort(([a], [b]) => compareText(a, b)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(",")}}`; } throw new Error(`Cannot hash non-JSON value: ${typeof value}`); }; -export const contentDigest = (value: unknown) => `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; +export const contentDigest = (value: unknown) => + `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`; const semantic = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(semantic); - if (value && typeof value === "object") return Object.fromEntries(Object.entries(value) - .filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation") - // These are authored data/maps, not schema nodes. A user field literally - // named displayName or documentation is semantic and must stay in the hash. - .map(([key, entry]) => [key, key === "defaultValue" || key === "fields" ? entry : semantic(entry)])); + if (value && typeof value === "object") + return Object.fromEntries( + Object.entries(value) + .filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation") + // These are authored data/maps, not schema nodes. A user field literally + // named displayName or documentation is semantic and must stay in the hash. + .map(([key, entry]) => [key, key === "defaultValue" || key === "fields" ? entry : semantic(entry)]), + ); return value; }; -const sorted = (entries: readonly T[], key: (entry: T) => string) => [...entries].sort((a, b) => compareText(key(a), key(b))); -export const conformanceIdentity = (entry: Conformance): string => entry.id ?? `legacy:${entry.atomId}:${entry.interfaceRevisionId}`; +const sorted = (entries: readonly T[], key: (entry: T) => string) => + [...entries].sort((a, b) => compareText(key(a), key(b))); +export const conformanceIdentity = (entry: Conformance): string => + entry.id ?? `legacy:${entry.atomId}:${entry.interfaceRevisionId}`; -export type StorageContract = { id: string; ownerId: string; kind: "state" | "edge"; digest: string; definition: unknown }; +export type StorageContract = { + id: string; + ownerId: string; + kind: "state" | "edge"; + digest: string; + definition: unknown; +}; /** Automatic evolution preserves values; it never interprets migration code or * guesses that a new nominal message descriptor means the same representation. */ -export const storageChangeRequiresMigration = (previous: StorageContract | undefined, next: StorageContract | undefined, - oldAtomIds: ReadonlySet): boolean => { +export const storageChangeRequiresMigration = ( + previous: StorageContract | undefined, + next: StorageContract | undefined, + oldAtomIds: ReadonlySet, +): boolean => { if (!next) return true; const after = next.definition as PersistentAttachment; if (!previous) { - if (after.kind === "state") return oldAtomIds.has(after.attachedTo) && after.defaultValue === undefined && after.valueType.kind !== "optional"; - return after.endpoints.some(endpoint => endpoint.cardinality === "exactly-one" && - (endpoint.constraint.kind !== "atom" || oldAtomIds.has(endpoint.constraint.atomId))); + if (after.kind === "state") + return ( + oldAtomIds.has(after.attachedTo) && after.defaultValue === undefined && after.valueType.kind !== "optional" + ); + return after.endpoints.some( + (endpoint) => + endpoint.cardinality === "exactly-one" && + (endpoint.constraint.kind !== "atom" || oldAtomIds.has(endpoint.constraint.atomId)), + ); } if (previous.ownerId !== next.ownerId || previous.kind !== next.kind) return true; const before = previous.definition as PersistentAttachment; if (before.kind === "state" && after.kind === "state") { // Capture materializes old defaults, so changing a default affects only // newly constructed objects, not existing sparse state. - const {defaultValue: _beforeDefault, ...beforeStorage} = before; - const {defaultValue: _afterDefault, ...afterStorage} = after; + const { defaultValue: _beforeDefault, ...beforeStorage } = before; + const { defaultValue: _afterDefault, ...afterStorage } = after; return canonicalJson(beforeStorage) !== canonicalJson(afterStorage); } return canonicalJson(before) !== canonicalJson(after); @@ -55,15 +80,29 @@ export const storageContracts = (workspace: WorkspaceRevision): StorageContract[ const result: StorageContract[] = []; const add = (attachment: PersistentAttachment, ownerId: string) => { const definition = semantic(attachment); - result.push({ id: attachment.id, ownerId, kind: attachment.kind, definition, digest: contentDigest({ ownerId, definition }) }); + result.push({ + id: attachment.id, + ownerId, + kind: attachment.kind, + definition, + digest: contentDigest({ ownerId, definition }), + }); }; for (const attachment of workspace.sharedAttachments) add(attachment, "legacy:workspace"); - for (const conformance of workspace.conformances) for (const attachment of conformance.privateAttachments) add(attachment, conformanceIdentity(conformance)); + for (const conformance of workspace.conformances) + for (const attachment of conformance.privateAttachments) add(attachment, conformanceIdentity(conformance)); return sorted(result, (entry) => entry.id); }; type GraphNode = { value: unknown; dependencies: Set; reviewProviders: Set }; -export type RuntimeContract = { groupId: string; packageId: string; packageRevisionId: string; digest: string; reviewProviders: string[]; dependencies: Array<{ id: string; digest: string }> }; +export type RuntimeContract = { + groupId: string; + packageId: string; + packageRevisionId: string; + digest: string; + reviewProviders: string[]; + dependencies: Array<{ id: string; digest: string }>; +}; /** Build only outbound execution dependencies. Incoming callers never retain or invalidate a provider. */ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[] => { @@ -75,13 +114,23 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[ }; const conformanceKey = (atom: string, iface: string) => `conformance:${atom}:${iface}`; for (const storage of storageContracts(workspace)) node(`attachment:${storage.id}`, storage); - for (const iface of workspace.interfaceImports) node(`interface:${iface.revisionId}`, { - ...iface, members: sorted(iface.members, (entry) => entry.id).map((entry) => ({ ...entry, operations: sorted(entry.operations, (operation) => operation.id) })), - }); - for (const pkg of workspace.packageImports) node(`package:${pkg.revisionId}`, { - ...pkg, semanticMajor: pkg.semanticMajor ?? 1, - exports: sorted(pkg.exports, (entry) => entry.id).map((entry) => ({ ...entry, dependencyPorts: sorted(entry.dependencyPorts, (port) => port.id) })), - }); + for (const iface of workspace.interfaceImports) + node(`interface:${iface.revisionId}`, { + ...iface, + members: sorted(iface.members, (entry) => entry.id).map((entry) => ({ + ...entry, + operations: sorted(entry.operations, (operation) => operation.id), + })), + }); + for (const pkg of workspace.packageImports) + node(`package:${pkg.revisionId}`, { + ...pkg, + semanticMajor: pkg.semanticMajor ?? 1, + exports: sorted(pkg.exports, (entry) => entry.id).map((entry) => ({ + ...entry, + dependencyPorts: sorted(entry.dependencyPorts, (port) => port.id), + })), + }); const dependency = (parent: GraphNode, binding: DependencyBinding, atomId: string, reviews?: Set) => { if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`); if (binding.kind === "edge") parent.dependencies.add(`attachment:${binding.edgeTypeId}`); @@ -96,7 +145,10 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[ parent.dependencies.add(`interface:${binding.interfaceRevisionId}`); // An edge traversal may select any matching target. Conservatively include every possible witness. for (const conformance of workspace.conformances) { - if (conformance.interfaceRevisionId === binding.interfaceRevisionId && (binding.via || conformance.atomId === atomId)) { + if ( + conformance.interfaceRevisionId === binding.interfaceRevisionId && + (binding.via || conformance.atomId === atomId) + ) { parent.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId)); reviews?.add(conformanceIdentity(conformance)); for (const operation of conformance.operationBindings) { @@ -110,7 +162,10 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[ } }; const binding = (parent: GraphNode, value: Binding, atomId: string, context: unknown) => { - if (value.kind !== "package") { dependency(parent, value, atomId); return; } + if (value.kind !== "package") { + dependency(parent, value, atomId); + return; + } const packageNode = nodes.get(`package:${value.packageRevisionId}`)!; parent.dependencies.add(`package:${value.packageRevisionId}`); const normalized = { ...value, dependencies: sorted(value.dependencies, (entry) => entry.portId) }; @@ -121,16 +176,21 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[ }; for (const conformance of workspace.conformances) { const parent = node(conformanceKey(conformance.atomId, conformance.interfaceRevisionId), { - id: conformanceIdentity(conformance), semanticMajor: conformance.semanticMajor ?? 1, - atomId: conformance.atomId, interfaceRevisionId: conformance.interfaceRevisionId, + id: conformanceIdentity(conformance), + semanticMajor: conformance.semanticMajor ?? 1, + atomId: conformance.atomId, + interfaceRevisionId: conformance.interfaceRevisionId, operations: sorted(conformance.operationBindings, (entry) => entry.operationId), materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId), }); parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`); for (const attachment of conformance.privateAttachments) parent.dependencies.add(`attachment:${attachment.id}`); - for (const operation of conformance.operationBindings) binding(parent, operation.binding, conformance.atomId, { - conformanceId: conformanceIdentity(conformance), semanticMajor: conformance.semanticMajor ?? 1, operationId: operation.operationId, - }); + for (const operation of conformance.operationBindings) + binding(parent, operation.binding, conformance.atomId, { + conformanceId: conformanceIdentity(conformance), + semanticMajor: conformance.semanticMajor ?? 1, + operationId: operation.operationId, + }); for (const materialization of conformance.relationshipMaterializations) { parent.dependencies.add(`constructor:${materialization.constructorAtomId}`); parent.dependencies.add(`attachment:${materialization.edgeTypeId}`); @@ -142,91 +202,195 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[ } const counts = new Map(); for (const pkg of workspace.packageImports) counts.set(pkg.packageId, (counts.get(pkg.packageId) ?? 0) + 1); - return sorted(workspace.packageImports.map((pkg): RuntimeContract => { - const visited = new Set(); - const walk = (key: string) => { - if (visited.has(key)) return; - const entry = nodes.get(key); - if (!entry) throw new Error(`Unresolved execution dependency ${key}`); - visited.add(key); - for (const target of entry.dependencies) walk(target); - }; - walk(`package:${pkg.revisionId}`); - const dependencies = [...visited].sort().map((id) => ({ id, digest: contentDigest(nodes.get(id)!.value) })); - return { groupId: counts.get(pkg.packageId) === 1 ? pkg.packageId : `${pkg.packageId}#${pkg.revisionId}`, - packageId: pkg.packageId, packageRevisionId: pkg.revisionId, digest: contentDigest(dependencies), - reviewProviders: [...nodes.get(`package:${pkg.revisionId}`)!.reviewProviders].sort(), dependencies }; - }), (entry) => entry.groupId); + return sorted( + workspace.packageImports.map((pkg): RuntimeContract => { + const visited = new Set(); + const walk = (key: string) => { + if (visited.has(key)) return; + const entry = nodes.get(key); + if (!entry) throw new Error(`Unresolved execution dependency ${key}`); + visited.add(key); + for (const target of entry.dependencies) walk(target); + }; + walk(`package:${pkg.revisionId}`); + const dependencies = [...visited].sort().map((id) => ({ id, digest: contentDigest(nodes.get(id)!.value) })); + return { + groupId: counts.get(pkg.packageId) === 1 ? pkg.packageId : `${pkg.packageId}#${pkg.revisionId}`, + packageId: pkg.packageId, + packageRevisionId: pkg.revisionId, + digest: contentDigest(dependencies), + reviewProviders: [...nodes.get(`package:${pkg.revisionId}`)!.reviewProviders].sort(), + dependencies, + }; + }), + (entry) => entry.groupId, + ); }; -export type EvolutionReview = { requirementDigest: string; decision: "changed" | "accepted-unchanged"; rationale: string; agentId: string }; -export type ReviewRequirement = { consumerId: string; providerId: string; oldMajor: number; newMajor: number; requirementDigest: string }; -export type RuntimeAction = { groupId: string; action: "keep" | "start" | "replace" | "retire"; previous?: RuntimeContract; candidate?: RuntimeContract; reasons: string[] }; +export type EvolutionReview = { + requirementDigest: string; + decision: "changed" | "accepted-unchanged"; + rationale: string; + agentId: string; +}; +export type ReviewRequirement = { + consumerId: string; + providerId: string; + oldMajor: number; + newMajor: number; + requirementDigest: string; +}; +export type RuntimeAction = { + groupId: string; + action: "keep" | "start" | "replace" | "retire"; + previous?: RuntimeContract; + candidate?: RuntimeContract; + reasons: string[]; +}; export type EvolutionReport = { - schemaVersion: 1; baselineDigest: string | null; candidateDigest: string; checkerVersion: string; + schemaVersion: 1; + baselineDigest: string | null; + candidateDigest: string; + checkerVersion: string; runtimeActions: RuntimeAction[]; - storageChanges: Array<{ id: string; kind: "add" | "remove" | "change"; requiresMigration: boolean; previous?: StorageContract; candidate?: StorageContract }>; + storageChanges: Array<{ + id: string; + kind: "add" | "remove" | "change"; + requiresMigration: boolean; + previous?: StorageContract; + candidate?: StorageContract; + }>; migrationRequired: string[]; reviews: Array; packageChecks: Array<{ groupId: string; contractDigest: string }>; blockers: string[]; }; -export const planEvolution = (baseline: WorkspaceRevision | null, candidate: WorkspaceRevision, - options: { reviews?: EvolutionReview[]; allowLegacy?: boolean } = {}): EvolutionReport => { +export const planEvolution = ( + baseline: WorkspaceRevision | null, + candidate: WorkspaceRevision, + options: { reviews?: EvolutionReview[]; allowLegacy?: boolean } = {}, +): EvolutionReport => { const issues = validateWorkspaceRevision(candidate); - if (issues.length) throw new Error(`Invalid candidate workspace:\n${issues.map((entry) => `${entry.path}: ${entry.message}`).join("\n")}`); - if (baseline && baseline.workspaceId !== candidate.workspaceId) throw new Error("Cannot evolve a different workspace"); + if (issues.length) + throw new Error( + `Invalid candidate workspace:\n${issues.map((entry) => `${entry.path}: ${entry.message}`).join("\n")}`, + ); + if (baseline && baseline.workspaceId !== candidate.workspaceId) + throw new Error("Cannot evolve a different workspace"); const candidateDigest = contentDigest(candidate); const checkerVersion = "quixos-evolution-v1"; const blockers: string[] = []; if (!options.allowLegacy) { - if (candidate.sharedAttachments.length) blockers.push("Assign legacy workspace-shared attachments to explicit conformance owners"); - for (const entry of candidate.conformances) if (!entry.id) blockers.push(`Conformance ${entry.atomId} as ${entry.interfaceRevisionId} requires an authored ID`); + if (candidate.sharedAttachments.length) + blockers.push("Assign legacy workspace-shared attachments to explicit conformance owners"); + for (const entry of candidate.conformances) + if (!entry.id) + blockers.push(`Conformance ${entry.atomId} as ${entry.interfaceRevisionId} requires an authored ID`); } const previousRuntimes = new Map((baseline ? runtimeContracts(baseline) : []).map((entry) => [entry.groupId, entry])); const nextRuntimes = new Map(runtimeContracts(candidate).map((entry) => [entry.groupId, entry])); - const runtimeActions: RuntimeAction[] = [...new Set([...previousRuntimes.keys(), ...nextRuntimes.keys()])].sort().map((groupId) => { - const previous = previousRuntimes.get(groupId), next = nextRuntimes.get(groupId); - const before = new Map(previous?.dependencies.map((entry) => [entry.id, entry.digest])); - const after = new Map(next?.dependencies.map((entry) => [entry.id, entry.digest])); - const reasons = [...new Set([...before.keys(), ...after.keys()])].sort().filter((id) => before.get(id) !== after.get(id)); - return { groupId, action: !previous ? "start" : !next ? "retire" : previous.digest === next.digest ? "keep" : "replace", - ...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}), reasons }; - }); + const runtimeActions: RuntimeAction[] = [...new Set([...previousRuntimes.keys(), ...nextRuntimes.keys()])] + .sort() + .map((groupId) => { + const previous = previousRuntimes.get(groupId), + next = nextRuntimes.get(groupId); + const before = new Map(previous?.dependencies.map((entry) => [entry.id, entry.digest])); + const after = new Map(next?.dependencies.map((entry) => [entry.id, entry.digest])); + const reasons = [...new Set([...before.keys(), ...after.keys()])] + .sort() + .filter((id) => before.get(id) !== after.get(id)); + return { + groupId, + action: !previous ? "start" : !next ? "retire" : previous.digest === next.digest ? "keep" : "replace", + ...(previous ? { previous } : {}), + ...(next ? { candidate: next } : {}), + reasons, + }; + }); const beforeStorage = new Map((baseline ? storageContracts(baseline) : []).map((entry) => [entry.id, entry])); const afterStorage = new Map(storageContracts(candidate).map((entry) => [entry.id, entry])); const storageChanges: EvolutionReport["storageChanges"] = []; for (const id of [...new Set([...beforeStorage.keys(), ...afterStorage.keys()])].sort()) { - const previous = beforeStorage.get(id), next = afterStorage.get(id); - if (previous?.digest !== next?.digest) storageChanges.push({ id, kind: !previous ? "add" : !next ? "remove" : "change", - requiresMigration: storageChangeRequiresMigration(previous, next, new Set(baseline?.atoms.map(atom => atom.id) ?? [])), - ...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}) }); + const previous = beforeStorage.get(id), + next = afterStorage.get(id); + if (previous?.digest !== next?.digest) + storageChanges.push({ + id, + kind: !previous ? "add" : !next ? "remove" : "change", + requiresMigration: storageChangeRequiresMigration( + previous, + next, + new Set(baseline?.atoms.map((atom) => atom.id) ?? []), + ), + ...(previous ? { previous } : {}), + ...(next ? { candidate: next } : {}), + }); } const providers = (workspace: WorkspaceRevision) => [ - ...workspace.packageImports.map((entry) => ({ id: entry.packageId as string, revision: entry.revisionId as string, major: entry.semanticMajor ?? 1, - node: `package:${entry.revisionId}`, digest: contentDigest(entry) })), - ...workspace.conformances.map((entry) => ({ id: conformanceIdentity(entry), revision: contentDigest(entry), major: entry.semanticMajor ?? 1, - node: `conformance:${entry.atomId}:${entry.interfaceRevisionId}`, digest: contentDigest(entry) })), + ...workspace.packageImports.map((entry) => ({ + id: entry.packageId as string, + revision: entry.revisionId as string, + major: entry.semanticMajor ?? 1, + node: `package:${entry.revisionId}`, + digest: contentDigest(entry), + })), + ...workspace.conformances.map((entry) => ({ + id: conformanceIdentity(entry), + revision: contentDigest(entry), + major: entry.semanticMajor ?? 1, + node: `conformance:${entry.atomId}:${entry.interfaceRevisionId}`, + digest: contentDigest(entry), + })), ]; const oldProviders = baseline ? providers(baseline) : []; const reviews: EvolutionReport["reviews"] = []; for (const provider of providers(candidate)) { const old = oldProviders.filter((entry) => entry.id === provider.id); - if (old.length > 1) { blockers.push(`Ambiguous semantic-major lineage for ${provider.id}`); continue; } + if (old.length > 1) { + blockers.push(`Ambiguous semantic-major lineage for ${provider.id}`); + continue; + } if (!old[0] || old[0].major === provider.major) continue; if (provider.major < old[0].major) blockers.push(`Semantic major decreases for ${provider.id}`); for (const consumer of nextRuntimes.values()) { if (consumer.packageId === provider.id || !consumer.reviewProviders.includes(provider.id)) continue; - const requirement = { consumerId: consumer.groupId, providerId: provider.id, oldMajor: old[0].major, newMajor: provider.major }; - const requirementDigest = contentDigest({ ...requirement, oldProvider: old[0].digest, newProvider: provider.digest, consumer: consumer.digest, checkerVersion }); - const accepted = (options.reviews ?? []).some((entry) => entry.requirementDigest === requirementDigest && - ["changed", "accepted-unchanged"].includes(entry.decision) && entry.rationale.trim() && entry.agentId.trim()); + const requirement = { + consumerId: consumer.groupId, + providerId: provider.id, + oldMajor: old[0].major, + newMajor: provider.major, + }; + const requirementDigest = contentDigest({ + ...requirement, + oldProvider: old[0].digest, + newProvider: provider.digest, + consumer: consumer.digest, + checkerVersion, + }); + const accepted = (options.reviews ?? []).some( + (entry) => + entry.requirementDigest === requirementDigest && + ["changed", "accepted-unchanged"].includes(entry.decision) && + entry.rationale.trim() && + entry.agentId.trim(), + ); reviews.push({ ...requirement, requirementDigest, accepted }); if (!accepted) blockers.push(`Semantic-major review required: ${consumer.groupId} consumes ${provider.id}`); } } - return { schemaVersion: 1, baselineDigest: baseline ? contentDigest(baseline) : null, candidateDigest, checkerVersion, - runtimeActions, storageChanges, migrationRequired: storageChanges.filter(entry => entry.requiresMigration).map(entry => entry.id), reviews, packageChecks: runtimeActions.filter((entry) => entry.candidate && entry.action !== "keep") - .map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })), blockers }; + return { + schemaVersion: 1, + baselineDigest: baseline ? contentDigest(baseline) : null, + candidateDigest, + checkerVersion, + runtimeActions, + storageChanges, + migrationRequired: storageChanges.filter((entry) => entry.requiresMigration).map((entry) => entry.id), + reviews, + packageChecks: runtimeActions + .filter((entry) => entry.candidate && entry.action !== "keep") + .map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })), + blockers, + }; }; diff --git a/src/capability-model/migrations.ts b/src/capability-model/migrations.ts index 7330f90..c4a367c 100644 --- a/src/capability-model/migrations.ts +++ b/src/capability-model/migrations.ts @@ -4,12 +4,23 @@ import type { PersistentAttachment } from "./types.js"; /** Portable storage shape: local owner/slot/projection identities are supplied * by the consuming workspace's explicit bindings, never baked into this hash. */ export const migrationPortContract = (attachment: PersistentAttachment): unknown => { - if (attachment.kind === "state") return {kind: "state", valueType: attachment.valueType, storagePolicy: attachment.storagePolicy, - ...(attachment.defaultValue === undefined ? {} : {defaultValue: attachment.defaultValue})}; - const endpoint = (value: typeof attachment.endpoints[number]) => ({constraint: value.constraint, cardinality: value.cardinality, - ordered: value.ordered, onDelete: value.onDelete ?? "restrict", retainOther: value.retainOther ?? false, - ...(value.keyType ? {keyType: value.keyType} : {}), ...(value.publicTraversal ? {publicTraversal: true} : {})}); - return {kind: "edge", first: endpoint(attachment.endpoints[0]), second: endpoint(attachment.endpoints[1])}; + if (attachment.kind === "state") + return { + kind: "state", + valueType: attachment.valueType, + storagePolicy: attachment.storagePolicy, + ...(attachment.defaultValue === undefined ? {} : { defaultValue: attachment.defaultValue }), + }; + const endpoint = (value: (typeof attachment.endpoints)[number]) => ({ + constraint: value.constraint, + cardinality: value.cardinality, + ordered: value.ordered, + onDelete: value.onDelete ?? "restrict", + retainOther: value.retainOther ?? false, + ...(value.keyType ? { keyType: value.keyType } : {}), + ...(value.publicTraversal ? { publicTraversal: true } : {}), + }); + return { kind: "edge", first: endpoint(attachment.endpoints[0]), second: endpoint(attachment.endpoints[1]) }; }; export type MigrationDeclaration = { @@ -19,7 +30,12 @@ export type MigrationDeclaration = { to: string; implementation: { exportId: string; file: string; digest: string }; predecessors: string[]; - ports: { name: string; view: "old" | "new"; access: ("read" | "write" | "create" | "edge")[]; contractDigest: string }[]; + ports: { + name: string; + view: "old" | "new"; + access: ("read" | "write" | "create" | "edge")[]; + contractDigest: string; + }[]; preservesOldReaders?: boolean; preservesOldWriters?: boolean; }; @@ -29,56 +45,99 @@ export type MigrationCatalog = { migrations: MigrationDeclaration[]; }; export const validateMigrationCatalog = (value: unknown, exportIds?: ReadonlySet): MigrationCatalog => { - if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Migration catalog must be an object"); + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Migration catalog must be an object"); const catalog = value as MigrationCatalog; - if (catalog.schemaVersion !== 1 || !catalog.contracts || Array.isArray(catalog.contracts) || !Array.isArray(catalog.migrations)) throw new Error("Unsupported migration catalog"); - for (const [digest, contract] of Object.entries(catalog.contracts)) if (contentDigest(contract) !== digest) throw new Error(`Migration contract digest mismatch: ${digest}`); + if ( + catalog.schemaVersion !== 1 || + !catalog.contracts || + Array.isArray(catalog.contracts) || + !Array.isArray(catalog.migrations) + ) + throw new Error("Unsupported migration catalog"); + for (const [digest, contract] of Object.entries(catalog.contracts)) + if (contentDigest(contract) !== digest) throw new Error(`Migration contract digest mismatch: ${digest}`); const ids = new Set(); for (const migration of catalog.migrations) { - if (!migration.id || !migration.scopeId || ids.has(migration.id)) throw new Error("Migration IDs must be stable and unique"); + if (!migration.id || !migration.scopeId || ids.has(migration.id)) + throw new Error("Migration IDs must be stable and unique"); ids.add(migration.id); - if (!catalog.contracts[migration.from] || !catalog.contracts[migration.to] || migration.from === migration.to) throw new Error(`Migration ${migration.id} requires distinct retained source and target contracts`); - if (!migration.implementation?.exportId || !/^sha256:[0-9a-f]{64}$/.test(migration.implementation.digest)) throw new Error(`Migration ${migration.id} requires an exact implementation digest`); - if (!migration.implementation.file || migration.implementation.file.startsWith("/") || migration.implementation.file.split(/[\\/]/).some((part) => !part || part === "." || part === "..")) throw new Error("Migration implementation must be a relative package file"); - if (exportIds && !exportIds.has(migration.implementation.exportId)) throw new Error(`Migration ${migration.id} refers to an undeclared package export`); - if (!Array.isArray(migration.predecessors) || !Array.isArray(migration.ports)) throw new Error(`Migration ${migration.id} requires predecessors and ports`); - if (new Set(migration.predecessors).size !== migration.predecessors.length) throw new Error(`Duplicate predecessor in ${migration.id}`); - for (const promise of [migration.preservesOldReaders, migration.preservesOldWriters]) if (promise !== undefined && typeof promise !== "boolean") throw new Error("Migration compatibility promises must be booleans"); + if (!catalog.contracts[migration.from] || !catalog.contracts[migration.to] || migration.from === migration.to) + throw new Error(`Migration ${migration.id} requires distinct retained source and target contracts`); + if (!migration.implementation?.exportId || !/^sha256:[0-9a-f]{64}$/.test(migration.implementation.digest)) + throw new Error(`Migration ${migration.id} requires an exact implementation digest`); + if ( + !migration.implementation.file || + migration.implementation.file.startsWith("/") || + migration.implementation.file.split(/[\\/]/).some((part) => !part || part === "." || part === "..") + ) + throw new Error("Migration implementation must be a relative package file"); + if (exportIds && !exportIds.has(migration.implementation.exportId)) + throw new Error(`Migration ${migration.id} refers to an undeclared package export`); + if (!Array.isArray(migration.predecessors) || !Array.isArray(migration.ports)) + throw new Error(`Migration ${migration.id} requires predecessors and ports`); + if (new Set(migration.predecessors).size !== migration.predecessors.length) + throw new Error(`Duplicate predecessor in ${migration.id}`); + for (const promise of [migration.preservesOldReaders, migration.preservesOldWriters]) + if (promise !== undefined && typeof promise !== "boolean") + throw new Error("Migration compatibility promises must be booleans"); const ports = new Set(); for (const port of migration.ports) { - if (!port.name || ports.has(port.name) || !["old", "new"].includes(port.view) || !Array.isArray(port.access) || !port.access.length - || port.access.some((access) => !["read", "write", "create", "edge"].includes(access)) || !catalog.contracts[port.contractDigest]) throw new Error(`Invalid migration port in ${migration.id}`); - if (port.view === "old" && port.access.some((access) => access !== "read")) throw new Error("Old migration views are read-only"); + if ( + !port.name || + ports.has(port.name) || + !["old", "new"].includes(port.view) || + !Array.isArray(port.access) || + !port.access.length || + port.access.some((access) => !["read", "write", "create", "edge"].includes(access)) || + !catalog.contracts[port.contractDigest] + ) + throw new Error(`Invalid migration port in ${migration.id}`); + if (port.view === "old" && port.access.some((access) => access !== "read")) + throw new Error("Old migration views are read-only"); ports.add(port.name); } } - for (const migration of catalog.migrations) for (const predecessor of migration.predecessors) if (!ids.has(predecessor)) throw new Error(`Missing retained predecessor ${predecessor}`); + for (const migration of catalog.migrations) + for (const predecessor of migration.predecessors) + if (!ids.has(predecessor)) throw new Error(`Missing retained predecessor ${predecessor}`); // Catalogs are retained across releases. Reject impossible histories at // publication/check time, not only when someone tries to select a path. const remaining = new Map(catalog.migrations.map((entry) => [entry.id, new Set(entry.predecessors)])); const ready = [...remaining].filter(([, dependencies]) => dependencies.size === 0).map(([id]) => id); for (let index = 0; index < ready.length; index++) { remaining.delete(ready[index]); - for (const [id, dependencies] of remaining) if (dependencies.delete(ready[index]) && dependencies.size === 0) ready.push(id); + for (const [id, dependencies] of remaining) + if (dependencies.delete(ready[index]) && dependencies.size === 0) ready.push(id); } if (remaining.size) throw new Error(`Cyclic migration predecessors: ${[...remaining.keys()].join(", ")}`); return catalog; }; export type MigrationSelection = { - scopeId: string; from: string; to: string; path: string[]; + scopeId: string; + from: string; + to: string; + path: string[]; bindings: Record; }; /** Explicit paths, not shortest-path guesses. Receipts identify code plus local scope mapping. */ -export const selectMigrationPath = (catalog: MigrationCatalog, selection: MigrationSelection, previousReceipts: ReadonlyMap = new Map()) => { +export const selectMigrationPath = ( + catalog: MigrationCatalog, + selection: MigrationSelection, + previousReceipts: ReadonlyMap = new Map(), +) => { validateMigrationCatalog(catalog); let current = selection.from; const seen = new Set(); const transitions = []; for (const id of selection.path) { const declaration = catalog.migrations.find((entry) => entry.id === id); - if (!declaration || declaration.scopeId !== selection.scopeId || declaration.from !== current || seen.has(id)) throw new Error(`Invalid selected migration transition ${id}`); - for (const predecessor of declaration.predecessors) if (!seen.has(predecessor) && !previousReceipts.has(predecessor)) throw new Error(`Unsatisfied predecessor ${predecessor}`); + if (!declaration || declaration.scopeId !== selection.scopeId || declaration.from !== current || seen.has(id)) + throw new Error(`Invalid selected migration transition ${id}`); + for (const predecessor of declaration.predecessors) + if (!seen.has(predecessor) && !previousReceipts.has(predecessor)) + throw new Error(`Unsatisfied predecessor ${predecessor}`); const usedBindings: Record = {}; for (const port of declaration.ports) { if (!selection.bindings[port.name]) throw new Error(`Missing local migration binding ${port.name}`); @@ -86,7 +145,8 @@ export const selectMigrationPath = (catalog: MigrationCatalog, selection: Migrat } const digest = contentDigest({ declaration, bindings: usedBindings }); const previous = previousReceipts.get(id); - if (previous && previous !== digest) throw new Error(`Migration identity ${id} was previously used with different code or scope`); + if (previous && previous !== digest) + throw new Error(`Migration identity ${id} was previously used with different code or scope`); transitions.push({ declaration, bindings: usedBindings, digest, alreadyApplied: Boolean(previous) }); seen.add(id); current = declaration.to; diff --git a/src/capability-model/types.ts b/src/capability-model/types.ts index 15bc9e3..b5ef4c1 100644 --- a/src/capability-model/types.ts +++ b/src/capability-model/types.ts @@ -29,13 +29,11 @@ const opaque = (value: string) => value as OpaqueId; */ export const capabilityId = { workspace: (value: string) => opaque<"WorkspaceId">(value), - workspaceRevision: (value: string) => - opaque<"WorkspaceRevisionId">(value), + workspaceRevision: (value: string) => opaque<"WorkspaceRevisionId">(value), atom: (value: string) => opaque<"AtomId">(value), conformance: (value: string) => opaque<"ConformanceId">(value), interface: (value: string) => opaque<"InterfaceId">(value), - interfaceRevision: (value: string) => - opaque<"InterfaceRevisionId">(value), + interfaceRevision: (value: string) => opaque<"InterfaceRevisionId">(value), member: (value: string) => opaque<"MemberId">(value), operation: (value: string) => opaque<"OperationId">(value), slot: (value: string) => opaque<"SlotId">(value), @@ -53,15 +51,7 @@ export interface SourceRevision { commit: string; } -export type ScalarValueTypeName = - | "bool" - | "bytes" - | "double" - | "int32" - | "int64" - | "string" - | "uint32" - | "uint64"; +export type ScalarValueTypeName = "bool" | "bytes" | "double" | "int32" | "int64" | "string" | "uint32" | "uint64"; export type ObjectExpectation = | { kind: "atom"; atomId: AtomId } @@ -112,12 +102,7 @@ export interface AtomDefinition { documentation?: string; } -export type InterfaceOperationMode = - | "call" - | "watch-start" - | "watch-stop" - | "subscribe" - | "unsubscribe"; +export type InterfaceOperationMode = "call" | "watch-start" | "watch-stop" | "subscribe" | "unsubscribe"; export interface InterfaceOperation { id: OperationId; @@ -140,11 +125,7 @@ export interface ValueInterfaceMember extends InterfaceMemberBase { valueType: ValueType; } -export type EdgeCardinality = - | "optional-one" - | "exactly-one" - | "many" - | "many-unique"; +export type EdgeCardinality = "optional-one" | "exactly-one" | "many" | "many-unique"; export type EdgeEndpointConstraint = | { kind: "atom"; atomId: AtomId } @@ -167,10 +148,7 @@ export interface OperationInterfaceMember extends InterfaceMemberBase { outputType: ValueType; } -export type InterfaceMember = - | ValueInterfaceMember - | RelationshipInterfaceMember - | OperationInterfaceMember; +export type InterfaceMember = ValueInterfaceMember | RelationshipInterfaceMember | OperationInterfaceMember; export interface InterfaceRevision { interfaceId: InterfaceId; @@ -180,9 +158,7 @@ export interface InterfaceRevision { members: InterfaceMember[]; } -export type StoragePolicy = - | { kind: "optimistic-register" } - | { kind: "crdt-document"; updateType: ValueType }; +export type StoragePolicy = { kind: "optimistic-register" } | { kind: "crdt-document"; updateType: ValueType }; export interface StateSlotDefinition { kind: "state"; @@ -215,18 +191,9 @@ export interface EdgeDefinition { export type PersistentAttachment = StateSlotDefinition | EdgeDefinition; -export type StatePrimitive = - | "read" - | "write" - | "watch-start" - | "watch-stop"; +export type StatePrimitive = "read" | "write" | "watch-start" | "watch-stop"; -export type EdgePrimitive = - | "resolve" - | "connect" - | "disconnect" - | "watch-start" - | "watch-stop"; +export type EdgePrimitive = "resolve" | "connect" | "disconnect" | "watch-start" | "watch-stop"; export type PackageReceiverRequirement = | { kind: "any-object" } @@ -284,10 +251,7 @@ export interface PackageConstructorExport extends PackageExportBase { constructsAtom: AtomId; } -export type PackageExport = - | PackageOperationExport - | PackageFunctionExport - | PackageConstructorExport; +export type PackageExport = PackageOperationExport | PackageFunctionExport | PackageConstructorExport; export interface PackageRevision { migrationCatalog?: import("./migrations.js").MigrationCatalog; diff --git a/src/capability-model/validation.ts b/src/capability-model/validation.ts index 7ffdd7a..6597082 100644 --- a/src/capability-model/validation.ts +++ b/src/capability-model/validation.ts @@ -102,22 +102,16 @@ interface ValidationIndexes { conformances: Map; conformancePaths: Map; attachments: Map; - projections: Map< - string, - { edge: EdgeDefinition; endpoint: EdgeEndpoint; endpointIndex: 0 | 1 } - >; + projections: Map; constructors: Map; } const hasText = (value: string) => value.trim().length > 0; -const conformanceKey = ( - atomId: AtomId, - interfaceRevisionId: InterfaceRevisionId, -): string => `${atomId}\u0000${interfaceRevisionId}`; +const conformanceKey = (atomId: AtomId, interfaceRevisionId: InterfaceRevisionId): string => + `${atomId}\u0000${interfaceRevisionId}`; -const attachmentKey = (attachment: PersistentAttachment): string => - `${attachment.kind}\u0000${attachment.id}`; +const attachmentKey = (attachment: PersistentAttachment): string => `${attachment.kind}\u0000${attachment.id}`; const issue = ( issues: CapabilityValidationIssue[], @@ -126,12 +120,7 @@ const issue = ( message: string, ) => issues.push({ code, path, message }); -const requireText = ( - issues: CapabilityValidationIssue[], - value: string, - path: string, - label: string, -) => { +const requireText = (issues: CapabilityValidationIssue[], value: string, path: string, label: string) => { if (!hasText(value)) { issue(issues, "required-value", path, `${label} is required`); } @@ -145,7 +134,10 @@ const typeLabel = (type: ValueType): string => { case "message": return `message:${type.descriptorId}`; case "record": - return `record{${Object.keys(type.fields).sort().map((key) => `${key}:${typeLabel(type.fields[key])}`).join(";")}}`; + return `record{${Object.keys(type.fields) + .sort() + .map((key) => `${key}:${typeLabel(type.fields[key])}`) + .join(";")}}`; case "object-ref": return type.expectation.kind === "atom" ? `object:atom:${type.expectation.atomId}` @@ -169,7 +161,12 @@ export const valueTypesEqual = (left: ValueType, right: ValueType): boolean => { return left.descriptorId === (right as typeof left).descriptorId; case "record": { const other = (right as typeof left).fields; - return Object.keys(left.fields).length === Object.keys(other).length && Object.entries(left.fields).every(([key, value]) => Object.hasOwn(other, key) && valueTypesEqual(value, other[key])); + return ( + Object.keys(left.fields).length === Object.keys(other).length && + Object.entries(left.fields).every( + ([key, value]) => Object.hasOwn(other, key) && valueTypesEqual(value, other[key]), + ) + ); } case "object-ref": { const other = (right as typeof left).expectation; @@ -177,10 +174,8 @@ export const valueTypesEqual = (left: ValueType, right: ValueType): boolean => { return false; } return left.expectation.kind === "atom" - ? left.expectation.atomId === - (other as typeof left.expectation).atomId - : left.expectation.interfaceRevisionId === - (other as typeof left.expectation).interfaceRevisionId; + ? left.expectation.atomId === (other as typeof left.expectation).atomId + : left.expectation.interfaceRevisionId === (other as typeof left.expectation).interfaceRevisionId; } case "optional": case "list": @@ -188,10 +183,7 @@ export const valueTypesEqual = (left: ValueType, right: ValueType): boolean => { } }; -const constraintsEqual = ( - left: EdgeEndpointConstraint, - right: EdgeEndpointConstraint, -) => +const constraintsEqual = (left: EdgeEndpointConstraint, right: EdgeEndpointConstraint) => left.kind === right.kind && (left.kind === "atom" ? left.atomId === (right as typeof left).atomId @@ -202,19 +194,10 @@ const sourceRequiredFields: Array<[keyof SourceRevision, string]> = [ ["commit", "commit"], ]; -const validateSource = ( - issues: CapabilityValidationIssue[], - source: SourceRevision, - path: string, -) => { +const validateSource = (issues: CapabilityValidationIssue[], source: SourceRevision, path: string) => { for (const [key, label] of sourceRequiredFields) { if (!hasText(source[key])) { - issue( - issues, - "invalid-source", - `${path}.${key}`, - `Source ${label} is required`, - ); + issue(issues, "invalid-source", `${path}.${key}`, `Source ${label} is required`); } } }; @@ -228,7 +211,8 @@ const validateValueType = ( switch (type.kind) { case "record": for (const [name, field] of Object.entries(type.fields)) { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) issue(issues, "invalid-value-type", path, "Invalid record field name"); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) + issue(issues, "invalid-value-type", path, "Invalid record field name"); validateValueType(issues, field, `${path}.fields.${name}`, indexes); } return; @@ -236,12 +220,7 @@ const validateValueType = ( case "scalar": return; case "message": - requireText( - issues, - type.descriptorId, - `${path}.descriptorId`, - "Message descriptor identity", - ); + requireText(issues, type.descriptorId, `${path}.descriptorId`, "Message descriptor identity"); return; case "optional": case "list": @@ -257,9 +236,7 @@ const validateValueType = ( `Unknown atom ${type.expectation.atomId}`, ); } - } else if ( - !indexes.interfaces.has(type.expectation.interfaceRevisionId) - ) { + } else if (!indexes.interfaces.has(type.expectation.interfaceRevisionId)) { issue( issues, "unresolved-reference", @@ -278,12 +255,7 @@ const validateConstraint = ( ) => { if (constraint.kind === "atom") { if (!indexes.atoms.has(constraint.atomId)) { - issue( - issues, - "unresolved-reference", - `${path}.atomId`, - `Unknown atom ${constraint.atomId}`, - ); + issue(issues, "unresolved-reference", `${path}.atomId`, `Unknown atom ${constraint.atomId}`); } } else if (!indexes.interfaces.has(constraint.interfaceRevisionId)) { issue( @@ -295,8 +267,7 @@ const validateConstraint = ( } }; -const emitsEvents = (mode: InterfaceOperationMode) => - mode === "watch-start" || mode === "subscribe"; +const emitsEvents = (mode: InterfaceOperationMode) => mode === "watch-start" || mode === "subscribe"; const validateOperationEventShape = ( issues: CapabilityValidationIssue[], @@ -304,12 +275,7 @@ const validateOperationEventShape = ( path: string, ) => { if (emitsEvents(operation.mode) && operation.eventType === undefined) { - issue( - issues, - "invalid-operation", - `${path}.eventType`, - `${operation.mode} operations require an event type`, - ); + issue(issues, "invalid-operation", `${path}.eventType`, `${operation.mode} operations require an event type`); } if (!emitsEvents(operation.mode) && operation.eventType !== undefined) { issue( @@ -328,33 +294,21 @@ interface OperationSignature { eventType?: ValueType; } -const signaturesMatch = ( - operation: OperationSignature, - implementation: OperationSignature, -) => +const signaturesMatch = (operation: OperationSignature, implementation: OperationSignature) => operation.mode === implementation.mode && valueTypesEqual(operation.inputType, implementation.inputType) && valueTypesEqual(operation.outputType, implementation.outputType) && (operation.eventType === undefined ? implementation.eventType === undefined - : implementation.eventType !== undefined && - valueTypesEqual(operation.eventType, implementation.eventType)); + : implementation.eventType !== undefined && valueTypesEqual(operation.eventType, implementation.eventType)); const describeSignature = (signature: OperationSignature) => { - const event = signature.eventType - ? ` emits ${typeLabel(signature.eventType)}` - : ""; + const event = signature.eventType ? ` emits ${typeLabel(signature.eventType)}` : ""; return `${signature.mode} ${typeLabel(signature.inputType)} -> ${typeLabel(signature.outputType)}${event}`; }; -export const statePrimitiveSignature = ( - slot: StateSlotDefinition, - primitive: StatePrimitive, -): OperationSignature => { - const writeType = - slot.storagePolicy.kind === "crdt-document" - ? slot.storagePolicy.updateType - : slot.valueType; +export const statePrimitiveSignature = (slot: StateSlotDefinition, primitive: StatePrimitive): OperationSignature => { + const writeType = slot.storagePolicy.kind === "crdt-document" ? slot.storagePolicy.updateType : slot.valueType; switch (primitive) { case "read": return { @@ -389,10 +343,7 @@ const constraintValueType = (constraint: EdgeEndpointConstraint): ValueType => ? valueType.atomRef(constraint.atomId) : valueType.interfaceRef(constraint.interfaceRevisionId); -export const cardinalityValueType = ( - constraint: EdgeEndpointConstraint, - cardinality: EdgeCardinality, -): ValueType => { +export const cardinalityValueType = (constraint: EdgeEndpointConstraint, cardinality: EdgeCardinality): ValueType => { const target = constraintValueType(constraint); switch (cardinality) { case "exactly-one": @@ -405,13 +356,8 @@ export const cardinalityValueType = ( } }; -const edgeProjection = ( - edge: EdgeDefinition, - projectionId: EdgeProjectionId, -) => { - const index = edge.endpoints.findIndex( - (endpoint) => endpoint.projectionId === projectionId, - ); +const edgeProjection = (edge: EdgeDefinition, projectionId: EdgeProjectionId) => { + const index = edge.endpoints.findIndex((endpoint) => endpoint.projectionId === projectionId); if (index !== 0 && index !== 1) { return undefined; } @@ -433,10 +379,7 @@ export const edgePrimitiveSignature = ( return undefined; } const targetType = constraintValueType(projection.target.constraint); - const resolvedType = cardinalityValueType( - projection.target.constraint, - projection.endpoint.cardinality, - ); + const resolvedType = cardinalityValueType(projection.target.constraint, projection.endpoint.cardinality); switch (primitive) { case "resolve": return { @@ -480,21 +423,20 @@ const constraintSatisfiesConstraint = ( actual: EdgeEndpointConstraint, required: EdgeEndpointConstraint, conformances: ReadonlyMap, -) => constraintsEqual(actual, required) || ( - actual.kind === "atom" && - required.kind === "interface" && - atomSatisfiesConstraint(actual.atomId, required, conformances) -); +) => + constraintsEqual(actual, required) || + (actual.kind === "atom" && + required.kind === "interface" && + atomSatisfiesConstraint(actual.atomId, required, conformances)); const canAccessAttachment = ( owner: AttachmentOwner, conformance: Pick | undefined, ) => - owner.kind === "workspace" || ( - conformance !== undefined && + owner.kind === "workspace" || + (conformance !== undefined && owner.atomId === conformance.atomId && - owner.interfaceRevisionId === conformance.interfaceRevisionId - ); + owner.interfaceRevisionId === conformance.interfaceRevisionId); const collectIdentityIndexes = ( workspace: WorkspaceRevision, @@ -516,12 +458,7 @@ const collectIdentityIndexes = ( for (const [interfaceIndex, revision] of workspace.interfaceImports.entries()) { const path = `interfaceImports[${interfaceIndex}]`; requireText(issues, revision.interfaceId, `${path}.interfaceId`, "Interface ID"); - requireText( - issues, - revision.revisionId, - `${path}.revisionId`, - "Interface revision ID", - ); + requireText(issues, revision.revisionId, `${path}.revisionId`, "Interface revision ID"); requireText(issues, revision.displayName, `${path}.displayName`, "Interface name"); validateSource(issues, revision.source, `${path}.source`); const memberIds = new Set(); @@ -531,31 +468,16 @@ const collectIdentityIndexes = ( requireText(issues, member.id, `${memberPath}.id`, "Member ID"); requireText(issues, member.displayName, `${memberPath}.displayName`, "Member name"); if (memberIds.has(member.id)) { - issue( - issues, - "duplicate-interface-member", - `${memberPath}.id`, - `Duplicate member ${member.id}`, - ); + issue(issues, "duplicate-interface-member", `${memberPath}.id`, `Duplicate member ${member.id}`); } memberIds.add(member.id); for (const [operationIndex, operation] of member.operations.entries()) { const operationPath = `${memberPath}.operations[${operationIndex}]`; requireText(issues, operation.id, `${operationPath}.id`, "Operation ID"); - requireText( - issues, - operation.displayName, - `${operationPath}.displayName`, - "Operation name", - ); + requireText(issues, operation.displayName, `${operationPath}.displayName`, "Operation name"); validateOperationEventShape(issues, operation, operationPath); if (operations.has(operation.id)) { - issue( - issues, - "duplicate-interface-operation", - `${operationPath}.id`, - `Duplicate operation ${operation.id}`, - ); + issue(issues, "duplicate-interface-operation", `${operationPath}.id`, `Duplicate operation ${operation.id}`); } else { operations.set(operation.id, { operation, member }); } @@ -576,16 +498,19 @@ const collectIdentityIndexes = ( const packages = new Map(); for (const [packageIndex, revision] of workspace.packageImports.entries()) { const path = `packageImports[${packageIndex}]`; - if (revision.semanticMajor !== undefined && (!Number.isSafeInteger(revision.semanticMajor) || revision.semanticMajor < 1)) { - issue(issues, "invalid-semantic-major", `${path}.semanticMajor`, "Semantic major must be a positive safe integer"); + if ( + revision.semanticMajor !== undefined && + (!Number.isSafeInteger(revision.semanticMajor) || revision.semanticMajor < 1) + ) { + issue( + issues, + "invalid-semantic-major", + `${path}.semanticMajor`, + "Semantic major must be a positive safe integer", + ); } requireText(issues, revision.packageId, `${path}.packageId`, "Package ID"); - requireText( - issues, - revision.revisionId, - `${path}.revisionId`, - "Package revision ID", - ); + requireText(issues, revision.revisionId, `${path}.revisionId`, "Package revision ID"); requireText(issues, revision.displayName, `${path}.displayName`, "Package name"); validateSource(issues, revision.source, `${path}.source`); const exports = new Map(); @@ -594,12 +519,7 @@ const collectIdentityIndexes = ( requireText(issues, entry.id, `${exportPath}.id`, "Package export ID"); requireText(issues, entry.displayName, `${exportPath}.displayName`, "Package export name"); if (exports.has(entry.id)) { - issue( - issues, - "duplicate-package-export", - `${exportPath}.id`, - `Duplicate package export ${entry.id}`, - ); + issue(issues, "duplicate-package-export", `${exportPath}.id`, `Duplicate package export ${entry.id}`); } else { exports.set(entry.id, entry); } @@ -609,12 +529,7 @@ const collectIdentityIndexes = ( requireText(issues, port.id, `${portPath}.id`, "Dependency port ID"); requireText(issues, port.displayName, `${portPath}.displayName`, "Dependency port name"); if (portIds.has(port.id)) { - issue( - issues, - "duplicate-dependency-port", - `${portPath}.id`, - `Duplicate dependency port ${port.id}`, - ); + issue(issues, "duplicate-dependency-port", `${portPath}.id`, `Duplicate dependency port ${port.id}`); } portIds.add(port.id); } @@ -636,12 +551,21 @@ const collectIdentityIndexes = ( const conformanceIds = new Set(); for (const [index, conformance] of workspace.conformances.entries()) { const path = `conformances[${index}]`; - if (conformance.semanticMajor !== undefined && (!Number.isSafeInteger(conformance.semanticMajor) || conformance.semanticMajor < 1)) { - issue(issues, "invalid-semantic-major", `${path}.semanticMajor`, "Semantic major must be a positive safe integer"); + if ( + conformance.semanticMajor !== undefined && + (!Number.isSafeInteger(conformance.semanticMajor) || conformance.semanticMajor < 1) + ) { + issue( + issues, + "invalid-semantic-major", + `${path}.semanticMajor`, + "Semantic major must be a positive safe integer", + ); } if (conformance.id !== undefined) { requireText(issues, conformance.id, `${path}.id`, "Conformance ID"); - if (conformanceIds.has(conformance.id)) issue(issues, "duplicate-conformance-id", `${path}.id`, `Duplicate conformance ID ${conformance.id}`); + if (conformanceIds.has(conformance.id)) + issue(issues, "duplicate-conformance-id", `${path}.id`, `Duplicate conformance ID ${conformance.id}`); conformanceIds.add(conformance.id); } const key = conformanceKey(conformance.atomId, conformance.interfaceRevisionId); @@ -662,35 +586,18 @@ const collectIdentityIndexes = ( for (const [index, constructor] of workspace.constructors.entries()) { const path = `constructors[${index}]`; if (constructors.has(constructor.atomId)) { - issue( - issues, - "duplicate-constructor", - path, - `Atom ${constructor.atomId} has more than one constructor`, - ); + issue(issues, "duplicate-constructor", path, `Atom ${constructor.atomId} has more than one constructor`); } else { constructors.set(constructor.atomId, constructor); } } const attachments = new Map(); - const projections = new Map< - string, - { edge: EdgeDefinition; endpoint: EdgeEndpoint; endpointIndex: 0 | 1 } - >(); - const addAttachment = ( - attachment: PersistentAttachment, - owner: AttachmentOwner, - path: string, - ) => { + const projections = new Map(); + const addAttachment = (attachment: PersistentAttachment, owner: AttachmentOwner, path: string) => { const key = attachmentKey(attachment); if (attachments.has(key)) { - issue( - issues, - "duplicate-attachment", - `${path}.id`, - `Duplicate ${attachment.kind} attachment ${attachment.id}`, - ); + issue(issues, "duplicate-attachment", `${path}.id`, `Duplicate ${attachment.kind} attachment ${attachment.id}`); } else { attachments.set(key, { attachment, owner, path }); } @@ -772,34 +679,32 @@ const validateInterfaces = ( } }; -const validateAttachments = ( - issues: CapabilityValidationIssue[], - indexes: ValidationIndexes, -) => { +const validateAttachments = (issues: CapabilityValidationIssue[], indexes: ValidationIndexes) => { for (const { attachment, owner, path } of indexes.attachments.values()) { requireText(issues, attachment.id, `${path}.id`, `${attachment.kind} attachment ID`); requireText(issues, attachment.displayName, `${path}.displayName`, `${attachment.kind} name`); if (attachment.kind === "state") { if (!indexes.atoms.has(attachment.attachedTo)) { - issue( - issues, - "unresolved-reference", - `${path}.attachedTo`, - `Unknown atom ${attachment.attachedTo}`, - ); + issue(issues, "unresolved-reference", `${path}.attachedTo`, `Unknown atom ${attachment.attachedTo}`); } validateValueType(issues, attachment.valueType, `${path}.valueType`, indexes); - const containsRpcType = (type: ValueType): boolean => type.kind === "object-ref" || type.kind === "record" || ((type.kind === "optional" || type.kind === "list") && containsRpcType(type.value)); - if (containsRpcType(attachment.valueType) || (attachment.storagePolicy.kind === "crdt-document" && containsRpcType(attachment.storagePolicy.updateType))) { - issue(issues, "invalid-attachment", `${path}.valueType`, "Managed object references belong in graph relationships, not ordinary state; record types are RPC-only"); + const containsRpcType = (type: ValueType): boolean => + type.kind === "object-ref" || + type.kind === "record" || + ((type.kind === "optional" || type.kind === "list") && containsRpcType(type.value)); + if ( + containsRpcType(attachment.valueType) || + (attachment.storagePolicy.kind === "crdt-document" && containsRpcType(attachment.storagePolicy.updateType)) + ) { + issue( + issues, + "invalid-attachment", + `${path}.valueType`, + "Managed object references belong in graph relationships, not ordinary state; record types are RPC-only", + ); } if (attachment.storagePolicy.kind === "crdt-document") { - validateValueType( - issues, - attachment.storagePolicy.updateType, - `${path}.storagePolicy.updateType`, - indexes, - ); + validateValueType(issues, attachment.storagePolicy.updateType, `${path}.storagePolicy.updateType`, indexes); } if (owner.kind === "conformance") { if (attachment.attachedTo !== owner.atomId) { @@ -815,26 +720,42 @@ const validateAttachments = ( } if (attachment.endpoints.length !== 2) { - issue( - issues, - "invalid-attachment", - `${path}.endpoints`, - `Edge ${attachment.id} must have exactly two endpoints`, - ); + issue(issues, "invalid-attachment", `${path}.endpoints`, `Edge ${attachment.id} must have exactly two endpoints`); } for (const [endpointIndex, endpoint] of attachment.endpoints.entries()) { const endpointPath = `${path}.endpoints[${endpointIndex}]`; - if (endpoint.keyType !== undefined && (!["string", "boolean", "int64"].includes(endpoint.keyType) || endpoint.ordered || !["many", "many-unique"].includes(endpoint.cardinality))) { - issue(issues, "invalid-attachment", `${endpointPath}.keyType`, "Keyed projections require string/boolean/int64 keys, many cardinality, and no ordering"); + if ( + endpoint.keyType !== undefined && + (!["string", "boolean", "int64"].includes(endpoint.keyType) || + endpoint.ordered || + !["many", "many-unique"].includes(endpoint.cardinality)) + ) { + issue( + issues, + "invalid-attachment", + `${endpointPath}.keyType`, + "Keyed projections require string/boolean/int64 keys, many cardinality, and no ordering", + ); } requireText(issues, endpoint.projectionId, `${endpointPath}.projectionId`, "Projection ID"); if (endpoint.onDelete !== undefined && !["restrict", "detach", "cascade-other"].includes(endpoint.onDelete)) { - issue(issues, "invalid-attachment", `${endpointPath}.onDelete`, "Deletion policy must be restrict, detach, or cascade-other"); + issue( + issues, + "invalid-attachment", + `${endpointPath}.onDelete`, + "Deletion policy must be restrict, detach, or cascade-other", + ); } requireText(issues, endpoint.displayName, `${endpointPath}.displayName`, "Projection name"); validateConstraint(issues, endpoint.constraint, `${endpointPath}.constraint`, indexes); } - if (attachment.endpoints.every((endpoint) => endpoint.keyType)) issue(issues, "invalid-attachment", `${path}.endpoints`, "A v0 map has one keyed projection and one unkeyed inverse"); + if (attachment.endpoints.every((endpoint) => endpoint.keyType)) + issue( + issues, + "invalid-attachment", + `${path}.endpoints`, + "A v0 map has one keyed projection and one unkeyed inverse", + ); if (owner.kind === "conformance") { if ( !attachment.endpoints.some((endpoint) => @@ -852,23 +773,14 @@ const validateAttachments = ( } }; -const uniquePrimitiveList = ( - issues: CapabilityValidationIssue[], - primitives: readonly string[], - path: string, -) => { +const uniquePrimitiveList = (issues: CapabilityValidationIssue[], primitives: readonly string[], path: string) => { if (primitives.length === 0) { issue(issues, "invalid-dependency-binding", path, "Dependency port requires at least one primitive"); } const seen = new Set(); for (const primitive of primitives) { if (seen.has(primitive)) { - issue( - issues, - "invalid-dependency-binding", - path, - `Dependency primitive ${primitive} is listed more than once`, - ); + issue(issues, "invalid-dependency-binding", path, `Dependency primitive ${primitive} is listed more than once`); } seen.add(primitive); } @@ -901,8 +813,10 @@ const validatePackages = ( } } else if (entry.receiverRequirement.kind === "all-interfaces") { const seen = new Set(); - for (const [requirementIndex, interfaceRevisionId] of - entry.receiverRequirement.interfaceRevisionIds.entries()) { + for (const [ + requirementIndex, + interfaceRevisionId, + ] of entry.receiverRequirement.interfaceRevisionIds.entries()) { const requirementPath = `${exportPath}.receiverRequirement.interfaceRevisionIds[${requirementIndex}]`; if (!indexes.interfaces.has(interfaceRevisionId)) { issue( @@ -925,12 +839,7 @@ const validatePackages = ( } } else if (entry.kind === "constructor") { if (!indexes.atoms.has(entry.constructsAtom)) { - issue( - issues, - "unresolved-reference", - `${exportPath}.constructsAtom`, - `Unknown atom ${entry.constructsAtom}`, - ); + issue(issues, "unresolved-reference", `${exportPath}.constructsAtom`, `Unknown atom ${entry.constructsAtom}`); } const expected = valueType.atomRef(entry.constructsAtom); if (!valueTypesEqual(entry.outputType, expected)) { @@ -965,7 +874,8 @@ const validatePackages = ( } break; case "constructor": - if (port.requirement.inputType) validateValueType(issues, port.requirement.inputType, `${portPath}.requirement.inputType`, indexes); + if (port.requirement.inputType) + validateValueType(issues, port.requirement.inputType, `${portPath}.requirement.inputType`, indexes); if (!indexes.atoms.has(port.requirement.atomId)) { issue( issues, @@ -998,9 +908,7 @@ const validateAttachmentAccess = ( "private-attachment-access", path, `Attachment ${entry.attachment.id} is private to conformance ${ - entry.owner.kind === "conformance" - ? `${entry.owner.atomId} as ${entry.owner.interfaceRevisionId}` - : "" + entry.owner.kind === "conformance" ? `${entry.owner.atomId} as ${entry.owner.interfaceRevisionId}` : "" }`, ); return false; @@ -1018,12 +926,7 @@ const validateTraversal = ( ) => { const attachment = findAttachment(indexes, "edge", traversal.edgeTypeId); if (!attachment || attachment.attachment.kind !== "edge") { - issue( - issues, - "invalid-dependency-binding", - `${path}.edgeTypeId`, - `Unknown traversal edge ${traversal.edgeTypeId}`, - ); + issue(issues, "invalid-dependency-binding", `${path}.edgeTypeId`, `Unknown traversal edge ${traversal.edgeTypeId}`); return undefined; } const projection = edgeProjection(attachment.attachment, traversal.projectionId); @@ -1036,7 +939,8 @@ const validateTraversal = ( ); return undefined; } - if (!projection.endpoint.publicTraversal) validateAttachmentAccess(issues, attachment, conformance, `${path}.edgeTypeId`); + if (!projection.endpoint.publicTraversal) + validateAttachmentAccess(issues, attachment, conformance, `${path}.edgeTypeId`); if (!atomSatisfiesConstraint(atomId, projection.endpoint.constraint, indexes.conformances)) { issue( issues, @@ -1085,12 +989,7 @@ const validateBoundDependencies = ( bindings.set(dependency.portId, dependency); const port = ports.get(dependency.portId); if (!port) { - issue( - issues, - "invalid-dependency-binding", - `${path}.portId`, - `Unknown dependency port ${dependency.portId}`, - ); + issue(issues, "invalid-dependency-binding", `${path}.portId`, `Unknown dependency port ${dependency.portId}`); continue; } const requirement = port.requirement; @@ -1130,7 +1029,8 @@ const validateBoundDependencies = ( : undefined; if ( stateBinding.via - ? traversal && !atomSatisfiesConstraint( + ? traversal && + !atomSatisfiesConstraint( attachment.attachment.attachedTo, traversal.target.constraint, params.indexes.conformances, @@ -1190,20 +1090,15 @@ const validateBoundDependencies = ( ) : undefined; const originMatches = edgeBinding.via - ? traversal && ( - projection.endpoint.constraint.kind === "atom" - ? atomSatisfiesConstraint( - projection.endpoint.constraint.atomId, - traversal.target.constraint, - params.indexes.conformances, - ) - : constraintsEqual(projection.endpoint.constraint, traversal.target.constraint) - ) - : atomSatisfiesConstraint( - params.atomId, - projection.endpoint.constraint, - params.indexes.conformances, - ); + ? traversal && + (projection.endpoint.constraint.kind === "atom" + ? atomSatisfiesConstraint( + projection.endpoint.constraint.atomId, + traversal.target.constraint, + params.indexes.conformances, + ) + : constraintsEqual(projection.endpoint.constraint, traversal.target.constraint)) + : atomSatisfiesConstraint(params.atomId, projection.endpoint.constraint, params.indexes.conformances); if (!originMatches) { issue( issues, @@ -1232,10 +1127,7 @@ const validateBoundDependencies = ( break; } case "interface": { - const interfaceBinding = binding as Extract< - DependencyBinding, - { kind: "interface" } - >; + const interfaceBinding = binding as Extract; if (interfaceBinding.interfaceRevisionId !== requirement.interfaceRevisionId) { issue( issues, @@ -1257,12 +1149,9 @@ const validateBoundDependencies = ( : undefined; const candidateAtoms = interfaceBinding.via ? traversal - ? [...params.indexes.atoms.keys()].filter((atomId) => - atomSatisfiesConstraint( - atomId as AtomId, - traversal.target.constraint, - params.indexes.conformances, - )) as AtomId[] + ? ([...params.indexes.atoms.keys()].filter((atomId) => + atomSatisfiesConstraint(atomId as AtomId, traversal.target.constraint, params.indexes.conformances), + ) as AtomId[]) : [] : [params.atomId]; for (const atomId of candidateAtoms) { @@ -1300,10 +1189,16 @@ const validateBoundDependencies = ( } if (requirement.inputType) { const selected = params.indexes.constructors.get(requirement.atomId); - const implementation = selected ? params.indexes.packages.get(selected.packageRevisionId)?.exports.get(selected.exportId) : undefined; + const implementation = selected + ? params.indexes.packages.get(selected.packageRevisionId)?.exports.get(selected.exportId) + : undefined; if (implementation && !valueTypesEqual(requirement.inputType, implementation.inputType)) { - issue(issues, "invalid-dependency-binding", `${path}.binding.atomId`, - `Constructor port requires input ${typeLabel(requirement.inputType)}, but selected constructor accepts ${typeLabel(implementation.inputType)}`); + issue( + issues, + "invalid-dependency-binding", + `${path}.binding.atomId`, + `Constructor port requires input ${typeLabel(requirement.inputType)}, but selected constructor accepts ${typeLabel(implementation.inputType)}`, + ); } } break; @@ -1373,12 +1268,7 @@ const validateConformances = ( const path = `conformances[${index}]`; const key = conformanceKey(conformance.atomId, conformance.interfaceRevisionId); if (!indexes.atoms.has(conformance.atomId)) { - issue( - issues, - "unresolved-reference", - `${path}.atomId`, - `Unknown atom ${conformance.atomId}`, - ); + issue(issues, "unresolved-reference", `${path}.atomId`, `Unknown atom ${conformance.atomId}`); } const interfaceEntry = indexes.interfaces.get(conformance.interfaceRevisionId); if (!interfaceEntry) { @@ -1473,9 +1363,7 @@ const validateConformances = ( if (!(binding.primitive === "resolve" && projection?.endpoint.publicTraversal)) { validateAttachmentAccess(issues, attachment, conformance, `${bindingPath}.binding.edgeTypeId`); } - const relationshipMember = operationEntry.member.kind === "relationship" - ? operationEntry.member - : undefined; + const relationshipMember = operationEntry.member.kind === "relationship" ? operationEntry.member : undefined; const operationEdge = relationshipMember ? { ...attachment.attachment, @@ -1486,11 +1374,7 @@ const validateConformances = ( ) as [EdgeEndpoint, EdgeEndpoint], } : attachment.attachment; - const expected = edgePrimitiveSignature( - operationEdge, - binding.projectionId, - binding.primitive, - ); + const expected = edgePrimitiveSignature(operationEdge, binding.projectionId, binding.primitive); if (!projection || !expected) { issue( issues, @@ -1599,10 +1483,8 @@ const validateConformances = ( } const materializedMembers = new Set(); - for (const [materializationIndex, materialization] of - (conformance.relationshipMaterializations ?? []).entries()) { - const materializationPath = - `${path}.relationshipMaterializations[${materializationIndex}]`; + for (const [materializationIndex, materialization] of (conformance.relationshipMaterializations ?? []).entries()) { + const materializationPath = `${path}.relationshipMaterializations[${materializationIndex}]`; if (materializedMembers.has(materialization.memberId)) { issue( issues, @@ -1613,8 +1495,7 @@ const validateConformances = ( continue; } materializedMembers.add(materialization.memberId); - const member = interfaceEntry.revision.members.find((entry) => - entry.id === materialization.memberId); + const member = interfaceEntry.revision.members.find((entry) => entry.id === materialization.memberId); if (!member || member.kind !== "relationship") { issue( issues, @@ -1632,11 +1513,8 @@ const validateConformances = ( "A lazily constructed relationship must be optional-one before construction", ); } - const resolveOperation = member.operations.find((entry) => - entry.displayName === "resolve"); - const resolveBinding = resolveOperation - ? bindings.get(resolveOperation.id) - : undefined; + const resolveOperation = member.operations.find((entry) => entry.displayName === "resolve"); + const resolveBinding = resolveOperation ? bindings.get(resolveOperation.id) : undefined; if (!resolveBinding || resolveBinding.kind !== "edge") { issue( issues, @@ -1664,22 +1542,14 @@ const validateConformances = ( ); continue; } - validateAttachmentAccess( - issues, - attachment, - conformance, - `${materializationPath}.edgeTypeId`, - ); - const hostProjection = edgeProjection( - attachment.attachment, - resolveBinding.projectionId, - ); - const constructedProjection = edgeProjection( - attachment.attachment, - materialization.constructedProjectionId, - ); - if (!hostProjection || !constructedProjection || - hostProjection.endpointIndex === constructedProjection.endpointIndex) { + validateAttachmentAccess(issues, attachment, conformance, `${materializationPath}.edgeTypeId`); + const hostProjection = edgeProjection(attachment.attachment, resolveBinding.projectionId); + const constructedProjection = edgeProjection(attachment.attachment, materialization.constructedProjectionId); + if ( + !hostProjection || + !constructedProjection || + hostProjection.endpointIndex === constructedProjection.endpointIndex + ) { issue( issues, "invalid-relationship-materialization", @@ -1688,11 +1558,7 @@ const validateConformances = ( ); continue; } - if (!atomSatisfiesConstraint( - materialization.constructorAtomId, - member.target, - indexes.conformances, - )) { + if (!atomSatisfiesConstraint(materialization.constructorAtomId, member.target, indexes.conformances)) { issue( issues, "invalid-relationship-materialization", @@ -1700,15 +1566,14 @@ const validateConformances = ( `Constructed atom ${materialization.constructorAtomId} does not satisfy the relationship target`, ); } - if (!atomSatisfiesConstraint( - materialization.constructorAtomId, - constructedProjection.endpoint.constraint, - indexes.conformances, - ) || !atomSatisfiesConstraint( - conformance.atomId, - constructedProjection.target.constraint, - indexes.conformances, - )) { + if ( + !atomSatisfiesConstraint( + materialization.constructorAtomId, + constructedProjection.endpoint.constraint, + indexes.conformances, + ) || + !atomSatisfiesConstraint(conformance.atomId, constructedProjection.target.constraint, indexes.conformances) + ) { issue( issues, "invalid-relationship-materialization", @@ -1727,10 +1592,7 @@ const validateConformances = ( `${materializationPath}.constructorAtomId`, `Constructed atom ${materialization.constructorAtomId} has no valid constructor`, ); - } else if (!valueTypesEqual( - constructorExport.inputType, - valueType.atomRef(conformance.atomId), - )) { + } else if (!valueTypesEqual(constructorExport.inputType, valueType.atomRef(conformance.atomId))) { issue( issues, "invalid-relationship-materialization", @@ -1803,12 +1665,7 @@ const validateConstructors = ( continue; } if (!packageExport) { - issue( - issues, - "invalid-constructor", - `${path}.exportId`, - `Unknown export ${constructor.exportId}`, - ); + issue(issues, "invalid-constructor", `${path}.exportId`, `Unknown export ${constructor.exportId}`); continue; } if (packageExport.kind !== "constructor") { @@ -1839,9 +1696,7 @@ const validateConstructors = ( } }; -export const validateWorkspaceRevision = ( - workspace: WorkspaceRevision, -): CapabilityValidationIssue[] => { +export const validateWorkspaceRevision = (workspace: WorkspaceRevision): CapabilityValidationIssue[] => { const issues: CapabilityValidationIssue[] = []; requireText(issues, workspace.id, "id", "Workspace revision ID"); requireText(issues, workspace.workspaceId, "workspaceId", "Workspace ID"); @@ -1874,9 +1729,7 @@ export type CompileWorkspaceRevisionResult = | { ok: true; plan: CompiledWorkspaceRevision } | { ok: false; issues: CapabilityValidationIssue[] }; -export const compileWorkspaceRevision = ( - workspace: WorkspaceRevision, -): CompileWorkspaceRevisionResult => { +export const compileWorkspaceRevision = (workspace: WorkspaceRevision): CompileWorkspaceRevisionResult => { const snapshot = structuredClone(workspace) as WorkspaceRevision; const issues = validateWorkspaceRevision(snapshot); if (issues.length > 0) { @@ -1902,27 +1755,18 @@ export const compileWorkspaceRevision = ( }, }); } - conformances.set( - conformanceKey(conformance.atomId, conformance.interfaceRevisionId), - { - source: conformance, - operationBindings: new Map( - conformance.operationBindings.map((entry) => [entry.operationId, entry.binding]), - ), - }, - ); + conformances.set(conformanceKey(conformance.atomId, conformance.interfaceRevisionId), { + source: conformance, + operationBindings: new Map(conformance.operationBindings.map((entry) => [entry.operationId, entry.binding])), + }); } return { ok: true, plan: { source: snapshot, atoms: new Map(snapshot.atoms.map((atom) => [atom.id, atom])), - interfaces: new Map( - snapshot.interfaceImports.map((revision) => [revision.revisionId, revision]), - ), - packages: new Map( - snapshot.packageImports.map((revision) => [revision.revisionId, revision]), - ), + interfaces: new Map(snapshot.interfaceImports.map((revision) => [revision.revisionId, revision])), + packages: new Map(snapshot.packageImports.map((revision) => [revision.revisionId, revision])), attachments, conformances, constructors: new Map(snapshot.constructors.map((entry) => [entry.atomId, entry])), @@ -1934,18 +1778,14 @@ export const resolveConformance = ( plan: CompiledWorkspaceRevision, atomId: AtomId, interfaceRevisionId: InterfaceRevisionId, -): CompiledConformance | undefined => - plan.conformances.get(conformanceKey(atomId, interfaceRevisionId)); +): CompiledConformance | undefined => plan.conformances.get(conformanceKey(atomId, interfaceRevisionId)); export const resolveOperationBinding = ( plan: CompiledWorkspaceRevision, atomId: AtomId, interfaceRevisionId: InterfaceRevisionId, operationId: OperationId, -): Binding | undefined => - resolveConformance(plan, atomId, interfaceRevisionId)?.operationBindings.get( - operationId, - ); +): Binding | undefined => resolveConformance(plan, atomId, interfaceRevisionId)?.operationBindings.get(operationId); export type ResolvedOperationPlan = | { @@ -2005,8 +1845,7 @@ export const resolveOperationPlan = ( } const packageRevision = plan.packages.get(binding.packageRevisionId); const packageExport = packageRevision?.exports.find( - (entry): entry is PackageOperationExport => - entry.id === binding.exportId && entry.kind === "operation", + (entry): entry is PackageOperationExport => entry.id === binding.exportId && entry.kind === "operation", ); if (!packageRevision || !packageExport) { return undefined; @@ -2038,23 +1877,21 @@ export const computeCapabilityClosure = ( plan: CompiledWorkspaceRevision, roots: Array<{ atomId: AtomId; interfaceRevisionId: InterfaceRevisionId }>, ): CapabilityClosure => { - const conformances = new Map(); + const conformances = new Map< + string, + { + atomId: AtomId; + interfaceRevisionId: InterfaceRevisionId; + } + >(); const packages = new Set(); const attachments = new Set(); const constructors = new Set(); const queued = [...roots]; const visited = new Set(); - const sourceConformances = new Map( - [...plan.conformances].map(([key, value]) => [key, value.source]), - ); + const sourceConformances = new Map([...plan.conformances].map(([key, value]) => [key, value.source])); - const includeDependencies = ( - atomId: AtomId, - dependencies: readonly BoundDependency[], - ) => { + const includeDependencies = (atomId: AtomId, dependencies: readonly BoundDependency[]) => { for (const dependency of dependencies) { switch (dependency.binding.kind) { case "state": @@ -2075,21 +1912,13 @@ export const computeCapabilityClosure = ( } const targetAtoms = dependency.binding.via ? (() => { - const attachment = plan.attachments.get( - `edge\u0000${dependency.binding.via!.edgeTypeId}`, - )?.attachment; + const attachment = plan.attachments.get(`edge\u0000${dependency.binding.via!.edgeTypeId}`)?.attachment; if (!attachment || attachment.kind !== "edge") return []; - const projection = edgeProjection( - attachment, - dependency.binding.via!.projectionId, - ); + const projection = edgeProjection(attachment, dependency.binding.via!.projectionId); return projection ? [...plan.atoms.keys()].filter((candidate) => - atomSatisfiesConstraint( - candidate, - projection.target.constraint, - sourceConformances, - )) + atomSatisfiesConstraint(candidate, projection.target.constraint, sourceConformances), + ) : []; })() : [atomId]; @@ -2138,19 +1967,16 @@ export const computeCapabilityClosure = ( includeDependencies(root.atomId, binding.dependencies); const packageRevision = plan.packages.get(binding.packageRevisionId); const operation = packageRevision?.exports.find( - (entry): entry is PackageOperationExport => - entry.id === binding.exportId && entry.kind === "operation", + (entry): entry is PackageOperationExport => entry.id === binding.exportId && entry.kind === "operation", ); if (operation?.receiverRequirement.kind === "all-interfaces") { - for (const interfaceRevisionId of operation.receiverRequirement - .interfaceRevisionIds) { + for (const interfaceRevisionId of operation.receiverRequirement.interfaceRevisionIds) { queued.push({ atomId: root.atomId, interfaceRevisionId }); } } } } - for (const materialization of - conformance.source.relationshipMaterializations ?? []) { + for (const materialization of conformance.source.relationshipMaterializations ?? []) { attachments.add(materialization.edgeTypeId); constructors.add(materialization.constructorAtomId); const constructor = plan.constructors.get(materialization.constructorAtomId); diff --git a/src/descriptor-check.ts b/src/descriptor-check.ts index 031038a..433d87b 100644 --- a/src/descriptor-check.ts +++ b/src/descriptor-check.ts @@ -1,10 +1,6 @@ #!/usr/bin/env node import fs from "node:fs"; -import { - packageDescriptorToJson, - parsePackageDescriptorTextproto, - validatePackageDescriptor, -} from "./descriptor.js"; +import { packageDescriptorToJson, parsePackageDescriptorTextproto, validatePackageDescriptor } from "./descriptor.js"; const usage = (): never => { console.error("Usage: quixos-descriptor-check "); @@ -14,9 +10,7 @@ const usage = (): never => { const path = process.argv[2] ?? usage(); try { - const descriptor = parsePackageDescriptorTextproto( - fs.readFileSync(path, "utf8"), - ); + const descriptor = parsePackageDescriptorTextproto(fs.readFileSync(path, "utf8")); const errors = validatePackageDescriptor(descriptor); if (errors.length > 0) { for (const error of errors) { diff --git a/src/descriptor.ts b/src/descriptor.ts index f4709fd..6477228 100644 --- a/src/descriptor.ts +++ b/src/descriptor.ts @@ -14,19 +14,13 @@ const protoPaths = () => { path.resolve(process.cwd(), "proto"), path.resolve(process.cwd(), "quixos-protocol/proto"), ]; - return [...new Set(candidates)].filter((candidate) => - fs.existsSync(path.join(candidate, "quixos/package.proto")), - ); + return [...new Set(candidates)].filter((candidate) => fs.existsSync(path.join(candidate, "quixos/package.proto"))); }; -export const parsePackageDescriptorTextproto = ( - text: string, -): PackageDescriptor => { +export const parsePackageDescriptorTextproto = (text: string): PackageDescriptor => { const paths = protoPaths(); if (paths.length === 0) { - throw new Error( - "QUIXOS_PROTO_PATH must include quixos-protocol/proto to parse package descriptors", - ); + throw new Error("QUIXOS_PROTO_PATH must include quixos-protocol/proto to parse package descriptors"); } const result = childProcess.spawnSync( "protoc", @@ -44,9 +38,7 @@ export const parsePackageDescriptorTextproto = ( throw result.error; } if (result.status !== 0) { - throw new Error( - `Failed to parse package descriptor textproto:\n${result.stderr.toString().trim()}`, - ); + throw new Error(`Failed to parse package descriptor textproto:\n${result.stderr.toString().trim()}`); } return fromBinary(PackageDescriptorSchema, result.stdout); }; @@ -56,9 +48,7 @@ export const packageDescriptorToJson = (descriptor: PackageDescriptor) => prettySpaces: 2, }); -export const validatePackageDescriptor = ( - descriptor: PackageDescriptor, -): string[] => { +export const validatePackageDescriptor = (descriptor: PackageDescriptor): string[] => { const errors: string[] = []; if (!descriptor.packageId) { errors.push("packageId is required"); diff --git a/src/resource-lock/loader.ts b/src/resource-lock/loader.ts index fed7c9f..7f134e9 100644 --- a/src/resource-lock/loader.ts +++ b/src/resource-lock/loader.ts @@ -1,24 +1,11 @@ import { lstat, readFile } from "node:fs/promises"; import path from "node:path"; -import { - parseQuixosLockDocument, - type QuixosLockDiagnostic, - type QuixosLockParseResult, -} from "./parser.js"; -import type { - LockedResource, - QuixosLockDocument, - QuixosRepositoryLock, -} from "./types.js"; +import { parseQuixosLockDocument, type QuixosLockDiagnostic, type QuixosLockParseResult } from "./parser.js"; +import type { LockedResource, QuixosLockDocument, QuixosRepositoryLock } from "./types.js"; export type QuixosLockSourceReader = (relativePath: string) => Promise; -const diagnostic = ( - code: string, - message: string, - fileName: string, - path?: string, -): QuixosLockDiagnostic => ({ +const diagnostic = (code: string, message: string, fileName: string, path?: string): QuixosLockDiagnostic => ({ phase: "resolution", code, message, @@ -38,11 +25,9 @@ export const resolveQuixosLock = async ( if (root.document.kind !== "root") { return { ok: false, - diagnostics: [diagnostic( - "expected-root-lock", - "The entrypoint must be a root Quixos lock, not a fragment", - rootFileName, - )], + diagnostics: [ + diagnostic("expected-root-lock", "The entrypoint must be a root Quixos lock, not a fragment", rootFileName), + ], }; } @@ -54,15 +39,16 @@ export const resolveQuixosLock = async ( const addResources = (document: QuixosLockDocument, fileName: string) => { for (const resource of document.resources) { - const duplicate = resources.find((entry) => - entry.kind === resource.kind && entry.binding === resource.binding); + const duplicate = resources.find((entry) => entry.kind === resource.kind && entry.binding === resource.binding); if (duplicate) { - diagnostics.push(diagnostic( - "duplicate-resource-binding", - `Duplicate ${resource.kind} binding ${resource.binding} across imported lock files`, - fileName, - `${resource.kind}.${resource.binding}`, - )); + diagnostics.push( + diagnostic( + "duplicate-resource-binding", + `Duplicate ${resource.kind} binding ${resource.binding} across imported lock files`, + fileName, + `${resource.kind}.${resource.binding}`, + ), + ); } else { resources.push(resource); } @@ -71,11 +57,9 @@ export const resolveQuixosLock = async ( const visit = async (importPath: string) => { if (active.includes(importPath)) { - diagnostics.push(diagnostic( - "import-cycle", - `Lock import cycle: ${[...active, importPath].join(" -> ")}`, - importPath, - )); + diagnostics.push( + diagnostic("import-cycle", `Lock import cycle: ${[...active, importPath].join(" -> ")}`, importPath), + ); return; } if (visited.has(importPath)) return; @@ -84,11 +68,13 @@ export const resolveQuixosLock = async ( try { source = await readSource(importPath); } catch (cause) { - diagnostics.push(diagnostic( - "import-read-failed", - `Could not read lock import ${importPath}: ${cause instanceof Error ? cause.message : String(cause)}`, - importPath, - )); + diagnostics.push( + diagnostic( + "import-read-failed", + `Could not read lock import ${importPath}: ${cause instanceof Error ? cause.message : String(cause)}`, + importPath, + ), + ); active.pop(); return; } @@ -99,11 +85,13 @@ export const resolveQuixosLock = async ( return; } if (parsed.document.kind !== "fragment") { - diagnostics.push(diagnostic( - "imported-root-lock", - `Imported file ${importPath} must begin with "quixos-lock fragment"`, - importPath, - )); + diagnostics.push( + diagnostic( + "imported-root-lock", + `Imported file ${importPath} must begin with "quixos-lock fragment"`, + importPath, + ), + ); active.pop(); return; } @@ -131,20 +119,24 @@ export const loadQuixosLock = async (fileName: string): Promise { - let absoluteImport = repositoryRoot; - const segments = relativePath.split("/"); - for (const [index, segment] of segments.entries()) { - absoluteImport = path.join(absoluteImport, segment); - const metadata = await lstat(absoluteImport); - if (metadata.isSymbolicLink()) { - throw new Error("imports must not traverse symbolic links"); + return await resolveQuixosLock( + rootSource, + async (relativePath) => { + let absoluteImport = repositoryRoot; + const segments = relativePath.split("/"); + for (const [index, segment] of segments.entries()) { + absoluteImport = path.join(absoluteImport, segment); + const metadata = await lstat(absoluteImport); + if (metadata.isSymbolicLink()) { + throw new Error("imports must not traverse symbolic links"); + } + const final = index === segments.length - 1; + if ((!final && !metadata.isDirectory()) || (final && !metadata.isFile())) { + throw new Error("imports must be ordinary files beneath ordinary directories"); + } } - const final = index === segments.length - 1; - if ((!final && !metadata.isDirectory()) || (final && !metadata.isFile())) { - throw new Error("imports must be ordinary files beneath ordinary directories"); - } - } - return await readFile(absoluteImport, "utf8"); - }, rootName); + return await readFile(absoluteImport, "utf8"); + }, + rootName, + ); }; diff --git a/src/resource-lock/parser.ts b/src/resource-lock/parser.ts index 9d2388a..4e633af 100644 --- a/src/resource-lock/parser.ts +++ b/src/resource-lock/parser.ts @@ -13,13 +13,7 @@ import { type QuixosSourceBlockContext, type SourceBlockContext, } from "./generated/QuixosLockParser.js"; -import type { - GitSource, - LockedResource, - QuixosLockDocument, - QuixosRepositoryLock, - QuixosSource, -} from "./types.js"; +import type { GitSource, LockedResource, QuixosLockDocument, QuixosRepositoryLock, QuixosSource } from "./types.js"; export type QuixosLockDiagnostic = { phase: "syntax" | "validation" | "resolution"; @@ -66,8 +60,7 @@ class SyntaxErrorListener extends BaseErrorListener { } } -const stringValue = (context: { getText(): string }): string => - JSON.parse(context.getText()) as string; +const stringValue = (context: { getText(): string }): string => JSON.parse(context.getText()) as string; const lowerSource = (context: SourceBlockContext): GitSource => ({ resolver: "git", @@ -103,15 +96,16 @@ const issue = ( path?: string, line = 1, column = 0, -) => diagnostics.push({ - phase: "validation", - code, - message, - fileName, - line, - column, - path, -}); +) => + diagnostics.push({ + phase: "validation", + code, + message, + fileName, + line, + column, + path, + }); const validateImport = ( importPath: string, @@ -123,11 +117,11 @@ const validateImport = ( ) => { const path = `imports[${index}]`; if ( - !importPath - || importPath.startsWith("/") - || importPath.includes("\\") - || importPath.split("/").some((segment) => !segment || segment === "." || segment === "..") - || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(importPath) + !importPath || + importPath.startsWith("/") || + importPath.includes("\\") || + importPath.split("/").some((segment) => !segment || segment === "." || segment === "..") || + /^[A-Za-z][A-Za-z0-9+.-]*:/.test(importPath) ) { issue( diagnostics, @@ -141,12 +135,7 @@ const validateImport = ( } }; -const validateSource = ( - source: GitSource, - path: string, - fileName: string, - diagnostics: QuixosLockDiagnostic[], -) => { +const validateSource = (source: GitSource, path: string, fileName: string, diagnostics: QuixosLockDiagnostic[]) => { if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(source.commit)) { issue( diagnostics, @@ -198,25 +187,22 @@ const validateSource = ( } }; -const validateQuixosSource = ( - source: QuixosSource, - fileName: string, - diagnostics: QuixosLockDiagnostic[], -) => { +const validateQuixosSource = (source: QuixosSource, fileName: string, diagnostics: QuixosLockDiagnostic[]) => { validateSource(source, "quixos", fileName, diagnostics); if (!source.policy) return; const forbiddenRefCharacters = new Set("~^:?*[\\"); - const invalidRef = !source.ref - || [...source.ref].some((character) => { + const invalidRef = + !source.ref || + [...source.ref].some((character) => { const code = character.charCodeAt(0); return code <= 0x20 || code === 0x7f || forbiddenRefCharacters.has(character); - }) - || source.ref.startsWith("/") - || source.ref.endsWith("/") - || source.ref.endsWith(".") - || source.ref.includes("..") - || source.ref.includes("@{") - || source.ref.includes("//"); + }) || + source.ref.startsWith("/") || + source.ref.endsWith("/") || + source.ref.endsWith(".") || + source.ref.includes("..") || + source.ref.includes("@{") || + source.ref.includes("//"); if (invalidRef) { issue( diagnostics, @@ -247,10 +233,7 @@ const validateQuixosSource = ( } }; -export const parseQuixosLockDocument = ( - source: string, - fileName = "", -): QuixosLockDocumentParseResult => { +export const parseQuixosLockDocument = (source: string, fileName = ""): QuixosLockDocumentParseResult => { const diagnostics: QuixosLockDiagnostic[] = []; const listener = new SyntaxErrorListener(fileName, diagnostics); const lexer = new QuixosLockLexer(CharStream.fromString(source)); @@ -299,26 +282,13 @@ export const parseQuixosLockDocument = ( const imports = tree.importEntry().map((context, index) => { const importPath = stringValue(context.stringLiteral()); - validateImport( - importPath, - index, - fileName, - diagnostics, - context.start?.line ?? 1, - context.start?.column ?? 0, - ); + validateImport(importPath, index, fileName, diagnostics, context.start?.line ?? 1, context.start?.column ?? 0); return importPath; }); const repeatedImports = new Set(); imports.forEach((importPath, index) => { if (repeatedImports.has(importPath)) { - issue( - diagnostics, - fileName, - "duplicate-import", - `Duplicate lock import ${importPath}`, - `imports[${index}]`, - ); + issue(diagnostics, fileName, "duplicate-import", `Duplicate lock import ${importPath}`, `imports[${index}]`); } repeatedImports.add(importPath); }); @@ -355,36 +325,38 @@ export const parseQuixosLockDocument = ( }; }; -export const parseQuixosLock = ( - source: string, - fileName = "", -): QuixosLockParseResult => { +export const parseQuixosLock = (source: string, fileName = ""): QuixosLockParseResult => { const parsed = parseQuixosLockDocument(source, fileName); if (!parsed.ok) return parsed; if (parsed.document.kind === "fragment") { return { ok: false, - diagnostics: [{ - phase: "validation", - code: "expected-root-lock", - message: "Expected a root Quixos lock, found a lock fragment", - fileName, - line: 1, - column: 0, - }], + diagnostics: [ + { + phase: "validation", + code: "expected-root-lock", + message: "Expected a root Quixos lock, found a lock fragment", + fileName, + line: 1, + column: 0, + }, + ], }; } if (parsed.document.imports.length) { return { ok: false, - diagnostics: [{ - phase: "resolution", - code: "imports-require-file-resolution", - message: "This lock has imports and must be loaded from its repository rather than parsed as an isolated string", - fileName, - line: 1, - column: 0, - }], + diagnostics: [ + { + phase: "resolution", + code: "imports-require-file-resolution", + message: + "This lock has imports and must be loaded from its repository rather than parsed as an isolated string", + fileName, + line: 1, + column: 0, + }, + ], }; } return { @@ -407,44 +379,23 @@ const sourceLines = (source: GitSource, indentation: string): string[] => [ const quixosSourceLines = (source: QuixosSource, indentation: string): string[] => [ `${indentation}repository ${quoted(source.repository)};`, - ...(source.policy - ? [ - `${indentation}policy ${source.policy};`, - `${indentation}ref ${quoted(source.ref)};`, - ] - : []), + ...(source.policy ? [`${indentation}policy ${source.policy};`, `${indentation}ref ${quoted(source.ref)};`] : []), `${indentation}commit ${quoted(source.commit.toLowerCase())};`, ]; export const formatQuixosLock = (lock: QuixosRepositoryLock): string => { - const lines = [ - "quixos-lock version 1 {", - " quixos source {", - ...quixosSourceLines(lock.quixos, " "), - " }", - ]; + const lines = ["quixos-lock version 1 {", " quixos source {", ...quixosSourceLines(lock.quixos, " "), " }"]; for (const resource of lock.resources) { - lines.push( - "", - ` ${resource.kind} ${resource.binding} source {`, - ...sourceLines(resource.source, " "), - " }", - ); + lines.push("", ` ${resource.kind} ${resource.binding} source {`, ...sourceLines(resource.source, " "), " }"); } lines.push("}", ""); return lines.join("\n"); }; export const formatQuixosLockDocument = (document: QuixosLockDocument): string => { - const lines = [ - `quixos-lock${document.kind === "fragment" ? " fragment" : ""} version 1 {`, - ]; + const lines = [`quixos-lock${document.kind === "fragment" ? " fragment" : ""} version 1 {`]; if (document.kind === "root") { - lines.push( - " quixos source {", - ...quixosSourceLines(document.quixos, " "), - " }", - ); + lines.push(" quixos source {", ...quixosSourceLines(document.quixos, " "), " }"); } for (const importPath of document.imports) { if (lines.length > 1) lines.push(""); @@ -452,11 +403,7 @@ export const formatQuixosLockDocument = (document: QuixosLockDocument): string = } for (const resource of document.resources) { if (lines.length > 1) lines.push(""); - lines.push( - ` ${resource.kind} ${resource.binding} source {`, - ...sourceLines(resource.source, " "), - " }", - ); + lines.push(` ${resource.kind} ${resource.binding} source {`, ...sourceLines(resource.source, " "), " }"); } lines.push("}", ""); return lines.join("\n"); diff --git a/src/resource-lock/types.ts b/src/resource-lock/types.ts index c073879..a7cf809 100644 --- a/src/resource-lock/types.ts +++ b/src/resource-lock/types.ts @@ -9,13 +9,17 @@ export type QuixosSourcePolicy = "pinned" | "track-release" | "track-development // Resource repositories only need the exact Quixos commit they were authored // against. A workspace root additionally declares how a runtime may advance // that exact baseline. -export type QuixosSource = GitSource & ({ - policy: QuixosSourcePolicy; - ref: string; -} | { - policy?: undefined; - ref?: undefined; -}); +export type QuixosSource = GitSource & + ( + | { + policy: QuixosSourcePolicy; + ref: string; + } + | { + policy?: undefined; + ref?: undefined; + } + ); export type LockedResourceKind = "interface" | "package"; @@ -40,9 +44,7 @@ export type QuixosLockFragmentDocument = { resources: LockedResource[]; }; -export type QuixosLockDocument = - | QuixosLockRootDocument - | QuixosLockFragmentDocument; +export type QuixosLockDocument = QuixosLockRootDocument | QuixosLockFragmentDocument; export type QuixosRepositoryLock = { formatVersion: 1; @@ -60,8 +62,7 @@ export type NixGitInput = { export const RETENTION_TAG_PREFIX = "refs/tags/quixos-reachability/"; -export const retentionTagForCommit = (commit: string): string => - `${RETENTION_TAG_PREFIX}${commit.toLowerCase()}`; +export const retentionTagForCommit = (commit: string): string => `${RETENTION_TAG_PREFIX}${commit.toLowerCase()}`; export const nixGitInput = (source: GitSource): NixGitInput => ({ type: "git", diff --git a/test/authoring-converge.test.ts b/test/authoring-converge.test.ts index 8f0b8fa..511c6c1 100644 --- a/test/authoring-converge.test.ts +++ b/test/authoring-converge.test.ts @@ -9,32 +9,55 @@ import { convergeAuthoring } from "../src/capability-language/authoring-converge import { loadQuixosLock } from "../src/resource-lock/index.js"; const execFile = promisify(callback); -test("source convergence propagates nested edits and unchanged snapshots reach a fixed point", async context => { +test("source convergence propagates nested edits and unchanged snapshots reach a fixed point", async (context) => { const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-converge-test-")); context.after(() => fs.rm(temporary, { recursive: true, force: true })); - const workbench = path.join(temporary, "workbench"), remotes = path.join(temporary, "remotes"); + const workbench = path.join(temporary, "workbench"), + remotes = path.join(temporary, "remotes"); await fs.mkdir(path.join(workbench, ".quixos"), { recursive: true }); await fs.mkdir(remotes); const origin = "https://convergence.example.test/"; - const previous = Object.fromEntries(["GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"].map(key => [key, process.env[key]])); + const previous = Object.fromEntries( + ["GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"].map((key) => [key, process.env[key]]), + ); process.env.GIT_CONFIG_COUNT = "1"; process.env.GIT_CONFIG_KEY_0 = `url.file://${remotes}/.insteadOf`; process.env.GIT_CONFIG_VALUE_0 = origin; - context.after(() => { for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } }); + context.after(() => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); const resources = []; let dependency = ""; - for (const [directory, kind, name] of [["resources/Base", "interface", "Base"], ["resources/Consumer", "package", "Consumer"], ["root", "workspace", "Root"]]) { + for (const [directory, kind, name] of [ + ["resources/Base", "interface", "Base"], + ["resources/Consumer", "package", "Consumer"], + ["root", "workspace", "Root"], + ]) { const root = path.join(workbench, directory); await fs.mkdir(root, { recursive: true }); await execFile("jj", ["git", "init", "--colocate", root]); await execFile("git", ["init", "--bare", path.join(remotes, name)]); const repository = `${origin}${name}`; await execFile("git", ["-C", root, "remote", "add", "origin", repository]); - await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } ${dependency} }`); + await fs.writeFile( + path.join(root, "quixos.lock"), + `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } ${dependency} }`, + ); await fs.writeFile(path.join(root, `${kind}.qx`), "draft"); await execFile("jj", ["status"], { cwd: root }); - const commit = (await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], { cwd: root })).stdout.trim(); - if (kind !== "workspace") resources.push({ directory, kind, resourceId: `${kind}:${name}`, source: { resolver: "git", repository, commit } }); + const commit = ( + await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], { cwd: root }) + ).stdout.trim(); + if (kind !== "workspace") + resources.push({ + directory, + kind, + resourceId: `${kind}:${name}`, + source: { resolver: "git", repository, commit }, + }); dependency = `${kind} ${name} source { repository "${repository}"; commit "${commit}"; }`; } await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({ resources })); @@ -48,7 +71,10 @@ test("source convergence propagates nested edits and unchanged snapshots reach a assert.notEqual(second.candidate?.commit, first.candidate?.commit); const consumer = await loadQuixosLock(path.join(workbench, "resources/Consumer/quixos.lock")); assert.ok(consumer.ok); - assert.equal(consumer.lock.resources[0].source.commit, second.retained.find(entry => entry.directory === "resources/Base")?.source.commit); + assert.equal( + consumer.lock.resources[0].source.commit, + second.retained.find((entry) => entry.directory === "resources/Base")?.source.commit, + ); const third = await convergeAuthoring(workbench); assert.deepEqual(third, second); const base = path.join(workbench, "resources/Base"); @@ -61,24 +87,30 @@ test("source convergence propagates nested edits and unchanged snapshots reach a const partial = await convergeAuthoring(workbench); assert.equal(partial.converged, false); const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8")); - assert.equal(graph.resources[0].source.commit, partial.retained.find(entry => entry.directory === "resources/Base")?.source.commit); + assert.equal( + graph.resources[0].source.commit, + partial.retained.find((entry) => entry.directory === "resources/Base")?.source.commit, + ); await fs.writeFile(path.join(consumerRoot, "quixos.lock"), goodLock); const resumed = await convergeAuthoring(workbench); assert.deepEqual(resumed.worklist, []); assert.deepEqual(await convergeAuthoring(workbench), resumed); await execFile("git", ["-C", base, "remote", "set-url", "origin", origin + "Wrong"]); const mismatch = await convergeAuthoring(workbench); - assert.ok(mismatch.worklist.some(entry => entry.phase === "source" && /Origin differs/.test(entry.message))); + assert.ok(mismatch.worklist.some((entry) => entry.phase === "source" && /Origin differs/.test(entry.message))); await execFile("git", ["-C", base, "remote", "set-url", "origin", origin + "Base"]); await fs.rename(path.join(remotes, "Base"), path.join(remotes, "Base-offline")); const offline = await convergeAuthoring(workbench); assert.equal(offline.candidate, null); - assert.ok(offline.worklist.some(entry => entry.phase === "publication")); + assert.ok(offline.worklist.some((entry) => entry.phase === "publication")); await fs.rename(path.join(remotes, "Base-offline"), path.join(remotes, "Base")); assert.equal((await convergeAuthoring(workbench)).converged, true); - const consumerSource = resumed.retained.find(entry => entry.directory === "resources/Consumer")!.source; - await fs.writeFile(path.join(base, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } package Consumer source { repository "${consumerSource.repository}"; commit "${consumerSource.commit}"; } }`); + const consumerSource = resumed.retained.find((entry) => entry.directory === "resources/Consumer")!.source; + await fs.writeFile( + path.join(base, "quixos.lock"), + `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } package Consumer source { repository "${consumerSource.repository}"; commit "${consumerSource.commit}"; } }`, + ); const cycle = await convergeAuthoring(workbench); assert.equal(cycle.candidate, null); - assert.ok(cycle.worklist.some(entry => /Source dependency cycle/.test(entry.message))); + assert.ok(cycle.worklist.some((entry) => /Source dependency cycle/.test(entry.message))); }); diff --git a/test/authoring-inspect.test.ts b/test/authoring-inspect.test.ts index e9e4418..e4046fc 100644 --- a/test/authoring-inspect.test.ts +++ b/test/authoring-inspect.test.ts @@ -8,12 +8,15 @@ import { promisify } from "node:util"; import { inspectAuthoringRepository } from "../src/capability-language/authoring-inspect.js"; const execFile = promisify(callback); -test("inspection keeps current files and labels historical recovery without granting verification", async context => { +test("inspection keeps current files and labels historical recovery without granting verification", async (context) => { const root = await mkdtemp(path.join(os.tmpdir(), "qx-inspection-test-")); context.after(() => rm(root, { recursive: true, force: true })); const git = (...args: string[]) => execFile("git", ["-C", root, ...args]); await git("init"); - await writeFile(path.join(root, "interface.qx"), 'interface Example id "interface:example" revision "interface:example@1" {}'); + await writeFile( + path.join(root, "interface.qx"), + 'interface Example id "interface:example" revision "interface:example@1" {}', + ); await git("add", "."); await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract"); const commit = (await git("rev-parse", "HEAD")).stdout.trim(); @@ -30,7 +33,7 @@ test("inspection keeps current files and labels historical recovery without gran assert.match(inspected.files[1].declarations[0].source, /New/); assert.equal((await git("rev-parse", "HEAD")).stdout.trim(), commit); await symlink(path.join(root, "interface.qx"), path.join(root, "linked.qx")); - const linked = (await inspectAuthoringRepository(root)).files.find(file => file.file === "linked.qx")!; + const linked = (await inspectAuthoringRepository(root)).files.find((file) => file.file === "linked.qx")!; assert.equal(linked.status, "unavailable"); assert.deepEqual(linked.declarations, []); assert.match(JSON.stringify(linked.currentErrors), /ordinary files/); diff --git a/test/authoring-worklist.test.ts b/test/authoring-worklist.test.ts index 659db7a..4967c9f 100644 --- a/test/authoring-worklist.test.ts +++ b/test/authoring-worklist.test.ts @@ -3,43 +3,81 @@ import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import {execFile as callback} from "node:child_process"; -import {promisify} from "node:util"; -import {authoringWorklist} from "../src/capability-language/authoring-worklist.js"; -import {checkRecordName} from "../src/capability-language/authoring-check.js"; -import {checkerIdentity, snapshotCommit} from "../src/capability-language/checked-build.js"; +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { authoringWorklist } from "../src/capability-language/authoring-worklist.js"; +import { checkRecordName } from "../src/capability-language/authoring-check.js"; +import { checkerIdentity, snapshotCommit } from "../src/capability-language/checked-build.js"; const execFile = promisify(callback); -test("worklist grows and clears from current source, dependency and checker observations", async context => { +test("worklist grows and clears from current source, dependency and checker observations", async (context) => { const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-worklist-test-")); - context.after(() => fs.rm(workbench, {recursive: true, force: true})); - const root = path.join(workbench, "root"), provider = path.join(workbench, "resources/Base"); - await fs.mkdir(root); await fs.mkdir(provider, {recursive: true}); - await fs.mkdir(path.join(workbench, ".quixos/checks"), {recursive: true}); + context.after(() => fs.rm(workbench, { recursive: true, force: true })); + const root = path.join(workbench, "root"), + provider = path.join(workbench, "resources/Base"); + await fs.mkdir(root); + await fs.mkdir(provider, { recursive: true }); + await fs.mkdir(path.join(workbench, ".quixos/checks"), { recursive: true }); const header = `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; }`; for (const directory of [root, provider]) await execFile("jj", ["git", "init", "--colocate", directory]); - await fs.writeFile(path.join(provider, "interface.qx"), 'interface Base id "interface:base" revision "interface:base@1" {}'); + await fs.writeFile( + path.join(provider, "interface.qx"), + 'interface Base id "interface:base" revision "interface:base@1" {}', + ); await fs.writeFile(path.join(provider, "quixos.lock"), `${header} }`); const baseCommit = await snapshotCommit(provider); - await fs.writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"b".repeat(40)}" { import interface Base; }`); - await fs.writeFile(path.join(root, "quixos.lock"), `${header} interface Base source { repository "https://example.test/base"; commit "${baseCommit}"; } }`); + await fs.writeFile( + path.join(root, "workspace.qx"), + `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"b".repeat(40)}" { import interface Base; }`, + ); + await fs.writeFile( + path.join(root, "quixos.lock"), + `${header} interface Base source { repository "https://example.test/base"; commit "${baseCommit}"; } }`, + ); const rootCommit = await snapshotCommit(root); - await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [ - {kind: "interface", directory: provider, source: {resolver: "git", repository: "https://example.test/base", commit: baseCommit}}, - ]})); - assert.equal((await authoringWorklist(workbench)).worklist.filter(entry => entry.phase === "unchecked").length, 2); - const remember = (directory: string, commit: string, checker = checkerIdentity()) => fs.writeFile( - path.join(workbench, ".quixos/checks", checkRecordName(directory)), JSON.stringify({commit, checker, phase: "checked", blockers: []})); - await remember("root", rootCommit); await remember("resources/Base", baseCommit); + await fs.writeFile( + path.join(workbench, ".quixos/resource-graph.json"), + JSON.stringify({ + resources: [ + { + kind: "interface", + directory: provider, + source: { resolver: "git", repository: "https://example.test/base", commit: baseCommit }, + }, + ], + }), + ); + assert.equal((await authoringWorklist(workbench)).worklist.filter((entry) => entry.phase === "unchecked").length, 2); + const remember = (directory: string, commit: string, checker = checkerIdentity()) => + fs.writeFile( + path.join(workbench, ".quixos/checks", checkRecordName(directory)), + JSON.stringify({ commit, checker, phase: "checked", blockers: [] }), + ); + await remember("root", rootCommit); + await remember("resources/Base", baseCommit); assert.deepEqual((await authoringWorklist(workbench)).worklist, []); await fs.writeFile(path.join(provider, "interface.qx"), "interface broken {{{"); const broken = await authoringWorklist(workbench); - assert.ok(broken.worklist.some(entry => entry.directory === "resources/Base" && entry.phase === "syntax" && /historical/.test(entry.message))); - assert.ok(broken.worklist.some(entry => entry.directory === "root" && entry.phase === "dependency")); - await fs.writeFile(path.join(provider, "interface.qx"), 'interface Base id "interface:base" revision "interface:base@1" {}'); + assert.ok( + broken.worklist.some( + (entry) => entry.directory === "resources/Base" && entry.phase === "syntax" && /historical/.test(entry.message), + ), + ); + assert.ok(broken.worklist.some((entry) => entry.directory === "root" && entry.phase === "dependency")); + await fs.writeFile( + path.join(provider, "interface.qx"), + 'interface Base id "interface:base" revision "interface:base@1" {}', + ); assert.deepEqual((await authoringWorklist(workbench)).worklist, []); await remember("resources/Base", baseCommit, "old-checker"); - assert.ok((await authoringWorklist(workbench)).worklist.some(entry => /checker changed/.test(entry.message))); - await fs.writeFile(path.join(workbench, ".quixos/checks", checkRecordName("resources/Base")), JSON.stringify({phase: "publication", blockers: ["source retention unavailable"]})); - assert.ok((await authoringWorklist(workbench)).worklist.some(entry => entry.phase === "publication" && /source retention/.test(entry.message))); + assert.ok((await authoringWorklist(workbench)).worklist.some((entry) => /checker changed/.test(entry.message))); + await fs.writeFile( + path.join(workbench, ".quixos/checks", checkRecordName("resources/Base")), + JSON.stringify({ phase: "publication", blockers: ["source retention unavailable"] }), + ); + assert.ok( + (await authoringWorklist(workbench)).worklist.some( + (entry) => entry.phase === "publication" && /source retention/.test(entry.message), + ), + ); }); diff --git a/test/bindings.test.ts b/test/bindings.test.ts index 60b7339..4dccdaa 100644 --- a/test/bindings.test.ts +++ b/test/bindings.test.ts @@ -4,13 +4,18 @@ import { compileCapabilityResourceSource } from "../src/capability-language/pars import { generateTypeScriptBindings, type BindingSchema } from "../src/bindings/index.js"; const source = { repository: "https://example.test/p.git", commit: "1".repeat(40) }; const schemaFor = (body: string): BindingSchema => { - const compiled = compileCapabilityResourceSource(`external atom Thing id "thing"; package Demo id "demo" revision "demo@1" { ${body} }`, { source }); + const compiled = compileCapabilityResourceSource( + `external atom Thing id "thing"; package Demo id "demo" revision "demo@1" { ${body} }`, + { source }, + ); assert.equal(compiled.ok, true, JSON.stringify(compiled.diagnostics)); if (!compiled.ok || compiled.resource.kind !== "package") throw new Error("expected package"); return { format: "quixos-bindings", version: 1, interfaces: [], packages: [compiled.resource.revision] }; }; test("generator uses exact IDs, restricted ports, nominal references, and lossless scalar types", () => { - const schema = schemaFor('operation run id "run-id" : list -> optional> mode call receiver atom Thing requires { state payload id "payload-id" : bytes [read]; };'); + const schema = schemaFor( + 'operation run id "run-id" : list -> optional> mode call receiver atom Thing requires { state payload id "payload-id" : bytes [read]; };', + ); const generated = generateTypeScriptBindings(schema, "demo@1"); assert.match(generated, /Array/); assert.match(generated, /Promise/); @@ -22,9 +27,18 @@ test("generator uses exact IDs, restricted ports, nominal references, and lossle test("missing external types and constructor signatures fail generation", () => { const schema = schemaFor('function run id "run" : unit -> message "example.Payload";'); assert.throws(() => generateTypeScriptBindings(schema, "demo@1"), /Missing TypeScript message binding/); - assert.match(generateTypeScriptBindings(schema, "demo@1", { messages: { "example.Payload": { module: "./payload.js", export: "payload" } } }), /BindingValue/); - const constructor = schemaFor('function run id "run" : unit -> unit requires { constructor thing id "ctor" : Thing; };'); + assert.match( + generateTypeScriptBindings(schema, "demo@1", { + messages: { "example.Payload": { module: "./payload.js", export: "payload" } }, + }), + /BindingValue/, + ); + const constructor = schemaFor( + 'function run id "run" : unit -> unit requires { constructor thing id "ctor" : Thing; };', + ); assert.throws(() => generateTypeScriptBindings(constructor, "demo@1"), /explicit input contract/); - const typed = schemaFor('function run id "run" : unit -> unit requires { constructor thing id "ctor" : Thing input string; };'); + const typed = schemaFor( + 'function run id "run" : unit -> unit requires { constructor thing id "ctor" : Thing input string; };', + ); assert.match(generateTypeScriptBindings(typed, "demo@1"), /construct.*input: string/); }); diff --git a/test/bundle-policy.test.ts b/test/bundle-policy.test.ts index b328036..306d5aa 100644 --- a/test/bundle-policy.test.ts +++ b/test/bundle-policy.test.ts @@ -1,21 +1,36 @@ import test from "node:test"; import assert from "node:assert/strict"; -import {bundlePolicyErrors} from "../src/bindings/bundle-policy.js"; -import {addImplementation} from "../src/capability-language/implementation-edit.js"; +import { bundlePolicyErrors } from "../src/bindings/bundle-policy.js"; +import { addImplementation } from "../src/capability-language/implementation-edit.js"; test("bundled source policy rejects location and dynamic-loading assumptions, not static assets or runtime I/O", () => { - for (const code of ['new URL("../x", import.meta.url)', '__dirname', '__filename', 'import(name)', 'require(name)', 'eval(code)', 'new Function(code)']) + for (const code of [ + 'new URL("../x", import.meta.url)', + "__dirname", + "__filename", + "import(name)", + "require(name)", + "eval(code)", + "new Function(code)", + ]) assert.ok(bundlePolicyErrors(code, "source.ts").length, code); - assert.deepEqual(bundlePolicyErrors('import source from "./component.js?browser-source"; import fs from "node:fs"; fs.readFile(userSelectedPath);', "source.ts"), []); + assert.deepEqual( + bundlePolicyErrors( + 'import source from "./component.js?browser-source"; import fs from "node:fs"; fs.readFile(userSelectedPath);', + "source.ts", + ), + [], + ); assert.deepEqual(bundlePolicyErrors('// import.meta.url\nconst text = "__dirname";', "source.ts"), []); }); test("imperative handler insertion preserves arbitrary existing code and rejects ambiguous targets", () => { - const original = 'const keep = "createRuntime({fake:1})";\nservePackageRuntime(createRuntime({ existing: customHandler }));\n'; + const original = + 'const keep = "createRuntime({fake:1})";\nservePackageRuntime(createRuntime({ existing: customHandler }));\n'; const edited = addImplementation(original, "createRuntime", "newHandler", "./impl/new.js"); assert.match(edited, /existing: customHandler/); assert.match(edited, /const keep =/); assert.match(edited, /"newHandler": qxImplementation/); assert.throws(() => addImplementation(original, "createRuntime", "existing", "./x.js"), /already exists/); - assert.throws(() => addImplementation('createRuntime(one);', "createRuntime", "x", "./x.js"), /Cannot safely/); + assert.throws(() => addImplementation("createRuntime(one);", "createRuntime", "x", "./x.js"), /Cannot safely/); }); diff --git a/test/candidate-check.test.ts b/test/candidate-check.test.ts index 757d46c..1e0d643 100644 --- a/test/candidate-check.test.ts +++ b/test/candidate-check.test.ts @@ -35,16 +35,23 @@ test("candidate snapshots include dirty and new files without changing Git histo test("candidate check produces explicitly non-activation evidence and never overwrites a report", async (context) => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "qx-check-test-")); context.after(() => fs.rm(directory, { recursive: true, force: true })); - const root = path.join(directory, "source"), output = path.join(directory, "check"); + const root = path.join(directory, "source"), + output = path.join(directory, "check"); await fs.mkdir(root); await execFile("jj", ["git", "init", "--colocate", root]); - await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${"a".repeat(40)}"; } }`); - await fs.writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"b".repeat(40)}" { atom Subject id "atom:subject"; }`); - const result = await checkWorkspaceCandidate({root, output}); + await fs.writeFile( + path.join(root, "quixos.lock"), + `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${"a".repeat(40)}"; } }`, + ); + await fs.writeFile( + path.join(root, "workspace.qx"), + `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"b".repeat(40)}" { atom Subject id "atom:subject"; }`, + ); + const result = await checkWorkspaceCandidate({ root, output }); assert.equal(result.candidateOnly, true); assert.equal(result.activationEvidence, false); assert.deepEqual(result.blockers, []); const report = await fs.readFile(path.join(output, "report.json")); - await assert.rejects(checkWorkspaceCandidate({root, output}), /EEXIST/); + await assert.rejects(checkWorkspaceCandidate({ root, output }), /EEXIST/); assert.deepEqual(await fs.readFile(path.join(output, "report.json")), report); }); diff --git a/test/capability-assembly.test.ts b/test/capability-assembly.test.ts index 200ad38..84f72b5 100644 --- a/test/capability-assembly.test.ts +++ b/test/capability-assembly.test.ts @@ -3,10 +3,7 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { - compileCapabilityResourceRepository, - compileWorkspaceRepository, -} from "../src/capability-language/index.js"; +import { compileCapabilityResourceRepository, compileWorkspaceRepository } from "../src/capability-language/index.js"; const quixosCommit = "1".repeat(40); const namedCommit = "2".repeat(40); @@ -28,31 +25,46 @@ test("workspace assembly resolves resource-owned dependencies recursively", asyn const named = path.join(directory, "named"); const runtime = path.join(directory, "runtime"); await Promise.all([mkdir(root), mkdir(named), mkdir(runtime)]); - await writeFile(path.join(root, "quixos.lock"), lock(`package Runtime source { + await writeFile( + path.join(root, "quixos.lock"), + lock(`package Runtime source { repository "https://example.test/package-runtime.git"; commit "${packageCommit}"; - }`)); - await writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"4".repeat(40)}" { + }`), + ); + await writeFile( + path.join(root, "workspace.qx"), + `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"4".repeat(40)}" { atom Subject id "atom:subject"; import package Runtime; } -`); +`, + ); await writeFile(path.join(named, "quixos.lock"), lock()); - await writeFile(path.join(named, "interface.qx"), `interface Named id "interface:named" revision "interface:named@1" { + await writeFile( + path.join(named, "interface.qx"), + `interface Named id "interface:named" revision "interface:named@1" { value name id "member:named:name" : string { get id "operation:named:name:get"; } } -`); - await writeFile(path.join(runtime, "quixos.lock"), lock(`interface Named source { +`, + ); + await writeFile( + path.join(runtime, "quixos.lock"), + lock(`interface Named source { repository "https://example.test/interface-named.git"; commit "${namedCommit}"; - }`)); - await writeFile(path.join(runtime, "package.qx"), `import interface Named; + }`), + ); + await writeFile( + path.join(runtime, "package.qx"), + `import interface Named; package Runtime id "package:runtime" revision "package:runtime@1" { function describe id "export:runtime:describe" : interface-ref -> string; } -`); +`, + ); const directories = new Map([ [`interface\0https://example.test/interface-named.git\0${namedCommit}`, named], @@ -100,15 +112,21 @@ package Runtime id "package:runtime" revision "package:runtime@1" { test("resource repositories cannot own workspace Quixos selection policy", async (context) => { const directory = await mkdtemp(path.join(os.tmpdir(), "quixos-resource-policy-")); context.after(() => rm(directory, { recursive: true, force: true })); - await writeFile(path.join(directory, "quixos.lock"), `quixos-lock version 1 { + await writeFile( + path.join(directory, "quixos.lock"), + `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; policy track-development; ref "dev/alice/main"; commit "${quixosCommit}"; } - }`); - await writeFile(path.join(directory, "package.qx"), `package Runtime id "package:runtime" revision "package:runtime@1" { }\n`); + }`, + ); + await writeFile( + path.join(directory, "package.qx"), + `package Runtime id "package:runtime" revision "package:runtime@1" { }\n`, + ); await assert.rejects( compileCapabilityResourceRepository({ @@ -131,19 +149,28 @@ test("resource manifests cannot hide lock dependencies", async (context) => { const root = path.join(directory, "root"); const runtime = path.join(directory, "runtime"); await Promise.all([mkdir(root), mkdir(runtime)]); - await writeFile(path.join(root, "quixos.lock"), lock(`package Runtime source { + await writeFile( + path.join(root, "quixos.lock"), + lock(`package Runtime source { repository "https://example.test/package-runtime.git"; commit "${packageCommit}"; - }`)); - await writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"4".repeat(40)}" { + }`), + ); + await writeFile( + path.join(root, "workspace.qx"), + `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"4".repeat(40)}" { atom Subject id "atom:subject"; import package Runtime; } -`); +`, + ); await writeFile(path.join(runtime, "quixos.lock"), lock()); - await writeFile(path.join(runtime, "package.qx"), `import interface Hidden; + await writeFile( + path.join(runtime, "package.qx"), + `import interface Hidden; package Runtime id "package:runtime" revision "package:runtime@1" { } -`); +`, + ); await assert.rejects( compileWorkspaceRepository({ @@ -160,19 +187,28 @@ test("workspace assembly must satisfy nominal external interfaces", async (conte const root = path.join(directory, "root"); const runtime = path.join(directory, "runtime"); await Promise.all([mkdir(root), mkdir(runtime)]); - await writeFile(path.join(root, "quixos.lock"), lock(`package Runtime source { + await writeFile( + path.join(root, "quixos.lock"), + lock(`package Runtime source { repository "https://example.test/package-runtime.git"; commit "${packageCommit}"; - }`)); - await writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"4".repeat(40)}" { + }`), + ); + await writeFile( + path.join(root, "workspace.qx"), + `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"4".repeat(40)}" { atom Subject id "atom:subject"; import package Runtime; } -`); +`, + ); await writeFile(path.join(runtime, "quixos.lock"), lock()); - await writeFile(path.join(runtime, "package.qx"), `external interface Named revision "interface:named@1"; + await writeFile( + path.join(runtime, "package.qx"), + `external interface Named revision "interface:named@1"; package Runtime id "package:runtime" revision "package:runtime@1" { } -`); +`, + ); await assert.rejects( compileWorkspaceRepository({ diff --git a/test/capability-cli.test.ts b/test/capability-cli.test.ts index b239f96..73fa856 100644 --- a/test/capability-cli.test.ts +++ b/test/capability-cli.test.ts @@ -5,14 +5,12 @@ import { test } from "node:test"; const cli = resolve(process.cwd(), "dist/src/capability-language/cli.js"); -const runResourceCheck = (source: string) => spawnSync( - process.execPath, - [cli, "--resource", "--check", "-"], - { input: source, encoding: "utf8" }, -); +const runResourceCheck = (source: string) => + spawnSync(process.execPath, [cli, "--resource", "--check", "-"], { input: source, encoding: "utf8" }); test("capability CLI checks a standalone interface resource", () => { - const result = runResourceCheck(`interface WeatherBase id "interface:weather-base" revision "interface:weather-base@1" { + const result = + runResourceCheck(`interface WeatherBase id "interface:weather-base" revision "interface:weather-base@1" { value temperature id "member:weather:temperature" : double { get id "operation:weather:temperature:get"; } @@ -22,7 +20,8 @@ test("capability CLI checks a standalone interface resource", () => { }); test("capability CLI reports invalid standalone interface members", () => { - const result = runResourceCheck(`interface WeatherBase id "interface:weather-base" revision "interface:weather-base@1" { + const result = + runResourceCheck(`interface WeatherBase id "interface:weather-base" revision "interface:weather-base@1" { value temperature : definitely-not-a-type; }\n`); assert.notEqual(result.status, 0); diff --git a/test/capability-language.test.ts b/test/capability-language.test.ts index 6ce8e9b..9a598da 100644 --- a/test/capability-language.test.ts +++ b/test/capability-language.test.ts @@ -2,10 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { test } from "node:test"; -import { - compileCapabilityResourceSource, - compileCapabilitySource, -} from "../src/capability-language/index.js"; +import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/index.js"; import { capabilityFixtureSource, capabilityResourceSources, @@ -14,91 +11,76 @@ import { } from "./fixtures/capability-model.js"; test("RPC record fields retain declared reference targets and reject duplicate fields", () => { - const compile = (fields: string) => compileCapabilityResourceSource(` + const compile = (fields: string) => + compileCapabilityResourceSource( + ` external atom Board id "atom:board"; package Runtime id "package:runtime" revision "package:runtime@1" { function move id "export:move" : record { ${fields} } -> unit; -}`, {source: {repository: "https://example.test/runtime.git", commit: "a".repeat(40)}}); +}`, + { source: { repository: "https://example.test/runtime.git", commit: "a".repeat(40) } }, + ); const result = compile("board: atom-ref; position: record { x: double; y: double; }; note: optional;"); assert.equal(result.ok, true, JSON.stringify(result.diagnostics)); assert.equal(compile("board: atom-ref; board: string;").ok, false); }); const compileWebStudioFixture = (packageTransform = (source: string) => source) => { - const fixture = (name: string) => readFileSync( - resolve(process.cwd(), "test/fixtures", name), - "utf8", - ); - const react = compileCapabilityResourceSource( - fixture("react-component.interface.qx"), - { - source: { - repository: "https://repos.quixos.org/org-quixos-web-studio/interface-react-component.git", - commit: "2".repeat(40), - }, + const fixture = (name: string) => readFileSync(resolve(process.cwd(), "test/fixtures", name), "utf8"); + const react = compileCapabilityResourceSource(fixture("react-component.interface.qx"), { + source: { + repository: "https://repos.quixos.org/org-quixos-web-studio/interface-react-component.git", + commit: "2".repeat(40), }, - ); + }); if (!react.ok || react.resource.kind !== "interface") { throw new Error("ReactComponent fixture did not compile"); } - const named = compileCapabilityResourceSource( - fixture("named.interface.qx"), - { - source: { - repository: "https://repos.quixos.org/quixos-test/interface-named.git", - commit: "5".repeat(40), - }, + const named = compileCapabilityResourceSource(fixture("named.interface.qx"), { + source: { + repository: "https://repos.quixos.org/quixos-test/interface-named.git", + commit: "5".repeat(40), }, - ); + }); if (!named.ok || named.resource.kind !== "interface") { throw new Error("Named fixture did not compile"); } - const has = compileCapabilityResourceSource( - fixture("has-react-component.interface.qx"), - { - source: { - repository: "https://repos.quixos.org/org-quixos-web-studio/interface-has-react-component.git", - commit: "3".repeat(40), - }, - environment: { - interfaces: new Map([["ReactComponent", react.resource.revision]]), - interfaceClosure: [react.resource.revision], - }, + const has = compileCapabilityResourceSource(fixture("has-react-component.interface.qx"), { + source: { + repository: "https://repos.quixos.org/org-quixos-web-studio/interface-has-react-component.git", + commit: "3".repeat(40), }, - ); + environment: { + interfaces: new Map([["ReactComponent", react.resource.revision]]), + interfaceClosure: [react.resource.revision], + }, + }); if (!has.ok || has.resource.kind !== "interface") { throw new Error("HasReactComponent fixture did not compile"); } - const component = compileCapabilityResourceSource( - packageTransform(fixture("component-runtime.package.qx")), - { - source: { - repository: "https://repos.quixos.org/quixos-test/package-component-runtime.git", - commit: "4".repeat(40), - }, - environment: { - interfaces: new Map([["Named", named.resource.revision]]), - interfaceClosure: [named.resource.revision], - }, + const component = compileCapabilityResourceSource(packageTransform(fixture("component-runtime.package.qx")), { + source: { + repository: "https://repos.quixos.org/quixos-test/package-component-runtime.git", + commit: "4".repeat(40), }, - ); + environment: { + interfaces: new Map([["Named", named.resource.revision]]), + interfaceClosure: [named.resource.revision], + }, + }); if (!component.ok || component.resource.kind !== "package") { throw new Error("ComponentRuntime fixture did not compile"); } - return compileCapabilitySource( - fixture("web-studio.capabilities.qx"), - "web-studio.capabilities.qx", - { - interfaces: new Map([ - ["Named", named.resource.revision], - ["ReactComponent", react.resource.revision], - ["HasReactComponent", has.resource.revision], - ]), - packages: new Map([["ComponentRuntime", component.resource.revision]]), - interfaceClosure: [named.resource.revision, react.resource.revision, has.resource.revision], - packageClosure: [component.resource.revision], - }, - ); + return compileCapabilitySource(fixture("web-studio.capabilities.qx"), "web-studio.capabilities.qx", { + interfaces: new Map([ + ["Named", named.resource.revision], + ["ReactComponent", react.resource.revision], + ["HasReactComponent", has.resource.revision], + ]), + packages: new Map([["ComponentRuntime", component.resource.revision]]), + interfaceClosure: [named.resource.revision, react.resource.revision, has.resource.revision], + packageClosure: [component.resource.revision], + }); }; test("ANTLR parses and validates a complete capability workspace", () => { @@ -128,7 +110,7 @@ test("the v1 language has no implicit relationship materialization rule", () => const result = compileCapabilityFixture({ workspace: capabilityFixtureSource.replace( /\n}\s*$/, - '\n materialize ProjectOwner.owner if absent with Person;\n}\n', + "\n materialize ProjectOwner.owner if absent with Person;\n}\n", ), }); assert.equal(result.ok, false); @@ -140,10 +122,7 @@ test("forward declarations make source order irrelevant", () => { const source = capabilityFixtureSource .replace(/ atom Project[^\n]*\n/, "") .replace(/ atom Person[^\n]*\n/, "") - .replace( - /\n}\s*$/, - '\n atom Project id "atom:project";\n atom Person id "atom:person";\n}\n', - ); + .replace(/\n}\s*$/, '\n atom Project id "atom:project";\n atom Person id "atom:person";\n}\n'); assert.equal(compileCapabilityFixture({ workspace: source }).ok, true); }); @@ -153,18 +132,17 @@ test("interfaces can declare ordinary call operations", () => { "bind summarize.call to package TodoRuntime.summaryGet", ); const summary = capabilityResourceSources.summary.replace( - " value summary id \"member:summary:summary\" : string {\n get id \"operation:summary:summary:get\";\n }", - " operation summarize id \"member:summary:summarize\" : string -> string {\n call id \"operation:summary:summarize\";\n }", + ' value summary id "member:summary:summary" : string {\n get id "operation:summary:summary:get";\n }', + ' operation summarize id "member:summary:summarize" : string -> string {\n call id "operation:summary:summarize";\n }', ); const todo = capabilityResourceSources.todo.replace( - "operation summaryGet id \"export:todo-runtime:summary-get\" : unit -> string", - "operation summaryGet id \"export:todo-runtime:summary-get\" : string -> string", + 'operation summaryGet id "export:todo-runtime:summary-get" : unit -> string', + 'operation summaryGet id "export:todo-runtime:summary-get" : string -> string', ); const result = compileCapabilityFixture({ workspace, summary, todo }); assert.equal(result.ok, true); if (!result.ok) return; - const member = result.workspace.interfaceImports - .find((entry) => entry.displayName === "Summary")?.members[0]; + const member = result.workspace.interfaceImports.find((entry) => entry.displayName === "Summary")?.members[0]; assert.equal(member?.kind, "operation"); }); @@ -178,9 +156,7 @@ test("state defaults accept recursive JSON values", () => { const result = compileCapabilityFixture({ workspace: source }); assert.equal(result.ok, true); if (!result.ok) return; - const metadata = result.workspace.sharedAttachments.find( - (entry) => entry.id === "slot:project:metadata", - ); + const metadata = result.workspace.sharedAttachments.find((entry) => entry.id === "slot:project:metadata"); assert.equal(metadata?.kind, "state"); if (metadata?.kind !== "state") return; assert.deepEqual(metadata.defaultValue, { @@ -197,25 +173,20 @@ test("syntax errors retain source locations", () => { }); assert.equal(result.ok, false); if (result.ok) return; - assert.ok(result.diagnostics.some((entry) => - entry.phase === "syntax" && entry.line > 0 - )); + assert.ok(result.diagnostics.some((entry) => entry.phase === "syntax" && entry.line > 0)); }); test("unknown authoring names are lowering errors", () => { const result = compileCapabilityFixture({ - workspace: capabilityFixtureSource.replace( - "to state ProjectTitle.read", - "to state NotAState.read", - ), + workspace: capabilityFixtureSource.replace("to state ProjectTitle.read", "to state NotAState.read"), }); assert.equal(result.ok, false); if (result.ok) return; - assert.ok(result.diagnostics.some((entry) => - entry.phase === "lowering" && - entry.code === "unknown-symbol" && - entry.message.includes("NotAState") - )); + assert.ok( + result.diagnostics.some( + (entry) => entry.phase === "lowering" && entry.code === "unknown-symbol" && entry.message.includes("NotAState"), + ), + ); }); test("well-formed but invalid programs report semantic paths", () => { @@ -227,9 +198,7 @@ test("well-formed but invalid programs report semantic paths", () => { }); assert.equal(result.ok, false); if (result.ok) return; - const diagnostic = result.diagnostics.find( - (entry) => entry.code === "invalid-state-binding", - ); + const diagnostic = result.diagnostics.find((entry) => entry.code === "invalid-state-binding"); assert.ok(diagnostic); assert.equal(diagnostic.phase, "validation"); assert.ok(diagnostic.path?.includes("operationBindings")); @@ -240,20 +209,21 @@ test("Web Studio sidecars declare lazy materialization and checked cross-object assert.equal(result.ok, true); if (!result.ok) return; - const host = result.workspace.conformances.find((entry) => - entry.atomId === "atom:project" && - entry.interfaceRevisionId === "interface:org.quixos.web-studio.has-react-component@1" + const host = result.workspace.conformances.find( + (entry) => + entry.atomId === "atom:project" && + entry.interfaceRevisionId === "interface:org.quixos.web-studio.has-react-component@1", ); - assert.deepEqual(host?.relationshipMaterializations, [{ - memberId: "member:org.quixos.web-studio.has-react-component:component", - constructorAtomId: "atom:project-component", - edgeTypeId: "edge:project:component", - constructedProjectionId: "projection:component:subject", - }]); + assert.deepEqual(host?.relationshipMaterializations, [ + { + memberId: "member:org.quixos.web-studio.has-react-component:component", + constructorAtomId: "atom:project-component", + edgeTypeId: "edge:project:component", + constructedProjectionId: "projection:component:subject", + }, + ]); - const component = result.workspace.conformances.find((entry) => - entry.atomId === "atom:project-component" - ); + const component = result.workspace.conformances.find((entry) => entry.atomId === "atom:project-component"); const binding = component?.operationBindings[0]?.binding; assert.equal(binding?.kind, "package"); if (binding?.kind !== "package") return; @@ -268,35 +238,40 @@ test("Web Studio sidecars declare lazy materialization and checked cross-object }); test("relationship materializers require a constructor from the host atom", () => { - const result = compileWebStudioFixture((source) => source.replace( - "constructs ProjectComponent : atom-ref;", - "constructs ProjectComponent : unit;", - )); + const result = compileWebStudioFixture((source) => + source.replace("constructs ProjectComponent : atom-ref;", "constructs ProjectComponent : unit;"), + ); assert.equal(result.ok, false); if (result.ok) return; - assert.ok(result.diagnostics.some((entry) => - entry.code === "invalid-relationship-materialization" && - entry.message.includes("must accept") - )); + assert.ok( + result.diagnostics.some( + (entry) => entry.code === "invalid-relationship-materialization" && entry.message.includes("must accept"), + ), + ); }); test("callable interface ports require a full source dependency, not a nominal reference", () => { - const result = compileCapabilityResourceSource(` + const result = compileCapabilityResourceSource( + ` external interface Named revision "interface:named@1"; package Runtime id "package:runtime" revision "package:runtime@1" { function readName id "export:runtime:read-name" : unit -> string requires { interface named id "port:runtime:named" : Named; }; } -`, { - source: { - repository: "https://repos.quixos.org/quixos-test/package-runtime.git", - commit: "6".repeat(40), +`, + { + source: { + repository: "https://repos.quixos.org/quixos-test/package-runtime.git", + commit: "6".repeat(40), + }, }, - }); + ); assert.equal(result.ok, false); if (result.ok) return; - assert.ok(result.diagnostics.some((entry) => - entry.code === "nominal-interface-port" && entry.message.includes("import interface Named") - )); + assert.ok( + result.diagnostics.some( + (entry) => entry.code === "nominal-interface-port" && entry.message.includes("import interface Named"), + ), + ); }); diff --git a/test/capability-model.test.ts b/test/capability-model.test.ts index 5dab7f0..28a3248 100644 --- a/test/capability-model.test.ts +++ b/test/capability-model.test.ts @@ -9,36 +9,36 @@ import { type CapabilityValidationIssueCode, type WorkspaceRevision, } from "../src/capability-model/index.js"; -import { - fixtureId, - makeValidCapabilityWorkspace, -} from "./fixtures/capability-model.js"; +import { fixtureId, makeValidCapabilityWorkspace } from "./fixtures/capability-model.js"; test("ordinary state rejects managed references even under list/optional wrappers", () => { const workspace = makeValidCapabilityWorkspace(); - const state = [...workspace.sharedAttachments, ...workspace.conformances.flatMap((entry) => entry.privateAttachments)].find((entry) => entry.kind === "state")!; + const state = [ + ...workspace.sharedAttachments, + ...workspace.conformances.flatMap((entry) => entry.privateAttachments), + ].find((entry) => entry.kind === "state")!; if (state.kind !== "state") throw new Error("fixture missing state"); - state.valueType = {kind: "list", value: {kind: "optional", value: {kind: "object-ref", expectation: {kind: "atom", atomId: state.attachedTo}}}}; + state.valueType = { + kind: "list", + value: { kind: "optional", value: { kind: "object-ref", expectation: { kind: "atom", atomId: state.attachedTo } } }, + }; assert.ok(validateWorkspaceRevision(workspace).some((entry) => entry.message.includes("graph relationships"))); }); -const expectIssue = ( - workspace: WorkspaceRevision, - code: CapabilityValidationIssueCode, -) => { +const expectIssue = (workspace: WorkspaceRevision, code: CapabilityValidationIssueCode) => { const issues = validateWorkspaceRevision(workspace); assert.ok( issues.some((entry) => entry.code === code), - `Expected ${code}, got:\n${issues - .map((entry) => `${entry.code} ${entry.path}: ${entry.message}`) - .join("\n")}`, + `Expected ${code}, got:\n${issues.map((entry) => `${entry.code} ${entry.path}: ${entry.message}`).join("\n")}`, ); assert.equal(compileWorkspaceRevision(workspace).ok, false); }; test("constructor dependency input contracts match the selected constructor", () => { const workspace = makeValidCapabilityWorkspace(); - const port = workspace.packageImports.flatMap((pkg) => pkg.exports).flatMap((entry) => entry.dependencyPorts) + const port = workspace.packageImports + .flatMap((pkg) => pkg.exports) + .flatMap((entry) => entry.dependencyPorts) .find((port) => port.requirement.kind === "constructor")!; assert.equal(port.requirement.kind, "constructor"); if (port.requirement.kind !== "constructor") return; @@ -52,14 +52,10 @@ const conformance = ( workspace: WorkspaceRevision, identity: Pick<(typeof workspace.conformances)[number], "atomId" | "interfaceRevisionId">, ) => { - const result = workspace.conformances.find((entry) => - entry.atomId === identity.atomId && - entry.interfaceRevisionId === identity.interfaceRevisionId - ); - assert.ok( - result, - `Missing fixture conformance ${identity.atomId} as ${identity.interfaceRevisionId}`, + const result = workspace.conformances.find( + (entry) => entry.atomId === identity.atomId && entry.interfaceRevisionId === identity.interfaceRevisionId, ); + assert.ok(result, `Missing fixture conformance ${identity.atomId} as ${identity.interfaceRevisionId}`); return result; }; @@ -70,12 +66,7 @@ test("the representative v1 workspace compiles to native and package plans", () assert.equal(compiled.ok, true); if (!compiled.ok) return; - const title = resolveOperationPlan( - compiled.plan, - fixtureId.project, - fixtureId.namedV1, - fixtureId.namedGet, - ); + const title = resolveOperationPlan(compiled.plan, fixtureId.project, fixtureId.namedV1, fixtureId.namedGet); assert.equal(title?.kind, "state"); if (title?.kind === "state") { assert.equal(title.binding.slotId, fixtureId.projectTitle); @@ -83,12 +74,7 @@ test("the representative v1 workspace compiles to native and package plans", () assert.deepEqual(title.attachment.owner, { kind: "workspace" }); } - const owner = resolveOperationPlan( - compiled.plan, - fixtureId.project, - fixtureId.ownedV1, - fixtureId.ownerResolve, - ); + const owner = resolveOperationPlan(compiled.plan, fixtureId.project, fixtureId.ownedV1, fixtureId.ownerResolve); assert.equal(owner?.kind, "edge"); if (owner?.kind === "edge") { assert.equal(owner.binding.edgeTypeId, fixtureId.projectOwner); @@ -98,12 +84,7 @@ test("the representative v1 workspace compiles to native and package plans", () }); } - const summary = resolveOperationPlan( - compiled.plan, - fixtureId.project, - fixtureId.summaryV1, - fixtureId.summaryGet, - ); + const summary = resolveOperationPlan(compiled.plan, fixtureId.project, fixtureId.summaryV1, fixtureId.summaryGet); assert.equal(summary?.kind, "package"); if (summary?.kind === "package") { assert.equal(summary.packageRevision.revisionId, fixtureId.todoRuntimeV1); @@ -126,10 +107,7 @@ test("the closure is an exact tree-shaking boundary", () => { const closure = computeCapabilityClosure(result.plan, [ { atomId: fixtureId.project, interfaceRevisionId: fixtureId.summaryV1 }, ]); - assert.deepEqual(closure.conformances, [ - fixtureId.projectNamedConformance, - fixtureId.projectSummaryConformance, - ]); + assert.deepEqual(closure.conformances, [fixtureId.projectNamedConformance, fixtureId.projectSummaryConformance]); assert.deepEqual(closure.packageRevisionIds, [fixtureId.todoRuntimeV1]); assert.deepEqual(closure.attachmentIds, [fixtureId.projectTitle]); assert.deepEqual(closure.constructorAtomIds, [fixtureId.person]); @@ -140,18 +118,12 @@ test("compiled plans are snapshots, not mutable authoring state", () => { const result = compileWorkspaceRevision(workspace); assert.equal(result.ok, true); if (!result.ok) return; - conformance(workspace, fixtureId.projectNamedConformance) - .operationBindings[0]!.binding = { - kind: "state", - slotId: fixtureId.personName, - primitive: "read", - }; - const resolved = resolveOperationPlan( - result.plan, - fixtureId.project, - fixtureId.namedV1, - fixtureId.namedGet, - ); + conformance(workspace, fixtureId.projectNamedConformance).operationBindings[0]!.binding = { + kind: "state", + slotId: fixtureId.personName, + primitive: "read", + }; + const resolved = resolveOperationPlan(result.plan, fixtureId.project, fixtureId.namedV1, fixtureId.namedGet); assert.equal(resolved?.kind, "state"); if (resolved?.kind === "state") { assert.equal(resolved.binding.slotId, fixtureId.projectTitle); @@ -164,20 +136,14 @@ test("a conformance binds every operation exactly once", () => { expectIssue(missing, "missing-operation-binding"); const duplicate = makeValidCapabilityWorkspace(); - const bindings = conformance( - duplicate, - fixtureId.projectNamedConformance, - ).operationBindings; + const bindings = conformance(duplicate, fixtureId.projectNamedConformance).operationBindings; bindings.push(structuredClone(bindings[0]!)); expectIssue(duplicate, "duplicate-operation-binding"); }); test("private attachments are visible only to their owning conformance", () => { const workspace = makeValidCapabilityWorkspace(); - const binding = conformance( - workspace, - fixtureId.projectNamedConformance, - ).operationBindings[0]!.binding; + const binding = conformance(workspace, fixtureId.projectNamedConformance).operationBindings[0]!.binding; assert.equal(binding.kind, "state"); if (binding.kind !== "state") return; binding.slotId = fixtureId.personName; @@ -186,15 +152,10 @@ test("private attachments are visible only to their owning conformance", () => { test("related-object dependency views cannot traverse another conformance's private edge", () => { const workspace = makeValidCapabilityWorkspace(); - const packageBinding = conformance( - workspace, - fixtureId.projectSummaryConformance, - ).operationBindings[0]!.binding; + const packageBinding = conformance(workspace, fixtureId.projectSummaryConformance).operationBindings[0]!.binding; assert.equal(packageBinding.kind, "package"); if (packageBinding.kind !== "package") return; - const named = packageBinding.dependencies.find( - (dependency) => dependency.portId === fixtureId.namedPort, - ); + const named = packageBinding.dependencies.find((dependency) => dependency.portId === fixtureId.namedPort); assert.ok(named && named.binding.kind === "interface"); named.binding.via = { edgeTypeId: fixtureId.projectOwner, @@ -205,20 +166,31 @@ test("related-object dependency views cannot traverse another conformance's priv const edge = owner.privateAttachments.find((entry) => entry.kind === "edge" && entry.id === fixtureId.projectOwner); assert.ok(edge?.kind === "edge"); edge.endpoints.find((endpoint) => endpoint.projectionId === fixtureId.projectOwnerProjection)!.publicTraversal = true; - assert.equal(validateWorkspaceRevision(workspace).some((entry) => entry.code === "private-attachment-access"), false, "Only an explicitly exported read-only traversal crosses ownership"); + assert.equal( + validateWorkspaceRevision(workspace).some((entry) => entry.code === "private-attachment-access"), + false, + "Only an explicitly exported read-only traversal crosses ownership", + ); }); test("public traversal permits a native inverse read without exporting mutation authority", () => { const workspace = makeValidCapabilityWorkspace(); const owned = conformance(workspace, fixtureId.projectOwnedConformance); - const index = owned.privateAttachments.findIndex(entry => entry.kind === "edge" && entry.id === fixtureId.projectOwner); + const index = owned.privateAttachments.findIndex( + (entry) => entry.kind === "edge" && entry.id === fixtureId.projectOwner, + ); const edge = owned.privateAttachments.splice(index, 1)[0]; assert.ok(edge?.kind === "edge"); conformance(workspace, fixtureId.projectNamedConformance).privateAttachments.push(edge); expectIssue(workspace, "private-attachment-access"); - edge.endpoints.find(endpoint => endpoint.projectionId === fixtureId.projectOwnerProjection)!.publicTraversal = true; + edge.endpoints.find((endpoint) => endpoint.projectionId === fixtureId.projectOwnerProjection)!.publicTraversal = true; const readPath = `conformances[${workspace.conformances.indexOf(owned)}].operationBindings[0]`; - assert.equal(validateWorkspaceRevision(workspace).some(issue => issue.code === "private-attachment-access" && issue.path.startsWith(readPath)), false); + assert.equal( + validateWorkspaceRevision(workspace).some( + (issue) => issue.code === "private-attachment-access" && issue.path.startsWith(readPath), + ), + false, + ); const binding = owned.operationBindings[0].binding; assert.ok(binding.kind === "edge"); binding.primitive = "connect"; @@ -227,19 +199,13 @@ test("public traversal permits a native inverse read without exporting mutation test("native state and edge providers must match operation shape", () => { const stateWorkspace = makeValidCapabilityWorkspace(); - const state = conformance( - stateWorkspace, - fixtureId.projectNamedConformance, - ).operationBindings[0]!.binding; + const state = conformance(stateWorkspace, fixtureId.projectNamedConformance).operationBindings[0]!.binding; assert.equal(state.kind, "state"); if (state.kind === "state") state.primitive = "write"; expectIssue(stateWorkspace, "invalid-state-binding"); const edgeWorkspace = makeValidCapabilityWorkspace(); - const edge = conformance( - edgeWorkspace, - fixtureId.projectOwnedConformance, - ).operationBindings[0]!.binding; + const edge = conformance(edgeWorkspace, fixtureId.projectOwnedConformance).operationBindings[0]!.binding; assert.equal(edge.kind, "edge"); if (edge.kind === "edge") edge.primitive = "connect"; expectIssue(edgeWorkspace, "invalid-edge-binding"); @@ -247,18 +213,13 @@ test("native state and edge providers must match operation shape", () => { test("package dependencies are complete, exact, and explicitly injected", () => { const missing = makeValidCapabilityWorkspace(); - const packageBinding = conformance( - missing, - fixtureId.projectSummaryConformance, - ).operationBindings[0]!.binding; + const packageBinding = conformance(missing, fixtureId.projectSummaryConformance).operationBindings[0]!.binding; assert.equal(packageBinding.kind, "package"); if (packageBinding.kind === "package") packageBinding.dependencies.pop(); expectIssue(missing, "invalid-dependency-binding"); const wrongType = makeValidCapabilityWorkspace(); - const summaryExport = wrongType.packageImports[0]!.exports.find( - (entry) => entry.id === fixtureId.summaryGetExport, - ); + const summaryExport = wrongType.packageImports[0]!.exports.find((entry) => entry.id === fixtureId.summaryGetExport); assert.ok(summaryExport); summaryExport.dependencyPorts[0]!.requirement = { kind: "state", @@ -270,19 +231,14 @@ test("package dependencies are complete, exact, and explicitly injected", () => test("package receiver requirements cannot depend on themselves", () => { const workspace = makeValidCapabilityWorkspace(); - const summaryExport = workspace.packageImports[0]!.exports.find( - (entry) => entry.id === fixtureId.summaryGetExport, - ); + const summaryExport = workspace.packageImports[0]!.exports.find((entry) => entry.id === fixtureId.summaryGetExport); assert.ok(summaryExport && summaryExport.kind === "operation"); summaryExport.receiverRequirement = { kind: "all-interfaces", interfaceRevisionIds: [fixtureId.summaryV1], }; summaryExport.dependencyPorts = []; - const packageBinding = conformance( - workspace, - fixtureId.projectSummaryConformance, - ).operationBindings[0]!.binding; + const packageBinding = conformance(workspace, fixtureId.projectSummaryConformance).operationBindings[0]!.binding; assert.equal(packageBinding.kind, "package"); if (packageBinding.kind === "package") packageBinding.dependencies = []; expectIssue(workspace, "cyclic-conformance-requirement"); diff --git a/test/evolution.test.ts b/test/evolution.test.ts index c0ccba8..93c91b1 100644 --- a/test/evolution.test.ts +++ b/test/evolution.test.ts @@ -1,13 +1,30 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { capabilityId as id, valueType, validateWorkspaceRevision } from "../src/capability-model/index.js"; -import { contentDigest, planEvolution, runtimeContracts, storageContracts, storageChangeRequiresMigration } from "../src/capability-model/evolution.js"; -import { capabilityFixtureSource, capabilityResourceSources, compileCapabilityFixture, makeValidCapabilityWorkspace } from "./fixtures/capability-model.js"; +import { + contentDigest, + planEvolution, + runtimeContracts, + storageContracts, + storageChangeRequiresMigration, +} from "../src/capability-model/evolution.js"; +import { + capabilityFixtureSource, + capabilityResourceSources, + compileCapabilityFixture, + makeValidCapabilityWorkspace, +} from "./fixtures/capability-model.js"; test("QX carries stable conformance IDs and implementation semantic majors", () => { const result = compileCapabilityFixture({ - workspace: capabilityFixtureSource.replace("conform Project as Named {", 'conform Project as Named id "conformance:project:named" semantic-major 3 {'), - todo: capabilityResourceSources.todo.replace('revision "package:todo-runtime@1" {', 'revision "package:todo-runtime@1" semantic-major 2 {'), + workspace: capabilityFixtureSource.replace( + "conform Project as Named {", + 'conform Project as Named id "conformance:project:named" semantic-major 3 {', + ), + todo: capabilityResourceSources.todo.replace( + 'revision "package:todo-runtime@1" {', + 'revision "package:todo-runtime@1" semantic-major 2 {', + ), }); assert.ok(result.ok, JSON.stringify(result)); assert.equal(result.workspace.conformances[0]!.id, "conformance:project:named"); @@ -43,16 +60,32 @@ test("a workspace root change and display names do not restart package runtimes" after.conformances.reverse(); after.interfaceImports.reverse(); const report = planEvolution(before, after, { allowLegacy: true }); - assert.deepEqual(report.runtimeActions.map((entry) => entry.action), ["keep"]); + assert.deepEqual( + report.runtimeActions.map((entry) => entry.action), + ["keep"], + ); assert.deepEqual(report.storageChanges, []); assert.deepEqual(report.packageChecks, []); }); test("consumer changes preserve unrelated resource owners", () => { const before = makeValidCapabilityWorkspace(); - before.packageImports.push({ packageId: id.package("package:resource-owner"), revisionId: id.packageRevision("package:resource-owner@1"), - displayName: "ResourceOwner", source: { repository: "https://example.org/owner.git", commit: "a".repeat(40) }, - exports: [{ kind: "function", id: id.packageExport("export:owner:ping"), displayName: "ping", inputType: valueType.unit, outputType: valueType.unit, dependencyPorts: [] }] }); + before.packageImports.push({ + packageId: id.package("package:resource-owner"), + revisionId: id.packageRevision("package:resource-owner@1"), + displayName: "ResourceOwner", + source: { repository: "https://example.org/owner.git", commit: "a".repeat(40) }, + exports: [ + { + kind: "function", + id: id.packageExport("export:owner:ping"), + displayName: "ping", + inputType: valueType.unit, + outputType: valueType.unit, + dependencyPorts: [], + }, + ], + }); const after = structuredClone(before); after.packageImports[0]!.source.commit = "e".repeat(40); const actions = planEvolution(before, after, { allowLegacy: true }).runtimeActions; @@ -82,8 +115,12 @@ test("semantic review receipts cover exact consumer/provider contracts", () => { const report = planEvolution(before, after, { allowLegacy: true }); assert.equal(report.reviews.length, 1); assert.equal(report.reviews[0]!.accepted, false); - const receipt = { requirementDigest: report.reviews[0]!.requirementDigest, decision: "accepted-unchanged" as const, - rationale: "Reviewed the semantic change against summary behavior", agentId: "workspace-agent" }; + const receipt = { + requirementDigest: report.reviews[0]!.requirementDigest, + decision: "accepted-unchanged" as const, + rationale: "Reviewed the semantic change against summary behavior", + agentId: "workspace-agent", + }; assert.equal(planEvolution(before, after, { allowLegacy: true, reviews: [receipt] }).reviews[0]!.accepted, true); after.packageImports[0]!.source.commit = "f".repeat(40); assert.equal(planEvolution(before, after, { allowLegacy: true, reviews: [receipt] }).reviews[0]!.accepted, false); @@ -95,15 +132,18 @@ test("evolution enrollment is explicit and never erases legacy ownership", () => assert.ok(report.blockers.some((entry) => entry.includes("workspace-shared"))); assert.ok(report.blockers.some((entry) => entry.includes("authored ID"))); assert.equal(runtimeContracts(workspace).length, 1); - assert.throws(() => planEvolution(workspace, { ...workspace, workspaceId: id.workspace("other") }), /different workspace/); + assert.throws( + () => planEvolution(workspace, { ...workspace, workspaceId: id.workspace("other") }), + /different workspace/, + ); }); test("automatic preservation distinguishes additions and defaults from incompatible storage", () => { const workspace = makeValidCapabilityWorkspace(); - const before = storageContracts(workspace).find(entry => entry.kind === "state")!; + const before = storageContracts(workspace).find((entry) => entry.kind === "state")!; const next = structuredClone(before); const value = next.definition as Record; - value.defaultValue = {displayName: "important user data"}; + value.defaultValue = { displayName: "important user data" }; assert.equal(storageChangeRequiresMigration(before, next, new Set()), false); next.ownerId = "another-owner"; assert.equal(storageChangeRequiresMigration(before, next, new Set()), true); @@ -111,10 +151,10 @@ test("automatic preservation distinguishes additions and defaults from incompati delete value.defaultValue; assert.equal(storageChangeRequiresMigration(undefined, next, new Set([value.attachedTo as string])), true); assert.equal(storageChangeRequiresMigration(undefined, next, new Set()), false); - const slot = workspace.sharedAttachments.find(entry => entry.kind === "state")!; + const slot = workspace.sharedAttachments.find((entry) => entry.kind === "state")!; if (slot.kind !== "state") throw new Error("fixture slot"); - slot.defaultValue = {displayName: "one"}; + slot.defaultValue = { displayName: "one" }; const first = storageContracts(workspace); - slot.defaultValue = {displayName: "two"}; + slot.defaultValue = { displayName: "two" }; assert.notDeepEqual(storageContracts(workspace), first, "user data must not be stripped as schema metadata"); }); diff --git a/test/file-lock.test.ts b/test/file-lock.test.ts index 2a09875..72cc543 100644 --- a/test/file-lock.test.ts +++ b/test/file-lock.test.ts @@ -1,34 +1,44 @@ import test from "node:test"; import assert from "node:assert/strict"; -import {mkdtemp, rm} from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import path from "node:path"; import os from "node:os"; -import {spawn} from "node:child_process"; -import {once} from "node:events"; -import {withFileLock} from "../src/capability-language/file-lock.js"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { withFileLock } from "../src/capability-language/file-lock.js"; -test("authoring lock excludes concurrent mutations and survives owner death", async context => { +test("authoring lock excludes concurrent mutations and survives owner death", async (context) => { const root = await mkdtemp(path.join(os.tmpdir(), "qx-lock-test-")); - context.after(() => rm(root, {recursive: true, force: true})); + context.after(() => rm(root, { recursive: true, force: true })); const filename = path.join(root, "lock"); const events: string[] = []; let queued: Promise; await withFileLock(filename, async () => { - queued = withFileLock(filename, async () => {events.push("second");}); + queued = withFileLock(filename, async () => { + events.push("second"); + }); events.push("first"); }); await queued!; assert.deepEqual(events, ["first", "second"]); const module = new URL("../src/capability-language/file-lock.js", import.meta.url).href; - const owner = spawn(process.execPath, ["--input-type=module", "-e", - `import {withFileLock} from ${JSON.stringify(module)}; await withFileLock(${JSON.stringify(filename)}, async () => {process.stdout.write('ready'); await new Promise(() => {});});`], - {stdio: ["ignore", "pipe", "pipe"]}); + const owner = spawn( + process.execPath, + [ + "--input-type=module", + "-e", + `import {withFileLock} from ${JSON.stringify(module)}; await withFileLock(${JSON.stringify(filename)}, async () => {process.stdout.write('ready'); await new Promise(() => {});});`, + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); context.after(() => owner.kill("SIGKILL")); await once(owner.stdout, "data"); const exited = once(owner, "exit"); owner.kill("SIGKILL"); await exited; let acquired = false; - await withFileLock(filename, async () => {acquired = true;}); + await withFileLock(filename, async () => { + acquired = true; + }); assert.equal(acquired, true); }); diff --git a/test/fixtures/capability-model.ts b/test/fixtures/capability-model.ts index ca51727..ace58c5 100644 --- a/test/fixtures/capability-model.ts +++ b/test/fixtures/capability-model.ts @@ -1,18 +1,12 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { - capabilityId, - type WorkspaceRevision, -} from "../../src/capability-model/index.js"; +import { capabilityId, type WorkspaceRevision } from "../../src/capability-model/index.js"; import { compileCapabilityResourceSource, compileCapabilitySource, type CapabilityImportEnvironment, } from "../../src/capability-language/index.js"; -import type { - InterfaceRevision, - PackageRevision, -} from "../../src/capability-model/index.js"; +import type { InterfaceRevision, PackageRevision } from "../../src/capability-model/index.js"; export const fixtureId = { workspace: capabilityId.workspace("workspace:todo"), @@ -29,9 +23,7 @@ export const fixtureId = { projectTitle: capabilityId.slot("slot:project:title"), personName: capabilityId.slot("slot:person:name"), projectOwner: capabilityId.edgeType("edge:project:owner"), - projectOwnerProjection: capabilityId.edgeProjection( - "projection:project-owner:owner", - ), + projectOwnerProjection: capabilityId.edgeProjection("projection:project-owner:owner"), projectNamedConformance: { atomId: capabilityId.atom("atom:project"), interfaceRevisionId: capabilityId.interfaceRevision("interface:named@1"), @@ -49,31 +41,18 @@ export const fixtureId = { interfaceRevisionId: capabilityId.interfaceRevision("interface:summary@1"), }, todoRuntimeV1: capabilityId.packageRevision("package:todo-runtime@1"), - summaryGetExport: capabilityId.packageExport( - "export:todo-runtime:summary-get", - ), - createPersonExport: capabilityId.packageExport( - "export:todo-runtime:create-person", - ), + summaryGetExport: capabilityId.packageExport("export:todo-runtime:summary-get"), + createPersonExport: capabilityId.packageExport("export:todo-runtime:create-person"), titlePort: capabilityId.dependencyPort("port:summary:title"), namedPort: capabilityId.dependencyPort("port:summary:named"), personPort: capabilityId.dependencyPort("port:summary:person"), } as const; -export const capabilityFixturePath = resolve( - process.cwd(), - "test/fixtures/todo.capabilities.qx", -); +export const capabilityFixturePath = resolve(process.cwd(), "test/fixtures/todo.capabilities.qx"); -export const capabilityFixtureSource = readFileSync( - capabilityFixturePath, - "utf8", -); +export const capabilityFixtureSource = readFileSync(capabilityFixturePath, "utf8"); -const resourceSource = (name: string) => readFileSync( - resolve(process.cwd(), "test/fixtures", name), - "utf8", -); +const resourceSource = (name: string) => readFileSync(resolve(process.cwd(), "test/fixtures", name), "utf8"); export const capabilityResourceSources = { named: resourceSource("named.interface.qx"), @@ -85,110 +64,74 @@ export const capabilityResourceSources = { const source = (repository: string, commit: string) => ({ repository, commit }); const interfaceRevision = ( - resource: { kind: "interface"; revision: InterfaceRevision } | - { kind: "package"; revision: PackageRevision }, + resource: { kind: "interface"; revision: InterfaceRevision } | { kind: "package"; revision: PackageRevision }, ) => { if (resource.kind !== "interface") throw new Error("Expected interface fixture"); return resource.revision; }; const packageRevision = ( - resource: { kind: "interface"; revision: InterfaceRevision } | - { kind: "package"; revision: PackageRevision }, + resource: { kind: "interface"; revision: InterfaceRevision } | { kind: "package"; revision: PackageRevision }, ) => { if (resource.kind !== "package") throw new Error("Expected package fixture"); return resource.revision; }; -export const compileCapabilityFixture = (overrides: Partial<{ - workspace: string; - named: string; - owned: string; - summary: string; - todo: string; -}> = {}) => { - const named = compileCapabilityResourceSource( - overrides.named ?? capabilityResourceSources.named, - { - source: source( - "https://repos.quixos.org/quixos-todo/interface-named.git", - "2".repeat(40), - ), - fileName: "named.interface.qx", - }, - ); +export const compileCapabilityFixture = ( + overrides: Partial<{ + workspace: string; + named: string; + owned: string; + summary: string; + todo: string; + }> = {}, +) => { + const named = compileCapabilityResourceSource(overrides.named ?? capabilityResourceSources.named, { + source: source("https://repos.quixos.org/quixos-todo/interface-named.git", "2".repeat(40)), + fileName: "named.interface.qx", + }); if (!named.ok) return named; const namedRevision = interfaceRevision(named.resource); const namedEnvironment: CapabilityImportEnvironment = { interfaces: new Map([["Named", namedRevision]]), interfaceClosure: [namedRevision], }; - const owned = compileCapabilityResourceSource( - overrides.owned ?? capabilityResourceSources.owned, - { - source: source( - "https://repos.quixos.org/quixos-todo/interface-owned.git", - "3".repeat(40), - ), - fileName: "owned.interface.qx", - environment: namedEnvironment, - }, - ); + const owned = compileCapabilityResourceSource(overrides.owned ?? capabilityResourceSources.owned, { + source: source("https://repos.quixos.org/quixos-todo/interface-owned.git", "3".repeat(40)), + fileName: "owned.interface.qx", + environment: namedEnvironment, + }); if (!owned.ok) return owned; const ownedRevision = interfaceRevision(owned.resource); - const summary = compileCapabilityResourceSource( - overrides.summary ?? capabilityResourceSources.summary, - { - source: source( - "https://repos.quixos.org/quixos-todo/interface-summary.git", - "4".repeat(40), - ), - fileName: "summary.interface.qx", - }, - ); + const summary = compileCapabilityResourceSource(overrides.summary ?? capabilityResourceSources.summary, { + source: source("https://repos.quixos.org/quixos-todo/interface-summary.git", "4".repeat(40)), + fileName: "summary.interface.qx", + }); if (!summary.ok) return summary; const summaryRevision = interfaceRevision(summary.resource); - const todo = compileCapabilityResourceSource( - overrides.todo ?? capabilityResourceSources.todo, - { - source: source( - "https://repos.quixos.org/quixos-todo/package-todo-runtime.git", - "5".repeat(40), - ), - fileName: "todo.package.qx", - environment: namedEnvironment, - }, - ); + const todo = compileCapabilityResourceSource(overrides.todo ?? capabilityResourceSources.todo, { + source: source("https://repos.quixos.org/quixos-todo/package-todo-runtime.git", "5".repeat(40)), + fileName: "todo.package.qx", + environment: namedEnvironment, + }); if (!todo.ok) return todo; const todoRevision = packageRevision(todo.resource); - return compileCapabilitySource( - overrides.workspace ?? capabilityFixtureSource, - capabilityFixturePath, - { - interfaces: new Map([ - ["Named", namedRevision], - ["Owned", ownedRevision], - ["Summary", summaryRevision], - ]), - packages: new Map([["TodoRuntime", todoRevision]]), - interfaceClosure: [ - namedRevision, - ownedRevision, - summaryRevision, - ], - packageClosure: [todoRevision], - }, - ); + return compileCapabilitySource(overrides.workspace ?? capabilityFixtureSource, capabilityFixturePath, { + interfaces: new Map([ + ["Named", namedRevision], + ["Owned", ownedRevision], + ["Summary", summaryRevision], + ]), + packages: new Map([["TodoRuntime", todoRevision]]), + interfaceClosure: [namedRevision, ownedRevision, summaryRevision], + packageClosure: [todoRevision], + }); }; export const makeValidCapabilityWorkspace = (): WorkspaceRevision => { const result = compileCapabilityFixture(); if (!result.ok) { - throw new Error( - result.diagnostics - .map((entry) => `${entry.phase}/${entry.code}: ${entry.message}`) - .join("\n"), - ); + throw new Error(result.diagnostics.map((entry) => `${entry.phase}/${entry.code}: ${entry.message}`).join("\n")); } return structuredClone(result.workspace); }; diff --git a/test/fixtures/todo.capabilities.qx b/test/fixtures/todo.capabilities.qx index 2c10fd1..466277e 100644 --- a/test/fixtures/todo.capabilities.qx +++ b/test/fixtures/todo.capabilities.qx @@ -8,7 +8,7 @@ workspace Todo id "workspace:todo" revision "workspace:todo@1" commit "111111111 import package TodoRuntime; shared state ProjectTitle id "slot:project:title" on Project : string - policy optimistic-register default "Untitled project"; + policy optimistic-register default "Untitled project"; conform Project as Named { bind name.get to state ProjectTitle.read; @@ -19,7 +19,7 @@ workspace Todo id "workspace:todo" revision "workspace:todo@1" commit "111111111 conform Person as Named { private state PersonName id "slot:person:name" on Person : string - policy optimistic-register default "Anonymous"; + policy optimistic-register default "Anonymous"; bind name.get to state PersonName.read; bind name.set to state PersonName.write; bind name.watch-start to state PersonName.watch-start; diff --git a/test/fixtures/todo.package.qx b/test/fixtures/todo.package.qx index c1c2143..f070186 100644 --- a/test/fixtures/todo.package.qx +++ b/test/fixtures/todo.package.qx @@ -3,10 +3,10 @@ external atom Person id "atom:person"; package TodoRuntime id "package:todo-runtime" revision "package:todo-runtime@1" { operation summaryGet id "export:todo-runtime:summary-get" : unit -> string - mode call receiver interfaces [Named] requires { - state title id "port:summary:title" : string [read]; - interface named id "port:summary:named" : Named; - constructor person id "port:summary:person" : Person; - }; + mode call receiver interfaces [Named] requires { + state title id "port:summary:title" : string [read]; + interface named id "port:summary:named" : Named; + constructor person id "port:summary:person" : Person; + }; constructor createPerson id "export:todo-runtime:create-person" constructs Person : unit; } diff --git a/test/fixtures/web-studio.capabilities.qx b/test/fixtures/web-studio.capabilities.qx index 6467910..5ead8b3 100644 --- a/test/fixtures/web-studio.capabilities.qx +++ b/test/fixtures/web-studio.capabilities.qx @@ -8,7 +8,7 @@ workspace WebStudioFixture id "workspace:web-studio-fixture" revision "workspace import package ComponentRuntime; shared state ProjectName id "slot:project:name" on Project : string - policy optimistic-register default "Untitled project"; + policy optimistic-register default "Untitled project"; shared edge ProjectComponentEdge id "edge:project:component" { atom ProjectComponent projection subject id "projection:component:subject" exactly-one; atom Project projection component id "projection:project:component" optional-one; diff --git a/test/git-resolver.test.ts b/test/git-resolver.test.ts index 6e6a96e..5df84f2 100644 --- a/test/git-resolver.test.ts +++ b/test/git-resolver.test.ts @@ -8,7 +8,7 @@ import { promisify } from "node:util"; import { createGitCapabilityResolver } from "../src/capability-language/git-resolver.js"; const execFile = promisify(callback); -test("dependency resolution is reusable across processes, concurrent and rejects modified checkouts", async context => { +test("dependency resolution is reusable across processes, concurrent and rejects modified checkouts", async (context) => { const temporary = await mkdtemp(path.join(os.tmpdir(), "qx-resolver-test-")); context.after(() => rm(temporary, { recursive: true, force: true })); const origin = path.join(temporary, "origin"); diff --git a/test/migrations.test.ts b/test/migrations.test.ts index 1ddf1df..51f22b1 100644 --- a/test/migrations.test.ts +++ b/test/migrations.test.ts @@ -1,28 +1,67 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { contentDigest, validateMigrationCatalog, selectMigrationPath, type MigrationCatalog } from "../src/capability-model/index.js"; -const before = { fields: ["name"] }, after = { fields: ["first", "last"] }; -const from = contentDigest(before), to = contentDigest(after); -const catalog = (): MigrationCatalog => ({ schemaVersion: 1, contracts: { [from]: before, [to]: after }, migrations: [{ - id: "split-name", scopeId: "person-name", from, to, - implementation: { exportId: "migrate-name", file: "src/migrate-name.ts", digest: contentDigest("implementation") }, predecessors: [], - ports: [{ name: "source", view: "old", access: ["read"], contractDigest: from }, { name: "target", view: "new", access: ["write"], contractDigest: to }], -}] }); +import { + contentDigest, + validateMigrationCatalog, + selectMigrationPath, + type MigrationCatalog, +} from "../src/capability-model/index.js"; +const before = { fields: ["name"] }, + after = { fields: ["first", "last"] }; +const from = contentDigest(before), + to = contentDigest(after); +const catalog = (): MigrationCatalog => ({ + schemaVersion: 1, + contracts: { [from]: before, [to]: after }, + migrations: [ + { + id: "split-name", + scopeId: "person-name", + from, + to, + implementation: { + exportId: "migrate-name", + file: "src/migrate-name.ts", + digest: contentDigest("implementation"), + }, + predecessors: [], + ports: [ + { name: "source", view: "old", access: ["read"], contractDigest: from }, + { name: "target", view: "new", access: ["write"], contractDigest: to }, + ], + }, + ], +}); test("published migrations retain exact old contracts and explicit local bindings", () => { const value = validateMigrationCatalog(catalog(), new Set(["migrate-name"])); - const selection = { scopeId: "person-name", from, to, path: ["split-name"], bindings: { source: "old-slot", target: "new-slot" } }; + const selection = { + scopeId: "person-name", + from, + to, + path: ["split-name"], + bindings: { source: "old-slot", target: "new-slot" }, + }; const result = selectMigrationPath(value, selection); assert.equal(result[0].alreadyApplied, false); - assert.equal(selectMigrationPath(value, selection, new Map([["split-name", result[0].digest]]))[0].alreadyApplied, true); - const changed = catalog(); changed.migrations[0].implementation.digest = contentDigest("new code"); - assert.throws(() => selectMigrationPath(changed, selection, new Map([["split-name", result[0].digest]])), /different code/); + assert.equal( + selectMigrationPath(value, selection, new Map([["split-name", result[0].digest]]))[0].alreadyApplied, + true, + ); + const changed = catalog(); + changed.migrations[0].implementation.digest = contentDigest("new code"); + assert.throws( + () => selectMigrationPath(changed, selection, new Map([["split-name", result[0].digest]])), + /different code/, + ); assert.throws(() => selectMigrationPath(value, { ...selection, path: [] }), /does not cover/); assert.throws(() => selectMigrationPath(value, { ...selection, bindings: {} }), /Missing local/); }); test("old migration views cannot be writable and contract hashes are verified", () => { - const value = catalog(); value.migrations[0].ports[0].access = ["write"]; + const value = catalog(); + value.migrations[0].ports[0].access = ["write"]; assert.throws(() => validateMigrationCatalog(value), /read-only/); - const corrupt = catalog(); corrupt.contracts[from] = {}; + const corrupt = catalog(); + corrupt.contracts[from] = {}; assert.throws(() => validateMigrationCatalog(corrupt), /digest mismatch/); }); @@ -31,6 +70,6 @@ test("migration history rejects cycles and non-boolean compatibility promises", cyclic.migrations[0].predecessors = ["split-name"]; assert.throws(() => validateMigrationCatalog(cyclic), /Cyclic/); const misleading = catalog(); - (misleading.migrations[0] as unknown as {preservesOldReaders: string}).preservesOldReaders = "false"; + (misleading.migrations[0] as unknown as { preservesOldReaders: string }).preservesOldReaders = "false"; assert.throws(() => validateMigrationCatalog(misleading), /must be booleans/); }); diff --git a/test/nix-candidate.test.ts b/test/nix-candidate.test.ts index 035b5d0..24823b7 100644 --- a/test/nix-candidate.test.ts +++ b/test/nix-candidate.test.ts @@ -7,28 +7,69 @@ import { execFile as callback } from "node:child_process"; import { promisify } from "node:util"; const execFile = promisify(callback); -test("Nix checks a retained immutable source without a local overlay and reuses the result", {skip: !process.env.QX_CHECK_GENERATOR}, async context => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-nix-candidate-test-")); - context.after(() => fs.rm(root, {recursive: true, force: true})); - const git = (...args: string[]) => execFile("git", ["-C", root, ...args]); - await git("init"); - await fs.writeFile(path.join(root, "interface.qx"), 'interface Example id "interface:example" revision "interface:example@1" {}'); - await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } }`); - await git("add", "."); - await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract"); - const commit = (await git("rev-parse", "HEAD")).stdout.trim(); - await git("tag", `quixos-reachability/${commit}`); - const generator = process.env.QX_CHECK_GENERATOR!; - const repository = "https://immutable-candidate.example.test/contract.git"; - const env = {...process.env, GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `url.file://${root}.insteadOf`, GIT_CONFIG_VALUE_0: repository}; - const build = async () => (await execFile("nix", ["build", "--impure", "--file", path.join(generator, "share/checked-candidate.nix"), - "--argstr", "repository", repository, "--argstr", "commit", commit, - "--argstr", "kind", "interface", "--argstr", "generator", generator, - "--option", "substitute", "false", "--no-link", "--print-out-paths"], {env, maxBuffer: 4 * 1024 * 1024})).stdout.trim(); - const output = await build(); - const candidate = JSON.parse(await fs.readFile(path.join(output, "candidate.json"), "utf8")); - assert.equal(candidate.revision.source.commit, commit); - await fs.writeFile(path.join(root, "interface.qx"), "broken draft"); - assert.equal(await build(), output); - assert.deepEqual(JSON.parse(await fs.readFile(path.join(output, "checks.json"), "utf8")), []); -}); +test( + "Nix checks a retained immutable source without a local overlay and reuses the result", + { skip: !process.env.QX_CHECK_GENERATOR }, + async (context) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-nix-candidate-test-")); + context.after(() => fs.rm(root, { recursive: true, force: true })); + const git = (...args: string[]) => execFile("git", ["-C", root, ...args]); + await git("init"); + await fs.writeFile( + path.join(root, "interface.qx"), + 'interface Example id "interface:example" revision "interface:example@1" {}', + ); + await fs.writeFile( + path.join(root, "quixos.lock"), + `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } }`, + ); + await git("add", "."); + await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract"); + const commit = (await git("rev-parse", "HEAD")).stdout.trim(); + await git("tag", `quixos-reachability/${commit}`); + const generator = process.env.QX_CHECK_GENERATOR!; + const repository = "https://immutable-candidate.example.test/contract.git"; + const env = { + ...process.env, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `url.file://${root}.insteadOf`, + GIT_CONFIG_VALUE_0: repository, + }; + const build = async () => + ( + await execFile( + "nix", + [ + "build", + "--impure", + "--file", + path.join(generator, "share/checked-candidate.nix"), + "--argstr", + "repository", + repository, + "--argstr", + "commit", + commit, + "--argstr", + "kind", + "interface", + "--argstr", + "generator", + generator, + "--option", + "substitute", + "false", + "--no-link", + "--print-out-paths", + ], + { env, maxBuffer: 4 * 1024 * 1024 }, + ) + ).stdout.trim(); + const output = await build(); + const candidate = JSON.parse(await fs.readFile(path.join(output, "candidate.json"), "utf8")); + assert.equal(candidate.revision.source.commit, commit); + await fs.writeFile(path.join(root, "interface.qx"), "broken draft"); + assert.equal(await build(), output); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(output, "checks.json"), "utf8")), []); + }, +); diff --git a/test/pin-upgrades.test.ts b/test/pin-upgrades.test.ts index d715bd8..f6e30ef 100644 --- a/test/pin-upgrades.test.ts +++ b/test/pin-upgrades.test.ts @@ -3,100 +3,179 @@ import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import {promisify} from "node:util"; -import {execFile as callback} from "node:child_process"; -import {planPinUpgrades, applyPinUpgrades, type UpgradeEffects} from "../src/capability-language/pin-upgrades.js"; +import { promisify } from "node:util"; +import { execFile as callback } from "node:child_process"; +import { planPinUpgrades, applyPinUpgrades, type UpgradeEffects } from "../src/capability-language/pin-upgrades.js"; const execFile = promisify(callback); -test("real jj snapshots and immutable Git publication propagate a changed interface into the root", {skip: !process.env.QX_CHECK_GENERATOR}, async (context) => { - const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-real-upgrade-")); - context.after(() => fs.rm(workbench, {recursive: true, force: true})); - // Exercise the actual effects with local bare remotes, without external writes. - const environment = { - GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `url.file://${workbench}/remotes/.insteadOf`, - GIT_CONFIG_VALUE_0: "https://upgrade.test/", QUIXOS_JJ_NO_CHECKPOINT: "1", - QUIXOS_CHECK_GENERATOR: process.env.QX_CHECK_GENERATOR!, - }; - const previous = Object.fromEntries(Object.keys(environment).map(key => [key, process.env[key]])); - Object.assign(process.env, environment); - context.after(() => {for (const [key, value] of Object.entries(previous)) if (value === undefined) delete process.env[key]; else process.env[key] = value;}); - const run = async (cwd: string, command: string, args: string[]) => (await execFile(command, args, {cwd})).stdout.trim(); - await fs.mkdir(path.join(workbench, "remotes")); - const nodes = []; - for (const [kind, directory, remote] of [["interface", "resources/Named", "named.git"], ["workspace", "root", "workspace.git"]] as const) { - const root = path.join(workbench, directory); - await fs.mkdir(root, {recursive: true}); - await run(workbench, "git", ["init", "--bare", path.join(workbench, "remotes", remote)]); - await run(root, "jj", ["git", "init", "--colocate"]); - await run(root, "git", ["remote", "add", "origin", `https://upgrade.test/${remote}`]); - await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n"); - const child = nodes[0]; - await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://upgrade.test/quixos.git"; commit "${"a".repeat(40)}"; } ${child ? `interface Named source { repository "${child.source.repository}"; commit "${child.source.commit}"; }` : ""} }`); - await fs.writeFile(path.join(root, `${kind}.qx`), kind === "interface" ? 'interface Named id "interface:named" revision "interface:named@1" {}' : `workspace W id "workspace:w" revision "workspace:w@1" commit "${"a".repeat(40)}" { import interface Named; atom A id "atom:a"; }`); - await run(root, "jj", ["describe", "-m", "Initial source"]); - const commit = await run(root, "jj", ["log", "--no-graph", "-r", "@", "-T", "commit_id"]); - await run(root, "git", ["push", "origin", `${commit}:refs/tags/quixos-reachability/${commit}`]); - nodes.push({kind, directory, source: {repository: `https://upgrade.test/${remote}`, commit}}); - } - await fs.mkdir(path.join(workbench, ".quixos")); - await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [nodes[0]]})); - await fs.appendFile(path.join(workbench, nodes[0].directory, "interface.qx"), "\n// incremental author edit\n"); - const plan = await planPinUpgrades(workbench, {nodes, bootstrap: true}); - const result = await applyPinUpgrades(plan); - assert.equal(result.activated, false); - const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8")); - const commit = graph.resources[0].source.commit; - assert.notEqual(commit, nodes[0].source.commit); - assert.match(await fs.readFile(path.join(workbench, "root/quixos.lock"), "utf8"), new RegExp(commit)); - assert.match(await run(workbench, "git", ["--git-dir", path.join(workbench, "remotes/named.git"), "show-ref"]), new RegExp(commit)); -}); +test( + "real jj snapshots and immutable Git publication propagate a changed interface into the root", + { skip: !process.env.QX_CHECK_GENERATOR }, + async (context) => { + const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-real-upgrade-")); + context.after(() => fs.rm(workbench, { recursive: true, force: true })); + // Exercise the actual effects with local bare remotes, without external writes. + const environment = { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `url.file://${workbench}/remotes/.insteadOf`, + GIT_CONFIG_VALUE_0: "https://upgrade.test/", + QUIXOS_JJ_NO_CHECKPOINT: "1", + QUIXOS_CHECK_GENERATOR: process.env.QX_CHECK_GENERATOR!, + }; + const previous = Object.fromEntries(Object.keys(environment).map((key) => [key, process.env[key]])); + Object.assign(process.env, environment); + context.after(() => { + for (const [key, value] of Object.entries(previous)) + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }); + const run = async (cwd: string, command: string, args: string[]) => + (await execFile(command, args, { cwd })).stdout.trim(); + await fs.mkdir(path.join(workbench, "remotes")); + const nodes = []; + for (const [kind, directory, remote] of [ + ["interface", "resources/Named", "named.git"], + ["workspace", "root", "workspace.git"], + ] as const) { + const root = path.join(workbench, directory); + await fs.mkdir(root, { recursive: true }); + await run(workbench, "git", ["init", "--bare", path.join(workbench, "remotes", remote)]); + await run(root, "jj", ["git", "init", "--colocate"]); + await run(root, "git", ["remote", "add", "origin", `https://upgrade.test/${remote}`]); + await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n"); + const child = nodes[0]; + await fs.writeFile( + path.join(root, "quixos.lock"), + `quixos-lock version 1 { quixos source { repository "https://upgrade.test/quixos.git"; commit "${"a".repeat(40)}"; } ${child ? `interface Named source { repository "${child.source.repository}"; commit "${child.source.commit}"; }` : ""} }`, + ); + await fs.writeFile( + path.join(root, `${kind}.qx`), + kind === "interface" + ? 'interface Named id "interface:named" revision "interface:named@1" {}' + : `workspace W id "workspace:w" revision "workspace:w@1" commit "${"a".repeat(40)}" { import interface Named; atom A id "atom:a"; }`, + ); + await run(root, "jj", ["describe", "-m", "Initial source"]); + const commit = await run(root, "jj", ["log", "--no-graph", "-r", "@", "-T", "commit_id"]); + await run(root, "git", ["push", "origin", `${commit}:refs/tags/quixos-reachability/${commit}`]); + nodes.push({ kind, directory, source: { repository: `https://upgrade.test/${remote}`, commit } }); + } + await fs.mkdir(path.join(workbench, ".quixos")); + await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({ resources: [nodes[0]] })); + await fs.appendFile(path.join(workbench, nodes[0].directory, "interface.qx"), "\n// incremental author edit\n"); + const plan = await planPinUpgrades(workbench, { nodes, bootstrap: true }); + const result = await applyPinUpgrades(plan); + assert.equal(result.activated, false); + const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8")); + const commit = graph.resources[0].source.commit; + assert.notEqual(commit, nodes[0].source.commit); + assert.match(await fs.readFile(path.join(workbench, "root/quixos.lock"), "utf8"), new RegExp(commit)); + assert.match( + await run(workbench, "git", ["--git-dir", path.join(workbench, "remotes/named.git"), "show-ref"]), + new RegExp(commit), + ); + }, +); test("pin upgrades publish children before parent locks and resume without republishing completed nodes", async (context) => { const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-upgrade-test-")); - context.after(() => fs.rm(workbench, {recursive: true, force: true})); - const from = "a".repeat(40), to = "b".repeat(40), framework = "c".repeat(40); - const sources = [{kind: "workspace" as const, directory: "root", source: {repository: "https://example.test/workspace.git", commit: from}}, - {kind: "interface" as const, directory: "resources/Named", source: {repository: "https://example.test/named.git", commit: from}}]; + context.after(() => fs.rm(workbench, { recursive: true, force: true })); + const from = "a".repeat(40), + to = "b".repeat(40), + framework = "c".repeat(40); + const sources = [ + { + kind: "workspace" as const, + directory: "root", + source: { repository: "https://example.test/workspace.git", commit: from }, + }, + { + kind: "interface" as const, + directory: "resources/Named", + source: { repository: "https://example.test/named.git", commit: from }, + }, + ]; const lock = `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${framework}"; }`; for (const node of sources) { const directory = path.join(workbench, node.directory); - await fs.mkdir(directory, {recursive: true}); + await fs.mkdir(directory, { recursive: true }); await execFile("git", ["-C", directory, "init"]); await execFile("git", ["-C", directory, "remote", "add", "origin", node.source.repository]); await fs.writeFile(path.join(directory, ".gitignore"), ".quixos/\n"); - await fs.writeFile(path.join(directory, "quixos.lock"), lock + (node.kind === "workspace" ? ` interface Named source { repository "${sources[1].source.repository}"; commit "${from}"; }` : "") + " }"); + await fs.writeFile( + path.join(directory, "quixos.lock"), + lock + + (node.kind === "workspace" + ? ` interface Named source { repository "${sources[1].source.repository}"; commit "${from}"; }` + : "") + + " }", + ); } - await fs.writeFile(path.join(workbench, "resources/Named/interface.qx"), 'interface Named id "interface:named" revision "interface:named@1" { value name id "member:name" : string { get id "op:get"; } }'); - await fs.writeFile(path.join(workbench, "root/workspace.qx"), `workspace W id "workspace:w" revision "workspace:w@1" commit "${from}" { import interface Named; atom A id "atom:a"; }`); + await fs.writeFile( + path.join(workbench, "resources/Named/interface.qx"), + 'interface Named id "interface:named" revision "interface:named@1" { value name id "member:name" : string { get id "op:get"; } }', + ); + await fs.writeFile( + path.join(workbench, "root/workspace.qx"), + `workspace W id "workspace:w" revision "workspace:w@1" commit "${from}" { import interface Named; atom A id "atom:a"; }`, + ); const snapshotMap = path.join(workbench, "snapshots.json"); - await fs.writeFile(snapshotMap, JSON.stringify({resources: [{kind: "interface", repository: sources[1].source.repository, commit: to, directory: path.join(workbench, "resources/Named")}]})); + await fs.writeFile( + snapshotMap, + JSON.stringify({ + resources: [ + { + kind: "interface", + repository: sources[1].source.repository, + commit: to, + directory: path.join(workbench, "resources/Named"), + }, + ], + }), + ); const oldMap = process.env.QUIXOS_SNAPSHOT_MAP; process.env.QUIXOS_SNAPSHOT_MAP = snapshotMap; - context.after(() => {if (oldMap === undefined) delete process.env.QUIXOS_SNAPSHOT_MAP; else process.env.QUIXOS_SNAPSHOT_MAP = oldMap;}); - const plan = await planPinUpgrades(workbench, {nodes: sources}); - await fs.mkdir(path.join(workbench, ".quixos"), {recursive: true}); - await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [{...sources[1], source: {resolver: "git", ...sources[1].source}}]})); - assert.deepEqual(plan.nodes.map((node) => node.directory), ["resources/Named", "root"]); + context.after(() => { + if (oldMap === undefined) delete process.env.QUIXOS_SNAPSHOT_MAP; + else process.env.QUIXOS_SNAPSHOT_MAP = oldMap; + }); + const plan = await planPinUpgrades(workbench, { nodes: sources }); + await fs.mkdir(path.join(workbench, ".quixos"), { recursive: true }); + await fs.writeFile( + path.join(workbench, ".quixos/resource-graph.json"), + JSON.stringify({ resources: [{ ...sources[1], source: { resolver: "git", ...sources[1].source } }] }), + ); + assert.deepEqual( + plan.nodes.map((node) => node.directory), + ["resources/Named", "root"], + ); let fail = true; const published: string[] = []; const effects: UpgradeEffects = { - check: async (node) => {if (node.kind === "workspace" && fail) throw new Error("refactor required");}, + check: async (node) => { + if (node.kind === "workspace" && fail) throw new Error("refactor required"); + }, snapshot: async () => to, - publish: async (root) => {published.push(path.relative(workbench, root));}, + publish: async (root) => { + published.push(path.relative(workbench, root)); + }, }; await assert.rejects(() => applyPinUpgrades(plan, undefined, effects), /refactor required/); assert.deepEqual(published, ["resources/Named"]); assert.match(await fs.readFile(path.join(workbench, "root/quixos.lock"), "utf8"), new RegExp(to)); - const id = (await fs.readdir(path.join(workbench, ".quixos/upgrades"))).find((name) => name.endsWith(".json"))!.slice(0, -5); + const id = (await fs.readdir(path.join(workbench, ".quixos/upgrades"))) + .find((name) => name.endsWith(".json"))! + .slice(0, -5); fail = false; await fs.appendFile(path.join(workbench, "root/workspace.qx"), "\n// explicit refactor\n"); await assert.rejects(() => applyPinUpgrades(plan, id, effects), /accept-edits/); - const result = await applyPinUpgrades(plan, id, effects, {acceptEdits: true}); + const result = await applyPinUpgrades(plan, id, effects, { acceptEdits: true }); assert.equal(result.activated, false); assert.deepEqual(published, ["resources/Named", "root"]); const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8")); assert.equal(graph.resources.length, 1); assert.equal(graph.resources[0].source.commit, to); - const next = await planPinUpgrades(workbench, {nodes: [sources[0], {...sources[1], source: graph.resources[0].source}]}); + const next = await planPinUpgrades(workbench, { + nodes: [sources[0], { ...sources[1], source: graph.resources[0].source }], + }); assert.deepEqual(next.nodes.find((node) => node.kind === "workspace")!.dependencies, ["resources/Named"]); }); diff --git a/test/qx-source.test.ts b/test/qx-source.test.ts index 1a974ef..9b9d2db 100644 --- a/test/qx-source.test.ts +++ b/test/qx-source.test.ts @@ -3,15 +3,30 @@ import test from "node:test"; import { mkdtemp, writeFile, readFile, rm, symlink } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { parseQx, formatQx, lintQx, applySourceEdits, addWorkspaceImport, walkSyntax, - resolveQxSources, readQxSource, compileCapabilitySource, scaffoldAtom, compileWorkspaceRepository } from "../src/capability-language/index.js"; +import { + parseQx, + formatQx, + lintQx, + applySourceEdits, + addWorkspaceImport, + walkSyntax, + resolveQxSources, + readQxSource, + compileCapabilitySource, + scaffoldAtom, + compileWorkspaceRepository, +} from "../src/capability-language/index.js"; const workspace = `workspace Test id "w" revision "w@1" commit "${"1".repeat(40)}" {\n// keep 🐈 comment\n}\n`; test("lint reports invalid and redundant imports without resolving repositories", () => { const source = workspace.replace("}\n", 'import "a.qx"; import "a.qx"; import "../bad.qx";\n}\n'); - assert.deepEqual(lintQx(source).map((entry) => [entry.code, entry.severity]), [ - ["duplicate-source-import", "warning"], ["invalid-source-import", "error"], - ]); + assert.deepEqual( + lintQx(source).map((entry) => [entry.code, entry.severity]), + [ + ["duplicate-source-import", "warning"], + ["invalid-source-import", "error"], + ], + ); }); test("lossless tokens, UTF-16 ranges, and safe source edits", () => { const text = workspace.replace("}\n", 'atom Cat id "🐈";\n}\n'); @@ -20,8 +35,18 @@ test("lossless tokens, UTF-16 ranges, and safe source edits", () => { assert.equal(parsed.tokens.map((token) => token.text).join(""), text); const atom = [...walkSyntax(parsed.root)].find((node) => node.kind === "atomDecl")!; assert.equal(text.slice(atom.start, atom.end), 'atom Cat id "🐈";'); - assert.equal(applySourceEdits(text, [{ ...atom, text: 'atom Dog id "dog";' }]), text.replace('atom Cat id "🐈";', 'atom Dog id "dog";')); - assert.throws(() => applySourceEdits(text, [{ start: 0, end: 4, text: "" }, { start: 3, end: 7, text: "" }]), /overlapping/); + assert.equal( + applySourceEdits(text, [{ ...atom, text: 'atom Dog id "dog";' }]), + text.replace('atom Cat id "🐈";', 'atom Dog id "dog";'), + ); + assert.throws( + () => + applySourceEdits(text, [ + { start: 0, end: 4, text: "" }, + { start: 3, end: 7, text: "" }, + ]), + /overlapping/, + ); assert.throws(() => applySourceEdits(text, [{ start: -1, end: 0, text: "" }]), /Invalid/); }); test("formatting preserves comments and strings, is idempotent, and rejects invalid source", () => { @@ -36,10 +61,12 @@ test("imports preserve root text, deduplicate diamonds, reject cycles, and compi const root = addWorkspaceImport(addWorkspaceImport(workspace, "a.qx"), "b.qx"); assert.equal(addWorkspaceImport(root, "a.qx"), root); assert.match(root, /keep 🐈 comment/); - const files: Record = { "workspace.qx": root, + const files: Record = { + "workspace.qx": root, "a.qx": 'fragment { import "shared.qx"; atom A id "a"; }', "b.qx": 'fragment { import "shared.qx"; atom B id "b"; }', - "shared.qx": 'fragment { atom Shared id "shared"; }' }; + "shared.qx": 'fragment { atom Shared id "shared"; }', + }; const result = await resolveQxSources(async (name) => files[name]!); assert.equal(result.sourceFiles.length, 4); const compiled = compileCapabilitySource(result.source); @@ -49,15 +76,23 @@ test("imports preserve root text, deduplicate diamonds, reject cycles, and compi assert.equal(unresolved.ok, false); assert.match(JSON.stringify(unresolved.diagnostics), /unresolved-source-import/); files["shared.qx"] = 'fragment { import "a.qx"; }'; - await assert.rejects(resolveQxSources(async (name) => files[name]!), /cycle/); + await assert.rejects( + resolveQxSources(async (name) => files[name]!), + /cycle/, + ); assert.throws(() => addWorkspaceImport(workspace, "../x.qx"), /Invalid/); }); test("repository scaffolding validates before writing and refuses duplicates and symlinks", async (t) => { const root = await mkdtemp(path.join(os.tmpdir(), "qx-scaffold-")); t.after(() => rm(root, { recursive: true, force: true })); await writeFile(path.join(root, "workspace.qx"), workspace); - await writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/q.git"; commit "${"1".repeat(40)}"; } }`); - const resolveResource = async (): Promise => { throw new Error("unexpected resource"); }; + await writeFile( + path.join(root, "quixos.lock"), + `quixos-lock version 1 { quixos source { repository "https://example.test/q.git"; commit "${"1".repeat(40)}"; } }`, + ); + const resolveResource = async (): Promise => { + throw new Error("unexpected resource"); + }; const options = { root, name: "Cat", id: "cat", write: false, resolveResource }; await scaffoldAtom(options); assert.equal(await readFile(path.join(root, "workspace.qx"), "utf8"), workspace); @@ -72,8 +107,10 @@ test("repository scaffolding validates before writing and refuses duplicates and await assert.rejects(readQxSource(root, "link.qx"), /ordinary files/); }); test("lowering diagnostics map to the imported file", async () => { - const files: Record = { "workspace.qx": addWorkspaceImport(workspace, "bad.qx"), - "bad.qx": 'fragment {\n conform Missing as Nope {}\n}' }; + const files: Record = { + "workspace.qx": addWorkspaceImport(workspace, "bad.qx"), + "bad.qx": "fragment {\n conform Missing as Nope {}\n}", + }; const sources = await resolveQxSources(async (name) => files[name]!); const compiled = compileCapabilitySource(sources.source); assert.equal(compiled.ok, false); diff --git a/test/resource-lock.test.ts b/test/resource-lock.test.ts index 79aed4a..31bc3c2 100644 --- a/test/resource-lock.test.ts +++ b/test/resource-lock.test.ts @@ -141,25 +141,34 @@ test("resolves root-relative lock fragments into one deterministic resource clos } }`; const sources = new Map([ - ["locks/web-studio.lock", `quixos-lock fragment version 1 { + [ + "locks/web-studio.lock", + `quixos-lock fragment version 1 { import "locks/shared.lock"; interface Placeable source { repository "https://repos.example/alice/interface-placeable.git"; commit "${namedCommit}"; } - }`], - ["locks/shared.lock", `quixos-lock fragment version 1 { + }`, + ], + [ + "locks/shared.lock", + `quixos-lock fragment version 1 { interface Named source { repository "https://repos.example/alice/interface-named.git"; commit "${namedCommit}"; } - }`], - ["locks/domain.lock", `quixos-lock fragment version 1 { + }`, + ], + [ + "locks/domain.lock", + `quixos-lock fragment version 1 { package TodoRuntime source { repository "https://repos.example/alice/package-todo.git"; commit "${packageCommit}"; } - }`], + }`, + ], ]); const result = await resolveQuixosLock(root, async (relativePath) => { const source = sources.get(relativePath); @@ -174,12 +183,15 @@ test("resolves root-relative lock fragments into one deterministic resource clos "locks/shared.lock", "locks/domain.lock", ]); - assert.deepEqual(result.lock.resources.map(({ kind, binding }) => ({ kind, binding })), [ - { kind: "package", binding: "RootRuntime" }, - { kind: "interface", binding: "Placeable" }, - { kind: "interface", binding: "Named" }, - { kind: "package", binding: "TodoRuntime" }, - ]); + assert.deepEqual( + result.lock.resources.map(({ kind, binding }) => ({ kind, binding })), + [ + { kind: "package", binding: "RootRuntime" }, + { kind: "interface", binding: "Placeable" }, + { kind: "interface", binding: "Named" }, + { kind: "package", binding: "TodoRuntime" }, + ], + ); }); test("lock fragments format canonically and cannot redeclare the Quixos source", () => { @@ -187,35 +199,43 @@ test("lock fragments format canonically and cannot redeclare the Quixos source", kind: "fragment" as const, formatVersion: 1 as const, imports: ["locks/shared.lock"], - resources: [{ - kind: "interface" as const, - binding: "Named", - source: { - resolver: "git" as const, - repository: "https://repos.example/alice/interface-named.git", - commit: namedCommit, + resources: [ + { + kind: "interface" as const, + binding: "Named", + source: { + resolver: "git" as const, + repository: "https://repos.example/alice/interface-named.git", + commit: namedCommit, + }, }, - }], + ], }; assert.deepEqual(parseQuixosLockDocument(formatQuixosLockDocument(document)), { ok: true, document, diagnostics: [], }); - const invalid = parseQuixosLockDocument(`quixos-lock fragment version 1 { + const invalid = parseQuixosLockDocument( + `quixos-lock fragment version 1 { quixos source { repository "https://gitea.example/quixos/quixos.git"; commit "${quixosCommit}"; } - }`, "bad.lock"); + }`, + "bad.lock", + ); assert.equal(invalid.ok, false); if (!invalid.ok) assert.equal(invalid.diagnostics[0]?.code, "fragment-has-quixos-source"); }); test("lock imports reject traversal, cycles, root documents, and cross-file binding collisions", async () => { - const invalidPath = parseQuixosLockDocument(`quixos-lock fragment version 1 { + const invalidPath = parseQuixosLockDocument( + `quixos-lock fragment version 1 { import "../outside.lock"; - }`, "bad-path.lock"); + }`, + "bad-path.lock", + ); assert.equal(invalidPath.ok, false); if (!invalidPath.ok) assert.equal(invalidPath.diagnostics[0]?.code, "invalid-import-path"); @@ -232,24 +252,26 @@ test("lock imports reject traversal, cycles, root documents, and cross-file bind } }`; const sources = new Map([ - ["a.lock", `quixos-lock fragment version 1 { + [ + "a.lock", + `quixos-lock fragment version 1 { import "b.lock"; interface Named source { repository "https://repos.example/alice/interface-named-copy.git"; commit "${namedCommit}"; } - }`], + }`, + ], ["b.lock", `quixos-lock fragment version 1 { import "a.lock"; }`], ["root-again.lock", root], ]); const result = await resolveQuixosLock(root, async (relativePath) => sources.get(relativePath) ?? ""); assert.equal(result.ok, false); if (!result.ok) { - assert.deepEqual(new Set(result.diagnostics.map(({ code }) => code)), new Set([ - "duplicate-resource-binding", - "import-cycle", - "imported-root-lock", - ])); + assert.deepEqual( + new Set(result.diagnostics.map(({ code }) => code)), + new Set(["duplicate-resource-binding", "import-cycle", "imported-root-lock"]), + ); } }); @@ -260,13 +282,16 @@ test("file loading rejects a symlink in any import path component", async (conte await mkdir(outside); await writeFile(path.join(outside, "fragment.lock"), "quixos-lock fragment version 1 {}\n"); await symlink(outside, path.join(directory, "linked"), "dir"); - await writeFile(path.join(directory, "quixos.lock"), `quixos-lock version 1 { + await writeFile( + path.join(directory, "quixos.lock"), + `quixos-lock version 1 { quixos source { repository "https://gitea.example/quixos/quixos.git"; commit "${quixosCommit}"; } import "linked/fragment.lock"; - }`); + }`, + ); const result = await loadQuixosLock(path.join(directory, "quixos.lock")); assert.equal(result.ok, false); diff --git a/test/scaffold-recipes.test.ts b/test/scaffold-recipes.test.ts index befd92f..f933301 100644 --- a/test/scaffold-recipes.test.ts +++ b/test/scaffold-recipes.test.ts @@ -3,46 +3,85 @@ import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import {execFile as callback} from "node:child_process"; -import {promisify} from "node:util"; -import {scaffoldRecipe} from "../src/capability-language/scaffold-recipes.js"; -import {planStructure, applyStructure} from "../src/capability-language/structural-plan.js"; -import {contentDigest} from "../src/capability-model/evolution.js"; -import {sealMigrations} from "../src/capability-language/migration-seal.js"; -import {reactPlatformTypes} from "../src/bindings/react-platform.js"; +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { scaffoldRecipe } from "../src/capability-language/scaffold-recipes.js"; +import { planStructure, applyStructure } from "../src/capability-language/structural-plan.js"; +import { contentDigest } from "../src/capability-model/evolution.js"; +import { sealMigrations } from "../src/capability-language/migration-seal.js"; +import { reactPlatformTypes } from "../src/bindings/react-platform.js"; const execFile = promisify(callback); test("React preset applies its browser build script and shared-platform imports", async (context) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-react-recipe-")); - context.after(() => fs.rm(root, {recursive: true, force: true})); + context.after(() => fs.rm(root, { recursive: true, force: true })); await execFile("git", ["-C", root, "init"]); - const source = {repository: "https://example.test/react.git", commit: "a".repeat(40)}; - const request = await scaffoldRecipe(root, "package", {source, name: "React", id: "package:react", revision: "package:react@1", template: "typescript-react", tools: {quixos: source, protocol: source, helpers: source, sdk: source}}); + const source = { repository: "https://example.test/react.git", commit: "a".repeat(40) }; + const request = await scaffoldRecipe(root, "package", { + source, + name: "React", + id: "package:react", + revision: "package:react@1", + template: "typescript-react", + tools: { quixos: source, protocol: source, helpers: source, sdk: source }, + }); await applyStructure(await planStructure(root, request)); assert.match(await fs.readFile(path.join(root, "src/impl/sourceGet.ts"), "utf8"), /component.js\?browser-source/); assert.match(await fs.readFile(path.join(root, "flake.nix"), "utf8"), /browserSources = true/); assert.equal(await fs.readFile(path.join(root, "src/gen/web-studio-react-runtime.d.ts"), "utf8"), reactPlatformTypes); assert.match(await fs.readFile(path.join(root, "src/browser-assets.d.ts"), "utf8"), /declare module "\*\.css"/); - assert.equal(JSON.parse(await fs.readFile(path.join(root, "quixos.check.json"), "utf8")).options.messages["org.quixos.web-studio.ReactProps"].export, "opaqueReactPropsBinding"); + assert.equal( + JSON.parse(await fs.readFile(path.join(root, "quixos.check.json"), "utf8")).options.messages[ + "org.quixos.web-studio.ReactProps" + ].export, + "opaqueReactPropsBinding", + ); await assert.rejects(fs.access(path.join(root, "quixos.scaffold.json"))); - assert.equal(JSON.parse(await fs.readFile(path.join(root, "tsconfig.json"), "utf8")).compilerOptions.jsx, "react-jsx"); + assert.equal( + JSON.parse(await fs.readFile(path.join(root, "tsconfig.json"), "utf8")).compilerOptions.jsx, + "react-jsx", + ); }); test("imperative scaffolds preserve authored wiring; migration sealing is explicit and separate", async (context) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-recipes-")); - context.after(() => fs.rm(root, {recursive: true, force: true})); + context.after(() => fs.rm(root, { recursive: true, force: true })); await execFile("git", ["-C", root, "init"]); await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n"); - const source = {repository: "https://example.test/chess.git", commit: "a".repeat(40)}; - const base = {source, directory: "packages/Chess"}; - const apply = async (command: Parameters[1], input: Parameters[2]) => applyStructure(await planStructure(root, await scaffoldRecipe(root, command, input))); - await apply("package", {...base, name: "Chess", id: "package:chess", revision: "package:chess@1", tools: {quixos: source, protocol: source, helpers: source, sdk: source}}); - await apply("function", {...base, name: "play", id: "export:play"}); + const source = { repository: "https://example.test/chess.git", commit: "a".repeat(40) }; + const base = { source, directory: "packages/Chess" }; + const apply = async (command: Parameters[1], input: Parameters[2]) => + applyStructure(await planStructure(root, await scaffoldRecipe(root, command, input))); + await apply("package", { + ...base, + name: "Chess", + id: "package:chess", + revision: "package:chess@1", + tools: { quixos: source, protocol: source, helpers: source, sdk: source }, + }); + await apply("function", { ...base, name: "play", id: "export:play" }); const filename = path.join(root, base.directory, "src/impl/play.ts"); - const edited = 'import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["play"] = async () => null;\n'; + const edited = + 'import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["play"] = async () => null;\n'; await fs.writeFile(filename, edited); - const old = {version: 1}, next = {version: 2}, from = contentDigest(old), to = contentDigest(next); - await apply("migration", {...base, name: "upgrade", id: "export:upgrade", migration: {id: "upgrade-v2", scopeId: "board", from, to, predecessors: [], ports: [], contracts: {[from]: old, [to]: next}}}); + const old = { version: 1 }, + next = { version: 2 }, + from = contentDigest(old), + to = contentDigest(next); + await apply("migration", { + ...base, + name: "upgrade", + id: "export:upgrade", + migration: { + id: "upgrade-v2", + scopeId: "board", + from, + to, + predecessors: [], + ports: [], + contracts: { [from]: old, [to]: next }, + }, + }); const migrationFile = path.join(root, base.directory, "src/migrations/upgrade.ts"); await fs.appendFile(migrationFile, "\n// authored migration change\n"); await sealMigrations(path.join(root, base.directory)); @@ -50,18 +89,38 @@ test("imperative scaffolds preserve authored wiring; migration sealing is explic const catalog = JSON.parse(await fs.readFile(path.join(root, base.directory, "quixos.migrations.json"), "utf8")); assert.equal(catalog.migrations[0].implementation.digest, contentDigest(await fs.readFile(migrationFile, "utf8"))); assert.match(await fs.readFile(path.join(root, base.directory, "src/migrate.ts"), "utf8"), /export:upgrade/); - await assert.rejects(() => apply("function", {...base, name: "play", id: "export:play"}), /unique/); + await assert.rejects(() => apply("function", { ...base, name: "play", id: "export:play" }), /unique/); const declarations = path.join(root, base.directory, "package.qx"); - await fs.writeFile(declarations, (await fs.readFile(declarations, "utf8")).replace(/}\s*$/, ' function authored id "export:authored" : unit -> unit;\n}\n')); + await fs.writeFile( + declarations, + (await fs.readFile(declarations, "utf8")).replace( + /}\s*$/, + ' function authored id "export:authored" : unit -> unit;\n}\n', + ), + ); const server = path.join(root, base.directory, "src/server.ts"); await fs.appendFile(server, "\n// authored comment must survive\n"); - await apply("function", {...base, name: "another", id: "export:another"}); + await apply("function", { ...base, name: "another", id: "export:another" }); assert.match(await fs.readFile(server, "utf8"), /authored comment must survive/); await assert.rejects(() => apply("refresh", base), /removed/); await assert.rejects(fs.access(path.join(root, base.directory, "src/impl/authored.ts"))); assert.equal(await fs.readFile(filename, "utf8"), edited); - await planStructure(root, {kind: "package", source, resourceRoot: base.directory, validation: "syntax", files: [{ - file: `${base.directory}/package.qx`, edits: [{operation: "replace", target: {kind: "packageResourceDecl", id: "package:chess"}, - source: 'package Other id "package:other" revision "package:other@1" {}'}], - }]}); + await planStructure(root, { + kind: "package", + source, + resourceRoot: base.directory, + validation: "syntax", + files: [ + { + file: `${base.directory}/package.qx`, + edits: [ + { + operation: "replace", + target: { kind: "packageResourceDecl", id: "package:chess" }, + source: 'package Other id "package:other" revision "package:other@1" {}', + }, + ], + }, + ], + }); }); diff --git a/test/structural-edits.test.ts b/test/structural-edits.test.ts index 4aef125..a8e582d 100644 --- a/test/structural-edits.test.ts +++ b/test/structural-edits.test.ts @@ -1,9 +1,16 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { editStructure, scaffoldResourceSource, instantiateWorkspaceIdentity } from "../src/capability-language/structural-edits.js"; +import { + editStructure, + scaffoldResourceSource, + instantiateWorkspaceIdentity, +} from "../src/capability-language/structural-edits.js"; test("template identity binding changes only the workspace header and is idempotent", () => { - const source = '// workspace fake id "do-not-touch"\nworkspace Todo id "workspace:todo" revision "workspace:todo@1" commit "' + "a".repeat(40) + '" { atom Task id "atom:task"; }'; + const source = + '// workspace fake id "do-not-touch"\nworkspace Todo id "workspace:todo" revision "workspace:todo@1" commit "' + + "a".repeat(40) + + '" { atom Task id "atom:task"; }'; const id = "00000000-0000-0000-0000-000000000123"; const bound = instantiateWorkspaceIdentity(source, id); assert.match(bound, /atom Task id "atom:task"/); @@ -16,41 +23,81 @@ test("template identity binding changes only the workspace header and is idempot test("package scaffolding and function edits preserve surrounding source and use exact selectors", () => { const source = `// 🧭 resource comment\n${scaffoldResourceSource("package", "Chess", "package:chess", "package:chess@1")}`; const functionSource = 'function evaluate id "export:evaluate" : string -> string;'; - const appended = editStructure(source, {operation: "append", parent: {kind: "packageResourceDecl", id: "package:chess"}, source: functionSource}); + const appended = editStructure(source, { + operation: "append", + parent: { kind: "packageResourceDecl", id: "package:chess" }, + source: functionSource, + }); assert.ok(appended.startsWith("// 🧭 resource comment\n")); assert.ok(appended.includes(functionSource)); - const replaced = editStructure(appended, {operation: "replace", target: {kind: "packageFunctionExport", id: "export:evaluate"}, source: 'function evaluate id "export:evaluate" : unit -> string;'}); + const replaced = editStructure(appended, { + operation: "replace", + target: { kind: "packageFunctionExport", id: "export:evaluate" }, + source: 'function evaluate id "export:evaluate" : unit -> string;', + }); assert.ok(replaced.includes(": unit -> string;")); - assert.ok(!editStructure(replaced, {operation: "remove", target: {kind: "packageFunctionExport", id: "export:evaluate"}}).includes("evaluate")); - assert.throws(() => editStructure(source, {operation: "remove", target: {kind: "packageResourceDecl", id: "package:chess@1"}}), /exactly once/); - assert.throws(() => editStructure(source, {operation: "append", parent: {kind: "packageResourceDecl"}, source: "not valid QX"}), /Invalid structural/); + assert.ok( + !editStructure(replaced, { + operation: "remove", + target: { kind: "packageFunctionExport", id: "export:evaluate" }, + }).includes("evaluate"), + ); + assert.throws( + () => + editStructure(source, { operation: "remove", target: { kind: "packageResourceDecl", id: "package:chess@1" } }), + /exactly once/, + ); + assert.throws( + () => + editStructure(source, { operation: "append", parent: { kind: "packageResourceDecl" }, source: "not valid QX" }), + /Invalid structural/, + ); const prefixed = `import interface Board;\nexternal atom Game id "atom:game";\n${source}`; - const replacedPackage = editStructure(prefixed, {operation: "replace", target: {kind: "packageResourceDecl", id: "package:chess"}, source: scaffoldResourceSource("package", "Chess", "package:chess", "package:chess@2")}); + const replacedPackage = editStructure(prefixed, { + operation: "replace", + target: { kind: "packageResourceDecl", id: "package:chess" }, + source: scaffoldResourceSource("package", "Chess", "package:chess", "package:chess@2"), + }); assert.ok(replacedPackage.startsWith('import interface Board;\nexternal atom Game id "atom:game";')); }); test("dependency scaffolding validates exact sources and preserves unrelated lock comments", () => { const source = `// 🧭 lock\nquixos-lock version 1 {\n quixos source { repository "https://example.test/quixos.git"; commit "${"a".repeat(40)}"; }\n // retained comment\n}\n`; - const dependency = {operation: "dependency" as const, kind: "package" as const, name: "Chess", source: {repository: "https://example.test/chess.git", commit: "b".repeat(40)}}; + const dependency = { + operation: "dependency" as const, + kind: "package" as const, + name: "Chess", + source: { repository: "https://example.test/chess.git", commit: "b".repeat(40) }, + }; const appended = editStructure(source, dependency); assert.ok(appended.includes("// retained comment")); assert.ok(appended.startsWith("// 🧭 lock\n")); - assert.throws(() => editStructure(source, {...dependency, source: {...dependency.source, commit: "main"}}), /Invalid dependency/); - const changed = editStructure(appended, {...dependency, source: {...dependency.source, commit: "c".repeat(40)}}); + assert.throws( + () => editStructure(source, { ...dependency, source: { ...dependency.source, commit: "main" } }), + /Invalid dependency/, + ); + const changed = editStructure(appended, { ...dependency, source: { ...dependency.source, commit: "c".repeat(40) } }); assert.ok(!changed.includes("b".repeat(40))); - assert.ok(!editStructure(changed, {...dependency, source: null}).includes("package Chess")); + assert.ok(!editStructure(changed, { ...dependency, source: null }).includes("package Chess")); }); test("conformance enrollment, major edits, imports, and private attachment removal preserve valid structure", () => { const source = `fragment { conform Game as Playable { private state Board id "slot:board" on Game : string policy optimistic-register; } }`; - const selector = {kind: "conformanceDecl", names: ["Game", "Playable"]}; - const enrolled = editStructure(source, {operation: "conformance-id", target: selector, id: "conformance:playable"}); + const selector = { kind: "conformanceDecl", names: ["Game", "Playable"] }; + const enrolled = editStructure(source, { operation: "conformance-id", target: selector, id: "conformance:playable" }); assert.ok(enrolled.includes('as Playable id "conformance:playable"')); - const major = editStructure(enrolled, {operation: "semantic-major", target: {kind: "conformanceDecl", id: "conformance:playable"}, major: 2}); + const major = editStructure(enrolled, { + operation: "semantic-major", + target: { kind: "conformanceDecl", id: "conformance:playable" }, + major: 2, + }); assert.ok(major.includes("semantic-major 2")); - assert.throws(() => editStructure(major, {operation: "conformance-id", target: selector, id: "different"}), /Cannot change/); - const removed = editStructure(major, {operation: "remove", target: {kind: "stateDecl", id: "slot:board"}}); + assert.throws( + () => editStructure(major, { operation: "conformance-id", target: selector, id: "different" }), + /Cannot change/, + ); + const removed = editStructure(major, { operation: "remove", target: { kind: "stateDecl", id: "slot:board" } }); assert.ok(!removed.includes("private")); - const imported = editStructure(removed, {operation: "import", kind: "interface", name: "Playable"}); - assert.equal(editStructure(imported, {operation: "import", kind: "interface", name: "Playable"}), imported); + const imported = editStructure(removed, { operation: "import", kind: "interface", name: "Playable" }); + assert.equal(editStructure(imported, { operation: "import", kind: "interface", name: "Playable" }), imported); }); diff --git a/test/structural-plan.test.ts b/test/structural-plan.test.ts index abfec34..d57c6a5 100644 --- a/test/structural-plan.test.ts +++ b/test/structural-plan.test.ts @@ -5,18 +5,34 @@ import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { execFile as callback } from "node:child_process"; -import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "../src/capability-language/structural-plan.js"; +import { + planStructure, + applyStructure, + resumeStructure, + type StructuralRequest, +} from "../src/capability-language/structural-plan.js"; const execFile = promisify(callback); test("structural plans validate the graph, journal originals, and reject stale edits", async (context) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-structural-test-")); - context.after(() => fs.rm(root, {recursive: true, force: true})); + context.after(() => fs.rm(root, { recursive: true, force: true })); await execFile("git", ["-C", root, "init"]); await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n"); - await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${"a".repeat(40)}"; } }`); + await fs.writeFile( + path.join(root, "quixos.lock"), + `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${"a".repeat(40)}"; } }`, + ); const before = `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"b".repeat(40)}" { atom Subject id "atom:subject"; }`; await fs.writeFile(path.join(root, "workspace.qx"), before); - const request: StructuralRequest = {kind: "workspace", files: [{file: "workspace.qx", edits: [{operation: "append", parent: {kind: "workspaceDecl"}, source: 'atom Game id "atom:game";'}]}]}; + const request: StructuralRequest = { + kind: "workspace", + files: [ + { + file: "workspace.qx", + edits: [{ operation: "append", parent: { kind: "workspaceDecl" }, source: 'atom Game id "atom:game";' }], + }, + ], + }; const plan = await planStructure(root, request); assert.equal(await fs.readFile(path.join(root, "workspace.qx"), "utf8"), before); await fs.writeFile(path.join(root, "workspace.qx"), `${before}\n// newer edit`); @@ -35,12 +51,14 @@ test("structural plans validate the graph, journal originals, and reject stale e assert.equal((await resumeStructure(root, applied.id)).phase, "complete"); await assert.rejects(() => planStructure(root, request), /Duplicate|duplicate/); // Cross-repository edits may temporarily refer to an unfinished provider. - const provisional: StructuralRequest = {kind: "workspace", validation: "syntax", files: [{file: "workspace.qx", edits: [ - {operation: "import", kind: "interface", name: "NotImplementedYet"}, - ]}]}; + const provisional: StructuralRequest = { + kind: "workspace", + validation: "syntax", + files: [{ file: "workspace.qx", edits: [{ operation: "import", kind: "interface", name: "NotImplementedYet" }] }], + }; const draft = await planStructure(root, provisional); assert.equal(draft.validation, "syntax"); await applyStructure(draft); assert.match(await fs.readFile(path.join(root, "workspace.qx"), "utf8"), /import interface NotImplementedYet/); - await assert.rejects(() => planStructure(root, {...provisional, validation: "resource-graph"})); + await assert.rejects(() => planStructure(root, { ...provisional, validation: "resource-graph" })); });