Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 332b84f258 | |||
| 4982cb7e9f | |||
| 14b0ef25c3 | |||
| c16271510a | |||
| 8aac091f6c | |||
| 01ca965c7f | |||
| fae4e48f72 | |||
| 6ecf565549 | |||
| 73cdeadc2c | |||
| 16b28f1bc4 | |||
| dcef58d6c5 | |||
| f7fcc7d08b | |||
| 295181244f | |||
| 42af87f8bd | |||
| e9fb42dce4 | |||
| 9867bf4552 | |||
| 483bc68a94 | |||
| 1e25f391e7 | |||
| 4e17693d82 | |||
| ce793cc54f | |||
| fd6fee133d | |||
| f5ced64533 | |||
| eabf721e94 | |||
| 0c46850973 | |||
| c4d42a0ac5 | |||
| fd6160ec67 | |||
| fd039f095b | |||
| f4987093f6 | |||
| 4d52789807 | |||
| 60550bb760 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git",
|
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
|
||||||
"sourceCommit": "ce0dd80f56df35bf3db1be01236a949a704cb4c2",
|
"sourceCommit": "fc13e9b8411c2210b12db5e558e2b4fa7785505e",
|
||||||
"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"
|
||||||
|
|||||||
@@ -13,34 +13,107 @@ on parse-tree shapes or declaration order.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix develop
|
nix develop
|
||||||
quixos-capability-compile ../quixos-instance/workspaces/todo.capabilities.qx
|
quixos-workspace-compile \
|
||||||
|
--root ../quixos-instance/workspaces/todo \
|
||||||
|
--checkout-root /tmp/quixos-resolved-resources
|
||||||
```
|
```
|
||||||
|
|
||||||
The compiler validates exact identities, interface operation coverage,
|
`workspace.qx` contains workspace-local atoms, attachments, conformances, and
|
||||||
|
constructor bindings. Each imported interface has an `interface.qx` in its own
|
||||||
|
repository; each imported package similarly has a `package.qx`. Source
|
||||||
|
locations never appear in those declarations. `quixos.lock` supplies their
|
||||||
|
exact Git repositories and commits, and the workspace compiler resolves that
|
||||||
|
dependency graph recursively.
|
||||||
|
|
||||||
|
Inside an interface or package manifest, `import interface Named;` is a true
|
||||||
|
source dependency: the repository lock must pin it, and tooling may use the
|
||||||
|
full contract for generated types. `external atom` and `external interface`
|
||||||
|
declare nominal identities which the final importing workspace must provide.
|
||||||
|
The latter is appropriate for relationship targets and other opaque
|
||||||
|
references; it deliberately does not grant the contract needed for an
|
||||||
|
interface invocation port. This distinction permits mutually referential
|
||||||
|
interface identities without creating a cycle in the Git Merkle graph.
|
||||||
|
|
||||||
|
The compiler validates exact identities, recursive lock/source agreement,
|
||||||
|
external requirement satisfaction, interface operation coverage,
|
||||||
attachment ownership, native state/edge providers, package receiver and
|
attachment ownership, native state/edge providers, package receiver and
|
||||||
dependency-port requirements, constructors, and the exact runtime closure.
|
dependency-port requirements, constructors, and the exact runtime closure.
|
||||||
Generics, interface composition, declarative forwarding, automatic
|
Generics, interface composition, declarative forwarding, automatic
|
||||||
relationship materialization, and first-class bundles are intentionally absent
|
relationship materialization, and first-class bundles are intentionally absent
|
||||||
from v1.
|
from v1.
|
||||||
|
|
||||||
|
Local workspace fragments, source-editing APIs, and generated package contracts
|
||||||
|
are documented in [QX bindings and tooling](../docs/QX_BINDINGS_AND_TOOLING.md).
|
||||||
|
`quixos-resource-compile --schema-out` exports the portable generator input;
|
||||||
|
`quixos-codegen-ts` is the first backend. `quixos-qx` provides parse, lint,
|
||||||
|
format, and nominal-atom scaffold commands.
|
||||||
|
|
||||||
## Repository locks
|
## Repository locks
|
||||||
|
|
||||||
Every workspace, interface, and package repository carries a `quixos.lock`.
|
Every workspace, interface, and package repository carries a `quixos.lock`.
|
||||||
It pins the exact Quixos toolchain revision plus exact Git sources for imported
|
A resource lock is not a miniature workspace: it contains only the exact
|
||||||
interfaces and packages. The v1 Git source is deliberately only a repository
|
Quixos commit that resource was authored against plus its dependency list.
|
||||||
URL and full commit ID; the publisher-owned reachability tag and Nix fetch
|
It also pins exact Git sources for interfaces and packages named by that
|
||||||
details are derived from those values.
|
resource's own manifest. Resource Git sources are deliberately only a
|
||||||
|
repository URL and full commit ID; the publisher-owned reachability tag and
|
||||||
|
Nix fetch details are derived from those values.
|
||||||
|
|
||||||
|
A workspace root additionally records its Quixos selection policy and ref.
|
||||||
|
Its `commit` is the exact compatibility baseline shared by the resource graph,
|
||||||
|
not necessarily the runtime candidate. `pinned` requires the ref, baseline,
|
||||||
|
and candidate to be one commit. `track-development` lets Central resolve the
|
||||||
|
named development ref to a newer candidate while preserving the authored
|
||||||
|
baseline. `track-release` is reserved for compatibility-filtered release-line
|
||||||
|
advancement and is not implemented yet.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
quixos-lock-check quixos.lock
|
quixos-lock-check quixos.lock
|
||||||
qx resource publish
|
quixos-resource-compile \
|
||||||
|
--root . --kind package \
|
||||||
|
--repository https://repos.quixos.org/example/package-example.git \
|
||||||
|
--commit 1111111111111111111111111111111111111111 \
|
||||||
|
--checkout-root /tmp/quixos-resource-dependencies
|
||||||
|
qx-workspace resource publish
|
||||||
```
|
```
|
||||||
|
|
||||||
The second command runs from a jj checkout. It validates the lock, snapshots
|
The resource compiler validates the local manifest against the recursively
|
||||||
the current working-copy commit, and pushes the immutable tag
|
resolved locks without turning the resource into a workspace. The final
|
||||||
|
command runs from a jj checkout. It performs that same recursive compilation,
|
||||||
|
snapshots the current working-copy commit, and pushes the immutable tag
|
||||||
`refs/tags/quixos-reachability/<commit>` without advancing an authoring
|
`refs/tags/quixos-reachability/<commit>` without advancing an authoring
|
||||||
bookmark.
|
bookmark.
|
||||||
|
|
||||||
|
A workspace root may split its resource pins into same-repository fragments:
|
||||||
|
|
||||||
|
```text
|
||||||
|
quixos-lock version 1 {
|
||||||
|
quixos source {
|
||||||
|
repository "https://repos.quixos.org/quixos/quixos.git";
|
||||||
|
policy track-development;
|
||||||
|
ref "dev/alice/main";
|
||||||
|
commit "1111111111111111111111111111111111111111";
|
||||||
|
}
|
||||||
|
import "locks/web-studio.lock";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
quixos-lock fragment version 1 {
|
||||||
|
package CanvasRuntime source {
|
||||||
|
repository "https://repos.quixos.org/org-quixos-web-studio/package-canvas-runtime.git";
|
||||||
|
commit "2222222222222222222222222222222222222222";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Import paths are normalized, repository-root-relative paths. Absolute paths,
|
||||||
|
URLs, traversal, empty segments, directories, and symbolic links are rejected.
|
||||||
|
Fragments may import other fragments; cycles fail, shared fragments are loaded
|
||||||
|
once, and duplicate resource bindings fail across the flattened closure.
|
||||||
|
`quixos-lock-check` resolves the complete closure and reports its `sourceFiles`
|
||||||
|
alongside the flattened resources. The root Git commit content-addresses every
|
||||||
|
fragment, so fragments do not become independent repositories or identities.
|
||||||
|
|
||||||
## Package descriptors
|
## Package descriptors
|
||||||
|
|
||||||
Executable package metadata remains protobuf text format:
|
Executable package metadata remains protobuf text format:
|
||||||
@@ -56,9 +129,10 @@ their exact dependency ports.
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix shell nixpkgs#protobuf -c npm test
|
nix develop -c yarn test
|
||||||
```
|
```
|
||||||
|
|
||||||
The suite covers parsing/diagnostics, source-order independence, ordinary call
|
The suite covers parsing/diagnostics, recursive repository assembly, external
|
||||||
operations, semantic validation, exact closure/tree-shaking, private and shared
|
requirement validation, source-order independence, ordinary call operations,
|
||||||
attachments, package port injection, and constructors.
|
semantic validation, exact closure/tree-shaking, private and shared
|
||||||
|
attachments, related-object interface-port injection, and constructors.
|
||||||
|
|||||||
@@ -15,23 +15,44 @@
|
|||||||
src = pkgs.lib.cleanSource ./.;
|
src = pkgs.lib.cleanSource ./.;
|
||||||
overrideAttrs = old: {
|
overrideAttrs = old: {
|
||||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
|
||||||
|
pkgs.diffutils
|
||||||
pkgs.esbuild
|
pkgs.esbuild
|
||||||
pkgs.protobuf
|
pkgs.protobuf
|
||||||
|
pkgs.git
|
||||||
|
pkgs.jujutsu
|
||||||
|
pkgs.gnutar
|
||||||
|
pkgs.util-linux
|
||||||
];
|
];
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
|
mkdir -p "$TMPDIR/generated-before"
|
||||||
|
cp --recursive src/gen "$TMPDIR/generated-before/proto"
|
||||||
|
cp --recursive src/capability-language/generated "$TMPDIR/generated-before/capability"
|
||||||
|
cp --recursive src/resource-lock/generated "$TMPDIR/generated-before/lock"
|
||||||
export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$PWD/proto''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}"
|
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
|
||||||
|
diff --recursive --unified "$TMPDIR/generated-before/proto" src/gen
|
||||||
|
diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated
|
||||||
|
diff --recursive --unified "$TMPDIR/generated-before/lock" src/resource-lock/generated
|
||||||
esbuild dist/src/descriptor-check.js \
|
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 \
|
||||||
|
--bundle --platform=node --target=node24 --format=esm \
|
||||||
|
--outfile=quixos-workspace-compile.mjs
|
||||||
|
esbuild dist/src/capability-language/resource-cli.js \
|
||||||
|
--bundle --platform=node --target=node24 --format=esm \
|
||||||
|
--outfile=quixos-resource-compile.mjs
|
||||||
esbuild dist/src/resource-lock/cli.js \
|
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/capability-language/tool-cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-qx.mjs
|
||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
doCheck = true;
|
doCheck = true;
|
||||||
@@ -43,11 +64,18 @@
|
|||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
mkdir -p "$out"
|
mkdir -p "$out"
|
||||||
|
install -Dm644 nix/checked-package.nix "$out/share/checked-package.nix"
|
||||||
|
substitute nix/checked-candidate.nix "$out/share/checked-candidate.nix" \
|
||||||
|
--replace-fail '@nixpkgs@' '${pkgs.path}'
|
||||||
cp --reflink=auto --recursive grammar "$out/grammar"
|
cp --reflink=auto --recursive grammar "$out/grammar"
|
||||||
cp --reflink=auto --recursive proto "$out/proto"
|
cp --reflink=auto --recursive proto "$out/proto"
|
||||||
cp --reflink=auto --recursive dist "$out/dist"
|
cp --reflink=auto --recursive dist "$out/dist"
|
||||||
cp package.json "$out/package.json"
|
cp package.json "$out/package.json"
|
||||||
mkdir -p "$out/bin"
|
mkdir -p "$out/bin"
|
||||||
|
for tool in quixos-codegen-ts quixos-qx; do
|
||||||
|
printf '#!/bin/sh\nexec ${pkgs.nodejs_24}/bin/node "%s/libexec/quixos-protocol/%s.mjs" "$@"\n' "$out" "$tool" > "$out/bin/$tool"
|
||||||
|
chmod +x "$out/bin/$tool"
|
||||||
|
done
|
||||||
cat > "$out/bin/quixos-descriptor-check" <<EOF
|
cat > "$out/bin/quixos-descriptor-check" <<EOF
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
export PATH="${pkgs.protobuf}/bin:\$PATH"
|
export PATH="${pkgs.protobuf}/bin:\$PATH"
|
||||||
@@ -60,14 +88,28 @@ EOF
|
|||||||
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-capability-compile.mjs" "\$@"
|
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-capability-compile.mjs" "\$@"
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$out/bin/quixos-capability-compile"
|
chmod +x "$out/bin/quixos-capability-compile"
|
||||||
|
cat > "$out/bin/quixos-workspace-compile" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-workspace-compile.mjs" "\$@"
|
||||||
|
EOF
|
||||||
|
chmod +x "$out/bin/quixos-workspace-compile"
|
||||||
|
cat > "$out/bin/quixos-resource-compile" <<EOF
|
||||||
|
#!/bin/sh
|
||||||
|
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-resource-compile.mjs" "\$@"
|
||||||
|
EOF
|
||||||
|
chmod +x "$out/bin/quixos-resource-compile"
|
||||||
cat > "$out/bin/quixos-lock-check" <<EOF
|
cat > "$out/bin/quixos-lock-check" <<EOF
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-lock-check.mjs" "\$@"
|
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-lock-check.mjs" "\$@"
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$out/bin/quixos-lock-check"
|
chmod +x "$out/bin/quixos-lock-check"
|
||||||
mkdir -p "$out/libexec/quixos-protocol"
|
mkdir -p "$out/libexec/quixos-protocol"
|
||||||
|
install -m644 client-codegen.mjs "$out/libexec/quixos-protocol/client-codegen.mjs"
|
||||||
|
install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/"
|
||||||
install -m644 quixos-descriptor-check.mjs "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs"
|
install -m644 quixos-descriptor-check.mjs "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs"
|
||||||
install -m644 quixos-capability-compile.mjs "$out/libexec/quixos-protocol/quixos-capability-compile.mjs"
|
install -m644 quixos-capability-compile.mjs "$out/libexec/quixos-protocol/quixos-capability-compile.mjs"
|
||||||
|
install -m644 quixos-workspace-compile.mjs "$out/libexec/quixos-protocol/quixos-workspace-compile.mjs"
|
||||||
|
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"
|
install -m644 quixos-lock-check.mjs "$out/libexec/quixos-protocol/quixos-lock-check.mjs"
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
@@ -75,6 +117,25 @@ EOF
|
|||||||
};
|
};
|
||||||
in {
|
in {
|
||||||
packages.default = quixos-protocol;
|
packages.default = quixos-protocol;
|
||||||
|
packages.inspector = (pkgs.callPackage ./yarn-project.nix { inherit nodejs; }) {
|
||||||
|
src = pkgs.lib.fileset.toSource {
|
||||||
|
root = ./.;
|
||||||
|
fileset = pkgs.lib.fileset.unions [ ./package.json ./yarn.lock ./.yarnrc.yml ./.yarn/plugins ./src ];
|
||||||
|
};
|
||||||
|
overrideAttrs = old: {
|
||||||
|
nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.esbuild ];
|
||||||
|
doCheck = false;
|
||||||
|
buildPhase = ''
|
||||||
|
esbuild src/capability-language/inspect-cli.ts --bundle --platform=node --target=node24 --format=esm --outfile=inspect.mjs
|
||||||
|
'';
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p "$out/bin" "$out/lib"
|
||||||
|
cp inspect.mjs "$out/lib/inspect.mjs"
|
||||||
|
printf '#!${pkgs.runtimeShell}\nexec ${nodejs}/bin/node --max-old-space-size=128 %s/lib/inspect.mjs\n' "$out" > "$out/bin/quixos-qx-inspect"
|
||||||
|
chmod +x "$out/bin/quixos-qx-inspect"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
checks.default = quixos-protocol;
|
checks.default = quixos-protocol;
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
packages = [
|
packages = [
|
||||||
|
|||||||
+74
-23
@@ -2,6 +2,17 @@ grammar QuixosCapability;
|
|||||||
|
|
||||||
document
|
document
|
||||||
: workspaceDecl EOF
|
: workspaceDecl EOF
|
||||||
|
| interfaceResourceDecl EOF
|
||||||
|
| packageResourceDecl EOF
|
||||||
|
| fragmentDecl EOF
|
||||||
|
;
|
||||||
|
|
||||||
|
fragmentDecl
|
||||||
|
: FRAGMENT LBRACE workspaceItem* RBRACE
|
||||||
|
;
|
||||||
|
|
||||||
|
sourceImportDecl
|
||||||
|
: IMPORT stringLiteral SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
workspaceDecl
|
workspaceDecl
|
||||||
@@ -10,30 +21,42 @@ workspaceDecl
|
|||||||
;
|
;
|
||||||
|
|
||||||
workspaceItem
|
workspaceItem
|
||||||
: atomDecl
|
: sourceImportDecl
|
||||||
| interfaceDecl
|
| atomDecl
|
||||||
| packageDecl
|
| resourceImportDecl
|
||||||
| sharedAttachmentDecl
|
| sharedAttachmentDecl
|
||||||
| conformanceDecl
|
| conformanceDecl
|
||||||
| constructorBindingDecl
|
| constructorBindingDecl
|
||||||
;
|
;
|
||||||
|
|
||||||
|
resourceImportDecl
|
||||||
|
: IMPORT INTERFACE identifier SEMI
|
||||||
|
| IMPORT PACKAGE identifier SEMI
|
||||||
|
;
|
||||||
|
|
||||||
|
externalAtomDecl
|
||||||
|
: EXTERNAL ATOM identifier ID stringLiteral SEMI
|
||||||
|
;
|
||||||
|
|
||||||
|
externalInterfaceDecl
|
||||||
|
: EXTERNAL INTERFACE identifier REVISION stringLiteral SEMI
|
||||||
|
;
|
||||||
|
|
||||||
|
resourcePreamble
|
||||||
|
: resourceImportDecl
|
||||||
|
| externalAtomDecl
|
||||||
|
| externalInterfaceDecl
|
||||||
|
;
|
||||||
|
|
||||||
atomDecl
|
atomDecl
|
||||||
: ATOM identifier ID stringLiteral (DOC stringLiteral)? SEMI
|
: ATOM identifier ID stringLiteral (DOC stringLiteral)? SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
interfaceDecl
|
interfaceResourceDecl
|
||||||
: INTERFACE identifier ID stringLiteral REVISION stringLiteral sourceBlock
|
: resourcePreamble* INTERFACE identifier ID stringLiteral REVISION stringLiteral
|
||||||
LBRACE interfaceMember* RBRACE
|
LBRACE interfaceMember* RBRACE
|
||||||
;
|
;
|
||||||
|
|
||||||
sourceBlock
|
|
||||||
: SOURCE LBRACE
|
|
||||||
REPOSITORY stringLiteral SEMI
|
|
||||||
COMMIT stringLiteral SEMI
|
|
||||||
RBRACE
|
|
||||||
;
|
|
||||||
|
|
||||||
interfaceMember
|
interfaceMember
|
||||||
: valueMember
|
: valueMember
|
||||||
| relationshipMember
|
| relationshipMember
|
||||||
@@ -73,8 +96,9 @@ targetConstraint
|
|||||||
| INTERFACE identifier
|
| INTERFACE identifier
|
||||||
;
|
;
|
||||||
|
|
||||||
packageDecl
|
packageResourceDecl
|
||||||
: PACKAGE identifier ID stringLiteral REVISION stringLiteral sourceBlock
|
: resourcePreamble* PACKAGE identifier ID stringLiteral REVISION stringLiteral
|
||||||
|
(SEMANTIC_MAJOR INTEGER)?
|
||||||
LBRACE packageExport* RBRACE
|
LBRACE packageExport* RBRACE
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -129,7 +153,7 @@ 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 SEMI
|
||||||
| CONSTRUCTOR identifier ID stringLiteral COLON identifier SEMI
|
| CONSTRUCTOR identifier ID stringLiteral COLON identifier (INPUT valueType)? SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
primitiveList
|
primitiveList
|
||||||
@@ -170,16 +194,23 @@ edgeDecl
|
|||||||
;
|
;
|
||||||
|
|
||||||
edgeEndpoint
|
edgeEndpoint
|
||||||
: targetConstraint PROJECTION identifier ID stringLiteral cardinality ORDERED? SEMI
|
: targetConstraint PROJECTION identifier ID stringLiteral cardinality ORDERED?
|
||||||
|
(ON_DELETE stringLiteral)? RETAIN_OTHER? (KEYED stringLiteral)? PUBLIC_TRAVERSAL? SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
conformanceDecl
|
conformanceDecl
|
||||||
: CONFORM identifier AS identifier LBRACE conformanceItem* RBRACE
|
: CONFORM identifier AS identifier (ID stringLiteral)? (SEMANTIC_MAJOR INTEGER)?
|
||||||
|
LBRACE conformanceItem* RBRACE
|
||||||
;
|
;
|
||||||
|
|
||||||
conformanceItem
|
conformanceItem
|
||||||
: PRIVATE attachmentDecl
|
: PRIVATE attachmentDecl
|
||||||
| operationBindingDecl
|
| operationBindingDecl
|
||||||
|
| relationshipMaterializationDecl
|
||||||
|
;
|
||||||
|
|
||||||
|
relationshipMaterializationDecl
|
||||||
|
: MATERIALIZE identifier IF ABSENT USING CONSTRUCTOR identifier VIA EDGE identifier DOT identifier SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
operationBindingDecl
|
operationBindingDecl
|
||||||
@@ -230,9 +261,9 @@ dependencyBindingBlock
|
|||||||
;
|
;
|
||||||
|
|
||||||
dependencyBinding
|
dependencyBinding
|
||||||
: identifier TO STATE identifier SEMI
|
: identifier TO STATE identifier (VIA EDGE identifier DOT identifier)? SEMI
|
||||||
| identifier TO EDGE identifier DOT identifier SEMI
|
| identifier TO EDGE identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI
|
||||||
| identifier TO INTERFACE identifier SEMI
|
| identifier TO INTERFACE identifier (VIA EDGE identifier DOT identifier)? SEMI
|
||||||
| identifier TO CONSTRUCTOR identifier SEMI
|
| identifier TO CONSTRUCTOR identifier SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -249,6 +280,11 @@ valueType
|
|||||||
| INTERFACE_REF LT identifier GT
|
| INTERFACE_REF LT identifier GT
|
||||||
| OPTIONAL LT valueType GT
|
| OPTIONAL LT valueType GT
|
||||||
| LIST LT valueType GT
|
| LIST LT valueType GT
|
||||||
|
| RECORD LBRACE recordField* RBRACE
|
||||||
|
;
|
||||||
|
|
||||||
|
recordField
|
||||||
|
: identifier COLON valueType SEMI
|
||||||
;
|
;
|
||||||
|
|
||||||
scalarType
|
scalarType
|
||||||
@@ -302,6 +338,9 @@ stringLiteral
|
|||||||
;
|
;
|
||||||
|
|
||||||
WORKSPACE: 'workspace';
|
WORKSPACE: 'workspace';
|
||||||
|
FRAGMENT: 'fragment';
|
||||||
|
IMPORT: 'import';
|
||||||
|
EXTERNAL: 'external';
|
||||||
ATOM: 'atom';
|
ATOM: 'atom';
|
||||||
INTERFACE: 'interface';
|
INTERFACE: 'interface';
|
||||||
INTERFACES: 'interfaces';
|
INTERFACES: 'interfaces';
|
||||||
@@ -312,6 +351,7 @@ OPERATION: 'operation';
|
|||||||
FUNCTION: 'function';
|
FUNCTION: 'function';
|
||||||
CONSTRUCTOR: 'constructor';
|
CONSTRUCTOR: 'constructor';
|
||||||
CONSTRUCTS: 'constructs';
|
CONSTRUCTS: 'constructs';
|
||||||
|
INPUT: 'input';
|
||||||
CONFORM: 'conform';
|
CONFORM: 'conform';
|
||||||
AS: 'as';
|
AS: 'as';
|
||||||
BIND: 'bind';
|
BIND: 'bind';
|
||||||
@@ -322,6 +362,11 @@ STATE: 'state';
|
|||||||
EDGE: 'edge';
|
EDGE: 'edge';
|
||||||
PROJECTION: 'projection';
|
PROJECTION: 'projection';
|
||||||
WITH: 'with';
|
WITH: 'with';
|
||||||
|
USING: 'using';
|
||||||
|
VIA: 'via';
|
||||||
|
MATERIALIZE: 'materialize';
|
||||||
|
IF: 'if';
|
||||||
|
ABSENT: 'absent';
|
||||||
ON: 'on';
|
ON: 'on';
|
||||||
POLICY: 'policy';
|
POLICY: 'policy';
|
||||||
DEFAULT: 'default';
|
DEFAULT: 'default';
|
||||||
@@ -329,6 +374,11 @@ SOURCE: 'source';
|
|||||||
REPOSITORY: 'repository';
|
REPOSITORY: 'repository';
|
||||||
COMMIT: 'commit';
|
COMMIT: 'commit';
|
||||||
REVISION: 'revision';
|
REVISION: 'revision';
|
||||||
|
SEMANTIC_MAJOR: 'semantic-major';
|
||||||
|
ON_DELETE: 'on-delete';
|
||||||
|
RETAIN_OTHER: 'retain-other';
|
||||||
|
KEYED: 'keyed';
|
||||||
|
PUBLIC_TRAVERSAL: 'public-traversal';
|
||||||
ID: 'id';
|
ID: 'id';
|
||||||
DOC: 'doc';
|
DOC: 'doc';
|
||||||
MODE: 'mode';
|
MODE: 'mode';
|
||||||
@@ -365,6 +415,7 @@ ATOM_REF: 'atom-ref';
|
|||||||
INTERFACE_REF: 'interface-ref';
|
INTERFACE_REF: 'interface-ref';
|
||||||
OPTIONAL: 'optional';
|
OPTIONAL: 'optional';
|
||||||
LIST: 'list';
|
LIST: 'list';
|
||||||
|
RECORD: 'record';
|
||||||
BOOL: 'bool';
|
BOOL: 'bool';
|
||||||
BYTES: 'bytes';
|
BYTES: 'bytes';
|
||||||
DOUBLE: 'double';
|
DOUBLE: 'double';
|
||||||
@@ -398,6 +449,6 @@ STRING_LITERAL: '"' (ESC | ~["\\\r\n])* '"';
|
|||||||
fragment ESC: '\\' (["\\/bfnrt] | 'u' HEX HEX HEX HEX);
|
fragment ESC: '\\' (["\\/bfnrt] | 'u' HEX HEX HEX HEX);
|
||||||
fragment HEX: [0-9a-fA-F];
|
fragment HEX: [0-9a-fA-F];
|
||||||
|
|
||||||
LINE_COMMENT: '//' ~[\r\n]* -> skip;
|
LINE_COMMENT: '//' ~[\r\n]* -> channel(HIDDEN);
|
||||||
BLOCK_COMMENT: '/*' .*? '*/' -> skip;
|
BLOCK_COMMENT: '/*' .*? '*/' -> channel(HIDDEN);
|
||||||
WS: [ \t\r\n]+ -> skip;
|
WS: [ \t\r\n]+ -> channel(HIDDEN);
|
||||||
|
|||||||
+28
-2
@@ -1,17 +1,22 @@
|
|||||||
grammar QuixosLock;
|
grammar QuixosLock;
|
||||||
|
|
||||||
document
|
document
|
||||||
: QUIXOS_LOCK VERSION INTEGER LBRACE quixosEntry resourceEntry* RBRACE EOF
|
: QUIXOS_LOCK FRAGMENT? VERSION INTEGER LBRACE
|
||||||
|
(quixosEntry | importEntry | resourceEntry)* RBRACE EOF
|
||||||
;
|
;
|
||||||
|
|
||||||
quixosEntry
|
quixosEntry
|
||||||
: QUIXOS SOURCE sourceBlock
|
: QUIXOS SOURCE quixosSourceBlock
|
||||||
;
|
;
|
||||||
|
|
||||||
resourceEntry
|
resourceEntry
|
||||||
: resourceKind identifier SOURCE sourceBlock
|
: resourceKind identifier SOURCE sourceBlock
|
||||||
;
|
;
|
||||||
|
|
||||||
|
importEntry
|
||||||
|
: IMPORT stringLiteral SEMI
|
||||||
|
;
|
||||||
|
|
||||||
resourceKind
|
resourceKind
|
||||||
: INTERFACE
|
: INTERFACE
|
||||||
| PACKAGE
|
| PACKAGE
|
||||||
@@ -24,6 +29,20 @@ sourceBlock
|
|||||||
RBRACE
|
RBRACE
|
||||||
;
|
;
|
||||||
|
|
||||||
|
quixosSourceBlock
|
||||||
|
: LBRACE
|
||||||
|
REPOSITORY stringLiteral SEMI
|
||||||
|
(POLICY sourcePolicy SEMI REF stringLiteral SEMI)?
|
||||||
|
COMMIT stringLiteral SEMI
|
||||||
|
RBRACE
|
||||||
|
;
|
||||||
|
|
||||||
|
sourcePolicy
|
||||||
|
: PINNED
|
||||||
|
| TRACK_RELEASE
|
||||||
|
| TRACK_DEVELOPMENT
|
||||||
|
;
|
||||||
|
|
||||||
identifier
|
identifier
|
||||||
: IDENTIFIER
|
: IDENTIFIER
|
||||||
;
|
;
|
||||||
@@ -33,6 +52,7 @@ stringLiteral
|
|||||||
;
|
;
|
||||||
|
|
||||||
QUIXOS_LOCK: 'quixos-lock';
|
QUIXOS_LOCK: 'quixos-lock';
|
||||||
|
FRAGMENT: 'fragment';
|
||||||
VERSION: 'version';
|
VERSION: 'version';
|
||||||
QUIXOS: 'quixos';
|
QUIXOS: 'quixos';
|
||||||
SOURCE: 'source';
|
SOURCE: 'source';
|
||||||
@@ -40,6 +60,12 @@ INTERFACE: 'interface';
|
|||||||
PACKAGE: 'package';
|
PACKAGE: 'package';
|
||||||
REPOSITORY: 'repository';
|
REPOSITORY: 'repository';
|
||||||
COMMIT: 'commit';
|
COMMIT: 'commit';
|
||||||
|
POLICY: 'policy';
|
||||||
|
REF: 'ref';
|
||||||
|
PINNED: 'pinned';
|
||||||
|
TRACK_RELEASE: 'track-release';
|
||||||
|
TRACK_DEVELOPMENT: 'track-development';
|
||||||
|
IMPORT: 'import';
|
||||||
|
|
||||||
LBRACE: '{';
|
LBRACE: '{';
|
||||||
RBRACE: '}';
|
RBRACE: '}';
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# All source trees and recursive locks are fetched by Nix at exact retained
|
||||||
|
# revisions. No authoring-directory overlay or externally assembled schema.
|
||||||
|
{ repository, commit, kind, generator, contractOnly ? false, system ? builtins.currentSystem }:
|
||||||
|
let
|
||||||
|
pkgs = import (/. + "@nixpkgs@") { inherit system; };
|
||||||
|
protocol = builtins.storePath generator;
|
||||||
|
sourceKey = source: "${source.kind}:${source.repository}@${source.commit}";
|
||||||
|
fetch = source: builtins.fetchGit {
|
||||||
|
url = source.repository;
|
||||||
|
rev = source.commit;
|
||||||
|
ref = "refs/tags/quixos-reachability/${source.commit}";
|
||||||
|
shallow = true;
|
||||||
|
};
|
||||||
|
load = ancestors: source:
|
||||||
|
if builtins.elem (sourceKey source) ancestors then
|
||||||
|
throw "Source dependency cycle at ${sourceKey source}"
|
||||||
|
else if builtins.length ancestors >= 100 then
|
||||||
|
throw "Source dependency depth exceeds 100"
|
||||||
|
else let
|
||||||
|
directory = fetch source;
|
||||||
|
lockFile = pkgs.runCommand "qx-source-lock.json" { } ''
|
||||||
|
${protocol}/bin/quixos-lock-check ${directory}/quixos.lock > "$out"
|
||||||
|
'';
|
||||||
|
lock = builtins.fromJSON (builtins.readFile lockFile);
|
||||||
|
children = map (entry: load (ancestors ++ [ (sourceKey source) ]) (entry.source // { inherit (entry) kind; })) lock.resources;
|
||||||
|
in source // { inherit directory children; };
|
||||||
|
root = load [ ] { inherit kind repository commit; };
|
||||||
|
flatten = node: [ node ] ++ pkgs.lib.concatMap flatten node.children;
|
||||||
|
nodes = builtins.attrValues (builtins.listToAttrs (map (node: {
|
||||||
|
name = sourceKey node; value = node;
|
||||||
|
}) (flatten root)));
|
||||||
|
snapshots = pkgs.writeText "qx-nix-source-graph.json" (builtins.toJSON {
|
||||||
|
resources = map (node: { inherit (node) kind repository commit directory; })
|
||||||
|
(builtins.filter (node: node.kind != "workspace") nodes);
|
||||||
|
});
|
||||||
|
compile = node: pkgs.runCommand "qx-${node.kind}-contract" { } ''
|
||||||
|
mkdir -p "$out"
|
||||||
|
${if node.kind == "workspace" then ''
|
||||||
|
${protocol}/bin/quixos-workspace-compile --root ${node.directory} \
|
||||||
|
--source-root-commit ${pkgs.lib.escapeShellArg node.commit} \
|
||||||
|
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} \
|
||||||
|
--graph-out "$out/graph.json" > "$out/candidate.json"
|
||||||
|
'' else ''
|
||||||
|
${protocol}/bin/quixos-resource-compile --root ${node.directory} \
|
||||||
|
--kind ${node.kind} --repository ${pkgs.lib.escapeShellArg node.repository} \
|
||||||
|
--commit ${pkgs.lib.escapeShellArg node.commit} \
|
||||||
|
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} --snapshot-only true \
|
||||||
|
--graph-out "$out/graph.json" --schema-out "$out/bindings.json" > "$out/candidate.json"
|
||||||
|
''}
|
||||||
|
'';
|
||||||
|
contract = compile root;
|
||||||
|
checkPackage = node: let
|
||||||
|
compiled = compile node;
|
||||||
|
candidate = builtins.fromJSON (builtins.readFile "${compiled}/candidate.json");
|
||||||
|
package = builtins.getFlake ("git+${node.repository}?rev=${node.commit}&ref=refs/tags/quixos-reachability/${node.commit}");
|
||||||
|
checked = package.quixosPackages.${system}.checkedServer or
|
||||||
|
(throw "Package ${node.repository} lacks checkedServer; use the supported package scaffold.");
|
||||||
|
artifact = checked {
|
||||||
|
schema = "${compiled}/bindings.json";
|
||||||
|
generator = protocol;
|
||||||
|
packageRevisionId = candidate.revision.revisionId;
|
||||||
|
};
|
||||||
|
in { packageRevisionId = candidate.revision.revisionId; artifactPath = artifact; };
|
||||||
|
checks = if contractOnly then [ ] else map checkPackage (builtins.filter (node: node.kind == "package") nodes);
|
||||||
|
manifest = pkgs.writeText "qx-candidate-checks.json" (builtins.toJSON checks);
|
||||||
|
in pkgs.runCommand (if contractOnly then "qx-contract" else "qx-checked-candidate") { } ''
|
||||||
|
mkdir -p "$out"
|
||||||
|
cp ${contract}/candidate.json "$out/candidate.json"
|
||||||
|
cp ${contract}/graph.json "$out/graph.json"
|
||||||
|
cp ${manifest} "$out/checks.json"
|
||||||
|
${pkgs.lib.optionalString (kind != "workspace") ''cp ${contract}/bindings.json "$out/bindings.json"''}
|
||||||
|
${pkgs.lib.optionalString (kind == "package") ''
|
||||||
|
${protocol}/bin/quixos-codegen-ts ${contract}/bindings.json \
|
||||||
|
${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")}
|
||||||
|
''}
|
||||||
|
''
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# One derivation path for provisional checking and activation. The source and
|
||||||
|
# schema come from exact committed inputs resolved by the Quixos compiler.
|
||||||
|
{ source, schema, generator, packageRevisionId, system ? builtins.currentSystem }:
|
||||||
|
let
|
||||||
|
packageSource = builtins.path {
|
||||||
|
path = /. + source;
|
||||||
|
name = "quixos-package-source";
|
||||||
|
filter = path: _: let name = baseNameOf path; in name != ".git" && name != ".jj";
|
||||||
|
};
|
||||||
|
package = builtins.getFlake ("path:" + builtins.unsafeDiscardStringContext (toString packageSource));
|
||||||
|
checked = package.quixosPackages.${system}.checkedServer or
|
||||||
|
(throw "Package ${packageRevisionId} lacks checkedServer; use the supported package scaffold.");
|
||||||
|
in checked {
|
||||||
|
inherit packageRevisionId;
|
||||||
|
schema = builtins.path { path = /. + schema; name = "candidate-package-bindings.json"; };
|
||||||
|
generator = builtins.storePath generator;
|
||||||
|
}
|
||||||
+7
-1
@@ -6,10 +6,15 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"quixos-capability-compile": "dist/src/capability-language/cli.js",
|
"quixos-capability-compile": "dist/src/capability-language/cli.js",
|
||||||
|
"quixos-codegen-ts": "dist/src/bindings/cli.js",
|
||||||
"quixos-descriptor-check": "dist/src/descriptor-check.js",
|
"quixos-descriptor-check": "dist/src/descriptor-check.js",
|
||||||
"quixos-lock-check": "dist/src/resource-lock/cli.js"
|
"quixos-lock-check": "dist/src/resource-lock/cli.js",
|
||||||
|
"quixos-qx": "dist/src/capability-language/tool-cli.js",
|
||||||
|
"quixos-resource-compile": "dist/src/capability-language/resource-cli.js",
|
||||||
|
"quixos-workspace-compile": "dist/src/capability-language/workspace-cli.js"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
|
"./bindings": "./dist/src/bindings/index.js",
|
||||||
"./capability-model": "./dist/src/capability-model/index.js",
|
"./capability-model": "./dist/src/capability-model/index.js",
|
||||||
"./capability-language": "./dist/src/capability-language/index.js",
|
"./capability-language": "./dist/src/capability-language/index.js",
|
||||||
"./resource-lock": "./dist/src/resource-lock/index.js",
|
"./resource-lock": "./dist/src/resource-lock/index.js",
|
||||||
@@ -24,6 +29,7 @@
|
|||||||
"typecheck": "yarn generate && tsc --noEmit"
|
"typecheck": "yarn generate && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.28.0",
|
||||||
"@bufbuild/protobuf": "^2.12.1",
|
"@bufbuild/protobuf": "^2.12.1",
|
||||||
"@bufbuild/protoc-gen-es": "^2.12.1",
|
"@bufbuild/protoc-gen-es": "^2.12.1",
|
||||||
"antlr4ng": "^3.0.16"
|
"antlr4ng": "^3.0.16"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ service CaminoService {
|
|||||||
rpc ConnectEdge(ConnectEdgeRequest) returns (ConnectEdgeResponse);
|
rpc ConnectEdge(ConnectEdgeRequest) returns (ConnectEdgeResponse);
|
||||||
rpc ResolveEdge(ResolveEdgeRequest) returns (ResolveEdgeResponse);
|
rpc ResolveEdge(ResolveEdgeRequest) returns (ResolveEdgeResponse);
|
||||||
rpc DisconnectEdge(DisconnectEdgeRequest) returns (DisconnectEdgeResponse);
|
rpc DisconnectEdge(DisconnectEdgeRequest) returns (DisconnectEdgeResponse);
|
||||||
|
rpc ReadCollection(ResolveEdgeRequest) returns (ReadCollectionResponse);
|
||||||
|
rpc ReplaceCollection(ReplaceCollectionRequest) returns (ReadCollectionResponse);
|
||||||
rpc ListOps(ListOpsRequest) returns (ListOpsResponse);
|
rpc ListOps(ListOpsRequest) returns (ListOpsResponse);
|
||||||
rpc WatchObject(WatchObjectRequest) returns (stream WatchObjectEvent);
|
rpc WatchObject(WatchObjectRequest) returns (stream WatchObjectEvent);
|
||||||
}
|
}
|
||||||
@@ -35,6 +37,10 @@ message StateValueSource {
|
|||||||
string value_type_json = 3;
|
string value_type_json = 3;
|
||||||
string storage_policy_json = 4;
|
string storage_policy_json = 4;
|
||||||
uint64 revision = 5;
|
uint64 revision = 5;
|
||||||
|
// CRDT-backed state is still read as its materialized Value. The snapshot
|
||||||
|
// travels with the writable source identity so a client can retain and
|
||||||
|
// advance a local replica without exposing the document to package code.
|
||||||
|
CrdtValue crdt_snapshot = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ValueSource { StateValueSource state = 1; }
|
message ValueSource { StateValueSource state = 1; }
|
||||||
@@ -104,12 +110,32 @@ message ResolveEdgeResponse { repeated CaminoEdge edges = 1; }
|
|||||||
message DisconnectEdgeRequest { string edge_id = 1; }
|
message DisconnectEdgeRequest { string edge_id = 1; }
|
||||||
message DisconnectEdgeResponse { string edge_id = 1; }
|
message DisconnectEdgeResponse { string edge_id = 1; }
|
||||||
|
|
||||||
|
message CollectionEntry {
|
||||||
|
// Existing entry identity to preserve; empty allocates a new canonical edge.
|
||||||
|
string edge_id = 1;
|
||||||
|
string target_object_id = 2;
|
||||||
|
Value key = 3;
|
||||||
|
}
|
||||||
|
message ReadCollectionResponse {
|
||||||
|
uint64 revision = 1;
|
||||||
|
repeated CollectionEntry entries = 2;
|
||||||
|
}
|
||||||
|
message ReplaceCollectionRequest {
|
||||||
|
string object_id = 1;
|
||||||
|
string edge_type_id = 2;
|
||||||
|
string projection_id = 3;
|
||||||
|
uint64 expected_revision = 4;
|
||||||
|
repeated CollectionEntry entries = 5;
|
||||||
|
}
|
||||||
|
|
||||||
message ListOpsRequest { string object_id = 1; }
|
message ListOpsRequest { string object_id = 1; }
|
||||||
message ListOpsResponse { repeated CaminoOp ops = 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;
|
||||||
bool include_snapshot = 3;
|
bool include_snapshot = 3;
|
||||||
|
// Managed runtimes must watch only their injected attachments.
|
||||||
|
repeated string attachment_ids = 4;
|
||||||
}
|
}
|
||||||
message WatchObjectEvent {
|
message WatchObjectEvent {
|
||||||
string object_id = 1;
|
string object_id = 1;
|
||||||
@@ -137,6 +163,8 @@ message CaminoEdge {
|
|||||||
optional int32 first_ordinal = 7;
|
optional int32 first_ordinal = 7;
|
||||||
optional int32 second_ordinal = 8;
|
optional int32 second_ordinal = 8;
|
||||||
string created_at = 9;
|
string created_at = 9;
|
||||||
|
string first_key_json = 10;
|
||||||
|
string second_key_json = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CaminoOp {
|
message CaminoOp {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ message AtomDefinition {
|
|||||||
message AtomConformance {
|
message AtomConformance {
|
||||||
string atom_id = 1;
|
string atom_id = 1;
|
||||||
string interface_revision_id = 2;
|
string interface_revision_id = 2;
|
||||||
|
string conformance_id = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message StateAttachment {
|
message StateAttachment {
|
||||||
@@ -27,6 +28,7 @@ message StateAttachment {
|
|||||||
string value_type_json = 4;
|
string value_type_json = 4;
|
||||||
string storage_policy_json = 5;
|
string storage_policy_json = 5;
|
||||||
string default_value_json = 6;
|
string default_value_json = 6;
|
||||||
|
string owner_conformance_id = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
message EndpointConstraint {
|
message EndpointConstraint {
|
||||||
@@ -42,6 +44,13 @@ message EdgeEndpoint {
|
|||||||
EndpointConstraint constraint = 3;
|
EndpointConstraint constraint = 3;
|
||||||
Cardinality cardinality = 4;
|
Cardinality cardinality = 4;
|
||||||
bool ordered = 5;
|
bool ordered = 5;
|
||||||
|
// Empty means restrict. Direction is the endpoint being deleted.
|
||||||
|
string on_delete = 6;
|
||||||
|
bool retain_other = 7;
|
||||||
|
// Empty for sets/lists, otherwise string, boolean, or int64 map keys.
|
||||||
|
string key_type = 8;
|
||||||
|
// Explicit read-only dependency injection traversal, not mutation authority.
|
||||||
|
bool public_traversal = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
message EdgeAttachment {
|
message EdgeAttachment {
|
||||||
@@ -49,6 +58,7 @@ message EdgeAttachment {
|
|||||||
string display_name = 2;
|
string display_name = 2;
|
||||||
EdgeEndpoint first = 3;
|
EdgeEndpoint first = 3;
|
||||||
EdgeEndpoint second = 4;
|
EdgeEndpoint second = 4;
|
||||||
|
string owner_conformance_id = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Camino consumes this persistence-only projection of a checked workspace.
|
// Camino consumes this persistence-only projection of a checked workspace.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ service OrchestratorRuntime {
|
|||||||
rpc InvokeCapability(InvokeCapabilityRequest) returns (InvokeCapabilityResponse);
|
rpc InvokeCapability(InvokeCapabilityRequest) 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 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);
|
||||||
@@ -24,10 +25,23 @@ message ConstructObjectRequest {
|
|||||||
}
|
}
|
||||||
message ConstructObjectResponse { camino.CaminoObject object = 1; }
|
message ConstructObjectResponse { camino.CaminoObject object = 1; }
|
||||||
|
|
||||||
|
message ResolveOrConstructRelatedObjectRequest {
|
||||||
|
string object_id = 1;
|
||||||
|
string interface_revision_id = 2;
|
||||||
|
string member_id = 3;
|
||||||
|
}
|
||||||
|
message ResolveOrConstructRelatedObjectResponse {
|
||||||
|
camino.CaminoObject object = 1;
|
||||||
|
bool constructed = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message InvokeCapabilityRequest {
|
message InvokeCapabilityRequest {
|
||||||
quixos.CapabilityRef capability = 1;
|
quixos.CapabilityRef capability = 1;
|
||||||
string object_id = 2;
|
string object_id = 2;
|
||||||
map<string, camino.Value> input = 3;
|
map<string, camino.Value> input = 3;
|
||||||
|
// Identifies one logical client mutation across the capability and Camino
|
||||||
|
// layers so a live-value controller can recognize its own confirmation.
|
||||||
|
string client_mutation_id = 4;
|
||||||
}
|
}
|
||||||
message InvokeCapabilityResponse {
|
message InvokeCapabilityResponse {
|
||||||
string invocation_id = 1;
|
string invocation_id = 1;
|
||||||
@@ -35,6 +49,7 @@ message InvokeCapabilityResponse {
|
|||||||
bool ok = 3;
|
bool ok = 3;
|
||||||
camino.Value result = 4;
|
camino.Value result = 4;
|
||||||
string error = 5;
|
string error = 5;
|
||||||
|
repeated quixos.runtime.DerivedDependency dependencies = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message WatchCapabilityRequest {
|
message WatchCapabilityRequest {
|
||||||
@@ -57,6 +72,20 @@ 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
|
||||||
|
// this with its temporary Createable marker; the marker is not a factory.
|
||||||
|
repeated string empty_input_constructible_atom_ids = 4;
|
||||||
|
repeated CapabilityInputContract capability_inputs = 5;
|
||||||
|
repeated ConstructorInputContract constructor_inputs = 6;
|
||||||
|
}
|
||||||
|
message CapabilityInputContract {
|
||||||
|
string interface_revision_id = 1;
|
||||||
|
string operation_id = 2;
|
||||||
|
string type_json = 3;
|
||||||
|
}
|
||||||
|
message ConstructorInputContract {
|
||||||
|
string atom_id = 1;
|
||||||
|
string type_json = 2;
|
||||||
}
|
}
|
||||||
message ListActivationsRequest {}
|
message ListActivationsRequest {}
|
||||||
message ListPackageDescriptorsRequest {}
|
message ListPackageDescriptorsRequest {}
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ message InjectedDependency {
|
|||||||
oneof binding {
|
oneof binding {
|
||||||
string state_slot_id = 2;
|
string state_slot_id = 2;
|
||||||
EdgeDependency edge = 3;
|
EdgeDependency edge = 3;
|
||||||
string receiver_interface_revision_id = 4;
|
string interface_revision_id = 4;
|
||||||
string constructor_atom_id = 5;
|
string constructor_atom_id = 5;
|
||||||
}
|
}
|
||||||
|
// Defaults to the invocation receiver. A checked dependency traversal can
|
||||||
|
// select a related object explicitly before the package runtime starts.
|
||||||
|
string object_id = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message EdgeDependency {
|
message EdgeDependency {
|
||||||
|
|||||||
@@ -9,13 +9,37 @@ service PackageRuntime {
|
|||||||
rpc Handshake(HandshakeRequest) returns (HandshakeResponse);
|
rpc Handshake(HandshakeRequest) returns (HandshakeResponse);
|
||||||
rpc Invoke(InvokeRequest) returns (InvokeResponse);
|
rpc Invoke(InvokeRequest) returns (InvokeResponse);
|
||||||
rpc Watch(WatchRequest) returns (stream WatchEvent);
|
rpc Watch(WatchRequest) returns (stream WatchEvent);
|
||||||
|
rpc GetInvocationStatus(InvocationControlRequest) returns (InvocationStatus);
|
||||||
|
rpc CancelInvocation(InvocationControlRequest) returns (InvocationStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
message HandshakeRequest { string orch_protocol_version = 1; }
|
message HandshakeRequest {
|
||||||
|
string orch_protocol_version = 1;
|
||||||
|
string nonce = 2;
|
||||||
|
}
|
||||||
message HandshakeResponse {
|
message HandshakeResponse {
|
||||||
string package_revision_id = 1;
|
string package_revision_id = 1;
|
||||||
string runtime_protocol_version = 2;
|
string runtime_protocol_version = 2;
|
||||||
repeated string export_ids = 3;
|
repeated string export_ids = 3;
|
||||||
|
string instance_id = 4;
|
||||||
|
string authentication_proof = 5;
|
||||||
|
repeated string capabilities = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InvocationContext {
|
||||||
|
string workspace_epoch = 1;
|
||||||
|
string instance_id = 2;
|
||||||
|
string binding_digest = 3;
|
||||||
|
string grant = 4;
|
||||||
|
string session_id = 5;
|
||||||
|
// Host-selected owner; packages must not invent workspace-local ownership.
|
||||||
|
string owner_conformance_id = 6;
|
||||||
|
}
|
||||||
|
message InvocationControlRequest { string invocation_id = 1; }
|
||||||
|
message InvocationStatus {
|
||||||
|
string invocation_id = 1;
|
||||||
|
// unknown, running, cancellation-requested, completed, failed
|
||||||
|
string state = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message InvokeRequest {
|
message InvokeRequest {
|
||||||
@@ -24,11 +48,13 @@ message InvokeRequest {
|
|||||||
string object_id = 3;
|
string object_id = 3;
|
||||||
map<string, camino.Value> input = 4;
|
map<string, camino.Value> input = 4;
|
||||||
repeated quixos.InjectedDependency dependencies = 5;
|
repeated quixos.InjectedDependency dependencies = 5;
|
||||||
|
InvocationContext context = 6;
|
||||||
}
|
}
|
||||||
message InvokeResponse {
|
message InvokeResponse {
|
||||||
bool ok = 1;
|
bool ok = 1;
|
||||||
camino.Value result = 2;
|
camino.Value result = 2;
|
||||||
string error = 3;
|
string error = 3;
|
||||||
|
repeated DerivedDependency dependencies = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message WatchRequest {
|
message WatchRequest {
|
||||||
@@ -37,6 +63,7 @@ message WatchRequest {
|
|||||||
string object_id = 3;
|
string object_id = 3;
|
||||||
map<string, camino.Value> input = 4;
|
map<string, camino.Value> input = 4;
|
||||||
repeated quixos.InjectedDependency dependencies = 5;
|
repeated quixos.InjectedDependency dependencies = 5;
|
||||||
|
InvocationContext context = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message DerivedDependency {
|
message DerivedDependency {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {parse} from "@babel/parser";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/** Enforce the authored bundled-code contract, not a security sandbox. */
|
||||||
|
export function bundlePolicyErrors(source: string, filename: string): string[] {
|
||||||
|
const ast = parse(source, {sourceType: "module", plugins: ["typescript", "jsx"]});
|
||||||
|
const errors: string[] = [];
|
||||||
|
const visit = (value: unknown) => {
|
||||||
|
if (Array.isArray(value)) {value.forEach(visit); return;}
|
||||||
|
if (!value || typeof value !== "object") return;
|
||||||
|
const n = value as Record<string, any>;
|
||||||
|
if (typeof n.type !== "string") return;
|
||||||
|
const fail = (message: string) => errors.push(`${filename}:${n.loc?.start.line ?? 1}: ${message}`);
|
||||||
|
if (n.type === "MetaProperty" && n.meta.name === "import") fail("Bundled package code cannot use import.meta; import packaged assets statically");
|
||||||
|
if (n.type === "Identifier" && ["__dirname", "__filename"].includes(n.name)) fail("Bundled package code cannot depend on module filesystem locations");
|
||||||
|
if (["CallExpression", "NewExpression"].includes(n.type)) {
|
||||||
|
if (n.callee?.type === "Identifier" && ["eval", "Function"].includes(n.callee.name)) fail("Dynamic code generation is unsupported in bundled package code");
|
||||||
|
if ((n.callee?.type === "Import" || (n.callee?.type === "Identifier" && n.callee.name === "require")) &&
|
||||||
|
(n.arguments.length !== 1 || n.arguments[0].type !== "StringLiteral")) fail("Module imports must have a static string specifier");
|
||||||
|
}
|
||||||
|
if (n.type === "ImportExpression" && n.source.type !== "StringLiteral") fail("Module imports must have a static string specifier");
|
||||||
|
Object.values(n).forEach(visit);
|
||||||
|
};
|
||||||
|
visit(ast);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
export async function checkBundleSources(directory: string): Promise<void> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
async function walk(current: string) {
|
||||||
|
for (const entry of await fs.readdir(current, {withFileTypes: true})) {
|
||||||
|
if (["gen", "node_modules"].includes(entry.name)) continue;
|
||||||
|
const file = path.join(current, entry.name);
|
||||||
|
if (entry.isSymbolicLink()) throw new Error(`Authored source symlinks are unsupported: ${file}`);
|
||||||
|
if (entry.isDirectory()) await walk(file);
|
||||||
|
else if (/\.[cm]?[jt]sx?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) errors.push(...bundlePolicyErrors(await fs.readFile(file, "utf8"), file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await walk(directory);
|
||||||
|
if (errors.length) throw new Error(errors.join("\n"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { generateTypeScriptBindings } from "./index.js";
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const [schema, revision, output, options, ...rest] = process.argv.slice(2);
|
||||||
|
if (!schema || !revision || !output || rest.length) throw new Error(
|
||||||
|
"usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]");
|
||||||
|
const generated = generateTypeScriptBindings(JSON.parse(await readFile(schema, "utf8")), revision,
|
||||||
|
options ? JSON.parse(await readFile(options, "utf8")) : {});
|
||||||
|
await writeFile(output, generated);
|
||||||
|
};
|
||||||
|
main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type {InterfaceRevision, ValueType} from "../capability-model/types.js";
|
||||||
|
|
||||||
|
/** Host clients have no package receiver, but must use the same checked
|
||||||
|
* interface signatures and argument framing as generated package ports. */
|
||||||
|
export const generateClientContracts = (interfaces: InterfaceRevision[], messages: Record<string, string>) => {
|
||||||
|
const type = (value: ValueType): string => {
|
||||||
|
switch (value.kind) {
|
||||||
|
case "builtin": return value.name === "unit" ? "undefined" : "string";
|
||||||
|
case "scalar": return ({bool: "boolean", string: "string", bytes: "Uint8Array", int32: "number", uint32: "number", double: "number", int64: "bigint", uint64: "bigint"})[value.name];
|
||||||
|
case "object-ref": return `{readonly $quixosRef: string}`;
|
||||||
|
case "optional": return `(${type(value.value)} | null)`;
|
||||||
|
case "list": return `Array<${type(value.value)}>`;
|
||||||
|
case "record": return `{${Object.entries(value.fields).map(([name, field]) => `${JSON.stringify(name)}${field.kind === "optional" ? "?" : ""}: ${type(field)}`).join("; ")}}`;
|
||||||
|
case "message": {
|
||||||
|
const binding = messages[value.descriptorId];
|
||||||
|
if (!binding) throw new Error(`Missing host message type ${value.descriptorId}`);
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const operations = interfaces.flatMap(iface => iface.members.flatMap(member => member.operations
|
||||||
|
.filter(operation => operation.mode === "call").map(operation => ({...operation, interfaceRevisionId: iface.revisionId}))));
|
||||||
|
return `// Generated from checked QX interfaces. Regenerate with scripts/generate-platform-contracts.mjs.\n` +
|
||||||
|
`export type PlatformInputs = {\n${operations.map(operation => ` ${JSON.stringify(operation.id)}: ${type(operation.inputType)};`).join("\n")}\n};\n` +
|
||||||
|
`export const platformOperations = ${JSON.stringify(Object.fromEntries(operations.map(operation => [operation.id, {
|
||||||
|
interfaceRevisionId: operation.interfaceRevisionId,
|
||||||
|
input: operation.inputType.kind === "builtin" && operation.inputType.name === "unit" ? "unit" : ["record", "message"].includes(operation.inputType.kind) ? "fields" : "value",
|
||||||
|
}])), null, 2)} as const;\n`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import type { InterfaceRevision, PackageRevision, ValueType, DependencyPort } from "../capability-model/types.js";
|
||||||
|
import type { CompiledCapabilityResourceRepository } from "../capability-language/assembly.js";
|
||||||
|
|
||||||
|
/** Portable generator input. New backends consume this instead of the parser or TS runtime. */
|
||||||
|
export type BindingSchema = {
|
||||||
|
format: "quixos-bindings";
|
||||||
|
version: 1;
|
||||||
|
interfaces: InterfaceRevision[];
|
||||||
|
packages: PackageRevision[];
|
||||||
|
};
|
||||||
|
export const bindingSchema = (compiled: CompiledCapabilityResourceRepository): BindingSchema => ({
|
||||||
|
format: "quixos-bindings", version: 1,
|
||||||
|
interfaces: compiled.resources.flatMap((node) => node.resource.kind === "interface" ? [node.resource.revision] : []),
|
||||||
|
packages: compiled.resources.flatMap((node) => node.resource.kind === "package" ? [node.resource.revision] : []),
|
||||||
|
});
|
||||||
|
export type TypeScriptBindingOptions = {
|
||||||
|
runtimeModule?: string;
|
||||||
|
/** Each export must implement MessageBinding<T>, providing both TS type and wire codec. */
|
||||||
|
messages?: Record<string, { module: string; export: string }>;
|
||||||
|
};
|
||||||
|
export function generatePackageDescriptor(schema: BindingSchema, revisionId: string): string {
|
||||||
|
const pkg = schema.packages.find(entry => entry.revisionId === revisionId);
|
||||||
|
if (!pkg) throw new Error(`Unknown package revision ${revisionId}`);
|
||||||
|
return `# Generated from the checked package contract\npackage_id: ${JSON.stringify(pkg.packageId)}\npackage_revision_id: ${JSON.stringify(pkg.revisionId)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` +
|
||||||
|
pkg.exports.map(entry => `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.displayName)} }\n`).join("");
|
||||||
|
}
|
||||||
|
const q = JSON.stringify;
|
||||||
|
const object = (entries: [string, string][]) => `{ ${entries.map(([key, value]) => `${q(key)}: ${value}`).join("; ")} }`;
|
||||||
|
const unit = (type: ValueType) => type.kind === "builtin" && type.name === "unit";
|
||||||
|
|
||||||
|
export const generateTypeScriptBindings = (
|
||||||
|
schema: BindingSchema, packageRevisionId: string, options: TypeScriptBindingOptions = {},
|
||||||
|
) => {
|
||||||
|
if (schema.format !== "quixos-bindings" || schema.version !== 1) throw new Error("Unsupported binding schema version");
|
||||||
|
const pkg = schema.packages.find((entry) => entry.revisionId === packageRevisionId);
|
||||||
|
if (!pkg) throw new Error(`Unknown package revision ${packageRevisionId}`);
|
||||||
|
const messages = new Map<string, string>();
|
||||||
|
const type = (value: ValueType): string => {
|
||||||
|
switch (value.kind) {
|
||||||
|
case "builtin": return value.name === "unit" ? "null" : "QxWatchHandle";
|
||||||
|
case "scalar": return ({ bool: "boolean", bytes: "Uint8Array", string: "string", int64: "bigint", uint64: "bigint",
|
||||||
|
double: "number", int32: "number", uint32: "number" })[value.name];
|
||||||
|
case "object-ref": return `QxObjectRef<${q(value.expectation.kind === "atom" ? `atom:${value.expectation.atomId}` : `interface:${value.expectation.interfaceRevisionId}`)}>`;
|
||||||
|
case "optional": return `(${type(value.value)} | null)`;
|
||||||
|
case "list": return `Array<${type(value.value)}>`;
|
||||||
|
case "record": return `{ ${Object.entries(value.fields).map(([name, field]) => `${q(name)}: ${type(field)}`).join("; ")} }`;
|
||||||
|
case "message": {
|
||||||
|
if (!options.messages?.[value.descriptorId]) throw new Error(`Missing TypeScript message binding for ${value.descriptorId}`);
|
||||||
|
if (!messages.has(value.descriptorId)) messages.set(value.descriptorId, `message${messages.size}`);
|
||||||
|
return `BindingValue<typeof ${messages.get(value.descriptorId)}>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ref = (target: { kind: "atom"; atomId: string } | { kind: "interface"; interfaceRevisionId: string }) =>
|
||||||
|
`QxObjectRef<${q(target.kind === "atom" ? `atom:${target.atomId}` : `interface:${target.interfaceRevisionId}`)}>`;
|
||||||
|
const params = (input: ValueType) => unit(input) ? "" : `input: ${type(input)}`;
|
||||||
|
const port = (entry: DependencyPort): { type: string; spec: unknown } => {
|
||||||
|
const requirement = entry.requirement;
|
||||||
|
switch (requirement.kind) {
|
||||||
|
case "state": {
|
||||||
|
const methods = requirement.primitives.map((primitive): [string, string] => {
|
||||||
|
if (primitive === "read") return ["get", `() => Promise<${type(requirement.valueType)}>`];
|
||||||
|
if (primitive === "write") return ["set", `(value: ${type(requirement.valueType)}) => Promise<void>`];
|
||||||
|
throw new Error(`State primitive ${primitive} is not supported by the TypeScript runtime binding yet`);
|
||||||
|
});
|
||||||
|
if (requirement.primitives.includes("read")) methods.push(["live", "() => Promise<QxLiveValue>"]);
|
||||||
|
return { type: object(methods), spec: { ...requirement, id: entry.id } };
|
||||||
|
}
|
||||||
|
case "edge": {
|
||||||
|
const methods = requirement.primitives.map((primitive): [string, string] => {
|
||||||
|
if (primitive === "resolve") return [primitive, `() => Promise<Array<${ref(requirement.target)}>>`];
|
||||||
|
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`);
|
||||||
|
});
|
||||||
|
if (requirement.primitives.includes("resolve")) 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 } };
|
||||||
|
}
|
||||||
|
case "interface": {
|
||||||
|
const contract = schema.interfaces.find((candidate) => candidate.revisionId === requirement.interfaceRevisionId);
|
||||||
|
if (!contract) throw new Error(`Missing imported interface contract ${requirement.interfaceRevisionId}`);
|
||||||
|
// Streaming ports need a future streaming ABI; ordinary calls are fully typed today.
|
||||||
|
const operations = contract.members.flatMap((member) => member.operations.filter((operation) => operation.mode === "call")
|
||||||
|
.map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` })));
|
||||||
|
return { type: object([
|
||||||
|
["objectId", ref({ kind: "interface", interfaceRevisionId: requirement.interfaceRevisionId })],
|
||||||
|
["live", object(operations.map(operation => [operation.name, `(${params(operation.inputType)}) => Promise<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": {
|
||||||
|
const input = requirement.inputType;
|
||||||
|
if (!input) throw new Error(`Constructor port ${entry.id} needs an explicit input contract: add 'input TYPE' after ${requirement.atomId} in QX`);
|
||||||
|
return { type: object([["construct", `(${params(input)}) => Promise<${ref({ kind: "atom", atomId: requirement.atomId })}>`]]),
|
||||||
|
spec: { kind: "constructor", id: entry.id, inputType: input } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const exports = [...pkg.exports].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
||||||
|
const names = new Set<string>();
|
||||||
|
const specs: Record<string, unknown> = {};
|
||||||
|
const contexts: [string, string][] = [];
|
||||||
|
const handlers: [string, string][] = [];
|
||||||
|
for (const entry of exports) {
|
||||||
|
if (names.has(entry.displayName)) throw new Error(`Duplicate export name ${entry.displayName}`);
|
||||||
|
names.add(entry.displayName);
|
||||||
|
const ports = entry.dependencyPorts.map((dependency) => ({ name: dependency.displayName, ...port(dependency) }));
|
||||||
|
if (new Set(ports.map((p) => p.name)).size !== ports.length) throw new Error(`Duplicate dependency name in ${entry.displayName}`);
|
||||||
|
const receiver = entry.kind === "constructor" ? ref({ kind: "atom", atomId: entry.constructsAtom }) :
|
||||||
|
entry.kind === "operation" && entry.receiverRequirement.kind === "exact-atom" ?
|
||||||
|
ref({ kind: "atom", atomId: entry.receiverRequirement.atomId }) :
|
||||||
|
entry.kind === "operation" && entry.receiverRequirement.kind === "all-interfaces" ?
|
||||||
|
`QxObjectRef<${entry.receiverRequirement.interfaceRevisionIds.map((id) => q(`interface:${id}`)).join(" | ") || "never"}>` : "QxObjectRef<string>";
|
||||||
|
const contextShape = object([["objectId", receiver], ["input", type(entry.inputType)],
|
||||||
|
["ports", object(ports.map((port) => [port.name, port.type]))]]);
|
||||||
|
contexts.push([entry.displayName, `${contextShape} & QxContextLifecycle<${contextShape} & {signal?: AbortSignal}>`]);
|
||||||
|
const event = entry.kind === "operation" ? entry.eventType : undefined;
|
||||||
|
const contextType = `Contexts[${q(entry.displayName)}]`;
|
||||||
|
const outputType = type(event ?? entry.outputType);
|
||||||
|
// Watch-start handlers produce events through the runtime's derived stream protocol.
|
||||||
|
handlers.push([entry.displayName, event ? `QxDerived<${contextType}, ${outputType}>` :
|
||||||
|
`QxHandler<${contextType}, ${outputType}>${entry.kind === "operation" && entry.mode === "call" ? ` | QxDerived<${contextType}, ${outputType}>` : ""}`]);
|
||||||
|
specs[entry.displayName] = { inputType: entry.inputType, outputType: entry.outputType,
|
||||||
|
...(event ? { eventType: event } : {}), ports: Object.fromEntries(ports.map((port) => [port.name, port.spec])) };
|
||||||
|
}
|
||||||
|
const imports = [...messages].map(([id, alias]) => {
|
||||||
|
const binding = options.messages![id]!;
|
||||||
|
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(binding.export)) throw new Error(`Invalid message binding export ${binding.export}`);
|
||||||
|
return `import { ${binding.export} as ${alias} } from ${q(binding.module)};`;
|
||||||
|
});
|
||||||
|
const signatures = `${object(contexts)} ${object(handlers)}`;
|
||||||
|
const typeImports = ["BindingValue", "QxObjectRef", "QxWatchHandle", "QxHandler", "QxDerived", "QxContextLifecycle", "QxLiveValue", "RelationshipCollection", "RelationshipEntry"].filter((name) => new RegExp(`\\b${name}\\b`).test(signatures));
|
||||||
|
return `// Generated by quixos-codegen-ts. Do not edit. Binding ABI version 1.\n` +
|
||||||
|
`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` +
|
||||||
|
`export type Contexts = ${object(contexts)};\nexport type Implementation = ${object(handlers)};\n` +
|
||||||
|
`const messages = { ${[...messages].map(([id, alias]) => `${q(id)}: ${alias}`).join(", ")} } satisfies QxMessages;\n` +
|
||||||
|
`const specs = ${JSON.stringify(specs, null, 2)} satisfies Record<string, QxHandlerSpec>;\n` +
|
||||||
|
`export const createRuntime = (implementation: Implementation) => ({\n packageRevisionId,\n exports: {\n` +
|
||||||
|
exports.map((entry) => ` ${q(entry.id)}: bindQxHandler(specs[${q(entry.displayName)}], implementation[${q(entry.displayName)}], messages),`).join("\n") +
|
||||||
|
`\n },\n});\n`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import { readFile, realpath } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { loadQxSources, resolveQxSources } from "./source-loader.js";
|
||||||
|
import {
|
||||||
|
capabilityId,
|
||||||
|
compileWorkspaceRevision,
|
||||||
|
validateMigrationCatalog,
|
||||||
|
contentDigest,
|
||||||
|
type AtomDefinition,
|
||||||
|
type CompiledWorkspaceRevision,
|
||||||
|
type InterfaceRevision,
|
||||||
|
type PackageRevision,
|
||||||
|
type SourceRevision,
|
||||||
|
type WorkspaceRevision,
|
||||||
|
} from "../capability-model/index.js";
|
||||||
|
import {
|
||||||
|
loadQuixosLock,
|
||||||
|
type GitSource,
|
||||||
|
type LockedResource,
|
||||||
|
type QuixosRepositoryLock,
|
||||||
|
} from "../resource-lock/index.js";
|
||||||
|
import {
|
||||||
|
compileCapabilityResourceSource,
|
||||||
|
compileCapabilitySource,
|
||||||
|
type CapabilityImportEnvironment,
|
||||||
|
type CapabilityResource,
|
||||||
|
type CapabilityResourceImport,
|
||||||
|
} from "./parser.js";
|
||||||
|
|
||||||
|
export type CapabilityRepositorySnapshot = {
|
||||||
|
directory: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CapabilityRepositoryResolver = (
|
||||||
|
source: GitSource,
|
||||||
|
kind: LockedResource["kind"],
|
||||||
|
) => Promise<CapabilityRepositorySnapshot>;
|
||||||
|
|
||||||
|
export type ResolvedCapabilityResource = {
|
||||||
|
key: string;
|
||||||
|
kind: LockedResource["kind"];
|
||||||
|
source: GitSource;
|
||||||
|
directory: string;
|
||||||
|
lock: QuixosRepositoryLock;
|
||||||
|
resource: CapabilityResource;
|
||||||
|
dependencies: ReadonlyMap<string, ResolvedCapabilityResource>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompiledWorkspaceRepository = {
|
||||||
|
workspace: WorkspaceRevision;
|
||||||
|
plan: CompiledWorkspaceRevision;
|
||||||
|
lock: QuixosRepositoryLock;
|
||||||
|
resources: ResolvedCapabilityResource[];
|
||||||
|
directResources: ReadonlyMap<string, ResolvedCapabilityResource>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompiledCapabilityResourceRepository = {
|
||||||
|
resource: CapabilityResource;
|
||||||
|
lock: QuixosRepositoryLock;
|
||||||
|
resources: ResolvedCapabilityResource[];
|
||||||
|
directResources: ReadonlyMap<string, ResolvedCapabilityResource>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceKey = (kind: LockedResource["kind"], source: GitSource) =>
|
||||||
|
`${kind}\0${source.repository}\0${source.commit.toLowerCase()}`;
|
||||||
|
|
||||||
|
const bindingKey = (kind: LockedResource["kind"], binding: string) =>
|
||||||
|
`${kind}\0${binding}`;
|
||||||
|
|
||||||
|
const importsKey = (entry: CapabilityResourceImport | LockedResource) =>
|
||||||
|
bindingKey(entry.kind, entry.binding);
|
||||||
|
|
||||||
|
const assertImportsMatchLock = (
|
||||||
|
label: string,
|
||||||
|
imports: readonly CapabilityResourceImport[],
|
||||||
|
resources: readonly LockedResource[],
|
||||||
|
) => {
|
||||||
|
const authored = [...imports].map(importsKey).sort();
|
||||||
|
const locked = [...resources].map(importsKey).sort();
|
||||||
|
if (
|
||||||
|
authored.length !== locked.length ||
|
||||||
|
authored.some((entry, index) => entry !== locked[index])
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`${label} imports do not match quixos.lock:\n` +
|
||||||
|
`authored: ${authored.join(", ") || "none"}\n` +
|
||||||
|
`locked: ${locked.join(", ") || "none"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exactRevisions = <Revision extends {
|
||||||
|
revisionId: string;
|
||||||
|
source: SourceRevision;
|
||||||
|
}>(revisions: readonly Revision[]) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return revisions.filter((revision) => {
|
||||||
|
const key = `${revision.revisionId}\0${revision.source.repository}\0${revision.source.commit}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const exactAtoms = (atoms: readonly AtomDefinition[]) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return atoms.filter((atom) => {
|
||||||
|
if (seen.has(atom.id)) return false;
|
||||||
|
seen.add(atom.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const diagnosticsMessage = (
|
||||||
|
label: string,
|
||||||
|
diagnostics: readonly {
|
||||||
|
fileName: string;
|
||||||
|
line: number;
|
||||||
|
column: number;
|
||||||
|
phase: string;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
path?: string;
|
||||||
|
}[],
|
||||||
|
) => `${label} did not compile:\n${diagnostics.map((entry) => {
|
||||||
|
const location = entry.line > 0
|
||||||
|
? `${entry.fileName}:${entry.line}:${entry.column + 1}`
|
||||||
|
: `${entry.fileName}${entry.path ? `:${entry.path}` : ""}`;
|
||||||
|
return `${location}: ${entry.phase} ${entry.code}: ${entry.message}`;
|
||||||
|
}).join("\n")}`;
|
||||||
|
|
||||||
|
const revisionFor = (node: ResolvedCapabilityResource) =>
|
||||||
|
node.resource.revision;
|
||||||
|
|
||||||
|
const resourceClosure = (
|
||||||
|
roots: readonly ResolvedCapabilityResource[],
|
||||||
|
): ResolvedCapabilityResource[] => {
|
||||||
|
const result: ResolvedCapabilityResource[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const visit = (node: ResolvedCapabilityResource) => {
|
||||||
|
if (seen.has(node.key)) return;
|
||||||
|
seen.add(node.key);
|
||||||
|
for (const dependency of node.dependencies.values()) visit(dependency);
|
||||||
|
result.push(node);
|
||||||
|
};
|
||||||
|
for (const root of roots) visit(root);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const environmentFor = (
|
||||||
|
direct: readonly [LockedResource, ResolvedCapabilityResource][],
|
||||||
|
closure: readonly ResolvedCapabilityResource[],
|
||||||
|
): CapabilityImportEnvironment => ({
|
||||||
|
interfaces: new Map(direct.flatMap(([locked, node]) =>
|
||||||
|
node.resource.kind === "interface"
|
||||||
|
? [[locked.binding, node.resource.revision] as const]
|
||||||
|
: [])),
|
||||||
|
packages: new Map(direct.flatMap(([locked, node]) =>
|
||||||
|
node.resource.kind === "package"
|
||||||
|
? [[locked.binding, node.resource.revision] as const]
|
||||||
|
: [])),
|
||||||
|
interfaceClosure: exactRevisions(closure.flatMap((node) =>
|
||||||
|
node.resource.kind === "interface" ? [node.resource.revision] : [])),
|
||||||
|
packageClosure: exactRevisions(closure.flatMap((node) =>
|
||||||
|
node.resource.kind === "package" ? [node.resource.revision] : [])),
|
||||||
|
externalAtoms: exactAtoms(closure.flatMap((node) => node.resource.externalAtoms)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createResourceGraphResolver = (
|
||||||
|
quixosCommit: string,
|
||||||
|
resolveResource: CapabilityRepositoryResolver,
|
||||||
|
) => {
|
||||||
|
const resolved = new Map<string, Promise<ResolvedCapabilityResource>>();
|
||||||
|
const active: string[] = [];
|
||||||
|
|
||||||
|
const visit = async (
|
||||||
|
locked: LockedResource,
|
||||||
|
suppliedSnapshot?: CapabilityRepositorySnapshot,
|
||||||
|
): Promise<ResolvedCapabilityResource> => {
|
||||||
|
const key = sourceKey(locked.kind, locked.source);
|
||||||
|
const cached = resolved.get(key);
|
||||||
|
if (cached) {
|
||||||
|
if (active.includes(key)) {
|
||||||
|
throw new Error(`Resource dependency cycle: ${[...active, key].join(" -> ")}`);
|
||||||
|
}
|
||||||
|
return await cached;
|
||||||
|
}
|
||||||
|
const pending = (async () => {
|
||||||
|
active.push(key);
|
||||||
|
try {
|
||||||
|
const snapshot = suppliedSnapshot ?? await resolveResource(locked.source, locked.kind);
|
||||||
|
const lockResult = await loadQuixosLock(path.join(snapshot.directory, "quixos.lock"));
|
||||||
|
if (!lockResult.ok) {
|
||||||
|
throw new Error(diagnosticsMessage(`${locked.kind} ${locked.binding} lock`, lockResult.diagnostics));
|
||||||
|
}
|
||||||
|
if (lockResult.lock.quixos.policy) {
|
||||||
|
throw new Error(
|
||||||
|
`${locked.kind} ${locked.binding} lock declares workspace Quixos policy ` +
|
||||||
|
`${lockResult.lock.quixos.policy}; resource locks may only declare their exact authored-against commit`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lockResult.lock.quixos.commit.toLowerCase() !== quixosCommit.toLowerCase()) {
|
||||||
|
throw new Error(
|
||||||
|
`${locked.kind} ${locked.binding} selects Quixos ${lockResult.lock.quixos.commit}, ` +
|
||||||
|
`but the repository graph selects ${quixosCommit}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const directPairs: Array<[LockedResource, ResolvedCapabilityResource]> = [];
|
||||||
|
for (const dependency of lockResult.lock.resources) {
|
||||||
|
directPairs.push([dependency, await visit(dependency)]);
|
||||||
|
}
|
||||||
|
const dependencyClosure = resourceClosure(
|
||||||
|
directPairs.map(([, node]) => node),
|
||||||
|
);
|
||||||
|
const environment = environmentFor(directPairs, dependencyClosure);
|
||||||
|
const manifestName = locked.kind === "interface" ? "interface.qx" : "package.qx";
|
||||||
|
const manifestPath = path.join(snapshot.directory, manifestName);
|
||||||
|
const sourceText = await readFile(manifestPath, "utf8");
|
||||||
|
const compiled = compileCapabilityResourceSource(sourceText, {
|
||||||
|
source: locked.source,
|
||||||
|
fileName: manifestPath,
|
||||||
|
environment,
|
||||||
|
});
|
||||||
|
if (!compiled.ok) {
|
||||||
|
throw new Error(diagnosticsMessage(`${locked.kind} ${locked.binding}`, compiled.diagnostics));
|
||||||
|
}
|
||||||
|
if (compiled.resource.kind !== locked.kind) {
|
||||||
|
throw new Error(
|
||||||
|
`${manifestPath} declares ${compiled.resource.kind}, not ${locked.kind}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertImportsMatchLock(manifestPath, compiled.resource.imports, lockResult.lock.resources);
|
||||||
|
if (compiled.resource.kind === "package") {
|
||||||
|
let catalogText: string | undefined;
|
||||||
|
try { catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8"); }
|
||||||
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||||
|
if (catalogText !== undefined) {
|
||||||
|
const catalog = validateMigrationCatalog(JSON.parse(catalogText), new Set(compiled.resource.revision.exports.map((entry) => entry.id)));
|
||||||
|
const root = await realpath(snapshot.directory);
|
||||||
|
for (const migration of catalog.migrations) {
|
||||||
|
const implementation = await realpath(path.join(root, migration.implementation.file));
|
||||||
|
if (!implementation.startsWith(`${root}${path.sep}`)) throw new Error("Migration implementation escapes its package");
|
||||||
|
if (contentDigest(await readFile(implementation, "utf8")) !== migration.implementation.digest) throw new Error(`Migration implementation digest mismatch: ${migration.id}`);
|
||||||
|
}
|
||||||
|
compiled.resource.revision.migrationCatalog = catalog;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
kind: locked.kind,
|
||||||
|
source: locked.source,
|
||||||
|
directory: snapshot.directory,
|
||||||
|
lock: lockResult.lock,
|
||||||
|
resource: compiled.resource,
|
||||||
|
dependencies: new Map(directPairs.map(([dependency, node]) => [
|
||||||
|
bindingKey(dependency.kind, dependency.binding),
|
||||||
|
node,
|
||||||
|
])),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
active.pop();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
resolved.set(key, pending);
|
||||||
|
return await pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
visit,
|
||||||
|
nodes: async () => await Promise.all(resolved.values()),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compileCapabilityResourceRepository = async (options: {
|
||||||
|
rootDirectory: string;
|
||||||
|
kind: LockedResource["kind"];
|
||||||
|
source: GitSource;
|
||||||
|
resolveResource: CapabilityRepositoryResolver;
|
||||||
|
}): Promise<CompiledCapabilityResourceRepository> => {
|
||||||
|
const lockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock"));
|
||||||
|
if (!lockResult.ok) {
|
||||||
|
throw new Error(diagnosticsMessage("Resource lock", lockResult.diagnostics));
|
||||||
|
}
|
||||||
|
const resolver = createResourceGraphResolver(
|
||||||
|
lockResult.lock.quixos.commit,
|
||||||
|
options.resolveResource,
|
||||||
|
);
|
||||||
|
const root = await resolver.visit({
|
||||||
|
kind: options.kind,
|
||||||
|
binding: "<root>",
|
||||||
|
source: options.source,
|
||||||
|
}, { directory: options.rootDirectory });
|
||||||
|
return {
|
||||||
|
resource: root.resource,
|
||||||
|
lock: root.lock,
|
||||||
|
resources: await resolver.nodes(),
|
||||||
|
directResources: root.dependencies,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compileWorkspaceRepository = async (options: {
|
||||||
|
rootDirectory: string;
|
||||||
|
resolveResource: CapabilityRepositoryResolver;
|
||||||
|
workspaceId?: string;
|
||||||
|
workspaceRevisionId?: string;
|
||||||
|
sourceRootCommit?: string;
|
||||||
|
/** An editor's proposed source snapshot; locks and dependency revisions remain exact. */
|
||||||
|
readSource?: (name: string) => Promise<string>;
|
||||||
|
}): Promise<CompiledWorkspaceRepository> => {
|
||||||
|
const rootLockResult = await loadQuixosLock(
|
||||||
|
path.join(options.rootDirectory, "quixos.lock"),
|
||||||
|
);
|
||||||
|
if (!rootLockResult.ok) {
|
||||||
|
throw new Error(diagnosticsMessage("Workspace lock", rootLockResult.diagnostics));
|
||||||
|
}
|
||||||
|
const rootLock = rootLockResult.lock;
|
||||||
|
const resolver = createResourceGraphResolver(rootLock.quixos.commit, options.resolveResource);
|
||||||
|
|
||||||
|
const directPairs: Array<[LockedResource, ResolvedCapabilityResource]> = [];
|
||||||
|
for (const locked of rootLock.resources) {
|
||||||
|
directPairs.push([locked, await resolver.visit(locked)]);
|
||||||
|
}
|
||||||
|
const nodes = await resolver.nodes();
|
||||||
|
const environment = environmentFor(directPairs, nodes);
|
||||||
|
const workspacePath = path.join(options.rootDirectory, "workspace.qx");
|
||||||
|
const sources = options.readSource ? await resolveQxSources(options.readSource) : await loadQxSources(options.rootDirectory);
|
||||||
|
const compiled = compileCapabilitySource(sources.source, workspacePath, environment);
|
||||||
|
if (!compiled.ok) {
|
||||||
|
throw new Error(diagnosticsMessage("Workspace", compiled.diagnostics.map((diagnostic) =>
|
||||||
|
diagnostic.line > 0 ? { ...diagnostic, ...sources.originalPosition(diagnostic.line, diagnostic.column) } : diagnostic)));
|
||||||
|
}
|
||||||
|
assertImportsMatchLock(workspacePath, compiled.imports, rootLock.resources);
|
||||||
|
const availableInterfaceIds = new Set(
|
||||||
|
nodes.flatMap((node) => node.resource.kind === "interface"
|
||||||
|
? [node.resource.revision.revisionId]
|
||||||
|
: []),
|
||||||
|
);
|
||||||
|
const availableAtomIds = new Set(compiled.workspace.atoms.map((atom) => atom.id));
|
||||||
|
for (const node of nodes) {
|
||||||
|
for (const requirement of node.resource.externalInterfaces) {
|
||||||
|
if (!availableInterfaceIds.has(requirement.revisionId)) {
|
||||||
|
throw new Error(
|
||||||
|
`${node.kind} ${node.resource.revision.displayName} requires external interface ` +
|
||||||
|
`${requirement.binding} (${requirement.revisionId}), but the workspace resource graph does not provide it`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const atom of node.resource.externalAtoms) {
|
||||||
|
if (!availableAtomIds.has(atom.id)) {
|
||||||
|
throw new Error(
|
||||||
|
`${node.kind} ${node.resource.revision.displayName} requires external atom ` +
|
||||||
|
`${atom.displayName} (${atom.id}), but the workspace does not define it`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const workspace: WorkspaceRevision = {
|
||||||
|
...compiled.workspace,
|
||||||
|
...(options.workspaceId
|
||||||
|
? { workspaceId: capabilityId.workspace(options.workspaceId) }
|
||||||
|
: {}),
|
||||||
|
...(options.workspaceRevisionId
|
||||||
|
? { id: capabilityId.workspaceRevision(options.workspaceRevisionId) }
|
||||||
|
: options.sourceRootCommit ? { id: capabilityId.workspaceRevision(`workspace-revision:${options.workspaceId ?? compiled.workspace.workspaceId}:${options.sourceRootCommit}`) } : {}),
|
||||||
|
...(options.sourceRootCommit
|
||||||
|
? { sourceRootCommit: options.sourceRootCommit }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const checked = compileWorkspaceRevision(workspace);
|
||||||
|
if (!checked.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Instantiated workspace did not compile:\n${checked.issues.map((entry) =>
|
||||||
|
`${entry.path}: ${entry.message}`).join("\n")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workspace,
|
||||||
|
plan: checked.plan,
|
||||||
|
lock: rootLock,
|
||||||
|
resources: nodes,
|
||||||
|
directResources: new Map(directPairs.map(([locked, node]) => [
|
||||||
|
bindingKey(locked.kind, locked.binding),
|
||||||
|
node,
|
||||||
|
])),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { authoringContext } from "./authoring-context.js";
|
||||||
|
import type { convergeAuthoring } from "./authoring-converge.js";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { buildImmutableCandidate, checkerIdentity } from "./checked-build.js";
|
||||||
|
import { planEvolution, type WorkspaceRevision, type EvolutionReview } from "../capability-model/index.js";
|
||||||
|
|
||||||
|
export const checkRecordName = (directory: string) => createHash("sha256").update(directory).digest("hex") + ".json";
|
||||||
|
export async function checkAuthoring(start: string, output: string, options: { baseline?: string; reviews?: string; contractOnly?: boolean } = {}) {
|
||||||
|
const started = performance.now();
|
||||||
|
const timings: Record<string, number> = {};
|
||||||
|
const context = await authoringContext(start);
|
||||||
|
const location = await fs.realpath(start);
|
||||||
|
const directory = location === context.workbench ? "root" : path.relative(context.workbench, location);
|
||||||
|
const resource = context.resources.find(entry => entry.directory === directory);
|
||||||
|
if (!resource) throw new Error("Run check from a registered repository root or the workbench");
|
||||||
|
await fs.mkdir(output, { mode: 0o700 });
|
||||||
|
const report: { directory: string; checker: string; candidateOnly: true; activationEvidence: false; commit?: string; artifactPath?: string; blockers: string[]; phase: string; output: string; compilation?: "passed"; activationReadiness?: "preserve" | "migration-required" | "blocked"; migrationRequired?: string[] } = {
|
||||||
|
directory, checker: checkerIdentity(), candidateOnly: true, activationEvidence: false, blockers: [], phase: "convergence", output,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
// Serialize only source capture, not the potentially slow Nix build.
|
||||||
|
// Repository-scoped agents can check separate immutable candidates in parallel.
|
||||||
|
const captured = await promisify(callback)("quixos-qx", ["converge", context.workbench, directory], {
|
||||||
|
maxBuffer: 4 * 1024 * 1024, env: {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"},
|
||||||
|
}).catch(error => {
|
||||||
|
if (typeof error.stdout === "string" && error.stdout.trim().startsWith("{")) return {stdout: error.stdout};
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const converged = JSON.parse(captured.stdout) as Awaited<ReturnType<typeof convergeAuthoring>>;
|
||||||
|
timings.captureMs = Math.round(performance.now() - started);
|
||||||
|
if (!converged.candidate) {
|
||||||
|
report.phase = converged.worklist.find(entry => entry.phase !== "dependency")?.phase ?? "convergence";
|
||||||
|
throw new Error(converged.worklist.map(entry => `${entry.directory} [${entry.phase}]: ${entry.message}`).join("\n"));
|
||||||
|
}
|
||||||
|
report.commit = converged.candidate.commit;
|
||||||
|
report.phase = "verification";
|
||||||
|
const buildStarted = performance.now();
|
||||||
|
report.artifactPath = await buildImmutableCandidate(converged.candidate, resource.kind, path.join(output, "nix.log"), options.contractOnly);
|
||||||
|
timings.immutableCheckMs = Math.round(performance.now() - buildStarted);
|
||||||
|
const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8");
|
||||||
|
await fs.writeFile(path.join(output, "candidate.json"), candidateText);
|
||||||
|
report.compilation = "passed";
|
||||||
|
if (resource.kind === "workspace" && !options.contractOnly) {
|
||||||
|
report.phase = "evolution";
|
||||||
|
let baseline = options.baseline;
|
||||||
|
if (!baseline) {
|
||||||
|
try {
|
||||||
|
const host = JSON.parse(await fs.readFile("/etc/quixos/workspace-source.json", "utf8"));
|
||||||
|
if (await fs.realpath(host.workbenchRoot) === context.workbench) baseline = JSON.parse(await fs.readFile(path.join(host.runtimeClosureRoot, "manifest.json"), "utf8")).workspacePlanPath;
|
||||||
|
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||||
|
}
|
||||||
|
const before = baseline ? JSON.parse(await fs.readFile(baseline, "utf8")) as WorkspaceRevision : null;
|
||||||
|
const reviews = options.reviews ? JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[] : [];
|
||||||
|
const evolution = planEvolution(before, JSON.parse(candidateText), { reviews });
|
||||||
|
await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2));
|
||||||
|
report.blockers.push(...evolution.blockers);
|
||||||
|
report.migrationRequired = evolution.migrationRequired;
|
||||||
|
report.activationReadiness = evolution.blockers.length ? "blocked" : evolution.migrationRequired.length ? "migration-required" : "preserve";
|
||||||
|
if (evolution.migrationRequired.length) report.blockers.push(`Explicit migration required for: ${evolution.migrationRequired.join(", ")}. Compilation passed; supply a migration path before cutover.`);
|
||||||
|
}
|
||||||
|
if (!report.blockers.length) report.phase = options.contractOnly ? "contract-only" : "checked";
|
||||||
|
} catch (error) { report.blockers.push(String(error instanceof Error ? error.message : error)); }
|
||||||
|
timings.totalMs = Math.round(performance.now() - started);
|
||||||
|
Object.assign(report, {timings});
|
||||||
|
await fs.writeFile(path.join(output, "report.json"), JSON.stringify(report, null, 2));
|
||||||
|
if (options.contractOnly) return report;
|
||||||
|
const records = path.join(context.workbench, ".quixos/checks");
|
||||||
|
await fs.mkdir(records, { recursive: true });
|
||||||
|
const remember = async (value: typeof report) => {
|
||||||
|
const filename = path.join(records, checkRecordName(value.directory)), temporary = `${filename}.${randomUUID()}.tmp`;
|
||||||
|
await fs.writeFile(temporary, JSON.stringify(value, null, 2), { flag: "wx", mode: 0o600 });
|
||||||
|
await fs.rename(temporary, filename);
|
||||||
|
};
|
||||||
|
if (report.artifactPath) {
|
||||||
|
const graph = JSON.parse(await fs.readFile(path.join(report.artifactPath, "graph.json"), "utf8"));
|
||||||
|
for (const checked of graph.resources) {
|
||||||
|
const managed = context.resources.find(entry => entry.kind === checked.kind && entry.source?.repository === checked.source.repository);
|
||||||
|
if (managed && managed.directory !== directory) await remember({...report, directory: managed.directory, commit: checked.source.commit, blockers: [], phase: "checked"});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await remember(report);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { readFile, realpath } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { loadQuixosLock, type GitSource } from "../resource-lock/index.js";
|
||||||
|
|
||||||
|
export type AuthoringResource = {
|
||||||
|
kind: "workspace" | "interface" | "package";
|
||||||
|
directory: string;
|
||||||
|
resourceId?: string;
|
||||||
|
source?: GitSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Registration, not directory co-location, defines the editable selection. */
|
||||||
|
export async function authoringContext(start: string) {
|
||||||
|
let workbench = await realpath(start);
|
||||||
|
for (;;) {
|
||||||
|
let text: string | undefined;
|
||||||
|
try { text = await readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"); }
|
||||||
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||||
|
if (text !== undefined) {
|
||||||
|
const graph = JSON.parse(text) as { resources: AuthoringResource[] };
|
||||||
|
if (!Array.isArray(graph.resources)) throw new Error("Managed resource inventory is malformed");
|
||||||
|
const resources: AuthoringResource[] = [{ kind: "workspace", directory: "root" }];
|
||||||
|
const identities = new Set<string>(), directories = new Set<string>(["root"]);
|
||||||
|
for (const entry of graph.resources) {
|
||||||
|
// Compiler graphs carry resolved paths; the authoring API presents
|
||||||
|
// stable workbench-relative names and validates containment here.
|
||||||
|
if (typeof entry.directory === "string" && path.isAbsolute(entry.directory)) entry.directory = path.relative(workbench, entry.directory);
|
||||||
|
if (!["interface", "package"].includes(entry.kind) || !entry.source ||
|
||||||
|
!/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(entry.directory)) {
|
||||||
|
throw new Error("Invalid managed resource registration");
|
||||||
|
}
|
||||||
|
const identity = `${entry.kind}\0${entry.resourceId ?? entry.source.repository}`;
|
||||||
|
if (identities.has(identity) || directories.has(entry.directory)) {
|
||||||
|
throw new Error(`Multiple editable selections for ${entry.resourceId ?? entry.source.repository}`);
|
||||||
|
}
|
||||||
|
identities.add(identity); directories.add(entry.directory);
|
||||||
|
resources.push({kind: entry.kind, directory: entry.directory, resourceId: entry.resourceId, source: entry.source});
|
||||||
|
}
|
||||||
|
return { workbench, resources, async baseline() {
|
||||||
|
const result = await loadQuixosLock(path.join(workbench, "root/quixos.lock"));
|
||||||
|
if (!result.ok) throw new Error(`Workspace source baseline is invalid: ${result.diagnostics.map(d => d.message).join("; ")}`);
|
||||||
|
return result.lock.quixos;
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
const parent = path.dirname(workbench);
|
||||||
|
if (parent === workbench) throw new Error("Not in a managed workbench; select one with --workbench DIRECTORY");
|
||||||
|
workbench = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { readFile, writeFile, rename, rm, realpath } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { authoringContext } from "./authoring-context.js";
|
||||||
|
import { snapshotCommit } from "./checked-build.js";
|
||||||
|
import { loadQuixosLock, parseQuixosLockDocument, formatQuixosLockDocument, retentionTagForCommit, type GitSource } from "../resource-lock/index.js";
|
||||||
|
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
const command = async (cwd: string, executable: string, args: string[]) => (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();
|
||||||
|
const identity = (kind: string, repository: string) => `${kind}\0${repository}`;
|
||||||
|
|
||||||
|
export type AuthoringBlocker = { directory: string; phase: "resolution" | "dependency" | "source" | "publication" | "concurrent-edit"; message: string };
|
||||||
|
|
||||||
|
/** Source retention only. Neither successful convergence nor an empty source
|
||||||
|
* worklist grants typechecking, semantic review or activation approval.
|
||||||
|
* Caller serializes coordinators; package authors may still be editing. */
|
||||||
|
export async function convergeAuthoring(start: string, target = "root") {
|
||||||
|
const context = await authoringContext(start);
|
||||||
|
const graphFile = path.join(context.workbench, ".quixos/resource-graph.json");
|
||||||
|
const graphBefore = await readFile(graphFile, "utf8");
|
||||||
|
const blockers: AuthoringBlocker[] = [];
|
||||||
|
const nodes = new Map<string, { directory: string; kind: string; source: GitSource; dependencies: string[] }>();
|
||||||
|
const selected = new Map<string, string>();
|
||||||
|
for (const entry of context.resources) {
|
||||||
|
const root = path.join(context.workbench, entry.directory);
|
||||||
|
let repository = entry.source?.repository;
|
||||||
|
try {
|
||||||
|
if (await realpath(root) !== root) throw new Error(`Managed checkout crosses a symlink: ${entry.directory}`);
|
||||||
|
// Transport rewrites must not become committed source identities.
|
||||||
|
const origin = await command(root, "git", ["config", "--get", "remote.origin.url"]);
|
||||||
|
if (repository && origin !== repository) throw new Error(`Origin differs from registered source for ${entry.directory}`);
|
||||||
|
repository ??= origin;
|
||||||
|
} catch (error) {
|
||||||
|
blockers.push({directory: entry.directory, phase: "source", message: String(error).slice(0, 2000)});
|
||||||
|
if (!repository) throw error; // The root has no separate registered source.
|
||||||
|
}
|
||||||
|
const key = identity(entry.kind, repository);
|
||||||
|
if (selected.has(key)) throw new Error(`More than one editable checkout for ${repository}`);
|
||||||
|
selected.set(key, entry.directory);
|
||||||
|
nodes.set(entry.directory, { ...entry, source: { resolver: "git", repository, commit: entry.source?.commit ?? "" }, dependencies: [] });
|
||||||
|
}
|
||||||
|
for (const node of nodes.values()) {
|
||||||
|
try {
|
||||||
|
const lock = await loadQuixosLock(path.join(context.workbench, node.directory, "quixos.lock"));
|
||||||
|
if (!lock.ok) throw new Error(lock.diagnostics.map(d => `${d.fileName}: ${d.message}`).join("\n"));
|
||||||
|
node.dependencies = [...new Set(lock.lock.resources.flatMap(entry => {
|
||||||
|
const directory = selected.get(identity(entry.kind, entry.source.repository));
|
||||||
|
return directory ? [directory] : [];
|
||||||
|
}))];
|
||||||
|
} catch (error) { blockers.push({ directory: node.directory, phase: "resolution", message: String(error) }); }
|
||||||
|
}
|
||||||
|
const complete = new Map<string, GitSource>(), active = new Set<string>();
|
||||||
|
const visited = new Set<string>();
|
||||||
|
if (!nodes.has(target)) throw new Error(`Not a registered repository: ${target}`);
|
||||||
|
const visit = async (directory: string): Promise<boolean> => {
|
||||||
|
visited.add(directory);
|
||||||
|
if (complete.has(directory)) return true;
|
||||||
|
if (blockers.some(entry => entry.directory === directory)) return false;
|
||||||
|
if (active.has(directory)) { blockers.push({ directory, phase: "dependency", message: `Source dependency cycle: ${[...active, directory].join(" -> ")}` }); return false; }
|
||||||
|
active.add(directory);
|
||||||
|
const node = nodes.get(directory)!;
|
||||||
|
for (const dependency of node.dependencies) if (!await visit(dependency)) {
|
||||||
|
blockers.push({ directory, phase: "dependency", message: `Waiting for ${dependency}` }); active.delete(directory); return false;
|
||||||
|
}
|
||||||
|
const root = path.join(context.workbench, directory);
|
||||||
|
let phase: AuthoringBlocker["phase"] = "source";
|
||||||
|
try {
|
||||||
|
const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
|
||||||
|
if (!lock.ok) throw new Error("Lock changed during convergence; retry after joining writers");
|
||||||
|
for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) {
|
||||||
|
const filename = path.join(root, file), before = await readFile(filename, "utf8");
|
||||||
|
const parsed = parseQuixosLockDocument(before, file);
|
||||||
|
if (!parsed.ok) throw new Error(`Invalid lock ${file}`);
|
||||||
|
let changed = false;
|
||||||
|
for (const dependency of parsed.document.resources) {
|
||||||
|
const target = selected.get(identity(dependency.kind, dependency.source.repository));
|
||||||
|
const source = target ? complete.get(target) : undefined;
|
||||||
|
if (target && !source) throw new Error(`Dependencies changed during convergence (${dependency.binding}); join writers and retry`);
|
||||||
|
if (source && source.commit !== dependency.source.commit) { dependency.source = source; changed = true; }
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
const temporary = `${filename}.${randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
await writeFile(temporary, formatQuixosLockDocument(parsed.document), { flag: "wx" });
|
||||||
|
if (await readFile(filename, "utf8") !== before) throw new Error(`Concurrent edit to ${file}; retry after joining writers`);
|
||||||
|
await rename(temporary, filename);
|
||||||
|
} finally { await rm(temporary, { force: true }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const commit = await snapshotCommit(root);
|
||||||
|
phase = "publication";
|
||||||
|
const ref = retentionTagForCommit(commit);
|
||||||
|
const remote = await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref]);
|
||||||
|
if (remote && remote.split(/\s+/)[0] !== commit) throw new Error(`Conflicting immutable retention ref ${ref}`);
|
||||||
|
if (!remote) await command(root, "git", ["push", node.source.repository, `${commit}:${ref}`]);
|
||||||
|
if ((await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref])).split(/\s+/)[0] !== commit) throw new Error("Published source retention was not observed");
|
||||||
|
complete.set(directory, { ...node.source, commit });
|
||||||
|
} catch (error) { blockers.push({ directory, phase, message: String(error).slice(0, 4000) }); }
|
||||||
|
active.delete(directory);
|
||||||
|
return complete.has(directory);
|
||||||
|
};
|
||||||
|
// Include newly created, not-yet-imported resources, then the root.
|
||||||
|
if (target === "root") for (const directory of [...nodes.keys()].filter(d => d !== "root")) await visit(directory);
|
||||||
|
await visit(target);
|
||||||
|
for (let index = blockers.length - 1; index >= 0; index--) if (!visited.has(blockers[index].directory)) blockers.splice(index, 1);
|
||||||
|
for (const [directory, source] of complete) {
|
||||||
|
try { if (await snapshotCommit(path.join(context.workbench, directory)) !== source.commit) throw new Error("Source advanced while converging; join writers and retry"); }
|
||||||
|
catch (error) { blockers.push({ directory, phase: "concurrent-edit", message: String(error) }); }
|
||||||
|
}
|
||||||
|
// Persist successful selections even if another repository is still broken.
|
||||||
|
// Recovery must not depend on all parents succeeding in the same invocation.
|
||||||
|
const graph = JSON.parse(graphBefore);
|
||||||
|
for (const resource of graph.resources) resource.directory = path.relative(context.workbench, path.resolve(context.workbench, resource.directory));
|
||||||
|
const replacements = new Map<string, string>();
|
||||||
|
for (const resource of graph.resources) {
|
||||||
|
const source = complete.get(resource.directory);
|
||||||
|
if (!source) continue;
|
||||||
|
const key = `${resource.kind}\0${source.repository}\0${source.commit}`;
|
||||||
|
replacements.set(resource.key, key);
|
||||||
|
if (resource.source.commit !== source.commit) delete resource.revisionId;
|
||||||
|
resource.source = source; resource.key = key;
|
||||||
|
}
|
||||||
|
for (const resource of graph.resources) for (const dependency of resource.dependencies ?? []) {
|
||||||
|
dependency.resourceKey = replacements.get(dependency.resourceKey) ?? dependency.resourceKey;
|
||||||
|
}
|
||||||
|
for (const direct of graph.directResources ?? []) direct.resourceKey = replacements.get(direct.resourceKey) ?? direct.resourceKey;
|
||||||
|
// Inventory is a projection of actual locks, including newly added/removed
|
||||||
|
// imports. Never require a successful parent compilation to repair it.
|
||||||
|
for (const [directory] of complete) {
|
||||||
|
const lock = await loadQuixosLock(path.join(context.workbench, directory, "quixos.lock"));
|
||||||
|
if (!lock.ok) continue;
|
||||||
|
const dependencies = lock.lock.resources.map(dependency => ({
|
||||||
|
binding: `${dependency.kind}\0${dependency.binding}`,
|
||||||
|
resourceKey: `${dependency.kind}\0${dependency.source.repository}\0${dependency.source.commit}`,
|
||||||
|
}));
|
||||||
|
if (directory === "root") {
|
||||||
|
graph.quixos = lock.lock.quixos;
|
||||||
|
graph.directResources = lock.lock.resources.map((dependency, index) => ({
|
||||||
|
kind: dependency.kind, binding: dependency.binding, resourceKey: dependencies[index].resourceKey,
|
||||||
|
...(selected.has(identity(dependency.kind, dependency.source.repository))
|
||||||
|
? {directory: selected.get(identity(dependency.kind, dependency.source.repository))} : {}),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const resource = graph.resources.find((entry: {directory: string}) => entry.directory === directory);
|
||||||
|
if (resource) resource.dependencies = dependencies;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const graphAfter = JSON.stringify(graph, null, 2) + "\n";
|
||||||
|
if (graphBefore !== graphAfter) {
|
||||||
|
const temporary = `${graphFile}.${randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
await writeFile(temporary, graphAfter, { flag: "wx", mode: 0o600 });
|
||||||
|
if (await readFile(graphFile, "utf8") !== graphBefore) throw new Error("Managed inventory changed during convergence; source is retained, retry after joining writers");
|
||||||
|
await rename(temporary, graphFile);
|
||||||
|
} finally { await rm(temporary, { force: true }); }
|
||||||
|
}
|
||||||
|
return { workbench: context.workbench, converged: blockers.length === 0,
|
||||||
|
candidate: blockers.length ? null : complete.get(target) ?? null,
|
||||||
|
retained: [...complete].map(([directory, source]) => ({ directory, source })),
|
||||||
|
worklist: blockers, verificationEvidence: false, activated: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { realpath } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { parseQx, walkSyntax } from "./source.js";
|
||||||
|
import { authoringContext } from "./authoring-context.js";
|
||||||
|
import { readQxSource } from "./source-loader.js";
|
||||||
|
import {loadQuixosLock} from "../resource-lock/index.js";
|
||||||
|
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
const git = async (root: string, args: string[]) => (await execFile("git", ["-C", root, ...args], {
|
||||||
|
maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
||||||
|
})).stdout;
|
||||||
|
const message = (error: unknown) => String(error instanceof Error ? error.message : error).slice(0, 2000);
|
||||||
|
|
||||||
|
/** Syntax-only contract inspection is deliberately NOT verification evidence.
|
||||||
|
* Each file can recover independently; current valid files always win. */
|
||||||
|
export async function inspectAuthoringRepository(root: string, historyLimit = 100) {
|
||||||
|
const names = (await git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]))
|
||||||
|
.split("\0").filter(name => name.endsWith(".qx"));
|
||||||
|
if (names.length > 128) throw new Error("Repository inspection exceeds 128 QX files; split the resource into smaller repositories");
|
||||||
|
const files = [];
|
||||||
|
for (const name of [...new Set(names)].sort()) {
|
||||||
|
let source = "", errors: unknown[] = [], revision: string | null = null;
|
||||||
|
try {
|
||||||
|
source = await readQxSource(root, name);
|
||||||
|
if (source.length > 262144) throw new Error(`Inspection file exceeds 256 KiB: ${name}`);
|
||||||
|
errors = parseQx(source, name).diagnostics;
|
||||||
|
} catch (error) { errors = [{ message: message(error) }]; }
|
||||||
|
const currentErrors = errors;
|
||||||
|
if (errors.length) {
|
||||||
|
// Git can traverse jj's immutable commit DAG without mutating/snapshotting @.
|
||||||
|
const head = await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"],
|
||||||
|
{ cwd: root, env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1" } }).then(r => r.stdout.trim(), () => "HEAD");
|
||||||
|
const commits = await git(root, ["rev-list", `--max-count=${historyLimit}`, head, "--", name]).catch(() => "");
|
||||||
|
for (const commit of commits.trim().split("\n").filter(Boolean)) {
|
||||||
|
const historical = await git(root, ["show", `${commit}:${name}`]).catch(() => null);
|
||||||
|
if (historical === null || historical.length > 262144) continue;
|
||||||
|
if (!parseQx(historical, name).diagnostics.length) {
|
||||||
|
source = historical; revision = commit; errors = []; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const syntax = errors.length ? null : parseQx(source, name);
|
||||||
|
files.push({ file: name, status: errors.length ? "unavailable" : revision ? "historical" : "current",
|
||||||
|
revision, currentErrors: currentErrors.slice(0, 20), omittedErrors: Math.max(0, currentErrors.length - 20),
|
||||||
|
declarations: syntax ? [...walkSyntax(syntax.root)]
|
||||||
|
.filter(node => /^(?:interface|package|atom|state|edge|method|function|event|conformance)\w*Decl$/.test(node.kind))
|
||||||
|
.map(node => ({ kind: node.kind, source: source.slice(node.start, node.end) })) : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { verificationEvidence: false as const, resolutionChecked: false as const, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inspectWorkbench(start: string, selector?: string) {
|
||||||
|
const context = await authoringContext(start);
|
||||||
|
if (!selector || selector === ".") {
|
||||||
|
const relative = path.relative(context.workbench, await realpath(start));
|
||||||
|
selector = context.resources.find(entry => relative === entry.directory || relative.startsWith(entry.directory + path.sep))?.directory ?? "root";
|
||||||
|
}
|
||||||
|
const lock = await loadQuixosLock(path.join(context.workbench, "root/quixos.lock"));
|
||||||
|
const aliases = lock.ok ? lock.lock.resources.filter(entry => entry.binding === selector) : [];
|
||||||
|
const selected = context.resources.filter(entry => !selector || selector === entry.directory ||
|
||||||
|
selector === entry.resourceId || selector === path.basename(entry.directory) || aliases.some(alias => alias.kind === entry.kind && alias.source.repository === entry.source?.repository));
|
||||||
|
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`);
|
||||||
|
const resources = [];
|
||||||
|
for (const entry of selected) {
|
||||||
|
try {
|
||||||
|
const root = path.join(context.workbench, entry.directory);
|
||||||
|
if (await realpath(root) !== root) throw new Error("Managed checkout crosses a symlink");
|
||||||
|
resources.push({ ...entry, ...await inspectAuthoringRepository(root) });
|
||||||
|
} catch (error) { resources.push({ ...entry, error: message(error) }); }
|
||||||
|
}
|
||||||
|
return { workbench: context.workbench, verificationEvidence: false, resources };
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { authoringContext } from "./authoring-context.js";
|
||||||
|
import { inspectAuthoringRepository } from "./authoring-inspect.js";
|
||||||
|
import { checkRecordName } from "./authoring-check.js";
|
||||||
|
import { loadQuixosLock } from "../resource-lock/index.js";
|
||||||
|
import { checkerIdentity } from "./checked-build.js";
|
||||||
|
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
export async function authoringWorklist(start: string) {
|
||||||
|
const context = await authoringContext(start);
|
||||||
|
const entries: {directory: string; resourceId?: string; phase: string; message: string; next: string}[] = [];
|
||||||
|
const dependencies = new Map<string, string[]>();
|
||||||
|
for (const resource of context.resources) {
|
||||||
|
const root = path.join(context.workbench, resource.directory);
|
||||||
|
const add = (phase: string, message: string) => entries.push({directory: resource.directory, resourceId: resource.resourceId, phase, message,
|
||||||
|
next: phase === "syntax" ? `qx-workspace inspect ${resource.directory}` : phase === "evolution" ? "Inspect evolution.json in the check output; resolve its named migration/review requirements before cutover" : `cd ${resource.directory} && qx-workspace check`});
|
||||||
|
try {
|
||||||
|
if (await fs.realpath(root) !== root) throw new Error("Registered checkout crosses a symlink");
|
||||||
|
const inspected = await inspectAuthoringRepository(root);
|
||||||
|
for (const file of inspected.files) if (file.currentErrors.length) add("syntax", `${file.file}: ${JSON.stringify(file.currentErrors)}${file.status === "historical" ? `; historical contract available at ${file.revision}` : ""}`);
|
||||||
|
const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
|
||||||
|
if (!lock.ok) add("resolution", lock.diagnostics.map(entry => `${entry.fileName}: ${entry.message}`).join("\n"));
|
||||||
|
else dependencies.set(resource.directory, lock.lock.resources.flatMap(dependency => {
|
||||||
|
const selected = context.resources.find(entry => entry.kind === dependency.kind && entry.source?.repository === dependency.source.repository);
|
||||||
|
if (selected?.source && selected.source.commit !== dependency.source.commit) add("propagation", `Dependency ${dependency.binding} has advanced; check will repin it automatically`);
|
||||||
|
return selected ? [selected.directory] : [];
|
||||||
|
}));
|
||||||
|
let record;
|
||||||
|
try { record = JSON.parse(await fs.readFile(path.join(context.workbench, ".quixos/checks", checkRecordName(resource.directory)), "utf8")); }
|
||||||
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||||
|
if (!record?.commit) {
|
||||||
|
if (record?.blockers?.length) add(record.phase, record.blockers.join("\n"));
|
||||||
|
else add("unchecked", "No immutable candidate check recorded yet");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (record.checker !== checkerIdentity()) add("unchecked", "The installed checker changed since the last check");
|
||||||
|
const env = {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"};
|
||||||
|
const commit = (await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], {cwd: root, env})).stdout.trim();
|
||||||
|
const dirty = await execFile("git", ["diff", "--quiet", "--no-ext-diff", record.commit, "--"], {cwd: root}).then(() => false, () => true);
|
||||||
|
const untracked = (await execFile("git", ["ls-files", "--others", "--exclude-standard"], {cwd: root})).stdout;
|
||||||
|
if (commit !== record.commit || dirty || untracked) add("unchecked", `Edits are newer than the last check (${record.commit.slice(0, 12)})`);
|
||||||
|
else if (record.blockers?.length) add(record.phase, record.blockers.join("\n"));
|
||||||
|
} catch (error) { add("inspection", String(error).slice(0, 3000)); }
|
||||||
|
}
|
||||||
|
// Fixed-point propagation, independent of registration order.
|
||||||
|
const blocked = new Set(entries.map(entry => entry.directory));
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const [directory, required] of dependencies) if (!blocked.has(directory)) {
|
||||||
|
const waiting = required.filter(dependency => blocked.has(dependency));
|
||||||
|
if (waiting.length) { blocked.add(directory); changed = true; entries.push({directory, phase: "dependency", message: `Waiting for ${waiting.join(", ")}`, next: "Resolve the named repositories, then rerun check"}); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { workbench: context.workbench, verificationEvidence: false, worklist: entries,
|
||||||
|
note: "Derived authoring guidance, not activation approval. Independent repositories can be delegated separately; join writers before a root check." };
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { execFile as execFileCallback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js";
|
||||||
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
|
import { contentDigest, planEvolution, type EvolutionReview, type WorkspaceRevision } from "../capability-model/index.js";
|
||||||
|
import { bindingSchema } from "../bindings/index.js";
|
||||||
|
import {snapshotCommit, checkoutCommit, buildCheckedPackage} from "./checked-build.js";
|
||||||
|
const execFile = promisify(execFileCallback);
|
||||||
|
const bytesDigest = (value: Uint8Array) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||||
|
|
||||||
|
export const localResourceSnapshots = async (root: string, filename?: string): Promise<{resources: {kind: string; repository: string; commit: string; directory: string}[]}> => {
|
||||||
|
if (filename) {
|
||||||
|
const document = JSON.parse(await fs.readFile(filename, "utf8"));
|
||||||
|
return {resources: document.resources.map((entry: {directory: string}) => ({...entry, directory: path.resolve(path.dirname(filename), entry.directory)}))};
|
||||||
|
}
|
||||||
|
let directory = await fs.realpath(root);
|
||||||
|
for (;;) {
|
||||||
|
let graphText: string | undefined;
|
||||||
|
try {graphText = await fs.readFile(path.join(directory, ".quixos/resource-graph.json"), "utf8");}
|
||||||
|
catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;}
|
||||||
|
if (graphText !== undefined) {
|
||||||
|
const graph = JSON.parse(graphText);
|
||||||
|
const resources = [];
|
||||||
|
for (const entry of graph.resources) {
|
||||||
|
const location = await fs.realpath(path.resolve(directory, entry.directory));
|
||||||
|
if (!location.startsWith(`${directory}/resources/`)) throw new Error("Workbench resource escapes managed directory");
|
||||||
|
resources.push({kind: entry.kind, ...entry.source, directory: location});
|
||||||
|
}
|
||||||
|
return {resources};
|
||||||
|
}
|
||||||
|
const parent = path.dirname(directory);
|
||||||
|
if (parent === directory) return {resources: []};
|
||||||
|
directory = parent;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Copy actual authoring files without snapshotting jj or creating a Git commit. */
|
||||||
|
export const snapshotRepository = async (source: string, destination: string) => {
|
||||||
|
const root = await fs.realpath(source);
|
||||||
|
const files = async () => (await execFile("git", ["-C", root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], { maxBuffer: 16 * 1024 * 1024 })).stdout.split("\0").filter(Boolean).sort();
|
||||||
|
const names = [...new Set(await files())];
|
||||||
|
if (names.length > 50_000) throw new Error("Candidate source exceeds 50000 files");
|
||||||
|
const contents: {name: string; digest: string; mode: number}[] = [];
|
||||||
|
let bytes = 0;
|
||||||
|
await fs.mkdir(destination, { recursive: true, mode: 0o700 });
|
||||||
|
for (const name of names) {
|
||||||
|
if (path.isAbsolute(name) || name.split(/[\\/]/).some((part) => part === ".." || part === ".git" || part === ".jj")) throw new Error("Invalid candidate source path");
|
||||||
|
const file = path.join(root, name);
|
||||||
|
let metadata;
|
||||||
|
try { metadata = await fs.lstat(file); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
||||||
|
if (!metadata.isFile() || metadata.isSymbolicLink() || !(await fs.realpath(file)).startsWith(`${root}${path.sep}`)) throw new Error(`Candidate source must be a regular file: ${name}`);
|
||||||
|
const data = await fs.readFile(file);
|
||||||
|
bytes += data.length;
|
||||||
|
if (bytes > 128 * 1024 * 1024) throw new Error("Candidate source exceeds 128 MiB");
|
||||||
|
contents.push({ name, digest: bytesDigest(data), mode: metadata.mode & 0o777 });
|
||||||
|
await fs.mkdir(path.dirname(path.join(destination, name)), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(destination, name), data, { flag: "wx", mode: metadata.mode & 0o777 });
|
||||||
|
}
|
||||||
|
if (JSON.stringify([...new Set(await files())]) !== JSON.stringify(names)) throw new Error("Source files changed during candidate snapshot");
|
||||||
|
for (const entry of contents) if (bytesDigest(await fs.readFile(path.join(root, entry.name))) !== entry.digest) throw new Error(`Source changed during candidate snapshot: ${entry.name}`);
|
||||||
|
return { source: root, directory: destination, treeDigest: contentDigest(contents), files: contents };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Local mirrors accelerate resolution, but only their committed locked trees
|
||||||
|
// may stand in for published dependencies. Never relabel dirty files as a pin.
|
||||||
|
async function committedResolver(root: string, temporary: string, filename?: string, publishedOnly = false) {
|
||||||
|
const map = publishedOnly ? {resources: []} : await localResourceSnapshots(root, filename);
|
||||||
|
const resources = [];
|
||||||
|
for (const [index, entry] of map.resources.entries()) {
|
||||||
|
const directory = path.join(temporary, `dependency-${index}`);
|
||||||
|
await checkoutCommit(entry.directory, entry.commit, directory);
|
||||||
|
resources.push({...entry, directory});
|
||||||
|
}
|
||||||
|
const snapshotMap = path.join(temporary, "snapshots.json");
|
||||||
|
await fs.writeFile(snapshotMap, JSON.stringify({resources}));
|
||||||
|
return createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resolved"), snapshotMap});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const checkResourceCandidate = async (options: {root: string; output: string; kind: "package" | "interface"; source: {repository: string; commit: string}; snapshotMap?: string; publishedOnly?: boolean}) => {
|
||||||
|
await fs.mkdir(options.output, {mode: 0o700});
|
||||||
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-resource-check-"));
|
||||||
|
const blockers: string[] = [];
|
||||||
|
let treeDigest: string | undefined, commit: string | undefined, artifactPath: string | undefined;
|
||||||
|
try {
|
||||||
|
commit = await snapshotCommit(options.root);
|
||||||
|
treeDigest = (await snapshotRepository(options.root, path.join(temporary, "observed"))).treeDigest;
|
||||||
|
const root = path.join(temporary, "source");
|
||||||
|
await checkoutCommit(options.root, commit, root);
|
||||||
|
const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap, options.publishedOnly);
|
||||||
|
const compiled = await compileCapabilityResourceRepository({rootDirectory: root, kind: options.kind, source: {resolver: "git", repository: options.source.repository, commit}, resolveResource});
|
||||||
|
if (compiled.resource.kind === "package") {
|
||||||
|
const schema = path.join(temporary, "bindings.json");
|
||||||
|
await fs.writeFile(schema, JSON.stringify(bindingSchema(compiled)));
|
||||||
|
artifactPath = await buildCheckedPackage(root, schema, compiled.resource.revision.revisionId);
|
||||||
|
}
|
||||||
|
if (await snapshotCommit(options.root) !== commit) throw new Error("Source changed during verification; run the check again");
|
||||||
|
await fs.writeFile(path.join(options.output, "candidate.json"), JSON.stringify(compiled.resource, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
blockers.push(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {await fs.rm(temporary, {recursive: true, force: true});}
|
||||||
|
const result = {candidateOnly: true, activationEvidence: false, commit, treeDigest, artifactPath, blockers,
|
||||||
|
note: "Checked immutable candidate; cutover independently checks current migration/review requirements. No publication or activation performed."};
|
||||||
|
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkWorkspaceCandidate = async (options: {root: string; output: string; snapshotMap?: string; baseline?: string; reviews?: string}) => {
|
||||||
|
await fs.mkdir(options.output, {mode: 0o700});
|
||||||
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-workspace-check-"));
|
||||||
|
const blockers: string[] = [], checks: {packageRevisionId: string; artifactPath: string}[] = [];
|
||||||
|
let commit: string | undefined;
|
||||||
|
try {
|
||||||
|
commit = await snapshotCommit(options.root);
|
||||||
|
for (const entry of (await localResourceSnapshots(options.root, options.snapshotMap)).resources) {
|
||||||
|
const current = await snapshotCommit(entry.directory);
|
||||||
|
const tree = async (revision: string) => (await execFile("git", ["rev-parse", `${revision}^{tree}`], {cwd: entry.directory})).stdout.trim();
|
||||||
|
if (await tree(current) !== await tree(entry.commit)) throw new Error(`Edited resource is not in the root's locked candidate: ${entry.directory}. Check that resource, then run qx-workspace resource upgrade --publish to propagate its revision.`);
|
||||||
|
}
|
||||||
|
const root = path.join(temporary, "source");
|
||||||
|
await checkoutCommit(options.root, commit, root);
|
||||||
|
const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap);
|
||||||
|
const compiled = await compileWorkspaceRepository({rootDirectory: root, sourceRootCommit: commit, resolveResource});
|
||||||
|
const baseline = options.baseline ? JSON.parse(await fs.readFile(options.baseline, "utf8")) as WorkspaceRevision : null;
|
||||||
|
const reviews = options.reviews ? JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[] : [];
|
||||||
|
const evolution = planEvolution(baseline, compiled.workspace, {reviews});
|
||||||
|
blockers.push(...evolution.blockers);
|
||||||
|
for (const resource of compiled.resources.filter(entry => entry.kind === "package")) {
|
||||||
|
// Per-package recursive schema, identical to host activation, not unrelated
|
||||||
|
// workspace declarations that would unnecessarily invalidate build caches.
|
||||||
|
const candidate = await compileCapabilityResourceRepository({rootDirectory: resource.directory, kind: "package", source: resource.source, resolveResource});
|
||||||
|
const schema = path.join(temporary, "bindings.json");
|
||||||
|
await fs.writeFile(schema, JSON.stringify(bindingSchema(candidate)));
|
||||||
|
const artifactPath = await buildCheckedPackage(resource.directory, schema, candidate.resource.revision.revisionId);
|
||||||
|
checks.push({packageRevisionId: candidate.resource.revision.revisionId, artifactPath});
|
||||||
|
}
|
||||||
|
if (await snapshotCommit(options.root) !== commit) throw new Error("Source changed during verification; run the check again");
|
||||||
|
const result = {schemaVersion: 1, candidateOnly: true, activationEvidence: false, commit, evolution, checks, blockers,
|
||||||
|
note: "Checks the committed root and its exact locked dependencies. Resource edits must be verified and repinned before they enter this candidate. No publication or activation performed."};
|
||||||
|
await fs.writeFile(path.join(options.output, "candidate.json"), JSON.stringify(compiled.workspace, null, 2));
|
||||||
|
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const result = {schemaVersion: 1, candidateOnly: true, activationEvidence: false, commit, checks, blockers: [...blockers, error instanceof Error ? error.message : String(error)]};
|
||||||
|
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
|
||||||
|
return result;
|
||||||
|
} finally {await fs.rm(temporary, {recursive: true, force: true});}
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {execFile as callback, spawn} from "node:child_process";
|
||||||
|
import { createWriteStream } from "node:fs";
|
||||||
|
import {promisify} from "node:util";
|
||||||
|
import {fileURLToPath} from "node:url";
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
const environment = () => ({...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", GIT_TERMINAL_PROMPT: "0"});
|
||||||
|
export const checkerIdentity = () => process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
|
||||||
|
/** Checking snapshots jj, but never publishes or activates the working copy. */
|
||||||
|
export async function snapshotCommit(root: string): Promise<string> {
|
||||||
|
const run = async (...args: string[]) => (await execFile("jj", args, {cwd: root, env: environment()})).stdout.trim();
|
||||||
|
await run("status");
|
||||||
|
// jj resolve --list exits 1 on a clean revision. Query structured revision
|
||||||
|
// metadata instead of depending on diagnostic wording or swallowing errors.
|
||||||
|
if (await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "conflict") !== "false") throw new Error("Resolve source conflicts before verification");
|
||||||
|
const commit = await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id");
|
||||||
|
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Verification requires an exact jj commit");
|
||||||
|
await execFile("git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"], {cwd: root, env: environment()});
|
||||||
|
const {stdout} = await execFile("git", ["ls-files", "--others", "--exclude-standard", "-z"], {cwd: root});
|
||||||
|
if (stdout) throw new Error("Source contains files not captured by jj; inspect jj tracking before verification");
|
||||||
|
return commit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Use Git's actual committed tree, never dirty overlays labelled as old pins. */
|
||||||
|
export async function checkoutCommit(root: string, commit: string, destination: string) {
|
||||||
|
await fs.mkdir(destination, {recursive: true});
|
||||||
|
const archive = await execFile("git", ["archive", "--format=tar", commit], {cwd: root, encoding: "buffer", maxBuffer: 128 * 1024 * 1024});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn("tar", ["-xf", "-", "-C", destination], {stdio: ["pipe", "ignore", "pipe"]});
|
||||||
|
let error = "";
|
||||||
|
child.stderr.on("data", chunk => {error += chunk;});
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", code => code === 0 ? resolve() : reject(new Error(`Cannot extract committed source: ${error}`)));
|
||||||
|
child.stdin.on("error", reject);
|
||||||
|
child.stdin.end(archive.stdout);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by provisional checks, template publication and host activation.
|
||||||
|
* The Nix derivation is the cached check; its metadata is internal provenance,
|
||||||
|
* not a certificate authored or approved by the workspace agent.
|
||||||
|
*/
|
||||||
|
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)), "../..");
|
||||||
|
if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Run verification with the installed Quixos tooling (its exact Nix checker is required)");
|
||||||
|
const builder = path.join(generator, "share/checked-package.nix");
|
||||||
|
await fs.access(builder);
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn("nix", ["build", "--impure", "--file", builder,
|
||||||
|
"--argstr", "source", source, "--argstr", "schema", schema,
|
||||||
|
"--argstr", "generator", generator, "--argstr", "packageRevisionId", packageRevisionId,
|
||||||
|
"--no-link", "--print-out-paths", "-L"], {env: environment(), stdio: ["ignore", "pipe", "inherit"]});
|
||||||
|
let output = "";
|
||||||
|
child.stdout.on("data", chunk => {output += chunk;});
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", code => {
|
||||||
|
const artifact = output.trim();
|
||||||
|
if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(artifact)) reject(new Error(`Checked Nix build failed (${code}); see build diagnostics above`));
|
||||||
|
else resolve(artifact);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact remote source DAG; the Nix checker owns schema construction and all
|
||||||
|
* nested fetches. Keep build noise in a named log, with a bounded failure tail. */
|
||||||
|
export async function buildImmutableCandidate(source: { repository: string; commit: string }, kind: "workspace" | "interface" | "package", logFile: string, contractOnly = false): Promise<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);
|
||||||
|
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("Candidate origin must be credential-free HTTPS");
|
||||||
|
const generator = process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Use the installed Quixos checker");
|
||||||
|
const builder = path.join(generator, "share/checked-candidate.nix");
|
||||||
|
await fs.access(builder);
|
||||||
|
const log = createWriteStream(logFile, { flags: "wx", mode: 0o600 });
|
||||||
|
await new Promise<void>((resolve, reject) => { log.once("open", () => resolve()); log.once("error", reject); });
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
let output = "", tail = "", failure: Error | undefined;
|
||||||
|
const child = spawn("nix", ["build", "--impure", "--file", builder,
|
||||||
|
"--argstr", "repository", source.repository, "--argstr", "commit", source.commit,
|
||||||
|
"--argstr", "kind", kind, "--argstr", "generator", generator,
|
||||||
|
"--arg", "contractOnly", contractOnly ? "true" : "false",
|
||||||
|
"--no-link", "--print-out-paths", "-L"], { env: environment(), stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
log.on("error", error => { failure = error; child.kill(); });
|
||||||
|
child.stdout.on("data", chunk => { output += chunk; });
|
||||||
|
child.stderr.on("data", chunk => { log.write(chunk); tail = (tail + String(chunk)).slice(-6000); });
|
||||||
|
child.on("error", error => { failure = error; });
|
||||||
|
child.on("close", code => log.end(() => {
|
||||||
|
if (failure) reject(failure);
|
||||||
|
else if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(output.trim())) reject(new Error(`Candidate Nix check failed (${code}). Full log: ${logFile}\n${tail}`));
|
||||||
|
else resolve(output.trim());
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,21 +2,23 @@
|
|||||||
|
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { compileCapabilitySource } from "./parser.js";
|
import { compileCapabilityResourceSource, compileCapabilitySource } from "./parser.js";
|
||||||
import { capabilityId } from "../capability-model/types.js";
|
import { capabilityId } from "../capability-model/types.js";
|
||||||
import { compileWorkspaceRevision } from "../capability-model/validation.js";
|
import { compileWorkspaceRevision } from "../capability-model/validation.js";
|
||||||
|
|
||||||
const usage = `usage: quixos-capability-compile [--check]
|
const usage = `usage: quixos-capability-compile [--check] [--resource]
|
||||||
[--workspace-id ID] [--workspace-revision-id ID]
|
[--workspace-id ID] [--workspace-revision-id ID]
|
||||||
[--source-root-commit GIT_REV] <workspace.qx | ->
|
[--source-root-commit GIT_REV] <workspace.qx | ->
|
||||||
|
|
||||||
Parses, lowers, and validates a Quixos capability workspace. By default the
|
Parses, lowers, and validates a Quixos capability workspace. --resource instead
|
||||||
checked semantic workspace revision is written as JSON. --check emits no JSON.
|
expects one standalone interface or package document. By default the checked
|
||||||
|
semantic workspace revision or resource is written as JSON. --check emits no JSON.
|
||||||
The identity overrides instantiate a checked built-in/template assembly for one
|
The identity overrides instantiate a checked built-in/template assembly for one
|
||||||
real workspace; both must be supplied together.`;
|
real workspace; both must be supplied together.`;
|
||||||
|
|
||||||
const parseArgs = (args: string[]) => {
|
const parseArgs = (args: string[]) => {
|
||||||
let checkOnly = false;
|
let checkOnly = false;
|
||||||
|
let resource = false;
|
||||||
let workspaceId: string | undefined;
|
let workspaceId: string | undefined;
|
||||||
let workspaceRevisionId: string | undefined;
|
let workspaceRevisionId: string | undefined;
|
||||||
let sourceRootCommit: string | undefined;
|
let sourceRootCommit: string | undefined;
|
||||||
@@ -24,6 +26,7 @@ const parseArgs = (args: string[]) => {
|
|||||||
for (let index = 0; index < args.length; index += 1) {
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
const argument = args[index]!;
|
const argument = args[index]!;
|
||||||
if (argument === "--check") checkOnly = true;
|
if (argument === "--check") checkOnly = true;
|
||||||
|
else if (argument === "--resource") resource = true;
|
||||||
else if (argument === "--workspace-id") workspaceId = args[++index];
|
else if (argument === "--workspace-id") workspaceId = args[++index];
|
||||||
else if (argument === "--workspace-revision-id") workspaceRevisionId = args[++index];
|
else if (argument === "--workspace-revision-id") workspaceRevisionId = args[++index];
|
||||||
else if (argument === "--source-root-commit") sourceRootCommit = args[++index];
|
else if (argument === "--source-root-commit") sourceRootCommit = args[++index];
|
||||||
@@ -31,10 +34,14 @@ const parseArgs = (args: string[]) => {
|
|||||||
else if (argument.startsWith("-")) throw new Error(usage);
|
else if (argument.startsWith("-")) throw new Error(usage);
|
||||||
else positional.push(argument);
|
else positional.push(argument);
|
||||||
}
|
}
|
||||||
if (positional.length !== 1 || Boolean(workspaceId) !== Boolean(workspaceRevisionId)) {
|
if (
|
||||||
|
positional.length !== 1
|
||||||
|
|| Boolean(workspaceId) !== Boolean(workspaceRevisionId)
|
||||||
|
|| (resource && Boolean(workspaceId || workspaceRevisionId || sourceRootCommit))
|
||||||
|
) {
|
||||||
throw new Error(usage);
|
throw new Error(usage);
|
||||||
}
|
}
|
||||||
return { checkOnly, workspaceId, workspaceRevisionId, sourceRootCommit, fileName: positional[0]! };
|
return { checkOnly, resource, workspaceId, workspaceRevisionId, sourceRootCommit, fileName: positional[0]! };
|
||||||
};
|
};
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
@@ -45,6 +52,7 @@ const main = async () => {
|
|||||||
}
|
}
|
||||||
const {
|
const {
|
||||||
checkOnly,
|
checkOnly,
|
||||||
|
resource,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceRevisionId,
|
workspaceRevisionId,
|
||||||
sourceRootCommit,
|
sourceRootCommit,
|
||||||
@@ -59,9 +67,16 @@ const main = async () => {
|
|||||||
process.stdin.on("error", reject);
|
process.stdin.on("error", reject);
|
||||||
})
|
})
|
||||||
: await readFile(fileName, "utf8");
|
: await readFile(fileName, "utf8");
|
||||||
const result = compileCapabilitySource(source, fileName);
|
const reportDiagnostics = (diagnostics: readonly {
|
||||||
if (!result.ok) {
|
fileName: string;
|
||||||
for (const diagnostic of result.diagnostics) {
|
line: number;
|
||||||
|
column: number;
|
||||||
|
phase: string;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
path?: string;
|
||||||
|
}[]) => {
|
||||||
|
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}` : ""}`;
|
||||||
@@ -70,6 +85,25 @@ const main = async () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
};
|
||||||
|
if (resource) {
|
||||||
|
const result = compileCapabilityResourceSource(source, {
|
||||||
|
source: {
|
||||||
|
repository: "https://compiler.invalid/resource.git",
|
||||||
|
commit: "0000000000000000000000000000000000000000",
|
||||||
|
},
|
||||||
|
fileName,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
reportDiagnostics(result.diagnostics);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!checkOnly) process.stdout.write(`${JSON.stringify(result.resource, null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = compileCapabilitySource(source, fileName);
|
||||||
|
if (!result.ok) {
|
||||||
|
reportDiagnostics(result.diagnostics);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workspace = workspaceId && workspaceRevisionId
|
const workspace = workspaceId && workspaceRevisionId
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
/** Kernel-owned lock: a crashed coordinator cannot leave a stale ownership file.
|
||||||
|
* The persistent file is just an inode; EOF releases the helper's lock. */
|
||||||
|
export async function withFileLock<T>(filename: string, work: () => Promise<T>): Promise<T> {
|
||||||
|
const child = spawn("flock", ["--exclusive", "--timeout", "120", "--conflict-exit-code", "75", filename,
|
||||||
|
process.execPath, "-e", 'process.stdout.write("locked\\n"); process.stdin.resume();'], {stdio: ["pipe", "pipe", "pipe"]});
|
||||||
|
let diagnostics = "";
|
||||||
|
child.stdin.on("error", () => { /* acquisition/exit handling reports helper failure */ });
|
||||||
|
child.stderr.on("data", chunk => { diagnostics = (diagnostics + String(chunk)).slice(-2000); });
|
||||||
|
const closed = new Promise<void>((resolve) => { child.once("close", () => resolve()); });
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let output = "";
|
||||||
|
child.once("error", reject);
|
||||||
|
child.once("exit", code => reject(new Error(code === 75 ? "Timed out after 120 seconds waiting for another authoring command; inspect that command before retrying" : `Cannot acquire authoring lock: ${diagnostics}`)));
|
||||||
|
child.stdout.on("data", chunk => { output += chunk; if (output.includes("locked\n")) resolve(); });
|
||||||
|
});
|
||||||
|
return await work();
|
||||||
|
} finally { child.stdin.end(); await closed; }
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,183 +1,213 @@
|
|||||||
WORKSPACE=1
|
WORKSPACE=1
|
||||||
ATOM=2
|
FRAGMENT=2
|
||||||
INTERFACE=3
|
IMPORT=3
|
||||||
INTERFACES=4
|
EXTERNAL=4
|
||||||
PACKAGE=5
|
ATOM=5
|
||||||
VALUE=6
|
INTERFACE=6
|
||||||
RELATION=7
|
INTERFACES=7
|
||||||
OPERATION=8
|
PACKAGE=8
|
||||||
FUNCTION=9
|
VALUE=9
|
||||||
CONSTRUCTOR=10
|
RELATION=10
|
||||||
CONSTRUCTS=11
|
OPERATION=11
|
||||||
CONFORM=12
|
FUNCTION=12
|
||||||
AS=13
|
CONSTRUCTOR=13
|
||||||
BIND=14
|
CONSTRUCTS=14
|
||||||
TO=15
|
INPUT=15
|
||||||
PRIVATE=16
|
CONFORM=16
|
||||||
SHARED=17
|
AS=17
|
||||||
STATE=18
|
BIND=18
|
||||||
EDGE=19
|
TO=19
|
||||||
PROJECTION=20
|
PRIVATE=20
|
||||||
WITH=21
|
SHARED=21
|
||||||
ON=22
|
STATE=22
|
||||||
POLICY=23
|
EDGE=23
|
||||||
DEFAULT=24
|
PROJECTION=24
|
||||||
SOURCE=25
|
WITH=25
|
||||||
REPOSITORY=26
|
USING=26
|
||||||
COMMIT=27
|
VIA=27
|
||||||
REVISION=28
|
MATERIALIZE=28
|
||||||
ID=29
|
IF=29
|
||||||
DOC=30
|
ABSENT=30
|
||||||
MODE=31
|
ON=31
|
||||||
EMITS=32
|
POLICY=32
|
||||||
RECEIVER=33
|
DEFAULT=33
|
||||||
REQUIRES=34
|
SOURCE=34
|
||||||
ANY=35
|
REPOSITORY=35
|
||||||
GET=36
|
COMMIT=36
|
||||||
SET=37
|
REVISION=37
|
||||||
WATCH=38
|
SEMANTIC_MAJOR=38
|
||||||
START=39
|
ON_DELETE=39
|
||||||
STOP=40
|
RETAIN_OTHER=40
|
||||||
READ=41
|
KEYED=41
|
||||||
WRITE=42
|
PUBLIC_TRAVERSAL=42
|
||||||
RESOLVE=43
|
ID=43
|
||||||
CONNECT=44
|
DOC=44
|
||||||
DISCONNECT=45
|
MODE=45
|
||||||
CALL=46
|
EMITS=46
|
||||||
WATCH_START=47
|
RECEIVER=47
|
||||||
WATCH_STOP=48
|
REQUIRES=48
|
||||||
SUBSCRIBE=49
|
ANY=49
|
||||||
UNSUBSCRIBE=50
|
GET=50
|
||||||
OPTIMISTIC_REGISTER=51
|
SET=51
|
||||||
CRDT=52
|
WATCH=52
|
||||||
OPTIONAL_ONE=53
|
START=53
|
||||||
EXACTLY_ONE=54
|
STOP=54
|
||||||
MANY_UNIQUE=55
|
READ=55
|
||||||
MANY=56
|
WRITE=56
|
||||||
ORDERED=57
|
RESOLVE=57
|
||||||
UNIT=58
|
CONNECT=58
|
||||||
WATCH_HANDLE=59
|
DISCONNECT=59
|
||||||
MESSAGE=60
|
CALL=60
|
||||||
ATOM_REF=61
|
WATCH_START=61
|
||||||
INTERFACE_REF=62
|
WATCH_STOP=62
|
||||||
OPTIONAL=63
|
SUBSCRIBE=63
|
||||||
LIST=64
|
UNSUBSCRIBE=64
|
||||||
BOOL=65
|
OPTIMISTIC_REGISTER=65
|
||||||
BYTES=66
|
CRDT=66
|
||||||
DOUBLE=67
|
OPTIONAL_ONE=67
|
||||||
INT32=68
|
EXACTLY_ONE=68
|
||||||
INT64=69
|
MANY_UNIQUE=69
|
||||||
STRING=70
|
MANY=70
|
||||||
UINT32=71
|
ORDERED=71
|
||||||
UINT64=72
|
UNIT=72
|
||||||
TRUE=73
|
WATCH_HANDLE=73
|
||||||
FALSE=74
|
MESSAGE=74
|
||||||
NULL=75
|
ATOM_REF=75
|
||||||
ARROW=76
|
INTERFACE_REF=76
|
||||||
COLON=77
|
OPTIONAL=77
|
||||||
SEMI=78
|
LIST=78
|
||||||
COMMA=79
|
RECORD=79
|
||||||
DOT=80
|
BOOL=80
|
||||||
LBRACE=81
|
BYTES=81
|
||||||
RBRACE=82
|
DOUBLE=82
|
||||||
LBRACK=83
|
INT32=83
|
||||||
RBRACK=84
|
INT64=84
|
||||||
LPAREN=85
|
STRING=85
|
||||||
RPAREN=86
|
UINT32=86
|
||||||
LT=87
|
UINT64=87
|
||||||
GT=88
|
TRUE=88
|
||||||
INTEGER=89
|
FALSE=89
|
||||||
JSON_NUMBER=90
|
NULL=90
|
||||||
IDENTIFIER=91
|
ARROW=91
|
||||||
STRING_LITERAL=92
|
COLON=92
|
||||||
LINE_COMMENT=93
|
SEMI=93
|
||||||
BLOCK_COMMENT=94
|
COMMA=94
|
||||||
WS=95
|
DOT=95
|
||||||
|
LBRACE=96
|
||||||
|
RBRACE=97
|
||||||
|
LBRACK=98
|
||||||
|
RBRACK=99
|
||||||
|
LPAREN=100
|
||||||
|
RPAREN=101
|
||||||
|
LT=102
|
||||||
|
GT=103
|
||||||
|
INTEGER=104
|
||||||
|
JSON_NUMBER=105
|
||||||
|
IDENTIFIER=106
|
||||||
|
STRING_LITERAL=107
|
||||||
|
LINE_COMMENT=108
|
||||||
|
BLOCK_COMMENT=109
|
||||||
|
WS=110
|
||||||
'workspace'=1
|
'workspace'=1
|
||||||
'atom'=2
|
'fragment'=2
|
||||||
'interface'=3
|
'import'=3
|
||||||
'interfaces'=4
|
'external'=4
|
||||||
'package'=5
|
'atom'=5
|
||||||
'value'=6
|
'interface'=6
|
||||||
'relation'=7
|
'interfaces'=7
|
||||||
'operation'=8
|
'package'=8
|
||||||
'function'=9
|
'value'=9
|
||||||
'constructor'=10
|
'relation'=10
|
||||||
'constructs'=11
|
'operation'=11
|
||||||
'conform'=12
|
'function'=12
|
||||||
'as'=13
|
'constructor'=13
|
||||||
'bind'=14
|
'constructs'=14
|
||||||
'to'=15
|
'input'=15
|
||||||
'private'=16
|
'conform'=16
|
||||||
'shared'=17
|
'as'=17
|
||||||
'state'=18
|
'bind'=18
|
||||||
'edge'=19
|
'to'=19
|
||||||
'projection'=20
|
'private'=20
|
||||||
'with'=21
|
'shared'=21
|
||||||
'on'=22
|
'state'=22
|
||||||
'policy'=23
|
'edge'=23
|
||||||
'default'=24
|
'projection'=24
|
||||||
'source'=25
|
'with'=25
|
||||||
'repository'=26
|
'using'=26
|
||||||
'commit'=27
|
'via'=27
|
||||||
'revision'=28
|
'materialize'=28
|
||||||
'id'=29
|
'if'=29
|
||||||
'doc'=30
|
'absent'=30
|
||||||
'mode'=31
|
'on'=31
|
||||||
'emits'=32
|
'policy'=32
|
||||||
'receiver'=33
|
'default'=33
|
||||||
'requires'=34
|
'source'=34
|
||||||
'any'=35
|
'repository'=35
|
||||||
'get'=36
|
'commit'=36
|
||||||
'set'=37
|
'revision'=37
|
||||||
'watch'=38
|
'semantic-major'=38
|
||||||
'start'=39
|
'on-delete'=39
|
||||||
'stop'=40
|
'retain-other'=40
|
||||||
'read'=41
|
'keyed'=41
|
||||||
'write'=42
|
'public-traversal'=42
|
||||||
'resolve'=43
|
'id'=43
|
||||||
'connect'=44
|
'doc'=44
|
||||||
'disconnect'=45
|
'mode'=45
|
||||||
'call'=46
|
'emits'=46
|
||||||
'watch-start'=47
|
'receiver'=47
|
||||||
'watch-stop'=48
|
'requires'=48
|
||||||
'subscribe'=49
|
'any'=49
|
||||||
'unsubscribe'=50
|
'get'=50
|
||||||
'optimistic-register'=51
|
'set'=51
|
||||||
'crdt'=52
|
'watch'=52
|
||||||
'optional-one'=53
|
'start'=53
|
||||||
'exactly-one'=54
|
'stop'=54
|
||||||
'many-unique'=55
|
'read'=55
|
||||||
'many'=56
|
'write'=56
|
||||||
'ordered'=57
|
'resolve'=57
|
||||||
'unit'=58
|
'connect'=58
|
||||||
'watch-handle'=59
|
'disconnect'=59
|
||||||
'message'=60
|
'call'=60
|
||||||
'atom-ref'=61
|
'watch-start'=61
|
||||||
'interface-ref'=62
|
'watch-stop'=62
|
||||||
'optional'=63
|
'subscribe'=63
|
||||||
'list'=64
|
'unsubscribe'=64
|
||||||
'bool'=65
|
'optimistic-register'=65
|
||||||
'bytes'=66
|
'crdt'=66
|
||||||
'double'=67
|
'optional-one'=67
|
||||||
'int32'=68
|
'exactly-one'=68
|
||||||
'int64'=69
|
'many-unique'=69
|
||||||
'string'=70
|
'many'=70
|
||||||
'uint32'=71
|
'ordered'=71
|
||||||
'uint64'=72
|
'unit'=72
|
||||||
'true'=73
|
'watch-handle'=73
|
||||||
'false'=74
|
'message'=74
|
||||||
'null'=75
|
'atom-ref'=75
|
||||||
'->'=76
|
'interface-ref'=76
|
||||||
':'=77
|
'optional'=77
|
||||||
';'=78
|
'list'=78
|
||||||
','=79
|
'record'=79
|
||||||
'.'=80
|
'bool'=80
|
||||||
'{'=81
|
'bytes'=81
|
||||||
'}'=82
|
'double'=82
|
||||||
'['=83
|
'int32'=83
|
||||||
']'=84
|
'int64'=84
|
||||||
'('=85
|
'string'=85
|
||||||
')'=86
|
'uint32'=86
|
||||||
'<'=87
|
'uint64'=87
|
||||||
'>'=88
|
'true'=88
|
||||||
|
'false'=89
|
||||||
|
'null'=90
|
||||||
|
'->'=91
|
||||||
|
':'=92
|
||||||
|
';'=93
|
||||||
|
','=94
|
||||||
|
'.'=95
|
||||||
|
'{'=96
|
||||||
|
'}'=97
|
||||||
|
'['=98
|
||||||
|
']'=99
|
||||||
|
'('=100
|
||||||
|
')'=101
|
||||||
|
'<'=102
|
||||||
|
'>'=103
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,183 +1,213 @@
|
|||||||
WORKSPACE=1
|
WORKSPACE=1
|
||||||
ATOM=2
|
FRAGMENT=2
|
||||||
INTERFACE=3
|
IMPORT=3
|
||||||
INTERFACES=4
|
EXTERNAL=4
|
||||||
PACKAGE=5
|
ATOM=5
|
||||||
VALUE=6
|
INTERFACE=6
|
||||||
RELATION=7
|
INTERFACES=7
|
||||||
OPERATION=8
|
PACKAGE=8
|
||||||
FUNCTION=9
|
VALUE=9
|
||||||
CONSTRUCTOR=10
|
RELATION=10
|
||||||
CONSTRUCTS=11
|
OPERATION=11
|
||||||
CONFORM=12
|
FUNCTION=12
|
||||||
AS=13
|
CONSTRUCTOR=13
|
||||||
BIND=14
|
CONSTRUCTS=14
|
||||||
TO=15
|
INPUT=15
|
||||||
PRIVATE=16
|
CONFORM=16
|
||||||
SHARED=17
|
AS=17
|
||||||
STATE=18
|
BIND=18
|
||||||
EDGE=19
|
TO=19
|
||||||
PROJECTION=20
|
PRIVATE=20
|
||||||
WITH=21
|
SHARED=21
|
||||||
ON=22
|
STATE=22
|
||||||
POLICY=23
|
EDGE=23
|
||||||
DEFAULT=24
|
PROJECTION=24
|
||||||
SOURCE=25
|
WITH=25
|
||||||
REPOSITORY=26
|
USING=26
|
||||||
COMMIT=27
|
VIA=27
|
||||||
REVISION=28
|
MATERIALIZE=28
|
||||||
ID=29
|
IF=29
|
||||||
DOC=30
|
ABSENT=30
|
||||||
MODE=31
|
ON=31
|
||||||
EMITS=32
|
POLICY=32
|
||||||
RECEIVER=33
|
DEFAULT=33
|
||||||
REQUIRES=34
|
SOURCE=34
|
||||||
ANY=35
|
REPOSITORY=35
|
||||||
GET=36
|
COMMIT=36
|
||||||
SET=37
|
REVISION=37
|
||||||
WATCH=38
|
SEMANTIC_MAJOR=38
|
||||||
START=39
|
ON_DELETE=39
|
||||||
STOP=40
|
RETAIN_OTHER=40
|
||||||
READ=41
|
KEYED=41
|
||||||
WRITE=42
|
PUBLIC_TRAVERSAL=42
|
||||||
RESOLVE=43
|
ID=43
|
||||||
CONNECT=44
|
DOC=44
|
||||||
DISCONNECT=45
|
MODE=45
|
||||||
CALL=46
|
EMITS=46
|
||||||
WATCH_START=47
|
RECEIVER=47
|
||||||
WATCH_STOP=48
|
REQUIRES=48
|
||||||
SUBSCRIBE=49
|
ANY=49
|
||||||
UNSUBSCRIBE=50
|
GET=50
|
||||||
OPTIMISTIC_REGISTER=51
|
SET=51
|
||||||
CRDT=52
|
WATCH=52
|
||||||
OPTIONAL_ONE=53
|
START=53
|
||||||
EXACTLY_ONE=54
|
STOP=54
|
||||||
MANY_UNIQUE=55
|
READ=55
|
||||||
MANY=56
|
WRITE=56
|
||||||
ORDERED=57
|
RESOLVE=57
|
||||||
UNIT=58
|
CONNECT=58
|
||||||
WATCH_HANDLE=59
|
DISCONNECT=59
|
||||||
MESSAGE=60
|
CALL=60
|
||||||
ATOM_REF=61
|
WATCH_START=61
|
||||||
INTERFACE_REF=62
|
WATCH_STOP=62
|
||||||
OPTIONAL=63
|
SUBSCRIBE=63
|
||||||
LIST=64
|
UNSUBSCRIBE=64
|
||||||
BOOL=65
|
OPTIMISTIC_REGISTER=65
|
||||||
BYTES=66
|
CRDT=66
|
||||||
DOUBLE=67
|
OPTIONAL_ONE=67
|
||||||
INT32=68
|
EXACTLY_ONE=68
|
||||||
INT64=69
|
MANY_UNIQUE=69
|
||||||
STRING=70
|
MANY=70
|
||||||
UINT32=71
|
ORDERED=71
|
||||||
UINT64=72
|
UNIT=72
|
||||||
TRUE=73
|
WATCH_HANDLE=73
|
||||||
FALSE=74
|
MESSAGE=74
|
||||||
NULL=75
|
ATOM_REF=75
|
||||||
ARROW=76
|
INTERFACE_REF=76
|
||||||
COLON=77
|
OPTIONAL=77
|
||||||
SEMI=78
|
LIST=78
|
||||||
COMMA=79
|
RECORD=79
|
||||||
DOT=80
|
BOOL=80
|
||||||
LBRACE=81
|
BYTES=81
|
||||||
RBRACE=82
|
DOUBLE=82
|
||||||
LBRACK=83
|
INT32=83
|
||||||
RBRACK=84
|
INT64=84
|
||||||
LPAREN=85
|
STRING=85
|
||||||
RPAREN=86
|
UINT32=86
|
||||||
LT=87
|
UINT64=87
|
||||||
GT=88
|
TRUE=88
|
||||||
INTEGER=89
|
FALSE=89
|
||||||
JSON_NUMBER=90
|
NULL=90
|
||||||
IDENTIFIER=91
|
ARROW=91
|
||||||
STRING_LITERAL=92
|
COLON=92
|
||||||
LINE_COMMENT=93
|
SEMI=93
|
||||||
BLOCK_COMMENT=94
|
COMMA=94
|
||||||
WS=95
|
DOT=95
|
||||||
|
LBRACE=96
|
||||||
|
RBRACE=97
|
||||||
|
LBRACK=98
|
||||||
|
RBRACK=99
|
||||||
|
LPAREN=100
|
||||||
|
RPAREN=101
|
||||||
|
LT=102
|
||||||
|
GT=103
|
||||||
|
INTEGER=104
|
||||||
|
JSON_NUMBER=105
|
||||||
|
IDENTIFIER=106
|
||||||
|
STRING_LITERAL=107
|
||||||
|
LINE_COMMENT=108
|
||||||
|
BLOCK_COMMENT=109
|
||||||
|
WS=110
|
||||||
'workspace'=1
|
'workspace'=1
|
||||||
'atom'=2
|
'fragment'=2
|
||||||
'interface'=3
|
'import'=3
|
||||||
'interfaces'=4
|
'external'=4
|
||||||
'package'=5
|
'atom'=5
|
||||||
'value'=6
|
'interface'=6
|
||||||
'relation'=7
|
'interfaces'=7
|
||||||
'operation'=8
|
'package'=8
|
||||||
'function'=9
|
'value'=9
|
||||||
'constructor'=10
|
'relation'=10
|
||||||
'constructs'=11
|
'operation'=11
|
||||||
'conform'=12
|
'function'=12
|
||||||
'as'=13
|
'constructor'=13
|
||||||
'bind'=14
|
'constructs'=14
|
||||||
'to'=15
|
'input'=15
|
||||||
'private'=16
|
'conform'=16
|
||||||
'shared'=17
|
'as'=17
|
||||||
'state'=18
|
'bind'=18
|
||||||
'edge'=19
|
'to'=19
|
||||||
'projection'=20
|
'private'=20
|
||||||
'with'=21
|
'shared'=21
|
||||||
'on'=22
|
'state'=22
|
||||||
'policy'=23
|
'edge'=23
|
||||||
'default'=24
|
'projection'=24
|
||||||
'source'=25
|
'with'=25
|
||||||
'repository'=26
|
'using'=26
|
||||||
'commit'=27
|
'via'=27
|
||||||
'revision'=28
|
'materialize'=28
|
||||||
'id'=29
|
'if'=29
|
||||||
'doc'=30
|
'absent'=30
|
||||||
'mode'=31
|
'on'=31
|
||||||
'emits'=32
|
'policy'=32
|
||||||
'receiver'=33
|
'default'=33
|
||||||
'requires'=34
|
'source'=34
|
||||||
'any'=35
|
'repository'=35
|
||||||
'get'=36
|
'commit'=36
|
||||||
'set'=37
|
'revision'=37
|
||||||
'watch'=38
|
'semantic-major'=38
|
||||||
'start'=39
|
'on-delete'=39
|
||||||
'stop'=40
|
'retain-other'=40
|
||||||
'read'=41
|
'keyed'=41
|
||||||
'write'=42
|
'public-traversal'=42
|
||||||
'resolve'=43
|
'id'=43
|
||||||
'connect'=44
|
'doc'=44
|
||||||
'disconnect'=45
|
'mode'=45
|
||||||
'call'=46
|
'emits'=46
|
||||||
'watch-start'=47
|
'receiver'=47
|
||||||
'watch-stop'=48
|
'requires'=48
|
||||||
'subscribe'=49
|
'any'=49
|
||||||
'unsubscribe'=50
|
'get'=50
|
||||||
'optimistic-register'=51
|
'set'=51
|
||||||
'crdt'=52
|
'watch'=52
|
||||||
'optional-one'=53
|
'start'=53
|
||||||
'exactly-one'=54
|
'stop'=54
|
||||||
'many-unique'=55
|
'read'=55
|
||||||
'many'=56
|
'write'=56
|
||||||
'ordered'=57
|
'resolve'=57
|
||||||
'unit'=58
|
'connect'=58
|
||||||
'watch-handle'=59
|
'disconnect'=59
|
||||||
'message'=60
|
'call'=60
|
||||||
'atom-ref'=61
|
'watch-start'=61
|
||||||
'interface-ref'=62
|
'watch-stop'=62
|
||||||
'optional'=63
|
'subscribe'=63
|
||||||
'list'=64
|
'unsubscribe'=64
|
||||||
'bool'=65
|
'optimistic-register'=65
|
||||||
'bytes'=66
|
'crdt'=66
|
||||||
'double'=67
|
'optional-one'=67
|
||||||
'int32'=68
|
'exactly-one'=68
|
||||||
'int64'=69
|
'many-unique'=69
|
||||||
'string'=70
|
'many'=70
|
||||||
'uint32'=71
|
'ordered'=71
|
||||||
'uint64'=72
|
'unit'=72
|
||||||
'true'=73
|
'watch-handle'=73
|
||||||
'false'=74
|
'message'=74
|
||||||
'null'=75
|
'atom-ref'=75
|
||||||
'->'=76
|
'interface-ref'=76
|
||||||
':'=77
|
'optional'=77
|
||||||
';'=78
|
'list'=78
|
||||||
','=79
|
'record'=79
|
||||||
'.'=80
|
'bool'=80
|
||||||
'{'=81
|
'bytes'=81
|
||||||
'}'=82
|
'double'=82
|
||||||
'['=83
|
'int32'=83
|
||||||
']'=84
|
'int64'=84
|
||||||
'('=85
|
'string'=85
|
||||||
')'=86
|
'uint32'=86
|
||||||
'<'=87
|
'uint64'=87
|
||||||
'>'=88
|
'true'=88
|
||||||
|
'false'=89
|
||||||
|
'null'=90
|
||||||
|
'->'=91
|
||||||
|
':'=92
|
||||||
|
';'=93
|
||||||
|
','=94
|
||||||
|
'.'=95
|
||||||
|
'{'=96
|
||||||
|
'}'=97
|
||||||
|
'['=98
|
||||||
|
']'=99
|
||||||
|
'('=100
|
||||||
|
')'=101
|
||||||
|
'<'=102
|
||||||
|
'>'=103
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,16 @@ import { AbstractParseTreeVisitor } from "antlr4ng";
|
|||||||
|
|
||||||
|
|
||||||
import { DocumentContext } from "./QuixosCapabilityParser.js";
|
import { DocumentContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { FragmentDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { SourceImportDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { WorkspaceDeclContext } from "./QuixosCapabilityParser.js";
|
import { WorkspaceDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { WorkspaceItemContext } from "./QuixosCapabilityParser.js";
|
import { WorkspaceItemContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { ResourceImportDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { ExternalAtomDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { ExternalInterfaceDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { ResourcePreambleContext } from "./QuixosCapabilityParser.js";
|
||||||
import { AtomDeclContext } from "./QuixosCapabilityParser.js";
|
import { AtomDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { InterfaceDeclContext } from "./QuixosCapabilityParser.js";
|
import { InterfaceResourceDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { SourceBlockContext } 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";
|
||||||
@@ -15,7 +20,7 @@ import { ValueMemberOperationContext } from "./QuixosCapabilityParser.js";
|
|||||||
import { RelationshipMemberContext } from "./QuixosCapabilityParser.js";
|
import { RelationshipMemberContext } from "./QuixosCapabilityParser.js";
|
||||||
import { RelationshipOperationContext } from "./QuixosCapabilityParser.js";
|
import { RelationshipOperationContext } from "./QuixosCapabilityParser.js";
|
||||||
import { TargetConstraintContext } from "./QuixosCapabilityParser.js";
|
import { TargetConstraintContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageDeclContext } from "./QuixosCapabilityParser.js";
|
import { PackageResourceDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageExportContext } from "./QuixosCapabilityParser.js";
|
import { PackageExportContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageOperationExportContext } from "./QuixosCapabilityParser.js";
|
import { PackageOperationExportContext } from "./QuixosCapabilityParser.js";
|
||||||
import { PackageFunctionExportContext } from "./QuixosCapabilityParser.js";
|
import { PackageFunctionExportContext } from "./QuixosCapabilityParser.js";
|
||||||
@@ -36,6 +41,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 { RelationshipMaterializationDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { OperationBindingDeclContext } from "./QuixosCapabilityParser.js";
|
import { OperationBindingDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { MemberOperationRefContext } from "./QuixosCapabilityParser.js";
|
import { MemberOperationRefContext } from "./QuixosCapabilityParser.js";
|
||||||
import { OperationNameContext } from "./QuixosCapabilityParser.js";
|
import { OperationNameContext } from "./QuixosCapabilityParser.js";
|
||||||
@@ -46,6 +52,7 @@ import { DependencyBindingBlockContext } from "./QuixosCapabilityParser.js";
|
|||||||
import { DependencyBindingContext } from "./QuixosCapabilityParser.js";
|
import { DependencyBindingContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ConstructorBindingDeclContext } from "./QuixosCapabilityParser.js";
|
import { ConstructorBindingDeclContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ValueTypeContext } from "./QuixosCapabilityParser.js";
|
import { ValueTypeContext } from "./QuixosCapabilityParser.js";
|
||||||
|
import { RecordFieldContext } from "./QuixosCapabilityParser.js";
|
||||||
import { ScalarTypeContext } from "./QuixosCapabilityParser.js";
|
import { ScalarTypeContext } from "./QuixosCapabilityParser.js";
|
||||||
import { CardinalityContext } from "./QuixosCapabilityParser.js";
|
import { CardinalityContext } from "./QuixosCapabilityParser.js";
|
||||||
import { JsonLiteralContext } from "./QuixosCapabilityParser.js";
|
import { JsonLiteralContext } from "./QuixosCapabilityParser.js";
|
||||||
@@ -70,6 +77,18 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitDocument?: (ctx: DocumentContext) => Result;
|
visitDocument?: (ctx: DocumentContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.fragmentDecl`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitFragmentDecl?: (ctx: FragmentDeclContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.sourceImportDecl`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitSourceImportDecl?: (ctx: SourceImportDeclContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.workspaceDecl`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.workspaceDecl`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
@@ -82,6 +101,30 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitWorkspaceItem?: (ctx: WorkspaceItemContext) => Result;
|
visitWorkspaceItem?: (ctx: WorkspaceItemContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.resourceImportDecl`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitResourceImportDecl?: (ctx: ResourceImportDeclContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.externalAtomDecl`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitExternalAtomDecl?: (ctx: ExternalAtomDeclContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.externalInterfaceDecl`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitExternalInterfaceDecl?: (ctx: ExternalInterfaceDeclContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.resourcePreamble`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitResourcePreamble?: (ctx: ResourcePreambleContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.atomDecl`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.atomDecl`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
@@ -89,17 +132,11 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
*/
|
*/
|
||||||
visitAtomDecl?: (ctx: AtomDeclContext) => Result;
|
visitAtomDecl?: (ctx: AtomDeclContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.interfaceDecl`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.interfaceResourceDecl`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitInterfaceDecl?: (ctx: InterfaceDeclContext) => Result;
|
visitInterfaceResourceDecl?: (ctx: InterfaceResourceDeclContext) => Result;
|
||||||
/**
|
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.sourceBlock`.
|
|
||||||
* @param ctx the parse tree
|
|
||||||
* @return the visitor result
|
|
||||||
*/
|
|
||||||
visitSourceBlock?: (ctx: SourceBlockContext) => 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
|
||||||
@@ -143,11 +180,11 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
*/
|
*/
|
||||||
visitTargetConstraint?: (ctx: TargetConstraintContext) => Result;
|
visitTargetConstraint?: (ctx: TargetConstraintContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.packageDecl`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.packageResourceDecl`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitPackageDecl?: (ctx: PackageDeclContext) => Result;
|
visitPackageResourceDecl?: (ctx: PackageResourceDeclContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.packageExport`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.packageExport`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
@@ -268,6 +305,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.relationshipMaterializationDecl`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitRelationshipMaterializationDecl?: (ctx: RelationshipMaterializationDeclContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.operationBindingDecl`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.operationBindingDecl`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
@@ -328,6 +371,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitValueType?: (ctx: ValueTypeContext) => Result;
|
visitValueType?: (ctx: ValueTypeContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosCapabilityParser.recordField`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitRecordField?: (ctx: RecordFieldContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosCapabilityParser.scalarType`.
|
* Visit a parse tree produced by `QuixosCapabilityParser.scalarType`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import childProcess from "node:child_process";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { mkdir, mkdtemp, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import type { CapabilityRepositoryResolver } from "./assembly.js";
|
||||||
|
|
||||||
|
const execFile = promisify(childProcess.execFile);
|
||||||
|
|
||||||
|
const sourceKey = (kind: string, repository: string, commit: string) =>
|
||||||
|
`${kind}\0${repository}\0${commit.toLowerCase()}`;
|
||||||
|
|
||||||
|
const checkoutName = (kind: string, repository: string, commit: string) => {
|
||||||
|
const digest = crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(sourceKey(kind, repository, commit))
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 24);
|
||||||
|
return `${kind}-${digest}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createGitCapabilityResolver = async (options: {
|
||||||
|
checkoutRoot: string;
|
||||||
|
snapshotMap?: string;
|
||||||
|
snapshotOnly?: boolean;
|
||||||
|
}): Promise<CapabilityRepositoryResolver> => {
|
||||||
|
await mkdir(options.checkoutRoot, { recursive: true });
|
||||||
|
const checkoutRoot = await realpath(options.checkoutRoot);
|
||||||
|
const snapshots = new Map<string, string>();
|
||||||
|
if (options.snapshotMap) {
|
||||||
|
const snapshotMapPath = await realpath(options.snapshotMap);
|
||||||
|
const document = JSON.parse(await readFile(snapshotMapPath, "utf8")) as {
|
||||||
|
resources?: Array<{
|
||||||
|
kind: string;
|
||||||
|
repository: string;
|
||||||
|
commit: string;
|
||||||
|
directory: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
for (const entry of document.resources ?? []) {
|
||||||
|
if (entry.kind !== "interface" && entry.kind !== "package") {
|
||||||
|
throw new Error(`Snapshot map has unsupported resource kind ${entry.kind}`);
|
||||||
|
}
|
||||||
|
const key = sourceKey(entry.kind, entry.repository, entry.commit);
|
||||||
|
if (snapshots.has(key)) throw new Error(`Snapshot map repeats ${key}`);
|
||||||
|
const directory = path.resolve(path.dirname(snapshotMapPath), entry.directory);
|
||||||
|
snapshots.set(key, await realpath(directory));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkouts = new Map<string, Promise<{ directory: string }>>();
|
||||||
|
return async (source, kind) => {
|
||||||
|
const key = sourceKey(kind, source.repository, source.commit);
|
||||||
|
const snapshot = snapshots.get(key);
|
||||||
|
if (snapshot) return { directory: snapshot };
|
||||||
|
if (options.snapshotOnly) throw new Error(`No offline snapshot for ${kind} ${source.repository}@${source.commit}`);
|
||||||
|
const existing = checkouts.get(key);
|
||||||
|
if (existing) return await existing;
|
||||||
|
const pending = (async () => {
|
||||||
|
const directory = path.join(
|
||||||
|
checkoutRoot,
|
||||||
|
checkoutName(kind, source.repository, source.commit),
|
||||||
|
);
|
||||||
|
const verify = async (checkout: string) => {
|
||||||
|
const { stdout } = await execFile("git", ["-C", checkout, "rev-parse", "HEAD"]);
|
||||||
|
if (stdout.trim().toLowerCase() !== source.commit.toLowerCase()) {
|
||||||
|
throw new Error(`Locked commit mismatch for ${source.repository}: wanted ${source.commit}, fetched ${stdout.trim()}`);
|
||||||
|
}
|
||||||
|
const { stdout: changes } = await execFile("git", ["-C", checkout, "status", "--porcelain", "--untracked-files=all"]);
|
||||||
|
if (changes.trim()) throw new Error(`Dependency checkout was modified: ${checkout}`);
|
||||||
|
};
|
||||||
|
// Only complete, checked clones become visible under the deterministic name.
|
||||||
|
// Concurrent resolvers may fetch independently, but cannot observe a partial clone.
|
||||||
|
if (await stat(directory).then(() => true, (error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === "ENOENT") return false;
|
||||||
|
throw error;
|
||||||
|
})) {
|
||||||
|
await verify(directory);
|
||||||
|
return { directory };
|
||||||
|
}
|
||||||
|
const staging = await mkdtemp(path.join(checkoutRoot, ".fetch-"));
|
||||||
|
const checkout = path.join(staging, "checkout");
|
||||||
|
try {
|
||||||
|
await execFile("git", [
|
||||||
|
"-c",
|
||||||
|
"advice.detachedHead=false",
|
||||||
|
"clone",
|
||||||
|
"--depth",
|
||||||
|
"1",
|
||||||
|
"--single-branch",
|
||||||
|
"--branch",
|
||||||
|
`quixos-reachability/${source.commit.toLowerCase()}`,
|
||||||
|
source.repository,
|
||||||
|
checkout,
|
||||||
|
]);
|
||||||
|
await verify(checkout);
|
||||||
|
try { await rename(checkout, directory); }
|
||||||
|
catch (error) {
|
||||||
|
if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
|
||||||
|
await verify(directory);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(staging, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
return { directory };
|
||||||
|
})();
|
||||||
|
checkouts.set(key, pending);
|
||||||
|
try { return await pending; }
|
||||||
|
catch (error) { checkouts.delete(key); throw error; }
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {parse} from "@babel/parser";
|
||||||
|
type Node = {type: string; start: number; end: number; [key: string]: unknown};
|
||||||
|
const node = (value: unknown): value is Node => !!value && typeof value === "object" && typeof (value as Node).type === "string";
|
||||||
|
|
||||||
|
/** One requested insertion into ordinary authored TypeScript, not regeneration.
|
||||||
|
* Ambiguous/custom wiring is left alone with an actionable error. */
|
||||||
|
export function addImplementation(text: string, factory: "createRuntime" | "serveMigration", key: string, importPath: string, migrationOnly = false): string {
|
||||||
|
const ast = parse(text, {sourceType: "module", plugins: ["typescript"]});
|
||||||
|
const objects: Node[] = [];
|
||||||
|
const names = new Set<string>();
|
||||||
|
const visit = (value: unknown) => {
|
||||||
|
if (Array.isArray(value)) { value.forEach(visit); return; }
|
||||||
|
if (!node(value)) return;
|
||||||
|
if (value.type === "Identifier") names.add(String(value.name));
|
||||||
|
if (value.type === "CallExpression" && node(value.callee) && value.callee.type === "Identifier" && value.callee.name === factory &&
|
||||||
|
Array.isArray(value.arguments) && value.arguments.length === 1 && node(value.arguments[0]) && value.arguments[0].type === "ObjectExpression") objects.push(value.arguments[0]);
|
||||||
|
Object.values(value).forEach(visit);
|
||||||
|
};
|
||||||
|
visit(ast);
|
||||||
|
if (objects.length !== 1) throw new Error(`Cannot safely add implementation: expected one ${factory}({...}) literal. Wire the handler in your authored server code instead.`);
|
||||||
|
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}`);
|
||||||
|
let alias = "qxImplementation";
|
||||||
|
for (let index = 1; names.has(alias); index++) alias = `qxImplementation${index}`;
|
||||||
|
const value = migrationOnly ? 'async () => { throw new Error("Migration-only export"); }' : alias;
|
||||||
|
const insertion = object.start + 1;
|
||||||
|
const edited = text.slice(0, insertion) + `\n ${JSON.stringify(key)}: ${value},\n` + text.slice(insertion);
|
||||||
|
return (migrationOnly ? "" : `import {handler as ${alias}} from ${JSON.stringify(importPath)};\n`) + edited;
|
||||||
|
}
|
||||||
@@ -1 +1,5 @@
|
|||||||
export * from "./parser.js";
|
export * from "./parser.js";
|
||||||
|
export * from "./assembly.js";
|
||||||
|
export * from "./source.js";
|
||||||
|
export * from "./source-loader.js";
|
||||||
|
export * from "./scaffold.js";
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Data only: never load files, resolve repositories, or evaluate code.
|
||||||
|
import { parseQx } from "./source.js";
|
||||||
|
import { parseQuixosLockDocument } from "../resource-lock/parser.js";
|
||||||
|
import { instantiateWorkspaceIdentity } from "./structural-edits.js";
|
||||||
|
let input = "";
|
||||||
|
for await (const chunk of process.stdin) {
|
||||||
|
input += chunk;
|
||||||
|
if (Buffer.byteLength(input) > 6 * 1024 * 1024) throw new Error("Inspection input exceeds limit");
|
||||||
|
}
|
||||||
|
const document = JSON.parse(input);
|
||||||
|
if (!Array.isArray(document) && document?.operation === "instantiate-workspace") {
|
||||||
|
if (typeof document.source !== "string" || document.source.length > 262144 || typeof document.workspaceId !== "string") throw new Error("Invalid template identity request");
|
||||||
|
process.stdout.write(JSON.stringify({ source: instantiateWorkspaceIdentity(document.source, document.workspaceId) }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const files: { path: string; source: string }[] = document;
|
||||||
|
if (!Array.isArray(files) || files.length > 128) throw new Error("Too many source files");
|
||||||
|
const result = files.map(({ path, source }) => {
|
||||||
|
if (typeof path !== "string" || typeof source !== "string" || source.length > 262144)
|
||||||
|
throw new Error("Invalid source input");
|
||||||
|
if (path.endsWith(".qx")) {
|
||||||
|
const parsed = parseQx(source, path);
|
||||||
|
return { path, root: parsed.root, diagnostics: parsed.diagnostics };
|
||||||
|
}
|
||||||
|
return { path, lock: parseQuixosLockDocument(source, path) };
|
||||||
|
});
|
||||||
|
process.stdout.write(JSON.stringify(result));
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {contentDigest} from "../capability-model/evolution.js";
|
||||||
|
import {validateMigrationCatalog, type MigrationCatalog} from "../capability-model/migrations.js";
|
||||||
|
import {applyStructure, planStructure} from "./structural-plan.js";
|
||||||
|
|
||||||
|
/** Explicitly acknowledge edited migration code. This does not change retained
|
||||||
|
* contracts or grant activation approval; the immutable checker verifies it. */
|
||||||
|
export async function sealMigrations(directory: string) {
|
||||||
|
const root = await fs.realpath(directory);
|
||||||
|
const file = "quixos.migrations.json";
|
||||||
|
const before = await fs.readFile(path.join(root, file), "utf8");
|
||||||
|
const catalog = JSON.parse(before) as MigrationCatalog;
|
||||||
|
for (const migration of catalog.migrations) {
|
||||||
|
const implementation = await fs.realpath(path.join(root, migration.implementation.file));
|
||||||
|
if (!implementation.startsWith(root + path.sep)) throw new Error("Migration implementation escapes package");
|
||||||
|
migration.implementation.digest = contentDigest(await fs.readFile(implementation, "utf8"));
|
||||||
|
}
|
||||||
|
validateMigrationCatalog(catalog);
|
||||||
|
return applyStructure(await planStructure(root, {kind: "package", validation: "syntax", files: [
|
||||||
|
{file, expected: before, replace: JSON.stringify(catalog, null, 2) + "\n"},
|
||||||
|
]}));
|
||||||
|
}
|
||||||
@@ -22,10 +22,12 @@ import {
|
|||||||
type EdgeEndpoint,
|
type EdgeEndpoint,
|
||||||
type EdgeEndpointConstraint,
|
type EdgeEndpointConstraint,
|
||||||
type EdgePrimitive,
|
type EdgePrimitive,
|
||||||
|
type EdgeTraversal,
|
||||||
type InterfaceMember,
|
type InterfaceMember,
|
||||||
type InterfaceOperation,
|
type InterfaceOperation,
|
||||||
type InterfaceOperationMode,
|
type InterfaceOperationMode,
|
||||||
type InterfaceRevision,
|
type InterfaceRevision,
|
||||||
|
type AtomDefinition,
|
||||||
type PackageExport,
|
type PackageExport,
|
||||||
type PackageReceiverRequirement,
|
type PackageReceiverRequirement,
|
||||||
type PackageRevision,
|
type PackageRevision,
|
||||||
@@ -42,23 +44,72 @@ import { QuixosCapabilityParser,
|
|||||||
type ConformanceDeclContext,
|
type ConformanceDeclContext,
|
||||||
type DependencyBindingBlockContext,
|
type DependencyBindingBlockContext,
|
||||||
type DependencyPortContext,
|
type DependencyPortContext,
|
||||||
|
type DocumentContext,
|
||||||
type EdgeDeclContext,
|
type EdgeDeclContext,
|
||||||
type EdgeEndpointContext,
|
type EdgeEndpointContext,
|
||||||
type InterfaceDeclContext,
|
type ExternalAtomDeclContext,
|
||||||
|
type ExternalInterfaceDeclContext,
|
||||||
|
type InterfaceResourceDeclContext,
|
||||||
type PackageConstructorExportContext,
|
type PackageConstructorExportContext,
|
||||||
type PackageDeclContext,
|
type PackageResourceDeclContext,
|
||||||
type PackageFunctionExportContext,
|
type PackageFunctionExportContext,
|
||||||
type PackageOperationExportContext,
|
type PackageOperationExportContext,
|
||||||
type OperationMemberContext,
|
type OperationMemberContext,
|
||||||
type SourceBlockContext,
|
type ResourceImportDeclContext,
|
||||||
|
type ResourcePreambleContext,
|
||||||
type StateDeclContext,
|
type StateDeclContext,
|
||||||
type TargetConstraintContext,
|
type TargetConstraintContext,
|
||||||
type ValueMemberContext,
|
type ValueMemberContext,
|
||||||
type ValueTypeContext,
|
type ValueTypeContext,
|
||||||
type RelationshipMemberContext,
|
type RelationshipMemberContext,
|
||||||
|
type RelationshipMaterializationDeclContext,
|
||||||
type WorkspaceDeclContext,
|
type WorkspaceDeclContext,
|
||||||
} from "./generated/QuixosCapabilityParser.js";
|
} from "./generated/QuixosCapabilityParser.js";
|
||||||
|
|
||||||
|
export type CapabilityResourceKind = "interface" | "package";
|
||||||
|
|
||||||
|
export type CapabilityResourceImport = {
|
||||||
|
kind: CapabilityResourceKind;
|
||||||
|
binding: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CapabilityExternalInterface = {
|
||||||
|
binding: string;
|
||||||
|
revisionId: InterfaceRevision["revisionId"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CapabilityImportEnvironment = {
|
||||||
|
interfaces?: ReadonlyMap<string, InterfaceRevision>;
|
||||||
|
packages?: ReadonlyMap<string, PackageRevision>;
|
||||||
|
/** Complete resolved closure; direct maps above provide local authoring names. */
|
||||||
|
interfaceClosure?: readonly InterfaceRevision[];
|
||||||
|
packageClosure?: readonly PackageRevision[];
|
||||||
|
/** External nominal atoms required by imported resources. */
|
||||||
|
externalAtoms?: readonly AtomDefinition[];
|
||||||
|
/** External nominal interfaces supplied by the importing workspace. */
|
||||||
|
externalInterfaces?: readonly CapabilityExternalInterface[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CapabilityResource =
|
||||||
|
| {
|
||||||
|
kind: "interface";
|
||||||
|
imports: CapabilityResourceImport[];
|
||||||
|
externalAtoms: AtomDefinition[];
|
||||||
|
externalInterfaces: CapabilityExternalInterface[];
|
||||||
|
revision: InterfaceRevision;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "package";
|
||||||
|
imports: CapabilityResourceImport[];
|
||||||
|
externalAtoms: AtomDefinition[];
|
||||||
|
externalInterfaces: CapabilityExternalInterface[];
|
||||||
|
revision: PackageRevision;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CapabilityResourceCompileResult =
|
||||||
|
| { ok: true; resource: CapabilityResource; diagnostics: [] }
|
||||||
|
| { ok: false; diagnostics: CapabilitySourceDiagnostic[] };
|
||||||
|
|
||||||
export type CapabilitySourceDiagnosticPhase =
|
export type CapabilitySourceDiagnosticPhase =
|
||||||
| "syntax"
|
| "syntax"
|
||||||
| "lowering"
|
| "lowering"
|
||||||
@@ -79,6 +130,7 @@ export type CapabilitySourceCompileResult =
|
|||||||
ok: true;
|
ok: true;
|
||||||
workspace: WorkspaceRevision;
|
workspace: WorkspaceRevision;
|
||||||
plan: CompiledWorkspaceRevision;
|
plan: CompiledWorkspaceRevision;
|
||||||
|
imports: CapabilityResourceImport[];
|
||||||
diagnostics: [];
|
diagnostics: [];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -88,9 +140,11 @@ export type CapabilitySourceCompileResult =
|
|||||||
|
|
||||||
interface InterfaceSymbol {
|
interface InterfaceSymbol {
|
||||||
revisionId: InterfaceRevision["revisionId"];
|
revisionId: InterfaceRevision["revisionId"];
|
||||||
|
contractAvailable: boolean;
|
||||||
members: Map<
|
members: Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
|
memberId: InterfaceMember["id"];
|
||||||
operations: Map<string, InterfaceOperation["id"]>;
|
operations: Map<string, InterfaceOperation["id"]>;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
@@ -217,11 +271,6 @@ const requireSymbol = <Value>(
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const lowerSource = (context: SourceBlockContext): SourceRevision => ({
|
|
||||||
repository: stringValue(context.stringLiteral(0)),
|
|
||||||
commit: stringValue(context.stringLiteral(1)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const lowerCardinality = (
|
const lowerCardinality = (
|
||||||
context: { getText(): string } | null,
|
context: { getText(): string } | null,
|
||||||
): EdgeCardinality => text(context) as EdgeCardinality;
|
): EdgeCardinality => text(context) as EdgeCardinality;
|
||||||
@@ -270,6 +319,11 @@ const lowerValueType = (
|
|||||||
if (context.MESSAGE()) {
|
if (context.MESSAGE()) {
|
||||||
return valueType.message(stringValue(context.stringLiteral()));
|
return valueType.message(stringValue(context.stringLiteral()));
|
||||||
}
|
}
|
||||||
|
if (context.RECORD()) {
|
||||||
|
const fields = context.recordField().map((field) => [identifier(field.identifier()), lowerValueType(state, field.valueType())] as const);
|
||||||
|
if (new Set(fields.map(([name]) => name)).size !== fields.length) loweringIssue(state, context, "invalid-type", "Duplicate record field");
|
||||||
|
return {kind: "record", fields: Object.fromEntries(fields)};
|
||||||
|
}
|
||||||
if (context.ATOM_REF()) {
|
if (context.ATOM_REF()) {
|
||||||
const name = identifier(context.identifier());
|
const name = identifier(context.identifier());
|
||||||
const atomId = requireSymbol(state, state.atoms, name, context, "atom");
|
const atomId = requireSymbol(state, state.atoms, name, context, "atom");
|
||||||
@@ -531,7 +585,8 @@ const lowerOperationMember = (
|
|||||||
|
|
||||||
const lowerInterface = (
|
const lowerInterface = (
|
||||||
state: LoweringState,
|
state: LoweringState,
|
||||||
context: InterfaceDeclContext,
|
context: InterfaceResourceDeclContext,
|
||||||
|
source: SourceRevision,
|
||||||
): InterfaceRevision => {
|
): InterfaceRevision => {
|
||||||
const alias = identifier(context.identifier());
|
const alias = identifier(context.identifier());
|
||||||
const members = context.interfaceMember().flatMap((entry) => {
|
const members = context.interfaceMember().flatMap((entry) => {
|
||||||
@@ -556,6 +611,7 @@ const lowerInterface = (
|
|||||||
symbol.members,
|
symbol.members,
|
||||||
member.displayName,
|
member.displayName,
|
||||||
{
|
{
|
||||||
|
memberId: member.id,
|
||||||
operations: new Map(
|
operations: new Map(
|
||||||
member.operations.map((operation) => [operation.displayName, operation.id]),
|
member.operations.map((operation) => [operation.displayName, operation.id]),
|
||||||
),
|
),
|
||||||
@@ -568,7 +624,7 @@ const lowerInterface = (
|
|||||||
interfaceId: capabilityId.interface(stringValue(context.stringLiteral(0))),
|
interfaceId: capabilityId.interface(stringValue(context.stringLiteral(0))),
|
||||||
revisionId: symbol.revisionId,
|
revisionId: symbol.revisionId,
|
||||||
displayName: alias,
|
displayName: alias,
|
||||||
source: lowerSource(context.sourceBlock()),
|
source,
|
||||||
members,
|
members,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -648,12 +704,21 @@ const lowerDependencyPort = (
|
|||||||
context,
|
context,
|
||||||
"interface",
|
"interface",
|
||||||
);
|
);
|
||||||
|
if (target && !target.contractAvailable) {
|
||||||
|
loweringIssue(
|
||||||
|
state,
|
||||||
|
context,
|
||||||
|
"nominal-interface-port",
|
||||||
|
`Interface port ${name} needs the full ${targetName} contract; use import interface ${targetName}`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return target
|
return target
|
||||||
? {
|
? {
|
||||||
id,
|
id,
|
||||||
displayName: name,
|
displayName: name,
|
||||||
requirement: {
|
requirement: {
|
||||||
kind: "receiver-interface",
|
kind: "interface",
|
||||||
interfaceRevisionId: target.revisionId,
|
interfaceRevisionId: target.revisionId,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -664,7 +729,8 @@ const lowerDependencyPort = (
|
|||||||
? {
|
? {
|
||||||
id,
|
id,
|
||||||
displayName: name,
|
displayName: name,
|
||||||
requirement: { kind: "constructor", atomId },
|
requirement: { kind: "constructor", atomId,
|
||||||
|
...(context.valueType() ? { inputType: lowerValueType(state, context.valueType()) } : {}) },
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
};
|
};
|
||||||
@@ -811,7 +877,8 @@ const lowerPackageConstructor = (
|
|||||||
|
|
||||||
const lowerPackage = (
|
const lowerPackage = (
|
||||||
state: LoweringState,
|
state: LoweringState,
|
||||||
context: PackageDeclContext,
|
context: PackageResourceDeclContext,
|
||||||
|
source: SourceRevision,
|
||||||
): PackageRevision => {
|
): PackageRevision => {
|
||||||
const alias = identifier(context.identifier());
|
const alias = identifier(context.identifier());
|
||||||
const exports = context.packageExport().map((exportContext) => {
|
const exports = context.packageExport().map((exportContext) => {
|
||||||
@@ -832,8 +899,9 @@ const lowerPackage = (
|
|||||||
return {
|
return {
|
||||||
packageId: capabilityId.package(stringValue(context.stringLiteral(0))),
|
packageId: capabilityId.package(stringValue(context.stringLiteral(0))),
|
||||||
revisionId: state.packages.get(alias)!.revisionId,
|
revisionId: state.packages.get(alias)!.revisionId,
|
||||||
|
...(context.INTEGER() ? { semanticMajor: Number(context.INTEGER()!.getText()) } : {}),
|
||||||
displayName: alias,
|
displayName: alias,
|
||||||
source: lowerSource(context.sourceBlock()),
|
source,
|
||||||
exports,
|
exports,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -876,12 +944,16 @@ const lowerEdgeEndpoint = (
|
|||||||
return constraint
|
return constraint
|
||||||
? {
|
? {
|
||||||
projectionId: capabilityId.edgeProjection(
|
projectionId: capabilityId.edgeProjection(
|
||||||
stringValue(context.stringLiteral()),
|
stringValue(context.stringLiteral(0)),
|
||||||
),
|
),
|
||||||
displayName: identifier(context.identifier()),
|
displayName: identifier(context.identifier()),
|
||||||
constraint,
|
constraint,
|
||||||
cardinality: lowerCardinality(context.cardinality()),
|
cardinality: lowerCardinality(context.cardinality()),
|
||||||
ordered: Boolean(context.ORDERED()),
|
ordered: Boolean(context.ORDERED()),
|
||||||
|
...(context.ON_DELETE() ? { onDelete: stringValue(context.stringLiteral(1)) as EdgeEndpoint["onDelete"] } : {}),
|
||||||
|
...(context.RETAIN_OTHER() ? { retainOther: true } : {}),
|
||||||
|
...(context.KEYED() ? {keyType: stringValue(context.stringLiteral(context.ON_DELETE() ? 2 : 1)) as EdgeEndpoint["keyType"]} : {}),
|
||||||
|
...(context.PUBLIC_TRAVERSAL() ? {publicTraversal: true} : {}),
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
};
|
};
|
||||||
@@ -1003,6 +1075,34 @@ const lowerBoundDependencies = (
|
|||||||
if (!portId) {
|
if (!portId) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const traversal = (
|
||||||
|
edgeName: string,
|
||||||
|
projectionName: string,
|
||||||
|
): EdgeTraversal | undefined => {
|
||||||
|
const attachment = requireSymbol(
|
||||||
|
state,
|
||||||
|
state.attachments,
|
||||||
|
edgeName,
|
||||||
|
entry,
|
||||||
|
"attachment",
|
||||||
|
);
|
||||||
|
if (!attachment || attachment.attachment.kind !== "edge") {
|
||||||
|
if (attachment) {
|
||||||
|
loweringIssue(state, entry, "wrong-attachment-kind", `${edgeName} is state, not an edge`);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const projectionId = requireSymbol(
|
||||||
|
state,
|
||||||
|
attachment.projections,
|
||||||
|
projectionName,
|
||||||
|
entry,
|
||||||
|
`projection on ${edgeName}`,
|
||||||
|
);
|
||||||
|
return projectionId
|
||||||
|
? { edgeTypeId: attachment.attachment.id, projectionId }
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
if (entry.STATE()) {
|
if (entry.STATE()) {
|
||||||
const attachmentName = identifier(entry.identifier(1));
|
const attachmentName = identifier(entry.identifier(1));
|
||||||
const attachment = requireSymbol(
|
const attachment = requireSymbol(
|
||||||
@@ -1023,9 +1123,16 @@ const lowerBoundDependencies = (
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return [{ portId, binding: { kind: "state", slotId: attachment.attachment.id } }];
|
const via = entry.VIA()
|
||||||
|
? traversal(identifier(entry.identifier(2)), identifier(entry.identifier(3)))
|
||||||
|
: undefined;
|
||||||
|
if (entry.VIA() && !via) return [];
|
||||||
|
return [{
|
||||||
|
portId,
|
||||||
|
binding: { kind: "state", slotId: attachment.attachment.id, ...(via ? { via } : {}) },
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
if (entry.EDGE()) {
|
if (entry.EDGE(0) && !entry.INTERFACE()) {
|
||||||
const attachmentName = identifier(entry.identifier(1));
|
const attachmentName = identifier(entry.identifier(1));
|
||||||
const projectionName = identifier(entry.identifier(2));
|
const projectionName = identifier(entry.identifier(2));
|
||||||
const attachment = requireSymbol(
|
const attachment = requireSymbol(
|
||||||
@@ -1053,6 +1160,10 @@ const lowerBoundDependencies = (
|
|||||||
entry,
|
entry,
|
||||||
`projection on ${attachmentName}`,
|
`projection on ${attachmentName}`,
|
||||||
);
|
);
|
||||||
|
const via = entry.VIA()
|
||||||
|
? traversal(identifier(entry.identifier(3)), identifier(entry.identifier(4)))
|
||||||
|
: undefined;
|
||||||
|
if (entry.VIA() && !via) return [];
|
||||||
return projectionId
|
return projectionId
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -1061,6 +1172,7 @@ const lowerBoundDependencies = (
|
|||||||
kind: "edge",
|
kind: "edge",
|
||||||
edgeTypeId: attachment.attachment.id,
|
edgeTypeId: attachment.attachment.id,
|
||||||
projectionId,
|
projectionId,
|
||||||
|
...(via ? { via } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -1075,13 +1187,18 @@ const lowerBoundDependencies = (
|
|||||||
entry,
|
entry,
|
||||||
"interface",
|
"interface",
|
||||||
);
|
);
|
||||||
|
const via = entry.VIA()
|
||||||
|
? traversal(identifier(entry.identifier(2)), identifier(entry.identifier(3)))
|
||||||
|
: undefined;
|
||||||
|
if (entry.VIA() && !via) return [];
|
||||||
return interfaceSymbol
|
return interfaceSymbol
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
portId,
|
portId,
|
||||||
binding: {
|
binding: {
|
||||||
kind: "receiver-interface",
|
kind: "interface",
|
||||||
interfaceRevisionId: interfaceSymbol.revisionId,
|
interfaceRevisionId: interfaceSymbol.revisionId,
|
||||||
|
...(via ? { via } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -1095,6 +1212,50 @@ const lowerBoundDependencies = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const lowerRelationshipMaterialization = (
|
||||||
|
state: LoweringState,
|
||||||
|
interfaceName: string,
|
||||||
|
context: RelationshipMaterializationDeclContext,
|
||||||
|
): WorkspaceRevision["conformances"][number]["relationshipMaterializations"][number] | undefined => {
|
||||||
|
const memberName = identifier(context.identifier(0));
|
||||||
|
const member = requireSymbol(
|
||||||
|
state,
|
||||||
|
state.interfaces.get(interfaceName)?.members ?? new Map(),
|
||||||
|
memberName,
|
||||||
|
context,
|
||||||
|
`member on ${interfaceName}`,
|
||||||
|
);
|
||||||
|
const constructorAtom = requireSymbol(
|
||||||
|
state,
|
||||||
|
state.atoms,
|
||||||
|
identifier(context.identifier(1)),
|
||||||
|
context,
|
||||||
|
"constructor atom",
|
||||||
|
);
|
||||||
|
const edgeName = identifier(context.identifier(2));
|
||||||
|
const edge = requireSymbol(state, state.attachments, edgeName, context, "attachment");
|
||||||
|
if (edge && edge.attachment.kind !== "edge") {
|
||||||
|
loweringIssue(state, context, "wrong-attachment-kind", `${edgeName} is state, not an edge`);
|
||||||
|
}
|
||||||
|
const projectionId = edge?.attachment.kind === "edge"
|
||||||
|
? requireSymbol(
|
||||||
|
state,
|
||||||
|
edge.projections,
|
||||||
|
identifier(context.identifier(3)),
|
||||||
|
context,
|
||||||
|
`projection on ${edgeName}`,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
return member && constructorAtom && edge?.attachment.kind === "edge" && projectionId
|
||||||
|
? {
|
||||||
|
memberId: member.memberId,
|
||||||
|
constructorAtomId: constructorAtom,
|
||||||
|
edgeTypeId: edge.attachment.id,
|
||||||
|
constructedProjectionId: projectionId,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const lowerConformance = (
|
const lowerConformance = (
|
||||||
state: LoweringState,
|
state: LoweringState,
|
||||||
context: ConformanceDeclContext,
|
context: ConformanceDeclContext,
|
||||||
@@ -1246,18 +1407,134 @@ const lowerConformance = (
|
|||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
});
|
});
|
||||||
|
const relationshipMaterializations = context.conformanceItem().flatMap((item) => {
|
||||||
|
const materialization = item.relationshipMaterializationDecl();
|
||||||
|
if (!materialization) return [];
|
||||||
|
const lowered = lowerRelationshipMaterialization(state, interfaceName, materialization);
|
||||||
|
return lowered ? [lowered] : [];
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
atomId,
|
atomId,
|
||||||
interfaceRevisionId: interfaceSymbol.revisionId,
|
interfaceRevisionId: interfaceSymbol.revisionId,
|
||||||
|
...(context.stringLiteral() ? { id: capabilityId.conformance(stringValue(context.stringLiteral()!)) } : {}),
|
||||||
|
...(context.INTEGER() ? { semanticMajor: Number(context.INTEGER()!.getText()) } : {}),
|
||||||
privateAttachments,
|
privateAttachments,
|
||||||
operationBindings,
|
operationBindings,
|
||||||
|
relationshipMaterializations,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const interfaceSymbolFor = (revision: InterfaceRevision): InterfaceSymbol => ({
|
||||||
|
revisionId: revision.revisionId,
|
||||||
|
contractAvailable: true,
|
||||||
|
members: new Map(revision.members.map((member) => [
|
||||||
|
member.displayName,
|
||||||
|
{
|
||||||
|
memberId: member.id,
|
||||||
|
operations: new Map(member.operations.map((operation) => [
|
||||||
|
operation.displayName,
|
||||||
|
operation.id,
|
||||||
|
])),
|
||||||
|
},
|
||||||
|
])),
|
||||||
|
});
|
||||||
|
|
||||||
|
const packageSymbolFor = (revision: PackageRevision): PackageSymbol => ({
|
||||||
|
revisionId: revision.revisionId,
|
||||||
|
exports: new Map(revision.exports.map((entry) => [
|
||||||
|
entry.displayName,
|
||||||
|
{
|
||||||
|
exportId: entry.id,
|
||||||
|
ports: new Map(entry.dependencyPorts.map((port) => [
|
||||||
|
port.displayName,
|
||||||
|
port.id,
|
||||||
|
])),
|
||||||
|
},
|
||||||
|
])),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resourceImport = (
|
||||||
|
context: ResourceImportDeclContext,
|
||||||
|
): CapabilityResourceImport => ({
|
||||||
|
kind: context.INTERFACE() ? "interface" : "package",
|
||||||
|
binding: identifier(context.identifier()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerImports = (
|
||||||
|
state: LoweringState,
|
||||||
|
contexts: readonly ResourceImportDeclContext[],
|
||||||
|
environment: CapabilityImportEnvironment,
|
||||||
|
) => contexts.map((context) => {
|
||||||
|
const imported = resourceImport(context);
|
||||||
|
if (imported.kind === "interface") {
|
||||||
|
const revision = environment.interfaces?.get(imported.binding);
|
||||||
|
if (!revision) {
|
||||||
|
loweringIssue(
|
||||||
|
state,
|
||||||
|
context,
|
||||||
|
"missing-import",
|
||||||
|
`No resolved interface is available for lock binding ${imported.binding}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
declareSymbol(
|
||||||
|
state,
|
||||||
|
state.interfaces,
|
||||||
|
imported.binding,
|
||||||
|
interfaceSymbolFor(revision),
|
||||||
|
context,
|
||||||
|
"interface",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const revision = environment.packages?.get(imported.binding);
|
||||||
|
if (!revision) {
|
||||||
|
loweringIssue(
|
||||||
|
state,
|
||||||
|
context,
|
||||||
|
"missing-import",
|
||||||
|
`No resolved package is available for lock binding ${imported.binding}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
declareSymbol(
|
||||||
|
state,
|
||||||
|
state.packages,
|
||||||
|
imported.binding,
|
||||||
|
packageSymbolFor(revision),
|
||||||
|
context,
|
||||||
|
"package",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return imported;
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueExactRevisions = <Revision extends {
|
||||||
|
revisionId: string;
|
||||||
|
source: SourceRevision;
|
||||||
|
}>(revisions: readonly Revision[]): Revision[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return revisions.filter((revision) => {
|
||||||
|
const key = `${revision.revisionId}\0${revision.source.repository}\0${revision.source.commit}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const uniqueAtoms = (atoms: readonly AtomDefinition[]): AtomDefinition[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return atoms.filter((atom) => {
|
||||||
|
if (seen.has(atom.id)) return false;
|
||||||
|
seen.add(atom.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const lowerWorkspace = (
|
const lowerWorkspace = (
|
||||||
state: LoweringState,
|
state: LoweringState,
|
||||||
context: WorkspaceDeclContext,
|
context: WorkspaceDeclContext,
|
||||||
): WorkspaceRevision => {
|
environment: CapabilityImportEnvironment,
|
||||||
|
): { workspace: WorkspaceRevision; imports: CapabilityResourceImport[] } => {
|
||||||
const items = context.workspaceItem();
|
const items = context.workspaceItem();
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -1273,40 +1550,13 @@ const lowerWorkspace = (
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const interfaceContext = item.interfaceDecl();
|
}
|
||||||
if (interfaceContext) {
|
|
||||||
declareSymbol(
|
const imports = registerImports(
|
||||||
state,
|
state,
|
||||||
state.interfaces,
|
items.flatMap((item) => item.resourceImportDecl() ?? []),
|
||||||
identifier(interfaceContext.identifier()),
|
environment,
|
||||||
{
|
|
||||||
revisionId: capabilityId.interfaceRevision(
|
|
||||||
stringValue(interfaceContext.stringLiteral(1)),
|
|
||||||
),
|
|
||||||
members: new Map(),
|
|
||||||
},
|
|
||||||
interfaceContext,
|
|
||||||
"interface",
|
|
||||||
);
|
);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const packageContext = item.packageDecl();
|
|
||||||
if (packageContext) {
|
|
||||||
declareSymbol(
|
|
||||||
state,
|
|
||||||
state.packages,
|
|
||||||
identifier(packageContext.identifier()),
|
|
||||||
{
|
|
||||||
revisionId: capabilityId.packageRevision(
|
|
||||||
stringValue(packageContext.stringLiteral(1)),
|
|
||||||
),
|
|
||||||
exports: new Map(),
|
|
||||||
},
|
|
||||||
packageContext,
|
|
||||||
"package",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const atoms = items.flatMap((item) => {
|
const atoms = items.flatMap((item) => {
|
||||||
const atom = item.atomDecl();
|
const atom = item.atomDecl();
|
||||||
@@ -1327,15 +1577,15 @@ const lowerWorkspace = (
|
|||||||
: [];
|
: [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const interfaceImports = items.flatMap((item) => {
|
const interfaceImports = uniqueExactRevisions([
|
||||||
const interfaceContext = item.interfaceDecl();
|
...(environment.interfaceClosure ?? []),
|
||||||
return interfaceContext ? [lowerInterface(state, interfaceContext)] : [];
|
...[...(environment.interfaces?.values() ?? [])],
|
||||||
});
|
]);
|
||||||
|
|
||||||
const packageImports = items.flatMap((item) => {
|
const packageImports = uniqueExactRevisions([
|
||||||
const packageContext = item.packageDecl();
|
...(environment.packageClosure ?? []),
|
||||||
return packageContext ? [lowerPackage(state, packageContext)] : [];
|
...[...(environment.packages?.values() ?? [])],
|
||||||
});
|
]);
|
||||||
|
|
||||||
const sharedAttachments: PersistentAttachment[] = [];
|
const sharedAttachments: PersistentAttachment[] = [];
|
||||||
const privateAttachments = new Map<
|
const privateAttachments = new Map<
|
||||||
@@ -1425,6 +1675,8 @@ const lowerWorkspace = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
imports,
|
||||||
|
workspace: {
|
||||||
id: capabilityId.workspaceRevision(stringValue(context.stringLiteral(1))),
|
id: capabilityId.workspaceRevision(stringValue(context.stringLiteral(1))),
|
||||||
workspaceId: capabilityId.workspace(stringValue(context.stringLiteral(0))),
|
workspaceId: capabilityId.workspace(stringValue(context.stringLiteral(0))),
|
||||||
parentRevisionIds: [],
|
parentRevisionIds: [],
|
||||||
@@ -1435,45 +1687,46 @@ const lowerWorkspace = (
|
|||||||
packageImports,
|
packageImports,
|
||||||
conformances,
|
conformances,
|
||||||
constructors,
|
constructors,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const compileCapabilitySource = (
|
export const parseDocument = (
|
||||||
source: string,
|
source: string,
|
||||||
fileName = "<memory>",
|
fileName: string,
|
||||||
): CapabilitySourceCompileResult => {
|
): { tree: DocumentContext; tokens: CommonTokenStream; diagnostics: CapabilitySourceDiagnostic[] } => {
|
||||||
const diagnostics: CapabilitySourceDiagnostic[] = [];
|
const diagnostics: CapabilitySourceDiagnostic[] = [];
|
||||||
const listener = new SyntaxErrorListener(fileName, diagnostics);
|
const listener = new SyntaxErrorListener(fileName, diagnostics);
|
||||||
const lexer = new QuixosCapabilityLexer(CharStream.fromString(source));
|
const lexer = new QuixosCapabilityLexer(CharStream.fromString(source));
|
||||||
lexer.removeErrorListeners();
|
lexer.removeErrorListeners();
|
||||||
lexer.addErrorListener(listener);
|
lexer.addErrorListener(listener);
|
||||||
const parser = new QuixosCapabilityParser(
|
const tokens = new CommonTokenStream(lexer);
|
||||||
new CommonTokenStream(lexer),
|
const parser = new QuixosCapabilityParser(tokens);
|
||||||
);
|
|
||||||
parser.removeErrorListeners();
|
parser.removeErrorListeners();
|
||||||
parser.addErrorListener(listener);
|
parser.addErrorListener(listener);
|
||||||
const tree = parser.document();
|
const tree = parser.document();
|
||||||
if (diagnostics.length > 0) {
|
tokens.fill();
|
||||||
return { ok: false, diagnostics };
|
return { tree, tokens, diagnostics };
|
||||||
}
|
};
|
||||||
|
|
||||||
const state: LoweringState = {
|
const newLoweringState = (
|
||||||
|
fileName: string,
|
||||||
|
diagnostics: CapabilitySourceDiagnostic[],
|
||||||
|
): LoweringState => ({
|
||||||
fileName,
|
fileName,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
atoms: new Map(),
|
atoms: new Map(),
|
||||||
interfaces: new Map(),
|
interfaces: new Map(),
|
||||||
packages: new Map(),
|
packages: new Map(),
|
||||||
attachments: new Map(),
|
attachments: new Map(),
|
||||||
};
|
});
|
||||||
const workspace = lowerWorkspace(state, tree.workspaceDecl());
|
|
||||||
if (diagnostics.length > 0) {
|
const validationDiagnostics = (
|
||||||
return { ok: false, diagnostics };
|
fileName: string,
|
||||||
}
|
issues: ReturnType<typeof compileWorkspaceRevision> extends infer _Result
|
||||||
const compiled = compileWorkspaceRevision(workspace);
|
? Array<{ code: string; message: string; path: string }>
|
||||||
if (!compiled.ok) {
|
: never,
|
||||||
return {
|
): CapabilitySourceDiagnostic[] => issues.map((entry) => ({
|
||||||
ok: false,
|
|
||||||
diagnostics: compiled.issues.map((entry) => ({
|
|
||||||
phase: "validation",
|
phase: "validation",
|
||||||
code: entry.code,
|
code: entry.code,
|
||||||
message: entry.message,
|
message: entry.message,
|
||||||
@@ -1481,8 +1734,251 @@ export const compileCapabilitySource = (
|
|||||||
line: 0,
|
line: 0,
|
||||||
column: 0,
|
column: 0,
|
||||||
path: entry.path,
|
path: entry.path,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const externalAtomsFrom = (
|
||||||
|
state: LoweringState,
|
||||||
|
contexts: readonly ExternalAtomDeclContext[],
|
||||||
|
): AtomDefinition[] => contexts.map((context) => {
|
||||||
|
const atom: AtomDefinition = {
|
||||||
|
id: capabilityId.atom(stringValue(context.stringLiteral())),
|
||||||
|
displayName: identifier(context.identifier()),
|
||||||
|
};
|
||||||
|
declareSymbol(
|
||||||
|
state,
|
||||||
|
state.atoms,
|
||||||
|
atom.displayName,
|
||||||
|
atom.id,
|
||||||
|
context,
|
||||||
|
"external atom",
|
||||||
|
);
|
||||||
|
return atom;
|
||||||
|
});
|
||||||
|
|
||||||
|
const externalInterfacesFrom = (
|
||||||
|
state: LoweringState,
|
||||||
|
contexts: readonly ExternalInterfaceDeclContext[],
|
||||||
|
): CapabilityExternalInterface[] => contexts.map((context) => {
|
||||||
|
const requirement: CapabilityExternalInterface = {
|
||||||
|
binding: identifier(context.identifier()),
|
||||||
|
revisionId: capabilityId.interfaceRevision(stringValue(context.stringLiteral())),
|
||||||
|
};
|
||||||
|
declareSymbol(
|
||||||
|
state,
|
||||||
|
state.interfaces,
|
||||||
|
requirement.binding,
|
||||||
|
{
|
||||||
|
revisionId: requirement.revisionId,
|
||||||
|
contractAvailable: false,
|
||||||
|
members: new Map(),
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
"external interface",
|
||||||
|
);
|
||||||
|
return requirement;
|
||||||
|
});
|
||||||
|
|
||||||
|
const resourcePreambleParts = (contexts: readonly ResourcePreambleContext[]) => ({
|
||||||
|
imports: contexts.flatMap((context) => context.resourceImportDecl() ?? []),
|
||||||
|
atoms: contexts.flatMap((context) => context.externalAtomDecl() ?? []),
|
||||||
|
interfaces: contexts.flatMap((context) => context.externalInterfaceDecl() ?? []),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resourceValidationWorkspace = (
|
||||||
|
resource: CapabilityResource,
|
||||||
|
environment: CapabilityImportEnvironment,
|
||||||
|
): WorkspaceRevision => {
|
||||||
|
const resolvedInterfaceIds = new Set([
|
||||||
|
...(environment.interfaceClosure ?? []).map((revision) => revision.revisionId),
|
||||||
|
...[...(environment.interfaces?.values() ?? [])].map((revision) => revision.revisionId),
|
||||||
|
...(resource.kind === "interface" ? [resource.revision.revisionId] : []),
|
||||||
|
]);
|
||||||
|
return ({
|
||||||
|
id: capabilityId.workspaceRevision("workspace-revision:resource-validation"),
|
||||||
|
workspaceId: capabilityId.workspace("workspace:resource-validation"),
|
||||||
|
parentRevisionIds: [],
|
||||||
|
sourceRootCommit: resource.revision.source.commit,
|
||||||
|
atoms: uniqueAtoms([
|
||||||
|
...(environment.externalAtoms ?? []),
|
||||||
|
...resource.externalAtoms,
|
||||||
|
]),
|
||||||
|
sharedAttachments: [],
|
||||||
|
interfaceImports: uniqueExactRevisions([
|
||||||
|
...(environment.interfaceClosure ?? []),
|
||||||
|
...[...(environment.interfaces?.values() ?? [])],
|
||||||
|
...(resource.kind === "interface" ? [resource.revision] : []),
|
||||||
|
...resource.externalInterfaces
|
||||||
|
.filter((requirement) => !resolvedInterfaceIds.has(requirement.revisionId))
|
||||||
|
.map((requirement): InterfaceRevision => ({
|
||||||
|
interfaceId: capabilityId.interface(`external:${requirement.revisionId}`),
|
||||||
|
revisionId: requirement.revisionId,
|
||||||
|
displayName: requirement.binding,
|
||||||
|
source: {
|
||||||
|
repository: `https://external.invalid/${encodeURIComponent(requirement.revisionId)}.git`,
|
||||||
|
commit: "0".repeat(40),
|
||||||
|
},
|
||||||
|
members: [],
|
||||||
})),
|
})),
|
||||||
|
]),
|
||||||
|
packageImports: uniqueExactRevisions([
|
||||||
|
...(environment.packageClosure ?? []),
|
||||||
|
...[...(environment.packages?.values() ?? [])],
|
||||||
|
...(resource.kind === "package" ? [resource.revision] : []),
|
||||||
|
]),
|
||||||
|
conformances: [],
|
||||||
|
constructors: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compileCapabilityResourceSource = (
|
||||||
|
sourceText: string,
|
||||||
|
options: {
|
||||||
|
source: SourceRevision;
|
||||||
|
fileName?: string;
|
||||||
|
environment?: CapabilityImportEnvironment;
|
||||||
|
},
|
||||||
|
): CapabilityResourceCompileResult => {
|
||||||
|
const fileName = options.fileName ?? "<memory>";
|
||||||
|
const environment = options.environment ?? {};
|
||||||
|
const { tree, diagnostics } = parseDocument(sourceText, fileName);
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
const interfaceContext = tree.interfaceResourceDecl();
|
||||||
|
const packageContext = tree.packageResourceDecl();
|
||||||
|
if (!interfaceContext && !packageContext) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
diagnostics: [{
|
||||||
|
phase: "lowering",
|
||||||
|
code: "expected-resource",
|
||||||
|
message: "Expected a standalone interface or package resource document",
|
||||||
|
fileName,
|
||||||
|
line: 1,
|
||||||
|
column: 0,
|
||||||
|
}],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: true, workspace, plan: compiled.plan, diagnostics: [] };
|
|
||||||
|
const state = newLoweringState(fileName, diagnostics);
|
||||||
|
const preamble = resourcePreambleParts(
|
||||||
|
(interfaceContext ?? packageContext)!.resourcePreamble(),
|
||||||
|
);
|
||||||
|
const externalAtoms = externalAtomsFrom(state, preamble.atoms);
|
||||||
|
const imports = registerImports(state, preamble.imports, environment);
|
||||||
|
const externalInterfaces = externalInterfacesFrom(state, preamble.interfaces);
|
||||||
|
|
||||||
|
let resource: CapabilityResource;
|
||||||
|
if (interfaceContext) {
|
||||||
|
const alias = identifier(interfaceContext.identifier());
|
||||||
|
declareSymbol(
|
||||||
|
state,
|
||||||
|
state.interfaces,
|
||||||
|
alias,
|
||||||
|
{
|
||||||
|
revisionId: capabilityId.interfaceRevision(
|
||||||
|
stringValue(interfaceContext.stringLiteral(1)),
|
||||||
|
),
|
||||||
|
contractAvailable: true,
|
||||||
|
members: new Map(),
|
||||||
|
},
|
||||||
|
interfaceContext,
|
||||||
|
"interface",
|
||||||
|
);
|
||||||
|
resource = {
|
||||||
|
kind: "interface",
|
||||||
|
imports,
|
||||||
|
externalAtoms,
|
||||||
|
externalInterfaces,
|
||||||
|
revision: lowerInterface(state, interfaceContext, options.source),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const context = packageContext!;
|
||||||
|
const alias = identifier(context.identifier());
|
||||||
|
declareSymbol(
|
||||||
|
state,
|
||||||
|
state.packages,
|
||||||
|
alias,
|
||||||
|
{
|
||||||
|
revisionId: capabilityId.packageRevision(
|
||||||
|
stringValue(context.stringLiteral(1)),
|
||||||
|
),
|
||||||
|
exports: new Map(),
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
"package",
|
||||||
|
);
|
||||||
|
resource = {
|
||||||
|
kind: "package",
|
||||||
|
imports,
|
||||||
|
externalAtoms,
|
||||||
|
externalInterfaces,
|
||||||
|
revision: lowerPackage(state, context, options.source),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
const compiled = compileWorkspaceRevision(
|
||||||
|
resourceValidationWorkspace(resource, environment),
|
||||||
|
);
|
||||||
|
if (!compiled.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
diagnostics: validationDiagnostics(fileName, compiled.issues),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, resource, diagnostics: [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compileCapabilitySource = (
|
||||||
|
source: string,
|
||||||
|
fileName = "<memory>",
|
||||||
|
environment: CapabilityImportEnvironment = {},
|
||||||
|
): CapabilitySourceCompileResult => {
|
||||||
|
const { tree, diagnostics } = parseDocument(source, fileName);
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
const workspaceContext = tree.workspaceDecl();
|
||||||
|
if (!workspaceContext) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
diagnostics: [{
|
||||||
|
phase: "lowering",
|
||||||
|
code: "expected-workspace",
|
||||||
|
message: "Expected a workspace document",
|
||||||
|
fileName,
|
||||||
|
line: 1,
|
||||||
|
column: 0,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = newLoweringState(fileName, diagnostics);
|
||||||
|
for (const item of workspaceContext.workspaceItem()) {
|
||||||
|
if (item.sourceImportDecl()) loweringIssue(state, item, "unresolved-source-import",
|
||||||
|
"Local imports require the workspace repository compiler");
|
||||||
|
}
|
||||||
|
if (diagnostics.length) return { ok: false, diagnostics };
|
||||||
|
const lowered = lowerWorkspace(state, workspaceContext, environment);
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
const compiled = compileWorkspaceRevision(lowered.workspace);
|
||||||
|
if (!compiled.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
diagnostics: validationDiagnostics(fileName, compiled.issues),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
workspace: lowered.workspace,
|
||||||
|
plan: compiled.plan,
|
||||||
|
imports: lowered.imports,
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import {randomUUID} from "node:crypto";
|
||||||
|
import {execFile as callback} from "node:child_process";
|
||||||
|
import {promisify} from "node:util";
|
||||||
|
import {loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js";
|
||||||
|
import {contentDigest} from "../capability-model/evolution.js";
|
||||||
|
import {planStructure, applyStructure, type StructuralRequest} from "./structural-plan.js";
|
||||||
|
import {snapshotRepository} from "./candidate-check.js";
|
||||||
|
import {compileWorkspaceRepository, compileCapabilityResourceRepository, type ResolvedCapabilityResource} from "./assembly.js";
|
||||||
|
import {createGitCapabilityResolver} from "./git-resolver.js";
|
||||||
|
import {snapshotCommit, buildImmutableCandidate} from "./checked-build.js";
|
||||||
|
import {planEvolution, type WorkspaceRevision, type EvolutionReview} from "../capability-model/index.js";
|
||||||
|
import {withFileLock} from "./file-lock.js";
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
type Source = {repository: string; commit: string};
|
||||||
|
export type UpgradeNode = {kind: "workspace" | "package" | "interface"; directory: string; source: Source};
|
||||||
|
export type UpgradeSpec = {nodes: UpgradeNode[]; quixos?: Source; baseline?: string; reviews?: string; bootstrap?: boolean};
|
||||||
|
type NodePlan = UpgradeNode & {treeDigest: string; dependencies: string[]; lockFiles: string[]};
|
||||||
|
export type UpgradePlan = {schemaVersion: 1; workbench: string; spec: UpgradeSpec; nodes: NodePlan[]; digest: string};
|
||||||
|
type Step = {directory: string; phase: "editing" | "prepared" | "refactor" | "checked" | "publishing" | "published"; commit?: string; treeDigest?: string; structuralJournal?: string; structuralPlan?: Awaited<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 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");
|
||||||
|
};
|
||||||
|
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");
|
||||||
|
const resolved = await fs.realpath(path.join(root, directory));
|
||||||
|
if (resolved !== path.join(root, directory)) throw new Error("Upgrade target crosses a symlink");
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
|
const treeDigest = async (root: string) => {
|
||||||
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-upgrade-tree-"));
|
||||||
|
try {return (await snapshotRepository(root, temporary)).treeDigest;} finally {await fs.rm(temporary, {recursive: true, force: true});}
|
||||||
|
};
|
||||||
|
const writeJournal = async (filename: string, journal: unknown) => {
|
||||||
|
const temp = `${filename}.${randomUUID()}.tmp`;
|
||||||
|
const handle = await fs.open(temp, "wx", 0o600);
|
||||||
|
try {await handle.writeFile(JSON.stringify(journal, null, 2)); await handle.sync();} finally {await handle.close();}
|
||||||
|
await fs.rename(temp, filename);
|
||||||
|
const directory = await fs.open(path.dirname(filename), "r");
|
||||||
|
try {await directory.sync();} finally {await directory.close();}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const discoverUpgradeSpec = async (workbench: string): Promise<UpgradeSpec> => {
|
||||||
|
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
||||||
|
const root = await location(workbench, "root");
|
||||||
|
const nodes: UpgradeNode[] = [{kind: "workspace", directory: "root", source: {
|
||||||
|
repository: await command(root, "git", ["config", "--get", "remote.origin.url"]),
|
||||||
|
commit: await command(root, "jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"]),
|
||||||
|
}}, ...graph.resources.map((entry: {kind: "interface" | "package"; directory: string; source: Source}) => ({kind: entry.kind, source: entry.source,
|
||||||
|
directory: path.relative(workbench, path.resolve(workbench, entry.directory))}))];
|
||||||
|
let baseline: string | undefined;
|
||||||
|
try {
|
||||||
|
const host = JSON.parse(await fs.readFile("/etc/quixos/workspace-source.json", "utf8"));
|
||||||
|
if (await fs.realpath(host.workbenchRoot) === await fs.realpath(workbench)) baseline = JSON.parse(await fs.readFile(path.join(host.runtimeClosureRoot, "manifest.json"), "utf8")).workspacePlanPath;
|
||||||
|
} catch (error) {if (!["ENOENT", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;}
|
||||||
|
return {nodes, baseline};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Read-only source plan. Repositories are selected explicitly, including any
|
||||||
|
* parallel versions of the same resource; no guesses at a floating 'latest'. */
|
||||||
|
export const planPinUpgrades = async (workbenchPath: string, spec: UpgradeSpec): Promise<UpgradePlan> => {
|
||||||
|
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 (spec.quixos) validSource(spec.quixos);
|
||||||
|
const keys = new Map<string, string>();
|
||||||
|
for (const node of spec.nodes) {
|
||||||
|
validSource(node.source);
|
||||||
|
if (!["workspace", "package", "interface"].includes(node.kind) || keys.has(sourceKey(node))) throw new Error("Duplicate/invalid upgrade resource identity");
|
||||||
|
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");
|
||||||
|
const nodes: NodePlan[] = [];
|
||||||
|
for (const node of spec.nodes) {
|
||||||
|
const root = await location(workbench, node.directory);
|
||||||
|
if (await command(root, "git", ["config", "--get", "remote.origin.url"]) !== node.source.repository) throw new Error(`Upgrade origin differs from selected source: ${node.directory}`);
|
||||||
|
const loaded = await loadQuixosLock(path.join(root, "quixos.lock"));
|
||||||
|
if (!loaded.ok) throw new Error(`Invalid lock in ${node.directory}: ${loaded.diagnostics.map((entry) => entry.message).join("; ")}`);
|
||||||
|
const dependencies = loaded.lock.resources.map((resource) => keys.get(sourceKey(resource))).filter((value): value is string => Boolean(value));
|
||||||
|
nodes.push({...node, treeDigest: await treeDigest(root), dependencies: [...new Set(dependencies)], lockFiles: loaded.lock.sourceFiles ?? ["quixos.lock"]});
|
||||||
|
}
|
||||||
|
const ordered: NodePlan[] = [], remaining = [...nodes];
|
||||||
|
while (remaining.length) {
|
||||||
|
const index = remaining.findIndex((node) => node.dependencies.every((dependency) => ordered.some((entry) => entry.directory === dependency)));
|
||||||
|
if (index < 0) throw new Error("Cyclic source publication graph");
|
||||||
|
ordered.push(remaining.splice(index, 1)[0]);
|
||||||
|
}
|
||||||
|
const workspace = ordered.find((node) => node.kind === "workspace")!;
|
||||||
|
// Even unreferenced new resources are published before the root.
|
||||||
|
ordered.splice(ordered.indexOf(workspace), 1); ordered.push(workspace);
|
||||||
|
const plan = {schemaVersion: 1 as const, workbench, spec, nodes: ordered};
|
||||||
|
return {...plan, digest: contentDigest(plan)};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpgradeEffects = {
|
||||||
|
check(node: NodePlan, root: string, output: string, spec: UpgradeSpec): Promise<void>;
|
||||||
|
snapshot(root: string): Promise<string>;
|
||||||
|
publish(root: string, commit: string): Promise<void>;
|
||||||
|
};
|
||||||
|
const effects: UpgradeEffects = {
|
||||||
|
async check(node, root, output, spec) {
|
||||||
|
if (node.kind === "workspace" && !spec.baseline && !spec.bootstrap) throw new Error("Upgrading a workspace requires its checked active baseline for major-review checks (or explicit bootstrap:true for a new workspace)");
|
||||||
|
// Explicit baseline upgrades use the same immutable Nix checker. Retaining
|
||||||
|
// an unverified source is safe and must precede a remote flake fetch.
|
||||||
|
await fs.mkdir(output);
|
||||||
|
const commit = await snapshotCommit(root);
|
||||||
|
await effects.publish(root, commit);
|
||||||
|
const artifact = await buildImmutableCandidate({...node.source, commit}, node.kind, path.join(output, "nix.log"));
|
||||||
|
const candidate = await fs.readFile(path.join(artifact, "candidate.json"), "utf8");
|
||||||
|
await fs.writeFile(path.join(output, "candidate.json"), candidate);
|
||||||
|
if (node.kind === "workspace") {
|
||||||
|
const baseline = spec.baseline ? JSON.parse(await fs.readFile(spec.baseline, "utf8")) as WorkspaceRevision : null;
|
||||||
|
const reviews = spec.reviews ? JSON.parse(await fs.readFile(spec.reviews, "utf8")) as EvolutionReview[] : [];
|
||||||
|
const evolution = planEvolution(baseline, JSON.parse(candidate), {reviews});
|
||||||
|
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("; ")}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async snapshot(root) {
|
||||||
|
const commit = await snapshotCommit(root);
|
||||||
|
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Publication did not resolve an exact commit");
|
||||||
|
await command(root, "git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"]);
|
||||||
|
const tracked = new Set((await command(root, "git", ["ls-tree", "-r", "--name-only", "-z", commit])).split("\0"));
|
||||||
|
for (const file of (await command(root, "git", ["ls-files", "--others", "--exclude-standard", "-z"])).split("\0").filter(Boolean)) if (!tracked.has(file)) throw new Error(`Uncaptured source file ${file}`);
|
||||||
|
return commit;
|
||||||
|
},
|
||||||
|
async publish(root, commit) {
|
||||||
|
const ref = `refs/tags/quixos-reachability/${commit}`;
|
||||||
|
const remote = await command(root, "git", ["ls-remote", "--refs", "origin", ref]);
|
||||||
|
if (remote && remote !== `${commit}\t${ref}`) throw new Error("Immutable publication ref conflict");
|
||||||
|
if (!remote) await command(root, "git", ["push", "origin", `${commit}:${ref}`]);
|
||||||
|
if (await command(root, "git", ["ls-remote", "--refs", "origin", ref]) !== `${commit}\t${ref}`) throw new Error("Publication response uncertain; retry the same journal");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Explicit --publish only. Append-only remote retention; never moves the
|
||||||
|
* workspace branch, activates code, or rolls back previously published nodes. */
|
||||||
|
export const applyPinUpgrades = async (plan: UpgradePlan, journalId?: string, implementation: UpgradeEffects = effects, options: {acceptEdits?: boolean} = {}) => {
|
||||||
|
if (implementation === effects && !plan.spec.baseline && !plan.spec.bootstrap) throw new Error("Publication requires an active checked baseline or explicit bootstrap:true");
|
||||||
|
const {digest, ...body} = plan;
|
||||||
|
if (contentDigest(body) !== digest) throw new Error("Upgrade plan digest mismatch");
|
||||||
|
const directory = path.join(plan.workbench, ".quixos", "upgrades");
|
||||||
|
await fs.mkdir(directory, {recursive: true, mode: 0o700});
|
||||||
|
if (await fs.realpath(directory) !== directory) throw new Error("Upgrade journals must not cross symlinks");
|
||||||
|
const id = journalId ?? randomUUID();
|
||||||
|
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid upgrade journal ID");
|
||||||
|
const filename = path.join(directory, `${id}.json`);
|
||||||
|
return withFileLock(path.join(directory, "writer.lock"), async () => {
|
||||||
|
try {
|
||||||
|
const journal: Journal = journalId ? JSON.parse(await fs.readFile(filename, "utf8")) : {schemaVersion: 1, plan, steps: []};
|
||||||
|
if (journal.plan.digest !== plan.digest) throw new Error("Upgrade journal belongs to another plan");
|
||||||
|
if (!journalId) await writeJournal(filename, journal);
|
||||||
|
for (const node of plan.nodes) {
|
||||||
|
const root = await location(plan.workbench, node.directory);
|
||||||
|
if (await command(root, "git", ["config", "--get", "remote.origin.url"]) !== node.source.repository) throw new Error("Upgrade remote changed after planning");
|
||||||
|
let step = journal.steps.find((entry) => entry.directory === node.directory);
|
||||||
|
if (step?.phase === "published") continue;
|
||||||
|
if (!step) {
|
||||||
|
if (await treeDigest(root) !== node.treeDigest) throw new Error(`Stale upgrade plan: ${node.directory}`);
|
||||||
|
const files: StructuralRequest["files"] = [];
|
||||||
|
for (const file of node.lockFiles) {
|
||||||
|
const parsed = parseQuixosLockDocument(await fs.readFile(path.join(root, file), "utf8"));
|
||||||
|
if (!parsed.ok) throw new Error("Invalid lock during upgrade");
|
||||||
|
const edits = [];
|
||||||
|
for (const resource of parsed.document.resources) {
|
||||||
|
const dependency = plan.nodes.find((entry) => sourceKey(entry) === sourceKey(resource));
|
||||||
|
const published = dependency && journal.steps.find((entry) => entry.directory === dependency.directory && entry.phase === "published");
|
||||||
|
if (published?.commit && published.commit !== resource.source.commit) edits.push({operation: "dependency" as const, kind: resource.kind, name: resource.binding, source: {...resource.source, commit: published.commit}});
|
||||||
|
}
|
||||||
|
if (parsed.document.kind === "root" && plan.spec.quixos) edits.push({operation: "quixos-pin" as const, source: plan.spec.quixos});
|
||||||
|
if (edits.length) files.push({file, edits});
|
||||||
|
}
|
||||||
|
const structuralPlan = files.length ? await planStructure(root, {kind: node.kind, source: node.source, files}, process.env.QUIXOS_SNAPSHOT_MAP) : undefined;
|
||||||
|
step = {directory: node.directory, phase: "editing", treeDigest: node.treeDigest, structuralPlan, structuralJournal: structuralPlan ? randomUUID() : undefined};
|
||||||
|
journal.steps.push(step); await writeJournal(filename, journal);
|
||||||
|
}
|
||||||
|
if (step.phase === "editing") {
|
||||||
|
if (step.structuralPlan) await applyStructure(step.structuralPlan, step.structuralJournal);
|
||||||
|
else if (await treeDigest(root) !== step.treeDigest) throw new Error("Source changed before upgrade editing");
|
||||||
|
step.treeDigest = await treeDigest(root);
|
||||||
|
step.phase = "prepared";
|
||||||
|
await writeJournal(filename, journal);
|
||||||
|
}
|
||||||
|
if (step.phase === "refactor") {
|
||||||
|
const current = await treeDigest(root);
|
||||||
|
if (current !== step.treeDigest && !options.acceptEdits) throw new Error("Refactored source requires --accept-edits when resuming");
|
||||||
|
step.treeDigest = current; step.phase = "prepared"; await writeJournal(filename, journal);
|
||||||
|
}
|
||||||
|
if (await treeDigest(root) !== step.treeDigest) throw new Error(`Source changed during upgrade: ${node.directory}; inspect ${filename}`);
|
||||||
|
if (step.phase === "prepared") {
|
||||||
|
try {await implementation.check(node, root, path.join(directory, `${id}-${node.directory.replaceAll("/", "-")}-${randomUUID()}`), plan.spec);}
|
||||||
|
catch (error) {step.phase = "refactor"; await writeJournal(filename, journal); throw error;}
|
||||||
|
if (await treeDigest(root) !== step.treeDigest) throw new Error("Source changed while checking");
|
||||||
|
step.phase = "checked"; await writeJournal(filename, journal);
|
||||||
|
}
|
||||||
|
if (step.phase === "checked") {
|
||||||
|
step.commit = await implementation.snapshot(root);
|
||||||
|
if (await treeDigest(root) !== step.treeDigest) throw new Error("Publication snapshot changed checked files");
|
||||||
|
step.phase = "publishing"; await writeJournal(filename, journal);
|
||||||
|
}
|
||||||
|
await implementation.publish(root, step.commit!);
|
||||||
|
step.phase = "published"; await writeJournal(filename, journal);
|
||||||
|
}
|
||||||
|
// Keep subsequent automatic upgrades associated with the newly published
|
||||||
|
// identities, without renaming repositories or changing any selected branch.
|
||||||
|
// Explicit-spec callers without a managed graph retain the journal as their
|
||||||
|
// source of revisions instead.
|
||||||
|
const graphFile = path.join(plan.workbench, ".quixos/resource-graph.json");
|
||||||
|
let graphText: string | undefined;
|
||||||
|
try {graphText = await fs.readFile(graphFile, "utf8");} catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;}
|
||||||
|
if (graphText !== undefined) {
|
||||||
|
const previous = JSON.parse(graphText);
|
||||||
|
const snapshots = await Promise.all(previous.resources.map(async (entry: {kind: string; source: Source; directory: string}) => ({kind: entry.kind, ...entry.source, directory: await location(plan.workbench, path.relative(plan.workbench, path.resolve(plan.workbench, entry.directory)))})));
|
||||||
|
for (const node of plan.nodes.filter((entry) => entry.kind !== "workspace")) {
|
||||||
|
const step = journal.steps.find((entry) => entry.directory === node.directory)!;
|
||||||
|
if (await treeDigest(await location(plan.workbench, node.directory)) !== step.treeDigest) throw new Error("Published source changed before workbench graph refresh");
|
||||||
|
snapshots.push({kind: node.kind, repository: node.source.repository, commit: step.commit, directory: path.join(plan.workbench, node.directory)});
|
||||||
|
}
|
||||||
|
const unique = [...new Map(snapshots.map((entry: {kind: string; repository: string; commit: string}) => [JSON.stringify([entry.kind, entry.repository, entry.commit]), entry])).values()];
|
||||||
|
const snapshotMap = path.join(directory, `${id}-published-snapshots.json`);
|
||||||
|
await fs.writeFile(snapshotMap, JSON.stringify({resources: unique}));
|
||||||
|
const resolveResource = await createGitCapabilityResolver({checkoutRoot: path.join(directory, `${id}-graph-resources`), snapshotMap, snapshotOnly: true});
|
||||||
|
const rootNode = plan.nodes.find((node) => node.kind === "workspace")!;
|
||||||
|
if (await treeDigest(await location(plan.workbench, rootNode.directory)) !== journal.steps.find((step) => step.directory === rootNode.directory)!.treeDigest) throw new Error("Root source changed before workbench graph refresh");
|
||||||
|
const compiled = await compileWorkspaceRepository({rootDirectory: await location(plan.workbench, rootNode.directory), resolveResource});
|
||||||
|
// Managed repositories need not currently be reachable from the workspace.
|
||||||
|
// Keep them discoverable/checkpointed until explicitly removed by the user.
|
||||||
|
const resources = new Map<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});}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import process from "node:process";
|
||||||
|
import { bindingSchema } from "../bindings/index.js";
|
||||||
|
import {
|
||||||
|
compileCapabilityResourceRepository,
|
||||||
|
type ResolvedCapabilityResource,
|
||||||
|
} from "./assembly.js";
|
||||||
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
|
|
||||||
|
const usage = `usage: quixos-resource-compile --root DIRECTORY --kind interface|package
|
||||||
|
--repository URL --commit GIT_REV --checkout-root DIRECTORY
|
||||||
|
[--snapshot-map PATH] [--snapshot-only true] [--graph-out PATH] [--schema-out PATH]
|
||||||
|
|
||||||
|
Resolves a standalone resource's recursive dependency-lock graph, validates
|
||||||
|
its interface.qx or package.qx manifest, and emits the checked resource as JSON.`;
|
||||||
|
|
||||||
|
const parseArgs = (args: string[]) => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
for (let index = 0; index < args.length; index += 2) {
|
||||||
|
const key = args[index];
|
||||||
|
const value = args[index + 1];
|
||||||
|
if (!key?.startsWith("--") || !value) throw new Error(usage);
|
||||||
|
if (!["--root", "--kind", "--repository", "--commit", "--checkout-root", "--snapshot-map", "--snapshot-only", "--graph-out", "--schema-out"].includes(key) || values.has(key)) throw new Error(usage);
|
||||||
|
values.set(key, value);
|
||||||
|
}
|
||||||
|
const rootDirectory = values.get("--root");
|
||||||
|
const kind = values.get("--kind");
|
||||||
|
const repository = values.get("--repository");
|
||||||
|
const commit = values.get("--commit")?.toLowerCase();
|
||||||
|
const checkoutRoot = values.get("--checkout-root");
|
||||||
|
if (!rootDirectory || !repository || !commit || !checkoutRoot ||
|
||||||
|
(kind !== "interface" && kind !== "package")) {
|
||||||
|
throw new Error(usage);
|
||||||
|
}
|
||||||
|
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
|
||||||
|
throw new Error("--commit must be a full Git object ID");
|
||||||
|
}
|
||||||
|
if (values.has("--snapshot-only") && values.get("--snapshot-only") !== "true") throw new Error("--snapshot-only accepts true");
|
||||||
|
return {
|
||||||
|
rootDirectory,
|
||||||
|
kind,
|
||||||
|
repository,
|
||||||
|
commit,
|
||||||
|
checkoutRoot,
|
||||||
|
snapshotMap: values.get("--snapshot-map"),
|
||||||
|
snapshotOnly: values.get("--snapshot-only") === "true",
|
||||||
|
graphOut: values.get("--graph-out"),
|
||||||
|
schemaOut: values.get("--schema-out"),
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
const graphEntry = (node: ResolvedCapabilityResource) => ({
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
||||||
|
process.stdout.write(`${usage}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const resolveResource = await createGitCapabilityResolver({
|
||||||
|
checkoutRoot: options.checkoutRoot,
|
||||||
|
snapshotMap: options.snapshotMap,
|
||||||
|
snapshotOnly: options.snapshotOnly,
|
||||||
|
});
|
||||||
|
const compiled = await compileCapabilityResourceRepository({
|
||||||
|
rootDirectory: options.rootDirectory,
|
||||||
|
kind: options.kind,
|
||||||
|
source: {
|
||||||
|
resolver: "git",
|
||||||
|
repository: options.repository,
|
||||||
|
commit: options.commit,
|
||||||
|
},
|
||||||
|
resolveResource,
|
||||||
|
});
|
||||||
|
if (options.graphOut) {
|
||||||
|
await writeFile(options.graphOut, `${JSON.stringify({
|
||||||
|
formatVersion: 1,
|
||||||
|
quixos: compiled.lock.quixos,
|
||||||
|
root: graphEntry(compiled.resources.find((node) =>
|
||||||
|
node.source.repository === options.repository &&
|
||||||
|
node.source.commit === options.commit &&
|
||||||
|
node.kind === options.kind)!),
|
||||||
|
resources: compiled.resources.map(graphEntry),
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
if (options.schemaOut) await writeFile(options.schemaOut, `${JSON.stringify(bindingSchema(compiled), null, 2)}\n`);
|
||||||
|
process.stdout.write(`${JSON.stringify(compiled.resource, null, 2)}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {contentDigest} from "../capability-model/evolution.js";
|
||||||
|
import {validateMigrationCatalog, type MigrationCatalog, type MigrationDeclaration} from "../capability-model/migrations.js";
|
||||||
|
import {formatQuixosLock, type GitSource} from "../resource-lock/index.js";
|
||||||
|
import {parseQx, walkSyntax} from "./source.js";
|
||||||
|
import type {StructuralRequest} from "./structural-plan.js";
|
||||||
|
import {addImplementation} from "./implementation-edit.js";
|
||||||
|
|
||||||
|
type Source = {repository: string; commit: string};
|
||||||
|
type Registry = {generatedBy: "qx-scaffold-v1"; name: string; id: string; revision: string; exports: {name: string; id: string; file: string; migration?: boolean}[]};
|
||||||
|
export type ScaffoldRecipe = {
|
||||||
|
template?: "typescript" | "typescript-react";
|
||||||
|
source: Source; directory?: string; name?: string; id?: string; revision?: string;
|
||||||
|
declaration?: string;
|
||||||
|
tools?: {quixos: Source; protocol: Source; helpers: Source; sdk: Source};
|
||||||
|
nixifyPluginUrl?: string;
|
||||||
|
migration?: Omit<MigrationDeclaration, "implementation"> & {contracts: Record<string, unknown>};
|
||||||
|
};
|
||||||
|
const marker = "// Generated by qx-scaffold-v1\n";
|
||||||
|
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
|
||||||
|
const source = (value: Source): GitSource => {
|
||||||
|
if (!value || typeof value.repository !== "string" || typeof value.commit !== "string") throw new Error("Scaffold requires an exact source identity; use qx-workspace from a registered repository");
|
||||||
|
const url = new URL(value.repository);
|
||||||
|
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit)) throw new Error("Scaffolds require credential-free HTTPS sources and full exact commits");
|
||||||
|
return {resolver: "git", ...value};
|
||||||
|
};
|
||||||
|
const nixSource = (value: Source) => `git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`;
|
||||||
|
const nixString = (value: string) => JSON.stringify(value).replaceAll("${", "\\${");
|
||||||
|
const safeName = (name: string | undefined): string => {
|
||||||
|
if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("Scaffold requires a simple authored name");
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
const ownedJson = async <T>(root: string, file: string): Promise<T> => {
|
||||||
|
const target = path.join(root, file);
|
||||||
|
if (!(await fs.realpath(target)).startsWith(`${await fs.realpath(root)}/`)) throw new Error("Scaffold input escapes repository");
|
||||||
|
const value = JSON.parse(await fs.readFile(target, "utf8"));
|
||||||
|
if (value.generatedBy !== "qx-scaffold-v1") throw new Error(`Not scaffold-owned: ${file}`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Recipes describe structural edits; planStructure owns validation/journaling.
|
||||||
|
* Implementation files are created once and never rewritten by refresh. */
|
||||||
|
export const scaffoldRecipe = async (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);
|
||||||
|
if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory)) throw new Error("Scaffold directory must be contained");
|
||||||
|
const prefix = spec.directory ? `${spec.directory}/` : "";
|
||||||
|
const files: StructuralRequest["files"] = [];
|
||||||
|
const create = (file: string, content: string) => files.push({file: prefix + file, create: content});
|
||||||
|
const generated = (file: string, content: string) => files.push({file: prefix + file, generated: content});
|
||||||
|
let registry: Registry;
|
||||||
|
let catalog: MigrationCatalog & {generatedBy: "qx-scaffold-v1"};
|
||||||
|
if (command === "package") {
|
||||||
|
const name = safeName(spec.name);
|
||||||
|
if (spec.template && !["typescript", "typescript-react"].includes(spec.template)) throw new Error("Unknown package template");
|
||||||
|
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");
|
||||||
|
Object.values(spec.tools).forEach(source);
|
||||||
|
registry = {generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: []};
|
||||||
|
catalog = {generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: []};
|
||||||
|
if (react) registry.exports.push({name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts"});
|
||||||
|
create("package.qx", `package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n${react ? ` function sourceGet id ${JSON.stringify(`export:${name}:source`)} : unit -> string;\n` : ""}}\n`);
|
||||||
|
create("quixos.lock", formatQuixosLock({formatVersion: 1, quixos: source(spec.tools.quixos), resources: []}));
|
||||||
|
create("package.json", json({name: `@quixos/${name.toLowerCase()}`, version: "0.1.0", private: true, type: "module", packageManager: "yarn@4.18.0",
|
||||||
|
scripts: {build: "tsc -p tsconfig.json", typecheck: "tsc --noEmit"}, dependencies: {"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`},
|
||||||
|
devDependencies: {"@types/node": "^24", typescript: "^7.0.2", ...(react ? {react: "^18.3.1", "@types/react": "^18.3.12", esbuild: "^0.25.12"} : {})}}));
|
||||||
|
create("tsconfig.json", json({compilerOptions: {target: "ES2023", module: "NodeNext", moduleResolution: "NodeNext", strict: true, types: ["node", ...(react ? ["react"] : [])], outDir: "dist", rootDir: "src", skipLibCheck: true, ...(react ? {jsx: "react-jsx", esModuleInterop: true} : {})}, include: ["src/**/*.ts", "src/**/*.tsx"]}));
|
||||||
|
if (react) {
|
||||||
|
create("src/component.tsx", `// Props are opaque at the platform boundary until capability generics exist.\nexport default function Component(_props: {camino: unknown; render: unknown; dispatch: (action: unknown) => void}) {\n return <section><h1>${name}</h1><p>Edit this component, then run qx-workspace check.</p></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; }\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`);
|
||||||
|
const nixifyPluginUrl = spec.nixifyPluginUrl ?? "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js";
|
||||||
|
const plugin = new URL(nixifyPluginUrl);
|
||||||
|
if (plugin.protocol !== "https:" || plugin.username || plugin.password || plugin.search || plugin.hash || !/\/commit\/[a-f0-9]{40,64}\//.test(plugin.pathname)) throw new Error("Nixify plugin must have an exact credential-free HTTPS commit URL");
|
||||||
|
generated("quixos.toolchain.json", json({generatedBy: "qx-scaffold-v1", nixifyPluginUrl}));
|
||||||
|
create("quixos.check.json", json({backend: "typescript", bindingOutput: "src/gen/qx.ts", ...(react ? {options: {messages: {"org.quixos.web-studio.ReactProps": {module: "@quixos/camino-package-runtime", export: "opaqueReactPropsBinding"}}}} : {})}));
|
||||||
|
create("flake.nix", `{
|
||||||
|
inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))};
|
||||||
|
inputs.nixpkgs.follows = "protocol/nixpkgs";
|
||||||
|
inputs.flake-utils.follows = "protocol/flake-utils";
|
||||||
|
inputs.helpers = { url = ${nixString(nixSource(spec.tools.helpers))}; flake = false; };
|
||||||
|
outputs = inputs@{ self, protocol, nixpkgs, flake-utils, helpers, ... }:
|
||||||
|
(import (toString helpers + "/quixos-package-helpers.nix")).mkCaminoTsYarnNixifyFlake {
|
||||||
|
inherit inputs nixpkgs flake-utils; packageRoot = ./.;
|
||||||
|
bundle = { entry = "dist/server.js"; ${react ? "browserSources = true;" : ""} };
|
||||||
|
migrationEntrypoint = "dist/migrate.js";
|
||||||
|
installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; };
|
||||||
|
};
|
||||||
|
}\n`);
|
||||||
|
} else {
|
||||||
|
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
|
||||||
|
// Derive the edit model from authored declarations; it is never persisted.
|
||||||
|
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
|
||||||
|
const syntax = parseQx(authored);
|
||||||
|
if (syntax.diagnostics.length) throw new Error("Cannot scaffold into an invalid package.qx; fix the reported syntax first");
|
||||||
|
const declaration = [...walkSyntax(syntax.root)].find(node => node.kind === "packageResourceDecl");
|
||||||
|
if (!declaration) throw new Error("Expected a package declaration");
|
||||||
|
const text = (node: typeof declaration) => authored.slice(node.start, node.end);
|
||||||
|
const literals = declaration.children.filter(node => node.kind === "stringLiteral");
|
||||||
|
registry = {generatedBy: "qx-scaffold-v1", name: text(declaration.children.find(node => node.kind === "identifier")!),
|
||||||
|
id: JSON.parse(text(literals[0])), revision: JSON.parse(text(literals[1])), exports: []};
|
||||||
|
for (const node of walkSyntax(declaration)) {
|
||||||
|
if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)) continue;
|
||||||
|
const name = safeName(text(node.children.find(child => child.kind === "identifier")!));
|
||||||
|
const id = JSON.parse(text(node.children.find(child => child.kind === "stringLiteral")!));
|
||||||
|
if (registry.exports.some(entry => entry.id === id || entry.name === name)) throw new Error("Duplicate package export name or ID");
|
||||||
|
const migration = catalog.migrations.find(entry => entry.implementation.exportId === id);
|
||||||
|
registry.exports.push({name, id, file: migration?.implementation.file ?? `src/impl/${name}.ts`, ...(migration ? {migration: true} : {})});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const name = safeName(spec.name);
|
||||||
|
if (!spec.id || registry.exports.some((entry) => entry.id === spec.id || entry.name === name)) throw new Error("New export requires a unique name and ID");
|
||||||
|
const declaration = spec.declaration ?? `function ${name} id ${JSON.stringify(spec.id)} : unit -> unit;`;
|
||||||
|
const parsed = parseQx(`package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`);
|
||||||
|
const exports = [...walkSyntax(parsed.root)].filter((node) => ["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind));
|
||||||
|
if (parsed.diagnostics.length || exports.length !== 1) throw new Error("Expected one valid package export declaration");
|
||||||
|
const wrapped = `package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`;
|
||||||
|
const node = exports[0];
|
||||||
|
const derived = [...walkSyntax(node)].some((entry) => entry.kind === "eventClause");
|
||||||
|
if (command === "migration" && (node.kind !== "packageFunctionExport" || spec.declaration)) throw new Error("Migration exports use the scaffold's unit function declaration and dedicated migration entrypoint");
|
||||||
|
if (wrapped.slice(node.children.find((child) => child.kind === "identifier")!.start, node.children.find((child) => child.kind === "identifier")!.end) !== name
|
||||||
|
|| JSON.parse(wrapped.slice(node.children.find((child) => child.kind === "stringLiteral")!.start, node.children.find((child) => child.kind === "stringLiteral")!.end)) !== spec.id) throw new Error("Declaration name/ID must match its registration");
|
||||||
|
files.push({file: prefix + "package.qx", edits: [{operation: "append", parent: {kind: "packageResourceDecl", id: registry.id}, source: declaration}]});
|
||||||
|
const file = `src/${command === "migration" ? "migrations" : "impl"}/${name}.ts`;
|
||||||
|
const implementation = command === "migration" ? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise<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);
|
||||||
|
registry.exports.push({name, id: spec.id, file, ...(command === "migration" ? {migration: true} : {})});
|
||||||
|
if (command === "migration") {
|
||||||
|
if (!spec.migration) throw new Error("Migration scaffold requires retained contracts and an explicit transition");
|
||||||
|
const {contracts, ...transition} = spec.migration;
|
||||||
|
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");
|
||||||
|
catalog.contracts[digest] = contract;
|
||||||
|
}
|
||||||
|
catalog.migrations.push({...transition, implementation: {exportId: spec.id, file, digest: contentDigest(implementation)}});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validateMigrationCatalog(catalog, new Set(registry.exports.map((entry) => entry.id)));
|
||||||
|
generated("quixos.migrations.json", json(catalog));
|
||||||
|
if (command !== "package") {
|
||||||
|
const entry = registry.exports.at(-1)!;
|
||||||
|
const file = prefix + "src/server.ts";
|
||||||
|
const before = await fs.readFile(path.join(root, file), "utf8");
|
||||||
|
files.push({file, expected: before, replace: addImplementation(before, "createRuntime", entry.name, `./${entry.file.slice(4, -3)}.js`, !!entry.migration)});
|
||||||
|
if (entry.migration) {
|
||||||
|
const file = prefix + "src/migrate.ts";
|
||||||
|
const before = await fs.readFile(path.join(root, file), "utf8");
|
||||||
|
files.push({file, expected: before, replace: addImplementation(before, "serveMigration", entry.id, `./${entry.file.slice(4, -3)}.js`)});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
generated("src/server.ts", marker + `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` + registry.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` + registry.exports.map((entry) => ` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${registry.exports.filter((value) => !value.migration).indexOf(entry)}`},`).join("\n") + `\n}));\n`);
|
||||||
|
const migrations = registry.exports.filter((entry) => entry.migration);
|
||||||
|
generated("src/migrate.ts", marker + `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(registry.id)}\npackage_revision_id: ${JSON.stringify(registry.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` + registry.exports.map((entry) => `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`).join(""));
|
||||||
|
return {kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files};
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { lstat, open, rename, unlink } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { addWorkspaceImport } from "./source.js";
|
||||||
|
import { readQxSource } from "./source-loader.js";
|
||||||
|
import { compileWorkspaceRepository, type CapabilityRepositoryResolver } from "./assembly.js";
|
||||||
|
|
||||||
|
export const planAtomScaffold = (workspace: string, name: string, id: string) => {
|
||||||
|
if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) throw new Error("Atom name must be PascalCase");
|
||||||
|
if (!id.trim()) throw new Error("Atom ID must not be empty");
|
||||||
|
const fileName = `${name}.qx`;
|
||||||
|
return {
|
||||||
|
before: workspace,
|
||||||
|
workspace: addWorkspaceImport(workspace, fileName),
|
||||||
|
fileName,
|
||||||
|
fragment: `fragment {\n atom ${name} id ${JSON.stringify(id)};\n}\n`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Validate an in-memory proposal before creating files. Never replace a fragment. */
|
||||||
|
export const scaffoldAtom = async (options: {
|
||||||
|
root: string; name: string; id: string; write: boolean; resolveResource: CapabilityRepositoryResolver;
|
||||||
|
}) => {
|
||||||
|
const before = await readQxSource(options.root, "workspace.qx");
|
||||||
|
const plan = planAtomScaffold(before, options.name, options.id);
|
||||||
|
const fragmentPath = path.join(options.root, plan.fileName);
|
||||||
|
try {
|
||||||
|
await lstat(fragmentPath);
|
||||||
|
throw new Error(`Scaffold target already exists: ${plan.fileName}`);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
const observed = new Map<string, string>();
|
||||||
|
await compileWorkspaceRepository({ rootDirectory: options.root, resolveResource: options.resolveResource,
|
||||||
|
readSource: async (name) => {
|
||||||
|
if (name === "workspace.qx") return plan.workspace;
|
||||||
|
if (name === plan.fileName) return plan.fragment;
|
||||||
|
const text = await readQxSource(options.root, name);
|
||||||
|
observed.set(name, text);
|
||||||
|
return text;
|
||||||
|
} });
|
||||||
|
if (!options.write) return plan;
|
||||||
|
const workspacePath = path.join(options.root, "workspace.qx");
|
||||||
|
const temporary = path.join(options.root, `.qx-scaffold-${randomUUID()}.tmp`);
|
||||||
|
let createdFragment = false;
|
||||||
|
let createdTemporary = false;
|
||||||
|
try {
|
||||||
|
const fragment = await open(fragmentPath, "wx");
|
||||||
|
createdFragment = true;
|
||||||
|
try { await fragment.writeFile(plan.fragment); } finally { await fragment.close(); }
|
||||||
|
const file = await open(temporary, "wx", (await lstat(workspacePath)).mode);
|
||||||
|
createdTemporary = true;
|
||||||
|
try { await file.writeFile(plan.workspace); } finally { await file.close(); }
|
||||||
|
observed.set("workspace.qx", before);
|
||||||
|
for (const [name, text] of observed) {
|
||||||
|
if (await readQxSource(options.root, name) !== text) throw new Error(`QX source changed during scaffolding: ${name}`);
|
||||||
|
}
|
||||||
|
await rename(temporary, workspacePath);
|
||||||
|
createdTemporary = false;
|
||||||
|
createdFragment = false;
|
||||||
|
} finally {
|
||||||
|
if (createdTemporary) await unlink(temporary);
|
||||||
|
if (createdFragment) await unlink(fragmentPath);
|
||||||
|
}
|
||||||
|
return plan;
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { lstat, readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { parseQx, validateQxImportPath, walkSyntax } from "./source.js";
|
||||||
|
|
||||||
|
export const readQxSource = async (root: string, name: string) => {
|
||||||
|
validateQxImportPath(name);
|
||||||
|
let current = path.resolve(root);
|
||||||
|
const segments = name.split("/");
|
||||||
|
for (const [index, segment] of segments.entries()) {
|
||||||
|
current = path.join(current, segment);
|
||||||
|
const stat = await lstat(current);
|
||||||
|
if (stat.isSymbolicLink() || (index === segments.length - 1 ? !stat.isFile() : !stat.isDirectory()))
|
||||||
|
throw new Error(`QX imports must be ordinary files beneath ordinary directories: ${name}`);
|
||||||
|
}
|
||||||
|
return readFile(current, "utf8");
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Local imports share one workspace scope; paths are always repository-relative. */
|
||||||
|
export const resolveQxSources = async (
|
||||||
|
read: (name: string) => Promise<string>, entry = "workspace.qx",
|
||||||
|
) => {
|
||||||
|
const files = new Map<string, string>();
|
||||||
|
const active: string[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const segments: { start: number; end: number; fileName: string; sourceStart: number }[] = [];
|
||||||
|
let source = "";
|
||||||
|
const append = (fileName: string, text: string, sourceStart: number) => {
|
||||||
|
segments.push({ start: source.length, end: source.length + text.length, fileName, sourceStart });
|
||||||
|
source += text;
|
||||||
|
};
|
||||||
|
const visit = async (name: string, root: boolean) => {
|
||||||
|
validateQxImportPath(name);
|
||||||
|
if (active.includes(name)) throw new Error(`QX import cycle: ${[...active, name].join(" -> ")}`);
|
||||||
|
if (visited.has(name)) return;
|
||||||
|
const text = await read(name);
|
||||||
|
const syntax = parseQx(text, name);
|
||||||
|
if (syntax.diagnostics.length) throw new Error(syntax.diagnostics.map((d) =>
|
||||||
|
`${name}:${d.line}:${d.column + 1}: ${d.message}`).join("\n"));
|
||||||
|
const declaration = syntax.root.children[0]!;
|
||||||
|
if (declaration.kind !== (root ? "workspaceDecl" : "fragmentDecl"))
|
||||||
|
throw new Error(`${name}: expected ${root ? "workspace" : "fragment"} document`);
|
||||||
|
files.set(name, text);
|
||||||
|
active.push(name);
|
||||||
|
visited.add(name);
|
||||||
|
const braces = syntax.tokens.filter((token) => !token.trivia);
|
||||||
|
let cursor = root ? 0 : braces.find((token) => token.kind === "LBRACE")!.end;
|
||||||
|
const end = root ? text.length : braces.filter((token) => token.kind === "RBRACE").at(-1)!.start;
|
||||||
|
for (const node of walkSyntax(declaration)) {
|
||||||
|
if (node.kind !== "sourceImportDecl") continue;
|
||||||
|
append(name, text.slice(cursor, node.start), cursor);
|
||||||
|
const literal = node.children.find((child) => child.kind === "stringLiteral")!;
|
||||||
|
// Separators prevent adjacent tokens/comments joining across files.
|
||||||
|
append(name, "\n", node.start);
|
||||||
|
await visit(JSON.parse(text.slice(literal.start, literal.end)), false);
|
||||||
|
append(name, "\n", node.end);
|
||||||
|
cursor = node.end;
|
||||||
|
}
|
||||||
|
append(name, text.slice(cursor, end), cursor);
|
||||||
|
active.pop();
|
||||||
|
};
|
||||||
|
await visit(entry, true);
|
||||||
|
return {
|
||||||
|
source, sourceFiles: [...files.keys()], files,
|
||||||
|
originalPosition(line: number, column: number) {
|
||||||
|
const lines = source.split("\n");
|
||||||
|
const offset = lines.slice(0, line - 1).reduce((sum, value) => sum + value.length + 1, 0) +
|
||||||
|
[...(lines[line - 1] ?? "")].slice(0, column).join("").length;
|
||||||
|
const segment = segments.find((entry) => entry.start <= offset && entry.end > offset);
|
||||||
|
if (!segment) return { fileName: entry, line, column };
|
||||||
|
const prefix = files.get(segment.fileName)!.slice(0, segment.sourceStart + offset - segment.start);
|
||||||
|
return { fileName: segment.fileName, line: prefix.split("\n").length,
|
||||||
|
column: prefix.length - prefix.lastIndexOf("\n") - 1 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadQxSources = (root: string) => resolveQxSources((name) => readQxSource(root, name));
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { ParserRuleContext } from "antlr4ng";
|
||||||
|
import { parseDocument } from "./parser.js";
|
||||||
|
import { QuixosCapabilityParser } from "./generated/QuixosCapabilityParser.js";
|
||||||
|
|
||||||
|
/** Offsets and columns use UTF-16, as do JavaScript and LSP. End is exclusive. */
|
||||||
|
export type SourceRange = { start: number; end: number };
|
||||||
|
export type SourceEdit = SourceRange & { text: string };
|
||||||
|
export type SyntaxNode = SourceRange & { kind: string; children: SyntaxNode[] };
|
||||||
|
|
||||||
|
export const parseQx = (source: string, fileName = "<memory>") => {
|
||||||
|
const parsed = parseDocument(source, fileName);
|
||||||
|
// ANTLR indexes Unicode code points; editors index UTF-16 code units.
|
||||||
|
const offsets = [0];
|
||||||
|
for (const character of source) offsets.push(offsets[offsets.length - 1]! + character.length);
|
||||||
|
const offset = (index: number) => offsets[Math.max(0, index)] ?? source.length;
|
||||||
|
const node = (context: ParserRuleContext): SyntaxNode => ({
|
||||||
|
kind: QuixosCapabilityParser.ruleNames[context.ruleIndex]!,
|
||||||
|
start: offset(context.start?.start ?? 0),
|
||||||
|
end: offset((context.stop?.stop ?? -1) + 1),
|
||||||
|
children: context.children.flatMap((child) => child instanceof ParserRuleContext ? [node(child)] : []),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
source, fileName,
|
||||||
|
root: node(parsed.tree),
|
||||||
|
diagnostics: parsed.diagnostics.map((diagnostic) => {
|
||||||
|
const line = source.split("\n")[diagnostic.line - 1] ?? "";
|
||||||
|
return { ...diagnostic, column: [...line].slice(0, diagnostic.column).join("").length };
|
||||||
|
}),
|
||||||
|
tokens: parsed.tokens.getTokens().filter((token) => token.type !== -1).map((token) => ({
|
||||||
|
kind: QuixosCapabilityParser.symbolicNames[token.type] ?? "token",
|
||||||
|
start: offset(token.start), end: offset(token.stop + 1),
|
||||||
|
text: token.text ?? "", trivia: token.channel !== 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Edits refer to one immutable source snapshot; overlapping edits are errors. */
|
||||||
|
export const applySourceEdits = (source: string, edits: readonly SourceEdit[]) => {
|
||||||
|
const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
|
||||||
|
let end = 0;
|
||||||
|
let previousStart = -1;
|
||||||
|
let result = "";
|
||||||
|
for (const edit of sorted) {
|
||||||
|
if (!Number.isInteger(edit.start) || !Number.isInteger(edit.end) ||
|
||||||
|
edit.start < end || edit.start === previousStart || edit.end < edit.start || edit.end > source.length) {
|
||||||
|
throw new Error("Invalid or overlapping source edits");
|
||||||
|
}
|
||||||
|
result += source.slice(end, edit.start) + edit.text;
|
||||||
|
end = edit.end;
|
||||||
|
previousStart = edit.start;
|
||||||
|
}
|
||||||
|
return result + source.slice(end);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const walkSyntax = function* (node: SyntaxNode): Generator<SyntaxNode> {
|
||||||
|
yield node;
|
||||||
|
for (const child of node.children) yield* walkSyntax(child);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lintQx = (source: string, fileName = "<memory>") => {
|
||||||
|
const syntax = parseQx(source, fileName);
|
||||||
|
const diagnostics = syntax.diagnostics.map((entry) => ({ ...entry, severity: "error" as "error" | "warning" }));
|
||||||
|
if (diagnostics.length) return diagnostics;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const node of walkSyntax(syntax.root)) {
|
||||||
|
if (node.kind !== "sourceImportDecl") continue;
|
||||||
|
const literal = node.children.find((child) => child.kind === "stringLiteral")!;
|
||||||
|
const importPath: string = JSON.parse(source.slice(literal.start, literal.end));
|
||||||
|
let message: string | undefined;
|
||||||
|
let code = "invalid-source-import";
|
||||||
|
try { validateQxImportPath(importPath); } catch (error) { message = (error as Error).message; }
|
||||||
|
if (!message && seen.has(importPath)) { code = "duplicate-source-import"; message = `Repeated local import ${importPath}`; }
|
||||||
|
seen.add(importPath);
|
||||||
|
if (message) {
|
||||||
|
const prefix = source.slice(0, node.start);
|
||||||
|
diagnostics.push({ phase: "syntax", code, message, fileName,
|
||||||
|
line: prefix.split("\n").length, column: prefix.length - prefix.lastIndexOf("\n") - 1,
|
||||||
|
severity: code === "duplicate-source-import" ? "warning" : "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Conservative formatter: indentation only, preserving strings and comments verbatim. */
|
||||||
|
export const formatQx = (source: string) => {
|
||||||
|
const syntax = parseQx(source);
|
||||||
|
if (syntax.diagnostics.length) throw new Error("Cannot format QX with syntax errors");
|
||||||
|
const edits: SourceEdit[] = [];
|
||||||
|
let depth = 0;
|
||||||
|
let lineStart = 0;
|
||||||
|
for (const token of syntax.tokens) {
|
||||||
|
if (token.kind === "WS") continue;
|
||||||
|
lineStart = source.lastIndexOf("\n", token.start - 1) + 1;
|
||||||
|
if (/^[ \t]*$/.test(source.slice(lineStart, token.start))) {
|
||||||
|
const indentation = " ".repeat(Math.max(0, depth - (token.kind === "RBRACE" ? 1 : 0)));
|
||||||
|
if (source.slice(lineStart, token.start) !== indentation)
|
||||||
|
edits.push({ start: lineStart, end: token.start, text: indentation });
|
||||||
|
}
|
||||||
|
if (!token.trivia) {
|
||||||
|
if (token.kind === "LBRACE") depth++;
|
||||||
|
if (token.kind === "RBRACE") depth--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return applySourceEdits(source, edits);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addWorkspaceImport = (source: string, importPath: string) => {
|
||||||
|
validateQxImportPath(importPath);
|
||||||
|
const syntax = parseQx(source);
|
||||||
|
if (syntax.diagnostics.length || syntax.root.children[0]?.kind !== "workspaceDecl")
|
||||||
|
throw new Error("Expected a syntactically valid workspace");
|
||||||
|
for (const node of walkSyntax(syntax.root)) {
|
||||||
|
if (node.kind === "sourceImportDecl") {
|
||||||
|
const literal = node.children.find((child) => child.kind === "stringLiteral")!;
|
||||||
|
if (JSON.parse(source.slice(literal.start, literal.end)) === importPath) return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const brace = syntax.tokens.find((token) => token.kind === "LBRACE")!;
|
||||||
|
const newline = source.includes("\r\n") ? "\r\n" : "\n";
|
||||||
|
return applySourceEdits(source, [{ start: brace.end, end: brace.end,
|
||||||
|
text: `${newline} import ${JSON.stringify(importPath)};` }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateQxImportPath = (value: string) => {
|
||||||
|
if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.qx$/.test(value) ||
|
||||||
|
value.split("/").some((part) => part === "." || part === ".."))
|
||||||
|
throw new Error(`Invalid repository-relative QX import path: ${value}`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { CharStream, CommonTokenStream } from "antlr4ng";
|
||||||
|
import { QuixosLockLexer } from "../resource-lock/generated/QuixosLockLexer.js";
|
||||||
|
import { QuixosLockParser } from "../resource-lock/generated/QuixosLockParser.js";
|
||||||
|
import { parseQuixosLockDocument } from "../resource-lock/parser.js";
|
||||||
|
import { parseQx, walkSyntax, applySourceEdits, type SyntaxNode } from "./source.js";
|
||||||
|
|
||||||
|
export type StructuralSelector = { kind: string; id?: string; name?: string; names?: string[] };
|
||||||
|
export type StructuralEdit =
|
||||||
|
| { operation: "append"; parent: StructuralSelector; source: string }
|
||||||
|
| { operation: "replace"; target: StructuralSelector; source: string }
|
||||||
|
| { operation: "remove"; target: StructuralSelector }
|
||||||
|
| { operation: "import"; kind: "interface" | "package"; name: string }
|
||||||
|
| { operation: "semantic-major"; target: StructuralSelector; major: number }
|
||||||
|
| { operation: "conformance-id"; target: StructuralSelector; id: string }
|
||||||
|
| { operation: "quixos-pin"; source: {repository: string; commit: string} }
|
||||||
|
| { operation: "dependency"; kind: "interface" | "package"; name: string; source: {repository: string; commit: string} | null };
|
||||||
|
|
||||||
|
// Deliberately exclude valueType/identifier/stringLiteral: callers operate on
|
||||||
|
// declaration structure, not arbitrary token offsets or lockfile text patches.
|
||||||
|
const selectable = new Set(["workspaceDecl", "fragmentDecl", "interfaceResourceDecl", "packageResourceDecl", "atomDecl",
|
||||||
|
"valueMember", "relationshipMember", "operationMember", "packageOperationExport", "packageFunctionExport", "packageConstructorExport",
|
||||||
|
"conformanceDecl", "stateDecl", "edgeDecl", "constructorBindingDecl", "resourceImportDecl", "sourceImportDecl", "operationBindingDecl"]);
|
||||||
|
|
||||||
|
/** Bind only the template root identity; schema/atom identities are reusable.
|
||||||
|
* The revision's real identity is derived from its containing commit at compile time. */
|
||||||
|
export const instantiateWorkspaceIdentity = (source: string, workspaceId: string): string => {
|
||||||
|
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(workspaceId)) throw new Error("Workspace identity must be a UUID");
|
||||||
|
const syntax = parseQx(source);
|
||||||
|
if (syntax.diagnostics.length) throw new Error("Cannot instantiate a malformed template workspace");
|
||||||
|
const declaration = syntax.root.children.find(node => node.kind === "workspaceDecl");
|
||||||
|
const literals = declaration?.children.filter(node => node.kind === "stringLiteral");
|
||||||
|
if (!literals || literals.length !== 3) throw new Error("Template must contain a workspace declaration");
|
||||||
|
return applySourceEdits(source, [
|
||||||
|
{ ...literals[0], text: JSON.stringify(workspaceId) },
|
||||||
|
{ ...literals[1], text: JSON.stringify(`workspace-revision:${workspaceId}:source`) },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
const syntax = parseQx(source);
|
||||||
|
if (syntax.diagnostics.length) throw new Error("Cannot scaffold syntactically invalid QX");
|
||||||
|
const matches = [...walkSyntax(syntax.root)].filter((node) => {
|
||||||
|
if (node.kind !== selector.kind) return false;
|
||||||
|
if (selector.name && !node.children.some((child) => child.kind === "identifier" && source.slice(child.start, child.end) === selector.name)) return false;
|
||||||
|
if (selector.names && JSON.stringify(node.children.filter((child) => child.kind === "identifier").map((child) => source.slice(child.start, child.end))) !== JSON.stringify(selector.names)) return false;
|
||||||
|
if (selector.id) {
|
||||||
|
// Only an explicit ID field counts, not a coincidentally equal revision,
|
||||||
|
// default value, nested declaration, or comment.
|
||||||
|
const tokens = syntax.tokens.filter((token) => !token.trivia && token.start >= node.start && token.end <= node.end);
|
||||||
|
const literal = node.children.find((child) => child.kind === "stringLiteral" && tokens.some((token, index) => token.start === child.start && tokens[index - 1]?.kind === "ID"));
|
||||||
|
if (!literal || JSON.parse(source.slice(literal.start, literal.end)) !== selector.id) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (matches.length !== 1) throw new Error(`Structural selector must resolve exactly once (found ${matches.length})`);
|
||||||
|
return {node: matches[0], syntax};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Comment-preserving structural edits; every result is parsed before returning. */
|
||||||
|
export const editStructure = (source: string, edit: StructuralEdit): string => {
|
||||||
|
if (edit.operation === "quixos-pin") {
|
||||||
|
const parsed = parseQuixosLockDocument(source);
|
||||||
|
if (!parsed.ok || parsed.document.kind !== "root") throw new Error("Quixos pins belong in a valid root lockfile");
|
||||||
|
const parser = new QuixosLockParser(new CommonTokenStream(new QuixosLockLexer(CharStream.fromString(source))));
|
||||||
|
const entries = parser.document().quixosEntry();
|
||||||
|
if (entries.length !== 1) throw new Error("Expected one Quixos source declaration");
|
||||||
|
const literals = entries[0].quixosSourceBlock().stringLiteral();
|
||||||
|
const offsets = [0];
|
||||||
|
for (const character of source) offsets.push(offsets[offsets.length - 1] + character.length);
|
||||||
|
const result = applySourceEdits(source, [
|
||||||
|
{start: offsets[literals[0].start!.start], end: offsets[literals[0].stop!.stop + 1], text: JSON.stringify(edit.source.repository)},
|
||||||
|
{start: offsets[literals[literals.length - 1].start!.start], end: offsets[literals[literals.length - 1].stop!.stop + 1], text: JSON.stringify(edit.source.commit)},
|
||||||
|
]);
|
||||||
|
const checked = parseQuixosLockDocument(result);
|
||||||
|
if (!checked.ok) throw new Error(`Invalid Quixos pin: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (edit.operation === "import") {
|
||||||
|
if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name)) throw new Error("Invalid resource import");
|
||||||
|
const syntax = parseQx(source);
|
||||||
|
if (syntax.diagnostics.length) throw new Error("Cannot scaffold invalid QX");
|
||||||
|
const text = `import ${edit.kind} ${edit.name};`;
|
||||||
|
if ([...walkSyntax(syntax.root)].some((entry) => entry.kind === "resourceImportDecl" && source.slice(entry.start, entry.end).replace(/\s+/g, " ") === text)) return source;
|
||||||
|
const root = syntax.root.children[0];
|
||||||
|
const position = ["workspaceDecl", "fragmentDecl"].includes(root.kind) ? syntax.tokens.find((token) => token.kind === "LBRACE")!.end : root.start;
|
||||||
|
const result = applySourceEdits(source, [{start: position, end: position, text: `\n${text}\n`}]);
|
||||||
|
if (parseQx(result).diagnostics.length) throw new Error("Invalid resource import position");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (edit.operation === "dependency") {
|
||||||
|
const parsed = parseQuixosLockDocument(source);
|
||||||
|
if (!parsed.ok) throw new Error("Cannot scaffold an invalid lockfile");
|
||||||
|
if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name)) throw new Error("Invalid dependency selector");
|
||||||
|
const parser = new QuixosLockParser(new CommonTokenStream(new QuixosLockLexer(CharStream.fromString(source))));
|
||||||
|
const tree = parser.document();
|
||||||
|
const offsets = [0];
|
||||||
|
for (const character of source) offsets.push(offsets[offsets.length - 1] + character.length);
|
||||||
|
const entries = tree.resourceEntry().filter((entry) => entry.resourceKind().getText() === edit.kind && entry.identifier().getText() === edit.name);
|
||||||
|
if (entries.length > 1) throw new Error("Ambiguous dependency selector");
|
||||||
|
const entry = entries[0];
|
||||||
|
const replacement = edit.source ? `${edit.kind} ${edit.name} source {\n repository ${JSON.stringify(edit.source.repository)};\n commit ${JSON.stringify(edit.source.commit)};\n}` : "";
|
||||||
|
if (!entry && !edit.source) throw new Error("Cannot remove an absent dependency");
|
||||||
|
const start = entry ? offsets[entry.start!.start] : offsets[tree.RBRACE().symbol.start];
|
||||||
|
const end = entry ? offsets[entry.stop!.stop + 1] : start;
|
||||||
|
const result = applySourceEdits(source, [{start, end, text: entry ? replacement : `${replacement}\n`}]);
|
||||||
|
const checked = parseQuixosLockDocument(result);
|
||||||
|
if (!checked.ok) throw new Error(`Invalid dependency change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const {node, syntax} = select(source, edit.operation === "append" ? edit.parent : edit.target);
|
||||||
|
let result: string;
|
||||||
|
if (edit.operation === "semantic-major") {
|
||||||
|
if (!["conformanceDecl", "packageResourceDecl"].includes(node.kind) || !Number.isSafeInteger(edit.major) || edit.major < 1) throw new Error("Semantic major requires a package/conformance and positive integer");
|
||||||
|
const tokens = syntax.tokens.filter((token) => !token.trivia && token.start >= node.start && token.end <= node.end);
|
||||||
|
const marker = tokens.findIndex((token) => token.kind === "SEMANTIC_MAJOR");
|
||||||
|
const value = marker < 0 ? undefined : tokens[marker + 1];
|
||||||
|
const brace = tokens.find((token) => token.kind === "LBRACE")!;
|
||||||
|
result = applySourceEdits(source, [{start: value?.start ?? brace.start, end: value?.end ?? brace.start, text: value ? String(edit.major) : `semantic-major ${edit.major} `}]);
|
||||||
|
} else if (edit.operation === "conformance-id") {
|
||||||
|
if (node.kind !== "conformanceDecl" || !edit.id) throw new Error("Identity enrollment requires a conformance and stable ID");
|
||||||
|
const existing = node.children.find((entry) => entry.kind === "stringLiteral");
|
||||||
|
if (existing) {
|
||||||
|
if (JSON.parse(source.slice(existing.start, existing.end)) !== edit.id) throw new Error("Cannot change an enrolled conformance identity; create a new conformance explicitly");
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
const identifiers = node.children.filter((entry) => entry.kind === "identifier");
|
||||||
|
const position = identifiers[identifiers.length - 1].end;
|
||||||
|
result = applySourceEdits(source, [{start: position, end: position, text: ` id ${JSON.stringify(edit.id)}`}]);
|
||||||
|
} else if (edit.operation === "append") {
|
||||||
|
const closing = syntax.tokens.find((token) => token.kind === "RBRACE" && token.end === node.end);
|
||||||
|
if (!closing) throw new Error("Append requires a declaration with a body");
|
||||||
|
result = applySourceEdits(source, [{start: closing.start, end: closing.start, text: `\n${edit.source}\n`}]);
|
||||||
|
} else {
|
||||||
|
// A resource parser node includes imports/external declarations preceding
|
||||||
|
// its header. Replacing the declaration must not delete that preamble.
|
||||||
|
const identifier = node.children.find(child => child.kind === "identifier");
|
||||||
|
const header = ["packageResourceDecl", "interfaceResourceDecl"].includes(node.kind) && identifier
|
||||||
|
? syntax.tokens.filter(token => token.start >= node.start && token.end <= identifier.start &&
|
||||||
|
token.kind === (node.kind === "packageResourceDecl" ? "PACKAGE" : "INTERFACE")).at(-1)?.start : undefined;
|
||||||
|
const wrapper = edit.operation === "remove" && ["stateDecl", "edgeDecl"].includes(node.kind)
|
||||||
|
? [...walkSyntax(syntax.root)].filter((entry) => ["conformanceItem", "sharedAttachmentDecl"].includes(entry.kind) && entry.start <= node.start && entry.end >= node.end).sort((a, b) => (a.end - a.start) - (b.end - b.start))[0]
|
||||||
|
: undefined;
|
||||||
|
result = applySourceEdits(source, [{start: wrapper?.start ?? header ?? node.start, end: wrapper?.end ?? node.end, text: edit.operation === "replace" ? edit.source : ""}]);
|
||||||
|
}
|
||||||
|
const checked = parseQx(result);
|
||||||
|
if (checked.diagnostics.length) throw new Error(`Invalid structural change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scaffoldResourceSource = (kind: "interface" | "package", name: string, id: string, revision: string) => {
|
||||||
|
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || !id || !revision) throw new Error("Resource scaffold requires a PascalCase name and explicit identities");
|
||||||
|
const source = `${kind} ${name} id ${JSON.stringify(id)} revision ${JSON.stringify(revision)} {\n}\n`;
|
||||||
|
if (parseQx(source).diagnostics.length) throw new Error("Invalid resource scaffold");
|
||||||
|
return source;
|
||||||
|
};
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { contentDigest } from "../capability-model/evolution.js";
|
||||||
|
import { editStructure, type StructuralEdit } from "./structural-edits.js";
|
||||||
|
import { snapshotRepository, localResourceSnapshots } from "./candidate-check.js";
|
||||||
|
import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js";
|
||||||
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
|
import {bindingSchema, generateTypeScriptBindings} from "../bindings/index.js";
|
||||||
|
import { parseQx } from "./source.js";
|
||||||
|
import { parseQuixosLockDocument } from "../resource-lock/index.js";
|
||||||
|
import { withFileLock } from "./file-lock.js";
|
||||||
|
|
||||||
|
export type StructuralRequest = {
|
||||||
|
kind: "workspace" | "interface" | "package";
|
||||||
|
source?: {repository: string; commit: string};
|
||||||
|
resourceRoot?: string;
|
||||||
|
validation?: "syntax" | "resource-graph";
|
||||||
|
files: ({file: string; edits: StructuralEdit[]} | {file: string; create: string} | {file: string; generated: string} | {file: string; expected: string; replace: string})[];
|
||||||
|
};
|
||||||
|
type Change = {file: string; before: string | null; after: string; mode: number};
|
||||||
|
type Journal = {schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[]};
|
||||||
|
const safeFile = (file: string) => {
|
||||||
|
if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|lock|ts|tsx|mjs|json|nix|txtpb))$/.test(file)
|
||||||
|
|| file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))) throw new Error(`Unsafe scaffold path ${file}`);
|
||||||
|
};
|
||||||
|
const read = async (root: string, file: string): Promise<string | null> => {
|
||||||
|
safeFile(file);
|
||||||
|
const target = path.join(root, file);
|
||||||
|
try {
|
||||||
|
const metadata = await fs.lstat(target);
|
||||||
|
if (!metadata.isFile() || metadata.isSymbolicLink() || !(await fs.realpath(target)).startsWith(`${root}/`)) throw new Error(`Scaffold target is not a contained regular file: ${file}`);
|
||||||
|
return await fs.readFile(target, "utf8");
|
||||||
|
} catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
|
||||||
|
};
|
||||||
|
const containedParent = async (root: string, file: string) => {
|
||||||
|
let current = root;
|
||||||
|
for (const part of file.split("/").slice(0, -1)) {
|
||||||
|
current = path.join(current, part);
|
||||||
|
await fs.mkdir(current).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; });
|
||||||
|
const metadata = await fs.lstat(current);
|
||||||
|
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("Scaffold parent must be a real directory");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const durableJson = async (file: string, value: unknown) => {
|
||||||
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
||||||
|
const handle = await fs.open(temporary, "wx", 0o600);
|
||||||
|
try { await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`); await handle.sync(); } finally { await handle.close(); }
|
||||||
|
await fs.rename(temporary, file);
|
||||||
|
const directory = await fs.open(path.dirname(file), "r");
|
||||||
|
try { await directory.sync(); } finally { await directory.close(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Validate the entire edited resource graph in a private snapshot before writes. */
|
||||||
|
export const planStructure = async (rootPath: string, request: StructuralRequest, snapshotMap?: string) => {
|
||||||
|
if (request.validation && !["syntax", "resource-graph"].includes(request.validation)) throw new Error("Unknown structural validation mode");
|
||||||
|
const root = await fs.realpath(rootPath);
|
||||||
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-structure-"));
|
||||||
|
try {
|
||||||
|
const snapshot = await snapshotRepository(root, path.join(temporary, "source"));
|
||||||
|
const observed = await Promise.all(snapshot.files.map(async ({name}) => ({file: name, digest: contentDigest(await fs.readFile(path.join(snapshot.directory, name), "utf8"))})));
|
||||||
|
const changes: Change[] = [];
|
||||||
|
if (!Array.isArray(request.files) || !request.files.length || request.files.length > 100) throw new Error("Structural plan requires 1–100 files");
|
||||||
|
for (const input of request.files) {
|
||||||
|
safeFile(input.file);
|
||||||
|
if (changes.some((entry) => entry.file === input.file)) throw new Error("Repeated structural file target");
|
||||||
|
const before = await read(root, input.file);
|
||||||
|
let after: string;
|
||||||
|
if ("create" in input) {
|
||||||
|
if (before !== null || typeof input.create !== "string") throw new Error("Scaffold creation cannot replace an existing file");
|
||||||
|
after = input.create;
|
||||||
|
} else if ("replace" in input) {
|
||||||
|
if (before !== input.expected || typeof input.replace !== "string") throw new Error(`Stale imperative edit: ${input.file}`);
|
||||||
|
after = input.replace;
|
||||||
|
} else if ("generated" in input) {
|
||||||
|
const generated = (text: string) => text.startsWith("// Generated by qx-scaffold-v1\n") || text.startsWith("# Generated by qx-scaffold-v1\n") || (() => {try {return JSON.parse(text).generatedBy === "qx-scaffold-v1";} catch {return false;}})();
|
||||||
|
if (typeof input.generated !== "string" || !generated(input.generated) || (before !== null && !generated(before))) throw new Error("Only scaffold-owned generated files may be regenerated");
|
||||||
|
after = input.generated;
|
||||||
|
} else {
|
||||||
|
if (before === null || !Array.isArray(input.edits)) throw new Error("Structural edit requires an existing source");
|
||||||
|
after = input.edits.reduce(editStructure, before);
|
||||||
|
}
|
||||||
|
if (Buffer.byteLength(after) > 1024 * 1024) throw new Error("Scaffold file exceeds 1 MiB");
|
||||||
|
const mode = before === null ? 0o644 : (await fs.stat(path.join(root, input.file))).mode & 0o777;
|
||||||
|
changes.push({file: input.file, before, after, mode});
|
||||||
|
await containedParent(snapshot.directory, input.file);
|
||||||
|
await fs.writeFile(path.join(snapshot.directory, input.file), after);
|
||||||
|
}
|
||||||
|
if (request.validation === "syntax") {
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.file.endsWith(".qx") && parseQx(change.after, change.file).diagnostics.length) throw new Error(`Invalid QX syntax in ${change.file}`);
|
||||||
|
if (change.file.endsWith(".lock") && !parseQuixosLockDocument(change.after, change.file).ok) throw new Error(`Invalid lock syntax in ${change.file}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const localMap = path.join(temporary, "local-resources.json");
|
||||||
|
await fs.writeFile(localMap, JSON.stringify(await localResourceSnapshots(root, snapshotMap)));
|
||||||
|
const resolveResource = await createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resources"), snapshotMap: localMap});
|
||||||
|
if (request.resourceRoot && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(request.resourceRoot)) throw new Error("Resource root must be a contained relative directory");
|
||||||
|
const resourceRoot = path.join(snapshot.directory, request.resourceRoot ?? "");
|
||||||
|
if (request.kind === "workspace") await compileWorkspaceRepository({rootDirectory: resourceRoot, resolveResource});
|
||||||
|
else if (["package", "interface"].includes(request.kind) && request.source) {
|
||||||
|
const compiled = await compileCapabilityResourceRepository({rootDirectory: resourceRoot, kind: request.kind as "package" | "interface", source: {resolver: "git", ...request.source}, resolveResource});
|
||||||
|
if (compiled.resource.kind === "package") {
|
||||||
|
const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8"));
|
||||||
|
const artifacts = [
|
||||||
|
{file: configuration.bindingOutput as string, after: generateTypeScriptBindings(bindingSchema(compiled), compiled.resource.revision.revisionId, configuration.options)},
|
||||||
|
];
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
const file = request.resourceRoot ? `${request.resourceRoot}/${artifact.file}` : artifact.file;
|
||||||
|
safeFile(file);
|
||||||
|
if (Buffer.byteLength(artifact.after) > 1024 * 1024) throw new Error("Generated scaffold file exceeds 1 MiB");
|
||||||
|
const before = await read(root, file);
|
||||||
|
if (before !== null && !before.startsWith("// Generated by quixos-codegen-ts.") && (() => {try {return JSON.parse(before).generatedBy !== "qx-scaffold-v1";} catch {return true;}})()) throw new Error(`Refusing to overwrite hand-authored generated artifact ${file}`);
|
||||||
|
const previous = changes.find((entry) => entry.file === file);
|
||||||
|
if (previous) previous.after = artifact.after;
|
||||||
|
else changes.push({file, before, after: artifact.after, mode: 0o644});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else throw new Error("Resource plans require kind and exact authored source identity");
|
||||||
|
}
|
||||||
|
if (changes.length > 100) throw new Error("Structural plan including generated artifacts exceeds 100 files");
|
||||||
|
// Validation may fetch dependencies; reject edits made while it was running.
|
||||||
|
for (const entry of changes) if (await read(root, entry.file) !== entry.before) throw new Error(`Source changed while planning: ${entry.file}`);
|
||||||
|
for (const entry of observed) if (contentDigest(await fs.readFile(path.join(root, entry.file), "utf8")) !== entry.digest) throw new Error(`Validation input changed while planning: ${entry.file}`);
|
||||||
|
return {root, changes, observed, digest: contentDigest(changes), validation: request.validation ?? "resource-graph" as const};
|
||||||
|
} finally { await fs.rm(temporary, {recursive: true, force: true}); }
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Replay only exact before/after states. A crash never loses the original text. */
|
||||||
|
const replayStructure = async (rootPath: string, id: string) => {
|
||||||
|
const root = await fs.realpath(rootPath);
|
||||||
|
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid scaffold journal ID");
|
||||||
|
const journalPath = path.join(root, ".quixos", "scaffolds", `${id}.json`);
|
||||||
|
const journal = JSON.parse(await fs.readFile(journalPath, "utf8")) as Journal;
|
||||||
|
if (journal.schemaVersion !== 1 || journal.root !== root || journal.id !== id) throw new Error("Scaffold journal identity mismatch");
|
||||||
|
for (const entry of journal.changes) {
|
||||||
|
const current = await read(root, entry.file);
|
||||||
|
if (current !== entry.before && current !== entry.after) throw new Error(`Scaffold conflicts with newer edits: ${entry.file}; original text is retained in ${journalPath}`);
|
||||||
|
}
|
||||||
|
if (journal.phase === "complete") return {id, journalPath, phase: journal.phase};
|
||||||
|
for (const entry of journal.changes) {
|
||||||
|
if (await read(root, entry.file) === entry.after) continue;
|
||||||
|
await containedParent(root, entry.file);
|
||||||
|
const target = path.join(root, entry.file);
|
||||||
|
const temporary = `${target}.qx-${randomUUID()}.tmp`;
|
||||||
|
const handle = await fs.open(temporary, "wx", entry.mode);
|
||||||
|
try { await handle.writeFile(entry.after); await handle.sync(); } finally { await handle.close(); }
|
||||||
|
if (entry.before === null) {
|
||||||
|
// link is atomic and fails if another author created the destination.
|
||||||
|
await fs.link(temporary, target);
|
||||||
|
await fs.unlink(temporary);
|
||||||
|
} else {
|
||||||
|
if (await read(root, entry.file) !== entry.before) throw new Error(`Source changed during scaffold: ${entry.file}`);
|
||||||
|
await fs.rename(temporary, target);
|
||||||
|
}
|
||||||
|
const directory = await fs.open(path.dirname(target), "r");
|
||||||
|
try { await directory.sync(); } finally { await directory.close(); }
|
||||||
|
}
|
||||||
|
journal.phase = "complete";
|
||||||
|
await durableJson(journalPath, journal);
|
||||||
|
return {id, journalPath, phase: journal.phase};
|
||||||
|
};
|
||||||
|
|
||||||
|
const withStructureLock = async <T>(root: string, work: () => Promise<T>) => {
|
||||||
|
await containedParent(root, ".quixos/scaffolds/placeholder.json");
|
||||||
|
const lock = path.join(root, ".quixos", "scaffolds", "writer.lock");
|
||||||
|
return withFileLock(lock, work);
|
||||||
|
};
|
||||||
|
export const resumeStructure = async (rootPath: string, id: string) => {
|
||||||
|
const root = await fs.realpath(rootPath);
|
||||||
|
return withStructureLock(root, () => replayStructure(root, id));
|
||||||
|
};
|
||||||
|
export const applyStructure = async (plan: Awaited<ReturnType<typeof planStructure>>, id: string = randomUUID()) => withStructureLock(plan.root, async () => {
|
||||||
|
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid scaffold journal ID");
|
||||||
|
const directory = path.join(plan.root, ".quixos", "scaffolds");
|
||||||
|
try {
|
||||||
|
const existing = JSON.parse(await fs.readFile(path.join(directory, `${id}.json`), "utf8")) as Journal;
|
||||||
|
if (existing.root !== plan.root || contentDigest(existing.changes) !== plan.digest) throw new Error("Scaffold journal identity conflict");
|
||||||
|
for (const entry of plan.observed) if (!plan.changes.some((change) => change.file === entry.file) && contentDigest(await fs.readFile(path.join(plan.root, entry.file), "utf8")) !== entry.digest) throw new Error(`Stale scaffold validation input: ${entry.file}`);
|
||||||
|
return replayStructure(plan.root, id);
|
||||||
|
} catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;}
|
||||||
|
// An unfinished journal must be recovered before another structural mutation.
|
||||||
|
for (const file of await fs.readdir(directory)) if (file.endsWith(".json")) {
|
||||||
|
const prior = JSON.parse(await fs.readFile(path.join(directory, file), "utf8")) as Journal;
|
||||||
|
if (prior.phase !== "complete") throw new Error(`Unfinished scaffold ${prior.id}; resume it first`);
|
||||||
|
}
|
||||||
|
for (const entry of plan.changes) if (await read(plan.root, entry.file) !== entry.before) throw new Error(`Stale scaffold plan: ${entry.file}`);
|
||||||
|
for (const entry of plan.observed) if (contentDigest(await fs.readFile(path.join(plan.root, entry.file), "utf8")) !== entry.digest) throw new Error(`Stale scaffold validation input: ${entry.file}`);
|
||||||
|
await durableJson(path.join(directory, `${id}.json`), {schemaVersion: 1, id, root: plan.root, phase: "prepared", changes: plan.changes} satisfies Journal);
|
||||||
|
return replayStructure(plan.root, id);
|
||||||
|
});
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { readFile, writeFile, mkdtemp, rm, realpath } from "node:fs/promises";
|
||||||
|
import { parseQx, formatQx, lintQx } from "./source.js";
|
||||||
|
import { scaffoldAtom } from "./scaffold.js";
|
||||||
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
|
import { planEvolution } from "../capability-model/index.js";
|
||||||
|
import { snapshotRepository } from "./candidate-check.js";
|
||||||
|
import {planPinUpgrades, applyPinUpgrades, discoverUpgradeSpec, type UpgradeSpec} from "./pin-upgrades.js";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import {spawnSync} from "node:child_process";
|
||||||
|
import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js";
|
||||||
|
import {scaffoldRecipe, type ScaffoldRecipe} from "./scaffold-recipes.js";
|
||||||
|
import {checkBundleSources} from "../bindings/bundle-policy.js";
|
||||||
|
import {sealMigrations} from "./migration-seal.js";
|
||||||
|
import {generatePackageDescriptor} from "../bindings/index.js";
|
||||||
|
import {buildCheckedPackage, buildImmutableCandidate, snapshotCommit} from "./checked-build.js";
|
||||||
|
import {formatQuixosLock, loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js";
|
||||||
|
import { walkSyntax } from "./source.js";
|
||||||
|
import { inspectWorkbench } from "./authoring-inspect.js";
|
||||||
|
import { authoringContext } from "./authoring-context.js";
|
||||||
|
import { convergeAuthoring } from "./authoring-converge.js";
|
||||||
|
import { checkAuthoring } from "./authoring-check.js";
|
||||||
|
import { authoringWorklist } from "./authoring-worklist.js";
|
||||||
|
|
||||||
|
const authorSource = async (root: string) => {
|
||||||
|
const result = spawnSync("git", ["config", "--get", "remote.origin.url"], {cwd: root, encoding: "utf8"});
|
||||||
|
if (result.error || result.status !== 0) throw new Error("Managed resource has no origin");
|
||||||
|
return {repository: result.stdout.trim(), commit: await snapshotCommit(root)};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readSpec = async (value: string) => {
|
||||||
|
if (value === "-") { let input = ""; for await (const chunk of process.stdin) { input += chunk; if (input.length > 1024 * 1024) throw new Error("Scaffold specification exceeds 1 MiB"); } return JSON.parse(input); }
|
||||||
|
return JSON.parse(value.trimStart().startsWith("{") ? value : await readFile(value, "utf8"));
|
||||||
|
};
|
||||||
|
const planSummary = (plan: Awaited<ReturnType<typeof planStructure>>) => ({
|
||||||
|
root: plan.root, validation: plan.validation,
|
||||||
|
changes: plan.changes.map(change => ({file: change.file, beforeBytes: change.before?.length ?? 0, afterBytes: change.after?.length ?? 0})),
|
||||||
|
note: "Structural plan only, not implementation verification. Run qx-workspace check while iterating.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const [command, ...args] = process.argv.slice(2);
|
||||||
|
if (command === "package-identity") {
|
||||||
|
if (args.length !== 1) throw new Error("usage: quixos-qx package-identity PACKAGE_QX");
|
||||||
|
const authored = await readFile(args[0], "utf8");
|
||||||
|
const syntax = parseQx(authored);
|
||||||
|
const declarations = [...walkSyntax(syntax.root)].filter(node => node.kind === "packageResourceDecl");
|
||||||
|
if (syntax.diagnostics.length || declarations.length !== 1) throw new Error("Expected one valid package declaration");
|
||||||
|
const literals = declarations[0].children.filter(node => node.kind === "stringLiteral");
|
||||||
|
process.stdout.write(JSON.stringify({id: JSON.parse(authored.slice(literals[0].start, literals[0].end)),
|
||||||
|
revision: JSON.parse(authored.slice(literals[1].start, literals[1].end))}) + "\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "package-descriptor") {
|
||||||
|
if (args.length !== 2) throw new Error("usage: quixos-qx package-descriptor SCHEMA REVISION_ID");
|
||||||
|
process.stdout.write(generatePackageDescriptor(JSON.parse(await readFile(args[0], "utf8")), args[1]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "migration-seal") {
|
||||||
|
if (args.length !== 1) throw new Error("usage: quixos-qx migration-seal PACKAGE_DIRECTORY");
|
||||||
|
process.stdout.write(JSON.stringify(await sealMigrations(args[0])) + "\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "bundle-policy") {
|
||||||
|
if (args.length !== 1) throw new Error("usage: quixos-qx bundle-policy SOURCE_DIRECTORY");
|
||||||
|
await checkBundleSources(args[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "worklist" && !args.includes("--help")) {
|
||||||
|
if (args.length !== 1) throw new Error("usage: quixos-qx worklist WORKBENCH");
|
||||||
|
process.stdout.write(`${JSON.stringify(await authoringWorklist(args[0]), null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (["author-check", "author-contract"].includes(command) && !args.includes("--help")) {
|
||||||
|
const [root, output, ...flags] = args;
|
||||||
|
if (!root || !output) throw new Error("usage: quixos-qx author-check ROOT OUTPUT [--baseline FILE] [--reviews FILE]");
|
||||||
|
const options: {baseline?: string; reviews?: string} = {};
|
||||||
|
for (let index = 0; index < flags.length; index += 2) {
|
||||||
|
if (!flags[index + 1]) throw new Error("Missing check option value");
|
||||||
|
if (flags[index] === "--baseline") options.baseline = flags[index + 1];
|
||||||
|
else if (flags[index] === "--reviews") options.reviews = flags[index + 1];
|
||||||
|
else throw new Error(`Unknown check option ${flags[index]}`);
|
||||||
|
}
|
||||||
|
const result = await checkAuthoring(root, output, {...options, contractOnly: command === "author-contract"});
|
||||||
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
if (result.blockers.length) process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (["converge", "_converge"].includes(command) && !args.includes("--help")) {
|
||||||
|
if (!args.length || args.length > 2) throw new Error("usage: quixos-qx converge WORKBENCH [REGISTERED_DIRECTORY] (join package writers first)");
|
||||||
|
const context = await authoringContext(args[0]);
|
||||||
|
if (command === "converge") {
|
||||||
|
const result = spawnSync("flock", ["--exclusive", "--timeout", "120", "--conflict-exit-code", "75", path.join(context.workbench, ".quixos/converge.lock"),
|
||||||
|
process.execPath, process.argv[1], "_converge", context.workbench, ...(args[1] ? [args[1]] : [])], {stdio: "inherit"});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status === 75) process.stderr.write("Timed out after 120 seconds waiting for source capture; inspect the active coordinator. No build lock is held.\n");
|
||||||
|
process.exitCode = result.status ?? 1; return;
|
||||||
|
}
|
||||||
|
const result = await convergeAuthoring(context.workbench, args[1]);
|
||||||
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
if (!result.converged) process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "check-committed" && !args.includes("--help")) {
|
||||||
|
const [kind, repository, commit, log, ...extra] = args;
|
||||||
|
if (!["workspace", "interface", "package"].includes(kind) || !log || extra.length) throw new Error("usage: quixos-qx check-committed workspace|interface|package REPOSITORY COMMIT LOG_FILE");
|
||||||
|
process.stdout.write(`${await buildImmutableCandidate({repository, commit}, kind as "workspace" | "interface" | "package", log)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!command || command === "--help" || args.includes("--help")) {
|
||||||
|
process.stdout.write("quixos-qx: author-check, author-contract, converge, worklist, inspect, resources, check-committed, source-baseline, scaffold-package, scaffold-interface, scaffold-function, scaffold-dependency, scaffold-structure, scaffold-resume, pin-upgrade, parse, lint, format\n" +
|
||||||
|
"inspect WORKBENCH [RESOURCE] shows provisional contracts, with explicit historical fallback; never verification evidence.\n" +
|
||||||
|
"resources WORKBENCH lists registered editable repositories. Use qx-workspace for the workspace authoring workflow.\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "inspect" || command === "resources") {
|
||||||
|
if (!args[0] || args.length > (command === "inspect" ? 2 : 1)) throw new Error(`usage: quixos-qx ${command} WORKBENCH${command === "inspect" ? " [RESOURCE]" : ""}`);
|
||||||
|
const result = command === "inspect" ? await inspectWorkbench(args[0], args[1]) : await authoringContext(args[0]);
|
||||||
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "source-baseline") {
|
||||||
|
if (args.length !== 1) throw new Error("usage: quixos-qx source-baseline WORKBENCH");
|
||||||
|
process.stdout.write(`${JSON.stringify(await (await authoringContext(args[0])).baseline())}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "scaffold-dependency") {
|
||||||
|
const [root, kind, name, ...remaining] = args;
|
||||||
|
let repository: string | undefined, commit: string | undefined;
|
||||||
|
const flags = [...remaining];
|
||||||
|
if (flags.length && !flags[0].startsWith("--")) { repository = flags.shift(); commit = flags.shift(); }
|
||||||
|
else if (root && ["interface", "package"].includes(kind) && name) {
|
||||||
|
const context = await authoringContext(root);
|
||||||
|
const alias = await realpath(path.join(context.workbench, `${kind}s`, name)).catch(() => null);
|
||||||
|
const matches = context.resources.filter(entry => entry.kind === kind && (entry.resourceId === name || path.basename(entry.directory) === name || path.join(context.workbench, entry.directory) === alias));
|
||||||
|
if (matches.length !== 1 || !matches[0].source) throw new Error(`Select exactly one registered ${kind} with qx-workspace resources; no match for ${name}`);
|
||||||
|
const selected = matches[0];
|
||||||
|
let source = selected.source!;
|
||||||
|
if (flags.includes("--write")) {
|
||||||
|
const retained = spawnSync("quixos-qx", ["converge", context.workbench, selected.directory], {encoding: "utf8", env: {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"}});
|
||||||
|
if (retained.error || retained.status !== 0) throw new Error(`Dependency source needs attention: ${retained.error?.message ?? retained.stdout ?? retained.stderr}`);
|
||||||
|
source = JSON.parse(retained.stdout).candidate;
|
||||||
|
}
|
||||||
|
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]");
|
||||||
|
const resourceKind = kind as "package" | "interface";
|
||||||
|
let entrypoint: "workspace" | "package" | "interface" | undefined;
|
||||||
|
for (const candidate of ["workspace", "package", "interface"] as const) {
|
||||||
|
try {await readFile(path.join(root, `${candidate}.qx`)); if (entrypoint) throw new Error("Ambiguous repository entrypoint"); entrypoint = candidate;}
|
||||||
|
catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;}
|
||||||
|
}
|
||||||
|
if (!entrypoint) throw new Error("No QX repository entrypoint");
|
||||||
|
const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
|
||||||
|
if (!lock.ok) throw new Error("Invalid resource lock");
|
||||||
|
let target = "quixos.lock";
|
||||||
|
for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) {
|
||||||
|
const parsed = parseQuixosLockDocument(await readFile(path.join(root, file), "utf8"));
|
||||||
|
if (parsed.ok && parsed.document.resources.some(entry => entry.kind === kind && entry.binding === name)) target = file;
|
||||||
|
}
|
||||||
|
const request: StructuralRequest = {kind: entrypoint, source: await authorSource(root), validation: "syntax", files: [
|
||||||
|
{file: `${entrypoint}.qx`, edits: [{operation: "import", kind: resourceKind, name}]},
|
||||||
|
{file: target, edits: [{operation: "dependency", kind: resourceKind, name, source: {repository, commit}}]},
|
||||||
|
]};
|
||||||
|
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
|
||||||
|
process.stdout.write(`${JSON.stringify({...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "scaffold-interface") {
|
||||||
|
const [root, specFile, ...flags] = args;
|
||||||
|
if (!root || !specFile || flags.some(flag => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-interface ROOT SPEC_JSON [--write]");
|
||||||
|
const spec = await readSpec(specFile) as ScaffoldRecipe;
|
||||||
|
if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id || !spec.revision || !spec.tools?.quixos) throw new Error("Interface scaffold requires name, id, revision and Quixos toolchain source");
|
||||||
|
const request: StructuralRequest = {kind: "interface", source: spec.source, files: [
|
||||||
|
{file: "interface.qx", create: `interface ${spec.name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`},
|
||||||
|
{file: "quixos.lock", create: formatQuixosLock({formatVersion: 1, quixos: {resolver: "git", ...spec.tools.quixos}, resources: []})},
|
||||||
|
{file: ".gitignore", create: ".quixos/\n"},
|
||||||
|
]};
|
||||||
|
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
|
||||||
|
process.stdout.write(`${JSON.stringify({...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "build-package") {
|
||||||
|
if (args.length !== 3) throw new Error("usage: quixos-qx build-package COMMITTED_SOURCE SCHEMA PACKAGE_REVISION_ID");
|
||||||
|
process.stdout.write(`${await buildCheckedPackage(args[0], args[1], args[2])}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "source-digest") {
|
||||||
|
if (!args[0] || args.length !== 1) throw new Error("usage: quixos-qx source-digest ROOT");
|
||||||
|
const temporary = await mkdtemp(path.join(os.tmpdir(), "qx-source-digest-"));
|
||||||
|
try {process.stdout.write(`${(await snapshotRepository(args[0], temporary)).treeDigest}\n`);} finally {await rm(temporary, {recursive: true, force: true});}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "pin-upgrade") {
|
||||||
|
const [workbench, specFile, ...flags] = args;
|
||||||
|
if (!workbench || !specFile) throw new Error("usage: quixos-qx pin-upgrade WORKBENCH SPEC_JSON [--publish] [--resume UUID]");
|
||||||
|
let publish = false, acceptEdits = false, resume: string | undefined;
|
||||||
|
for (let index = 0; index < flags.length; index++) {
|
||||||
|
if (flags[index] === "--publish") publish = true;
|
||||||
|
else if (flags[index] === "--accept-edits") acceptEdits = true;
|
||||||
|
else if (flags[index] === "--resume" && /^[a-f0-9-]{36}$/.test(flags[index + 1] ?? "")) resume = 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)");
|
||||||
|
const plan = resume ? JSON.parse(await readFile(path.join(workbench, ".quixos/upgrades", `${resume}.json`), "utf8")).plan
|
||||||
|
: await planPinUpgrades(workbench, specFile === "auto" ? await discoverUpgradeSpec(workbench) : JSON.parse(await readFile(specFile, "utf8")) as UpgradeSpec);
|
||||||
|
if (resume && path.resolve(workbench) !== plan.workbench) throw new Error("Upgrade journal belongs to another workbench");
|
||||||
|
process.stdout.write(`${JSON.stringify(publish ? await applyPinUpgrades(plan, resume, undefined, {acceptEdits}) : plan, null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "scaffold-refresh") throw new Error("Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependency installation uses scaffold install.");
|
||||||
|
if (["scaffold-package", "scaffold-function", "scaffold-migration", "scaffold-install"].includes(command)) {
|
||||||
|
const [root, specFile, ...flags] = args;
|
||||||
|
let spec: ScaffoldRecipe;
|
||||||
|
if (command === "scaffold-function" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(specFile ?? "")) {
|
||||||
|
const authored = parseQx(await readFile(path.join(root, "package.qx"), "utf8"));
|
||||||
|
const declarationNode = [...walkSyntax(authored.root)].find(node => node.kind === "packageResourceDecl");
|
||||||
|
const nameNode = declarationNode?.children.find(node => node.kind === "identifier");
|
||||||
|
if (!nameNode) throw new Error("Expected a package declaration");
|
||||||
|
const name = authored.source.slice(nameNode.start, nameNode.end);
|
||||||
|
spec = {source: await authorSource(root), name: specFile, id: `export:${name}:${specFile}`};
|
||||||
|
const declaration = flags.indexOf("--declaration");
|
||||||
|
if (declaration >= 0) {
|
||||||
|
if (!flags[declaration + 1]) throw new Error("--declaration requires a QX declaration file");
|
||||||
|
spec.declaration = await readFile(flags[declaration + 1], "utf8");
|
||||||
|
const parsed = parseQx(`package Draft id "package:draft" revision "package:draft@1" { ${spec.declaration} }`);
|
||||||
|
if (parsed.diagnostics.length) throw new Error(parsed.diagnostics.map(d => d.message).join("\n"));
|
||||||
|
const exported = [...walkSyntax(parsed.root)].filter(node => ["packageOperationExport", "packageFunctionExport", "packageConstructorExport"].includes(node.kind));
|
||||||
|
if (exported.length !== 1) throw new Error("--declaration must contain exactly one function, operation or constructor export");
|
||||||
|
const literal = exported[0].children.find(node => node.kind === "stringLiteral");
|
||||||
|
if (!literal) throw new Error("Declaration requires an authored export ID");
|
||||||
|
spec.id = JSON.parse(parsed.source.slice(literal.start, literal.end));
|
||||||
|
flags.splice(declaration, 2);
|
||||||
|
}
|
||||||
|
} else spec = await readSpec(specFile) as ScaffoldRecipe;
|
||||||
|
if (!root || !specFile || flags.some((flag) => !["--write", "--install"].includes(flag)) || (flags.includes("--install") && !flags.includes("--write"))) throw new Error("usage: quixos-qx scaffold-package|function|migration|refresh ROOT SPEC_JSON [--write [--install]]");
|
||||||
|
const plan = command === "scaffold-install" ? undefined : await planStructure(root,
|
||||||
|
await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration", spec), process.env.QUIXOS_SNAPSHOT_MAP);
|
||||||
|
const applied = plan && flags.includes("--write") ? await applyStructure(plan) : undefined;
|
||||||
|
if (flags.includes("--install")) {
|
||||||
|
const cwd = path.resolve(root, spec.directory ?? "");
|
||||||
|
const toolchain = JSON.parse(await readFile(path.join(cwd, "quixos.toolchain.json"), "utf8"));
|
||||||
|
if (toolchain.generatedBy !== "qx-scaffold-v1" || typeof toolchain.nixifyPluginUrl !== "string") throw new Error("Missing scaffold toolchain");
|
||||||
|
for (const [executable, args] of [["corepack", ["yarn", "plugin", "import", toolchain.nixifyPluginUrl]], ["corepack", ["yarn", "config", "set", "generateDefaultNix", "false"]], ["corepack", ["yarn", "config", "set", "individualNixPackaging", "true"]], ["corepack", ["yarn", "install"]]] as const) {
|
||||||
|
const result = spawnSync(executable, [...args], {cwd, stdio: ["inherit", 2, 2]});
|
||||||
|
if (result.error || result.status !== 0) throw new Error(`Scaffold files retained; ${executable} ${args.join(" ")} failed: ${result.error?.message ?? result.status}`);
|
||||||
|
}
|
||||||
|
try {await readFile(path.join(cwd, "yarn-project.nix"));}
|
||||||
|
catch {throw new Error("Nixify did not generate yarn-project.nix. It skips repositories under the OS temporary directory; use an ordinary workspace checkout and retry installation.");}
|
||||||
|
await snapshotCommit(cwd);
|
||||||
|
const locked = spawnSync("nix", ["flake", "lock"], {cwd, stdio: ["inherit", 2, 2]});
|
||||||
|
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`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "scaffold-structure") {
|
||||||
|
const [root, spec, ...flags] = args;
|
||||||
|
if (!root || !spec || flags.some((flag) => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-structure ROOT SPEC_JSON [--write]");
|
||||||
|
const request = await readSpec(spec) as StructuralRequest;
|
||||||
|
if (request.kind !== "workspace" && !request.source) request.source = await authorSource(root);
|
||||||
|
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
|
||||||
|
const applied = flags.includes("--write") ? await applyStructure(plan) : undefined;
|
||||||
|
process.stdout.write(`${JSON.stringify({...planSummary(plan), applied}, null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "scaffold-resume") {
|
||||||
|
const [root, id, ...extra] = args;
|
||||||
|
if (!root || !id || extra.length) throw new Error("usage: quixos-qx scaffold-resume ROOT JOURNAL_ID");
|
||||||
|
process.stdout.write(`${JSON.stringify(await resumeStructure(root, id), null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (["check", "check-resource"].includes(command)) throw new Error("Use qx-workspace check in the registered repository; handwritten source/snapshot-map candidates are no longer an authoring check path");
|
||||||
|
if (command === "evolution") {
|
||||||
|
const [baseline, candidate, reviews, ...extra] = args;
|
||||||
|
if (!baseline || !candidate || extra.length) throw new Error("usage: quixos-qx evolution BASELINE_JSON CANDIDATE_JSON [REVIEWS_JSON]");
|
||||||
|
const before = baseline === "none" ? null : JSON.parse(await readFile(baseline, "utf8"));
|
||||||
|
const after = JSON.parse(await readFile(candidate, "utf8"));
|
||||||
|
const decisions = reviews ? JSON.parse(await readFile(reviews, "utf8")) : [];
|
||||||
|
if (!Array.isArray(decisions)) throw new Error("Reviews must be an array");
|
||||||
|
process.stdout.write(`${JSON.stringify(planEvolution(before, after, { reviews: decisions }), null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "scaffold-atom") {
|
||||||
|
const [root, name, id, ...flags] = args;
|
||||||
|
if (!root || !name || !id || flags.some((flag) => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-atom ROOT NAME ID [--write]");
|
||||||
|
const resolveResource = await createGitCapabilityResolver({ checkoutRoot: `${root}/.quixos/resource-checkouts` });
|
||||||
|
const plan = await scaffoldAtom({ root, name, id, write: flags.includes("--write"), resolveResource });
|
||||||
|
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [file, flag, ...rest] = args;
|
||||||
|
if (!file || rest.length || (flag && flag !== "--write") || !["parse", "lint", "format"].includes(command ?? "") ||
|
||||||
|
(flag && command !== "format")) throw new Error("usage: quixos-qx parse|lint|format FILE [--write (format only)]");
|
||||||
|
const source = await readFile(file, "utf8");
|
||||||
|
if (command === "format") {
|
||||||
|
const formatted = formatQx(source);
|
||||||
|
if (flag) await writeFile(file, formatted); else process.stdout.write(formatted);
|
||||||
|
} else {
|
||||||
|
const result = command === "parse" ? parseQx(source, file) : lintQx(source, file);
|
||||||
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
if (Array.isArray(result) ? result.some((entry) => entry.severity === "error") : result.diagnostics.length) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import process from "node:process";
|
||||||
|
import { compileWorkspaceRepository } from "./assembly.js";
|
||||||
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
|
import { planEvolution, runtimeContracts, type EvolutionReview, type WorkspaceRevision } from "../capability-model/index.js";
|
||||||
|
|
||||||
|
const usage = `usage: quixos-workspace-compile --root DIRECTORY --checkout-root DIRECTORY
|
||||||
|
[--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID]
|
||||||
|
[--source-root-commit GIT_REV] [--baseline PLAN_JSON] [--evolution-out PATH]
|
||||||
|
[--reviews REVIEW_JSON]
|
||||||
|
|
||||||
|
Resolves a workspace's recursive resource-lock graph, clones every exact
|
||||||
|
resource revision, validates standalone interface/package manifests, and emits
|
||||||
|
the checked workspace plan as JSON.`;
|
||||||
|
|
||||||
|
const parseArgs = (args: string[]) => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
for (let index = 0; index < args.length; index += 2) {
|
||||||
|
const key = args[index];
|
||||||
|
const value = args[index + 1];
|
||||||
|
if (!key?.startsWith("--") || !value) throw new Error(usage);
|
||||||
|
values.set(key, value);
|
||||||
|
}
|
||||||
|
const rootDirectory = values.get("--root");
|
||||||
|
const checkoutRoot = values.get("--checkout-root");
|
||||||
|
if (!rootDirectory || !checkoutRoot) throw new Error(usage);
|
||||||
|
return {
|
||||||
|
rootDirectory,
|
||||||
|
checkoutRoot,
|
||||||
|
graphOut: values.get("--graph-out"),
|
||||||
|
snapshotMap: values.get("--snapshot-map"),
|
||||||
|
workspaceId: values.get("--workspace-id"),
|
||||||
|
workspaceRevisionId: values.get("--workspace-revision-id"),
|
||||||
|
sourceRootCommit: values.get("--source-root-commit"),
|
||||||
|
baseline: values.get("--baseline"),
|
||||||
|
evolutionOut: values.get("--evolution-out"),
|
||||||
|
reviews: values.get("--reviews"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
||||||
|
process.stdout.write(`${usage}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const resolveResource = await createGitCapabilityResolver({
|
||||||
|
checkoutRoot: options.checkoutRoot,
|
||||||
|
snapshotMap: options.snapshotMap,
|
||||||
|
});
|
||||||
|
const assembled = await compileWorkspaceRepository({
|
||||||
|
rootDirectory: options.rootDirectory,
|
||||||
|
workspaceId: options.workspaceId,
|
||||||
|
workspaceRevisionId: options.workspaceRevisionId,
|
||||||
|
sourceRootCommit: options.sourceRootCommit,
|
||||||
|
resolveResource,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.graphOut) {
|
||||||
|
await writeFile(options.graphOut, `${JSON.stringify({
|
||||||
|
formatVersion: 1,
|
||||||
|
quixos: assembled.lock.quixos,
|
||||||
|
directResources: [...assembled.directResources.entries()].map(([bindingKey, node]) => {
|
||||||
|
const [kind, binding] = bindingKey.split("\0");
|
||||||
|
return { kind, binding, resourceKey: node.key, directory: node.directory };
|
||||||
|
}),
|
||||||
|
resources: assembled.resources.map((node) => ({
|
||||||
|
key: node.key,
|
||||||
|
kind: node.kind,
|
||||||
|
source: node.source,
|
||||||
|
directory: node.directory,
|
||||||
|
resourceId: node.resource.kind === "interface"
|
||||||
|
? node.resource.revision.interfaceId
|
||||||
|
: node.resource.revision.packageId,
|
||||||
|
revisionId: node.resource.revision.revisionId,
|
||||||
|
dependencies: [...node.dependencies.entries()].map(([binding, dependency]) => ({
|
||||||
|
binding,
|
||||||
|
resourceKey: dependency.key,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
const candidate = { ...assembled.workspace, executionContracts: runtimeContracts(assembled.workspace) };
|
||||||
|
if (options.evolutionOut) {
|
||||||
|
const baseline = options.baseline ? JSON.parse(await readFile(options.baseline, "utf8")) as WorkspaceRevision : null;
|
||||||
|
const reviews = options.reviews ? JSON.parse(await readFile(options.reviews, "utf8")) as EvolutionReview[] : [];
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
process.stdout.write(`${JSON.stringify(candidate, null, 2)}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { Binding, Conformance, DependencyBinding, PersistentAttachment, WorkspaceRevision } from "./types.js";
|
||||||
|
import { validateWorkspaceRevision } from "./validation.js";
|
||||||
|
|
||||||
|
/** Content hashing is independent of JSON object insertion order, not array order. */
|
||||||
|
const compareText = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
|
||||||
|
export const canonicalJson = (value: unknown): string => {
|
||||||
|
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
|
||||||
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error("Expected a plain JSON object");
|
||||||
|
return `{${Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => compareText(a, b))
|
||||||
|
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
|
||||||
|
}
|
||||||
|
throw new Error(`Cannot hash non-JSON value: ${typeof value}`);
|
||||||
|
};
|
||||||
|
export const contentDigest = (value: unknown) => `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
||||||
|
const semantic = (value: unknown): unknown => {
|
||||||
|
if (Array.isArray(value)) return value.map(semantic);
|
||||||
|
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value)
|
||||||
|
.filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation")
|
||||||
|
// These are authored data/maps, not schema nodes. A user field literally
|
||||||
|
// named displayName or documentation is semantic and must stay in the hash.
|
||||||
|
.map(([key, entry]) => [key, key === "defaultValue" || key === "fields" ? entry : semantic(entry)]));
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const sorted = <T>(entries: readonly T[], key: (entry: T) => string) => [...entries].sort((a, b) => compareText(key(a), key(b)));
|
||||||
|
export const conformanceIdentity = (entry: Conformance): string => entry.id ?? `legacy:${entry.atomId}:${entry.interfaceRevisionId}`;
|
||||||
|
|
||||||
|
export type StorageContract = { id: string; ownerId: string; kind: "state" | "edge"; digest: string; definition: unknown };
|
||||||
|
/** Automatic evolution preserves values; it never interprets migration code or
|
||||||
|
* guesses that a new nominal message descriptor means the same representation. */
|
||||||
|
export const storageChangeRequiresMigration = (previous: StorageContract | undefined, next: StorageContract | undefined,
|
||||||
|
oldAtomIds: ReadonlySet<string>): boolean => {
|
||||||
|
if (!next) return true;
|
||||||
|
const after = next.definition as PersistentAttachment;
|
||||||
|
if (!previous) {
|
||||||
|
if (after.kind === "state") return oldAtomIds.has(after.attachedTo) && after.defaultValue === undefined && after.valueType.kind !== "optional";
|
||||||
|
return after.endpoints.some(endpoint => endpoint.cardinality === "exactly-one" &&
|
||||||
|
(endpoint.constraint.kind !== "atom" || oldAtomIds.has(endpoint.constraint.atomId)));
|
||||||
|
}
|
||||||
|
if (previous.ownerId !== next.ownerId || previous.kind !== next.kind) return true;
|
||||||
|
const before = previous.definition as PersistentAttachment;
|
||||||
|
if (before.kind === "state" && after.kind === "state") {
|
||||||
|
// Capture materializes old defaults, so changing a default affects only
|
||||||
|
// newly constructed objects, not existing sparse state.
|
||||||
|
const {defaultValue: _beforeDefault, ...beforeStorage} = before;
|
||||||
|
const {defaultValue: _afterDefault, ...afterStorage} = after;
|
||||||
|
return canonicalJson(beforeStorage) !== canonicalJson(afterStorage);
|
||||||
|
}
|
||||||
|
return canonicalJson(before) !== canonicalJson(after);
|
||||||
|
};
|
||||||
|
export const storageContracts = (workspace: WorkspaceRevision): StorageContract[] => {
|
||||||
|
const result: StorageContract[] = [];
|
||||||
|
const add = (attachment: PersistentAttachment, ownerId: string) => {
|
||||||
|
const definition = semantic(attachment);
|
||||||
|
result.push({ id: attachment.id, ownerId, kind: attachment.kind, definition, digest: contentDigest({ ownerId, definition }) });
|
||||||
|
};
|
||||||
|
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));
|
||||||
|
return sorted(result, (entry) => entry.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 }> };
|
||||||
|
|
||||||
|
/** Build only outbound execution dependencies. Incoming callers never retain or invalidate a provider. */
|
||||||
|
export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[] => {
|
||||||
|
const nodes = new Map<string, GraphNode>();
|
||||||
|
const node = (key: string, value: unknown) => {
|
||||||
|
const result = { value: semantic(value), dependencies: new Set<string>(), reviewProviders: new Set<string>() };
|
||||||
|
nodes.set(key, result);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const conformanceKey = (atom: string, iface: string) => `conformance:${atom}:${iface}`;
|
||||||
|
for (const storage of storageContracts(workspace)) node(`attachment:${storage.id}`, storage);
|
||||||
|
for (const iface of workspace.interfaceImports) node(`interface:${iface.revisionId}`, {
|
||||||
|
...iface, members: sorted(iface.members, (entry) => entry.id).map((entry) => ({ ...entry, operations: sorted(entry.operations, (operation) => operation.id) })),
|
||||||
|
});
|
||||||
|
for (const pkg of workspace.packageImports) node(`package:${pkg.revisionId}`, {
|
||||||
|
...pkg, semanticMajor: pkg.semanticMajor ?? 1,
|
||||||
|
exports: sorted(pkg.exports, (entry) => entry.id).map((entry) => ({ ...entry, dependencyPorts: sorted(entry.dependencyPorts, (port) => port.id) })),
|
||||||
|
});
|
||||||
|
const dependency = (parent: GraphNode, binding: DependencyBinding, atomId: string, reviews?: Set<string>) => {
|
||||||
|
if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`);
|
||||||
|
if (binding.kind === "edge") parent.dependencies.add(`attachment:${binding.edgeTypeId}`);
|
||||||
|
if (binding.kind === "constructor") {
|
||||||
|
parent.dependencies.add(`constructor:${binding.atomId}`);
|
||||||
|
const ctor = workspace.constructors.find((entry) => entry.atomId === binding.atomId);
|
||||||
|
const pkg = workspace.packageImports.find((entry) => entry.revisionId === ctor?.packageRevisionId);
|
||||||
|
if (pkg) reviews?.add(pkg.packageId);
|
||||||
|
}
|
||||||
|
if (binding.kind !== "constructor" && binding.via) parent.dependencies.add(`attachment:${binding.via.edgeTypeId}`);
|
||||||
|
if (binding.kind === "interface") {
|
||||||
|
parent.dependencies.add(`interface:${binding.interfaceRevisionId}`);
|
||||||
|
// An edge traversal may select any matching target. Conservatively include every possible witness.
|
||||||
|
for (const conformance of workspace.conformances) {
|
||||||
|
if (conformance.interfaceRevisionId === binding.interfaceRevisionId && (binding.via || conformance.atomId === atomId)) {
|
||||||
|
parent.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
|
||||||
|
reviews?.add(conformanceIdentity(conformance));
|
||||||
|
for (const operation of conformance.operationBindings) {
|
||||||
|
if (operation.binding.kind !== "package") continue;
|
||||||
|
const revisionId = operation.binding.packageRevisionId;
|
||||||
|
const pkg = workspace.packageImports.find((entry) => entry.revisionId === revisionId);
|
||||||
|
if (pkg) reviews?.add(pkg.packageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const binding = (parent: GraphNode, value: Binding, atomId: string, context: unknown) => {
|
||||||
|
if (value.kind !== "package") { dependency(parent, value, atomId); return; }
|
||||||
|
const packageNode = nodes.get(`package:${value.packageRevisionId}`)!;
|
||||||
|
parent.dependencies.add(`package:${value.packageRevisionId}`);
|
||||||
|
const normalized = { ...value, dependencies: sorted(value.dependencies, (entry) => entry.portId) };
|
||||||
|
const key = `binding:${contentDigest({ atomId, context, value: semantic(normalized) })}`;
|
||||||
|
const bound = node(key, { atomId, context, binding: normalized });
|
||||||
|
packageNode.dependencies.add(key);
|
||||||
|
for (const port of value.dependencies) dependency(bound, port.binding, atomId, packageNode.reviewProviders);
|
||||||
|
};
|
||||||
|
for (const conformance of workspace.conformances) {
|
||||||
|
const parent = node(conformanceKey(conformance.atomId, conformance.interfaceRevisionId), {
|
||||||
|
id: conformanceIdentity(conformance), semanticMajor: conformance.semanticMajor ?? 1,
|
||||||
|
atomId: conformance.atomId, interfaceRevisionId: conformance.interfaceRevisionId,
|
||||||
|
operations: sorted(conformance.operationBindings, (entry) => entry.operationId),
|
||||||
|
materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId),
|
||||||
|
});
|
||||||
|
parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`);
|
||||||
|
for (const attachment of conformance.privateAttachments) parent.dependencies.add(`attachment:${attachment.id}`);
|
||||||
|
for (const operation of conformance.operationBindings) binding(parent, operation.binding, conformance.atomId, {
|
||||||
|
conformanceId: conformanceIdentity(conformance), semanticMajor: conformance.semanticMajor ?? 1, operationId: operation.operationId,
|
||||||
|
});
|
||||||
|
for (const materialization of conformance.relationshipMaterializations) {
|
||||||
|
parent.dependencies.add(`constructor:${materialization.constructorAtomId}`);
|
||||||
|
parent.dependencies.add(`attachment:${materialization.edgeTypeId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const constructor of workspace.constructors) {
|
||||||
|
const parent = node(`constructor:${constructor.atomId}`, constructor);
|
||||||
|
binding(parent, { kind: "package", ...constructor }, constructor.atomId, { constructor: constructor.atomId });
|
||||||
|
}
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const pkg of workspace.packageImports) counts.set(pkg.packageId, (counts.get(pkg.packageId) ?? 0) + 1);
|
||||||
|
return sorted(workspace.packageImports.map((pkg): RuntimeContract => {
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const walk = (key: string) => {
|
||||||
|
if (visited.has(key)) return;
|
||||||
|
const entry = nodes.get(key);
|
||||||
|
if (!entry) throw new Error(`Unresolved execution dependency ${key}`);
|
||||||
|
visited.add(key);
|
||||||
|
for (const target of entry.dependencies) walk(target);
|
||||||
|
};
|
||||||
|
walk(`package:${pkg.revisionId}`);
|
||||||
|
const dependencies = [...visited].sort().map((id) => ({ id, digest: contentDigest(nodes.get(id)!.value) }));
|
||||||
|
return { groupId: counts.get(pkg.packageId) === 1 ? pkg.packageId : `${pkg.packageId}#${pkg.revisionId}`,
|
||||||
|
packageId: pkg.packageId, packageRevisionId: pkg.revisionId, digest: contentDigest(dependencies),
|
||||||
|
reviewProviders: [...nodes.get(`package:${pkg.revisionId}`)!.reviewProviders].sort(), dependencies };
|
||||||
|
}), (entry) => entry.groupId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EvolutionReview = { requirementDigest: string; decision: "changed" | "accepted-unchanged"; rationale: string; agentId: string };
|
||||||
|
export type ReviewRequirement = { consumerId: string; providerId: string; oldMajor: number; newMajor: number; requirementDigest: string };
|
||||||
|
export type RuntimeAction = { groupId: string; action: "keep" | "start" | "replace" | "retire"; previous?: RuntimeContract; candidate?: RuntimeContract; reasons: string[] };
|
||||||
|
export type EvolutionReport = {
|
||||||
|
schemaVersion: 1; baselineDigest: string | null; candidateDigest: string; checkerVersion: string;
|
||||||
|
runtimeActions: RuntimeAction[];
|
||||||
|
storageChanges: Array<{ id: string; kind: "add" | "remove" | "change"; requiresMigration: boolean; previous?: StorageContract; candidate?: StorageContract }>;
|
||||||
|
migrationRequired: string[];
|
||||||
|
reviews: Array<ReviewRequirement & { accepted: boolean }>;
|
||||||
|
packageChecks: Array<{ groupId: string; contractDigest: string }>;
|
||||||
|
blockers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const planEvolution = (baseline: WorkspaceRevision | null, candidate: WorkspaceRevision,
|
||||||
|
options: { reviews?: EvolutionReview[]; allowLegacy?: boolean } = {}): EvolutionReport => {
|
||||||
|
const issues = validateWorkspaceRevision(candidate);
|
||||||
|
if (issues.length) throw new Error(`Invalid candidate workspace:\n${issues.map((entry) => `${entry.path}: ${entry.message}`).join("\n")}`);
|
||||||
|
if (baseline && baseline.workspaceId !== candidate.workspaceId) throw new Error("Cannot evolve a different workspace");
|
||||||
|
const candidateDigest = contentDigest(candidate);
|
||||||
|
const checkerVersion = "quixos-evolution-v1";
|
||||||
|
const blockers: string[] = [];
|
||||||
|
if (!options.allowLegacy) {
|
||||||
|
if (candidate.sharedAttachments.length) blockers.push("Assign legacy workspace-shared attachments to explicit conformance owners");
|
||||||
|
for (const entry of candidate.conformances) if (!entry.id) blockers.push(`Conformance ${entry.atomId} as ${entry.interfaceRevisionId} requires an authored ID`);
|
||||||
|
}
|
||||||
|
const previousRuntimes = new Map((baseline ? runtimeContracts(baseline) : []).map((entry) => [entry.groupId, entry]));
|
||||||
|
const nextRuntimes = new Map(runtimeContracts(candidate).map((entry) => [entry.groupId, entry]));
|
||||||
|
const runtimeActions: RuntimeAction[] = [...new Set([...previousRuntimes.keys(), ...nextRuntimes.keys()])].sort().map((groupId) => {
|
||||||
|
const previous = previousRuntimes.get(groupId), next = nextRuntimes.get(groupId);
|
||||||
|
const before = new Map(previous?.dependencies.map((entry) => [entry.id, entry.digest]));
|
||||||
|
const after = new Map(next?.dependencies.map((entry) => [entry.id, entry.digest]));
|
||||||
|
const reasons = [...new Set([...before.keys(), ...after.keys()])].sort().filter((id) => before.get(id) !== after.get(id));
|
||||||
|
return { groupId, action: !previous ? "start" : !next ? "retire" : previous.digest === next.digest ? "keep" : "replace",
|
||||||
|
...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}), reasons };
|
||||||
|
});
|
||||||
|
const beforeStorage = new Map((baseline ? storageContracts(baseline) : []).map((entry) => [entry.id, entry]));
|
||||||
|
const afterStorage = new Map(storageContracts(candidate).map((entry) => [entry.id, entry]));
|
||||||
|
const storageChanges: EvolutionReport["storageChanges"] = [];
|
||||||
|
for (const id of [...new Set([...beforeStorage.keys(), ...afterStorage.keys()])].sort()) {
|
||||||
|
const previous = beforeStorage.get(id), next = afterStorage.get(id);
|
||||||
|
if (previous?.digest !== next?.digest) storageChanges.push({ id, kind: !previous ? "add" : !next ? "remove" : "change",
|
||||||
|
requiresMigration: storageChangeRequiresMigration(previous, next, new Set(baseline?.atoms.map(atom => atom.id) ?? [])),
|
||||||
|
...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}) });
|
||||||
|
}
|
||||||
|
const providers = (workspace: WorkspaceRevision) => [
|
||||||
|
...workspace.packageImports.map((entry) => ({ id: entry.packageId as string, revision: entry.revisionId as string, major: entry.semanticMajor ?? 1,
|
||||||
|
node: `package:${entry.revisionId}`, digest: contentDigest(entry) })),
|
||||||
|
...workspace.conformances.map((entry) => ({ id: conformanceIdentity(entry), revision: contentDigest(entry), major: entry.semanticMajor ?? 1,
|
||||||
|
node: `conformance:${entry.atomId}:${entry.interfaceRevisionId}`, digest: contentDigest(entry) })),
|
||||||
|
];
|
||||||
|
const oldProviders = baseline ? providers(baseline) : [];
|
||||||
|
const reviews: EvolutionReport["reviews"] = [];
|
||||||
|
for (const provider of providers(candidate)) {
|
||||||
|
const old = oldProviders.filter((entry) => entry.id === provider.id);
|
||||||
|
if (old.length > 1) { blockers.push(`Ambiguous semantic-major lineage for ${provider.id}`); continue; }
|
||||||
|
if (!old[0] || old[0].major === provider.major) continue;
|
||||||
|
if (provider.major < old[0].major) blockers.push(`Semantic major decreases for ${provider.id}`);
|
||||||
|
for (const consumer of nextRuntimes.values()) {
|
||||||
|
if (consumer.packageId === provider.id || !consumer.reviewProviders.includes(provider.id)) continue;
|
||||||
|
const requirement = { consumerId: consumer.groupId, providerId: provider.id, oldMajor: old[0].major, newMajor: provider.major };
|
||||||
|
const requirementDigest = contentDigest({ ...requirement, oldProvider: old[0].digest, newProvider: provider.digest, consumer: consumer.digest, checkerVersion });
|
||||||
|
const accepted = (options.reviews ?? []).some((entry) => entry.requirementDigest === requirementDigest &&
|
||||||
|
["changed", "accepted-unchanged"].includes(entry.decision) && entry.rationale.trim() && entry.agentId.trim());
|
||||||
|
reviews.push({ ...requirement, requirementDigest, accepted });
|
||||||
|
if (!accepted) blockers.push(`Semantic-major review required: ${consumer.groupId} consumes ${provider.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { schemaVersion: 1, baselineDigest: baseline ? contentDigest(baseline) : null, candidateDigest, checkerVersion,
|
||||||
|
runtimeActions, storageChanges, migrationRequired: storageChanges.filter(entry => entry.requiresMigration).map(entry => entry.id), reviews, packageChecks: runtimeActions.filter((entry) => entry.candidate && entry.action !== "keep")
|
||||||
|
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })), blockers };
|
||||||
|
};
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export * from "./types.js";
|
export * from "./types.js";
|
||||||
export * from "./validation.js";
|
export * from "./validation.js";
|
||||||
|
export * from "./evolution.js";
|
||||||
|
export * from "./migrations.js";
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { contentDigest } from "./evolution.js";
|
||||||
|
import type { PersistentAttachment } from "./types.js";
|
||||||
|
|
||||||
|
/** Portable storage shape: local owner/slot/projection identities are supplied
|
||||||
|
* by the consuming workspace's explicit bindings, never baked into this hash. */
|
||||||
|
export const migrationPortContract = (attachment: PersistentAttachment): unknown => {
|
||||||
|
if (attachment.kind === "state") return {kind: "state", valueType: attachment.valueType, storagePolicy: attachment.storagePolicy,
|
||||||
|
...(attachment.defaultValue === undefined ? {} : {defaultValue: attachment.defaultValue})};
|
||||||
|
const endpoint = (value: typeof attachment.endpoints[number]) => ({constraint: value.constraint, cardinality: value.cardinality,
|
||||||
|
ordered: value.ordered, onDelete: value.onDelete ?? "restrict", retainOther: value.retainOther ?? false,
|
||||||
|
...(value.keyType ? {keyType: value.keyType} : {}), ...(value.publicTraversal ? {publicTraversal: true} : {})});
|
||||||
|
return {kind: "edge", first: endpoint(attachment.endpoints[0]), second: endpoint(attachment.endpoints[1])};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MigrationDeclaration = {
|
||||||
|
id: string;
|
||||||
|
scopeId: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
implementation: { exportId: string; file: string; digest: string };
|
||||||
|
predecessors: string[];
|
||||||
|
ports: { name: string; view: "old" | "new"; access: ("read" | "write" | "create" | "edge")[]; contractDigest: string }[];
|
||||||
|
preservesOldReaders?: boolean;
|
||||||
|
preservesOldWriters?: boolean;
|
||||||
|
};
|
||||||
|
export type MigrationCatalog = {
|
||||||
|
schemaVersion: 1;
|
||||||
|
contracts: Record<string, unknown>;
|
||||||
|
migrations: MigrationDeclaration[];
|
||||||
|
};
|
||||||
|
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");
|
||||||
|
const catalog = value as MigrationCatalog;
|
||||||
|
if (catalog.schemaVersion !== 1 || !catalog.contracts || Array.isArray(catalog.contracts) || !Array.isArray(catalog.migrations)) throw new Error("Unsupported migration catalog");
|
||||||
|
for (const [digest, contract] of Object.entries(catalog.contracts)) if (contentDigest(contract) !== digest) throw new Error(`Migration contract digest mismatch: ${digest}`);
|
||||||
|
const ids = new Set<string>();
|
||||||
|
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");
|
||||||
|
ids.add(migration.id);
|
||||||
|
if (!catalog.contracts[migration.from] || !catalog.contracts[migration.to] || migration.from === migration.to) throw new Error(`Migration ${migration.id} requires distinct retained source and target contracts`);
|
||||||
|
if (!migration.implementation?.exportId || !/^sha256:[0-9a-f]{64}$/.test(migration.implementation.digest)) throw new Error(`Migration ${migration.id} requires an exact implementation digest`);
|
||||||
|
if (!migration.implementation.file || migration.implementation.file.startsWith("/") || migration.implementation.file.split(/[\\/]/).some((part) => !part || part === "." || part === "..")) throw new Error("Migration implementation must be a relative package file");
|
||||||
|
if (exportIds && !exportIds.has(migration.implementation.exportId)) throw new Error(`Migration ${migration.id} refers to an undeclared package export`);
|
||||||
|
if (!Array.isArray(migration.predecessors) || !Array.isArray(migration.ports)) throw new Error(`Migration ${migration.id} requires predecessors and ports`);
|
||||||
|
if (new Set(migration.predecessors).size !== migration.predecessors.length) throw new Error(`Duplicate predecessor in ${migration.id}`);
|
||||||
|
for (const promise of [migration.preservesOldReaders, migration.preservesOldWriters]) if (promise !== undefined && typeof promise !== "boolean") throw new Error("Migration compatibility promises must be booleans");
|
||||||
|
const ports = new Set<string>();
|
||||||
|
for (const port of migration.ports) {
|
||||||
|
if (!port.name || ports.has(port.name) || !["old", "new"].includes(port.view) || !Array.isArray(port.access) || !port.access.length
|
||||||
|
|| port.access.some((access) => !["read", "write", "create", "edge"].includes(access)) || !catalog.contracts[port.contractDigest]) throw new Error(`Invalid migration port in ${migration.id}`);
|
||||||
|
if (port.view === "old" && port.access.some((access) => access !== "read")) throw new Error("Old migration views are read-only");
|
||||||
|
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}`);
|
||||||
|
// Catalogs are retained across releases. Reject impossible histories at
|
||||||
|
// publication/check time, not only when someone tries to select a path.
|
||||||
|
const remaining = new Map(catalog.migrations.map((entry) => [entry.id, new Set(entry.predecessors)]));
|
||||||
|
const ready = [...remaining].filter(([, dependencies]) => dependencies.size === 0).map(([id]) => id);
|
||||||
|
for (let index = 0; index < ready.length; index++) {
|
||||||
|
remaining.delete(ready[index]);
|
||||||
|
for (const [id, dependencies] of remaining) if (dependencies.delete(ready[index]) && dependencies.size === 0) ready.push(id);
|
||||||
|
}
|
||||||
|
if (remaining.size) throw new Error(`Cyclic migration predecessors: ${[...remaining.keys()].join(", ")}`);
|
||||||
|
return catalog;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MigrationSelection = {
|
||||||
|
scopeId: string; from: string; to: string; path: string[];
|
||||||
|
bindings: Record<string, string>;
|
||||||
|
};
|
||||||
|
/** 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()) => {
|
||||||
|
validateMigrationCatalog(catalog);
|
||||||
|
let current = selection.from;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const transitions = [];
|
||||||
|
for (const id of selection.path) {
|
||||||
|
const declaration = catalog.migrations.find((entry) => entry.id === id);
|
||||||
|
if (!declaration || declaration.scopeId !== selection.scopeId || declaration.from !== current || seen.has(id)) throw new Error(`Invalid selected migration transition ${id}`);
|
||||||
|
for (const predecessor of declaration.predecessors) if (!seen.has(predecessor) && !previousReceipts.has(predecessor)) throw new Error(`Unsatisfied predecessor ${predecessor}`);
|
||||||
|
const usedBindings: Record<string, string> = {};
|
||||||
|
for (const port of declaration.ports) {
|
||||||
|
if (!selection.bindings[port.name]) throw new Error(`Missing local migration binding ${port.name}`);
|
||||||
|
usedBindings[port.name] = selection.bindings[port.name];
|
||||||
|
}
|
||||||
|
const digest = contentDigest({ declaration, bindings: usedBindings });
|
||||||
|
const previous = previousReceipts.get(id);
|
||||||
|
if (previous && previous !== digest) throw new Error(`Migration identity ${id} was previously used with different code or scope`);
|
||||||
|
transitions.push({ declaration, bindings: usedBindings, digest, alreadyApplied: Boolean(previous) });
|
||||||
|
seen.add(id);
|
||||||
|
current = declaration.to;
|
||||||
|
}
|
||||||
|
if (current !== selection.to) throw new Error("Selected migration path does not cover the target storage contract");
|
||||||
|
return transitions;
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ type OpaqueId<Kind extends string> = string & {
|
|||||||
export type WorkspaceId = OpaqueId<"WorkspaceId">;
|
export type WorkspaceId = OpaqueId<"WorkspaceId">;
|
||||||
export type WorkspaceRevisionId = OpaqueId<"WorkspaceRevisionId">;
|
export type WorkspaceRevisionId = OpaqueId<"WorkspaceRevisionId">;
|
||||||
export type AtomId = OpaqueId<"AtomId">;
|
export type AtomId = OpaqueId<"AtomId">;
|
||||||
|
export type ConformanceId = OpaqueId<"ConformanceId">;
|
||||||
export type InterfaceId = OpaqueId<"InterfaceId">;
|
export type InterfaceId = OpaqueId<"InterfaceId">;
|
||||||
export type InterfaceRevisionId = OpaqueId<"InterfaceRevisionId">;
|
export type InterfaceRevisionId = OpaqueId<"InterfaceRevisionId">;
|
||||||
export type MemberId = OpaqueId<"MemberId">;
|
export type MemberId = OpaqueId<"MemberId">;
|
||||||
@@ -31,6 +32,7 @@ export const capabilityId = {
|
|||||||
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),
|
||||||
interface: (value: string) => opaque<"InterfaceId">(value),
|
interface: (value: string) => opaque<"InterfaceId">(value),
|
||||||
interfaceRevision: (value: string) =>
|
interfaceRevision: (value: string) =>
|
||||||
opaque<"InterfaceRevisionId">(value),
|
opaque<"InterfaceRevisionId">(value),
|
||||||
@@ -72,6 +74,7 @@ export type ValueType =
|
|||||||
| { kind: "builtin"; name: "unit" | "watch-handle" }
|
| { kind: "builtin"; name: "unit" | "watch-handle" }
|
||||||
| { kind: "scalar"; name: ScalarValueTypeName }
|
| { kind: "scalar"; name: ScalarValueTypeName }
|
||||||
| { kind: "message"; descriptorId: string }
|
| { kind: "message"; descriptorId: string }
|
||||||
|
| { kind: "record"; fields: Record<string, ValueType> }
|
||||||
| { kind: "object-ref"; expectation: ObjectExpectation }
|
| { kind: "object-ref"; expectation: ObjectExpectation }
|
||||||
| { kind: "optional"; value: ValueType }
|
| { kind: "optional"; value: ValueType }
|
||||||
| { kind: "list"; value: ValueType };
|
| { kind: "list"; value: ValueType };
|
||||||
@@ -192,11 +195,15 @@ export interface StateSlotDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface EdgeEndpoint {
|
export interface EdgeEndpoint {
|
||||||
|
keyType?: "string" | "boolean" | "int64";
|
||||||
|
publicTraversal?: boolean;
|
||||||
projectionId: EdgeProjectionId;
|
projectionId: EdgeProjectionId;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
constraint: EdgeEndpointConstraint;
|
constraint: EdgeEndpointConstraint;
|
||||||
cardinality: EdgeCardinality;
|
cardinality: EdgeCardinality;
|
||||||
ordered: boolean;
|
ordered: boolean;
|
||||||
|
onDelete?: "restrict" | "detach" | "cascade-other";
|
||||||
|
retainOther?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EdgeDefinition {
|
export interface EdgeDefinition {
|
||||||
@@ -242,10 +249,10 @@ export type DependencyPortRequirement =
|
|||||||
primitives: EdgePrimitive[];
|
primitives: EdgePrimitive[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "receiver-interface";
|
kind: "interface";
|
||||||
interfaceRevisionId: InterfaceRevisionId;
|
interfaceRevisionId: InterfaceRevisionId;
|
||||||
}
|
}
|
||||||
| { kind: "constructor"; atomId: AtomId };
|
| { kind: "constructor"; atomId: AtomId; inputType?: ValueType };
|
||||||
|
|
||||||
export interface DependencyPort {
|
export interface DependencyPort {
|
||||||
id: DependencyPortId;
|
id: DependencyPortId;
|
||||||
@@ -283,23 +290,28 @@ export type PackageExport =
|
|||||||
| PackageConstructorExport;
|
| PackageConstructorExport;
|
||||||
|
|
||||||
export interface PackageRevision {
|
export interface PackageRevision {
|
||||||
|
migrationCatalog?: import("./migrations.js").MigrationCatalog;
|
||||||
packageId: PackageId;
|
packageId: PackageId;
|
||||||
revisionId: PackageRevisionId;
|
revisionId: PackageRevisionId;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
source: SourceRevision;
|
source: SourceRevision;
|
||||||
exports: PackageExport[];
|
exports: PackageExport[];
|
||||||
|
/** Author-declared implementation semantics; omitted legacy values mean 1. */
|
||||||
|
semanticMajor?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DependencyBinding =
|
export type DependencyBinding =
|
||||||
| { kind: "state"; slotId: SlotId }
|
| { kind: "state"; slotId: SlotId; via?: EdgeTraversal }
|
||||||
| {
|
| {
|
||||||
kind: "edge";
|
kind: "edge";
|
||||||
edgeTypeId: EdgeTypeId;
|
edgeTypeId: EdgeTypeId;
|
||||||
projectionId: EdgeProjectionId;
|
projectionId: EdgeProjectionId;
|
||||||
|
via?: EdgeTraversal;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "receiver-interface";
|
kind: "interface";
|
||||||
interfaceRevisionId: InterfaceRevisionId;
|
interfaceRevisionId: InterfaceRevisionId;
|
||||||
|
via?: EdgeTraversal;
|
||||||
}
|
}
|
||||||
| { kind: "constructor"; atomId: AtomId };
|
| { kind: "constructor"; atomId: AtomId };
|
||||||
|
|
||||||
@@ -308,6 +320,24 @@ export interface BoundDependency {
|
|||||||
binding: DependencyBinding;
|
binding: DependencyBinding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Selects the object at the other end of an exactly-one relationship. */
|
||||||
|
export interface EdgeTraversal {
|
||||||
|
edgeTypeId: EdgeTypeId;
|
||||||
|
projectionId: EdgeProjectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provisional get-or-create recipe for a relationship implementation. The
|
||||||
|
* host projection is supplied by the relationship's resolve binding; this
|
||||||
|
* projection originates at the object constructed for the relationship.
|
||||||
|
*/
|
||||||
|
export interface RelationshipMaterialization {
|
||||||
|
memberId: MemberId;
|
||||||
|
constructorAtomId: AtomId;
|
||||||
|
edgeTypeId: EdgeTypeId;
|
||||||
|
constructedProjectionId: EdgeProjectionId;
|
||||||
|
}
|
||||||
|
|
||||||
export type Binding =
|
export type Binding =
|
||||||
| {
|
| {
|
||||||
kind: "state";
|
kind: "state";
|
||||||
@@ -333,10 +363,14 @@ export interface OperationBinding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Conformance {
|
export interface Conformance {
|
||||||
|
/** Absent only for legacy assemblies awaiting explicit ownership enrollment. */
|
||||||
|
id?: ConformanceId;
|
||||||
|
semanticMajor?: number;
|
||||||
atomId: AtomId;
|
atomId: AtomId;
|
||||||
interfaceRevisionId: InterfaceRevisionId;
|
interfaceRevisionId: InterfaceRevisionId;
|
||||||
privateAttachments: PersistentAttachment[];
|
privateAttachments: PersistentAttachment[];
|
||||||
operationBindings: OperationBinding[];
|
operationBindings: OperationBinding[];
|
||||||
|
relationshipMaterializations: RelationshipMaterialization[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AtomConstructorBinding {
|
export interface AtomConstructorBinding {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import type {
|
|||||||
import { valueType } from "./types.js";
|
import { valueType } from "./types.js";
|
||||||
|
|
||||||
export type CapabilityValidationIssueCode =
|
export type CapabilityValidationIssueCode =
|
||||||
|
| "invalid-semantic-major"
|
||||||
|
| "duplicate-conformance-id"
|
||||||
| "required-value"
|
| "required-value"
|
||||||
| "invalid-source"
|
| "invalid-source"
|
||||||
| "invalid-value-type"
|
| "invalid-value-type"
|
||||||
@@ -51,6 +53,7 @@ export type CapabilityValidationIssueCode =
|
|||||||
| "duplicate-dependency-port"
|
| "duplicate-dependency-port"
|
||||||
| "duplicate-conformance"
|
| "duplicate-conformance"
|
||||||
| "duplicate-operation-binding"
|
| "duplicate-operation-binding"
|
||||||
|
| "duplicate-relationship-materialization"
|
||||||
| "duplicate-constructor"
|
| "duplicate-constructor"
|
||||||
| "unresolved-reference"
|
| "unresolved-reference"
|
||||||
| "invalid-attachment"
|
| "invalid-attachment"
|
||||||
@@ -60,11 +63,12 @@ export type CapabilityValidationIssueCode =
|
|||||||
| "invalid-dependency-binding"
|
| "invalid-dependency-binding"
|
||||||
| "private-attachment-access"
|
| "private-attachment-access"
|
||||||
| "incompatible-package-receiver"
|
| "incompatible-package-receiver"
|
||||||
| "unsatisfied-receiver-interface"
|
| "unsatisfied-interface"
|
||||||
| "cyclic-conformance-requirement"
|
| "cyclic-conformance-requirement"
|
||||||
| "missing-operation-binding"
|
| "missing-operation-binding"
|
||||||
| "unknown-operation-binding"
|
| "unknown-operation-binding"
|
||||||
| "invalid-constructor";
|
| "invalid-constructor"
|
||||||
|
| "invalid-relationship-materialization";
|
||||||
|
|
||||||
export interface CapabilityValidationIssue {
|
export interface CapabilityValidationIssue {
|
||||||
code: CapabilityValidationIssueCode;
|
code: CapabilityValidationIssueCode;
|
||||||
@@ -140,6 +144,8 @@ const typeLabel = (type: ValueType): string => {
|
|||||||
return type.name;
|
return type.name;
|
||||||
case "message":
|
case "message":
|
||||||
return `message:${type.descriptorId}`;
|
return `message:${type.descriptorId}`;
|
||||||
|
case "record":
|
||||||
|
return `record{${Object.keys(type.fields).sort().map((key) => `${key}:${typeLabel(type.fields[key])}`).join(";")}}`;
|
||||||
case "object-ref":
|
case "object-ref":
|
||||||
return type.expectation.kind === "atom"
|
return type.expectation.kind === "atom"
|
||||||
? `object:atom:${type.expectation.atomId}`
|
? `object:atom:${type.expectation.atomId}`
|
||||||
@@ -161,6 +167,10 @@ export const valueTypesEqual = (left: ValueType, right: ValueType): boolean => {
|
|||||||
return left.name === (right as typeof left).name;
|
return left.name === (right as typeof left).name;
|
||||||
case "message":
|
case "message":
|
||||||
return left.descriptorId === (right as typeof left).descriptorId;
|
return left.descriptorId === (right as typeof left).descriptorId;
|
||||||
|
case "record": {
|
||||||
|
const other = (right as typeof left).fields;
|
||||||
|
return Object.keys(left.fields).length === Object.keys(other).length && Object.entries(left.fields).every(([key, value]) => Object.hasOwn(other, key) && valueTypesEqual(value, other[key]));
|
||||||
|
}
|
||||||
case "object-ref": {
|
case "object-ref": {
|
||||||
const other = (right as typeof left).expectation;
|
const other = (right as typeof left).expectation;
|
||||||
if (left.expectation.kind !== other.kind) {
|
if (left.expectation.kind !== other.kind) {
|
||||||
@@ -216,6 +226,12 @@ const validateValueType = (
|
|||||||
indexes: Pick<ValidationIndexes, "atoms" | "interfaces">,
|
indexes: Pick<ValidationIndexes, "atoms" | "interfaces">,
|
||||||
) => {
|
) => {
|
||||||
switch (type.kind) {
|
switch (type.kind) {
|
||||||
|
case "record":
|
||||||
|
for (const [name, field] of Object.entries(type.fields)) {
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) issue(issues, "invalid-value-type", path, "Invalid record field name");
|
||||||
|
validateValueType(issues, field, `${path}.fields.${name}`, indexes);
|
||||||
|
}
|
||||||
|
return;
|
||||||
case "builtin":
|
case "builtin":
|
||||||
case "scalar":
|
case "scalar":
|
||||||
return;
|
return;
|
||||||
@@ -460,6 +476,16 @@ const atomSatisfiesConstraint = (
|
|||||||
? constraint.atomId === atomId
|
? constraint.atomId === atomId
|
||||||
: conformances.has(conformanceKey(atomId, constraint.interfaceRevisionId));
|
: conformances.has(conformanceKey(atomId, constraint.interfaceRevisionId));
|
||||||
|
|
||||||
|
const constraintSatisfiesConstraint = (
|
||||||
|
actual: EdgeEndpointConstraint,
|
||||||
|
required: EdgeEndpointConstraint,
|
||||||
|
conformances: ReadonlyMap<string, Conformance>,
|
||||||
|
) => constraintsEqual(actual, required) || (
|
||||||
|
actual.kind === "atom" &&
|
||||||
|
required.kind === "interface" &&
|
||||||
|
atomSatisfiesConstraint(actual.atomId, required, conformances)
|
||||||
|
);
|
||||||
|
|
||||||
const canAccessAttachment = (
|
const canAccessAttachment = (
|
||||||
owner: AttachmentOwner,
|
owner: AttachmentOwner,
|
||||||
conformance: Pick<Conformance, "atomId" | "interfaceRevisionId"> | undefined,
|
conformance: Pick<Conformance, "atomId" | "interfaceRevisionId"> | undefined,
|
||||||
@@ -550,6 +576,9 @@ const collectIdentityIndexes = (
|
|||||||
const packages = new Map<string, PackageIndexEntry>();
|
const packages = new Map<string, PackageIndexEntry>();
|
||||||
for (const [packageIndex, revision] of workspace.packageImports.entries()) {
|
for (const [packageIndex, revision] of workspace.packageImports.entries()) {
|
||||||
const path = `packageImports[${packageIndex}]`;
|
const path = `packageImports[${packageIndex}]`;
|
||||||
|
if (revision.semanticMajor !== undefined && (!Number.isSafeInteger(revision.semanticMajor) || revision.semanticMajor < 1)) {
|
||||||
|
issue(issues, "invalid-semantic-major", `${path}.semanticMajor`, "Semantic major must be a positive safe integer");
|
||||||
|
}
|
||||||
requireText(issues, revision.packageId, `${path}.packageId`, "Package ID");
|
requireText(issues, revision.packageId, `${path}.packageId`, "Package ID");
|
||||||
requireText(
|
requireText(
|
||||||
issues,
|
issues,
|
||||||
@@ -604,8 +633,17 @@ const collectIdentityIndexes = (
|
|||||||
|
|
||||||
const conformances = new Map<string, Conformance>();
|
const conformances = new Map<string, Conformance>();
|
||||||
const conformancePaths = new Map<string, string>();
|
const conformancePaths = new Map<string, string>();
|
||||||
|
const conformanceIds = new Set<string>();
|
||||||
for (const [index, conformance] of workspace.conformances.entries()) {
|
for (const [index, conformance] of workspace.conformances.entries()) {
|
||||||
const path = `conformances[${index}]`;
|
const path = `conformances[${index}]`;
|
||||||
|
if (conformance.semanticMajor !== undefined && (!Number.isSafeInteger(conformance.semanticMajor) || conformance.semanticMajor < 1)) {
|
||||||
|
issue(issues, "invalid-semantic-major", `${path}.semanticMajor`, "Semantic major must be a positive safe integer");
|
||||||
|
}
|
||||||
|
if (conformance.id !== undefined) {
|
||||||
|
requireText(issues, conformance.id, `${path}.id`, "Conformance ID");
|
||||||
|
if (conformanceIds.has(conformance.id)) issue(issues, "duplicate-conformance-id", `${path}.id`, `Duplicate conformance ID ${conformance.id}`);
|
||||||
|
conformanceIds.add(conformance.id);
|
||||||
|
}
|
||||||
const key = conformanceKey(conformance.atomId, conformance.interfaceRevisionId);
|
const key = conformanceKey(conformance.atomId, conformance.interfaceRevisionId);
|
||||||
if (conformances.has(key)) {
|
if (conformances.has(key)) {
|
||||||
issue(
|
issue(
|
||||||
@@ -751,6 +789,10 @@ const validateAttachments = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
validateValueType(issues, attachment.valueType, `${path}.valueType`, indexes);
|
validateValueType(issues, attachment.valueType, `${path}.valueType`, indexes);
|
||||||
|
const containsRpcType = (type: ValueType): boolean => type.kind === "object-ref" || type.kind === "record" || ((type.kind === "optional" || type.kind === "list") && containsRpcType(type.value));
|
||||||
|
if (containsRpcType(attachment.valueType) || (attachment.storagePolicy.kind === "crdt-document" && containsRpcType(attachment.storagePolicy.updateType))) {
|
||||||
|
issue(issues, "invalid-attachment", `${path}.valueType`, "Managed object references belong in graph relationships, not ordinary state; record types are RPC-only");
|
||||||
|
}
|
||||||
if (attachment.storagePolicy.kind === "crdt-document") {
|
if (attachment.storagePolicy.kind === "crdt-document") {
|
||||||
validateValueType(
|
validateValueType(
|
||||||
issues,
|
issues,
|
||||||
@@ -782,10 +824,17 @@ const validateAttachments = (
|
|||||||
}
|
}
|
||||||
for (const [endpointIndex, endpoint] of attachment.endpoints.entries()) {
|
for (const [endpointIndex, endpoint] of attachment.endpoints.entries()) {
|
||||||
const endpointPath = `${path}.endpoints[${endpointIndex}]`;
|
const endpointPath = `${path}.endpoints[${endpointIndex}]`;
|
||||||
|
if (endpoint.keyType !== undefined && (!["string", "boolean", "int64"].includes(endpoint.keyType) || endpoint.ordered || !["many", "many-unique"].includes(endpoint.cardinality))) {
|
||||||
|
issue(issues, "invalid-attachment", `${endpointPath}.keyType`, "Keyed projections require string/boolean/int64 keys, many cardinality, and no ordering");
|
||||||
|
}
|
||||||
requireText(issues, endpoint.projectionId, `${endpointPath}.projectionId`, "Projection ID");
|
requireText(issues, endpoint.projectionId, `${endpointPath}.projectionId`, "Projection ID");
|
||||||
|
if (endpoint.onDelete !== undefined && !["restrict", "detach", "cascade-other"].includes(endpoint.onDelete)) {
|
||||||
|
issue(issues, "invalid-attachment", `${endpointPath}.onDelete`, "Deletion policy must be restrict, detach, or cascade-other");
|
||||||
|
}
|
||||||
requireText(issues, endpoint.displayName, `${endpointPath}.displayName`, "Projection name");
|
requireText(issues, endpoint.displayName, `${endpointPath}.displayName`, "Projection name");
|
||||||
validateConstraint(issues, endpoint.constraint, `${endpointPath}.constraint`, indexes);
|
validateConstraint(issues, endpoint.constraint, `${endpointPath}.constraint`, indexes);
|
||||||
}
|
}
|
||||||
|
if (attachment.endpoints.every((endpoint) => endpoint.keyType)) issue(issues, "invalid-attachment", `${path}.endpoints`, "A v0 map has one keyed projection and one unkeyed inverse");
|
||||||
if (owner.kind === "conformance") {
|
if (owner.kind === "conformance") {
|
||||||
if (
|
if (
|
||||||
!attachment.endpoints.some((endpoint) =>
|
!attachment.endpoints.some((endpoint) =>
|
||||||
@@ -905,7 +954,7 @@ const validatePackages = (
|
|||||||
validateConstraint(issues, port.requirement.target, `${portPath}.requirement.target`, indexes);
|
validateConstraint(issues, port.requirement.target, `${portPath}.requirement.target`, indexes);
|
||||||
uniquePrimitiveList(issues, port.requirement.primitives, `${portPath}.requirement.primitives`);
|
uniquePrimitiveList(issues, port.requirement.primitives, `${portPath}.requirement.primitives`);
|
||||||
break;
|
break;
|
||||||
case "receiver-interface":
|
case "interface":
|
||||||
if (!indexes.interfaces.has(port.requirement.interfaceRevisionId)) {
|
if (!indexes.interfaces.has(port.requirement.interfaceRevisionId)) {
|
||||||
issue(
|
issue(
|
||||||
issues,
|
issues,
|
||||||
@@ -916,6 +965,7 @@ const validatePackages = (
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "constructor":
|
case "constructor":
|
||||||
|
if (port.requirement.inputType) validateValueType(issues, port.requirement.inputType, `${portPath}.requirement.inputType`, indexes);
|
||||||
if (!indexes.atoms.has(port.requirement.atomId)) {
|
if (!indexes.atoms.has(port.requirement.atomId)) {
|
||||||
issue(
|
issue(
|
||||||
issues,
|
issues,
|
||||||
@@ -958,6 +1008,54 @@ const validateAttachmentAccess = (
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateTraversal = (
|
||||||
|
issues: CapabilityValidationIssue[],
|
||||||
|
indexes: ValidationIndexes,
|
||||||
|
atomId: AtomId,
|
||||||
|
traversal: { edgeTypeId: EdgeTypeId; projectionId: EdgeProjectionId },
|
||||||
|
path: string,
|
||||||
|
conformance?: Pick<Conformance, "atomId" | "interfaceRevisionId">,
|
||||||
|
) => {
|
||||||
|
const attachment = findAttachment(indexes, "edge", traversal.edgeTypeId);
|
||||||
|
if (!attachment || attachment.attachment.kind !== "edge") {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-dependency-binding",
|
||||||
|
`${path}.edgeTypeId`,
|
||||||
|
`Unknown traversal edge ${traversal.edgeTypeId}`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const projection = edgeProjection(attachment.attachment, traversal.projectionId);
|
||||||
|
if (!projection) {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-dependency-binding",
|
||||||
|
`${path}.projectionId`,
|
||||||
|
`Traversal projection ${traversal.projectionId} is not on edge ${traversal.edgeTypeId}`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!projection.endpoint.publicTraversal) validateAttachmentAccess(issues, attachment, conformance, `${path}.edgeTypeId`);
|
||||||
|
if (!atomSatisfiesConstraint(atomId, projection.endpoint.constraint, indexes.conformances)) {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-dependency-binding",
|
||||||
|
`${path}.projectionId`,
|
||||||
|
`Traversal projection ${traversal.projectionId} cannot originate at receiver atom ${atomId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (projection.endpoint.cardinality !== "exactly-one") {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-dependency-binding",
|
||||||
|
`${path}.projectionId`,
|
||||||
|
"Dependency traversal must select exactly one object",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return projection;
|
||||||
|
};
|
||||||
|
|
||||||
const validateBoundDependencies = (
|
const validateBoundDependencies = (
|
||||||
issues: CapabilityValidationIssue[],
|
issues: CapabilityValidationIssue[],
|
||||||
params: {
|
params: {
|
||||||
@@ -1020,12 +1118,32 @@ const validateBoundDependencies = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
validateAttachmentAccess(issues, attachment, params.conformance, `${path}.binding.slotId`);
|
validateAttachmentAccess(issues, attachment, params.conformance, `${path}.binding.slotId`);
|
||||||
if (attachment.attachment.attachedTo !== params.atomId) {
|
const traversal = stateBinding.via
|
||||||
|
? validateTraversal(
|
||||||
|
issues,
|
||||||
|
params.indexes,
|
||||||
|
params.atomId,
|
||||||
|
stateBinding.via,
|
||||||
|
`${path}.binding.via`,
|
||||||
|
params.conformance,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
stateBinding.via
|
||||||
|
? traversal && !atomSatisfiesConstraint(
|
||||||
|
attachment.attachment.attachedTo,
|
||||||
|
traversal.target.constraint,
|
||||||
|
params.indexes.conformances,
|
||||||
|
)
|
||||||
|
: attachment.attachment.attachedTo !== params.atomId
|
||||||
|
) {
|
||||||
issue(
|
issue(
|
||||||
issues,
|
issues,
|
||||||
"invalid-dependency-binding",
|
"invalid-dependency-binding",
|
||||||
`${path}.binding.slotId`,
|
`${path}.binding.slotId`,
|
||||||
`State ${stateBinding.slotId} is not attached to receiver atom ${params.atomId}`,
|
stateBinding.via
|
||||||
|
? `State ${stateBinding.slotId} is not attached to the traversal target`
|
||||||
|
: `State ${stateBinding.slotId} is not attached to receiver atom ${params.atomId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!valueTypesEqual(requirement.valueType, attachment.attachment.valueType)) {
|
if (!valueTypesEqual(requirement.valueType, attachment.attachment.valueType)) {
|
||||||
@@ -1061,16 +1179,47 @@ const validateBoundDependencies = (
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (!atomSatisfiesConstraint(params.atomId, projection.endpoint.constraint, params.indexes.conformances)) {
|
const traversal = edgeBinding.via
|
||||||
|
? validateTraversal(
|
||||||
|
issues,
|
||||||
|
params.indexes,
|
||||||
|
params.atomId,
|
||||||
|
edgeBinding.via,
|
||||||
|
`${path}.binding.via`,
|
||||||
|
params.conformance,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const originMatches = edgeBinding.via
|
||||||
|
? traversal && (
|
||||||
|
projection.endpoint.constraint.kind === "atom"
|
||||||
|
? atomSatisfiesConstraint(
|
||||||
|
projection.endpoint.constraint.atomId,
|
||||||
|
traversal.target.constraint,
|
||||||
|
params.indexes.conformances,
|
||||||
|
)
|
||||||
|
: constraintsEqual(projection.endpoint.constraint, traversal.target.constraint)
|
||||||
|
)
|
||||||
|
: atomSatisfiesConstraint(
|
||||||
|
params.atomId,
|
||||||
|
projection.endpoint.constraint,
|
||||||
|
params.indexes.conformances,
|
||||||
|
);
|
||||||
|
if (!originMatches) {
|
||||||
issue(
|
issue(
|
||||||
issues,
|
issues,
|
||||||
"invalid-dependency-binding",
|
"invalid-dependency-binding",
|
||||||
`${path}.binding.projectionId`,
|
`${path}.binding.projectionId`,
|
||||||
`Projection ${edgeBinding.projectionId} cannot originate at receiver atom ${params.atomId}`,
|
edgeBinding.via
|
||||||
|
? `Projection ${edgeBinding.projectionId} cannot originate at the traversal target`
|
||||||
|
: `Projection ${edgeBinding.projectionId} cannot originate at receiver atom ${params.atomId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!constraintsEqual(requirement.target, projection.target.constraint) ||
|
!constraintSatisfiesConstraint(
|
||||||
|
projection.target.constraint,
|
||||||
|
requirement.target,
|
||||||
|
params.indexes.conformances,
|
||||||
|
) ||
|
||||||
requirement.cardinality !== projection.endpoint.cardinality
|
requirement.cardinality !== projection.endpoint.cardinality
|
||||||
) {
|
) {
|
||||||
issue(
|
issue(
|
||||||
@@ -1082,10 +1231,10 @@ const validateBoundDependencies = (
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "receiver-interface": {
|
case "interface": {
|
||||||
const interfaceBinding = binding as Extract<
|
const interfaceBinding = binding as Extract<
|
||||||
DependencyBinding,
|
DependencyBinding,
|
||||||
{ kind: "receiver-interface" }
|
{ kind: "interface" }
|
||||||
>;
|
>;
|
||||||
if (interfaceBinding.interfaceRevisionId !== requirement.interfaceRevisionId) {
|
if (interfaceBinding.interfaceRevisionId !== requirement.interfaceRevisionId) {
|
||||||
issue(
|
issue(
|
||||||
@@ -1096,17 +1245,39 @@ const validateBoundDependencies = (
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const targetKey = conformanceKey(params.atomId, requirement.interfaceRevisionId);
|
const traversal = interfaceBinding.via
|
||||||
|
? validateTraversal(
|
||||||
|
issues,
|
||||||
|
params.indexes,
|
||||||
|
params.atomId,
|
||||||
|
interfaceBinding.via,
|
||||||
|
`${path}.binding.via`,
|
||||||
|
params.conformance,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const candidateAtoms = interfaceBinding.via
|
||||||
|
? traversal
|
||||||
|
? [...params.indexes.atoms.keys()].filter((atomId) =>
|
||||||
|
atomSatisfiesConstraint(
|
||||||
|
atomId as AtomId,
|
||||||
|
traversal.target.constraint,
|
||||||
|
params.indexes.conformances,
|
||||||
|
)) as AtomId[]
|
||||||
|
: []
|
||||||
|
: [params.atomId];
|
||||||
|
for (const atomId of candidateAtoms) {
|
||||||
|
const targetKey = conformanceKey(atomId, requirement.interfaceRevisionId);
|
||||||
if (!params.indexes.conformances.has(targetKey)) {
|
if (!params.indexes.conformances.has(targetKey)) {
|
||||||
issue(
|
issue(
|
||||||
issues,
|
issues,
|
||||||
"unsatisfied-receiver-interface",
|
"unsatisfied-interface",
|
||||||
`${path}.binding.interfaceRevisionId`,
|
`${path}.binding.interfaceRevisionId`,
|
||||||
`Receiver atom ${params.atomId} does not conform to ${requirement.interfaceRevisionId}`,
|
`${interfaceBinding.via ? "Related" : "Receiver"} atom ${atomId} does not conform to ${requirement.interfaceRevisionId}`,
|
||||||
);
|
);
|
||||||
} else if (params.graphSourceKey) {
|
} else if (params.graphSourceKey) {
|
||||||
params.requirementGraph.get(params.graphSourceKey)?.add(targetKey);
|
params.requirementGraph.get(params.graphSourceKey)?.add(targetKey);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "constructor": {
|
case "constructor": {
|
||||||
@@ -1127,6 +1298,14 @@ const validateBoundDependencies = (
|
|||||||
`Atom ${requirement.atomId} has no constructor binding`,
|
`Atom ${requirement.atomId} has no constructor binding`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (requirement.inputType) {
|
||||||
|
const selected = params.indexes.constructors.get(requirement.atomId);
|
||||||
|
const implementation = selected ? params.indexes.packages.get(selected.packageRevisionId)?.exports.get(selected.exportId) : undefined;
|
||||||
|
if (implementation && !valueTypesEqual(requirement.inputType, implementation.inputType)) {
|
||||||
|
issue(issues, "invalid-dependency-binding", `${path}.binding.atomId`,
|
||||||
|
`Constructor port requires input ${typeLabel(requirement.inputType)}, but selected constructor accepts ${typeLabel(implementation.inputType)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1169,7 +1348,7 @@ const validatePackageReceiver = (
|
|||||||
if (!params.indexes.conformances.has(targetKey)) {
|
if (!params.indexes.conformances.has(targetKey)) {
|
||||||
issue(
|
issue(
|
||||||
issues,
|
issues,
|
||||||
"unsatisfied-receiver-interface",
|
"unsatisfied-interface",
|
||||||
params.path,
|
params.path,
|
||||||
`Atom ${params.atomId} does not conform to ${requiredInterface}`,
|
`Atom ${params.atomId} does not conform to ${requiredInterface}`,
|
||||||
);
|
);
|
||||||
@@ -1288,10 +1467,27 @@ const validateConformances = (
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
validateAttachmentAccess(issues, attachment, conformance, `${bindingPath}.binding.edgeTypeId`);
|
|
||||||
const projection = edgeProjection(attachment.attachment, binding.projectionId);
|
const projection = edgeProjection(attachment.attachment, binding.projectionId);
|
||||||
|
// An owned endpoint may explicitly export read-only traversal, including
|
||||||
|
// a native inverse relationship view. This never exports mutation rights.
|
||||||
|
if (!(binding.primitive === "resolve" && projection?.endpoint.publicTraversal)) {
|
||||||
|
validateAttachmentAccess(issues, attachment, conformance, `${bindingPath}.binding.edgeTypeId`);
|
||||||
|
}
|
||||||
|
const relationshipMember = operationEntry.member.kind === "relationship"
|
||||||
|
? operationEntry.member
|
||||||
|
: undefined;
|
||||||
|
const operationEdge = relationshipMember
|
||||||
|
? {
|
||||||
|
...attachment.attachment,
|
||||||
|
endpoints: attachment.attachment.endpoints.map((endpoint, index) =>
|
||||||
|
index === (projection?.endpointIndex === 0 ? 1 : 0)
|
||||||
|
? { ...endpoint, constraint: relationshipMember.target }
|
||||||
|
: endpoint,
|
||||||
|
) as [EdgeEndpoint, EdgeEndpoint],
|
||||||
|
}
|
||||||
|
: attachment.attachment;
|
||||||
const expected = edgePrimitiveSignature(
|
const expected = edgePrimitiveSignature(
|
||||||
attachment.attachment,
|
operationEdge,
|
||||||
binding.projectionId,
|
binding.projectionId,
|
||||||
binding.primitive,
|
binding.primitive,
|
||||||
);
|
);
|
||||||
@@ -1320,7 +1516,11 @@ const validateConformances = (
|
|||||||
"Edge primitives may only implement relationship members",
|
"Edge primitives may only implement relationship members",
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
!constraintsEqual(operationEntry.member.target, projection.target.constraint) ||
|
!constraintSatisfiesConstraint(
|
||||||
|
projection.target.constraint,
|
||||||
|
operationEntry.member.target,
|
||||||
|
indexes.conformances,
|
||||||
|
) ||
|
||||||
operationEntry.member.cardinality !== projection.endpoint.cardinality
|
operationEntry.member.cardinality !== projection.endpoint.cardinality
|
||||||
) {
|
) {
|
||||||
issue(
|
issue(
|
||||||
@@ -1398,6 +1598,148 @@ const validateConformances = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const materializedMembers = new Set<string>();
|
||||||
|
for (const [materializationIndex, materialization] of
|
||||||
|
(conformance.relationshipMaterializations ?? []).entries()) {
|
||||||
|
const materializationPath =
|
||||||
|
`${path}.relationshipMaterializations[${materializationIndex}]`;
|
||||||
|
if (materializedMembers.has(materialization.memberId)) {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"duplicate-relationship-materialization",
|
||||||
|
materializationPath,
|
||||||
|
`Relationship ${materialization.memberId} has more than one materialization recipe`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
materializedMembers.add(materialization.memberId);
|
||||||
|
const member = interfaceEntry.revision.members.find((entry) =>
|
||||||
|
entry.id === materialization.memberId);
|
||||||
|
if (!member || member.kind !== "relationship") {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-relationship-materialization",
|
||||||
|
`${materializationPath}.memberId`,
|
||||||
|
`Member ${materialization.memberId} is not a relationship on ${conformance.interfaceRevisionId}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (member.cardinality !== "optional-one") {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-relationship-materialization",
|
||||||
|
`${materializationPath}.memberId`,
|
||||||
|
"A lazily constructed relationship must be optional-one before construction",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const resolveOperation = member.operations.find((entry) =>
|
||||||
|
entry.displayName === "resolve");
|
||||||
|
const resolveBinding = resolveOperation
|
||||||
|
? bindings.get(resolveOperation.id)
|
||||||
|
: undefined;
|
||||||
|
if (!resolveBinding || resolveBinding.kind !== "edge") {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-relationship-materialization",
|
||||||
|
`${materializationPath}.memberId`,
|
||||||
|
"A materialized relationship must bind its resolve operation to an edge",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (resolveBinding.edgeTypeId !== materialization.edgeTypeId) {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-relationship-materialization",
|
||||||
|
`${materializationPath}.edgeTypeId`,
|
||||||
|
"Materialization and relationship resolution must use the same edge",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const attachment = findAttachment(indexes, "edge", materialization.edgeTypeId);
|
||||||
|
if (!attachment || attachment.attachment.kind !== "edge") {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-relationship-materialization",
|
||||||
|
`${materializationPath}.edgeTypeId`,
|
||||||
|
`Unknown materialization edge ${materialization.edgeTypeId}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validateAttachmentAccess(
|
||||||
|
issues,
|
||||||
|
attachment,
|
||||||
|
conformance,
|
||||||
|
`${materializationPath}.edgeTypeId`,
|
||||||
|
);
|
||||||
|
const hostProjection = edgeProjection(
|
||||||
|
attachment.attachment,
|
||||||
|
resolveBinding.projectionId,
|
||||||
|
);
|
||||||
|
const constructedProjection = edgeProjection(
|
||||||
|
attachment.attachment,
|
||||||
|
materialization.constructedProjectionId,
|
||||||
|
);
|
||||||
|
if (!hostProjection || !constructedProjection ||
|
||||||
|
hostProjection.endpointIndex === constructedProjection.endpointIndex) {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-relationship-materialization",
|
||||||
|
`${materializationPath}.constructedProjectionId`,
|
||||||
|
"Materialization must connect through the opposite projection of the resolved edge",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!atomSatisfiesConstraint(
|
||||||
|
materialization.constructorAtomId,
|
||||||
|
member.target,
|
||||||
|
indexes.conformances,
|
||||||
|
)) {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-relationship-materialization",
|
||||||
|
`${materializationPath}.constructorAtomId`,
|
||||||
|
`Constructed atom ${materialization.constructorAtomId} does not satisfy the relationship target`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!atomSatisfiesConstraint(
|
||||||
|
materialization.constructorAtomId,
|
||||||
|
constructedProjection.endpoint.constraint,
|
||||||
|
indexes.conformances,
|
||||||
|
) || !atomSatisfiesConstraint(
|
||||||
|
conformance.atomId,
|
||||||
|
constructedProjection.target.constraint,
|
||||||
|
indexes.conformances,
|
||||||
|
)) {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-relationship-materialization",
|
||||||
|
`${materializationPath}.constructedProjectionId`,
|
||||||
|
"Constructed projection does not connect the constructed atom back to the host atom",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const constructor = indexes.constructors.get(materialization.constructorAtomId);
|
||||||
|
const constructorExport = constructor
|
||||||
|
? indexes.packages.get(constructor.packageRevisionId)?.exports.get(constructor.exportId)
|
||||||
|
: undefined;
|
||||||
|
if (!constructor || !constructorExport || constructorExport.kind !== "constructor") {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-relationship-materialization",
|
||||||
|
`${materializationPath}.constructorAtomId`,
|
||||||
|
`Constructed atom ${materialization.constructorAtomId} has no valid constructor`,
|
||||||
|
);
|
||||||
|
} else if (!valueTypesEqual(
|
||||||
|
constructorExport.inputType,
|
||||||
|
valueType.atomRef(conformance.atomId),
|
||||||
|
)) {
|
||||||
|
issue(
|
||||||
|
issues,
|
||||||
|
"invalid-relationship-materialization",
|
||||||
|
`${materializationPath}.constructorAtomId`,
|
||||||
|
`Component constructor must accept ${typeLabel(valueType.atomRef(conformance.atomId))}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const operation of interfaceEntry.operations.values()) {
|
for (const operation of interfaceEntry.operations.values()) {
|
||||||
if (!bindings.has(operation.operation.id)) {
|
if (!bindings.has(operation.operation.id)) {
|
||||||
issue(
|
issue(
|
||||||
@@ -1705,6 +2047,9 @@ export const computeCapabilityClosure = (
|
|||||||
const constructors = new Set<AtomId>();
|
const constructors = new Set<AtomId>();
|
||||||
const queued = [...roots];
|
const queued = [...roots];
|
||||||
const visited = new Set<string>();
|
const visited = new Set<string>();
|
||||||
|
const sourceConformances = new Map(
|
||||||
|
[...plan.conformances].map(([key, value]) => [key, value.source]),
|
||||||
|
);
|
||||||
|
|
||||||
const includeDependencies = (
|
const includeDependencies = (
|
||||||
atomId: AtomId,
|
atomId: AtomId,
|
||||||
@@ -1714,16 +2059,48 @@ export const computeCapabilityClosure = (
|
|||||||
switch (dependency.binding.kind) {
|
switch (dependency.binding.kind) {
|
||||||
case "state":
|
case "state":
|
||||||
attachments.add(dependency.binding.slotId);
|
attachments.add(dependency.binding.slotId);
|
||||||
|
if (dependency.binding.via) {
|
||||||
|
attachments.add(dependency.binding.via.edgeTypeId);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "edge":
|
case "edge":
|
||||||
attachments.add(dependency.binding.edgeTypeId);
|
attachments.add(dependency.binding.edgeTypeId);
|
||||||
|
if (dependency.binding.via) {
|
||||||
|
attachments.add(dependency.binding.via.edgeTypeId);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "receiver-interface":
|
case "interface": {
|
||||||
|
if (dependency.binding.via) {
|
||||||
|
attachments.add(dependency.binding.via.edgeTypeId);
|
||||||
|
}
|
||||||
|
const targetAtoms = dependency.binding.via
|
||||||
|
? (() => {
|
||||||
|
const attachment = plan.attachments.get(
|
||||||
|
`edge\u0000${dependency.binding.via!.edgeTypeId}`,
|
||||||
|
)?.attachment;
|
||||||
|
if (!attachment || attachment.kind !== "edge") return [];
|
||||||
|
const projection = edgeProjection(
|
||||||
|
attachment,
|
||||||
|
dependency.binding.via!.projectionId,
|
||||||
|
);
|
||||||
|
return projection
|
||||||
|
? [...plan.atoms.keys()].filter((candidate) =>
|
||||||
|
atomSatisfiesConstraint(
|
||||||
|
candidate,
|
||||||
|
projection.target.constraint,
|
||||||
|
sourceConformances,
|
||||||
|
))
|
||||||
|
: [];
|
||||||
|
})()
|
||||||
|
: [atomId];
|
||||||
|
for (const targetAtom of targetAtoms) {
|
||||||
queued.push({
|
queued.push({
|
||||||
atomId,
|
atomId: targetAtom,
|
||||||
interfaceRevisionId: dependency.binding.interfaceRevisionId,
|
interfaceRevisionId: dependency.binding.interfaceRevisionId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "constructor": {
|
case "constructor": {
|
||||||
constructors.add(dependency.binding.atomId);
|
constructors.add(dependency.binding.atomId);
|
||||||
const constructor = plan.constructors.get(dependency.binding.atomId);
|
const constructor = plan.constructors.get(dependency.binding.atomId);
|
||||||
@@ -1772,6 +2149,16 @@ export const computeCapabilityClosure = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const materialization of
|
||||||
|
conformance.source.relationshipMaterializations ?? []) {
|
||||||
|
attachments.add(materialization.edgeTypeId);
|
||||||
|
constructors.add(materialization.constructorAtomId);
|
||||||
|
const constructor = plan.constructors.get(materialization.constructorAtomId);
|
||||||
|
if (constructor) {
|
||||||
|
packages.add(constructor.packageRevisionId);
|
||||||
|
includeDependencies(materialization.constructorAtomId, constructor.dependencies);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+138
-8
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf";
|
|||||||
* Describes the file camino/schema.proto.
|
* Describes the file camino/schema.proto.
|
||||||
*/
|
*/
|
||||||
export const file_camino_schema: GenFile = /*@__PURE__*/
|
export const file_camino_schema: GenFile = /*@__PURE__*/
|
||||||
fileDesc("ChNjYW1pbm8vc2NoZW1hLnByb3RvEgZjYW1pbm8iNwoOQXRvbURlZmluaXRpb24SDwoHYXRvbV9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkiQQoPQXRvbUNvbmZvcm1hbmNlEg8KB2F0b21faWQYASABKAkSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAIgASgJIqQBCg9TdGF0ZUF0dGFjaG1lbnQSDwoHc2xvdF9pZBgBIAEoCRIYChBhdHRhY2hlZF9hdG9tX2lkGAIgASgJEhQKDGRpc3BsYXlfbmFtZRgDIAEoCRIXCg92YWx1ZV90eXBlX2pzb24YBCABKAkSGwoTc3RvcmFnZV9wb2xpY3lfanNvbhgFIAEoCRIaChJkZWZhdWx0X3ZhbHVlX2pzb24YBiABKAkiUAoSRW5kcG9pbnRDb25zdHJhaW50EhEKB2F0b21faWQYASABKAlIABIfChVpbnRlcmZhY2VfcmV2aXNpb25faWQYAiABKAlIAEIGCgRraW5kIqYBCgxFZGdlRW5kcG9pbnQSFQoNcHJvamVjdGlvbl9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSLgoKY29uc3RyYWludBgDIAEoCzIaLmNhbWluby5FbmRwb2ludENvbnN0cmFpbnQSKAoLY2FyZGluYWxpdHkYBCABKA4yEy5jYW1pbm8uQ2FyZGluYWxpdHkSDwoHb3JkZXJlZBgFIAEoCCKHAQoORWRnZUF0dGFjaG1lbnQSFAoMZWRnZV90eXBlX2lkGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRIjCgVmaXJzdBgDIAEoCzIULmNhbWluby5FZGdlRW5kcG9pbnQSJAoGc2Vjb25kGAQgASgLMhQuY2FtaW5vLkVkZ2VFbmRwb2ludCLsAQoPUGVyc2lzdGVuY2VQbGFuEhQKDHdvcmtzcGFjZV9pZBgBIAEoCRIdChV3b3Jrc3BhY2VfcmV2aXNpb25faWQYAiABKAkSJQoFYXRvbXMYAyADKAsyFi5jYW1pbm8uQXRvbURlZmluaXRpb24SLQoMY29uZm9ybWFuY2VzGAQgAygLMhcuY2FtaW5vLkF0b21Db25mb3JtYW5jZRInCgZzdGF0ZXMYBSADKAsyFy5jYW1pbm8uU3RhdGVBdHRhY2htZW50EiUKBWVkZ2VzGAYgAygLMhYuY2FtaW5vLkVkZ2VBdHRhY2htZW50KmgKC0NhcmRpbmFsaXR5EhsKF0NBUkRJTkFMSVRZX1VOU1BFQ0lGSUVEEAASEAoMT1BUSU9OQUxfT05FEAESDwoLRVhBQ1RMWV9PTkUQAhIICgRNQU5ZEAMSDwoLTUFOWV9VTklRVUUQBGIGcHJvdG8z");
|
fileDesc("ChNjYW1pbm8vc2NoZW1hLnByb3RvEgZjYW1pbm8iNwoOQXRvbURlZmluaXRpb24SDwoHYXRvbV9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkiWQoPQXRvbUNvbmZvcm1hbmNlEg8KB2F0b21faWQYASABKAkSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAIgASgJEhYKDmNvbmZvcm1hbmNlX2lkGAMgASgJIsIBCg9TdGF0ZUF0dGFjaG1lbnQSDwoHc2xvdF9pZBgBIAEoCRIYChBhdHRhY2hlZF9hdG9tX2lkGAIgASgJEhQKDGRpc3BsYXlfbmFtZRgDIAEoCRIXCg92YWx1ZV90eXBlX2pzb24YBCABKAkSGwoTc3RvcmFnZV9wb2xpY3lfanNvbhgFIAEoCRIaChJkZWZhdWx0X3ZhbHVlX2pzb24YBiABKAkSHAoUb3duZXJfY29uZm9ybWFuY2VfaWQYByABKAkiUAoSRW5kcG9pbnRDb25zdHJhaW50EhEKB2F0b21faWQYASABKAlIABIfChVpbnRlcmZhY2VfcmV2aXNpb25faWQYAiABKAlIAEIGCgRraW5kIvsBCgxFZGdlRW5kcG9pbnQSFQoNcHJvamVjdGlvbl9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSLgoKY29uc3RyYWludBgDIAEoCzIaLmNhbWluby5FbmRwb2ludENvbnN0cmFpbnQSKAoLY2FyZGluYWxpdHkYBCABKA4yEy5jYW1pbm8uQ2FyZGluYWxpdHkSDwoHb3JkZXJlZBgFIAEoCBIRCglvbl9kZWxldGUYBiABKAkSFAoMcmV0YWluX290aGVyGAcgASgIEhAKCGtleV90eXBlGAggASgJEhgKEHB1YmxpY190cmF2ZXJzYWwYCSABKAgipQEKDkVkZ2VBdHRhY2htZW50EhQKDGVkZ2VfdHlwZV9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSIwoFZmlyc3QYAyABKAsyFC5jYW1pbm8uRWRnZUVuZHBvaW50EiQKBnNlY29uZBgEIAEoCzIULmNhbWluby5FZGdlRW5kcG9pbnQSHAoUb3duZXJfY29uZm9ybWFuY2VfaWQYBSABKAki7AEKD1BlcnNpc3RlbmNlUGxhbhIUCgx3b3Jrc3BhY2VfaWQYASABKAkSHQoVd29ya3NwYWNlX3JldmlzaW9uX2lkGAIgASgJEiUKBWF0b21zGAMgAygLMhYuY2FtaW5vLkF0b21EZWZpbml0aW9uEi0KDGNvbmZvcm1hbmNlcxgEIAMoCzIXLmNhbWluby5BdG9tQ29uZm9ybWFuY2USJwoGc3RhdGVzGAUgAygLMhcuY2FtaW5vLlN0YXRlQXR0YWNobWVudBIlCgVlZGdlcxgGIAMoCzIWLmNhbWluby5FZGdlQXR0YWNobWVudCpoCgtDYXJkaW5hbGl0eRIbChdDQVJESU5BTElUWV9VTlNQRUNJRklFRBAAEhAKDE9QVElPTkFMX09ORRABEg8KC0VYQUNUTFlfT05FEAISCAoETUFOWRADEg8KC01BTllfVU5JUVVFEARiBnByb3RvMw");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message camino.AtomDefinition
|
* @generated from message camino.AtomDefinition
|
||||||
@@ -47,6 +47,11 @@ export type AtomConformance = Message<"camino.AtomConformance"> & {
|
|||||||
* @generated from field: string interface_revision_id = 2;
|
* @generated from field: string interface_revision_id = 2;
|
||||||
*/
|
*/
|
||||||
interfaceRevisionId: string;
|
interfaceRevisionId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string conformance_id = 3;
|
||||||
|
*/
|
||||||
|
conformanceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,6 +94,11 @@ export type StateAttachment = Message<"camino.StateAttachment"> & {
|
|||||||
* @generated from field: string default_value_json = 6;
|
* @generated from field: string default_value_json = 6;
|
||||||
*/
|
*/
|
||||||
defaultValueJson: string;
|
defaultValueJson: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string owner_conformance_id = 7;
|
||||||
|
*/
|
||||||
|
ownerConformanceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,6 +165,32 @@ export type EdgeEndpoint = Message<"camino.EdgeEndpoint"> & {
|
|||||||
* @generated from field: bool ordered = 5;
|
* @generated from field: bool ordered = 5;
|
||||||
*/
|
*/
|
||||||
ordered: boolean;
|
ordered: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty means restrict. Direction is the endpoint being deleted.
|
||||||
|
*
|
||||||
|
* @generated from field: string on_delete = 6;
|
||||||
|
*/
|
||||||
|
onDelete: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: bool retain_other = 7;
|
||||||
|
*/
|
||||||
|
retainOther: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty for sets/lists, otherwise string, boolean, or int64 map keys.
|
||||||
|
*
|
||||||
|
* @generated from field: string key_type = 8;
|
||||||
|
*/
|
||||||
|
keyType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit read-only dependency injection traversal, not mutation authority.
|
||||||
|
*
|
||||||
|
* @generated from field: bool public_traversal = 9;
|
||||||
|
*/
|
||||||
|
publicTraversal: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,6 +223,11 @@ export type EdgeAttachment = Message<"camino.EdgeAttachment"> & {
|
|||||||
* @generated from field: camino.EdgeEndpoint second = 4;
|
* @generated from field: camino.EdgeEndpoint second = 4;
|
||||||
*/
|
*/
|
||||||
second?: EdgeEndpoint | undefined;
|
second?: EdgeEndpoint | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string owner_conformance_id = 5;
|
||||||
|
*/
|
||||||
|
ownerConformanceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+154
-17
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf";
|
|||||||
* Describes the file quixos/refs.proto.
|
* Describes the file quixos/refs.proto.
|
||||||
*/
|
*/
|
||||||
export const file_quixos_refs: GenFile = /*@__PURE__*/
|
export const file_quixos_refs: GenFile = /*@__PURE__*/
|
||||||
fileDesc("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zIkQKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCSJCChBQYWNrYWdlRXhwb3J0UmVmEhsKE3BhY2thZ2VfcmV2aXNpb25faWQYASABKAkSEQoJZXhwb3J0X2lkGAIgASgJIroBChJJbmplY3RlZERlcGVuZGVuY3kSDwoHcG9ydF9pZBgBIAEoCRIXCg1zdGF0ZV9zbG90X2lkGAIgASgJSAASJgoEZWRnZRgDIAEoCzIWLnF1aXhvcy5FZGdlRGVwZW5kZW5jeUgAEigKHnJlY2VpdmVyX2ludGVyZmFjZV9yZXZpc2lvbl9pZBgEIAEoCUgAEh0KE2NvbnN0cnVjdG9yX2F0b21faWQYBSABKAlIAEIJCgdiaW5kaW5nIj0KDkVkZ2VEZXBlbmRlbmN5EhQKDGVkZ2VfdHlwZV9pZBgBIAEoCRIVCg1wcm9qZWN0aW9uX2lkGAIgASgJYgZwcm90bzM");
|
fileDesc("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zIkQKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCSJCChBQYWNrYWdlRXhwb3J0UmVmEhsKE3BhY2thZ2VfcmV2aXNpb25faWQYASABKAkSEQoJZXhwb3J0X2lkGAIgASgJIsQBChJJbmplY3RlZERlcGVuZGVuY3kSDwoHcG9ydF9pZBgBIAEoCRIXCg1zdGF0ZV9zbG90X2lkGAIgASgJSAASJgoEZWRnZRgDIAEoCzIWLnF1aXhvcy5FZGdlRGVwZW5kZW5jeUgAEh8KFWludGVyZmFjZV9yZXZpc2lvbl9pZBgEIAEoCUgAEh0KE2NvbnN0cnVjdG9yX2F0b21faWQYBSABKAlIABIRCglvYmplY3RfaWQYBiABKAlCCQoHYmluZGluZyI9Cg5FZGdlRGVwZW5kZW5jeRIUCgxlZGdlX3R5cGVfaWQYASABKAkSFQoNcHJvamVjdGlvbl9pZBgCIAEoCWIGcHJvdG8z");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message quixos.CapabilityRef
|
* @generated from message quixos.CapabilityRef
|
||||||
@@ -82,10 +82,10 @@ export type InjectedDependency = Message<"quixos.InjectedDependency"> & {
|
|||||||
case: "edge";
|
case: "edge";
|
||||||
} | {
|
} | {
|
||||||
/**
|
/**
|
||||||
* @generated from field: string receiver_interface_revision_id = 4;
|
* @generated from field: string interface_revision_id = 4;
|
||||||
*/
|
*/
|
||||||
value: string;
|
value: string;
|
||||||
case: "receiverInterfaceRevisionId";
|
case: "interfaceRevisionId";
|
||||||
} | {
|
} | {
|
||||||
/**
|
/**
|
||||||
* @generated from field: string constructor_atom_id = 5;
|
* @generated from field: string constructor_atom_id = 5;
|
||||||
@@ -93,6 +93,14 @@ export type InjectedDependency = Message<"quixos.InjectedDependency"> & {
|
|||||||
value: string;
|
value: string;
|
||||||
case: "constructorAtomId";
|
case: "constructorAtomId";
|
||||||
} | { case: undefined; value?: undefined };
|
} | { case: undefined; value?: undefined };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defaults to the invocation receiver. A checked dependency traversal can
|
||||||
|
* select a related object explicitly before the package runtime starts.
|
||||||
|
*
|
||||||
|
* @generated from field: string object_id = 6;
|
||||||
|
*/
|
||||||
|
objectId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { Message } from "@bufbuild/protobuf";
|
|||||||
* Describes the file quixos/runtime.proto.
|
* Describes the file quixos/runtime.proto.
|
||||||
*/
|
*/
|
||||||
export const file_quixos_runtime: GenFile = /*@__PURE__*/
|
export const file_quixos_runtime: GenFile = /*@__PURE__*/
|
||||||
fileDesc("ChRxdWl4b3MvcnVudGltZS5wcm90bxIOcXVpeG9zLnJ1bnRpbWUiMQoQSGFuZHNoYWtlUmVxdWVzdBIdChVvcmNoX3Byb3RvY29sX3ZlcnNpb24YASABKAkiZgoRSGFuZHNoYWtlUmVzcG9uc2USGwoTcGFja2FnZV9yZXZpc2lvbl9pZBgBIAEoCRIgChhydW50aW1lX3Byb3RvY29sX3ZlcnNpb24YAiABKAkSEgoKZXhwb3J0X2lkcxgDIAMoCSKLAgoNSW52b2tlUmVxdWVzdBIVCg1pbnZvY2F0aW9uX2lkGAEgASgJEigKBmV4cG9ydBgCIAEoCzIYLnF1aXhvcy5QYWNrYWdlRXhwb3J0UmVmEhEKCW9iamVjdF9pZBgDIAEoCRI3CgVpbnB1dBgEIAMoCzIoLnF1aXhvcy5ydW50aW1lLkludm9rZVJlcXVlc3QuSW5wdXRFbnRyeRIwCgxkZXBlbmRlbmNpZXMYBSADKAsyGi5xdWl4b3MuSW5qZWN0ZWREZXBlbmRlbmN5GjsKCklucHV0RW50cnkSCwoDa2V5GAEgASgJEhwKBXZhbHVlGAIgASgLMg0uY2FtaW5vLlZhbHVlOgI4ASJKCg5JbnZva2VSZXNwb25zZRIKCgJvaxgBIAEoCBIdCgZyZXN1bHQYAiABKAsyDS5jYW1pbm8uVmFsdWUSDQoFZXJyb3IYAyABKAkiiQIKDFdhdGNoUmVxdWVzdBIVCg1pbnZvY2F0aW9uX2lkGAEgASgJEigKBmV4cG9ydBgCIAEoCzIYLnF1aXhvcy5QYWNrYWdlRXhwb3J0UmVmEhEKCW9iamVjdF9pZBgDIAEoCRI2CgVpbnB1dBgEIAMoCzInLnF1aXhvcy5ydW50aW1lLldhdGNoUmVxdWVzdC5JbnB1dEVudHJ5EjAKDGRlcGVuZGVuY2llcxgFIAMoCzIaLnF1aXhvcy5JbmplY3RlZERlcGVuZGVuY3kaOwoKSW5wdXRFbnRyeRILCgNrZXkYASABKAkSHAoFdmFsdWUYAiABKAsyDS5jYW1pbm8uVmFsdWU6AjgBImIKEURlcml2ZWREZXBlbmRlbmN5EgwKBGtpbmQYASABKAkSEQoJb2JqZWN0X2lkGAIgASgJEhUKDWF0dGFjaG1lbnRfaWQYAyABKAkSFQoNcHJvamVjdGlvbl9pZBgEIAEoCSKVAQoKV2F0Y2hFdmVudBIQCgh3YXRjaF9pZBgBIAEoCRIcCgV2YWx1ZRgCIAEoCzINLmNhbWluby5WYWx1ZRI3CgxkZXBlbmRlbmNpZXMYAyADKAsyIS5xdWl4b3MucnVudGltZS5EZXJpdmVkRGVwZW5kZW5jeRINCgVlcnJvchgEIAEoCRIPCgdpbml0aWFsGAUgASgIMvABCg5QYWNrYWdlUnVudGltZRJQCglIYW5kc2hha2USIC5xdWl4b3MucnVudGltZS5IYW5kc2hha2VSZXF1ZXN0GiEucXVpeG9zLnJ1bnRpbWUuSGFuZHNoYWtlUmVzcG9uc2USRwoGSW52b2tlEh0ucXVpeG9zLnJ1bnRpbWUuSW52b2tlUmVxdWVzdBoeLnF1aXhvcy5ydW50aW1lLkludm9rZVJlc3BvbnNlEkMKBVdhdGNoEhwucXVpeG9zLnJ1bnRpbWUuV2F0Y2hSZXF1ZXN0GhoucXVpeG9zLnJ1bnRpbWUuV2F0Y2hFdmVudDABYgZwcm90bzM", [file_camino_api, file_quixos_refs]);
|
fileDesc("ChRxdWl4b3MvcnVudGltZS5wcm90bxIOcXVpeG9zLnJ1bnRpbWUiQAoQSGFuZHNoYWtlUmVxdWVzdBIdChVvcmNoX3Byb3RvY29sX3ZlcnNpb24YASABKAkSDQoFbm9uY2UYAiABKAkirwEKEUhhbmRzaGFrZVJlc3BvbnNlEhsKE3BhY2thZ2VfcmV2aXNpb25faWQYASABKAkSIAoYcnVudGltZV9wcm90b2NvbF92ZXJzaW9uGAIgASgJEhIKCmV4cG9ydF9pZHMYAyADKAkSEwoLaW5zdGFuY2VfaWQYBCABKAkSHAoUYXV0aGVudGljYXRpb25fcHJvb2YYBSABKAkSFAoMY2FwYWJpbGl0aWVzGAYgAygJIpoBChFJbnZvY2F0aW9uQ29udGV4dBIXCg93b3Jrc3BhY2VfZXBvY2gYASABKAkSEwoLaW5zdGFuY2VfaWQYAiABKAkSFgoOYmluZGluZ19kaWdlc3QYAyABKAkSDQoFZ3JhbnQYBCABKAkSEgoKc2Vzc2lvbl9pZBgFIAEoCRIcChRvd25lcl9jb25mb3JtYW5jZV9pZBgGIAEoCSIxChhJbnZvY2F0aW9uQ29udHJvbFJlcXVlc3QSFQoNaW52b2NhdGlvbl9pZBgBIAEoCSI4ChBJbnZvY2F0aW9uU3RhdHVzEhUKDWludm9jYXRpb25faWQYASABKAkSDQoFc3RhdGUYAiABKAkivwIKDUludm9rZVJlcXVlc3QSFQoNaW52b2NhdGlvbl9pZBgBIAEoCRIoCgZleHBvcnQYAiABKAsyGC5xdWl4b3MuUGFja2FnZUV4cG9ydFJlZhIRCglvYmplY3RfaWQYAyABKAkSNwoFaW5wdXQYBCADKAsyKC5xdWl4b3MucnVudGltZS5JbnZva2VSZXF1ZXN0LklucHV0RW50cnkSMAoMZGVwZW5kZW5jaWVzGAUgAygLMhoucXVpeG9zLkluamVjdGVkRGVwZW5kZW5jeRIyCgdjb250ZXh0GAYgASgLMiEucXVpeG9zLnJ1bnRpbWUuSW52b2NhdGlvbkNvbnRleHQaOwoKSW5wdXRFbnRyeRILCgNrZXkYASABKAkSHAoFdmFsdWUYAiABKAsyDS5jYW1pbm8uVmFsdWU6AjgBIoMBCg5JbnZva2VSZXNwb25zZRIKCgJvaxgBIAEoCBIdCgZyZXN1bHQYAiABKAsyDS5jYW1pbm8uVmFsdWUSDQoFZXJyb3IYAyABKAkSNwoMZGVwZW5kZW5jaWVzGAQgAygLMiEucXVpeG9zLnJ1bnRpbWUuRGVyaXZlZERlcGVuZGVuY3kivQIKDFdhdGNoUmVxdWVzdBIVCg1pbnZvY2F0aW9uX2lkGAEgASgJEigKBmV4cG9ydBgCIAEoCzIYLnF1aXhvcy5QYWNrYWdlRXhwb3J0UmVmEhEKCW9iamVjdF9pZBgDIAEoCRI2CgVpbnB1dBgEIAMoCzInLnF1aXhvcy5ydW50aW1lLldhdGNoUmVxdWVzdC5JbnB1dEVudHJ5EjAKDGRlcGVuZGVuY2llcxgFIAMoCzIaLnF1aXhvcy5JbmplY3RlZERlcGVuZGVuY3kSMgoHY29udGV4dBgGIAEoCzIhLnF1aXhvcy5ydW50aW1lLkludm9jYXRpb25Db250ZXh0GjsKCklucHV0RW50cnkSCwoDa2V5GAEgASgJEhwKBXZhbHVlGAIgASgLMg0uY2FtaW5vLlZhbHVlOgI4ASJiChFEZXJpdmVkRGVwZW5kZW5jeRIMCgRraW5kGAEgASgJEhEKCW9iamVjdF9pZBgCIAEoCRIVCg1hdHRhY2htZW50X2lkGAMgASgJEhUKDXByb2plY3Rpb25faWQYBCABKAkilQEKCldhdGNoRXZlbnQSEAoId2F0Y2hfaWQYASABKAkSHAoFdmFsdWUYAiABKAsyDS5jYW1pbm8uVmFsdWUSNwoMZGVwZW5kZW5jaWVzGAMgAygLMiEucXVpeG9zLnJ1bnRpbWUuRGVyaXZlZERlcGVuZGVuY3kSDQoFZXJyb3IYBCABKAkSDwoHaW5pdGlhbBgFIAEoCDKzAwoOUGFja2FnZVJ1bnRpbWUSUAoJSGFuZHNoYWtlEiAucXVpeG9zLnJ1bnRpbWUuSGFuZHNoYWtlUmVxdWVzdBohLnF1aXhvcy5ydW50aW1lLkhhbmRzaGFrZVJlc3BvbnNlEkcKBkludm9rZRIdLnF1aXhvcy5ydW50aW1lLkludm9rZVJlcXVlc3QaHi5xdWl4b3MucnVudGltZS5JbnZva2VSZXNwb25zZRJDCgVXYXRjaBIcLnF1aXhvcy5ydW50aW1lLldhdGNoUmVxdWVzdBoaLnF1aXhvcy5ydW50aW1lLldhdGNoRXZlbnQwARJhChNHZXRJbnZvY2F0aW9uU3RhdHVzEigucXVpeG9zLnJ1bnRpbWUuSW52b2NhdGlvbkNvbnRyb2xSZXF1ZXN0GiAucXVpeG9zLnJ1bnRpbWUuSW52b2NhdGlvblN0YXR1cxJeChBDYW5jZWxJbnZvY2F0aW9uEigucXVpeG9zLnJ1bnRpbWUuSW52b2NhdGlvbkNvbnRyb2xSZXF1ZXN0GiAucXVpeG9zLnJ1bnRpbWUuSW52b2NhdGlvblN0YXR1c2IGcHJvdG8z", [file_camino_api, file_quixos_refs]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message quixos.runtime.HandshakeRequest
|
* @generated from message quixos.runtime.HandshakeRequest
|
||||||
@@ -24,6 +24,11 @@ export type HandshakeRequest = Message<"quixos.runtime.HandshakeRequest"> & {
|
|||||||
* @generated from field: string orch_protocol_version = 1;
|
* @generated from field: string orch_protocol_version = 1;
|
||||||
*/
|
*/
|
||||||
orchProtocolVersion: string;
|
orchProtocolVersion: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string nonce = 2;
|
||||||
|
*/
|
||||||
|
nonce: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,6 +56,21 @@ export type HandshakeResponse = Message<"quixos.runtime.HandshakeResponse"> & {
|
|||||||
* @generated from field: repeated string export_ids = 3;
|
* @generated from field: repeated string export_ids = 3;
|
||||||
*/
|
*/
|
||||||
exportIds: string[];
|
exportIds: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string instance_id = 4;
|
||||||
|
*/
|
||||||
|
instanceId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string authentication_proof = 5;
|
||||||
|
*/
|
||||||
|
authenticationProof: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: repeated string capabilities = 6;
|
||||||
|
*/
|
||||||
|
capabilities: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +80,91 @@ export type HandshakeResponse = Message<"quixos.runtime.HandshakeResponse"> & {
|
|||||||
export const HandshakeResponseSchema: GenMessage<HandshakeResponse> = /*@__PURE__*/
|
export const HandshakeResponseSchema: GenMessage<HandshakeResponse> = /*@__PURE__*/
|
||||||
messageDesc(file_quixos_runtime, 1);
|
messageDesc(file_quixos_runtime, 1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message quixos.runtime.InvocationContext
|
||||||
|
*/
|
||||||
|
export type InvocationContext = Message<"quixos.runtime.InvocationContext"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string workspace_epoch = 1;
|
||||||
|
*/
|
||||||
|
workspaceEpoch: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string instance_id = 2;
|
||||||
|
*/
|
||||||
|
instanceId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string binding_digest = 3;
|
||||||
|
*/
|
||||||
|
bindingDigest: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string grant = 4;
|
||||||
|
*/
|
||||||
|
grant: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string session_id = 5;
|
||||||
|
*/
|
||||||
|
sessionId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-selected owner; packages must not invent workspace-local ownership.
|
||||||
|
*
|
||||||
|
* @generated from field: string owner_conformance_id = 6;
|
||||||
|
*/
|
||||||
|
ownerConformanceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message quixos.runtime.InvocationContext.
|
||||||
|
* Use `create(InvocationContextSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const InvocationContextSchema: GenMessage<InvocationContext> = /*@__PURE__*/
|
||||||
|
messageDesc(file_quixos_runtime, 2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message quixos.runtime.InvocationControlRequest
|
||||||
|
*/
|
||||||
|
export type InvocationControlRequest = Message<"quixos.runtime.InvocationControlRequest"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string invocation_id = 1;
|
||||||
|
*/
|
||||||
|
invocationId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message quixos.runtime.InvocationControlRequest.
|
||||||
|
* Use `create(InvocationControlRequestSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const InvocationControlRequestSchema: GenMessage<InvocationControlRequest> = /*@__PURE__*/
|
||||||
|
messageDesc(file_quixos_runtime, 3);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message quixos.runtime.InvocationStatus
|
||||||
|
*/
|
||||||
|
export type InvocationStatus = Message<"quixos.runtime.InvocationStatus"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string invocation_id = 1;
|
||||||
|
*/
|
||||||
|
invocationId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unknown, running, cancellation-requested, completed, failed
|
||||||
|
*
|
||||||
|
* @generated from field: string state = 2;
|
||||||
|
*/
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message quixos.runtime.InvocationStatus.
|
||||||
|
* Use `create(InvocationStatusSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const InvocationStatusSchema: GenMessage<InvocationStatus> = /*@__PURE__*/
|
||||||
|
messageDesc(file_quixos_runtime, 4);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message quixos.runtime.InvokeRequest
|
* @generated from message quixos.runtime.InvokeRequest
|
||||||
*/
|
*/
|
||||||
@@ -88,6 +193,11 @@ export type InvokeRequest = Message<"quixos.runtime.InvokeRequest"> & {
|
|||||||
* @generated from field: repeated quixos.InjectedDependency dependencies = 5;
|
* @generated from field: repeated quixos.InjectedDependency dependencies = 5;
|
||||||
*/
|
*/
|
||||||
dependencies: InjectedDependency[];
|
dependencies: InjectedDependency[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: quixos.runtime.InvocationContext context = 6;
|
||||||
|
*/
|
||||||
|
context?: InvocationContext | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,7 +205,7 @@ export type InvokeRequest = Message<"quixos.runtime.InvokeRequest"> & {
|
|||||||
* Use `create(InvokeRequestSchema)` to create a new message.
|
* Use `create(InvokeRequestSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const InvokeRequestSchema: GenMessage<InvokeRequest> = /*@__PURE__*/
|
export const InvokeRequestSchema: GenMessage<InvokeRequest> = /*@__PURE__*/
|
||||||
messageDesc(file_quixos_runtime, 2);
|
messageDesc(file_quixos_runtime, 5);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message quixos.runtime.InvokeResponse
|
* @generated from message quixos.runtime.InvokeResponse
|
||||||
@@ -115,6 +225,11 @@ export type InvokeResponse = Message<"quixos.runtime.InvokeResponse"> & {
|
|||||||
* @generated from field: string error = 3;
|
* @generated from field: string error = 3;
|
||||||
*/
|
*/
|
||||||
error: string;
|
error: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: repeated quixos.runtime.DerivedDependency dependencies = 4;
|
||||||
|
*/
|
||||||
|
dependencies: DerivedDependency[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,7 +237,7 @@ export type InvokeResponse = Message<"quixos.runtime.InvokeResponse"> & {
|
|||||||
* Use `create(InvokeResponseSchema)` to create a new message.
|
* Use `create(InvokeResponseSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const InvokeResponseSchema: GenMessage<InvokeResponse> = /*@__PURE__*/
|
export const InvokeResponseSchema: GenMessage<InvokeResponse> = /*@__PURE__*/
|
||||||
messageDesc(file_quixos_runtime, 3);
|
messageDesc(file_quixos_runtime, 6);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message quixos.runtime.WatchRequest
|
* @generated from message quixos.runtime.WatchRequest
|
||||||
@@ -152,6 +267,11 @@ export type WatchRequest = Message<"quixos.runtime.WatchRequest"> & {
|
|||||||
* @generated from field: repeated quixos.InjectedDependency dependencies = 5;
|
* @generated from field: repeated quixos.InjectedDependency dependencies = 5;
|
||||||
*/
|
*/
|
||||||
dependencies: InjectedDependency[];
|
dependencies: InjectedDependency[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: quixos.runtime.InvocationContext context = 6;
|
||||||
|
*/
|
||||||
|
context?: InvocationContext | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,7 +279,7 @@ export type WatchRequest = Message<"quixos.runtime.WatchRequest"> & {
|
|||||||
* Use `create(WatchRequestSchema)` to create a new message.
|
* Use `create(WatchRequestSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const WatchRequestSchema: GenMessage<WatchRequest> = /*@__PURE__*/
|
export const WatchRequestSchema: GenMessage<WatchRequest> = /*@__PURE__*/
|
||||||
messageDesc(file_quixos_runtime, 4);
|
messageDesc(file_quixos_runtime, 7);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message quixos.runtime.DerivedDependency
|
* @generated from message quixos.runtime.DerivedDependency
|
||||||
@@ -191,7 +311,7 @@ export type DerivedDependency = Message<"quixos.runtime.DerivedDependency"> & {
|
|||||||
* Use `create(DerivedDependencySchema)` to create a new message.
|
* Use `create(DerivedDependencySchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const DerivedDependencySchema: GenMessage<DerivedDependency> = /*@__PURE__*/
|
export const DerivedDependencySchema: GenMessage<DerivedDependency> = /*@__PURE__*/
|
||||||
messageDesc(file_quixos_runtime, 5);
|
messageDesc(file_quixos_runtime, 8);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message quixos.runtime.WatchEvent
|
* @generated from message quixos.runtime.WatchEvent
|
||||||
@@ -228,7 +348,7 @@ export type WatchEvent = Message<"quixos.runtime.WatchEvent"> & {
|
|||||||
* Use `create(WatchEventSchema)` to create a new message.
|
* Use `create(WatchEventSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const WatchEventSchema: GenMessage<WatchEvent> = /*@__PURE__*/
|
export const WatchEventSchema: GenMessage<WatchEvent> = /*@__PURE__*/
|
||||||
messageDesc(file_quixos_runtime, 6);
|
messageDesc(file_quixos_runtime, 9);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from service quixos.runtime.PackageRuntime
|
* @generated from service quixos.runtime.PackageRuntime
|
||||||
@@ -258,6 +378,22 @@ export const PackageRuntime: GenService<{
|
|||||||
input: typeof WatchRequestSchema;
|
input: typeof WatchRequestSchema;
|
||||||
output: typeof WatchEventSchema;
|
output: typeof WatchEventSchema;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc quixos.runtime.PackageRuntime.GetInvocationStatus
|
||||||
|
*/
|
||||||
|
getInvocationStatus: {
|
||||||
|
methodKind: "unary";
|
||||||
|
input: typeof InvocationControlRequestSchema;
|
||||||
|
output: typeof InvocationStatusSchema;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @generated from rpc quixos.runtime.PackageRuntime.CancelInvocation
|
||||||
|
*/
|
||||||
|
cancelInvocation: {
|
||||||
|
methodKind: "unary";
|
||||||
|
input: typeof InvocationControlRequestSchema;
|
||||||
|
output: typeof InvocationStatusSchema;
|
||||||
|
},
|
||||||
}> = /*@__PURE__*/
|
}> = /*@__PURE__*/
|
||||||
serviceDesc(file_quixos_runtime, 0);
|
serviceDesc(file_quixos_runtime, 0);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { readFile } from "node:fs/promises";
|
import { loadQuixosLock } from "./loader.js";
|
||||||
import { parseQuixosLock } from "./parser.js";
|
|
||||||
|
|
||||||
const fileName = process.argv[2];
|
const fileName = process.argv[2];
|
||||||
if (!fileName || process.argv.length !== 3) {
|
if (!fileName || process.argv.length !== 3) {
|
||||||
@@ -8,7 +7,7 @@ if (!fileName || process.argv.length !== 3) {
|
|||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = parseQuixosLock(await readFile(fileName, "utf8"), fileName);
|
const result = await loadQuixosLock(fileName);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
for (const diagnostic of result.diagnostics) {
|
for (const diagnostic of result.diagnostics) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
token literal names:
|
token literal names:
|
||||||
null
|
null
|
||||||
'quixos-lock'
|
'quixos-lock'
|
||||||
|
'fragment'
|
||||||
'version'
|
'version'
|
||||||
'quixos'
|
'quixos'
|
||||||
'source'
|
'source'
|
||||||
@@ -8,6 +9,12 @@ null
|
|||||||
'package'
|
'package'
|
||||||
'repository'
|
'repository'
|
||||||
'commit'
|
'commit'
|
||||||
|
'policy'
|
||||||
|
'ref'
|
||||||
|
'pinned'
|
||||||
|
'track-release'
|
||||||
|
'track-development'
|
||||||
|
'import'
|
||||||
'{'
|
'{'
|
||||||
'}'
|
'}'
|
||||||
';'
|
';'
|
||||||
@@ -21,6 +28,7 @@ null
|
|||||||
token symbolic names:
|
token symbolic names:
|
||||||
null
|
null
|
||||||
QUIXOS_LOCK
|
QUIXOS_LOCK
|
||||||
|
FRAGMENT
|
||||||
VERSION
|
VERSION
|
||||||
QUIXOS
|
QUIXOS
|
||||||
SOURCE
|
SOURCE
|
||||||
@@ -28,6 +36,12 @@ INTERFACE
|
|||||||
PACKAGE
|
PACKAGE
|
||||||
REPOSITORY
|
REPOSITORY
|
||||||
COMMIT
|
COMMIT
|
||||||
|
POLICY
|
||||||
|
REF
|
||||||
|
PINNED
|
||||||
|
TRACK_RELEASE
|
||||||
|
TRACK_DEVELOPMENT
|
||||||
|
IMPORT
|
||||||
LBRACE
|
LBRACE
|
||||||
RBRACE
|
RBRACE
|
||||||
SEMI
|
SEMI
|
||||||
@@ -42,11 +56,14 @@ rule names:
|
|||||||
document
|
document
|
||||||
quixosEntry
|
quixosEntry
|
||||||
resourceEntry
|
resourceEntry
|
||||||
|
importEntry
|
||||||
resourceKind
|
resourceKind
|
||||||
sourceBlock
|
sourceBlock
|
||||||
|
quixosSourceBlock
|
||||||
|
sourcePolicy
|
||||||
identifier
|
identifier
|
||||||
stringLiteral
|
stringLiteral
|
||||||
|
|
||||||
|
|
||||||
atn:
|
atn:
|
||||||
[4, 1, 17, 53, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 5, 0, 21, 8, 0, 10, 0, 12, 0, 24, 9, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 4, 1, 5, 1, 5, 1, 6, 1, 6, 1, 6, 0, 0, 7, 0, 2, 4, 6, 8, 10, 12, 0, 1, 1, 0, 5, 6, 46, 0, 14, 1, 0, 0, 0, 2, 28, 1, 0, 0, 0, 4, 32, 1, 0, 0, 0, 6, 37, 1, 0, 0, 0, 8, 39, 1, 0, 0, 0, 10, 48, 1, 0, 0, 0, 12, 50, 1, 0, 0, 0, 14, 15, 5, 1, 0, 0, 15, 16, 5, 2, 0, 0, 16, 17, 5, 12, 0, 0, 17, 18, 5, 9, 0, 0, 18, 22, 3, 2, 1, 0, 19, 21, 3, 4, 2, 0, 20, 19, 1, 0, 0, 0, 21, 24, 1, 0, 0, 0, 22, 20, 1, 0, 0, 0, 22, 23, 1, 0, 0, 0, 23, 25, 1, 0, 0, 0, 24, 22, 1, 0, 0, 0, 25, 26, 5, 10, 0, 0, 26, 27, 5, 0, 0, 1, 27, 1, 1, 0, 0, 0, 28, 29, 5, 3, 0, 0, 29, 30, 5, 4, 0, 0, 30, 31, 3, 8, 4, 0, 31, 3, 1, 0, 0, 0, 32, 33, 3, 6, 3, 0, 33, 34, 3, 10, 5, 0, 34, 35, 5, 4, 0, 0, 35, 36, 3, 8, 4, 0, 36, 5, 1, 0, 0, 0, 37, 38, 7, 0, 0, 0, 38, 7, 1, 0, 0, 0, 39, 40, 5, 9, 0, 0, 40, 41, 5, 7, 0, 0, 41, 42, 3, 12, 6, 0, 42, 43, 5, 11, 0, 0, 43, 44, 5, 8, 0, 0, 44, 45, 3, 12, 6, 0, 45, 46, 5, 11, 0, 0, 46, 47, 5, 10, 0, 0, 47, 9, 1, 0, 0, 0, 48, 49, 5, 13, 0, 0, 49, 11, 1, 0, 0, 0, 50, 51, 5, 14, 0, 0, 51, 13, 1, 0, 0, 0, 1, 22]
|
[4, 1, 24, 87, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 1, 0, 1, 0, 3, 0, 23, 8, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 5, 0, 31, 8, 0, 10, 0, 12, 0, 34, 9, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 3, 1, 3, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 5, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 3, 6, 74, 8, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 7, 1, 7, 1, 8, 1, 8, 1, 9, 1, 9, 1, 9, 0, 0, 10, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 0, 2, 1, 0, 6, 7, 1, 0, 12, 14, 81, 0, 20, 1, 0, 0, 0, 2, 38, 1, 0, 0, 0, 4, 42, 1, 0, 0, 0, 6, 47, 1, 0, 0, 0, 8, 51, 1, 0, 0, 0, 10, 53, 1, 0, 0, 0, 12, 62, 1, 0, 0, 0, 14, 80, 1, 0, 0, 0, 16, 82, 1, 0, 0, 0, 18, 84, 1, 0, 0, 0, 20, 22, 5, 1, 0, 0, 21, 23, 5, 2, 0, 0, 22, 21, 1, 0, 0, 0, 22, 23, 1, 0, 0, 0, 23, 24, 1, 0, 0, 0, 24, 25, 5, 3, 0, 0, 25, 26, 5, 19, 0, 0, 26, 32, 5, 16, 0, 0, 27, 31, 3, 2, 1, 0, 28, 31, 3, 6, 3, 0, 29, 31, 3, 4, 2, 0, 30, 27, 1, 0, 0, 0, 30, 28, 1, 0, 0, 0, 30, 29, 1, 0, 0, 0, 31, 34, 1, 0, 0, 0, 32, 30, 1, 0, 0, 0, 32, 33, 1, 0, 0, 0, 33, 35, 1, 0, 0, 0, 34, 32, 1, 0, 0, 0, 35, 36, 5, 17, 0, 0, 36, 37, 5, 0, 0, 1, 37, 1, 1, 0, 0, 0, 38, 39, 5, 4, 0, 0, 39, 40, 5, 5, 0, 0, 40, 41, 3, 12, 6, 0, 41, 3, 1, 0, 0, 0, 42, 43, 3, 8, 4, 0, 43, 44, 3, 16, 8, 0, 44, 45, 5, 5, 0, 0, 45, 46, 3, 10, 5, 0, 46, 5, 1, 0, 0, 0, 47, 48, 5, 15, 0, 0, 48, 49, 3, 18, 9, 0, 49, 50, 5, 18, 0, 0, 50, 7, 1, 0, 0, 0, 51, 52, 7, 0, 0, 0, 52, 9, 1, 0, 0, 0, 53, 54, 5, 16, 0, 0, 54, 55, 5, 8, 0, 0, 55, 56, 3, 18, 9, 0, 56, 57, 5, 18, 0, 0, 57, 58, 5, 9, 0, 0, 58, 59, 3, 18, 9, 0, 59, 60, 5, 18, 0, 0, 60, 61, 5, 17, 0, 0, 61, 11, 1, 0, 0, 0, 62, 63, 5, 16, 0, 0, 63, 64, 5, 8, 0, 0, 64, 65, 3, 18, 9, 0, 65, 73, 5, 18, 0, 0, 66, 67, 5, 10, 0, 0, 67, 68, 3, 14, 7, 0, 68, 69, 5, 18, 0, 0, 69, 70, 5, 11, 0, 0, 70, 71, 3, 18, 9, 0, 71, 72, 5, 18, 0, 0, 72, 74, 1, 0, 0, 0, 73, 66, 1, 0, 0, 0, 73, 74, 1, 0, 0, 0, 74, 75, 1, 0, 0, 0, 75, 76, 5, 9, 0, 0, 76, 77, 3, 18, 9, 0, 77, 78, 5, 18, 0, 0, 78, 79, 5, 17, 0, 0, 79, 13, 1, 0, 0, 0, 80, 81, 7, 1, 0, 0, 81, 15, 1, 0, 0, 0, 82, 83, 5, 20, 0, 0, 83, 17, 1, 0, 0, 0, 84, 85, 5, 21, 0, 0, 85, 19, 1, 0, 0, 0, 4, 22, 30, 32, 73]
|
||||||
@@ -1,28 +1,42 @@
|
|||||||
QUIXOS_LOCK=1
|
QUIXOS_LOCK=1
|
||||||
VERSION=2
|
FRAGMENT=2
|
||||||
QUIXOS=3
|
VERSION=3
|
||||||
SOURCE=4
|
QUIXOS=4
|
||||||
INTERFACE=5
|
SOURCE=5
|
||||||
PACKAGE=6
|
INTERFACE=6
|
||||||
REPOSITORY=7
|
PACKAGE=7
|
||||||
COMMIT=8
|
REPOSITORY=8
|
||||||
LBRACE=9
|
COMMIT=9
|
||||||
RBRACE=10
|
POLICY=10
|
||||||
SEMI=11
|
REF=11
|
||||||
INTEGER=12
|
PINNED=12
|
||||||
IDENTIFIER=13
|
TRACK_RELEASE=13
|
||||||
STRING_LITERAL=14
|
TRACK_DEVELOPMENT=14
|
||||||
LINE_COMMENT=15
|
IMPORT=15
|
||||||
BLOCK_COMMENT=16
|
LBRACE=16
|
||||||
WS=17
|
RBRACE=17
|
||||||
|
SEMI=18
|
||||||
|
INTEGER=19
|
||||||
|
IDENTIFIER=20
|
||||||
|
STRING_LITERAL=21
|
||||||
|
LINE_COMMENT=22
|
||||||
|
BLOCK_COMMENT=23
|
||||||
|
WS=24
|
||||||
'quixos-lock'=1
|
'quixos-lock'=1
|
||||||
'version'=2
|
'fragment'=2
|
||||||
'quixos'=3
|
'version'=3
|
||||||
'source'=4
|
'quixos'=4
|
||||||
'interface'=5
|
'source'=5
|
||||||
'package'=6
|
'interface'=6
|
||||||
'repository'=7
|
'package'=7
|
||||||
'commit'=8
|
'repository'=8
|
||||||
'{'=9
|
'commit'=9
|
||||||
'}'=10
|
'policy'=10
|
||||||
';'=11
|
'ref'=11
|
||||||
|
'pinned'=12
|
||||||
|
'track-release'=13
|
||||||
|
'track-development'=14
|
||||||
|
'import'=15
|
||||||
|
'{'=16
|
||||||
|
'}'=17
|
||||||
|
';'=18
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,28 +1,42 @@
|
|||||||
QUIXOS_LOCK=1
|
QUIXOS_LOCK=1
|
||||||
VERSION=2
|
FRAGMENT=2
|
||||||
QUIXOS=3
|
VERSION=3
|
||||||
SOURCE=4
|
QUIXOS=4
|
||||||
INTERFACE=5
|
SOURCE=5
|
||||||
PACKAGE=6
|
INTERFACE=6
|
||||||
REPOSITORY=7
|
PACKAGE=7
|
||||||
COMMIT=8
|
REPOSITORY=8
|
||||||
LBRACE=9
|
COMMIT=9
|
||||||
RBRACE=10
|
POLICY=10
|
||||||
SEMI=11
|
REF=11
|
||||||
INTEGER=12
|
PINNED=12
|
||||||
IDENTIFIER=13
|
TRACK_RELEASE=13
|
||||||
STRING_LITERAL=14
|
TRACK_DEVELOPMENT=14
|
||||||
LINE_COMMENT=15
|
IMPORT=15
|
||||||
BLOCK_COMMENT=16
|
LBRACE=16
|
||||||
WS=17
|
RBRACE=17
|
||||||
|
SEMI=18
|
||||||
|
INTEGER=19
|
||||||
|
IDENTIFIER=20
|
||||||
|
STRING_LITERAL=21
|
||||||
|
LINE_COMMENT=22
|
||||||
|
BLOCK_COMMENT=23
|
||||||
|
WS=24
|
||||||
'quixos-lock'=1
|
'quixos-lock'=1
|
||||||
'version'=2
|
'fragment'=2
|
||||||
'quixos'=3
|
'version'=3
|
||||||
'source'=4
|
'quixos'=4
|
||||||
'interface'=5
|
'source'=5
|
||||||
'package'=6
|
'interface'=6
|
||||||
'repository'=7
|
'package'=7
|
||||||
'commit'=8
|
'repository'=8
|
||||||
'{'=9
|
'commit'=9
|
||||||
'}'=10
|
'policy'=10
|
||||||
';'=11
|
'ref'=11
|
||||||
|
'pinned'=12
|
||||||
|
'track-release'=13
|
||||||
|
'track-development'=14
|
||||||
|
'import'=15
|
||||||
|
'{'=16
|
||||||
|
'}'=17
|
||||||
|
';'=18
|
||||||
|
|||||||
@@ -5,37 +5,47 @@ import { Token } from "antlr4ng";
|
|||||||
|
|
||||||
export class QuixosLockLexer extends antlr.Lexer {
|
export class QuixosLockLexer extends antlr.Lexer {
|
||||||
public static readonly QUIXOS_LOCK = 1;
|
public static readonly QUIXOS_LOCK = 1;
|
||||||
public static readonly VERSION = 2;
|
public static readonly FRAGMENT = 2;
|
||||||
public static readonly QUIXOS = 3;
|
public static readonly VERSION = 3;
|
||||||
public static readonly SOURCE = 4;
|
public static readonly QUIXOS = 4;
|
||||||
public static readonly INTERFACE = 5;
|
public static readonly SOURCE = 5;
|
||||||
public static readonly PACKAGE = 6;
|
public static readonly INTERFACE = 6;
|
||||||
public static readonly REPOSITORY = 7;
|
public static readonly PACKAGE = 7;
|
||||||
public static readonly COMMIT = 8;
|
public static readonly REPOSITORY = 8;
|
||||||
public static readonly LBRACE = 9;
|
public static readonly COMMIT = 9;
|
||||||
public static readonly RBRACE = 10;
|
public static readonly POLICY = 10;
|
||||||
public static readonly SEMI = 11;
|
public static readonly REF = 11;
|
||||||
public static readonly INTEGER = 12;
|
public static readonly PINNED = 12;
|
||||||
public static readonly IDENTIFIER = 13;
|
public static readonly TRACK_RELEASE = 13;
|
||||||
public static readonly STRING_LITERAL = 14;
|
public static readonly TRACK_DEVELOPMENT = 14;
|
||||||
public static readonly LINE_COMMENT = 15;
|
public static readonly IMPORT = 15;
|
||||||
public static readonly BLOCK_COMMENT = 16;
|
public static readonly LBRACE = 16;
|
||||||
public static readonly WS = 17;
|
public static readonly RBRACE = 17;
|
||||||
|
public static readonly SEMI = 18;
|
||||||
|
public static readonly INTEGER = 19;
|
||||||
|
public static readonly IDENTIFIER = 20;
|
||||||
|
public static readonly STRING_LITERAL = 21;
|
||||||
|
public static readonly LINE_COMMENT = 22;
|
||||||
|
public static readonly BLOCK_COMMENT = 23;
|
||||||
|
public static readonly WS = 24;
|
||||||
|
|
||||||
public static readonly channelNames = [
|
public static readonly channelNames = [
|
||||||
"DEFAULT_TOKEN_CHANNEL", "HIDDEN"
|
"DEFAULT_TOKEN_CHANNEL", "HIDDEN"
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly literalNames = [
|
public static readonly literalNames = [
|
||||||
null, "'quixos-lock'", "'version'", "'quixos'", "'source'", "'interface'",
|
null, "'quixos-lock'", "'fragment'", "'version'", "'quixos'", "'source'",
|
||||||
"'package'", "'repository'", "'commit'", "'{'", "'}'", "';'"
|
"'interface'", "'package'", "'repository'", "'commit'", "'policy'",
|
||||||
|
"'ref'", "'pinned'", "'track-release'", "'track-development'", "'import'",
|
||||||
|
"'{'", "'}'", "';'"
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly symbolicNames = [
|
public static readonly symbolicNames = [
|
||||||
null, "QUIXOS_LOCK", "VERSION", "QUIXOS", "SOURCE", "INTERFACE",
|
null, "QUIXOS_LOCK", "FRAGMENT", "VERSION", "QUIXOS", "SOURCE",
|
||||||
"PACKAGE", "REPOSITORY", "COMMIT", "LBRACE", "RBRACE", "SEMI", "INTEGER",
|
"INTERFACE", "PACKAGE", "REPOSITORY", "COMMIT", "POLICY", "REF",
|
||||||
"IDENTIFIER", "STRING_LITERAL", "LINE_COMMENT", "BLOCK_COMMENT",
|
"PINNED", "TRACK_RELEASE", "TRACK_DEVELOPMENT", "IMPORT", "LBRACE",
|
||||||
"WS"
|
"RBRACE", "SEMI", "INTEGER", "IDENTIFIER", "STRING_LITERAL", "LINE_COMMENT",
|
||||||
|
"BLOCK_COMMENT", "WS"
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly modeNames = [
|
public static readonly modeNames = [
|
||||||
@@ -43,9 +53,10 @@ export class QuixosLockLexer extends antlr.Lexer {
|
|||||||
];
|
];
|
||||||
|
|
||||||
public static readonly ruleNames = [
|
public static readonly ruleNames = [
|
||||||
"QUIXOS_LOCK", "VERSION", "QUIXOS", "SOURCE", "INTERFACE", "PACKAGE",
|
"QUIXOS_LOCK", "FRAGMENT", "VERSION", "QUIXOS", "SOURCE", "INTERFACE",
|
||||||
"REPOSITORY", "COMMIT", "LBRACE", "RBRACE", "SEMI", "INTEGER", "IDENTIFIER",
|
"PACKAGE", "REPOSITORY", "COMMIT", "POLICY", "REF", "PINNED", "TRACK_RELEASE",
|
||||||
"STRING_LITERAL", "ESC", "HEX", "LINE_COMMENT", "BLOCK_COMMENT",
|
"TRACK_DEVELOPMENT", "IMPORT", "LBRACE", "RBRACE", "SEMI", "INTEGER",
|
||||||
|
"IDENTIFIER", "STRING_LITERAL", "ESC", "HEX", "LINE_COMMENT", "BLOCK_COMMENT",
|
||||||
"WS",
|
"WS",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -68,70 +79,98 @@ export class QuixosLockLexer extends antlr.Lexer {
|
|||||||
public get modeNames(): string[] { return QuixosLockLexer.modeNames; }
|
public get modeNames(): string[] { return QuixosLockLexer.modeNames; }
|
||||||
|
|
||||||
public static readonly _serializedATN: number[] = [
|
public static readonly _serializedATN: number[] = [
|
||||||
4,0,17,181,6,-1,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,
|
4,0,24,261,6,-1,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,
|
||||||
2,6,7,6,2,7,7,7,2,8,7,8,2,9,7,9,2,10,7,10,2,11,7,11,2,12,7,12,2,
|
2,6,7,6,2,7,7,7,2,8,7,8,2,9,7,9,2,10,7,10,2,11,7,11,2,12,7,12,2,
|
||||||
13,7,13,2,14,7,14,2,15,7,15,2,16,7,16,2,17,7,17,2,18,7,18,1,0,1,
|
13,7,13,2,14,7,14,2,15,7,15,2,16,7,16,2,17,7,17,2,18,7,18,2,19,7,
|
||||||
0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,1,1,1,1,1,1,1,1,1,1,
|
19,2,20,7,20,2,21,7,21,2,22,7,22,2,23,7,23,2,24,7,24,2,25,7,25,1,
|
||||||
1,1,1,1,1,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,3,1,3,1,3,1,3,1,3,1,3,1,
|
0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,1,1,1,1,1,1,1,1,
|
||||||
3,1,4,1,4,1,4,1,4,1,4,1,4,1,4,1,4,1,4,1,4,1,5,1,5,1,5,1,5,1,5,1,
|
1,1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,3,1,3,1,3,1,
|
||||||
5,1,5,1,5,1,6,1,6,1,6,1,6,1,6,1,6,1,6,1,6,1,6,1,6,1,6,1,7,1,7,1,
|
3,1,3,1,3,1,3,1,4,1,4,1,4,1,4,1,4,1,4,1,4,1,5,1,5,1,5,1,5,1,5,1,
|
||||||
7,1,7,1,7,1,7,1,7,1,8,1,8,1,9,1,9,1,10,1,10,1,11,4,11,117,8,11,11,
|
5,1,5,1,5,1,5,1,5,1,6,1,6,1,6,1,6,1,6,1,6,1,6,1,6,1,7,1,7,1,7,1,
|
||||||
11,12,11,118,1,12,1,12,5,12,123,8,12,10,12,12,12,126,9,12,1,13,1,
|
7,1,7,1,7,1,7,1,7,1,7,1,7,1,7,1,8,1,8,1,8,1,8,1,8,1,8,1,8,1,9,1,
|
||||||
13,1,13,5,13,131,8,13,10,13,12,13,134,9,13,1,13,1,13,1,14,1,14,1,
|
9,1,9,1,9,1,9,1,9,1,9,1,10,1,10,1,10,1,10,1,11,1,11,1,11,1,11,1,
|
||||||
14,1,14,1,14,1,14,1,14,1,14,3,14,146,8,14,1,15,1,15,1,16,1,16,1,
|
11,1,11,1,11,1,12,1,12,1,12,1,12,1,12,1,12,1,12,1,12,1,12,1,12,1,
|
||||||
16,1,16,5,16,154,8,16,10,16,12,16,157,9,16,1,16,1,16,1,17,1,17,1,
|
12,1,12,1,12,1,12,1,13,1,13,1,13,1,13,1,13,1,13,1,13,1,13,1,13,1,
|
||||||
17,1,17,5,17,165,8,17,10,17,12,17,168,9,17,1,17,1,17,1,17,1,17,1,
|
13,1,13,1,13,1,13,1,13,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,
|
||||||
17,1,18,4,18,176,8,18,11,18,12,18,177,1,18,1,18,1,166,0,19,1,1,3,
|
14,1,14,1,14,1,15,1,15,1,16,1,16,1,17,1,17,1,18,4,18,197,8,18,11,
|
||||||
|
18,12,18,198,1,19,1,19,5,19,203,8,19,10,19,12,19,206,9,19,1,20,1,
|
||||||
|
20,1,20,5,20,211,8,20,10,20,12,20,214,9,20,1,20,1,20,1,21,1,21,1,
|
||||||
|
21,1,21,1,21,1,21,1,21,1,21,3,21,226,8,21,1,22,1,22,1,23,1,23,1,
|
||||||
|
23,1,23,5,23,234,8,23,10,23,12,23,237,9,23,1,23,1,23,1,24,1,24,1,
|
||||||
|
24,1,24,5,24,245,8,24,10,24,12,24,248,9,24,1,24,1,24,1,24,1,24,1,
|
||||||
|
24,1,25,4,25,256,8,25,11,25,12,25,257,1,25,1,25,1,246,0,26,1,1,3,
|
||||||
2,5,3,7,4,9,5,11,6,13,7,15,8,17,9,19,10,21,11,23,12,25,13,27,14,
|
2,5,3,7,4,9,5,11,6,13,7,15,8,17,9,19,10,21,11,23,12,25,13,27,14,
|
||||||
29,0,31,0,33,15,35,16,37,17,1,0,8,1,0,48,57,3,0,65,90,95,95,97,122,
|
29,15,31,16,33,17,35,18,37,19,39,20,41,21,43,0,45,0,47,22,49,23,
|
||||||
4,0,48,57,65,90,95,95,97,122,4,0,10,10,13,13,34,34,92,92,8,0,34,
|
51,24,1,0,8,1,0,48,57,3,0,65,90,95,95,97,122,4,0,48,57,65,90,95,
|
||||||
34,47,47,92,92,98,98,102,102,110,110,114,114,116,116,3,0,48,57,65,
|
95,97,122,4,0,10,10,13,13,34,34,92,92,8,0,34,34,47,47,92,92,98,98,
|
||||||
70,97,102,2,0,10,10,13,13,3,0,9,10,13,13,32,32,186,0,1,1,0,0,0,0,
|
102,102,110,110,114,114,116,116,3,0,48,57,65,70,97,102,2,0,10,10,
|
||||||
3,1,0,0,0,0,5,1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,13,
|
13,13,3,0,9,10,13,13,32,32,266,0,1,1,0,0,0,0,3,1,0,0,0,0,5,1,0,0,
|
||||||
1,0,0,0,0,15,1,0,0,0,0,17,1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,
|
0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,
|
||||||
1,0,0,0,0,25,1,0,0,0,0,27,1,0,0,0,0,33,1,0,0,0,0,35,1,0,0,0,0,37,
|
0,17,1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,0,0,0,25,1,0,0,0,
|
||||||
1,0,0,0,1,39,1,0,0,0,3,51,1,0,0,0,5,59,1,0,0,0,7,66,1,0,0,0,9,73,
|
0,27,1,0,0,0,0,29,1,0,0,0,0,31,1,0,0,0,0,33,1,0,0,0,0,35,1,0,0,0,
|
||||||
1,0,0,0,11,83,1,0,0,0,13,91,1,0,0,0,15,102,1,0,0,0,17,109,1,0,0,
|
0,37,1,0,0,0,0,39,1,0,0,0,0,41,1,0,0,0,0,47,1,0,0,0,0,49,1,0,0,0,
|
||||||
0,19,111,1,0,0,0,21,113,1,0,0,0,23,116,1,0,0,0,25,120,1,0,0,0,27,
|
0,51,1,0,0,0,1,53,1,0,0,0,3,65,1,0,0,0,5,74,1,0,0,0,7,82,1,0,0,0,
|
||||||
127,1,0,0,0,29,137,1,0,0,0,31,147,1,0,0,0,33,149,1,0,0,0,35,160,
|
9,89,1,0,0,0,11,96,1,0,0,0,13,106,1,0,0,0,15,114,1,0,0,0,17,125,
|
||||||
1,0,0,0,37,175,1,0,0,0,39,40,5,113,0,0,40,41,5,117,0,0,41,42,5,105,
|
1,0,0,0,19,132,1,0,0,0,21,139,1,0,0,0,23,143,1,0,0,0,25,150,1,0,
|
||||||
0,0,42,43,5,120,0,0,43,44,5,111,0,0,44,45,5,115,0,0,45,46,5,45,0,
|
0,0,27,164,1,0,0,0,29,182,1,0,0,0,31,189,1,0,0,0,33,191,1,0,0,0,
|
||||||
0,46,47,5,108,0,0,47,48,5,111,0,0,48,49,5,99,0,0,49,50,5,107,0,0,
|
35,193,1,0,0,0,37,196,1,0,0,0,39,200,1,0,0,0,41,207,1,0,0,0,43,217,
|
||||||
50,2,1,0,0,0,51,52,5,118,0,0,52,53,5,101,0,0,53,54,5,114,0,0,54,
|
1,0,0,0,45,227,1,0,0,0,47,229,1,0,0,0,49,240,1,0,0,0,51,255,1,0,
|
||||||
55,5,115,0,0,55,56,5,105,0,0,56,57,5,111,0,0,57,58,5,110,0,0,58,
|
0,0,53,54,5,113,0,0,54,55,5,117,0,0,55,56,5,105,0,0,56,57,5,120,
|
||||||
4,1,0,0,0,59,60,5,113,0,0,60,61,5,117,0,0,61,62,5,105,0,0,62,63,
|
0,0,57,58,5,111,0,0,58,59,5,115,0,0,59,60,5,45,0,0,60,61,5,108,0,
|
||||||
5,120,0,0,63,64,5,111,0,0,64,65,5,115,0,0,65,6,1,0,0,0,66,67,5,115,
|
0,61,62,5,111,0,0,62,63,5,99,0,0,63,64,5,107,0,0,64,2,1,0,0,0,65,
|
||||||
0,0,67,68,5,111,0,0,68,69,5,117,0,0,69,70,5,114,0,0,70,71,5,99,0,
|
66,5,102,0,0,66,67,5,114,0,0,67,68,5,97,0,0,68,69,5,103,0,0,69,70,
|
||||||
0,71,72,5,101,0,0,72,8,1,0,0,0,73,74,5,105,0,0,74,75,5,110,0,0,75,
|
5,109,0,0,70,71,5,101,0,0,71,72,5,110,0,0,72,73,5,116,0,0,73,4,1,
|
||||||
76,5,116,0,0,76,77,5,101,0,0,77,78,5,114,0,0,78,79,5,102,0,0,79,
|
0,0,0,74,75,5,118,0,0,75,76,5,101,0,0,76,77,5,114,0,0,77,78,5,115,
|
||||||
80,5,97,0,0,80,81,5,99,0,0,81,82,5,101,0,0,82,10,1,0,0,0,83,84,5,
|
0,0,78,79,5,105,0,0,79,80,5,111,0,0,80,81,5,110,0,0,81,6,1,0,0,0,
|
||||||
112,0,0,84,85,5,97,0,0,85,86,5,99,0,0,86,87,5,107,0,0,87,88,5,97,
|
82,83,5,113,0,0,83,84,5,117,0,0,84,85,5,105,0,0,85,86,5,120,0,0,
|
||||||
0,0,88,89,5,103,0,0,89,90,5,101,0,0,90,12,1,0,0,0,91,92,5,114,0,
|
86,87,5,111,0,0,87,88,5,115,0,0,88,8,1,0,0,0,89,90,5,115,0,0,90,
|
||||||
0,92,93,5,101,0,0,93,94,5,112,0,0,94,95,5,111,0,0,95,96,5,115,0,
|
91,5,111,0,0,91,92,5,117,0,0,92,93,5,114,0,0,93,94,5,99,0,0,94,95,
|
||||||
0,96,97,5,105,0,0,97,98,5,116,0,0,98,99,5,111,0,0,99,100,5,114,0,
|
5,101,0,0,95,10,1,0,0,0,96,97,5,105,0,0,97,98,5,110,0,0,98,99,5,
|
||||||
0,100,101,5,121,0,0,101,14,1,0,0,0,102,103,5,99,0,0,103,104,5,111,
|
116,0,0,99,100,5,101,0,0,100,101,5,114,0,0,101,102,5,102,0,0,102,
|
||||||
0,0,104,105,5,109,0,0,105,106,5,109,0,0,106,107,5,105,0,0,107,108,
|
103,5,97,0,0,103,104,5,99,0,0,104,105,5,101,0,0,105,12,1,0,0,0,106,
|
||||||
5,116,0,0,108,16,1,0,0,0,109,110,5,123,0,0,110,18,1,0,0,0,111,112,
|
107,5,112,0,0,107,108,5,97,0,0,108,109,5,99,0,0,109,110,5,107,0,
|
||||||
5,125,0,0,112,20,1,0,0,0,113,114,5,59,0,0,114,22,1,0,0,0,115,117,
|
0,110,111,5,97,0,0,111,112,5,103,0,0,112,113,5,101,0,0,113,14,1,
|
||||||
7,0,0,0,116,115,1,0,0,0,117,118,1,0,0,0,118,116,1,0,0,0,118,119,
|
0,0,0,114,115,5,114,0,0,115,116,5,101,0,0,116,117,5,112,0,0,117,
|
||||||
1,0,0,0,119,24,1,0,0,0,120,124,7,1,0,0,121,123,7,2,0,0,122,121,1,
|
118,5,111,0,0,118,119,5,115,0,0,119,120,5,105,0,0,120,121,5,116,
|
||||||
0,0,0,123,126,1,0,0,0,124,122,1,0,0,0,124,125,1,0,0,0,125,26,1,0,
|
0,0,121,122,5,111,0,0,122,123,5,114,0,0,123,124,5,121,0,0,124,16,
|
||||||
0,0,126,124,1,0,0,0,127,132,5,34,0,0,128,131,3,29,14,0,129,131,8,
|
1,0,0,0,125,126,5,99,0,0,126,127,5,111,0,0,127,128,5,109,0,0,128,
|
||||||
3,0,0,130,128,1,0,0,0,130,129,1,0,0,0,131,134,1,0,0,0,132,130,1,
|
129,5,109,0,0,129,130,5,105,0,0,130,131,5,116,0,0,131,18,1,0,0,0,
|
||||||
0,0,0,132,133,1,0,0,0,133,135,1,0,0,0,134,132,1,0,0,0,135,136,5,
|
132,133,5,112,0,0,133,134,5,111,0,0,134,135,5,108,0,0,135,136,5,
|
||||||
34,0,0,136,28,1,0,0,0,137,145,5,92,0,0,138,146,7,4,0,0,139,140,5,
|
105,0,0,136,137,5,99,0,0,137,138,5,121,0,0,138,20,1,0,0,0,139,140,
|
||||||
117,0,0,140,141,3,31,15,0,141,142,3,31,15,0,142,143,3,31,15,0,143,
|
5,114,0,0,140,141,5,101,0,0,141,142,5,102,0,0,142,22,1,0,0,0,143,
|
||||||
144,3,31,15,0,144,146,1,0,0,0,145,138,1,0,0,0,145,139,1,0,0,0,146,
|
144,5,112,0,0,144,145,5,105,0,0,145,146,5,110,0,0,146,147,5,110,
|
||||||
30,1,0,0,0,147,148,7,5,0,0,148,32,1,0,0,0,149,150,5,47,0,0,150,151,
|
0,0,147,148,5,101,0,0,148,149,5,100,0,0,149,24,1,0,0,0,150,151,5,
|
||||||
5,47,0,0,151,155,1,0,0,0,152,154,8,6,0,0,153,152,1,0,0,0,154,157,
|
116,0,0,151,152,5,114,0,0,152,153,5,97,0,0,153,154,5,99,0,0,154,
|
||||||
1,0,0,0,155,153,1,0,0,0,155,156,1,0,0,0,156,158,1,0,0,0,157,155,
|
155,5,107,0,0,155,156,5,45,0,0,156,157,5,114,0,0,157,158,5,101,0,
|
||||||
1,0,0,0,158,159,6,16,0,0,159,34,1,0,0,0,160,161,5,47,0,0,161,162,
|
0,158,159,5,108,0,0,159,160,5,101,0,0,160,161,5,97,0,0,161,162,5,
|
||||||
5,42,0,0,162,166,1,0,0,0,163,165,9,0,0,0,164,163,1,0,0,0,165,168,
|
115,0,0,162,163,5,101,0,0,163,26,1,0,0,0,164,165,5,116,0,0,165,166,
|
||||||
1,0,0,0,166,167,1,0,0,0,166,164,1,0,0,0,167,169,1,0,0,0,168,166,
|
5,114,0,0,166,167,5,97,0,0,167,168,5,99,0,0,168,169,5,107,0,0,169,
|
||||||
1,0,0,0,169,170,5,42,0,0,170,171,5,47,0,0,171,172,1,0,0,0,172,173,
|
170,5,45,0,0,170,171,5,100,0,0,171,172,5,101,0,0,172,173,5,118,0,
|
||||||
6,17,0,0,173,36,1,0,0,0,174,176,7,7,0,0,175,174,1,0,0,0,176,177,
|
0,173,174,5,101,0,0,174,175,5,108,0,0,175,176,5,111,0,0,176,177,
|
||||||
1,0,0,0,177,175,1,0,0,0,177,178,1,0,0,0,178,179,1,0,0,0,179,180,
|
5,112,0,0,177,178,5,109,0,0,178,179,5,101,0,0,179,180,5,110,0,0,
|
||||||
6,18,0,0,180,38,1,0,0,0,9,0,118,124,130,132,145,155,166,177,1,6,
|
180,181,5,116,0,0,181,28,1,0,0,0,182,183,5,105,0,0,183,184,5,109,
|
||||||
|
0,0,184,185,5,112,0,0,185,186,5,111,0,0,186,187,5,114,0,0,187,188,
|
||||||
|
5,116,0,0,188,30,1,0,0,0,189,190,5,123,0,0,190,32,1,0,0,0,191,192,
|
||||||
|
5,125,0,0,192,34,1,0,0,0,193,194,5,59,0,0,194,36,1,0,0,0,195,197,
|
||||||
|
7,0,0,0,196,195,1,0,0,0,197,198,1,0,0,0,198,196,1,0,0,0,198,199,
|
||||||
|
1,0,0,0,199,38,1,0,0,0,200,204,7,1,0,0,201,203,7,2,0,0,202,201,1,
|
||||||
|
0,0,0,203,206,1,0,0,0,204,202,1,0,0,0,204,205,1,0,0,0,205,40,1,0,
|
||||||
|
0,0,206,204,1,0,0,0,207,212,5,34,0,0,208,211,3,43,21,0,209,211,8,
|
||||||
|
3,0,0,210,208,1,0,0,0,210,209,1,0,0,0,211,214,1,0,0,0,212,210,1,
|
||||||
|
0,0,0,212,213,1,0,0,0,213,215,1,0,0,0,214,212,1,0,0,0,215,216,5,
|
||||||
|
34,0,0,216,42,1,0,0,0,217,225,5,92,0,0,218,226,7,4,0,0,219,220,5,
|
||||||
|
117,0,0,220,221,3,45,22,0,221,222,3,45,22,0,222,223,3,45,22,0,223,
|
||||||
|
224,3,45,22,0,224,226,1,0,0,0,225,218,1,0,0,0,225,219,1,0,0,0,226,
|
||||||
|
44,1,0,0,0,227,228,7,5,0,0,228,46,1,0,0,0,229,230,5,47,0,0,230,231,
|
||||||
|
5,47,0,0,231,235,1,0,0,0,232,234,8,6,0,0,233,232,1,0,0,0,234,237,
|
||||||
|
1,0,0,0,235,233,1,0,0,0,235,236,1,0,0,0,236,238,1,0,0,0,237,235,
|
||||||
|
1,0,0,0,238,239,6,23,0,0,239,48,1,0,0,0,240,241,5,47,0,0,241,242,
|
||||||
|
5,42,0,0,242,246,1,0,0,0,243,245,9,0,0,0,244,243,1,0,0,0,245,248,
|
||||||
|
1,0,0,0,246,247,1,0,0,0,246,244,1,0,0,0,247,249,1,0,0,0,248,246,
|
||||||
|
1,0,0,0,249,250,5,42,0,0,250,251,5,47,0,0,251,252,1,0,0,0,252,253,
|
||||||
|
6,24,0,0,253,50,1,0,0,0,254,256,7,7,0,0,255,254,1,0,0,0,256,257,
|
||||||
|
1,0,0,0,257,255,1,0,0,0,257,258,1,0,0,0,258,259,1,0,0,0,259,260,
|
||||||
|
6,25,0,0,260,52,1,0,0,0,9,0,198,204,210,212,225,235,246,257,1,6,
|
||||||
0,0
|
0,0
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -11,44 +11,58 @@ type int = number;
|
|||||||
|
|
||||||
export class QuixosLockParser extends antlr.Parser {
|
export class QuixosLockParser extends antlr.Parser {
|
||||||
public static readonly QUIXOS_LOCK = 1;
|
public static readonly QUIXOS_LOCK = 1;
|
||||||
public static readonly VERSION = 2;
|
public static readonly FRAGMENT = 2;
|
||||||
public static readonly QUIXOS = 3;
|
public static readonly VERSION = 3;
|
||||||
public static readonly SOURCE = 4;
|
public static readonly QUIXOS = 4;
|
||||||
public static readonly INTERFACE = 5;
|
public static readonly SOURCE = 5;
|
||||||
public static readonly PACKAGE = 6;
|
public static readonly INTERFACE = 6;
|
||||||
public static readonly REPOSITORY = 7;
|
public static readonly PACKAGE = 7;
|
||||||
public static readonly COMMIT = 8;
|
public static readonly REPOSITORY = 8;
|
||||||
public static readonly LBRACE = 9;
|
public static readonly COMMIT = 9;
|
||||||
public static readonly RBRACE = 10;
|
public static readonly POLICY = 10;
|
||||||
public static readonly SEMI = 11;
|
public static readonly REF = 11;
|
||||||
public static readonly INTEGER = 12;
|
public static readonly PINNED = 12;
|
||||||
public static readonly IDENTIFIER = 13;
|
public static readonly TRACK_RELEASE = 13;
|
||||||
public static readonly STRING_LITERAL = 14;
|
public static readonly TRACK_DEVELOPMENT = 14;
|
||||||
public static readonly LINE_COMMENT = 15;
|
public static readonly IMPORT = 15;
|
||||||
public static readonly BLOCK_COMMENT = 16;
|
public static readonly LBRACE = 16;
|
||||||
public static readonly WS = 17;
|
public static readonly RBRACE = 17;
|
||||||
|
public static readonly SEMI = 18;
|
||||||
|
public static readonly INTEGER = 19;
|
||||||
|
public static readonly IDENTIFIER = 20;
|
||||||
|
public static readonly STRING_LITERAL = 21;
|
||||||
|
public static readonly LINE_COMMENT = 22;
|
||||||
|
public static readonly BLOCK_COMMENT = 23;
|
||||||
|
public static readonly WS = 24;
|
||||||
public static readonly RULE_document = 0;
|
public static readonly RULE_document = 0;
|
||||||
public static readonly RULE_quixosEntry = 1;
|
public static readonly RULE_quixosEntry = 1;
|
||||||
public static readonly RULE_resourceEntry = 2;
|
public static readonly RULE_resourceEntry = 2;
|
||||||
public static readonly RULE_resourceKind = 3;
|
public static readonly RULE_importEntry = 3;
|
||||||
public static readonly RULE_sourceBlock = 4;
|
public static readonly RULE_resourceKind = 4;
|
||||||
public static readonly RULE_identifier = 5;
|
public static readonly RULE_sourceBlock = 5;
|
||||||
public static readonly RULE_stringLiteral = 6;
|
public static readonly RULE_quixosSourceBlock = 6;
|
||||||
|
public static readonly RULE_sourcePolicy = 7;
|
||||||
|
public static readonly RULE_identifier = 8;
|
||||||
|
public static readonly RULE_stringLiteral = 9;
|
||||||
|
|
||||||
public static readonly literalNames = [
|
public static readonly literalNames = [
|
||||||
null, "'quixos-lock'", "'version'", "'quixos'", "'source'", "'interface'",
|
null, "'quixos-lock'", "'fragment'", "'version'", "'quixos'", "'source'",
|
||||||
"'package'", "'repository'", "'commit'", "'{'", "'}'", "';'"
|
"'interface'", "'package'", "'repository'", "'commit'", "'policy'",
|
||||||
|
"'ref'", "'pinned'", "'track-release'", "'track-development'", "'import'",
|
||||||
|
"'{'", "'}'", "';'"
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly symbolicNames = [
|
public static readonly symbolicNames = [
|
||||||
null, "QUIXOS_LOCK", "VERSION", "QUIXOS", "SOURCE", "INTERFACE",
|
null, "QUIXOS_LOCK", "FRAGMENT", "VERSION", "QUIXOS", "SOURCE",
|
||||||
"PACKAGE", "REPOSITORY", "COMMIT", "LBRACE", "RBRACE", "SEMI", "INTEGER",
|
"INTERFACE", "PACKAGE", "REPOSITORY", "COMMIT", "POLICY", "REF",
|
||||||
"IDENTIFIER", "STRING_LITERAL", "LINE_COMMENT", "BLOCK_COMMENT",
|
"PINNED", "TRACK_RELEASE", "TRACK_DEVELOPMENT", "IMPORT", "LBRACE",
|
||||||
"WS"
|
"RBRACE", "SEMI", "INTEGER", "IDENTIFIER", "STRING_LITERAL", "LINE_COMMENT",
|
||||||
|
"BLOCK_COMMENT", "WS"
|
||||||
];
|
];
|
||||||
public static readonly ruleNames = [
|
public static readonly ruleNames = [
|
||||||
"document", "quixosEntry", "resourceEntry", "resourceKind", "sourceBlock",
|
"document", "quixosEntry", "resourceEntry", "importEntry", "resourceKind",
|
||||||
"identifier", "stringLiteral",
|
"sourceBlock", "quixosSourceBlock", "sourcePolicy", "identifier",
|
||||||
|
"stringLiteral",
|
||||||
];
|
];
|
||||||
|
|
||||||
public get grammarFileName(): string { return "QuixosLock.g4"; }
|
public get grammarFileName(): string { return "QuixosLock.g4"; }
|
||||||
@@ -72,33 +86,62 @@ export class QuixosLockParser extends antlr.Parser {
|
|||||||
try {
|
try {
|
||||||
this.enterOuterAlt(localContext, 1);
|
this.enterOuterAlt(localContext, 1);
|
||||||
{
|
{
|
||||||
this.state = 14;
|
this.state = 20;
|
||||||
this.match(QuixosLockParser.QUIXOS_LOCK);
|
this.match(QuixosLockParser.QUIXOS_LOCK);
|
||||||
this.state = 15;
|
|
||||||
this.match(QuixosLockParser.VERSION);
|
|
||||||
this.state = 16;
|
|
||||||
this.match(QuixosLockParser.INTEGER);
|
|
||||||
this.state = 17;
|
|
||||||
this.match(QuixosLockParser.LBRACE);
|
|
||||||
this.state = 18;
|
|
||||||
this.quixosEntry();
|
|
||||||
this.state = 22;
|
this.state = 22;
|
||||||
this.errorHandler.sync(this);
|
this.errorHandler.sync(this);
|
||||||
_la = this.tokenStream.LA(1);
|
_la = this.tokenStream.LA(1);
|
||||||
while (_la === 5 || _la === 6) {
|
if (_la === 2) {
|
||||||
{
|
{
|
||||||
|
this.state = 21;
|
||||||
|
this.match(QuixosLockParser.FRAGMENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 24;
|
||||||
|
this.match(QuixosLockParser.VERSION);
|
||||||
|
this.state = 25;
|
||||||
|
this.match(QuixosLockParser.INTEGER);
|
||||||
|
this.state = 26;
|
||||||
|
this.match(QuixosLockParser.LBRACE);
|
||||||
|
this.state = 32;
|
||||||
|
this.errorHandler.sync(this);
|
||||||
|
_la = this.tokenStream.LA(1);
|
||||||
|
while ((((_la) & ~0x1F) === 0 && ((1 << _la) & 32976) !== 0)) {
|
||||||
{
|
{
|
||||||
this.state = 19;
|
this.state = 30;
|
||||||
|
this.errorHandler.sync(this);
|
||||||
|
switch (this.tokenStream.LA(1)) {
|
||||||
|
case QuixosLockParser.QUIXOS:
|
||||||
|
{
|
||||||
|
this.state = 27;
|
||||||
|
this.quixosEntry();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case QuixosLockParser.IMPORT:
|
||||||
|
{
|
||||||
|
this.state = 28;
|
||||||
|
this.importEntry();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case QuixosLockParser.INTERFACE:
|
||||||
|
case QuixosLockParser.PACKAGE:
|
||||||
|
{
|
||||||
|
this.state = 29;
|
||||||
this.resourceEntry();
|
this.resourceEntry();
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new antlr.NoViableAltException(this);
|
||||||
}
|
}
|
||||||
this.state = 24;
|
}
|
||||||
|
this.state = 34;
|
||||||
this.errorHandler.sync(this);
|
this.errorHandler.sync(this);
|
||||||
_la = this.tokenStream.LA(1);
|
_la = this.tokenStream.LA(1);
|
||||||
}
|
}
|
||||||
this.state = 25;
|
this.state = 35;
|
||||||
this.match(QuixosLockParser.RBRACE);
|
this.match(QuixosLockParser.RBRACE);
|
||||||
this.state = 26;
|
this.state = 36;
|
||||||
this.match(QuixosLockParser.EOF);
|
this.match(QuixosLockParser.EOF);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,12 +164,12 @@ export class QuixosLockParser extends antlr.Parser {
|
|||||||
try {
|
try {
|
||||||
this.enterOuterAlt(localContext, 1);
|
this.enterOuterAlt(localContext, 1);
|
||||||
{
|
{
|
||||||
this.state = 28;
|
this.state = 38;
|
||||||
this.match(QuixosLockParser.QUIXOS);
|
this.match(QuixosLockParser.QUIXOS);
|
||||||
this.state = 29;
|
this.state = 39;
|
||||||
this.match(QuixosLockParser.SOURCE);
|
this.match(QuixosLockParser.SOURCE);
|
||||||
this.state = 30;
|
this.state = 40;
|
||||||
this.sourceBlock();
|
this.quixosSourceBlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (re) {
|
catch (re) {
|
||||||
@@ -148,13 +191,13 @@ export class QuixosLockParser extends antlr.Parser {
|
|||||||
try {
|
try {
|
||||||
this.enterOuterAlt(localContext, 1);
|
this.enterOuterAlt(localContext, 1);
|
||||||
{
|
{
|
||||||
this.state = 32;
|
this.state = 42;
|
||||||
this.resourceKind();
|
this.resourceKind();
|
||||||
this.state = 33;
|
this.state = 43;
|
||||||
this.identifier();
|
this.identifier();
|
||||||
this.state = 34;
|
this.state = 44;
|
||||||
this.match(QuixosLockParser.SOURCE);
|
this.match(QuixosLockParser.SOURCE);
|
||||||
this.state = 35;
|
this.state = 45;
|
||||||
this.sourceBlock();
|
this.sourceBlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,16 +214,43 @@ export class QuixosLockParser extends antlr.Parser {
|
|||||||
}
|
}
|
||||||
return localContext;
|
return localContext;
|
||||||
}
|
}
|
||||||
|
public importEntry(): ImportEntryContext {
|
||||||
|
let localContext = new ImportEntryContext(this.context, this.state);
|
||||||
|
this.enterRule(localContext, 6, QuixosLockParser.RULE_importEntry);
|
||||||
|
try {
|
||||||
|
this.enterOuterAlt(localContext, 1);
|
||||||
|
{
|
||||||
|
this.state = 47;
|
||||||
|
this.match(QuixosLockParser.IMPORT);
|
||||||
|
this.state = 48;
|
||||||
|
this.stringLiteral();
|
||||||
|
this.state = 49;
|
||||||
|
this.match(QuixosLockParser.SEMI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (re) {
|
||||||
|
if (re instanceof antlr.RecognitionException) {
|
||||||
|
this.errorHandler.reportError(this, re);
|
||||||
|
this.errorHandler.recover(this, re);
|
||||||
|
} else {
|
||||||
|
throw re;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
this.exitRule();
|
||||||
|
}
|
||||||
|
return localContext;
|
||||||
|
}
|
||||||
public resourceKind(): ResourceKindContext {
|
public resourceKind(): ResourceKindContext {
|
||||||
let localContext = new ResourceKindContext(this.context, this.state);
|
let localContext = new ResourceKindContext(this.context, this.state);
|
||||||
this.enterRule(localContext, 6, QuixosLockParser.RULE_resourceKind);
|
this.enterRule(localContext, 8, QuixosLockParser.RULE_resourceKind);
|
||||||
let _la: number;
|
let _la: number;
|
||||||
try {
|
try {
|
||||||
this.enterOuterAlt(localContext, 1);
|
this.enterOuterAlt(localContext, 1);
|
||||||
{
|
{
|
||||||
this.state = 37;
|
this.state = 51;
|
||||||
_la = this.tokenStream.LA(1);
|
_la = this.tokenStream.LA(1);
|
||||||
if(!(_la === 5 || _la === 6)) {
|
if(!(_la === 6 || _la === 7)) {
|
||||||
this.errorHandler.recoverInline(this);
|
this.errorHandler.recoverInline(this);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -204,25 +274,25 @@ export class QuixosLockParser extends antlr.Parser {
|
|||||||
}
|
}
|
||||||
public sourceBlock(): SourceBlockContext {
|
public sourceBlock(): SourceBlockContext {
|
||||||
let localContext = new SourceBlockContext(this.context, this.state);
|
let localContext = new SourceBlockContext(this.context, this.state);
|
||||||
this.enterRule(localContext, 8, QuixosLockParser.RULE_sourceBlock);
|
this.enterRule(localContext, 10, QuixosLockParser.RULE_sourceBlock);
|
||||||
try {
|
try {
|
||||||
this.enterOuterAlt(localContext, 1);
|
this.enterOuterAlt(localContext, 1);
|
||||||
{
|
{
|
||||||
this.state = 39;
|
this.state = 53;
|
||||||
this.match(QuixosLockParser.LBRACE);
|
this.match(QuixosLockParser.LBRACE);
|
||||||
this.state = 40;
|
this.state = 54;
|
||||||
this.match(QuixosLockParser.REPOSITORY);
|
this.match(QuixosLockParser.REPOSITORY);
|
||||||
this.state = 41;
|
this.state = 55;
|
||||||
this.stringLiteral();
|
this.stringLiteral();
|
||||||
this.state = 42;
|
this.state = 56;
|
||||||
this.match(QuixosLockParser.SEMI);
|
this.match(QuixosLockParser.SEMI);
|
||||||
this.state = 43;
|
this.state = 57;
|
||||||
this.match(QuixosLockParser.COMMIT);
|
this.match(QuixosLockParser.COMMIT);
|
||||||
this.state = 44;
|
this.state = 58;
|
||||||
this.stringLiteral();
|
this.stringLiteral();
|
||||||
this.state = 45;
|
this.state = 59;
|
||||||
this.match(QuixosLockParser.SEMI);
|
this.match(QuixosLockParser.SEMI);
|
||||||
this.state = 46;
|
this.state = 60;
|
||||||
this.match(QuixosLockParser.RBRACE);
|
this.match(QuixosLockParser.RBRACE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,13 +309,102 @@ export class QuixosLockParser extends antlr.Parser {
|
|||||||
}
|
}
|
||||||
return localContext;
|
return localContext;
|
||||||
}
|
}
|
||||||
|
public quixosSourceBlock(): QuixosSourceBlockContext {
|
||||||
|
let localContext = new QuixosSourceBlockContext(this.context, this.state);
|
||||||
|
this.enterRule(localContext, 12, QuixosLockParser.RULE_quixosSourceBlock);
|
||||||
|
let _la: number;
|
||||||
|
try {
|
||||||
|
this.enterOuterAlt(localContext, 1);
|
||||||
|
{
|
||||||
|
this.state = 62;
|
||||||
|
this.match(QuixosLockParser.LBRACE);
|
||||||
|
this.state = 63;
|
||||||
|
this.match(QuixosLockParser.REPOSITORY);
|
||||||
|
this.state = 64;
|
||||||
|
this.stringLiteral();
|
||||||
|
this.state = 65;
|
||||||
|
this.match(QuixosLockParser.SEMI);
|
||||||
|
this.state = 73;
|
||||||
|
this.errorHandler.sync(this);
|
||||||
|
_la = this.tokenStream.LA(1);
|
||||||
|
if (_la === 10) {
|
||||||
|
{
|
||||||
|
this.state = 66;
|
||||||
|
this.match(QuixosLockParser.POLICY);
|
||||||
|
this.state = 67;
|
||||||
|
this.sourcePolicy();
|
||||||
|
this.state = 68;
|
||||||
|
this.match(QuixosLockParser.SEMI);
|
||||||
|
this.state = 69;
|
||||||
|
this.match(QuixosLockParser.REF);
|
||||||
|
this.state = 70;
|
||||||
|
this.stringLiteral();
|
||||||
|
this.state = 71;
|
||||||
|
this.match(QuixosLockParser.SEMI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = 75;
|
||||||
|
this.match(QuixosLockParser.COMMIT);
|
||||||
|
this.state = 76;
|
||||||
|
this.stringLiteral();
|
||||||
|
this.state = 77;
|
||||||
|
this.match(QuixosLockParser.SEMI);
|
||||||
|
this.state = 78;
|
||||||
|
this.match(QuixosLockParser.RBRACE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (re) {
|
||||||
|
if (re instanceof antlr.RecognitionException) {
|
||||||
|
this.errorHandler.reportError(this, re);
|
||||||
|
this.errorHandler.recover(this, re);
|
||||||
|
} else {
|
||||||
|
throw re;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
this.exitRule();
|
||||||
|
}
|
||||||
|
return localContext;
|
||||||
|
}
|
||||||
|
public sourcePolicy(): SourcePolicyContext {
|
||||||
|
let localContext = new SourcePolicyContext(this.context, this.state);
|
||||||
|
this.enterRule(localContext, 14, QuixosLockParser.RULE_sourcePolicy);
|
||||||
|
let _la: number;
|
||||||
|
try {
|
||||||
|
this.enterOuterAlt(localContext, 1);
|
||||||
|
{
|
||||||
|
this.state = 80;
|
||||||
|
_la = this.tokenStream.LA(1);
|
||||||
|
if(!((((_la) & ~0x1F) === 0 && ((1 << _la) & 28672) !== 0))) {
|
||||||
|
this.errorHandler.recoverInline(this);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.errorHandler.reportMatch(this);
|
||||||
|
this.consume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (re) {
|
||||||
|
if (re instanceof antlr.RecognitionException) {
|
||||||
|
this.errorHandler.reportError(this, re);
|
||||||
|
this.errorHandler.recover(this, re);
|
||||||
|
} else {
|
||||||
|
throw re;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
this.exitRule();
|
||||||
|
}
|
||||||
|
return localContext;
|
||||||
|
}
|
||||||
public identifier(): IdentifierContext {
|
public identifier(): IdentifierContext {
|
||||||
let localContext = new IdentifierContext(this.context, this.state);
|
let localContext = new IdentifierContext(this.context, this.state);
|
||||||
this.enterRule(localContext, 10, QuixosLockParser.RULE_identifier);
|
this.enterRule(localContext, 16, QuixosLockParser.RULE_identifier);
|
||||||
try {
|
try {
|
||||||
this.enterOuterAlt(localContext, 1);
|
this.enterOuterAlt(localContext, 1);
|
||||||
{
|
{
|
||||||
this.state = 48;
|
this.state = 82;
|
||||||
this.match(QuixosLockParser.IDENTIFIER);
|
this.match(QuixosLockParser.IDENTIFIER);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,11 +423,11 @@ export class QuixosLockParser extends antlr.Parser {
|
|||||||
}
|
}
|
||||||
public stringLiteral(): StringLiteralContext {
|
public stringLiteral(): StringLiteralContext {
|
||||||
let localContext = new StringLiteralContext(this.context, this.state);
|
let localContext = new StringLiteralContext(this.context, this.state);
|
||||||
this.enterRule(localContext, 12, QuixosLockParser.RULE_stringLiteral);
|
this.enterRule(localContext, 18, QuixosLockParser.RULE_stringLiteral);
|
||||||
try {
|
try {
|
||||||
this.enterOuterAlt(localContext, 1);
|
this.enterOuterAlt(localContext, 1);
|
||||||
{
|
{
|
||||||
this.state = 50;
|
this.state = 84;
|
||||||
this.match(QuixosLockParser.STRING_LITERAL);
|
this.match(QuixosLockParser.STRING_LITERAL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,21 +446,31 @@ export class QuixosLockParser extends antlr.Parser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static readonly _serializedATN: number[] = [
|
public static readonly _serializedATN: number[] = [
|
||||||
4,1,17,53,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,
|
4,1,24,87,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,
|
||||||
6,1,0,1,0,1,0,1,0,1,0,1,0,5,0,21,8,0,10,0,12,0,24,9,0,1,0,1,0,1,
|
6,2,7,7,7,2,8,7,8,2,9,7,9,1,0,1,0,3,0,23,8,0,1,0,1,0,1,0,1,0,1,0,
|
||||||
0,1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2,1,2,1,3,1,3,1,4,1,4,1,4,1,4,1,
|
1,0,5,0,31,8,0,10,0,12,0,34,9,0,1,0,1,0,1,0,1,1,1,1,1,1,1,1,1,2,
|
||||||
4,1,4,1,4,1,4,1,4,1,5,1,5,1,6,1,6,1,6,0,0,7,0,2,4,6,8,10,12,0,1,
|
1,2,1,2,1,2,1,2,1,3,1,3,1,3,1,3,1,4,1,4,1,5,1,5,1,5,1,5,1,5,1,5,
|
||||||
1,0,5,6,46,0,14,1,0,0,0,2,28,1,0,0,0,4,32,1,0,0,0,6,37,1,0,0,0,8,
|
1,5,1,5,1,5,1,6,1,6,1,6,1,6,1,6,1,6,1,6,1,6,1,6,1,6,1,6,3,6,74,8,
|
||||||
39,1,0,0,0,10,48,1,0,0,0,12,50,1,0,0,0,14,15,5,1,0,0,15,16,5,2,0,
|
6,1,6,1,6,1,6,1,6,1,6,1,7,1,7,1,8,1,8,1,9,1,9,1,9,0,0,10,0,2,4,6,
|
||||||
0,16,17,5,12,0,0,17,18,5,9,0,0,18,22,3,2,1,0,19,21,3,4,2,0,20,19,
|
8,10,12,14,16,18,0,2,1,0,6,7,1,0,12,14,81,0,20,1,0,0,0,2,38,1,0,
|
||||||
1,0,0,0,21,24,1,0,0,0,22,20,1,0,0,0,22,23,1,0,0,0,23,25,1,0,0,0,
|
0,0,4,42,1,0,0,0,6,47,1,0,0,0,8,51,1,0,0,0,10,53,1,0,0,0,12,62,1,
|
||||||
24,22,1,0,0,0,25,26,5,10,0,0,26,27,5,0,0,1,27,1,1,0,0,0,28,29,5,
|
0,0,0,14,80,1,0,0,0,16,82,1,0,0,0,18,84,1,0,0,0,20,22,5,1,0,0,21,
|
||||||
3,0,0,29,30,5,4,0,0,30,31,3,8,4,0,31,3,1,0,0,0,32,33,3,6,3,0,33,
|
23,5,2,0,0,22,21,1,0,0,0,22,23,1,0,0,0,23,24,1,0,0,0,24,25,5,3,0,
|
||||||
34,3,10,5,0,34,35,5,4,0,0,35,36,3,8,4,0,36,5,1,0,0,0,37,38,7,0,0,
|
0,25,26,5,19,0,0,26,32,5,16,0,0,27,31,3,2,1,0,28,31,3,6,3,0,29,31,
|
||||||
0,38,7,1,0,0,0,39,40,5,9,0,0,40,41,5,7,0,0,41,42,3,12,6,0,42,43,
|
3,4,2,0,30,27,1,0,0,0,30,28,1,0,0,0,30,29,1,0,0,0,31,34,1,0,0,0,
|
||||||
5,11,0,0,43,44,5,8,0,0,44,45,3,12,6,0,45,46,5,11,0,0,46,47,5,10,
|
32,30,1,0,0,0,32,33,1,0,0,0,33,35,1,0,0,0,34,32,1,0,0,0,35,36,5,
|
||||||
0,0,47,9,1,0,0,0,48,49,5,13,0,0,49,11,1,0,0,0,50,51,5,14,0,0,51,
|
17,0,0,36,37,5,0,0,1,37,1,1,0,0,0,38,39,5,4,0,0,39,40,5,5,0,0,40,
|
||||||
13,1,0,0,0,1,22
|
41,3,12,6,0,41,3,1,0,0,0,42,43,3,8,4,0,43,44,3,16,8,0,44,45,5,5,
|
||||||
|
0,0,45,46,3,10,5,0,46,5,1,0,0,0,47,48,5,15,0,0,48,49,3,18,9,0,49,
|
||||||
|
50,5,18,0,0,50,7,1,0,0,0,51,52,7,0,0,0,52,9,1,0,0,0,53,54,5,16,0,
|
||||||
|
0,54,55,5,8,0,0,55,56,3,18,9,0,56,57,5,18,0,0,57,58,5,9,0,0,58,59,
|
||||||
|
3,18,9,0,59,60,5,18,0,0,60,61,5,17,0,0,61,11,1,0,0,0,62,63,5,16,
|
||||||
|
0,0,63,64,5,8,0,0,64,65,3,18,9,0,65,73,5,18,0,0,66,67,5,10,0,0,67,
|
||||||
|
68,3,14,7,0,68,69,5,18,0,0,69,70,5,11,0,0,70,71,3,18,9,0,71,72,5,
|
||||||
|
18,0,0,72,74,1,0,0,0,73,66,1,0,0,0,73,74,1,0,0,0,74,75,1,0,0,0,75,
|
||||||
|
76,5,9,0,0,76,77,3,18,9,0,77,78,5,18,0,0,78,79,5,17,0,0,79,13,1,
|
||||||
|
0,0,0,80,81,7,1,0,0,81,15,1,0,0,0,82,83,5,20,0,0,83,17,1,0,0,0,84,
|
||||||
|
85,5,21,0,0,85,19,1,0,0,0,4,22,30,32,73
|
||||||
];
|
];
|
||||||
|
|
||||||
private static __ATN: antlr.ATN;
|
private static __ATN: antlr.ATN;
|
||||||
@@ -339,15 +508,33 @@ export class DocumentContext extends antlr.ParserRuleContext {
|
|||||||
public LBRACE(): antlr.TerminalNode {
|
public LBRACE(): antlr.TerminalNode {
|
||||||
return this.getToken(QuixosLockParser.LBRACE, 0)!;
|
return this.getToken(QuixosLockParser.LBRACE, 0)!;
|
||||||
}
|
}
|
||||||
public quixosEntry(): QuixosEntryContext {
|
|
||||||
return this.getRuleContext(0, QuixosEntryContext)!;
|
|
||||||
}
|
|
||||||
public RBRACE(): antlr.TerminalNode {
|
public RBRACE(): antlr.TerminalNode {
|
||||||
return this.getToken(QuixosLockParser.RBRACE, 0)!;
|
return this.getToken(QuixosLockParser.RBRACE, 0)!;
|
||||||
}
|
}
|
||||||
public EOF(): antlr.TerminalNode {
|
public EOF(): antlr.TerminalNode {
|
||||||
return this.getToken(QuixosLockParser.EOF, 0)!;
|
return this.getToken(QuixosLockParser.EOF, 0)!;
|
||||||
}
|
}
|
||||||
|
public FRAGMENT(): antlr.TerminalNode | null {
|
||||||
|
return this.getToken(QuixosLockParser.FRAGMENT, 0);
|
||||||
|
}
|
||||||
|
public quixosEntry(): QuixosEntryContext[];
|
||||||
|
public quixosEntry(i: number): QuixosEntryContext | null;
|
||||||
|
public quixosEntry(i?: number): QuixosEntryContext[] | QuixosEntryContext | null {
|
||||||
|
if (i === undefined) {
|
||||||
|
return this.getRuleContexts(QuixosEntryContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getRuleContext(i, QuixosEntryContext);
|
||||||
|
}
|
||||||
|
public importEntry(): ImportEntryContext[];
|
||||||
|
public importEntry(i: number): ImportEntryContext | null;
|
||||||
|
public importEntry(i?: number): ImportEntryContext[] | ImportEntryContext | null {
|
||||||
|
if (i === undefined) {
|
||||||
|
return this.getRuleContexts(ImportEntryContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getRuleContext(i, ImportEntryContext);
|
||||||
|
}
|
||||||
public resourceEntry(): ResourceEntryContext[];
|
public resourceEntry(): ResourceEntryContext[];
|
||||||
public resourceEntry(i: number): ResourceEntryContext | null;
|
public resourceEntry(i: number): ResourceEntryContext | null;
|
||||||
public resourceEntry(i?: number): ResourceEntryContext[] | ResourceEntryContext | null {
|
public resourceEntry(i?: number): ResourceEntryContext[] | ResourceEntryContext | null {
|
||||||
@@ -380,8 +567,8 @@ export class QuixosEntryContext extends antlr.ParserRuleContext {
|
|||||||
public SOURCE(): antlr.TerminalNode {
|
public SOURCE(): antlr.TerminalNode {
|
||||||
return this.getToken(QuixosLockParser.SOURCE, 0)!;
|
return this.getToken(QuixosLockParser.SOURCE, 0)!;
|
||||||
}
|
}
|
||||||
public sourceBlock(): SourceBlockContext {
|
public quixosSourceBlock(): QuixosSourceBlockContext {
|
||||||
return this.getRuleContext(0, SourceBlockContext)!;
|
return this.getRuleContext(0, QuixosSourceBlockContext)!;
|
||||||
}
|
}
|
||||||
public override get ruleIndex(): number {
|
public override get ruleIndex(): number {
|
||||||
return QuixosLockParser.RULE_quixosEntry;
|
return QuixosLockParser.RULE_quixosEntry;
|
||||||
@@ -425,6 +612,32 @@ export class ResourceEntryContext extends antlr.ParserRuleContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class ImportEntryContext extends antlr.ParserRuleContext {
|
||||||
|
public constructor(parent: antlr.ParserRuleContext | null, invokingState: number) {
|
||||||
|
super(parent, invokingState);
|
||||||
|
}
|
||||||
|
public IMPORT(): antlr.TerminalNode {
|
||||||
|
return this.getToken(QuixosLockParser.IMPORT, 0)!;
|
||||||
|
}
|
||||||
|
public stringLiteral(): StringLiteralContext {
|
||||||
|
return this.getRuleContext(0, StringLiteralContext)!;
|
||||||
|
}
|
||||||
|
public SEMI(): antlr.TerminalNode {
|
||||||
|
return this.getToken(QuixosLockParser.SEMI, 0)!;
|
||||||
|
}
|
||||||
|
public override get ruleIndex(): number {
|
||||||
|
return QuixosLockParser.RULE_importEntry;
|
||||||
|
}
|
||||||
|
public override accept<Result>(visitor: QuixosLockVisitor<Result>): Result | null {
|
||||||
|
if (visitor.visitImportEntry) {
|
||||||
|
return visitor.visitImportEntry(this);
|
||||||
|
} else {
|
||||||
|
return visitor.visitChildren(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export class ResourceKindContext extends antlr.ParserRuleContext {
|
export class ResourceKindContext extends antlr.ParserRuleContext {
|
||||||
public constructor(parent: antlr.ParserRuleContext | null, invokingState: number) {
|
public constructor(parent: antlr.ParserRuleContext | null, invokingState: number) {
|
||||||
super(parent, invokingState);
|
super(parent, invokingState);
|
||||||
@@ -495,6 +708,88 @@ export class SourceBlockContext extends antlr.ParserRuleContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class QuixosSourceBlockContext extends antlr.ParserRuleContext {
|
||||||
|
public constructor(parent: antlr.ParserRuleContext | null, invokingState: number) {
|
||||||
|
super(parent, invokingState);
|
||||||
|
}
|
||||||
|
public LBRACE(): antlr.TerminalNode {
|
||||||
|
return this.getToken(QuixosLockParser.LBRACE, 0)!;
|
||||||
|
}
|
||||||
|
public REPOSITORY(): antlr.TerminalNode {
|
||||||
|
return this.getToken(QuixosLockParser.REPOSITORY, 0)!;
|
||||||
|
}
|
||||||
|
public stringLiteral(): StringLiteralContext[];
|
||||||
|
public stringLiteral(i: number): StringLiteralContext | null;
|
||||||
|
public stringLiteral(i?: number): StringLiteralContext[] | StringLiteralContext | null {
|
||||||
|
if (i === undefined) {
|
||||||
|
return this.getRuleContexts(StringLiteralContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getRuleContext(i, StringLiteralContext);
|
||||||
|
}
|
||||||
|
public SEMI(): antlr.TerminalNode[];
|
||||||
|
public SEMI(i: number): antlr.TerminalNode | null;
|
||||||
|
public SEMI(i?: number): antlr.TerminalNode | null | antlr.TerminalNode[] {
|
||||||
|
if (i === undefined) {
|
||||||
|
return this.getTokens(QuixosLockParser.SEMI);
|
||||||
|
} else {
|
||||||
|
return this.getToken(QuixosLockParser.SEMI, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public COMMIT(): antlr.TerminalNode {
|
||||||
|
return this.getToken(QuixosLockParser.COMMIT, 0)!;
|
||||||
|
}
|
||||||
|
public RBRACE(): antlr.TerminalNode {
|
||||||
|
return this.getToken(QuixosLockParser.RBRACE, 0)!;
|
||||||
|
}
|
||||||
|
public POLICY(): antlr.TerminalNode | null {
|
||||||
|
return this.getToken(QuixosLockParser.POLICY, 0);
|
||||||
|
}
|
||||||
|
public sourcePolicy(): SourcePolicyContext | null {
|
||||||
|
return this.getRuleContext(0, SourcePolicyContext);
|
||||||
|
}
|
||||||
|
public REF(): antlr.TerminalNode | null {
|
||||||
|
return this.getToken(QuixosLockParser.REF, 0);
|
||||||
|
}
|
||||||
|
public override get ruleIndex(): number {
|
||||||
|
return QuixosLockParser.RULE_quixosSourceBlock;
|
||||||
|
}
|
||||||
|
public override accept<Result>(visitor: QuixosLockVisitor<Result>): Result | null {
|
||||||
|
if (visitor.visitQuixosSourceBlock) {
|
||||||
|
return visitor.visitQuixosSourceBlock(this);
|
||||||
|
} else {
|
||||||
|
return visitor.visitChildren(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class SourcePolicyContext extends antlr.ParserRuleContext {
|
||||||
|
public constructor(parent: antlr.ParserRuleContext | null, invokingState: number) {
|
||||||
|
super(parent, invokingState);
|
||||||
|
}
|
||||||
|
public PINNED(): antlr.TerminalNode | null {
|
||||||
|
return this.getToken(QuixosLockParser.PINNED, 0);
|
||||||
|
}
|
||||||
|
public TRACK_RELEASE(): antlr.TerminalNode | null {
|
||||||
|
return this.getToken(QuixosLockParser.TRACK_RELEASE, 0);
|
||||||
|
}
|
||||||
|
public TRACK_DEVELOPMENT(): antlr.TerminalNode | null {
|
||||||
|
return this.getToken(QuixosLockParser.TRACK_DEVELOPMENT, 0);
|
||||||
|
}
|
||||||
|
public override get ruleIndex(): number {
|
||||||
|
return QuixosLockParser.RULE_sourcePolicy;
|
||||||
|
}
|
||||||
|
public override accept<Result>(visitor: QuixosLockVisitor<Result>): Result | null {
|
||||||
|
if (visitor.visitSourcePolicy) {
|
||||||
|
return visitor.visitSourcePolicy(this);
|
||||||
|
} else {
|
||||||
|
return visitor.visitChildren(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export class IdentifierContext extends antlr.ParserRuleContext {
|
export class IdentifierContext extends antlr.ParserRuleContext {
|
||||||
public constructor(parent: antlr.ParserRuleContext | null, invokingState: number) {
|
public constructor(parent: antlr.ParserRuleContext | null, invokingState: number) {
|
||||||
super(parent, invokingState);
|
super(parent, invokingState);
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import { AbstractParseTreeVisitor } from "antlr4ng";
|
|||||||
import { DocumentContext } from "./QuixosLockParser.js";
|
import { DocumentContext } from "./QuixosLockParser.js";
|
||||||
import { QuixosEntryContext } from "./QuixosLockParser.js";
|
import { QuixosEntryContext } from "./QuixosLockParser.js";
|
||||||
import { ResourceEntryContext } from "./QuixosLockParser.js";
|
import { ResourceEntryContext } from "./QuixosLockParser.js";
|
||||||
|
import { ImportEntryContext } from "./QuixosLockParser.js";
|
||||||
import { ResourceKindContext } from "./QuixosLockParser.js";
|
import { ResourceKindContext } from "./QuixosLockParser.js";
|
||||||
import { SourceBlockContext } from "./QuixosLockParser.js";
|
import { SourceBlockContext } from "./QuixosLockParser.js";
|
||||||
|
import { QuixosSourceBlockContext } from "./QuixosLockParser.js";
|
||||||
|
import { SourcePolicyContext } from "./QuixosLockParser.js";
|
||||||
import { IdentifierContext } from "./QuixosLockParser.js";
|
import { IdentifierContext } from "./QuixosLockParser.js";
|
||||||
import { StringLiteralContext } from "./QuixosLockParser.js";
|
import { StringLiteralContext } from "./QuixosLockParser.js";
|
||||||
|
|
||||||
@@ -37,6 +40,12 @@ export class QuixosLockVisitor<Result> extends AbstractParseTreeVisitor<Result>
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitResourceEntry?: (ctx: ResourceEntryContext) => Result;
|
visitResourceEntry?: (ctx: ResourceEntryContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosLockParser.importEntry`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitImportEntry?: (ctx: ImportEntryContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosLockParser.resourceKind`.
|
* Visit a parse tree produced by `QuixosLockParser.resourceKind`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
@@ -49,6 +58,18 @@ export class QuixosLockVisitor<Result> extends AbstractParseTreeVisitor<Result>
|
|||||||
* @return the visitor result
|
* @return the visitor result
|
||||||
*/
|
*/
|
||||||
visitSourceBlock?: (ctx: SourceBlockContext) => Result;
|
visitSourceBlock?: (ctx: SourceBlockContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosLockParser.quixosSourceBlock`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitQuixosSourceBlock?: (ctx: QuixosSourceBlockContext) => Result;
|
||||||
|
/**
|
||||||
|
* Visit a parse tree produced by `QuixosLockParser.sourcePolicy`.
|
||||||
|
* @param ctx the parse tree
|
||||||
|
* @return the visitor result
|
||||||
|
*/
|
||||||
|
visitSourcePolicy?: (ctx: SourcePolicyContext) => Result;
|
||||||
/**
|
/**
|
||||||
* Visit a parse tree produced by `QuixosLockParser.identifier`.
|
* Visit a parse tree produced by `QuixosLockParser.identifier`.
|
||||||
* @param ctx the parse tree
|
* @param ctx the parse tree
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export * from "./loader.js";
|
||||||
export * from "./parser.js";
|
export * from "./parser.js";
|
||||||
export * from "./types.js";
|
export * from "./types.js";
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { lstat, readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
parseQuixosLockDocument,
|
||||||
|
type QuixosLockDiagnostic,
|
||||||
|
type QuixosLockParseResult,
|
||||||
|
} from "./parser.js";
|
||||||
|
import type {
|
||||||
|
LockedResource,
|
||||||
|
QuixosLockDocument,
|
||||||
|
QuixosRepositoryLock,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export type QuixosLockSourceReader = (relativePath: string) => Promise<string>;
|
||||||
|
|
||||||
|
const diagnostic = (
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
fileName: string,
|
||||||
|
path?: string,
|
||||||
|
): QuixosLockDiagnostic => ({
|
||||||
|
phase: "resolution",
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
fileName,
|
||||||
|
line: 1,
|
||||||
|
column: 0,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resolveQuixosLock = async (
|
||||||
|
rootSource: string,
|
||||||
|
readSource: QuixosLockSourceReader,
|
||||||
|
rootFileName = "quixos.lock",
|
||||||
|
): Promise<QuixosLockParseResult> => {
|
||||||
|
const root = parseQuixosLockDocument(rootSource, rootFileName);
|
||||||
|
if (!root.ok) return root;
|
||||||
|
if (root.document.kind !== "root") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
diagnostics: [diagnostic(
|
||||||
|
"expected-root-lock",
|
||||||
|
"The entrypoint must be a root Quixos lock, not a fragment",
|
||||||
|
rootFileName,
|
||||||
|
)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagnostics: QuixosLockDiagnostic[] = [];
|
||||||
|
const resources: LockedResource[] = [];
|
||||||
|
const sourceFiles = [rootFileName];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const active: string[] = [];
|
||||||
|
|
||||||
|
const addResources = (document: QuixosLockDocument, fileName: string) => {
|
||||||
|
for (const resource of document.resources) {
|
||||||
|
const duplicate = resources.find((entry) =>
|
||||||
|
entry.kind === resource.kind && entry.binding === resource.binding);
|
||||||
|
if (duplicate) {
|
||||||
|
diagnostics.push(diagnostic(
|
||||||
|
"duplicate-resource-binding",
|
||||||
|
`Duplicate ${resource.kind} binding ${resource.binding} across imported lock files`,
|
||||||
|
fileName,
|
||||||
|
`${resource.kind}.${resource.binding}`,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
resources.push(resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visit = async (importPath: string) => {
|
||||||
|
if (active.includes(importPath)) {
|
||||||
|
diagnostics.push(diagnostic(
|
||||||
|
"import-cycle",
|
||||||
|
`Lock import cycle: ${[...active, importPath].join(" -> ")}`,
|
||||||
|
importPath,
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (visited.has(importPath)) return;
|
||||||
|
active.push(importPath);
|
||||||
|
let source: string;
|
||||||
|
try {
|
||||||
|
source = await readSource(importPath);
|
||||||
|
} catch (cause) {
|
||||||
|
diagnostics.push(diagnostic(
|
||||||
|
"import-read-failed",
|
||||||
|
`Could not read lock import ${importPath}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||||
|
importPath,
|
||||||
|
));
|
||||||
|
active.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = parseQuixosLockDocument(source, importPath);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
diagnostics.push(...parsed.diagnostics);
|
||||||
|
active.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.document.kind !== "fragment") {
|
||||||
|
diagnostics.push(diagnostic(
|
||||||
|
"imported-root-lock",
|
||||||
|
`Imported file ${importPath} must begin with "quixos-lock fragment"`,
|
||||||
|
importPath,
|
||||||
|
));
|
||||||
|
active.pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visited.add(importPath);
|
||||||
|
sourceFiles.push(importPath);
|
||||||
|
addResources(parsed.document, importPath);
|
||||||
|
for (const nested of parsed.document.imports) await visit(nested);
|
||||||
|
active.pop();
|
||||||
|
};
|
||||||
|
|
||||||
|
addResources(root.document, rootFileName);
|
||||||
|
for (const importPath of root.document.imports) await visit(importPath);
|
||||||
|
if (diagnostics.length) return { ok: false, diagnostics };
|
||||||
|
const lock: QuixosRepositoryLock = {
|
||||||
|
formatVersion: 1,
|
||||||
|
quixos: root.document.quixos,
|
||||||
|
resources,
|
||||||
|
sourceFiles,
|
||||||
|
};
|
||||||
|
return { ok: true, lock, diagnostics: [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadQuixosLock = async (fileName: string): Promise<QuixosLockParseResult> => {
|
||||||
|
const absoluteRoot = path.resolve(fileName);
|
||||||
|
const repositoryRoot = path.dirname(absoluteRoot);
|
||||||
|
const rootName = path.basename(absoluteRoot);
|
||||||
|
const rootSource = await readFile(absoluteRoot, "utf8");
|
||||||
|
return await resolveQuixosLock(rootSource, async (relativePath) => {
|
||||||
|
let absoluteImport = repositoryRoot;
|
||||||
|
const segments = relativePath.split("/");
|
||||||
|
for (const [index, segment] of segments.entries()) {
|
||||||
|
absoluteImport = path.join(absoluteImport, segment);
|
||||||
|
const metadata = await lstat(absoluteImport);
|
||||||
|
if (metadata.isSymbolicLink()) {
|
||||||
|
throw new Error("imports must not traverse symbolic links");
|
||||||
|
}
|
||||||
|
const final = index === segments.length - 1;
|
||||||
|
if ((!final && !metadata.isDirectory()) || (final && !metadata.isFile())) {
|
||||||
|
throw new Error("imports must be ordinary files beneath ordinary directories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await readFile(absoluteImport, "utf8");
|
||||||
|
}, rootName);
|
||||||
|
};
|
||||||
+245
-9
@@ -10,16 +10,19 @@ import {
|
|||||||
import { QuixosLockLexer } from "./generated/QuixosLockLexer.js";
|
import { QuixosLockLexer } from "./generated/QuixosLockLexer.js";
|
||||||
import {
|
import {
|
||||||
QuixosLockParser,
|
QuixosLockParser,
|
||||||
|
type QuixosSourceBlockContext,
|
||||||
type SourceBlockContext,
|
type SourceBlockContext,
|
||||||
} from "./generated/QuixosLockParser.js";
|
} from "./generated/QuixosLockParser.js";
|
||||||
import type {
|
import type {
|
||||||
GitSource,
|
GitSource,
|
||||||
LockedResource,
|
LockedResource,
|
||||||
|
QuixosLockDocument,
|
||||||
QuixosRepositoryLock,
|
QuixosRepositoryLock,
|
||||||
|
QuixosSource,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
export type QuixosLockDiagnostic = {
|
export type QuixosLockDiagnostic = {
|
||||||
phase: "syntax" | "validation";
|
phase: "syntax" | "validation" | "resolution";
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -32,6 +35,10 @@ export type QuixosLockParseResult =
|
|||||||
| { ok: true; lock: QuixosRepositoryLock; diagnostics: [] }
|
| { ok: true; lock: QuixosRepositoryLock; diagnostics: [] }
|
||||||
| { ok: false; diagnostics: QuixosLockDiagnostic[] };
|
| { ok: false; diagnostics: QuixosLockDiagnostic[] };
|
||||||
|
|
||||||
|
export type QuixosLockDocumentParseResult =
|
||||||
|
| { ok: true; document: QuixosLockDocument; diagnostics: [] }
|
||||||
|
| { ok: false; diagnostics: QuixosLockDiagnostic[] };
|
||||||
|
|
||||||
class SyntaxErrorListener extends BaseErrorListener {
|
class SyntaxErrorListener extends BaseErrorListener {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly fileName: string,
|
private readonly fileName: string,
|
||||||
@@ -68,22 +75,72 @@ const lowerSource = (context: SourceBlockContext): GitSource => ({
|
|||||||
commit: stringValue(context.stringLiteral(1)!).toLowerCase(),
|
commit: stringValue(context.stringLiteral(1)!).toLowerCase(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lowerQuixosSource = (context: QuixosSourceBlockContext): QuixosSource => {
|
||||||
|
const source = {
|
||||||
|
resolver: "git" as const,
|
||||||
|
repository: stringValue(context.stringLiteral(0)!),
|
||||||
|
commit: stringValue(context.stringLiteral(context.stringLiteral().length - 1)!).toLowerCase(),
|
||||||
|
};
|
||||||
|
const policyContext = context.sourcePolicy();
|
||||||
|
if (!policyContext) return source;
|
||||||
|
const policy = policyContext.PINNED()
|
||||||
|
? "pinned"
|
||||||
|
: policyContext.TRACK_RELEASE()
|
||||||
|
? "track-release"
|
||||||
|
: "track-development";
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
policy,
|
||||||
|
ref: stringValue(context.stringLiteral(1)!),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const issue = (
|
const issue = (
|
||||||
diagnostics: QuixosLockDiagnostic[],
|
diagnostics: QuixosLockDiagnostic[],
|
||||||
fileName: string,
|
fileName: string,
|
||||||
code: string,
|
code: string,
|
||||||
message: string,
|
message: string,
|
||||||
path?: string,
|
path?: string,
|
||||||
|
line = 1,
|
||||||
|
column = 0,
|
||||||
) => diagnostics.push({
|
) => diagnostics.push({
|
||||||
phase: "validation",
|
phase: "validation",
|
||||||
code,
|
code,
|
||||||
message,
|
message,
|
||||||
fileName,
|
fileName,
|
||||||
line: 1,
|
line,
|
||||||
column: 0,
|
column,
|
||||||
path,
|
path,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const validateImport = (
|
||||||
|
importPath: string,
|
||||||
|
index: number,
|
||||||
|
fileName: string,
|
||||||
|
diagnostics: QuixosLockDiagnostic[],
|
||||||
|
line: number,
|
||||||
|
column: number,
|
||||||
|
) => {
|
||||||
|
const path = `imports[${index}]`;
|
||||||
|
if (
|
||||||
|
!importPath
|
||||||
|
|| importPath.startsWith("/")
|
||||||
|
|| importPath.includes("\\")
|
||||||
|
|| importPath.split("/").some((segment) => !segment || segment === "." || segment === "..")
|
||||||
|
|| /^[A-Za-z][A-Za-z0-9+.-]*:/.test(importPath)
|
||||||
|
) {
|
||||||
|
issue(
|
||||||
|
diagnostics,
|
||||||
|
fileName,
|
||||||
|
"invalid-import-path",
|
||||||
|
`${path} must be a normalized relative path beneath the workspace repository root`,
|
||||||
|
path,
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const validateSource = (
|
const validateSource = (
|
||||||
source: GitSource,
|
source: GitSource,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -141,10 +198,59 @@ const validateSource = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parseQuixosLock = (
|
const validateQuixosSource = (
|
||||||
|
source: QuixosSource,
|
||||||
|
fileName: string,
|
||||||
|
diagnostics: QuixosLockDiagnostic[],
|
||||||
|
) => {
|
||||||
|
validateSource(source, "quixos", fileName, diagnostics);
|
||||||
|
if (!source.policy) return;
|
||||||
|
const forbiddenRefCharacters = new Set("~^:?*[\\");
|
||||||
|
const invalidRef = !source.ref
|
||||||
|
|| [...source.ref].some((character) => {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
return code <= 0x20 || code === 0x7f || forbiddenRefCharacters.has(character);
|
||||||
|
})
|
||||||
|
|| source.ref.startsWith("/")
|
||||||
|
|| source.ref.endsWith("/")
|
||||||
|
|| source.ref.endsWith(".")
|
||||||
|
|| source.ref.includes("..")
|
||||||
|
|| source.ref.includes("@{")
|
||||||
|
|| source.ref.includes("//");
|
||||||
|
if (invalidRef) {
|
||||||
|
issue(
|
||||||
|
diagnostics,
|
||||||
|
fileName,
|
||||||
|
"invalid-quixos-ref",
|
||||||
|
"quixos.ref must be a non-empty Git ref without whitespace or Git ref metacharacters",
|
||||||
|
"quixos.ref",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.policy === "pinned") {
|
||||||
|
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(source.ref)) {
|
||||||
|
issue(
|
||||||
|
diagnostics,
|
||||||
|
fileName,
|
||||||
|
"pinned-quixos-ref-not-commit",
|
||||||
|
"A pinned Quixos source must use an exact commit as its ref",
|
||||||
|
"quixos.ref",
|
||||||
|
);
|
||||||
|
} else if (source.ref.toLowerCase() !== source.commit) {
|
||||||
|
issue(
|
||||||
|
diagnostics,
|
||||||
|
fileName,
|
||||||
|
"pinned-quixos-commit-mismatch",
|
||||||
|
"A pinned Quixos source ref and resolved commit must be identical",
|
||||||
|
"quixos.commit",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseQuixosLockDocument = (
|
||||||
source: string,
|
source: string,
|
||||||
fileName = "<memory>",
|
fileName = "<memory>",
|
||||||
): QuixosLockParseResult => {
|
): 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));
|
||||||
@@ -167,8 +273,55 @@ export const parseQuixosLock = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quixos = lowerSource(tree.quixosEntry().sourceBlock());
|
const fragment = Boolean(tree.FRAGMENT());
|
||||||
validateSource(quixos, "quixos", fileName, diagnostics);
|
const quixosEntries = tree.quixosEntry();
|
||||||
|
let quixos: QuixosSource | undefined;
|
||||||
|
if (fragment && quixosEntries.length) {
|
||||||
|
issue(
|
||||||
|
diagnostics,
|
||||||
|
fileName,
|
||||||
|
"fragment-has-quixos-source",
|
||||||
|
"A lock fragment inherits the root document's Quixos source and must not redeclare it",
|
||||||
|
"quixos",
|
||||||
|
);
|
||||||
|
} else if (!fragment && quixosEntries.length !== 1) {
|
||||||
|
issue(
|
||||||
|
diagnostics,
|
||||||
|
fileName,
|
||||||
|
"root-quixos-source-count",
|
||||||
|
"A root lock document must contain exactly one Quixos source",
|
||||||
|
"quixos",
|
||||||
|
);
|
||||||
|
} else if (!fragment) {
|
||||||
|
quixos = lowerQuixosSource(quixosEntries[0]!.quixosSourceBlock());
|
||||||
|
validateQuixosSource(quixos, fileName, diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imports = tree.importEntry().map((context, index) => {
|
||||||
|
const importPath = stringValue(context.stringLiteral());
|
||||||
|
validateImport(
|
||||||
|
importPath,
|
||||||
|
index,
|
||||||
|
fileName,
|
||||||
|
diagnostics,
|
||||||
|
context.start?.line ?? 1,
|
||||||
|
context.start?.column ?? 0,
|
||||||
|
);
|
||||||
|
return importPath;
|
||||||
|
});
|
||||||
|
const repeatedImports = new Set<string>();
|
||||||
|
imports.forEach((importPath, index) => {
|
||||||
|
if (repeatedImports.has(importPath)) {
|
||||||
|
issue(
|
||||||
|
diagnostics,
|
||||||
|
fileName,
|
||||||
|
"duplicate-import",
|
||||||
|
`Duplicate lock import ${importPath}`,
|
||||||
|
`imports[${index}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
repeatedImports.add(importPath);
|
||||||
|
});
|
||||||
|
|
||||||
const resources: LockedResource[] = [];
|
const resources: LockedResource[] = [];
|
||||||
const bindings = new Set<string>();
|
const bindings = new Set<string>();
|
||||||
@@ -195,7 +348,52 @@ export const parseQuixosLock = (
|
|||||||
? { ok: false, diagnostics }
|
? { ok: false, diagnostics }
|
||||||
: {
|
: {
|
||||||
ok: true,
|
ok: true,
|
||||||
lock: { formatVersion: 1, quixos, resources },
|
document: fragment
|
||||||
|
? { kind: "fragment", formatVersion: 1, imports, resources }
|
||||||
|
: { kind: "root", formatVersion: 1, quixos: quixos!, imports, resources },
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseQuixosLock = (
|
||||||
|
source: string,
|
||||||
|
fileName = "<memory>",
|
||||||
|
): QuixosLockParseResult => {
|
||||||
|
const parsed = parseQuixosLockDocument(source, fileName);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
if (parsed.document.kind === "fragment") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
diagnostics: [{
|
||||||
|
phase: "validation",
|
||||||
|
code: "expected-root-lock",
|
||||||
|
message: "Expected a root Quixos lock, found a lock fragment",
|
||||||
|
fileName,
|
||||||
|
line: 1,
|
||||||
|
column: 0,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (parsed.document.imports.length) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
diagnostics: [{
|
||||||
|
phase: "resolution",
|
||||||
|
code: "imports-require-file-resolution",
|
||||||
|
message: "This lock has imports and must be loaded from its repository rather than parsed as an isolated string",
|
||||||
|
fileName,
|
||||||
|
line: 1,
|
||||||
|
column: 0,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
lock: {
|
||||||
|
formatVersion: 1,
|
||||||
|
quixos: parsed.document.quixos,
|
||||||
|
resources: parsed.document.resources,
|
||||||
|
},
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -207,11 +405,22 @@ const sourceLines = (source: GitSource, indentation: string): string[] => [
|
|||||||
`${indentation}commit ${quoted(source.commit.toLowerCase())};`,
|
`${indentation}commit ${quoted(source.commit.toLowerCase())};`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const quixosSourceLines = (source: QuixosSource, indentation: string): string[] => [
|
||||||
|
`${indentation}repository ${quoted(source.repository)};`,
|
||||||
|
...(source.policy
|
||||||
|
? [
|
||||||
|
`${indentation}policy ${source.policy};`,
|
||||||
|
`${indentation}ref ${quoted(source.ref)};`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
`${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-lock version 1 {",
|
||||||
" quixos source {",
|
" quixos source {",
|
||||||
...sourceLines(lock.quixos, " "),
|
...quixosSourceLines(lock.quixos, " "),
|
||||||
" }",
|
" }",
|
||||||
];
|
];
|
||||||
for (const resource of lock.resources) {
|
for (const resource of lock.resources) {
|
||||||
@@ -225,3 +434,30 @@ export const formatQuixosLock = (lock: QuixosRepositoryLock): string => {
|
|||||||
lines.push("}", "");
|
lines.push("}", "");
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatQuixosLockDocument = (document: QuixosLockDocument): string => {
|
||||||
|
const lines = [
|
||||||
|
`quixos-lock${document.kind === "fragment" ? " fragment" : ""} version 1 {`,
|
||||||
|
];
|
||||||
|
if (document.kind === "root") {
|
||||||
|
lines.push(
|
||||||
|
" quixos source {",
|
||||||
|
...quixosSourceLines(document.quixos, " "),
|
||||||
|
" }",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const importPath of document.imports) {
|
||||||
|
if (lines.length > 1) lines.push("");
|
||||||
|
lines.push(` import ${quoted(importPath)};`);
|
||||||
|
}
|
||||||
|
for (const resource of document.resources) {
|
||||||
|
if (lines.length > 1) lines.push("");
|
||||||
|
lines.push(
|
||||||
|
` ${resource.kind} ${resource.binding} source {`,
|
||||||
|
...sourceLines(resource.source, " "),
|
||||||
|
" }",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push("}", "");
|
||||||
|
return lines.join("\n");
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,6 +4,19 @@ export type GitSource = {
|
|||||||
commit: string;
|
commit: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QuixosSourcePolicy = "pinned" | "track-release" | "track-development";
|
||||||
|
|
||||||
|
// Resource repositories only need the exact Quixos commit they were authored
|
||||||
|
// against. A workspace root additionally declares how a runtime may advance
|
||||||
|
// that exact baseline.
|
||||||
|
export type QuixosSource = GitSource & ({
|
||||||
|
policy: QuixosSourcePolicy;
|
||||||
|
ref: string;
|
||||||
|
} | {
|
||||||
|
policy?: undefined;
|
||||||
|
ref?: undefined;
|
||||||
|
});
|
||||||
|
|
||||||
export type LockedResourceKind = "interface" | "package";
|
export type LockedResourceKind = "interface" | "package";
|
||||||
|
|
||||||
export type LockedResource = {
|
export type LockedResource = {
|
||||||
@@ -12,10 +25,30 @@ export type LockedResource = {
|
|||||||
source: GitSource;
|
source: GitSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QuixosLockRootDocument = {
|
||||||
|
kind: "root";
|
||||||
|
formatVersion: 1;
|
||||||
|
quixos: QuixosSource;
|
||||||
|
imports: string[];
|
||||||
|
resources: LockedResource[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuixosLockFragmentDocument = {
|
||||||
|
kind: "fragment";
|
||||||
|
formatVersion: 1;
|
||||||
|
imports: string[];
|
||||||
|
resources: LockedResource[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuixosLockDocument =
|
||||||
|
| QuixosLockRootDocument
|
||||||
|
| QuixosLockFragmentDocument;
|
||||||
|
|
||||||
export type QuixosRepositoryLock = {
|
export type QuixosRepositoryLock = {
|
||||||
formatVersion: 1;
|
formatVersion: 1;
|
||||||
quixos: GitSource;
|
quixos: QuixosSource;
|
||||||
resources: LockedResource[];
|
resources: LockedResource[];
|
||||||
|
sourceFiles?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NixGitInput = {
|
export type NixGitInput = {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { convergeAuthoring } from "../src/capability-language/authoring-converge.js";
|
||||||
|
import { loadQuixosLock } from "../src/resource-lock/index.js";
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
|
||||||
|
test("source convergence propagates nested edits and unchanged snapshots reach a fixed point", async context => {
|
||||||
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-converge-test-"));
|
||||||
|
context.after(() => fs.rm(temporary, { recursive: true, force: true }));
|
||||||
|
const workbench = path.join(temporary, "workbench"), remotes = path.join(temporary, "remotes");
|
||||||
|
await fs.mkdir(path.join(workbench, ".quixos"), { recursive: true });
|
||||||
|
await fs.mkdir(remotes);
|
||||||
|
const origin = "https://convergence.example.test/";
|
||||||
|
const previous = Object.fromEntries(["GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"].map(key => [key, process.env[key]]));
|
||||||
|
process.env.GIT_CONFIG_COUNT = "1";
|
||||||
|
process.env.GIT_CONFIG_KEY_0 = `url.file://${remotes}/.insteadOf`;
|
||||||
|
process.env.GIT_CONFIG_VALUE_0 = origin;
|
||||||
|
context.after(() => { for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } });
|
||||||
|
const resources = [];
|
||||||
|
let dependency = "";
|
||||||
|
for (const [directory, kind, name] of [["resources/Base", "interface", "Base"], ["resources/Consumer", "package", "Consumer"], ["root", "workspace", "Root"]]) {
|
||||||
|
const root = path.join(workbench, directory);
|
||||||
|
await fs.mkdir(root, { recursive: true });
|
||||||
|
await execFile("jj", ["git", "init", "--colocate", root]);
|
||||||
|
await execFile("git", ["init", "--bare", path.join(remotes, name)]);
|
||||||
|
const repository = `${origin}${name}`;
|
||||||
|
await execFile("git", ["-C", root, "remote", "add", "origin", repository]);
|
||||||
|
await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } ${dependency} }`);
|
||||||
|
await fs.writeFile(path.join(root, `${kind}.qx`), "draft");
|
||||||
|
await execFile("jj", ["status"], { cwd: root });
|
||||||
|
const commit = (await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], { cwd: root })).stdout.trim();
|
||||||
|
if (kind !== "workspace") resources.push({ directory, kind, resourceId: `${kind}:${name}`, source: { resolver: "git", repository, commit } });
|
||||||
|
dependency = `${kind} ${name} source { repository "${repository}"; commit "${commit}"; }`;
|
||||||
|
}
|
||||||
|
await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({ resources }));
|
||||||
|
const first = await convergeAuthoring(workbench);
|
||||||
|
assert.deepEqual(first.worklist, []);
|
||||||
|
assert.equal(first.converged, true);
|
||||||
|
assert.equal(first.verificationEvidence, false, "source retention never approves even syntactically invalid code");
|
||||||
|
await fs.writeFile(path.join(workbench, "resources/Base/interface.qx"), "edited draft");
|
||||||
|
const second = await convergeAuthoring(workbench);
|
||||||
|
assert.deepEqual(second.worklist, []);
|
||||||
|
assert.notEqual(second.candidate?.commit, first.candidate?.commit);
|
||||||
|
const consumer = await loadQuixosLock(path.join(workbench, "resources/Consumer/quixos.lock"));
|
||||||
|
assert.ok(consumer.ok);
|
||||||
|
assert.equal(consumer.lock.resources[0].source.commit, second.retained.find(entry => entry.directory === "resources/Base")?.source.commit);
|
||||||
|
const third = await convergeAuthoring(workbench);
|
||||||
|
assert.deepEqual(third, second);
|
||||||
|
const base = path.join(workbench, "resources/Base");
|
||||||
|
const consumerRoot = path.join(workbench, "resources/Consumer");
|
||||||
|
const goodLock = await fs.readFile(path.join(consumerRoot, "quixos.lock"), "utf8");
|
||||||
|
await fs.writeFile(path.join(consumerRoot, "quixos.lock"), "broken draft");
|
||||||
|
const scoped = await convergeAuthoring(workbench, "resources/Base");
|
||||||
|
assert.deepEqual(scoped.worklist, [], "an unrelated malformed consumer cannot block a provider check");
|
||||||
|
await fs.writeFile(path.join(base, "interface.qx"), "another edit");
|
||||||
|
const partial = await convergeAuthoring(workbench);
|
||||||
|
assert.equal(partial.converged, false);
|
||||||
|
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
||||||
|
assert.equal(graph.resources[0].source.commit, partial.retained.find(entry => entry.directory === "resources/Base")?.source.commit);
|
||||||
|
await fs.writeFile(path.join(consumerRoot, "quixos.lock"), goodLock);
|
||||||
|
const resumed = await convergeAuthoring(workbench);
|
||||||
|
assert.deepEqual(resumed.worklist, []);
|
||||||
|
assert.deepEqual(await convergeAuthoring(workbench), resumed);
|
||||||
|
await execFile("git", ["-C", base, "remote", "set-url", "origin", origin + "Wrong"]);
|
||||||
|
const mismatch = await convergeAuthoring(workbench);
|
||||||
|
assert.ok(mismatch.worklist.some(entry => entry.phase === "source" && /Origin differs/.test(entry.message)));
|
||||||
|
await execFile("git", ["-C", base, "remote", "set-url", "origin", origin + "Base"]);
|
||||||
|
await fs.rename(path.join(remotes, "Base"), path.join(remotes, "Base-offline"));
|
||||||
|
const offline = await convergeAuthoring(workbench);
|
||||||
|
assert.equal(offline.candidate, null);
|
||||||
|
assert.ok(offline.worklist.some(entry => entry.phase === "publication"));
|
||||||
|
await fs.rename(path.join(remotes, "Base-offline"), path.join(remotes, "Base"));
|
||||||
|
assert.equal((await convergeAuthoring(workbench)).converged, true);
|
||||||
|
const consumerSource = resumed.retained.find(entry => entry.directory === "resources/Consumer")!.source;
|
||||||
|
await fs.writeFile(path.join(base, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } package Consumer source { repository "${consumerSource.repository}"; commit "${consumerSource.commit}"; } }`);
|
||||||
|
const cycle = await convergeAuthoring(workbench);
|
||||||
|
assert.equal(cycle.candidate, null);
|
||||||
|
assert.ok(cycle.worklist.some(entry => /Source dependency cycle/.test(entry.message)));
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, writeFile, rm, symlink } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { inspectAuthoringRepository } from "../src/capability-language/authoring-inspect.js";
|
||||||
|
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
test("inspection keeps current files and labels historical recovery without granting verification", async context => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "qx-inspection-test-"));
|
||||||
|
context.after(() => rm(root, { recursive: true, force: true }));
|
||||||
|
const git = (...args: string[]) => execFile("git", ["-C", root, ...args]);
|
||||||
|
await git("init");
|
||||||
|
await writeFile(path.join(root, "interface.qx"), 'interface Example id "interface:example" revision "interface:example@1" {}');
|
||||||
|
await git("add", ".");
|
||||||
|
await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract");
|
||||||
|
const commit = (await git("rev-parse", "HEAD")).stdout.trim();
|
||||||
|
await writeFile(path.join(root, "interface.qx"), "interface broken {{{");
|
||||||
|
await writeFile(path.join(root, "new.qx"), 'interface New id "interface:new" revision "interface:new@1" {}');
|
||||||
|
const inspected = await inspectAuthoringRepository(root);
|
||||||
|
assert.equal(inspected.verificationEvidence, false);
|
||||||
|
assert.equal(inspected.resolutionChecked, false);
|
||||||
|
assert.equal(inspected.files[0].status, "historical");
|
||||||
|
assert.equal(inspected.files[0].revision, commit);
|
||||||
|
assert.ok(inspected.files[0].currentErrors.length);
|
||||||
|
assert.match(inspected.files[0].declarations[0].source, /Example/);
|
||||||
|
assert.equal(inspected.files[1].status, "current");
|
||||||
|
assert.match(inspected.files[1].declarations[0].source, /New/);
|
||||||
|
assert.equal((await git("rev-parse", "HEAD")).stdout.trim(), commit);
|
||||||
|
await symlink(path.join(root, "interface.qx"), path.join(root, "linked.qx"));
|
||||||
|
const linked = (await inspectAuthoringRepository(root)).files.find(file => file.file === "linked.qx")!;
|
||||||
|
assert.equal(linked.status, "unavailable");
|
||||||
|
assert.deepEqual(linked.declarations, []);
|
||||||
|
assert.match(JSON.stringify(linked.currentErrors), /ordinary files/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {execFile as callback} from "node:child_process";
|
||||||
|
import {promisify} from "node:util";
|
||||||
|
import {authoringWorklist} from "../src/capability-language/authoring-worklist.js";
|
||||||
|
import {checkRecordName} from "../src/capability-language/authoring-check.js";
|
||||||
|
import {checkerIdentity, snapshotCommit} from "../src/capability-language/checked-build.js";
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
|
||||||
|
test("worklist grows and clears from current source, dependency and checker observations", async context => {
|
||||||
|
const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-worklist-test-"));
|
||||||
|
context.after(() => fs.rm(workbench, {recursive: true, force: true}));
|
||||||
|
const root = path.join(workbench, "root"), provider = path.join(workbench, "resources/Base");
|
||||||
|
await fs.mkdir(root); await fs.mkdir(provider, {recursive: true});
|
||||||
|
await fs.mkdir(path.join(workbench, ".quixos/checks"), {recursive: true});
|
||||||
|
const header = `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; }`;
|
||||||
|
for (const directory of [root, provider]) await execFile("jj", ["git", "init", "--colocate", directory]);
|
||||||
|
await fs.writeFile(path.join(provider, "interface.qx"), 'interface Base id "interface:base" revision "interface:base@1" {}');
|
||||||
|
await fs.writeFile(path.join(provider, "quixos.lock"), `${header} }`);
|
||||||
|
const baseCommit = await snapshotCommit(provider);
|
||||||
|
await fs.writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"b".repeat(40)}" { import interface Base; }`);
|
||||||
|
await fs.writeFile(path.join(root, "quixos.lock"), `${header} interface Base source { repository "https://example.test/base"; commit "${baseCommit}"; } }`);
|
||||||
|
const rootCommit = await snapshotCommit(root);
|
||||||
|
await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [
|
||||||
|
{kind: "interface", directory: provider, source: {resolver: "git", repository: "https://example.test/base", commit: baseCommit}},
|
||||||
|
]}));
|
||||||
|
assert.equal((await authoringWorklist(workbench)).worklist.filter(entry => entry.phase === "unchecked").length, 2);
|
||||||
|
const remember = (directory: string, commit: string, checker = checkerIdentity()) => fs.writeFile(
|
||||||
|
path.join(workbench, ".quixos/checks", checkRecordName(directory)), JSON.stringify({commit, checker, phase: "checked", blockers: []}));
|
||||||
|
await remember("root", rootCommit); await remember("resources/Base", baseCommit);
|
||||||
|
assert.deepEqual((await authoringWorklist(workbench)).worklist, []);
|
||||||
|
await fs.writeFile(path.join(provider, "interface.qx"), "interface broken {{{");
|
||||||
|
const broken = await authoringWorklist(workbench);
|
||||||
|
assert.ok(broken.worklist.some(entry => entry.directory === "resources/Base" && entry.phase === "syntax" && /historical/.test(entry.message)));
|
||||||
|
assert.ok(broken.worklist.some(entry => entry.directory === "root" && entry.phase === "dependency"));
|
||||||
|
await fs.writeFile(path.join(provider, "interface.qx"), 'interface Base id "interface:base" revision "interface:base@1" {}');
|
||||||
|
assert.deepEqual((await authoringWorklist(workbench)).worklist, []);
|
||||||
|
await remember("resources/Base", baseCommit, "old-checker");
|
||||||
|
assert.ok((await authoringWorklist(workbench)).worklist.some(entry => /checker changed/.test(entry.message)));
|
||||||
|
await fs.writeFile(path.join(workbench, ".quixos/checks", checkRecordName("resources/Base")), JSON.stringify({phase: "publication", blockers: ["source retention unavailable"]}));
|
||||||
|
assert.ok((await authoringWorklist(workbench)).worklist.some(entry => entry.phase === "publication" && /source retention/.test(entry.message)));
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { compileCapabilityResourceSource } from "../src/capability-language/parser.js";
|
||||||
|
import { generateTypeScriptBindings, type BindingSchema } from "../src/bindings/index.js";
|
||||||
|
const source = { repository: "https://example.test/p.git", commit: "1".repeat(40) };
|
||||||
|
const schemaFor = (body: string): BindingSchema => {
|
||||||
|
const compiled = compileCapabilityResourceSource(`external atom Thing id "thing"; package Demo id "demo" revision "demo@1" { ${body} }`, { source });
|
||||||
|
assert.equal(compiled.ok, true, JSON.stringify(compiled.diagnostics));
|
||||||
|
if (!compiled.ok || compiled.resource.kind !== "package") throw new Error("expected package");
|
||||||
|
return { format: "quixos-bindings", version: 1, interfaces: [], packages: [compiled.resource.revision] };
|
||||||
|
};
|
||||||
|
test("generator uses exact IDs, restricted ports, nominal references, and lossless scalar types", () => {
|
||||||
|
const schema = schemaFor('operation run id "run-id" : list<int64> -> optional<atom-ref<Thing>> mode call receiver atom Thing requires { state payload id "payload-id" : bytes [read]; };');
|
||||||
|
const generated = generateTypeScriptBindings(schema, "demo@1");
|
||||||
|
assert.match(generated, /Array<bigint>/);
|
||||||
|
assert.match(generated, /Promise<Uint8Array>/);
|
||||||
|
assert.match(generated, /QxObjectRef<"atom:thing">/);
|
||||||
|
assert.doesNotMatch(generated, /"set":/);
|
||||||
|
assert.match(generated, /"run-id": bindQxHandler/);
|
||||||
|
assert.equal(generateTypeScriptBindings(schema, "demo@1"), generated);
|
||||||
|
});
|
||||||
|
test("missing external types and constructor signatures fail generation", () => {
|
||||||
|
const schema = schemaFor('function run id "run" : unit -> message "example.Payload";');
|
||||||
|
assert.throws(() => generateTypeScriptBindings(schema, "demo@1"), /Missing TypeScript message binding/);
|
||||||
|
assert.match(generateTypeScriptBindings(schema, "demo@1", { messages: { "example.Payload": { module: "./payload.js", export: "payload" } } }), /BindingValue<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/);
|
||||||
|
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/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {bundlePolicyErrors} from "../src/bindings/bundle-policy.js";
|
||||||
|
import {addImplementation} from "../src/capability-language/implementation-edit.js";
|
||||||
|
|
||||||
|
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)'])
|
||||||
|
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.meta.url\nconst text = "__dirname";', "source.ts"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imperative handler insertion preserves arbitrary existing code and rejects ambiguous targets", () => {
|
||||||
|
const original = 'const keep = "createRuntime({fake:1})";\nservePackageRuntime(createRuntime({ existing: customHandler }));\n';
|
||||||
|
const edited = addImplementation(original, "createRuntime", "newHandler", "./impl/new.js");
|
||||||
|
assert.match(edited, /existing: customHandler/);
|
||||||
|
assert.match(edited, /const keep =/);
|
||||||
|
assert.match(edited, /"newHandler": qxImplementation/);
|
||||||
|
assert.throws(() => addImplementation(original, "createRuntime", "existing", "./x.js"), /already exists/);
|
||||||
|
assert.throws(() => addImplementation('createRuntime(one);', "createRuntime", "x", "./x.js"), /Cannot safely/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { snapshotRepository, checkWorkspaceCandidate } from "../src/capability-language/candidate-check.js";
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
|
||||||
|
test("candidate snapshots include dirty and new files without changing Git history or index", async (context) => {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "qx-candidate-test-"));
|
||||||
|
context.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||||
|
const root = path.join(directory, "source");
|
||||||
|
await fs.mkdir(root);
|
||||||
|
const git = (...args: string[]) => execFile("git", ["-C", root, ...args]);
|
||||||
|
await git("init");
|
||||||
|
await fs.writeFile(path.join(root, "tracked"), "original");
|
||||||
|
await fs.writeFile(path.join(root, ".gitignore"), "ignored\n");
|
||||||
|
await git("add", ".");
|
||||||
|
const index = await fs.readFile(path.join(root, ".git/index"));
|
||||||
|
await fs.writeFile(path.join(root, "tracked"), "edited");
|
||||||
|
await fs.writeFile(path.join(root, "new"), "new content");
|
||||||
|
await fs.writeFile(path.join(root, "ignored"), "not source");
|
||||||
|
const snapshot = await snapshotRepository(root, path.join(directory, "snapshot"));
|
||||||
|
assert.equal(await fs.readFile(path.join(snapshot.directory, "tracked"), "utf8"), "edited");
|
||||||
|
assert.equal(await fs.readFile(path.join(snapshot.directory, "new"), "utf8"), "new content");
|
||||||
|
await assert.rejects(fs.access(path.join(snapshot.directory, "ignored")));
|
||||||
|
assert.deepEqual(await fs.readFile(path.join(root, ".git/index")), index);
|
||||||
|
await assert.rejects(git("rev-parse", "--verify", "HEAD"), "no commit was created");
|
||||||
|
await fs.symlink("tracked", path.join(root, "link"));
|
||||||
|
await assert.rejects(snapshotRepository(root, path.join(directory, "rejected")), /regular file/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("candidate check produces explicitly non-activation evidence and never overwrites a report", async (context) => {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "qx-check-test-"));
|
||||||
|
context.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||||
|
const root = path.join(directory, "source"), output = path.join(directory, "check");
|
||||||
|
await fs.mkdir(root);
|
||||||
|
await execFile("jj", ["git", "init", "--colocate", root]);
|
||||||
|
await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${"a".repeat(40)}"; } }`);
|
||||||
|
await fs.writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"b".repeat(40)}" { atom Subject id "atom:subject"; }`);
|
||||||
|
const result = await checkWorkspaceCandidate({root, output});
|
||||||
|
assert.equal(result.candidateOnly, true);
|
||||||
|
assert.equal(result.activationEvidence, false);
|
||||||
|
assert.deepEqual(result.blockers, []);
|
||||||
|
const report = await fs.readFile(path.join(output, "report.json"));
|
||||||
|
await assert.rejects(checkWorkspaceCandidate({root, output}), /EEXIST/);
|
||||||
|
assert.deepEqual(await fs.readFile(path.join(output, "report.json")), report);
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
compileCapabilityResourceRepository,
|
||||||
|
compileWorkspaceRepository,
|
||||||
|
} from "../src/capability-language/index.js";
|
||||||
|
|
||||||
|
const quixosCommit = "1".repeat(40);
|
||||||
|
const namedCommit = "2".repeat(40);
|
||||||
|
const packageCommit = "3".repeat(40);
|
||||||
|
|
||||||
|
const lock = (resources = "") => `quixos-lock version 1 {
|
||||||
|
quixos source {
|
||||||
|
repository "https://example.test/quixos.git";
|
||||||
|
commit "${quixosCommit}";
|
||||||
|
}
|
||||||
|
${resources}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("workspace assembly resolves resource-owned dependencies recursively", async (context) => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "quixos-assembly-"));
|
||||||
|
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
const root = path.join(directory, "root");
|
||||||
|
const named = path.join(directory, "named");
|
||||||
|
const runtime = path.join(directory, "runtime");
|
||||||
|
await Promise.all([mkdir(root), mkdir(named), mkdir(runtime)]);
|
||||||
|
await writeFile(path.join(root, "quixos.lock"), lock(`package Runtime source {
|
||||||
|
repository "https://example.test/package-runtime.git";
|
||||||
|
commit "${packageCommit}";
|
||||||
|
}`));
|
||||||
|
await writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"4".repeat(40)}" {
|
||||||
|
atom Subject id "atom:subject";
|
||||||
|
import package Runtime;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
await writeFile(path.join(named, "quixos.lock"), lock());
|
||||||
|
await writeFile(path.join(named, "interface.qx"), `interface Named id "interface:named" revision "interface:named@1" {
|
||||||
|
value name id "member:named:name" : string {
|
||||||
|
get id "operation:named:name:get";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
await writeFile(path.join(runtime, "quixos.lock"), lock(`interface Named source {
|
||||||
|
repository "https://example.test/interface-named.git";
|
||||||
|
commit "${namedCommit}";
|
||||||
|
}`));
|
||||||
|
await writeFile(path.join(runtime, "package.qx"), `import interface Named;
|
||||||
|
package Runtime id "package:runtime" revision "package:runtime@1" {
|
||||||
|
function describe id "export:runtime:describe" : interface-ref<Named> -> string;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const directories = new Map([
|
||||||
|
[`interface\0https://example.test/interface-named.git\0${namedCommit}`, named],
|
||||||
|
[`package\0https://example.test/package-runtime.git\0${packageCommit}`, runtime],
|
||||||
|
]);
|
||||||
|
const result = await compileWorkspaceRepository({
|
||||||
|
rootDirectory: root,
|
||||||
|
resolveResource: async (source, kind) => {
|
||||||
|
const resolved = directories.get(`${kind}\0${source.repository}\0${source.commit}`);
|
||||||
|
if (!resolved) throw new Error(`Unexpected source ${source.repository}`);
|
||||||
|
return { directory: resolved };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.resources.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
result.workspace.interfaceImports.map((entry) => entry.revisionId),
|
||||||
|
["interface:named@1"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
result.workspace.packageImports.map((entry) => entry.revisionId),
|
||||||
|
["package:runtime@1"],
|
||||||
|
);
|
||||||
|
assert.equal(result.directResources.size, 1);
|
||||||
|
|
||||||
|
const resource = await compileCapabilityResourceRepository({
|
||||||
|
rootDirectory: runtime,
|
||||||
|
kind: "package",
|
||||||
|
source: {
|
||||||
|
resolver: "git",
|
||||||
|
repository: "https://example.test/package-runtime.git",
|
||||||
|
commit: packageCommit,
|
||||||
|
},
|
||||||
|
resolveResource: async (source, kind) => {
|
||||||
|
const resolved = directories.get(`${kind}\0${source.repository}\0${source.commit}`);
|
||||||
|
if (!resolved) throw new Error(`Unexpected source ${source.repository}`);
|
||||||
|
return { directory: resolved };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(resource.resource.kind, "package");
|
||||||
|
assert.equal(resource.resources.length, 2);
|
||||||
|
assert.equal(resource.directResources.size, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource repositories cannot own workspace Quixos selection policy", async (context) => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "quixos-resource-policy-"));
|
||||||
|
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
await writeFile(path.join(directory, "quixos.lock"), `quixos-lock version 1 {
|
||||||
|
quixos source {
|
||||||
|
repository "https://example.test/quixos.git";
|
||||||
|
policy track-development;
|
||||||
|
ref "dev/alice/main";
|
||||||
|
commit "${quixosCommit}";
|
||||||
|
}
|
||||||
|
}`);
|
||||||
|
await writeFile(path.join(directory, "package.qx"), `package Runtime id "package:runtime" revision "package:runtime@1" { }\n`);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
compileCapabilityResourceRepository({
|
||||||
|
rootDirectory: directory,
|
||||||
|
kind: "package",
|
||||||
|
source: {
|
||||||
|
resolver: "git",
|
||||||
|
repository: "https://example.test/package-runtime.git",
|
||||||
|
commit: packageCommit,
|
||||||
|
},
|
||||||
|
resolveResource: async () => ({ directory }),
|
||||||
|
}),
|
||||||
|
/resource locks may only declare their exact authored-against commit/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource manifests cannot hide lock dependencies", async (context) => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "quixos-assembly-mismatch-"));
|
||||||
|
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
const root = path.join(directory, "root");
|
||||||
|
const runtime = path.join(directory, "runtime");
|
||||||
|
await Promise.all([mkdir(root), mkdir(runtime)]);
|
||||||
|
await writeFile(path.join(root, "quixos.lock"), lock(`package Runtime source {
|
||||||
|
repository "https://example.test/package-runtime.git";
|
||||||
|
commit "${packageCommit}";
|
||||||
|
}`));
|
||||||
|
await writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"4".repeat(40)}" {
|
||||||
|
atom Subject id "atom:subject";
|
||||||
|
import package Runtime;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
await writeFile(path.join(runtime, "quixos.lock"), lock());
|
||||||
|
await writeFile(path.join(runtime, "package.qx"), `import interface Hidden;
|
||||||
|
package Runtime id "package:runtime" revision "package:runtime@1" { }
|
||||||
|
`);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
compileWorkspaceRepository({
|
||||||
|
rootDirectory: root,
|
||||||
|
resolveResource: async () => ({ directory: runtime }),
|
||||||
|
}),
|
||||||
|
/No resolved interface is available for lock binding Hidden/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspace assembly must satisfy nominal external interfaces", async (context) => {
|
||||||
|
const directory = await mkdtemp(path.join(os.tmpdir(), "quixos-assembly-external-"));
|
||||||
|
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
const root = path.join(directory, "root");
|
||||||
|
const runtime = path.join(directory, "runtime");
|
||||||
|
await Promise.all([mkdir(root), mkdir(runtime)]);
|
||||||
|
await writeFile(path.join(root, "quixos.lock"), lock(`package Runtime source {
|
||||||
|
repository "https://example.test/package-runtime.git";
|
||||||
|
commit "${packageCommit}";
|
||||||
|
}`));
|
||||||
|
await writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"4".repeat(40)}" {
|
||||||
|
atom Subject id "atom:subject";
|
||||||
|
import package Runtime;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
await writeFile(path.join(runtime, "quixos.lock"), lock());
|
||||||
|
await writeFile(path.join(runtime, "package.qx"), `external interface Named revision "interface:named@1";
|
||||||
|
package Runtime id "package:runtime" revision "package:runtime@1" { }
|
||||||
|
`);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
compileWorkspaceRepository({
|
||||||
|
rootDirectory: root,
|
||||||
|
resolveResource: async () => ({ directory: runtime }),
|
||||||
|
}),
|
||||||
|
/requires external interface Named \(interface:named@1\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
const cli = resolve(process.cwd(), "dist/src/capability-language/cli.js");
|
||||||
|
|
||||||
|
const runResourceCheck = (source: string) => spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[cli, "--resource", "--check", "-"],
|
||||||
|
{ input: source, encoding: "utf8" },
|
||||||
|
);
|
||||||
|
|
||||||
|
test("capability CLI checks a standalone interface resource", () => {
|
||||||
|
const result = runResourceCheck(`interface WeatherBase id "interface:weather-base" revision "interface:weather-base@1" {
|
||||||
|
value temperature id "member:weather:temperature" : double {
|
||||||
|
get id "operation:weather:temperature:get";
|
||||||
|
}
|
||||||
|
}\n`);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stdout, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("capability CLI reports invalid standalone interface members", () => {
|
||||||
|
const result = runResourceCheck(`interface WeatherBase id "interface:weather-base" revision "interface:weather-base@1" {
|
||||||
|
value temperature : definitely-not-a-type;
|
||||||
|
}\n`);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /syntax-error/);
|
||||||
|
});
|
||||||
@@ -1,17 +1,108 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { compileCapabilitySource } from "../src/capability-language/index.js";
|
|
||||||
import {
|
import {
|
||||||
capabilityFixturePath,
|
compileCapabilityResourceSource,
|
||||||
|
compileCapabilitySource,
|
||||||
|
} from "../src/capability-language/index.js";
|
||||||
|
import {
|
||||||
capabilityFixtureSource,
|
capabilityFixtureSource,
|
||||||
|
capabilityResourceSources,
|
||||||
|
compileCapabilityFixture,
|
||||||
fixtureId,
|
fixtureId,
|
||||||
} from "./fixtures/capability-model.js";
|
} from "./fixtures/capability-model.js";
|
||||||
|
|
||||||
test("ANTLR parses and validates a complete capability workspace", () => {
|
test("RPC record fields retain declared reference targets and reject duplicate fields", () => {
|
||||||
const result = compileCapabilitySource(
|
const compile = (fields: string) => compileCapabilityResourceSource(`
|
||||||
capabilityFixtureSource,
|
external atom Board id "atom:board";
|
||||||
capabilityFixturePath,
|
package Runtime id "package:runtime" revision "package:runtime@1" {
|
||||||
|
function move id "export:move" : record { ${fields} } -> unit;
|
||||||
|
}`, {source: {repository: "https://example.test/runtime.git", commit: "a".repeat(40)}});
|
||||||
|
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(compile("board: atom-ref<Board>; board: string;").ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const compileWebStudioFixture = (packageTransform = (source: string) => source) => {
|
||||||
|
const fixture = (name: string) => readFileSync(
|
||||||
|
resolve(process.cwd(), "test/fixtures", name),
|
||||||
|
"utf8",
|
||||||
);
|
);
|
||||||
|
const react = compileCapabilityResourceSource(
|
||||||
|
fixture("react-component.interface.qx"),
|
||||||
|
{
|
||||||
|
source: {
|
||||||
|
repository: "https://repos.quixos.org/org-quixos-web-studio/interface-react-component.git",
|
||||||
|
commit: "2".repeat(40),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!react.ok || react.resource.kind !== "interface") {
|
||||||
|
throw new Error("ReactComponent fixture did not compile");
|
||||||
|
}
|
||||||
|
const named = compileCapabilityResourceSource(
|
||||||
|
fixture("named.interface.qx"),
|
||||||
|
{
|
||||||
|
source: {
|
||||||
|
repository: "https://repos.quixos.org/quixos-test/interface-named.git",
|
||||||
|
commit: "5".repeat(40),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!named.ok || named.resource.kind !== "interface") {
|
||||||
|
throw new Error("Named fixture did not compile");
|
||||||
|
}
|
||||||
|
const has = compileCapabilityResourceSource(
|
||||||
|
fixture("has-react-component.interface.qx"),
|
||||||
|
{
|
||||||
|
source: {
|
||||||
|
repository: "https://repos.quixos.org/org-quixos-web-studio/interface-has-react-component.git",
|
||||||
|
commit: "3".repeat(40),
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
interfaces: new Map([["ReactComponent", react.resource.revision]]),
|
||||||
|
interfaceClosure: [react.resource.revision],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!has.ok || has.resource.kind !== "interface") {
|
||||||
|
throw new Error("HasReactComponent fixture did not compile");
|
||||||
|
}
|
||||||
|
const component = compileCapabilityResourceSource(
|
||||||
|
packageTransform(fixture("component-runtime.package.qx")),
|
||||||
|
{
|
||||||
|
source: {
|
||||||
|
repository: "https://repos.quixos.org/quixos-test/package-component-runtime.git",
|
||||||
|
commit: "4".repeat(40),
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
interfaces: new Map([["Named", named.resource.revision]]),
|
||||||
|
interfaceClosure: [named.resource.revision],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!component.ok || component.resource.kind !== "package") {
|
||||||
|
throw new Error("ComponentRuntime fixture did not compile");
|
||||||
|
}
|
||||||
|
return compileCapabilitySource(
|
||||||
|
fixture("web-studio.capabilities.qx"),
|
||||||
|
"web-studio.capabilities.qx",
|
||||||
|
{
|
||||||
|
interfaces: new Map([
|
||||||
|
["Named", named.resource.revision],
|
||||||
|
["ReactComponent", react.resource.revision],
|
||||||
|
["HasReactComponent", has.resource.revision],
|
||||||
|
]),
|
||||||
|
packages: new Map([["ComponentRuntime", component.resource.revision]]),
|
||||||
|
interfaceClosure: [named.resource.revision, react.resource.revision, has.resource.revision],
|
||||||
|
packageClosure: [component.resource.revision],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("ANTLR parses and validates a complete capability workspace", () => {
|
||||||
|
const result = compileCapabilityFixture();
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
assert.equal(result.workspace.workspaceId, fixtureId.workspace);
|
assert.equal(result.workspace.workspaceId, fixtureId.workspace);
|
||||||
@@ -21,27 +112,25 @@ test("ANTLR parses and validates a complete capability workspace", () => {
|
|||||||
assert.equal("id" in result.workspace.conformances[0]!, false);
|
assert.equal("id" in result.workspace.conformances[0]!, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("conformances do not accept authored IDs", () => {
|
test("conformances preserve authored migration lineage IDs", () => {
|
||||||
const result = compileCapabilitySource(
|
const result = compileCapabilityFixture({
|
||||||
capabilityFixtureSource.replace(
|
workspace: capabilityFixtureSource.replace(
|
||||||
"conform Project as Named {",
|
"conform Project as Named {",
|
||||||
'conform Project as Named id "conformance:project:named" {',
|
'conform Project as Named id "conformance:project:named" {',
|
||||||
),
|
),
|
||||||
"conformance-id.qx",
|
});
|
||||||
);
|
assert.equal(result.ok, true);
|
||||||
assert.equal(result.ok, false);
|
if (!result.ok) return;
|
||||||
if (result.ok) return;
|
assert.equal(result.workspace.conformances[0]!.id, "conformance:project:named");
|
||||||
assert.ok(result.diagnostics.some((entry) => entry.phase === "syntax"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the v1 language has no implicit relationship materialization rule", () => {
|
test("the v1 language has no implicit relationship materialization rule", () => {
|
||||||
const result = compileCapabilitySource(
|
const result = compileCapabilityFixture({
|
||||||
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',
|
||||||
),
|
),
|
||||||
"materialize.qx",
|
});
|
||||||
);
|
|
||||||
assert.equal(result.ok, false);
|
assert.equal(result.ok, false);
|
||||||
if (result.ok) return;
|
if (result.ok) return;
|
||||||
assert.ok(result.diagnostics.some((entry) => entry.phase === "syntax"));
|
assert.ok(result.diagnostics.some((entry) => entry.phase === "syntax"));
|
||||||
@@ -55,21 +144,23 @@ test("forward declarations make source order irrelevant", () => {
|
|||||||
/\n}\s*$/,
|
/\n}\s*$/,
|
||||||
'\n atom Project id "atom:project";\n atom Person id "atom:person";\n}\n',
|
'\n atom Project id "atom:project";\n atom Person id "atom:person";\n}\n',
|
||||||
);
|
);
|
||||||
assert.equal(compileCapabilitySource(source, "reordered.qx").ok, true);
|
assert.equal(compileCapabilityFixture({ workspace: source }).ok, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("interfaces can declare ordinary call operations", () => {
|
test("interfaces can declare ordinary call operations", () => {
|
||||||
const source = capabilityFixtureSource.replace(
|
const workspace = capabilityFixtureSource.replace(
|
||||||
" value summary id \"member:summary:summary\" : string {\n get id \"operation:summary:summary:get\";\n }",
|
|
||||||
" operation summarize id \"member:summary:summarize\" : string -> string {\n call id \"operation:summary:summarize\";\n }",
|
|
||||||
).replace(
|
|
||||||
"bind summary.get to package TodoRuntime.summaryGet",
|
"bind summary.get to package TodoRuntime.summaryGet",
|
||||||
"bind summarize.call to package TodoRuntime.summaryGet",
|
"bind summarize.call to package TodoRuntime.summaryGet",
|
||||||
).replace(
|
);
|
||||||
|
const summary = capabilityResourceSources.summary.replace(
|
||||||
|
" value summary id \"member:summary:summary\" : string {\n get id \"operation:summary:summary:get\";\n }",
|
||||||
|
" operation summarize id \"member:summary:summarize\" : string -> string {\n call id \"operation:summary:summarize\";\n }",
|
||||||
|
);
|
||||||
|
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 = compileCapabilitySource(source, "operation-member.qx");
|
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
|
||||||
@@ -84,7 +175,7 @@ test("state defaults accept recursive JSON values", () => {
|
|||||||
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};`,
|
||||||
);
|
);
|
||||||
const result = compileCapabilitySource(source, "json-default.qx");
|
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(
|
||||||
@@ -101,25 +192,23 @@ test("state defaults accept recursive JSON values", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("syntax errors retain source locations", () => {
|
test("syntax errors retain source locations", () => {
|
||||||
const result = compileCapabilitySource(
|
const result = compileCapabilityFixture({
|
||||||
capabilityFixtureSource.replace("atom Project", "atom Project ???"),
|
workspace: capabilityFixtureSource.replace("atom Project", "atom Project ???"),
|
||||||
"broken.qx",
|
});
|
||||||
);
|
|
||||||
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.fileName === "broken.qx" && 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 = compileCapabilitySource(
|
const result = compileCapabilityFixture({
|
||||||
capabilityFixtureSource.replace(
|
workspace: capabilityFixtureSource.replace(
|
||||||
"to state ProjectTitle.read",
|
"to state ProjectTitle.read",
|
||||||
"to state NotAState.read",
|
"to state NotAState.read",
|
||||||
),
|
),
|
||||||
"unknown-name.qx",
|
});
|
||||||
);
|
|
||||||
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) =>
|
||||||
@@ -130,13 +219,12 @@ test("unknown authoring names are lowering errors", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("well-formed but invalid programs report semantic paths", () => {
|
test("well-formed but invalid programs report semantic paths", () => {
|
||||||
const result = compileCapabilitySource(
|
const result = compileCapabilityFixture({
|
||||||
capabilityFixtureSource.replace(
|
workspace: capabilityFixtureSource.replace(
|
||||||
"bind name.get to state ProjectTitle.read",
|
"bind name.get to state ProjectTitle.read",
|
||||||
"bind name.get to state ProjectTitle.write",
|
"bind name.get to state ProjectTitle.write",
|
||||||
),
|
),
|
||||||
"invalid-binding.qx",
|
});
|
||||||
);
|
|
||||||
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(
|
||||||
@@ -146,3 +234,69 @@ test("well-formed but invalid programs report semantic paths", () => {
|
|||||||
assert.equal(diagnostic.phase, "validation");
|
assert.equal(diagnostic.phase, "validation");
|
||||||
assert.ok(diagnostic.path?.includes("operationBindings"));
|
assert.ok(diagnostic.path?.includes("operationBindings"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Web Studio sidecars declare lazy materialization and checked cross-object ports", () => {
|
||||||
|
const result = compileWebStudioFixture();
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const host = result.workspace.conformances.find((entry) =>
|
||||||
|
entry.atomId === "atom:project" &&
|
||||||
|
entry.interfaceRevisionId === "interface:org.quixos.web-studio.has-react-component@1"
|
||||||
|
);
|
||||||
|
assert.deepEqual(host?.relationshipMaterializations, [{
|
||||||
|
memberId: "member:org.quixos.web-studio.has-react-component:component",
|
||||||
|
constructorAtomId: "atom:project-component",
|
||||||
|
edgeTypeId: "edge:project:component",
|
||||||
|
constructedProjectionId: "projection:component:subject",
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const component = result.workspace.conformances.find((entry) =>
|
||||||
|
entry.atomId === "atom:project-component"
|
||||||
|
);
|
||||||
|
const binding = component?.operationBindings[0]?.binding;
|
||||||
|
assert.equal(binding?.kind, "package");
|
||||||
|
if (binding?.kind !== "package") return;
|
||||||
|
assert.deepEqual(binding.dependencies[0]?.binding, {
|
||||||
|
kind: "interface",
|
||||||
|
interfaceRevisionId: "interface:named@1",
|
||||||
|
via: {
|
||||||
|
edgeTypeId: "edge:project:component",
|
||||||
|
projectionId: "projection:component:subject",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("relationship materializers require a constructor from the host atom", () => {
|
||||||
|
const result = compileWebStudioFixture((source) => source.replace(
|
||||||
|
"constructs ProjectComponent : atom-ref<Project>;",
|
||||||
|
"constructs ProjectComponent : unit;",
|
||||||
|
));
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (result.ok) return;
|
||||||
|
assert.ok(result.diagnostics.some((entry) =>
|
||||||
|
entry.code === "invalid-relationship-materialization" &&
|
||||||
|
entry.message.includes("must accept")
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("callable interface ports require a full source dependency, not a nominal reference", () => {
|
||||||
|
const result = compileCapabilityResourceSource(`
|
||||||
|
external interface Named revision "interface:named@1";
|
||||||
|
package Runtime id "package:runtime" revision "package:runtime@1" {
|
||||||
|
function readName id "export:runtime:read-name" : unit -> string requires {
|
||||||
|
interface named id "port:runtime:named" : Named;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`, {
|
||||||
|
source: {
|
||||||
|
repository: "https://repos.quixos.org/quixos-test/package-runtime.git",
|
||||||
|
commit: "6".repeat(40),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (result.ok) return;
|
||||||
|
assert.ok(result.diagnostics.some((entry) =>
|
||||||
|
entry.code === "nominal-interface-port" && entry.message.includes("import interface Named")
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ import {
|
|||||||
makeValidCapabilityWorkspace,
|
makeValidCapabilityWorkspace,
|
||||||
} from "./fixtures/capability-model.js";
|
} from "./fixtures/capability-model.js";
|
||||||
|
|
||||||
|
test("ordinary state rejects managed references even under list/optional wrappers", () => {
|
||||||
|
const workspace = makeValidCapabilityWorkspace();
|
||||||
|
const state = [...workspace.sharedAttachments, ...workspace.conformances.flatMap((entry) => entry.privateAttachments)].find((entry) => entry.kind === "state")!;
|
||||||
|
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}}}};
|
||||||
|
assert.ok(validateWorkspaceRevision(workspace).some((entry) => entry.message.includes("graph relationships")));
|
||||||
|
});
|
||||||
|
|
||||||
const expectIssue = (
|
const expectIssue = (
|
||||||
workspace: WorkspaceRevision,
|
workspace: WorkspaceRevision,
|
||||||
code: CapabilityValidationIssueCode,
|
code: CapabilityValidationIssueCode,
|
||||||
@@ -28,6 +36,18 @@ const expectIssue = (
|
|||||||
assert.equal(compileWorkspaceRevision(workspace).ok, false);
|
assert.equal(compileWorkspaceRevision(workspace).ok, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test("constructor dependency input contracts match the selected constructor", () => {
|
||||||
|
const workspace = makeValidCapabilityWorkspace();
|
||||||
|
const port = workspace.packageImports.flatMap((pkg) => pkg.exports).flatMap((entry) => entry.dependencyPorts)
|
||||||
|
.find((port) => port.requirement.kind === "constructor")!;
|
||||||
|
assert.equal(port.requirement.kind, "constructor");
|
||||||
|
if (port.requirement.kind !== "constructor") return;
|
||||||
|
port.requirement.inputType = valueType.unit;
|
||||||
|
assert.deepEqual(validateWorkspaceRevision(workspace), []);
|
||||||
|
port.requirement.inputType = valueType.string;
|
||||||
|
expectIssue(workspace, "invalid-dependency-binding");
|
||||||
|
});
|
||||||
|
|
||||||
const conformance = (
|
const conformance = (
|
||||||
workspace: WorkspaceRevision,
|
workspace: WorkspaceRevision,
|
||||||
identity: Pick<(typeof workspace.conformances)[number], "atomId" | "interfaceRevisionId">,
|
identity: Pick<(typeof workspace.conformances)[number], "atomId" | "interfaceRevisionId">,
|
||||||
@@ -92,7 +112,7 @@ test("the representative v1 workspace compiles to native and package plans", ()
|
|||||||
summary.dependencies.map((entry) => [entry.port.id, entry.binding.kind]),
|
summary.dependencies.map((entry) => [entry.port.id, entry.binding.kind]),
|
||||||
[
|
[
|
||||||
[fixtureId.titlePort, "state"],
|
[fixtureId.titlePort, "state"],
|
||||||
[fixtureId.namedPort, "receiver-interface"],
|
[fixtureId.namedPort, "interface"],
|
||||||
[fixtureId.personPort, "constructor"],
|
[fixtureId.personPort, "constructor"],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -164,6 +184,47 @@ test("private attachments are visible only to their owning conformance", () => {
|
|||||||
expectIssue(workspace, "private-attachment-access");
|
expectIssue(workspace, "private-attachment-access");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("related-object dependency views cannot traverse another conformance's private edge", () => {
|
||||||
|
const workspace = makeValidCapabilityWorkspace();
|
||||||
|
const packageBinding = conformance(
|
||||||
|
workspace,
|
||||||
|
fixtureId.projectSummaryConformance,
|
||||||
|
).operationBindings[0]!.binding;
|
||||||
|
assert.equal(packageBinding.kind, "package");
|
||||||
|
if (packageBinding.kind !== "package") return;
|
||||||
|
const named = packageBinding.dependencies.find(
|
||||||
|
(dependency) => dependency.portId === fixtureId.namedPort,
|
||||||
|
);
|
||||||
|
assert.ok(named && named.binding.kind === "interface");
|
||||||
|
named.binding.via = {
|
||||||
|
edgeTypeId: fixtureId.projectOwner,
|
||||||
|
projectionId: fixtureId.projectOwnerProjection,
|
||||||
|
};
|
||||||
|
expectIssue(workspace, "private-attachment-access");
|
||||||
|
const owner = conformance(workspace, fixtureId.projectOwnedConformance);
|
||||||
|
const edge = owner.privateAttachments.find((entry) => entry.kind === "edge" && entry.id === fixtureId.projectOwner);
|
||||||
|
assert.ok(edge?.kind === "edge");
|
||||||
|
edge.endpoints.find((endpoint) => endpoint.projectionId === fixtureId.projectOwnerProjection)!.publicTraversal = true;
|
||||||
|
assert.equal(validateWorkspaceRevision(workspace).some((entry) => entry.code === "private-attachment-access"), false, "Only an explicitly exported read-only traversal crosses ownership");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public traversal permits a native inverse read without exporting mutation authority", () => {
|
||||||
|
const workspace = makeValidCapabilityWorkspace();
|
||||||
|
const owned = conformance(workspace, fixtureId.projectOwnedConformance);
|
||||||
|
const index = owned.privateAttachments.findIndex(entry => entry.kind === "edge" && entry.id === fixtureId.projectOwner);
|
||||||
|
const edge = owned.privateAttachments.splice(index, 1)[0];
|
||||||
|
assert.ok(edge?.kind === "edge");
|
||||||
|
conformance(workspace, fixtureId.projectNamedConformance).privateAttachments.push(edge);
|
||||||
|
expectIssue(workspace, "private-attachment-access");
|
||||||
|
edge.endpoints.find(endpoint => endpoint.projectionId === fixtureId.projectOwnerProjection)!.publicTraversal = true;
|
||||||
|
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);
|
||||||
|
const binding = owned.operationBindings[0].binding;
|
||||||
|
assert.ok(binding.kind === "edge");
|
||||||
|
binding.primitive = "connect";
|
||||||
|
expectIssue(workspace, "private-attachment-access");
|
||||||
|
});
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { capabilityId as id, valueType, validateWorkspaceRevision } from "../src/capability-model/index.js";
|
||||||
|
import { contentDigest, planEvolution, runtimeContracts, storageContracts, storageChangeRequiresMigration } from "../src/capability-model/evolution.js";
|
||||||
|
import { capabilityFixtureSource, capabilityResourceSources, compileCapabilityFixture, makeValidCapabilityWorkspace } from "./fixtures/capability-model.js";
|
||||||
|
|
||||||
|
test("QX carries stable conformance IDs and implementation semantic majors", () => {
|
||||||
|
const result = compileCapabilityFixture({
|
||||||
|
workspace: capabilityFixtureSource.replace("conform Project as Named {", 'conform Project as Named id "conformance:project:named" semantic-major 3 {'),
|
||||||
|
todo: capabilityResourceSources.todo.replace('revision "package:todo-runtime@1" {', 'revision "package:todo-runtime@1" semantic-major 2 {'),
|
||||||
|
});
|
||||||
|
assert.ok(result.ok, JSON.stringify(result));
|
||||||
|
assert.equal(result.workspace.conformances[0]!.id, "conformance:project:named");
|
||||||
|
assert.equal(result.workspace.conformances[0]!.semanticMajor, 3);
|
||||||
|
assert.equal(result.workspace.packageImports[0]!.semanticMajor, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid majors and duplicate owner identities are rejected", () => {
|
||||||
|
const workspace = makeValidCapabilityWorkspace();
|
||||||
|
workspace.packageImports[0]!.semanticMajor = 0;
|
||||||
|
workspace.conformances[0]!.semanticMajor = 1.5;
|
||||||
|
workspace.conformances[0]!.id = id.conformance("same");
|
||||||
|
workspace.conformances[1]!.id = id.conformance("same");
|
||||||
|
const issues = validateWorkspaceRevision(workspace);
|
||||||
|
assert.equal(issues.filter((entry) => entry.code === "invalid-semantic-major").length, 2);
|
||||||
|
assert.ok(issues.some((entry) => entry.code === "duplicate-conformance-id"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hashing is canonical and rejects values outside the persisted JSON contract", () => {
|
||||||
|
assert.equal(contentDigest({ z: 1, a: { y: 3, b: 2 } }), contentDigest({ a: { b: 2, y: 3 }, z: 1 }));
|
||||||
|
assert.notEqual(contentDigest([1, 2]), contentDigest([2, 1]));
|
||||||
|
assert.throws(() => contentDigest(NaN));
|
||||||
|
assert.throws(() => contentDigest(new Date()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a workspace root change and display names do not restart package runtimes", () => {
|
||||||
|
const before = makeValidCapabilityWorkspace();
|
||||||
|
const after = structuredClone(before);
|
||||||
|
after.id = id.workspaceRevision("next");
|
||||||
|
after.sourceRootCommit = "f".repeat(40);
|
||||||
|
after.atoms[0]!.displayName = "Renamed";
|
||||||
|
after.sharedAttachments[0]!.displayName = "Renamed storage";
|
||||||
|
after.conformances.reverse();
|
||||||
|
after.interfaceImports.reverse();
|
||||||
|
const report = planEvolution(before, after, { allowLegacy: true });
|
||||||
|
assert.deepEqual(report.runtimeActions.map((entry) => entry.action), ["keep"]);
|
||||||
|
assert.deepEqual(report.storageChanges, []);
|
||||||
|
assert.deepEqual(report.packageChecks, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("consumer changes preserve unrelated resource owners", () => {
|
||||||
|
const before = makeValidCapabilityWorkspace();
|
||||||
|
before.packageImports.push({ packageId: id.package("package:resource-owner"), revisionId: id.packageRevision("package:resource-owner@1"),
|
||||||
|
displayName: "ResourceOwner", source: { repository: "https://example.org/owner.git", commit: "a".repeat(40) },
|
||||||
|
exports: [{ kind: "function", id: id.packageExport("export:owner:ping"), displayName: "ping", inputType: valueType.unit, outputType: valueType.unit, dependencyPorts: [] }] });
|
||||||
|
const after = structuredClone(before);
|
||||||
|
after.packageImports[0]!.source.commit = "e".repeat(40);
|
||||||
|
const actions = planEvolution(before, after, { allowLegacy: true }).runtimeActions;
|
||||||
|
assert.equal(actions.find((entry) => entry.groupId === "package:resource-owner")!.action, "keep");
|
||||||
|
assert.equal(actions.find((entry) => entry.groupId === "package:todo-runtime")!.action, "replace");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("storage defaults and ownership changes invalidate consumers without requiring a major", () => {
|
||||||
|
const before = makeValidCapabilityWorkspace();
|
||||||
|
const after = structuredClone(before);
|
||||||
|
const slot = after.sharedAttachments[0]!;
|
||||||
|
assert.equal(slot.kind, "state");
|
||||||
|
if (slot.kind === "state") slot.defaultValue = "Different default";
|
||||||
|
const report = planEvolution(before, after, { allowLegacy: true });
|
||||||
|
assert.equal(report.storageChanges.length, 1);
|
||||||
|
assert.deepEqual(report.migrationRequired, []);
|
||||||
|
assert.equal(report.runtimeActions[0]!.action, "replace");
|
||||||
|
assert.deepEqual(report.reviews, []);
|
||||||
|
assert.notDeepEqual(storageContracts(before), storageContracts(after));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("semantic review receipts cover exact consumer/provider contracts", () => {
|
||||||
|
const before = makeValidCapabilityWorkspace();
|
||||||
|
before.conformances[0]!.id = id.conformance("conformance:project:named");
|
||||||
|
const after = structuredClone(before);
|
||||||
|
after.conformances[0]!.semanticMajor = 2;
|
||||||
|
const report = planEvolution(before, after, { allowLegacy: true });
|
||||||
|
assert.equal(report.reviews.length, 1);
|
||||||
|
assert.equal(report.reviews[0]!.accepted, false);
|
||||||
|
const receipt = { requirementDigest: report.reviews[0]!.requirementDigest, decision: "accepted-unchanged" as const,
|
||||||
|
rationale: "Reviewed the semantic change against summary behavior", agentId: "workspace-agent" };
|
||||||
|
assert.equal(planEvolution(before, after, { allowLegacy: true, reviews: [receipt] }).reviews[0]!.accepted, true);
|
||||||
|
after.packageImports[0]!.source.commit = "f".repeat(40);
|
||||||
|
assert.equal(planEvolution(before, after, { allowLegacy: true, reviews: [receipt] }).reviews[0]!.accepted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evolution enrollment is explicit and never erases legacy ownership", () => {
|
||||||
|
const workspace = makeValidCapabilityWorkspace();
|
||||||
|
const report = planEvolution(workspace, workspace);
|
||||||
|
assert.ok(report.blockers.some((entry) => entry.includes("workspace-shared")));
|
||||||
|
assert.ok(report.blockers.some((entry) => entry.includes("authored ID")));
|
||||||
|
assert.equal(runtimeContracts(workspace).length, 1);
|
||||||
|
assert.throws(() => planEvolution(workspace, { ...workspace, workspaceId: id.workspace("other") }), /different workspace/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("automatic preservation distinguishes additions and defaults from incompatible storage", () => {
|
||||||
|
const workspace = makeValidCapabilityWorkspace();
|
||||||
|
const before = storageContracts(workspace).find(entry => entry.kind === "state")!;
|
||||||
|
const next = structuredClone(before);
|
||||||
|
const value = next.definition as Record<string, unknown>;
|
||||||
|
value.defaultValue = {displayName: "important user data"};
|
||||||
|
assert.equal(storageChangeRequiresMigration(before, next, new Set()), false);
|
||||||
|
next.ownerId = "another-owner";
|
||||||
|
assert.equal(storageChangeRequiresMigration(before, next, new Set()), true);
|
||||||
|
assert.equal(storageChangeRequiresMigration(before, undefined, new Set()), true);
|
||||||
|
delete value.defaultValue;
|
||||||
|
assert.equal(storageChangeRequiresMigration(undefined, next, new Set([value.attachedTo as string])), true);
|
||||||
|
assert.equal(storageChangeRequiresMigration(undefined, next, new Set()), false);
|
||||||
|
const slot = workspace.sharedAttachments.find(entry => entry.kind === "state")!;
|
||||||
|
if (slot.kind !== "state") throw new Error("fixture slot");
|
||||||
|
slot.defaultValue = {displayName: "one"};
|
||||||
|
const first = storageContracts(workspace);
|
||||||
|
slot.defaultValue = {displayName: "two"};
|
||||||
|
assert.notDeepEqual(storageContracts(workspace), first, "user data must not be stripped as schema metadata");
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {mkdtemp, rm} from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import {spawn} from "node:child_process";
|
||||||
|
import {once} from "node:events";
|
||||||
|
import {withFileLock} from "../src/capability-language/file-lock.js";
|
||||||
|
|
||||||
|
test("authoring lock excludes concurrent mutations and survives owner death", async context => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "qx-lock-test-"));
|
||||||
|
context.after(() => rm(root, {recursive: true, force: true}));
|
||||||
|
const filename = path.join(root, "lock");
|
||||||
|
const events: string[] = [];
|
||||||
|
let queued: Promise<void>;
|
||||||
|
await withFileLock(filename, async () => {
|
||||||
|
queued = withFileLock(filename, async () => {events.push("second");});
|
||||||
|
events.push("first");
|
||||||
|
});
|
||||||
|
await queued!;
|
||||||
|
assert.deepEqual(events, ["first", "second"]);
|
||||||
|
const module = new URL("../src/capability-language/file-lock.js", import.meta.url).href;
|
||||||
|
const owner = spawn(process.execPath, ["--input-type=module", "-e",
|
||||||
|
`import {withFileLock} from ${JSON.stringify(module)}; await withFileLock(${JSON.stringify(filename)}, async () => {process.stdout.write('ready'); await new Promise(() => {});});`],
|
||||||
|
{stdio: ["ignore", "pipe", "pipe"]});
|
||||||
|
context.after(() => owner.kill("SIGKILL"));
|
||||||
|
await once(owner.stdout, "data");
|
||||||
|
const exited = once(owner, "exit");
|
||||||
|
owner.kill("SIGKILL");
|
||||||
|
await exited;
|
||||||
|
let acquired = false;
|
||||||
|
await withFileLock(filename, async () => {acquired = true;});
|
||||||
|
assert.equal(acquired, true);
|
||||||
|
});
|
||||||
Vendored
+121
-5
@@ -4,7 +4,15 @@ import {
|
|||||||
capabilityId,
|
capabilityId,
|
||||||
type WorkspaceRevision,
|
type WorkspaceRevision,
|
||||||
} from "../../src/capability-model/index.js";
|
} from "../../src/capability-model/index.js";
|
||||||
import { compileCapabilitySource } from "../../src/capability-language/index.js";
|
import {
|
||||||
|
compileCapabilityResourceSource,
|
||||||
|
compileCapabilitySource,
|
||||||
|
type CapabilityImportEnvironment,
|
||||||
|
} from "../../src/capability-language/index.js";
|
||||||
|
import type {
|
||||||
|
InterfaceRevision,
|
||||||
|
PackageRevision,
|
||||||
|
} from "../../src/capability-model/index.js";
|
||||||
|
|
||||||
export const fixtureId = {
|
export const fixtureId = {
|
||||||
workspace: capabilityId.workspace("workspace:todo"),
|
workspace: capabilityId.workspace("workspace:todo"),
|
||||||
@@ -62,11 +70,119 @@ export const capabilityFixtureSource = readFileSync(
|
|||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
export const makeValidCapabilityWorkspace = (): WorkspaceRevision => {
|
const resourceSource = (name: string) => readFileSync(
|
||||||
const result = compileCapabilitySource(
|
resolve(process.cwd(), "test/fixtures", name),
|
||||||
capabilityFixtureSource,
|
"utf8",
|
||||||
capabilityFixturePath,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const capabilityResourceSources = {
|
||||||
|
named: resourceSource("named.interface.qx"),
|
||||||
|
owned: resourceSource("owned.interface.qx"),
|
||||||
|
summary: resourceSource("summary.interface.qx"),
|
||||||
|
todo: resourceSource("todo.package.qx"),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const source = (repository: string, commit: string) => ({ repository, commit });
|
||||||
|
|
||||||
|
const interfaceRevision = (
|
||||||
|
resource: { kind: "interface"; revision: InterfaceRevision } |
|
||||||
|
{ kind: "package"; revision: PackageRevision },
|
||||||
|
) => {
|
||||||
|
if (resource.kind !== "interface") throw new Error("Expected interface fixture");
|
||||||
|
return resource.revision;
|
||||||
|
};
|
||||||
|
|
||||||
|
const packageRevision = (
|
||||||
|
resource: { kind: "interface"; revision: InterfaceRevision } |
|
||||||
|
{ kind: "package"; revision: PackageRevision },
|
||||||
|
) => {
|
||||||
|
if (resource.kind !== "package") throw new Error("Expected package fixture");
|
||||||
|
return resource.revision;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const compileCapabilityFixture = (overrides: Partial<{
|
||||||
|
workspace: string;
|
||||||
|
named: string;
|
||||||
|
owned: string;
|
||||||
|
summary: string;
|
||||||
|
todo: string;
|
||||||
|
}> = {}) => {
|
||||||
|
const named = compileCapabilityResourceSource(
|
||||||
|
overrides.named ?? capabilityResourceSources.named,
|
||||||
|
{
|
||||||
|
source: source(
|
||||||
|
"https://repos.quixos.org/quixos-todo/interface-named.git",
|
||||||
|
"2".repeat(40),
|
||||||
|
),
|
||||||
|
fileName: "named.interface.qx",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!named.ok) return named;
|
||||||
|
const namedRevision = interfaceRevision(named.resource);
|
||||||
|
const namedEnvironment: CapabilityImportEnvironment = {
|
||||||
|
interfaces: new Map([["Named", namedRevision]]),
|
||||||
|
interfaceClosure: [namedRevision],
|
||||||
|
};
|
||||||
|
const owned = compileCapabilityResourceSource(
|
||||||
|
overrides.owned ?? capabilityResourceSources.owned,
|
||||||
|
{
|
||||||
|
source: source(
|
||||||
|
"https://repos.quixos.org/quixos-todo/interface-owned.git",
|
||||||
|
"3".repeat(40),
|
||||||
|
),
|
||||||
|
fileName: "owned.interface.qx",
|
||||||
|
environment: namedEnvironment,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!owned.ok) return owned;
|
||||||
|
const ownedRevision = interfaceRevision(owned.resource);
|
||||||
|
const summary = compileCapabilityResourceSource(
|
||||||
|
overrides.summary ?? capabilityResourceSources.summary,
|
||||||
|
{
|
||||||
|
source: source(
|
||||||
|
"https://repos.quixos.org/quixos-todo/interface-summary.git",
|
||||||
|
"4".repeat(40),
|
||||||
|
),
|
||||||
|
fileName: "summary.interface.qx",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!summary.ok) return summary;
|
||||||
|
const summaryRevision = interfaceRevision(summary.resource);
|
||||||
|
const todo = compileCapabilityResourceSource(
|
||||||
|
overrides.todo ?? capabilityResourceSources.todo,
|
||||||
|
{
|
||||||
|
source: source(
|
||||||
|
"https://repos.quixos.org/quixos-todo/package-todo-runtime.git",
|
||||||
|
"5".repeat(40),
|
||||||
|
),
|
||||||
|
fileName: "todo.package.qx",
|
||||||
|
environment: namedEnvironment,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!todo.ok) return todo;
|
||||||
|
const todoRevision = packageRevision(todo.resource);
|
||||||
|
return compileCapabilitySource(
|
||||||
|
overrides.workspace ?? capabilityFixtureSource,
|
||||||
|
capabilityFixturePath,
|
||||||
|
{
|
||||||
|
interfaces: new Map([
|
||||||
|
["Named", namedRevision],
|
||||||
|
["Owned", ownedRevision],
|
||||||
|
["Summary", summaryRevision],
|
||||||
|
]),
|
||||||
|
packages: new Map([["TodoRuntime", todoRevision]]),
|
||||||
|
interfaceClosure: [
|
||||||
|
namedRevision,
|
||||||
|
ownedRevision,
|
||||||
|
summaryRevision,
|
||||||
|
],
|
||||||
|
packageClosure: [todoRevision],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeValidCapabilityWorkspace = (): WorkspaceRevision => {
|
||||||
|
const result = compileCapabilityFixture();
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
result.diagnostics
|
result.diagnostics
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import interface Named;
|
||||||
|
external atom Project id "atom:project";
|
||||||
|
external atom ProjectComponent id "atom:project-component";
|
||||||
|
|
||||||
|
package ComponentRuntime id "package:component-runtime" revision "package:component-runtime@1" {
|
||||||
|
constructor createComponent id "export:component-runtime:create-component" constructs ProjectComponent : atom-ref<Project>;
|
||||||
|
operation propsGet id "export:component-runtime:props-get" : unit -> string mode call receiver atom ProjectComponent requires {
|
||||||
|
interface project id "port:component:project" : Named;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import interface ReactComponent;
|
||||||
|
|
||||||
|
interface HasReactComponent id "interface:org.quixos.web-studio.has-react-component" revision "interface:org.quixos.web-studio.has-react-component@1" {
|
||||||
|
relation component id "member:org.quixos.web-studio.has-react-component:component" : optional-one interface ReactComponent {
|
||||||
|
resolve id "operation:org.quixos.web-studio.has-react-component:component:resolve";
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
interface Named id "interface:named" revision "interface:named@1" {
|
||||||
|
value name id "member:named:name" : string {
|
||||||
|
get id "operation:named:name:get";
|
||||||
|
set id "operation:named:name:set";
|
||||||
|
watch start id "operation:named:name:watch-start" stop id "operation:named:name:watch-stop";
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import interface Named;
|
||||||
|
|
||||||
|
interface Owned id "interface:owned" revision "interface:owned@1" {
|
||||||
|
relation owner id "member:owned:owner" : exactly-one interface Named {
|
||||||
|
resolve id "operation:owned:owner:resolve";
|
||||||
|
connect id "operation:owned:owner:connect";
|
||||||
|
disconnect id "operation:owned:owner:disconnect";
|
||||||
|
watch start id "operation:owned:owner:watch-start" stop id "operation:owned:owner:watch-stop";
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
interface ReactComponent id "interface:org.quixos.web-studio.react-component" revision "interface:org.quixos.web-studio.react-component@1" {
|
||||||
|
value props id "member:org.quixos.web-studio.react-component:props" : string {
|
||||||
|
get id "operation:org.quixos.web-studio.react-component:props:get";
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
interface Summary id "interface:summary" revision "interface:summary@1" {
|
||||||
|
value summary id "member:summary:summary" : string {
|
||||||
|
get id "operation:summary:summary:get";
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+4
-44
@@ -2,50 +2,10 @@ workspace Todo id "workspace:todo" revision "workspace:todo@1" commit "111111111
|
|||||||
atom Project id "atom:project" doc "A project containing work.";
|
atom Project id "atom:project" doc "A project containing work.";
|
||||||
atom Person id "atom:person";
|
atom Person id "atom:person";
|
||||||
|
|
||||||
interface Named id "interface:named" revision "interface:named@1" source {
|
import interface Named;
|
||||||
repository "https://repos.quixos.org/interfaces/named.git";
|
import interface Owned;
|
||||||
commit "2222222222222222222222222222222222222222";
|
import interface Summary;
|
||||||
} {
|
import package TodoRuntime;
|
||||||
value name id "member:named:name" : string {
|
|
||||||
get id "operation:named:name:get";
|
|
||||||
set id "operation:named:name:set";
|
|
||||||
watch start id "operation:named:name:watch-start" stop id "operation:named:name:watch-stop";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Owned id "interface:owned" revision "interface:owned@1" source {
|
|
||||||
repository "https://repos.quixos.org/interfaces/owned.git";
|
|
||||||
commit "3333333333333333333333333333333333333333";
|
|
||||||
} {
|
|
||||||
relation owner id "member:owned:owner" : exactly-one interface Named {
|
|
||||||
resolve id "operation:owned:owner:resolve";
|
|
||||||
connect id "operation:owned:owner:connect";
|
|
||||||
disconnect id "operation:owned:owner:disconnect";
|
|
||||||
watch start id "operation:owned:owner:watch-start" stop id "operation:owned:owner:watch-stop";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Summary id "interface:summary" revision "interface:summary@1" source {
|
|
||||||
repository "https://repos.quixos.org/interfaces/summary.git";
|
|
||||||
commit "4444444444444444444444444444444444444444";
|
|
||||||
} {
|
|
||||||
value summary id "member:summary:summary" : string {
|
|
||||||
get id "operation:summary:summary:get";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
package TodoRuntime id "package:todo-runtime" revision "package:todo-runtime@1" source {
|
|
||||||
repository "https://repos.quixos.org/packages/todo-runtime.git";
|
|
||||||
commit "5555555555555555555555555555555555555555";
|
|
||||||
} {
|
|
||||||
operation summaryGet id "export:todo-runtime:summary-get" : unit -> string
|
|
||||||
mode call receiver interfaces [Named] requires {
|
|
||||||
state title id "port:summary:title" : string [read];
|
|
||||||
interface named id "port:summary:named" : Named;
|
|
||||||
constructor person id "port:summary:person" : Person;
|
|
||||||
};
|
|
||||||
constructor createPerson id "export:todo-runtime:create-person" constructs Person : unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
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";
|
||||||
|
|||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
import interface Named;
|
||||||
|
external atom Person id "atom:person";
|
||||||
|
|
||||||
|
package TodoRuntime id "package:todo-runtime" revision "package:todo-runtime@1" {
|
||||||
|
operation summaryGet id "export:todo-runtime:summary-get" : unit -> string
|
||||||
|
mode call receiver interfaces [Named] requires {
|
||||||
|
state title id "port:summary:title" : string [read];
|
||||||
|
interface named id "port:summary:named" : Named;
|
||||||
|
constructor person id "port:summary:person" : Person;
|
||||||
|
};
|
||||||
|
constructor createPerson id "export:todo-runtime:create-person" constructs Person : unit;
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
external atom Thing id "atom:example:thing";
|
||||||
|
|
||||||
|
package TypedExample id "package:typed-example" revision "package:typed-example@1" {
|
||||||
|
constructor createThing id "export:typed-example:create" constructs Thing : unit;
|
||||||
|
operation payloadGet id "export:typed-example:payload" : unit -> bytes mode call receiver atom Thing requires {
|
||||||
|
state payload id "port:typed-example:payload" : bytes [read];
|
||||||
|
};
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
quixos-lock version 1 {
|
||||||
|
quixos source {
|
||||||
|
repository "https://example.test/quixos.git";
|
||||||
|
commit "1111111111111111111111111111111111111111";
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
workspace WebStudioFixture id "workspace:web-studio-fixture" revision "workspace:web-studio-fixture@1" commit "1111111111111111111111111111111111111111" {
|
||||||
|
atom Project id "atom:project";
|
||||||
|
atom ProjectComponent id "atom:project-component";
|
||||||
|
|
||||||
|
import interface Named;
|
||||||
|
import interface ReactComponent;
|
||||||
|
import interface HasReactComponent;
|
||||||
|
import package ComponentRuntime;
|
||||||
|
|
||||||
|
shared state ProjectName id "slot:project:name" on Project : string
|
||||||
|
policy optimistic-register default "Untitled project";
|
||||||
|
shared edge ProjectComponentEdge id "edge:project:component" {
|
||||||
|
atom ProjectComponent projection subject id "projection:component:subject" exactly-one;
|
||||||
|
atom Project projection component id "projection:project:component" optional-one;
|
||||||
|
}
|
||||||
|
|
||||||
|
conform Project as Named {
|
||||||
|
bind name.get to state ProjectName.read;
|
||||||
|
bind name.set to state ProjectName.write;
|
||||||
|
bind name.watch-start to state ProjectName.watch-start;
|
||||||
|
bind name.watch-stop to state ProjectName.watch-stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
conform Project as HasReactComponent {
|
||||||
|
bind component.resolve to edge ProjectComponentEdge.component.resolve;
|
||||||
|
materialize component if absent using constructor ProjectComponent via edge ProjectComponentEdge.subject;
|
||||||
|
}
|
||||||
|
conform ProjectComponent as ReactComponent {
|
||||||
|
bind props.get to package ComponentRuntime.propsGet with {
|
||||||
|
project to interface Named via edge ProjectComponentEdge.subject;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor ProjectComponent to ComponentRuntime.createComponent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { createGitCapabilityResolver } from "../src/capability-language/git-resolver.js";
|
||||||
|
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
test("dependency resolution is reusable across processes, concurrent and rejects modified checkouts", async context => {
|
||||||
|
const temporary = await mkdtemp(path.join(os.tmpdir(), "qx-resolver-test-"));
|
||||||
|
context.after(() => rm(temporary, { recursive: true, force: true }));
|
||||||
|
const origin = path.join(temporary, "origin");
|
||||||
|
await mkdir(origin);
|
||||||
|
const git = (...args: string[]) => execFile("git", ["-C", origin, ...args]);
|
||||||
|
await git("init");
|
||||||
|
await writeFile(path.join(origin, "interface.qx"), "contract");
|
||||||
|
await git("add", ".");
|
||||||
|
await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract");
|
||||||
|
const commit = (await git("rev-parse", "HEAD")).stdout.trim();
|
||||||
|
await git("tag", `quixos-reachability/${commit}`);
|
||||||
|
const source = { resolver: "git" as const, repository: origin, commit };
|
||||||
|
const options = { checkoutRoot: path.join(temporary, "cache") };
|
||||||
|
const a = await createGitCapabilityResolver(options);
|
||||||
|
const b = await createGitCapabilityResolver(options);
|
||||||
|
const [first, second] = await Promise.all([a(source, "interface"), b(source, "interface")]);
|
||||||
|
assert.equal(first.directory, second.directory);
|
||||||
|
const c = await createGitCapabilityResolver(options);
|
||||||
|
assert.equal((await c(source, "interface")).directory, first.directory);
|
||||||
|
await writeFile(path.join(first.directory, "interface.qx"), "changed");
|
||||||
|
const d = await createGitCapabilityResolver(options);
|
||||||
|
await assert.rejects(d(source, "interface"), /modified/);
|
||||||
|
// A failed request is not memoized forever; repair can be observed on retry.
|
||||||
|
await writeFile(path.join(first.directory, "interface.qx"), "contract");
|
||||||
|
assert.equal((await d(source, "interface")).directory, first.directory);
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { contentDigest, validateMigrationCatalog, selectMigrationPath, type MigrationCatalog } from "../src/capability-model/index.js";
|
||||||
|
const before = { fields: ["name"] }, after = { fields: ["first", "last"] };
|
||||||
|
const from = contentDigest(before), to = contentDigest(after);
|
||||||
|
const catalog = (): MigrationCatalog => ({ schemaVersion: 1, contracts: { [from]: before, [to]: after }, migrations: [{
|
||||||
|
id: "split-name", scopeId: "person-name", from, to,
|
||||||
|
implementation: { exportId: "migrate-name", file: "src/migrate-name.ts", digest: contentDigest("implementation") }, predecessors: [],
|
||||||
|
ports: [{ name: "source", view: "old", access: ["read"], contractDigest: from }, { name: "target", view: "new", access: ["write"], contractDigest: to }],
|
||||||
|
}] });
|
||||||
|
test("published migrations retain exact old contracts and explicit local bindings", () => {
|
||||||
|
const value = validateMigrationCatalog(catalog(), new Set(["migrate-name"]));
|
||||||
|
const selection = { scopeId: "person-name", from, to, path: ["split-name"], bindings: { source: "old-slot", target: "new-slot" } };
|
||||||
|
const result = selectMigrationPath(value, selection);
|
||||||
|
assert.equal(result[0].alreadyApplied, false);
|
||||||
|
assert.equal(selectMigrationPath(value, selection, new Map([["split-name", result[0].digest]]))[0].alreadyApplied, true);
|
||||||
|
const changed = catalog(); changed.migrations[0].implementation.digest = contentDigest("new code");
|
||||||
|
assert.throws(() => selectMigrationPath(changed, selection, new Map([["split-name", result[0].digest]])), /different code/);
|
||||||
|
assert.throws(() => selectMigrationPath(value, { ...selection, path: [] }), /does not cover/);
|
||||||
|
assert.throws(() => selectMigrationPath(value, { ...selection, bindings: {} }), /Missing local/);
|
||||||
|
});
|
||||||
|
test("old migration views cannot be writable and contract hashes are verified", () => {
|
||||||
|
const value = catalog(); value.migrations[0].ports[0].access = ["write"];
|
||||||
|
assert.throws(() => validateMigrationCatalog(value), /read-only/);
|
||||||
|
const corrupt = catalog(); corrupt.contracts[from] = {};
|
||||||
|
assert.throws(() => validateMigrationCatalog(corrupt), /digest mismatch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("migration history rejects cycles and non-boolean compatibility promises", () => {
|
||||||
|
const cyclic = catalog();
|
||||||
|
cyclic.migrations[0].predecessors = ["split-name"];
|
||||||
|
assert.throws(() => validateMigrationCatalog(cyclic), /Cyclic/);
|
||||||
|
const misleading = catalog();
|
||||||
|
(misleading.migrations[0] as unknown as {preservesOldReaders: string}).preservesOldReaders = "false";
|
||||||
|
assert.throws(() => validateMigrationCatalog(misleading), /must be booleans/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
|
||||||
|
test("Nix checks a retained immutable source without a local overlay and reuses the result", {skip: !process.env.QX_CHECK_GENERATOR}, async context => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-nix-candidate-test-"));
|
||||||
|
context.after(() => fs.rm(root, {recursive: true, force: true}));
|
||||||
|
const git = (...args: string[]) => execFile("git", ["-C", root, ...args]);
|
||||||
|
await git("init");
|
||||||
|
await fs.writeFile(path.join(root, "interface.qx"), 'interface Example id "interface:example" revision "interface:example@1" {}');
|
||||||
|
await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } }`);
|
||||||
|
await git("add", ".");
|
||||||
|
await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract");
|
||||||
|
const commit = (await git("rev-parse", "HEAD")).stdout.trim();
|
||||||
|
await git("tag", `quixos-reachability/${commit}`);
|
||||||
|
const generator = process.env.QX_CHECK_GENERATOR!;
|
||||||
|
const repository = "https://immutable-candidate.example.test/contract.git";
|
||||||
|
const env = {...process.env, GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `url.file://${root}.insteadOf`, GIT_CONFIG_VALUE_0: repository};
|
||||||
|
const build = async () => (await execFile("nix", ["build", "--impure", "--file", path.join(generator, "share/checked-candidate.nix"),
|
||||||
|
"--argstr", "repository", repository, "--argstr", "commit", commit,
|
||||||
|
"--argstr", "kind", "interface", "--argstr", "generator", generator,
|
||||||
|
"--option", "substitute", "false", "--no-link", "--print-out-paths"], {env, maxBuffer: 4 * 1024 * 1024})).stdout.trim();
|
||||||
|
const output = await build();
|
||||||
|
const candidate = JSON.parse(await fs.readFile(path.join(output, "candidate.json"), "utf8"));
|
||||||
|
assert.equal(candidate.revision.source.commit, commit);
|
||||||
|
await fs.writeFile(path.join(root, "interface.qx"), "broken draft");
|
||||||
|
assert.equal(await build(), output);
|
||||||
|
assert.deepEqual(JSON.parse(await fs.readFile(path.join(output, "checks.json"), "utf8")), []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {promisify} from "node:util";
|
||||||
|
import {execFile as callback} from "node:child_process";
|
||||||
|
import {planPinUpgrades, applyPinUpgrades, type UpgradeEffects} from "../src/capability-language/pin-upgrades.js";
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
|
||||||
|
test("real jj snapshots and immutable Git publication propagate a changed interface into the root", {skip: !process.env.QX_CHECK_GENERATOR}, async (context) => {
|
||||||
|
const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-real-upgrade-"));
|
||||||
|
context.after(() => fs.rm(workbench, {recursive: true, force: true}));
|
||||||
|
// Exercise the actual effects with local bare remotes, without external writes.
|
||||||
|
const environment = {
|
||||||
|
GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `url.file://${workbench}/remotes/.insteadOf`,
|
||||||
|
GIT_CONFIG_VALUE_0: "https://upgrade.test/", QUIXOS_JJ_NO_CHECKPOINT: "1",
|
||||||
|
QUIXOS_CHECK_GENERATOR: process.env.QX_CHECK_GENERATOR!,
|
||||||
|
};
|
||||||
|
const previous = Object.fromEntries(Object.keys(environment).map(key => [key, process.env[key]]));
|
||||||
|
Object.assign(process.env, environment);
|
||||||
|
context.after(() => {for (const [key, value] of Object.entries(previous)) if (value === undefined) delete process.env[key]; else process.env[key] = value;});
|
||||||
|
const run = async (cwd: string, command: string, args: string[]) => (await execFile(command, args, {cwd})).stdout.trim();
|
||||||
|
await fs.mkdir(path.join(workbench, "remotes"));
|
||||||
|
const nodes = [];
|
||||||
|
for (const [kind, directory, remote] of [["interface", "resources/Named", "named.git"], ["workspace", "root", "workspace.git"]] as const) {
|
||||||
|
const root = path.join(workbench, directory);
|
||||||
|
await fs.mkdir(root, {recursive: true});
|
||||||
|
await run(workbench, "git", ["init", "--bare", path.join(workbench, "remotes", remote)]);
|
||||||
|
await run(root, "jj", ["git", "init", "--colocate"]);
|
||||||
|
await run(root, "git", ["remote", "add", "origin", `https://upgrade.test/${remote}`]);
|
||||||
|
await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n");
|
||||||
|
const child = nodes[0];
|
||||||
|
await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://upgrade.test/quixos.git"; commit "${"a".repeat(40)}"; } ${child ? `interface Named source { repository "${child.source.repository}"; commit "${child.source.commit}"; }` : ""} }`);
|
||||||
|
await fs.writeFile(path.join(root, `${kind}.qx`), kind === "interface" ? 'interface Named id "interface:named" revision "interface:named@1" {}' : `workspace W id "workspace:w" revision "workspace:w@1" commit "${"a".repeat(40)}" { import interface Named; atom A id "atom:a"; }`);
|
||||||
|
await run(root, "jj", ["describe", "-m", "Initial source"]);
|
||||||
|
const commit = await run(root, "jj", ["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||||
|
await run(root, "git", ["push", "origin", `${commit}:refs/tags/quixos-reachability/${commit}`]);
|
||||||
|
nodes.push({kind, directory, source: {repository: `https://upgrade.test/${remote}`, commit}});
|
||||||
|
}
|
||||||
|
await fs.mkdir(path.join(workbench, ".quixos"));
|
||||||
|
await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [nodes[0]]}));
|
||||||
|
await fs.appendFile(path.join(workbench, nodes[0].directory, "interface.qx"), "\n// incremental author edit\n");
|
||||||
|
const plan = await planPinUpgrades(workbench, {nodes, bootstrap: true});
|
||||||
|
const result = await applyPinUpgrades(plan);
|
||||||
|
assert.equal(result.activated, false);
|
||||||
|
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
||||||
|
const commit = graph.resources[0].source.commit;
|
||||||
|
assert.notEqual(commit, nodes[0].source.commit);
|
||||||
|
assert.match(await fs.readFile(path.join(workbench, "root/quixos.lock"), "utf8"), new RegExp(commit));
|
||||||
|
assert.match(await run(workbench, "git", ["--git-dir", path.join(workbench, "remotes/named.git"), "show-ref"]), new RegExp(commit));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pin upgrades publish children before parent locks and resume without republishing completed nodes", async (context) => {
|
||||||
|
const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-upgrade-test-"));
|
||||||
|
context.after(() => fs.rm(workbench, {recursive: true, force: true}));
|
||||||
|
const from = "a".repeat(40), to = "b".repeat(40), framework = "c".repeat(40);
|
||||||
|
const sources = [{kind: "workspace" as const, directory: "root", source: {repository: "https://example.test/workspace.git", commit: from}},
|
||||||
|
{kind: "interface" as const, directory: "resources/Named", source: {repository: "https://example.test/named.git", commit: from}}];
|
||||||
|
const lock = `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${framework}"; }`;
|
||||||
|
for (const node of sources) {
|
||||||
|
const directory = path.join(workbench, node.directory);
|
||||||
|
await fs.mkdir(directory, {recursive: true});
|
||||||
|
await execFile("git", ["-C", directory, "init"]);
|
||||||
|
await execFile("git", ["-C", directory, "remote", "add", "origin", node.source.repository]);
|
||||||
|
await fs.writeFile(path.join(directory, ".gitignore"), ".quixos/\n");
|
||||||
|
await fs.writeFile(path.join(directory, "quixos.lock"), lock + (node.kind === "workspace" ? ` interface Named source { repository "${sources[1].source.repository}"; commit "${from}"; }` : "") + " }");
|
||||||
|
}
|
||||||
|
await fs.writeFile(path.join(workbench, "resources/Named/interface.qx"), 'interface Named id "interface:named" revision "interface:named@1" { value name id "member:name" : string { get id "op:get"; } }');
|
||||||
|
await fs.writeFile(path.join(workbench, "root/workspace.qx"), `workspace W id "workspace:w" revision "workspace:w@1" commit "${from}" { import interface Named; atom A id "atom:a"; }`);
|
||||||
|
const snapshotMap = path.join(workbench, "snapshots.json");
|
||||||
|
await fs.writeFile(snapshotMap, JSON.stringify({resources: [{kind: "interface", repository: sources[1].source.repository, commit: to, directory: path.join(workbench, "resources/Named")}]}));
|
||||||
|
const oldMap = process.env.QUIXOS_SNAPSHOT_MAP;
|
||||||
|
process.env.QUIXOS_SNAPSHOT_MAP = snapshotMap;
|
||||||
|
context.after(() => {if (oldMap === undefined) delete process.env.QUIXOS_SNAPSHOT_MAP; else process.env.QUIXOS_SNAPSHOT_MAP = oldMap;});
|
||||||
|
const plan = await planPinUpgrades(workbench, {nodes: sources});
|
||||||
|
await fs.mkdir(path.join(workbench, ".quixos"), {recursive: true});
|
||||||
|
await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [{...sources[1], source: {resolver: "git", ...sources[1].source}}]}));
|
||||||
|
assert.deepEqual(plan.nodes.map((node) => node.directory), ["resources/Named", "root"]);
|
||||||
|
let fail = true;
|
||||||
|
const published: string[] = [];
|
||||||
|
const effects: UpgradeEffects = {
|
||||||
|
check: async (node) => {if (node.kind === "workspace" && fail) throw new Error("refactor required");},
|
||||||
|
snapshot: async () => to,
|
||||||
|
publish: async (root) => {published.push(path.relative(workbench, root));},
|
||||||
|
};
|
||||||
|
await assert.rejects(() => applyPinUpgrades(plan, undefined, effects), /refactor required/);
|
||||||
|
assert.deepEqual(published, ["resources/Named"]);
|
||||||
|
assert.match(await fs.readFile(path.join(workbench, "root/quixos.lock"), "utf8"), new RegExp(to));
|
||||||
|
const id = (await fs.readdir(path.join(workbench, ".quixos/upgrades"))).find((name) => name.endsWith(".json"))!.slice(0, -5);
|
||||||
|
fail = false;
|
||||||
|
await fs.appendFile(path.join(workbench, "root/workspace.qx"), "\n// explicit refactor\n");
|
||||||
|
await assert.rejects(() => applyPinUpgrades(plan, id, effects), /accept-edits/);
|
||||||
|
const result = await applyPinUpgrades(plan, id, effects, {acceptEdits: true});
|
||||||
|
assert.equal(result.activated, false);
|
||||||
|
assert.deepEqual(published, ["resources/Named", "root"]);
|
||||||
|
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
||||||
|
assert.equal(graph.resources.length, 1);
|
||||||
|
assert.equal(graph.resources[0].source.commit, to);
|
||||||
|
const next = await planPinUpgrades(workbench, {nodes: [sources[0], {...sources[1], source: graph.resources[0].source}]});
|
||||||
|
assert.deepEqual(next.nodes.find((node) => node.kind === "workspace")!.dependencies, ["resources/Named"]);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user