Publish quixos-protocol from 1de28b236de076d239752b77553f833dfbdb5b43

This commit is contained in:
Quixos Subtree Publisher
2026-09-16 23:49:09 +00:00
90 changed files with 11982 additions and 5434 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"version": 1, "version": 1,
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos", "sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
"sourceCommit": "8484d99fcf78a49341a17597bc62211e5f1807dd", "sourceCommit": "1de28b236de076d239752b77553f833dfbdb5b43",
"sourcePath": "quixos-protocol", "sourcePath": "quixos-protocol",
"exportName": "quixos-protocol", "exportName": "quixos-protocol",
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" "mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
+3 -3
View File
@@ -1,8 +1,8 @@
# Quixos protocol and capability compiler # Quixos protocol and capability compiler
This package owns the shared protobuf APIs for Camino, `quixos-orch`, package Shared protobuf APIs for Camino, orch, package runtimes/descriptors and values,
runtimes, package descriptors, and generic runtime values. It also owns the v1 plus the capability language/compiler. Commands below are for backend development;
capability authoring language and semantic compiler. workspace authors use `qx-workspace check` for all verification.
## Capability language ## Capability language
+122 -105
View File
@@ -6,124 +6,140 @@
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
}; };
outputs = { self, nixpkgs, flake-utils, ... }: outputs =
flake-utils.lib.eachDefaultSystem (system: {
self,
nixpkgs,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let let
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
nodejs = pkgs.nodejs_24; nodejs = pkgs.nodejs_24;
quixos-protocol = (pkgs.callPackage ./yarn-project.nix { inherit nodejs; }) { quixos-protocol = (pkgs.callPackage ./yarn-project.nix { inherit nodejs; }) {
src = pkgs.lib.cleanSource ./.; src = pkgs.lib.cleanSource ./.;
overrideAttrs = old: { overrideAttrs = old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
pkgs.diffutils pkgs.diffutils
pkgs.esbuild pkgs.esbuild
pkgs.protobuf pkgs.protobuf
pkgs.git pkgs.git
pkgs.jujutsu pkgs.jujutsu
pkgs.gnutar pkgs.gnutar
pkgs.util-linux pkgs.util-linux
]; ];
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
mkdir -p "$TMPDIR/generated-before" mkdir -p "$TMPDIR/generated-before"
cp --recursive src/gen "$TMPDIR/generated-before/proto" cp --recursive src/gen "$TMPDIR/generated-before/proto"
cp --recursive src/capability-language/generated "$TMPDIR/generated-before/capability" cp --recursive src/capability-language/generated "$TMPDIR/generated-before/capability"
cp --recursive src/resource-lock/generated "$TMPDIR/generated-before/lock" 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}" 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 patchShebangs node_modules/.bin node_modules/@bufbuild/protoc-gen-es/bin
yarn build yarn build
esbuild dist/src/bindings/client.js --bundle --platform=node --target=node24 --format=esm --outfile=client-codegen.mjs 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/proto" src/gen
diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated
diff --recursive --unified "$TMPDIR/generated-before/lock" src/resource-lock/generated diff --recursive --unified "$TMPDIR/generated-before/lock" src/resource-lock/generated
esbuild dist/src/descriptor-check.js \ esbuild dist/src/descriptor-check.js \
--bundle --platform=node --target=node24 --format=esm \ --bundle --platform=node --target=node24 --format=esm \
--outfile=quixos-descriptor-check.mjs --outfile=quixos-descriptor-check.mjs
esbuild dist/src/capability-language/cli.js \ esbuild dist/src/capability-language/cli.js \
--bundle --platform=node --target=node24 --format=esm \ --bundle --platform=node --target=node24 --format=esm \
--outfile=quixos-capability-compile.mjs --outfile=quixos-capability-compile.mjs
esbuild dist/src/capability-language/workspace-cli.js \ esbuild dist/src/capability-language/workspace-cli.js \
--bundle --platform=node --target=node24 --format=esm \ --bundle --platform=node --target=node24 --format=esm \
--outfile=quixos-workspace-compile.mjs --outfile=quixos-workspace-compile.mjs
esbuild dist/src/capability-language/resource-cli.js \ esbuild dist/src/capability-language/resource-cli.js \
--bundle --platform=node --target=node24 --format=esm \ --bundle --platform=node --target=node24 --format=esm \
--outfile=quixos-resource-compile.mjs --outfile=quixos-resource-compile.mjs
esbuild dist/src/resource-lock/cli.js \ esbuild dist/src/resource-lock/cli.js \
--bundle --platform=node --target=node24 --format=esm \ --bundle --platform=node --target=node24 --format=esm \
--outfile=quixos-lock-check.mjs --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/cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-codegen-ts.mjs
esbuild dist/src/capability-language/tool-cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-qx.mjs esbuild dist/src/bindings/react-platform.js --bundle --platform=node --target=node24 --format=esm --outfile=react-platform.mjs
runHook postBuild esbuild dist/src/capability-language/tool-cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-qx.mjs
''; runHook postBuild
doCheck = true; '';
checkPhase = '' doCheck = true;
runHook preCheck checkPhase = ''
yarn test runHook preCheck
runHook postCheck yarn test
''; runHook postCheck
installPhase = '' '';
runHook preInstall installPhase = ''
mkdir -p "$out" runHook preInstall
install -Dm644 nix/checked-package.nix "$out/share/checked-package.nix" mkdir -p "$out"
substitute nix/checked-candidate.nix "$out/share/checked-candidate.nix" \ install -Dm644 nix/checked-package.nix "$out/share/checked-package.nix"
--replace-fail '@nixpkgs@' '${pkgs.path}' substitute nix/checked-candidate.nix "$out/share/checked-candidate.nix" \
cp --reflink=auto --recursive grammar "$out/grammar" --replace-fail '@nixpkgs@' '${pkgs.path}'
cp --reflink=auto --recursive proto "$out/proto" cp --reflink=auto --recursive grammar "$out/grammar"
cp --reflink=auto --recursive dist "$out/dist" cp --reflink=auto --recursive proto "$out/proto"
cp package.json "$out/package.json" cp --reflink=auto --recursive dist "$out/dist"
mkdir -p "$out/bin" cp package.json "$out/package.json"
for tool in quixos-codegen-ts quixos-qx; do mkdir -p "$out/bin"
printf '#!/bin/sh\nexec ${pkgs.nodejs_24}/bin/node "%s/libexec/quixos-protocol/%s.mjs" "$@"\n' "$out" "$tool" > "$out/bin/$tool" for tool in quixos-codegen-ts quixos-qx; do
chmod +x "$out/bin/$tool" printf '#!/bin/sh\nexec ${pkgs.nodejs_24}/bin/node "%s/libexec/quixos-protocol/%s.mjs" "$@"\n' "$out" "$tool" > "$out/bin/$tool"
done chmod +x "$out/bin/$tool"
cat > "$out/bin/quixos-descriptor-check" <<EOF done
#!/bin/sh cat > "$out/bin/quixos-descriptor-check" <<EOF
export PATH="${pkgs.protobuf}/bin:\$PATH" #!/bin/sh
export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$out/proto\''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}" export PATH="${pkgs.protobuf}/bin:\$PATH"
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs" "\$@" export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$out/proto\''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}"
EOF exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs" "\$@"
chmod +x "$out/bin/quixos-descriptor-check" EOF
cat > "$out/bin/quixos-capability-compile" <<EOF chmod +x "$out/bin/quixos-descriptor-check"
#!/bin/sh cat > "$out/bin/quixos-capability-compile" <<EOF
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-capability-compile.mjs" "\$@" #!/bin/sh
EOF exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-capability-compile.mjs" "\$@"
chmod +x "$out/bin/quixos-capability-compile" EOF
cat > "$out/bin/quixos-workspace-compile" <<EOF chmod +x "$out/bin/quixos-capability-compile"
#!/bin/sh cat > "$out/bin/quixos-workspace-compile" <<EOF
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-workspace-compile.mjs" "\$@" #!/bin/sh
EOF exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-workspace-compile.mjs" "\$@"
chmod +x "$out/bin/quixos-workspace-compile" EOF
cat > "$out/bin/quixos-resource-compile" <<EOF chmod +x "$out/bin/quixos-workspace-compile"
#!/bin/sh cat > "$out/bin/quixos-resource-compile" <<EOF
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-resource-compile.mjs" "\$@" #!/bin/sh
EOF exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-resource-compile.mjs" "\$@"
chmod +x "$out/bin/quixos-resource-compile" EOF
cat > "$out/bin/quixos-lock-check" <<EOF chmod +x "$out/bin/quixos-resource-compile"
#!/bin/sh cat > "$out/bin/quixos-lock-check" <<EOF
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-lock-check.mjs" "\$@" #!/bin/sh
EOF exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-lock-check.mjs" "\$@"
chmod +x "$out/bin/quixos-lock-check" EOF
mkdir -p "$out/libexec/quixos-protocol" chmod +x "$out/bin/quixos-lock-check"
install -m644 client-codegen.mjs "$out/libexec/quixos-protocol/client-codegen.mjs" mkdir -p "$out/libexec/quixos-protocol"
install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/" install -m644 client-codegen.mjs "$out/libexec/quixos-protocol/client-codegen.mjs"
install -m644 quixos-descriptor-check.mjs "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs" install -m644 react-platform.mjs "$out/libexec/quixos-protocol/react-platform.mjs"
install -m644 quixos-capability-compile.mjs "$out/libexec/quixos-protocol/quixos-capability-compile.mjs" install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/"
install -m644 quixos-workspace-compile.mjs "$out/libexec/quixos-protocol/quixos-workspace-compile.mjs" install -m644 quixos-descriptor-check.mjs "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs"
install -m644 quixos-resource-compile.mjs "$out/libexec/quixos-protocol/quixos-resource-compile.mjs" install -m644 quixos-capability-compile.mjs "$out/libexec/quixos-protocol/quixos-capability-compile.mjs"
install -m644 quixos-lock-check.mjs "$out/libexec/quixos-protocol/quixos-lock-check.mjs" install -m644 quixos-workspace-compile.mjs "$out/libexec/quixos-protocol/quixos-workspace-compile.mjs"
runHook postInstall install -m644 quixos-resource-compile.mjs "$out/libexec/quixos-protocol/quixos-resource-compile.mjs"
''; install -m644 quixos-lock-check.mjs "$out/libexec/quixos-protocol/quixos-lock-check.mjs"
runHook postInstall
'';
}; };
}; };
in { in
{
packages.default = quixos-protocol; packages.default = quixos-protocol;
packages.inspector = (pkgs.callPackage ./yarn-project.nix { inherit nodejs; }) { packages.inspector = (pkgs.callPackage ./yarn-project.nix { inherit nodejs; }) {
src = pkgs.lib.fileset.toSource { src = pkgs.lib.fileset.toSource {
root = ./.; root = ./.;
fileset = pkgs.lib.fileset.unions [ ./package.json ./yarn.lock ./.yarnrc.yml ./.yarn/plugins ./src ]; fileset = pkgs.lib.fileset.unions [
./package.json
./yarn.lock
./.yarnrc.yml
./.yarn/plugins
./src
];
}; };
overrideAttrs = old: { overrideAttrs = old: {
nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.esbuild ]; nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.esbuild ];
doCheck = false; doCheck = false;
buildPhase = '' buildPhase = ''
esbuild src/capability-language/inspect-cli.ts --bundle --platform=node --target=node24 --format=esm --outfile=inspect.mjs esbuild src/capability-language/inspect-cli.ts --bundle --platform=node --target=node24 --format=esm --outfile=inspect.mjs
@@ -155,5 +171,6 @@ EOF
export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$PWD/proto''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}" export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$PWD/proto''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}"
''; '';
}; };
}); }
);
} }
+59 -11
View File
@@ -27,6 +27,7 @@ workspaceItem
| sharedAttachmentDecl | sharedAttachmentDecl
| conformanceDecl | conformanceDecl
| constructorBindingDecl | constructorBindingDecl
| typeAliasDecl
; ;
resourceImportDecl resourceImportDecl
@@ -46,6 +47,7 @@ resourcePreamble
: resourceImportDecl : resourceImportDecl
| externalAtomDecl | externalAtomDecl
| externalInterfaceDecl | externalInterfaceDecl
| typeAliasDecl
; ;
atomDecl atomDecl
@@ -53,10 +55,39 @@ atomDecl
; ;
interfaceResourceDecl interfaceResourceDecl
: resourcePreamble* INTERFACE identifier ID stringLiteral REVISION stringLiteral : resourcePreamble* INTERFACE identifier typeParameters? ID stringLiteral REVISION stringLiteral
(REQUIRES interfaceType (COMMA interfaceType)*)?
LBRACE interfaceMember* RBRACE LBRACE interfaceMember* RBRACE
; ;
typeParameters
: LT typeParameter (COMMA typeParameter)* GT
;
typeParameter
: VALUE identifier (COLON STORABLE)?
| OBJECT identifier (IMPLEMENTS interfaceType (AMP interfaceType)*)?
;
interfaceType
: identifier typeArguments?
;
typeArguments
: LT typeArgument (COMMA typeArgument)* GT
;
typeArgument
: ATOM identifier
| INTERFACE interfaceType
| OBJECT identifier
| valueType
;
typeAliasDecl
: TYPE identifier typeParameters? EQUAL valueType SEMI
;
interfaceMember interfaceMember
: valueMember : valueMember
| relationshipMember | relationshipMember
@@ -64,7 +95,7 @@ interfaceMember
; ;
operationMember operationMember
: OPERATION identifier ID stringLiteral COLON valueType ARROW valueType : STATIC? OPERATION identifier ID stringLiteral COLON valueType ARROW valueType
LBRACE CALL ID stringLiteral SEMI RBRACE LBRACE CALL ID stringLiteral SEMI RBRACE
; ;
@@ -93,7 +124,8 @@ relationshipOperation
targetConstraint targetConstraint
: ATOM identifier : ATOM identifier
| INTERFACE identifier | INTERFACE identifier typeArguments?
| OBJECT identifier
; ;
packageResourceDecl packageResourceDecl
@@ -109,12 +141,12 @@ packageExport
; ;
packageOperationExport packageOperationExport
: OPERATION identifier ID stringLiteral COLON valueType ARROW valueType : OPERATION identifier typeParameters? ID stringLiteral COLON valueType ARROW valueType
MODE operationMode eventClause? RECEIVER receiverRequirement dependencyBlock? SEMI MODE operationMode eventClause? RECEIVER receiverRequirement dependencyBlock? SEMI
; ;
packageFunctionExport packageFunctionExport
: FUNCTION identifier ID stringLiteral COLON valueType ARROW valueType : FUNCTION identifier typeParameters? ID stringLiteral COLON valueType ARROW valueType
dependencyBlock? SEMI dependencyBlock? SEMI
; ;
@@ -138,7 +170,8 @@ operationMode
receiverRequirement receiverRequirement
: ANY : ANY
| ATOM identifier | ATOM identifier
| INTERFACES LBRACK identifierList? RBRACK | OBJECT identifier
| INTERFACES LBRACK (interfaceType (COMMA interfaceType)*)? RBRACK
; ;
identifierList identifierList
@@ -152,7 +185,7 @@ dependencyBlock
dependencyPort dependencyPort
: STATE identifier ID stringLiteral COLON valueType primitiveList SEMI : STATE identifier ID stringLiteral COLON valueType primitiveList SEMI
| EDGE identifier ID stringLiteral COLON cardinality targetConstraint primitiveList SEMI | EDGE identifier ID stringLiteral COLON cardinality targetConstraint primitiveList SEMI
| INTERFACE identifier ID stringLiteral COLON identifier SEMI | INTERFACE identifier ID stringLiteral COLON identifier typeArguments? SEMI
| CONSTRUCTOR identifier ID stringLiteral COLON identifier (INPUT valueType)? SEMI | CONSTRUCTOR identifier ID stringLiteral COLON identifier (INPUT valueType)? SEMI
; ;
@@ -199,16 +232,21 @@ edgeEndpoint
; ;
conformanceDecl conformanceDecl
: CONFORM identifier AS identifier (ID stringLiteral)? (SEMANTIC_MAJOR INTEGER)? : CONFORM identifier AS identifier typeArguments? (ID stringLiteral)? (SEMANTIC_MAJOR INTEGER)?
LBRACE conformanceItem* RBRACE LBRACE conformanceItem* RBRACE
; ;
conformanceItem conformanceItem
: PRIVATE attachmentDecl : PRIVATE attachmentDecl
| operationBindingDecl | operationBindingDecl
| stateFieldBindingDecl
| relationshipMaterializationDecl | relationshipMaterializationDecl
; ;
stateFieldBindingDecl
: BIND identifier TO STATE identifier SEMI
;
relationshipMaterializationDecl relationshipMaterializationDecl
: MATERIALIZE identifier IF ABSENT USING CONSTRUCTOR identifier VIA EDGE identifier DOT identifier SEMI : MATERIALIZE identifier IF ABSENT USING CONSTRUCTOR identifier VIA EDGE identifier DOT identifier SEMI
; ;
@@ -238,7 +276,7 @@ operationName
operationProvider operationProvider
: STATE identifier DOT statePrimitive : STATE identifier DOT statePrimitive
| EDGE identifier DOT identifier DOT edgePrimitive | EDGE identifier DOT identifier DOT edgePrimitive
| PACKAGE identifier DOT identifier dependencyBindingBlock? | PACKAGE identifier DOT identifier typeArguments? dependencyBindingBlock?
; ;
statePrimitive statePrimitive
@@ -263,7 +301,7 @@ dependencyBindingBlock
dependencyBinding dependencyBinding
: identifier TO STATE identifier (VIA EDGE identifier DOT identifier)? SEMI : identifier TO STATE identifier (VIA EDGE identifier DOT identifier)? SEMI
| identifier TO EDGE identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI | identifier TO EDGE identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI
| identifier TO INTERFACE identifier (VIA EDGE identifier DOT identifier)? SEMI | identifier TO INTERFACE identifier typeArguments? (VIA EDGE identifier DOT identifier)? SEMI
| identifier TO CONSTRUCTOR identifier SEMI | identifier TO CONSTRUCTOR identifier SEMI
; ;
@@ -277,10 +315,12 @@ valueType
| WATCH_HANDLE | WATCH_HANDLE
| MESSAGE stringLiteral | MESSAGE stringLiteral
| ATOM_REF LT identifier GT | ATOM_REF LT identifier GT
| INTERFACE_REF LT identifier GT | INTERFACE_REF LT identifier typeArguments? GT
| REF LT identifier GT
| OPTIONAL LT valueType GT | OPTIONAL LT valueType GT
| LIST LT valueType GT | LIST LT valueType GT
| RECORD LBRACE recordField* RBRACE | RECORD LBRACE recordField* RBRACE
| identifier typeArguments?
; ;
recordField recordField
@@ -338,6 +378,11 @@ stringLiteral
; ;
WORKSPACE: 'workspace'; WORKSPACE: 'workspace';
TYPE: 'type';
OBJECT: 'object';
STORABLE: 'storable';
IMPLEMENTS: 'implements';
REF: 'ref';
FRAGMENT: 'fragment'; FRAGMENT: 'fragment';
IMPORT: 'import'; IMPORT: 'import';
EXTERNAL: 'external'; EXTERNAL: 'external';
@@ -355,6 +400,7 @@ INPUT: 'input';
CONFORM: 'conform'; CONFORM: 'conform';
AS: 'as'; AS: 'as';
BIND: 'bind'; BIND: 'bind';
STATIC: 'static';
TO: 'to'; TO: 'to';
PRIVATE: 'private'; PRIVATE: 'private';
SHARED: 'shared'; SHARED: 'shared';
@@ -441,6 +487,8 @@ LPAREN: '(';
RPAREN: ')'; RPAREN: ')';
LT: '<'; LT: '<';
GT: '>'; GT: '>';
AMP: '&';
EQUAL: '=';
INTEGER: '-'? [0-9]+; INTEGER: '-'? [0-9]+;
JSON_NUMBER: '-'? ('0' | [1-9] [0-9]*) ('.' [0-9]+)? ([eE] [+-]? [0-9]+)?; JSON_NUMBER: '-'? ('0' | [1-9] [0-9]*) ('.' [0-9]+)? ([eE] [+-]? [0-9]+)?;
+115 -52
View File
@@ -1,77 +1,140 @@
# All source trees and recursive locks are fetched by Nix at exact retained # All source trees and recursive locks are fetched by Nix at exact retained
# revisions. No authoring-directory overlay or externally assembled schema. # revisions. No authoring-directory overlay or externally assembled schema.
{ repository, commit, kind, generator, contractOnly ? false, system ? builtins.currentSystem }: {
repository,
commit,
kind,
generator,
contractOnly ? false,
system ? builtins.currentSystem,
}:
let let
pkgs = import (/. + "@nixpkgs@") { inherit system; }; pkgs = import (/. + "@nixpkgs@") { inherit system; };
protocol = builtins.storePath generator; protocol = builtins.storePath generator;
sourceKey = source: "${source.kind}:${source.repository}@${source.commit}"; sourceKey = source: "${source.kind}:${source.repository}@${source.commit}";
fetch = source: builtins.fetchGit { fetch =
url = source.repository; source:
rev = source.commit; builtins.fetchGit {
ref = "refs/tags/quixos-reachability/${source.commit}"; url = source.repository;
shallow = true; rev = source.commit;
}; ref = "refs/tags/quixos-reachability/${source.commit}";
load = ancestors: source: shallow = true;
};
load =
ancestors: source:
if builtins.elem (sourceKey source) ancestors then if builtins.elem (sourceKey source) ancestors then
throw "Source dependency cycle at ${sourceKey source}" throw "Source dependency cycle at ${sourceKey source}"
else if builtins.length ancestors >= 100 then else if builtins.length ancestors >= 100 then
throw "Source dependency depth exceeds 100" throw "Source dependency depth exceeds 100"
else let else
directory = fetch source; let
lockFile = pkgs.runCommand "qx-source-lock.json" { } '' directory = fetch source;
${protocol}/bin/quixos-lock-check ${directory}/quixos.lock > "$out" 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; lock = builtins.fromJSON (builtins.readFile lockFile);
in source // { inherit directory children; }; 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; }; root = load [ ] { inherit kind repository commit; };
flatten = node: [ node ] ++ pkgs.lib.concatMap flatten node.children; flatten = node: [ node ] ++ pkgs.lib.concatMap flatten node.children;
nodes = builtins.attrValues (builtins.listToAttrs (map (node: { nodes = builtins.attrValues (
name = sourceKey node; value = node; builtins.listToAttrs (
}) (flatten root))); map (node: {
snapshots = pkgs.writeText "qx-nix-source-graph.json" (builtins.toJSON { name = sourceKey node;
resources = map (node: { inherit (node) kind repository commit directory; }) value = node;
(builtins.filter (node: node.kind != "workspace") nodes); }) (flatten root)
}); )
compile = node: pkgs.runCommand "qx-${node.kind}-contract" { } '' );
mkdir -p "$out" snapshots = pkgs.writeText "qx-nix-source-graph.json" (
${if node.kind == "workspace" then '' builtins.toJSON {
${protocol}/bin/quixos-workspace-compile --root ${node.directory} \ resources = map (node: {
--source-root-commit ${pkgs.lib.escapeShellArg node.commit} \ inherit (node)
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} \ kind
--graph-out "$out/graph.json" > "$out/candidate.json" repository
'' else '' commit
${protocol}/bin/quixos-resource-compile --root ${node.directory} \ directory
--kind ${node.kind} --repository ${pkgs.lib.escapeShellArg node.repository} \ ;
--commit ${pkgs.lib.escapeShellArg node.commit} \ }) (builtins.filter (node: node.kind != "workspace") nodes);
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} --snapshot-only true \ }
--graph-out "$out/graph.json" --schema-out "$out/bindings.json" > "$out/candidate.json" );
''} 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" --schemas-out "$out/package-schemas.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; contract = compile root;
checkPackage = node: let checkPackage =
compiled = compile node; node:
candidate = builtins.fromJSON (builtins.readFile "${compiled}/candidate.json"); let
package = builtins.getFlake ("git+${node.repository}?rev=${node.commit}&ref=refs/tags/quixos-reachability/${node.commit}"); compiled = compile node;
checked = package.quixosPackages.${system}.checkedServer or candidate = builtins.fromJSON (builtins.readFile "${compiled}/candidate.json");
(throw "Package ${node.repository} lacks checkedServer; use the supported package scaffold."); package = builtins.getFlake (
artifact = checked { "git+${node.repository}?rev=${node.commit}&ref=refs/tags/quixos-reachability/${node.commit}"
schema = "${compiled}/bindings.json"; );
generator = protocol; checked =
package.quixosPackages.${system}.checkedServer
or (throw "Package ${node.repository} lacks checkedServer; use the supported package scaffold.");
artifact = checked {
schema =
if kind == "workspace" then
pkgs.writeText "package-specialization-schema.json" (
builtins.toJSON
(builtins.fromJSON (builtins.readFile "${contract}/package-schemas.json"))
.${candidate.revision.revisionId}
)
else
"${compiled}/bindings.json";
generator = protocol;
packageRevisionId = candidate.revision.revisionId;
};
in
{
packageRevisionId = candidate.revision.revisionId; packageRevisionId = candidate.revision.revisionId;
artifactPath = artifact;
}; };
in { packageRevisionId = candidate.revision.revisionId; artifactPath = artifact; }; checks =
checks = if contractOnly then [ ] else map checkPackage (builtins.filter (node: node.kind == "package") nodes); if contractOnly then
[ ]
else
map checkPackage (builtins.filter (node: node.kind == "package") nodes);
manifest = pkgs.writeText "qx-candidate-checks.json" (builtins.toJSON checks); manifest = pkgs.writeText "qx-candidate-checks.json" (builtins.toJSON checks);
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" mkdir -p "$out"
cp ${contract}/candidate.json "$out/candidate.json" cp ${contract}/candidate.json "$out/candidate.json"
cp ${contract}/graph.json "$out/graph.json" cp ${contract}/graph.json "$out/graph.json"
cp ${manifest} "$out/checks.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") '' ${pkgs.lib.optionalString (kind == "package") ''
${protocol}/bin/quixos-codegen-ts ${contract}/bindings.json \ ${protocol}/bin/quixos-codegen-ts ${contract}/bindings.json \
${pkgs.lib.escapeShellArg (builtins.fromJSON (builtins.readFile "${contract}/candidate.json")).revision.revisionId} \ ${pkgs.lib.escapeShellArg (builtins.fromJSON (builtins.readFile "${contract}/candidate.json")).revision.revisionId} \
"$out/bindings.ts" ${pkgs.lib.optionalString (builtins.pathExists (root.directory + "/bindings.json")) (toString root.directory + "/bindings.json")} "$out/bindings.ts" ${bindingOptions}
''} ''}
'' ''
+25 -7
View File
@@ -1,17 +1,35 @@
# One derivation path for provisional checking and activation. The source and # One derivation path for provisional checking and activation. The source and
# schema come from exact committed inputs resolved by the Quixos compiler. # 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 let
packageSource = builtins.path { packageSource = builtins.path {
path = /. + source; path = /. + source;
name = "quixos-package-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)); package = builtins.getFlake (
checked = package.quixosPackages.${system}.checkedServer or "path:" + builtins.unsafeDiscardStringContext (toString packageSource)
(throw "Package ${packageRevisionId} lacks checkedServer; use the supported package scaffold."); );
in checked { checked =
package.quixosPackages.${system}.checkedServer
or (throw "Package ${packageRevisionId} lacks checkedServer; use the supported package scaffold.");
in
checked {
inherit packageRevisionId; 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; generator = builtins.storePath generator;
} }
+57 -19
View File
@@ -22,9 +22,15 @@ service CaminoService {
} }
message NullValue {} message NullValue {}
message ObjectValue { map<string, Value> fields = 1; } message ObjectValue {
message ListValue { repeated Value values = 1; } map<string, Value> fields = 1;
message RefValue { string object_id = 1; } }
message ListValue {
repeated Value values = 1;
}
message RefValue {
string object_id = 1;
}
message CrdtValue { message CrdtValue {
string type = 1; string type = 1;
string encoding = 2; string encoding = 2;
@@ -43,7 +49,9 @@ message StateValueSource {
CrdtValue crdt_snapshot = 6; CrdtValue crdt_snapshot = 6;
} }
message ValueSource { StateValueSource state = 1; } message ValueSource {
StateValueSource state = 1;
}
message Value { message Value {
oneof kind { oneof kind {
@@ -61,26 +69,44 @@ message Value {
ValueSource source = 11; ValueSource source = 11;
} }
message InstallPersistencePlanRequest { PersistencePlan plan = 1; } message InstallPersistencePlanRequest {
message InstallPersistencePlanResponse { PersistencePlan plan = 1; } PersistencePlan plan = 1;
}
message InstallPersistencePlanResponse {
PersistencePlan plan = 1;
}
message GetPersistencePlanRequest {} message GetPersistencePlanRequest {}
message GetPersistencePlanResponse { PersistencePlan plan = 1; } message GetPersistencePlanResponse {
PersistencePlan plan = 1;
}
message CreateObjectRequest { message CreateObjectRequest {
string atom_id = 1; string atom_id = 1;
map<string, Value> initial_state = 2; map<string, Value> initial_state = 2;
} }
message CreateObjectResponse { CaminoObject object = 1; } message CreateObjectResponse {
message GetObjectRequest { string object_id = 1; } CaminoObject object = 1;
message GetObjectResponse { CaminoObject object = 1; } }
message ListObjectsRequest { string atom_id = 1; } message GetObjectRequest {
message ListObjectsResponse { repeated CaminoObject objects = 1; } string object_id = 1;
}
message GetObjectResponse {
CaminoObject object = 1;
}
message ListObjectsRequest {
string atom_id = 1;
}
message ListObjectsResponse {
repeated CaminoObject objects = 1;
}
message ReadStateRequest { message ReadStateRequest {
string object_id = 1; string object_id = 1;
string slot_id = 2; string slot_id = 2;
} }
message ReadStateResponse { Value value = 1; } message ReadStateResponse {
Value value = 1;
}
message WriteStateRequest { message WriteStateRequest {
string object_id = 1; string object_id = 1;
string slot_id = 2; string slot_id = 2;
@@ -100,15 +126,23 @@ message ConnectEdgeRequest {
optional int32 ordinal = 5; optional int32 ordinal = 5;
optional int32 target_ordinal = 6; optional int32 target_ordinal = 6;
} }
message ConnectEdgeResponse { CaminoEdge edge = 1; } message ConnectEdgeResponse {
CaminoEdge edge = 1;
}
message ResolveEdgeRequest { message ResolveEdgeRequest {
string object_id = 1; string object_id = 1;
string edge_type_id = 2; string edge_type_id = 2;
string projection_id = 3; string projection_id = 3;
} }
message ResolveEdgeResponse { repeated CaminoEdge edges = 1; } message ResolveEdgeResponse {
message DisconnectEdgeRequest { string edge_id = 1; } repeated CaminoEdge edges = 1;
message DisconnectEdgeResponse { string edge_id = 1; } }
message DisconnectEdgeRequest {
string edge_id = 1;
}
message DisconnectEdgeResponse {
string edge_id = 1;
}
message CollectionEntry { message CollectionEntry {
// Existing entry identity to preserve; empty allocates a new canonical edge. // Existing entry identity to preserve; empty allocates a new canonical edge.
@@ -128,8 +162,12 @@ message ReplaceCollectionRequest {
repeated CollectionEntry entries = 5; repeated CollectionEntry entries = 5;
} }
message ListOpsRequest { string object_id = 1; } message ListOpsRequest {
message ListOpsResponse { repeated CaminoOp ops = 1; } string object_id = 1;
}
message ListOpsResponse {
repeated CaminoOp ops = 1;
}
message WatchObjectRequest { message WatchObjectRequest {
string object_id = 1; string object_id = 1;
string after_op_id = 2; string after_op_id = 2;
+65 -10
View File
@@ -9,9 +9,12 @@ import "quixos/runtime.proto";
service OrchestratorRuntime { service OrchestratorRuntime {
rpc InvokeCapability(InvokeCapabilityRequest) returns (InvokeCapabilityResponse); rpc InvokeCapability(InvokeCapabilityRequest) returns (InvokeCapabilityResponse);
rpc EditCapabilityField(EditCapabilityFieldRequest) returns (InvokeCapabilityResponse);
rpc InvokeClassCapability(InvokeClassCapabilityRequest) returns (InvokeCapabilityResponse);
rpc WatchCapability(WatchCapabilityRequest) returns (stream WatchCapabilityEvent); rpc WatchCapability(WatchCapabilityRequest) returns (stream WatchCapabilityEvent);
rpc ConstructObject(ConstructObjectRequest) returns (ConstructObjectResponse); rpc ConstructObject(ConstructObjectRequest) returns (ConstructObjectResponse);
rpc ResolveOrConstructRelatedObject(ResolveOrConstructRelatedObjectRequest) returns (ResolveOrConstructRelatedObjectResponse); rpc ResolveOrConstructRelatedObject(ResolveOrConstructRelatedObjectRequest)
returns (ResolveOrConstructRelatedObjectResponse);
rpc GetWorkspace(GetWorkspaceRequest) returns (GetWorkspaceResponse); rpc GetWorkspace(GetWorkspaceRequest) returns (GetWorkspaceResponse);
rpc ListPackageDescriptors(ListPackageDescriptorsRequest) returns (ListPackageDescriptorsResponse); rpc ListPackageDescriptors(ListPackageDescriptorsRequest) returns (ListPackageDescriptorsResponse);
rpc ListPackageRuntimes(ListPackageRuntimesRequest) returns (ListPackageRuntimesResponse); rpc ListPackageRuntimes(ListPackageRuntimesRequest) returns (ListPackageRuntimesResponse);
@@ -23,7 +26,9 @@ message ConstructObjectRequest {
string atom_id = 1; string atom_id = 1;
map<string, camino.Value> input = 2; map<string, camino.Value> input = 2;
} }
message ConstructObjectResponse { camino.CaminoObject object = 1; } message ConstructObjectResponse {
camino.CaminoObject object = 1;
}
message ResolveOrConstructRelatedObjectRequest { message ResolveOrConstructRelatedObjectRequest {
string object_id = 1; string object_id = 1;
@@ -43,6 +48,11 @@ message InvokeCapabilityRequest {
// layers so a live-value controller can recognize its own confirmation. // layers so a live-value controller can recognize its own confirmation.
string client_mutation_id = 4; string client_mutation_id = 4;
} }
message InvokeClassCapabilityRequest {
string conformance_id = 1;
string operation_id = 2;
map<string, camino.Value> input = 3;
}
message InvokeCapabilityResponse { message InvokeCapabilityResponse {
string invocation_id = 1; string invocation_id = 1;
Activation activation = 2; Activation activation = 2;
@@ -50,6 +60,24 @@ message InvokeCapabilityResponse {
camino.Value result = 4; camino.Value result = 4;
string error = 5; string error = 5;
repeated quixos.runtime.DerivedDependency dependencies = 6; repeated quixos.runtime.DerivedDependency dependencies = 6;
FieldEditing field_editing = 7;
}
// Resolved from the checked native getter/setter binding, not Value.source.
message FieldEditing {
string getter_operation_id = 1;
string setter_operation_id = 2;
string document_type = 3;
string binding_digest = 4;
}
message EditCapabilityFieldRequest {
// The public getter; setter must belong to the same value member.
quixos.CapabilityRef capability = 1;
string object_id = 2;
string setter_operation_id = 3;
string binding_digest = 4;
camino.CrdtValue update = 5;
string client_mutation_id = 6;
} }
message WatchCapabilityRequest { message WatchCapabilityRequest {
@@ -65,18 +93,34 @@ message WatchCapabilityEvent {
repeated quixos.runtime.DerivedDependency dependencies = 5; repeated quixos.runtime.DerivedDependency dependencies = 5;
string error = 6; string error = 6;
bool initial = 7; bool initial = 7;
FieldEditing field_editing = 8;
} }
message GetWorkspaceRequest {} message GetWorkspaceRequest {
// Revision polling must not download the entire interface graph.
bool include_interface_contracts = 1;
}
message GetWorkspaceResponse { message GetWorkspaceResponse {
string workspace_id = 1; string workspace_id = 1;
string workspace_revision_id = 2; string workspace_revision_id = 2;
string source_root_commit = 3; string source_root_commit = 3;
// Checked constructors whose wire input can be empty. Web Studio intersects // Checked constructors whose wire input can be empty. The create panel uses
// this with its temporary Createable marker; the marker is not a factory. // class factory conformances instead of this constructor inventory.
repeated string empty_input_constructible_atom_ids = 4; repeated string empty_input_constructible_atom_ids = 4;
repeated CapabilityInputContract capability_inputs = 5; repeated CapabilityInputContract capability_inputs = 5;
repeated ConstructorInputContract constructor_inputs = 6; repeated ConstructorInputContract constructor_inputs = 6;
// Exact closed interface contracts used by checked presentation consumers.
string interfaces_json = 7;
repeated ClassCapability class_capabilities = 8;
}
message ClassCapability {
string conformance_id = 1;
string atom_id = 2;
string interface_revision_id = 3;
string definition_id = 4;
string operation_id = 5;
string input_type_json = 6;
string output_type_json = 7;
} }
message CapabilityInputContract { message CapabilityInputContract {
string interface_revision_id = 1; string interface_revision_id = 1;
@@ -89,12 +133,23 @@ message ConstructorInputContract {
} }
message ListActivationsRequest {} message ListActivationsRequest {}
message ListPackageDescriptorsRequest {} message ListPackageDescriptorsRequest {}
message ListPackageDescriptorsResponse { repeated quixos.PackageDescriptor descriptors = 1; } message ListPackageDescriptorsResponse {
repeated quixos.PackageDescriptor descriptors = 1;
}
message ListPackageRuntimesRequest {} message ListPackageRuntimesRequest {}
message ListPackageRuntimesResponse { repeated PackageRuntimeStatus runtimes = 1; } message ListPackageRuntimesResponse {
message ListActivationsResponse { repeated Activation activations = 1; } repeated PackageRuntimeStatus runtimes = 1;
message CloseActivationRequest { string activation_id = 1; string reason = 2; } }
message CloseActivationResponse { Activation activation = 1; } message ListActivationsResponse {
repeated Activation activations = 1;
}
message CloseActivationRequest {
string activation_id = 1;
string reason = 2;
}
message CloseActivationResponse {
Activation activation = 1;
}
message Activation { message Activation {
string activation_id = 1; string activation_id = 1;
+3 -1
View File
@@ -35,7 +35,9 @@ message InvocationContext {
// Host-selected owner; packages must not invent workspace-local ownership. // Host-selected owner; packages must not invent workspace-local ownership.
string owner_conformance_id = 6; string owner_conformance_id = 6;
} }
message InvocationControlRequest { string invocation_id = 1; } message InvocationControlRequest {
string invocation_id = 1;
}
message InvocationStatus { message InvocationStatus {
string invocation_id = 1; string invocation_id = 1;
// unknown, running, cancellation-requested, completed, failed // unknown, running, cancellation-requested, completed, failed
+22 -11
View File
@@ -1,25 +1,35 @@
import {parse} from "@babel/parser"; import { parse } from "@babel/parser";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
/** Enforce the authored bundled-code contract, not a security sandbox. */ /** Enforce the authored bundled-code contract, not a security sandbox. */
export function bundlePolicyErrors(source: string, filename: string): string[] { 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 errors: string[] = [];
const visit = (value: unknown) => { 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; if (!value || typeof value !== "object") return;
const n = value as Record<string, any>; const n = value as Record<string, any>;
if (typeof n.type !== "string") return; if (typeof n.type !== "string") return;
const fail = (message: string) => errors.push(`${filename}:${n.loc?.start.line ?? 1}: ${message}`); 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 === "MetaProperty" && n.meta.name === "import")
if (n.type === "Identifier" && ["__dirname", "__filename"].includes(n.name)) fail("Bundled package code cannot depend on module filesystem locations"); 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 (["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 === "Identifier" && ["eval", "Function"].includes(n.callee.name))
if ((n.callee?.type === "Import" || (n.callee?.type === "Identifier" && n.callee.name === "require")) && fail("Dynamic code generation is unsupported in bundled package code");
(n.arguments.length !== 1 || n.arguments[0].type !== "StringLiteral")) fail("Module imports must have a static string specifier"); 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); Object.values(n).forEach(visit);
}; };
visit(ast); visit(ast);
@@ -28,12 +38,13 @@ export function bundlePolicyErrors(source: string, filename: string): string[] {
export async function checkBundleSources(directory: string): Promise<void> { export async function checkBundleSources(directory: string): Promise<void> {
const errors: string[] = []; const errors: string[] = [];
async function walk(current: 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; if (["gen", "node_modules"].includes(entry.name)) continue;
const file = path.join(current, entry.name); const file = path.join(current, entry.name);
if (entry.isSymbolicLink()) throw new Error(`Authored source symlinks are unsupported: ${file}`); if (entry.isSymbolicLink()) throw new Error(`Authored source symlinks are unsupported: ${file}`);
if (entry.isDirectory()) await walk(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); await walk(directory);
+19 -5
View File
@@ -1,13 +1,27 @@
#!/usr/bin/env node #!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises"; import { readFile, writeFile } from "node:fs/promises";
import { generateTypeScriptBindings } from "./index.js"; import { generateTypeScriptBindings } from "./index.js";
import path from "node:path";
import { reactPlatformTypes } from "./react-platform.js";
import { generateReactBindings } from "./react.js";
const main = async () => { const main = async () => {
const [schema, revision, output, options, ...rest] = process.argv.slice(2); const [schema, revision, output, options, ...rest] = process.argv.slice(2);
if (!schema || !revision || !output || rest.length) throw new Error( if (!schema || !revision || !output || rest.length)
"usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]"); throw new Error("usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]");
const generated = generateTypeScriptBindings(JSON.parse(await readFile(schema, "utf8")), revision, const config = options ? JSON.parse(await readFile(options, "utf8")) : {};
options ? JSON.parse(await readFile(options, "utf8")) : {}); const contracts = JSON.parse(await readFile(schema, "utf8"));
const generated = generateTypeScriptBindings(contracts, revision, config);
await writeFile(output, generated); await writeFile(output, generated);
if (config.react) {
await writeFile(path.join(path.dirname(output), "web-studio-react-runtime.d.ts"), reactPlatformTypes);
await writeFile(
path.join(path.dirname(output), "react-props.gen.ts"),
generateReactBindings(contracts, revision, config.react.propsExports, config.react.components),
);
}
}; };
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;
});
+92 -16
View File
@@ -1,16 +1,41 @@
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 /** Host clients have no package receiver, but must use the same checked
* interface signatures and argument framing as generated package ports. */ * interface signatures and argument framing as generated package ports. */
export const generateClientContracts = (interfaces: InterfaceRevision[], messages: Record<string, string>) => { export const generateClientContracts = (
interfaces: InterfaceRevision[],
messages: Record<string, string>,
qualified = false,
) => {
if (interfaces.some((entry) => entry.template))
throw new Error("Host contracts require closed interface applications, not generic definitions");
const type = (value: ValueType): string => { const type = (value: ValueType): string => {
switch (value.kind) { switch (value.kind) {
case "builtin": return value.name === "unit" ? "undefined" : "string"; case "builtin":
case "scalar": return ({bool: "boolean", string: "string", bytes: "Uint8Array", int32: "number", uint32: "number", double: "number", int64: "bigint", uint64: "bigint"})[value.name]; return value.name === "unit" ? "undefined" : "string";
case "object-ref": return `{readonly $quixosRef: string}`; case "scalar":
case "optional": return `(${type(value.value)} | null)`; return {
case "list": return `Array<${type(value.value)}>`; bool: "boolean",
case "record": return `{${Object.entries(value.fields).map(([name, field]) => `${JSON.stringify(name)}${field.kind === "optional" ? "?" : ""}: ${type(field)}`).join("; ")}}`; string: "string",
bytes: "Uint8Array",
int32: "number",
uint32: "number",
double: "number",
int64: "bigint",
uint64: "bigint",
}[value.name];
case "object-ref":
return qualified
? `CapabilityReference<${JSON.stringify(value.expectation.kind === "atom" ? `atom:${value.expectation.atomId}` : `interface:${value.expectation.interfaceRevisionId}`)}>`
: `{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": { case "message": {
const binding = messages[value.descriptorId]; const binding = messages[value.descriptorId];
if (!binding) throw new Error(`Missing host message type ${value.descriptorId}`); if (!binding) throw new Error(`Missing host message type ${value.descriptorId}`);
@@ -18,12 +43,63 @@ export const generateClientContracts = (interfaces: InterfaceRevision[], message
} }
} }
}; };
const operations = interfaces.flatMap(iface => iface.members.flatMap(member => member.operations const operations = interfaces.flatMap((iface) =>
.filter(operation => operation.mode === "call").map(operation => ({...operation, interfaceRevisionId: iface.revisionId})))); iface.members.flatMap((member) =>
return `// Generated from checked QX interfaces. Regenerate with scripts/generate-platform-contracts.mjs.\n` + member.operations
`export type PlatformInputs = {\n${operations.map(operation => ` ${JSON.stringify(operation.id)}: ${type(operation.inputType)};`).join("\n")}\n};\n` + .filter((operation) => operation.mode === "call")
`export const platformOperations = ${JSON.stringify(Object.fromEntries(operations.map(operation => [operation.id, { .map((operation) => ({ ...operation, interfaceRevisionId: iface.revisionId })),
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`; if (qualified) {
const contracts = interfaces.map((iface) => {
const members = iface.members.flatMap((member) =>
member.operations
.filter((op) => op.mode === "call")
.map((op) => ` ${JSON.stringify(op.id)}: {input: ${type(op.inputType)}; output: ${type(op.outputType)}};`),
);
return `${JSON.stringify(iface.revisionId)}: {\n${members.join("\n")}\n}`;
});
if (new Set(interfaces.map((iface) => iface.revisionId)).size !== interfaces.length)
throw new Error("Duplicate closed interface identity");
return (
`// Generated closed capability contracts. Dispatch by interface AND operation.\n` +
`declare const referenceType: unique symbol;\nexport type CapabilityReference<T extends string> = {readonly $quixosRef: string; readonly [referenceType]: T};\n` +
`export type CapabilityContracts = {${contracts.join(";\n")}};\n` +
`export type CapabilityInput<I extends keyof CapabilityContracts, O extends keyof CapabilityContracts[I]> = CapabilityContracts[I][O] extends {input: infer T} ? T : never;\n` +
`export type CapabilityOutput<I extends keyof CapabilityContracts, O extends keyof CapabilityContracts[I]> = CapabilityContracts[I][O] extends {output: infer T} ? T : never;\n` +
`export const capabilityApplications = ${JSON.stringify(Object.fromEntries(interfaces.map((iface) => [iface.revisionId, { definitionId: iface.application?.definitionId ?? iface.revisionId, arguments: iface.application?.arguments ?? [], ...(iface.application?.self ? { self: iface.application.self } : {}) }])))} as const;\n`
);
}
if (new Set(operations.map((entry) => entry.id)).size !== operations.length)
throw new Error(
"Host operation IDs are ambiguous across interfaces; select a closed interface application explicitly",
);
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`
);
}; };
/** New consumers use exact closed interface identities; operation IDs alone are not unique. */
export const generateAppliedClientContracts = (
interfaces: InterfaceRevision[],
messages: Record<string, string> = {},
) => generateClientContracts(interfaces, messages, true);
+170
View File
@@ -0,0 +1,170 @@
import type { GenericPackageExport, GenericDependencyPort } from "../capability-model/generic-packages.js";
import type { InterfaceRevision, ValueType } from "../capability-model/types.js";
import type {
ValueTypeExpression,
ObjectTypeExpression,
TypeArgumentExpression,
TypeParameter,
ValueAliasDefinition,
} from "../capability-model/generics.js";
/** Universal source contracts. Only closed exports get executable codecs. */
export const genericImplementationType = (
definition: GenericPackageExport,
interfaces: InterfaceRevision[],
concrete: (type: ValueType) => string,
): string => {
const names = new Map(definition.parameters.map((parameter, index) => [parameter.id, `T${index}`]));
type Scope = { arguments: Map<string, string>; aliases: ValueAliasDefinition[] };
const rootScope = (): Scope => ({ arguments: new Map(), aliases: definition.aliases });
const object = (entries: [string, string][]) =>
`{${entries.map(([key, value]) => `${JSON.stringify(key)}:${value}`).join(";")}}`;
const target = (type: ObjectTypeExpression, scope: Scope): string => {
if (type.kind === "parameter") {
const bound = scope.arguments.get(type.parameterId);
if (bound) return bound;
const name = names.get(type.parameterId);
if (!name) throw new Error(`Unbound object parameter ${type.parameterId}`);
return name;
}
if (type.kind === "atom") return JSON.stringify(`atom:${type.atomId}`);
if (type.kind === "interface") return JSON.stringify(`interface:${type.interfaceRevisionId}`);
if (type.kind === "application")
return `QxApplied<${JSON.stringify(type.application.definitionId)}, [${type.application.arguments.map((arg) => argument(arg, scope)).join(",")}]>`;
throw new Error("Generic package Self must be expressed as an explicit object parameter");
};
const argument = (arg: TypeArgumentExpression, scope: Scope): string =>
arg.kind === "value" ? value(arg.type, scope) : target(arg.target, scope);
const bind = (parameters: TypeParameter[], args: TypeArgumentExpression[], scope: Scope): Scope => {
if (parameters.length !== args.length) throw new Error("Wrong generic arity during code generation");
const result = new Map(scope.arguments);
// Render arguments in their original lexical scope before introducing binders.
const rendered = args.map((arg) => argument(arg, scope));
parameters.forEach((parameter, index) => result.set(parameter.id, rendered[index]));
return { ...scope, arguments: result };
};
const value = (type: ValueTypeExpression, scope: Scope, depth = 0): string => {
if (depth > 128) throw new Error("Generic type expansion exceeds depth limit");
switch (type.kind) {
case "parameter": {
const bound = scope.arguments.get(type.parameterId);
if (bound) return bound;
const name = names.get(type.parameterId);
if (!name) throw new Error(`Unbound value parameter ${type.parameterId}`);
return name;
}
case "object-ref":
return `QxObjectRef<${target(type.expectation, scope)}>`;
case "record":
return object(Object.entries(type.fields).map(([name, field]) => [name, value(field, scope, depth + 1)]));
case "optional":
return `(${value(type.value, scope, depth + 1)} | null)`;
case "list":
return `Array<${value(type.value, scope, depth + 1)}>`;
case "alias": {
const alias = scope.aliases.find((entry) => entry.id === type.definitionId);
if (!alias) throw new Error(`Missing alias ${type.definitionId}`);
return value(alias.body, bind(alias.parameters, type.arguments, scope), depth + 1);
}
default:
return concrete(type);
}
};
const params = (type: ValueTypeExpression, scope: Scope) =>
type.kind === "builtin" && type.name === "unit" ? "" : `input:${value(type, scope)}`;
const port = (entry: GenericDependencyPort): string => {
const requirement = entry.requirement,
scope = rootScope();
switch (requirement.kind) {
case "state":
return object([
...requirement.primitives.map((primitive): [string, string] => {
if (primitive === "read") return ["get", `()=>Promise<${value(requirement.valueType, scope)}>`];
if (primitive === "write") return ["set", `(value:${value(requirement.valueType, scope)})=>Promise<void>`];
throw new Error(`Unsupported generic state primitive ${primitive}`);
}),
...(requirement.primitives.includes("read")
? [["live", "()=>Promise<QxLiveValue>"] as [string, string]]
: []),
]);
case "edge": {
const ref = `QxObjectRef<${target(requirement.target, scope)}>`;
const methods = requirement.primitives.map((primitive): [string, string] => {
if (primitive === "resolve") return ["resolve", `()=>Promise<Array<${ref}>>`];
if (primitive === "connect" || primitive === "disconnect")
return [primitive, `(target:${ref})=>Promise<void>`];
throw new Error(`Unsupported generic edge primitive ${primitive}`);
});
if (requirement.primitives.includes("resolve"))
methods.push(["collection", `()=>Promise<RelationshipCollection<${ref}>>`]);
if (
["resolve", "connect", "disconnect"].every((primitive) =>
requirement.primitives.includes(primitive as "resolve"),
)
)
methods.push([
"replace",
`(entries:RelationshipEntry<${ref}>[],expectedRevision:bigint)=>Promise<RelationshipCollection<${ref}>>`,
]);
return object(methods);
}
case "constructor":
return object([
[
"construct",
`(${params(requirement.inputType, scope)})=>Promise<QxObjectRef<${target(requirement.target, scope)}>>`,
],
]);
case "interface": {
const contract = interfaces.find((entry) => entry.revisionId === requirement.application.definitionId);
if (!contract) throw new Error(`Missing generic port contract ${requirement.application.definitionId}`);
const local = {
...bind(contract.template?.parameters ?? [], requirement.application.arguments, scope),
aliases: contract.template?.aliases ?? [],
};
const operations = (contract.template?.members ?? contract.members).flatMap((member) =>
member.operations
.filter((op) => op.mode === "call" && op.scope !== "class")
.map((op) => ({ ...op, name: `${member.displayName}.${op.displayName}` })),
);
return object([
["objectId", `QxObjectRef<${target({ kind: "application", application: requirement.application }, scope)}>`],
["live", object(operations.map((op) => [op.name, `(${params(op.inputType, local)})=>Promise<QxLiveValue>`]))],
...operations.map(
(op) =>
[op.name, `(${params(op.inputType, local)})=>Promise<${value(op.outputType, local)}>`] as [
string,
string,
],
),
]);
}
}
};
const declarations = definition.parameters
.map((parameter) => `${names.get(parameter.id)}${parameter.kind === "object" ? " extends string" : ""}`)
.join(",");
const receiver = definition.receiverRequirement;
const context = object([
...(definition.kind === "function"
? []
: [
[
"objectId",
`QxObjectRef<${receiver.kind === "target" ? target(receiver.target, rootScope()) : "string"}>`,
] as [string, string],
]),
["input", value(definition.inputType, rootScope())],
["ports", object(definition.dependencyPorts.map((entry) => [entry.displayName, port(entry)]))],
]);
const contextWithLifecycle =
definition.kind === "function"
? `${context} & {signal?: AbortSignal}`
: `${context} & QxContextLifecycle<${context} & {signal?: AbortSignal}>`;
const result = value(definition.eventType ?? definition.outputType, rootScope());
const handler = `<${declarations}>(context:${contextWithLifecycle})=>${result}|Promise<${result}>`;
const derived = `{kind:"derived";get:${handler}}`;
return definition.eventType
? derived
: `(${handler})${definition.kind === "operation" && definition.mode === "call" ? ` | ${derived}` : ""}`;
};
+234 -58
View File
@@ -1,51 +1,137 @@
import type { InterfaceRevision, PackageRevision, ValueType, DependencyPort } from "../capability-model/types.js"; import type {
InterfaceRevision,
PackageRevision,
ValueType,
DependencyPort,
WorkspaceRevision,
} from "../capability-model/types.js";
import type { CompiledCapabilityResourceRepository } from "../capability-language/assembly.js"; import type { CompiledCapabilityResourceRepository } from "../capability-language/assembly.js";
import { genericImplementationType } from "./generics.js";
/** Portable generator input. New backends consume this instead of the parser or TS runtime. */ /** Portable generator input. New backends consume this instead of the parser or TS runtime. */
export type BindingSchema = { export type BindingSchema = {
format: "quixos-bindings"; format: "quixos-bindings";
version: 1; version: 1;
interfaces: InterfaceRevision[]; interfaces: InterfaceRevision[];
interfaceTemplates?: InterfaceRevision[];
packages: PackageRevision[]; packages: PackageRevision[];
}; };
export const bindingSchema = (compiled: CompiledCapabilityResourceRepository): BindingSchema => ({ export const bindingSchema = (compiled: Pick<CompiledCapabilityResourceRepository, "resources">): BindingSchema => ({
format: "quixos-bindings", version: 1, format: "quixos-bindings",
interfaces: compiled.resources.flatMap((node) => node.resource.kind === "interface" ? [node.resource.revision] : []), version: 1,
packages: compiled.resources.flatMap((node) => node.resource.kind === "package" ? [node.resource.revision] : []), interfaceTemplates: compiled.resources.flatMap((node) =>
node.resource.kind === "interface" && node.resource.revision.template ? [node.resource.revision] : [],
),
interfaces: [
...new Map(
compiled.resources
.flatMap((node) => [
...(node.resource.kind === "interface" ? [node.resource.revision] : []),
...(node.resource.specializations ?? []),
])
.filter((entry) => !entry.template)
.map((entry) => [entry.revisionId, entry]),
).values(),
],
packages: compiled.resources.flatMap((node) => (node.resource.kind === "package" ? [node.resource.revision] : [])),
}); });
export const specializeBindingSchema = (
base: BindingSchema,
workspace: WorkspaceRevision,
revisionId: string,
): BindingSchema => {
const pkg = workspace.packageImports.find((entry) => entry.revisionId === revisionId);
if (!pkg) throw new Error(`Missing candidate package ${revisionId}`);
const needed = new Set<string>();
const visit = (value: unknown): void => {
if (!value || typeof value !== "object") return;
if ("interfaceRevisionId" in value && typeof value.interfaceRevisionId === "string")
needed.add(value.interfaceRevisionId);
Object.values(value).forEach(visit);
};
visit(pkg.exports);
const interfaces = new Map<string, InterfaceRevision>(base.interfaces.map((entry) => [entry.revisionId, entry]));
for (const id of needed) {
const iface = workspace.interfaceImports.find((entry) => entry.revisionId === id);
if (iface) {
interfaces.set(id, iface);
visit(iface.members);
}
}
return {
...base,
interfaces: [...interfaces.values()],
packages: base.packages.map((entry) => {
const candidate = workspace.packageImports.find((value) => value.revisionId === entry.revisionId);
if (!candidate) throw new Error(`Missing dependency package ${entry.revisionId}`);
return candidate;
}),
};
};
export type TypeScriptBindingOptions = { export type TypeScriptBindingOptions = {
runtimeModule?: string; runtimeModule?: string;
/** Each export must implement MessageBinding<T>, providing both TS type and wire codec. */ /** Each export must implement MessageBinding<T>, providing both TS type and wire codec. */
messages?: Record<string, { module: string; export: string }>; messages?: Record<string, { module: string; export: string }>;
}; };
export function generatePackageDescriptor(schema: BindingSchema, revisionId: string): string { 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}`); 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` + return (
pkg.exports.map(entry => `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.displayName)} }\n`).join(""); `# 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 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"; const unit = (type: ValueType) => type.kind === "builtin" && type.name === "unit";
export const generateTypeScriptBindings = ( 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");
if (schema.interfaces.some((entry) => entry.template))
throw new Error("Package bindings require closed interface applications, not generic definitions");
const pkg = schema.packages.find((entry) => entry.revisionId === packageRevisionId); const pkg = schema.packages.find((entry) => entry.revisionId === packageRevisionId);
if (!pkg) throw new Error(`Unknown package revision ${packageRevisionId}`); if (!pkg) throw new Error(`Unknown package revision ${packageRevisionId}`);
const messages = new Map<string, string>(); const messages = new Map<string, string>();
const type = (value: ValueType): string => { const type = (value: ValueType): string => {
switch (value.kind) { switch (value.kind) {
case "builtin": return value.name === "unit" ? "null" : "QxWatchHandle"; case "builtin":
case "scalar": return ({ bool: "boolean", bytes: "Uint8Array", string: "string", int64: "bigint", uint64: "bigint", return value.name === "unit" ? "null" : "QxWatchHandle";
double: "number", int32: "number", uint32: "number" })[value.name]; case "scalar":
case "object-ref": return `QxObjectRef<${q(value.expectation.kind === "atom" ? `atom:${value.expectation.atomId}` : `interface:${value.expectation.interfaceRevisionId}`)}>`; return {
case "optional": return `(${type(value.value)} | null)`; bool: "boolean",
case "list": return `Array<${type(value.value)}>`; bytes: "Uint8Array",
case "record": return `{ ${Object.entries(value.fields).map(([name, field]) => `${q(name)}: ${type(field)}`).join("; ")} }`; 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": { 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}`); if (!messages.has(value.descriptorId)) messages.set(value.descriptorId, `message${messages.size}`);
return `BindingValue<typeof ${messages.get(value.descriptorId)}>`; return `BindingValue<typeof ${messages.get(value.descriptorId)}>`;
} }
@@ -53,7 +139,7 @@ export const generateTypeScriptBindings = (
}; };
const ref = (target: { kind: "atom"; atomId: string } | { kind: "interface"; interfaceRevisionId: string }) => const ref = (target: { kind: "atom"; atomId: string } | { kind: "interface"; interfaceRevisionId: string }) =>
`QxObjectRef<${q(target.kind === "atom" ? `atom:${target.atomId}` : `interface:${target.interfaceRevisionId}`)}>`; `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 port = (entry: DependencyPort): { type: string; spec: unknown } => {
const requirement = entry.requirement; const requirement = entry.requirement;
switch (requirement.kind) { switch (requirement.kind) {
@@ -69,76 +155,166 @@ export const generateTypeScriptBindings = (
case "edge": { case "edge": {
const methods = requirement.primitives.map((primitive): [string, string] => { const methods = requirement.primitives.map((primitive): [string, string] => {
if (primitive === "resolve") return [primitive, `() => Promise<Array<${ref(requirement.target)}>>`]; if (primitive === "resolve") return [primitive, `() => Promise<Array<${ref(requirement.target)}>>`];
if (primitive === "connect" || primitive === "disconnect") return [primitive, `(target: ${ref(requirement.target)}) => Promise<void>`]; if (primitive === "connect" || primitive === "disconnect")
return [primitive, `(target: ${ref(requirement.target)}) => Promise<void>`];
throw new Error(`Edge primitive ${primitive} is not supported by the TypeScript runtime binding yet`); throw new Error(`Edge primitive ${primitive} is not supported by the TypeScript runtime binding yet`);
}); });
if (requirement.primitives.includes("resolve")) methods.push(["collection", `() => Promise<RelationshipCollection<${ref(requirement.target)}>>`]); if (requirement.primitives.includes("resolve"))
if (["resolve", "connect", "disconnect"].every((primitive) => requirement.primitives.includes(primitive as "resolve"))) methods.push(["replace", `(entries: RelationshipEntry<${ref(requirement.target)}>[], expectedRevision: bigint) => Promise<RelationshipCollection<${ref(requirement.target)}>>`]); methods.push(["collection", `() => Promise<RelationshipCollection<${ref(requirement.target)}>>`]);
if (
["resolve", "connect", "disconnect"].every((primitive) =>
requirement.primitives.includes(primitive as "resolve"),
)
)
methods.push([
"replace",
`(entries: RelationshipEntry<${ref(requirement.target)}>[], expectedRevision: bigint) => Promise<RelationshipCollection<${ref(requirement.target)}>>`,
]);
return { type: object(methods), spec: { kind: "edge", id: entry.id, primitives: requirement.primitives } }; return { type: object(methods), spec: { kind: "edge", id: entry.id, primitives: requirement.primitives } };
} }
case "interface": { 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}`); if (!contract) throw new Error(`Missing imported interface contract ${requirement.interfaceRevisionId}`);
// Streaming ports need a future streaming ABI; ordinary calls are fully typed today. // 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") const operations = contract.members.flatMap((member) =>
.map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` }))); member.operations
return { type: object([ .filter((operation) => operation.mode === "call" && operation.scope !== "class")
["objectId", ref({ kind: "interface", interfaceRevisionId: requirement.interfaceRevisionId })], .map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` })),
["live", object(operations.map(operation => [operation.name, `(${params(operation.inputType)}) => Promise<QxLiveValue>`]))], );
...operations.map((operation): [string, string] => [operation.name, `(${params(operation.inputType)}) => Promise<${type(operation.outputType)}>`]), return {
]), type: object([
spec: { kind: "interface", id: entry.id, operations: Object.fromEntries(operations.map(({name, id, inputType, outputType}) => ["objectId", ref({ kind: "interface", interfaceRevisionId: requirement.interfaceRevisionId })],
[name, {id, inputType, outputType}])) } }; [
"live",
object(
operations.map((operation) => [
operation.name,
`(${params(operation.inputType)}) => Promise<QxLiveValue>`,
]),
),
],
...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": { case "constructor": {
const input = requirement.inputType; 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`); if (!input)
return { type: object([["construct", `(${params(input)}) => Promise<${ref({ kind: "atom", atomId: requirement.atomId })}>`]]), throw new Error(
spec: { kind: "constructor", id: entry.id, inputType: input } }; `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<string>(); const names = new Set<string>();
const specs: Record<string, unknown> = {}; const specs: Record<string, unknown> = {};
const contexts: [string, string][] = []; const contexts: [string, string][] = [];
const handlers: [string, string][] = []; const handlers: [string, string][] = [];
const results: [string, string][] = [];
for (const entry of exports) { for (const entry of exports) {
if (names.has(entry.displayName)) throw new Error(`Duplicate export name ${entry.displayName}`); if (names.has(entry.displayName)) throw new Error(`Duplicate export name ${entry.displayName}`);
names.add(entry.displayName); names.add(entry.displayName);
const ports = entry.dependencyPorts.map((dependency) => ({ name: dependency.displayName, ...port(dependency) })); 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}`); if (new Set(ports.map((p) => p.name)).size !== ports.length)
const receiver = entry.kind === "constructor" ? ref({ kind: "atom", atomId: entry.constructsAtom }) : throw new Error(`Duplicate dependency name in ${entry.displayName}`);
entry.kind === "operation" && entry.receiverRequirement.kind === "exact-atom" ? const receiver =
ref({ kind: "atom", atomId: entry.receiverRequirement.atomId }) : entry.kind === "constructor"
entry.kind === "operation" && entry.receiverRequirement.kind === "all-interfaces" ? ? ref({ kind: "atom", atomId: entry.constructsAtom })
`QxObjectRef<${entry.receiverRequirement.interfaceRevisionIds.map((id) => q(`interface:${id}`)).join(" | ") || "never"}>` : "QxObjectRef<string>"; : entry.kind === "operation" && entry.receiverRequirement.kind === "exact-atom"
const contextShape = object([["objectId", receiver], ["input", type(entry.inputType)], ? ref({ kind: "atom", atomId: entry.receiverRequirement.atomId })
["ports", object(ports.map((port) => [port.name, port.type]))]]); : entry.kind === "operation" && entry.receiverRequirement.kind === "all-interfaces"
contexts.push([entry.displayName, `${contextShape} & QxContextLifecycle<${contextShape} & {signal?: AbortSignal}>`]); ? `QxObjectRef<${entry.receiverRequirement.interfaceRevisionIds.map((id) => q(`interface:${id}`)).join(" | ") || "never"}>`
: "QxObjectRef<string>";
const contextShape = object([
...(entry.kind === "function" ? [] : [["objectId", receiver] as [string, string]]),
["input", type(entry.inputType)],
["ports", object(ports.map((port) => [port.name, port.type]))],
]);
contexts.push([
entry.displayName,
entry.kind === "function"
? `${contextShape} & {signal?: AbortSignal}`
: `${contextShape} & QxContextLifecycle<${contextShape} & {signal?: AbortSignal}>`,
]);
const event = entry.kind === "operation" ? entry.eventType : undefined; const event = entry.kind === "operation" ? entry.eventType : undefined;
const contextType = `Contexts[${q(entry.displayName)}]`; const contextType = `Contexts[${q(entry.displayName)}]`;
const outputType = type(event ?? entry.outputType); const outputType = type(event ?? entry.outputType);
results.push([entry.displayName, outputType]);
// Watch-start handlers produce events through the runtime's derived stream protocol. // Watch-start handlers produce events through the runtime's derived stream protocol.
handlers.push([entry.displayName, event ? `QxDerived<${contextType}, ${outputType}>` : if (!entry.application)
`QxHandler<${contextType}, ${outputType}>${entry.kind === "operation" && entry.mode === "call" ? ` | QxDerived<${contextType}, ${outputType}>` : ""}`]); handlers.push([
specs[entry.displayName] = { inputType: entry.inputType, outputType: entry.outputType, entry.displayName,
...(event ? { eventType: event } : {}), ports: Object.fromEntries(ports.map((port) => [port.name, port.spec])) }; event
? `QxDerived<${contextType}, ${outputType}>`
: `QxHandler<${contextType}, ${outputType}>${entry.kind === "operation" && entry.mode === "call" ? ` | QxDerived<${contextType}, ${outputType}>` : ""}`,
]);
specs[entry.displayName] = {
...(entry.kind === "function" ? { receiver: "none" } : {}),
inputType: entry.inputType,
outputType: entry.outputType,
...(event ? { eventType: event } : {}),
ports: Object.fromEntries(ports.map((port) => [port.name, port.spec])),
};
}
for (const definition of pkg.genericExports ?? []) {
handlers.push([
definition.displayName,
genericImplementationType(definition, [...schema.interfaces, ...(schema.interfaceTemplates ?? [])], type),
]);
} }
const imports = [...messages].map(([id, alias]) => { const imports = [...messages].map(([id, alias]) => {
const binding = options.messages![id]!; 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)};`; return `import { ${binding.export} as ${alias} } from ${q(binding.module)};`;
}); });
const signatures = `${object(contexts)} ${object(handlers)}`; const signatures = `${object(contexts)} ${object(handlers)} ${object(results)}`;
const typeImports = ["BindingValue", "QxObjectRef", "QxWatchHandle", "QxHandler", "QxDerived", "QxContextLifecycle", "QxLiveValue", "RelationshipCollection", "RelationshipEntry"].filter((name) => new RegExp(`\\b${name}\\b`).test(signatures)); const typeImports = [
return `// Generated by quixos-codegen-ts. Do not edit. Binding ABI version 1.\n` + "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` + `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") +
`export type Contexts = ${object(contexts)};\nexport type Implementation = ${object(handlers)};\n` + `\nexport const packageRevisionId = ${q(pkg.revisionId)};\n` +
`declare const appliedType: unique symbol;\ntype QxApplied<Definition extends string, Arguments extends readonly unknown[]> = string & {readonly [appliedType]: (value: [Definition, Arguments]) => [Definition, Arguments]};\n` +
`export type Contexts = ${object(contexts)};\nexport type Results = ${object(results)};\nexport type Implementation = ${object(handlers)};\n` +
`const messages = { ${[...messages].map(([id, alias]) => `${q(id)}: ${alias}`).join(", ")} } satisfies QxMessages;\n` + `const messages = { ${[...messages].map(([id, alias]) => `${q(id)}: ${alias}`).join(", ")} } satisfies QxMessages;\n` +
`const specs = ${JSON.stringify(specs, null, 2)} satisfies Record<string, QxHandlerSpec>;\n` + `const specs = ${JSON.stringify(specs, null, 2)} satisfies Record<string, QxHandlerSpec>;\n` +
`export const createRuntime = (implementation: Implementation) => ({\n packageRevisionId,\n exports: {\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") + exports
`\n },\n});\n`; .map(
(entry) =>
` ${q(entry.id)}: bindQxHandler(specs[${q(entry.displayName)}], implementation[${q(entry.application ? pkg.genericExports!.find((definition) => definition.id === entry.application!.exportId)!.displayName : entry.displayName)}], messages),`,
)
.join("\n") +
`\n },\n});\n`
);
}; };
+48
View File
@@ -0,0 +1,48 @@
/** One authoring contract, distributed by scaffolding and immutable codegen.
* Web Studio checks its concrete exports against this contract during its build. */
export const reactPlatformTypes = `// Generated by the Quixos React platform contract. Do not edit.
declare module "@quixos/web-studio-react-runtime" {
import type * as React from "react";
export function useComponentOverlayContainer(): HTMLElement;
export function useComponentStyleRoot(): ShadowRoot;
export type ObjectRef<AtomId extends string> = string & {
readonly $quixosAtom: AtomId;
};
export type FieldCapability = {objectId: string; interfaceRevisionId: string; getOperationId: string; watchOperationId?: string; setOperationId?: string; inputKind?: "fields" | "value"};
export type ReadableField<T> = {readonly value?: T; readonly capability: FieldCapability};
/** set(T) uses the resolved capability: native CRDT fields send incremental edits;
* custom/manual setters receive semantic values. Storage provenance grants no authority. */
export type WritableField<T> = ReadableField<T> & {readonly $writeType?: (value: T) => T; readonly writable: true; readonly capability: FieldCapability & {setOperationId: string}};
export type LiveFieldProp<T> = ReadableField<T>;
export type InterfaceReference<I extends string, Fields> = {readonly $quixosRef: string; readonly interfaceRevisionId: I; readonly fields: Fields};
export type ReactComponentHostProps<Action> = {
onAction?: (action: Action) => void;
fallback?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
/** Optional extra action. The host still displays and reports errors by default. */
extraErrorAction?: (error: Error) => void;
};
export type ReactComponentImplementationProps<CaminoProps, RenderProps, Action> = {
camino: CaminoProps;
render: RenderProps;
dispatch: (action: Action) => void;
};
export const createWebStudioComponent: <
ForObject extends ObjectRef<string>, RenderProps extends object, Action,
>(config: { expectedAtomId: string }) => (props: {
forObject: ForObject;
} & RenderProps & ReactComponentHostProps<Action>) => any;
export const invokeCapability: <Result = unknown>(
objectId: string,
interfaceRevisionId: string,
operationId: string,
value?: unknown,
options?: {clientMutationId?: string; signal?: AbortSignal},
) => Promise<Result>;
export const h: typeof React.createElement;
export function useLiveField<T>(field: ReadableField<T>, options: {write: (value: T) => Promise<void>}): readonly [T, (value: T) => Promise<void>];
export function useLiveField<T>(field: WritableField<T>): readonly [T, (value: T) => Promise<void>];
export function useLiveField<T>(field: ReadableField<T>): readonly [T];
}
`;
+87
View File
@@ -0,0 +1,87 @@
import type { BindingSchema } from "./index.js";
import type { ValueType } from "../capability-model/types.js";
/** Browser projection of the SAME checked RPC types, not a second props schema. */
export function generateReactBindings(
schema: BindingSchema,
packageRevisionId: string,
propsExports: readonly string[],
components: readonly { module: string; propsExport: string }[] = [],
) {
const pkg = schema.packages.find((entry) => entry.revisionId === packageRevisionId);
if (!pkg) throw new Error(`Unknown package ${packageRevisionId}`);
const q = JSON.stringify;
const references = new Map<string, string>();
const declarations: string[] = [];
const type = (value: ValueType): string => {
switch (value.kind) {
case "builtin":
if (value.name !== "unit") throw new Error(`Unsupported React props builtin ${value.name}`);
return "null";
case "scalar":
return {
string: "string",
bool: "boolean",
bytes: "Uint8Array",
int64: "bigint",
uint64: "bigint",
int32: "number",
uint32: "number",
double: "number",
}[value.name];
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":
throw new Error(`React props require checked values, not opaque message ${value.descriptorId}`);
case "object-ref": {
if (value.expectation.kind === "atom") return `ObjectRef<${q(value.expectation.atomId)}>`;
const id = value.expectation.interfaceRevisionId;
const existing = references.get(id);
if (existing) return existing;
const name = `Interface${references.size}`;
references.set(id, name);
const iface = schema.interfaces.find((entry) => entry.revisionId === id);
if (!iface) throw new Error(`Missing React reference contract ${id}`);
const fields = iface.members.flatMap((member) => {
if (member.kind === "operation") return [];
const get = member.operations.find((op) => op.displayName === (member.kind === "value" ? "get" : "resolve"));
if (!get) return [];
const writable = member.kind === "value" && member.operations.some((op) => op.displayName === "set");
return [`${q(member.displayName)}: ${writable ? "WritableField" : "ReadableField"}<${type(get.outputType)}>`];
});
declarations.push(`export type ${name} = InterfaceReference<${q(id)}, {${fields.join(";")}}> ;`);
return name;
}
}
};
// Only exports explicitly selected as props are projected. Non-props exports
// may legitimately use opaque messages or types unrelated to the browser.
const selected = propsExports.map((name) => {
const entry = pkg.exports.find((candidate) => candidate.displayName === name);
if (!entry) throw new Error(`Unknown React props export ${name}`);
return entry;
});
const results = selected.map(
(entry) =>
`${q(entry.displayName)}: ${type(entry.kind === "operation" ? (entry.eventType ?? entry.outputType) : entry.outputType)}`,
);
const checks = components.map((component, i) => {
if (!propsExports.includes(component.propsExport))
throw new Error(`Component refers to unselected props export ${component.propsExport}`);
if (!component.module.startsWith("."))
throw new Error("React component check must name a local module relative to generated bindings");
return `type Component${i} = CheckedComponent<${q(component.propsExport)}, typeof import(${q(component.module)})["default"]>;`;
});
return (
`// Generated from checked QX contracts. Do not edit.\nimport type {ObjectRef, InterfaceReference, ReadableField, WritableField} from "@quixos/web-studio-react-runtime";\n${declarations.join("\n")}\nexport type ReactResults = {${results.join(";\n")}};\n` +
(checks.length
? `type CheckedComponent<K extends keyof ReactResults, C extends (props: {camino: ReactResults[K]; render: any; dispatch: (action: any) => void}) => unknown> = C;\n${checks.join("\n")}\n`
: "")
);
}
+104 -91
View File
@@ -64,11 +64,9 @@ export type CompiledCapabilityResourceRepository = {
const sourceKey = (kind: LockedResource["kind"], source: GitSource) => const sourceKey = (kind: LockedResource["kind"], source: GitSource) =>
`${kind}\0${source.repository}\0${source.commit.toLowerCase()}`; `${kind}\0${source.repository}\0${source.commit.toLowerCase()}`;
const bindingKey = (kind: LockedResource["kind"], binding: string) => const bindingKey = (kind: LockedResource["kind"], binding: string) => `${kind}\0${binding}`;
`${kind}\0${binding}`;
const importsKey = (entry: CapabilityResourceImport | LockedResource) => const importsKey = (entry: CapabilityResourceImport | LockedResource) => bindingKey(entry.kind, entry.binding);
bindingKey(entry.kind, entry.binding);
const assertImportsMatchLock = ( const assertImportsMatchLock = (
label: string, label: string,
@@ -77,22 +75,23 @@ const assertImportsMatchLock = (
) => { ) => {
const authored = [...imports].map(importsKey).sort(); const authored = [...imports].map(importsKey).sort();
const locked = [...resources].map(importsKey).sort(); const locked = [...resources].map(importsKey).sort();
if ( if (authored.length !== locked.length || authored.some((entry, index) => entry !== locked[index])) {
authored.length !== locked.length ||
authored.some((entry, index) => entry !== locked[index])
) {
throw new Error( throw new Error(
`${label} imports do not match quixos.lock:\n` + `${label} imports do not match quixos.lock:\n` +
`authored: ${authored.join(", ") || "none"}\n` + `authored: ${authored.join(", ") || "none"}\n` +
`locked: ${locked.join(", ") || "none"}`, `locked: ${locked.join(", ") || "none"}`,
); );
} }
}; };
const exactRevisions = <Revision extends { const exactRevisions = <
revisionId: string; Revision extends {
source: SourceRevision; revisionId: string;
}>(revisions: readonly Revision[]) => { source: SourceRevision;
},
>(
revisions: readonly Revision[],
) => {
const seen = new Set<string>(); const seen = new Set<string>();
return revisions.filter((revision) => { return revisions.filter((revision) => {
const key = `${revision.revisionId}\0${revision.source.repository}\0${revision.source.commit}`; const key = `${revision.revisionId}\0${revision.source.repository}\0${revision.source.commit}`;
@@ -122,19 +121,20 @@ const diagnosticsMessage = (
message: string; message: string;
path?: string; path?: string;
}[], }[],
) => `${label} did not compile:\n${diagnostics.map((entry) => { ) =>
const location = entry.line > 0 `${label} did not compile:\n${diagnostics
? `${entry.fileName}:${entry.line}:${entry.column + 1}` .map((entry) => {
: `${entry.fileName}${entry.path ? `:${entry.path}` : ""}`; const location =
return `${location}: ${entry.phase} ${entry.code}: ${entry.message}`; entry.line > 0
}).join("\n")}`; ? `${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) => const revisionFor = (node: ResolvedCapabilityResource) => node.resource.revision;
node.resource.revision;
const resourceClosure = ( const resourceClosure = (roots: readonly ResolvedCapabilityResource[]): ResolvedCapabilityResource[] => {
roots: readonly ResolvedCapabilityResource[],
): ResolvedCapabilityResource[] => {
const result: ResolvedCapabilityResource[] = []; const result: ResolvedCapabilityResource[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const visit = (node: ResolvedCapabilityResource) => { const visit = (node: ResolvedCapabilityResource) => {
@@ -151,25 +151,29 @@ const environmentFor = (
direct: readonly [LockedResource, ResolvedCapabilityResource][], direct: readonly [LockedResource, ResolvedCapabilityResource][],
closure: readonly ResolvedCapabilityResource[], closure: readonly ResolvedCapabilityResource[],
): CapabilityImportEnvironment => ({ ): CapabilityImportEnvironment => ({
interfaces: new Map(direct.flatMap(([locked, node]) => interfaces: new Map(
node.resource.kind === "interface" direct.flatMap(([locked, node]) =>
? [[locked.binding, node.resource.revision] as const] node.resource.kind === "interface" ? [[locked.binding, node.resource.revision] as const] : [],
: [])), ),
packages: new Map(direct.flatMap(([locked, node]) => ),
node.resource.kind === "package" packages: new Map(
? [[locked.binding, node.resource.revision] as const] 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) => interfaceClosure: exactRevisions(
node.resource.kind === "package" ? [node.resource.revision] : [])), closure.flatMap((node) => [
...(node.resource.kind === "interface" ? [node.resource.revision] : []),
...(node.resource.specializations ?? []),
]),
),
packageClosure: exactRevisions(
closure.flatMap((node) => (node.resource.kind === "package" ? [node.resource.revision] : [])),
),
externalAtoms: exactAtoms(closure.flatMap((node) => node.resource.externalAtoms)), externalAtoms: exactAtoms(closure.flatMap((node) => node.resource.externalAtoms)),
}); });
const createResourceGraphResolver = ( const createResourceGraphResolver = (quixosCommit: string, resolveResource: CapabilityRepositoryResolver) => {
quixosCommit: string,
resolveResource: CapabilityRepositoryResolver,
) => {
const resolved = new Map<string, Promise<ResolvedCapabilityResource>>(); const resolved = new Map<string, Promise<ResolvedCapabilityResource>>();
const active: string[] = []; const active: string[] = [];
@@ -188,7 +192,7 @@ const createResourceGraphResolver = (
const pending = (async () => { const pending = (async () => {
active.push(key); active.push(key);
try { 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")); const lockResult = await loadQuixosLock(path.join(snapshot.directory, "quixos.lock"));
if (!lockResult.ok) { if (!lockResult.ok) {
throw new Error(diagnosticsMessage(`${locked.kind} ${locked.binding} lock`, lockResult.diagnostics)); throw new Error(diagnosticsMessage(`${locked.kind} ${locked.binding} lock`, lockResult.diagnostics));
@@ -196,22 +200,20 @@ const createResourceGraphResolver = (
if (lockResult.lock.quixos.policy) { if (lockResult.lock.quixos.policy) {
throw new Error( throw new Error(
`${locked.kind} ${locked.binding} lock declares workspace Quixos policy ` + `${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()) { if (lockResult.lock.quixos.commit.toLowerCase() !== quixosCommit.toLowerCase()) {
throw new Error( throw new Error(
`${locked.kind} ${locked.binding} selects Quixos ${lockResult.lock.quixos.commit}, ` + `${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]> = []; const directPairs: Array<[LockedResource, ResolvedCapabilityResource]> = [];
for (const dependency of lockResult.lock.resources) { for (const dependency of lockResult.lock.resources) {
directPairs.push([dependency, await visit(dependency)]); directPairs.push([dependency, await visit(dependency)]);
} }
const dependencyClosure = resourceClosure( const dependencyClosure = resourceClosure(directPairs.map(([, node]) => node));
directPairs.map(([, node]) => node),
);
const environment = environmentFor(directPairs, dependencyClosure); const environment = environmentFor(directPairs, dependencyClosure);
const manifestName = locked.kind === "interface" ? "interface.qx" : "package.qx"; const manifestName = locked.kind === "interface" ? "interface.qx" : "package.qx";
const manifestPath = path.join(snapshot.directory, manifestName); const manifestPath = path.join(snapshot.directory, manifestName);
@@ -225,22 +227,28 @@ const createResourceGraphResolver = (
throw new Error(diagnosticsMessage(`${locked.kind} ${locked.binding}`, compiled.diagnostics)); throw new Error(diagnosticsMessage(`${locked.kind} ${locked.binding}`, compiled.diagnostics));
} }
if (compiled.resource.kind !== locked.kind) { if (compiled.resource.kind !== locked.kind) {
throw new Error( throw new Error(`${manifestPath} declares ${compiled.resource.kind}, not ${locked.kind}`);
`${manifestPath} declares ${compiled.resource.kind}, not ${locked.kind}`,
);
} }
assertImportsMatchLock(manifestPath, compiled.resource.imports, lockResult.lock.resources); assertImportsMatchLock(manifestPath, compiled.resource.imports, lockResult.lock.resources);
if (compiled.resource.kind === "package") { if (compiled.resource.kind === "package") {
let catalogText: string | undefined; let catalogText: string | undefined;
try { catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8"); } try {
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } 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) { 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); const root = await realpath(snapshot.directory);
for (const migration of catalog.migrations) { for (const migration of catalog.migrations) {
const implementation = await realpath(path.join(root, migration.implementation.file)); 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 (!implementation.startsWith(`${root}${path.sep}`))
if (contentDigest(await readFile(implementation, "utf8")) !== migration.implementation.digest) throw new Error(`Migration implementation digest mismatch: ${migration.id}`); 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; compiled.resource.revision.migrationCatalog = catalog;
} }
@@ -252,10 +260,9 @@ const createResourceGraphResolver = (
directory: snapshot.directory, directory: snapshot.directory,
lock: lockResult.lock, lock: lockResult.lock,
resource: compiled.resource, resource: compiled.resource,
dependencies: new Map(directPairs.map(([dependency, node]) => [ dependencies: new Map(
bindingKey(dependency.kind, dependency.binding), directPairs.map(([dependency, node]) => [bindingKey(dependency.kind, dependency.binding), node]),
node, ),
])),
}; };
} finally { } finally {
active.pop(); active.pop();
@@ -281,15 +288,15 @@ export const compileCapabilityResourceRepository = async (options: {
if (!lockResult.ok) { if (!lockResult.ok) {
throw new Error(diagnosticsMessage("Resource lock", lockResult.diagnostics)); throw new Error(diagnosticsMessage("Resource lock", lockResult.diagnostics));
} }
const resolver = createResourceGraphResolver( const resolver = createResourceGraphResolver(lockResult.lock.quixos.commit, options.resolveResource);
lockResult.lock.quixos.commit, const root = await resolver.visit(
options.resolveResource, {
kind: options.kind,
binding: "<root>",
source: options.source,
},
{ directory: options.rootDirectory },
); );
const root = await resolver.visit({
kind: options.kind,
binding: "<root>",
source: options.source,
}, { directory: options.rootDirectory });
return { return {
resource: root.resource, resource: root.resource,
lock: root.lock, lock: root.lock,
@@ -307,9 +314,7 @@ export const compileWorkspaceRepository = async (options: {
/** An editor's proposed source snapshot; locks and dependency revisions remain exact. */ /** An editor's proposed source snapshot; locks and dependency revisions remain exact. */
readSource?: (name: string) => Promise<string>; readSource?: (name: string) => Promise<string>;
}): Promise<CompiledWorkspaceRepository> => { }): Promise<CompiledWorkspaceRepository> => {
const rootLockResult = await loadQuixosLock( const rootLockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock"));
path.join(options.rootDirectory, "quixos.lock"),
);
if (!rootLockResult.ok) { if (!rootLockResult.ok) {
throw new Error(diagnosticsMessage("Workspace lock", rootLockResult.diagnostics)); throw new Error(diagnosticsMessage("Workspace lock", rootLockResult.diagnostics));
} }
@@ -323,17 +328,25 @@ export const compileWorkspaceRepository = async (options: {
const nodes = await resolver.nodes(); const nodes = await resolver.nodes();
const environment = environmentFor(directPairs, nodes); const environment = environmentFor(directPairs, nodes);
const workspacePath = path.join(options.rootDirectory, "workspace.qx"); 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); const compiled = compileCapabilitySource(sources.source, workspacePath, environment);
if (!compiled.ok) { if (!compiled.ok) {
throw new Error(diagnosticsMessage("Workspace", compiled.diagnostics.map((diagnostic) => throw new Error(
diagnostic.line > 0 ? { ...diagnostic, ...sources.originalPosition(diagnostic.line, diagnostic.column) } : diagnostic))); diagnosticsMessage(
"Workspace",
compiled.diagnostics.map((diagnostic) =>
diagnostic.line > 0
? { ...diagnostic, ...sources.originalPosition(diagnostic.line, diagnostic.column) }
: diagnostic,
),
),
);
} }
assertImportsMatchLock(workspacePath, compiled.imports, rootLock.resources); assertImportsMatchLock(workspacePath, compiled.imports, rootLock.resources);
const availableInterfaceIds = new Set( const availableInterfaceIds = new Set(
nodes.flatMap((node) => node.resource.kind === "interface" nodes.flatMap((node) => (node.resource.kind === "interface" ? [node.resource.revision.revisionId] : [])),
? [node.resource.revision.revisionId]
: []),
); );
const availableAtomIds = new Set(compiled.workspace.atoms.map((atom) => atom.id)); const availableAtomIds = new Set(compiled.workspace.atoms.map((atom) => atom.id));
for (const node of nodes) { for (const node of nodes) {
@@ -341,7 +354,7 @@ export const compileWorkspaceRepository = async (options: {
if (!availableInterfaceIds.has(requirement.revisionId)) { if (!availableInterfaceIds.has(requirement.revisionId)) {
throw new Error( throw new Error(
`${node.kind} ${node.resource.revision.displayName} requires external interface ` + `${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 +362,31 @@ export const compileWorkspaceRepository = async (options: {
if (!availableAtomIds.has(atom.id)) { if (!availableAtomIds.has(atom.id)) {
throw new Error( throw new Error(
`${node.kind} ${node.resource.revision.displayName} requires external atom ` + `${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 = { const workspace: WorkspaceRevision = {
...compiled.workspace, ...compiled.workspace,
...(options.workspaceId ...(options.workspaceId ? { workspaceId: capabilityId.workspace(options.workspaceId) } : {}),
? { workspaceId: capabilityId.workspace(options.workspaceId) }
: {}),
...(options.workspaceRevisionId ...(options.workspaceRevisionId
? { id: capabilityId.workspaceRevision(options.workspaceRevisionId) } ? { id: capabilityId.workspaceRevision(options.workspaceRevisionId) }
: options.sourceRootCommit ? { id: capabilityId.workspaceRevision(`workspace-revision:${options.workspaceId ?? compiled.workspace.workspaceId}:${options.sourceRootCommit}`) } : {}), : options.sourceRootCommit
...(options.sourceRootCommit ? {
? { sourceRootCommit: options.sourceRootCommit } id: capabilityId.workspaceRevision(
: {}), `workspace-revision:${options.workspaceId ?? compiled.workspace.workspaceId}:${options.sourceRootCommit}`,
),
}
: {}),
...(options.sourceRootCommit ? { sourceRootCommit: options.sourceRootCommit } : {}),
}; };
const checked = compileWorkspaceRevision(workspace); const checked = compileWorkspaceRevision(workspace);
if (!checked.ok) { if (!checked.ok) {
throw new Error( throw new Error(
`Instantiated workspace did not compile:\n${checked.issues.map((entry) => `Instantiated workspace did not compile:\n${checked.issues
`${entry.path}: ${entry.message}`).join("\n")}`, .map((entry) => `${entry.path}: ${entry.message}`)
.join("\n")}`,
); );
} }
return { return {
@@ -378,9 +394,6 @@ export const compileWorkspaceRepository = async (options: {
plan: checked.plan, plan: checked.plan,
lock: rootLock, lock: rootLock,
resources: nodes, resources: nodes,
directResources: new Map(directPairs.map(([locked, node]) => [ directResources: new Map(directPairs.map(([locked, node]) => [bindingKey(locked.kind, locked.binding), node])),
bindingKey(locked.kind, locked.binding),
node,
])),
}; };
}; };
+105 -24
View File
@@ -1,4 +1,5 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { appendFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { authoringContext } from "./authoring-context.js"; import { authoringContext } from "./authoring-context.js";
@@ -9,77 +10,157 @@ import { buildImmutableCandidate, checkerIdentity } from "./checked-build.js";
import { planEvolution, type WorkspaceRevision, type EvolutionReview } from "../capability-model/index.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 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 started = performance.now();
const timings: Record<string, number> = {}; const timings: Record<string, number> = {};
const context = await authoringContext(start); const context = await authoringContext(start);
const location = await fs.realpath(start); const location = await fs.realpath(start);
const directory = location === context.workbench ? "root" : path.relative(context.workbench, location); 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"); if (!resource) throw new Error("Run check from a registered repository root or the workbench");
await fs.mkdir(output, { mode: 0o700 }); 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[] } = { const report: {
directory, checker: checkerIdentity(), candidateOnly: true, activationEvidence: false, blockers: [], phase: "convergence", output, 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));
await fs.rename(temp, file);
};
await progress();
try { try {
console.error(`[${new Date().toISOString()}] Check: capture source and converge dependencies`);
// Serialize only source capture, not the potentially slow Nix build. // Serialize only source capture, not the potentially slow Nix build.
// Repository-scoped agents can check separate immutable candidates in parallel. // Repository-scoped agents can check separate immutable candidates in parallel.
const captured = await promisify(callback)("quixos-qx", ["converge", context.workbench, directory], { const capture = promisify(callback)("quixos-qx", ["converge", context.workbench, directory], {
maxBuffer: 4 * 1024 * 1024, env: {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"}, maxBuffer: 4 * 1024 * 1024,
}).catch(error => { env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_TRACE_CAPTURE: "1" },
if (typeof error.stdout === "string" && error.stdout.trim().startsWith("{")) return {stdout: error.stdout}; });
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 };
throw error; throw error;
}); });
const converged = JSON.parse(captured.stdout) as Awaited<ReturnType<typeof convergeAuthoring>>; const converged = JSON.parse(captured.stdout) as Awaited<ReturnType<typeof convergeAuthoring>>;
timings.captureMs = Math.round(performance.now() - started); 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) { if (!converged.candidate) {
report.phase = converged.worklist.find(entry => entry.phase !== "dependency")?.phase ?? "convergence"; 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")); throw new Error(
converged.worklist.map((entry) => `${entry.directory} [${entry.phase}]: ${entry.message}`).join("\n"),
);
} }
report.commit = converged.candidate.commit; report.commit = converged.candidate.commit;
report.phase = "verification"; report.phase = "verification";
await progress();
const buildStarted = performance.now(); const buildStarted = performance.now();
report.artifactPath = await buildImmutableCandidate(converged.candidate, resource.kind, path.join(output, "nix.log"), options.contractOnly); console.error(
timings.immutableCheckMs = Math.round(performance.now() - buildStarted); `[${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,
);
} finally {
timings.immutableCheckMs = Math.round(performance.now() - buildStarted);
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"); const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8");
await fs.writeFile(path.join(output, "candidate.json"), candidateText); await fs.writeFile(path.join(output, "candidate.json"), candidateText);
report.compilation = "passed"; report.compilation = "passed";
if (resource.kind === "workspace" && !options.contractOnly) { if (resource.kind === "workspace" && !options.contractOnly) {
report.phase = "evolution"; report.phase = "evolution";
await progress();
let baseline = options.baseline; let baseline = options.baseline;
if (!baseline) { if (!baseline) {
try { try {
const host = JSON.parse(await fs.readFile("/etc/quixos/workspace-source.json", "utf8")); 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; if ((await fs.realpath(host.workbenchRoot)) === context.workbench)
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } 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 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 reviews = options.reviews
? (JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[])
: [];
const evolution = planEvolution(before, JSON.parse(candidateText), { reviews }); const evolution = planEvolution(before, JSON.parse(candidateText), { reviews });
await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2)); await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2));
report.blockers.push(...evolution.blockers); report.blockers.push(...evolution.blockers);
report.migrationRequired = evolution.migrationRequired; report.migrationRequired = evolution.migrationRequired;
report.activationReadiness = evolution.blockers.length ? "blocked" : evolution.migrationRequired.length ? "migration-required" : "preserve"; report.activationReadiness = evolution.blockers.length
if (evolution.migrationRequired.length) report.blockers.push(`Explicit migration required for: ${evolution.migrationRequired.join(", ")}. Compilation passed; supply a migration path before cutover.`); ? "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"; 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); timings.totalMs = Math.round(performance.now() - started);
Object.assign(report, {timings}); Object.assign(report, { timings });
await fs.writeFile(path.join(output, "report.json"), JSON.stringify(report, null, 2)); await progress(false);
if (options.contractOnly) return report; if (options.contractOnly) return report;
const records = path.join(context.workbench, ".quixos/checks"); const records = path.join(context.workbench, ".quixos/checks");
await fs.mkdir(records, { recursive: true }); await fs.mkdir(records, { recursive: true });
const remember = async (value: typeof report) => { 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.writeFile(temporary, JSON.stringify(value, null, 2), { flag: "wx", mode: 0o600 });
await fs.rename(temporary, filename); await fs.rename(temporary, filename);
}; };
if (report.artifactPath) { if (report.artifactPath) {
const graph = JSON.parse(await fs.readFile(path.join(report.artifactPath, "graph.json"), "utf8")); const graph = JSON.parse(await fs.readFile(path.join(report.artifactPath, "graph.json"), "utf8"));
for (const checked of graph.resources) { for (const checked of graph.resources) {
const managed = context.resources.find(entry => entry.kind === checked.kind && entry.source?.repository === checked.source.repository); const managed = context.resources.find(
if (managed && managed.directory !== directory) await remember({...report, directory: managed.directory, commit: checked.source.commit, blockers: [], phase: "checked"}); (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); await remember(report);
+34 -13
View File
@@ -14,33 +14,54 @@ export async function authoringContext(start: string) {
let workbench = await realpath(start); let workbench = await realpath(start);
for (;;) { for (;;) {
let text: string | undefined; let text: string | undefined;
try { text = await readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"); } try {
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } 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) { if (text !== undefined) {
const graph = JSON.parse(text) as { resources: AuthoringResource[] }; const graph = JSON.parse(text) as { resources: AuthoringResource[] };
if (!Array.isArray(graph.resources)) throw new Error("Managed resource inventory is malformed"); if (!Array.isArray(graph.resources)) throw new Error("Managed resource inventory is malformed");
const resources: AuthoringResource[] = [{ kind: "workspace", directory: "root" }]; const resources: AuthoringResource[] = [{ kind: "workspace", directory: "root" }];
const identities = new Set<string>(), directories = new Set<string>(["root"]); const identities = new Set<string>(),
directories = new Set<string>(["root"]);
for (const entry of graph.resources) { for (const entry of graph.resources) {
// Compiler graphs carry resolved paths; the authoring API presents // Compiler graphs carry resolved paths; the authoring API presents
// stable workbench-relative names and validates containment here. // 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 (typeof entry.directory === "string" && path.isAbsolute(entry.directory))
if (!["interface", "package"].includes(entry.kind) || !entry.source || entry.directory = path.relative(workbench, entry.directory);
!/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(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"); throw new Error("Invalid managed resource registration");
} }
const identity = `${entry.kind}\0${entry.resourceId ?? entry.source.repository}`; const identity = `${entry.kind}\0${entry.resourceId ?? entry.source.repository}`;
if (identities.has(identity) || directories.has(entry.directory)) { if (identities.has(identity) || directories.has(entry.directory)) {
throw new Error(`Multiple editable selections for ${entry.resourceId ?? entry.source.repository}`); throw new Error(`Multiple editable selections for ${entry.resourceId ?? entry.source.repository}`);
} }
identities.add(identity); directories.add(entry.directory); identities.add(identity);
resources.push({kind: entry.kind, directory: entry.directory, resourceId: entry.resourceId, source: entry.source}); directories.add(entry.directory);
resources.push({
kind: entry.kind,
directory: entry.directory,
resourceId: entry.resourceId,
source: entry.source,
});
} }
return { workbench, resources, async baseline() { return {
const result = await loadQuixosLock(path.join(workbench, "root/quixos.lock")); workbench,
if (!result.ok) throw new Error(`Workspace source baseline is invalid: ${result.diagnostics.map(d => d.message).join("; ")}`); resources,
return result.lock.quixos; 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); const parent = path.dirname(workbench);
if (parent === workbench) throw new Error("Not in a managed workbench; select one with --workbench DIRECTORY"); if (parent === workbench) throw new Error("Not in a managed workbench; select one with --workbench DIRECTORY");
+132 -48
View File
@@ -5,16 +5,40 @@ import { promisify } from "node:util";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { authoringContext } from "./authoring-context.js"; import { authoringContext } from "./authoring-context.js";
import { snapshotCommit } from "./checked-build.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 execFile = promisify(callback);
const command = async (cwd: string, executable: string, args: string[]) => (await execFile(executable, args, { const command = async (cwd: string, executable: string, args: string[]) => {
cwd, maxBuffer: 4 * 1024 * 1024, const start = performance.now();
env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0", GIT_TERMINAL_PROMPT: "0" }, // Do not log arguments: transports may contain credentials. Source identities
})).stdout.trim(); // 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`);
}
};
const identity = (kind: string, repository: string) => `${kind}\0${repository}`; 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 /** Source retention only. Neither successful convergence nor an empty source
* worklist grants typechecking, semantic review or activation approval. * worklist grants typechecking, semantic review or activation approval.
@@ -30,92 +54,136 @@ export async function convergeAuthoring(start: string, target = "root") {
const root = path.join(context.workbench, entry.directory); const root = path.join(context.workbench, entry.directory);
let repository = entry.source?.repository; let repository = entry.source?.repository;
try { 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. // Transport rewrites must not become committed source identities.
const origin = await command(root, "git", ["config", "--get", "remote.origin.url"]); 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; repository ??= origin;
} catch (error) { } 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. if (!repository) throw error; // The root has no separate registered source.
} }
const key = identity(entry.kind, repository); const key = identity(entry.kind, repository);
if (selected.has(key)) throw new Error(`More than one editable checkout for ${repository}`); if (selected.has(key)) throw new Error(`More than one editable checkout for ${repository}`);
selected.set(key, entry.directory); 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()) { for (const node of nodes.values()) {
try { try {
const lock = await loadQuixosLock(path.join(context.workbench, node.directory, "quixos.lock")); 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")); 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 => { node.dependencies = [
const directory = selected.get(identity(entry.kind, entry.source.repository)); ...new Set(
return directory ? [directory] : []; lock.lock.resources.flatMap((entry) => {
}))]; const directory = selected.get(identity(entry.kind, entry.source.repository));
} catch (error) { blockers.push({ directory: node.directory, phase: "resolution", message: String(error) }); } return directory ? [directory] : [];
}),
),
];
} catch (error) {
blockers.push({ directory: node.directory, phase: "resolution", message: String(error) });
}
} }
const complete = new Map<string, GitSource>(), active = new Set<string>(); const complete = new Map<string, GitSource>(),
active = new Set<string>();
const visited = new Set<string>(); const visited = new Set<string>();
if (!nodes.has(target)) throw new Error(`Not a registered repository: ${target}`); if (!nodes.has(target)) throw new Error(`Not a registered repository: ${target}`);
const visit = async (directory: string): Promise<boolean> => { const visit = async (directory: string): Promise<boolean> => {
visited.add(directory); visited.add(directory);
if (complete.has(directory)) return true; if (complete.has(directory)) return true;
if (blockers.some(entry => entry.directory === directory)) 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; } if (active.has(directory)) {
blockers.push({
directory,
phase: "dependency",
message: `Source dependency cycle: ${[...active, directory].join(" -> ")}`,
});
return false;
}
active.add(directory); active.add(directory);
const node = nodes.get(directory)!; const node = nodes.get(directory)!;
for (const dependency of node.dependencies) if (!await visit(dependency)) { for (const dependency of node.dependencies)
blockers.push({ directory, phase: "dependency", message: `Waiting for ${dependency}` }); active.delete(directory); return false; 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); const root = path.join(context.workbench, directory);
let phase: AuthoringBlocker["phase"] = "source"; let phase: AuthoringBlocker["phase"] = "source";
try { try {
const lock = await loadQuixosLock(path.join(root, "quixos.lock")); const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
if (!lock.ok) throw new Error("Lock changed during convergence; retry after joining writers"); if (!lock.ok) throw new Error("Lock changed during convergence; retry after joining writers");
for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) { 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); const parsed = parseQuixosLockDocument(before, file);
if (!parsed.ok) throw new Error(`Invalid lock ${file}`); if (!parsed.ok) throw new Error(`Invalid lock ${file}`);
let changed = false; let changed = false;
for (const dependency of parsed.document.resources) { for (const dependency of parsed.document.resources) {
const target = selected.get(identity(dependency.kind, dependency.source.repository)); const target = selected.get(identity(dependency.kind, dependency.source.repository));
const source = target ? complete.get(target) : undefined; const source = target ? complete.get(target) : undefined;
if (target && !source) throw new Error(`Dependencies changed during convergence (${dependency.binding}); join writers and retry`); if (target && !source)
if (source && source.commit !== dependency.source.commit) { dependency.source = source; changed = true; } 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) { if (changed) {
const temporary = `${filename}.${randomUUID()}.tmp`; const temporary = `${filename}.${randomUUID()}.tmp`;
try { try {
await writeFile(temporary, formatQuixosLockDocument(parsed.document), { flag: "wx" }); 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); 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); 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`,
);
phase = "publication"; phase = "publication";
const ref = retentionTagForCommit(commit); const ref = retentionTagForCommit(commit);
const remote = await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref]); 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 && 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 (!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 }); 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); active.delete(directory);
return complete.has(directory); return complete.has(directory);
}; };
// Include newly created, not-yet-imported resources, then the root. // 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); 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) { 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"); } try {
catch (error) { blockers.push({ directory, phase: "concurrent-edit", message: String(error) }); } 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. // Persist successful selections even if another repository is still broken.
// Recovery must not depend on all parents succeeding in the same invocation. // Recovery must not depend on all parents succeeding in the same invocation.
const graph = JSON.parse(graphBefore); 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<string, string>(); const replacements = new Map<string, string>();
for (const resource of graph.resources) { for (const resource of graph.resources) {
const source = complete.get(resource.directory); const source = complete.get(resource.directory);
@@ -123,30 +191,36 @@ export async function convergeAuthoring(start: string, target = "root") {
const key = `${resource.kind}\0${source.repository}\0${source.commit}`; const key = `${resource.kind}\0${source.repository}\0${source.commit}`;
replacements.set(resource.key, key); replacements.set(resource.key, key);
if (resource.source.commit !== source.commit) delete resource.revisionId; 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 ?? []) { for (const resource of graph.resources)
dependency.resourceKey = replacements.get(dependency.resourceKey) ?? dependency.resourceKey; 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 direct of graph.directResources ?? [])
direct.resourceKey = replacements.get(direct.resourceKey) ?? direct.resourceKey;
// Inventory is a projection of actual locks, including newly added/removed // Inventory is a projection of actual locks, including newly added/removed
// imports. Never require a successful parent compilation to repair it. // imports. Never require a successful parent compilation to repair it.
for (const [directory] of complete) { for (const [directory] of complete) {
const lock = await loadQuixosLock(path.join(context.workbench, directory, "quixos.lock")); const lock = await loadQuixosLock(path.join(context.workbench, directory, "quixos.lock"));
if (!lock.ok) continue; if (!lock.ok) continue;
const dependencies = lock.lock.resources.map(dependency => ({ const dependencies = lock.lock.resources.map((dependency) => ({
binding: `${dependency.kind}\0${dependency.binding}`, binding: `${dependency.kind}\0${dependency.binding}`,
resourceKey: `${dependency.kind}\0${dependency.source.repository}\0${dependency.source.commit}`, resourceKey: `${dependency.kind}\0${dependency.source.repository}\0${dependency.source.commit}`,
})); }));
if (directory === "root") { if (directory === "root") {
graph.quixos = lock.lock.quixos; graph.quixos = lock.lock.quixos;
graph.directResources = lock.lock.resources.map((dependency, index) => ({ 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)) ...(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 { } 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; if (resource) resource.dependencies = dependencies;
} }
} }
@@ -155,12 +229,22 @@ export async function convergeAuthoring(start: string, target = "root") {
const temporary = `${graphFile}.${randomUUID()}.tmp`; const temporary = `${graphFile}.${randomUUID()}.tmp`;
try { try {
await writeFile(temporary, graphAfter, { flag: "wx", mode: 0o600 }); 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); await rename(temporary, graphFile);
} finally { await rm(temporary, { force: true }); } } finally {
await rm(temporary, { force: true });
}
} }
return { workbench: context.workbench, converged: blockers.length === 0, return {
candidate: blockers.length ? null : complete.get(target) ?? null, workbench: context.workbench,
converged: blockers.length === 0,
candidate: blockers.length ? null : (complete.get(target) ?? null),
retained: [...complete].map(([directory, source]) => ({ directory, source })), retained: [...complete].map(([directory, source]) => ({ directory, source })),
worklist: blockers, verificationEvidence: false, activated: false }; worklist: blockers,
verificationEvidence: false,
activated: false,
};
} }
+61 -24
View File
@@ -5,48 +5,74 @@ import path from "node:path";
import { parseQx, walkSyntax } from "./source.js"; import { parseQx, walkSyntax } from "./source.js";
import { authoringContext } from "./authoring-context.js"; import { authoringContext } from "./authoring-context.js";
import { readQxSource } from "./source-loader.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 execFile = promisify(callback);
const git = async (root: string, args: string[]) => (await execFile("git", ["-C", root, ...args], { const git = async (root: string, args: string[]) =>
maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, (
})).stdout; 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); const message = (error: unknown) => String(error instanceof Error ? error.message : error).slice(0, 2000);
/** Syntax-only contract inspection is deliberately NOT verification evidence. /** Syntax-only contract inspection is deliberately NOT verification evidence.
* Each file can recover independently; current valid files always win. */ * Each file can recover independently; current valid files always win. */
export async function inspectAuthoringRepository(root: string, historyLimit = 100) { export async function inspectAuthoringRepository(root: string, historyLimit = 100) {
const names = (await git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"])) const names = (await git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]))
.split("\0").filter(name => name.endsWith(".qx")); .split("\0")
if (names.length > 128) throw new Error("Repository inspection exceeds 128 QX files; split the resource into smaller repositories"); .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 = []; const files = [];
for (const name of [...new Set(names)].sort()) { 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 { try {
source = await readQxSource(root, name); source = await readQxSource(root, name);
if (source.length > 262144) throw new Error(`Inspection file exceeds 256 KiB: ${name}`); if (source.length > 262144) throw new Error(`Inspection file exceeds 256 KiB: ${name}`);
errors = parseQx(source, name).diagnostics; errors = parseQx(source, name).diagnostics;
} catch (error) { errors = [{ message: message(error) }]; } } catch (error) {
errors = [{ message: message(error) }];
}
const currentErrors = errors; const currentErrors = errors;
if (errors.length) { if (errors.length) {
// Git can traverse jj's immutable commit DAG without mutating/snapshotting @. // 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"], 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"); 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(() => ""); const commits = await git(root, ["rev-list", `--max-count=${historyLimit}`, head, "--", name]).catch(() => "");
for (const commit of commits.trim().split("\n").filter(Boolean)) { for (const commit of commits.trim().split("\n").filter(Boolean)) {
const historical = await git(root, ["show", `${commit}:${name}`]).catch(() => null); const historical = await git(root, ["show", `${commit}:${name}`]).catch(() => null);
if (historical === null || historical.length > 262144) continue; if (historical === null || historical.length > 262144) continue;
if (!parseQx(historical, name).diagnostics.length) { 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); const syntax = errors.length ? null : parseQx(source, name);
files.push({ file: name, status: errors.length ? "unavailable" : revision ? "historical" : "current", files.push({
revision, currentErrors: currentErrors.slice(0, 20), omittedErrors: Math.max(0, currentErrors.length - 20), file: name,
declarations: syntax ? [...walkSyntax(syntax.root)] status: errors.length ? "unavailable" : revision ? "historical" : "current",
.filter(node => /^(?:interface|package|atom|state|edge|method|function|event|conformance)\w*Decl$/.test(node.kind)) revision,
.map(node => ({ kind: node.kind, source: source.slice(node.start, node.end) })) : [], 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 }; 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); const context = await authoringContext(start);
if (!selector || selector === ".") { if (!selector || selector === ".") {
const relative = path.relative(context.workbench, await realpath(start)); 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 lock = await loadQuixosLock(path.join(context.workbench, "root/quixos.lock"));
const aliases = lock.ok ? lock.lock.resources.filter(entry => entry.binding === selector) : []; const aliases = lock.ok ? lock.lock.resources.filter((entry) => entry.binding === selector) : [];
const selected = context.resources.filter(entry => !selector || selector === entry.directory || const selected = context.resources.filter(
selector === entry.resourceId || selector === path.basename(entry.directory) || aliases.some(alias => alias.kind === entry.kind && alias.source.repository === entry.source?.repository)); (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 (!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 = []; const resources = [];
for (const entry of selected) { for (const entry of selected) {
try { try {
const root = path.join(context.workbench, entry.directory); const root = path.join(context.workbench, entry.directory);
if (await realpath(root) !== root) throw new Error("Managed checkout crosses a symlink"); if ((await realpath(root)) !== root) throw new Error("Managed checkout crosses a symlink");
resources.push({ ...entry, ...await inspectAuthoringRepository(root) }); resources.push({ ...entry, ...(await inspectAuthoringRepository(root)) });
} catch (error) { resources.push({ ...entry, error: message(error) }); } } catch (error) {
resources.push({ ...entry, error: message(error) });
}
} }
return { workbench: context.workbench, verificationEvidence: false, resources }; return { workbench: context.workbench, verificationEvidence: false, resources };
} }
+85 -26
View File
@@ -11,50 +11,109 @@ import { checkerIdentity } from "./checked-build.js";
const execFile = promisify(callback); const execFile = promisify(callback);
export async function authoringWorklist(start: string) { export async function authoringWorklist(start: string) {
const context = await authoringContext(start); 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<string, string[]>(); const dependencies = new Map<string, string[]>();
for (const resource of context.resources) { for (const resource of context.resources) {
const root = path.join(context.workbench, resource.directory); const root = path.join(context.workbench, resource.directory);
const add = (phase: string, message: string) => entries.push({directory: resource.directory, resourceId: resource.resourceId, phase, message, const add = (phase: string, message: string) =>
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`}); 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 { 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); 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")); const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
if (!lock.ok) add("resolution", lock.diagnostics.map(entry => `${entry.fileName}: ${entry.message}`).join("\n")); if (!lock.ok)
else dependencies.set(resource.directory, lock.lock.resources.flatMap(dependency => { add("resolution", lock.diagnostics.map((entry) => `${entry.fileName}: ${entry.message}`).join("\n"));
const selected = context.resources.find(entry => entry.kind === dependency.kind && entry.source?.repository === dependency.source.repository); else
if (selected?.source && selected.source.commit !== dependency.source.commit) add("propagation", `Dependency ${dependency.binding} has advanced; check will repin it automatically`); dependencies.set(
return selected ? [selected.directory] : []; 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; let record;
try { record = JSON.parse(await fs.readFile(path.join(context.workbench, ".quixos/checks", checkRecordName(resource.directory)), "utf8")); } try {
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } 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?.commit) {
if (record?.blockers?.length) add(record.phase, record.blockers.join("\n")); if (record?.blockers?.length) add(record.phase, record.blockers.join("\n"));
else add("unchecked", "No immutable candidate check recorded yet"); else add("unchecked", "No immutable candidate check recorded yet");
continue; continue;
} }
if (record.checker !== checkerIdentity()) add("unchecked", "The installed checker changed since the last check"); if (record.checker !== checkerIdentity()) add("unchecked", "The installed checker changed since the last check");
const env = {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"}; 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 commit = (
const dirty = await execFile("git", ["diff", "--quiet", "--no-ext-diff", record.commit, "--"], {cwd: root}).then(() => false, () => true); await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], {
const untracked = (await execFile("git", ["ls-files", "--others", "--exclude-standard"], {cwd: root})).stdout; cwd: root,
if (commit !== record.commit || dirty || untracked) add("unchecked", `Edits are newer than the last check (${record.commit.slice(0, 12)})`); 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")); 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. // 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; let changed = true;
while (changed) { while (changed) {
changed = false; changed = false;
for (const [directory, required] of dependencies) if (!blocked.has(directory)) { for (const [directory, required] of dependencies)
const waiting = required.filter(dependency => blocked.has(dependency)); if (!blocked.has(directory)) {
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"}); } 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, return {
note: "Derived authoring guidance, not activation approval. Independent repositories can be delegated separately; join writers before a root check." }; 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.",
};
} }
+162 -49
View File
@@ -6,34 +6,51 @@ import { promisify } from "node:util";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js"; import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js";
import { createGitCapabilityResolver } from "./git-resolver.js"; import { createGitCapabilityResolver } from "./git-resolver.js";
import { contentDigest, planEvolution, type EvolutionReview, type WorkspaceRevision } from "../capability-model/index.js"; import {
import { bindingSchema } from "../bindings/index.js"; contentDigest,
import {snapshotCommit, checkoutCommit, buildCheckedPackage} from "./checked-build.js"; planEvolution,
type EvolutionReview,
type WorkspaceRevision,
} from "../capability-model/index.js";
import { bindingSchema, specializeBindingSchema } from "../bindings/index.js";
import { snapshotCommit, checkoutCommit, buildCheckedPackage } from "./checked-build.js";
const execFile = promisify(execFileCallback); const execFile = promisify(execFileCallback);
const bytesDigest = (value: Uint8Array) => `sha256:${createHash("sha256").update(value).digest("hex")}`; 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) { if (filename) {
const document = JSON.parse(await fs.readFile(filename, "utf8")); 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); let directory = await fs.realpath(root);
for (;;) { for (;;) {
let graphText: string | undefined; let graphText: string | undefined;
try {graphText = await fs.readFile(path.join(directory, ".quixos/resource-graph.json"), "utf8");} try {
catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} 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) { if (graphText !== undefined) {
const graph = JSON.parse(graphText); const graph = JSON.parse(graphText);
const resources = []; const resources = [];
for (const entry of graph.resources) { for (const entry of graph.resources) {
const location = await fs.realpath(path.resolve(directory, entry.directory)); const location = await fs.realpath(path.resolve(directory, entry.directory));
if (!location.startsWith(`${directory}/resources/`)) throw new Error("Workbench resource escapes managed directory"); if (!location.startsWith(`${directory}/resources/`))
resources.push({kind: entry.kind, ...entry.source, directory: location}); 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); const parent = path.dirname(directory);
if (parent === directory) return {resources: []}; if (parent === directory) return { resources: [] };
directory = parent; 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. */ /** Copy actual authoring files without snapshotting jj or creating a Git commit. */
export const snapshotRepository = async (source: string, destination: string) => { export const snapshotRepository = async (source: string, destination: string) => {
const root = await fs.realpath(source); 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())]; const names = [...new Set(await files())];
if (names.length > 50_000) throw new Error("Candidate source exceeds 50000 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; let bytes = 0;
await fs.mkdir(destination, { recursive: true, mode: 0o700 }); await fs.mkdir(destination, { recursive: true, mode: 0o700 });
for (const name of names) { 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); const file = path.join(root, name);
let metadata; let metadata;
try { metadata = await fs.lstat(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; } try {
if (!metadata.isFile() || metadata.isSymbolicLink() || !(await fs.realpath(file)).startsWith(`${root}${path.sep}`)) throw new Error(`Candidate source must be a regular file: ${name}`); 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); const data = await fs.readFile(file);
bytes += data.length; bytes += data.length;
if (bytes > 128 * 1024 * 1024) throw new Error("Candidate source exceeds 128 MiB"); 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.mkdir(path.dirname(path.join(destination, name)), { recursive: true });
await fs.writeFile(path.join(destination, name), data, { flag: "wx", mode: metadata.mode & 0o777 }); 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"); if (JSON.stringify([...new Set(await files())]) !== JSON.stringify(names))
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}`); 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 }; return { source: root, directory: destination, treeDigest: contentDigest(contents), files: contents };
}; };
// Local mirrors accelerate resolution, but only their committed locked trees // Local mirrors accelerate resolution, but only their committed locked trees
// may stand in for published dependencies. Never relabel dirty files as a pin. // may stand in for published dependencies. Never relabel dirty files as a pin.
async function committedResolver(root: string, temporary: string, filename?: string, publishedOnly = false) { 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 = []; const resources = [];
for (const [index, entry] of map.resources.entries()) { for (const [index, entry] of map.resources.entries()) {
const directory = path.join(temporary, `dependency-${index}`); const directory = path.join(temporary, `dependency-${index}`);
await checkoutCommit(entry.directory, entry.commit, directory); await checkoutCommit(entry.directory, entry.commit, directory);
resources.push({...entry, directory}); resources.push({ ...entry, directory });
} }
const snapshotMap = path.join(temporary, "snapshots.json"); const snapshotMap = path.join(temporary, "snapshots.json");
await fs.writeFile(snapshotMap, JSON.stringify({resources})); await fs.writeFile(snapshotMap, JSON.stringify({ resources }));
return createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resolved"), snapshotMap}); 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}) => { export const checkResourceCandidate = async (options: {
await fs.mkdir(options.output, {mode: 0o700}); 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 temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-resource-check-"));
const blockers: string[] = []; const blockers: string[] = [];
let treeDigest: string | undefined, commit: string | undefined, artifactPath: string | undefined; let treeDigest: string | undefined, commit: string | undefined, artifactPath: string | undefined;
@@ -90,62 +132,133 @@ export const checkResourceCandidate = async (options: {root: string; output: str
treeDigest = (await snapshotRepository(options.root, path.join(temporary, "observed"))).treeDigest; treeDigest = (await snapshotRepository(options.root, path.join(temporary, "observed"))).treeDigest;
const root = path.join(temporary, "source"); const root = path.join(temporary, "source");
await checkoutCommit(options.root, commit, root); await checkoutCommit(options.root, commit, root);
const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap, options.publishedOnly); const resolveResource = await committedResolver(
const compiled = await compileCapabilityResourceRepository({rootDirectory: root, kind: options.kind, source: {resolver: "git", repository: options.source.repository, commit}, resolveResource}); 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") { if (compiled.resource.kind === "package") {
const schema = path.join(temporary, "bindings.json"); const schema = path.join(temporary, "bindings.json");
await fs.writeFile(schema, JSON.stringify(bindingSchema(compiled))); await fs.writeFile(schema, JSON.stringify(bindingSchema(compiled)));
artifactPath = await buildCheckedPackage(root, schema, compiled.resource.revision.revisionId); 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)); await fs.writeFile(path.join(options.output, "candidate.json"), JSON.stringify(compiled.resource, null, 2));
} catch (error) { } catch (error) {
blockers.push(error instanceof Error ? error.message : String(error)); blockers.push(error instanceof Error ? error.message : String(error));
} finally {await fs.rm(temporary, {recursive: true, force: true});} } finally {
const result = {candidateOnly: true, activationEvidence: false, commit, treeDigest, artifactPath, blockers, await fs.rm(temporary, { recursive: true, force: true });
note: "Checked immutable candidate; cutover independently checks current migration/review requirements. No publication or activation performed."}; }
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)); await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
return result; return result;
}; };
export const checkWorkspaceCandidate = async (options: {root: string; output: string; snapshotMap?: string; baseline?: string; reviews?: string}) => { export const checkWorkspaceCandidate = async (options: {
await fs.mkdir(options.output, {mode: 0o700}); 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 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; let commit: string | undefined;
try { try {
commit = await snapshotCommit(options.root); commit = await snapshotCommit(options.root);
for (const entry of (await localResourceSnapshots(options.root, options.snapshotMap)).resources) { for (const entry of (await localResourceSnapshots(options.root, options.snapshotMap)).resources) {
const current = await snapshotCommit(entry.directory); const current = await snapshotCommit(entry.directory);
const tree = async (revision: string) => (await execFile("git", ["rev-parse", `${revision}^{tree}`], {cwd: entry.directory})).stdout.trim(); const tree = async (revision: string) =>
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.`); (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"); const root = path.join(temporary, "source");
await checkoutCommit(options.root, commit, root); await checkoutCommit(options.root, commit, root);
const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap); const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap);
const compiled = await compileWorkspaceRepository({rootDirectory: root, sourceRootCommit: commit, resolveResource}); const compiled = await compileWorkspaceRepository({
const baseline = options.baseline ? JSON.parse(await fs.readFile(options.baseline, "utf8")) as WorkspaceRevision : null; rootDirectory: root,
const reviews = options.reviews ? JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[] : []; sourceRootCommit: commit,
const evolution = planEvolution(baseline, compiled.workspace, {reviews}); 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); 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 // Per-package recursive schema, identical to host activation, not unrelated
// workspace declarations that would unnecessarily invalidate build caches. // 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"); const schema = path.join(temporary, "bindings.json");
await fs.writeFile(schema, JSON.stringify(bindingSchema(candidate))); await fs.writeFile(
const artifactPath = await buildCheckedPackage(resource.directory, schema, candidate.resource.revision.revisionId); schema,
checks.push({packageRevisionId: candidate.resource.revision.revisionId, artifactPath}); JSON.stringify(
specializeBindingSchema(bindingSchema(candidate), compiled.workspace, candidate.resource.revision.revisionId),
),
);
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"); if ((await snapshotCommit(options.root)) !== commit)
const result = {schemaVersion: 1, candidateOnly: true, activationEvidence: false, commit, evolution, checks, blockers, throw new Error("Source changed during verification; run the check again");
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."}; 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, "candidate.json"), JSON.stringify(compiled.workspace, null, 2));
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2)); await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
return result; return result;
} catch (error) { } 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)); await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
return result; return result;
} finally {await fs.rm(temporary, {recursive: true, force: true});} } finally {
await fs.rm(temporary, { recursive: true, force: true });
}
}; };
+132 -43
View File
@@ -1,38 +1,52 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; 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 { createWriteStream } from "node:fs";
import {promisify} from "node:util"; import { promisify } from "node:util";
import {fileURLToPath} from "node:url"; import { fileURLToPath } from "node:url";
const execFile = promisify(callback); const execFile = promisify(callback);
const environment = () => ({...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", GIT_TERMINAL_PROMPT: "0"}); 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)), "../.."); 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. */ /** Checking snapshots jj, but never publishes or activates the working copy. */
export async function snapshotCommit(root: string): Promise<string> { export async function snapshotCommit(root: string): Promise<string> {
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"); await run("status");
// jj resolve --list exits 1 on a clean revision. Query structured revision // jj resolve --list exits 1 on a clean revision. Query structured revision
// metadata instead of depending on diagnostic wording or swallowing errors. // 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"); 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"); 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()}); await execFile("git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"], {
const {stdout} = await execFile("git", ["ls-files", "--others", "--exclude-standard", "-z"], {cwd: root}); 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"); if (stdout) throw new Error("Source contains files not captured by jj; inspect jj tracking before verification");
return commit; return commit;
} }
/** Use Git's actual committed tree, never dirty overlays labelled as old pins. */ /** Use Git's actual committed tree, never dirty overlays labelled as old pins. */
export async function checkoutCommit(root: string, commit: string, destination: string) { export async function checkoutCommit(root: string, commit: string, destination: string) {
await fs.mkdir(destination, {recursive: true}); await fs.mkdir(destination, { recursive: true });
const archive = await execFile("git", ["archive", "--format=tar", commit], {cwd: root, encoding: "buffer", maxBuffer: 128 * 1024 * 1024}); const archive = await execFile("git", ["archive", "--format=tar", commit], {
cwd: root,
encoding: "buffer",
maxBuffer: 128 * 1024 * 1024,
});
await new Promise<void>((resolve, reject) => { await new Promise<void>((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 = ""; let error = "";
child.stderr.on("data", chunk => {error += chunk;}); child.stderr.on("data", (chunk) => {
error += chunk;
});
child.on("error", reject); 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.on("error", reject);
child.stdin.end(archive.stdout); 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. * not a certificate authored or approved by the workspace agent.
*/ */
export async function buildCheckedPackage(source: string, schema: string, packageRevisionId: string): Promise<string> { export async function buildCheckedPackage(source: string, schema: string, packageRevisionId: string): Promise<string> {
const generator = process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const generator =
if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Run verification with the installed Quixos tooling (its exact Nix checker is required)"); 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"); const builder = path.join(generator, "share/checked-package.nix");
await fs.access(builder); await fs.access(builder);
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const child = spawn("nix", ["build", "--impure", "--file", builder, const child = spawn(
"--argstr", "source", source, "--argstr", "schema", schema, "nix",
"--argstr", "generator", generator, "--argstr", "packageRevisionId", packageRevisionId, [
"--no-link", "--print-out-paths", "-L"], {env: environment(), stdio: ["ignore", "pipe", "inherit"]}); "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 = ""; let output = "";
child.stdout.on("data", chunk => {output += chunk;}); child.stdout.on("data", (chunk) => {
output += chunk;
});
child.on("error", reject); child.on("error", reject);
child.on("close", code => { child.on("close", (code) => {
const artifact = output.trim(); 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); 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 /** 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. */ * 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<string> { export async function buildImmutableCandidate(
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(source.commit)) throw new Error("An immutable candidate requires an exact commit"); source: { repository: string; commit: string },
kind: "workspace" | "interface" | "package",
logFile: string,
contractOnly = false,
): Promise<string> {
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); 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"); if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash)
const generator = process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); 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"); if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Use the installed Quixos checker");
const builder = path.join(generator, "share/checked-candidate.nix"); const builder = path.join(generator, "share/checked-candidate.nix");
await fs.access(builder); await fs.access(builder);
const log = createWriteStream(logFile, { flags: "wx", mode: 0o600 }); const log = createWriteStream(logFile, { flags: "wx", mode: 0o600 });
await new Promise<void>((resolve, reject) => { log.once("open", () => resolve()); log.once("error", reject); }); await new Promise<void>((resolve, reject) => {
log.once("open", () => resolve());
log.once("error", reject);
});
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
let output = "", tail = "", failure: Error | undefined; let output = "",
const child = spawn("nix", ["build", "--impure", "--file", builder, tail = "",
"--argstr", "repository", source.repository, "--argstr", "commit", source.commit, failure: Error | undefined;
"--argstr", "kind", kind, "--argstr", "generator", generator, const child = spawn(
"--arg", "contractOnly", contractOnly ? "true" : "false", "nix",
"--no-link", "--print-out-paths", "-L"], { env: environment(), stdio: ["ignore", "pipe", "pipe"] }); [
log.on("error", error => { failure = error; child.kill(); }); "build",
child.stdout.on("data", chunk => { output += chunk; }); "--impure",
child.stderr.on("data", chunk => { log.write(chunk); tail = (tail + String(chunk)).slice(-6000); }); "--file",
child.on("error", error => { failure = error; }); builder,
child.on("close", code => log.end(() => { "--argstr",
if (failure) reject(failure); "repository",
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}`)); source.repository,
else resolve(output.trim()); "--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());
}),
);
}); });
} }
+31 -37
View File
@@ -35,9 +35,9 @@ const parseArgs = (args: string[]) => {
else positional.push(argument); else positional.push(argument);
} }
if ( if (
positional.length !== 1 positional.length !== 1 ||
|| Boolean(workspaceId) !== Boolean(workspaceRevisionId) Boolean(workspaceId) !== Boolean(workspaceRevisionId) ||
|| (resource && Boolean(workspaceId || workspaceRevisionId || sourceRootCommit)) (resource && Boolean(workspaceId || workspaceRevisionId || sourceRootCommit))
) { ) {
throw new Error(usage); throw new Error(usage);
} }
@@ -50,14 +50,7 @@ const main = async () => {
process.stdout.write(`${usage}\n`); process.stdout.write(`${usage}\n`);
return; return;
} }
const { const { checkOnly, resource, workspaceId, workspaceRevisionId, sourceRootCommit, fileName } = parseArgs(args);
checkOnly,
resource,
workspaceId,
workspaceRevisionId,
sourceRootCommit,
fileName,
} = parseArgs(args);
const source = const source =
fileName === "-" fileName === "-"
? await new Promise<string>((resolve, reject) => { ? await new Promise<string>((resolve, reject) => {
@@ -67,33 +60,33 @@ const main = async () => {
process.stdin.on("error", reject); process.stdin.on("error", reject);
}) })
: await readFile(fileName, "utf8"); : await readFile(fileName, "utf8");
const reportDiagnostics = (diagnostics: readonly { const reportDiagnostics = (
fileName: string; diagnostics: readonly {
line: number; fileName: string;
column: number; line: number;
phase: string; column: number;
code: string; phase: string;
message: string; code: string;
path?: string; message: string;
}[]) => { path?: string;
}[],
) => {
for (const diagnostic of diagnostics) { for (const diagnostic of diagnostics) {
const location = diagnostic.line const location = diagnostic.line
? `${diagnostic.fileName}:${diagnostic.line}:${diagnostic.column + 1}` ? `${diagnostic.fileName}:${diagnostic.line}:${diagnostic.column + 1}`
: `${diagnostic.fileName}${diagnostic.path ? `:${diagnostic.path}` : ""}`; : `${diagnostic.fileName}${diagnostic.path ? `:${diagnostic.path}` : ""}`;
process.stderr.write( process.stderr.write(`${location}: ${diagnostic.phase} ${diagnostic.code}: ${diagnostic.message}\n`);
`${location}: ${diagnostic.phase} ${diagnostic.code}: ${diagnostic.message}\n`,
);
} }
process.exitCode = 1; process.exitCode = 1;
}; };
if (resource) { if (resource) {
const result = compileCapabilityResourceSource(source, { const result = compileCapabilityResourceSource(source, {
source: { source: {
repository: "https://compiler.invalid/resource.git", repository: "https://compiler.invalid/resource.git",
commit: "0000000000000000000000000000000000000000", commit: "0000000000000000000000000000000000000000",
}, },
fileName, fileName,
}); });
if (!result.ok) { if (!result.ok) {
reportDiagnostics(result.diagnostics); reportDiagnostics(result.diagnostics);
return; return;
@@ -106,14 +99,15 @@ const main = async () => {
reportDiagnostics(result.diagnostics); reportDiagnostics(result.diagnostics);
return; return;
} }
const workspace = workspaceId && workspaceRevisionId const workspace =
? { workspaceId && workspaceRevisionId
...result.workspace, ? {
workspaceId: capabilityId.workspace(workspaceId), ...result.workspace,
id: capabilityId.workspaceRevision(workspaceRevisionId), workspaceId: capabilityId.workspace(workspaceId),
...(sourceRootCommit ? { sourceRootCommit } : {}), id: capabilityId.workspaceRevision(workspaceRevisionId),
} ...(sourceRootCommit ? { sourceRootCommit } : {}),
: { ...result.workspace, ...(sourceRootCommit ? { sourceRootCommit } : {}) }; }
: { ...result.workspace, ...(sourceRootCommit ? { sourceRootCommit } : {}) };
const instantiated = compileWorkspaceRevision(workspace); const instantiated = compileWorkspaceRevision(workspace);
if (!instantiated.ok) { if (!instantiated.ok) {
throw new Error(instantiated.issues.map((issue) => `${issue.path}: ${issue.message}`).join("\n")); throw new Error(instantiated.issues.map((issue) => `${issue.path}: ${issue.message}`).join("\n"));
+41 -8
View File
@@ -3,19 +3,52 @@ import { spawn } from "node:child_process";
/** Kernel-owned lock: a crashed coordinator cannot leave a stale ownership file. /** 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. */ * The persistent file is just an inode; EOF releases the helper's lock. */
export async function withFileLock<T>(filename: string, work: () => Promise<T>): Promise<T> { export async function withFileLock<T>(filename: string, work: () => Promise<T>): Promise<T> {
const child = spawn("flock", ["--exclusive", "--timeout", "120", "--conflict-exit-code", "75", filename, const child = spawn(
process.execPath, "-e", 'process.stdout.write("locked\\n"); process.stdin.resume();'], {stdio: ["pipe", "pipe", "pipe"]}); "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 = ""; let diagnostics = "";
child.stdin.on("error", () => { /* acquisition/exit handling reports helper failure */ }); child.stdin.on("error", () => {
child.stderr.on("data", chunk => { diagnostics = (diagnostics + String(chunk)).slice(-2000); }); /* acquisition/exit handling reports helper failure */
const closed = new Promise<void>((resolve) => { child.once("close", () => resolve()); }); });
child.stderr.on("data", (chunk) => {
diagnostics = (diagnostics + String(chunk)).slice(-2000);
});
const closed = new Promise<void>((resolve) => {
child.once("close", () => resolve());
});
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
let output = ""; let output = "";
child.once("error", reject); 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.once("exit", (code) =>
child.stdout.on("data", chunk => { output += chunk; if (output.includes("locked\n")) resolve(); }); 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(); return await work();
} finally { child.stdin.end(); await closed; } } finally {
child.stdin.end();
await closed;
}
} }
File diff suppressed because one or more lines are too long
@@ -1,213 +1,229 @@
WORKSPACE=1 WORKSPACE=1
FRAGMENT=2 TYPE=2
IMPORT=3 OBJECT=3
EXTERNAL=4 STORABLE=4
ATOM=5 IMPLEMENTS=5
INTERFACE=6 REF=6
INTERFACES=7 FRAGMENT=7
PACKAGE=8 IMPORT=8
VALUE=9 EXTERNAL=9
RELATION=10 ATOM=10
OPERATION=11 INTERFACE=11
FUNCTION=12 INTERFACES=12
CONSTRUCTOR=13 PACKAGE=13
CONSTRUCTS=14 VALUE=14
INPUT=15 RELATION=15
CONFORM=16 OPERATION=16
AS=17 FUNCTION=17
BIND=18 CONSTRUCTOR=18
TO=19 CONSTRUCTS=19
PRIVATE=20 INPUT=20
SHARED=21 CONFORM=21
STATE=22 AS=22
EDGE=23 BIND=23
PROJECTION=24 STATIC=24
WITH=25 TO=25
USING=26 PRIVATE=26
VIA=27 SHARED=27
MATERIALIZE=28 STATE=28
IF=29 EDGE=29
ABSENT=30 PROJECTION=30
ON=31 WITH=31
POLICY=32 USING=32
DEFAULT=33 VIA=33
SOURCE=34 MATERIALIZE=34
REPOSITORY=35 IF=35
COMMIT=36 ABSENT=36
REVISION=37 ON=37
SEMANTIC_MAJOR=38 POLICY=38
ON_DELETE=39 DEFAULT=39
RETAIN_OTHER=40 SOURCE=40
KEYED=41 REPOSITORY=41
PUBLIC_TRAVERSAL=42 COMMIT=42
ID=43 REVISION=43
DOC=44 SEMANTIC_MAJOR=44
MODE=45 ON_DELETE=45
EMITS=46 RETAIN_OTHER=46
RECEIVER=47 KEYED=47
REQUIRES=48 PUBLIC_TRAVERSAL=48
ANY=49 ID=49
GET=50 DOC=50
SET=51 MODE=51
WATCH=52 EMITS=52
START=53 RECEIVER=53
STOP=54 REQUIRES=54
READ=55 ANY=55
WRITE=56 GET=56
RESOLVE=57 SET=57
CONNECT=58 WATCH=58
DISCONNECT=59 START=59
CALL=60 STOP=60
WATCH_START=61 READ=61
WATCH_STOP=62 WRITE=62
SUBSCRIBE=63 RESOLVE=63
UNSUBSCRIBE=64 CONNECT=64
OPTIMISTIC_REGISTER=65 DISCONNECT=65
CRDT=66 CALL=66
OPTIONAL_ONE=67 WATCH_START=67
EXACTLY_ONE=68 WATCH_STOP=68
MANY_UNIQUE=69 SUBSCRIBE=69
MANY=70 UNSUBSCRIBE=70
ORDERED=71 OPTIMISTIC_REGISTER=71
UNIT=72 CRDT=72
WATCH_HANDLE=73 OPTIONAL_ONE=73
MESSAGE=74 EXACTLY_ONE=74
ATOM_REF=75 MANY_UNIQUE=75
INTERFACE_REF=76 MANY=76
OPTIONAL=77 ORDERED=77
LIST=78 UNIT=78
RECORD=79 WATCH_HANDLE=79
BOOL=80 MESSAGE=80
BYTES=81 ATOM_REF=81
DOUBLE=82 INTERFACE_REF=82
INT32=83 OPTIONAL=83
INT64=84 LIST=84
STRING=85 RECORD=85
UINT32=86 BOOL=86
UINT64=87 BYTES=87
TRUE=88 DOUBLE=88
FALSE=89 INT32=89
NULL=90 INT64=90
ARROW=91 STRING=91
COLON=92 UINT32=92
SEMI=93 UINT64=93
COMMA=94 TRUE=94
DOT=95 FALSE=95
LBRACE=96 NULL=96
RBRACE=97 ARROW=97
LBRACK=98 COLON=98
RBRACK=99 SEMI=99
LPAREN=100 COMMA=100
RPAREN=101 DOT=101
LT=102 LBRACE=102
GT=103 RBRACE=103
INTEGER=104 LBRACK=104
JSON_NUMBER=105 RBRACK=105
IDENTIFIER=106 LPAREN=106
STRING_LITERAL=107 RPAREN=107
LINE_COMMENT=108 LT=108
BLOCK_COMMENT=109 GT=109
WS=110 AMP=110
EQUAL=111
INTEGER=112
JSON_NUMBER=113
IDENTIFIER=114
STRING_LITERAL=115
LINE_COMMENT=116
BLOCK_COMMENT=117
WS=118
'workspace'=1 'workspace'=1
'fragment'=2 'type'=2
'import'=3 'object'=3
'external'=4 'storable'=4
'atom'=5 'implements'=5
'interface'=6 'ref'=6
'interfaces'=7 'fragment'=7
'package'=8 'import'=8
'value'=9 'external'=9
'relation'=10 'atom'=10
'operation'=11 'interface'=11
'function'=12 'interfaces'=12
'constructor'=13 'package'=13
'constructs'=14 'value'=14
'input'=15 'relation'=15
'conform'=16 'operation'=16
'as'=17 'function'=17
'bind'=18 'constructor'=18
'to'=19 'constructs'=19
'private'=20 'input'=20
'shared'=21 'conform'=21
'state'=22 'as'=22
'edge'=23 'bind'=23
'projection'=24 'static'=24
'with'=25 'to'=25
'using'=26 'private'=26
'via'=27 'shared'=27
'materialize'=28 'state'=28
'if'=29 'edge'=29
'absent'=30 'projection'=30
'on'=31 'with'=31
'policy'=32 'using'=32
'default'=33 'via'=33
'source'=34 'materialize'=34
'repository'=35 'if'=35
'commit'=36 'absent'=36
'revision'=37 'on'=37
'semantic-major'=38 'policy'=38
'on-delete'=39 'default'=39
'retain-other'=40 'source'=40
'keyed'=41 'repository'=41
'public-traversal'=42 'commit'=42
'id'=43 'revision'=43
'doc'=44 'semantic-major'=44
'mode'=45 'on-delete'=45
'emits'=46 'retain-other'=46
'receiver'=47 'keyed'=47
'requires'=48 'public-traversal'=48
'any'=49 'id'=49
'get'=50 'doc'=50
'set'=51 'mode'=51
'watch'=52 'emits'=52
'start'=53 'receiver'=53
'stop'=54 'requires'=54
'read'=55 'any'=55
'write'=56 'get'=56
'resolve'=57 'set'=57
'connect'=58 'watch'=58
'disconnect'=59 'start'=59
'call'=60 'stop'=60
'watch-start'=61 'read'=61
'watch-stop'=62 'write'=62
'subscribe'=63 'resolve'=63
'unsubscribe'=64 'connect'=64
'optimistic-register'=65 'disconnect'=65
'crdt'=66 'call'=66
'optional-one'=67 'watch-start'=67
'exactly-one'=68 'watch-stop'=68
'many-unique'=69 'subscribe'=69
'many'=70 'unsubscribe'=70
'ordered'=71 'optimistic-register'=71
'unit'=72 'crdt'=72
'watch-handle'=73 'optional-one'=73
'message'=74 'exactly-one'=74
'atom-ref'=75 'many-unique'=75
'interface-ref'=76 'many'=76
'optional'=77 'ordered'=77
'list'=78 'unit'=78
'record'=79 'watch-handle'=79
'bool'=80 'message'=80
'bytes'=81 'atom-ref'=81
'double'=82 'interface-ref'=82
'int32'=83 'optional'=83
'int64'=84 'list'=84
'string'=85 'record'=85
'uint32'=86 'bool'=86
'uint64'=87 'bytes'=87
'true'=88 'double'=88
'false'=89 'int32'=89
'null'=90 'int64'=90
'->'=91 'string'=91
':'=92 'uint32'=92
';'=93 'uint64'=93
','=94 'true'=94
'.'=95 'false'=95
'{'=96 'null'=96
'}'=97 '->'=97
'['=98 ':'=98
']'=99 ';'=99
'('=100 ','=100
')'=101 '.'=101
'<'=102 '{'=102
'>'=103 '}'=103
'['=104
']'=105
'('=106
')'=107
'<'=108
'>'=109
'&'=110
'='=111
File diff suppressed because one or more lines are too long
@@ -1,213 +1,229 @@
WORKSPACE=1 WORKSPACE=1
FRAGMENT=2 TYPE=2
IMPORT=3 OBJECT=3
EXTERNAL=4 STORABLE=4
ATOM=5 IMPLEMENTS=5
INTERFACE=6 REF=6
INTERFACES=7 FRAGMENT=7
PACKAGE=8 IMPORT=8
VALUE=9 EXTERNAL=9
RELATION=10 ATOM=10
OPERATION=11 INTERFACE=11
FUNCTION=12 INTERFACES=12
CONSTRUCTOR=13 PACKAGE=13
CONSTRUCTS=14 VALUE=14
INPUT=15 RELATION=15
CONFORM=16 OPERATION=16
AS=17 FUNCTION=17
BIND=18 CONSTRUCTOR=18
TO=19 CONSTRUCTS=19
PRIVATE=20 INPUT=20
SHARED=21 CONFORM=21
STATE=22 AS=22
EDGE=23 BIND=23
PROJECTION=24 STATIC=24
WITH=25 TO=25
USING=26 PRIVATE=26
VIA=27 SHARED=27
MATERIALIZE=28 STATE=28
IF=29 EDGE=29
ABSENT=30 PROJECTION=30
ON=31 WITH=31
POLICY=32 USING=32
DEFAULT=33 VIA=33
SOURCE=34 MATERIALIZE=34
REPOSITORY=35 IF=35
COMMIT=36 ABSENT=36
REVISION=37 ON=37
SEMANTIC_MAJOR=38 POLICY=38
ON_DELETE=39 DEFAULT=39
RETAIN_OTHER=40 SOURCE=40
KEYED=41 REPOSITORY=41
PUBLIC_TRAVERSAL=42 COMMIT=42
ID=43 REVISION=43
DOC=44 SEMANTIC_MAJOR=44
MODE=45 ON_DELETE=45
EMITS=46 RETAIN_OTHER=46
RECEIVER=47 KEYED=47
REQUIRES=48 PUBLIC_TRAVERSAL=48
ANY=49 ID=49
GET=50 DOC=50
SET=51 MODE=51
WATCH=52 EMITS=52
START=53 RECEIVER=53
STOP=54 REQUIRES=54
READ=55 ANY=55
WRITE=56 GET=56
RESOLVE=57 SET=57
CONNECT=58 WATCH=58
DISCONNECT=59 START=59
CALL=60 STOP=60
WATCH_START=61 READ=61
WATCH_STOP=62 WRITE=62
SUBSCRIBE=63 RESOLVE=63
UNSUBSCRIBE=64 CONNECT=64
OPTIMISTIC_REGISTER=65 DISCONNECT=65
CRDT=66 CALL=66
OPTIONAL_ONE=67 WATCH_START=67
EXACTLY_ONE=68 WATCH_STOP=68
MANY_UNIQUE=69 SUBSCRIBE=69
MANY=70 UNSUBSCRIBE=70
ORDERED=71 OPTIMISTIC_REGISTER=71
UNIT=72 CRDT=72
WATCH_HANDLE=73 OPTIONAL_ONE=73
MESSAGE=74 EXACTLY_ONE=74
ATOM_REF=75 MANY_UNIQUE=75
INTERFACE_REF=76 MANY=76
OPTIONAL=77 ORDERED=77
LIST=78 UNIT=78
RECORD=79 WATCH_HANDLE=79
BOOL=80 MESSAGE=80
BYTES=81 ATOM_REF=81
DOUBLE=82 INTERFACE_REF=82
INT32=83 OPTIONAL=83
INT64=84 LIST=84
STRING=85 RECORD=85
UINT32=86 BOOL=86
UINT64=87 BYTES=87
TRUE=88 DOUBLE=88
FALSE=89 INT32=89
NULL=90 INT64=90
ARROW=91 STRING=91
COLON=92 UINT32=92
SEMI=93 UINT64=93
COMMA=94 TRUE=94
DOT=95 FALSE=95
LBRACE=96 NULL=96
RBRACE=97 ARROW=97
LBRACK=98 COLON=98
RBRACK=99 SEMI=99
LPAREN=100 COMMA=100
RPAREN=101 DOT=101
LT=102 LBRACE=102
GT=103 RBRACE=103
INTEGER=104 LBRACK=104
JSON_NUMBER=105 RBRACK=105
IDENTIFIER=106 LPAREN=106
STRING_LITERAL=107 RPAREN=107
LINE_COMMENT=108 LT=108
BLOCK_COMMENT=109 GT=109
WS=110 AMP=110
EQUAL=111
INTEGER=112
JSON_NUMBER=113
IDENTIFIER=114
STRING_LITERAL=115
LINE_COMMENT=116
BLOCK_COMMENT=117
WS=118
'workspace'=1 'workspace'=1
'fragment'=2 'type'=2
'import'=3 'object'=3
'external'=4 'storable'=4
'atom'=5 'implements'=5
'interface'=6 'ref'=6
'interfaces'=7 'fragment'=7
'package'=8 'import'=8
'value'=9 'external'=9
'relation'=10 'atom'=10
'operation'=11 'interface'=11
'function'=12 'interfaces'=12
'constructor'=13 'package'=13
'constructs'=14 'value'=14
'input'=15 'relation'=15
'conform'=16 'operation'=16
'as'=17 'function'=17
'bind'=18 'constructor'=18
'to'=19 'constructs'=19
'private'=20 'input'=20
'shared'=21 'conform'=21
'state'=22 'as'=22
'edge'=23 'bind'=23
'projection'=24 'static'=24
'with'=25 'to'=25
'using'=26 'private'=26
'via'=27 'shared'=27
'materialize'=28 'state'=28
'if'=29 'edge'=29
'absent'=30 'projection'=30
'on'=31 'with'=31
'policy'=32 'using'=32
'default'=33 'via'=33
'source'=34 'materialize'=34
'repository'=35 'if'=35
'commit'=36 'absent'=36
'revision'=37 'on'=37
'semantic-major'=38 'policy'=38
'on-delete'=39 'default'=39
'retain-other'=40 'source'=40
'keyed'=41 'repository'=41
'public-traversal'=42 'commit'=42
'id'=43 'revision'=43
'doc'=44 'semantic-major'=44
'mode'=45 'on-delete'=45
'emits'=46 'retain-other'=46
'receiver'=47 'keyed'=47
'requires'=48 'public-traversal'=48
'any'=49 'id'=49
'get'=50 'doc'=50
'set'=51 'mode'=51
'watch'=52 'emits'=52
'start'=53 'receiver'=53
'stop'=54 'requires'=54
'read'=55 'any'=55
'write'=56 'get'=56
'resolve'=57 'set'=57
'connect'=58 'watch'=58
'disconnect'=59 'start'=59
'call'=60 'stop'=60
'watch-start'=61 'read'=61
'watch-stop'=62 'write'=62
'subscribe'=63 'resolve'=63
'unsubscribe'=64 'connect'=64
'optimistic-register'=65 'disconnect'=65
'crdt'=66 'call'=66
'optional-one'=67 'watch-start'=67
'exactly-one'=68 'watch-stop'=68
'many-unique'=69 'subscribe'=69
'many'=70 'unsubscribe'=70
'ordered'=71 'optimistic-register'=71
'unit'=72 'crdt'=72
'watch-handle'=73 'optional-one'=73
'message'=74 'exactly-one'=74
'atom-ref'=75 'many-unique'=75
'interface-ref'=76 'many'=76
'optional'=77 'ordered'=77
'list'=78 'unit'=78
'record'=79 'watch-handle'=79
'bool'=80 'message'=80
'bytes'=81 'atom-ref'=81
'double'=82 'interface-ref'=82
'int32'=83 'optional'=83
'int64'=84 'list'=84
'string'=85 'record'=85
'uint32'=86 'bool'=86
'uint64'=87 'bytes'=87
'true'=88 'double'=88
'false'=89 'int32'=89
'null'=90 'int64'=90
'->'=91 'string'=91
':'=92 'uint32'=92
';'=93 'uint64'=93
','=94 'true'=94
'.'=95 'false'=95
'{'=96 'null'=96
'}'=97 '->'=97
'['=98 ':'=98
']'=99 ';'=99
'('=100 ','=100
')'=101 '.'=101
'<'=102 '{'=102
'>'=103 '}'=103
'['=104
']'=105
'('=106
')'=107
'<'=108
'>'=109
'&'=110
'='=111
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,12 @@ import { ExternalInterfaceDeclContext } from "./QuixosCapabilityParser.js";
import { ResourcePreambleContext } from "./QuixosCapabilityParser.js"; import { ResourcePreambleContext } from "./QuixosCapabilityParser.js";
import { AtomDeclContext } from "./QuixosCapabilityParser.js"; import { AtomDeclContext } from "./QuixosCapabilityParser.js";
import { InterfaceResourceDeclContext } from "./QuixosCapabilityParser.js"; import { InterfaceResourceDeclContext } from "./QuixosCapabilityParser.js";
import { TypeParametersContext } from "./QuixosCapabilityParser.js";
import { TypeParameterContext } from "./QuixosCapabilityParser.js";
import { InterfaceTypeContext } from "./QuixosCapabilityParser.js";
import { TypeArgumentsContext } from "./QuixosCapabilityParser.js";
import { TypeArgumentContext } from "./QuixosCapabilityParser.js";
import { TypeAliasDeclContext } from "./QuixosCapabilityParser.js";
import { InterfaceMemberContext } from "./QuixosCapabilityParser.js"; import { InterfaceMemberContext } from "./QuixosCapabilityParser.js";
import { OperationMemberContext } from "./QuixosCapabilityParser.js"; import { OperationMemberContext } from "./QuixosCapabilityParser.js";
import { ValueMemberContext } from "./QuixosCapabilityParser.js"; import { ValueMemberContext } from "./QuixosCapabilityParser.js";
@@ -41,6 +47,7 @@ import { EdgeDeclContext } from "./QuixosCapabilityParser.js";
import { EdgeEndpointContext } from "./QuixosCapabilityParser.js"; import { EdgeEndpointContext } from "./QuixosCapabilityParser.js";
import { ConformanceDeclContext } from "./QuixosCapabilityParser.js"; import { ConformanceDeclContext } from "./QuixosCapabilityParser.js";
import { ConformanceItemContext } from "./QuixosCapabilityParser.js"; import { ConformanceItemContext } from "./QuixosCapabilityParser.js";
import { StateFieldBindingDeclContext } from "./QuixosCapabilityParser.js";
import { RelationshipMaterializationDeclContext } from "./QuixosCapabilityParser.js"; import { RelationshipMaterializationDeclContext } from "./QuixosCapabilityParser.js";
import { OperationBindingDeclContext } from "./QuixosCapabilityParser.js"; import { OperationBindingDeclContext } from "./QuixosCapabilityParser.js";
import { MemberOperationRefContext } from "./QuixosCapabilityParser.js"; import { MemberOperationRefContext } from "./QuixosCapabilityParser.js";
@@ -137,6 +144,42 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
* @return the visitor result * @return the visitor result
*/ */
visitInterfaceResourceDecl?: (ctx: InterfaceResourceDeclContext) => Result; visitInterfaceResourceDecl?: (ctx: InterfaceResourceDeclContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.typeParameters`.
* @param ctx the parse tree
* @return the visitor result
*/
visitTypeParameters?: (ctx: TypeParametersContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.typeParameter`.
* @param ctx the parse tree
* @return the visitor result
*/
visitTypeParameter?: (ctx: TypeParameterContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.interfaceType`.
* @param ctx the parse tree
* @return the visitor result
*/
visitInterfaceType?: (ctx: InterfaceTypeContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.typeArguments`.
* @param ctx the parse tree
* @return the visitor result
*/
visitTypeArguments?: (ctx: TypeArgumentsContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.typeArgument`.
* @param ctx the parse tree
* @return the visitor result
*/
visitTypeArgument?: (ctx: TypeArgumentContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.typeAliasDecl`.
* @param ctx the parse tree
* @return the visitor result
*/
visitTypeAliasDecl?: (ctx: TypeAliasDeclContext) => Result;
/** /**
* Visit a parse tree produced by `QuixosCapabilityParser.interfaceMember`. * Visit a parse tree produced by `QuixosCapabilityParser.interfaceMember`.
* @param ctx the parse tree * @param ctx the parse tree
@@ -305,6 +348,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
* @return the visitor result * @return the visitor result
*/ */
visitConformanceItem?: (ctx: ConformanceItemContext) => Result; visitConformanceItem?: (ctx: ConformanceItemContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.stateFieldBindingDecl`.
* @param ctx the parse tree
* @return the visitor result
*/
visitStateFieldBindingDecl?: (ctx: StateFieldBindingDeclContext) => Result;
/** /**
* Visit a parse tree produced by `QuixosCapabilityParser.relationshipMaterializationDecl`. * Visit a parse tree produced by `QuixosCapabilityParser.relationshipMaterializationDecl`.
* @param ctx the parse tree * @param ctx the parse tree
+392
View File
@@ -0,0 +1,392 @@
import {
TypeSubstitution,
GenericTypeError,
instantiateInterface,
appliedInterfaceId,
valueType,
type AtomId,
type ClosedTypeArgument,
type GenericTypeEnvironment,
type InterfaceApplicationExpression,
type InterfaceRevision,
type ObjectTypeExpression,
type TypeArgumentExpression,
type TypeParameter,
type ValueAliasDefinition,
type ValueType,
type ValueTypeExpression,
} from "../capability-model/index.js";
import type {
InterfaceTypeContext,
TargetConstraintContext,
TypeArgumentsContext,
TypeParametersContext,
TypeAliasDeclContext,
ValueTypeContext,
} from "./generated/QuixosCapabilityParser.js";
const literal = (context: { getText(): string }) => JSON.parse(context.getText()) as string;
/** Lexical authoring scope; only `value`/`target` return installed types. */
export class GenericSourceTypes {
readonly interfaces = new Map<string, InterfaceRevision>();
readonly definitions = new Map<string, InterfaceRevision>();
readonly applications = new Map<string, InterfaceRevision>();
readonly aliases = new Map<string, ValueAliasDefinition>();
parameters = new Map<string, TypeParameter>();
self?: AtomId;
private active: string[] = [];
private applicationCount = 0;
constructor(readonly atoms: Map<string, AtomId>) {}
environment(): GenericTypeEnvironment {
return {
arguments: new Map(),
aliases: this.aliases,
self: this.self,
applyInterface: (id, args) => this.apply(id, args).revisionId,
// Obligations are retained on the application and discharged by workspace
// validation, where all atom conformances are known (not source order).
implementsInterface: () => true,
};
}
register(name: string, definition: InterfaceRevision) {
this.interfaces.set(name, definition);
this.definitions.set(definition.revisionId, definition);
}
/** Check authored applications even when nobody has instantiated this template yet. */
validateTemplate(definition: InterfaceRevision) {
const template = definition.template;
if (!template) return;
const aliases = new Map((template.aliases ?? []).map((alias) => [alias.id, alias]));
const parametersById = new Map(
[...template.parameters, ...(template.aliases ?? []).flatMap((alias) => alias.parameters)].map((parameter) => [
parameter.id,
parameter,
]),
);
const replace = (node: unknown, arguments_: Map<string, TypeArgumentExpression>): unknown => {
if (!node || typeof node !== "object") return node;
if ("kind" in node && node.kind === "parameter" && "parameterId" in node) {
const arg = arguments_.get(String(node.parameterId));
if (arg) return arg.kind === "value" ? arg.type : arg.target;
}
if (Array.isArray(node)) return node.map((entry) => replace(entry, arguments_));
return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, replace(value, arguments_)]));
};
const implies = (
actual: InterfaceApplicationExpression,
required: InterfaceApplicationExpression,
seen = new Set<string>(),
): boolean => {
const key = JSON.stringify(actual);
if (key === JSON.stringify(required)) return true;
if (seen.has(key) || seen.size > 128) return false;
seen.add(key);
const contract = this.definitions.get(actual.definitionId);
if (!contract?.template)
return (contract?.requiredInterfaces ?? []).includes(required.definitionId) && required.arguments.length === 0;
const args = new Map(
contract.template.parameters.map((parameter, index) => [parameter.id, actual.arguments[index]]),
);
return contract.template.requires.some((parent) =>
implies(replace(parent, args) as InterfaceApplicationExpression, required, seen),
);
};
const storable = (type: ValueTypeExpression, depth = 0): boolean => {
if (depth > 128)
throw new GenericTypeError(
"type-complexity-limit",
definition.displayName,
"Storable alias expansion exceeds depth limit",
);
if (type.kind === "parameter") {
const parameter = parametersById.get(type.parameterId);
return parameter?.kind === "value" && Boolean(parameter.storable);
}
if (type.kind === "list" || type.kind === "optional") return storable(type.value, depth + 1);
if (type.kind === "alias") {
const alias = aliases.get(type.definitionId);
if (!alias || alias.parameters.length !== type.arguments.length) return false;
return storable(
replace(
alias.body,
new Map(alias.parameters.map((parameter, index) => [parameter.id, type.arguments[index]])),
) as ValueTypeExpression,
depth + 1,
);
}
return type.kind === "scalar" || (type.kind === "builtin" && type.name === "unit");
};
let remaining = 10000;
const visit = (node: unknown, path: string, depth = 0): void => {
if (depth > 128 || --remaining < 0)
throw new GenericTypeError("type-complexity-limit", path, "Type exceeds the depth or expansion budget");
if (!node || typeof node !== "object") return;
if ("definitionId" in node && "arguments" in node) {
const application = node as InterfaceApplicationExpression | Extract<ValueTypeExpression, { kind: "alias" }>;
const alias = "kind" in application && application.kind === "alias";
const target = alias ? aliases.get(application.definitionId) : this.definitions.get(application.definitionId);
if (!target)
throw new GenericTypeError(
alias ? "unknown-alias" : "unknown-interface",
path,
`Unknown definition ${application.definitionId}`,
);
if ("template" in target && target.template?.usesSelf) template.usesSelf = true;
const parameters = "parameters" in target ? target.parameters : (target.template?.parameters ?? []);
if (parameters.length !== application.arguments.length)
throw new GenericTypeError(
"type-arity",
path,
`Expected ${parameters.length} type arguments, received ${application.arguments.length}`,
);
parameters.forEach((parameter, index) => {
if (parameter.kind !== application.arguments[index].kind)
throw new GenericTypeError(
"parameter-kind",
path,
`Expected ${parameter.kind}, received ${application.arguments[index].kind}`,
);
const argument = application.arguments[index];
if (parameter.kind === "value" && parameter.storable && argument.kind === "value" && !storable(argument.type))
throw new GenericTypeError(
"non-storable-argument",
path,
"Generic application cannot prove its value argument is storable",
);
if (parameter.kind === "object" && argument.kind === "object" && argument.target.kind === "parameter") {
const offered = parametersById.get(argument.target.parameterId);
const mapping = new Map(parameters.map((p, i) => [p.id, application.arguments[i]]));
for (const bound of parameter.implements) {
const required = replace(bound, mapping) as InterfaceApplicationExpression;
if (offered?.kind !== "object" || !offered.implements.some((evidence) => implies(evidence, required)))
throw new GenericTypeError(
"unsatisfied-bound",
path,
`Object parameter does not prove ${required.definitionId}`,
);
}
}
});
}
for (const [name, child] of Object.entries(node)) visit(child, `${path}.${name}`, depth + 1);
};
visit(template, definition.displayName);
const active = new Set<string>();
const done = new Set<string>();
const checkAlias = (id: string) => {
if (done.has(id)) return;
if (active.has(id))
throw new GenericTypeError("recursive-alias", id, `Recursive value alias: ${[...active, id].join(" -> ")}`);
active.add(id);
const walk = (node: unknown): void => {
if (!node || typeof node !== "object") return;
if ("kind" in node && node.kind === "alias")
checkAlias((node as ValueAliasDefinition["body"] & { definitionId: string }).definitionId);
Object.values(node).forEach(walk);
};
walk(aliases.get(id)?.body);
active.delete(id);
done.add(id);
};
for (const id of aliases.keys()) checkAlias(id);
}
apply(id: string, arguments_: readonly ClosedTypeArgument[]): InterfaceRevision {
const definition = this.definitions.get(id);
if (!definition) throw new GenericTypeError("unknown-interface", id, "Unknown interface definition");
const application = {
definitionId: definition.revisionId,
source: definition.source,
arguments: [...arguments_],
...(definition.template?.usesSelf ? { self: this.self } : {}),
};
const appliedId = definition.template ? appliedInterfaceId(application) : definition.revisionId;
const cached = this.applications.get(appliedId);
if (cached) return cached;
if (this.active.includes(id))
throw new GenericTypeError(
"recursive-application",
id,
`Expanding recursive interface application: ${[...this.active, id].join(" -> ")}`,
);
if (++this.applicationCount > 10000)
throw new GenericTypeError("type-complexity-limit", id, "Too many interface applications");
this.active.push(id);
if (definition.template)
this.applications.set(appliedId, {
interfaceId: definition.interfaceId,
revisionId: appliedId,
displayName: definition.displayName,
source: definition.source,
members: [],
application,
});
try {
const obligations: NonNullable<InterfaceRevision["argumentRequirements"]> = [];
const instance = instantiateInterface(definition, arguments_, {
...this.environment(),
implementsInterface: (target, required) => {
obligations.push({ target, required });
return true;
},
});
if (instance === definition) return definition;
instance.argumentRequirements = obligations;
this.applications.set(instance.revisionId, instance);
this.definitions.set(instance.revisionId, instance);
return instance;
} catch (error) {
this.applications.delete(appliedId);
throw error;
} finally {
this.active.pop();
}
}
interface(context: InterfaceTypeContext): InterfaceApplicationExpression {
return this.interfaceByName(context.identifier().getText(), context.typeArguments());
}
interfaceByName(name: string, arguments_: TypeArgumentsContext | null): InterfaceApplicationExpression {
const definition = this.interfaces.get(name);
if (!definition) throw new GenericTypeError("unknown-interface", name, "Unknown interface");
return { definitionId: definition.revisionId, arguments: this.arguments(arguments_) };
}
arguments(context: TypeArgumentsContext | null): TypeArgumentExpression[] {
return (context?.typeArgument() ?? []).map((argument): TypeArgumentExpression => {
if (argument.INTERFACE())
return {
kind: "object",
target: { kind: "application", application: this.interface(argument.interfaceType()!) },
};
if (argument.ATOM()) return { kind: "object", target: this.atom(argument.identifier()!.getText()) };
if (argument.OBJECT()) return { kind: "object", target: this.objectParameter(argument.identifier()!.getText()) };
const type = argument.valueType()!;
if (type.getText() === "Self") return { kind: "object", target: { kind: "self" } };
const parameter = type.identifier() && this.parameters.get(type.identifier()!.getText());
if (parameter?.kind === "object" && !type.typeArguments())
return { kind: "object", target: { kind: "parameter", parameterId: parameter.id } };
return { kind: "value", type: this.expression(type) };
});
}
private atom(name: string): ObjectTypeExpression {
if (name === "Self") return { kind: "self" };
const atomId = this.atoms.get(name);
if (!atomId) throw new GenericTypeError("unknown-atom", name, "Unknown atom");
return { kind: "atom", atomId };
}
private objectParameter(name: string): ObjectTypeExpression {
if (name === "Self") return { kind: "self" };
const parameter = this.parameters.get(name);
if (!parameter || parameter.kind !== "object")
throw new GenericTypeError("parameter-kind", name, "Expected an object parameter");
return { kind: "parameter", parameterId: parameter.id };
}
targetExpression(context: TargetConstraintContext): ObjectTypeExpression {
const name = context.identifier().getText();
if (context.ATOM()) return this.atom(name);
if (context.OBJECT()) return this.objectParameter(name);
return { kind: "application", application: this.interfaceByName(name, context.typeArguments()) };
}
expression(context: ValueTypeContext): ValueTypeExpression {
if (context.scalarType())
return {
kind: "scalar",
name: context.scalarType()!.getText() as Extract<ValueType, { kind: "scalar" }>["name"],
};
if (context.UNIT()) return valueType.unit;
if (context.WATCH_HANDLE()) return valueType.watchHandle;
if (context.MESSAGE()) return valueType.message(literal(context.stringLiteral()!));
if (context.OPTIONAL() || context.LIST())
return { kind: context.LIST() ? "list" : "optional", value: this.expression(context.valueType()!) };
if (context.RECORD()) {
const fields = context
.recordField()
.map((field) => [field.identifier().getText(), this.expression(field.valueType())] as const);
if (new Set(fields.map(([name]) => name)).size !== fields.length)
throw new GenericTypeError("duplicate-field", "record", "Duplicate record field");
return { kind: "record", fields: Object.fromEntries(fields) };
}
const name = context.identifier()!.getText();
if (context.ATOM_REF()) return { kind: "object-ref", expectation: this.atom(name) };
if (context.INTERFACE_REF())
return {
kind: "object-ref",
expectation: { kind: "application", application: this.interfaceByName(name, context.typeArguments()) },
};
if (context.REF()) return { kind: "object-ref", expectation: this.objectParameter(name) };
const parameter = this.parameters.get(name);
if (parameter) {
if (parameter.kind !== "value" || context.typeArguments())
throw new GenericTypeError("parameter-kind", name, "Expected a value parameter; object parameters need ref<T>");
return { kind: "parameter", parameterId: parameter.id };
}
if (!this.aliases.has(name)) throw new GenericTypeError("unknown-type", name, "Unknown value type");
return { kind: "alias", definitionId: name, arguments: this.arguments(context.typeArguments()) };
}
value(context: ValueTypeContext): ValueType {
return new TypeSubstitution(this.environment()).value(this.expression(context));
}
target(context: TargetConstraintContext) {
return new TypeSubstitution(this.environment()).object(this.targetExpression(context));
}
declareParameters(context: TypeParametersContext | null, owner: string): TypeParameter[] {
const entries = context?.typeParameter() ?? [];
const result: TypeParameter[] = entries.map((parameter, index) => {
const name = parameter.identifier().getText();
if (name === "Self" || this.parameters.has(name))
throw new GenericTypeError("duplicate-parameter", owner, `Duplicate or reserved parameter ${name}`);
const declaration: TypeParameter = parameter.VALUE()
? {
id: `${owner}/parameter/${index}`,
name,
kind: "value",
...(parameter.STORABLE() ? { storable: true } : {}),
}
: { id: `${owner}/parameter/${index}`, name, kind: "object", implements: [] };
this.parameters.set(name, declaration);
return declaration;
});
entries.forEach((entry, index) => {
const parameter = result[index];
if (parameter.kind === "object")
parameter.implements = entry.interfaceType().map((bound) => this.interface(bound));
});
return result;
}
declareAliases(contexts: readonly TypeAliasDeclContext[]) {
for (const context of contexts) {
const name = context.identifier().getText();
if (this.aliases.has(name)) throw new GenericTypeError("duplicate-alias", name, "Duplicate type alias");
this.aliases.set(name, { id: name, parameters: [], body: valueType.unit });
}
for (const context of contexts) {
const name = context.identifier().getText();
const previous = this.parameters;
this.parameters = new Map();
try {
this.aliases.set(name, {
id: name,
parameters: this.declareParameters(context.typeParameters(), JSON.stringify(["alias", name])),
body: this.expression(context.valueType()),
});
} finally {
this.parameters = previous;
}
}
}
}
+40 -25
View File
@@ -57,24 +57,34 @@ export const createGitCapabilityResolver = async (options: {
const existing = checkouts.get(key); const existing = checkouts.get(key);
if (existing) return await existing; if (existing) return await existing;
const pending = (async () => { const pending = (async () => {
const directory = path.join( const directory = path.join(checkoutRoot, checkoutName(kind, source.repository, source.commit));
checkoutRoot,
checkoutName(kind, source.repository, source.commit),
);
const verify = async (checkout: string) => { const verify = async (checkout: string) => {
const { stdout } = await execFile("git", ["-C", checkout, "rev-parse", "HEAD"]); const { stdout } = await execFile("git", ["-C", checkout, "rev-parse", "HEAD"]);
if (stdout.trim().toLowerCase() !== source.commit.toLowerCase()) { 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}`); if (changes.trim()) throw new Error(`Dependency checkout was modified: ${checkout}`);
}; };
// Only complete, checked clones become visible under the deterministic name. // Only complete, checked clones become visible under the deterministic name.
// Concurrent resolvers may fetch independently, but cannot observe a partial clone. // Concurrent resolvers may fetch independently, but cannot observe a partial clone.
if (await stat(directory).then(() => true, (error: NodeJS.ErrnoException) => { if (
if (error.code === "ENOENT") return false; await stat(directory).then(
throw error; () => true,
})) { (error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return false;
throw error;
},
)
) {
await verify(directory); await verify(directory);
return { directory }; return { directory };
} }
@@ -82,20 +92,21 @@ export const createGitCapabilityResolver = async (options: {
const checkout = path.join(staging, "checkout"); const checkout = path.join(staging, "checkout");
try { try {
await execFile("git", [ await execFile("git", [
"-c", "-c",
"advice.detachedHead=false", "advice.detachedHead=false",
"clone", "clone",
"--depth", "--depth",
"1", "1",
"--single-branch", "--single-branch",
"--branch", "--branch",
`quixos-reachability/${source.commit.toLowerCase()}`, `quixos-reachability/${source.commit.toLowerCase()}`,
source.repository, source.repository,
checkout, checkout,
]); ]);
await verify(checkout); await verify(checkout);
try { await rename(checkout, directory); } try {
catch (error) { await rename(checkout, directory);
} catch (error) {
if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
await verify(directory); await verify(directory);
} }
@@ -105,7 +116,11 @@ export const createGitCapabilityResolver = async (options: {
return { directory }; return { directory };
})(); })();
checkouts.set(key, pending); checkouts.set(key, pending);
try { return await pending; } try {
catch (error) { checkouts.delete(key); throw error; } return await pending;
} catch (error) {
checkouts.delete(key);
throw error;
}
}; };
}; };
+35 -10
View File
@@ -1,25 +1,50 @@
import {parse} from "@babel/parser"; import { parse } from "@babel/parser";
type Node = {type: string; start: number; end: number; [key: string]: unknown}; 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"; 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. /** One requested insertion into ordinary authored TypeScript, not regeneration.
* Ambiguous/custom wiring is left alone with an actionable error. */ * 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 { export function addImplementation(
const ast = parse(text, {sourceType: "module", plugins: ["typescript"]}); text: string,
factory: "createRuntime" | "serveMigration",
key: string,
importPath: string,
migrationOnly = false,
): string {
const ast = parse(text, { sourceType: "module", plugins: ["typescript"] });
const objects: Node[] = []; const objects: Node[] = [];
const names = new Set<string>(); const names = new Set<string>();
const visit = (value: unknown) => { 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 (!node(value)) return;
if (value.type === "Identifier") names.add(String(value.name)); if (value.type === "Identifier") names.add(String(value.name));
if (value.type === "CallExpression" && node(value.callee) && value.callee.type === "Identifier" && value.callee.name === factory && if (
Array.isArray(value.arguments) && value.arguments.length === 1 && node(value.arguments[0]) && value.arguments[0].type === "ObjectExpression") objects.push(value.arguments[0]); 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); Object.values(value).forEach(visit);
}; };
visit(ast); 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]; 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"; let alias = "qxImplementation";
for (let index = 1; names.has(alias); index++) alias = `qxImplementation${index}`; for (let index = 1; names.has(alias); index++) alias = `qxImplementation${index}`;
const value = migrationOnly ? 'async () => { throw new Error("Migration-only export"); }' : alias; const value = migrationOnly ? 'async () => { throw new Error("Migration-only export"); }' : alias;
+6 -1
View File
@@ -10,7 +10,12 @@ for await (const chunk of process.stdin) {
} }
const document = JSON.parse(input); const document = JSON.parse(input);
if (!Array.isArray(document) && document?.operation === "instantiate-workspace") { 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.stdout.write(JSON.stringify({ source: instantiateWorkspaceIdentity(document.source, document.workspaceId) }));
process.exit(0); process.exit(0);
} }
+10 -6
View File
@@ -1,8 +1,8 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import {contentDigest} from "../capability-model/evolution.js"; import { contentDigest } from "../capability-model/evolution.js";
import {validateMigrationCatalog, type MigrationCatalog} from "../capability-model/migrations.js"; import { validateMigrationCatalog, type MigrationCatalog } from "../capability-model/migrations.js";
import {applyStructure, planStructure} from "./structural-plan.js"; import { applyStructure, planStructure } from "./structural-plan.js";
/** Explicitly acknowledge edited migration code. This does not change retained /** Explicitly acknowledge edited migration code. This does not change retained
* contracts or grant activation approval; the immutable checker verifies it. */ * 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")); migration.implementation.digest = contentDigest(await fs.readFile(implementation, "utf8"));
} }
validateMigrationCatalog(catalog); validateMigrationCatalog(catalog);
return applyStructure(await planStructure(root, {kind: "package", validation: "syntax", files: [ return applyStructure(
{file, expected: before, replace: JSON.stringify(catalog, null, 2) + "\n"}, await planStructure(root, {
]})); kind: "package",
validation: "syntax",
files: [{ file, expected: before, replace: JSON.stringify(catalog, null, 2) + "\n" }],
}),
);
} }
File diff suppressed because it is too large Load Diff
+373 -150
View File
@@ -1,100 +1,185 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import {randomUUID} from "node:crypto"; import { randomUUID } from "node:crypto";
import {execFile as callback} from "node:child_process"; import { execFile as callback } from "node:child_process";
import {promisify} from "node:util"; import { promisify } from "node:util";
import {loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js"; import { loadQuixosLock, parseQuixosLockDocument } from "../resource-lock/index.js";
import {contentDigest} from "../capability-model/evolution.js"; import { contentDigest } from "../capability-model/evolution.js";
import {planStructure, applyStructure, type StructuralRequest} from "./structural-plan.js"; import { planStructure, applyStructure, type StructuralRequest } from "./structural-plan.js";
import {snapshotRepository} from "./candidate-check.js"; import { snapshotRepository } from "./candidate-check.js";
import {compileWorkspaceRepository, compileCapabilityResourceRepository, type ResolvedCapabilityResource} from "./assembly.js"; import {
import {createGitCapabilityResolver} from "./git-resolver.js"; compileWorkspaceRepository,
import {snapshotCommit, buildImmutableCandidate} from "./checked-build.js"; compileCapabilityResourceRepository,
import {planEvolution, type WorkspaceRevision, type EvolutionReview} from "../capability-model/index.js"; type ResolvedCapabilityResource,
import {withFileLock} from "./file-lock.js"; } 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); const execFile = promisify(callback);
type Source = {repository: string; commit: string}; type Source = { repository: string; commit: string };
export type UpgradeNode = {kind: "workspace" | "package" | "interface"; directory: string; source: Source}; export type UpgradeNode = { kind: "workspace" | "package" | "interface"; directory: string; source: Source };
export type UpgradeSpec = {nodes: UpgradeNode[]; quixos?: Source; baseline?: string; reviews?: string; bootstrap?: boolean}; export type UpgradeSpec = {
type NodePlan = UpgradeNode & {treeDigest: string; dependencies: string[]; lockFiles: string[]}; nodes: UpgradeNode[];
export type UpgradePlan = {schemaVersion: 1; workbench: string; spec: UpgradeSpec; nodes: NodePlan[]; digest: string}; quixos?: Source;
type Step = {directory: string; phase: "editing" | "prepared" | "refactor" | "checked" | "publishing" | "published"; commit?: string; treeDigest?: string; structuralJournal?: string; structuralPlan?: Awaited<ReturnType<typeof planStructure>>}; baseline?: string;
type Journal = {schemaVersion: 1; plan: UpgradePlan; steps: Step[]}; reviews?: string;
const sourceKey = (node: {kind: string; source: Source}) => JSON.stringify([node.kind, node.source.repository, node.source.commit]); bootstrap?: boolean;
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 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<ReturnType<typeof planStructure>>;
};
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 validSource = (value: Source) => {
const url = new URL(value.repository); 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) => { 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)); const resolved = await fs.realpath(path.join(root, directory));
if (resolved !== path.join(root, directory)) throw new Error("Upgrade target crosses a symlink"); if (resolved !== path.join(root, directory)) throw new Error("Upgrade target crosses a symlink");
return resolved; return resolved;
}; };
const treeDigest = async (root: string) => { const treeDigest = async (root: string) => {
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-upgrade-tree-")); 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 writeJournal = async (filename: string, journal: unknown) => {
const temp = `${filename}.${randomUUID()}.tmp`; const temp = `${filename}.${randomUUID()}.tmp`;
const handle = await fs.open(temp, "wx", 0o600); 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); await fs.rename(temp, filename);
const directory = await fs.open(path.dirname(filename), "r"); 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<UpgradeSpec> => { export const discoverUpgradeSpec = async (workbench: string): Promise<UpgradeSpec> => {
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8")); const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
const root = await location(workbench, "root"); const root = await location(workbench, "root");
const nodes: UpgradeNode[] = [{kind: "workspace", directory: "root", source: { const nodes: UpgradeNode[] = [
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"]), kind: "workspace",
}}, ...graph.resources.map((entry: {kind: "interface" | "package"; directory: string; source: Source}) => ({kind: entry.kind, source: entry.source, directory: "root",
directory: path.relative(workbench, path.resolve(workbench, entry.directory))}))]; 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; let baseline: string | undefined;
try { try {
const host = JSON.parse(await fs.readFile("/etc/quixos/workspace-source.json", "utf8")); 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; if ((await fs.realpath(host.workbenchRoot)) === (await fs.realpath(workbench)))
} catch (error) {if (!["ENOENT", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;} baseline = JSON.parse(
return {nodes, baseline}; 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 /** Read-only source plan. Repositories are selected explicitly, including any
* parallel versions of the same resource; no guesses at a floating 'latest'. */ * parallel versions of the same resource; no guesses at a floating 'latest'. */
export const planPinUpgrades = async (workbenchPath: string, spec: UpgradeSpec): Promise<UpgradePlan> => { export const planPinUpgrades = async (workbenchPath: string, spec: UpgradeSpec): Promise<UpgradePlan> => {
const workbench = await fs.realpath(workbenchPath); 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); if (spec.quixos) validSource(spec.quixos);
const keys = new Map<string, string>(); const keys = new Map<string, string>();
for (const node of spec.nodes) { for (const node of spec.nodes) {
validSource(node.source); 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); 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[] = []; const nodes: NodePlan[] = [];
for (const node of spec.nodes) { for (const node of spec.nodes) {
const root = await location(workbench, node.directory); 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")); 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("; ")}`); if (!loaded.ok)
const dependencies = loaded.lock.resources.map((resource) => keys.get(sourceKey(resource))).filter((value): value is string => Boolean(value)); throw new Error(
nodes.push({...node, treeDigest: await treeDigest(root), dependencies: [...new Set(dependencies)], lockFiles: loaded.lock.sourceFiles ?? ["quixos.lock"]}); `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) { 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"); if (index < 0) throw new Error("Cyclic source publication graph");
ordered.push(remaining.splice(index, 1)[0]); ordered.push(remaining.splice(index, 1)[0]);
} }
const workspace = ordered.find((node) => node.kind === "workspace")!; const workspace = ordered.find((node) => node.kind === "workspace")!;
// Even unreferenced new resources are published before the root. // Even unreferenced new resources are published before the root.
ordered.splice(ordered.indexOf(workspace), 1); ordered.push(workspace); ordered.splice(ordered.indexOf(workspace), 1);
const plan = {schemaVersion: 1 as const, workbench, spec, nodes: ordered}; ordered.push(workspace);
return {...plan, digest: contentDigest(plan)}; const plan = { schemaVersion: 1 as const, workbench, spec, nodes: ordered };
return { ...plan, digest: contentDigest(plan) };
}; };
export type UpgradeEffects = { export type UpgradeEffects = {
@@ -104,21 +189,27 @@ export type UpgradeEffects = {
}; };
const effects: UpgradeEffects = { const effects: UpgradeEffects = {
async check(node, root, output, spec) { 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 // Explicit baseline upgrades use the same immutable Nix checker. Retaining
// an unverified source is safe and must precede a remote flake fetch. // an unverified source is safe and must precede a remote flake fetch.
await fs.mkdir(output); await fs.mkdir(output);
const commit = await snapshotCommit(root); const commit = await snapshotCommit(root);
await effects.publish(root, commit); 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"); const candidate = await fs.readFile(path.join(artifact, "candidate.json"), "utf8");
await fs.writeFile(path.join(output, "candidate.json"), candidate); await fs.writeFile(path.join(output, "candidate.json"), candidate);
if (node.kind === "workspace") { if (node.kind === "workspace") {
const baseline = spec.baseline ? JSON.parse(await fs.readFile(spec.baseline, "utf8")) as WorkspaceRevision : null; const baseline = spec.baseline
const reviews = spec.reviews ? JSON.parse(await fs.readFile(spec.reviews, "utf8")) as EvolutionReview[] : []; ? (JSON.parse(await fs.readFile(spec.baseline, "utf8")) as WorkspaceRevision)
const evolution = planEvolution(baseline, JSON.parse(candidate), {reviews}); : 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)); 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) { 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"); 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, "--"]); 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")); 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; return commit;
}, },
async publish(root, commit) { async publish(root, commit) {
@@ -134,121 +228,250 @@ const effects: UpgradeEffects = {
const remote = await command(root, "git", ["ls-remote", "--refs", "origin", ref]); 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 && remote !== `${commit}\t${ref}`) throw new Error("Immutable publication ref conflict");
if (!remote) await command(root, "git", ["push", "origin", `${commit}:${ref}`]); 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 /** Explicit --publish only. Append-only remote retention; never moves the
* workspace branch, activates code, or rolls back previously published nodes. */ * workspace branch, activates code, or rolls back previously published nodes. */
export const applyPinUpgrades = async (plan: UpgradePlan, journalId?: string, implementation: UpgradeEffects = effects, options: {acceptEdits?: boolean} = {}) => { export const applyPinUpgrades = async (
if (implementation === effects && !plan.spec.baseline && !plan.spec.bootstrap) throw new Error("Publication requires an active checked baseline or explicit bootstrap:true"); plan: UpgradePlan,
const {digest, ...body} = plan; 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"); if (contentDigest(body) !== digest) throw new Error("Upgrade plan digest mismatch");
const directory = path.join(plan.workbench, ".quixos", "upgrades"); const directory = path.join(plan.workbench, ".quixos", "upgrades");
await fs.mkdir(directory, {recursive: true, mode: 0o700}); await fs.mkdir(directory, { recursive: true, mode: 0o700 });
if (await fs.realpath(directory) !== directory) throw new Error("Upgrade journals must not cross symlinks"); if ((await fs.realpath(directory)) !== directory) throw new Error("Upgrade journals must not cross symlinks");
const id = journalId ?? randomUUID(); const id = journalId ?? randomUUID();
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid upgrade journal ID"); if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid upgrade journal ID");
const filename = path.join(directory, `${id}.json`); const filename = path.join(directory, `${id}.json`);
return withFileLock(path.join(directory, "writer.lock"), async () => { return withFileLock(path.join(directory, "writer.lock"), async () => {
try { try {
const journal: Journal = journalId ? JSON.parse(await fs.readFile(filename, "utf8")) : {schemaVersion: 1, plan, steps: []}; const journal: Journal = journalId
if (journal.plan.digest !== plan.digest) throw new Error("Upgrade journal belongs to another plan"); ? JSON.parse(await fs.readFile(filename, "utf8"))
if (!journalId) await writeJournal(filename, journal); : { schemaVersion: 1, plan, steps: [] };
for (const node of plan.nodes) { if (journal.plan.digest !== plan.digest) throw new Error("Upgrade journal belongs to another plan");
const root = await location(plan.workbench, node.directory); if (!journalId) await writeJournal(filename, journal);
if (await command(root, "git", ["config", "--get", "remote.origin.url"]) !== node.source.repository) throw new Error("Upgrade remote changed after planning"); for (const node of plan.nodes) {
let step = journal.steps.find((entry) => entry.directory === node.directory); const root = await location(plan.workbench, node.directory);
if (step?.phase === "published") continue; if ((await command(root, "git", ["config", "--get", "remote.origin.url"])) !== node.source.repository)
if (!step) { throw new Error("Upgrade remote changed after planning");
if (await treeDigest(root) !== node.treeDigest) throw new Error(`Stale upgrade plan: ${node.directory}`); let step = journal.steps.find((entry) => entry.directory === node.directory);
const files: StructuralRequest["files"] = []; if (step?.phase === "published") continue;
for (const file of node.lockFiles) { if (!step) {
const parsed = parseQuixosLockDocument(await fs.readFile(path.join(root, file), "utf8")); if ((await treeDigest(root)) !== node.treeDigest) throw new Error(`Stale upgrade plan: ${node.directory}`);
if (!parsed.ok) throw new Error("Invalid lock during upgrade"); const files: StructuralRequest["files"] = [];
const edits = []; for (const file of node.lockFiles) {
for (const resource of parsed.document.resources) { const parsed = parseQuixosLockDocument(await fs.readFile(path.join(root, file), "utf8"));
const dependency = plan.nodes.find((entry) => sourceKey(entry) === sourceKey(resource)); if (!parsed.ok) throw new Error("Invalid lock during upgrade");
const published = dependency && journal.steps.find((entry) => entry.directory === dependency.directory && entry.phase === "published"); const edits = [];
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}}); 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}); const structuralPlan = files.length
if (edits.length) files.push({file, edits}); ? 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; if (step.phase === "editing") {
step = {directory: node.directory, phase: "editing", treeDigest: node.treeDigest, structuralPlan, structuralJournal: structuralPlan ? randomUUID() : undefined}; if (step.structuralPlan) await applyStructure(step.structuralPlan, step.structuralJournal);
journal.steps.push(step); await writeJournal(filename, journal); else if ((await treeDigest(root)) !== step.treeDigest)
} throw new Error("Source changed before upgrade editing");
if (step.phase === "editing") { step.treeDigest = await treeDigest(root);
if (step.structuralPlan) await applyStructure(step.structuralPlan, step.structuralJournal); step.phase = "prepared";
else if (await treeDigest(root) !== step.treeDigest) throw new Error("Source changed before upgrade editing"); await writeJournal(filename, journal);
step.treeDigest = await treeDigest(root); }
step.phase = "prepared"; 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); await writeJournal(filename, journal);
} }
if (step.phase === "refactor") { // Keep subsequent automatic upgrades associated with the newly published
const current = await treeDigest(root); // identities, without renaming repositories or changing any selected branch.
if (current !== step.treeDigest && !options.acceptEdits) throw new Error("Refactored source requires --accept-edits when resuming"); // Explicit-spec callers without a managed graph retain the journal as their
step.treeDigest = current; step.phase = "prepared"; await writeJournal(filename, journal); // 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 (graphText !== undefined) {
if (step.phase === "prepared") { const previous = JSON.parse(graphText);
try {await implementation.check(node, root, path.join(directory, `${id}-${node.directory.replaceAll("/", "-")}-${randomUUID()}`), plan.spec);} const snapshots = await Promise.all(
catch (error) {step.phase = "refactor"; await writeJournal(filename, journal); throw error;} previous.resources.map(async (entry: { kind: string; source: Source; directory: string }) => ({
if (await treeDigest(root) !== step.treeDigest) throw new Error("Source changed while checking"); kind: entry.kind,
step.phase = "checked"; await writeJournal(filename, journal); ...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<string, ResolvedCapabilityResource>(
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") { return {
step.commit = await implementation.snapshot(root); id,
if (await treeDigest(root) !== step.treeDigest) throw new Error("Publication snapshot changed checked files"); journal: filename,
step.phase = "publishing"; await writeJournal(filename, journal); revisions: journal.steps.map((step) => ({ directory: step.directory, commit: step.commit })),
} activated: false,
await implementation.publish(root, step.commit!); };
step.phase = "published"; await writeJournal(filename, journal); } catch (error) {
} throw new Error(`${error instanceof Error ? error.message : String(error)}; upgrade journal ${filename}`, {
// Keep subsequent automatic upgrades associated with the newly published cause: error,
// 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<string, ResolvedCapabilityResource>(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});}
}); });
}; };
+42 -21
View File
@@ -3,10 +3,7 @@
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { bindingSchema } from "../bindings/index.js"; import { bindingSchema } from "../bindings/index.js";
import { import { compileCapabilityResourceRepository, type ResolvedCapabilityResource } from "./assembly.js";
compileCapabilityResourceRepository,
type ResolvedCapabilityResource,
} from "./assembly.js";
import { createGitCapabilityResolver } from "./git-resolver.js"; import { createGitCapabilityResolver } from "./git-resolver.js";
const usage = `usage: quixos-resource-compile --root DIRECTORY --kind interface|package const usage = `usage: quixos-resource-compile --root DIRECTORY --kind interface|package
@@ -22,7 +19,21 @@ const parseArgs = (args: string[]) => {
const key = args[index]; const key = args[index];
const value = args[index + 1]; const value = args[index + 1];
if (!key?.startsWith("--") || !value) throw new Error(usage); 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); values.set(key, value);
} }
const rootDirectory = values.get("--root"); const rootDirectory = values.get("--root");
@@ -30,14 +41,14 @@ const parseArgs = (args: string[]) => {
const repository = values.get("--repository"); const repository = values.get("--repository");
const commit = values.get("--commit")?.toLowerCase(); const commit = values.get("--commit")?.toLowerCase();
const checkoutRoot = values.get("--checkout-root"); const checkoutRoot = values.get("--checkout-root");
if (!rootDirectory || !repository || !commit || !checkoutRoot || if (!rootDirectory || !repository || !commit || !checkoutRoot || (kind !== "interface" && kind !== "package")) {
(kind !== "interface" && kind !== "package")) {
throw new Error(usage); throw new Error(usage);
} }
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) { if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
throw new Error("--commit must be a full Git object ID"); 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 { return {
rootDirectory, rootDirectory,
kind, kind,
@@ -56,9 +67,8 @@ const graphEntry = (node: ResolvedCapabilityResource) => ({
kind: node.kind, kind: node.kind,
source: node.source, source: node.source,
directory: node.directory, directory: node.directory,
resourceId: node.resource.kind === "interface" resourceId:
? node.resource.revision.interfaceId node.resource.kind === "interface" ? node.resource.revision.interfaceId : node.resource.revision.packageId,
: node.resource.revision.packageId,
revisionId: node.resource.revision.revisionId, revisionId: node.resource.revision.revisionId,
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({ dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
binding, binding,
@@ -88,21 +98,32 @@ const main = async () => {
resolveResource, resolveResource,
}); });
if (options.graphOut) { if (options.graphOut) {
await writeFile(options.graphOut, `${JSON.stringify({ await writeFile(
formatVersion: 1, options.graphOut,
quixos: compiled.lock.quixos, `${JSON.stringify(
root: graphEntry(compiled.resources.find((node) => {
node.source.repository === options.repository && formatVersion: 1,
node.source.commit === options.commit && quixos: compiled.lock.quixos,
node.kind === options.kind)!), root: graphEntry(
resources: compiled.resources.map(graphEntry), compiled.resources.find(
}, null, 2)}\n`); (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`); if (options.schemaOut) await writeFile(options.schemaOut, `${JSON.stringify(bindingSchema(compiled), null, 2)}\n`);
process.stdout.write(`${JSON.stringify(compiled.resource, null, 2)}\n`); process.stdout.write(`${JSON.stringify(compiled.resource, null, 2)}\n`);
}; };
main().catch((error: unknown) => { 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; process.exitCode = 1;
}); });
+334 -76
View File
@@ -1,30 +1,57 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import {contentDigest} from "../capability-model/evolution.js"; import { contentDigest } from "../capability-model/evolution.js";
import {validateMigrationCatalog, type MigrationCatalog, type MigrationDeclaration} from "../capability-model/migrations.js"; import {
import {formatQuixosLock, type GitSource} from "../resource-lock/index.js"; validateMigrationCatalog,
import {parseQx, walkSyntax} from "./source.js"; type MigrationCatalog,
import type {StructuralRequest} from "./structural-plan.js"; type MigrationDeclaration,
import {addImplementation} from "./implementation-edit.js"; } 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 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 PackageEditModel = {
generatedBy: "qx-scaffold-v1";
name: string;
id: string;
revision: string;
exports: { name: string; id: string; file: string; migration?: boolean }[];
};
export type ScaffoldRecipe = { export type ScaffoldRecipe = {
template?: "typescript" | "typescript-react"; 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; declaration?: string;
tools?: {quixos: Source; protocol: Source; helpers: Source; sdk: Source}; /** Initial authored files for a composed recipe; only used for a new package. */
initialFiles?: Record<string, string>;
tools?: { quixos: Source; protocol: Source; helpers: Source; sdk: Source };
nixifyPluginUrl?: string; nixifyPluginUrl?: string;
migration?: Omit<MigrationDeclaration, "implementation"> & {contracts: Record<string, unknown>}; migration?: Omit<MigrationDeclaration, "implementation"> & { contracts: Record<string, unknown> };
}; };
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
const source = (value: Source): GitSource => { 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); 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"); if (
return {resolver: "git", ...value}; 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 nixString = (value: string) => JSON.stringify(value).replaceAll("${", "\\${");
const safeName = (name: string | undefined): string => { 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"); if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("Scaffold requires a simple authored name");
@@ -32,7 +59,8 @@ const safeName = (name: string | undefined): string => {
}; };
const ownedJson = async <T>(root: string, file: string): Promise<T> => { const ownedJson = async <T>(root: string, file: string): Promise<T> => {
const target = path.join(root, file); 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")); const value = JSON.parse(await fs.readFile(target, "utf8"));
if (value.generatedBy !== "qx-scaffold-v1") throw new Error(`Not scaffold-owned: ${file}`); if (value.generatedBy !== "qx-scaffold-v1") throw new Error(`Not scaffold-owned: ${file}`);
return value; return value;
@@ -40,44 +68,126 @@ const ownedJson = async <T>(root: string, file: string): Promise<T> => {
/** Recipes describe structural edits; planStructure owns validation/journaling. /** Recipes describe structural edits; planStructure owns validation/journaling.
* Implementation files are created once; later edits preserve authored wiring. */ * Implementation files are created once; later edits preserve authored wiring. */
export const scaffoldRecipe = async (root: string, command: "package" | "function" | "migration" | "refresh", spec: ScaffoldRecipe): Promise<StructuralRequest> => { export const scaffoldRecipe = async (
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."); root: string,
command: "package" | "function" | "migration" | "refresh",
spec: ScaffoldRecipe,
): Promise<StructuralRequest> => {
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); 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 prefix = spec.directory ? `${spec.directory}/` : "";
const files: StructuralRequest["files"] = []; const files: StructuralRequest["files"] = [];
const create = (file: string, content: string) => files.push({file: prefix + file, create: 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}); const generated = (file: string, content: string) => files.push({ file: prefix + file, generated: content });
let packageModel: PackageEditModel; let packageModel: PackageEditModel;
let catalog: MigrationCatalog & {generatedBy: "qx-scaffold-v1"}; let catalog: MigrationCatalog & { generatedBy: "qx-scaffold-v1" };
if (command === "package") { if (command === "package") {
const name = safeName(spec.name); 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"; 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); Object.values(spec.tools).forEach(source);
packageModel = {generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: []}; packageModel = { generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: [] };
catalog = {generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: []}; 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"}); if (react)
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`); packageModel.exports.push({ name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts" });
create("quixos.lock", formatQuixosLock({formatVersion: 1, quixos: source(spec.tools.quixos), resources: []})); create(
create("package.json", json({name: `@quixos/${name.toLowerCase()}`, version: "0.1.0", private: true, type: "module", packageManager: "yarn@4.18.0", "package.qx",
scripts: {build: "tsc -p tsconfig.json", typecheck: "tsc --noEmit"}, dependencies: {"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`}, `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`,
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"]})); 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) { 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 <section><h1>${name}</h1><p>Edit this component, then run qx-workspace check.</p></section>;\n}\n`); create(
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`); "src/component.tsx",
create("src/browser-assets.d.ts", `declare module "*?browser-source" { const source: string; export default source; }\n`); `// Add a checked props export, select it in quixos.check.json options.react,\n// then use its generated ReactResults type. Scaffold bundle does this for you.\nexport default function Component(_props: {camino: Record<string, never>; render: unknown; dispatch: (action: unknown) => void}) {\n return <section><h1>${name}</h1></section>;\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(".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`); create(
const nixifyPluginUrl = spec.nixifyPluginUrl ?? "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js"; ".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); 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"); if (
generated("quixos.toolchain.json", json({generatedBy: "qx-scaffold-v1", nixifyPluginUrl})); plugin.protocol !== "https:" ||
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"}}}} : {})})); plugin.username ||
create("flake.nix", `{ 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: { react: { propsExports: [] } },
}
: {}),
}),
);
create(
"flake.nix",
`{
inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))}; inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))};
inputs.nixpkgs.follows = "protocol/nixpkgs"; inputs.nixpkgs.follows = "protocol/nixpkgs";
inputs.flake-utils.follows = "protocol/flake-utils"; inputs.flake-utils.follows = "protocol/flake-utils";
@@ -89,54 +199,101 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
migrationEntrypoint = "dist/migrate.js"; migrationEntrypoint = "dist/migrate.js";
installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; }; installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; };
}; };
}\n`); }\n`,
);
} else { } else {
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json"); catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
// Derive the edit model from authored declarations; it is never persisted. // Derive the edit model from authored declarations; it is never persisted.
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8"); const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
const syntax = parseQx(authored); const syntax = parseQx(authored);
if (syntax.diagnostics.length) throw new Error("Cannot scaffold into an invalid package.qx; fix the reported syntax first"); if (syntax.diagnostics.length)
const declaration = [...walkSyntax(syntax.root)].find(node => node.kind === "packageResourceDecl"); 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"); if (!declaration) throw new Error("Expected a package declaration");
const text = (node: typeof declaration) => authored.slice(node.start, node.end); const text = (node: typeof declaration) => authored.slice(node.start, node.end);
const literals = declaration.children.filter(node => node.kind === "stringLiteral"); const literals = declaration.children.filter((node) => node.kind === "stringLiteral");
packageModel = {generatedBy: "qx-scaffold-v1", name: text(declaration.children.find(node => node.kind === "identifier")!), packageModel = {
id: JSON.parse(text(literals[0])), revision: JSON.parse(text(literals[1])), exports: []}; 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)) { for (const node of walkSyntax(declaration)) {
if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)) continue; if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind))
const name = safeName(text(node.children.find(child => child.kind === "identifier")!)); continue;
const id = JSON.parse(text(node.children.find(child => child.kind === "stringLiteral")!)); const name = safeName(text(node.children.find((child) => child.kind === "identifier")!));
if (packageModel.exports.some(entry => entry.id === id || entry.name === name)) throw new Error("Duplicate package export name or ID"); const id = JSON.parse(text(node.children.find((child) => child.kind === "stringLiteral")!));
const migration = catalog.migrations.find(entry => entry.implementation.exportId === id); if (packageModel.exports.some((entry) => entry.id === id || entry.name === name))
packageModel.exports.push({name, id, file: migration?.implementation.file ?? `src/impl/${name}.ts`, ...(migration ? {migration: true} : {})}); 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); 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 declaration = spec.declaration ?? `function ${name} id ${JSON.stringify(spec.id)} : unit -> unit;`;
const parsed = parseQx(`package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`); const parsed = parseQx(`package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`);
const exports = [...walkSyntax(parsed.root)].filter((node) => ["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)); const exports = [...walkSyntax(parsed.root)].filter((node) =>
if (parsed.diagnostics.length || exports.length !== 1) throw new Error("Expected one valid package export declaration"); ["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 wrapped = `package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`;
const node = exports[0]; const node = exports[0];
const derived = [...walkSyntax(node)].some((entry) => entry.kind === "eventClause"); 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 (command === "migration" && (node.kind !== "packageFunctionExport" || spec.declaration))
if (wrapped.slice(node.children.find((child) => child.kind === "identifier")!.start, node.children.find((child) => child.kind === "identifier")!.end) !== name throw new Error(
|| 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"); "Migration exports use the scaffold's unit function declaration and dedicated migration entrypoint",
files.push({file: prefix + "package.qx", edits: [{operation: "append", parent: {kind: "packageResourceDecl", id: packageModel.id}, source: declaration}]}); );
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 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<void> => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\n` const implementation =
: `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`; command === "migration"
? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise<void> => { 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); 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 (command === "migration") {
if (!spec.migration) throw new Error("Migration scaffold requires retained contracts and an explicit transition"); if (!spec.migration)
const {contracts, ...transition} = 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)) { 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.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) },
});
} }
} }
} }
@@ -146,18 +303,119 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
const entry = packageModel.exports.at(-1)!; const entry = packageModel.exports.at(-1)!;
const file = prefix + "src/server.ts"; const file = prefix + "src/server.ts";
const before = await fs.readFile(path.join(root, file), "utf8"); 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) { if (entry.migration) {
const file = prefix + "src/migrate.ts"; const file = prefix + "src/migrate.ts";
const before = await fs.readFile(path.join(root, file), "utf8"); 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 { } 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("") + create(
`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`); "src/server.ts",
const migrations = packageModel.exports.filter((entry) => entry.migration); `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` +
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`); 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(
return {kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files}; "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
)
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", "package.json"].includes(file)
)
throw new Error(`Not an initial authored file: ${file}`);
if (file === "quixos.check.json") {
const check = JSON.parse(content);
if (
check.backend !== "typescript" ||
check.bindingOutput !== "src/gen/qx.ts" ||
Object.keys(check).some((key) => !["backend", "bindingOutput", "options"].includes(key))
)
throw new Error(
"Initial check configuration may customize binding options, not the scaffold verification backend or output",
);
}
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);
}
}
}
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"];
+22 -6
View File
@@ -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. */ /** Validate an in-memory proposal before creating files. Never replace a fragment. */
export const scaffoldAtom = async (options: { 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 before = await readQxSource(options.root, "workspace.qx");
const plan = planAtomScaffold(before, options.name, options.id); 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; if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
} }
const observed = new Map<string, string>(); const observed = new Map<string, string>();
await compileWorkspaceRepository({ rootDirectory: options.root, resolveResource: options.resolveResource, await compileWorkspaceRepository({
rootDirectory: options.root,
resolveResource: options.resolveResource,
readSource: async (name) => { readSource: async (name) => {
if (name === "workspace.qx") return plan.workspace; if (name === "workspace.qx") return plan.workspace;
if (name === plan.fileName) return plan.fragment; if (name === plan.fileName) return plan.fragment;
const text = await readQxSource(options.root, name); const text = await readQxSource(options.root, name);
observed.set(name, text); observed.set(name, text);
return text; return text;
} }); },
});
if (!options.write) return plan; if (!options.write) return plan;
const workspacePath = path.join(options.root, "workspace.qx"); const workspacePath = path.join(options.root, "workspace.qx");
const temporary = path.join(options.root, `.qx-scaffold-${randomUUID()}.tmp`); const temporary = path.join(options.root, `.qx-scaffold-${randomUUID()}.tmp`);
@@ -47,13 +54,22 @@ export const scaffoldAtom = async (options: {
try { try {
const fragment = await open(fragmentPath, "wx"); const fragment = await open(fragmentPath, "wx");
createdFragment = true; 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); const file = await open(temporary, "wx", (await lstat(workspacePath)).mode);
createdTemporary = true; 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); observed.set("workspace.qx", before);
for (const [name, text] of observed) { 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); await rename(temporary, workspacePath);
createdTemporary = false; createdTemporary = false;
+13 -9
View File
@@ -16,9 +16,7 @@ export const readQxSource = async (root: string, name: string) => {
}; };
/** Local imports share one workspace scope; paths are always repository-relative. */ /** Local imports share one workspace scope; paths are always repository-relative. */
export const resolveQxSources = async ( export const resolveQxSources = async (read: (name: string) => Promise<string>, entry = "workspace.qx") => {
read: (name: string) => Promise<string>, entry = "workspace.qx",
) => {
const files = new Map<string, string>(); const files = new Map<string, string>();
const active: string[] = []; const active: string[] = [];
const visited = new Set<string>(); const visited = new Set<string>();
@@ -34,8 +32,8 @@ export const resolveQxSources = async (
if (visited.has(name)) return; if (visited.has(name)) return;
const text = await read(name); const text = await read(name);
const syntax = parseQx(text, name); const syntax = parseQx(text, name);
if (syntax.diagnostics.length) throw new Error(syntax.diagnostics.map((d) => if (syntax.diagnostics.length)
`${name}:${d.line}:${d.column + 1}: ${d.message}`).join("\n")); throw new Error(syntax.diagnostics.map((d) => `${name}:${d.line}:${d.column + 1}: ${d.message}`).join("\n"));
const declaration = syntax.root.children[0]!; const declaration = syntax.root.children[0]!;
if (declaration.kind !== (root ? "workspaceDecl" : "fragmentDecl")) if (declaration.kind !== (root ? "workspaceDecl" : "fragmentDecl"))
throw new Error(`${name}: expected ${root ? "workspace" : "fragment"} document`); throw new Error(`${name}: expected ${root ? "workspace" : "fragment"} document`);
@@ -60,16 +58,22 @@ export const resolveQxSources = async (
}; };
await visit(entry, true); await visit(entry, true);
return { return {
source, sourceFiles: [...files.keys()], files, source,
sourceFiles: [...files.keys()],
files,
originalPosition(line: number, column: number) { originalPosition(line: number, column: number) {
const lines = source.split("\n"); 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; [...(lines[line - 1] ?? "")].slice(0, column).join("").length;
const segment = segments.find((entry) => entry.start <= offset && entry.end > offset); const segment = segments.find((entry) => entry.start <= offset && entry.end > offset);
if (!segment) return { fileName: entry, line, column }; if (!segment) return { fileName: entry, line, column };
const prefix = files.get(segment.fileName)!.slice(0, segment.sourceStart + offset - segment.start); const prefix = files.get(segment.fileName)!.slice(0, segment.sourceStart + offset - segment.start);
return { fileName: segment.fileName, line: prefix.split("\n").length, return {
column: prefix.length - prefix.lastIndexOf("\n") - 1 }; fileName: segment.fileName,
line: prefix.split("\n").length,
column: prefix.length - prefix.lastIndexOf("\n") - 1,
};
}, },
}; };
}; };
+46 -18
View File
@@ -17,20 +17,26 @@ export const parseQx = (source: string, fileName = "<memory>") => {
kind: QuixosCapabilityParser.ruleNames[context.ruleIndex]!, kind: QuixosCapabilityParser.ruleNames[context.ruleIndex]!,
start: offset(context.start?.start ?? 0), start: offset(context.start?.start ?? 0),
end: offset((context.stop?.stop ?? -1) + 1), 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 { return {
source, fileName, source,
fileName,
root: node(parsed.tree), root: node(parsed.tree),
diagnostics: parsed.diagnostics.map((diagnostic) => { diagnostics: parsed.diagnostics.map((diagnostic) => {
const line = source.split("\n")[diagnostic.line - 1] ?? ""; const line = source.split("\n")[diagnostic.line - 1] ?? "";
return { ...diagnostic, column: [...line].slice(0, diagnostic.column).join("").length }; return { ...diagnostic, column: [...line].slice(0, diagnostic.column).join("").length };
}), }),
tokens: parsed.tokens.getTokens().filter((token) => token.type !== -1).map((token) => ({ tokens: parsed.tokens
kind: QuixosCapabilityParser.symbolicNames[token.type] ?? "token", .getTokens()
start: offset(token.start), end: offset(token.stop + 1), .filter((token) => token.type !== -1)
text: token.text ?? "", trivia: token.channel !== 0, .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 previousStart = -1;
let result = ""; let result = "";
for (const edit of sorted) { for (const edit of sorted) {
if (!Number.isInteger(edit.start) || !Number.isInteger(edit.end) || if (
edit.start < end || edit.start === previousStart || edit.end < edit.start || edit.end > source.length) { !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"); throw new Error("Invalid or overlapping source edits");
} }
result += source.slice(end, edit.start) + edit.text; result += source.slice(end, edit.start) + edit.text;
@@ -68,14 +80,27 @@ export const lintQx = (source: string, fileName = "<memory>") => {
const importPath: string = JSON.parse(source.slice(literal.start, literal.end)); const importPath: string = JSON.parse(source.slice(literal.start, literal.end));
let message: string | undefined; let message: string | undefined;
let code = "invalid-source-import"; let code = "invalid-source-import";
try { validateQxImportPath(importPath); } catch (error) { message = (error as Error).message; } try {
if (!message && seen.has(importPath)) { code = "duplicate-source-import"; message = `Repeated local import ${importPath}`; } 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); seen.add(importPath);
if (message) { if (message) {
const prefix = source.slice(0, node.start); const prefix = source.slice(0, node.start);
diagnostics.push({ phase: "syntax", code, message, fileName, diagnostics.push({
line: prefix.split("\n").length, column: prefix.length - prefix.lastIndexOf("\n") - 1, phase: "syntax",
severity: code === "duplicate-source-import" ? "warning" : "error" }); code,
message,
fileName,
line: prefix.split("\n").length,
column: prefix.length - prefix.lastIndexOf("\n") - 1,
severity: code === "duplicate-source-import" ? "warning" : "error",
});
} }
} }
return diagnostics; return diagnostics;
@@ -117,12 +142,15 @@ export const addWorkspaceImport = (source: string, importPath: string) => {
} }
const brace = syntax.tokens.find((token) => token.kind === "LBRACE")!; const brace = syntax.tokens.find((token) => token.kind === "LBRACE")!;
const newline = source.includes("\r\n") ? "\r\n" : "\n"; const newline = source.includes("\r\n") ? "\r\n" : "\n";
return applySourceEdits(source, [{ start: brace.end, end: brace.end, return applySourceEdits(source, [
text: `${newline} import ${JSON.stringify(importPath)};` }]); { start: brace.end, end: brace.end, text: `${newline} import ${JSON.stringify(importPath)};` },
]);
}; };
export const validateQxImportPath = (value: string) => { 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) || if (
value.split("/").some((part) => part === "." || part === "..")) !/^(?:[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}`); throw new Error(`Invalid repository-relative QX import path: ${value}`);
}; };
+150 -43
View File
@@ -12,23 +12,46 @@ export type StructuralEdit =
| { operation: "import"; kind: "interface" | "package"; name: string } | { operation: "import"; kind: "interface" | "package"; name: string }
| { operation: "semantic-major"; target: StructuralSelector; major: number } | { operation: "semantic-major"; target: StructuralSelector; major: number }
| { operation: "conformance-id"; target: StructuralSelector; id: string } | { operation: "conformance-id"; target: StructuralSelector; id: string }
| { operation: "quixos-pin"; source: {repository: string; commit: string} } | { operation: "quixos-pin"; source: { repository: string; commit: string } }
| { operation: "dependency"; kind: "interface" | "package"; name: string; source: {repository: string; commit: string} | null }; | {
operation: "dependency";
kind: "interface" | "package";
name: string;
source: { repository: string; commit: string } | null;
};
// Deliberately exclude valueType/identifier/stringLiteral: callers operate on // Deliberately exclude valueType/identifier/stringLiteral: callers operate on
// declaration structure, not arbitrary token offsets or lockfile text patches. // declaration structure, not arbitrary token offsets or lockfile text patches.
const selectable = new Set(["workspaceDecl", "fragmentDecl", "interfaceResourceDecl", "packageResourceDecl", "atomDecl", const selectable = new Set([
"valueMember", "relationshipMember", "operationMember", "packageOperationExport", "packageFunctionExport", "packageConstructorExport", "workspaceDecl",
"conformanceDecl", "stateDecl", "edgeDecl", "constructorBindingDecl", "resourceImportDecl", "sourceImportDecl", "operationBindingDecl"]); "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. /** 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. */ * The revision's real identity is derived from its containing commit at compile time. */
export const instantiateWorkspaceIdentity = (source: string, workspaceId: string): string => { 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); const syntax = parseQx(source);
if (syntax.diagnostics.length) throw new Error("Cannot instantiate a malformed template workspace"); if (syntax.diagnostics.length) throw new Error("Cannot instantiate a malformed template workspace");
const declaration = syntax.root.children.find(node => node.kind === "workspaceDecl"); const declaration = syntax.root.children.find((node) => node.kind === "workspaceDecl");
const literals = declaration?.children.filter(node => node.kind === "stringLiteral"); const literals = declaration?.children.filter((node) => node.kind === "stringLiteral");
if (!literals || literals.length !== 3) throw new Error("Template must contain a workspace declaration"); if (!literals || literals.length !== 3) throw new Error("Template must contain a workspace declaration");
return applySourceEdits(source, [ return applySourceEdits(source, [
{ ...literals[0], text: JSON.stringify(workspaceId) }, { ...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<typeof parseQx>} => { const select = (
source: string,
selector: StructuralSelector,
): { node: SyntaxNode; syntax: ReturnType<typeof parseQx> } => {
if (!selectable.has(selector.kind)) throw new Error(`Unsupported structural selector ${selector.kind}`); if (!selectable.has(selector.kind)) throw new Error(`Unsupported structural selector ${selector.kind}`);
const syntax = parseQx(source); const syntax = parseQx(source);
if (syntax.diagnostics.length) throw new Error("Cannot scaffold syntactically invalid QX"); if (syntax.diagnostics.length) throw new Error("Cannot scaffold syntactically invalid QX");
const matches = [...walkSyntax(syntax.root)].filter((node) => { const matches = [...walkSyntax(syntax.root)].filter((node) => {
if (node.kind !== selector.kind) return false; 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 (
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; 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) { if (selector.id) {
// Only an explicit ID field counts, not a coincidentally equal revision, // Only an explicit ID field counts, not a coincidentally equal revision,
// default value, nested declaration, or comment. // default value, nested declaration, or comment.
const tokens = syntax.tokens.filter((token) => !token.trivia && token.start >= node.start && token.end <= node.end); const tokens = syntax.tokens.filter(
const literal = node.children.find((child) => child.kind === "stringLiteral" && tokens.some((token, index) => token.start === child.start && tokens[index - 1]?.kind === "ID")); (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; if (!literal || JSON.parse(source.slice(literal.start, literal.end)) !== selector.id) return false;
} }
return true; return true;
}); });
if (matches.length !== 1) throw new Error(`Structural selector must resolve exactly once (found ${matches.length})`); 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. */ /** 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]; const offsets = [0];
for (const character of source) offsets.push(offsets[offsets.length - 1] + character.length); for (const character of source) offsets.push(offsets[offsets.length - 1] + character.length);
const result = applySourceEdits(source, [ 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); 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; return result;
} }
if (edit.operation === "import") { 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); const syntax = parseQx(source);
if (syntax.diagnostics.length) throw new Error("Cannot scaffold invalid QX"); if (syntax.diagnostics.length) throw new Error("Cannot scaffold invalid QX");
const text = `import ${edit.kind} ${edit.name};`; 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 root = syntax.root.children[0];
const position = ["workspaceDecl", "fragmentDecl"].includes(root.kind) ? syntax.tokens.find((token) => token.kind === "LBRACE")!.end : root.start; const position = ["workspaceDecl", "fragmentDecl"].includes(root.kind)
const result = applySourceEdits(source, [{start: position, end: position, text: `\n${text}\n`}]); ? 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"); if (parseQx(result).diagnostics.length) throw new Error("Invalid resource import position");
return result; return result;
} }
if (edit.operation === "dependency") { if (edit.operation === "dependency") {
const parsed = parseQuixosLockDocument(source); const parsed = parseQuixosLockDocument(source);
if (!parsed.ok) throw new Error("Cannot scaffold an invalid lockfile"); 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 parser = new QuixosLockParser(new CommonTokenStream(new QuixosLockLexer(CharStream.fromString(source))));
const tree = parser.document(); const tree = parser.document();
const offsets = [0]; const offsets = [0];
for (const character of source) offsets.push(offsets[offsets.length - 1] + character.length); 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"); if (entries.length > 1) throw new Error("Ambiguous dependency selector");
const entry = entries[0]; 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"); 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 start = entry ? offsets[entry.start!.start] : offsets[tree.RBRACE().symbol.start];
const end = entry ? offsets[entry.stop!.stop + 1] : 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); 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; 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; let result: string;
if (edit.operation === "semantic-major") { 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 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 marker = tokens.findIndex((token) => token.kind === "SEMANTIC_MAJOR");
const value = marker < 0 ? undefined : tokens[marker + 1]; const value = marker < 0 ? undefined : tokens[marker + 1];
const brace = tokens.find((token) => token.kind === "LBRACE")!; 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") { } 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"); const existing = node.children.find((entry) => entry.kind === "stringLiteral");
if (existing) { 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; return source;
} }
const identifiers = node.children.filter((entry) => entry.kind === "identifier"); const identifiers = node.children.filter((entry) => entry.kind === "identifier");
const position = identifiers[identifiers.length - 1].end; 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") { } else if (edit.operation === "append") {
const closing = syntax.tokens.find((token) => token.kind === "RBRACE" && token.end === node.end); 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"); 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 { } else {
// A resource parser node includes imports/external declarations preceding // A resource parser node includes imports/external declarations preceding
// its header. Replacing the declaration must not delete that preamble. // its header. Replacing the declaration must not delete that preamble.
const identifier = node.children.find(child => child.kind === "identifier"); const identifier = node.children.find((child) => child.kind === "identifier");
const header = ["packageResourceDecl", "interfaceResourceDecl"].includes(node.kind) && identifier const header =
? syntax.tokens.filter(token => token.start >= node.start && token.end <= identifier.start && ["packageResourceDecl", "interfaceResourceDecl"].includes(node.kind) && identifier
token.kind === (node.kind === "packageResourceDecl" ? "PACKAGE" : "INTERFACE")).at(-1)?.start : undefined; ? syntax.tokens
const wrapper = edit.operation === "remove" && ["stateDecl", "edgeDecl"].includes(node.kind) .filter(
? [...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] (token) =>
: undefined; token.start >= node.start &&
result = applySourceEdits(source, [{start: wrapper?.start ?? header ?? node.start, end: wrapper?.end ?? node.end, text: edit.operation === "replace" ? edit.source : ""}]); 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); 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; return result;
}; };
export const scaffoldResourceSource = (kind: "interface" | "package", name: string, id: string, revision: string) => { 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`; 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"); if (parseQx(source).diagnostics.length) throw new Error("Invalid resource scaffold");
return source; return source;
+207 -79
View File
@@ -7,125 +7,220 @@ import { editStructure, type StructuralEdit } from "./structural-edits.js";
import { snapshotRepository, localResourceSnapshots } from "./candidate-check.js"; import { snapshotRepository, localResourceSnapshots } from "./candidate-check.js";
import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js"; import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js";
import { createGitCapabilityResolver } from "./git-resolver.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 { parseQx } from "./source.js";
import { parseQuixosLockDocument } from "../resource-lock/index.js"; import { parseQuixosLockDocument } from "../resource-lock/index.js";
import { withFileLock } from "./file-lock.js"; import { withFileLock } from "./file-lock.js";
export type StructuralRequest = { export type StructuralRequest = {
kind: "workspace" | "interface" | "package"; kind: "workspace" | "interface" | "package";
source?: {repository: string; commit: string}; source?: { repository: string; commit: string };
resourceRoot?: string; resourceRoot?: string;
validation?: "syntax" | "resource-graph"; 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 Change = { file: string; before: string | null; after: string; mode: number };
type Journal = {schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[]}; type Journal = { schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[] };
const safeFile = (file: string) => { 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|mjs|json|nix|txtpb))$/.test(file) if (
|| file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))) throw new Error(`Unsafe scaffold path ${file}`); !/^(?:[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|md))$/.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<string | null> => { const read = async (root: string, file: string): Promise<string | null> => {
safeFile(file); safeFile(file);
const target = path.join(root, file); const target = path.join(root, file);
try { try {
const metadata = await fs.lstat(target); 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"); 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) => { const containedParent = async (root: string, file: string) => {
let current = root; let current = root;
for (const part of file.split("/").slice(0, -1)) { for (const part of file.split("/").slice(0, -1)) {
current = path.join(current, part); 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); 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 durableJson = async (file: string, value: unknown) => {
const temporary = `${file}.${randomUUID()}.tmp`; const temporary = `${file}.${randomUUID()}.tmp`;
const handle = await fs.open(temporary, "wx", 0o600); 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); await fs.rename(temporary, file);
const directory = await fs.open(path.dirname(file), "r"); 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. */ /** Validate the entire edited resource graph in a private snapshot before writes. */
export const planStructure = async (rootPath: string, request: StructuralRequest, snapshotMap?: string) => { 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 root = await fs.realpath(rootPath);
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-structure-")); const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-structure-"));
try { try {
const snapshot = await snapshotRepository(root, path.join(temporary, "source")); 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[] = []; const changes: Change[] = [];
if (!Array.isArray(request.files) || !request.files.length || request.files.length > 100) throw new Error("Structural plan requires 1100 files"); if (!Array.isArray(request.files) || !request.files.length || request.files.length > 100)
throw new Error("Structural plan requires 1100 files");
for (const input of request.files) { for (const input of request.files) {
safeFile(input.file); safeFile(input.file);
if (changes.some((entry) => entry.file === input.file)) throw new Error("Repeated structural file target"); if (changes.some((entry) => entry.file === input.file)) throw new Error("Repeated structural file target");
const before = await read(root, input.file); const before = await read(root, input.file);
let after: string; let after: string;
if ("create" in input) { 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; after = input.create;
} else if ("replace" in input) { } 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; after = input.replace;
} else if ("generated" in input) { } 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;}})(); const generated = (text: string) =>
if (typeof input.generated !== "string" || !generated(input.generated) || (before !== null && !generated(before))) throw new Error("Only scaffold-owned generated files may be regenerated"); 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; after = input.generated;
} else { } 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); after = input.edits.reduce(editStructure, before);
} }
if (Buffer.byteLength(after) > 1024 * 1024) throw new Error("Scaffold file exceeds 1 MiB"); 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; 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 containedParent(snapshot.directory, input.file);
await fs.writeFile(path.join(snapshot.directory, input.file), after); await fs.writeFile(path.join(snapshot.directory, input.file), after);
} }
if (request.validation === "syntax") { if (request.validation === "syntax") {
for (const change of changes) { 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(".qx") && parseQx(change.after, change.file).diagnostics.length)
if (change.file.endsWith(".lock") && !parseQuixosLockDocument(change.after, change.file).ok) throw new Error(`Invalid lock syntax in ${change.file}`); 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 { } else {
const localMap = path.join(temporary, "local-resources.json"); const localMap = path.join(temporary, "local-resources.json");
await fs.writeFile(localMap, JSON.stringify(await localResourceSnapshots(root, snapshotMap))); await fs.writeFile(localMap, JSON.stringify(await localResourceSnapshots(root, snapshotMap)));
const resolveResource = await createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resources"), snapshotMap: localMap}); const resolveResource = await createGitCapabilityResolver({
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"); checkoutRoot: path.join(temporary, "resources"),
const resourceRoot = path.join(snapshot.directory, request.resourceRoot ?? ""); snapshotMap: localMap,
if (request.kind === "workspace") await compileWorkspaceRepository({rootDirectory: resourceRoot, resolveResource}); });
else if (["package", "interface"].includes(request.kind) && request.source) { if (
const compiled = await compileCapabilityResourceRepository({rootDirectory: resourceRoot, kind: request.kind as "package" | "interface", source: {resolver: "git", ...request.source}, resolveResource}); request.resourceRoot &&
if (compiled.resource.kind === "package") { !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(request.resourceRoot)
const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8")); )
const artifacts = [ throw new Error("Resource root must be a contained relative directory");
{file: configuration.bindingOutput as string, after: generateTypeScriptBindings(bindingSchema(compiled), compiled.resource.revision.revisionId, configuration.options)}, const resourceRoot = path.join(snapshot.directory, request.resourceRoot ?? "");
]; if (request.kind === "workspace")
for (const artifact of artifacts) { await compileWorkspaceRepository({ rootDirectory: resourceRoot, resolveResource });
const file = request.resourceRoot ? `${request.resourceRoot}/${artifact.file}` : artifact.file; else if (["package", "interface"].includes(request.kind) && request.source) {
safeFile(file); const compiled = await compileCapabilityResourceRepository({
if (Buffer.byteLength(artifact.after) > 1024 * 1024) throw new Error("Generated scaffold file exceeds 1 MiB"); rootDirectory: resourceRoot,
const before = await read(root, file); kind: request.kind as "package" | "interface",
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}`); source: { resolver: "git", ...request.source },
const previous = changes.find((entry) => entry.file === file); resolveResource,
if (previous) previous.after = artifact.after; });
else changes.push({file, before, after: artifact.after, mode: 0o644}); 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"); 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. // 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 changes)
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}`); if ((await read(root, entry.file)) !== entry.before)
return {root, changes, observed, digest: contentDigest(changes), validation: request.validation ?? "resource-graph" as const}; throw new Error(`Source changed while planning: ${entry.file}`);
} finally { await fs.rm(temporary, {recursive: true, force: true}); } 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. */ /** 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"); if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid scaffold journal ID");
const journalPath = path.join(root, ".quixos", "scaffolds", `${id}.json`); const journalPath = path.join(root, ".quixos", "scaffolds", `${id}.json`);
const journal = JSON.parse(await fs.readFile(journalPath, "utf8")) as Journal; 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) { for (const entry of journal.changes) {
const current = await read(root, entry.file); 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) { 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); await containedParent(root, entry.file);
const target = path.join(root, entry.file); const target = path.join(root, entry.file);
const temporary = `${target}.qx-${randomUUID()}.tmp`; const temporary = `${target}.qx-${randomUUID()}.tmp`;
const handle = await fs.open(temporary, "wx", entry.mode); 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) { if (entry.before === null) {
// link is atomic and fails if another author created the destination. // link is atomic and fails if another author created the destination.
await fs.link(temporary, target); await fs.link(temporary, target);
await fs.unlink(temporary); await fs.unlink(temporary);
} else { } 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); await fs.rename(temporary, target);
} }
const directory = await fs.open(path.dirname(target), "r"); 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"; journal.phase = "complete";
await durableJson(journalPath, journal); await durableJson(journalPath, journal);
return {id, journalPath, phase: journal.phase}; return { id, journalPath, phase: journal.phase };
}; };
const withStructureLock = async <T>(root: string, work: () => Promise<T>) => { const withStructureLock = async <T>(root: string, work: () => Promise<T>) => {
@@ -172,22 +281,41 @@ export const resumeStructure = async (rootPath: string, id: string) => {
const root = await fs.realpath(rootPath); const root = await fs.realpath(rootPath);
return withStructureLock(root, () => replayStructure(root, id)); return withStructureLock(root, () => replayStructure(root, id));
}; };
export const applyStructure = async (plan: Awaited<ReturnType<typeof planStructure>>, id: string = randomUUID()) => withStructureLock(plan.root, async () => { export const applyStructure = async (plan: Awaited<ReturnType<typeof planStructure>>, id: string = randomUUID()) =>
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid scaffold journal ID"); withStructureLock(plan.root, async () => {
const directory = path.join(plan.root, ".quixos", "scaffolds"); if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid scaffold journal ID");
try { const directory = path.join(plan.root, ".quixos", "scaffolds");
const existing = JSON.parse(await fs.readFile(path.join(directory, `${id}.json`), "utf8")) as Journal; try {
if (existing.root !== plan.root || contentDigest(existing.changes) !== plan.digest) throw new Error("Scaffold journal identity conflict"); const existing = JSON.parse(await fs.readFile(path.join(directory, `${id}.json`), "utf8")) as Journal;
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}`); 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); 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);
});
+323 -96
View File
@@ -2,20 +2,21 @@
import { readFile, writeFile, mkdtemp, rm, realpath } from "node:fs/promises"; import { readFile, writeFile, mkdtemp, rm, realpath } from "node:fs/promises";
import { parseQx, formatQx, lintQx } from "./source.js"; import { parseQx, formatQx, lintQx } from "./source.js";
import { scaffoldAtom } from "./scaffold.js"; import { scaffoldAtom } from "./scaffold.js";
import { loadQxSources } from "./source-loader.js";
import { createGitCapabilityResolver } from "./git-resolver.js"; import { createGitCapabilityResolver } from "./git-resolver.js";
import { planEvolution } from "../capability-model/index.js"; import { planEvolution } from "../capability-model/index.js";
import { snapshotRepository } from "./candidate-check.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 path from "node:path";
import os from "node:os"; 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 { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js";
import {scaffoldRecipe, type ScaffoldRecipe} from "./scaffold-recipes.js"; import { scaffoldRecipe, type ScaffoldRecipe } from "./scaffold-recipes.js";
import {checkBundleSources} from "../bindings/bundle-policy.js"; import { checkBundleSources } from "../bindings/bundle-policy.js";
import {sealMigrations} from "./migration-seal.js"; import { sealMigrations } from "./migration-seal.js";
import {generatePackageDescriptor} from "../bindings/index.js"; import { generatePackageDescriptor } from "../bindings/index.js";
import {buildCheckedPackage, buildImmutableCandidate, snapshotCommit} from "./checked-build.js"; import { buildCheckedPackage, buildImmutableCandidate, snapshotCommit } from "./checked-build.js";
import {formatQuixosLock, loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js"; import { formatQuixosLock, loadQuixosLock, parseQuixosLockDocument } from "../resource-lock/index.js";
import { walkSyntax } from "./source.js"; import { walkSyntax } from "./source.js";
import { inspectWorkbench } from "./authoring-inspect.js"; import { inspectWorkbench } from "./authoring-inspect.js";
import { authoringContext } from "./authoring-context.js"; import { authoringContext } from "./authoring-context.js";
@@ -24,32 +25,96 @@ import { checkAuthoring } from "./authoring-check.js";
import { authoringWorklist } from "./authoring-worklist.js"; import { authoringWorklist } from "./authoring-worklist.js";
const authorSource = async (root: string) => { 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"); 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) => { 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")); return JSON.parse(value.trimStart().startsWith("{") ? value : await readFile(value, "utf8"));
}; };
const planSummary = (plan: Awaited<ReturnType<typeof planStructure>>) => ({ const planSummary = (plan: Awaited<ReturnType<typeof planStructure>>) => ({
root: plan.root, validation: plan.validation, root: plan.root,
changes: plan.changes.map(change => ({file: change.file, beforeBytes: change.before?.length ?? 0, afterBytes: change.after?.length ?? 0})), 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.", note: "Structural plan only, not implementation verification. Run qx-workspace check while iterating.",
}); });
const main = async () => { const main = async () => {
const [command, ...args] = process.argv.slice(2); const [command, ...args] = process.argv.slice(2);
if (command === "scaffold-validate-qx") {
if (args.length !== 1) throw new Error("scaffold-validate-qx SOURCES_JSON");
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");
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"));
}
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 syntax = parseQx(source);
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")!);
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");
return;
}
if (command === "package-identity") { if (command === "package-identity") {
if (args.length !== 1) throw new Error("usage: quixos-qx package-identity PACKAGE_QX"); if (args.length !== 1) throw new Error("usage: quixos-qx package-identity PACKAGE_QX");
const authored = await readFile(args[0], "utf8"); const authored = await readFile(args[0], "utf8");
const syntax = parseQx(authored); const syntax = parseQx(authored);
const declarations = [...walkSyntax(syntax.root)].filter(node => node.kind === "packageResourceDecl"); 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"); if (syntax.diagnostics.length || declarations.length !== 1)
const literals = declarations[0].children.filter(node => node.kind === "stringLiteral"); throw new Error("Expected one valid package declaration");
process.stdout.write(JSON.stringify({id: JSON.parse(authored.slice(literals[0].start, literals[0].end)), const literals = declarations[0].children.filter((node) => node.kind === "stringLiteral");
revision: JSON.parse(authored.slice(literals[1].start, literals[1].end))}) + "\n"); 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; return;
} }
if (command === "package-descriptor") { if (command === "package-descriptor") {
@@ -74,29 +139,52 @@ const main = async () => {
} }
if (["author-check", "author-contract"].includes(command) && !args.includes("--help")) { if (["author-check", "author-contract"].includes(command) && !args.includes("--help")) {
const [root, output, ...flags] = args; const [root, output, ...flags] = args;
if (!root || !output) throw new Error("usage: quixos-qx author-check ROOT OUTPUT [--baseline FILE] [--reviews FILE]"); if (!root || !output)
const options: {baseline?: string; reviews?: string} = {}; 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) { for (let index = 0; index < flags.length; index += 2) {
if (!flags[index + 1]) throw new Error("Missing check option value"); if (!flags[index + 1]) throw new Error("Missing check option value");
if (flags[index] === "--baseline") options.baseline = flags[index + 1]; if (flags[index] === "--baseline") options.baseline = flags[index + 1];
else if (flags[index] === "--reviews") options.reviews = flags[index + 1]; else if (flags[index] === "--reviews") options.reviews = flags[index + 1];
else throw new Error(`Unknown check option ${flags[index]}`); 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`); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
if (result.blockers.length) process.exitCode = 1; if (result.blockers.length) process.exitCode = 1;
return; return;
} }
if (["converge", "_converge"].includes(command) && !args.includes("--help")) { 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]); const context = await authoringContext(args[0]);
if (command === "converge") { if (command === "converge") {
const result = spawnSync("flock", ["--exclusive", "--timeout", "120", "--conflict-exit-code", "75", path.join(context.workbench, ".quixos/converge.lock"), console.error(`[${new Date().toISOString()}] Capture: waiting for coordinator lock (120s limit)`);
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.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"); if (result.status === 75)
process.exitCode = result.status ?? 1; return; 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]); const result = await convergeAuthoring(context.workbench, args[1]);
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
if (!result.converged) process.exitCode = 1; if (!result.converged) process.exitCode = 1;
@@ -104,18 +192,24 @@ const main = async () => {
} }
if (command === "check-committed" && !args.includes("--help")) { if (command === "check-committed" && !args.includes("--help")) {
const [kind, repository, commit, log, ...extra] = args; 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"); if (!["workspace", "interface", "package"].includes(kind) || !log || extra.length)
process.stdout.write(`${await buildImmutableCandidate({repository, commit}, kind as "workspace" | "interface" | "package", log)}\n`); 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; return;
} }
if (!command || command === "--help" || args.includes("--help")) { 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" + process.stdout.write(
"inspect WORKBENCH [RESOURCE] shows provisional contracts, with explicit historical fallback; never verification evidence.\n" + "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" +
"resources WORKBENCH lists registered editable repositories. Use qx-workspace for the workspace authoring workflow.\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; return;
} }
if (command === "inspect" || command === "resources") { 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]); const result = command === "inspect" ? await inspectWorkbench(args[0], args[1]) : await authoringContext(args[0]);
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return; return;
@@ -129,27 +223,56 @@ const main = async () => {
const [root, kind, name, ...remaining] = args; const [root, kind, name, ...remaining] = args;
let repository: string | undefined, commit: string | undefined; let repository: string | undefined, commit: string | undefined;
const flags = [...remaining]; const flags = [...remaining];
if (flags.length && !flags[0].startsWith("--")) { repository = flags.shift(); commit = flags.shift(); } if (flags.length && !flags[0].startsWith("--")) {
else if (root && ["interface", "package"].includes(kind) && name) { repository = flags.shift();
commit = flags.shift();
} else if (root && ["interface", "package"].includes(kind) && name) {
const context = await authoringContext(root); const context = await authoringContext(root);
const alias = await realpath(path.join(context.workbench, `${kind}s`, name)).catch(() => null); 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)); const matches = context.resources.filter(
if (matches.length !== 1 || !matches[0].source) throw new Error(`Select exactly one registered ${kind} with qx-workspace resources; no match for ${name}`); (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]; const selected = matches[0];
let source = selected.source!; let source = selected.source!;
if (flags.includes("--write")) { if (flags.includes("--write")) {
const retained = spawnSync("quixos-qx", ["converge", context.workbench, selected.directory], {encoding: "utf8", env: {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"}}); const retained = spawnSync("quixos-qx", ["converge", context.workbench, selected.directory], {
if (retained.error || retained.status !== 0) throw new Error(`Dependency source needs attention: ${retained.error?.message ?? retained.stdout ?? retained.stderr}`); 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; 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"; const resourceKind = kind as "package" | "interface";
let entrypoint: "workspace" | "package" | "interface" | undefined; let entrypoint: "workspace" | "package" | "interface" | undefined;
for (const candidate of ["workspace", "package", "interface"] as const) { 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;} try {
catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} 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"); if (!entrypoint) throw new Error("No QX repository entrypoint");
const lock = await loadQuixosLock(path.join(root, "quixos.lock")); const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
@@ -157,45 +280,87 @@ const main = async () => {
let target = "quixos.lock"; let target = "quixos.lock";
for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) { for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) {
const parsed = parseQuixosLockDocument(await readFile(path.join(root, file), "utf8")); 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: [ const request: StructuralRequest = {
{file: `${entrypoint}.qx`, edits: [{operation: "import", kind: resourceKind, name}]}, kind: entrypoint,
{file: target, edits: [{operation: "dependency", kind: resourceKind, name, source: {repository, commit}}]}, 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); 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; return;
} }
if (command === "scaffold-interface") { if (command === "scaffold-interface") {
const [root, specFile, ...flags] = args; 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]"); if (!root || !specFile || flags.some((flag) => flag !== "--write"))
const spec = await readSpec(specFile) as ScaffoldRecipe; throw new Error("usage: quixos-qx scaffold-interface ROOT SPEC_JSON [--write]");
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 spec = (await readSpec(specFile)) as ScaffoldRecipe;
const request: StructuralRequest = {kind: "interface", source: spec.source, files: [ if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id || !spec.revision || !spec.tools?.quixos)
{file: "interface.qx", create: `interface ${spec.name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`}, throw new Error("Interface scaffold requires name, id, revision and Quixos toolchain source");
{file: "quixos.lock", create: formatQuixosLock({formatVersion: 1, quixos: {resolver: "git", ...spec.tools.quixos}, resources: []})}, const request: StructuralRequest = {
{file: ".gitignore", create: ".quixos/\n"}, kind: "interface",
]}; // Like package scaffolding, declaration creation is provisional. Imports
// can be attached next; the normal check verifies the complete graph.
validation: "syntax",
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); 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; return;
} }
if (command === "build-package") { 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`); process.stdout.write(`${await buildCheckedPackage(args[0], args[1], args[2])}\n`);
return; return;
} }
if (command === "source-digest") { if (command === "source-digest") {
if (!args[0] || args.length !== 1) throw new Error("usage: quixos-qx source-digest ROOT"); 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-")); 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; return;
} }
if (command === "pin-upgrade") { if (command === "pin-upgrade") {
const [workbench, specFile, ...flags] = args; const [workbench, specFile, ...flags] = args;
if (!workbench || !specFile) throw new Error("usage: quixos-qx pin-upgrade WORKBENCH SPEC_JSON [--publish] [--resume UUID]"); if (!workbench || !specFile)
let publish = false, acceptEdits = false, resume: string | undefined; 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++) { for (let index = 0; index < flags.length; index++) {
if (flags[index] === "--publish") publish = true; if (flags[index] === "--publish") publish = true;
else if (flags[index] === "--accept-edits") acceptEdits = true; else if (flags[index] === "--accept-edits") acceptEdits = true;
@@ -203,66 +368,112 @@ const main = async () => {
else throw new Error(`Unknown pin-upgrade option ${flags[index]}`); else throw new Error(`Unknown pin-upgrade option ${flags[index]}`);
} }
if (acceptEdits && !resume) throw new Error("--accept-edits requires an existing refactor journal (--resume)"); 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 const plan = resume
: await planPinUpgrades(workbench, specFile === "auto" ? await discoverUpgradeSpec(workbench) : JSON.parse(await readFile(specFile, "utf8")) as UpgradeSpec); ? JSON.parse(await readFile(path.join(workbench, ".quixos/upgrades", `${resume}.json`), "utf8")).plan
if (resume && path.resolve(workbench) !== plan.workbench) throw new Error("Upgrade journal belongs to another workbench"); : await planPinUpgrades(
process.stdout.write(`${JSON.stringify(publish ? await applyPinUpgrades(plan, resume, undefined, {acceptEdits}) : plan, null, 2)}\n`); 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; 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)) { if (["scaffold-package", "scaffold-function", "scaffold-migration", "scaffold-install"].includes(command)) {
const [root, specFile, ...flags] = args; const [root, specFile, ...flags] = args;
let spec: ScaffoldRecipe; let spec: ScaffoldRecipe;
if (command === "scaffold-function" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(specFile ?? "")) { 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 authored = parseQx(await readFile(path.join(root, "package.qx"), "utf8"));
const declarationNode = [...walkSyntax(authored.root)].find(node => node.kind === "packageResourceDecl"); const declarationNode = [...walkSyntax(authored.root)].find((node) => node.kind === "packageResourceDecl");
const nameNode = declarationNode?.children.find(node => node.kind === "identifier"); const nameNode = declarationNode?.children.find((node) => node.kind === "identifier");
if (!nameNode) throw new Error("Expected a package declaration"); if (!nameNode) throw new Error("Expected a package declaration");
const name = authored.source.slice(nameNode.start, nameNode.end); 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"); const declaration = flags.indexOf("--declaration");
if (declaration >= 0) { if (declaration >= 0) {
if (!flags[declaration + 1]) throw new Error("--declaration requires a QX declaration file"); if (!flags[declaration + 1]) throw new Error("--declaration requires a QX declaration file");
spec.declaration = await readFile(flags[declaration + 1], "utf8"); spec.declaration = await readFile(flags[declaration + 1], "utf8");
const parsed = parseQx(`package Draft id "package:draft" revision "package:draft@1" { ${spec.declaration} }`); 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")); 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)); const exported = [...walkSyntax(parsed.root)].filter((node) =>
if (exported.length !== 1) throw new Error("--declaration must contain exactly one function, operation or constructor export"); ["packageOperationExport", "packageFunctionExport", "packageConstructorExport"].includes(node.kind),
const literal = exported[0].children.find(node => node.kind === "stringLiteral"); );
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"); if (!literal) throw new Error("Declaration requires an authored export ID");
spec.id = JSON.parse(parsed.source.slice(literal.start, literal.end)); spec.id = JSON.parse(parsed.source.slice(literal.start, literal.end));
flags.splice(declaration, 2); flags.splice(declaration, 2);
} }
} else spec = await readSpec(specFile) as ScaffoldRecipe; } 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]]"); if (
const plan = command === "scaffold-install" ? undefined : await planStructure(root, !root ||
await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration", spec), process.env.QUIXOS_SNAPSHOT_MAP); !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; const applied = plan && flags.includes("--write") ? await applyStructure(plan) : undefined;
if (flags.includes("--install")) { if (flags.includes("--install")) {
const cwd = path.resolve(root, spec.directory ?? ""); const cwd = path.resolve(root, spec.directory ?? "");
const toolchain = JSON.parse(await readFile(path.join(cwd, "quixos.toolchain.json"), "utf8")); 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"); if (toolchain.generatedBy !== "qx-scaffold-v1" || typeof toolchain.nixifyPluginUrl !== "string")
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) { throw new Error("Missing scaffold toolchain");
const result = spawnSync(executable, [...args], {cwd, stdio: ["inherit", 2, 2]}); for (const [executable, args] of [
if (result.error || result.status !== 0) throw new Error(`Scaffold files retained; ${executable} ${args.join(" ")} failed: ${result.error?.message ?? result.status}`); ["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); 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"); 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; return;
} }
if (command === "scaffold-structure") { if (command === "scaffold-structure") {
const [root, spec, ...flags] = args; 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]"); if (!root || !spec || flags.some((flag) => flag !== "--write"))
const request = await readSpec(spec) as StructuralRequest; 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); if (request.kind !== "workspace" && !request.source) request.source = await authorSource(root);
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP); const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
const applied = flags.includes("--write") ? await applyStructure(plan) : undefined; 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; return;
} }
if (command === "scaffold-resume") { if (command === "scaffold-resume") {
@@ -271,10 +482,14 @@ const main = async () => {
process.stdout.write(`${JSON.stringify(await resumeStructure(root, id), null, 2)}\n`); process.stdout.write(`${JSON.stringify(await resumeStructure(root, id), null, 2)}\n`);
return; 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") { if (command === "evolution") {
const [baseline, candidate, reviews, ...extra] = args; 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 before = baseline === "none" ? null : JSON.parse(await readFile(baseline, "utf8"));
const after = JSON.parse(await readFile(candidate, "utf8")); const after = JSON.parse(await readFile(candidate, "utf8"));
const decisions = reviews ? JSON.parse(await readFile(reviews, "utf8")) : []; const decisions = reviews ? JSON.parse(await readFile(reviews, "utf8")) : [];
@@ -284,23 +499,35 @@ const main = async () => {
} }
if (command === "scaffold-atom") { if (command === "scaffold-atom") {
const [root, name, id, ...flags] = args; 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 resolveResource = await createGitCapabilityResolver({ checkoutRoot: `${root}/.quixos/resource-checkouts` });
const plan = await scaffoldAtom({ root, name, id, write: flags.includes("--write"), resolveResource }); const plan = await scaffoldAtom({ root, name, id, write: flags.includes("--write"), resolveResource });
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
return; return;
} }
const [file, flag, ...rest] = args; const [file, flag, ...rest] = args;
if (!file || rest.length || (flag && flag !== "--write") || !["parse", "lint", "format"].includes(command ?? "") || if (
(flag && command !== "format")) throw new Error("usage: quixos-qx parse|lint|format FILE [--write (format only)]"); !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"); const source = await readFile(file, "utf8");
if (command === "format") { if (command === "format") {
const formatted = formatQx(source); 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 { } else {
const result = command === "parse" ? parseQx(source, file) : lintQx(source, file); const result = command === "parse" ? parseQx(source, file) : lintQx(source, file);
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); 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;
});
+66 -28
View File
@@ -4,12 +4,18 @@ import { readFile, writeFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { compileWorkspaceRepository } from "./assembly.js"; import { compileWorkspaceRepository } from "./assembly.js";
import { createGitCapabilityResolver } from "./git-resolver.js"; import { createGitCapabilityResolver } from "./git-resolver.js";
import { planEvolution, runtimeContracts, type EvolutionReview, type WorkspaceRevision } from "../capability-model/index.js"; import { bindingSchema, specializeBindingSchema } from "../bindings/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 const usage = `usage: quixos-workspace-compile --root DIRECTORY --checkout-root DIRECTORY
[--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID] [--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID]
[--source-root-commit GIT_REV] [--baseline PLAN_JSON] [--evolution-out PATH] [--source-root-commit GIT_REV] [--baseline PLAN_JSON] [--evolution-out PATH]
[--reviews REVIEW_JSON] [--reviews REVIEW_JSON] [--schemas-out PATH]
Resolves a workspace's recursive resource-lock graph, clones every exact Resolves a workspace's recursive resource-lock graph, clones every exact
resource revision, validates standalone interface/package manifests, and emits resource revision, validates standalone interface/package manifests, and emits
@@ -30,6 +36,7 @@ const parseArgs = (args: string[]) => {
rootDirectory, rootDirectory,
checkoutRoot, checkoutRoot,
graphOut: values.get("--graph-out"), graphOut: values.get("--graph-out"),
schemasOut: values.get("--schemas-out"),
snapshotMap: values.get("--snapshot-map"), snapshotMap: values.get("--snapshot-map"),
workspaceId: values.get("--workspace-id"), workspaceId: values.get("--workspace-id"),
workspaceRevisionId: values.get("--workspace-revision-id"), workspaceRevisionId: values.get("--workspace-revision-id"),
@@ -59,40 +66,71 @@ const main = async () => {
}); });
if (options.graphOut) { if (options.graphOut) {
await writeFile(options.graphOut, `${JSON.stringify({ await writeFile(
formatVersion: 1, options.graphOut,
quixos: assembled.lock.quixos, `${JSON.stringify(
directResources: [...assembled.directResources.entries()].map(([bindingKey, node]) => { {
const [kind, binding] = bindingKey.split("\0"); formatVersion: 1,
return { kind, binding, resourceKey: node.key, directory: node.directory }; quixos: assembled.lock.quixos,
}), directResources: [...assembled.directResources.entries()].map(([bindingKey, node]) => {
resources: assembled.resources.map((node) => ({ const [kind, binding] = bindingKey.split("\0");
key: node.key, return { kind, binding, resourceKey: node.key, directory: node.directory };
kind: node.kind, }),
source: node.source, resources: assembled.resources.map((node) => ({
directory: node.directory, key: node.key,
resourceId: node.resource.kind === "interface" kind: node.kind,
? node.resource.revision.interfaceId source: node.source,
: node.resource.revision.packageId, directory: node.directory,
revisionId: node.resource.revision.revisionId, resourceId:
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({ node.resource.kind === "interface"
binding, ? node.resource.revision.interfaceId
resourceKey: dependency.key, : node.resource.revision.packageId,
})), revisionId: node.resource.revision.revisionId,
})), dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
}, null, 2)}\n`); binding,
resourceKey: dependency.key,
})),
})),
},
null,
2,
)}\n`,
);
} }
const candidate = { ...assembled.workspace, executionContracts: runtimeContracts(assembled.workspace) }; const candidate = { ...assembled.workspace, executionContracts: runtimeContracts(assembled.workspace) };
if (options.schemasOut) {
const schemas: Record<string, unknown> = {};
for (const node of assembled.resources.filter((entry) => entry.kind === "package")) {
const closure = new Map<string, typeof node>();
const visit = (entry: typeof node) => {
if (closure.has(entry.key)) return;
closure.set(entry.key, entry);
entry.dependencies.forEach(visit);
};
visit(node);
schemas[node.resource.revision.revisionId] = specializeBindingSchema(
bindingSchema({ resources: [...closure.values()] }),
candidate,
node.resource.revision.revisionId,
);
}
await writeFile(options.schemasOut, JSON.stringify(schemas));
}
if (options.evolutionOut) { if (options.evolutionOut) {
const baseline = options.baseline ? JSON.parse(await readFile(options.baseline, "utf8")) as WorkspaceRevision : null; const baseline = options.baseline
const reviews = options.reviews ? JSON.parse(await readFile(options.reviews, "utf8")) as EvolutionReview[] : []; ? (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"); 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`); process.stdout.write(`${JSON.stringify(candidate, null, 2)}\n`);
}; };
main().catch((error: unknown) => { 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; process.exitCode = 1;
}); });
+254 -87
View File
@@ -3,50 +3,75 @@ import type { Binding, Conformance, DependencyBinding, PersistentAttachment, Wor
import { validateWorkspaceRevision } from "./validation.js"; import { validateWorkspaceRevision } from "./validation.js";
/** Content hashing is independent of JSON object insertion order, not array order. */ /** 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 => { export const canonicalJson = (value: unknown): string => {
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); 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 (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (typeof value === "object" && value !== null) { if (typeof value === "object" && value !== null) {
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error("Expected a plain JSON object"); if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
return `{${Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => compareText(a, b)) throw new Error("Expected a plain JSON object");
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`; 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}`); 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 => { const semantic = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(semantic); if (Array.isArray(value)) return value.map(semantic);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value) if (value && typeof value === "object")
.filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation") return Object.fromEntries(
// These are authored data/maps, not schema nodes. A user field literally Object.entries(value)
// named displayName or documentation is semantic and must stay in the hash. .filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation")
.map(([key, entry]) => [key, key === "defaultValue" || key === "fields" ? entry : semantic(entry)])); // 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; return value;
}; };
const sorted = <T>(entries: readonly T[], key: (entry: T) => string) => [...entries].sort((a, b) => compareText(key(a), key(b))); const sorted = <T>(entries: readonly T[], key: (entry: T) => string) =>
export const conformanceIdentity = (entry: Conformance): string => entry.id ?? `legacy:${entry.atomId}:${entry.interfaceRevisionId}`; [...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 /** Automatic evolution preserves values; it never interprets migration code or
* guesses that a new nominal message descriptor means the same representation. */ * guesses that a new nominal message descriptor means the same representation. */
export const storageChangeRequiresMigration = (previous: StorageContract | undefined, next: StorageContract | undefined, export const storageChangeRequiresMigration = (
oldAtomIds: ReadonlySet<string>): boolean => { previous: StorageContract | undefined,
next: StorageContract | undefined,
oldAtomIds: ReadonlySet<string>,
): boolean => {
if (!next) return true; if (!next) return true;
const after = next.definition as PersistentAttachment; const after = next.definition as PersistentAttachment;
if (!previous) { if (!previous) {
if (after.kind === "state") return oldAtomIds.has(after.attachedTo) && after.defaultValue === undefined && after.valueType.kind !== "optional"; if (after.kind === "state")
return after.endpoints.some(endpoint => endpoint.cardinality === "exactly-one" && return (
(endpoint.constraint.kind !== "atom" || oldAtomIds.has(endpoint.constraint.atomId))); 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; if (previous.ownerId !== next.ownerId || previous.kind !== next.kind) return true;
const before = previous.definition as PersistentAttachment; const before = previous.definition as PersistentAttachment;
if (before.kind === "state" && after.kind === "state") { if (before.kind === "state" && after.kind === "state") {
// Capture materializes old defaults, so changing a default affects only // Capture materializes old defaults, so changing a default affects only
// newly constructed objects, not existing sparse state. // newly constructed objects, not existing sparse state.
const {defaultValue: _beforeDefault, ...beforeStorage} = before; const { defaultValue: _beforeDefault, ...beforeStorage } = before;
const {defaultValue: _afterDefault, ...afterStorage} = after; const { defaultValue: _afterDefault, ...afterStorage } = after;
return canonicalJson(beforeStorage) !== canonicalJson(afterStorage); return canonicalJson(beforeStorage) !== canonicalJson(afterStorage);
} }
return canonicalJson(before) !== canonicalJson(after); return canonicalJson(before) !== canonicalJson(after);
@@ -55,15 +80,29 @@ export const storageContracts = (workspace: WorkspaceRevision): StorageContract[
const result: StorageContract[] = []; const result: StorageContract[] = [];
const add = (attachment: PersistentAttachment, ownerId: string) => { const add = (attachment: PersistentAttachment, ownerId: string) => {
const definition = semantic(attachment); 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 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); return sorted(result, (entry) => entry.id);
}; };
type GraphNode = { value: unknown; dependencies: Set<string>; reviewProviders: Set<string> }; type GraphNode = { value: unknown; dependencies: Set<string>; reviewProviders: Set<string> };
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. */ /** Build only outbound execution dependencies. Incoming callers never retain or invalidate a provider. */
export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[] => { 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}`; const conformanceKey = (atom: string, iface: string) => `conformance:${atom}:${iface}`;
for (const storage of storageContracts(workspace)) node(`attachment:${storage.id}`, storage); for (const storage of storageContracts(workspace)) node(`attachment:${storage.id}`, storage);
for (const iface of workspace.interfaceImports) node(`interface:${iface.revisionId}`, { for (const iface of workspace.interfaceImports)
...iface, members: sorted(iface.members, (entry) => entry.id).map((entry) => ({ ...entry, operations: sorted(entry.operations, (operation) => operation.id) })), node(`interface:${iface.revisionId}`, {
}); ...iface,
for (const pkg of workspace.packageImports) node(`package:${pkg.revisionId}`, { members: sorted(iface.members, (entry) => entry.id).map((entry) => ({
...pkg, semanticMajor: pkg.semanticMajor ?? 1, ...entry,
exports: sorted(pkg.exports, (entry) => entry.id).map((entry) => ({ ...entry, dependencyPorts: sorted(entry.dependencyPorts, (port) => port.id) })), 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<string>) => { const dependency = (parent: GraphNode, binding: DependencyBinding, atomId: string, reviews?: Set<string>) => {
if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`); if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`);
if (binding.kind === "edge") parent.dependencies.add(`attachment:${binding.edgeTypeId}`); 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}`); parent.dependencies.add(`interface:${binding.interfaceRevisionId}`);
// An edge traversal may select any matching target. Conservatively include every possible witness. // An edge traversal may select any matching target. Conservatively include every possible witness.
for (const conformance of workspace.conformances) { 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)); parent.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
reviews?.add(conformanceIdentity(conformance)); reviews?.add(conformanceIdentity(conformance));
for (const operation of conformance.operationBindings) { 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) => { 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}`)!; const packageNode = nodes.get(`package:${value.packageRevisionId}`)!;
parent.dependencies.add(`package:${value.packageRevisionId}`); parent.dependencies.add(`package:${value.packageRevisionId}`);
const normalized = { ...value, dependencies: sorted(value.dependencies, (entry) => entry.portId) }; const normalized = { ...value, dependencies: sorted(value.dependencies, (entry) => entry.portId) };
@@ -121,16 +176,24 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
}; };
for (const conformance of workspace.conformances) { for (const conformance of workspace.conformances) {
const parent = node(conformanceKey(conformance.atomId, conformance.interfaceRevisionId), { const parent = node(conformanceKey(conformance.atomId, conformance.interfaceRevisionId), {
id: conformanceIdentity(conformance), semanticMajor: conformance.semanticMajor ?? 1, id: conformanceIdentity(conformance),
atomId: conformance.atomId, interfaceRevisionId: conformance.interfaceRevisionId, semanticMajor: conformance.semanticMajor ?? 1,
atomId: conformance.atomId,
interfaceRevisionId: conformance.interfaceRevisionId,
operations: sorted(conformance.operationBindings, (entry) => entry.operationId), operations: sorted(conformance.operationBindings, (entry) => entry.operationId),
materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId), materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId),
}); });
parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`); parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`);
const contract = workspace.interfaceImports.find((entry) => entry.revisionId === conformance.interfaceRevisionId);
for (const required of contract?.requiredInterfaces ?? [])
parent.dependencies.add(conformanceKey(conformance.atomId, required));
for (const attachment of conformance.privateAttachments) parent.dependencies.add(`attachment:${attachment.id}`); for (const attachment of conformance.privateAttachments) parent.dependencies.add(`attachment:${attachment.id}`);
for (const operation of conformance.operationBindings) binding(parent, operation.binding, conformance.atomId, { for (const operation of conformance.operationBindings)
conformanceId: conformanceIdentity(conformance), semanticMajor: conformance.semanticMajor ?? 1, operationId: operation.operationId, binding(parent, operation.binding, conformance.atomId, {
}); conformanceId: conformanceIdentity(conformance),
semanticMajor: conformance.semanticMajor ?? 1,
operationId: operation.operationId,
});
for (const materialization of conformance.relationshipMaterializations) { for (const materialization of conformance.relationshipMaterializations) {
parent.dependencies.add(`constructor:${materialization.constructorAtomId}`); parent.dependencies.add(`constructor:${materialization.constructorAtomId}`);
parent.dependencies.add(`attachment:${materialization.edgeTypeId}`); parent.dependencies.add(`attachment:${materialization.edgeTypeId}`);
@@ -142,91 +205,195 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
} }
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const pkg of workspace.packageImports) counts.set(pkg.packageId, (counts.get(pkg.packageId) ?? 0) + 1); for (const pkg of workspace.packageImports) counts.set(pkg.packageId, (counts.get(pkg.packageId) ?? 0) + 1);
return sorted(workspace.packageImports.map((pkg): RuntimeContract => { return sorted(
const visited = new Set<string>(); workspace.packageImports.map((pkg): RuntimeContract => {
const walk = (key: string) => { const visited = new Set<string>();
if (visited.has(key)) return; const walk = (key: string) => {
const entry = nodes.get(key); if (visited.has(key)) return;
if (!entry) throw new Error(`Unresolved execution dependency ${key}`); const entry = nodes.get(key);
visited.add(key); if (!entry) throw new Error(`Unresolved execution dependency ${key}`);
for (const target of entry.dependencies) walk(target); 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) })); walk(`package:${pkg.revisionId}`);
return { groupId: counts.get(pkg.packageId) === 1 ? pkg.packageId : `${pkg.packageId}#${pkg.revisionId}`, const dependencies = [...visited].sort().map((id) => ({ id, digest: contentDigest(nodes.get(id)!.value) }));
packageId: pkg.packageId, packageRevisionId: pkg.revisionId, digest: contentDigest(dependencies), return {
reviewProviders: [...nodes.get(`package:${pkg.revisionId}`)!.reviewProviders].sort(), dependencies }; groupId: counts.get(pkg.packageId) === 1 ? pkg.packageId : `${pkg.packageId}#${pkg.revisionId}`,
}), (entry) => entry.groupId); 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 EvolutionReview = {
export type ReviewRequirement = { consumerId: string; providerId: string; oldMajor: number; newMajor: number; requirementDigest: string }; requirementDigest: string;
export type RuntimeAction = { groupId: string; action: "keep" | "start" | "replace" | "retire"; previous?: RuntimeContract; candidate?: RuntimeContract; reasons: 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 = { export type EvolutionReport = {
schemaVersion: 1; baselineDigest: string | null; candidateDigest: string; checkerVersion: string; schemaVersion: 1;
baselineDigest: string | null;
candidateDigest: string;
checkerVersion: string;
runtimeActions: RuntimeAction[]; 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[]; migrationRequired: string[];
reviews: Array<ReviewRequirement & { accepted: boolean }>; reviews: Array<ReviewRequirement & { accepted: boolean }>;
packageChecks: Array<{ groupId: string; contractDigest: string }>; packageChecks: Array<{ groupId: string; contractDigest: string }>;
blockers: string[]; blockers: string[];
}; };
export const planEvolution = (baseline: WorkspaceRevision | null, candidate: WorkspaceRevision, export const planEvolution = (
options: { reviews?: EvolutionReview[]; allowLegacy?: boolean } = {}): EvolutionReport => { baseline: WorkspaceRevision | null,
candidate: WorkspaceRevision,
options: { reviews?: EvolutionReview[]; allowLegacy?: boolean } = {},
): EvolutionReport => {
const issues = validateWorkspaceRevision(candidate); const issues = validateWorkspaceRevision(candidate);
if (issues.length) throw new Error(`Invalid candidate workspace:\n${issues.map((entry) => `${entry.path}: ${entry.message}`).join("\n")}`); if (issues.length)
if (baseline && baseline.workspaceId !== candidate.workspaceId) throw new Error("Cannot evolve a different workspace"); 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 candidateDigest = contentDigest(candidate);
const checkerVersion = "quixos-evolution-v1"; const checkerVersion = "quixos-evolution-v1";
const blockers: string[] = []; const blockers: string[] = [];
if (!options.allowLegacy) { if (!options.allowLegacy) {
if (candidate.sharedAttachments.length) blockers.push("Assign legacy workspace-shared attachments to explicit conformance owners"); if (candidate.sharedAttachments.length)
for (const entry of candidate.conformances) if (!entry.id) blockers.push(`Conformance ${entry.atomId} as ${entry.interfaceRevisionId} requires an authored ID`); 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 previousRuntimes = new Map((baseline ? runtimeContracts(baseline) : []).map((entry) => [entry.groupId, entry]));
const nextRuntimes = new Map(runtimeContracts(candidate).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 runtimeActions: RuntimeAction[] = [...new Set([...previousRuntimes.keys(), ...nextRuntimes.keys()])]
const previous = previousRuntimes.get(groupId), next = nextRuntimes.get(groupId); .sort()
const before = new Map(previous?.dependencies.map((entry) => [entry.id, entry.digest])); .map((groupId) => {
const after = new Map(next?.dependencies.map((entry) => [entry.id, entry.digest])); const previous = previousRuntimes.get(groupId),
const reasons = [...new Set([...before.keys(), ...after.keys()])].sort().filter((id) => before.get(id) !== after.get(id)); next = nextRuntimes.get(groupId);
return { groupId, action: !previous ? "start" : !next ? "retire" : previous.digest === next.digest ? "keep" : "replace", const before = new Map(previous?.dependencies.map((entry) => [entry.id, entry.digest]));
...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}), reasons }; 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 beforeStorage = new Map((baseline ? storageContracts(baseline) : []).map((entry) => [entry.id, entry]));
const afterStorage = new Map(storageContracts(candidate).map((entry) => [entry.id, entry])); const afterStorage = new Map(storageContracts(candidate).map((entry) => [entry.id, entry]));
const storageChanges: EvolutionReport["storageChanges"] = []; const storageChanges: EvolutionReport["storageChanges"] = [];
for (const id of [...new Set([...beforeStorage.keys(), ...afterStorage.keys()])].sort()) { for (const id of [...new Set([...beforeStorage.keys(), ...afterStorage.keys()])].sort()) {
const previous = beforeStorage.get(id), next = afterStorage.get(id); const previous = beforeStorage.get(id),
if (previous?.digest !== next?.digest) storageChanges.push({ id, kind: !previous ? "add" : !next ? "remove" : "change", next = afterStorage.get(id);
requiresMigration: storageChangeRequiresMigration(previous, next, new Set(baseline?.atoms.map(atom => atom.id) ?? [])), if (previous?.digest !== next?.digest)
...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}) }); 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) => [ const providers = (workspace: WorkspaceRevision) => [
...workspace.packageImports.map((entry) => ({ id: entry.packageId as string, revision: entry.revisionId as string, major: entry.semanticMajor ?? 1, ...workspace.packageImports.map((entry) => ({
node: `package:${entry.revisionId}`, digest: contentDigest(entry) })), id: entry.packageId as string,
...workspace.conformances.map((entry) => ({ id: conformanceIdentity(entry), revision: contentDigest(entry), major: entry.semanticMajor ?? 1, revision: entry.revisionId as string,
node: `conformance:${entry.atomId}:${entry.interfaceRevisionId}`, digest: contentDigest(entry) })), 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 oldProviders = baseline ? providers(baseline) : [];
const reviews: EvolutionReport["reviews"] = []; const reviews: EvolutionReport["reviews"] = [];
for (const provider of providers(candidate)) { for (const provider of providers(candidate)) {
const old = oldProviders.filter((entry) => entry.id === provider.id); 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 (!old[0] || old[0].major === provider.major) continue;
if (provider.major < old[0].major) blockers.push(`Semantic major decreases for ${provider.id}`); if (provider.major < old[0].major) blockers.push(`Semantic major decreases for ${provider.id}`);
for (const consumer of nextRuntimes.values()) { for (const consumer of nextRuntimes.values()) {
if (consumer.packageId === provider.id || !consumer.reviewProviders.includes(provider.id)) continue; 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 requirement = {
const requirementDigest = contentDigest({ ...requirement, oldProvider: old[0].digest, newProvider: provider.digest, consumer: consumer.digest, checkerVersion }); consumerId: consumer.groupId,
const accepted = (options.reviews ?? []).some((entry) => entry.requirementDigest === requirementDigest && providerId: provider.id,
["changed", "accepted-unchanged"].includes(entry.decision) && entry.rationale.trim() && entry.agentId.trim()); 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 }); reviews.push({ ...requirement, requirementDigest, accepted });
if (!accepted) blockers.push(`Semantic-major review required: ${consumer.groupId} consumes ${provider.id}`); if (!accepted) blockers.push(`Semantic-major review required: ${consumer.groupId} consumes ${provider.id}`);
} }
} }
return { schemaVersion: 1, baselineDigest: baseline ? contentDigest(baseline) : null, candidateDigest, checkerVersion, return {
runtimeActions, storageChanges, migrationRequired: storageChanges.filter(entry => entry.requiresMigration).map(entry => entry.id), reviews, packageChecks: runtimeActions.filter((entry) => entry.candidate && entry.action !== "keep") schemaVersion: 1,
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })), blockers }; 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,
};
}; };
+141
View File
@@ -0,0 +1,141 @@
import { createHash } from "node:crypto";
import {
capabilityId,
type PackageExport,
type PackageExportId,
type DependencyPort,
type PackageRevision,
} from "./types.js";
import {
TypeSubstitution,
GenericTypeError,
bindTypeParameters,
canonicalTypeArgument,
type TypeParameter,
type ValueTypeExpression,
type ObjectTypeExpression,
type InterfaceApplicationExpression,
type GenericTypeEnvironment,
type ClosedTypeArgument,
type ValueAliasDefinition,
} from "./generics.js";
export type GenericDependencyPort = Omit<DependencyPort, "requirement"> & {
requirement:
| {
kind: "state";
valueType: ValueTypeExpression;
primitives: Extract<DependencyPort["requirement"], { kind: "state" }>["primitives"];
}
| {
kind: "edge";
target: ObjectTypeExpression;
cardinality: Extract<DependencyPort["requirement"], { kind: "edge" }>["cardinality"];
primitives: Extract<DependencyPort["requirement"], { kind: "edge" }>["primitives"];
}
| { kind: "interface"; application: InterfaceApplicationExpression }
| { kind: "constructor"; target: ObjectTypeExpression; inputType: ValueTypeExpression };
};
export interface GenericPackageExport {
id: PackageExportId;
displayName: string;
parameters: TypeParameter[];
kind: "operation" | "function";
inputType: ValueTypeExpression;
outputType: ValueTypeExpression;
eventType?: ValueTypeExpression;
mode?: Extract<PackageExport, { kind: "operation" }>["mode"];
receiverRequirement:
| { kind: "any-object" }
| { kind: "target"; target: ObjectTypeExpression }
| { kind: "interfaces"; interfaces: InterfaceApplicationExpression[] };
dependencyPorts: GenericDependencyPort[];
aliases: ValueAliasDefinition[];
}
/** A closed manifest is a Nix build input, never a mutable source checkout. */
export const specializePackageExport = (
pkg: PackageRevision,
definition: GenericPackageExport,
arguments_: ClosedTypeArgument[],
environment: GenericTypeEnvironment,
): PackageExport => {
const lexical = { ...environment, aliases: new Map(definition.aliases.map((alias) => [alias.id, alias])) };
const bindings = bindTypeParameters(definition.parameters, arguments_, lexical, definition.displayName);
const substitution = new TypeSubstitution({ ...lexical, arguments: bindings });
const digest = createHash("sha256")
.update(
JSON.stringify([
"quixos-package-specialization-v1",
pkg.revisionId,
[pkg.source.repository, pkg.source.commit],
definition.id,
arguments_.map(canonicalTypeArgument),
environment.self ?? null,
]),
)
.digest("hex");
const ports: DependencyPort[] = definition.dependencyPorts.map((port) => {
const requirement = port.requirement;
switch (requirement.kind) {
case "state":
return { ...port, requirement: { ...requirement, valueType: substitution.value(requirement.valueType) } };
case "edge":
return { ...port, requirement: { ...requirement, target: substitution.object(requirement.target) } };
case "interface":
return {
...port,
requirement: { kind: "interface", interfaceRevisionId: substitution.application(requirement.application) },
};
case "constructor": {
const target = substitution.object(requirement.target);
if (target.kind !== "atom")
throw new GenericTypeError(
"constructor-target",
port.displayName,
"Constructor ports require a concrete atom argument, not an interface view",
);
return {
...port,
requirement: {
kind: "constructor",
atomId: target.atomId,
inputType: substitution.value(requirement.inputType),
},
};
}
}
});
const common = {
id: capabilityId.packageExport(`export-application:sha256:${digest}`),
displayName: `${definition.displayName}$${digest}`,
inputType: substitution.value(definition.inputType),
outputType: substitution.value(definition.outputType),
dependencyPorts: ports,
application: {
exportId: definition.id,
arguments: structuredClone(arguments_),
...(environment.self ? { self: environment.self } : {}),
},
};
if (definition.kind === "function") return { ...common, kind: "function" };
const receiver = definition.receiverRequirement;
const target = receiver.kind === "target" ? substitution.object(receiver.target) : undefined;
return {
...common,
kind: "operation",
mode: definition.mode!,
...(definition.eventType ? { eventType: substitution.value(definition.eventType) } : {}),
receiverRequirement: target
? target.kind === "atom"
? { kind: "exact-atom", atomId: target.atomId }
: { kind: "all-interfaces", interfaceRevisionIds: [target.interfaceRevisionId] }
: receiver.kind === "interfaces"
? {
kind: "all-interfaces",
interfaceRevisionIds: receiver.interfaces.map((value) => substitution.application(value)),
}
: { kind: "any-object" },
};
};
+420
View File
@@ -0,0 +1,420 @@
import { createHash } from "node:crypto";
import {
capabilityId,
type AtomId,
type InterfaceMember,
type InterfaceOperation,
type InterfaceRevision,
type InterfaceRevisionId,
type ObjectExpectation,
type SourceRevision,
type ValueType,
} from "./types.js";
/** Authoring-only expressions. Installed ValueType deliberately has no variable case. */
export type ValueTypeExpression =
| Extract<ValueType, { kind: "builtin" | "scalar" | "message" }>
| { kind: "parameter"; parameterId: string }
| { kind: "record"; fields: Record<string, ValueTypeExpression> }
| { kind: "list" | "optional"; value: ValueTypeExpression }
| { kind: "object-ref"; expectation: ObjectTypeExpression }
| { kind: "alias"; definitionId: string; arguments: TypeArgumentExpression[] };
export type ObjectTypeExpression =
| { kind: "atom"; atomId: AtomId }
| { kind: "interface"; interfaceRevisionId: InterfaceRevisionId }
| { kind: "parameter"; parameterId: string }
| { kind: "self" }
| { kind: "application"; application: InterfaceApplicationExpression };
export type TypeArgumentExpression =
| { kind: "value"; type: ValueTypeExpression }
| { kind: "object"; target: ObjectTypeExpression };
export type ClosedTypeArgument = { kind: "value"; type: ValueType } | { kind: "object"; target: ObjectExpectation };
export interface InterfaceApplicationExpression {
definitionId: InterfaceRevisionId;
arguments: TypeArgumentExpression[];
}
export type TypeParameter =
| { id: string; name: string; kind: "value"; storable?: boolean }
| { id: string; name: string; kind: "object"; implements: InterfaceApplicationExpression[] };
export interface ValueAliasDefinition {
id: string;
parameters: TypeParameter[];
body: ValueTypeExpression;
}
export interface GenericInterfaceTemplate {
parameters: TypeParameter[];
members: InterfaceMember<ValueTypeExpression, ObjectTypeExpression>[];
requires: InterfaceApplicationExpression[];
usesSelf: boolean;
aliases?: ValueAliasDefinition[];
}
/** Instantiation produces the existing closed runtime IR, with explicit provenance. */
export const instantiateInterface = (
definition: InterfaceRevision,
arguments_: readonly ClosedTypeArgument[],
environment: GenericTypeEnvironment,
): InterfaceRevision => {
const template = definition.template;
if (!template) {
if (arguments_.length) fail("type-arity", definition.displayName, "Non-generic interface takes no type arguments");
return definition;
}
const lexicalEnvironment = {
...environment,
aliases: new Map((template.aliases ?? []).map((alias) => [alias.id, alias])),
};
const argumentsMap = bindTypeParameters(template.parameters, arguments_, lexicalEnvironment, definition.displayName);
if (template.usesSelf && !environment.self)
fail("unbound-self", definition.displayName, "Self requires an implementing atom");
const substitution = new TypeSubstitution({ ...lexicalEnvironment, arguments: argumentsMap });
const operation = (entry: InterfaceOperation<ValueTypeExpression>): InterfaceOperation => ({
...entry,
inputType: substitution.value(entry.inputType, `${definition.displayName}.${entry.displayName}.input`),
outputType: substitution.value(entry.outputType, `${definition.displayName}.${entry.displayName}.output`),
eventType: entry.eventType
? substitution.value(entry.eventType, `${definition.displayName}.${entry.displayName}.event`)
: undefined,
});
const members: InterfaceMember[] = template.members.map((member) => {
const common = { id: member.id, displayName: member.displayName, operations: member.operations.map(operation) };
switch (member.kind) {
case "value":
return { ...common, kind: "value", valueType: substitution.value(member.valueType, member.displayName) };
case "relationship":
return {
...common,
kind: "relationship",
target: substitution.object(member.target, member.displayName),
cardinality: member.cardinality,
ordered: member.ordered,
};
case "operation":
return {
...common,
kind: "operation",
inputType: substitution.value(member.inputType, member.displayName),
outputType: substitution.value(member.outputType, member.displayName),
};
}
});
const application: AppliedInterfaceIdentity = {
definitionId: definition.revisionId,
source: definition.source,
arguments: structuredClone([...arguments_]),
...(template.usesSelf ? { self: environment.self } : {}),
};
return {
interfaceId: definition.interfaceId,
revisionId: appliedInterfaceId(application),
displayName: definition.displayName,
source: definition.source,
members,
application,
requiredInterfaces: template.requires.map((required) => substitution.application(required)),
};
};
export interface GenericTypeEnvironment {
arguments: ReadonlyMap<string, ClosedTypeArgument>;
self?: AtomId;
aliases?: ReadonlyMap<string, ValueAliasDefinition>;
/** Resolves only checked declarations, never a caller-supplied runtime type string. */
applyInterface: (definitionId: InterfaceRevisionId, arguments_: readonly ClosedTypeArgument[]) => InterfaceRevisionId;
/** Proof in the candidate, not an authorization grant. */
implementsInterface: (target: ObjectExpectation, required: InterfaceRevisionId) => boolean;
/** External codecs must explicitly declare reference-free persistence support. */
storableMessage?: (descriptorId: string) => boolean;
}
export class GenericTypeError extends Error {
constructor(
readonly code: string,
readonly path: string,
message: string,
) {
super(`${path}: ${message}`);
this.name = "GenericTypeError";
}
}
const fail = (code: string, path: string, message: string): never => {
throw new GenericTypeError(code, path, message);
};
/** One budget across nested aliases/substitutions, including concrete arguments. */
class Budget {
private remaining = 10000;
enter(path: string, depth: number) {
if (depth > 128 || --this.remaining < 0)
fail("type-complexity-limit", path, "Type exceeds the depth or expansion budget");
}
}
export const isStorableType = (
type: ValueType,
storableMessage: (descriptorId: string) => boolean = () => false,
): boolean => {
const budget = new Budget();
const visit = (value: ValueType, depth: number): boolean => {
budget.enter("storable", depth);
switch (value.kind) {
case "scalar":
return true;
case "builtin":
return value.name === "unit";
case "message":
return storableMessage(value.descriptorId);
case "object-ref":
return false;
case "list":
case "optional":
return visit(value.value, depth + 1);
// Records currently have RPC codecs, not ordinary-state persistence codecs.
// A generic bound must not promise storage that the installed model rejects.
case "record":
return false;
}
};
return visit(type, 0);
};
/** Canonical closed-type encoding, independent of record insertion order. */
export const canonicalTypeArgument = (argument: ClosedTypeArgument): string => {
const budget = new Budget();
const target = (value: ObjectExpectation): unknown => {
switch (value.kind) {
case "atom":
return ["atom", value.atomId];
case "interface":
return ["interface", value.interfaceRevisionId];
default:
return fail("unresolved-type", "argument", "Expected a closed object target");
}
};
const type = (value: ValueType, depth: number): unknown => {
budget.enter("argument", depth);
switch (value.kind) {
case "builtin":
case "scalar":
return [value.kind, value.name];
case "message":
return ["message", value.descriptorId];
case "object-ref":
return ["object-ref", target(value.expectation)];
case "list":
case "optional":
return [value.kind, type(value.value, depth + 1)];
case "record":
return [
"record",
Object.keys(value.fields)
.sort()
.map((key) => [key, type(value.fields[key], depth + 1)]),
];
default:
return fail("unresolved-type", "argument", "Expected a closed value type");
}
};
switch (argument.kind) {
case "value":
return JSON.stringify(["value", type(argument.type, 0)]);
case "object":
return JSON.stringify(["object", target(argument.target)]);
default:
return fail("invalid-kind", "argument", "Expected a value or object type argument");
}
};
export interface AppliedInterfaceIdentity {
definitionId: InterfaceRevisionId;
source: SourceRevision;
arguments: ClosedTypeArgument[];
/** Only include Self when it is actually part of the closed contract. */
self?: AtomId;
}
export const appliedInterfaceId = (application: AppliedInterfaceIdentity): InterfaceRevisionId => {
const encoding = JSON.stringify([
"quixos-applied-interface-v1",
application.definitionId,
application.source.repository,
application.source.commit.toLowerCase(),
application.arguments.map(canonicalTypeArgument),
application.self ?? null,
]);
return capabilityId.interfaceRevision(
`interface-application:sha256:${createHash("sha256").update(encoding).digest("hex")}`,
);
};
export class TypeSubstitution {
private readonly budget = new Budget();
private readonly aliases: string[] = [];
constructor(private environment: GenericTypeEnvironment) {}
argument(expression: TypeArgumentExpression, path = "argument", depth = 0): ClosedTypeArgument {
this.budget.enter(path, depth);
switch (expression.kind) {
case "value":
return { kind: "value", type: this.value(expression.type, path, depth + 1) };
case "object":
return { kind: "object", target: this.object(expression.target, path, depth + 1) };
default:
return fail("invalid-kind", path, "Expected a value or object type argument");
}
}
application(expression: InterfaceApplicationExpression, path = "interface", depth = 0): InterfaceRevisionId {
this.budget.enter(path, depth);
return this.environment.applyInterface(
expression.definitionId,
expression.arguments.map((argument, index) => this.argument(argument, `${path}.arguments[${index}]`, depth + 1)),
);
}
object(expression: ObjectTypeExpression, path = "target", depth = 0): ObjectExpectation {
this.budget.enter(path, depth);
switch (expression.kind) {
case "atom":
return { kind: "atom", atomId: expression.atomId };
case "interface":
return { kind: "interface", interfaceRevisionId: expression.interfaceRevisionId };
case "self":
return this.environment.self
? { kind: "atom", atomId: this.environment.self }
: fail("unbound-self", path, "Self requires an implementing atom");
case "parameter": {
const argument = this.environment.arguments.get(expression.parameterId);
if (!argument) return fail("unbound-parameter", path, `Unbound parameter ${expression.parameterId}`);
if (argument.kind !== "object")
return fail("parameter-kind", path, `Parameter ${expression.parameterId} is a value, not an object target`);
canonicalTypeArgument(argument);
return structuredClone(argument.target);
}
case "application":
return { kind: "interface", interfaceRevisionId: this.application(expression.application, path, depth + 1) };
default:
return fail("invalid-type", path, "Unknown object type expression");
}
}
value(expression: ValueTypeExpression, path = "type", depth = 0): ValueType {
this.budget.enter(path, depth);
switch (expression.kind) {
case "builtin":
return { kind: "builtin", name: expression.name };
case "scalar":
return { kind: "scalar", name: expression.name };
case "message":
return { kind: "message", descriptorId: expression.descriptorId };
case "list":
case "optional":
return { kind: expression.kind, value: this.value(expression.value, `${path}.${expression.kind}`, depth + 1) };
case "record":
return {
kind: "record",
fields: Object.fromEntries(
Object.entries(expression.fields).map(([name, field]) => [
name,
this.value(field, `${path}.${name}`, depth + 1),
]),
),
};
case "object-ref":
return { kind: "object-ref", expectation: this.object(expression.expectation, `${path}.ref`, depth + 1) };
case "parameter": {
const argument = this.environment.arguments.get(expression.parameterId);
if (!argument) return fail("unbound-parameter", path, `Unbound parameter ${expression.parameterId}`);
if (argument.kind !== "value")
return fail("parameter-kind", path, `Parameter ${expression.parameterId} is an object target; use ref<T>`);
canonicalTypeArgument(argument);
return this.value(argument.type, path, depth + 1);
}
case "alias": {
const alias = this.environment.aliases?.get(expression.definitionId);
if (!alias) return fail("unknown-alias", path, `Unknown type alias ${expression.definitionId}`);
if (this.aliases.includes(alias.id))
return fail("recursive-alias", path, `Recursive value alias: ${[...this.aliases, alias.id].join(" -> ")}`);
const arguments_ = expression.arguments.map((argument, index) =>
this.argument(argument, `${path}.arguments[${index}]`, depth + 1),
);
const bindings = bindTypeParameters(alias.parameters, arguments_, this.environment, path);
this.aliases.push(alias.id);
try {
// Reuse this expansion budget/stack; lexical parameter maps are restored.
const previous = this.environment;
this.environment = { ...previous, arguments: bindings };
try {
return this.value(alias.body, `${path}.${alias.id}`, depth + 1);
} finally {
this.environment = previous;
}
} finally {
this.aliases.pop();
}
}
default:
return fail("invalid-type", path, "Unknown value type expression");
}
}
}
export const bindTypeParameters = (
parameters: readonly TypeParameter[],
arguments_: readonly ClosedTypeArgument[],
environment: GenericTypeEnvironment,
path = "parameters",
): ReadonlyMap<string, ClosedTypeArgument> => {
if (parameters.length !== arguments_.length)
fail("type-arity", path, `Expected ${parameters.length} type arguments, received ${arguments_.length}`);
const bindings = new Map<string, ClosedTypeArgument>();
const names = new Set<string>();
for (const [index, parameter] of parameters.entries()) {
if (
!parameter.id ||
!parameter.name ||
parameter.name === "Self" ||
bindings.has(parameter.id) ||
names.has(parameter.name)
)
fail("duplicate-parameter", path, `Invalid or duplicate parameter ${parameter.name}`);
names.add(parameter.name);
const argument = arguments_[index];
if (parameter.kind !== argument.kind)
fail("parameter-kind", `${path}.${parameter.name}`, `Expected ${parameter.kind}, received ${argument.kind}`);
canonicalTypeArgument(argument);
bindings.set(parameter.id, structuredClone(argument));
}
const substitution = new TypeSubstitution({ ...environment, arguments: bindings });
for (const parameter of parameters) {
const argument = bindings.get(parameter.id)!;
if (
parameter.kind === "value" &&
argument.kind === "value" &&
parameter.storable &&
!isStorableType(argument.type, environment.storableMessage)
)
fail(
"non-storable-argument",
`${path}.${parameter.name}`,
"State values cannot contain managed references or unsupported transport values",
);
if (parameter.kind === "object" && argument.kind === "object") {
for (const bound of parameter.implements) {
const required = substitution.application(bound, `${path}.${parameter.name}.implements`);
if (!environment.implementsInterface(argument.target, required))
fail("unsatisfied-bound", `${path}.${parameter.name}`, `Object target does not implement ${required}`);
}
}
}
return bindings;
};
+2
View File
@@ -2,3 +2,5 @@ export * from "./types.js";
export * from "./validation.js"; export * from "./validation.js";
export * from "./evolution.js"; export * from "./evolution.js";
export * from "./migrations.js"; export * from "./migrations.js";
export * from "./generics.js";
export * from "./generic-packages.js";
+88 -28
View File
@@ -4,12 +4,23 @@ import type { PersistentAttachment } from "./types.js";
/** Portable storage shape: local owner/slot/projection identities are supplied /** Portable storage shape: local owner/slot/projection identities are supplied
* by the consuming workspace's explicit bindings, never baked into this hash. */ * by the consuming workspace's explicit bindings, never baked into this hash. */
export const migrationPortContract = (attachment: PersistentAttachment): unknown => { export const migrationPortContract = (attachment: PersistentAttachment): unknown => {
if (attachment.kind === "state") return {kind: "state", valueType: attachment.valueType, storagePolicy: attachment.storagePolicy, if (attachment.kind === "state")
...(attachment.defaultValue === undefined ? {} : {defaultValue: attachment.defaultValue})}; return {
const endpoint = (value: typeof attachment.endpoints[number]) => ({constraint: value.constraint, cardinality: value.cardinality, kind: "state",
ordered: value.ordered, onDelete: value.onDelete ?? "restrict", retainOther: value.retainOther ?? false, valueType: attachment.valueType,
...(value.keyType ? {keyType: value.keyType} : {}), ...(value.publicTraversal ? {publicTraversal: true} : {})}); storagePolicy: attachment.storagePolicy,
return {kind: "edge", first: endpoint(attachment.endpoints[0]), second: endpoint(attachment.endpoints[1])}; ...(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 = { export type MigrationDeclaration = {
@@ -19,7 +30,12 @@ export type MigrationDeclaration = {
to: string; to: string;
implementation: { exportId: string; file: string; digest: string }; implementation: { exportId: string; file: string; digest: string };
predecessors: 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; preservesOldReaders?: boolean;
preservesOldWriters?: boolean; preservesOldWriters?: boolean;
}; };
@@ -29,56 +45,99 @@ export type MigrationCatalog = {
migrations: MigrationDeclaration[]; migrations: MigrationDeclaration[];
}; };
export const validateMigrationCatalog = (value: unknown, exportIds?: ReadonlySet<string>): MigrationCatalog => { export const validateMigrationCatalog = (value: unknown, exportIds?: ReadonlySet<string>): 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; 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"); if (
for (const [digest, contract] of Object.entries(catalog.contracts)) if (contentDigest(contract) !== digest) throw new Error(`Migration contract digest mismatch: ${digest}`); 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<string>(); const ids = new Set<string>();
for (const migration of catalog.migrations) { 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); 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 (!catalog.contracts[migration.from] || !catalog.contracts[migration.to] || migration.from === migration.to)
if (!migration.implementation?.exportId || !/^sha256:[0-9a-f]{64}$/.test(migration.implementation.digest)) throw new Error(`Migration ${migration.id} requires an exact implementation digest`); throw new Error(`Migration ${migration.id} requires distinct retained source and target contracts`);
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 (!migration.implementation?.exportId || !/^sha256:[0-9a-f]{64}$/.test(migration.implementation.digest))
if (exportIds && !exportIds.has(migration.implementation.exportId)) throw new Error(`Migration ${migration.id} refers to an undeclared package export`); throw new Error(`Migration ${migration.id} requires an exact implementation digest`);
if (!Array.isArray(migration.predecessors) || !Array.isArray(migration.ports)) throw new Error(`Migration ${migration.id} requires predecessors and ports`); if (
if (new Set(migration.predecessors).size !== migration.predecessors.length) throw new Error(`Duplicate predecessor in ${migration.id}`); !migration.implementation.file ||
for (const promise of [migration.preservesOldReaders, migration.preservesOldWriters]) if (promise !== undefined && typeof promise !== "boolean") throw new Error("Migration compatibility promises must be booleans"); 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<string>(); const ports = new Set<string>();
for (const port of migration.ports) { 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 if (
|| port.access.some((access) => !["read", "write", "create", "edge"].includes(access)) || !catalog.contracts[port.contractDigest]) throw new Error(`Invalid migration port in ${migration.id}`); !port.name ||
if (port.view === "old" && port.access.some((access) => access !== "read")) throw new Error("Old migration views are read-only"); 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); 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 // Catalogs are retained across releases. Reject impossible histories at
// publication/check time, not only when someone tries to select a path. // 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 remaining = new Map(catalog.migrations.map((entry) => [entry.id, new Set(entry.predecessors)]));
const ready = [...remaining].filter(([, dependencies]) => dependencies.size === 0).map(([id]) => id); const ready = [...remaining].filter(([, dependencies]) => dependencies.size === 0).map(([id]) => id);
for (let index = 0; index < ready.length; index++) { for (let index = 0; index < ready.length; index++) {
remaining.delete(ready[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(", ")}`); if (remaining.size) throw new Error(`Cyclic migration predecessors: ${[...remaining.keys()].join(", ")}`);
return catalog; return catalog;
}; };
export type MigrationSelection = { export type MigrationSelection = {
scopeId: string; from: string; to: string; path: string[]; scopeId: string;
from: string;
to: string;
path: string[];
bindings: Record<string, string>; bindings: Record<string, string>;
}; };
/** Explicit paths, not shortest-path guesses. Receipts identify code plus local scope mapping. */ /** Explicit paths, not shortest-path guesses. Receipts identify code plus local scope mapping. */
export const selectMigrationPath = (catalog: MigrationCatalog, selection: MigrationSelection, previousReceipts: ReadonlyMap<string, string> = new Map()) => { export const selectMigrationPath = (
catalog: MigrationCatalog,
selection: MigrationSelection,
previousReceipts: ReadonlyMap<string, string> = new Map(),
) => {
validateMigrationCatalog(catalog); validateMigrationCatalog(catalog);
let current = selection.from; let current = selection.from;
const seen = new Set<string>(); const seen = new Set<string>();
const transitions = []; const transitions = [];
for (const id of selection.path) { for (const id of selection.path) {
const declaration = catalog.migrations.find((entry) => entry.id === id); 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}`); if (!declaration || declaration.scopeId !== selection.scopeId || declaration.from !== current || seen.has(id))
for (const predecessor of declaration.predecessors) if (!seen.has(predecessor) && !previousReceipts.has(predecessor)) throw new Error(`Unsatisfied predecessor ${predecessor}`); 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<string, string> = {}; const usedBindings: Record<string, string> = {};
for (const port of declaration.ports) { for (const port of declaration.ports) {
if (!selection.bindings[port.name]) throw new Error(`Missing local migration binding ${port.name}`); 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 digest = contentDigest({ declaration, bindings: usedBindings });
const previous = previousReceipts.get(id); 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) }); transitions.push({ declaration, bindings: usedBindings, digest, alreadyApplied: Boolean(previous) });
seen.add(id); seen.add(id);
current = declaration.to; current = declaration.to;
+41 -59
View File
@@ -29,13 +29,11 @@ const opaque = <Kind extends string>(value: string) => value as OpaqueId<Kind>;
*/ */
export const capabilityId = { export const capabilityId = {
workspace: (value: string) => opaque<"WorkspaceId">(value), workspace: (value: string) => opaque<"WorkspaceId">(value),
workspaceRevision: (value: string) => workspaceRevision: (value: string) => opaque<"WorkspaceRevisionId">(value),
opaque<"WorkspaceRevisionId">(value),
atom: (value: string) => opaque<"AtomId">(value), atom: (value: string) => opaque<"AtomId">(value),
conformance: (value: string) => opaque<"ConformanceId">(value), conformance: (value: string) => opaque<"ConformanceId">(value),
interface: (value: string) => opaque<"InterfaceId">(value), interface: (value: string) => opaque<"InterfaceId">(value),
interfaceRevision: (value: string) => interfaceRevision: (value: string) => opaque<"InterfaceRevisionId">(value),
opaque<"InterfaceRevisionId">(value),
member: (value: string) => opaque<"MemberId">(value), member: (value: string) => opaque<"MemberId">(value),
operation: (value: string) => opaque<"OperationId">(value), operation: (value: string) => opaque<"OperationId">(value),
slot: (value: string) => opaque<"SlotId">(value), slot: (value: string) => opaque<"SlotId">(value),
@@ -53,15 +51,7 @@ export interface SourceRevision {
commit: string; commit: string;
} }
export type ScalarValueTypeName = export type ScalarValueTypeName = "bool" | "bytes" | "double" | "int32" | "int64" | "string" | "uint32" | "uint64";
| "bool"
| "bytes"
| "double"
| "int32"
| "int64"
| "string"
| "uint32"
| "uint64";
export type ObjectExpectation = export type ObjectExpectation =
| { kind: "atom"; atomId: AtomId } | { kind: "atom"; atomId: AtomId }
@@ -112,39 +102,32 @@ export interface AtomDefinition {
documentation?: string; documentation?: string;
} }
export type InterfaceOperationMode = export type InterfaceOperationMode = "call" | "watch-start" | "watch-stop" | "subscribe" | "unsubscribe";
| "call"
| "watch-start"
| "watch-stop"
| "subscribe"
| "unsubscribe";
export interface InterfaceOperation { export interface InterfaceOperation<Type = ValueType> {
id: OperationId; id: OperationId;
displayName: string; displayName: string;
inputType: ValueType; inputType: Type;
outputType: ValueType; outputType: Type;
mode: InterfaceOperationMode; mode: InterfaceOperationMode;
/** Class capabilities have no object receiver and bind free functions. */
scope?: "class";
/** Required for watch-start/subscribe and absent for other modes. */ /** Required for watch-start/subscribe and absent for other modes. */
eventType?: ValueType; eventType?: Type;
} }
interface InterfaceMemberBase { interface InterfaceMemberBase<Type = ValueType> {
id: MemberId; id: MemberId;
displayName: string; displayName: string;
operations: InterfaceOperation[]; operations: InterfaceOperation<Type>[];
} }
export interface ValueInterfaceMember extends InterfaceMemberBase { export interface ValueInterfaceMember<Type = ValueType> extends InterfaceMemberBase<Type> {
kind: "value"; kind: "value";
valueType: ValueType; valueType: Type;
} }
export type EdgeCardinality = export type EdgeCardinality = "optional-one" | "exactly-one" | "many" | "many-unique";
| "optional-one"
| "exactly-one"
| "many"
| "many-unique";
export type EdgeEndpointConstraint = export type EdgeEndpointConstraint =
| { kind: "atom"; atomId: AtomId } | { kind: "atom"; atomId: AtomId }
@@ -153,24 +136,27 @@ export type EdgeEndpointConstraint =
interfaceRevisionId: InterfaceRevisionId; interfaceRevisionId: InterfaceRevisionId;
}; };
export interface RelationshipInterfaceMember extends InterfaceMemberBase { export interface RelationshipInterfaceMember<
Type = ValueType,
Target = EdgeEndpointConstraint,
> extends InterfaceMemberBase<Type> {
kind: "relationship"; kind: "relationship";
target: EdgeEndpointConstraint; target: Target;
cardinality: EdgeCardinality; cardinality: EdgeCardinality;
ordered: boolean; ordered: boolean;
} }
/** A named callable capability that is not value or relationship sugar. */ /** A named callable capability that is not value or relationship sugar. */
export interface OperationInterfaceMember extends InterfaceMemberBase { export interface OperationInterfaceMember<Type = ValueType> extends InterfaceMemberBase<Type> {
kind: "operation"; kind: "operation";
inputType: ValueType; inputType: Type;
outputType: ValueType; outputType: Type;
} }
export type InterfaceMember = export type InterfaceMember<Type = ValueType, Target = EdgeEndpointConstraint> =
| ValueInterfaceMember | ValueInterfaceMember<Type>
| RelationshipInterfaceMember | RelationshipInterfaceMember<Type, Target>
| OperationInterfaceMember; | OperationInterfaceMember<Type>;
export interface InterfaceRevision { export interface InterfaceRevision {
interfaceId: InterfaceId; interfaceId: InterfaceId;
@@ -178,11 +164,15 @@ export interface InterfaceRevision {
displayName: string; displayName: string;
source: SourceRevision; source: SourceRevision;
members: InterfaceMember[]; members: InterfaceMember[];
/** Authored generic definition; never an executable contract by itself. */
template?: import("./generics.js").GenericInterfaceTemplate;
/** Immutable provenance of a closed generic application. */
application?: import("./generics.js").AppliedInterfaceIdentity;
requiredInterfaces?: InterfaceRevisionId[];
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
} }
export type StoragePolicy = export type StoragePolicy = { kind: "optimistic-register" } | { kind: "crdt-document"; updateType: ValueType };
| { kind: "optimistic-register" }
| { kind: "crdt-document"; updateType: ValueType };
export interface StateSlotDefinition { export interface StateSlotDefinition {
kind: "state"; kind: "state";
@@ -215,18 +205,9 @@ export interface EdgeDefinition {
export type PersistentAttachment = StateSlotDefinition | EdgeDefinition; export type PersistentAttachment = StateSlotDefinition | EdgeDefinition;
export type StatePrimitive = export type StatePrimitive = "read" | "write" | "watch-start" | "watch-stop";
| "read"
| "write"
| "watch-start"
| "watch-stop";
export type EdgePrimitive = export type EdgePrimitive = "resolve" | "connect" | "disconnect" | "watch-start" | "watch-stop";
| "resolve"
| "connect"
| "disconnect"
| "watch-start"
| "watch-stop";
export type PackageReceiverRequirement = export type PackageReceiverRequirement =
| { kind: "any-object" } | { kind: "any-object" }
@@ -266,6 +247,8 @@ interface PackageExportBase {
inputType: ValueType; inputType: ValueType;
outputType: ValueType; outputType: ValueType;
dependencyPorts: DependencyPort[]; dependencyPorts: DependencyPort[];
/** Closed adapter served by the same immutable package build. */
application?: { exportId: PackageExportId; arguments: import("./generics.js").ClosedTypeArgument[]; self?: AtomId };
} }
export interface PackageOperationExport extends PackageExportBase { export interface PackageOperationExport extends PackageExportBase {
@@ -284,12 +267,11 @@ export interface PackageConstructorExport extends PackageExportBase {
constructsAtom: AtomId; constructsAtom: AtomId;
} }
export type PackageExport = export type PackageExport = PackageOperationExport | PackageFunctionExport | PackageConstructorExport;
| PackageOperationExport
| PackageFunctionExport
| PackageConstructorExport;
export interface PackageRevision { export interface PackageRevision {
genericExports?: import("./generic-packages.js").GenericPackageExport[];
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
migrationCatalog?: import("./migrations.js").MigrationCatalog; migrationCatalog?: import("./migrations.js").MigrationCatalog;
packageId: PackageId; packageId: PackageId;
revisionId: PackageRevisionId; revisionId: PackageRevisionId;
File diff suppressed because it is too large Load Diff
+2 -8
View File
@@ -1,10 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import fs from "node:fs"; import fs from "node:fs";
import { import { packageDescriptorToJson, parsePackageDescriptorTextproto, validatePackageDescriptor } from "./descriptor.js";
packageDescriptorToJson,
parsePackageDescriptorTextproto,
validatePackageDescriptor,
} from "./descriptor.js";
const usage = (): never => { const usage = (): never => {
console.error("Usage: quixos-descriptor-check <descriptor.txtpb>"); console.error("Usage: quixos-descriptor-check <descriptor.txtpb>");
@@ -14,9 +10,7 @@ const usage = (): never => {
const path = process.argv[2] ?? usage(); const path = process.argv[2] ?? usage();
try { try {
const descriptor = parsePackageDescriptorTextproto( const descriptor = parsePackageDescriptorTextproto(fs.readFileSync(path, "utf8"));
fs.readFileSync(path, "utf8"),
);
const errors = validatePackageDescriptor(descriptor); const errors = validatePackageDescriptor(descriptor);
if (errors.length > 0) { if (errors.length > 0) {
for (const error of errors) { for (const error of errors) {
+5 -15
View File
@@ -14,19 +14,13 @@ const protoPaths = () => {
path.resolve(process.cwd(), "proto"), path.resolve(process.cwd(), "proto"),
path.resolve(process.cwd(), "quixos-protocol/proto"), path.resolve(process.cwd(), "quixos-protocol/proto"),
]; ];
return [...new Set(candidates)].filter((candidate) => return [...new Set(candidates)].filter((candidate) => fs.existsSync(path.join(candidate, "quixos/package.proto")));
fs.existsSync(path.join(candidate, "quixos/package.proto")),
);
}; };
export const parsePackageDescriptorTextproto = ( export const parsePackageDescriptorTextproto = (text: string): PackageDescriptor => {
text: string,
): PackageDescriptor => {
const paths = protoPaths(); const paths = protoPaths();
if (paths.length === 0) { if (paths.length === 0) {
throw new Error( throw new Error("QUIXOS_PROTO_PATH must include quixos-protocol/proto to parse package descriptors");
"QUIXOS_PROTO_PATH must include quixos-protocol/proto to parse package descriptors",
);
} }
const result = childProcess.spawnSync( const result = childProcess.spawnSync(
"protoc", "protoc",
@@ -44,9 +38,7 @@ export const parsePackageDescriptorTextproto = (
throw result.error; throw result.error;
} }
if (result.status !== 0) { if (result.status !== 0) {
throw new Error( throw new Error(`Failed to parse package descriptor textproto:\n${result.stderr.toString().trim()}`);
`Failed to parse package descriptor textproto:\n${result.stderr.toString().trim()}`,
);
} }
return fromBinary(PackageDescriptorSchema, result.stdout); return fromBinary(PackageDescriptorSchema, result.stdout);
}; };
@@ -56,9 +48,7 @@ export const packageDescriptorToJson = (descriptor: PackageDescriptor) =>
prettySpaces: 2, prettySpaces: 2,
}); });
export const validatePackageDescriptor = ( export const validatePackageDescriptor = (descriptor: PackageDescriptor): string[] => {
descriptor: PackageDescriptor,
): string[] => {
const errors: string[] = []; const errors: string[] = [];
if (!descriptor.packageId) { if (!descriptor.packageId) {
errors.push("packageId is required"); errors.push("packageId is required");
+217 -21
View File
File diff suppressed because one or more lines are too long
+51 -59
View File
@@ -1,24 +1,11 @@
import { lstat, readFile } from "node:fs/promises"; import { lstat, readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { import { parseQuixosLockDocument, type QuixosLockDiagnostic, type QuixosLockParseResult } from "./parser.js";
parseQuixosLockDocument, import type { LockedResource, QuixosLockDocument, QuixosRepositoryLock } from "./types.js";
type QuixosLockDiagnostic,
type QuixosLockParseResult,
} from "./parser.js";
import type {
LockedResource,
QuixosLockDocument,
QuixosRepositoryLock,
} from "./types.js";
export type QuixosLockSourceReader = (relativePath: string) => Promise<string>; export type QuixosLockSourceReader = (relativePath: string) => Promise<string>;
const diagnostic = ( const diagnostic = (code: string, message: string, fileName: string, path?: string): QuixosLockDiagnostic => ({
code: string,
message: string,
fileName: string,
path?: string,
): QuixosLockDiagnostic => ({
phase: "resolution", phase: "resolution",
code, code,
message, message,
@@ -38,11 +25,9 @@ export const resolveQuixosLock = async (
if (root.document.kind !== "root") { if (root.document.kind !== "root") {
return { return {
ok: false, ok: false,
diagnostics: [diagnostic( diagnostics: [
"expected-root-lock", diagnostic("expected-root-lock", "The entrypoint must be a root Quixos lock, not a fragment", rootFileName),
"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) => { const addResources = (document: QuixosLockDocument, fileName: string) => {
for (const resource of document.resources) { for (const resource of document.resources) {
const duplicate = resources.find((entry) => const duplicate = resources.find((entry) => entry.kind === resource.kind && entry.binding === resource.binding);
entry.kind === resource.kind && entry.binding === resource.binding);
if (duplicate) { if (duplicate) {
diagnostics.push(diagnostic( diagnostics.push(
"duplicate-resource-binding", diagnostic(
`Duplicate ${resource.kind} binding ${resource.binding} across imported lock files`, "duplicate-resource-binding",
fileName, `Duplicate ${resource.kind} binding ${resource.binding} across imported lock files`,
`${resource.kind}.${resource.binding}`, fileName,
)); `${resource.kind}.${resource.binding}`,
),
);
} else { } else {
resources.push(resource); resources.push(resource);
} }
@@ -71,11 +57,9 @@ export const resolveQuixosLock = async (
const visit = async (importPath: string) => { const visit = async (importPath: string) => {
if (active.includes(importPath)) { if (active.includes(importPath)) {
diagnostics.push(diagnostic( diagnostics.push(
"import-cycle", diagnostic("import-cycle", `Lock import cycle: ${[...active, importPath].join(" -> ")}`, importPath),
`Lock import cycle: ${[...active, importPath].join(" -> ")}`, );
importPath,
));
return; return;
} }
if (visited.has(importPath)) return; if (visited.has(importPath)) return;
@@ -84,11 +68,13 @@ export const resolveQuixosLock = async (
try { try {
source = await readSource(importPath); source = await readSource(importPath);
} catch (cause) { } catch (cause) {
diagnostics.push(diagnostic( diagnostics.push(
"import-read-failed", diagnostic(
`Could not read lock import ${importPath}: ${cause instanceof Error ? cause.message : String(cause)}`, "import-read-failed",
importPath, `Could not read lock import ${importPath}: ${cause instanceof Error ? cause.message : String(cause)}`,
)); importPath,
),
);
active.pop(); active.pop();
return; return;
} }
@@ -99,11 +85,13 @@ export const resolveQuixosLock = async (
return; return;
} }
if (parsed.document.kind !== "fragment") { if (parsed.document.kind !== "fragment") {
diagnostics.push(diagnostic( diagnostics.push(
"imported-root-lock", diagnostic(
`Imported file ${importPath} must begin with "quixos-lock fragment"`, "imported-root-lock",
importPath, `Imported file ${importPath} must begin with "quixos-lock fragment"`,
)); importPath,
),
);
active.pop(); active.pop();
return; return;
} }
@@ -131,20 +119,24 @@ export const loadQuixosLock = async (fileName: string): Promise<QuixosLockParseR
const repositoryRoot = path.dirname(absoluteRoot); const repositoryRoot = path.dirname(absoluteRoot);
const rootName = path.basename(absoluteRoot); const rootName = path.basename(absoluteRoot);
const rootSource = await readFile(absoluteRoot, "utf8"); const rootSource = await readFile(absoluteRoot, "utf8");
return await resolveQuixosLock(rootSource, async (relativePath) => { return await resolveQuixosLock(
let absoluteImport = repositoryRoot; rootSource,
const segments = relativePath.split("/"); async (relativePath) => {
for (const [index, segment] of segments.entries()) { let absoluteImport = repositoryRoot;
absoluteImport = path.join(absoluteImport, segment); const segments = relativePath.split("/");
const metadata = await lstat(absoluteImport); for (const [index, segment] of segments.entries()) {
if (metadata.isSymbolicLink()) { absoluteImport = path.join(absoluteImport, segment);
throw new Error("imports must not traverse symbolic links"); 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; return await readFile(absoluteImport, "utf8");
if ((!final && !metadata.isDirectory()) || (final && !metadata.isFile())) { },
throw new Error("imports must be ordinary files beneath ordinary directories"); rootName,
} );
}
return await readFile(absoluteImport, "utf8");
}, rootName);
}; };
+60 -113
View File
@@ -13,13 +13,7 @@ import {
type QuixosSourceBlockContext, type QuixosSourceBlockContext,
type SourceBlockContext, type SourceBlockContext,
} from "./generated/QuixosLockParser.js"; } from "./generated/QuixosLockParser.js";
import type { import type { GitSource, LockedResource, QuixosLockDocument, QuixosRepositoryLock, QuixosSource } from "./types.js";
GitSource,
LockedResource,
QuixosLockDocument,
QuixosRepositoryLock,
QuixosSource,
} from "./types.js";
export type QuixosLockDiagnostic = { export type QuixosLockDiagnostic = {
phase: "syntax" | "validation" | "resolution"; phase: "syntax" | "validation" | "resolution";
@@ -66,8 +60,7 @@ class SyntaxErrorListener extends BaseErrorListener {
} }
} }
const stringValue = (context: { getText(): string }): string => const stringValue = (context: { getText(): string }): string => JSON.parse(context.getText()) as string;
JSON.parse(context.getText()) as string;
const lowerSource = (context: SourceBlockContext): GitSource => ({ const lowerSource = (context: SourceBlockContext): GitSource => ({
resolver: "git", resolver: "git",
@@ -103,15 +96,16 @@ const issue = (
path?: string, path?: string,
line = 1, line = 1,
column = 0, column = 0,
) => diagnostics.push({ ) =>
phase: "validation", diagnostics.push({
code, phase: "validation",
message, code,
fileName, message,
line, fileName,
column, line,
path, column,
}); path,
});
const validateImport = ( const validateImport = (
importPath: string, importPath: string,
@@ -123,11 +117,11 @@ const validateImport = (
) => { ) => {
const path = `imports[${index}]`; const path = `imports[${index}]`;
if ( if (
!importPath !importPath ||
|| importPath.startsWith("/") importPath.startsWith("/") ||
|| importPath.includes("\\") importPath.includes("\\") ||
|| importPath.split("/").some((segment) => !segment || segment === "." || segment === "..") importPath.split("/").some((segment) => !segment || segment === "." || segment === "..") ||
|| /^[A-Za-z][A-Za-z0-9+.-]*:/.test(importPath) /^[A-Za-z][A-Za-z0-9+.-]*:/.test(importPath)
) { ) {
issue( issue(
diagnostics, diagnostics,
@@ -141,12 +135,7 @@ const validateImport = (
} }
}; };
const validateSource = ( const validateSource = (source: GitSource, path: string, fileName: string, diagnostics: QuixosLockDiagnostic[]) => {
source: GitSource,
path: string,
fileName: string,
diagnostics: QuixosLockDiagnostic[],
) => {
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(source.commit)) { if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(source.commit)) {
issue( issue(
diagnostics, diagnostics,
@@ -198,25 +187,22 @@ const validateSource = (
} }
}; };
const validateQuixosSource = ( const validateQuixosSource = (source: QuixosSource, fileName: string, diagnostics: QuixosLockDiagnostic[]) => {
source: QuixosSource,
fileName: string,
diagnostics: QuixosLockDiagnostic[],
) => {
validateSource(source, "quixos", fileName, diagnostics); validateSource(source, "quixos", fileName, diagnostics);
if (!source.policy) return; if (!source.policy) return;
const forbiddenRefCharacters = new Set("~^:?*[\\"); const forbiddenRefCharacters = new Set("~^:?*[\\");
const invalidRef = !source.ref const invalidRef =
|| [...source.ref].some((character) => { !source.ref ||
[...source.ref].some((character) => {
const code = character.charCodeAt(0); const code = character.charCodeAt(0);
return code <= 0x20 || code === 0x7f || forbiddenRefCharacters.has(character); return code <= 0x20 || code === 0x7f || forbiddenRefCharacters.has(character);
}) }) ||
|| source.ref.startsWith("/") source.ref.startsWith("/") ||
|| source.ref.endsWith("/") source.ref.endsWith("/") ||
|| source.ref.endsWith(".") source.ref.endsWith(".") ||
|| source.ref.includes("..") source.ref.includes("..") ||
|| source.ref.includes("@{") source.ref.includes("@{") ||
|| source.ref.includes("//"); source.ref.includes("//");
if (invalidRef) { if (invalidRef) {
issue( issue(
diagnostics, diagnostics,
@@ -247,10 +233,7 @@ const validateQuixosSource = (
} }
}; };
export const parseQuixosLockDocument = ( export const parseQuixosLockDocument = (source: string, fileName = "<memory>"): QuixosLockDocumentParseResult => {
source: string,
fileName = "<memory>",
): QuixosLockDocumentParseResult => {
const diagnostics: QuixosLockDiagnostic[] = []; const diagnostics: QuixosLockDiagnostic[] = [];
const listener = new SyntaxErrorListener(fileName, diagnostics); const listener = new SyntaxErrorListener(fileName, diagnostics);
const lexer = new QuixosLockLexer(CharStream.fromString(source)); const lexer = new QuixosLockLexer(CharStream.fromString(source));
@@ -299,26 +282,13 @@ export const parseQuixosLockDocument = (
const imports = tree.importEntry().map((context, index) => { const imports = tree.importEntry().map((context, index) => {
const importPath = stringValue(context.stringLiteral()); const importPath = stringValue(context.stringLiteral());
validateImport( validateImport(importPath, index, fileName, diagnostics, context.start?.line ?? 1, context.start?.column ?? 0);
importPath,
index,
fileName,
diagnostics,
context.start?.line ?? 1,
context.start?.column ?? 0,
);
return importPath; return importPath;
}); });
const repeatedImports = new Set<string>(); const repeatedImports = new Set<string>();
imports.forEach((importPath, index) => { imports.forEach((importPath, index) => {
if (repeatedImports.has(importPath)) { if (repeatedImports.has(importPath)) {
issue( issue(diagnostics, fileName, "duplicate-import", `Duplicate lock import ${importPath}`, `imports[${index}]`);
diagnostics,
fileName,
"duplicate-import",
`Duplicate lock import ${importPath}`,
`imports[${index}]`,
);
} }
repeatedImports.add(importPath); repeatedImports.add(importPath);
}); });
@@ -355,36 +325,38 @@ export const parseQuixosLockDocument = (
}; };
}; };
export const parseQuixosLock = ( export const parseQuixosLock = (source: string, fileName = "<memory>"): QuixosLockParseResult => {
source: string,
fileName = "<memory>",
): QuixosLockParseResult => {
const parsed = parseQuixosLockDocument(source, fileName); const parsed = parseQuixosLockDocument(source, fileName);
if (!parsed.ok) return parsed; if (!parsed.ok) return parsed;
if (parsed.document.kind === "fragment") { if (parsed.document.kind === "fragment") {
return { return {
ok: false, ok: false,
diagnostics: [{ diagnostics: [
phase: "validation", {
code: "expected-root-lock", phase: "validation",
message: "Expected a root Quixos lock, found a lock fragment", code: "expected-root-lock",
fileName, message: "Expected a root Quixos lock, found a lock fragment",
line: 1, fileName,
column: 0, line: 1,
}], column: 0,
},
],
}; };
} }
if (parsed.document.imports.length) { if (parsed.document.imports.length) {
return { return {
ok: false, ok: false,
diagnostics: [{ diagnostics: [
phase: "resolution", {
code: "imports-require-file-resolution", phase: "resolution",
message: "This lock has imports and must be loaded from its repository rather than parsed as an isolated string", code: "imports-require-file-resolution",
fileName, message:
line: 1, "This lock has imports and must be loaded from its repository rather than parsed as an isolated string",
column: 0, fileName,
}], line: 1,
column: 0,
},
],
}; };
} }
return { return {
@@ -407,44 +379,23 @@ const sourceLines = (source: GitSource, indentation: string): string[] => [
const quixosSourceLines = (source: QuixosSource, indentation: string): string[] => [ const quixosSourceLines = (source: QuixosSource, indentation: string): string[] => [
`${indentation}repository ${quoted(source.repository)};`, `${indentation}repository ${quoted(source.repository)};`,
...(source.policy ...(source.policy ? [`${indentation}policy ${source.policy};`, `${indentation}ref ${quoted(source.ref)};`] : []),
? [
`${indentation}policy ${source.policy};`,
`${indentation}ref ${quoted(source.ref)};`,
]
: []),
`${indentation}commit ${quoted(source.commit.toLowerCase())};`, `${indentation}commit ${quoted(source.commit.toLowerCase())};`,
]; ];
export const formatQuixosLock = (lock: QuixosRepositoryLock): string => { export const formatQuixosLock = (lock: QuixosRepositoryLock): string => {
const lines = [ const lines = ["quixos-lock version 1 {", " quixos source {", ...quixosSourceLines(lock.quixos, " "), " }"];
"quixos-lock version 1 {",
" quixos source {",
...quixosSourceLines(lock.quixos, " "),
" }",
];
for (const resource of lock.resources) { for (const resource of lock.resources) {
lines.push( lines.push("", ` ${resource.kind} ${resource.binding} source {`, ...sourceLines(resource.source, " "), " }");
"",
` ${resource.kind} ${resource.binding} source {`,
...sourceLines(resource.source, " "),
" }",
);
} }
lines.push("}", ""); lines.push("}", "");
return lines.join("\n"); return lines.join("\n");
}; };
export const formatQuixosLockDocument = (document: QuixosLockDocument): string => { export const formatQuixosLockDocument = (document: QuixosLockDocument): string => {
const lines = [ const lines = [`quixos-lock${document.kind === "fragment" ? " fragment" : ""} version 1 {`];
`quixos-lock${document.kind === "fragment" ? " fragment" : ""} version 1 {`,
];
if (document.kind === "root") { if (document.kind === "root") {
lines.push( lines.push(" quixos source {", ...quixosSourceLines(document.quixos, " "), " }");
" quixos source {",
...quixosSourceLines(document.quixos, " "),
" }",
);
} }
for (const importPath of document.imports) { for (const importPath of document.imports) {
if (lines.length > 1) lines.push(""); if (lines.length > 1) lines.push("");
@@ -452,11 +403,7 @@ export const formatQuixosLockDocument = (document: QuixosLockDocument): string =
} }
for (const resource of document.resources) { for (const resource of document.resources) {
if (lines.length > 1) lines.push(""); if (lines.length > 1) lines.push("");
lines.push( lines.push(` ${resource.kind} ${resource.binding} source {`, ...sourceLines(resource.source, " "), " }");
` ${resource.kind} ${resource.binding} source {`,
...sourceLines(resource.source, " "),
" }",
);
} }
lines.push("}", ""); lines.push("}", "");
return lines.join("\n"); return lines.join("\n");
+13 -12
View File
@@ -9,13 +9,17 @@ export type QuixosSourcePolicy = "pinned" | "track-release" | "track-development
// Resource repositories only need the exact Quixos commit they were authored // Resource repositories only need the exact Quixos commit they were authored
// against. A workspace root additionally declares how a runtime may advance // against. A workspace root additionally declares how a runtime may advance
// that exact baseline. // that exact baseline.
export type QuixosSource = GitSource & ({ export type QuixosSource = GitSource &
policy: QuixosSourcePolicy; (
ref: string; | {
} | { policy: QuixosSourcePolicy;
policy?: undefined; ref: string;
ref?: undefined; }
}); | {
policy?: undefined;
ref?: undefined;
}
);
export type LockedResourceKind = "interface" | "package"; export type LockedResourceKind = "interface" | "package";
@@ -40,9 +44,7 @@ export type QuixosLockFragmentDocument = {
resources: LockedResource[]; resources: LockedResource[];
}; };
export type QuixosLockDocument = export type QuixosLockDocument = QuixosLockRootDocument | QuixosLockFragmentDocument;
| QuixosLockRootDocument
| QuixosLockFragmentDocument;
export type QuixosRepositoryLock = { export type QuixosRepositoryLock = {
formatVersion: 1; formatVersion: 1;
@@ -60,8 +62,7 @@ export type NixGitInput = {
export const RETENTION_TAG_PREFIX = "refs/tags/quixos-reachability/"; export const RETENTION_TAG_PREFIX = "refs/tags/quixos-reachability/";
export const retentionTagForCommit = (commit: string): string => export const retentionTagForCommit = (commit: string): string => `${RETENTION_TAG_PREFIX}${commit.toLowerCase()}`;
`${RETENTION_TAG_PREFIX}${commit.toLowerCase()}`;
export const nixGitInput = (source: GitSource): NixGitInput => ({ export const nixGitInput = (source: GitSource): NixGitInput => ({
type: "git", type: "git",
+47 -15
View File
@@ -9,32 +9,55 @@ import { convergeAuthoring } from "../src/capability-language/authoring-converge
import { loadQuixosLock } from "../src/resource-lock/index.js"; import { loadQuixosLock } from "../src/resource-lock/index.js";
const execFile = promisify(callback); 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-")); const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-converge-test-"));
context.after(() => fs.rm(temporary, { recursive: true, force: true })); 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(path.join(workbench, ".quixos"), { recursive: true });
await fs.mkdir(remotes); await fs.mkdir(remotes);
const origin = "https://convergence.example.test/"; 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_COUNT = "1";
process.env.GIT_CONFIG_KEY_0 = `url.file://${remotes}/.insteadOf`; process.env.GIT_CONFIG_KEY_0 = `url.file://${remotes}/.insteadOf`;
process.env.GIT_CONFIG_VALUE_0 = origin; 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 = []; const resources = [];
let dependency = ""; 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); const root = path.join(workbench, directory);
await fs.mkdir(root, { recursive: true }); await fs.mkdir(root, { recursive: true });
await execFile("jj", ["git", "init", "--colocate", root]); await execFile("jj", ["git", "init", "--colocate", root]);
await execFile("git", ["init", "--bare", path.join(remotes, name)]); await execFile("git", ["init", "--bare", path.join(remotes, name)]);
const repository = `${origin}${name}`; const repository = `${origin}${name}`;
await execFile("git", ["-C", root, "remote", "add", "origin", repository]); 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 fs.writeFile(path.join(root, `${kind}.qx`), "draft");
await execFile("jj", ["status"], { cwd: root }); 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(); const commit = (
if (kind !== "workspace") resources.push({ directory, kind, resourceId: `${kind}:${name}`, source: { resolver: "git", repository, 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}"; }`; dependency = `${kind} ${name} source { repository "${repository}"; commit "${commit}"; }`;
} }
await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({ resources })); 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); assert.notEqual(second.candidate?.commit, first.candidate?.commit);
const consumer = await loadQuixosLock(path.join(workbench, "resources/Consumer/quixos.lock")); const consumer = await loadQuixosLock(path.join(workbench, "resources/Consumer/quixos.lock"));
assert.ok(consumer.ok); 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); const third = await convergeAuthoring(workbench);
assert.deepEqual(third, second); assert.deepEqual(third, second);
const base = path.join(workbench, "resources/Base"); 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); const partial = await convergeAuthoring(workbench);
assert.equal(partial.converged, false); assert.equal(partial.converged, false);
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8")); 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); await fs.writeFile(path.join(consumerRoot, "quixos.lock"), goodLock);
const resumed = await convergeAuthoring(workbench); const resumed = await convergeAuthoring(workbench);
assert.deepEqual(resumed.worklist, []); assert.deepEqual(resumed.worklist, []);
assert.deepEqual(await convergeAuthoring(workbench), resumed); assert.deepEqual(await convergeAuthoring(workbench), resumed);
await execFile("git", ["-C", base, "remote", "set-url", "origin", origin + "Wrong"]); await execFile("git", ["-C", base, "remote", "set-url", "origin", origin + "Wrong"]);
const mismatch = await convergeAuthoring(workbench); 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 execFile("git", ["-C", base, "remote", "set-url", "origin", origin + "Base"]);
await fs.rename(path.join(remotes, "Base"), path.join(remotes, "Base-offline")); await fs.rename(path.join(remotes, "Base"), path.join(remotes, "Base-offline"));
const offline = await convergeAuthoring(workbench); const offline = await convergeAuthoring(workbench);
assert.equal(offline.candidate, null); 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")); await fs.rename(path.join(remotes, "Base-offline"), path.join(remotes, "Base"));
assert.equal((await convergeAuthoring(workbench)).converged, true); assert.equal((await convergeAuthoring(workbench)).converged, true);
const consumerSource = resumed.retained.find(entry => entry.directory === "resources/Consumer")!.source; 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}"; } }`); 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); const cycle = await convergeAuthoring(workbench);
assert.equal(cycle.candidate, null); 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)));
}); });
+6 -3
View File
@@ -8,12 +8,15 @@ import { promisify } from "node:util";
import { inspectAuthoringRepository } from "../src/capability-language/authoring-inspect.js"; import { inspectAuthoringRepository } from "../src/capability-language/authoring-inspect.js";
const execFile = promisify(callback); 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-")); const root = await mkdtemp(path.join(os.tmpdir(), "qx-inspection-test-"));
context.after(() => rm(root, { recursive: true, force: true })); context.after(() => rm(root, { recursive: true, force: true }));
const git = (...args: string[]) => execFile("git", ["-C", root, ...args]); const git = (...args: string[]) => execFile("git", ["-C", root, ...args]);
await git("init"); 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("add", ".");
await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract"); await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract");
const commit = (await git("rev-parse", "HEAD")).stdout.trim(); 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.match(inspected.files[1].declarations[0].source, /New/);
assert.equal((await git("rev-parse", "HEAD")).stdout.trim(), commit); assert.equal((await git("rev-parse", "HEAD")).stdout.trim(), commit);
await symlink(path.join(root, "interface.qx"), path.join(root, "linked.qx")); 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.equal(linked.status, "unavailable");
assert.deepEqual(linked.declarations, []); assert.deepEqual(linked.declarations, []);
assert.match(JSON.stringify(linked.currentErrors), /ordinary files/); assert.match(JSON.stringify(linked.currentErrors), /ordinary files/);
+64 -26
View File
@@ -3,43 +3,81 @@ import assert from "node:assert/strict";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import {execFile as callback} from "node:child_process"; import { execFile as callback } from "node:child_process";
import {promisify} from "node:util"; import { promisify } from "node:util";
import {authoringWorklist} from "../src/capability-language/authoring-worklist.js"; import { authoringWorklist } from "../src/capability-language/authoring-worklist.js";
import {checkRecordName} from "../src/capability-language/authoring-check.js"; import { checkRecordName } from "../src/capability-language/authoring-check.js";
import {checkerIdentity, snapshotCommit} from "../src/capability-language/checked-build.js"; import { checkerIdentity, snapshotCommit } from "../src/capability-language/checked-build.js";
const execFile = promisify(callback); 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-")); const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-worklist-test-"));
context.after(() => fs.rm(workbench, {recursive: true, force: true})); context.after(() => fs.rm(workbench, { recursive: true, force: true }));
const root = path.join(workbench, "root"), provider = path.join(workbench, "resources/Base"); const root = path.join(workbench, "root"),
await fs.mkdir(root); await fs.mkdir(provider, {recursive: true}); provider = path.join(workbench, "resources/Base");
await fs.mkdir(path.join(workbench, ".quixos/checks"), {recursive: true}); 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)}"; }`; 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]); 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} }`); await fs.writeFile(path.join(provider, "quixos.lock"), `${header} }`);
const baseCommit = await snapshotCommit(provider); 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(
await fs.writeFile(path.join(root, "quixos.lock"), `${header} interface Base source { repository "https://example.test/base"; commit "${baseCommit}"; } }`); 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); const rootCommit = await snapshotCommit(root);
await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [ await fs.writeFile(
{kind: "interface", directory: provider, source: {resolver: "git", repository: "https://example.test/base", commit: baseCommit}}, path.join(workbench, ".quixos/resource-graph.json"),
]})); JSON.stringify({
assert.equal((await authoringWorklist(workbench)).worklist.filter(entry => entry.phase === "unchecked").length, 2); resources: [
const remember = (directory: string, commit: string, checker = checkerIdentity()) => fs.writeFile( {
path.join(workbench, ".quixos/checks", checkRecordName(directory)), JSON.stringify({commit, checker, phase: "checked", blockers: []})); kind: "interface",
await remember("root", rootCommit); await remember("resources/Base", baseCommit); 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, []); assert.deepEqual((await authoringWorklist(workbench)).worklist, []);
await fs.writeFile(path.join(provider, "interface.qx"), "interface broken {{{"); await fs.writeFile(path.join(provider, "interface.qx"), "interface broken {{{");
const broken = await authoringWorklist(workbench); const broken = await authoringWorklist(workbench);
assert.ok(broken.worklist.some(entry => entry.directory === "resources/Base" && entry.phase === "syntax" && /historical/.test(entry.message))); assert.ok(
assert.ok(broken.worklist.some(entry => entry.directory === "root" && entry.phase === "dependency")); broken.worklist.some(
await fs.writeFile(path.join(provider, "interface.qx"), 'interface Base id "interface:base" revision "interface:base@1" {}'); (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, []); assert.deepEqual((await authoringWorklist(workbench)).worklist, []);
await remember("resources/Base", baseCommit, "old-checker"); await remember("resources/Base", baseCommit, "old-checker");
assert.ok((await authoringWorklist(workbench)).worklist.some(entry => /checker changed/.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"]})); await fs.writeFile(
assert.ok((await authoringWorklist(workbench)).worklist.some(entry => entry.phase === "publication" && /source retention/.test(entry.message))); 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),
),
);
}); });
+19 -5
View File
@@ -4,13 +4,18 @@ import { compileCapabilityResourceSource } from "../src/capability-language/pars
import { generateTypeScriptBindings, type BindingSchema } from "../src/bindings/index.js"; import { generateTypeScriptBindings, type BindingSchema } from "../src/bindings/index.js";
const source = { repository: "https://example.test/p.git", commit: "1".repeat(40) }; const source = { repository: "https://example.test/p.git", commit: "1".repeat(40) };
const schemaFor = (body: string): BindingSchema => { 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)); assert.equal(compiled.ok, true, JSON.stringify(compiled.diagnostics));
if (!compiled.ok || compiled.resource.kind !== "package") throw new Error("expected package"); if (!compiled.ok || compiled.resource.kind !== "package") throw new Error("expected package");
return { format: "quixos-bindings", version: 1, interfaces: [], packages: [compiled.resource.revision] }; return { format: "quixos-bindings", version: 1, interfaces: [], packages: [compiled.resource.revision] };
}; };
test("generator uses exact IDs, restricted ports, nominal references, and lossless scalar types", () => { test("generator uses exact IDs, restricted ports, nominal references, and lossless scalar types", () => {
const schema = schemaFor('operation run id "run-id" : list<int64> -> optional<atom-ref<Thing>> mode call receiver atom Thing requires { state payload id "payload-id" : bytes [read]; };'); const schema = schemaFor(
'operation run id "run-id" : list<int64> -> optional<atom-ref<Thing>> mode call receiver atom Thing requires { state payload id "payload-id" : bytes [read]; };',
);
const generated = generateTypeScriptBindings(schema, "demo@1"); const generated = generateTypeScriptBindings(schema, "demo@1");
assert.match(generated, /Array<bigint>/); assert.match(generated, /Array<bigint>/);
assert.match(generated, /Promise<Uint8Array>/); assert.match(generated, /Promise<Uint8Array>/);
@@ -22,9 +27,18 @@ test("generator uses exact IDs, restricted ports, nominal references, and lossle
test("missing external types and constructor signatures fail generation", () => { test("missing external types and constructor signatures fail generation", () => {
const schema = schemaFor('function run id "run" : unit -> message "example.Payload";'); const schema = schemaFor('function run id "run" : unit -> message "example.Payload";');
assert.throws(() => generateTypeScriptBindings(schema, "demo@1"), /Missing TypeScript message binding/); 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<typeof message0>/); assert.match(
const constructor = schemaFor('function run id "run" : unit -> unit requires { constructor thing id "ctor" : Thing; };'); generateTypeScriptBindings(schema, "demo@1", {
messages: { "example.Payload": { module: "./payload.js", export: "payload" } },
}),
/BindingValue<typeof message0>/,
);
const constructor = schemaFor(
'function run id "run" : unit -> unit requires { constructor thing id "ctor" : Thing; };',
);
assert.throws(() => generateTypeScriptBindings(constructor, "demo@1"), /explicit input contract/); 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/); assert.match(generateTypeScriptBindings(typed, "demo@1"), /construct.*input: string/);
}); });
+21 -6
View File
@@ -1,21 +1,36 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import {bundlePolicyErrors} from "../src/bindings/bundle-policy.js"; import { bundlePolicyErrors } from "../src/bindings/bundle-policy.js";
import {addImplementation} from "../src/capability-language/implementation-edit.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", () => { 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.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"), []); assert.deepEqual(bundlePolicyErrors('// import.meta.url\nconst text = "__dirname";', "source.ts"), []);
}); });
test("imperative handler insertion preserves arbitrary existing code and rejects ambiguous targets", () => { 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"); const edited = addImplementation(original, "createRuntime", "newHandler", "./impl/new.js");
assert.match(edited, /existing: customHandler/); assert.match(edited, /existing: customHandler/);
assert.match(edited, /const keep =/); assert.match(edited, /const keep =/);
assert.match(edited, /"newHandler": qxImplementation/); assert.match(edited, /"newHandler": qxImplementation/);
assert.throws(() => addImplementation(original, "createRuntime", "existing", "./x.js"), /already exists/); 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/);
}); });
+12 -5
View File
@@ -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) => { 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-")); const directory = await fs.mkdtemp(path.join(os.tmpdir(), "qx-check-test-"));
context.after(() => fs.rm(directory, { recursive: true, force: true })); 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 fs.mkdir(root);
await execFile("jj", ["git", "init", "--colocate", 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(
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"; }`); path.join(root, "quixos.lock"),
const result = await checkWorkspaceCandidate({root, output}); `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.candidateOnly, true);
assert.equal(result.activationEvidence, false); assert.equal(result.activationEvidence, false);
assert.deepEqual(result.blockers, []); assert.deepEqual(result.blockers, []);
const report = await fs.readFile(path.join(output, "report.json")); 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); assert.deepEqual(await fs.readFile(path.join(output, "report.json")), report);
}); });
+65 -29
View File
@@ -3,10 +3,7 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import test from "node:test"; import test from "node:test";
import { import { compileCapabilityResourceRepository, compileWorkspaceRepository } from "../src/capability-language/index.js";
compileCapabilityResourceRepository,
compileWorkspaceRepository,
} from "../src/capability-language/index.js";
const quixosCommit = "1".repeat(40); const quixosCommit = "1".repeat(40);
const namedCommit = "2".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 named = path.join(directory, "named");
const runtime = path.join(directory, "runtime"); const runtime = path.join(directory, "runtime");
await Promise.all([mkdir(root), mkdir(named), mkdir(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"; repository "https://example.test/package-runtime.git";
commit "${packageCommit}"; 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"; atom Subject id "atom:subject";
import package Runtime; import package Runtime;
} }
`); `,
);
await writeFile(path.join(named, "quixos.lock"), lock()); 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 { value name id "member:named:name" : string {
get id "operation:named:name:get"; 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"; repository "https://example.test/interface-named.git";
commit "${namedCommit}"; 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" { package Runtime id "package:runtime" revision "package:runtime@1" {
function describe id "export:runtime:describe" : interface-ref<Named> -> string; function describe id "export:runtime:describe" : interface-ref<Named> -> string;
} }
`); `,
);
const directories = new Map([ const directories = new Map([
[`interface\0https://example.test/interface-named.git\0${namedCommit}`, named], [`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) => { test("resource repositories cannot own workspace Quixos selection policy", async (context) => {
const directory = await mkdtemp(path.join(os.tmpdir(), "quixos-resource-policy-")); const directory = await mkdtemp(path.join(os.tmpdir(), "quixos-resource-policy-"));
context.after(() => rm(directory, { recursive: true, force: true })); 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 { quixos source {
repository "https://example.test/quixos.git"; repository "https://example.test/quixos.git";
policy track-development; policy track-development;
ref "dev/alice/main"; ref "dev/alice/main";
commit "${quixosCommit}"; 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( await assert.rejects(
compileCapabilityResourceRepository({ compileCapabilityResourceRepository({
@@ -131,19 +149,28 @@ test("resource manifests cannot hide lock dependencies", async (context) => {
const root = path.join(directory, "root"); const root = path.join(directory, "root");
const runtime = path.join(directory, "runtime"); const runtime = path.join(directory, "runtime");
await Promise.all([mkdir(root), mkdir(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"; repository "https://example.test/package-runtime.git";
commit "${packageCommit}"; 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"; atom Subject id "atom:subject";
import package Runtime; import package Runtime;
} }
`); `,
);
await writeFile(path.join(runtime, "quixos.lock"), lock()); 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" { } package Runtime id "package:runtime" revision "package:runtime@1" { }
`); `,
);
await assert.rejects( await assert.rejects(
compileWorkspaceRepository({ compileWorkspaceRepository({
@@ -160,19 +187,28 @@ test("workspace assembly must satisfy nominal external interfaces", async (conte
const root = path.join(directory, "root"); const root = path.join(directory, "root");
const runtime = path.join(directory, "runtime"); const runtime = path.join(directory, "runtime");
await Promise.all([mkdir(root), mkdir(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"; repository "https://example.test/package-runtime.git";
commit "${packageCommit}"; 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"; atom Subject id "atom:subject";
import package Runtime; import package Runtime;
} }
`); `,
);
await writeFile(path.join(runtime, "quixos.lock"), lock()); 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" { } package Runtime id "package:runtime" revision "package:runtime@1" { }
`); `,
);
await assert.rejects( await assert.rejects(
compileWorkspaceRepository({ compileWorkspaceRepository({
+6 -7
View File
@@ -5,14 +5,12 @@ import { test } from "node:test";
const cli = resolve(process.cwd(), "dist/src/capability-language/cli.js"); const cli = resolve(process.cwd(), "dist/src/capability-language/cli.js");
const runResourceCheck = (source: string) => spawnSync( const runResourceCheck = (source: string) =>
process.execPath, spawnSync(process.execPath, [cli, "--resource", "--check", "-"], { input: source, encoding: "utf8" });
[cli, "--resource", "--check", "-"],
{ input: source, encoding: "utf8" },
);
test("capability CLI checks a standalone interface resource", () => { 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 { value temperature id "member:weather:temperature" : double {
get id "operation:weather:temperature:get"; 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", () => { 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; value temperature : definitely-not-a-type;
}\n`); }\n`);
assert.notEqual(result.status, 0); assert.notEqual(result.status, 0);
+98 -123
View File
@@ -2,10 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/index.js";
compileCapabilityResourceSource,
compileCapabilitySource,
} from "../src/capability-language/index.js";
import { import {
capabilityFixtureSource, capabilityFixtureSource,
capabilityResourceSources, capabilityResourceSources,
@@ -14,91 +11,76 @@ import {
} from "./fixtures/capability-model.js"; } from "./fixtures/capability-model.js";
test("RPC record fields retain declared reference targets and reject duplicate fields", () => { 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"; external atom Board id "atom:board";
package Runtime id "package:runtime" revision "package:runtime@1" { package Runtime id "package:runtime" revision "package:runtime@1" {
function move id "export:move" : record { ${fields} } -> unit; 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<Board>; position: record { x: double; y: double; }; note: optional<string>;"); const result = compile("board: atom-ref<Board>; position: record { x: double; y: double; }; note: optional<string>;");
assert.equal(result.ok, true, JSON.stringify(result.diagnostics)); assert.equal(result.ok, true, JSON.stringify(result.diagnostics));
assert.equal(compile("board: atom-ref<Board>; board: string;").ok, false); assert.equal(compile("board: atom-ref<Board>; board: string;").ok, false);
}); });
const compileWebStudioFixture = (packageTransform = (source: string) => source) => { const compileWebStudioFixture = (packageTransform = (source: string) => source) => {
const fixture = (name: string) => readFileSync( const fixture = (name: string) => readFileSync(resolve(process.cwd(), "test/fixtures", name), "utf8");
resolve(process.cwd(), "test/fixtures", name), const react = compileCapabilityResourceSource(fixture("react-component.interface.qx"), {
"utf8", source: {
); repository: "https://repos.quixos.org/org-quixos-web-studio/interface-react-component.git",
const react = compileCapabilityResourceSource( commit: "2".repeat(40),
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") { if (!react.ok || react.resource.kind !== "interface") {
throw new Error("ReactComponent fixture did not compile"); throw new Error("ReactComponent fixture did not compile");
} }
const named = compileCapabilityResourceSource( const named = compileCapabilityResourceSource(fixture("named.interface.qx"), {
fixture("named.interface.qx"), source: {
{ repository: "https://repos.quixos.org/quixos-test/interface-named.git",
source: { commit: "5".repeat(40),
repository: "https://repos.quixos.org/quixos-test/interface-named.git",
commit: "5".repeat(40),
},
}, },
); });
if (!named.ok || named.resource.kind !== "interface") { if (!named.ok || named.resource.kind !== "interface") {
throw new Error("Named fixture did not compile"); throw new Error("Named fixture did not compile");
} }
const has = compileCapabilityResourceSource( const has = compileCapabilityResourceSource(fixture("has-react-component.interface.qx"), {
fixture("has-react-component.interface.qx"), source: {
{ repository: "https://repos.quixos.org/org-quixos-web-studio/interface-has-react-component.git",
source: { commit: "3".repeat(40),
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],
},
}, },
); environment: {
interfaces: new Map([["ReactComponent", react.resource.revision]]),
interfaceClosure: [react.resource.revision],
},
});
if (!has.ok || has.resource.kind !== "interface") { if (!has.ok || has.resource.kind !== "interface") {
throw new Error("HasReactComponent fixture did not compile"); throw new Error("HasReactComponent fixture did not compile");
} }
const component = compileCapabilityResourceSource( const component = compileCapabilityResourceSource(packageTransform(fixture("component-runtime.package.qx")), {
packageTransform(fixture("component-runtime.package.qx")), source: {
{ repository: "https://repos.quixos.org/quixos-test/package-component-runtime.git",
source: { commit: "4".repeat(40),
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],
},
}, },
); environment: {
interfaces: new Map([["Named", named.resource.revision]]),
interfaceClosure: [named.resource.revision],
},
});
if (!component.ok || component.resource.kind !== "package") { if (!component.ok || component.resource.kind !== "package") {
throw new Error("ComponentRuntime fixture did not compile"); throw new Error("ComponentRuntime fixture did not compile");
} }
return compileCapabilitySource( return compileCapabilitySource(fixture("web-studio.capabilities.qx"), "web-studio.capabilities.qx", {
fixture("web-studio.capabilities.qx"), interfaces: new Map([
"web-studio.capabilities.qx", ["Named", named.resource.revision],
{ ["ReactComponent", react.resource.revision],
interfaces: new Map([ ["HasReactComponent", has.resource.revision],
["Named", named.resource.revision], ]),
["ReactComponent", react.resource.revision], packages: new Map([["ComponentRuntime", component.resource.revision]]),
["HasReactComponent", has.resource.revision], interfaceClosure: [named.resource.revision, react.resource.revision, has.resource.revision],
]), packageClosure: [component.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", () => { 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({ const result = compileCapabilityFixture({
workspace: capabilityFixtureSource.replace( workspace: capabilityFixtureSource.replace(
/\n}\s*$/, /\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); assert.equal(result.ok, false);
@@ -140,10 +122,7 @@ test("forward declarations make source order irrelevant", () => {
const source = capabilityFixtureSource const source = capabilityFixtureSource
.replace(/ atom Project[^\n]*\n/, "") .replace(/ atom Project[^\n]*\n/, "")
.replace(/ atom Person[^\n]*\n/, "") .replace(/ atom Person[^\n]*\n/, "")
.replace( .replace(/\n}\s*$/, '\n atom Project id "atom:project";\n atom Person id "atom:person";\n}\n');
/\n}\s*$/,
'\n atom Project id "atom:project";\n atom Person id "atom:person";\n}\n',
);
assert.equal(compileCapabilityFixture({ workspace: source }).ok, true); assert.equal(compileCapabilityFixture({ workspace: source }).ok, true);
}); });
@@ -153,24 +132,23 @@ test("interfaces can declare ordinary call operations", () => {
"bind summarize.call to package TodoRuntime.summaryGet", "bind summarize.call to package TodoRuntime.summaryGet",
); );
const summary = capabilityResourceSources.summary.replace( const summary = capabilityResourceSources.summary.replace(
" value summary id \"member:summary:summary\" : string {\n get id \"operation:summary:summary:get\";\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 }", ' operation summarize id "member:summary:summarize" : string -> string {\n call id "operation:summary:summarize";\n }',
); );
const todo = capabilityResourceSources.todo.replace( const todo = capabilityResourceSources.todo.replace(
"operation summaryGet id \"export:todo-runtime:summary-get\" : unit -> string", '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" : string -> string',
); );
const result = compileCapabilityFixture({ workspace, summary, todo }); const result = compileCapabilityFixture({ workspace, summary, todo });
assert.equal(result.ok, true); assert.equal(result.ok, true);
if (!result.ok) return; if (!result.ok) return;
const member = result.workspace.interfaceImports const member = result.workspace.interfaceImports.find((entry) => entry.displayName === "Summary")?.members[0];
.find((entry) => entry.displayName === "Summary")?.members[0];
assert.equal(member?.kind, "operation"); assert.equal(member?.kind, "operation");
}); });
test("state defaults accept recursive JSON values", () => { test("state defaults accept recursive JSON values", () => {
const source = capabilityFixtureSource.replace( const source = capabilityFixtureSource.replace(
' policy optimistic-register default "Untitled project";', /policy optimistic-register default "Untitled project";/,
` policy optimistic-register default "Untitled project"; ` policy optimistic-register default "Untitled project";
shared state ProjectMetadata id "slot:project:metadata" on Project : message "example.Metadata" shared state ProjectMetadata id "slot:project:metadata" on Project : message "example.Metadata"
policy optimistic-register default {"labels":["compiler","runtime"],"score":1.5,"enabled":true,"extra":null};`, policy optimistic-register default {"labels":["compiler","runtime"],"score":1.5,"enabled":true,"extra":null};`,
@@ -178,9 +156,7 @@ test("state defaults accept recursive JSON values", () => {
const result = compileCapabilityFixture({ workspace: source }); const result = compileCapabilityFixture({ workspace: source });
assert.equal(result.ok, true); assert.equal(result.ok, true);
if (!result.ok) return; if (!result.ok) return;
const metadata = result.workspace.sharedAttachments.find( const metadata = result.workspace.sharedAttachments.find((entry) => entry.id === "slot:project:metadata");
(entry) => entry.id === "slot:project:metadata",
);
assert.equal(metadata?.kind, "state"); assert.equal(metadata?.kind, "state");
if (metadata?.kind !== "state") return; if (metadata?.kind !== "state") return;
assert.deepEqual(metadata.defaultValue, { assert.deepEqual(metadata.defaultValue, {
@@ -197,25 +173,20 @@ test("syntax errors retain source locations", () => {
}); });
assert.equal(result.ok, false); assert.equal(result.ok, false);
if (result.ok) return; if (result.ok) return;
assert.ok(result.diagnostics.some((entry) => assert.ok(result.diagnostics.some((entry) => entry.phase === "syntax" && entry.line > 0));
entry.phase === "syntax" && entry.line > 0
));
}); });
test("unknown authoring names are lowering errors", () => { test("unknown authoring names are lowering errors", () => {
const result = compileCapabilityFixture({ const result = compileCapabilityFixture({
workspace: capabilityFixtureSource.replace( workspace: capabilityFixtureSource.replace("to state ProjectTitle.read", "to state NotAState.read"),
"to state ProjectTitle.read",
"to state NotAState.read",
),
}); });
assert.equal(result.ok, false); assert.equal(result.ok, false);
if (result.ok) return; if (result.ok) return;
assert.ok(result.diagnostics.some((entry) => assert.ok(
entry.phase === "lowering" && result.diagnostics.some(
entry.code === "unknown-symbol" && (entry) => entry.phase === "lowering" && entry.code === "unknown-symbol" && entry.message.includes("NotAState"),
entry.message.includes("NotAState") ),
)); );
}); });
test("well-formed but invalid programs report semantic paths", () => { 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); assert.equal(result.ok, false);
if (result.ok) return; if (result.ok) return;
const diagnostic = result.diagnostics.find( const diagnostic = result.diagnostics.find((entry) => entry.code === "invalid-state-binding");
(entry) => entry.code === "invalid-state-binding",
);
assert.ok(diagnostic); assert.ok(diagnostic);
assert.equal(diagnostic.phase, "validation"); assert.equal(diagnostic.phase, "validation");
assert.ok(diagnostic.path?.includes("operationBindings")); 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); assert.equal(result.ok, true);
if (!result.ok) return; if (!result.ok) return;
const host = result.workspace.conformances.find((entry) => const host = result.workspace.conformances.find(
entry.atomId === "atom:project" && (entry) =>
entry.interfaceRevisionId === "interface:org.quixos.web-studio.has-react-component@1" entry.atomId === "atom:project" &&
entry.interfaceRevisionId === "interface:org.quixos.web-studio.has-react-component@1",
); );
assert.deepEqual(host?.relationshipMaterializations, [{ assert.deepEqual(host?.relationshipMaterializations, [
memberId: "member:org.quixos.web-studio.has-react-component:component", {
constructorAtomId: "atom:project-component", memberId: "member:org.quixos.web-studio.has-react-component:component",
edgeTypeId: "edge:project:component", constructorAtomId: "atom:project-component",
constructedProjectionId: "projection:component:subject", edgeTypeId: "edge:project:component",
}]); constructedProjectionId: "projection:component:subject",
},
]);
const component = result.workspace.conformances.find((entry) => const component = result.workspace.conformances.find((entry) => entry.atomId === "atom:project-component");
entry.atomId === "atom:project-component"
);
const binding = component?.operationBindings[0]?.binding; const binding = component?.operationBindings[0]?.binding;
assert.equal(binding?.kind, "package"); assert.equal(binding?.kind, "package");
if (binding?.kind !== "package") return; 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", () => { test("relationship materializers require a constructor from the host atom", () => {
const result = compileWebStudioFixture((source) => source.replace( const result = compileWebStudioFixture((source) =>
"constructs ProjectComponent : atom-ref<Project>;", source.replace("constructs ProjectComponent : atom-ref<Project>;", "constructs ProjectComponent : unit;"),
"constructs ProjectComponent : unit;", );
));
assert.equal(result.ok, false); assert.equal(result.ok, false);
if (result.ok) return; if (result.ok) return;
assert.ok(result.diagnostics.some((entry) => assert.ok(
entry.code === "invalid-relationship-materialization" && result.diagnostics.some(
entry.message.includes("must accept") (entry) => entry.code === "invalid-relationship-materialization" && entry.message.includes("must accept"),
)); ),
);
}); });
test("callable interface ports require a full source dependency, not a nominal reference", () => { 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"; external interface Named revision "interface:named@1";
package Runtime id "package:runtime" revision "package:runtime@1" { package Runtime id "package:runtime" revision "package:runtime@1" {
function readName id "export:runtime:read-name" : unit -> string requires { function readName id "export:runtime:read-name" : unit -> string requires {
interface named id "port:runtime:named" : Named; interface named id "port:runtime:named" : Named;
}; };
} }
`, { `,
source: { {
repository: "https://repos.quixos.org/quixos-test/package-runtime.git", source: {
commit: "6".repeat(40), repository: "https://repos.quixos.org/quixos-test/package-runtime.git",
commit: "6".repeat(40),
},
}, },
}); );
assert.equal(result.ok, false); assert.equal(result.ok, false);
if (result.ok) return; if (result.ok) return;
assert.ok(result.diagnostics.some((entry) => assert.ok(
entry.code === "nominal-interface-port" && entry.message.includes("import interface Named") result.diagnostics.some(
)); (entry) => entry.code === "nominal-interface-port" && entry.message.includes("import interface Named"),
),
);
}); });
+52 -96
View File
@@ -9,36 +9,36 @@ import {
type CapabilityValidationIssueCode, type CapabilityValidationIssueCode,
type WorkspaceRevision, type WorkspaceRevision,
} from "../src/capability-model/index.js"; } from "../src/capability-model/index.js";
import { import { fixtureId, makeValidCapabilityWorkspace } from "./fixtures/capability-model.js";
fixtureId,
makeValidCapabilityWorkspace,
} from "./fixtures/capability-model.js";
test("ordinary state rejects managed references even under list/optional wrappers", () => { test("ordinary state rejects managed references even under list/optional wrappers", () => {
const workspace = makeValidCapabilityWorkspace(); 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"); 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"))); assert.ok(validateWorkspaceRevision(workspace).some((entry) => entry.message.includes("graph relationships")));
}); });
const expectIssue = ( const expectIssue = (workspace: WorkspaceRevision, code: CapabilityValidationIssueCode) => {
workspace: WorkspaceRevision,
code: CapabilityValidationIssueCode,
) => {
const issues = validateWorkspaceRevision(workspace); const issues = validateWorkspaceRevision(workspace);
assert.ok( assert.ok(
issues.some((entry) => entry.code === code), issues.some((entry) => entry.code === code),
`Expected ${code}, got:\n${issues `Expected ${code}, got:\n${issues.map((entry) => `${entry.code} ${entry.path}: ${entry.message}`).join("\n")}`,
.map((entry) => `${entry.code} ${entry.path}: ${entry.message}`)
.join("\n")}`,
); );
assert.equal(compileWorkspaceRevision(workspace).ok, false); assert.equal(compileWorkspaceRevision(workspace).ok, false);
}; };
test("constructor dependency input contracts match the selected constructor", () => { test("constructor dependency input contracts match the selected constructor", () => {
const workspace = makeValidCapabilityWorkspace(); 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")!; .find((port) => port.requirement.kind === "constructor")!;
assert.equal(port.requirement.kind, "constructor"); assert.equal(port.requirement.kind, "constructor");
if (port.requirement.kind !== "constructor") return; if (port.requirement.kind !== "constructor") return;
@@ -52,14 +52,10 @@ const conformance = (
workspace: WorkspaceRevision, workspace: WorkspaceRevision,
identity: Pick<(typeof workspace.conformances)[number], "atomId" | "interfaceRevisionId">, identity: Pick<(typeof workspace.conformances)[number], "atomId" | "interfaceRevisionId">,
) => { ) => {
const result = workspace.conformances.find((entry) => const result = workspace.conformances.find(
entry.atomId === identity.atomId && (entry) => entry.atomId === identity.atomId && entry.interfaceRevisionId === identity.interfaceRevisionId,
entry.interfaceRevisionId === identity.interfaceRevisionId
);
assert.ok(
result,
`Missing fixture conformance ${identity.atomId} as ${identity.interfaceRevisionId}`,
); );
assert.ok(result, `Missing fixture conformance ${identity.atomId} as ${identity.interfaceRevisionId}`);
return result; return result;
}; };
@@ -70,12 +66,7 @@ test("the representative v1 workspace compiles to native and package plans", ()
assert.equal(compiled.ok, true); assert.equal(compiled.ok, true);
if (!compiled.ok) return; if (!compiled.ok) return;
const title = resolveOperationPlan( const title = resolveOperationPlan(compiled.plan, fixtureId.project, fixtureId.namedV1, fixtureId.namedGet);
compiled.plan,
fixtureId.project,
fixtureId.namedV1,
fixtureId.namedGet,
);
assert.equal(title?.kind, "state"); assert.equal(title?.kind, "state");
if (title?.kind === "state") { if (title?.kind === "state") {
assert.equal(title.binding.slotId, fixtureId.projectTitle); 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" }); assert.deepEqual(title.attachment.owner, { kind: "workspace" });
} }
const owner = resolveOperationPlan( const owner = resolveOperationPlan(compiled.plan, fixtureId.project, fixtureId.ownedV1, fixtureId.ownerResolve);
compiled.plan,
fixtureId.project,
fixtureId.ownedV1,
fixtureId.ownerResolve,
);
assert.equal(owner?.kind, "edge"); assert.equal(owner?.kind, "edge");
if (owner?.kind === "edge") { if (owner?.kind === "edge") {
assert.equal(owner.binding.edgeTypeId, fixtureId.projectOwner); 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( const summary = resolveOperationPlan(compiled.plan, fixtureId.project, fixtureId.summaryV1, fixtureId.summaryGet);
compiled.plan,
fixtureId.project,
fixtureId.summaryV1,
fixtureId.summaryGet,
);
assert.equal(summary?.kind, "package"); assert.equal(summary?.kind, "package");
if (summary?.kind === "package") { if (summary?.kind === "package") {
assert.equal(summary.packageRevision.revisionId, fixtureId.todoRuntimeV1); 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, [ const closure = computeCapabilityClosure(result.plan, [
{ atomId: fixtureId.project, interfaceRevisionId: fixtureId.summaryV1 }, { atomId: fixtureId.project, interfaceRevisionId: fixtureId.summaryV1 },
]); ]);
assert.deepEqual(closure.conformances, [ assert.deepEqual(closure.conformances, [fixtureId.projectNamedConformance, fixtureId.projectSummaryConformance]);
fixtureId.projectNamedConformance,
fixtureId.projectSummaryConformance,
]);
assert.deepEqual(closure.packageRevisionIds, [fixtureId.todoRuntimeV1]); assert.deepEqual(closure.packageRevisionIds, [fixtureId.todoRuntimeV1]);
assert.deepEqual(closure.attachmentIds, [fixtureId.projectTitle]); assert.deepEqual(closure.attachmentIds, [fixtureId.projectTitle]);
assert.deepEqual(closure.constructorAtomIds, [fixtureId.person]); assert.deepEqual(closure.constructorAtomIds, [fixtureId.person]);
@@ -140,18 +118,12 @@ test("compiled plans are snapshots, not mutable authoring state", () => {
const result = compileWorkspaceRevision(workspace); const result = compileWorkspaceRevision(workspace);
assert.equal(result.ok, true); assert.equal(result.ok, true);
if (!result.ok) return; if (!result.ok) return;
conformance(workspace, fixtureId.projectNamedConformance) conformance(workspace, fixtureId.projectNamedConformance).operationBindings[0]!.binding = {
.operationBindings[0]!.binding = { kind: "state",
kind: "state", slotId: fixtureId.personName,
slotId: fixtureId.personName, primitive: "read",
primitive: "read", };
}; const resolved = resolveOperationPlan(result.plan, fixtureId.project, fixtureId.namedV1, fixtureId.namedGet);
const resolved = resolveOperationPlan(
result.plan,
fixtureId.project,
fixtureId.namedV1,
fixtureId.namedGet,
);
assert.equal(resolved?.kind, "state"); assert.equal(resolved?.kind, "state");
if (resolved?.kind === "state") { if (resolved?.kind === "state") {
assert.equal(resolved.binding.slotId, fixtureId.projectTitle); assert.equal(resolved.binding.slotId, fixtureId.projectTitle);
@@ -164,20 +136,14 @@ test("a conformance binds every operation exactly once", () => {
expectIssue(missing, "missing-operation-binding"); expectIssue(missing, "missing-operation-binding");
const duplicate = makeValidCapabilityWorkspace(); const duplicate = makeValidCapabilityWorkspace();
const bindings = conformance( const bindings = conformance(duplicate, fixtureId.projectNamedConformance).operationBindings;
duplicate,
fixtureId.projectNamedConformance,
).operationBindings;
bindings.push(structuredClone(bindings[0]!)); bindings.push(structuredClone(bindings[0]!));
expectIssue(duplicate, "duplicate-operation-binding"); expectIssue(duplicate, "duplicate-operation-binding");
}); });
test("private attachments are visible only to their owning conformance", () => { test("private attachments are visible only to their owning conformance", () => {
const workspace = makeValidCapabilityWorkspace(); const workspace = makeValidCapabilityWorkspace();
const binding = conformance( const binding = conformance(workspace, fixtureId.projectNamedConformance).operationBindings[0]!.binding;
workspace,
fixtureId.projectNamedConformance,
).operationBindings[0]!.binding;
assert.equal(binding.kind, "state"); assert.equal(binding.kind, "state");
if (binding.kind !== "state") return; if (binding.kind !== "state") return;
binding.slotId = fixtureId.personName; 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", () => { test("related-object dependency views cannot traverse another conformance's private edge", () => {
const workspace = makeValidCapabilityWorkspace(); const workspace = makeValidCapabilityWorkspace();
const packageBinding = conformance( const packageBinding = conformance(workspace, fixtureId.projectSummaryConformance).operationBindings[0]!.binding;
workspace,
fixtureId.projectSummaryConformance,
).operationBindings[0]!.binding;
assert.equal(packageBinding.kind, "package"); assert.equal(packageBinding.kind, "package");
if (packageBinding.kind !== "package") return; if (packageBinding.kind !== "package") return;
const named = packageBinding.dependencies.find( const named = packageBinding.dependencies.find((dependency) => dependency.portId === fixtureId.namedPort);
(dependency) => dependency.portId === fixtureId.namedPort,
);
assert.ok(named && named.binding.kind === "interface"); assert.ok(named && named.binding.kind === "interface");
named.binding.via = { named.binding.via = {
edgeTypeId: fixtureId.projectOwner, 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); const edge = owner.privateAttachments.find((entry) => entry.kind === "edge" && entry.id === fixtureId.projectOwner);
assert.ok(edge?.kind === "edge"); assert.ok(edge?.kind === "edge");
edge.endpoints.find((endpoint) => endpoint.projectionId === fixtureId.projectOwnerProjection)!.publicTraversal = true; 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", () => { test("public traversal permits a native inverse read without exporting mutation authority", () => {
const workspace = makeValidCapabilityWorkspace(); const workspace = makeValidCapabilityWorkspace();
const owned = conformance(workspace, fixtureId.projectOwnedConformance); 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]; const edge = owned.privateAttachments.splice(index, 1)[0];
assert.ok(edge?.kind === "edge"); assert.ok(edge?.kind === "edge");
conformance(workspace, fixtureId.projectNamedConformance).privateAttachments.push(edge); conformance(workspace, fixtureId.projectNamedConformance).privateAttachments.push(edge);
expectIssue(workspace, "private-attachment-access"); 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]`; 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; const binding = owned.operationBindings[0].binding;
assert.ok(binding.kind === "edge"); assert.ok(binding.kind === "edge");
binding.primitive = "connect"; 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", () => { test("native state and edge providers must match operation shape", () => {
const stateWorkspace = makeValidCapabilityWorkspace(); const stateWorkspace = makeValidCapabilityWorkspace();
const state = conformance( const state = conformance(stateWorkspace, fixtureId.projectNamedConformance).operationBindings[0]!.binding;
stateWorkspace,
fixtureId.projectNamedConformance,
).operationBindings[0]!.binding;
assert.equal(state.kind, "state"); assert.equal(state.kind, "state");
if (state.kind === "state") state.primitive = "write"; if (state.kind === "state") state.primitive = "write";
expectIssue(stateWorkspace, "invalid-state-binding"); expectIssue(stateWorkspace, "invalid-state-binding");
const edgeWorkspace = makeValidCapabilityWorkspace(); const edgeWorkspace = makeValidCapabilityWorkspace();
const edge = conformance( const edge = conformance(edgeWorkspace, fixtureId.projectOwnedConformance).operationBindings[0]!.binding;
edgeWorkspace,
fixtureId.projectOwnedConformance,
).operationBindings[0]!.binding;
assert.equal(edge.kind, "edge"); assert.equal(edge.kind, "edge");
if (edge.kind === "edge") edge.primitive = "connect"; if (edge.kind === "edge") edge.primitive = "connect";
expectIssue(edgeWorkspace, "invalid-edge-binding"); 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", () => { test("package dependencies are complete, exact, and explicitly injected", () => {
const missing = makeValidCapabilityWorkspace(); const missing = makeValidCapabilityWorkspace();
const packageBinding = conformance( const packageBinding = conformance(missing, fixtureId.projectSummaryConformance).operationBindings[0]!.binding;
missing,
fixtureId.projectSummaryConformance,
).operationBindings[0]!.binding;
assert.equal(packageBinding.kind, "package"); assert.equal(packageBinding.kind, "package");
if (packageBinding.kind === "package") packageBinding.dependencies.pop(); if (packageBinding.kind === "package") packageBinding.dependencies.pop();
expectIssue(missing, "invalid-dependency-binding"); expectIssue(missing, "invalid-dependency-binding");
const wrongType = makeValidCapabilityWorkspace(); const wrongType = makeValidCapabilityWorkspace();
const summaryExport = wrongType.packageImports[0]!.exports.find( const summaryExport = wrongType.packageImports[0]!.exports.find((entry) => entry.id === fixtureId.summaryGetExport);
(entry) => entry.id === fixtureId.summaryGetExport,
);
assert.ok(summaryExport); assert.ok(summaryExport);
summaryExport.dependencyPorts[0]!.requirement = { summaryExport.dependencyPorts[0]!.requirement = {
kind: "state", kind: "state",
@@ -270,19 +231,14 @@ test("package dependencies are complete, exact, and explicitly injected", () =>
test("package receiver requirements cannot depend on themselves", () => { test("package receiver requirements cannot depend on themselves", () => {
const workspace = makeValidCapabilityWorkspace(); const workspace = makeValidCapabilityWorkspace();
const summaryExport = workspace.packageImports[0]!.exports.find( const summaryExport = workspace.packageImports[0]!.exports.find((entry) => entry.id === fixtureId.summaryGetExport);
(entry) => entry.id === fixtureId.summaryGetExport,
);
assert.ok(summaryExport && summaryExport.kind === "operation"); assert.ok(summaryExport && summaryExport.kind === "operation");
summaryExport.receiverRequirement = { summaryExport.receiverRequirement = {
kind: "all-interfaces", kind: "all-interfaces",
interfaceRevisionIds: [fixtureId.summaryV1], interfaceRevisionIds: [fixtureId.summaryV1],
}; };
summaryExport.dependencyPorts = []; summaryExport.dependencyPorts = [];
const packageBinding = conformance( const packageBinding = conformance(workspace, fixtureId.projectSummaryConformance).operationBindings[0]!.binding;
workspace,
fixtureId.projectSummaryConformance,
).operationBindings[0]!.binding;
assert.equal(packageBinding.kind, "package"); assert.equal(packageBinding.kind, "package");
if (packageBinding.kind === "package") packageBinding.dependencies = []; if (packageBinding.kind === "package") packageBinding.dependencies = [];
expectIssue(workspace, "cyclic-conformance-requirement"); expectIssue(workspace, "cyclic-conformance-requirement");
+56 -16
View File
@@ -1,13 +1,30 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { capabilityId as id, valueType, validateWorkspaceRevision } from "../src/capability-model/index.js"; 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 {
import { capabilityFixtureSource, capabilityResourceSources, compileCapabilityFixture, makeValidCapabilityWorkspace } from "./fixtures/capability-model.js"; 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", () => { test("QX carries stable conformance IDs and implementation semantic majors", () => {
const result = compileCapabilityFixture({ const result = compileCapabilityFixture({
workspace: capabilityFixtureSource.replace("conform Project as Named {", 'conform Project as Named id "conformance:project:named" semantic-major 3 {'), workspace: capabilityFixtureSource.replace(
todo: capabilityResourceSources.todo.replace('revision "package:todo-runtime@1" {', 'revision "package:todo-runtime@1" semantic-major 2 {'), "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.ok(result.ok, JSON.stringify(result));
assert.equal(result.workspace.conformances[0]!.id, "conformance:project:named"); 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.conformances.reverse();
after.interfaceImports.reverse(); after.interfaceImports.reverse();
const report = planEvolution(before, after, { allowLegacy: true }); 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.storageChanges, []);
assert.deepEqual(report.packageChecks, []); assert.deepEqual(report.packageChecks, []);
}); });
test("consumer changes preserve unrelated resource owners", () => { test("consumer changes preserve unrelated resource owners", () => {
const before = makeValidCapabilityWorkspace(); const before = makeValidCapabilityWorkspace();
before.packageImports.push({ packageId: id.package("package:resource-owner"), revisionId: id.packageRevision("package:resource-owner@1"), before.packageImports.push({
displayName: "ResourceOwner", source: { repository: "https://example.org/owner.git", commit: "a".repeat(40) }, packageId: id.package("package:resource-owner"),
exports: [{ kind: "function", id: id.packageExport("export:owner:ping"), displayName: "ping", inputType: valueType.unit, outputType: valueType.unit, dependencyPorts: [] }] }); 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); const after = structuredClone(before);
after.packageImports[0]!.source.commit = "e".repeat(40); after.packageImports[0]!.source.commit = "e".repeat(40);
const actions = planEvolution(before, after, { allowLegacy: true }).runtimeActions; 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 }); const report = planEvolution(before, after, { allowLegacy: true });
assert.equal(report.reviews.length, 1); assert.equal(report.reviews.length, 1);
assert.equal(report.reviews[0]!.accepted, false); assert.equal(report.reviews[0]!.accepted, false);
const receipt = { requirementDigest: report.reviews[0]!.requirementDigest, decision: "accepted-unchanged" as const, const receipt = {
rationale: "Reviewed the semantic change against summary behavior", agentId: "workspace-agent" }; 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); assert.equal(planEvolution(before, after, { allowLegacy: true, reviews: [receipt] }).reviews[0]!.accepted, true);
after.packageImports[0]!.source.commit = "f".repeat(40); after.packageImports[0]!.source.commit = "f".repeat(40);
assert.equal(planEvolution(before, after, { allowLegacy: true, reviews: [receipt] }).reviews[0]!.accepted, false); 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("workspace-shared")));
assert.ok(report.blockers.some((entry) => entry.includes("authored ID"))); assert.ok(report.blockers.some((entry) => entry.includes("authored ID")));
assert.equal(runtimeContracts(workspace).length, 1); 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", () => { test("automatic preservation distinguishes additions and defaults from incompatible storage", () => {
const workspace = makeValidCapabilityWorkspace(); 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 next = structuredClone(before);
const value = next.definition as Record<string, unknown>; const value = next.definition as Record<string, unknown>;
value.defaultValue = {displayName: "important user data"}; value.defaultValue = { displayName: "important user data" };
assert.equal(storageChangeRequiresMigration(before, next, new Set()), false); assert.equal(storageChangeRequiresMigration(before, next, new Set()), false);
next.ownerId = "another-owner"; next.ownerId = "another-owner";
assert.equal(storageChangeRequiresMigration(before, next, new Set()), true); assert.equal(storageChangeRequiresMigration(before, next, new Set()), true);
@@ -111,10 +151,10 @@ test("automatic preservation distinguishes additions and defaults from incompati
delete value.defaultValue; delete value.defaultValue;
assert.equal(storageChangeRequiresMigration(undefined, next, new Set([value.attachedTo as string])), true); assert.equal(storageChangeRequiresMigration(undefined, next, new Set([value.attachedTo as string])), true);
assert.equal(storageChangeRequiresMigration(undefined, next, new Set()), false); 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"); if (slot.kind !== "state") throw new Error("fixture slot");
slot.defaultValue = {displayName: "one"}; slot.defaultValue = { displayName: "one" };
const first = storageContracts(workspace); 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"); assert.notDeepEqual(storageContracts(workspace), first, "user data must not be stripped as schema metadata");
}); });
+21 -11
View File
@@ -1,34 +1,44 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; 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 path from "node:path";
import os from "node:os"; import os from "node:os";
import {spawn} from "node:child_process"; import { spawn } from "node:child_process";
import {once} from "node:events"; import { once } from "node:events";
import {withFileLock} from "../src/capability-language/file-lock.js"; 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-")); 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 filename = path.join(root, "lock");
const events: string[] = []; const events: string[] = [];
let queued: Promise<void>; let queued: Promise<void>;
await withFileLock(filename, async () => { await withFileLock(filename, async () => {
queued = withFileLock(filename, async () => {events.push("second");}); queued = withFileLock(filename, async () => {
events.push("second");
});
events.push("first"); events.push("first");
}); });
await queued!; await queued!;
assert.deepEqual(events, ["first", "second"]); assert.deepEqual(events, ["first", "second"]);
const module = new URL("../src/capability-language/file-lock.js", import.meta.url).href; const module = new URL("../src/capability-language/file-lock.js", import.meta.url).href;
const owner = spawn(process.execPath, ["--input-type=module", "-e", const owner = spawn(
`import {withFileLock} from ${JSON.stringify(module)}; await withFileLock(${JSON.stringify(filename)}, async () => {process.stdout.write('ready'); await new Promise(() => {});});`], process.execPath,
{stdio: ["ignore", "pipe", "pipe"]}); [
"--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")); context.after(() => owner.kill("SIGKILL"));
await once(owner.stdout, "data"); await once(owner.stdout, "data");
const exited = once(owner, "exit"); const exited = once(owner, "exit");
owner.kill("SIGKILL"); owner.kill("SIGKILL");
await exited; await exited;
let acquired = false; let acquired = false;
await withFileLock(filename, async () => {acquired = true;}); await withFileLock(filename, async () => {
acquired = true;
});
assert.equal(acquired, true); assert.equal(acquired, true);
}); });
+48 -105
View File
@@ -1,18 +1,12 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { import { capabilityId, type WorkspaceRevision } from "../../src/capability-model/index.js";
capabilityId,
type WorkspaceRevision,
} from "../../src/capability-model/index.js";
import { import {
compileCapabilityResourceSource, compileCapabilityResourceSource,
compileCapabilitySource, compileCapabilitySource,
type CapabilityImportEnvironment, type CapabilityImportEnvironment,
} from "../../src/capability-language/index.js"; } from "../../src/capability-language/index.js";
import type { import type { InterfaceRevision, PackageRevision } from "../../src/capability-model/index.js";
InterfaceRevision,
PackageRevision,
} from "../../src/capability-model/index.js";
export const fixtureId = { export const fixtureId = {
workspace: capabilityId.workspace("workspace:todo"), workspace: capabilityId.workspace("workspace:todo"),
@@ -29,9 +23,7 @@ export const fixtureId = {
projectTitle: capabilityId.slot("slot:project:title"), projectTitle: capabilityId.slot("slot:project:title"),
personName: capabilityId.slot("slot:person:name"), personName: capabilityId.slot("slot:person:name"),
projectOwner: capabilityId.edgeType("edge:project:owner"), projectOwner: capabilityId.edgeType("edge:project:owner"),
projectOwnerProjection: capabilityId.edgeProjection( projectOwnerProjection: capabilityId.edgeProjection("projection:project-owner:owner"),
"projection:project-owner:owner",
),
projectNamedConformance: { projectNamedConformance: {
atomId: capabilityId.atom("atom:project"), atomId: capabilityId.atom("atom:project"),
interfaceRevisionId: capabilityId.interfaceRevision("interface:named@1"), interfaceRevisionId: capabilityId.interfaceRevision("interface:named@1"),
@@ -49,31 +41,18 @@ export const fixtureId = {
interfaceRevisionId: capabilityId.interfaceRevision("interface:summary@1"), interfaceRevisionId: capabilityId.interfaceRevision("interface:summary@1"),
}, },
todoRuntimeV1: capabilityId.packageRevision("package:todo-runtime@1"), todoRuntimeV1: capabilityId.packageRevision("package:todo-runtime@1"),
summaryGetExport: capabilityId.packageExport( summaryGetExport: capabilityId.packageExport("export:todo-runtime:summary-get"),
"export:todo-runtime:summary-get", createPersonExport: capabilityId.packageExport("export:todo-runtime:create-person"),
),
createPersonExport: capabilityId.packageExport(
"export:todo-runtime:create-person",
),
titlePort: capabilityId.dependencyPort("port:summary:title"), titlePort: capabilityId.dependencyPort("port:summary:title"),
namedPort: capabilityId.dependencyPort("port:summary:named"), namedPort: capabilityId.dependencyPort("port:summary:named"),
personPort: capabilityId.dependencyPort("port:summary:person"), personPort: capabilityId.dependencyPort("port:summary:person"),
} as const; } as const;
export const capabilityFixturePath = resolve( export const capabilityFixturePath = resolve(process.cwd(), "test/fixtures/todo.capabilities.qx");
process.cwd(),
"test/fixtures/todo.capabilities.qx",
);
export const capabilityFixtureSource = readFileSync( export const capabilityFixtureSource = readFileSync(capabilityFixturePath, "utf8");
capabilityFixturePath,
"utf8",
);
const resourceSource = (name: string) => readFileSync( const resourceSource = (name: string) => readFileSync(resolve(process.cwd(), "test/fixtures", name), "utf8");
resolve(process.cwd(), "test/fixtures", name),
"utf8",
);
export const capabilityResourceSources = { export const capabilityResourceSources = {
named: resourceSource("named.interface.qx"), named: resourceSource("named.interface.qx"),
@@ -85,110 +64,74 @@ export const capabilityResourceSources = {
const source = (repository: string, commit: string) => ({ repository, commit }); const source = (repository: string, commit: string) => ({ repository, commit });
const interfaceRevision = ( const interfaceRevision = (
resource: { kind: "interface"; revision: InterfaceRevision } | resource: { kind: "interface"; revision: InterfaceRevision } | { kind: "package"; revision: PackageRevision },
{ kind: "package"; revision: PackageRevision },
) => { ) => {
if (resource.kind !== "interface") throw new Error("Expected interface fixture"); if (resource.kind !== "interface") throw new Error("Expected interface fixture");
return resource.revision; return resource.revision;
}; };
const packageRevision = ( const packageRevision = (
resource: { kind: "interface"; revision: InterfaceRevision } | resource: { kind: "interface"; revision: InterfaceRevision } | { kind: "package"; revision: PackageRevision },
{ kind: "package"; revision: PackageRevision },
) => { ) => {
if (resource.kind !== "package") throw new Error("Expected package fixture"); if (resource.kind !== "package") throw new Error("Expected package fixture");
return resource.revision; return resource.revision;
}; };
export const compileCapabilityFixture = (overrides: Partial<{ export const compileCapabilityFixture = (
workspace: string; overrides: Partial<{
named: string; workspace: string;
owned: string; named: string;
summary: string; owned: string;
todo: string; summary: string;
}> = {}) => { todo: string;
const named = compileCapabilityResourceSource( }> = {},
overrides.named ?? capabilityResourceSources.named, ) => {
{ const named = compileCapabilityResourceSource(overrides.named ?? capabilityResourceSources.named, {
source: source( source: source("https://repos.quixos.org/quixos-todo/interface-named.git", "2".repeat(40)),
"https://repos.quixos.org/quixos-todo/interface-named.git", fileName: "named.interface.qx",
"2".repeat(40), });
),
fileName: "named.interface.qx",
},
);
if (!named.ok) return named; if (!named.ok) return named;
const namedRevision = interfaceRevision(named.resource); const namedRevision = interfaceRevision(named.resource);
const namedEnvironment: CapabilityImportEnvironment = { const namedEnvironment: CapabilityImportEnvironment = {
interfaces: new Map([["Named", namedRevision]]), interfaces: new Map([["Named", namedRevision]]),
interfaceClosure: [namedRevision], interfaceClosure: [namedRevision],
}; };
const owned = compileCapabilityResourceSource( const owned = compileCapabilityResourceSource(overrides.owned ?? capabilityResourceSources.owned, {
overrides.owned ?? capabilityResourceSources.owned, source: source("https://repos.quixos.org/quixos-todo/interface-owned.git", "3".repeat(40)),
{ fileName: "owned.interface.qx",
source: source( environment: namedEnvironment,
"https://repos.quixos.org/quixos-todo/interface-owned.git", });
"3".repeat(40),
),
fileName: "owned.interface.qx",
environment: namedEnvironment,
},
);
if (!owned.ok) return owned; if (!owned.ok) return owned;
const ownedRevision = interfaceRevision(owned.resource); const ownedRevision = interfaceRevision(owned.resource);
const summary = compileCapabilityResourceSource( const summary = compileCapabilityResourceSource(overrides.summary ?? capabilityResourceSources.summary, {
overrides.summary ?? capabilityResourceSources.summary, source: source("https://repos.quixos.org/quixos-todo/interface-summary.git", "4".repeat(40)),
{ fileName: "summary.interface.qx",
source: source( });
"https://repos.quixos.org/quixos-todo/interface-summary.git",
"4".repeat(40),
),
fileName: "summary.interface.qx",
},
);
if (!summary.ok) return summary; if (!summary.ok) return summary;
const summaryRevision = interfaceRevision(summary.resource); const summaryRevision = interfaceRevision(summary.resource);
const todo = compileCapabilityResourceSource( const todo = compileCapabilityResourceSource(overrides.todo ?? capabilityResourceSources.todo, {
overrides.todo ?? capabilityResourceSources.todo, source: source("https://repos.quixos.org/quixos-todo/package-todo-runtime.git", "5".repeat(40)),
{ fileName: "todo.package.qx",
source: source( environment: namedEnvironment,
"https://repos.quixos.org/quixos-todo/package-todo-runtime.git", });
"5".repeat(40),
),
fileName: "todo.package.qx",
environment: namedEnvironment,
},
);
if (!todo.ok) return todo; if (!todo.ok) return todo;
const todoRevision = packageRevision(todo.resource); const todoRevision = packageRevision(todo.resource);
return compileCapabilitySource( return compileCapabilitySource(overrides.workspace ?? capabilityFixtureSource, capabilityFixturePath, {
overrides.workspace ?? capabilityFixtureSource, interfaces: new Map([
capabilityFixturePath, ["Named", namedRevision],
{ ["Owned", ownedRevision],
interfaces: new Map([ ["Summary", summaryRevision],
["Named", namedRevision], ]),
["Owned", ownedRevision], packages: new Map([["TodoRuntime", todoRevision]]),
["Summary", summaryRevision], interfaceClosure: [namedRevision, ownedRevision, summaryRevision],
]), packageClosure: [todoRevision],
packages: new Map([["TodoRuntime", todoRevision]]), });
interfaceClosure: [
namedRevision,
ownedRevision,
summaryRevision,
],
packageClosure: [todoRevision],
},
);
}; };
export const makeValidCapabilityWorkspace = (): WorkspaceRevision => { export const makeValidCapabilityWorkspace = (): WorkspaceRevision => {
const result = compileCapabilityFixture(); const result = compileCapabilityFixture();
if (!result.ok) { if (!result.ok) {
throw new Error( throw new Error(result.diagnostics.map((entry) => `${entry.phase}/${entry.code}: ${entry.message}`).join("\n"));
result.diagnostics
.map((entry) => `${entry.phase}/${entry.code}: ${entry.message}`)
.join("\n"),
);
} }
return structuredClone(result.workspace); return structuredClone(result.workspace);
}; };
+2 -2
View File
@@ -8,7 +8,7 @@ workspace Todo id "workspace:todo" revision "workspace:todo@1" commit "111111111
import package TodoRuntime; import package TodoRuntime;
shared state ProjectTitle id "slot:project:title" on Project : string 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 { conform Project as Named {
bind name.get to state ProjectTitle.read; 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 { conform Person as Named {
private state PersonName id "slot:person:name" on Person : string 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.get to state PersonName.read;
bind name.set to state PersonName.write; bind name.set to state PersonName.write;
bind name.watch-start to state PersonName.watch-start; bind name.watch-start to state PersonName.watch-start;
+5 -5
View File
@@ -3,10 +3,10 @@ external atom Person id "atom:person";
package TodoRuntime id "package:todo-runtime" revision "package:todo-runtime@1" { package TodoRuntime id "package:todo-runtime" revision "package:todo-runtime@1" {
operation summaryGet id "export:todo-runtime:summary-get" : unit -> string operation summaryGet id "export:todo-runtime:summary-get" : unit -> string
mode call receiver interfaces [Named] requires { mode call receiver interfaces [Named] requires {
state title id "port:summary:title" : string [read]; state title id "port:summary:title" : string [read];
interface named id "port:summary:named" : Named; interface named id "port:summary:named" : Named;
constructor person id "port:summary:person" : Person; constructor person id "port:summary:person" : Person;
}; };
constructor createPerson id "export:todo-runtime:create-person" constructs Person : unit; constructor createPerson id "export:todo-runtime:create-person" constructs Person : unit;
} }
+1 -1
View File
@@ -8,7 +8,7 @@ workspace WebStudioFixture id "workspace:web-studio-fixture" revision "workspace
import package ComponentRuntime; import package ComponentRuntime;
shared state ProjectName id "slot:project:name" on Project : string 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" { shared edge ProjectComponentEdge id "edge:project:component" {
atom ProjectComponent projection subject id "projection:component:subject" exactly-one; atom ProjectComponent projection subject id "projection:component:subject" exactly-one;
atom Project projection component id "projection:project:component" optional-one; atom Project projection component id "projection:project:component" optional-one;
+199
View File
@@ -0,0 +1,199 @@
import assert from "node:assert/strict";
import test from "node:test";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createRequire } from "node:module";
import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/parser.js";
import { generateTypeScriptBindings, generatePackageDescriptor } from "../src/bindings/index.js";
import { genericImplementationType } from "../src/bindings/generics.js";
import { compileWorkspaceRevision, runtimeContracts } from "../src/capability-model/index.js";
import { generateAppliedClientContracts } from "../src/bindings/client.js";
const source = { repository: "https://example.test/generic.git", commit: "a".repeat(40) };
test("generic port aliases retain their defining scope, including shadowed alias names", () => {
const iface = compileCapabilityResourceSource(
'type Box<value V> = list<V>; interface Data<value V> id "data" revision "data@1" {value payload id "payload" : Box<V> {get id "payload:get";}}',
{ source },
);
assert.ok(iface.ok, JSON.stringify(iface.diagnostics));
if (iface.resource.kind !== "interface") throw new Error("expected interface");
const pkg = compileCapabilityResourceSource(
'import interface Data; type Box<value V> = optional<V>; package P id "p" revision "p@1" {operation fetch<value V> id "data@1" : unit -> list<Box<V>> mode call receiver any requires {interface data id "data" : Data<Box<V>>;};}',
{ source, environment: { interfaces: new Map([["Data", iface.resource.revision]]) } },
);
assert.ok(pkg.ok, JSON.stringify(pkg.diagnostics));
if (pkg.resource.kind !== "package") throw new Error("expected package");
const generated = generateTypeScriptBindings(
{
format: "quixos-bindings",
version: 1,
interfaces: [],
interfaceTemplates: [iface.resource.revision],
packages: [pkg.resource.revision],
},
pkg.resource.revision.revisionId,
);
assert.match(generated, /"payload.get":\(\)=>Promise<Array<\(T0 \| null\)>>/);
});
const definition = () => {
const result = compileCapabilityResourceSource(
`package Generic id "generic" revision "generic@1" {
operation echo<value T> id "echo" : T -> T mode call receiver any;
}`,
{ source },
);
assert.ok(result.ok, JSON.stringify(result.diagnostics));
if (result.resource.kind !== "package") throw new Error("expected package");
return result.resource.revision;
};
test("generic package exports specialize per binding without changing immutable source identity", () => {
const iface = compileCapabilityResourceSource(
`interface Echo<value T> id "echo-interface" revision "echo-interface@1" {
operation echo id "echo-member" : T -> T {call id "echo-call";}
}`,
{ source },
);
assert.ok(iface.ok);
if (iface.resource.kind !== "interface") throw new Error("expected interface");
const pkg = definition();
const result = compileCapabilitySource(
`workspace W id "w" revision "w@1" commit "${source.commit}" {
import interface Echo; import package Generic; atom Thing id "thing";
conform Thing as Echo<string> id "string-echo" {bind echo.call to package Generic.echo<string>;}
conform Thing as Echo<list<int64>> id "list-echo" {bind echo.call to package Generic.echo<list<int64>>;}
}`,
"workspace.qx",
{ interfaces: new Map([["Echo", iface.resource.revision]]), packages: new Map([["Generic", pkg]]) },
);
assert.ok(result.ok, JSON.stringify(result.diagnostics));
assert.equal(pkg.exports.length, 0, "source definition was not mutated");
const closed = result.workspace.packageImports[0];
assert.equal(closed.revisionId, pkg.revisionId);
assert.equal(closed.exports.length, 2);
assert.notEqual(closed.exports[0].id, closed.exports[1].id);
const oneApplication = structuredClone(result.workspace);
oneApplication.packageImports[0].exports.splice(1);
oneApplication.conformances.splice(1);
assert.notDeepEqual(
runtimeContracts(oneApplication),
runtimeContracts(result.workspace),
"New specializations invalidate the executable contract even with unchanged package source",
);
const schema = {
format: "quixos-bindings" as const,
version: 1 as const,
interfaces: result.workspace.interfaceImports,
packages: [closed],
};
const generated = generateTypeScriptBindings(schema, pkg.revisionId);
assert.match(generated, /"echo":\s*\(<T0>/);
for (const entry of closed.exports) {
assert.ok(generated.includes(entry.id));
assert.ok(generatePackageDescriptor(schema, pkg.revisionId).includes(entry.id));
}
const forged = structuredClone(result.workspace);
forged.packageImports[0].exports[0].outputType = { kind: "scalar", name: "bool" };
const rejected = compileWorkspaceRevision(forged);
assert.equal(rejected.ok, false);
if (!rejected.ok) assert.ok(rejected.issues.some((issue) => issue.message.includes("Specialized export differs")));
});
test("generated universal implementations compile and cannot assume a concrete value type", async () => {
const pkg = definition();
const signature = genericImplementationType(pkg.genericExports![0], [], () => {
throw new Error("unexpected concrete type");
});
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "qx-generic-ts-"));
const require = createRequire(import.meta.url);
const compiler = path.join(path.dirname(require.resolve("typescript/package.json")), "bin/tsc");
const run = promisify(execFile);
const preamble = `type QxObjectRef<T extends string>={readonly identity:T}; type QxContextLifecycle<C>={signal?:AbortSignal}; type Handler=${signature};\n`;
try {
const file = path.join(directory, "generic.ts");
await fs.writeFile(file, preamble + `const handler:Handler=({input})=>input;`);
await run(process.execPath, [compiler, "--ignoreConfig", "--noEmit", "--strict", "--target", "es2023", file]);
await fs.writeFile(file, preamble + `const handler:Handler=({input})=>"not universally T";`);
await assert.rejects(
run(process.execPath, [compiler, "--ignoreConfig", "--noEmit", "--strict", "--target", "es2023", file]),
(error) => {
assert.match(String((error as { stdout: string }).stdout), /not assignable/);
return true;
},
);
} finally {
await fs.rm(directory, { recursive: true, force: true });
}
});
test("generic Self follows the receiver parameter, never a preceding export", () => {
const compile = (declaration: string) =>
compileCapabilityResourceSource(`package P id "p" revision "p@1" {${declaration}}`, { source });
const valid = compile('operation identity<object T> id "identity" : unit -> ref<Self> mode call receiver object T;');
assert.ok(valid.ok, JSON.stringify(valid.diagnostics));
if (valid.resource.kind !== "package") throw new Error("expected package");
const definition = valid.resource.revision.genericExports![0];
assert.deepEqual(definition.outputType, {
kind: "object-ref",
expectation: { kind: "parameter", parameterId: definition.parameters[0].id },
});
assert.equal(compile('function identity<value T> id "identity" : T -> ref<Self>;').ok, false);
assert.equal(
compile('operation events<value T> id "events" : unit -> watch-handle mode watch-start receiver any;').ok,
false,
);
});
test("typed presentation and factory consumers preserve distinct closed applications and return targets", async () => {
const interfaces = new Map();
for (const text of [
'interface Presentation<value Props> id "presentation" revision "presentation@1" {operation props id "props" : unit -> Props {call id "props:get";}}',
'interface Factory<object Result> id "factory" revision "factory@1" {operation create id "create" : unit -> ref<Result> {call id "create:call";}}',
]) {
const result = compileCapabilityResourceSource(text, { source });
assert.ok(result.ok, JSON.stringify(result.diagnostics));
interfaces.set(result.resource.revision.displayName, result.resource.revision);
}
const pkg = compileCapabilityResourceSource(
`import interface Presentation; import interface Factory;
external atom Note id "note"; external atom Notebook id "notebook";
package Views id "views" revision "views@1" {
operation note id "note-view" : unit -> unit mode call receiver any requires {interface props id "props" : Presentation<record {title:string; note:atom-ref<Note>;}>;};
operation notebook id "notebook-view" : unit -> unit mode call receiver any requires {interface props id "props" : Presentation<record {count:int32;}>;};
operation factory id "factory-view" : unit -> unit mode call receiver any requires {interface factory id "factory" : Factory<atom Note>;};
}`,
{ source, environment: { interfaces } },
);
assert.ok(pkg.ok, JSON.stringify(pkg.diagnostics));
const closed = pkg.resource.specializations!;
const presentation = closed.find(
(contract) =>
contract.application?.definitionId === "presentation@1" && JSON.stringify(contract).includes('"title"'),
)!;
const factory = closed.find((contract) => contract.application?.definitionId === "factory@1")!;
const contracts = generateAppliedClientContracts(closed);
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "qx-presentation-types-"));
const compiler = path.join(
path.dirname(createRequire(import.meta.url).resolve("typescript/package.json")),
"bin/tsc",
);
const run = promisify(execFile);
const usage = `type Props=CapabilityOutput<${JSON.stringify(presentation.revisionId)},"props:get">;
type Created=CapabilityOutput<${JSON.stringify(factory.revisionId)},"create:call">;
const render=({camino,render}:{camino:Props;render:{onSelect:(note:Created)=>void;compact:boolean}})=>{render.onSelect(camino.note);return camino.title;};\n`;
try {
const file = path.join(directory, "consumer.ts");
await fs.writeFile(file, contracts + usage);
await run(process.execPath, [compiler, "--ignoreConfig", "--noEmit", "--strict", "--target", "es2023", file]);
await fs.writeFile(file, contracts + usage + "const wrong=(props:Props)=>props.count;");
await assert.rejects(
run(process.execPath, [compiler, "--ignoreConfig", "--noEmit", "--strict", "--target", "es2023", file]),
(error) => {
assert.match(String((error as { stdout: string }).stdout), /count.*does not exist/);
return true;
},
);
} finally {
await fs.rm(directory, { recursive: true, force: true });
}
});
+601
View File
@@ -0,0 +1,601 @@
import assert from "node:assert/strict";
import test from "node:test";
import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/parser.js";
import { generateTypeScriptBindings } from "../src/bindings/index.js";
import { generateClientContracts } from "../src/bindings/client.js";
import {
TypeSubstitution,
appliedInterfaceId,
bindTypeParameters,
canonicalTypeArgument,
instantiateInterface,
capabilityId,
isStorableType,
valueType,
computeCapabilityClosure,
compileWorkspaceRevision,
type ClosedTypeArgument,
type GenericTypeEnvironment,
type ValueTypeExpression,
} from "../src/capability-model/index.js";
test("unused generic definitions prove symbolic bounds and storable aliases", () => {
const source = { repository: "https://example.test/bounds.git", commit: "a".repeat(40) };
const interfaces = new Map();
for (const text of [
'interface Named id "named" revision "named@1" {}',
'import interface Named; interface Detailed id "detailed" revision "detailed@1" requires Named {}',
'import interface Named; interface Requires<object T implements Named> id "requires" revision "requires@1" {}',
'interface Stored<value V : storable> id "stored" revision "stored@1" {}',
]) {
const result = compileCapabilityResourceSource(text, { source, environment: { interfaces } });
assert.ok(result.ok, JSON.stringify(result.diagnostics));
assert.equal(result.resource.kind, "interface");
interfaces.set(result.resource.revision.displayName, result.resource.revision);
}
const compile = (body: string) => compileCapabilityResourceSource(body, { source, environment: { interfaces } });
const bad = compile(
'import interface Requires; interface Bad<object T> id "bad" revision "bad@1" requires Requires<T> {}',
);
assert.equal(bad.ok, false);
assert.match(JSON.stringify(bad.diagnostics), /does not prove/);
const good = compile(
'import interface Detailed; import interface Requires; interface Good<object T implements Detailed> id "good" revision "good@1" requires Requires<T> {}',
);
assert.ok(good.ok, JSON.stringify(good.diagnostics));
const alias = compile(
'import interface Stored; type Values<value V : storable> = list<optional<V>>; interface Good<value T : storable> id "good" revision "good@1" requires Stored<Values<T>> {}',
);
assert.ok(alias.ok, JSON.stringify(alias.diagnostics));
const nonstorable = compile(
'import interface Stored; interface Bad<value T> id "bad" revision "bad@1" requires Stored<T> {}',
);
assert.equal(nonstorable.ok, false);
});
const note = capabilityId.atom("atom:note");
test("Self remains contextual through local aliases", () => {
const result = compileCapabilityResourceSource(
'type Me = ref<Self>; type Mine = optional<Me>; interface Identity id "identity" revision "identity@1" {value mine id "mine" : Mine {get id "mine:get";}}',
{ source: { repository: "https://example.test/self.git", commit: "a".repeat(40) } },
);
assert.ok(result.ok, JSON.stringify(result.diagnostics));
if (result.resource.kind !== "interface") throw new Error("expected interface");
assert.equal(result.resource.revision.template?.usesSelf, true);
const closed = instantiateInterface(result.resource.revision, [], { ...environment(), self: note });
assert.deepEqual(closed.members[0].operations[0].outputType, valueType.optional(valueType.atomRef(note)));
});
const named = capabilityId.interfaceRevision("interface:named@1");
const environment = (arguments_: [string, ClosedTypeArgument][] = []): GenericTypeEnvironment => ({
arguments: new Map(arguments_),
applyInterface: (id, args) => {
assert.equal(args.length, 0);
return id;
},
implementsInterface: (target, required) => target.kind === "atom" && target.atomId === note && required === named,
});
test("substitution reaches nested records, lists, optionals and object references", () => {
const substitute = new TypeSubstitution(
environment([
["scope/value", { kind: "value", type: valueType.int64 }],
["scope/object", { kind: "object", target: { kind: "atom", atomId: note } }],
]),
);
const result = substitute.value({
kind: "record",
fields: {
values: { kind: "list", value: { kind: "optional", value: { kind: "parameter", parameterId: "scope/value" } } },
target: { kind: "object-ref", expectation: { kind: "parameter", parameterId: "scope/object" } },
},
});
assert.deepEqual(result, {
kind: "record",
fields: {
values: valueType.list(valueType.optional(valueType.int64)),
target: valueType.atomRef(note),
},
});
assert.equal(isStorableType(result), false);
});
test("Self is the exact implementing atom and cannot be unbound", () => {
const expression = { kind: "object-ref", expectation: { kind: "self" } } as const;
assert.throws(() => new TypeSubstitution(environment()).value(expression), /Self requires an implementing atom/);
assert.deepEqual(new TypeSubstitution({ ...environment(), self: note }).value(expression), valueType.atomRef(note));
});
test("parameter kinds and missing parameters fail closed", () => {
const substitute = new TypeSubstitution(
environment([["T", { kind: "object", target: { kind: "atom", atomId: note } }]]),
);
assert.throws(() => substitute.value({ kind: "parameter", parameterId: "T" }), /use ref<T>/);
assert.throws(() => substitute.value({ kind: "parameter", parameterId: "unknown" }), /Unbound parameter/);
assert.throws(
() => bindTypeParameters([{ id: "T", name: "T", kind: "value" }], [], environment()),
/Expected 1 type arguments/,
);
assert.throws(
() =>
bindTypeParameters(
[{ id: "T", name: "T", kind: "value" }],
[{ kind: "object", target: { kind: "atom", atomId: note } }],
environment(),
),
/Expected value, received object/,
);
});
test("bounds require evidence and storable constraints inspect nested values", () => {
const parameters = [
{ id: "T", name: "T", kind: "object", implements: [{ definitionId: named, arguments: [] }] },
] as const;
const mutableParameters = parameters.map((p) => ({
...p,
implements: p.implements.map((b) => ({ ...b, arguments: [] })),
}));
assert.equal(
bindTypeParameters(mutableParameters, [{ kind: "object", target: { kind: "atom", atomId: note } }], environment())
.size,
1,
);
assert.throws(
() =>
bindTypeParameters(
mutableParameters,
[{ kind: "object", target: { kind: "atom", atomId: capabilityId.atom("person") } }],
environment(),
),
/does not implement/,
);
assert.throws(
() =>
bindTypeParameters(
[{ kind: "value", id: "V", name: "V", storable: true }],
[{ kind: "value", type: valueType.list(valueType.optional(valueType.atomRef(note))) }],
environment(),
),
/cannot contain managed references/,
);
assert.equal(isStorableType(valueType.message("opaque")), false);
assert.equal(isStorableType(valueType.watchHandle), false);
assert.equal(
isStorableType(valueType.message("checked"), (id) => id === "checked"),
true,
);
});
test("closed applications canonicalize records without erasing nominal identity or provenance", () => {
const a: ClosedTypeArgument = {
kind: "value",
type: { kind: "record", fields: { b: valueType.int64, a: valueType.string } },
};
const b: ClosedTypeArgument = {
kind: "value",
type: { kind: "record", fields: { a: valueType.string, b: valueType.int64 } },
};
assert.equal(canonicalTypeArgument(a), canonicalTypeArgument(b));
const application = {
definitionId: named,
source: { repository: "https://example.test/named.git", commit: "a".repeat(40) },
arguments: [a],
};
assert.equal(appliedInterfaceId(application), appliedInterfaceId({ ...application, arguments: [b] }));
assert.notEqual(appliedInterfaceId(application), appliedInterfaceId({ ...application, self: note }));
assert.notEqual(
appliedInterfaceId(application),
appliedInterfaceId({ ...application, source: { ...application.source, commit: "b".repeat(40) } }),
);
assert.throws(
() =>
canonicalTypeArgument({
kind: "value",
type: { kind: "parameter", parameterId: "T" },
} as unknown as ClosedTypeArgument),
/Expected a closed value type/,
);
});
test("aliases use lexical parameter identities, preserve outer bindings and reject cycles", () => {
const env = environment([["outer/T", { kind: "value", type: valueType.string }]]);
env.aliases = new Map([
[
"Box",
{
id: "Box",
parameters: [{ id: "box/T", name: "T", kind: "value" }],
body: { kind: "list", value: { kind: "parameter", parameterId: "box/T" } },
},
],
["Loop", { id: "Loop", parameters: [], body: { kind: "alias", definitionId: "Loop", arguments: [] } }],
]);
const substitute = new TypeSubstitution(env);
assert.deepEqual(
substitute.value({
kind: "alias",
definitionId: "Box",
arguments: [{ kind: "value", type: { kind: "parameter", parameterId: "outer/T" } }],
}),
valueType.list(valueType.string),
);
assert.deepEqual(substitute.value({ kind: "parameter", parameterId: "outer/T" }), valueType.string);
assert.throws(() => substitute.value({ kind: "alias", definitionId: "Loop", arguments: [] }), /Loop -> Loop/);
});
test("excessively deep types produce a bounded diagnostic", () => {
let expression: ValueTypeExpression = valueType.string;
for (let index = 0; index < 200; index++) expression = { kind: "list", value: expression };
assert.throws(() => new TypeSubstitution(environment()).value(expression), /depth or expansion budget/);
});
const source = { repository: "https://example.test/contracts.git", commit: "a".repeat(40) };
const reader = () => {
const result = compileCapabilityResourceSource(
`interface Reader<value V> id "reader" revision "reader@1" {
value values id "values" : list<optional<V>> { get id "values:get"; watch start id "values:watch" stop id "values:stop"; }
}`,
{ source },
);
assert.ok(result.ok, JSON.stringify(result.diagnostics));
assert.equal(result.resource.kind, "interface");
if (result.resource.kind !== "interface") throw new Error("expected interface");
return result.resource.revision;
};
test("generic interface source retains its template and produces closed package/codegen contracts", () => {
const definition = reader();
assert.equal(definition.template?.parameters[0].name, "V");
const result = compileCapabilityResourceSource(
`import interface Reader;
package Client id "client" revision "client@1" {
function run id "run" : unit -> list<optional<string>> requires { interface reader id "reader-port" : Reader<string>; };
}`,
{ source, environment: { interfaces: new Map([["Reader", definition]]) } },
);
assert.ok(result.ok, JSON.stringify(result.diagnostics));
if (result.resource.kind !== "package") throw new Error("expected package");
assert.equal(result.resource.specializations?.length, 1);
const closed = result.resource.specializations![0];
assert.deepEqual(closed.members[0].operations[0].outputType, valueType.list(valueType.optional(valueType.string)));
assert.deepEqual(closed.members[0].operations[1].eventType, closed.members[0].operations[0].outputType);
const generated = generateTypeScriptBindings(
{ format: "quixos-bindings", version: 1, interfaces: [closed], packages: [result.resource.revision] },
"client@1",
);
assert.match(generated, /Array<\(string \| null\)>/);
assert.ok(generated.includes(closed.revisionId));
});
test("generic conformances bind state against specialized signatures", () => {
const result = compileCapabilitySource(
`workspace Demo id "ws" revision "ws@1" commit "${source.commit}" {
atom Document id "document";
import interface Reader;
conform Document as Reader<string> id "reader-conformance" {
private state Values id "values-slot" on Document : list<optional<string>> policy optimistic-register;
bind values.get to state Values.read;
bind values.watch-start to state Values.watch-start;
bind values.watch-stop to state Values.watch-stop;
}
}`,
"workspace.qx",
{ interfaces: new Map([["Reader", reader()]]) },
);
assert.ok(result.ok, JSON.stringify(result.diagnostics));
assert.equal(result.workspace.interfaceImports.length, 1);
assert.equal(result.workspace.interfaceImports[0].template, undefined);
assert.equal(result.workspace.conformances[0].interfaceRevisionId, result.workspace.interfaceImports[0].revisionId);
});
test("source rejects wrong generic arity and an unapplied interface", () => {
for (const input of [
"interface-ref<Reader>",
"interface-ref<Reader<string, int32>>",
"interface-ref<Reader<atom Note>>",
]) {
const result = compileCapabilityResourceSource(
`import interface Reader; external atom Note id "note";
package P id "p" revision "p@1" { function f id "f" : ${input} -> unit; }`,
{ source, environment: { interfaces: new Map([["Reader", reader()]]) } },
);
assert.equal(result.ok, false, input);
assert.match(result.diagnostics[0].code, /type-arity|parameter-kind/);
}
});
test("package receivers and injected dependencies select exact applications", () => {
const definition = reader();
const summary = compileCapabilityResourceSource(
`interface Summary id "summary" revision "summary@1" {
value summary id "summary-value" : list<optional<string>> { get id "summary:get"; }
}`,
{ source },
);
assert.ok(summary.ok);
if (summary.resource.kind !== "interface") throw new Error("expected interface");
const pkg = compileCapabilityResourceSource(
`import interface Reader;
package P id "p" revision "p@1" {
operation summarize id "summarize" : unit -> list<optional<string>> mode call receiver interfaces [Reader<string>]
requires { interface reader id "reader-port" : Reader<string>; };
}`,
{ source, environment: { interfaces: new Map([["Reader", definition]]) } },
);
assert.ok(pkg.ok, JSON.stringify(pkg.diagnostics));
if (pkg.resource.kind !== "package") throw new Error("expected package");
const result = compileCapabilitySource(
`workspace W id "w" revision "w@1" commit "${source.commit}" {
import interface Reader; import interface Summary; import package P;
atom Note id "note";
conform Note as Reader<string> id "reader-conformance" {
private state Values id "values" on Note : list<optional<string>> policy optimistic-register;
bind values.get to state Values.read;
bind values.watch-start to state Values.watch-start;
bind values.watch-stop to state Values.watch-stop;
}
conform Note as Summary id "summary-conformance" {
bind summary.get to package P.summarize with { reader to interface Reader<string>; };
}
}`,
"workspace.qx",
{
interfaces: new Map([
["Reader", definition],
["Summary", summary.resource.revision],
]),
interfaceClosure: pkg.resource.specializations,
packages: new Map([["P", pkg.resource.revision]]),
},
);
assert.ok(result.ok, JSON.stringify(result.diagnostics));
});
test("named generic value aliases elaborate through nested lists", () => {
const result = compileCapabilityResourceSource(
`type Page<value T> = record { items: list<T>; next: optional<string>; };
package P id "p" revision "p@1" { function f id "f" : unit -> Page<int64>; }`,
{ source },
);
assert.ok(result.ok, JSON.stringify(result.diagnostics));
if (result.resource.kind !== "package") throw new Error("expected package");
assert.deepEqual(result.resource.revision.exports[0].outputType, {
kind: "record",
fields: { items: valueType.list(valueType.int64), next: valueType.optional(valueType.string) },
});
});
test("Self specializes to the implementing atom rather than an erased interface", () => {
const definition = compileCapabilityResourceSource(
`interface Identity id "identity" revision "identity@1" {
operation identity id "identity-member" : ref<Self> -> ref<Self> { call id "identity-call"; }
}`,
{ source },
);
assert.ok(definition.ok, JSON.stringify(definition.diagnostics));
if (definition.resource.kind !== "interface") throw new Error("expected interface");
assert.equal(definition.resource.revision.template?.usesSelf, true);
const { revision } = definition.resource;
const first = instantiateInterface(revision, [], { ...environment(), self: note });
const second = instantiateInterface(revision, [], { ...environment(), self: capabilityId.atom("other") });
assert.deepEqual(first.members[0].operations[0].outputType, valueType.atomRef(note));
assert.notEqual(first.revisionId, second.revisionId);
});
test("package Self comes from an exact receiver, never a previous export", () => {
const valid = compileCapabilityResourceSource(
`type Owned<object T> = ref<T>; external atom Note id "note";
package P id "p" revision "p@1" {
operation identity id "identity" : ref<Self> -> Owned<Self> mode call receiver atom Note;
}`,
{ source },
);
assert.ok(valid.ok, JSON.stringify(valid.diagnostics));
if (valid.resource.kind !== "package") throw new Error("expected package");
assert.deepEqual(valid.resource.revision.exports[0].outputType, valueType.atomRef(capabilityId.atom("note")));
const invalid = compileCapabilityResourceSource(
`external atom Note id "note";
package P id "p" revision "p@1" {
operation identity id "identity" : ref<Self> -> ref<Self> mode call receiver atom Note;
function bad id "bad" : unit -> ref<Self>;
}`,
{ source },
);
assert.equal(invalid.ok, false);
assert.equal(invalid.diagnostics[0].code, "unbound-self");
});
test("host generation rejects unresolved definitions and operation-only dispatch ambiguity", () => {
const definition = reader();
assert.throws(() => generateClientContracts([definition], {}), /closed interface/);
const first = instantiateInterface(definition, [{ kind: "value", type: valueType.string }], environment());
const second = instantiateInterface(definition, [{ kind: "value", type: valueType.int32 }], environment());
assert.throws(() => generateClientContracts([first, second], {}), /ambiguous/);
});
test("finite recursive generic interface references share the same closed application", () => {
const definition = compileCapabilityResourceSource(
`interface Node<value V> id "node" revision "node@1" {
value next id "next" : optional<interface-ref<Node<V>>> { get id "next:get"; }
}`,
{ source },
);
assert.ok(definition.ok, JSON.stringify(definition.diagnostics));
if (definition.resource.kind !== "interface") throw new Error("expected interface");
const result = compileCapabilityResourceSource(
`import interface Node;
package P id "p" revision "p@1" { function f id "f" : interface-ref<Node<string>> -> unit; }
`,
{ source, environment: { interfaces: new Map([["Node", definition.resource.revision]]) } },
);
assert.ok(result.ok, JSON.stringify(result.diagnostics));
const [closed] = result.resource.specializations!;
assert.equal(result.resource.specializations!.length, 1);
assert.deepEqual(
closed.members[0].operations[0].outputType,
valueType.optional(valueType.interfaceRef(closed.revisionId)),
);
});
test("expanding recursive generic interfaces fail with a bounded diagnostic", () => {
const definition = compileCapabilityResourceSource(
`interface Node<value V> id "node" revision "node@1" {
value next id "next" : interface-ref<Node<list<V>>> { get id "next:get"; }
}`,
{ source },
);
assert.ok(definition.ok, JSON.stringify(definition.diagnostics));
if (definition.resource.kind !== "interface") throw new Error("expected interface");
const result = compileCapabilityResourceSource(
`import interface Node;
package P id "p" revision "p@1" { function f id "f" : interface-ref<Node<string>> -> unit; }
`,
{ source, environment: { interfaces: new Map([["Node", definition.resource.revision]]) } },
);
assert.equal(result.ok, false);
assert.equal(result.diagnostics[0].code, "recursive-application");
});
test("storable parameters do not imply support for RPC-only records", () => {
assert.equal(isStorableType({ kind: "record", fields: { name: valueType.string } }), false);
assert.equal(isStorableType(valueType.list(valueType.optional(valueType.string))), true);
});
test("generic declarations reject duplicate members before specialization", () => {
const result = compileCapabilityResourceSource(
`interface Bad<value V> id "bad" revision "bad@1" {
value first id "duplicate" : V { get id "first:get"; }
value second id "duplicate" : V { get id "second:get"; }
}`,
{ source },
);
assert.equal(result.ok, false);
assert.equal(result.diagnostics[0].code, "duplicate-interface-member");
});
test("unused templates still check nested application arity and alias cycles", () => {
const malformed = compileCapabilityResourceSource(
`import interface Reader;
interface Bad<value V> id "bad" revision "bad@1" {
value item id "item" : interface-ref<Reader<V, V>> { get id "get"; }
}`,
{ source, environment: { interfaces: new Map([["Reader", reader()]]) } },
);
assert.equal(malformed.ok, false);
assert.equal(malformed.diagnostics[0].code, "type-arity");
const cycle = compileCapabilityResourceSource(
`type Loop<value V> = list<Loop<V>>;
interface Bad<value V> id "bad" revision "bad@1" {
value item id "item" : Loop<V> { get id "get"; }
}`,
{ source },
);
assert.equal(cycle.ok, false);
assert.equal(cycle.diagnostics[0].code, "recursive-alias");
});
test("Self in a prerequisite contributes to the outer application identity", () => {
const identity = compileCapabilityResourceSource(
`interface Identity id "identity" revision "identity@1" {
value self id "self" : ref<Self> { get id "self:get"; }
}`,
{ source },
);
assert.ok(identity.ok);
if (identity.resource.kind !== "interface") throw new Error("expected interface");
const outer = compileCapabilityResourceSource(
`import interface Identity;
interface Outer<value V> id "outer" revision "outer@1" requires Identity {}`,
{ source, environment: { interfaces: new Map([["Identity", identity.resource.revision]]) } },
);
assert.ok(outer.ok, JSON.stringify(outer.diagnostics));
if (outer.resource.kind !== "interface") throw new Error("expected interface");
assert.equal(outer.resource.revision.template?.usesSelf, true);
});
test("object bounds are discharged against the complete candidate, not source order", () => {
const namedResult = compileCapabilityResourceSource(`interface Named id "named" revision "named@1" {}`, { source });
assert.ok(namedResult.ok);
if (namedResult.resource.kind !== "interface") throw new Error("expected interface");
const named = namedResult.resource.revision;
const bounded = compileCapabilityResourceSource(
`import interface Named;
interface Container<object T implements Named> id "container" revision "container@1" {}`,
{ source, environment: { interfaces: new Map([["Named", named]]) } },
);
assert.ok(bounded.ok, JSON.stringify(bounded.diagnostics));
if (bounded.resource.kind !== "interface") throw new Error("expected interface");
const container = bounded.resource.revision;
const compile = (evidence: string) =>
compileCapabilitySource(
`workspace W id "w" revision "w@1" commit "${source.commit}" {
import interface Named; import interface Container;
atom Note id "note"; atom Index id "index";
conform Index as Container<atom Note> id "index-container" {}
${evidence}
}`,
"workspace.qx",
{
interfaces: new Map([
["Named", named],
["Container", container],
]),
},
);
const missing = compile("");
assert.equal(missing.ok, false);
assert.ok(missing.diagnostics.some((issue) => issue.code === "unsatisfied-interface"));
const valid = compile('conform Note as Named id "note-named" {}');
assert.ok(valid.ok, JSON.stringify(valid.diagnostics));
});
test("prerequisites require explicit conformances and remain in the capability closure", () => {
const baseResult = compileCapabilityResourceSource(`interface Base id "base" revision "base@1" {}`, { source });
assert.ok(baseResult.ok);
if (baseResult.resource.kind !== "interface") throw new Error("expected interface");
const base = baseResult.resource.revision;
const derivedResult = compileCapabilityResourceSource(
`import interface Base;
interface Derived<value V> id "derived" revision "derived@1" requires Base {}`,
{
source,
environment: { interfaces: new Map([["Base", base]]) },
},
);
assert.ok(derivedResult.ok, JSON.stringify(derivedResult.diagnostics));
if (derivedResult.resource.kind !== "interface") throw new Error("expected interface");
const derived = derivedResult.resource.revision;
const compile = (evidence: string) =>
compileCapabilitySource(
`workspace W id "w" revision "w@1" commit "${source.commit}" {
import interface Base; import interface Derived; atom Note id "note";
conform Note as Derived<string> id "derived-conformance" {}
${evidence}
}`,
"workspace.qx",
{
interfaces: new Map([
["Base", base],
["Derived", derived],
]),
},
);
assert.equal(compile("").ok, false);
const result = compile('conform Note as Base id "base-conformance" {}');
assert.ok(result.ok, JSON.stringify(result.diagnostics));
const root = result.workspace.conformances[0];
const closure = computeCapabilityClosure(result.plan, [root]);
assert.equal(closure.conformances.length, 2);
const unresolved = structuredClone(result.workspace);
unresolved.interfaceImports[0].members.push({
kind: "value",
id: capabilityId.member("unresolved"),
displayName: "unresolved",
operations: [],
valueType: { kind: "parameter", parameterId: "T" } as unknown as typeof valueType.string,
});
assert.equal(compileWorkspaceRevision(unresolved).ok, false);
const forged = structuredClone(result.workspace);
const applied = forged.interfaceImports.find((entry) => entry.application)!;
applied.application!.arguments = [{ kind: "value", type: valueType.int64 }];
assert.equal(compileWorkspaceRevision(forged).ok, false);
});
+1 -1
View File
@@ -8,7 +8,7 @@ import { promisify } from "node:util";
import { createGitCapabilityResolver } from "../src/capability-language/git-resolver.js"; import { createGitCapabilityResolver } from "../src/capability-language/git-resolver.js";
const execFile = promisify(callback); 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-")); const temporary = await mkdtemp(path.join(os.tmpdir(), "qx-resolver-test-"));
context.after(() => rm(temporary, { recursive: true, force: true })); context.after(() => rm(temporary, { recursive: true, force: true }));
const origin = path.join(temporary, "origin"); const origin = path.join(temporary, "origin");
+54 -15
View File
@@ -1,28 +1,67 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { contentDigest, validateMigrationCatalog, selectMigrationPath, type MigrationCatalog } from "../src/capability-model/index.js"; import {
const before = { fields: ["name"] }, after = { fields: ["first", "last"] }; contentDigest,
const from = contentDigest(before), to = contentDigest(after); validateMigrationCatalog,
const catalog = (): MigrationCatalog => ({ schemaVersion: 1, contracts: { [from]: before, [to]: after }, migrations: [{ selectMigrationPath,
id: "split-name", scopeId: "person-name", from, to, type MigrationCatalog,
implementation: { exportId: "migrate-name", file: "src/migrate-name.ts", digest: contentDigest("implementation") }, predecessors: [], } from "../src/capability-model/index.js";
ports: [{ name: "source", view: "old", access: ["read"], contractDigest: from }, { name: "target", view: "new", access: ["write"], contractDigest: to }], 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", () => { test("published migrations retain exact old contracts and explicit local bindings", () => {
const value = validateMigrationCatalog(catalog(), new Set(["migrate-name"])); 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); const result = selectMigrationPath(value, selection);
assert.equal(result[0].alreadyApplied, false); assert.equal(result[0].alreadyApplied, false);
assert.equal(selectMigrationPath(value, selection, new Map([["split-name", result[0].digest]]))[0].alreadyApplied, true); assert.equal(
const changed = catalog(); changed.migrations[0].implementation.digest = contentDigest("new code"); selectMigrationPath(value, selection, new Map([["split-name", result[0].digest]]))[0].alreadyApplied,
assert.throws(() => selectMigrationPath(changed, selection, new Map([["split-name", result[0].digest]])), /different code/); 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, path: [] }), /does not cover/);
assert.throws(() => selectMigrationPath(value, { ...selection, bindings: {} }), /Missing local/); assert.throws(() => selectMigrationPath(value, { ...selection, bindings: {} }), /Missing local/);
}); });
test("old migration views cannot be writable and contract hashes are verified", () => { 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/); assert.throws(() => validateMigrationCatalog(value), /read-only/);
const corrupt = catalog(); corrupt.contracts[from] = {}; const corrupt = catalog();
corrupt.contracts[from] = {};
assert.throws(() => validateMigrationCatalog(corrupt), /digest mismatch/); 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"]; cyclic.migrations[0].predecessors = ["split-name"];
assert.throws(() => validateMigrationCatalog(cyclic), /Cyclic/); assert.throws(() => validateMigrationCatalog(cyclic), /Cyclic/);
const misleading = catalog(); 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/); assert.throws(() => validateMigrationCatalog(misleading), /must be booleans/);
}); });
+66 -25
View File
@@ -7,28 +7,69 @@ import { execFile as callback } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
const execFile = promisify(callback); 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 => { test(
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-nix-candidate-test-")); "Nix checks a retained immutable source without a local overlay and reuses the result",
context.after(() => fs.rm(root, {recursive: true, force: true})); { skip: !process.env.QX_CHECK_GENERATOR },
const git = (...args: string[]) => execFile("git", ["-C", root, ...args]); async (context) => {
await git("init"); const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-nix-candidate-test-"));
await fs.writeFile(path.join(root, "interface.qx"), 'interface Example id "interface:example" revision "interface:example@1" {}'); context.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } }`); const git = (...args: string[]) => execFile("git", ["-C", root, ...args]);
await git("add", "."); await git("init");
await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract"); await fs.writeFile(
const commit = (await git("rev-parse", "HEAD")).stdout.trim(); path.join(root, "interface.qx"),
await git("tag", `quixos-reachability/${commit}`); 'interface Example id "interface:example" revision "interface:example@1" {}',
const generator = process.env.QX_CHECK_GENERATOR!; );
const repository = "https://immutable-candidate.example.test/contract.git"; await fs.writeFile(
const env = {...process.env, GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `url.file://${root}.insteadOf`, GIT_CONFIG_VALUE_0: repository}; path.join(root, "quixos.lock"),
const build = async () => (await execFile("nix", ["build", "--impure", "--file", path.join(generator, "share/checked-candidate.nix"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } }`,
"--argstr", "repository", repository, "--argstr", "commit", commit, );
"--argstr", "kind", "interface", "--argstr", "generator", generator, await git("add", ".");
"--option", "substitute", "false", "--no-link", "--print-out-paths"], {env, maxBuffer: 4 * 1024 * 1024})).stdout.trim(); await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract");
const output = await build(); const commit = (await git("rev-parse", "HEAD")).stdout.trim();
const candidate = JSON.parse(await fs.readFile(path.join(output, "candidate.json"), "utf8")); await git("tag", `quixos-reachability/${commit}`);
assert.equal(candidate.revision.source.commit, commit); const generator = process.env.QX_CHECK_GENERATOR!;
await fs.writeFile(path.join(root, "interface.qx"), "broken draft"); const repository = "https://immutable-candidate.example.test/contract.git";
assert.equal(await build(), output); const env = {
assert.deepEqual(JSON.parse(await fs.readFile(path.join(output, "checks.json"), "utf8")), []); ...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")), []);
},
);
+143 -64
View File
@@ -3,100 +3,179 @@ import assert from "node:assert/strict";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import {promisify} from "node:util"; import { promisify } from "node:util";
import {execFile as callback} from "node:child_process"; import { execFile as callback } from "node:child_process";
import {planPinUpgrades, applyPinUpgrades, type UpgradeEffects} from "../src/capability-language/pin-upgrades.js"; import { planPinUpgrades, applyPinUpgrades, type UpgradeEffects } from "../src/capability-language/pin-upgrades.js";
const execFile = promisify(callback); 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) => { test(
const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-real-upgrade-")); "real jj snapshots and immutable Git publication propagate a changed interface into the root",
context.after(() => fs.rm(workbench, {recursive: true, force: true})); { skip: !process.env.QX_CHECK_GENERATOR },
// Exercise the actual effects with local bare remotes, without external writes. async (context) => {
const environment = { const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-real-upgrade-"));
GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `url.file://${workbench}/remotes/.insteadOf`, context.after(() => fs.rm(workbench, { recursive: true, force: true }));
GIT_CONFIG_VALUE_0: "https://upgrade.test/", QUIXOS_JJ_NO_CHECKPOINT: "1", // Exercise the actual effects with local bare remotes, without external writes.
QUIXOS_CHECK_GENERATOR: process.env.QX_CHECK_GENERATOR!, const environment = {
}; GIT_CONFIG_COUNT: "1",
const previous = Object.fromEntries(Object.keys(environment).map(key => [key, process.env[key]])); GIT_CONFIG_KEY_0: `url.file://${workbench}/remotes/.insteadOf`,
Object.assign(process.env, environment); GIT_CONFIG_VALUE_0: "https://upgrade.test/",
context.after(() => {for (const [key, value] of Object.entries(previous)) if (value === undefined) delete process.env[key]; else process.env[key] = value;}); QUIXOS_JJ_NO_CHECKPOINT: "1",
const run = async (cwd: string, command: string, args: string[]) => (await execFile(command, args, {cwd})).stdout.trim(); QUIXOS_CHECK_GENERATOR: process.env.QX_CHECK_GENERATOR!,
await fs.mkdir(path.join(workbench, "remotes")); };
const nodes = []; const previous = Object.fromEntries(Object.keys(environment).map((key) => [key, process.env[key]]));
for (const [kind, directory, remote] of [["interface", "resources/Named", "named.git"], ["workspace", "root", "workspace.git"]] as const) { Object.assign(process.env, environment);
const root = path.join(workbench, directory); context.after(() => {
await fs.mkdir(root, {recursive: true}); for (const [key, value] of Object.entries(previous))
await run(workbench, "git", ["init", "--bare", path.join(workbench, "remotes", remote)]); if (value === undefined) delete process.env[key];
await run(root, "jj", ["git", "init", "--colocate"]); else process.env[key] = value;
await run(root, "git", ["remote", "add", "origin", `https://upgrade.test/${remote}`]); });
await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n"); const run = async (cwd: string, command: string, args: string[]) =>
const child = nodes[0]; (await execFile(command, args, { cwd })).stdout.trim();
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.mkdir(path.join(workbench, "remotes"));
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"; }`); const nodes = [];
await run(root, "jj", ["describe", "-m", "Initial source"]); for (const [kind, directory, remote] of [
const commit = await run(root, "jj", ["log", "--no-graph", "-r", "@", "-T", "commit_id"]); ["interface", "resources/Named", "named.git"],
await run(root, "git", ["push", "origin", `${commit}:refs/tags/quixos-reachability/${commit}`]); ["workspace", "root", "workspace.git"],
nodes.push({kind, directory, source: {repository: `https://upgrade.test/${remote}`, commit}}); ] as const) {
} const root = path.join(workbench, directory);
await fs.mkdir(path.join(workbench, ".quixos")); await fs.mkdir(root, { recursive: true });
await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [nodes[0]]})); await run(workbench, "git", ["init", "--bare", path.join(workbench, "remotes", remote)]);
await fs.appendFile(path.join(workbench, nodes[0].directory, "interface.qx"), "\n// incremental author edit\n"); await run(root, "jj", ["git", "init", "--colocate"]);
const plan = await planPinUpgrades(workbench, {nodes, bootstrap: true}); await run(root, "git", ["remote", "add", "origin", `https://upgrade.test/${remote}`]);
const result = await applyPinUpgrades(plan); await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n");
assert.equal(result.activated, false); const child = nodes[0];
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8")); await fs.writeFile(
const commit = graph.resources[0].source.commit; path.join(root, "quixos.lock"),
assert.notEqual(commit, nodes[0].source.commit); `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}"; }` : ""} }`,
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)); 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) => { 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-")); const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-upgrade-test-"));
context.after(() => fs.rm(workbench, {recursive: true, force: true})); context.after(() => fs.rm(workbench, { recursive: true, force: true }));
const from = "a".repeat(40), to = "b".repeat(40), framework = "c".repeat(40); const from = "a".repeat(40),
const sources = [{kind: "workspace" as const, directory: "root", source: {repository: "https://example.test/workspace.git", commit: from}}, to = "b".repeat(40),
{kind: "interface" as const, directory: "resources/Named", source: {repository: "https://example.test/named.git", commit: from}}]; 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}"; }`; const lock = `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${framework}"; }`;
for (const node of sources) { for (const node of sources) {
const directory = path.join(workbench, node.directory); 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, "init"]);
await execFile("git", ["-C", directory, "remote", "add", "origin", node.source.repository]); 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, ".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(
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"; }`); 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"); 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; const oldMap = process.env.QUIXOS_SNAPSHOT_MAP;
process.env.QUIXOS_SNAPSHOT_MAP = snapshotMap; process.env.QUIXOS_SNAPSHOT_MAP = snapshotMap;
context.after(() => {if (oldMap === undefined) delete process.env.QUIXOS_SNAPSHOT_MAP; else process.env.QUIXOS_SNAPSHOT_MAP = oldMap;}); context.after(() => {
const plan = await planPinUpgrades(workbench, {nodes: sources}); if (oldMap === undefined) delete process.env.QUIXOS_SNAPSHOT_MAP;
await fs.mkdir(path.join(workbench, ".quixos"), {recursive: true}); else process.env.QUIXOS_SNAPSHOT_MAP = oldMap;
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"]); 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; let fail = true;
const published: string[] = []; const published: string[] = [];
const effects: UpgradeEffects = { 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, 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/); await assert.rejects(() => applyPinUpgrades(plan, undefined, effects), /refactor required/);
assert.deepEqual(published, ["resources/Named"]); assert.deepEqual(published, ["resources/Named"]);
assert.match(await fs.readFile(path.join(workbench, "root/quixos.lock"), "utf8"), new RegExp(to)); 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; fail = false;
await fs.appendFile(path.join(workbench, "root/workspace.qx"), "\n// explicit refactor\n"); await fs.appendFile(path.join(workbench, "root/workspace.qx"), "\n// explicit refactor\n");
await assert.rejects(() => applyPinUpgrades(plan, id, effects), /accept-edits/); 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.equal(result.activated, false);
assert.deepEqual(published, ["resources/Named", "root"]); assert.deepEqual(published, ["resources/Named", "root"]);
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8")); 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.length, 1);
assert.equal(graph.resources[0].source.commit, to); 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"]); assert.deepEqual(next.nodes.find((node) => node.kind === "workspace")!.dependencies, ["resources/Named"]);
}); });
+51 -14
View File
@@ -3,15 +3,30 @@ import test from "node:test";
import { mkdtemp, writeFile, readFile, rm, symlink } from "node:fs/promises"; import { mkdtemp, writeFile, readFile, rm, symlink } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { parseQx, formatQx, lintQx, applySourceEdits, addWorkspaceImport, walkSyntax, import {
resolveQxSources, readQxSource, compileCapabilitySource, scaffoldAtom, compileWorkspaceRepository } from "../src/capability-language/index.js"; 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`; 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", () => { 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'); 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]), [ assert.deepEqual(
["duplicate-source-import", "warning"], ["invalid-source-import", "error"], 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", () => { test("lossless tokens, UTF-16 ranges, and safe source edits", () => {
const text = workspace.replace("}\n", 'atom Cat id "🐈";\n}\n'); 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); assert.equal(parsed.tokens.map((token) => token.text).join(""), text);
const atom = [...walkSyntax(parsed.root)].find((node) => node.kind === "atomDecl")!; const atom = [...walkSyntax(parsed.root)].find((node) => node.kind === "atomDecl")!;
assert.equal(text.slice(atom.start, atom.end), 'atom Cat id "🐈";'); 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.equal(
assert.throws(() => applySourceEdits(text, [{ start: 0, end: 4, text: "" }, { start: 3, end: 7, text: "" }]), /overlapping/); 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/); assert.throws(() => applySourceEdits(text, [{ start: -1, end: 0, text: "" }]), /Invalid/);
}); });
test("formatting preserves comments and strings, is idempotent, and rejects invalid source", () => { 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"); const root = addWorkspaceImport(addWorkspaceImport(workspace, "a.qx"), "b.qx");
assert.equal(addWorkspaceImport(root, "a.qx"), root); assert.equal(addWorkspaceImport(root, "a.qx"), root);
assert.match(root, /keep 🐈 comment/); assert.match(root, /keep 🐈 comment/);
const files: Record<string, string> = { "workspace.qx": root, const files: Record<string, string> = {
"workspace.qx": root,
"a.qx": 'fragment { import "shared.qx"; atom A id "a"; }', "a.qx": 'fragment { import "shared.qx"; atom A id "a"; }',
"b.qx": 'fragment { import "shared.qx"; atom B id "b"; }', "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]!); const result = await resolveQxSources(async (name) => files[name]!);
assert.equal(result.sourceFiles.length, 4); assert.equal(result.sourceFiles.length, 4);
const compiled = compileCapabilitySource(result.source); 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.equal(unresolved.ok, false);
assert.match(JSON.stringify(unresolved.diagnostics), /unresolved-source-import/); assert.match(JSON.stringify(unresolved.diagnostics), /unresolved-source-import/);
files["shared.qx"] = 'fragment { import "a.qx"; }'; 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/); assert.throws(() => addWorkspaceImport(workspace, "../x.qx"), /Invalid/);
}); });
test("repository scaffolding validates before writing and refuses duplicates and symlinks", async (t) => { test("repository scaffolding validates before writing and refuses duplicates and symlinks", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "qx-scaffold-")); const root = await mkdtemp(path.join(os.tmpdir(), "qx-scaffold-"));
t.after(() => rm(root, { recursive: true, force: true })); t.after(() => rm(root, { recursive: true, force: true }));
await writeFile(path.join(root, "workspace.qx"), workspace); 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)}"; } }`); await writeFile(
const resolveResource = async (): Promise<never> => { throw new Error("unexpected resource"); }; 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<never> => {
throw new Error("unexpected resource");
};
const options = { root, name: "Cat", id: "cat", write: false, resolveResource }; const options = { root, name: "Cat", id: "cat", write: false, resolveResource };
await scaffoldAtom(options); await scaffoldAtom(options);
assert.equal(await readFile(path.join(root, "workspace.qx"), "utf8"), workspace); 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/); await assert.rejects(readQxSource(root, "link.qx"), /ordinary files/);
}); });
test("lowering diagnostics map to the imported file", async () => { test("lowering diagnostics map to the imported file", async () => {
const files: Record<string, string> = { "workspace.qx": addWorkspaceImport(workspace, "bad.qx"), const files: Record<string, string> = {
"bad.qx": 'fragment {\n conform Missing as Nope {}\n}' }; "workspace.qx": addWorkspaceImport(workspace, "bad.qx"),
"bad.qx": "fragment {\n conform Missing as Nope {}\n}",
};
const sources = await resolveQxSources(async (name) => files[name]!); const sources = await resolveQxSources(async (name) => files[name]!);
const compiled = compileCapabilitySource(sources.source); const compiled = compileCapabilitySource(sources.source);
assert.equal(compiled.ok, false); assert.equal(compiled.ok, false);
+187
View File
@@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import test from "node:test";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/parser.js";
import { generateReactBindings } from "../src/bindings/react.js";
import { reactPlatformTypes } from "../src/bindings/react-platform.js";
const source = { repository: "https://example.test/fields.git", commit: "a".repeat(40) };
test("React bindings preserve read-only, writable and nested reference contracts", async (t) => {
const iface = compileCapabilityResourceSource(
`interface Fields id "fields" revision "fields@1" {
value title id "title" : string { get id "title:get"; set id "title:set"; watch start id "watch" stop id "stop"; }
value summary id "summary" : string { get id "summary:get"; }
}`,
{ source },
);
assert.ok(iface.ok && iface.resource.kind === "interface");
if (!iface.ok || iface.resource.kind !== "interface") throw new Error("interface failed");
const pkg = compileCapabilityResourceSource(
`import interface Fields; package P id "p" revision "p@1" {
function props id "props" : unit -> record {fields: interface-ref<Fields>; caption: string;};
}`,
{ source, environment: { interfaces: new Map([["Fields", iface.resource.revision]]) } },
);
assert.ok(pkg.ok && pkg.resource.kind === "package");
if (!pkg.ok || pkg.resource.kind !== "package") throw new Error("package failed");
const schema = {
format: "quixos-bindings",
version: 1,
interfaces: [iface.resource.revision],
packages: [pkg.resource.revision],
} as const;
const generated = generateReactBindings(
{ ...schema, interfaces: [...schema.interfaces], packages: [...schema.packages] },
"p@1",
["props"],
);
assert.match(generated, /"title": WritableField<string>/);
assert.match(generated, /"summary": ReadableField<string>/);
assert.throws(
() => generateReactBindings({ ...schema, interfaces: [], packages: [...schema.packages] }, "p@1", ["props"]),
/Missing React reference/,
);
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-react-types-"));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.writeFile(path.join(root, "react-props.gen.ts"), generated);
await fs.writeFile(
path.join(root, "platform.d.ts"),
reactPlatformTypes +
'\ndeclare module "react" {export type ReactNode = unknown; export type CSSProperties = {}; export function createElement(...args: unknown[]): unknown;}\n',
);
await fs.writeFile(
path.join(root, "consumer.ts"),
`import {useLiveField, type ReadableField, type WritableField} from "@quixos/web-studio-react-runtime";
import type {ReactResults} from "./react-props.gen.js";
declare const props: ReactResults["props"];
const [title, setTitle] = useLiveField(props.fields.fields.title);
setTitle("new");
// @ts-expect-error wrong setter value
setTitle(123);
// @ts-expect-error read-only hook has no setter
const [summary, setSummary] = useLiveField(props.fields.fields.summary);
const [manual, write] = useLiveField(props.fields.fields.summary, {write: async (value: string) => {}});
write("new");
const readonly: ReadableField<string> = props.fields.fields.title;
// @ts-expect-error read-only does not satisfy writable
const writable: WritableField<string> = props.fields.fields.summary;
declare const narrow: WritableField<"only">;
// @ts-expect-error writable references are invariant
const widened: WritableField<string> = narrow;
// @ts-expect-error callbacks must accept the field's type
useLiveField(props.fields.fields.summary, {write: async (value: number) => {}});
`,
);
const result = spawnSync(
process.execPath,
[
path.resolve("node_modules/typescript/bin/tsc"),
"--strict",
"--noEmit",
"--skipLibCheck",
"--target",
"ES2022",
path.join(root, "platform.d.ts"),
path.join(root, "consumer.ts"),
],
{ encoding: "utf8", cwd: root },
);
assert.equal(result.status, 0, result.stdout + result.stderr);
await fs.writeFile(
path.join(root, "react-props.gen.ts"),
generateReactBindings(
{ ...schema, interfaces: [...schema.interfaces], packages: [...schema.packages] },
"p@1",
["props"],
[{ module: "./component.js", propsExport: "props" }],
),
);
const checkComponent = () =>
spawnSync(
process.execPath,
[
path.resolve("node_modules/typescript/bin/tsc"),
"--strict",
"--noEmit",
"--skipLibCheck",
"--target",
"ES2022",
path.join(root, "platform.d.ts"),
path.join(root, "react-props.gen.ts"),
path.join(root, "component.ts"),
],
{ encoding: "utf8", cwd: root },
);
await fs.writeFile(
path.join(root, "component.ts"),
'import type {ReactResults} from "./react-props.gen.js"; export default function Component(props: {camino: ReactResults["props"]}) {return null;}',
);
const matching = checkComponent();
assert.equal(matching.status, 0, matching.stdout + matching.stderr);
await fs.writeFile(
path.join(root, "component.ts"),
'import type {WritableField} from "@quixos/web-studio-react-runtime"; export default function Component(props: {camino: {fields: {fields: {summary: WritableField<string>}}}}) {return null;}',
);
const incompatible = checkComponent();
assert.notEqual(incompatible.status, 0, "component cannot strengthen a read-only prop into a writable field");
assert.match(incompatible.stdout + incompatible.stderr, /writable|WritableField/);
});
test("class factories specialize return types and reject instance implementations or mismatched inputs", () => {
const factory = compileCapabilityResourceSource(
'interface Factory<object T> id "factory" revision "factory@1" {static operation create id "create" : unit -> ref<T> {call id "call";}}',
{ source },
);
assert.ok(factory.ok && factory.resource.kind === "interface");
if (!factory.ok || factory.resource.kind !== "interface") throw new Error("factory failed");
const factoryRevision = factory.resource.revision;
const compile = (declaration: string) => {
const pkg = compileCapabilityResourceSource(
`external atom Note id "note"; package P id "p" revision "p@1" {${declaration}}`,
{ source },
);
assert.ok(pkg.ok && pkg.resource.kind === "package", JSON.stringify(pkg.diagnostics));
if (!pkg.ok || pkg.resource.kind !== "package") throw new Error("package failed");
return compileCapabilitySource(
`workspace W id "w" revision "w@1" commit "${source.commit}" {
atom Note id "note"; import interface Factory; import package P;
conform Note as Factory<atom Note> id "factory-conformance" {bind create.call to package P.make;}
}`,
"workspace.qx",
{ interfaces: new Map([["Factory", factoryRevision]]), packages: new Map([["P", pkg.resource.revision]]) },
);
};
const good = compile('function make id "make" : unit -> atom-ref<Note>;');
assert.ok(good.ok, JSON.stringify(good.diagnostics));
assert.equal(good.workspace.interfaceImports[0].members[0].operations[0].scope, "class");
const wrongInput = compile('function make id "make" : string -> atom-ref<Note>;');
assert.equal(wrongInput.ok, false);
const wrongReceiver = compile('operation make id "make" : unit -> atom-ref<Note> mode call receiver atom Note;');
assert.equal(wrongReceiver.ok, false);
assert.match(JSON.stringify(wrongReceiver.diagnostics), /free function/);
});
test("state-field shorthand binds exactly the declared accessors, never invents writes", () => {
const iface = compileCapabilityResourceSource(
'interface Reader id "reader" revision "reader@1" {value title id "title" : string {get id "get"; watch start id "watch" stop id "stop";}}',
{ source },
);
assert.ok(iface.ok && iface.resource.kind === "interface");
if (!iface.ok || iface.resource.kind !== "interface") throw new Error("interface failed");
const result = compileCapabilitySource(
`workspace W id "w" revision "w@1" commit "${source.commit}" {
atom Note id "note"; import interface Reader;
conform Note as Reader id "reader-conformance" {private state Title id "title-slot" on Note : string policy crdt(string) default ""; bind title to state Title;}
}`,
"workspace.qx",
{ interfaces: new Map([["Reader", iface.resource.revision]]) },
);
assert.ok(result.ok, JSON.stringify(result.diagnostics));
assert.deepEqual(
result.workspace.conformances[0].operationBindings.map((binding) => binding.operationId),
["get", "watch", "stop"],
);
});
+58 -33
View File
@@ -141,25 +141,34 @@ test("resolves root-relative lock fragments into one deterministic resource clos
} }
}`; }`;
const sources = new Map([ 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"; import "locks/shared.lock";
interface Placeable source { interface Placeable source {
repository "https://repos.example/alice/interface-placeable.git"; repository "https://repos.example/alice/interface-placeable.git";
commit "${namedCommit}"; commit "${namedCommit}";
} }
}`], }`,
["locks/shared.lock", `quixos-lock fragment version 1 { ],
[
"locks/shared.lock",
`quixos-lock fragment version 1 {
interface Named source { interface Named source {
repository "https://repos.example/alice/interface-named.git"; repository "https://repos.example/alice/interface-named.git";
commit "${namedCommit}"; commit "${namedCommit}";
} }
}`], }`,
["locks/domain.lock", `quixos-lock fragment version 1 { ],
[
"locks/domain.lock",
`quixos-lock fragment version 1 {
package TodoRuntime source { package TodoRuntime source {
repository "https://repos.example/alice/package-todo.git"; repository "https://repos.example/alice/package-todo.git";
commit "${packageCommit}"; commit "${packageCommit}";
} }
}`], }`,
],
]); ]);
const result = await resolveQuixosLock(root, async (relativePath) => { const result = await resolveQuixosLock(root, async (relativePath) => {
const source = sources.get(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/shared.lock",
"locks/domain.lock", "locks/domain.lock",
]); ]);
assert.deepEqual(result.lock.resources.map(({ kind, binding }) => ({ kind, binding })), [ assert.deepEqual(
{ kind: "package", binding: "RootRuntime" }, result.lock.resources.map(({ kind, binding }) => ({ kind, binding })),
{ kind: "interface", binding: "Placeable" }, [
{ kind: "interface", binding: "Named" }, { kind: "package", binding: "RootRuntime" },
{ kind: "package", binding: "TodoRuntime" }, { kind: "interface", binding: "Placeable" },
]); { kind: "interface", binding: "Named" },
{ kind: "package", binding: "TodoRuntime" },
],
);
}); });
test("lock fragments format canonically and cannot redeclare the Quixos source", () => { 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, kind: "fragment" as const,
formatVersion: 1 as const, formatVersion: 1 as const,
imports: ["locks/shared.lock"], imports: ["locks/shared.lock"],
resources: [{ resources: [
kind: "interface" as const, {
binding: "Named", kind: "interface" as const,
source: { binding: "Named",
resolver: "git" as const, source: {
repository: "https://repos.example/alice/interface-named.git", resolver: "git" as const,
commit: namedCommit, repository: "https://repos.example/alice/interface-named.git",
commit: namedCommit,
},
}, },
}], ],
}; };
assert.deepEqual(parseQuixosLockDocument(formatQuixosLockDocument(document)), { assert.deepEqual(parseQuixosLockDocument(formatQuixosLockDocument(document)), {
ok: true, ok: true,
document, document,
diagnostics: [], diagnostics: [],
}); });
const invalid = parseQuixosLockDocument(`quixos-lock fragment version 1 { const invalid = parseQuixosLockDocument(
`quixos-lock fragment version 1 {
quixos source { quixos source {
repository "https://gitea.example/quixos/quixos.git"; repository "https://gitea.example/quixos/quixos.git";
commit "${quixosCommit}"; commit "${quixosCommit}";
} }
}`, "bad.lock"); }`,
"bad.lock",
);
assert.equal(invalid.ok, false); assert.equal(invalid.ok, false);
if (!invalid.ok) assert.equal(invalid.diagnostics[0]?.code, "fragment-has-quixos-source"); 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 () => { 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"; import "../outside.lock";
}`, "bad-path.lock"); }`,
"bad-path.lock",
);
assert.equal(invalidPath.ok, false); assert.equal(invalidPath.ok, false);
if (!invalidPath.ok) assert.equal(invalidPath.diagnostics[0]?.code, "invalid-import-path"); 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([ const sources = new Map([
["a.lock", `quixos-lock fragment version 1 { [
"a.lock",
`quixos-lock fragment version 1 {
import "b.lock"; import "b.lock";
interface Named source { interface Named source {
repository "https://repos.example/alice/interface-named-copy.git"; repository "https://repos.example/alice/interface-named-copy.git";
commit "${namedCommit}"; commit "${namedCommit}";
} }
}`], }`,
],
["b.lock", `quixos-lock fragment version 1 { import "a.lock"; }`], ["b.lock", `quixos-lock fragment version 1 { import "a.lock"; }`],
["root-again.lock", root], ["root-again.lock", root],
]); ]);
const result = await resolveQuixosLock(root, async (relativePath) => sources.get(relativePath) ?? ""); const result = await resolveQuixosLock(root, async (relativePath) => sources.get(relativePath) ?? "");
assert.equal(result.ok, false); assert.equal(result.ok, false);
if (!result.ok) { if (!result.ok) {
assert.deepEqual(new Set(result.diagnostics.map(({ code }) => code)), new Set([ assert.deepEqual(
"duplicate-resource-binding", new Set(result.diagnostics.map(({ code }) => code)),
"import-cycle", new Set(["duplicate-resource-binding", "import-cycle", "imported-root-lock"]),
"imported-root-lock", );
]));
} }
}); });
@@ -260,13 +282,16 @@ test("file loading rejects a symlink in any import path component", async (conte
await mkdir(outside); await mkdir(outside);
await writeFile(path.join(outside, "fragment.lock"), "quixos-lock fragment version 1 {}\n"); await writeFile(path.join(outside, "fragment.lock"), "quixos-lock fragment version 1 {}\n");
await symlink(outside, path.join(directory, "linked"), "dir"); 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 { quixos source {
repository "https://gitea.example/quixos/quixos.git"; repository "https://gitea.example/quixos/quixos.git";
commit "${quixosCommit}"; commit "${quixosCommit}";
} }
import "linked/fragment.lock"; import "linked/fragment.lock";
}`); }`,
);
const result = await loadQuixosLock(path.join(directory, "quixos.lock")); const result = await loadQuixosLock(path.join(directory, "quixos.lock"));
assert.equal(result.ok, false); assert.equal(result.ok, false);
+87 -27
View File
@@ -3,43 +3,83 @@ import assert from "node:assert/strict";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import {execFile as callback} from "node:child_process"; import { execFile as callback } from "node:child_process";
import {promisify} from "node:util"; import { promisify } from "node:util";
import {scaffoldRecipe} from "../src/capability-language/scaffold-recipes.js"; import { scaffoldRecipe } from "../src/capability-language/scaffold-recipes.js";
import {planStructure, applyStructure} from "../src/capability-language/structural-plan.js"; import { planStructure, applyStructure } from "../src/capability-language/structural-plan.js";
import {contentDigest} from "../src/capability-model/evolution.js"; import { contentDigest } from "../src/capability-model/evolution.js";
import {sealMigrations} from "../src/capability-language/migration-seal.js"; import { sealMigrations } from "../src/capability-language/migration-seal.js";
import { reactPlatformTypes } from "../src/bindings/react-platform.js";
const execFile = promisify(callback); const execFile = promisify(callback);
test("React preset applies its browser build script and shared-platform imports", async (context) => { 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-")); 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"]); await execFile("git", ["-C", root, "init"]);
const source = {repository: "https://example.test/react.git", commit: "a".repeat(40)}; 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 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)); 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, "src/impl/sourceGet.ts"), "utf8"), /component.js\?browser-source/);
assert.match(await fs.readFile(path.join(root, "flake.nix"), "utf8"), /browserSources = true/); assert.match(await fs.readFile(path.join(root, "flake.nix"), "utf8"), /browserSources = true/);
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(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.deepEqual(
JSON.parse(await fs.readFile(path.join(root, "quixos.check.json"), "utf8")).options.react.propsExports,
[],
);
await assert.rejects(fs.access(path.join(root, "quixos.scaffold.json"))); 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) => { 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-")); 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 execFile("git", ["-C", root, "init"]);
await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n"); await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n");
const source = {repository: "https://example.test/chess.git", commit: "a".repeat(40)}; const source = { repository: "https://example.test/chess.git", commit: "a".repeat(40) };
const base = {source, directory: "packages/Chess"}; const base = { source, directory: "packages/Chess" };
const apply = async (command: Parameters<typeof scaffoldRecipe>[1], input: Parameters<typeof scaffoldRecipe>[2]) => applyStructure(await planStructure(root, await scaffoldRecipe(root, command, input))); const apply = async (command: Parameters<typeof scaffoldRecipe>[1], input: Parameters<typeof scaffoldRecipe>[2]) =>
await apply("package", {...base, name: "Chess", id: "package:chess", revision: "package:chess@1", tools: {quixos: source, protocol: source, helpers: source, sdk: source}}); applyStructure(await planStructure(root, await scaffoldRecipe(root, command, input)));
await apply("function", {...base, name: "play", id: "export:play"}); 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 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); await fs.writeFile(filename, edited);
const old = {version: 1}, next = {version: 2}, from = contentDigest(old), to = contentDigest(next); const old = { version: 1 },
await apply("migration", {...base, name: "upgrade", id: "export:upgrade", migration: {id: "upgrade-v2", scopeId: "board", from, to, predecessors: [], ports: [], contracts: {[from]: old, [to]: next}}}); 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"); const migrationFile = path.join(root, base.directory, "src/migrations/upgrade.ts");
await fs.appendFile(migrationFile, "\n// authored migration change\n"); await fs.appendFile(migrationFile, "\n// authored migration change\n");
await sealMigrations(path.join(root, base.directory)); await sealMigrations(path.join(root, base.directory));
@@ -47,18 +87,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")); 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.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/); 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"); 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"); const server = path.join(root, base.directory, "src/server.ts");
await fs.appendFile(server, "\n// authored comment must survive\n"); 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/); assert.match(await fs.readFile(server, "utf8"), /authored comment must survive/);
await assert.rejects(() => apply("refresh", base), /removed/); await assert.rejects(() => apply("refresh", base), /removed/);
await assert.rejects(fs.access(path.join(root, base.directory, "src/impl/authored.ts"))); await assert.rejects(fs.access(path.join(root, base.directory, "src/impl/authored.ts")));
assert.equal(await fs.readFile(filename, "utf8"), edited); assert.equal(await fs.readFile(filename, "utf8"), edited);
await planStructure(root, {kind: "package", source, resourceRoot: base.directory, validation: "syntax", files: [{ await planStructure(root, {
file: `${base.directory}/package.qx`, edits: [{operation: "replace", target: {kind: "packageResourceDecl", id: "package:chess"}, kind: "package",
source: 'package Other id "package:other" revision "package:other@1" {}'}], 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" {}',
},
],
},
],
});
}); });
+66 -19
View File
@@ -1,9 +1,16 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; 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", () => { 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 id = "00000000-0000-0000-0000-000000000123";
const bound = instantiateWorkspaceIdentity(source, id); const bound = instantiateWorkspaceIdentity(source, id);
assert.match(bound, /atom Task id "atom:task"/); 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", () => { 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 source = `// 🧭 resource comment\n${scaffoldResourceSource("package", "Chess", "package:chess", "package:chess@1")}`;
const functionSource = 'function evaluate id "export:evaluate" : string -> string;'; 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.startsWith("// 🧭 resource comment\n"));
assert.ok(appended.includes(functionSource)); 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(replaced.includes(": unit -> string;"));
assert.ok(!editStructure(replaced, {operation: "remove", target: {kind: "packageFunctionExport", id: "export:evaluate"}}).includes("evaluate")); assert.ok(
assert.throws(() => editStructure(source, {operation: "remove", target: {kind: "packageResourceDecl", id: "package:chess@1"}}), /exactly once/); !editStructure(replaced, {
assert.throws(() => editStructure(source, {operation: "append", parent: {kind: "packageResourceDecl"}, source: "not valid QX"}), /Invalid structural/); 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 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";')); assert.ok(replacedPackage.startsWith('import interface Board;\nexternal atom Game id "atom:game";'));
}); });
test("dependency scaffolding validates exact sources and preserves unrelated lock comments", () => { 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 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); const appended = editStructure(source, dependency);
assert.ok(appended.includes("// retained comment")); assert.ok(appended.includes("// retained comment"));
assert.ok(appended.startsWith("// 🧭 lock\n")); assert.ok(appended.startsWith("// 🧭 lock\n"));
assert.throws(() => editStructure(source, {...dependency, source: {...dependency.source, commit: "main"}}), /Invalid dependency/); assert.throws(
const changed = editStructure(appended, {...dependency, source: {...dependency.source, commit: "c".repeat(40)}}); () => 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(!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", () => { 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 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 selector = { kind: "conformanceDecl", names: ["Game", "Playable"] };
const enrolled = editStructure(source, {operation: "conformance-id", target: selector, id: "conformance:playable"}); const enrolled = editStructure(source, { operation: "conformance-id", target: selector, id: "conformance:playable" });
assert.ok(enrolled.includes('as Playable 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.ok(major.includes("semantic-major 2"));
assert.throws(() => editStructure(major, {operation: "conformance-id", target: selector, id: "different"}), /Cannot change/); assert.throws(
const removed = editStructure(major, {operation: "remove", target: {kind: "stateDecl", id: "slot:board"}}); () => 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")); assert.ok(!removed.includes("private"));
const imported = editStructure(removed, {operation: "import", kind: "interface", name: "Playable"}); const imported = editStructure(removed, { operation: "import", kind: "interface", name: "Playable" });
assert.equal(editStructure(imported, {operation: "import", kind: "interface", name: "Playable"}), imported); assert.equal(editStructure(imported, { operation: "import", kind: "interface", name: "Playable" }), imported);
}); });
+26 -8
View File
@@ -5,18 +5,34 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { execFile as callback } from "node:child_process"; 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); const execFile = promisify(callback);
test("structural plans validate the graph, journal originals, and reject stale edits", async (context) => { 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-")); 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 execFile("git", ["-C", root, "init"]);
await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n"); 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"; }`; 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); 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); const plan = await planStructure(root, request);
assert.equal(await fs.readFile(path.join(root, "workspace.qx"), "utf8"), before); assert.equal(await fs.readFile(path.join(root, "workspace.qx"), "utf8"), before);
await fs.writeFile(path.join(root, "workspace.qx"), `${before}\n// newer edit`); 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"); assert.equal((await resumeStructure(root, applied.id)).phase, "complete");
await assert.rejects(() => planStructure(root, request), /Duplicate|duplicate/); await assert.rejects(() => planStructure(root, request), /Duplicate|duplicate/);
// Cross-repository edits may temporarily refer to an unfinished provider. // Cross-repository edits may temporarily refer to an unfinished provider.
const provisional: StructuralRequest = {kind: "workspace", validation: "syntax", files: [{file: "workspace.qx", edits: [ const provisional: StructuralRequest = {
{operation: "import", kind: "interface", name: "NotImplementedYet"}, kind: "workspace",
]}]}; validation: "syntax",
files: [{ file: "workspace.qx", edits: [{ operation: "import", kind: "interface", name: "NotImplementedYet" }] }],
};
const draft = await planStructure(root, provisional); const draft = await planStructure(root, provisional);
assert.equal(draft.validation, "syntax"); assert.equal(draft.validation, "syntax");
await applyStructure(draft); await applyStructure(draft);
assert.match(await fs.readFile(path.join(root, "workspace.qx"), "utf8"), /import interface NotImplementedYet/); 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" }));
}); });