Compare commits

..

56 Commits

Author SHA1 Message Date
Quixos Subtree Publisher aadc35581d Publish quixos-protocol from 1c2dbc6ca2c3917292f158cbbaef531836eaacca 2026-09-18 02:43:46 +00:00
Timothy J. Aveni e61b0a36ac Implement checked aggregation plans, native SQL, and bounded RPC capture 2026-09-17 19:43:46 -07:00
Quixos Subtree Publisher ae0a5064a4 Publish quixos-protocol from 53b9a56e8cb120c34d6b5c85b52b18080dd1d9a3 2026-09-17 23:49:44 +00:00
Timothy J. Aveni cd13120937 Implement query execution, scoped RPC enrichment, live collections, and scaffold integration 2026-09-17 16:49:44 -07:00
Timothy J. Aveni 61a410f98f Add queryable capability contracts and checked GraphQL artifacts 2026-09-17 14:55:10 -07:00
Timothy J. Aveni e3501772c5 Add exact runtime conformance lookup and agent-visible language limits
Add authorized exact-contract lookup, revision-fenced views, generated typed package/browser descriptors, and lifecycle-safe React discovery. Preserve closed generic contracts without competing-conformance policies or new authority grants. Document supported workarounds and privacy boundaries for agent feedback. Publish matching protocol/SDK exports and update scaffold/starter pins; leave the deployed template unchanged.
2026-09-17 12:04:06 -07:00
Quixos Subtree Publisher 7863ce3e12 Publish quixos-protocol from db6d7990a4ffec273258ac313b7d2aeac57b261a 2026-09-17 18:54:46 +00:00
Timothy J. Aveni ea51ca89e7 Add exact runtime conformance lookup and agent-visible language limits 2026-09-17 11:54:46 -07:00
Quixos Subtree Publisher c3fe7c86b8 Publish quixos-protocol from a45fd9a85b541ce6b449b28e8b47954bf04de96b 2026-09-17 18:49:38 +00:00
Timothy J. Aveni 74f00fe28f Add exact runtime conformance lookup and agent-visible language limits 2026-09-17 11:49:38 -07:00
Quixos Subtree Publisher eaf6203b73 Publish quixos-protocol from 0355d845d7ad4b797343b681e9b5e1a77c63c720 2026-09-17 18:47:28 +00:00
Timothy J. Aveni 17f6941f64 Add exact runtime conformance lookup and agent-visible language limits 2026-09-17 11:47:28 -07:00
Timothy J. Aveni 59735c5e38 Add checked React field bindings and Web Studio factories
Replace opaque props with checked generic presentation contracts and lazy typed
interface references. Generate readonly/writable field APIs and component checks.

Preserve CRDT editing through explicit resolved getter/setter contracts, binding-
fenced delta RPCs, native watches and replica-aware field adapters. Custom setters
retain semantic writes; storage snapshots never grant write authority. Cover
concurrent edits, lost acknowledgements, readonly contracts and authorization.

Add receiver-free static factory dispatch, state-field binding shorthand, and
conformance-based creation. Migrate TODO, editable scaffolds and authoring guides.
Verify language/codegen, SDK, RPC, browser lifecycle, local scaffolds, production
browser bundling and CRDT persistence with temporary PostgreSQL.
2026-09-16 16:49:09 -07:00
Quixos Subtree Publisher 499b65b274 Publish quixos-protocol from 1de28b236de076d239752b77553f833dfbdb5b43 2026-09-16 23:49:09 +00:00
Timothy J. Aveni 52803dda05 Implement capability generics, checked package specializations and CRUD scaffolding
Add kinded parameters, capability bounds, Self, aliases and closed application identities. Check generic implementations universally and build candidate-specific codecs and descriptors from immutable schemas. Preserve lexical aliases and exact dispatch identities in package and host bindings.

Add an imperative CRUD+index domain scaffold with explicit soft-deletion semantics, source/codegen regression coverage, installed CLI tests and an authoring guide. Existing Web Studio opaque props and class-level create-menu migration are separate from the implemented language core.
2026-09-16 10:42:12 -07:00
Timothy J. Aveni 9358b5ed0e Revert generated-file renaming; retain formatter exclusions 2026-09-15 20:13:10 -07:00
Timothy J. Aveni e557a057d8 Mark first-party generated files with .gen; preserve third-party conventions 2026-09-15 17:57:46 -07:00
Timothy J. Aveni 1afc9feb94 Name optional component error notifications extraErrorAction 2026-09-15 17:31:17 -07:00
Timothy J. Aveni 8f28ab1b93 Add lightweight correctness linting and resolve formatting conflicts 2026-09-15 15:41:24 -07:00
Timothy J. Aveni 00fc2b9ff1 Format authored monorepo code with pinned language formatters 2026-09-15 15:41:24 -07:00
Timothy J. Aveni a174faea5c Add imperative feature bundles, intrinsic component sizing, and authoring timing logs
Generate state-backed atoms, interfaces, relationships and React/CSS packages
in a resumable command that prints the full source diff. Keep scaffold output
ordinary editable code. Shorten installed authoring guides while preserving
contracts and clarify historical design context.

Test generated bundles through immutable Nix verification and cover browser
isolation, nested sizing, interruption recovery, and command help.
2026-09-15 14:20:02 -07:00
Timothy J. Aveni 53708ac06f Provide typed React live-field authoring, strict CLI arguments, and timed build stages 2026-09-14 23:47:05 -07:00
Timothy J. Aveni a84c985256 Fix standalone candidate binding options and test real React props contracts 2026-09-14 22:11:36 -07:00
Timothy J. Aveni 6c2f030243 Simplify workspace authoring and isolate managed component styles
Use imperative scaffolds without a registry/refresh lifecycle; preserve authored runtime wiring and generate checked descriptors. Consolidate binding options, embed checked TSX/CSS source, queue mutable convergence while allowing parallel immutable checks, and preload real CLI observations. Add Shadow DOM boundaries with scoped overlays and composed-event handling. Update template/toolchain pins and document VM-free authoring, browser, and stateful acceptance; no deployment or default-template selection.
2026-09-14 20:55:32 -07:00
Quixos Subtree Publisher 0d5220a6ce Publish quixos-protocol from 8484d99fcf78a49341a17597bc62211e5f1807dd 2026-09-15 03:49:38 +00:00
Timothy J. Aveni e753ffe601 Simplify workspace authoring and isolate managed component styles 2026-09-14 20:49:38 -07:00
Quixos Subtree Publisher 332b84f258 Publish quixos-protocol from fc13e9b8411c2210b12db5e558e2b4fa7785505e 2026-09-15 03:42:37 +00:00
Timothy J. Aveni 4982cb7e9f Simplify workspace authoring and isolate managed component styles 2026-09-14 20:42:37 -07:00
Timothy J. Aveni 14b0ef25c3 Repair workspace authoring: typed RPC inputs, automatic preservation, and template releases
Use checked input contracts and protobuf JSON for CLI roundtrips; generate Web Studio platform inputs; reject invalid constructors before allocation and filter Createable eligibility.

Preserve compatible storage without no-op migrations, report activation readiness, and validate migration coverage before maintenance. Follow authored package declarations during scaffold refresh and queue source capture.

Add a real local TODO check/activate/create/place/edit acceptance, publish updated protocol and SDK dependencies, and make verified template default selection explicit.
2026-09-14 16:04:59 -07:00
Quixos Subtree Publisher c16271510a Publish quixos-protocol from 0023a184c839088480702512751ebb05bf170162 2026-09-14 22:54:30 +00:00
Timothy J. Aveni 8aac091f6c Make workspace authoring transitions and typed invocation coherent 2026-09-14 15:54:30 -07:00
Timothy J. Aveni 01ca965c7f Make workspace authoring converge through immutable Nix candidates
Coordinate registered resource edits bottom-up into retained exact remote sources.
Use one Nix-owned source graph for provisional checking, template publication,
explicit baseline upgrades and host activation; retain independent runtime pins.

Add scoped contract inspection, historical recovery, derived worklists, crash-safe
locks, named dependency adoption and plain-QX structural editing. Repair TODO
ownership and template instantiation, and document the supported agent workflow.

Validated with protocol and command suites, real jj/Nix convergence and cache
checks, TS/React installed-command acceptance, and fresh TODO first-edit acceptance.
No live deployment or public publication performed. Props projection generation
and a one-command rich feature generator remain explicitly outside this delivery.
2026-09-14 12:25:47 -07:00
Timothy J. Aveni fae4e48f72 Unify workspace authoring, verification and scaffolding workflows
Use exact jj snapshots and one candidate-bound Nix builder for incremental checks, template validation and activation. Keep provenance internal and separate recovery checkpoint failures from local command success.

Provision workspace-scoped managed package/interface repositories with recoverable Central effects. Add TypeScript/React presets, function and dependency commands, and scaffold enrollment for all TODO packages. Install authoring guides and controlled Codex sandbox rules.

Invalidate module resolutions across cutover, including in-flight races, and content-address host platform entries. Strengthen domain-model and verification instructions.

Validated real jj/Nix authoring, React/Slate dependency installation, bottom-up local Git publication, packaged CLI tests, PostgreSQL recovery/auth tests, Web Studio tests and host configuration. Public protocol/helpers and the validated 19-resource TODO template are published. Retained the approved exact private baseline and updated the installation's default template pin to 68d54f0d52be433ebf60bdc1faf7646c57f90307. Master and live deployments remain unchanged. See docs/WORKSPACE_AUTHORING_PROGRESS.md.
2026-09-13 23:06:38 -07:00
Quixos Subtree Publisher 6ecf565549 Publish quixos-protocol from 7fd5e15105cef21d2b1c3da860c53c5033f66256 2026-09-14 05:46:08 +00:00
Timothy J. Aveni 73cdeadc2c Unify workspace authoring, verification and scaffolding workflows 2026-09-13 22:46:08 -07:00
Timothy J. Aveni 16b28f1bc4 Migrate TODO implementations to generated candidate bindings 2026-09-13 20:28:50 -07:00
Quixos Subtree Publisher dcef58d6c5 Publish quixos-protocol from 22bb3d02264980d74de65c34bbbcb81764dc65c0 2026-09-14 03:28:50 +00:00
Quixos Subtree Publisher f7fcc7d08b Publish quixos-protocol from e25eee6ce4f13702b2454a9354bc79a29eb2e4a1 2026-09-14 03:01:53 +00:00
Timothy J. Aveni 295181244f Migrate TODO implementations to generated candidate bindings 2026-09-13 20:01:53 -07:00
Timothy J. Aveni 42af87f8bd Fix supervised credentials and declare typed RPC records for canvas placement 2026-09-13 16:38:55 -07:00
Quixos Subtree Publisher e9fb42dce4 Publish quixos-protocol from 61cb9aa5a580c2846d426464228c7d1082b7021a 2026-09-13 23:38:55 +00:00
Quixos Subtree Publisher 9867bf4552 Publish quixos-protocol from 0e6bb95d903004916bd93670497ab310a076a28b 2026-09-13 19:34:15 +00:00
Timothy J. Aveni 483bc68a94 Implement workspace evolution, migrations, and runtime continuity
Enable evolution by default for source-backed workspaces. Add stable
conformance ownership, semantic-major review, candidate typechecking,
and durable fenced cutover with explicit migrations and forward recovery.

Independently supervise package runtimes so unchanged resource owners keep
their processes and connections across cutover. Add scoped invocation
authority, resource sessions, and typed callback rebinding.

Wire opaque object references through generated bindings and RPCs. Add
canonical relationship sets, keyed maps, and ordered lists with scoped
transactional mutations, revision checks, and inverse consistency. Support
planned cascade deletion, protection, tombstones, and lifecycle foundations.

Add journaled structural edits, package/function/migration scaffolding,
managed repository creation, and resumable bottom-up dependency pin
publication. Document lifetime boundaries, revision pinning, prototype
compatibility policy, commands, and deferred work.

Validate with 210 tests, user-systemd process/connection continuity,
generated-package TypeScript checks, and Nix host/protocol checks.
TTL handoff, physical reclamation, general multi-step migrations, and
root-systemd migration isolation acceptance remain deferred.
2026-09-10 18:29:25 -07:00
Timothy J. Aveni 1e25f391e7 Browse immutable workspace source and jj history in Central 2026-09-10 00:51:26 -07:00
Timothy J. Aveni 4e17693d82 Generate typed QX bindings and add source editing tools 2026-09-08 15:42:20 -07:00
timothy ce793cc54f Build workspace agent, capability graph, and versioned cutovers 2026-09-06 11:12:04 -07:00
Quixos Subtree Publisher fd6fee133d Publish quixos-protocol from b384206b9c01a9ac50030a583d028b71291bc8c5 2026-09-06 07:07:06 +00:00
timothy f5ced64533 Build workspace agent, capability graph, and versioned cutovers 2026-09-06 00:07:06 -07:00
Quixos Subtree Publisher eabf721e94 Publish quixos-protocol from 33266c4ba9e0dd7b6370715522b281807e8b038b 2026-09-06 00:49:07 +00:00
timothy 0c46850973 Add the workspace coding agent, launch gate, and composable locks
- run a singleton Codex App Server adapter in each dev workspace
- gate Web Studio with a reloadable fragment-token to HttpOnly-session exchange
- add ChatGPT device login and streamed chat to Web Studio
- isolate authoring access in a declarative NixOS service account
- split Web Studio canvas, object, panel, and model concerns
- check generated Quixos protocol clients for drift
- resolve same-repository quixos.lock fragments deterministically
- preserve the collaborative canvas gesture fixes
2026-09-05 17:49:07 -07:00
timothy c4d42a0ac5 Use the client runtime for canvas state 2026-09-05 12:11:30 -07:00
Quixos Subtree Publisher fd6160ec67 Publish quixos-protocol from 3ea7dcc6a38ba0c5ddc099b966d48c5aeae732b1 2026-09-05 04:04:43 +00:00
timothy fd039f095b Introduce persisted Web Studio component capabilities 2026-09-04 21:04:43 -07:00
timothy f4987093f6 Introduce repository-backed capability workspaces
- define and validate the capability and resource-lock languages
- provision workspace source repositories through Central Gitea
- build package runtimes from pinned standalone sources
- replace legacy schema compilation with workspace persistence plans
- add stable optimistic registers and Automerge CRDT documents
- modernize TypeScript/Nix package builds and runtime activation readiness
2026-09-04 19:08:10 -07:00
Quixos Subtree Publisher 4d52789807 Publish quixos-protocol from 4af89fa7c703d9fecf36b4e97b76658a503dd5aa 2026-09-05 01:29:10 +00:00
timothy 60550bb760 Host workspace repositories on Central Gitea 2026-09-04 18:29:10 -07:00
134 changed files with 27129 additions and 4225 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git",
"sourceCommit": "ce0dd80f56df35bf3db1be01236a949a704cb4c2",
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
"sourceCommit": "1c2dbc6ca2c3917292f158cbbaef531836eaacca",
"sourcePath": "quixos-protocol",
"exportName": "quixos-protocol",
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
+90 -16
View File
@@ -1,8 +1,8 @@
# Quixos protocol and capability compiler
This package owns the shared protobuf APIs for Camino, `quixos-orch`, package
runtimes, package descriptors, and generic runtime values. It also owns the v1
capability authoring language and semantic compiler.
Shared protobuf APIs for Camino, orch, package runtimes/descriptors and values,
plus the capability language/compiler. Commands below are for backend development;
workspace authors use `qx-workspace check` for all verification.
## Capability language
@@ -13,34 +13,107 @@ on parse-tree shapes or declaration order.
```sh
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
dependency-port requirements, constructors, and the exact runtime closure.
Generics, interface composition, declarative forwarding, automatic
relationship materialization, and first-class bundles are intentionally absent
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
Every workspace, interface, and package repository carries a `quixos.lock`.
It pins the exact Quixos toolchain revision plus exact Git sources for imported
interfaces and packages. The v1 Git source is deliberately only a repository
URL and full commit ID; the publisher-owned reachability tag and Nix fetch
details are derived from those values.
A resource lock is not a miniature workspace: it contains only the exact
Quixos commit that resource was authored against plus its dependency list.
It also pins exact Git sources for interfaces and packages named by that
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
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 current working-copy commit, and pushes the immutable tag
The resource compiler validates the local manifest against the recursively
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
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
Executable package metadata remains protobuf text format:
@@ -56,9 +129,10 @@ their exact dependency ports.
## Tests
```sh
nix shell nixpkgs#protobuf -c npm test
nix develop -c yarn test
```
The suite covers parsing/diagnostics, source-order independence, ordinary call
operations, semantic validation, exact closure/tree-shaking, private and shared
attachments, package port injection, and constructors.
The suite covers parsing/diagnostics, recursive repository assembly, external
requirement validation, source-order independence, ordinary call operations,
semantic validation, exact closure/tree-shaking, private and shared
attachments, related-object interface-port injection, and constructors.
+93 -15
View File
@@ -6,8 +6,15 @@
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
outputs =
{
self,
nixpkgs,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };
nodejs = pkgs.nodejs_24;
@@ -15,23 +22,45 @@
src = pkgs.lib.cleanSource ./.;
overrideAttrs = old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [
pkgs.diffutils
pkgs.esbuild
pkgs.protobuf
pkgs.git
pkgs.jujutsu
pkgs.gnutar
pkgs.util-linux
];
buildPhase = ''
runHook preBuild
mkdir -p "$TMPDIR/generated-before"
cp --recursive src/gen "$TMPDIR/generated-before/proto"
cp --recursive src/capability-language/generated "$TMPDIR/generated-before/capability"
cp --recursive src/resource-lock/generated "$TMPDIR/generated-before/lock"
export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$PWD/proto''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}"
patchShebangs node_modules/.bin node_modules/@bufbuild/protoc-gen-es/bin
yarn build
esbuild dist/src/bindings/client.js --bundle --platform=node --target=node24 --format=esm --outfile=client-codegen.mjs
diff --recursive --unified "$TMPDIR/generated-before/proto" src/gen
diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated
diff --recursive --unified "$TMPDIR/generated-before/lock" src/resource-lock/generated
esbuild dist/src/descriptor-check.js \
--bundle --platform=node --target=node24 --format=esm \
--outfile=quixos-descriptor-check.mjs
esbuild dist/src/capability-language/cli.js \
--bundle --platform=node --target=node24 --format=esm \
--outfile=quixos-capability-compile.mjs
esbuild dist/src/capability-language/workspace-cli.js \
--bundle --platform=node --target=node24 --format=esm \
--outfile=quixos-workspace-compile.mjs
esbuild dist/src/capability-language/resource-cli.js \
--bundle --platform=node --target=node24 --format=esm \
--outfile=quixos-resource-compile.mjs
esbuild dist/src/resource-lock/cli.js \
--bundle --platform=node --target=node24 --format=esm \
--outfile=quixos-lock-check.mjs
esbuild dist/src/bindings/cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-codegen-ts.mjs
esbuild dist/src/bindings/react-platform.js --bundle --platform=node --target=node24 --format=esm --outfile=react-platform.mjs
esbuild dist/src/capability-language/tool-cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-qx.mjs
runHook postBuild
'';
doCheck = true;
@@ -43,38 +72,86 @@
installPhase = ''
runHook preInstall
mkdir -p "$out"
install -Dm644 nix/checked-package.nix "$out/share/checked-package.nix"
substitute nix/checked-candidate.nix "$out/share/checked-candidate.nix" \
--replace-fail '@nixpkgs@' '${pkgs.path}'
cp --reflink=auto --recursive grammar "$out/grammar"
cp --reflink=auto --recursive proto "$out/proto"
cp --reflink=auto --recursive dist "$out/dist"
cp package.json "$out/package.json"
mkdir -p "$out/bin"
for tool in quixos-codegen-ts quixos-qx; do
printf '#!/bin/sh\nexec ${pkgs.nodejs_24}/bin/node "%s/libexec/quixos-protocol/%s.mjs" "$@"\n' "$out" "$tool" > "$out/bin/$tool"
chmod +x "$out/bin/$tool"
done
cat > "$out/bin/quixos-descriptor-check" <<EOF
#!/bin/sh
export PATH="${pkgs.protobuf}/bin:\$PATH"
export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$out/proto\''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}"
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs" "\$@"
EOF
#!/bin/sh
export PATH="${pkgs.protobuf}/bin:\$PATH"
export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$out/proto\''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}"
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs" "\$@"
EOF
chmod +x "$out/bin/quixos-descriptor-check"
cat > "$out/bin/quixos-capability-compile" <<EOF
#!/bin/sh
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-capability-compile.mjs" "\$@"
EOF
#!/bin/sh
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-capability-compile.mjs" "\$@"
EOF
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
#!/bin/sh
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-lock-check.mjs" "\$@"
EOF
#!/bin/sh
exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-lock-check.mjs" "\$@"
EOF
chmod +x "$out/bin/quixos-lock-check"
mkdir -p "$out/libexec/quixos-protocol"
install -m644 client-codegen.mjs "$out/libexec/quixos-protocol/client-codegen.mjs"
install -m644 react-platform.mjs "$out/libexec/quixos-protocol/react-platform.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-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"
runHook postInstall
'';
};
};
in {
in
{
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;
devShells.default = pkgs.mkShell {
packages = [
@@ -94,5 +171,6 @@ EOF
export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$PWD/proto''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}"
'';
};
});
}
);
}
+180 -32
View File
@@ -2,6 +2,17 @@ grammar QuixosCapability;
document
: workspaceDecl EOF
| interfaceResourceDecl EOF
| packageResourceDecl EOF
| fragmentDecl EOF
;
fragmentDecl
: FRAGMENT LBRACE workspaceItem* RBRACE
;
sourceImportDecl
: IMPORT stringLiteral SEMI
;
workspaceDecl
@@ -10,28 +21,71 @@ workspaceDecl
;
workspaceItem
: atomDecl
| interfaceDecl
| packageDecl
: sourceImportDecl
| atomDecl
| resourceImportDecl
| sharedAttachmentDecl
| conformanceDecl
| constructorBindingDecl
| typeAliasDecl
;
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
| typeAliasDecl
;
atomDecl
: ATOM identifier ID stringLiteral (DOC stringLiteral)? SEMI
;
interfaceDecl
: INTERFACE identifier ID stringLiteral REVISION stringLiteral sourceBlock
interfaceResourceDecl
: resourcePreamble* INTERFACE identifier typeParameters? ID stringLiteral REVISION stringLiteral
(REQUIRES interfaceType (COMMA interfaceType)*)?
LBRACE interfaceMember* RBRACE
;
sourceBlock
: SOURCE LBRACE
REPOSITORY stringLiteral SEMI
COMMIT stringLiteral SEMI
RBRACE
typeParameters
: LT typeParameter (COMMA typeParameter)* GT
;
typeParameter
: VALUE identifier (COLON STORABLE)?
| OBJECT identifier (IMPLEMENTS interfaceType (AMP interfaceType)*)?
;
interfaceType
: identifier typeArguments?
;
typeArguments
: LT typeArgument (COMMA typeArgument)* GT
;
typeArgument
: ATOM identifier
| INTERFACE interfaceType
| OBJECT identifier
| valueType
;
typeAliasDecl
: TYPE identifier typeParameters? EQUAL valueType SEMI
;
interfaceMember
@@ -41,12 +95,12 @@ interfaceMember
;
operationMember
: OPERATION identifier ID stringLiteral COLON valueType ARROW valueType
: STATIC? OPERATION identifier ID stringLiteral COLON valueType ARROW valueType
LBRACE CALL ID stringLiteral SEMI RBRACE
;
valueMember
: VALUE identifier ID stringLiteral COLON valueType
: queryReadContract? VALUE identifier ID stringLiteral COLON valueType
LBRACE valueMemberOperation* RBRACE
;
@@ -57,10 +111,14 @@ valueMemberOperation
;
relationshipMember
: RELATION identifier ID stringLiteral COLON cardinality targetConstraint ORDERED?
: queryReadContract? RELATION identifier ID stringLiteral COLON cardinality targetConstraint ORDERED? (KEYED stringLiteral)?
LBRACE relationshipOperation* RBRACE
;
queryReadContract
: QUERYABLE RPC?
;
relationshipOperation
: RESOLVE ID stringLiteral SEMI
| CONNECT ID stringLiteral SEMI
@@ -70,11 +128,13 @@ relationshipOperation
targetConstraint
: ATOM identifier
| INTERFACE identifier
| INTERFACE identifier typeArguments?
| OBJECT identifier
;
packageDecl
: PACKAGE identifier ID stringLiteral REVISION stringLiteral sourceBlock
packageResourceDecl
: resourcePreamble* PACKAGE identifier ID stringLiteral REVISION stringLiteral
(SEMANTIC_MAJOR INTEGER)?
LBRACE packageExport* RBRACE
;
@@ -82,15 +142,35 @@ packageExport
: packageOperationExport
| packageFunctionExport
| packageConstructorExport
| packageQuery
| packageQuerySpecialization
;
packageQuery
: QUERY identifier typeParameters? ID stringLiteral ROOT interfaceType
DOCUMENT stringLiteral OPERATION stringLiteral LBRACE queryClause* RBRACE
;
packageQuerySpecialization
: QUERY identifier ID stringLiteral SPECIALIZE identifier typeArguments SEMI
;
queryClause
: FRAGMENTS stringLiteral SEMI
| VIEW OBJECT? identifier AS interfaceType SEMI
| MAX identifier INTEGER SEMI
| ALLOW interfaceType DOT identifier identifier stringLiteral SEMI
| WATCH SEMI
| POLL INTEGER stringLiteral SEMI
;
packageOperationExport
: OPERATION identifier ID stringLiteral COLON valueType ARROW valueType
: OPERATION identifier typeParameters? ID stringLiteral COLON valueType ARROW valueType
MODE operationMode eventClause? RECEIVER receiverRequirement dependencyBlock? SEMI
;
packageFunctionExport
: FUNCTION identifier ID stringLiteral COLON valueType ARROW valueType
: FUNCTION identifier typeParameters? ID stringLiteral COLON valueType ARROW valueType
dependencyBlock? SEMI
;
@@ -114,7 +194,8 @@ operationMode
receiverRequirement
: ANY
| ATOM identifier
| INTERFACES LBRACK identifierList? RBRACK
| OBJECT identifier
| INTERFACES LBRACK (interfaceType (COMMA interfaceType)*)? RBRACK
;
identifierList
@@ -128,8 +209,9 @@ dependencyBlock
dependencyPort
: STATE identifier ID stringLiteral COLON valueType primitiveList SEMI
| EDGE identifier ID stringLiteral COLON cardinality targetConstraint primitiveList SEMI
| INTERFACE identifier ID stringLiteral COLON identifier SEMI
| CONSTRUCTOR identifier ID stringLiteral COLON identifier SEMI
| INTERFACE identifier ID stringLiteral COLON identifier typeArguments? SEMI
| CONSTRUCTOR identifier ID stringLiteral COLON identifier (INPUT valueType)? SEMI
| QUERY identifier ID stringLiteral COLON identifier DOT identifier SEMI
;
primitiveList
@@ -170,20 +252,32 @@ edgeDecl
;
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
: CONFORM identifier AS identifier LBRACE conformanceItem* RBRACE
: CONFORM identifier AS identifier typeArguments? (ID stringLiteral)? (SEMANTIC_MAJOR INTEGER)?
LBRACE conformanceItem* RBRACE
;
conformanceItem
: PRIVATE attachmentDecl
| operationBindingDecl
| stateFieldBindingDecl
| relationshipMaterializationDecl
;
stateFieldBindingDecl
: BIND identifier TO STATE identifier SEMI
;
relationshipMaterializationDecl
: MATERIALIZE identifier IF ABSENT USING CONSTRUCTOR identifier VIA EDGE identifier DOT identifier SEMI
;
operationBindingDecl
: BIND memberOperationRef TO operationProvider SEMI
: BIND memberOperationRef TO operationProvider (QUERY_REASON stringLiteral)? SEMI
;
memberOperationRef
@@ -207,7 +301,7 @@ operationName
operationProvider
: STATE identifier DOT statePrimitive
| EDGE identifier DOT identifier DOT edgePrimitive
| PACKAGE identifier DOT identifier dependencyBindingBlock?
| PACKAGE identifier DOT identifier typeArguments? dependencyBindingBlock?
;
statePrimitive
@@ -230,10 +324,11 @@ dependencyBindingBlock
;
dependencyBinding
: identifier TO STATE identifier SEMI
| identifier TO EDGE identifier DOT identifier SEMI
| identifier TO INTERFACE identifier SEMI
: identifier TO STATE identifier (VIA EDGE identifier DOT identifier)? SEMI
| identifier TO EDGE identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI
| identifier TO INTERFACE identifier typeArguments? (VIA EDGE identifier DOT identifier)? SEMI
| identifier TO CONSTRUCTOR identifier SEMI
| identifier TO QUERY identifier DOT identifier (VIA EDGE identifier DOT identifier)? SEMI
;
constructorBindingDecl
@@ -246,9 +341,16 @@ valueType
| WATCH_HANDLE
| MESSAGE stringLiteral
| ATOM_REF LT identifier GT
| INTERFACE_REF LT identifier GT
| INTERFACE_REF LT identifier typeArguments? GT
| REF LT identifier GT
| OPTIONAL LT valueType GT
| LIST LT valueType GT
| RECORD LBRACE recordField* RBRACE
| identifier typeArguments?
;
recordField
: identifier COLON valueType SEMI
;
scalarType
@@ -295,6 +397,17 @@ jsonArray
identifier
: IDENTIFIER
| SOURCE
| QUERY
| SPECIALIZE
| ROOT
| DOCUMENT
| FRAGMENTS
| VIEW
| MAX
| ALLOW
| POLL
| RPC
| QUERYABLE
;
stringLiteral
@@ -302,6 +415,26 @@ stringLiteral
;
WORKSPACE: 'workspace';
QUERY: 'query';
SPECIALIZE: 'specialize';
ROOT: 'root';
DOCUMENT: 'document';
FRAGMENTS: 'fragments';
VIEW: 'view';
MAX: 'max';
ALLOW: 'allow';
POLL: 'poll';
QUERYABLE: 'queryable';
RPC: 'rpc';
QUERY_REASON: 'query-reason';
TYPE: 'type';
OBJECT: 'object';
STORABLE: 'storable';
IMPLEMENTS: 'implements';
REF: 'ref';
FRAGMENT: 'fragment';
IMPORT: 'import';
EXTERNAL: 'external';
ATOM: 'atom';
INTERFACE: 'interface';
INTERFACES: 'interfaces';
@@ -312,9 +445,11 @@ OPERATION: 'operation';
FUNCTION: 'function';
CONSTRUCTOR: 'constructor';
CONSTRUCTS: 'constructs';
INPUT: 'input';
CONFORM: 'conform';
AS: 'as';
BIND: 'bind';
STATIC: 'static';
TO: 'to';
PRIVATE: 'private';
SHARED: 'shared';
@@ -322,6 +457,11 @@ STATE: 'state';
EDGE: 'edge';
PROJECTION: 'projection';
WITH: 'with';
USING: 'using';
VIA: 'via';
MATERIALIZE: 'materialize';
IF: 'if';
ABSENT: 'absent';
ON: 'on';
POLICY: 'policy';
DEFAULT: 'default';
@@ -329,6 +469,11 @@ SOURCE: 'source';
REPOSITORY: 'repository';
COMMIT: 'commit';
REVISION: 'revision';
SEMANTIC_MAJOR: 'semantic-major';
ON_DELETE: 'on-delete';
RETAIN_OTHER: 'retain-other';
KEYED: 'keyed';
PUBLIC_TRAVERSAL: 'public-traversal';
ID: 'id';
DOC: 'doc';
MODE: 'mode';
@@ -365,6 +510,7 @@ ATOM_REF: 'atom-ref';
INTERFACE_REF: 'interface-ref';
OPTIONAL: 'optional';
LIST: 'list';
RECORD: 'record';
BOOL: 'bool';
BYTES: 'bytes';
DOUBLE: 'double';
@@ -390,6 +536,8 @@ LPAREN: '(';
RPAREN: ')';
LT: '<';
GT: '>';
AMP: '&';
EQUAL: '=';
INTEGER: '-'? [0-9]+;
JSON_NUMBER: '-'? ('0' | [1-9] [0-9]*) ('.' [0-9]+)? ([eE] [+-]? [0-9]+)?;
@@ -398,6 +546,6 @@ STRING_LITERAL: '"' (ESC | ~["\\\r\n])* '"';
fragment ESC: '\\' (["\\/bfnrt] | 'u' HEX HEX HEX HEX);
fragment HEX: [0-9a-fA-F];
LINE_COMMENT: '//' ~[\r\n]* -> skip;
BLOCK_COMMENT: '/*' .*? '*/' -> skip;
WS: [ \t\r\n]+ -> skip;
LINE_COMMENT: '//' ~[\r\n]* -> channel(HIDDEN);
BLOCK_COMMENT: '/*' .*? '*/' -> channel(HIDDEN);
WS: [ \t\r\n]+ -> channel(HIDDEN);
+28 -2
View File
@@ -1,17 +1,22 @@
grammar QuixosLock;
document
: QUIXOS_LOCK VERSION INTEGER LBRACE quixosEntry resourceEntry* RBRACE EOF
: QUIXOS_LOCK FRAGMENT? VERSION INTEGER LBRACE
(quixosEntry | importEntry | resourceEntry)* RBRACE EOF
;
quixosEntry
: QUIXOS SOURCE sourceBlock
: QUIXOS SOURCE quixosSourceBlock
;
resourceEntry
: resourceKind identifier SOURCE sourceBlock
;
importEntry
: IMPORT stringLiteral SEMI
;
resourceKind
: INTERFACE
| PACKAGE
@@ -24,6 +29,20 @@ sourceBlock
RBRACE
;
quixosSourceBlock
: LBRACE
REPOSITORY stringLiteral SEMI
(POLICY sourcePolicy SEMI REF stringLiteral SEMI)?
COMMIT stringLiteral SEMI
RBRACE
;
sourcePolicy
: PINNED
| TRACK_RELEASE
| TRACK_DEVELOPMENT
;
identifier
: IDENTIFIER
;
@@ -33,6 +52,7 @@ stringLiteral
;
QUIXOS_LOCK: 'quixos-lock';
FRAGMENT: 'fragment';
VERSION: 'version';
QUIXOS: 'quixos';
SOURCE: 'source';
@@ -40,6 +60,12 @@ INTERFACE: 'interface';
PACKAGE: 'package';
REPOSITORY: 'repository';
COMMIT: 'commit';
POLICY: 'policy';
REF: 'ref';
PINNED: 'pinned';
TRACK_RELEASE: 'track-release';
TRACK_DEVELOPMENT: 'track-development';
IMPORT: 'import';
LBRACE: '{';
RBRACE: '}';
+140
View File
@@ -0,0 +1,140 @@
# 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" --schemas-out "$out/package-schemas.json" > "$out/candidate.json"
''
else
''
${protocol}/bin/quixos-resource-compile --root ${node.directory} \
--kind ${node.kind} --repository ${pkgs.lib.escapeShellArg node.repository} \
--commit ${pkgs.lib.escapeShellArg node.commit} \
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} --snapshot-only true \
--graph-out "$out/graph.json" --schema-out "$out/bindings.json" > "$out/candidate.json"
''
}
'';
contract = compile root;
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 =
if kind == "workspace" then
pkgs.writeText "package-specialization-schema.json" (
builtins.toJSON
(builtins.fromJSON (builtins.readFile "${contract}/package-schemas.json"))
.${candidate.revision.revisionId}
)
else
"${compiled}/bindings.json";
generator = protocol;
packageRevisionId = candidate.revision.revisionId;
};
in
{
packageRevisionId = candidate.revision.revisionId;
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);
bindingOptions = pkgs.writeText "qx-typescript-options.json" (
builtins.toJSON (
(builtins.fromJSON (builtins.readFile "${root.directory}/quixos.check.json")).options or { }
)
);
in
pkgs.runCommand (if contractOnly then "qx-contract" else "qx-checked-candidate") { } ''
mkdir -p "$out"
cp ${contract}/candidate.json "$out/candidate.json"
cp ${contract}/graph.json "$out/graph.json"
cp ${manifest} "$out/checks.json"
${pkgs.lib.optionalString (
kind != "workspace"
) ''cp ${contract}/bindings.json "$out/bindings.json"''}
${pkgs.lib.optionalString (kind == "package") ''
${protocol}/bin/quixos-codegen-ts ${contract}/bindings.json \
${pkgs.lib.escapeShellArg (builtins.fromJSON (builtins.readFile "${contract}/candidate.json")).revision.revisionId} \
"$out/bindings.ts" ${bindingOptions}
''}
''
+35
View File
@@ -0,0 +1,35 @@
# One derivation path for provisional checking and activation. The source and
# schema come from exact committed inputs resolved by the Quixos compiler.
{
source,
schema,
generator,
packageRevisionId,
system ? builtins.currentSystem,
}:
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;
}
+9 -2
View File
@@ -6,10 +6,15 @@
"type": "module",
"bin": {
"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-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": {
"./bindings": "./dist/src/bindings/index.js",
"./capability-model": "./dist/src/capability-model/index.js",
"./capability-language": "./dist/src/capability-language/index.js",
"./resource-lock": "./dist/src/resource-lock/index.js",
@@ -24,9 +29,11 @@
"typecheck": "yarn generate && tsc --noEmit"
},
"dependencies": {
"@babel/parser": "^7.28.0",
"@bufbuild/protobuf": "^2.12.1",
"@bufbuild/protoc-gen-es": "^2.12.1",
"antlr4ng": "^3.0.16"
"antlr4ng": "^3.0.16",
"graphql": "16.11.0"
},
"devDependencies": {
"@types/node": "^24",
+204 -19
View File
@@ -5,6 +5,8 @@ package camino;
import "camino/schema.proto";
service CaminoService {
rpc ExecuteQuery(QueryRequest) returns (QueryResponse);
rpc QueryChanges(QueryChangesRequest) returns (QueryChangesResponse);
rpc InstallPersistencePlan(InstallPersistencePlanRequest) returns (InstallPersistencePlanResponse);
rpc GetPersistencePlan(GetPersistencePlanRequest) returns (GetPersistencePlanResponse);
rpc CreateObject(CreateObjectRequest) returns (CreateObjectResponse);
@@ -15,14 +17,22 @@ service CaminoService {
rpc ConnectEdge(ConnectEdgeRequest) returns (ConnectEdgeResponse);
rpc ResolveEdge(ResolveEdgeRequest) returns (ResolveEdgeResponse);
rpc DisconnectEdge(DisconnectEdgeRequest) returns (DisconnectEdgeResponse);
rpc ReadCollection(ResolveEdgeRequest) returns (ReadCollectionResponse);
rpc ReplaceCollection(ReplaceCollectionRequest) returns (ReadCollectionResponse);
rpc ListOps(ListOpsRequest) returns (ListOpsResponse);
rpc WatchObject(WatchObjectRequest) returns (stream WatchObjectEvent);
}
message NullValue {}
message ObjectValue { map<string, Value> fields = 1; }
message ListValue { repeated Value values = 1; }
message RefValue { string object_id = 1; }
message ObjectValue {
map<string, Value> fields = 1;
}
message ListValue {
repeated Value values = 1;
}
message RefValue {
string object_id = 1;
}
message CrdtValue {
string type = 1;
string encoding = 2;
@@ -35,9 +45,15 @@ message StateValueSource {
string value_type_json = 3;
string storage_policy_json = 4;
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;
}
message Value {
oneof kind {
@@ -55,26 +71,44 @@ message Value {
ValueSource source = 11;
}
message InstallPersistencePlanRequest { PersistencePlan plan = 1; }
message InstallPersistencePlanResponse { PersistencePlan plan = 1; }
message InstallPersistencePlanRequest {
PersistencePlan plan = 1;
}
message InstallPersistencePlanResponse {
PersistencePlan plan = 1;
}
message GetPersistencePlanRequest {}
message GetPersistencePlanResponse { PersistencePlan plan = 1; }
message GetPersistencePlanResponse {
PersistencePlan plan = 1;
}
message CreateObjectRequest {
string atom_id = 1;
map<string, Value> initial_state = 2;
}
message CreateObjectResponse { CaminoObject object = 1; }
message GetObjectRequest { string object_id = 1; }
message GetObjectResponse { CaminoObject object = 1; }
message ListObjectsRequest { string atom_id = 1; }
message ListObjectsResponse { repeated CaminoObject objects = 1; }
message CreateObjectResponse {
CaminoObject object = 1;
}
message GetObjectRequest {
string object_id = 1;
}
message GetObjectResponse {
CaminoObject object = 1;
}
message ListObjectsRequest {
string atom_id = 1;
}
message ListObjectsResponse {
repeated CaminoObject objects = 1;
}
message ReadStateRequest {
string object_id = 1;
string slot_id = 2;
}
message ReadStateResponse { Value value = 1; }
message ReadStateResponse {
Value value = 1;
}
message WriteStateRequest {
string object_id = 1;
string slot_id = 2;
@@ -94,22 +128,54 @@ message ConnectEdgeRequest {
optional int32 ordinal = 5;
optional int32 target_ordinal = 6;
}
message ConnectEdgeResponse { CaminoEdge edge = 1; }
message ConnectEdgeResponse {
CaminoEdge edge = 1;
}
message ResolveEdgeRequest {
string object_id = 1;
string edge_type_id = 2;
string projection_id = 3;
}
message ResolveEdgeResponse { repeated CaminoEdge edges = 1; }
message DisconnectEdgeRequest { string edge_id = 1; }
message DisconnectEdgeResponse { string edge_id = 1; }
message ResolveEdgeResponse {
repeated CaminoEdge edges = 1;
}
message DisconnectEdgeRequest {
string edge_id = 1;
}
message DisconnectEdgeResponse {
string edge_id = 1;
}
message ListOpsRequest { string object_id = 1; }
message ListOpsResponse { repeated CaminoOp ops = 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 ListOpsResponse {
repeated CaminoOp ops = 1;
}
message WatchObjectRequest {
string object_id = 1;
string after_op_id = 2;
bool include_snapshot = 3;
// Managed runtimes must watch only their injected attachments.
repeated string attachment_ids = 4;
}
message WatchObjectEvent {
string object_id = 1;
@@ -137,6 +203,8 @@ message CaminoEdge {
optional int32 first_ordinal = 7;
optional int32 second_ordinal = 8;
string created_at = 9;
string first_key_json = 10;
string second_key_json = 11;
}
message CaminoOp {
@@ -148,3 +216,120 @@ message CaminoOp {
string created_at = 6;
string client_mutation_id = 7;
}
message QueryRequest {
string query_id = 1;
string object_id = 2;
map<string, Value> variables = 3;
// Typed callers require this exact checked definition. Administrative/raw
// callers may omit it to explicitly select the currently installed definition.
string expected_definition_digest = 4;
}
message QueryPathPart {
oneof part {
string field = 1;
uint32 index = 2;
}
}
message QueryPendingField {
repeated QueryPathPart path = 1;
string object_id = 2;
string interface_revision_id = 3;
string member_id = 4;
string operation_id = 5;
string value_type_json = 6;
// Internal residual facts are separate from the authored result projection.
optional uint32 residual_window = 7;
uint32 residual_row = 8;
string residual_field = 9;
optional uint32 relational_capture = 10;
uint32 captured_object = 11;
}
message QueryStats {
uint32 sql_count = 1;
double sql_ms = 2;
uint32 rpc_count = 3;
double rpc_ms = 4;
uint32 result_bytes = 5;
double preparation_ms = 6;
double total_ms = 7;
uint32 relational_stages = 8;
uint32 captured_candidates = 9;
double relational_ms = 10;
}
message QueryResponse {
Value value = 1;
string data_version = 2;
string binding_digest = 3;
repeated QueryPendingField pending = 4;
QueryStats stats = 5;
// Redeemable only through the host's private control socket, not an RPC grant.
string preparation_token = 6;
repeated QueryFieldFailure errors = 7;
repeated QueryResidualWindow residual_windows = 8;
// Native reads share one database snapshot; package enrichment does not.
string consistency = 9; // native-snapshot | mixed
repeated QueryRelationalCapture relational_captures = 10;
}
// Private coordinator input, removed before publishing a result. Memberships
// and native facts share one snapshot; package reads are sampled afterwards.
message QueryCapturedMember {
string object_id = 1;
string entry_id = 2;
Value map_key = 3;
}
message QueryCapturedMembers {
repeated QueryCapturedMember entries = 1;
}
message QueryCapturedObject {
string object_id = 1;
map<string, Value> fields = 2;
map<string, string> field_types = 3;
map<string, QueryCapturedMembers> relationships = 4;
}
message QueryRelationalCapture {
repeated QueryPathPart path = 1;
QueryRelationalPlan plan = 2;
string root_object_id = 3;
repeated QueryCapturedObject objects = 4;
string variables_json = 5;
uint32 row_limit = 6;
uint32 candidate_limit = 7;
optional uint32 residual_window = 8;
repeated string result_path = 9;
}
message QueryResidualRow {
string entry_id = 1;
map<string, Value> fields = 2;
}
message QueryResidualOrder {
string field = 1;
bool descending = 2;
}
message QueryResidualWindow {
repeated QueryPathPart path = 1;
repeated QueryResidualRow rows = 2;
string predicate_json = 3;
repeated QueryResidualOrder order = 4;
uint32 limit = 5;
bool bounded_all = 6;
repeated QuerySelection selection = 7;
map<string, string> field_types = 8;
bool relational = 9;
repeated string matched_entries = 10;
}
message QueryFieldFailure {
repeated QueryPathPart path = 1;
string error = 2;
}
message QueryChangesRequest {
string query_id = 1;
string object_id = 2;
string data_version = 3;
}
message QueryChangesResponse {
bool changed = 1;
}
+247
View File
@@ -18,6 +18,7 @@ message AtomDefinition {
message AtomConformance {
string atom_id = 1;
string interface_revision_id = 2;
string conformance_id = 3;
}
message StateAttachment {
@@ -27,6 +28,7 @@ message StateAttachment {
string value_type_json = 4;
string storage_policy_json = 5;
string default_value_json = 6;
string owner_conformance_id = 7;
}
message EndpointConstraint {
@@ -42,6 +44,13 @@ message EdgeEndpoint {
EndpointConstraint constraint = 3;
Cardinality cardinality = 4;
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 {
@@ -49,6 +58,7 @@ message EdgeAttachment {
string display_name = 2;
EdgeEndpoint first = 3;
EdgeEndpoint second = 4;
string owner_conformance_id = 5;
}
// Camino consumes this persistence-only projection of a checked workspace.
@@ -60,4 +70,241 @@ message PersistencePlan {
repeated AtomConformance conformances = 4;
repeated StateAttachment states = 5;
repeated EdgeAttachment edges = 6;
repeated InstalledQuery queries = 7;
}
// Immutable checked query IR. Installed only with the checked persistence plan;
// query callers select its ID, never send or modify these definitions.
message QueryArgument {
oneof value {
string variable = 1;
string literal_json = 2;
QueryArgumentList list = 3;
QueryArgumentObject object = 4;
}
}
message QueryArgumentList {
repeated QueryArgument values = 1;
}
message QueryArgumentObject {
map<string, QueryArgument> fields = 1;
}
message QueryCondition {
bool include = 1;
QueryArgument value = 2;
}
message QuerySelection {
string name = 1;
string key = 2;
string interface_revision_id = 3;
string member_id = 4;
string target_interface_revision_id = 5;
repeated QueryCondition conditions = 6;
map<string, QueryArgument> arguments = 7;
repeated QuerySelection selection = 8;
QueryRelationalPlan relational = 9;
QueryPredicate predicate = 10;
}
// Resolved IDs, not GraphQL names, determine execution. Response selections
// remain separate so aliases and fragments cannot change relational semantics.
message QueryPathStep {
oneof step {
bool source = 1;
bool target = 2;
QueryRelationPath relation = 3;
}
}
message QueryRelationPath {
string interface_revision_id = 1;
string member_id = 2;
string target_interface_revision_id = 3;
bool optional = 4;
}
message QueryFieldOperand {
string interface_revision_id = 1;
string member_id = 2;
}
message QueryExpression {
repeated QueryPathStep path = 1;
oneof leaf {
QueryFieldOperand field = 2;
string ref = 3;
bool entry = 4;
bool map_key = 5;
}
string value_type_json = 6;
}
enum QueryAggregateOperator {
QUERY_AGGREGATE_OPERATOR_UNSPECIFIED = 0;
QUERY_AGGREGATE_OPERATOR_COUNT = 1;
QUERY_AGGREGATE_OPERATOR_COUNT_PRESENT = 2;
QUERY_AGGREGATE_OPERATOR_SUM = 3;
QUERY_AGGREGATE_OPERATOR_AVG = 4;
QUERY_AGGREGATE_OPERATOR_MIN = 5;
QUERY_AGGREGATE_OPERATOR_MAX = 6;
}
message QueryReduction {
QueryAggregateOperator operator = 1;
QueryExpression operand = 2;
string value_type_json = 3;
}
message QueryOperand {
oneof operand {
QueryExpression expression = 1;
QueryReduction reduction = 2;
}
}
enum QueryComparisonOperator {
QUERY_COMPARISON_OPERATOR_UNSPECIFIED = 0;
QUERY_COMPARISON_OPERATOR_EQ = 1;
QUERY_COMPARISON_OPERATOR_IN = 2;
QUERY_COMPARISON_OPERATOR_IS_NULL = 3;
QUERY_COMPARISON_OPERATOR_LT = 4;
QUERY_COMPARISON_OPERATOR_LTE = 5;
QUERY_COMPARISON_OPERATOR_GT = 6;
QUERY_COMPARISON_OPERATOR_GTE = 7;
}
message QueryComparison {
QueryOperand operand = 1;
QueryComparisonOperator operator = 2;
QueryArgument value = 3;
}
message QueryPredicateList {
repeated QueryPredicate children = 1;
}
enum QueryRelationPredicateOperator {
QUERY_RELATION_PREDICATE_OPERATOR_UNSPECIFIED = 0;
QUERY_RELATION_PREDICATE_OPERATOR_SOME = 1;
QUERY_RELATION_PREDICATE_OPERATOR_NONE = 2;
QUERY_RELATION_PREDICATE_OPERATOR_IS = 3;
QUERY_RELATION_PREDICATE_OPERATOR_IS_NULL = 4;
}
message QueryRelationPredicate {
repeated QueryPathStep path = 1;
QueryRelationPath relation = 2;
QueryRelationPredicateOperator operator = 3;
QueryPredicate predicate = 4;
QueryArgument value = 5;
}
message QueryReductionPredicate {
repeated QueryPathStep path = 1;
QueryRelationPath relation = 2;
QueryPredicate where = 3;
QueryPredicate having = 4;
}
message QueryPredicate {
oneof predicate {
QueryPredicateList and = 1;
QueryPredicateList or = 2;
QueryPredicate not = 3;
QueryComparison compare = 4;
QueryRelationPredicate relation = 5;
QueryReductionPredicate reduce = 6;
}
}
message QueryRow {
oneof row {
QueryObjectRow object = 1;
QueryPairRow pair = 2;
}
}
message QueryObjectRow {
string interface_revision_id = 1;
bool membership = 2;
string key_type = 3;
}
message QueryPairRow {
QueryRow source = 1;
QueryRow target = 2;
}
message QueryKey {
repeated string path = 1;
QueryExpression expression = 2;
}
message QueryDistinct {
repeated QueryKey keys = 1;
}
message QueryExpansion {
repeated QueryPathStep path = 1;
QueryRelationPath relation = 2;
}
message QueryRelationalStage {
uint32 id = 1;
optional uint32 input = 2;
QueryRow row = 3;
oneof operation {
QueryRelationPath source = 4;
QueryPredicate filter = 5;
QueryExpansion expand = 6;
QueryDistinct distinct = 7;
}
}
message QueryReductionProjection {
repeated string path = 1;
QueryReduction reduction = 2;
}
message QueryAggregateOrder {
QueryOperand operand = 1;
bool descending = 2;
}
message QueryRelationalTerminal {
uint32 stage = 1;
repeated string path = 2;
bool groups = 3;
repeated QueryKey keys = 4;
repeated QueryReductionProjection reductions = 5;
QueryPredicate where = 6;
QueryPredicate having = 7;
repeated QueryAggregateOrder order = 8;
QueryArgument first = 9;
QueryArgument all = 10;
QueryArgument after = 11;
repeated QuerySelection selection = 12;
bool residual = 13;
// Internal ordinary-relationship residual: yields ordered membership IDs.
bool rows = 14;
}
message QueryRelationalPlan {
repeated QueryRelationalStage stages = 1;
repeated QueryRelationalTerminal terminals = 2;
}
message QueryReadBinding {
string atom_id = 1;
string interface_revision_id = 2;
string member_id = 3;
string getter_operation_id = 4;
string slot_id = 5;
string edge_type_id = 6;
string projection_id = 7;
bool rpc = 8;
string watch_start_operation_id = 9;
string watch_stop_operation_id = 10;
string field_name = 11;
string value_type_json = 12;
Cardinality cardinality = 13;
string key_type = 14;
}
message QueryBudgets {
uint32 rows = 1;
uint32 depth = 2;
uint32 result_bytes = 3;
uint32 candidates = 4;
uint32 rpc_calls = 5;
uint32 concurrency = 6;
uint32 deadline_ms = 7;
}
message InstalledQuery {
string id = 1;
string definition_digest = 2;
string binding_digest = 3;
string root_interface_revision_id = 4;
repeated QuerySelection selection = 5;
repeated QueryReadBinding bindings = 6;
QueryBudgets budgets = 7;
string variables_type_json = 8;
string output_type_json = 9;
map<string, QueryArgument> variable_defaults = 10;
bool watch = 11;
uint32 polling_interval_ms = 12;
bool rpc_predicate_or_order = 13;
}
+128 -7
View File
@@ -3,14 +3,22 @@ syntax = "proto3";
package quixos.orch;
import "camino/api.proto";
import "camino/schema.proto";
import "quixos/package.proto";
import "quixos/refs.proto";
import "quixos/runtime.proto";
service OrchestratorRuntime {
rpc ExecuteQuery(camino.QueryRequest) returns (camino.QueryResponse);
rpc WatchQuery(camino.QueryRequest) returns (stream QueryEvent);
rpc TryConform(TryConformRequest) returns (TryConformResponse);
rpc InvokeCapability(InvokeCapabilityRequest) returns (InvokeCapabilityResponse);
rpc EditCapabilityField(EditCapabilityFieldRequest) returns (InvokeCapabilityResponse);
rpc InvokeClassCapability(InvokeClassCapabilityRequest) returns (InvokeCapabilityResponse);
rpc WatchCapability(WatchCapabilityRequest) returns (stream WatchCapabilityEvent);
rpc ConstructObject(ConstructObjectRequest) returns (ConstructObjectResponse);
rpc ResolveOrConstructRelatedObject(ResolveOrConstructRelatedObjectRequest)
returns (ResolveOrConstructRelatedObjectResponse);
rpc GetWorkspace(GetWorkspaceRequest) returns (GetWorkspaceResponse);
rpc ListPackageDescriptors(ListPackageDescriptorsRequest) returns (ListPackageDescriptorsResponse);
rpc ListPackageRuntimes(ListPackageRuntimesRequest) returns (ListPackageRuntimesResponse);
@@ -18,16 +26,56 @@ service OrchestratorRuntime {
rpc CloseActivation(CloseActivationRequest) returns (CloseActivationResponse);
}
message QueryEvent {
string run_id = 1;
uint64 sequence = 2;
string kind = 3;
camino.QueryResponse snapshot = 4;
repeated camino.QueryPathPart path = 5;
camino.Value value = 6;
string error = 7;
}
// Exact closed interface lookup; no policy selection or competing conformances.
message TryConformRequest {
string object_id = 1;
string interface_revision_id = 2;
}
message TryConformResponse {
// Absent only when this object lacks a known contract. Other failures are errors.
quixos.ConformanceWitness conformance = 1;
}
message ConstructObjectRequest {
string atom_id = 1;
map<string, camino.Value> input = 2;
}
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 {
quixos.CapabilityRef capability = 1;
string object_id = 2;
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 InvokeClassCapabilityRequest {
string conformance_id = 1;
string operation_id = 2;
map<string, camino.Value> input = 3;
}
message InvokeCapabilityResponse {
string invocation_id = 1;
@@ -35,6 +83,25 @@ message InvokeCapabilityResponse {
bool ok = 3;
camino.Value result = 4;
string error = 5;
repeated quixos.runtime.DerivedDependency dependencies = 6;
FieldEditing field_editing = 7;
}
// Resolved from the checked native getter/setter binding, not Value.source.
message FieldEditing {
string getter_operation_id = 1;
string setter_operation_id = 2;
string document_type = 3;
string binding_digest = 4;
}
message EditCapabilityFieldRequest {
// The public getter; setter must belong to the same value member.
quixos.CapabilityRef capability = 1;
string object_id = 2;
string setter_operation_id = 3;
string binding_digest = 4;
camino.CrdtValue update = 5;
string client_mutation_id = 6;
}
message WatchCapabilityRequest {
@@ -50,22 +117,76 @@ message WatchCapabilityEvent {
repeated quixos.runtime.DerivedDependency dependencies = 5;
string error = 6;
bool initial = 7;
FieldEditing field_editing = 8;
}
message GetWorkspaceRequest {}
message GetWorkspaceRequest {
// Revision polling must not download the entire interface graph.
bool include_interface_contracts = 1;
bool include_query_contracts = 2;
}
message GetWorkspaceResponse {
string workspace_id = 1;
string workspace_revision_id = 2;
string source_root_commit = 3;
// Checked constructors whose wire input can be empty. The create panel uses
// class factory conformances instead of this constructor inventory.
repeated string empty_input_constructible_atom_ids = 4;
repeated CapabilityInputContract capability_inputs = 5;
repeated ConstructorInputContract constructor_inputs = 6;
// Exact closed interface contracts used by checked presentation consumers.
string interfaces_json = 7;
repeated ClassCapability class_capabilities = 8;
repeated QueryDescription queries = 9;
uint32 active_query_executions = 10;
}
message QueryDescription {
string id = 1;
string name = 2;
string package_revision_id = 3;
string document = 4;
string schema = 5;
repeated string source_files = 6;
string effects_json = 7;
camino.InstalledQuery plan = 8;
}
message ClassCapability {
string conformance_id = 1;
string atom_id = 2;
string interface_revision_id = 3;
string definition_id = 4;
string operation_id = 5;
string input_type_json = 6;
string output_type_json = 7;
}
message CapabilityInputContract {
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 ListPackageDescriptorsRequest {}
message ListPackageDescriptorsResponse { repeated quixos.PackageDescriptor descriptors = 1; }
message ListPackageDescriptorsResponse {
repeated quixos.PackageDescriptor descriptors = 1;
}
message ListPackageRuntimesRequest {}
message ListPackageRuntimesResponse { repeated PackageRuntimeStatus runtimes = 1; }
message ListActivationsResponse { repeated Activation activations = 1; }
message CloseActivationRequest { string activation_id = 1; string reason = 2; }
message CloseActivationResponse { Activation activation = 1; }
message ListPackageRuntimesResponse {
repeated PackageRuntimeStatus runtimes = 1;
}
message ListActivationsResponse {
repeated Activation activations = 1;
}
message CloseActivationRequest {
string activation_id = 1;
string reason = 2;
}
message CloseActivationResponse {
Activation activation = 1;
}
message Activation {
string activation_id = 1;
+15 -1
View File
@@ -5,6 +5,16 @@ package quixos;
message CapabilityRef {
string interface_revision_id = 1;
string operation_id = 2;
// Optional fence for a view acquired through TryConform. Not an authority grant.
ConformanceWitness conformance = 3;
}
message ConformanceWitness {
string object_id = 1;
string interface_revision_id = 2;
string conformance_id = 3;
string workspace_revision_id = 4;
string workspace_epoch = 5;
}
message PackageExportRef {
@@ -17,9 +27,13 @@ message InjectedDependency {
oneof binding {
string state_slot_id = 2;
EdgeDependency edge = 3;
string receiver_interface_revision_id = 4;
string interface_revision_id = 4;
string constructor_atom_id = 5;
string query_id = 7;
}
// 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 {
+30 -1
View File
@@ -9,13 +9,39 @@ service PackageRuntime {
rpc Handshake(HandshakeRequest) returns (HandshakeResponse);
rpc Invoke(InvokeRequest) returns (InvokeResponse);
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 {
string package_revision_id = 1;
string runtime_protocol_version = 2;
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 {
@@ -24,11 +50,13 @@ message InvokeRequest {
string object_id = 3;
map<string, camino.Value> input = 4;
repeated quixos.InjectedDependency dependencies = 5;
InvocationContext context = 6;
}
message InvokeResponse {
bool ok = 1;
camino.Value result = 2;
string error = 3;
repeated DerivedDependency dependencies = 4;
}
message WatchRequest {
@@ -37,6 +65,7 @@ message WatchRequest {
string object_id = 3;
map<string, camino.Value> input = 4;
repeated quixos.InjectedDependency dependencies = 5;
InvocationContext context = 6;
}
message DerivedDependency {
+52
View File
@@ -0,0 +1,52 @@
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"));
}
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises";
import { generateTypeScriptBindings } from "./index.js";
import path from "node:path";
import { reactPlatformTypes } from "./react-platform.js";
import { generateReactBindings } from "./react.js";
const main = async () => {
const [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 config = options ? JSON.parse(await readFile(options, "utf8")) : {};
const contracts = JSON.parse(await readFile(schema, "utf8"));
const generated = generateTypeScriptBindings(contracts, revision, config);
await writeFile(output, generated);
if (config.react) {
await writeFile(path.join(path.dirname(output), "web-studio-react-runtime.d.ts"), reactPlatformTypes);
await writeFile(
path.join(path.dirname(output), "react-props.gen.ts"),
generateReactBindings(contracts, revision, config.react.propsExports, config.react.components),
);
}
};
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
+105
View File
@@ -0,0 +1,105 @@
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>,
qualified = false,
) => {
if (interfaces.some((entry) => entry.template))
throw new Error("Host contracts require closed interface applications, not generic definitions");
const type = (value: ValueType): string => {
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 qualified
? `CapabilityReference<${JSON.stringify(value.expectation.kind === "atom" ? `atom:${value.expectation.atomId}` : `interface:${value.expectation.interfaceRevisionId}`)}>`
: `{readonly $quixosRef: string}`;
case "optional":
return `(${type(value.value)} | null)`;
case "list":
return `Array<${type(value.value)}>`;
case "record":
return `{${Object.entries(value.fields)
.map(([name, field]) => `${JSON.stringify(name)}${field.kind === "optional" ? "?" : ""}: ${type(field)}`)
.join("; ")}}`;
case "message": {
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 })),
),
);
if (qualified) {
const contracts = interfaces.map((iface) => {
const members = iface.members.flatMap((member) =>
member.operations
.filter((op) => op.mode === "call")
.map((op) => ` ${JSON.stringify(op.id)}: {input: ${type(op.inputType)}; output: ${type(op.outputType)}};`),
);
return `${JSON.stringify(iface.revisionId)}: {\n${members.join("\n")}\n}`;
});
if (new Set(interfaces.map((iface) => iface.revisionId)).size !== interfaces.length)
throw new Error("Duplicate closed interface identity");
return (
`// Generated closed capability contracts. Dispatch by interface AND operation.\n` +
`declare const referenceType: unique symbol;\nexport type CapabilityReference<T extends string> = {readonly $quixosRef: string; readonly [referenceType]: T};\n` +
`export type CapabilityContracts = {${contracts.join(";\n")}};\n` +
`export type CapabilityInput<I extends keyof CapabilityContracts, O extends keyof CapabilityContracts[I]> = CapabilityContracts[I][O] extends {input: infer T} ? T : never;\n` +
`export type CapabilityOutput<I extends keyof CapabilityContracts, O extends keyof CapabilityContracts[I]> = CapabilityContracts[I][O] extends {output: infer T} ? T : never;\n` +
`export const capabilityApplications = ${JSON.stringify(Object.fromEntries(interfaces.map((iface) => [iface.revisionId, { definitionId: iface.application?.definitionId ?? iface.revisionId, arguments: iface.application?.arguments ?? [], ...(iface.application?.self ? { self: iface.application.self } : {}) }])))} as const;\n`
);
}
if (new Set(operations.map((entry) => entry.id)).size !== operations.length)
throw new Error(
"Host operation IDs are ambiguous across interfaces; select a closed interface application explicitly",
);
return (
`// Generated from checked QX interfaces. Regenerate with scripts/generate-platform-contracts.mjs.\n` +
`export type PlatformInputs = {\n${operations.map((operation) => ` ${JSON.stringify(operation.id)}: ${type(operation.inputType)};`).join("\n")}\n};\n` +
`export const platformOperations = ${JSON.stringify(
Object.fromEntries(
operations.map((operation) => [
operation.id,
{
interfaceRevisionId: operation.interfaceRevisionId,
input:
operation.inputType.kind === "builtin" && operation.inputType.name === "unit"
? "unit"
: ["record", "message"].includes(operation.inputType.kind)
? "fields"
: "value",
},
]),
),
null,
2,
)} as const;\n`
);
};
/** New consumers use exact closed interface identities; operation IDs alone are not unique. */
export const generateAppliedClientContracts = (
interfaces: InterfaceRevision[],
messages: Record<string, string> = {},
) => generateClientContracts(interfaces, messages, true);
+177
View File
@@ -0,0 +1,177 @@
import type { GenericPackageExport, GenericDependencyPort } from "../capability-model/generic-packages.js";
import type { InterfaceRevision, ValueType } from "../capability-model/types.js";
import type {
ValueTypeExpression,
ObjectTypeExpression,
TypeArgumentExpression,
TypeParameter,
ValueAliasDefinition,
} from "../capability-model/generics.js";
/** Universal source contracts. Only closed exports get executable codecs. */
export const genericImplementationType = (
definition: GenericPackageExport,
interfaces: InterfaceRevision[],
concrete: (type: ValueType) => string,
queryPort: (requirement: Extract<GenericDependencyPort["requirement"], { kind: "query" }>) => string = () => {
throw new Error("Query binding schema required");
},
): string => {
const names = new Map(definition.parameters.map((parameter, index) => [parameter.id, `T${index}`]));
type Scope = { arguments: Map<string, string>; aliases: ValueAliasDefinition[] };
const rootScope = (): Scope => ({ arguments: new Map(), aliases: definition.aliases });
const object = (entries: [string, string][]) =>
`{${entries.map(([key, value]) => `${JSON.stringify(key)}:${value}`).join(";")}}`;
const target = (type: ObjectTypeExpression, scope: Scope): string => {
if (type.kind === "parameter") {
const bound = scope.arguments.get(type.parameterId);
if (bound) return bound;
const name = names.get(type.parameterId);
if (!name) throw new Error(`Unbound object parameter ${type.parameterId}`);
return name;
}
if (type.kind === "atom") return JSON.stringify(`atom:${type.atomId}`);
if (type.kind === "interface") return JSON.stringify(`interface:${type.interfaceRevisionId}`);
if (type.kind === "application")
return `QxApplied<${JSON.stringify(type.application.definitionId)}, [${type.application.arguments.map((arg) => argument(arg, scope)).join(",")}]>`;
throw new Error("Generic package Self must be expressed as an explicit object parameter");
};
const argument = (arg: TypeArgumentExpression, scope: Scope): string =>
arg.kind === "value" ? value(arg.type, scope) : target(arg.target, scope);
const bind = (parameters: TypeParameter[], args: TypeArgumentExpression[], scope: Scope): Scope => {
if (parameters.length !== args.length) throw new Error("Wrong generic arity during code generation");
const result = new Map(scope.arguments);
// Render arguments in their original lexical scope before introducing binders.
const rendered = args.map((arg) => argument(arg, scope));
parameters.forEach((parameter, index) => result.set(parameter.id, rendered[index]));
return { ...scope, arguments: result };
};
const value = (type: ValueTypeExpression, scope: Scope, depth = 0): string => {
if (depth > 128) throw new Error("Generic type expansion exceeds depth limit");
switch (type.kind) {
case "parameter": {
const bound = scope.arguments.get(type.parameterId);
if (bound) return bound;
const name = names.get(type.parameterId);
if (!name) throw new Error(`Unbound value parameter ${type.parameterId}`);
return name;
}
case "object-ref":
return `QxObjectRef<${target(type.expectation, scope)}>`;
case "record":
return object(Object.entries(type.fields).map(([name, field]) => [name, value(field, scope, depth + 1)]));
case "optional":
return `(${value(type.value, scope, depth + 1)} | null)`;
case "list":
return `Array<${value(type.value, scope, depth + 1)}>`;
case "alias": {
const alias = scope.aliases.find((entry) => entry.id === type.definitionId);
if (!alias) throw new Error(`Missing alias ${type.definitionId}`);
return value(alias.body, bind(alias.parameters, type.arguments, scope), depth + 1);
}
default:
return concrete(type);
}
};
const params = (type: ValueTypeExpression, scope: Scope) =>
type.kind === "builtin" && type.name === "unit" ? "" : `input:${value(type, scope)}`;
const port = (entry: GenericDependencyPort): string => {
const requirement = entry.requirement,
scope = rootScope();
switch (requirement.kind) {
case "state":
return object([
...requirement.primitives.map((primitive): [string, string] => {
if (primitive === "read") return ["get", `()=>Promise<${value(requirement.valueType, scope)}>`];
if (primitive === "write") return ["set", `(value:${value(requirement.valueType, scope)})=>Promise<void>`];
throw new Error(`Unsupported generic state primitive ${primitive}`);
}),
...(requirement.primitives.includes("read")
? [["live", "()=>Promise<QxLiveValue>"] as [string, string]]
: []),
]);
case "edge": {
const ref = `QxObjectRef<${target(requirement.target, scope)}>`;
const methods = requirement.primitives.map((primitive): [string, string] => {
if (primitive === "resolve") return ["resolve", `()=>Promise<Array<${ref}>>`];
if (primitive === "connect" || primitive === "disconnect")
return [primitive, `(target:${ref})=>Promise<void>`];
throw new Error(`Unsupported generic edge primitive ${primitive}`);
});
if (requirement.primitives.includes("resolve"))
methods.push(["collection", `()=>Promise<RelationshipCollection<${ref}>>`]);
if (
["resolve", "connect", "disconnect"].every((primitive) =>
requirement.primitives.includes(primitive as "resolve"),
)
)
methods.push([
"replace",
`(entries:RelationshipEntry<${ref}>[],expectedRevision:bigint)=>Promise<RelationshipCollection<${ref}>>`,
]);
return object(methods);
}
case "query":
return queryPort(requirement);
case "constructor":
return object([
[
"construct",
`(${params(requirement.inputType, scope)})=>Promise<QxObjectRef<${target(requirement.target, scope)}>>`,
],
]);
case "interface": {
const contract = interfaces.find((entry) => entry.revisionId === requirement.application.definitionId);
if (!contract) throw new Error(`Missing generic port contract ${requirement.application.definitionId}`);
const local = {
...bind(contract.template?.parameters ?? [], requirement.application.arguments, scope),
aliases: contract.template?.aliases ?? [],
};
const operations = (contract.template?.members ?? contract.members).flatMap((member) =>
member.operations
.filter((op) => op.mode === "call" && op.scope !== "class")
.map((op) => ({ ...op, name: `${member.displayName}.${op.displayName}` })),
);
const methods: [string, string][] = [
["objectId", `QxObjectRef<${target({ kind: "application", application: requirement.application }, scope)}>`],
["live", object(operations.map((op) => [op.name, `(${params(op.inputType, local)})=>Promise<QxLiveValue>`]))],
...operations.map(
(op) =>
[op.name, `(${params(op.inputType, local)})=>Promise<${value(op.outputType, local)}>`] as [
string,
string,
],
),
];
return object([...methods, ["contract", `QxInterfaceContract<${object(methods)}>`]]);
}
}
};
const declarations = definition.parameters
.map((parameter) => `${names.get(parameter.id)}${parameter.kind === "object" ? " extends string" : ""}`)
.join(",");
const receiver = definition.receiverRequirement;
const context = object([
["conform", "QxConformer"],
...(definition.kind === "function"
? []
: [
[
"objectId",
`QxObjectRef<${receiver.kind === "target" ? target(receiver.target, rootScope()) : "string"}>`,
] as [string, string],
]),
["input", value(definition.inputType, rootScope())],
["ports", object(definition.dependencyPorts.map((entry) => [entry.displayName, port(entry)]))],
]);
const contextWithLifecycle =
definition.kind === "function"
? `${context} & {signal?: AbortSignal}`
: `${context} & QxContextLifecycle<${context} & {signal?: AbortSignal}>`;
const result = value(definition.eventType ?? definition.outputType, rootScope());
const handler = `<${declarations}>(context:${contextWithLifecycle})=>${result}|Promise<${result}>`;
const derived = `{kind:"derived";get:${handler}}`;
return definition.eventType
? derived
: `(${handler})${definition.kind === "operation" && definition.mode === "call" ? ` | ${derived}` : ""}`;
};
+408
View File
@@ -0,0 +1,408 @@
import type {
InterfaceRevision,
PackageRevision,
ValueType,
DependencyPort,
WorkspaceRevision,
} from "../capability-model/types.js";
import type { CompiledCapabilityResourceRepository } from "../capability-language/assembly.js";
import { genericImplementationType } from "./generics.js";
import { capabilityId } from "../capability-model/types.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[];
interfaceTemplates?: InterfaceRevision[];
packages: PackageRevision[];
};
export const bindingSchema = (compiled: Pick<CompiledCapabilityResourceRepository, "resources">): BindingSchema => ({
format: "quixos-bindings",
version: 1,
interfaceTemplates: compiled.resources.flatMap((node) =>
node.resource.kind === "interface" && node.resource.revision.template ? [node.resource.revision] : [],
),
interfaces: [
...new Map(
compiled.resources
.flatMap((node) => [
...(node.resource.kind === "interface" ? [node.resource.revision] : []),
...(node.resource.specializations ?? []),
])
.filter((entry) => !entry.template)
.map((entry) => [entry.revisionId, entry]),
).values(),
],
packages: compiled.resources.flatMap((node) => (node.resource.kind === "package" ? [node.resource.revision] : [])),
});
export const specializeBindingSchema = (
base: BindingSchema,
workspace: WorkspaceRevision,
revisionId: string,
): BindingSchema => {
const pkg = workspace.packageImports.find((entry) => entry.revisionId === revisionId);
if (!pkg) throw new Error(`Missing candidate package ${revisionId}`);
const needed = new Set<string>();
const visit = (value: unknown): void => {
if (!value || typeof value !== "object") return;
if ("interfaceRevisionId" in value && typeof value.interfaceRevisionId === "string")
needed.add(value.interfaceRevisionId);
Object.values(value).forEach(visit);
};
visit(pkg.exports);
const interfaces = new Map<string, InterfaceRevision>(base.interfaces.map((entry) => [entry.revisionId, entry]));
for (const id of needed) {
const iface = workspace.interfaceImports.find((entry) => entry.revisionId === id);
if (iface) {
interfaces.set(id, iface);
visit(iface.members);
}
}
return {
...base,
interfaces: [...interfaces.values()],
packages: base.packages.map((entry) => {
const candidate = workspace.packageImports.find((value) => value.revisionId === entry.revisionId);
if (!candidate) throw new Error(`Missing dependency package ${entry.revisionId}`);
return candidate;
}),
};
};
export type TypeScriptBindingOptions = {
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");
if (schema.interfaces.some((entry) => entry.template))
throw new Error("Package bindings require closed interface applications, not generic definitions");
const pkg = schema.packages.find((entry) => entry.revisionId === packageRevisionId);
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 "query": {
const query = schema.packages
.find((candidate) => candidate.revisionId === requirement.packageRevisionId)
?.checkedQueries?.find((query) => query.declaration.id === requirement.queryId);
if (!query) throw new Error(`Missing checked query ${requirement.packageRevisionId}:${requirement.queryId}`);
return {
type: object([
["execute", `(variables: ${type(query.variables)}) => Promise<${type(query.output)}>`],
...(query.declaration.watch
? ([
[
"watch",
`(variables: ${type(query.variables)}, signal: AbortSignal) => AsyncIterable<QxQuerySnapshot<${type(query.output)}>>`,
],
] as [string, string][])
: []),
]),
spec: {
kind: "query",
id: entry.id,
definitionDigest: query.definitionDigest,
variables: query.variables,
output: query.output,
watch: query.declaration.watch,
},
};
}
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" && operation.scope !== "class")
.map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` })),
);
const methods: [string, string][] = [
["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)}>`,
]),
];
return {
type: object([...methods, ["contract", `QxInterfaceContract<${object(methods)}>`]]),
spec: {
kind: "interface",
id: entry.id,
interfaceRevisionId: requirement.interfaceRevisionId,
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][] = [];
const results: [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([
["conform", "QxConformer"],
...(entry.kind === "function" ? [] : [["objectId", receiver] as [string, string]]),
["input", type(entry.inputType)],
["ports", object(ports.map((port) => [port.name, port.type]))],
]);
contexts.push([
entry.displayName,
entry.kind === "function"
? `${contextShape} & {signal?: AbortSignal}`
: `${contextShape} & QxContextLifecycle<${contextShape} & {signal?: AbortSignal}>`,
]);
const event = entry.kind === "operation" ? entry.eventType : undefined;
const contextType = `Contexts[${q(entry.displayName)}]`;
const outputType = type(event ?? entry.outputType);
results.push([entry.displayName, outputType]);
// Watch-start handlers produce events through the runtime's derived stream protocol.
if (!entry.application)
handlers.push([
entry.displayName,
event
? `QxDerived<${contextType}, ${outputType}>`
: `QxHandler<${contextType}, ${outputType}>${entry.kind === "operation" && entry.mode === "call" ? ` | QxDerived<${contextType}, ${outputType}>` : ""}`,
]);
specs[entry.displayName] = {
...(entry.kind === "function" ? { receiver: "none" } : {}),
inputType: entry.inputType,
outputType: entry.outputType,
...(event ? { eventType: event } : {}),
ports: Object.fromEntries(ports.map((port) => [port.name, port.spec])),
};
}
for (const definition of pkg.genericExports ?? []) {
handlers.push([
definition.displayName,
genericImplementationType(
definition,
[...schema.interfaces, ...(schema.interfaceTemplates ?? [])],
type,
(requirement) => port({ id: capabilityId.dependencyPort("query"), displayName: "query", requirement }).type,
),
]);
}
// Unused imported interfaces must not introduce new message-codec obligations.
// A used port still fails above if its own required codec is missing.
const hasCodec = (value: ValueType): boolean => {
if (value.kind === "message") return !!options.messages?.[value.descriptorId];
if (value.kind === "record") return Object.values(value.fields).every(hasCodec);
if (value.kind === "list" || value.kind === "optional") return hasCodec(value.value);
return true;
};
const contracts = schema.interfaces
.filter((iface) =>
iface.members.every((member) =>
member.operations
.filter((operation) => operation.mode === "call" && operation.scope !== "class")
.every((operation) => hasCodec(operation.inputType) && hasCodec(operation.outputType)),
),
)
.map((iface) => {
const generated = port({
id: capabilityId.dependencyPort("contract"),
displayName: iface.displayName,
requirement: { kind: "interface", interfaceRevisionId: iface.revisionId },
});
const spec = generated.spec as { operations: unknown };
return {
iface,
type: generated.type,
code: `defineQxInterfaceContract<${generated.type}>(${q(iface.revisionId)}, ${JSON.stringify(spec.operations)})`,
};
});
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 queryVariables = object(
(pkg.checkedQueries ?? []).map((query) => [query.declaration.displayName, type(query.variables)]),
);
const queryResults = object(
(pkg.checkedQueries ?? []).map((query) => [query.declaration.displayName, type(query.output)]),
);
const queryDescriptors = object(
(pkg.checkedQueries ?? []).map((query) => [
query.declaration.displayName,
`QxQueryDescriptor<QueryVariables[${q(query.declaration.displayName)}], QueryResults[${q(query.declaration.displayName)}], ${q(query.declaration.root)}>`,
]),
);
const signatures = `${object(contexts)} ${object(handlers)} ${object(results)} ${queryVariables} ${queryResults} ${queryDescriptors} ${contracts.map((entry) => entry.type).join(" ")}`;
const typeImports = [
"BindingValue",
"QxObjectRef",
"QxWatchHandle",
"QxHandler",
"QxDerived",
"QxContextLifecycle",
"QxConformer",
"QxInterfaceContract",
"QxLiveValue",
"RelationshipCollection",
"RelationshipEntry",
"QxQuerySnapshot",
"QxQueryDescriptor",
].filter((name) => new RegExp(`\\b${name}\\b`).test(signatures));
return (
`// Generated by quixos-codegen-ts. Do not edit. Binding ABI version 1.\n` +
`import { ${contracts.length ? "defineQxInterfaceContract, " : ""}${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` +
`declare const appliedType: unique symbol;\ntype QxApplied<Definition extends string, Arguments extends readonly unknown[]> = string & {readonly [appliedType]: (value: [Definition, Arguments]) => [Definition, Arguments]};\n` +
`export type Contexts = ${object(contexts)};\nexport type Results = ${object(results)};\nexport type Implementation = ${object(handlers)};\n` +
`export type QueryVariables = ${queryVariables};\nexport type QueryResults = ${queryResults};\n` +
`export const queries: ${queryDescriptors} = ${JSON.stringify(Object.fromEntries((pkg.checkedQueries ?? []).map((query) => [query.declaration.displayName, { id: `${pkg.revisionId}:${query.declaration.id}`, definitionDigest: query.definitionDigest, rootInterfaceRevisionId: query.declaration.root, variables: query.variables, output: query.output, watch: query.declaration.watch }])), null, 2)} as const;\n` +
`export const contracts = {${contracts.map((entry) => `${q(entry.iface.revisionId)}: ${entry.code}`).join(",\n")}};\n` +
`export const interfaces = {${contracts
.filter((entry) => contracts.filter((other) => other.iface.displayName === entry.iface.displayName).length === 1)
.map((entry) => `${q(entry.iface.displayName)}: contracts[${q(entry.iface.revisionId)}]`)
.join(",\n")}};\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.application ? pkg.genericExports!.find((definition) => definition.id === entry.application!.exportId)!.displayName : entry.displayName)}], messages),`,
)
.join("\n") +
`\n },\n});\n`
);
};
+62
View File
@@ -0,0 +1,62 @@
/** One authoring contract, distributed by scaffolding and immutable codegen.
* Web Studio checks its concrete exports against this contract during its build. */
export const reactPlatformTypes = `// Generated by the Quixos React platform contract. Do not edit.
declare module "@quixos/web-studio-react-runtime" {
import type * as React from "react";
export function useComponentOverlayContainer(): HTMLElement;
export function useComponentStyleRoot(): ShadowRoot;
export type ObjectRef<AtomId extends string> = string & {
readonly $quixosAtom: AtomId;
};
export type FieldCapability = {objectId: string; interfaceRevisionId: string; getOperationId: string; watchOperationId?: string; setOperationId?: string; inputKind?: "fields" | "value"};
export type ReadableField<T> = {readonly value?: T; readonly capability: FieldCapability};
/** set(T) uses the resolved capability: native CRDT fields send incremental edits;
* custom/manual setters receive semantic values. Storage provenance grants no authority. */
export type WritableField<T> = ReadableField<T> & {readonly $writeType?: (value: T) => T; readonly writable: true; readonly capability: FieldCapability & {setOperationId: string}};
export type LiveFieldProp<T> = ReadableField<T>;
export type InterfaceReference<I extends string, Fields> = {readonly $quixosRef: string; readonly interfaceRevisionId: I; readonly fields: Fields};
export type ReactInterfaceContract<View> = {readonly interfaceRevisionId: string; readonly $viewType?: (view: View) => View};
export function defineReactInterfaceContract<View>(interfaceRevisionId: string, interfaces: unknown): ReactInterfaceContract<View>;
export function tryConform<View>(object: string | {readonly $quixosRef: string}, contract: ReactInterfaceContract<View>, options?: {signal?: AbortSignal}): Promise<View | undefined>;
export type ConformanceResult<View> = {status: "loading"} | {status: "absent"} | {status: "available"; view: View} | {status: "error"; error: Error};
export function useTryConform<View>(object: string | {readonly $quixosRef: string}, contract: ReactInterfaceContract<View>): ConformanceResult<View>;
export type QueryValueType = {kind: "builtin" | "scalar"; name: string} | {kind: "record"; fields: Record<string, QueryValueType>} | {kind: "optional" | "list"; value: QueryValueType} | {kind: "object-ref"; expectation: {kind: "atom"; atomId: string} | {kind: "interface"; interfaceRevisionId: string}};
export type QueryReference<Contract extends string = string> = {readonly $quixosRef: string; readonly queryContract: Contract};
export interface QueryDescriptor<Variables, Result> {readonly id: string; readonly definitionDigest: string; readonly rootInterfaceRevisionId: string; readonly variables: QueryValueType; readonly output: QueryValueType; readonly watch: boolean; readonly $types?: (variables: Variables, result: Result) => [Variables, Result]}
export type QueryPartial<T> = T extends QueryReference ? T : T extends readonly (infer Item)[] ? QueryPartial<Item>[] : T extends object ? {[Key in keyof T]?: QueryPartial<T[Key]>} : T;
export type QueryFieldState = {path: readonly (string | number)[]; status: "pending" | "error"; error?: string};
export type QueryState<T> = {refreshing: boolean; fields: QueryFieldState[]; runId?: string; sequence?: bigint; consistency?: string} & ({status: "loading"; data?: undefined; error?: undefined} | {status: "ready"; data: T; error?: undefined} | {status: "partial"; data: QueryPartial<T>; error?: undefined} | {status: "error"; data?: T | QueryPartial<T>; error: Error});
export function useQuery<Variables, Result>(descriptor: QueryDescriptor<Variables, Result>, options: {root: string | {readonly $quixosRef: string}; variables: Variables}): QueryState<Result> & {refresh(): void};
export interface QueryConnection<Row> {entries: {key: string; node: Row; cursor?: string | null}[]; pageInfo: {hasNextPage: boolean; endCursor?: string | null}}
export function CollectionView<Result, Row>(props: {result: QueryState<Result> & {refresh(): void}; connection(data: QueryPartial<Result>): QueryPartial<QueryConnection<Row>> | undefined; renderItem(row: QueryPartial<Row>, entryKey: string): React.ReactNode; onCreate?: () => void; onLoadMore?: (cursor: string | null) => void; onReset?: () => void; loadingMore?: boolean; empty?: React.ReactNode; prefetch?: boolean}): React.ReactElement;
export type ReactComponentHostProps<Action> = {
onAction?: (action: Action) => void;
fallback?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
/** Optional extra action. The host still displays and reports errors by default. */
extraErrorAction?: (error: Error) => void;
};
export type ReactComponentImplementationProps<CaminoProps, RenderProps, Action> = {
camino: CaminoProps;
render: RenderProps;
dispatch: (action: Action) => void;
};
export const createWebStudioComponent: <
ForObject extends ObjectRef<string>, RenderProps extends object, Action,
>(config: { expectedAtomId: string }) => (props: {
forObject: ForObject;
} & RenderProps & ReactComponentHostProps<Action>) => any;
export const invokeCapability: <Result = unknown>(
objectId: string,
interfaceRevisionId: string,
operationId: string,
value?: unknown,
options?: {clientMutationId?: string; signal?: AbortSignal},
) => Promise<Result>;
export const h: typeof React.createElement;
export function useLiveField<T>(field: ReadableField<T>, options: {write: (value: T) => Promise<void>}): readonly [T, (value: T) => Promise<void>];
export function useLiveField<T>(field: WritableField<T>): readonly [T, (value: T) => Promise<void>];
export function useLiveField<T>(field: ReadableField<T>): readonly [T];
}
`;
+162
View File
@@ -0,0 +1,162 @@
import type { BindingSchema } from "./index.js";
import type { ValueType } from "../capability-model/types.js";
/** Browser projection of the SAME checked RPC types, not a second props schema. */
export function generateReactBindings(
schema: BindingSchema,
packageRevisionId: string,
propsExports: readonly string[],
components: readonly { module: string; propsExport: string }[] = [],
) {
const pkg = schema.packages.find((entry) => entry.revisionId === packageRevisionId);
if (!pkg) throw new Error(`Unknown package ${packageRevisionId}`);
const q = JSON.stringify;
const references = new Map<string, string>();
const declarations: string[] = [];
const type = (value: ValueType): string => {
switch (value.kind) {
case "builtin":
if (value.name !== "unit") throw new Error(`Unsupported React props builtin ${value.name}`);
return "null";
case "scalar":
return {
string: "string",
bool: "boolean",
bytes: "Uint8Array",
int64: "bigint",
uint64: "bigint",
int32: "number",
uint32: "number",
double: "number",
}[value.name];
case "optional":
return `(${type(value.value)} | null)`;
case "list":
return `Array<${type(value.value)}>`;
case "record":
return `{${Object.entries(value.fields)
.map(([name, field]) => `${q(name)}: ${type(field)}`)
.join(";")}}`;
case "message":
throw new Error(`React props require checked values, not opaque message ${value.descriptorId}`);
case "object-ref": {
if (value.expectation.kind === "atom") return `ObjectRef<${q(value.expectation.atomId)}>`;
const id = value.expectation.interfaceRevisionId;
const existing = references.get(id);
if (existing) return existing;
const name = `Interface${references.size}`;
references.set(id, name);
const iface = schema.interfaces.find((entry) => entry.revisionId === id);
if (!iface) throw new Error(`Missing React reference contract ${id}`);
const fields = iface.members.flatMap((member) => {
if (member.kind === "operation") return [];
const get = member.operations.find((op) => op.displayName === (member.kind === "value" ? "get" : "resolve"));
if (!get) return [];
const writable = member.kind === "value" && member.operations.some((op) => op.displayName === "set");
return [`${q(member.displayName)}: ${writable ? "WritableField" : "ReadableField"}<${type(get.outputType)}>`];
});
declarations.push(`export type ${name} = InterfaceReference<${q(id)}, {${fields.join(";")}}> ;`);
return name;
}
}
};
// Only exports explicitly selected as props are projected. Non-props exports
// may legitimately use opaque messages or types unrelated to the browser.
const selected = propsExports.map((name) => {
const entry = pkg.exports.find((candidate) => candidate.displayName === name);
if (!entry) throw new Error(`Unknown React props export ${name}`);
return entry;
});
const results = selected.map(
(entry) =>
`${q(entry.displayName)}: ${type(entry.kind === "operation" ? (entry.eventType ?? entry.outputType) : entry.outputType)}`,
);
const checks = components.map((component, i) => {
if (!propsExports.includes(component.propsExport))
throw new Error(`Component refers to unselected props export ${component.propsExport}`);
if (!component.module.startsWith("."))
throw new Error("React component check must name a local module relative to generated bindings");
return `type Component${i} = CheckedComponent<${q(component.propsExport)}, typeof import(${q(component.module)})["default"]>;`;
});
// Emit optional discovery descriptors only when the browser has all codecs.
// Unrelated opaque-message imports must not break an otherwise checked UI.
const browserValue = (value: ValueType, seen: Set<string>): boolean => {
if (value.kind === "message") return false;
if (value.kind === "builtin") return value.name === "unit";
if (value.kind === "record") return Object.values(value.fields).every((field) => browserValue(field, seen));
if (value.kind === "list" || value.kind === "optional") return browserValue(value.value, seen);
if (value.kind === "object-ref" && value.expectation.kind === "interface") {
const id = value.expectation.interfaceRevisionId;
if (seen.has(id)) return true;
const contract = schema.interfaces.find((entry) => entry.revisionId === id);
return !!contract && browserInterface(contract, new Set([...seen, id]));
}
return true;
};
const browserInterface = (iface: (typeof schema.interfaces)[number], seen: Set<string>): boolean =>
iface.members.every((member) =>
member.operations
.filter((op) => op.mode === "call" && op.scope !== "class")
.every((op) => browserValue(op.inputType, seen) && browserValue(op.outputType, seen)),
);
const contracts = schema.interfaces
.filter((iface) => browserInterface(iface, new Set([iface.revisionId])))
.map((iface) => {
const reference = type({
kind: "object-ref",
expectation: { kind: "interface", interfaceRevisionId: iface.revisionId },
});
const calls = iface.members.flatMap((member) =>
member.operations
.filter((operation) => operation.mode === "call" && operation.scope !== "class")
.map(
(operation) =>
`${q(`${member.displayName}.${operation.displayName}`)}: (${operation.inputType.kind === "builtin" && operation.inputType.name === "unit" ? "" : `input: ${type(operation.inputType)}`}) => Promise<${type(operation.outputType)}>`,
),
);
return { iface, view: `${reference} & {call: {${calls.join(";")}}}` };
});
const queryType = (value: ValueType): string => {
if (value.kind === "object-ref")
return `QueryReference<${q(value.expectation.kind === "atom" ? value.expectation.atomId : value.expectation.interfaceRevisionId)}>`;
if (value.kind === "optional") return `(${queryType(value.value)} | null)`;
if (value.kind === "list") return `Array<${queryType(value.value)}>`;
if (value.kind === "record")
return `{${Object.entries(value.fields)
.map(([name, field]) => `${q(name)}: ${queryType(field)}`)
.join(";")}}`;
return type(value);
};
const queries = pkg.checkedQueries ?? [];
const queryCode =
`export type QueryVariables = {${queries.map((query) => `${q(query.declaration.displayName)}: ${queryType(query.variables)}`).join(";")}};\n` +
`export type QueryResults = {${queries.map((query) => `${q(query.declaration.displayName)}: ${queryType(query.output)}`).join(";")}};\n` +
`export const queries: {${queries.map((query) => `${q(query.declaration.displayName)}: QueryDescriptor<QueryVariables[${q(query.declaration.displayName)}], QueryResults[${q(query.declaration.displayName)}]>`).join(";")}} = ${JSON.stringify(
Object.fromEntries(
queries.map((query) => [
query.declaration.displayName,
{
id: `${pkg.revisionId}:${query.declaration.id}`,
definitionDigest: query.definitionDigest,
rootInterfaceRevisionId: query.declaration.root,
variables: query.variables,
output: query.output,
watch: query.declaration.watch,
},
]),
),
)};\n`;
return (
`// Generated from checked QX contracts. Do not edit.\nimport {defineReactInterfaceContract} from "@quixos/web-studio-react-runtime";\nimport type {ObjectRef, InterfaceReference, ReadableField, WritableField, QueryDescriptor, QueryReference} from "@quixos/web-studio-react-runtime";\n${declarations.join("\n")}\nexport type ReactResults = {${results.join(";\n")}};\n` +
queryCode +
`const contractsData = ${JSON.stringify(schema.interfaces)};\n` +
`export const reactContracts = {${contracts.map(({ iface, view }) => `${q(iface.revisionId)}: defineReactInterfaceContract<${view}>(${q(iface.revisionId)}, contractsData)`).join(",\n")}};\n` +
`export const reactInterfaces = {${contracts
.filter(({ iface }) => contracts.filter((other) => other.iface.displayName === iface.displayName).length === 1)
.map(({ iface }) => `${q(iface.displayName)}: reactContracts[${q(iface.revisionId)}]`)
.join(",\n")}};\n` +
(checks.length
? `type CheckedComponent<K extends keyof ReactResults, C extends (props: {camino: ReactResults[K]; render: any; dispatch: (action: any) => void}) => unknown> = C;\n${checks.join("\n")}\n`
: "")
);
}
+422
View File
@@ -0,0 +1,422 @@
import { readFile, realpath } from "node:fs/promises";
import path from "node:path";
import { loadQxSources, resolveQxSources } from "./source-loader.js";
import { compileRepositoryQuery } from "../query/compile.js";
import { checkQueryTemplate } from "../query/templates.js";
import { readRepositorySource } from "./source-loader.js";
import { linkQueries } from "../query/link.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] : []),
...(node.resource.specializations ?? []),
]),
),
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") {
const queryInterfaces = [
...(environment.interfaceClosure ?? []),
...(compiled.resource.specializations ?? []),
];
for (const template of compiled.resource.revision.queryTemplates ?? [])
await checkQueryTemplate(template, queryInterfaces, (name) =>
readRepositorySource(snapshot.directory, name),
);
if (compiled.resource.revision.queries?.length)
compiled.resource.revision.checkedQueries = await Promise.all(
compiled.resource.revision.queries.map((query) =>
compileRepositoryQuery(snapshot.directory, query, queryInterfaces),
),
);
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")}`,
);
}
const linkedQueries = linkQueries(workspace);
if (linkedQueries.length) {
workspace.linkedQueries = linkedQueries;
checked.plan.source.linkedQueries = structuredClone(linkedQueries);
}
return {
workspace,
plan: checked.plan,
lock: rootLock,
resources: nodes,
directResources: new Map(directPairs.map(([locked, node]) => [bindingKey(locked.kind, locked.binding), node])),
};
};
+168
View File
@@ -0,0 +1,168 @@
import fs from "node:fs/promises";
import { appendFileSync } from "node:fs";
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,
};
const progress = async (running = true) => {
const file = path.join(output, "report.json"),
temp = `${file}.tmp`;
await fs.writeFile(temp, JSON.stringify({ ...report, timings, running }, null, 2));
await fs.rename(temp, file);
};
await progress();
try {
console.error(`[${new Date().toISOString()}] Check: capture source and converge dependencies`);
// Serialize only source capture, not the potentially slow Nix build.
// Repository-scoped agents can check separate immutable candidates in parallel.
const capture = promisify(callback)("quixos-qx", ["converge", context.workbench, directory], {
maxBuffer: 4 * 1024 * 1024,
env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_TRACE_CAPTURE: "1" },
});
console.error(`Capture details: ${path.join(output, "capture.log")}`);
capture.child.stderr?.on("data", (chunk) => appendFileSync(path.join(output, "capture.log"), chunk));
const captured = await capture.catch((error) => {
if (typeof error.stdout === "string" && error.stdout.trim().startsWith("{")) return { stdout: error.stdout };
throw error;
});
const converged = JSON.parse(captured.stdout) as Awaited<ReturnType<typeof convergeAuthoring>>;
timings.captureMs = Math.round(performance.now() - started);
console.error(`[${new Date().toISOString()}] Check: source captured in ${(timings.captureMs / 1000).toFixed(1)}s`);
if (!converged.candidate) {
report.phase = converged.worklist.find((entry) => entry.phase !== "dependency")?.phase ?? "convergence";
throw new Error(
converged.worklist.map((entry) => `${entry.directory} [${entry.phase}]: ${entry.message}`).join("\n"),
);
}
report.commit = converged.candidate.commit;
report.phase = "verification";
await progress();
const buildStarted = performance.now();
console.error(
`[${new Date().toISOString()}] Check: immutable Nix ${options.contractOnly ? "contract" : "verification"}; build output: ${path.join(output, "nix.log")}`,
);
try {
report.artifactPath = await buildImmutableCandidate(
converged.candidate,
resource.kind,
path.join(output, "nix.log"),
options.contractOnly,
);
} finally {
timings.immutableCheckMs = Math.round(performance.now() - buildStarted);
console.error(
`[${new Date().toISOString()}] Check: immutable phase ended after ${(timings.immutableCheckMs / 1000).toFixed(1)}s`,
);
}
const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8");
await fs.writeFile(path.join(output, "candidate.json"), candidateText);
report.compilation = "passed";
if (resource.kind === "workspace" && !options.contractOnly) {
report.phase = "evolution";
await progress();
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 progress(false);
if (options.contractOnly) return report;
const records = path.join(context.workbench, ".quixos/checks");
await fs.mkdir(records, { recursive: true });
const remember = async (value: typeof report) => {
const filename = path.join(records, checkRecordName(value.directory)),
temporary = `${filename}.${randomUUID()}.tmp`;
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,70 @@
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,250 @@
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[]) => {
const start = performance.now();
// Do not log arguments: transports may contain credentials. Source identities
// remain in the normal checked result, not in this timing channel.
const label = `${path.basename(cwd)} ${executable} ${args[0]}`;
try {
return (
await execFile(executable, args, {
cwd,
maxBuffer: 4 * 1024 * 1024,
env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0", GIT_TERMINAL_PROMPT: "0" },
})
).stdout.trim();
} finally {
if (process.env.QUIXOS_TRACE_CAPTURE === "1")
console.error(`[${new Date().toISOString()}] Capture: ${label}: ${Math.round(performance.now() - start)}ms`);
}
};
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 snapshotStarted = performance.now();
const commit = await snapshotCommit(root);
if (process.env.QUIXOS_TRACE_CAPTURE === "1")
console.error(
`[${new Date().toISOString()}] Capture: ${directory} snapshot: ${Math.round(performance.now() - snapshotStarted)}ms`,
);
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,113 @@
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,119 @@
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.",
};
}
+264
View File
@@ -0,0 +1,264 @@
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, specializeBindingSchema } 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(
specializeBindingSchema(bindingSchema(candidate), compiled.workspace, candidate.resource.revision.revisionId),
),
);
const artifactPath = await buildCheckedPackage(
resource.directory,
schema,
candidate.resource.revision.revisionId,
);
checks.push({ packageRevisionId: candidate.resource.revision.revisionId, artifactPath });
}
if ((await snapshotCommit(options.root)) !== commit)
throw new Error("Source changed during verification; run the check again");
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 });
}
};
+184
View File
@@ -0,0 +1,184 @@
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());
}),
);
});
}
+48 -20
View File
@@ -2,21 +2,23 @@
import { readFile } from "node:fs/promises";
import process from "node:process";
import { compileCapabilitySource } from "./parser.js";
import { compileCapabilityResourceSource, compileCapabilitySource } from "./parser.js";
import { capabilityId } from "../capability-model/types.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]
[--source-root-commit GIT_REV] <workspace.qx | ->
Parses, lowers, and validates a Quixos capability workspace. By default the
checked semantic workspace revision is written as JSON. --check emits no JSON.
Parses, lowers, and validates a Quixos capability workspace. --resource instead
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
real workspace; both must be supplied together.`;
const parseArgs = (args: string[]) => {
let checkOnly = false;
let resource = false;
let workspaceId: string | undefined;
let workspaceRevisionId: string | undefined;
let sourceRootCommit: string | undefined;
@@ -24,6 +26,7 @@ const parseArgs = (args: string[]) => {
for (let index = 0; index < args.length; index += 1) {
const argument = args[index]!;
if (argument === "--check") checkOnly = true;
else if (argument === "--resource") resource = true;
else if (argument === "--workspace-id") workspaceId = args[++index];
else if (argument === "--workspace-revision-id") workspaceRevisionId = 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 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);
}
return { checkOnly, workspaceId, workspaceRevisionId, sourceRootCommit, fileName: positional[0]! };
return { checkOnly, resource, workspaceId, workspaceRevisionId, sourceRootCommit, fileName: positional[0]! };
};
const main = async () => {
@@ -43,13 +50,7 @@ const main = async () => {
process.stdout.write(`${usage}\n`);
return;
}
const {
checkOnly,
workspaceId,
workspaceRevisionId,
sourceRootCommit,
fileName,
} = parseArgs(args);
const { checkOnly, resource, workspaceId, workspaceRevisionId, sourceRootCommit, fileName } = parseArgs(args);
const source =
fileName === "-"
? await new Promise<string>((resolve, reject) => {
@@ -59,20 +60,47 @@ const main = async () => {
process.stdin.on("error", reject);
})
: await readFile(fileName, "utf8");
const result = compileCapabilitySource(source, fileName);
if (!result.ok) {
for (const diagnostic of result.diagnostics) {
const reportDiagnostics = (
diagnostics: readonly {
fileName: string;
line: number;
column: number;
phase: string;
code: string;
message: string;
path?: string;
}[],
) => {
for (const diagnostic of diagnostics) {
const location = diagnostic.line
? `${diagnostic.fileName}:${diagnostic.line}:${diagnostic.column + 1}`
: `${diagnostic.fileName}${diagnostic.path ? `:${diagnostic.path}` : ""}`;
process.stderr.write(
`${location}: ${diagnostic.phase} ${diagnostic.code}: ${diagnostic.message}\n`,
);
process.stderr.write(`${location}: ${diagnostic.phase} ${diagnostic.code}: ${diagnostic.message}\n`);
}
process.exitCode = 1;
};
if (resource) {
const result = compileCapabilityResourceSource(source, {
source: {
repository: "https://compiler.invalid/resource.git",
commit: "0000000000000000000000000000000000000000",
},
fileName,
});
if (!result.ok) {
reportDiagnostics(result.diagnostics);
return;
}
const workspace = workspaceId && workspaceRevisionId
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;
}
const workspace =
workspaceId && workspaceRevisionId
? {
...result.workspace,
workspaceId: capabilityId.workspace(workspaceId),
+54
View File
@@ -0,0 +1,54 @@
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,253 @@
WORKSPACE=1
ATOM=2
INTERFACE=3
INTERFACES=4
PACKAGE=5
VALUE=6
RELATION=7
OPERATION=8
FUNCTION=9
CONSTRUCTOR=10
CONSTRUCTS=11
CONFORM=12
AS=13
BIND=14
TO=15
PRIVATE=16
SHARED=17
STATE=18
EDGE=19
PROJECTION=20
WITH=21
ON=22
POLICY=23
DEFAULT=24
SOURCE=25
REPOSITORY=26
COMMIT=27
REVISION=28
ID=29
DOC=30
MODE=31
EMITS=32
RECEIVER=33
REQUIRES=34
ANY=35
GET=36
SET=37
WATCH=38
START=39
STOP=40
READ=41
WRITE=42
RESOLVE=43
CONNECT=44
DISCONNECT=45
CALL=46
WATCH_START=47
WATCH_STOP=48
SUBSCRIBE=49
UNSUBSCRIBE=50
OPTIMISTIC_REGISTER=51
CRDT=52
OPTIONAL_ONE=53
EXACTLY_ONE=54
MANY_UNIQUE=55
MANY=56
ORDERED=57
UNIT=58
WATCH_HANDLE=59
MESSAGE=60
ATOM_REF=61
INTERFACE_REF=62
OPTIONAL=63
LIST=64
BOOL=65
BYTES=66
DOUBLE=67
INT32=68
INT64=69
STRING=70
UINT32=71
UINT64=72
TRUE=73
FALSE=74
NULL=75
ARROW=76
COLON=77
SEMI=78
COMMA=79
DOT=80
LBRACE=81
RBRACE=82
LBRACK=83
RBRACK=84
LPAREN=85
RPAREN=86
LT=87
GT=88
INTEGER=89
JSON_NUMBER=90
IDENTIFIER=91
STRING_LITERAL=92
LINE_COMMENT=93
BLOCK_COMMENT=94
WS=95
QUERY=2
SPECIALIZE=3
ROOT=4
DOCUMENT=5
FRAGMENTS=6
VIEW=7
MAX=8
ALLOW=9
POLL=10
QUERYABLE=11
RPC=12
QUERY_REASON=13
TYPE=14
OBJECT=15
STORABLE=16
IMPLEMENTS=17
REF=18
FRAGMENT=19
IMPORT=20
EXTERNAL=21
ATOM=22
INTERFACE=23
INTERFACES=24
PACKAGE=25
VALUE=26
RELATION=27
OPERATION=28
FUNCTION=29
CONSTRUCTOR=30
CONSTRUCTS=31
INPUT=32
CONFORM=33
AS=34
BIND=35
STATIC=36
TO=37
PRIVATE=38
SHARED=39
STATE=40
EDGE=41
PROJECTION=42
WITH=43
USING=44
VIA=45
MATERIALIZE=46
IF=47
ABSENT=48
ON=49
POLICY=50
DEFAULT=51
SOURCE=52
REPOSITORY=53
COMMIT=54
REVISION=55
SEMANTIC_MAJOR=56
ON_DELETE=57
RETAIN_OTHER=58
KEYED=59
PUBLIC_TRAVERSAL=60
ID=61
DOC=62
MODE=63
EMITS=64
RECEIVER=65
REQUIRES=66
ANY=67
GET=68
SET=69
WATCH=70
START=71
STOP=72
READ=73
WRITE=74
RESOLVE=75
CONNECT=76
DISCONNECT=77
CALL=78
WATCH_START=79
WATCH_STOP=80
SUBSCRIBE=81
UNSUBSCRIBE=82
OPTIMISTIC_REGISTER=83
CRDT=84
OPTIONAL_ONE=85
EXACTLY_ONE=86
MANY_UNIQUE=87
MANY=88
ORDERED=89
UNIT=90
WATCH_HANDLE=91
MESSAGE=92
ATOM_REF=93
INTERFACE_REF=94
OPTIONAL=95
LIST=96
RECORD=97
BOOL=98
BYTES=99
DOUBLE=100
INT32=101
INT64=102
STRING=103
UINT32=104
UINT64=105
TRUE=106
FALSE=107
NULL=108
ARROW=109
COLON=110
SEMI=111
COMMA=112
DOT=113
LBRACE=114
RBRACE=115
LBRACK=116
RBRACK=117
LPAREN=118
RPAREN=119
LT=120
GT=121
AMP=122
EQUAL=123
INTEGER=124
JSON_NUMBER=125
IDENTIFIER=126
STRING_LITERAL=127
LINE_COMMENT=128
BLOCK_COMMENT=129
WS=130
'workspace'=1
'atom'=2
'interface'=3
'interfaces'=4
'package'=5
'value'=6
'relation'=7
'operation'=8
'function'=9
'constructor'=10
'constructs'=11
'conform'=12
'as'=13
'bind'=14
'to'=15
'private'=16
'shared'=17
'state'=18
'edge'=19
'projection'=20
'with'=21
'on'=22
'policy'=23
'default'=24
'source'=25
'repository'=26
'commit'=27
'revision'=28
'id'=29
'doc'=30
'mode'=31
'emits'=32
'receiver'=33
'requires'=34
'any'=35
'get'=36
'set'=37
'watch'=38
'start'=39
'stop'=40
'read'=41
'write'=42
'resolve'=43
'connect'=44
'disconnect'=45
'call'=46
'watch-start'=47
'watch-stop'=48
'subscribe'=49
'unsubscribe'=50
'optimistic-register'=51
'crdt'=52
'optional-one'=53
'exactly-one'=54
'many-unique'=55
'many'=56
'ordered'=57
'unit'=58
'watch-handle'=59
'message'=60
'atom-ref'=61
'interface-ref'=62
'optional'=63
'list'=64
'bool'=65
'bytes'=66
'double'=67
'int32'=68
'int64'=69
'string'=70
'uint32'=71
'uint64'=72
'true'=73
'false'=74
'null'=75
'->'=76
':'=77
';'=78
','=79
'.'=80
'{'=81
'}'=82
'['=83
']'=84
'('=85
')'=86
'<'=87
'>'=88
'query'=2
'specialize'=3
'root'=4
'document'=5
'fragments'=6
'view'=7
'max'=8
'allow'=9
'poll'=10
'queryable'=11
'rpc'=12
'query-reason'=13
'type'=14
'object'=15
'storable'=16
'implements'=17
'ref'=18
'fragment'=19
'import'=20
'external'=21
'atom'=22
'interface'=23
'interfaces'=24
'package'=25
'value'=26
'relation'=27
'operation'=28
'function'=29
'constructor'=30
'constructs'=31
'input'=32
'conform'=33
'as'=34
'bind'=35
'static'=36
'to'=37
'private'=38
'shared'=39
'state'=40
'edge'=41
'projection'=42
'with'=43
'using'=44
'via'=45
'materialize'=46
'if'=47
'absent'=48
'on'=49
'policy'=50
'default'=51
'source'=52
'repository'=53
'commit'=54
'revision'=55
'semantic-major'=56
'on-delete'=57
'retain-other'=58
'keyed'=59
'public-traversal'=60
'id'=61
'doc'=62
'mode'=63
'emits'=64
'receiver'=65
'requires'=66
'any'=67
'get'=68
'set'=69
'watch'=70
'start'=71
'stop'=72
'read'=73
'write'=74
'resolve'=75
'connect'=76
'disconnect'=77
'call'=78
'watch-start'=79
'watch-stop'=80
'subscribe'=81
'unsubscribe'=82
'optimistic-register'=83
'crdt'=84
'optional-one'=85
'exactly-one'=86
'many-unique'=87
'many'=88
'ordered'=89
'unit'=90
'watch-handle'=91
'message'=92
'atom-ref'=93
'interface-ref'=94
'optional'=95
'list'=96
'record'=97
'bool'=98
'bytes'=99
'double'=100
'int32'=101
'int64'=102
'string'=103
'uint32'=104
'uint64'=105
'true'=106
'false'=107
'null'=108
'->'=109
':'=110
';'=111
','=112
'.'=113
'{'=114
'}'=115
'['=116
']'=117
'('=118
')'=119
'<'=120
'>'=121
'&'=122
'='=123
File diff suppressed because one or more lines are too long
@@ -1,183 +1,253 @@
WORKSPACE=1
ATOM=2
INTERFACE=3
INTERFACES=4
PACKAGE=5
VALUE=6
RELATION=7
OPERATION=8
FUNCTION=9
CONSTRUCTOR=10
CONSTRUCTS=11
CONFORM=12
AS=13
BIND=14
TO=15
PRIVATE=16
SHARED=17
STATE=18
EDGE=19
PROJECTION=20
WITH=21
ON=22
POLICY=23
DEFAULT=24
SOURCE=25
REPOSITORY=26
COMMIT=27
REVISION=28
ID=29
DOC=30
MODE=31
EMITS=32
RECEIVER=33
REQUIRES=34
ANY=35
GET=36
SET=37
WATCH=38
START=39
STOP=40
READ=41
WRITE=42
RESOLVE=43
CONNECT=44
DISCONNECT=45
CALL=46
WATCH_START=47
WATCH_STOP=48
SUBSCRIBE=49
UNSUBSCRIBE=50
OPTIMISTIC_REGISTER=51
CRDT=52
OPTIONAL_ONE=53
EXACTLY_ONE=54
MANY_UNIQUE=55
MANY=56
ORDERED=57
UNIT=58
WATCH_HANDLE=59
MESSAGE=60
ATOM_REF=61
INTERFACE_REF=62
OPTIONAL=63
LIST=64
BOOL=65
BYTES=66
DOUBLE=67
INT32=68
INT64=69
STRING=70
UINT32=71
UINT64=72
TRUE=73
FALSE=74
NULL=75
ARROW=76
COLON=77
SEMI=78
COMMA=79
DOT=80
LBRACE=81
RBRACE=82
LBRACK=83
RBRACK=84
LPAREN=85
RPAREN=86
LT=87
GT=88
INTEGER=89
JSON_NUMBER=90
IDENTIFIER=91
STRING_LITERAL=92
LINE_COMMENT=93
BLOCK_COMMENT=94
WS=95
QUERY=2
SPECIALIZE=3
ROOT=4
DOCUMENT=5
FRAGMENTS=6
VIEW=7
MAX=8
ALLOW=9
POLL=10
QUERYABLE=11
RPC=12
QUERY_REASON=13
TYPE=14
OBJECT=15
STORABLE=16
IMPLEMENTS=17
REF=18
FRAGMENT=19
IMPORT=20
EXTERNAL=21
ATOM=22
INTERFACE=23
INTERFACES=24
PACKAGE=25
VALUE=26
RELATION=27
OPERATION=28
FUNCTION=29
CONSTRUCTOR=30
CONSTRUCTS=31
INPUT=32
CONFORM=33
AS=34
BIND=35
STATIC=36
TO=37
PRIVATE=38
SHARED=39
STATE=40
EDGE=41
PROJECTION=42
WITH=43
USING=44
VIA=45
MATERIALIZE=46
IF=47
ABSENT=48
ON=49
POLICY=50
DEFAULT=51
SOURCE=52
REPOSITORY=53
COMMIT=54
REVISION=55
SEMANTIC_MAJOR=56
ON_DELETE=57
RETAIN_OTHER=58
KEYED=59
PUBLIC_TRAVERSAL=60
ID=61
DOC=62
MODE=63
EMITS=64
RECEIVER=65
REQUIRES=66
ANY=67
GET=68
SET=69
WATCH=70
START=71
STOP=72
READ=73
WRITE=74
RESOLVE=75
CONNECT=76
DISCONNECT=77
CALL=78
WATCH_START=79
WATCH_STOP=80
SUBSCRIBE=81
UNSUBSCRIBE=82
OPTIMISTIC_REGISTER=83
CRDT=84
OPTIONAL_ONE=85
EXACTLY_ONE=86
MANY_UNIQUE=87
MANY=88
ORDERED=89
UNIT=90
WATCH_HANDLE=91
MESSAGE=92
ATOM_REF=93
INTERFACE_REF=94
OPTIONAL=95
LIST=96
RECORD=97
BOOL=98
BYTES=99
DOUBLE=100
INT32=101
INT64=102
STRING=103
UINT32=104
UINT64=105
TRUE=106
FALSE=107
NULL=108
ARROW=109
COLON=110
SEMI=111
COMMA=112
DOT=113
LBRACE=114
RBRACE=115
LBRACK=116
RBRACK=117
LPAREN=118
RPAREN=119
LT=120
GT=121
AMP=122
EQUAL=123
INTEGER=124
JSON_NUMBER=125
IDENTIFIER=126
STRING_LITERAL=127
LINE_COMMENT=128
BLOCK_COMMENT=129
WS=130
'workspace'=1
'atom'=2
'interface'=3
'interfaces'=4
'package'=5
'value'=6
'relation'=7
'operation'=8
'function'=9
'constructor'=10
'constructs'=11
'conform'=12
'as'=13
'bind'=14
'to'=15
'private'=16
'shared'=17
'state'=18
'edge'=19
'projection'=20
'with'=21
'on'=22
'policy'=23
'default'=24
'source'=25
'repository'=26
'commit'=27
'revision'=28
'id'=29
'doc'=30
'mode'=31
'emits'=32
'receiver'=33
'requires'=34
'any'=35
'get'=36
'set'=37
'watch'=38
'start'=39
'stop'=40
'read'=41
'write'=42
'resolve'=43
'connect'=44
'disconnect'=45
'call'=46
'watch-start'=47
'watch-stop'=48
'subscribe'=49
'unsubscribe'=50
'optimistic-register'=51
'crdt'=52
'optional-one'=53
'exactly-one'=54
'many-unique'=55
'many'=56
'ordered'=57
'unit'=58
'watch-handle'=59
'message'=60
'atom-ref'=61
'interface-ref'=62
'optional'=63
'list'=64
'bool'=65
'bytes'=66
'double'=67
'int32'=68
'int64'=69
'string'=70
'uint32'=71
'uint64'=72
'true'=73
'false'=74
'null'=75
'->'=76
':'=77
';'=78
','=79
'.'=80
'{'=81
'}'=82
'['=83
']'=84
'('=85
')'=86
'<'=87
'>'=88
'query'=2
'specialize'=3
'root'=4
'document'=5
'fragments'=6
'view'=7
'max'=8
'allow'=9
'poll'=10
'queryable'=11
'rpc'=12
'query-reason'=13
'type'=14
'object'=15
'storable'=16
'implements'=17
'ref'=18
'fragment'=19
'import'=20
'external'=21
'atom'=22
'interface'=23
'interfaces'=24
'package'=25
'value'=26
'relation'=27
'operation'=28
'function'=29
'constructor'=30
'constructs'=31
'input'=32
'conform'=33
'as'=34
'bind'=35
'static'=36
'to'=37
'private'=38
'shared'=39
'state'=40
'edge'=41
'projection'=42
'with'=43
'using'=44
'via'=45
'materialize'=46
'if'=47
'absent'=48
'on'=49
'policy'=50
'default'=51
'source'=52
'repository'=53
'commit'=54
'revision'=55
'semantic-major'=56
'on-delete'=57
'retain-other'=58
'keyed'=59
'public-traversal'=60
'id'=61
'doc'=62
'mode'=63
'emits'=64
'receiver'=65
'requires'=66
'any'=67
'get'=68
'set'=69
'watch'=70
'start'=71
'stop'=72
'read'=73
'write'=74
'resolve'=75
'connect'=76
'disconnect'=77
'call'=78
'watch-start'=79
'watch-stop'=80
'subscribe'=81
'unsubscribe'=82
'optimistic-register'=83
'crdt'=84
'optional-one'=85
'exactly-one'=86
'many-unique'=87
'many'=88
'ordered'=89
'unit'=90
'watch-handle'=91
'message'=92
'atom-ref'=93
'interface-ref'=94
'optional'=95
'list'=96
'record'=97
'bool'=98
'bytes'=99
'double'=100
'int32'=101
'int64'=102
'string'=103
'uint32'=104
'uint64'=105
'true'=106
'false'=107
'null'=108
'->'=109
':'=110
';'=111
','=112
'.'=113
'{'=114
'}'=115
'['=116
']'=117
'('=118
')'=119
'<'=120
'>'=121
'&'=122
'='=123
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -3,20 +3,35 @@ import { AbstractParseTreeVisitor } from "antlr4ng";
import { DocumentContext } from "./QuixosCapabilityParser.js";
import { FragmentDeclContext } from "./QuixosCapabilityParser.js";
import { SourceImportDeclContext } from "./QuixosCapabilityParser.js";
import { WorkspaceDeclContext } 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 { InterfaceDeclContext } from "./QuixosCapabilityParser.js";
import { SourceBlockContext } from "./QuixosCapabilityParser.js";
import { InterfaceResourceDeclContext } from "./QuixosCapabilityParser.js";
import { TypeParametersContext } from "./QuixosCapabilityParser.js";
import { TypeParameterContext } from "./QuixosCapabilityParser.js";
import { InterfaceTypeContext } from "./QuixosCapabilityParser.js";
import { TypeArgumentsContext } from "./QuixosCapabilityParser.js";
import { TypeArgumentContext } from "./QuixosCapabilityParser.js";
import { TypeAliasDeclContext } from "./QuixosCapabilityParser.js";
import { InterfaceMemberContext } from "./QuixosCapabilityParser.js";
import { OperationMemberContext } from "./QuixosCapabilityParser.js";
import { ValueMemberContext } from "./QuixosCapabilityParser.js";
import { ValueMemberOperationContext } from "./QuixosCapabilityParser.js";
import { RelationshipMemberContext } from "./QuixosCapabilityParser.js";
import { QueryReadContractContext } from "./QuixosCapabilityParser.js";
import { RelationshipOperationContext } from "./QuixosCapabilityParser.js";
import { TargetConstraintContext } from "./QuixosCapabilityParser.js";
import { PackageDeclContext } from "./QuixosCapabilityParser.js";
import { PackageResourceDeclContext } from "./QuixosCapabilityParser.js";
import { PackageExportContext } from "./QuixosCapabilityParser.js";
import { PackageQueryContext } from "./QuixosCapabilityParser.js";
import { PackageQuerySpecializationContext } from "./QuixosCapabilityParser.js";
import { QueryClauseContext } from "./QuixosCapabilityParser.js";
import { PackageOperationExportContext } from "./QuixosCapabilityParser.js";
import { PackageFunctionExportContext } from "./QuixosCapabilityParser.js";
import { PackageConstructorExportContext } from "./QuixosCapabilityParser.js";
@@ -36,6 +51,8 @@ import { EdgeDeclContext } from "./QuixosCapabilityParser.js";
import { EdgeEndpointContext } from "./QuixosCapabilityParser.js";
import { ConformanceDeclContext } from "./QuixosCapabilityParser.js";
import { ConformanceItemContext } from "./QuixosCapabilityParser.js";
import { StateFieldBindingDeclContext } from "./QuixosCapabilityParser.js";
import { RelationshipMaterializationDeclContext } from "./QuixosCapabilityParser.js";
import { OperationBindingDeclContext } from "./QuixosCapabilityParser.js";
import { MemberOperationRefContext } from "./QuixosCapabilityParser.js";
import { OperationNameContext } from "./QuixosCapabilityParser.js";
@@ -46,6 +63,7 @@ import { DependencyBindingBlockContext } from "./QuixosCapabilityParser.js";
import { DependencyBindingContext } from "./QuixosCapabilityParser.js";
import { ConstructorBindingDeclContext } from "./QuixosCapabilityParser.js";
import { ValueTypeContext } from "./QuixosCapabilityParser.js";
import { RecordFieldContext } from "./QuixosCapabilityParser.js";
import { ScalarTypeContext } from "./QuixosCapabilityParser.js";
import { CardinalityContext } from "./QuixosCapabilityParser.js";
import { JsonLiteralContext } from "./QuixosCapabilityParser.js";
@@ -70,6 +88,18 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
* @return the visitor 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`.
* @param ctx the parse tree
@@ -82,6 +112,30 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
* @return the visitor 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`.
* @param ctx the parse tree
@@ -89,17 +143,47 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
*/
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
* @return the visitor result
*/
visitInterfaceDecl?: (ctx: InterfaceDeclContext) => Result;
visitInterfaceResourceDecl?: (ctx: InterfaceResourceDeclContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.sourceBlock`.
* Visit a parse tree produced by `QuixosCapabilityParser.typeParameters`.
* @param ctx the parse tree
* @return the visitor result
*/
visitSourceBlock?: (ctx: SourceBlockContext) => Result;
visitTypeParameters?: (ctx: TypeParametersContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.typeParameter`.
* @param ctx the parse tree
* @return the visitor result
*/
visitTypeParameter?: (ctx: TypeParameterContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.interfaceType`.
* @param ctx the parse tree
* @return the visitor result
*/
visitInterfaceType?: (ctx: InterfaceTypeContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.typeArguments`.
* @param ctx the parse tree
* @return the visitor result
*/
visitTypeArguments?: (ctx: TypeArgumentsContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.typeArgument`.
* @param ctx the parse tree
* @return the visitor result
*/
visitTypeArgument?: (ctx: TypeArgumentContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.typeAliasDecl`.
* @param ctx the parse tree
* @return the visitor result
*/
visitTypeAliasDecl?: (ctx: TypeAliasDeclContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.interfaceMember`.
* @param ctx the parse tree
@@ -130,6 +214,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
* @return the visitor result
*/
visitRelationshipMember?: (ctx: RelationshipMemberContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.queryReadContract`.
* @param ctx the parse tree
* @return the visitor result
*/
visitQueryReadContract?: (ctx: QueryReadContractContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.relationshipOperation`.
* @param ctx the parse tree
@@ -143,17 +233,35 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
*/
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
* @return the visitor result
*/
visitPackageDecl?: (ctx: PackageDeclContext) => Result;
visitPackageResourceDecl?: (ctx: PackageResourceDeclContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.packageExport`.
* @param ctx the parse tree
* @return the visitor result
*/
visitPackageExport?: (ctx: PackageExportContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.packageQuery`.
* @param ctx the parse tree
* @return the visitor result
*/
visitPackageQuery?: (ctx: PackageQueryContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.packageQuerySpecialization`.
* @param ctx the parse tree
* @return the visitor result
*/
visitPackageQuerySpecialization?: (ctx: PackageQuerySpecializationContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.queryClause`.
* @param ctx the parse tree
* @return the visitor result
*/
visitQueryClause?: (ctx: QueryClauseContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.packageOperationExport`.
* @param ctx the parse tree
@@ -268,6 +376,18 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
* @return the visitor result
*/
visitConformanceItem?: (ctx: ConformanceItemContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.stateFieldBindingDecl`.
* @param ctx the parse tree
* @return the visitor result
*/
visitStateFieldBindingDecl?: (ctx: StateFieldBindingDeclContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.relationshipMaterializationDecl`.
* @param ctx the parse tree
* @return the visitor result
*/
visitRelationshipMaterializationDecl?: (ctx: RelationshipMaterializationDeclContext) => Result;
/**
* Visit a parse tree produced by `QuixosCapabilityParser.operationBindingDecl`.
* @param ctx the parse tree
@@ -328,6 +448,12 @@ export class QuixosCapabilityVisitor<Result> extends AbstractParseTreeVisitor<Re
* @return the visitor 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`.
* @param ctx the parse tree
+392
View File
@@ -0,0 +1,392 @@
import {
TypeSubstitution,
GenericTypeError,
instantiateInterface,
appliedInterfaceId,
valueType,
type AtomId,
type ClosedTypeArgument,
type GenericTypeEnvironment,
type InterfaceApplicationExpression,
type InterfaceRevision,
type ObjectTypeExpression,
type TypeArgumentExpression,
type TypeParameter,
type ValueAliasDefinition,
type ValueType,
type ValueTypeExpression,
} from "../capability-model/index.js";
import type {
InterfaceTypeContext,
TargetConstraintContext,
TypeArgumentsContext,
TypeParametersContext,
TypeAliasDeclContext,
ValueTypeContext,
} from "./generated/QuixosCapabilityParser.js";
const literal = (context: { getText(): string }) => JSON.parse(context.getText()) as string;
/** Lexical authoring scope; only `value`/`target` return installed types. */
export class GenericSourceTypes {
readonly interfaces = new Map<string, InterfaceRevision>();
readonly definitions = new Map<string, InterfaceRevision>();
readonly applications = new Map<string, InterfaceRevision>();
readonly aliases = new Map<string, ValueAliasDefinition>();
parameters = new Map<string, TypeParameter>();
self?: AtomId;
private active: string[] = [];
private applicationCount = 0;
constructor(readonly atoms: Map<string, AtomId>) {}
environment(): GenericTypeEnvironment {
return {
arguments: new Map(),
aliases: this.aliases,
self: this.self,
applyInterface: (id, args) => this.apply(id, args).revisionId,
// Obligations are retained on the application and discharged by workspace
// validation, where all atom conformances are known (not source order).
implementsInterface: () => true,
};
}
register(name: string, definition: InterfaceRevision) {
this.interfaces.set(name, definition);
this.definitions.set(definition.revisionId, definition);
}
/** Check authored applications even when nobody has instantiated this template yet. */
validateTemplate(definition: InterfaceRevision) {
const template = definition.template;
if (!template) return;
const aliases = new Map((template.aliases ?? []).map((alias) => [alias.id, alias]));
const parametersById = new Map(
[...template.parameters, ...(template.aliases ?? []).flatMap((alias) => alias.parameters)].map((parameter) => [
parameter.id,
parameter,
]),
);
const replace = (node: unknown, arguments_: Map<string, TypeArgumentExpression>): unknown => {
if (!node || typeof node !== "object") return node;
if ("kind" in node && node.kind === "parameter" && "parameterId" in node) {
const arg = arguments_.get(String(node.parameterId));
if (arg) return arg.kind === "value" ? arg.type : arg.target;
}
if (Array.isArray(node)) return node.map((entry) => replace(entry, arguments_));
return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, replace(value, arguments_)]));
};
const implies = (
actual: InterfaceApplicationExpression,
required: InterfaceApplicationExpression,
seen = new Set<string>(),
): boolean => {
const key = JSON.stringify(actual);
if (key === JSON.stringify(required)) return true;
if (seen.has(key) || seen.size > 128) return false;
seen.add(key);
const contract = this.definitions.get(actual.definitionId);
if (!contract?.template)
return (contract?.requiredInterfaces ?? []).includes(required.definitionId) && required.arguments.length === 0;
const args = new Map(
contract.template.parameters.map((parameter, index) => [parameter.id, actual.arguments[index]]),
);
return contract.template.requires.some((parent) =>
implies(replace(parent, args) as InterfaceApplicationExpression, required, seen),
);
};
const storable = (type: ValueTypeExpression, depth = 0): boolean => {
if (depth > 128)
throw new GenericTypeError(
"type-complexity-limit",
definition.displayName,
"Storable alias expansion exceeds depth limit",
);
if (type.kind === "parameter") {
const parameter = parametersById.get(type.parameterId);
return parameter?.kind === "value" && Boolean(parameter.storable);
}
if (type.kind === "list" || type.kind === "optional") return storable(type.value, depth + 1);
if (type.kind === "alias") {
const alias = aliases.get(type.definitionId);
if (!alias || alias.parameters.length !== type.arguments.length) return false;
return storable(
replace(
alias.body,
new Map(alias.parameters.map((parameter, index) => [parameter.id, type.arguments[index]])),
) as ValueTypeExpression,
depth + 1,
);
}
return type.kind === "scalar" || (type.kind === "builtin" && type.name === "unit");
};
let remaining = 10000;
const visit = (node: unknown, path: string, depth = 0): void => {
if (depth > 128 || --remaining < 0)
throw new GenericTypeError("type-complexity-limit", path, "Type exceeds the depth or expansion budget");
if (!node || typeof node !== "object") return;
if ("definitionId" in node && "arguments" in node) {
const application = node as InterfaceApplicationExpression | Extract<ValueTypeExpression, { kind: "alias" }>;
const alias = "kind" in application && application.kind === "alias";
const target = alias ? aliases.get(application.definitionId) : this.definitions.get(application.definitionId);
if (!target)
throw new GenericTypeError(
alias ? "unknown-alias" : "unknown-interface",
path,
`Unknown definition ${application.definitionId}`,
);
if ("template" in target && target.template?.usesSelf) template.usesSelf = true;
const parameters = "parameters" in target ? target.parameters : (target.template?.parameters ?? []);
if (parameters.length !== application.arguments.length)
throw new GenericTypeError(
"type-arity",
path,
`Expected ${parameters.length} type arguments, received ${application.arguments.length}`,
);
parameters.forEach((parameter, index) => {
if (parameter.kind !== application.arguments[index].kind)
throw new GenericTypeError(
"parameter-kind",
path,
`Expected ${parameter.kind}, received ${application.arguments[index].kind}`,
);
const argument = application.arguments[index];
if (parameter.kind === "value" && parameter.storable && argument.kind === "value" && !storable(argument.type))
throw new GenericTypeError(
"non-storable-argument",
path,
"Generic application cannot prove its value argument is storable",
);
if (parameter.kind === "object" && argument.kind === "object" && argument.target.kind === "parameter") {
const offered = parametersById.get(argument.target.parameterId);
const mapping = new Map(parameters.map((p, i) => [p.id, application.arguments[i]]));
for (const bound of parameter.implements) {
const required = replace(bound, mapping) as InterfaceApplicationExpression;
if (offered?.kind !== "object" || !offered.implements.some((evidence) => implies(evidence, required)))
throw new GenericTypeError(
"unsatisfied-bound",
path,
`Object parameter does not prove ${required.definitionId}`,
);
}
}
});
}
for (const [name, child] of Object.entries(node)) visit(child, `${path}.${name}`, depth + 1);
};
visit(template, definition.displayName);
const active = new Set<string>();
const done = new Set<string>();
const checkAlias = (id: string) => {
if (done.has(id)) return;
if (active.has(id))
throw new GenericTypeError("recursive-alias", id, `Recursive value alias: ${[...active, id].join(" -> ")}`);
active.add(id);
const walk = (node: unknown): void => {
if (!node || typeof node !== "object") return;
if ("kind" in node && node.kind === "alias")
checkAlias((node as ValueAliasDefinition["body"] & { definitionId: string }).definitionId);
Object.values(node).forEach(walk);
};
walk(aliases.get(id)?.body);
active.delete(id);
done.add(id);
};
for (const id of aliases.keys()) checkAlias(id);
}
apply(id: string, arguments_: readonly ClosedTypeArgument[]): InterfaceRevision {
const definition = this.definitions.get(id);
if (!definition) throw new GenericTypeError("unknown-interface", id, "Unknown interface definition");
const application = {
definitionId: definition.revisionId,
source: definition.source,
arguments: [...arguments_],
...(definition.template?.usesSelf ? { self: this.self } : {}),
};
const appliedId = definition.template ? appliedInterfaceId(application) : definition.revisionId;
const cached = this.applications.get(appliedId);
if (cached) return cached;
if (this.active.includes(id))
throw new GenericTypeError(
"recursive-application",
id,
`Expanding recursive interface application: ${[...this.active, id].join(" -> ")}`,
);
if (++this.applicationCount > 10000)
throw new GenericTypeError("type-complexity-limit", id, "Too many interface applications");
this.active.push(id);
if (definition.template)
this.applications.set(appliedId, {
interfaceId: definition.interfaceId,
revisionId: appliedId,
displayName: definition.displayName,
source: definition.source,
members: [],
application,
});
try {
const obligations: NonNullable<InterfaceRevision["argumentRequirements"]> = [];
const instance = instantiateInterface(definition, arguments_, {
...this.environment(),
implementsInterface: (target, required) => {
obligations.push({ target, required });
return true;
},
});
if (instance === definition) return definition;
instance.argumentRequirements = obligations;
this.applications.set(instance.revisionId, instance);
this.definitions.set(instance.revisionId, instance);
return instance;
} catch (error) {
this.applications.delete(appliedId);
throw error;
} finally {
this.active.pop();
}
}
interface(context: InterfaceTypeContext): InterfaceApplicationExpression {
return this.interfaceByName(context.identifier().getText(), context.typeArguments());
}
interfaceByName(name: string, arguments_: TypeArgumentsContext | null): InterfaceApplicationExpression {
const definition = this.interfaces.get(name);
if (!definition) throw new GenericTypeError("unknown-interface", name, "Unknown interface");
return { definitionId: definition.revisionId, arguments: this.arguments(arguments_) };
}
arguments(context: TypeArgumentsContext | null): TypeArgumentExpression[] {
return (context?.typeArgument() ?? []).map((argument): TypeArgumentExpression => {
if (argument.INTERFACE())
return {
kind: "object",
target: { kind: "application", application: this.interface(argument.interfaceType()!) },
};
if (argument.ATOM()) return { kind: "object", target: this.atom(argument.identifier()!.getText()) };
if (argument.OBJECT()) return { kind: "object", target: this.objectParameter(argument.identifier()!.getText()) };
const type = argument.valueType()!;
if (type.getText() === "Self") return { kind: "object", target: { kind: "self" } };
const parameter = type.identifier() && this.parameters.get(type.identifier()!.getText());
if (parameter?.kind === "object" && !type.typeArguments())
return { kind: "object", target: { kind: "parameter", parameterId: parameter.id } };
return { kind: "value", type: this.expression(type) };
});
}
private atom(name: string): ObjectTypeExpression {
if (name === "Self") return { kind: "self" };
const atomId = this.atoms.get(name);
if (!atomId) throw new GenericTypeError("unknown-atom", name, "Unknown atom");
return { kind: "atom", atomId };
}
private objectParameter(name: string): ObjectTypeExpression {
if (name === "Self") return { kind: "self" };
const parameter = this.parameters.get(name);
if (!parameter || parameter.kind !== "object")
throw new GenericTypeError("parameter-kind", name, "Expected an object parameter");
return { kind: "parameter", parameterId: parameter.id };
}
targetExpression(context: TargetConstraintContext): ObjectTypeExpression {
const name = context.identifier().getText();
if (context.ATOM()) return this.atom(name);
if (context.OBJECT()) return this.objectParameter(name);
return { kind: "application", application: this.interfaceByName(name, context.typeArguments()) };
}
expression(context: ValueTypeContext): ValueTypeExpression {
if (context.scalarType())
return {
kind: "scalar",
name: context.scalarType()!.getText() as Extract<ValueType, { kind: "scalar" }>["name"],
};
if (context.UNIT()) return valueType.unit;
if (context.WATCH_HANDLE()) return valueType.watchHandle;
if (context.MESSAGE()) return valueType.message(literal(context.stringLiteral()!));
if (context.OPTIONAL() || context.LIST())
return { kind: context.LIST() ? "list" : "optional", value: this.expression(context.valueType()!) };
if (context.RECORD()) {
const fields = context
.recordField()
.map((field) => [field.identifier().getText(), this.expression(field.valueType())] as const);
if (new Set(fields.map(([name]) => name)).size !== fields.length)
throw new GenericTypeError("duplicate-field", "record", "Duplicate record field");
return { kind: "record", fields: Object.fromEntries(fields) };
}
const name = context.identifier()!.getText();
if (context.ATOM_REF()) return { kind: "object-ref", expectation: this.atom(name) };
if (context.INTERFACE_REF())
return {
kind: "object-ref",
expectation: { kind: "application", application: this.interfaceByName(name, context.typeArguments()) },
};
if (context.REF()) return { kind: "object-ref", expectation: this.objectParameter(name) };
const parameter = this.parameters.get(name);
if (parameter) {
if (parameter.kind !== "value" || context.typeArguments())
throw new GenericTypeError("parameter-kind", name, "Expected a value parameter; object parameters need ref<T>");
return { kind: "parameter", parameterId: parameter.id };
}
if (!this.aliases.has(name)) throw new GenericTypeError("unknown-type", name, "Unknown value type");
return { kind: "alias", definitionId: name, arguments: this.arguments(context.typeArguments()) };
}
value(context: ValueTypeContext): ValueType {
return new TypeSubstitution(this.environment()).value(this.expression(context));
}
target(context: TargetConstraintContext) {
return new TypeSubstitution(this.environment()).object(this.targetExpression(context));
}
declareParameters(context: TypeParametersContext | null, owner: string): TypeParameter[] {
const entries = context?.typeParameter() ?? [];
const result: TypeParameter[] = entries.map((parameter, index) => {
const name = parameter.identifier().getText();
if (name === "Self" || this.parameters.has(name))
throw new GenericTypeError("duplicate-parameter", owner, `Duplicate or reserved parameter ${name}`);
const declaration: TypeParameter = parameter.VALUE()
? {
id: `${owner}/parameter/${index}`,
name,
kind: "value",
...(parameter.STORABLE() ? { storable: true } : {}),
}
: { id: `${owner}/parameter/${index}`, name, kind: "object", implements: [] };
this.parameters.set(name, declaration);
return declaration;
});
entries.forEach((entry, index) => {
const parameter = result[index];
if (parameter.kind === "object")
parameter.implements = entry.interfaceType().map((bound) => this.interface(bound));
});
return result;
}
declareAliases(contexts: readonly TypeAliasDeclContext[]) {
for (const context of contexts) {
const name = context.identifier().getText();
if (this.aliases.has(name)) throw new GenericTypeError("duplicate-alias", name, "Duplicate type alias");
this.aliases.set(name, { id: name, parameters: [], body: valueType.unit });
}
for (const context of contexts) {
const name = context.identifier().getText();
const previous = this.parameters;
this.parameters = new Map();
try {
this.aliases.set(name, {
id: name,
parameters: this.declareParameters(context.typeParameters(), JSON.stringify(["alias", name])),
body: this.expression(context.valueType()),
});
} finally {
this.parameters = previous;
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
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,54 @@
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;
}
+4
View File
@@ -1 +1,5 @@
export * from "./parser.js";
export * from "./assembly.js";
export * from "./source.js";
export * from "./source-loader.js";
export * from "./scaffold.js";
+33
View File
@@ -0,0 +1,33 @@
#!/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));
+27
View File
@@ -0,0 +1,27 @@
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" }],
}),
);
}
File diff suppressed because it is too large Load Diff
+477
View File
@@ -0,0 +1,477 @@
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,
});
}
});
};
+129
View File
@@ -0,0 +1,129 @@
#!/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;
});
+421
View File
@@ -0,0 +1,421 @@
import fs from "node:fs/promises";
import path from "node:path";
import { contentDigest } from "../capability-model/evolution.js";
import {
validateMigrationCatalog,
type MigrationCatalog,
type MigrationDeclaration,
} from "../capability-model/migrations.js";
import { formatQuixosLock, type GitSource } from "../resource-lock/index.js";
import { parseQx, walkSyntax } from "./source.js";
import type { StructuralRequest } from "./structural-plan.js";
import { addImplementation } from "./implementation-edit.js";
import { reactPlatformTypes } from "../bindings/react-platform.js";
type Source = { repository: string; commit: string };
type PackageEditModel = {
generatedBy: "qx-scaffold-v1";
name: string;
id: string;
revision: string;
exports: { name: string; id: string; file: string; migration?: boolean }[];
};
export type ScaffoldRecipe = {
template?: "typescript" | "typescript-react";
source: Source;
directory?: string;
name?: string;
id?: string;
revision?: string;
declaration?: string;
/** Initial authored files for a composed recipe; only used for a new package. */
initialFiles?: Record<string, string>;
tools?: { quixos: Source; protocol: Source; helpers: Source; sdk: Source };
nixifyPluginUrl?: string;
migration?: Omit<MigrationDeclaration, "implementation"> & { contracts: Record<string, unknown> };
};
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; later edits preserve authored wiring. */
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 packageModel: PackageEditModel;
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);
packageModel = { generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: [] };
catalog = { generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: [] };
if (react)
packageModel.exports.push({ name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts" });
create(
"package.qx",
`package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n${react ? ` function sourceGet id ${JSON.stringify(`export:${name}:source`)} : unit -> string;\n` : ""}}\n`,
);
create("quixos.lock", formatQuixosLock({ formatVersion: 1, quixos: source(spec.tools.quixos), resources: [] }));
create(
"package.json",
json({
name: `@quixos/${name.toLowerCase()}`,
version: "0.1.0",
private: true,
type: "module",
packageManager: "yarn@4.18.0",
scripts: { build: "tsc -p tsconfig.json", typecheck: "tsc --noEmit" },
dependencies: {
"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`,
},
devDependencies: {
"@types/node": "^24",
typescript: "^7.0.2",
...(react ? { react: "^18.3.1", "@types/react": "^18.3.12", esbuild: "^0.25.12" } : {}),
},
}),
);
create(
"tsconfig.json",
json({
compilerOptions: {
target: "ES2023",
module: "NodeNext",
moduleResolution: "NodeNext",
strict: true,
types: ["node", ...(react ? ["react"] : [])],
outDir: "dist",
rootDir: "src",
skipLibCheck: true,
...(react ? { jsx: "react-jsx", esModuleInterop: true } : {}),
},
include: ["src/**/*.ts", "src/**/*.tsx"],
}),
);
if (react) {
create(
"src/component.tsx",
`// Add a checked props export, select it in quixos.check.json options.react,\n// then use its generated ReactResults type. Scaffold bundle does this for you.\nexport default function Component(_props: {camino: Record<string, never>; render: unknown; dispatch: (action: unknown) => void}) {\n return <section><h1>${name}</h1></section>;\n}\n`,
);
create(
"src/impl/sourceGet.ts",
`import componentSource from "../component.js?browser-source";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => componentSource;\n`,
);
create(
"src/browser-assets.d.ts",
`declare module "*?browser-source" { const source: string; export default source; }\ndeclare module "*.css" {}\n`,
);
create("src/gen/web-studio-react-runtime.d.ts", reactPlatformTypes);
}
create(".gitignore", "node_modules/\ndist/\n.quixos/\nresult\n.yarn/install-state.gz\n");
create(
".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: { react: { propsExports: [] } },
}
: {}),
}),
);
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 = ${JSON.stringify(react ? "src/server.ts" : "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");
packageModel = {
generatedBy: "qx-scaffold-v1",
name: text(declaration.children.find((node) => node.kind === "identifier")!),
id: JSON.parse(text(literals[0])),
revision: JSON.parse(text(literals[1])),
exports: [],
};
for (const node of walkSyntax(declaration)) {
if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind))
continue;
const name = safeName(text(node.children.find((child) => child.kind === "identifier")!));
const id = JSON.parse(text(node.children.find((child) => child.kind === "stringLiteral")!));
if (packageModel.exports.some((entry) => entry.id === id || entry.name === name))
throw new Error("Duplicate package export name or ID");
const migration = catalog.migrations.find((entry) => entry.implementation.exportId === id);
packageModel.exports.push({
name,
id,
file: migration?.implementation.file ?? `src/impl/${name}.ts`,
...(migration ? { migration: true } : {}),
});
}
{
const name = safeName(spec.name);
if (!spec.id || packageModel.exports.some((entry) => entry.id === spec.id || entry.name === name))
throw new Error("New export requires a unique name and ID");
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: packageModel.id }, source: declaration },
],
});
const file = `src/${command === "migration" ? "migrations" : "impl"}/${name}.ts`;
const implementation =
command === "migration"
? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise<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);
packageModel.exports.push({ name, id: spec.id, file, ...(command === "migration" ? { migration: true } : {}) });
if (command === "migration") {
if (!spec.migration)
throw new Error("Migration scaffold requires retained contracts and an explicit transition");
const { contracts, ...transition } = spec.migration;
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(packageModel.exports.map((entry) => entry.id)));
generated("quixos.migrations.json", json(catalog));
if (command !== "package") {
const entry = packageModel.exports.at(-1)!;
const file = prefix + "src/server.ts";
const before = await fs.readFile(path.join(root, file), "utf8");
files.push({
file,
expected: before,
replace: addImplementation(
before,
"createRuntime",
entry.name,
`./${entry.file.slice(4, -3)}.js`,
!!entry.migration,
),
});
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 {
create(
"src/server.ts",
`import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` +
packageModel.exports
.filter((entry) => !entry.migration)
.map(
(entry, index) =>
`import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`,
)
.join("") +
`servePackageRuntime(createRuntime({\n` +
packageModel.exports
.map(
(entry) =>
` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${packageModel.exports.filter((value) => !value.migration).indexOf(entry)}`},`,
)
.join("\n") +
`\n}));\n`,
);
const migrations = packageModel.exports.filter((entry) => entry.migration);
create(
"src/migrate.ts",
`import {serveMigration} from "@quixos/camino-package-runtime";\n` +
migrations
.map(
(entry, index) =>
`import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`,
)
.join("") +
`await serveMigration({${migrations.map((entry, index) => `${JSON.stringify(entry.id)}: impl${index}`).join(", ")}});\n`,
);
}
generated(
"descriptor.quixos-package.txtpb",
`# Generated by qx-scaffold-v1\npackage_id: ${JSON.stringify(packageModel.id)}\npackage_revision_id: ${JSON.stringify(packageModel.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` +
packageModel.exports
.map(
(entry) =>
`exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`,
)
.join(""),
);
if (spec.initialFiles) {
if (command !== "package") throw new Error("initialFiles is only valid when creating a package");
const authored = spec.initialFiles["package.qx"];
if (authored !== undefined) {
const parsed = parseQx(authored);
const declaration = [...walkSyntax(parsed.root)].find((n) => n.kind === "packageResourceDecl");
const literals = declaration?.children.filter((n) => n.kind === "stringLiteral") ?? [];
const identifier = declaration?.children.find((n) => n.kind === "identifier");
if (
parsed.diagnostics.length ||
literals.length !== 2 ||
!identifier ||
authored.slice(identifier.start, identifier.end) !== spec.name ||
JSON.parse(authored.slice(literals[0].start, literals[0].end)) !== spec.id ||
JSON.parse(authored.slice(literals[1].start, literals[1].end)) !== spec.revision
)
throw new Error("Initial package declaration must match the provisioned name, ID and revision");
}
for (const [file, content] of Object.entries(spec.initialFiles)) {
if (
typeof content !== "string" ||
["quixos.lock", "flake.nix", "quixos.toolchain.json", "package.json"].includes(file)
)
throw new Error(`Not an initial authored file: ${file}`);
if (file === "quixos.check.json") {
const check = JSON.parse(content);
if (
check.backend !== "typescript" ||
check.bindingOutput !== "src/gen/qx.ts" ||
Object.keys(check).some((key) => !["backend", "bindingOutput", "options"].includes(key))
)
throw new Error(
"Initial check configuration may customize binding options, not the scaffold verification backend or output",
);
}
const existing = files.findIndex((entry) => entry.file === prefix + file);
if (existing >= 0) files.splice(existing, 1);
create(file, content);
}
// A composed React recipe supplies its own modules and complete server.
if (reactRecipe(spec)) {
for (const file of ["src/component.tsx", "src/impl/sourceGet.ts", "descriptor.quixos-package.txtpb"])
if (!(file in spec.initialFiles)) {
const index = files.findIndex((entry) => entry.file === prefix + file);
if (index >= 0) files.splice(index, 1);
}
}
}
return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files };
};
const reactRecipe = (spec: ScaffoldRecipe) =>
spec.template === "typescript-react" && spec.initialFiles?.["package.qx"] && spec.initialFiles?.["src/server.ts"];
+82
View File
@@ -0,0 +1,82 @@
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;
};
+88
View File
@@ -0,0 +1,88 @@
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);
return readRepositorySource(root, name);
};
/** Refuse symlinks at every component, including the final source file. */
export const readRepositorySource = async (root: string, name: string) => {
if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*$/.test(name))
throw new Error(`Invalid repository-relative source path: ${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(`Sources 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));
+156
View File
@@ -0,0 +1,156 @@
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}`);
};
+263
View File
@@ -0,0 +1,263 @@
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;
};
+321
View File
@@ -0,0 +1,321 @@
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|graphql|lock|ts|tsx|css|mjs|json|nix|txtpb|md))$/.test(
file,
) ||
file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))
)
throw new Error(`Unsafe scaffold path ${file}`);
};
const read = async (root: string, file: string): Promise<string | null> => {
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 1100 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);
});
+533
View File
@@ -0,0 +1,533 @@
#!/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 { loadQxSources } from "./source-loader.js";
import { createGitCapabilityResolver } from "./git-resolver.js";
import { planEvolution } from "../capability-model/index.js";
import { snapshotRepository } from "./candidate-check.js";
import { planPinUpgrades, applyPinUpgrades, discoverUpgradeSpec, type UpgradeSpec } from "./pin-upgrades.js";
import 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 === "scaffold-validate-qx") {
if (args.length !== 1) throw new Error("scaffold-validate-qx SOURCES_JSON");
const sources = JSON.parse(args[0]);
if (!Array.isArray(sources) || sources.length > 100) throw new Error("Expected at most 100 scaffold sources");
for (const entry of sources) {
if (
!entry ||
typeof entry.file !== "string" ||
typeof entry.source !== "string" ||
entry.source.length > 1024 * 1024
)
throw new Error("Invalid scaffold source");
const diagnostics = parseQx(entry.source, entry.file).diagnostics;
if (diagnostics.length)
throw new Error(diagnostics.map((d) => `${entry.file}:${d.line}:${d.column + 1}: ${d.message}`).join("\n"));
}
process.stdout.write('{"syntaxValid":true,"verificationEvidence":false}\n');
return;
}
if (command === "scaffold-placement-binding") {
if (args.length !== 1) throw new Error("scaffold-placement-binding ROOT");
const { source } = await loadQxSources(args[0]);
const syntax = parseQx(source);
const text = (n: { start: number; end: number }) => source.slice(n.start, n.end);
const matches: string[] = [];
for (const edge of walkSyntax(syntax.root)) {
if (edge.kind !== "edgeDecl") continue;
for (const endpoint of edge.children.filter((n) => n.kind === "edgeEndpoint")) {
const target = endpoint.children.find((n) => n.kind === "targetConstraint");
if (
!target ||
!syntax.tokens.some((t) => t.start >= target.start && t.end <= target.end && t.kind === "INTERFACE") ||
!target.children.some((n) => n.kind === "identifier" && text(n) === "WebStudioPlaceable")
)
continue;
const edgeName = text(edge.children.find((n) => n.kind === "identifier")!);
const projection = text(endpoint.children.find((n) => n.kind === "identifier")!);
matches.push(`bind placements.resolve to edge ${edgeName}.${projection}.resolve;`);
}
}
if (matches.length !== 1)
throw new Error(
`Expected one canvas placement edge for WebStudioPlaceable; found ${matches.length}. Configure the workspace canvas before adding a bundle.`,
);
process.stdout.write(JSON.stringify({ binding: matches[0] }) + "\n");
return;
}
if (command === "package-identity") {
if (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") {
console.error(`[${new Date().toISOString()}] Capture: waiting for coordinator lock (120s limit)`);
const result = spawnSync(
"flock",
[
"--exclusive",
"--timeout",
"120",
"--conflict-exit-code",
"75",
path.join(context.workbench, ".quixos/converge.lock"),
process.execPath,
process.argv[1],
"_converge",
context.workbench,
...(args[1] ? [args[1]] : []),
],
{ stdio: "inherit" },
);
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;
}
console.error(`[${new Date().toISOString()}] Capture: coordinator lock acquired`);
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",
// Like package scaffolding, declaration creation is provisional. Imports
// can be attached next; the normal check verifies the complete graph.
validation: "syntax",
source: spec.source,
files: [
{
file: "interface.qx",
create:
spec.declaration ??
`interface ${spec.name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`,
},
{
file: "quixos.lock",
create: formatQuixosLock({
formatVersion: 1,
quixos: { resolver: "git", ...spec.tools.quixos },
resources: [],
}),
},
{ file: ".gitignore", create: ".quixos/\n" },
],
};
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
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;
});
+136
View File
@@ -0,0 +1,136 @@
#!/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 { bindingSchema, specializeBindingSchema } from "../bindings/index.js";
import {
planEvolution,
runtimeContracts,
type EvolutionReview,
type WorkspaceRevision,
} from "../capability-model/index.js";
const usage = `usage: quixos-workspace-compile --root DIRECTORY --checkout-root DIRECTORY
[--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID]
[--source-root-commit GIT_REV] [--baseline PLAN_JSON] [--evolution-out PATH]
[--reviews REVIEW_JSON] [--schemas-out PATH]
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"),
schemasOut: values.get("--schemas-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.schemasOut) {
const schemas: Record<string, unknown> = {};
for (const node of assembled.resources.filter((entry) => entry.kind === "package")) {
const closure = new Map<string, typeof node>();
const visit = (entry: typeof node) => {
if (closure.has(entry.key)) return;
closure.set(entry.key, entry);
entry.dependencies.forEach(visit);
};
visit(node);
schemas[node.resource.revision.revisionId] = specializeBindingSchema(
bindingSchema({ resources: [...closure.values()] }),
candidate,
node.resource.revision.revisionId,
);
}
await writeFile(options.schemasOut, JSON.stringify(schemas));
}
if (options.evolutionOut) {
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;
});
+414
View File
@@ -0,0 +1,414 @@
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 === "query") {
const pkg = workspace.packageImports.find((pkg) => pkg.revisionId === binding.packageRevisionId);
const query = pkg?.checkedQueries?.find((query) => query.declaration.id === binding.queryId);
if (!query) throw new Error(`Missing checked query contract ${binding.packageRevisionId}:${binding.queryId}`);
const key = `query:${binding.packageRevisionId}:${binding.queryId}`;
const queryNode = nodes.get(key) ?? node(key, query);
parent.dependencies.add(key);
for (const effect of query.effects) {
queryNode.dependencies.add(`interface:${effect.interfaceRevisionId}`);
for (const conformance of workspace.conformances.filter(
(entry) => entry.interfaceRevisionId === effect.interfaceRevisionId,
))
queryNode.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
}
}
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}`);
const contract = workspace.interfaceImports.find((entry) => entry.revisionId === conformance.interfaceRevisionId);
for (const required of contract?.requiredInterfaces ?? [])
parent.dependencies.add(conformanceKey(conformance.atomId, required));
for (const attachment of conformance.privateAttachments) parent.dependencies.add(`attachment:${attachment.id}`);
for (const 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,
};
};
+144
View File
@@ -0,0 +1,144 @@
import { createHash } from "node:crypto";
import {
capabilityId,
type PackageExport,
type PackageExportId,
type DependencyPort,
type PackageRevision,
} from "./types.js";
import {
TypeSubstitution,
GenericTypeError,
bindTypeParameters,
canonicalTypeArgument,
type TypeParameter,
type ValueTypeExpression,
type ObjectTypeExpression,
type InterfaceApplicationExpression,
type GenericTypeEnvironment,
type ClosedTypeArgument,
type ValueAliasDefinition,
} from "./generics.js";
export type GenericDependencyPort = Omit<DependencyPort, "requirement"> & {
requirement:
| Extract<DependencyPort["requirement"], { kind: "query" }>
| {
kind: "state";
valueType: ValueTypeExpression;
primitives: Extract<DependencyPort["requirement"], { kind: "state" }>["primitives"];
}
| {
kind: "edge";
target: ObjectTypeExpression;
cardinality: Extract<DependencyPort["requirement"], { kind: "edge" }>["cardinality"];
primitives: Extract<DependencyPort["requirement"], { kind: "edge" }>["primitives"];
}
| { kind: "interface"; application: InterfaceApplicationExpression }
| { kind: "constructor"; target: ObjectTypeExpression; inputType: ValueTypeExpression };
};
export interface GenericPackageExport {
id: PackageExportId;
displayName: string;
parameters: TypeParameter[];
kind: "operation" | "function";
inputType: ValueTypeExpression;
outputType: ValueTypeExpression;
eventType?: ValueTypeExpression;
mode?: Extract<PackageExport, { kind: "operation" }>["mode"];
receiverRequirement:
| { kind: "any-object" }
| { kind: "target"; target: ObjectTypeExpression }
| { kind: "interfaces"; interfaces: InterfaceApplicationExpression[] };
dependencyPorts: GenericDependencyPort[];
aliases: ValueAliasDefinition[];
}
/** A closed manifest is a Nix build input, never a mutable source checkout. */
export const specializePackageExport = (
pkg: PackageRevision,
definition: GenericPackageExport,
arguments_: ClosedTypeArgument[],
environment: GenericTypeEnvironment,
): PackageExport => {
const lexical = { ...environment, aliases: new Map(definition.aliases.map((alias) => [alias.id, alias])) };
const bindings = bindTypeParameters(definition.parameters, arguments_, lexical, definition.displayName);
const substitution = new TypeSubstitution({ ...lexical, arguments: bindings });
const digest = createHash("sha256")
.update(
JSON.stringify([
"quixos-package-specialization-v1",
pkg.revisionId,
[pkg.source.repository, pkg.source.commit],
definition.id,
arguments_.map(canonicalTypeArgument),
environment.self ?? null,
]),
)
.digest("hex");
const ports: DependencyPort[] = definition.dependencyPorts.map((port) => {
const requirement = port.requirement;
switch (requirement.kind) {
case "query":
return { ...port, requirement };
case "state":
return { ...port, requirement: { ...requirement, valueType: substitution.value(requirement.valueType) } };
case "edge":
return { ...port, requirement: { ...requirement, target: substitution.object(requirement.target) } };
case "interface":
return {
...port,
requirement: { kind: "interface", interfaceRevisionId: substitution.application(requirement.application) },
};
case "constructor": {
const target = substitution.object(requirement.target);
if (target.kind !== "atom")
throw new GenericTypeError(
"constructor-target",
port.displayName,
"Constructor ports require a concrete atom argument, not an interface view",
);
return {
...port,
requirement: {
kind: "constructor",
atomId: target.atomId,
inputType: substitution.value(requirement.inputType),
},
};
}
}
});
const common = {
id: capabilityId.packageExport(`export-application:sha256:${digest}`),
displayName: `${definition.displayName}$${digest}`,
inputType: substitution.value(definition.inputType),
outputType: substitution.value(definition.outputType),
dependencyPorts: ports,
application: {
exportId: definition.id,
arguments: structuredClone(arguments_),
...(environment.self ? { self: environment.self } : {}),
},
};
if (definition.kind === "function") return { ...common, kind: "function" };
const receiver = definition.receiverRequirement;
const target = receiver.kind === "target" ? substitution.object(receiver.target) : undefined;
return {
...common,
kind: "operation",
mode: definition.mode!,
...(definition.eventType ? { eventType: substitution.value(definition.eventType) } : {}),
receiverRequirement: target
? target.kind === "atom"
? { kind: "exact-atom", atomId: target.atomId }
: { kind: "all-interfaces", interfaceRevisionIds: [target.interfaceRevisionId] }
: receiver.kind === "interfaces"
? {
kind: "all-interfaces",
interfaceRevisionIds: receiver.interfaces.map((value) => substitution.application(value)),
}
: { kind: "any-object" },
};
};
+427
View File
@@ -0,0 +1,427 @@
import { createHash } from "node:crypto";
import {
capabilityId,
type AtomId,
type InterfaceMember,
type InterfaceOperation,
type InterfaceRevision,
type InterfaceRevisionId,
type ObjectExpectation,
type SourceRevision,
type ValueType,
} from "./types.js";
/** Authoring-only expressions. Installed ValueType deliberately has no variable case. */
export type ValueTypeExpression =
| Extract<ValueType, { kind: "builtin" | "scalar" | "message" }>
| { kind: "parameter"; parameterId: string }
| { kind: "record"; fields: Record<string, ValueTypeExpression> }
| { kind: "list" | "optional"; value: ValueTypeExpression }
| { kind: "object-ref"; expectation: ObjectTypeExpression }
| { kind: "alias"; definitionId: string; arguments: TypeArgumentExpression[] };
export type ObjectTypeExpression =
| { kind: "atom"; atomId: AtomId }
| { kind: "interface"; interfaceRevisionId: InterfaceRevisionId }
| { kind: "parameter"; parameterId: string }
| { kind: "self" }
| { kind: "application"; application: InterfaceApplicationExpression };
export type TypeArgumentExpression =
| { kind: "value"; type: ValueTypeExpression }
| { kind: "object"; target: ObjectTypeExpression };
export type ClosedTypeArgument = { kind: "value"; type: ValueType } | { kind: "object"; target: ObjectExpectation };
export interface InterfaceApplicationExpression {
definitionId: InterfaceRevisionId;
arguments: TypeArgumentExpression[];
}
export type TypeParameter =
| { id: string; name: string; kind: "value"; storable?: boolean }
| { id: string; name: string; kind: "object"; implements: InterfaceApplicationExpression[] };
export interface ValueAliasDefinition {
id: string;
parameters: TypeParameter[];
body: ValueTypeExpression;
}
export interface GenericInterfaceTemplate {
parameters: TypeParameter[];
members: InterfaceMember<ValueTypeExpression, ObjectTypeExpression>[];
requires: InterfaceApplicationExpression[];
usesSelf: boolean;
aliases?: ValueAliasDefinition[];
}
/** Instantiation produces the existing closed runtime IR, with explicit provenance. */
export const instantiateInterface = (
definition: InterfaceRevision,
arguments_: readonly ClosedTypeArgument[],
environment: GenericTypeEnvironment,
): InterfaceRevision => {
const template = definition.template;
if (!template) {
if (arguments_.length) fail("type-arity", definition.displayName, "Non-generic interface takes no type arguments");
return definition;
}
const lexicalEnvironment = {
...environment,
aliases: new Map((template.aliases ?? []).map((alias) => [alias.id, alias])),
};
const argumentsMap = bindTypeParameters(template.parameters, arguments_, lexicalEnvironment, definition.displayName);
if (template.usesSelf && !environment.self)
fail("unbound-self", definition.displayName, "Self requires an implementing atom");
const substitution = new TypeSubstitution({ ...lexicalEnvironment, arguments: argumentsMap });
const operation = (entry: InterfaceOperation<ValueTypeExpression>): InterfaceOperation => ({
...entry,
inputType: substitution.value(entry.inputType, `${definition.displayName}.${entry.displayName}.input`),
outputType: substitution.value(entry.outputType, `${definition.displayName}.${entry.displayName}.output`),
eventType: entry.eventType
? substitution.value(entry.eventType, `${definition.displayName}.${entry.displayName}.event`)
: undefined,
});
const members: InterfaceMember[] = template.members.map((member) => {
const common = { id: member.id, displayName: member.displayName, operations: member.operations.map(operation) };
switch (member.kind) {
case "value":
return {
...common,
kind: "value",
...(member.queryRead ? { queryRead: { ...member.queryRead } } : {}),
valueType: substitution.value(member.valueType, member.displayName),
};
case "relationship":
return {
...common,
kind: "relationship",
...(member.queryRead ? { queryRead: { ...member.queryRead } } : {}),
target: substitution.object(member.target, member.displayName),
cardinality: member.cardinality,
ordered: member.ordered,
...(member.keyType ? { keyType: member.keyType } : {}),
};
case "operation":
return {
...common,
kind: "operation",
inputType: substitution.value(member.inputType, member.displayName),
outputType: substitution.value(member.outputType, member.displayName),
};
}
});
const application: AppliedInterfaceIdentity = {
definitionId: definition.revisionId,
source: definition.source,
arguments: structuredClone([...arguments_]),
...(template.usesSelf ? { self: environment.self } : {}),
};
return {
interfaceId: definition.interfaceId,
revisionId: appliedInterfaceId(application),
displayName: definition.displayName,
source: definition.source,
members,
application,
requiredInterfaces: template.requires.map((required) => substitution.application(required)),
};
};
export interface GenericTypeEnvironment {
arguments: ReadonlyMap<string, ClosedTypeArgument>;
self?: AtomId;
aliases?: ReadonlyMap<string, ValueAliasDefinition>;
/** Resolves only checked declarations, never a caller-supplied runtime type string. */
applyInterface: (definitionId: InterfaceRevisionId, arguments_: readonly ClosedTypeArgument[]) => InterfaceRevisionId;
/** Proof in the candidate, not an authorization grant. */
implementsInterface: (target: ObjectExpectation, required: InterfaceRevisionId) => boolean;
/** External codecs must explicitly declare reference-free persistence support. */
storableMessage?: (descriptorId: string) => boolean;
}
export class GenericTypeError extends Error {
constructor(
readonly code: string,
readonly path: string,
message: string,
) {
super(`${path}: ${message}`);
this.name = "GenericTypeError";
}
}
const fail = (code: string, path: string, message: string): never => {
throw new GenericTypeError(code, path, message);
};
/** One budget across nested aliases/substitutions, including concrete arguments. */
class Budget {
private remaining = 10000;
enter(path: string, depth: number) {
if (depth > 128 || --this.remaining < 0)
fail("type-complexity-limit", path, "Type exceeds the depth or expansion budget");
}
}
export const isStorableType = (
type: ValueType,
storableMessage: (descriptorId: string) => boolean = () => false,
): boolean => {
const budget = new Budget();
const visit = (value: ValueType, depth: number): boolean => {
budget.enter("storable", depth);
switch (value.kind) {
case "scalar":
return true;
case "builtin":
return value.name === "unit";
case "message":
return storableMessage(value.descriptorId);
case "object-ref":
return false;
case "list":
case "optional":
return visit(value.value, depth + 1);
// Records currently have RPC codecs, not ordinary-state persistence codecs.
// A generic bound must not promise storage that the installed model rejects.
case "record":
return false;
}
};
return visit(type, 0);
};
/** Canonical closed-type encoding, independent of record insertion order. */
export const canonicalTypeArgument = (argument: ClosedTypeArgument): string => {
const budget = new Budget();
const target = (value: ObjectExpectation): unknown => {
switch (value.kind) {
case "atom":
return ["atom", value.atomId];
case "interface":
return ["interface", value.interfaceRevisionId];
default:
return fail("unresolved-type", "argument", "Expected a closed object target");
}
};
const type = (value: ValueType, depth: number): unknown => {
budget.enter("argument", depth);
switch (value.kind) {
case "builtin":
case "scalar":
return [value.kind, value.name];
case "message":
return ["message", value.descriptorId];
case "object-ref":
return ["object-ref", target(value.expectation)];
case "list":
case "optional":
return [value.kind, type(value.value, depth + 1)];
case "record":
return [
"record",
Object.keys(value.fields)
.sort()
.map((key) => [key, type(value.fields[key], depth + 1)]),
];
default:
return fail("unresolved-type", "argument", "Expected a closed value type");
}
};
switch (argument.kind) {
case "value":
return JSON.stringify(["value", type(argument.type, 0)]);
case "object":
return JSON.stringify(["object", target(argument.target)]);
default:
return fail("invalid-kind", "argument", "Expected a value or object type argument");
}
};
export interface AppliedInterfaceIdentity {
definitionId: InterfaceRevisionId;
source: SourceRevision;
arguments: ClosedTypeArgument[];
/** Only include Self when it is actually part of the closed contract. */
self?: AtomId;
}
export const appliedInterfaceId = (application: AppliedInterfaceIdentity): InterfaceRevisionId => {
const encoding = JSON.stringify([
"quixos-applied-interface-v1",
application.definitionId,
application.source.repository,
application.source.commit.toLowerCase(),
application.arguments.map(canonicalTypeArgument),
application.self ?? null,
]);
return capabilityId.interfaceRevision(
`interface-application:sha256:${createHash("sha256").update(encoding).digest("hex")}`,
);
};
export class TypeSubstitution {
private readonly budget = new Budget();
private readonly aliases: string[] = [];
constructor(private environment: GenericTypeEnvironment) {}
argument(expression: TypeArgumentExpression, path = "argument", depth = 0): ClosedTypeArgument {
this.budget.enter(path, depth);
switch (expression.kind) {
case "value":
return { kind: "value", type: this.value(expression.type, path, depth + 1) };
case "object":
return { kind: "object", target: this.object(expression.target, path, depth + 1) };
default:
return fail("invalid-kind", path, "Expected a value or object type argument");
}
}
application(expression: InterfaceApplicationExpression, path = "interface", depth = 0): InterfaceRevisionId {
this.budget.enter(path, depth);
return this.environment.applyInterface(
expression.definitionId,
expression.arguments.map((argument, index) => this.argument(argument, `${path}.arguments[${index}]`, depth + 1)),
);
}
object(expression: ObjectTypeExpression, path = "target", depth = 0): ObjectExpectation {
this.budget.enter(path, depth);
switch (expression.kind) {
case "atom":
return { kind: "atom", atomId: expression.atomId };
case "interface":
return { kind: "interface", interfaceRevisionId: expression.interfaceRevisionId };
case "self":
return this.environment.self
? { kind: "atom", atomId: this.environment.self }
: fail("unbound-self", path, "Self requires an implementing atom");
case "parameter": {
const argument = this.environment.arguments.get(expression.parameterId);
if (!argument) return fail("unbound-parameter", path, `Unbound parameter ${expression.parameterId}`);
if (argument.kind !== "object")
return fail("parameter-kind", path, `Parameter ${expression.parameterId} is a value, not an object target`);
canonicalTypeArgument(argument);
return structuredClone(argument.target);
}
case "application":
return { kind: "interface", interfaceRevisionId: this.application(expression.application, path, depth + 1) };
default:
return fail("invalid-type", path, "Unknown object type expression");
}
}
value(expression: ValueTypeExpression, path = "type", depth = 0): ValueType {
this.budget.enter(path, depth);
switch (expression.kind) {
case "builtin":
return { kind: "builtin", name: expression.name };
case "scalar":
return { kind: "scalar", name: expression.name };
case "message":
return { kind: "message", descriptorId: expression.descriptorId };
case "list":
case "optional":
return { kind: expression.kind, value: this.value(expression.value, `${path}.${expression.kind}`, depth + 1) };
case "record":
return {
kind: "record",
fields: Object.fromEntries(
Object.entries(expression.fields).map(([name, field]) => [
name,
this.value(field, `${path}.${name}`, depth + 1),
]),
),
};
case "object-ref":
return { kind: "object-ref", expectation: this.object(expression.expectation, `${path}.ref`, depth + 1) };
case "parameter": {
const argument = this.environment.arguments.get(expression.parameterId);
if (!argument) return fail("unbound-parameter", path, `Unbound parameter ${expression.parameterId}`);
if (argument.kind !== "value")
return fail("parameter-kind", path, `Parameter ${expression.parameterId} is an object target; use ref<T>`);
canonicalTypeArgument(argument);
return this.value(argument.type, path, depth + 1);
}
case "alias": {
const alias = this.environment.aliases?.get(expression.definitionId);
if (!alias) return fail("unknown-alias", path, `Unknown type alias ${expression.definitionId}`);
if (this.aliases.includes(alias.id))
return fail("recursive-alias", path, `Recursive value alias: ${[...this.aliases, alias.id].join(" -> ")}`);
const arguments_ = expression.arguments.map((argument, index) =>
this.argument(argument, `${path}.arguments[${index}]`, depth + 1),
);
const bindings = bindTypeParameters(alias.parameters, arguments_, this.environment, path);
this.aliases.push(alias.id);
try {
// Reuse this expansion budget/stack; lexical parameter maps are restored.
const previous = this.environment;
this.environment = { ...previous, arguments: bindings };
try {
return this.value(alias.body, `${path}.${alias.id}`, depth + 1);
} finally {
this.environment = previous;
}
} finally {
this.aliases.pop();
}
}
default:
return fail("invalid-type", path, "Unknown value type expression");
}
}
}
export const bindTypeParameters = (
parameters: readonly TypeParameter[],
arguments_: readonly ClosedTypeArgument[],
environment: GenericTypeEnvironment,
path = "parameters",
): ReadonlyMap<string, ClosedTypeArgument> => {
if (parameters.length !== arguments_.length)
fail("type-arity", path, `Expected ${parameters.length} type arguments, received ${arguments_.length}`);
const bindings = new Map<string, ClosedTypeArgument>();
const names = new Set<string>();
for (const [index, parameter] of parameters.entries()) {
if (
!parameter.id ||
!parameter.name ||
parameter.name === "Self" ||
bindings.has(parameter.id) ||
names.has(parameter.name)
)
fail("duplicate-parameter", path, `Invalid or duplicate parameter ${parameter.name}`);
names.add(parameter.name);
const argument = arguments_[index];
if (parameter.kind !== argument.kind)
fail("parameter-kind", `${path}.${parameter.name}`, `Expected ${parameter.kind}, received ${argument.kind}`);
canonicalTypeArgument(argument);
bindings.set(parameter.id, structuredClone(argument));
}
const substitution = new TypeSubstitution({ ...environment, arguments: bindings });
for (const parameter of parameters) {
const argument = bindings.get(parameter.id)!;
if (
parameter.kind === "value" &&
argument.kind === "value" &&
parameter.storable &&
!isStorableType(argument.type, environment.storableMessage)
)
fail(
"non-storable-argument",
`${path}.${parameter.name}`,
"State values cannot contain managed references or unsupported transport values",
);
if (parameter.kind === "object" && argument.kind === "object") {
for (const bound of parameter.implements) {
const required = substitution.application(bound, `${path}.${parameter.name}.implements`);
if (!environment.implementsInterface(argument.target, required))
fail("unsatisfied-bound", `${path}.${parameter.name}`, `Object target does not implement ${required}`);
}
}
}
return bindings;
};
+4
View File
@@ -1,2 +1,6 @@
export * from "./types.js";
export * from "./validation.js";
export * from "./evolution.js";
export * from "./migrations.js";
export * from "./generics.js";
export * from "./generic-packages.js";
+156
View File
@@ -0,0 +1,156 @@
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;
};
+92 -63
View File
@@ -7,6 +7,7 @@ type OpaqueId<Kind extends string> = string & {
export type WorkspaceId = OpaqueId<"WorkspaceId">;
export type WorkspaceRevisionId = OpaqueId<"WorkspaceRevisionId">;
export type AtomId = OpaqueId<"AtomId">;
export type ConformanceId = OpaqueId<"ConformanceId">;
export type InterfaceId = OpaqueId<"InterfaceId">;
export type InterfaceRevisionId = OpaqueId<"InterfaceRevisionId">;
export type MemberId = OpaqueId<"MemberId">;
@@ -28,12 +29,11 @@ const opaque = <Kind extends string>(value: string) => value as OpaqueId<Kind>;
*/
export const capabilityId = {
workspace: (value: string) => opaque<"WorkspaceId">(value),
workspaceRevision: (value: string) =>
opaque<"WorkspaceRevisionId">(value),
workspaceRevision: (value: string) => opaque<"WorkspaceRevisionId">(value),
atom: (value: string) => opaque<"AtomId">(value),
conformance: (value: string) => opaque<"ConformanceId">(value),
interface: (value: string) => opaque<"InterfaceId">(value),
interfaceRevision: (value: string) =>
opaque<"InterfaceRevisionId">(value),
interfaceRevision: (value: string) => opaque<"InterfaceRevisionId">(value),
member: (value: string) => opaque<"MemberId">(value),
operation: (value: string) => opaque<"OperationId">(value),
slot: (value: string) => opaque<"SlotId">(value),
@@ -51,15 +51,7 @@ export interface SourceRevision {
commit: string;
}
export type ScalarValueTypeName =
| "bool"
| "bytes"
| "double"
| "int32"
| "int64"
| "string"
| "uint32"
| "uint64";
export type ScalarValueTypeName = "bool" | "bytes" | "double" | "int32" | "int64" | "string" | "uint32" | "uint64";
export type ObjectExpectation =
| { kind: "atom"; atomId: AtomId }
@@ -72,6 +64,7 @@ export type ValueType =
| { kind: "builtin"; name: "unit" | "watch-handle" }
| { kind: "scalar"; name: ScalarValueTypeName }
| { kind: "message"; descriptorId: string }
| { kind: "record"; fields: Record<string, ValueType> }
| { kind: "object-ref"; expectation: ObjectExpectation }
| { kind: "optional"; value: ValueType }
| { kind: "list"; value: ValueType };
@@ -109,39 +102,35 @@ export interface AtomDefinition {
documentation?: string;
}
export type InterfaceOperationMode =
| "call"
| "watch-start"
| "watch-stop"
| "subscribe"
| "unsubscribe";
export type InterfaceOperationMode = "call" | "watch-start" | "watch-stop" | "subscribe" | "unsubscribe";
export interface InterfaceOperation {
export interface InterfaceOperation<Type = ValueType> {
id: OperationId;
displayName: string;
inputType: ValueType;
outputType: ValueType;
inputType: Type;
outputType: Type;
mode: InterfaceOperationMode;
/** Class capabilities have no object receiver and bind free functions. */
scope?: "class";
/** Required for watch-start/subscribe and absent for other modes. */
eventType?: ValueType;
eventType?: Type;
}
interface InterfaceMemberBase {
export type QueryReadContract = { execution: "native" | "rpc-permitted" };
interface InterfaceMemberBase<Type = ValueType> {
id: MemberId;
displayName: string;
operations: InterfaceOperation[];
operations: InterfaceOperation<Type>[];
}
export interface ValueInterfaceMember extends InterfaceMemberBase {
export interface ValueInterfaceMember<Type = ValueType> extends InterfaceMemberBase<Type> {
kind: "value";
valueType: ValueType;
queryRead?: QueryReadContract;
valueType: Type;
}
export type EdgeCardinality =
| "optional-one"
| "exactly-one"
| "many"
| "many-unique";
export type EdgeCardinality = "optional-one" | "exactly-one" | "many" | "many-unique";
export type EdgeEndpointConstraint =
| { kind: "atom"; atomId: AtomId }
@@ -150,24 +139,29 @@ export type EdgeEndpointConstraint =
interfaceRevisionId: InterfaceRevisionId;
};
export interface RelationshipInterfaceMember extends InterfaceMemberBase {
export interface RelationshipInterfaceMember<
Type = ValueType,
Target = EdgeEndpointConstraint,
> extends InterfaceMemberBase<Type> {
kind: "relationship";
target: EdgeEndpointConstraint;
queryRead?: QueryReadContract;
target: Target;
cardinality: EdgeCardinality;
ordered: boolean;
keyType?: "string" | "boolean" | "int64";
}
/** A named callable capability that is not value or relationship sugar. */
export interface OperationInterfaceMember extends InterfaceMemberBase {
export interface OperationInterfaceMember<Type = ValueType> extends InterfaceMemberBase<Type> {
kind: "operation";
inputType: ValueType;
outputType: ValueType;
inputType: Type;
outputType: Type;
}
export type InterfaceMember =
| ValueInterfaceMember
| RelationshipInterfaceMember
| OperationInterfaceMember;
export type InterfaceMember<Type = ValueType, Target = EdgeEndpointConstraint> =
| ValueInterfaceMember<Type>
| RelationshipInterfaceMember<Type, Target>
| OperationInterfaceMember<Type>;
export interface InterfaceRevision {
interfaceId: InterfaceId;
@@ -175,11 +169,15 @@ export interface InterfaceRevision {
displayName: string;
source: SourceRevision;
members: InterfaceMember[];
/** Authored generic definition; never an executable contract by itself. */
template?: import("./generics.js").GenericInterfaceTemplate;
/** Immutable provenance of a closed generic application. */
application?: import("./generics.js").AppliedInterfaceIdentity;
requiredInterfaces?: InterfaceRevisionId[];
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
}
export type StoragePolicy =
| { kind: "optimistic-register" }
| { kind: "crdt-document"; updateType: ValueType };
export type StoragePolicy = { kind: "optimistic-register" } | { kind: "crdt-document"; updateType: ValueType };
export interface StateSlotDefinition {
kind: "state";
@@ -192,11 +190,15 @@ export interface StateSlotDefinition {
}
export interface EdgeEndpoint {
keyType?: "string" | "boolean" | "int64";
publicTraversal?: boolean;
projectionId: EdgeProjectionId;
displayName: string;
constraint: EdgeEndpointConstraint;
cardinality: EdgeCardinality;
ordered: boolean;
onDelete?: "restrict" | "detach" | "cascade-other";
retainOther?: boolean;
}
export interface EdgeDefinition {
@@ -208,18 +210,9 @@ export interface EdgeDefinition {
export type PersistentAttachment = StateSlotDefinition | EdgeDefinition;
export type StatePrimitive =
| "read"
| "write"
| "watch-start"
| "watch-stop";
export type StatePrimitive = "read" | "write" | "watch-start" | "watch-stop";
export type EdgePrimitive =
| "resolve"
| "connect"
| "disconnect"
| "watch-start"
| "watch-stop";
export type EdgePrimitive = "resolve" | "connect" | "disconnect" | "watch-start" | "watch-stop";
export type PackageReceiverRequirement =
| { kind: "any-object" }
@@ -230,6 +223,7 @@ export type PackageReceiverRequirement =
};
export type DependencyPortRequirement =
| { kind: "query"; packageRevisionId: PackageRevisionId; queryId: string }
| {
kind: "state";
valueType: ValueType;
@@ -242,10 +236,10 @@ export type DependencyPortRequirement =
primitives: EdgePrimitive[];
}
| {
kind: "receiver-interface";
kind: "interface";
interfaceRevisionId: InterfaceRevisionId;
}
| { kind: "constructor"; atomId: AtomId };
| { kind: "constructor"; atomId: AtomId; inputType?: ValueType };
export interface DependencyPort {
id: DependencyPortId;
@@ -259,6 +253,8 @@ interface PackageExportBase {
inputType: ValueType;
outputType: ValueType;
dependencyPorts: DependencyPort[];
/** Closed adapter served by the same immutable package build. */
application?: { exportId: PackageExportId; arguments: import("./generics.js").ClosedTypeArgument[]; self?: AtomId };
}
export interface PackageOperationExport extends PackageExportBase {
@@ -277,29 +273,37 @@ export interface PackageConstructorExport extends PackageExportBase {
constructsAtom: AtomId;
}
export type PackageExport =
| PackageOperationExport
| PackageFunctionExport
| PackageConstructorExport;
export type PackageExport = PackageOperationExport | PackageFunctionExport | PackageConstructorExport;
export interface PackageRevision {
queries?: import("../query/types.js").QueryDeclaration[];
checkedQueries?: import("../query/types.js").CheckedQuery[];
queryTemplates?: import("../query/types.js").QueryTemplate[];
genericExports?: import("./generic-packages.js").GenericPackageExport[];
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
migrationCatalog?: import("./migrations.js").MigrationCatalog;
packageId: PackageId;
revisionId: PackageRevisionId;
displayName: string;
source: SourceRevision;
exports: PackageExport[];
/** Author-declared implementation semantics; omitted legacy values mean 1. */
semanticMajor?: number;
}
export type DependencyBinding =
| { kind: "state"; slotId: SlotId }
| { kind: "query"; packageRevisionId: PackageRevisionId; queryId: string; via?: EdgeTraversal }
| { kind: "state"; slotId: SlotId; via?: EdgeTraversal }
| {
kind: "edge";
edgeTypeId: EdgeTypeId;
projectionId: EdgeProjectionId;
via?: EdgeTraversal;
}
| {
kind: "receiver-interface";
kind: "interface";
interfaceRevisionId: InterfaceRevisionId;
via?: EdgeTraversal;
}
| { kind: "constructor"; atomId: AtomId };
@@ -308,6 +312,24 @@ export interface BoundDependency {
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 =
| {
kind: "state";
@@ -330,13 +352,19 @@ export type Binding =
export interface OperationBinding {
operationId: OperationId;
binding: Binding;
/** Required for package-backed reads of an RPC-permitted queryable field. */
queryReason?: string;
}
export interface Conformance {
/** Absent only for legacy assemblies awaiting explicit ownership enrollment. */
id?: ConformanceId;
semanticMajor?: number;
atomId: AtomId;
interfaceRevisionId: InterfaceRevisionId;
privateAttachments: PersistentAttachment[];
operationBindings: OperationBinding[];
relationshipMaterializations: RelationshipMaterialization[];
}
export interface AtomConstructorBinding {
@@ -347,6 +375,7 @@ export interface AtomConstructorBinding {
}
export interface WorkspaceRevision {
linkedQueries?: import("../query/link.js").LinkedQuery[];
id: WorkspaceRevisionId;
workspaceId: WorkspaceId;
parentRevisionIds: WorkspaceRevisionId[];
File diff suppressed because it is too large Load Diff
+2 -8
View File
@@ -1,10 +1,6 @@
#!/usr/bin/env node
import fs from "node:fs";
import {
packageDescriptorToJson,
parsePackageDescriptorTextproto,
validatePackageDescriptor,
} from "./descriptor.js";
import { packageDescriptorToJson, parsePackageDescriptorTextproto, validatePackageDescriptor } from "./descriptor.js";
const usage = (): never => {
console.error("Usage: quixos-descriptor-check <descriptor.txtpb>");
@@ -14,9 +10,7 @@ const usage = (): never => {
const path = process.argv[2] ?? usage();
try {
const descriptor = parsePackageDescriptorTextproto(
fs.readFileSync(path, "utf8"),
);
const descriptor = parsePackageDescriptorTextproto(fs.readFileSync(path, "utf8"));
const errors = validatePackageDescriptor(descriptor);
if (errors.length > 0) {
for (const error of errors) {
+5 -15
View File
@@ -14,19 +14,13 @@ const protoPaths = () => {
path.resolve(process.cwd(), "proto"),
path.resolve(process.cwd(), "quixos-protocol/proto"),
];
return [...new Set(candidates)].filter((candidate) =>
fs.existsSync(path.join(candidate, "quixos/package.proto")),
);
return [...new Set(candidates)].filter((candidate) => fs.existsSync(path.join(candidate, "quixos/package.proto")));
};
export const parsePackageDescriptorTextproto = (
text: string,
): PackageDescriptor => {
export const parsePackageDescriptorTextproto = (text: string): PackageDescriptor => {
const paths = protoPaths();
if (paths.length === 0) {
throw new Error(
"QUIXOS_PROTO_PATH must include quixos-protocol/proto to parse package descriptors",
);
throw new Error("QUIXOS_PROTO_PATH must include quixos-protocol/proto to parse package descriptors");
}
const result = childProcess.spawnSync(
"protoc",
@@ -44,9 +38,7 @@ export const parsePackageDescriptorTextproto = (
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`Failed to parse package descriptor textproto:\n${result.stderr.toString().trim()}`,
);
throw new Error(`Failed to parse package descriptor textproto:\n${result.stderr.toString().trim()}`);
}
return fromBinary(PackageDescriptorSchema, result.stdout);
};
@@ -56,9 +48,7 @@ export const packageDescriptorToJson = (descriptor: PackageDescriptor) =>
prettySpaces: 2,
});
export const validatePackageDescriptor = (
descriptor: PackageDescriptor,
): string[] => {
export const validatePackageDescriptor = (descriptor: PackageDescriptor): string[] => {
const errors: string[] = [];
if (!descriptor.packageId) {
errors.push("packageId is required");
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+537 -21
View File
File diff suppressed because one or more lines are too long
+64 -6
View File
@@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file quixos/refs.proto.
*/
export const file_quixos_refs: GenFile = /*@__PURE__*/
fileDesc("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zIkQKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCSJCChBQYWNrYWdlRXhwb3J0UmVmEhsKE3BhY2thZ2VfcmV2aXNpb25faWQYASABKAkSEQoJZXhwb3J0X2lkGAIgASgJIroBChJJbmplY3RlZERlcGVuZGVuY3kSDwoHcG9ydF9pZBgBIAEoCRIXCg1zdGF0ZV9zbG90X2lkGAIgASgJSAASJgoEZWRnZRgDIAEoCzIWLnF1aXhvcy5FZGdlRGVwZW5kZW5jeUgAEigKHnJlY2VpdmVyX2ludGVyZmFjZV9yZXZpc2lvbl9pZBgEIAEoCUgAEh0KE2NvbnN0cnVjdG9yX2F0b21faWQYBSABKAlIAEIJCgdiaW5kaW5nIj0KDkVkZ2VEZXBlbmRlbmN5EhQKDGVkZ2VfdHlwZV9pZBgBIAEoCRIVCg1wcm9qZWN0aW9uX2lkGAIgASgJYgZwcm90bzM");
fileDesc("ChFxdWl4b3MvcmVmcy5wcm90bxIGcXVpeG9zInUKDUNhcGFiaWxpdHlSZWYSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAEgASgJEhQKDG9wZXJhdGlvbl9pZBgCIAEoCRIvCgtjb25mb3JtYW5jZRgDIAEoCzIaLnF1aXhvcy5Db25mb3JtYW5jZVdpdG5lc3MilgEKEkNvbmZvcm1hbmNlV2l0bmVzcxIRCglvYmplY3RfaWQYASABKAkSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAIgASgJEhYKDmNvbmZvcm1hbmNlX2lkGAMgASgJEh0KFXdvcmtzcGFjZV9yZXZpc2lvbl9pZBgEIAEoCRIXCg93b3Jrc3BhY2VfZXBvY2gYBSABKAkiQgoQUGFja2FnZUV4cG9ydFJlZhIbChNwYWNrYWdlX3JldmlzaW9uX2lkGAEgASgJEhEKCWV4cG9ydF9pZBgCIAEoCSLYAQoSSW5qZWN0ZWREZXBlbmRlbmN5Eg8KB3BvcnRfaWQYASABKAkSFwoNc3RhdGVfc2xvdF9pZBgCIAEoCUgAEiYKBGVkZ2UYAyABKAsyFi5xdWl4b3MuRWRnZURlcGVuZGVuY3lIABIfChVpbnRlcmZhY2VfcmV2aXNpb25faWQYBCABKAlIABIdChNjb25zdHJ1Y3Rvcl9hdG9tX2lkGAUgASgJSAASEgoIcXVlcnlfaWQYByABKAlIABIRCglvYmplY3RfaWQYBiABKAlCCQoHYmluZGluZyI9Cg5FZGdlRGVwZW5kZW5jeRIUCgxlZGdlX3R5cGVfaWQYASABKAkSFQoNcHJvamVjdGlvbl9pZBgCIAEoCWIGcHJvdG8z");
/**
* @generated from message quixos.CapabilityRef
@@ -25,6 +25,13 @@ export type CapabilityRef = Message<"quixos.CapabilityRef"> & {
* @generated from field: string operation_id = 2;
*/
operationId: string;
/**
* Optional fence for a view acquired through TryConform. Not an authority grant.
*
* @generated from field: quixos.ConformanceWitness conformance = 3;
*/
conformance?: ConformanceWitness | undefined;
};
/**
@@ -34,6 +41,43 @@ export type CapabilityRef = Message<"quixos.CapabilityRef"> & {
export const CapabilityRefSchema: GenMessage<CapabilityRef> = /*@__PURE__*/
messageDesc(file_quixos_refs, 0);
/**
* @generated from message quixos.ConformanceWitness
*/
export type ConformanceWitness = Message<"quixos.ConformanceWitness"> & {
/**
* @generated from field: string object_id = 1;
*/
objectId: string;
/**
* @generated from field: string interface_revision_id = 2;
*/
interfaceRevisionId: string;
/**
* @generated from field: string conformance_id = 3;
*/
conformanceId: string;
/**
* @generated from field: string workspace_revision_id = 4;
*/
workspaceRevisionId: string;
/**
* @generated from field: string workspace_epoch = 5;
*/
workspaceEpoch: string;
};
/**
* Describes the message quixos.ConformanceWitness.
* Use `create(ConformanceWitnessSchema)` to create a new message.
*/
export const ConformanceWitnessSchema: GenMessage<ConformanceWitness> = /*@__PURE__*/
messageDesc(file_quixos_refs, 1);
/**
* @generated from message quixos.PackageExportRef
*/
@@ -54,7 +98,7 @@ export type PackageExportRef = Message<"quixos.PackageExportRef"> & {
* Use `create(PackageExportRefSchema)` to create a new message.
*/
export const PackageExportRefSchema: GenMessage<PackageExportRef> = /*@__PURE__*/
messageDesc(file_quixos_refs, 1);
messageDesc(file_quixos_refs, 2);
/**
* @generated from message quixos.InjectedDependency
@@ -82,17 +126,31 @@ export type InjectedDependency = Message<"quixos.InjectedDependency"> & {
case: "edge";
} | {
/**
* @generated from field: string receiver_interface_revision_id = 4;
* @generated from field: string interface_revision_id = 4;
*/
value: string;
case: "receiverInterfaceRevisionId";
case: "interfaceRevisionId";
} | {
/**
* @generated from field: string constructor_atom_id = 5;
*/
value: string;
case: "constructorAtomId";
} | {
/**
* @generated from field: string query_id = 7;
*/
value: string;
case: "queryId";
} | { 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;
};
/**
@@ -100,7 +158,7 @@ export type InjectedDependency = Message<"quixos.InjectedDependency"> & {
* Use `create(InjectedDependencySchema)` to create a new message.
*/
export const InjectedDependencySchema: GenMessage<InjectedDependency> = /*@__PURE__*/
messageDesc(file_quixos_refs, 2);
messageDesc(file_quixos_refs, 3);
/**
* @generated from message quixos.EdgeDependency
@@ -122,5 +180,5 @@ export type EdgeDependency = Message<"quixos.EdgeDependency"> & {
* Use `create(EdgeDependencySchema)` to create a new message.
*/
export const EdgeDependencySchema: GenMessage<EdgeDependency> = /*@__PURE__*/
messageDesc(file_quixos_refs, 3);
messageDesc(file_quixos_refs, 4);
+142 -6
View File
@@ -14,7 +14,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file quixos/runtime.proto.
*/
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
@@ -24,6 +24,11 @@ export type HandshakeRequest = Message<"quixos.runtime.HandshakeRequest"> & {
* @generated from field: string orch_protocol_version = 1;
*/
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;
*/
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__*/
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
*/
@@ -88,6 +193,11 @@ export type InvokeRequest = Message<"quixos.runtime.InvokeRequest"> & {
* @generated from field: repeated quixos.InjectedDependency dependencies = 5;
*/
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.
*/
export const InvokeRequestSchema: GenMessage<InvokeRequest> = /*@__PURE__*/
messageDesc(file_quixos_runtime, 2);
messageDesc(file_quixos_runtime, 5);
/**
* @generated from message quixos.runtime.InvokeResponse
@@ -115,6 +225,11 @@ export type InvokeResponse = Message<"quixos.runtime.InvokeResponse"> & {
* @generated from field: string error = 3;
*/
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.
*/
export const InvokeResponseSchema: GenMessage<InvokeResponse> = /*@__PURE__*/
messageDesc(file_quixos_runtime, 3);
messageDesc(file_quixos_runtime, 6);
/**
* @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;
*/
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.
*/
export const WatchRequestSchema: GenMessage<WatchRequest> = /*@__PURE__*/
messageDesc(file_quixos_runtime, 4);
messageDesc(file_quixos_runtime, 7);
/**
* @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.
*/
export const DerivedDependencySchema: GenMessage<DerivedDependency> = /*@__PURE__*/
messageDesc(file_quixos_runtime, 5);
messageDesc(file_quixos_runtime, 8);
/**
* @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.
*/
export const WatchEventSchema: GenMessage<WatchEvent> = /*@__PURE__*/
messageDesc(file_quixos_runtime, 6);
messageDesc(file_quixos_runtime, 9);
/**
* @generated from service quixos.runtime.PackageRuntime
@@ -258,6 +378,22 @@ export const PackageRuntime: GenService<{
input: typeof WatchRequestSchema;
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__*/
serviceDesc(file_quixos_runtime, 0);
+452
View File
@@ -0,0 +1,452 @@
import {
parse,
Source,
validate,
specifiedRules,
print,
printSchema,
visit,
TypeInfo,
visitWithTypeInfo,
getNamedType,
isNonNullType,
isListType,
isObjectType,
isInputObjectType,
isScalarType,
isEnumType,
typeFromAST,
type GraphQLType,
type SelectionSetNode,
type FragmentDefinitionNode,
type ValueNode,
type ASTNode,
type DocumentNode,
type DirectiveNode,
} from "graphql";
import { createHash } from "node:crypto";
import { canonicalJson } from "../capability-model/evolution.js";
import { readRepositorySource } from "../capability-language/source-loader.js";
import {
valueType,
type InterfaceRevision,
type ValueType,
type InterfaceRevisionId,
} from "../capability-model/types.js";
import { querySchema } from "./schema.js";
import { objectRow } from "./relational-schema.js";
import { relationalCompiler } from "./relational-compile.js";
import {
QueryCompileError,
type QueryDeclaration,
type CheckedQuery,
type QueryFieldEffect,
type QueryUse,
type QueryArgument,
type QuerySelection,
} from "./types.js";
const fail = (code: string, message: string, node?: ASTNode): never => {
const token = node?.loc?.startToken;
throw new QueryCompileError(
code,
message,
token
? {
file: node!.loc!.source.name,
line: token.line,
column: token.column,
}
: undefined,
);
};
/** Only immutable repository sources call this. Runtime requests never compile documents. */
export async function compileQuery(
declaration: QueryDeclaration,
interfaces: readonly InterfaceRevision[],
read: (name: string) => Promise<string>,
): Promise<CheckedQuery> {
const paths = [declaration.document, ...declaration.fragments];
if (paths.length > 128 || new Set(paths).size !== paths.length)
fail("QUERY_SOURCE_LIMIT", "Query files must be distinct and bounded to 128");
const definitions: DocumentNode["definitions"][number][] = [];
let totalBytes = 0;
for (const path of paths) {
if (
!path.endsWith(".graphql") ||
path.startsWith("/") ||
path.split(/[\\/]/).some((part) => !part || part === "." || part === "..")
)
fail("QUERY_SOURCE_PATH", `Invalid query source path ${path}`);
const text = await read(path);
totalBytes += Buffer.byteLength(text);
if (totalBytes > 262144) fail("QUERY_SOURCE_LIMIT", "Query sources exceed 256 KiB");
const document = parse(new Source(text, path), { maxTokens: 10000 });
definitions.push(...document.definitions);
}
const document: DocumentNode = { kind: "Document" as DocumentNode["kind"], definitions };
const generated = querySchema(declaration, interfaces, document);
const operations = definitions.filter((entry) => entry.kind === "OperationDefinition");
if (
operations.length !== 1 ||
operations[0]!.operation !== "query" ||
operations[0]!.name?.value !== declaration.operation
)
fail("QUERY_UNSUPPORTED_FEATURE", "Exactly one named query matching the declaration is required");
visit(document, {
Field(node) {
if (node.name.value === "_unavailable")
fail("QUERY_AGGREGATE_TYPE", "No supported field exists at this operator path", node);
if (node.name.value.startsWith("__")) fail("QUERY_UNSUPPORTED_FEATURE", "Introspection is not supported", node);
},
Directive(node) {
if (!["include", "skip"].includes(node.name.value))
fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported directive ${node.name.value}`, node);
},
InlineFragment(node) {
fail("QUERY_UNSUPPORTED_FEATURE", "Use named fragments on the exact selected type", node);
},
});
const errors = validate(generated.schema, document, specifiedRules, { maxErrors: 20 });
if (errors.length) {
const error = errors[0]!;
fail("QUERY_VALIDATION", error.message, error.nodes?.[0]);
}
const effects = new Map<string, QueryFieldEffect>();
const fragments = new Map(
definitions
.filter((entry): entry is FragmentDefinitionNode => entry.kind === "FragmentDefinition")
.map((entry) => [entry.name.value, entry]),
);
const mark = (id: InterfaceRevisionId, name: string, use: QueryUse, node: ASTNode) => {
const contract = generated.contracts.get(id)!;
const member = contract.members.find((entry) => entry.displayName === name);
if (!member || member.kind === "operation" || !member.queryRead)
return fail("QUERY_FIELD_NOT_QUERYABLE", `${contract.displayName}.${name} is not queryable`, node);
const key = `${id}\0${member.id}`;
const effect = effects.get(key) ?? {
interfaceRevisionId: id,
memberId: member.id,
uses: [],
execution: member.queryRead.execution,
};
if (!effect.uses.includes(use)) effect.uses.push(use);
effects.set(key, effect);
if (
effect.execution === "rpc-permitted" &&
!declaration.allowances.some(
(allow) =>
allow.interfaceRevisionId === id &&
allow.memberId === member.id &&
allow.uses.includes(use) &&
allow.reason.trim(),
)
)
fail("QUERY_RPC_CONSUMER_REASON", `${contract.displayName}.${name} needs an allowance for ${use}`, node);
if (
declaration.watch &&
effect.execution === "rpc-permitted" &&
!declaration.polling &&
!member.operations.some((op) => op.displayName === "watch-start")
)
fail(
"QUERY_WATCH_UNSUPPORTED",
`${contract.displayName}.${name} has no watch; explicitly acknowledge polling or use a one-shot query`,
node,
);
};
const relational = relationalCompiler(generated, declaration, mark, fragments);
const compiledPredicates = new WeakMap<ASTNode, import("./relational.js").QueryPredicate>();
const inputEffects = (id: InterfaceRevisionId, value: ValueNode, use: "predicate" | "order") => {
if (use === "predicate") {
relational.predicate(objectRow(id), value);
return;
}
if (value.kind === "Variable") {
// A dynamic filter may name any field in its declared input type.
for (const member of generated.contracts.get(id)!.members)
if (member.kind === "value" && member.queryRead) mark(id, member.displayName, use, value);
} else if (value.kind === "ListValue") value.values.forEach((child) => inputEffects(id, child, use));
else if (value.kind === "ObjectValue")
for (const field of value.fields) {
mark(id, field.name.value, use, field);
}
};
const info = new TypeInfo(generated.schema);
visit(
document,
visitWithTypeInfo(info, {
Field(node) {
const parent = info.getParentType();
const contract = parent && generated.byName.get(parent.name);
if (!contract || node.name.value === "_qx") return;
mark(contract.revisionId, node.name.value, "select", node);
const member = contract.members.find((entry) => entry.displayName === node.name.value)!;
if (member.kind !== "relationship" || (member.cardinality !== "many" && member.cardinality !== "many-unique"))
return;
const target = generated.target(member);
const bounds = (node.arguments ?? []).filter(
(argument) => argument.name.value === "first" || argument.name.value === "all",
);
if (bounds.length !== 1) fail("QUERY_ROW_LIMIT", "Specify exactly one of first or all", node);
if (bounds[0]!.name.value === "all" && node.arguments?.some((argument) => argument.name.value === "after"))
fail("QUERY_UNSUPPORTED_FEATURE", "Bounded-all does not accept a continuation", node);
for (const argument of node.arguments ?? []) {
if (argument.name.value === "where")
compiledPredicates.set(node, relational.predicate(objectRow(target), argument.value));
if (argument.name.value === "orderBy") inputEffects(target, argument.value, "order");
if (
["first", "all"].includes(argument.name.value) &&
argument.value.kind !== "Variable" &&
(argument.value.kind !== "IntValue" ||
Number(argument.value.value) < 1 ||
Number(argument.value.value) > declaration.budgets.rows)
)
fail(
"QUERY_ROW_LIMIT",
`${argument.name.value} must be between 1 and ${declaration.budgets.rows}`,
argument,
);
}
if (node.arguments?.some((a) => a.name.value === "after")) {
const rpcOrder = (value: ValueNode): boolean =>
value.kind === "Variable"
? generated.contracts
.get(target)!
.members.some((m) => m.kind === "value" && m.queryRead?.execution === "rpc-permitted")
: value.kind === "ListValue"
? value.values.some(rpcOrder)
: value.kind === "ObjectValue" &&
value.fields.some((f) =>
generated.contracts
.get(target)!
.members.some(
(m) =>
m.displayName === f.name.value &&
m.kind === "value" &&
m.queryRead?.execution === "rpc-permitted",
),
);
const order = node.arguments.find((a) => a.name.value === "orderBy");
if (relational.needsRpc(compiledPredicates.get(node)) || (order && rpcOrder(order.value)))
fail(
"QUERY_UNSUPPORTED_FEATURE",
"RPC predicates/order require a bounded first/all window without continuation",
node,
);
}
},
FragmentSpread(node) {
const fragment = definitions.find(
(entry) => entry.kind === "FragmentDefinition" && entry.name.value === node.name.value,
) as FragmentDefinitionNode;
if (fragment.typeCondition.name.value !== info.getParentType()?.name)
fail("QUERY_UNSUPPORTED_FEATURE", "Fragments must select the exact current type", node);
},
}),
);
let expandedFields = 0;
// GraphQL merges repeated response keys. In particular, fragments may each
// contribute different children of one relationship; last-write-wins would
// silently remove fields from both the generated type and the wire codec.
const mergeOutput = (left: ValueType, right: ValueType): ValueType => {
const a = left.kind === "optional" ? left.value : left;
const b = right.kind === "optional" ? right.value : right;
let result = a;
if (a.kind === "record" && b.kind === "record") {
const fields = { ...a.fields };
for (const [key, type] of Object.entries(b.fields))
fields[key] = fields[key] ? mergeOutput(fields[key], type) : type;
result = { kind: "record", fields };
} else if (a.kind === "list" && b.kind === "list") result = valueType.list(mergeOutput(a.value, b.value));
return left.kind === "optional" && right.kind === "optional" ? valueType.optional(result) : result;
};
const output = (type: GraphQLType, selections?: SelectionSetNode, depth = 0, conditional = false): ValueType => {
if (depth > declaration.budgets.depth * 4 + 4) fail("QUERY_DEPTH_LIMIT", "Expanded query exceeds its depth budget");
if (isNonNullType(type)) return required(type.ofType, selections, depth, conditional);
return valueType.optional(required(type, selections, depth, conditional));
};
const required = (type: GraphQLType, selections?: SelectionSetNode, depth = 0, conditional = false): ValueType => {
if (isListType(type)) return valueType.list(output(type.ofType, selections, depth, conditional));
if (isObjectType(type)) {
const fields: Record<string, ValueType> = {};
const add = (set: SelectionSetNode, conditional = false) => {
for (const selection of set.selections) {
if (++expandedFields > 10000) fail("QUERY_WORK_LIMIT", "Expanded query exceeds 10000 fields", selection);
if (selection.kind === "FragmentSpread") {
add(fragments.get(selection.name.value)!.selectionSet, conditional || !!selection.directives?.length);
continue;
}
if (selection.kind !== "Field") continue;
const key = selection.alias?.value ?? selection.name.value;
const field = type.getFields()[selection.name.value]!;
const previous = fields[key];
fields[key] = output(
field.type,
selection.selectionSet,
depth + 1,
conditional || !!selection.directives?.length,
);
if ((conditional || selection.directives?.length) && fields[key]!.kind !== "optional")
fields[key] = valueType.optional(fields[key]!);
if (previous) fields[key] = mergeOutput(previous, fields[key]!);
}
};
if (selections) add(selections, conditional);
return { kind: "record", fields };
}
return (
generated.scalarTypes.get(getNamedType(type)!.name) ??
fail("QUERY_TYPE", `Unsupported query scalar ${getNamedType(type)!.name}`)
);
};
const variables: Record<string, ValueType> = {};
const inputShape = (type: GraphQLType, seen = new Set<string>()): ValueType => {
if (isNonNullType(type)) return inputRequired(type.ofType, seen);
return valueType.optional(inputRequired(type, seen));
};
const inputRequired = (type: GraphQLType, seen: Set<string>): ValueType => {
if (isListType(type)) return valueType.list(inputShape(type.ofType, seen));
if (isInputObjectType(type)) {
// Recursive boolean filter inputs need generated recursive TS shapes; never degrade to any.
if (seen.has(type.name))
fail(
"QUERY_UNSUPPORTED_FEATURE",
"Pass scalar variables inside fixed filter expressions instead of a whole recursive filter",
);
return {
kind: "record",
fields: Object.fromEntries(
Object.entries(type.getFields()).map(([key, field]) => [
key,
inputShape(field.type, new Set([...seen, type.name])),
]),
),
};
}
if (isEnumType(type)) return valueType.string;
if (isScalarType(type))
return generated.scalarTypes.get(type.name) ?? fail("QUERY_TYPE", `Unsupported query scalar ${type.name}`);
return fail("QUERY_VARIABLE_TYPE", "Unsupported variable type");
};
for (const variable of operations[0]!.variableDefinitions ?? [])
variables[variable.variable.name.value] = inputShape(typeFromAST(generated.schema, variable.type)!);
const result = required(generated.schema.getQueryType()!, operations[0]!.selectionSet);
const argument = (node: ValueNode): QueryArgument => {
switch (node.kind) {
case "Variable":
return { kind: "variable", name: node.name.value };
case "ListValue":
return { kind: "list", values: node.values.map(argument) };
case "ObjectValue":
return {
kind: "object",
fields: Object.fromEntries(node.fields.map((field) => [field.name.value, argument(field.value)])),
};
case "NullValue":
return { kind: "literal", value: null };
case "BooleanValue":
return { kind: "literal", value: node.value };
// Int literals stay decimal until their checked field codec interprets them.
default:
return { kind: "literal", value: node.value };
}
};
const conditions = (directives: readonly DirectiveNode[] = []) =>
directives.map((directive) => ({
include: directive.name.value === "include",
value: argument(directive.arguments!.find((entry) => entry.name.value === "if")!.value),
}));
const selection = (
set: SelectionSetNode,
parent: import("graphql").GraphQLObjectType,
inherited: QuerySelection["conditions"] = [],
): QuerySelection[] =>
set.selections.flatMap((node): QuerySelection[] => {
if (node.kind === "FragmentSpread")
return selection(fragments.get(node.name.value)!.selectionSet, parent, [
...inherited,
...conditions(node.directives),
]);
if (node.kind !== "Field") return [];
const contract = generated.byName.get(parent.name);
const member = contract?.members.find((entry) => entry.displayName === node.name.value);
const type = getNamedType(parent.getFields()[node.name.value]!.type);
const role = generated.relational.roles.get(parent.name);
const relationship =
role?.kind === "relations"
? generated.relational.relations(role.row).find((m) => m.displayName === node.name.value)
: undefined;
const plan =
relationship && role?.row.kind === "object" && node.selectionSet
? relational.plan(role.row.interfaceRevisionId, relationship, node.selectionSet, (set, name) => {
const t = generated.schema.getType(name);
if (!t || !isObjectType(t)) return fail("QUERY_TYPE", `Missing generated output type ${name}`, node);
return selection(set, t);
})
: undefined;
return [
{
name: node.name.value,
key: node.alias?.value ?? node.name.value,
...(member && contract ? { interfaceRevisionId: contract.revisionId, memberId: member.id } : {}),
...(member?.kind === "relationship" ? { targetInterfaceRevisionId: generated.target(member) } : {}),
conditions: [...inherited, ...conditions(node.directives)],
arguments: Object.fromEntries(
(node.arguments ?? []).map((entry) => [entry.name.value, argument(entry.value)]),
),
selection: node.selectionSet && isObjectType(type) ? selection(node.selectionSet, type) : [],
...(plan ? { relational: plan } : {}),
...(compiledPredicates.has(node) ? { predicate: compiledPredicates.get(node)! } : {}),
},
];
});
const normalized = print(document),
schema = printSchema(generated.schema);
const selections = selection(operations[0]!.selectionSet, generated.schema.getQueryType()!);
const definitionDigest = createHash("sha256")
.update(
canonicalJson({
semantics: 2,
declaration,
normalized,
interfaces: [...generated.byName.values()].sort((a, b) =>
a.revisionId < b.revisionId ? -1 : a.revisionId > b.revisionId ? 1 : 0,
),
}),
)
.digest("hex");
return {
declaration,
definitionDigest,
document: normalized,
schema,
variables: { kind: "record", fields: variables },
output: result,
effects: [...effects.values()],
selection: selections,
variableDefaults: Object.fromEntries(
(operations[0]!.variableDefinitions ?? [])
.filter((entry) => entry.defaultValue)
.map((entry) => [entry.variable.name.value, argument(entry.defaultValue!)]),
),
sourceFiles: paths,
};
}
export const compileRepositoryQuery = async (
root: string,
declaration: QueryDeclaration,
interfaces: readonly InterfaceRevision[],
) => {
const started = performance.now();
try {
return await compileQuery(declaration, interfaces, (name) => readRepositorySource(root, name));
} finally {
// Timing belongs in check logs, never in immutable artifact identities.
console.error(
`[query-check] ${JSON.stringify(declaration.displayName)}: ${(performance.now() - started).toFixed(1)}ms`,
);
}
};
+156
View File
@@ -0,0 +1,156 @@
import { createHash } from "node:crypto";
import type {
WorkspaceRevision,
Binding,
AtomId,
InterfaceRevisionId,
MemberId,
PersistentAttachment,
} from "../capability-model/types.js";
import { QueryCompileError, type CheckedQuery } from "./types.js";
import { queryRuntimePlan } from "./proto.js";
import { canonicalJson } from "../capability-model/evolution.js";
export interface LinkedQueryField {
atomId: AtomId;
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
getter: string;
binding: Binding;
watch?: { start: string; stop: string; binding: Binding };
}
export interface LinkedQuery {
runtime?: import("@bufbuild/protobuf").JsonValue;
id: string;
packageRevisionId: string;
checked: CheckedQuery;
bindingDigest: string;
fields: LinkedQueryField[];
attachments: PersistentAttachment[];
}
/** Link every possible implementation, including atoms absent from today's data. */
export function linkQueries(workspace: WorkspaceRevision): LinkedQuery[] {
const interfaces = new Map(workspace.interfaceImports.map((entry) => [entry.revisionId, entry]));
const attachments = [
...workspace.sharedAttachments,
...workspace.conformances.flatMap((entry) => entry.privateAttachments),
];
const linked: LinkedQuery[] = [];
for (const pkg of workspace.packageImports)
for (const declaration of pkg.queries ?? []) {
const checked = pkg.checkedQueries?.find((query) => query.declaration.id === declaration.id);
if (!checked || JSON.stringify(checked.declaration) !== JSON.stringify(declaration))
throw new QueryCompileError(
"QUERY_ARTIFACT_MISSING",
`${pkg.displayName}.${declaration.displayName} has no checked source artifact`,
);
const fields: LinkedQueryField[] = [];
const needed = new Set<string>();
for (const view of declaration.views)
if (
!workspace.conformances.some(
(entry) => entry.atomId === view.atomId && entry.interfaceRevisionId === view.interfaceRevisionId,
)
)
throw new QueryCompileError(
"QUERY_VIEW_REQUIRED",
`${view.atomId} does not conform to declared query view ${view.interfaceRevisionId}`,
);
for (const effect of checked.effects) {
const contract = interfaces.get(effect.interfaceRevisionId);
const member = contract?.members.find((entry) => entry.id === effect.memberId);
if (!member || member.kind === "operation" || member.queryRead?.execution !== effect.execution)
throw new QueryCompileError(
"QUERY_STALE_CONTRACT",
`Query field ${effect.interfaceRevisionId}.${effect.memberId} changed`,
);
const getter = member.operations.find((op) => op.displayName === (member.kind === "value" ? "get" : "resolve"));
if (!getter) throw new QueryCompileError("QUERY_CONTRACT", `Missing getter ${effect.memberId}`);
for (const conformance of workspace.conformances.filter(
(entry) => entry.interfaceRevisionId === effect.interfaceRevisionId,
)) {
const provider = conformance.operationBindings.find((entry) => entry.operationId === getter.id);
if (!provider)
throw new QueryCompileError("QUERY_CONTRACT", `Missing binding ${conformance.atomId}.${getter.id}`);
const binding = provider.binding;
if (binding.kind === "package") {
if (effect.execution !== "rpc-permitted")
throw new QueryCompileError(
"QUERY_NATIVE_BINDING_REQUIRED",
`Native query field ${effect.memberId} binds package code`,
);
if (!provider.queryReason?.trim())
throw new QueryCompileError(
"QUERY_RPC_PROVIDER_REASON",
`Package query field ${effect.memberId} needs query-reason`,
);
} else if (binding.kind === "state" && binding.primitive === "read") needed.add(binding.slotId);
else if (binding.kind === "edge" && binding.primitive === "resolve") needed.add(binding.edgeTypeId);
else
throw new QueryCompileError(
"QUERY_NATIVE_BINDING_REQUIRED",
`Unsupported query read binding ${effect.memberId}`,
);
const start = member.operations.find((op) => op.displayName === "watch-start");
const stop = member.operations.find((op) => op.displayName === "watch-stop");
const watch = start && stop && conformance.operationBindings.find((entry) => entry.operationId === start.id);
if (
binding.kind === "state" &&
watch &&
(watch.binding.kind !== "state" ||
watch.binding.slotId !== binding.slotId ||
watch.binding.primitive !== "watch-start")
)
throw new QueryCompileError(
"QUERY_WATCH_CONTRACT",
`Native query field ${effect.memberId} watch disagrees with its getter`,
);
if (
binding.kind === "edge" &&
watch &&
(watch.binding.kind !== "edge" ||
watch.binding.edgeTypeId !== binding.edgeTypeId ||
watch.binding.projectionId !== binding.projectionId)
)
throw new QueryCompileError(
"QUERY_WATCH_CONTRACT",
`Native query relation ${effect.memberId} watch disagrees with its resolver`,
);
fields.push({
atomId: conformance.atomId,
interfaceRevisionId: effect.interfaceRevisionId,
memberId: effect.memberId,
getter: getter.id,
binding,
...(watch && start && stop ? { watch: { start: start.id, stop: stop.id, binding: watch.binding } } : {}),
});
}
}
const storage = attachments.filter((entry) => needed.has(entry.id));
const bindingDigest = createHash("sha256")
.update(
canonicalJson({
definition: checked.definitionDigest,
fields: [...fields].sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b))),
storage: [...storage].sort((a, b) => a.id.localeCompare(b.id)),
}),
)
.digest("hex");
linked.push({
id: `${pkg.revisionId}:${declaration.id}`,
packageRevisionId: pkg.revisionId,
checked,
bindingDigest,
fields,
attachments: storage,
});
}
for (const entry of linked) entry.runtime = queryRuntimePlan(entry, workspace);
if (new Set(linked.map((entry) => entry.id)).size !== linked.length)
throw new QueryCompileError(
"QUERY_ID_COLLISION",
"Query export identities collide; choose distinct package/query IDs",
);
return linked;
}
+94
View File
@@ -0,0 +1,94 @@
import { create, toJson } from "@bufbuild/protobuf";
import {
InstalledQuerySchema,
QueryArgumentSchema,
QuerySelectionSchema,
QueryReadBindingSchema,
Cardinality,
type QueryArgument as WireArgument,
} from "../gen/camino/schema_pb.js";
import type { LinkedQuery } from "./link.js";
import type { QueryArgument, QuerySelection } from "./types.js";
import type { WorkspaceRevision } from "../capability-model/types.js";
import { relationalWire } from "./relational-proto.js";
const argument = (entry: QueryArgument): WireArgument => {
switch (entry.kind) {
case "variable":
return create(QueryArgumentSchema, { value: { case: "variable", value: entry.name } });
case "literal":
return create(QueryArgumentSchema, { value: { case: "literalJson", value: JSON.stringify(entry.value) } });
case "list":
return create(QueryArgumentSchema, { value: { case: "list", value: { values: entry.values.map(argument) } } });
case "object":
return create(QueryArgumentSchema, {
value: {
case: "object",
value: { fields: Object.fromEntries(Object.entries(entry.fields).map(([k, v]) => [k, argument(v)])) },
},
});
}
};
const selection = (entry: QuerySelection): import("../gen/camino/schema_pb.js").QuerySelection =>
create(QuerySelectionSchema, {
...entry,
conditions: entry.conditions.map((condition) => ({ include: condition.include, value: argument(condition.value) })),
arguments: Object.fromEntries(Object.entries(entry.arguments).map(([k, v]) => [k, argument(v)])),
selection: entry.selection.map(selection),
relational: entry.relational && relationalWire(argument, selection).plan(entry.relational),
predicate: entry.predicate && relationalWire(argument, selection).predicate(entry.predicate),
});
export const querySelectionToWire = selection;
export const queryRuntimePlan = (linked: LinkedQuery, workspace: WorkspaceRevision) =>
toJson(
InstalledQuerySchema,
create(InstalledQuerySchema, {
id: linked.id,
definitionDigest: linked.checked.definitionDigest,
bindingDigest: linked.bindingDigest,
rootInterfaceRevisionId: linked.checked.declaration.root,
selection: linked.checked.selection.map(selection),
budgets: linked.checked.declaration.budgets,
variablesTypeJson: JSON.stringify(linked.checked.variables),
outputTypeJson: JSON.stringify(linked.checked.output),
variableDefaults: Object.fromEntries(
Object.entries(linked.checked.variableDefaults).map(([k, v]) => [k, argument(v)]),
),
watch: linked.checked.declaration.watch,
pollingIntervalMs: linked.checked.declaration.polling?.intervalMs,
rpcPredicateOrOrder: linked.checked.effects.some(
(effect) => effect.execution === "rpc-permitted" && effect.uses.some((use) => use !== "select"),
),
bindings: linked.fields.map((field) => {
const member = workspace.interfaceImports
.find((entry) => entry.revisionId === field.interfaceRevisionId)!
.members.find((entry) => entry.id === field.memberId)!;
return create(QueryReadBindingSchema, {
atomId: field.atomId,
interfaceRevisionId: field.interfaceRevisionId,
memberId: field.memberId,
getterOperationId: field.getter,
fieldName: member.displayName,
keyType: member.kind === "relationship" ? member.keyType : undefined,
valueTypeJson: member.kind === "value" ? JSON.stringify(member.valueType) : "",
cardinality:
member.kind === "relationship"
? {
"optional-one": Cardinality.OPTIONAL_ONE,
"exactly-one": Cardinality.EXACTLY_ONE,
many: Cardinality.MANY,
"many-unique": Cardinality.MANY_UNIQUE,
}[member.cardinality]
: undefined,
slotId: field.binding.kind === "state" ? field.binding.slotId : "",
edgeTypeId: field.binding.kind === "edge" ? field.binding.edgeTypeId : "",
projectionId: field.binding.kind === "edge" ? field.binding.projectionId : "",
rpc: field.binding.kind === "package",
watchStartOperationId: field.watch?.start,
watchStopOperationId: field.watch?.stop,
});
}),
}),
);
+605
View File
@@ -0,0 +1,605 @@
import type { ASTNode, FieldNode, FragmentDefinitionNode, SelectionSetNode, ValueNode } from "graphql";
import {
valueType,
type InterfaceRevisionId,
type RelationshipInterfaceMember,
type ValueType,
} from "../capability-model/types.js";
import {
QueryCompileError,
type QueryArgument,
type QueryDeclaration,
type QuerySelection,
type QueryUse,
} from "./types.js";
import {
aggregateType,
queryKeyType,
type AggregateOperator,
type QueryAggregatePredicate,
type QueryEffectRecorder,
type QueryExpression,
type QueryKey,
type QueryPathStep,
type QueryPredicate,
type QueryReduction,
type QueryRelationalPlan,
type QueryRelationalTerminal,
type QueryRow,
} from "./relational.js";
import { objectRow } from "./relational-schema.js";
export function queryArgument(node: ValueNode): QueryArgument {
if (node.kind === "Variable") return { kind: "variable", name: node.name.value };
if (node.kind === "ListValue") return { kind: "list", values: node.values.map(queryArgument) };
if (node.kind === "ObjectValue")
return {
kind: "object",
fields: Object.fromEntries(node.fields.map((f) => [f.name.value, queryArgument(f.value)])),
};
return { kind: "literal", value: node.kind === "NullValue" ? null : node.value };
}
type Environment = ReturnType<typeof import("./schema.js").querySchema>;
type Availability = readonly QueryKey[] | undefined;
function fail(code: string, message: string, node: ASTNode): never {
const token = node.loc?.startToken;
throw new QueryCompileError(
code,
message,
token ? { file: node.loc!.source.name, line: token.line, column: token.column } : undefined,
);
}
const pairs = (node: ValueNode): readonly import("graphql").ObjectFieldNode[] =>
node.kind === "ObjectValue"
? node.fields
: fail("QUERY_UNSUPPORTED_FEATURE", "Query structure must be a literal object", node);
const ops = new Set(["eq", "in", "isNull", "lt", "lte", "gt", "gte"]);
const prefix = (a: readonly string[], b: readonly string[]) => a.every((part, i) => part === b[i]);
export function relationalCompiler(
env: Environment,
declaration: QueryDeclaration,
mark: QueryEffectRecorder,
fragments: Map<string, FragmentDefinitionNode>,
) {
const rowFields = env.relational.fields;
const relationships = env.relational.relations;
const needsRpc = (value: unknown): boolean => {
if (!value || typeof value !== "object") return false;
if ("leaf" in value) {
const leaf = (value as QueryExpression).leaf;
if (leaf.kind === "field")
return (
env.contracts
.get(leaf.interfaceRevisionId)
?.members.some(
(m) => m.id === leaf.memberId && m.kind === "value" && m.queryRead?.execution === "rpc-permitted",
) ?? false
);
}
return Object.values(value).some(needsRpc);
};
let stageCount = 0;
const fields = (set: SelectionSetNode): FieldNode[] =>
set.selections.flatMap((n) =>
n.kind === "Field" ? [n] : n.kind === "FragmentSpread" ? fields(fragments.get(n.name.value)!.selectionSet) : [],
);
const requireAvailable = (path: string[], available: Availability, node: ASTNode) => {
if (!available) return;
const ok = available.some((key) => {
if (key.path.join(".") === path.join(".")) return true;
const last = key.expression.leaf.kind;
if (last !== "ref" && last !== "entry") return false;
const base = key.path.slice(0, -2);
if (!prefix(base, path)) return false;
// Object identity does not determine which incoming membership carried it.
return (
last === "entry" || !(path[base.length] === "_qx" && ["entry", "mapKey"].includes(path[base.length + 1] ?? ""))
);
});
if (!ok)
fail(
"QUERY_DISTINCT_FIELD_UNAVAILABLE",
`${path.join(".")} was dropped by distinct; retain its identity or key explicitly`,
node,
);
};
const walk = (
root: QueryRow,
names: string[],
uses: QueryUse[],
node: ASTNode,
): { row: QueryRow; path: QueryPathStep[]; nullable: boolean } => {
let row = root,
nullable = false;
const path: QueryPathStep[] = [];
for (const name of names) {
if (row.kind === "pair") {
if (name !== "source" && name !== "target")
return fail("QUERY_FIELD_NOT_QUERYABLE", `Unknown row binding ${name}`, node);
path.push({ kind: name });
row = row[name];
continue;
}
const relation = relationships(row).find((m) => m.displayName === name);
if (!relation) return fail("QUERY_FIELD_NOT_QUERYABLE", `Expected queryable relationship ${name}`, node);
if (relation.cardinality === "many" || relation.cardinality === "many-unique")
return fail(
"QUERY_EXPANSION_REQUIRED",
`${name} is to-many; explicitly expand it, quantify it or aggregate it`,
node,
);
for (const use of uses) mark(row.interfaceRevisionId, name, use, node);
const target = env.target(relation),
optional = relation.cardinality === "optional-one";
path.push({
kind: "relation",
interfaceRevisionId: row.interfaceRevisionId,
memberId: relation.id,
targetInterfaceRevisionId: target,
optional,
});
nullable ||= optional;
row = objectRow(target);
}
if (path.length > declaration.budgets.depth) fail("QUERY_DEPTH_LIMIT", "Expression path exceeds query depth", node);
return { row, path, nullable };
};
const expression = (
root: QueryRow,
names: string[],
uses: QueryUse[],
node: ASTNode,
available?: Availability,
): QueryExpression => {
requireAvailable(names, available, node);
const meta = names.at(-2) === "_qx";
const { row, path, nullable } = walk(root, names.slice(0, meta ? -2 : -1), uses, node);
const name = names.at(-1)!;
let type: ValueType, leaf: QueryExpression["leaf"];
if (meta) {
if (name === "ref" && row.kind === "object") {
type = valueType.interfaceRef(row.interfaceRevisionId);
leaf = { kind: "ref", interfaceRevisionId: row.interfaceRevisionId };
} else if (name === "entry" && (row.kind === "pair" || row.membership)) {
type = valueType.string;
leaf = { kind: "entry" };
} else if (name === "mapKey" && row.kind === "object" && row.membership?.keyType) {
type =
row.membership.keyType === "boolean"
? valueType.bool
: row.membership.keyType === "int64"
? valueType.int64
: valueType.string;
leaf = { kind: "mapKey" };
} else return fail("QUERY_KEY_INVALID", `Unavailable metadata ${names.join(".")}`, node);
} else {
if (row.kind !== "object")
return fail("QUERY_FIELD_NOT_QUERYABLE", `Expected source or target, not ${name}`, node);
const field = rowFields(row).get(name);
if (!field || field.kind !== "leaf") {
if (relationships(row).some((m) => m.displayName === name))
fail("QUERY_EXPANSION_REQUIRED", `${name} is not a scalar operand`, node);
return fail("QUERY_FIELD_NOT_QUERYABLE", `Unknown queryable scalar ${name}`, node);
}
for (const use of uses) mark(row.interfaceRevisionId, name, use, node);
const member = env.contracts.get(row.interfaceRevisionId)!.members.find((m) => m.displayName === name)!;
type = field.type;
leaf = { kind: "field", interfaceRevisionId: row.interfaceRevisionId, memberId: member.id };
}
return { path, leaf, type: nullable && type.kind !== "optional" ? valueType.optional(type) : type };
};
const keyList = (row: QueryRow, node: ValueNode, use: "group" | "distinct", available?: Availability): QueryKey[] => {
const result: QueryKey[] = [];
const visit = (node: ValueNode, path: string[]) => {
if (node.kind === "BooleanValue") {
if (!node.value) fail("QUERY_KEY_INVALID", "Key selector leaves must be literal true", node);
const expr = expression(row, path, [use], node, available);
if (!queryKeyType(expr.type)) fail("QUERY_KEY_INVALID", `Unsupported key ${path.join(".")}`, node);
result.push({ path, expression: expr });
return;
}
for (const field of pairs(node)) visit(field.value, [...path, field.name.value]);
};
visit(node, []);
if (!result.length) fail("QUERY_KEY_INVALID", "Key selectors must not be empty", node);
return result.sort((a, b) => JSON.stringify(a.expression).localeCompare(JSON.stringify(b.expression)));
};
const comparisons = (
node: ValueNode,
make: (
operator: Extract<QueryPredicate, { kind: "compare" }>["operator"],
value: QueryArgument,
) => QueryPredicate | QueryAggregatePredicate,
) =>
pairs(node).map((field) => {
if (!ops.has(field.name.value))
return fail("QUERY_PREDICATE_INVALID", `Unsupported comparison ${field.name.value}`, field);
if (field.name.value === "in" && field.value.kind === "ListValue" && field.value.values.length > 1000)
fail("QUERY_WORK_LIMIT", "in supports at most 1000 operands", field);
return make(
field.name.value as Extract<QueryPredicate, { kind: "compare" }>["operator"],
queryArgument(field.value),
);
});
const reduction = (
row: QueryRow,
op: AggregateOperator,
path: string[],
node: ASTNode,
uses: QueryUse[],
available?: Availability,
): QueryReduction => {
if (op === "count") return { operator: op, type: aggregateType(op) };
const operand = expression(row, path, ["aggregate", ...uses], node, available);
return { operator: op, operand, type: aggregateType(op, operand.type) };
};
const aggregatePredicate = (
row: QueryRow,
node: ValueNode,
keys: QueryKey[] = [],
available?: Availability,
): QueryAggregatePredicate => {
const children: QueryAggregatePredicate[] = [];
for (const field of pairs(node)) {
const name = field.name.value;
if (name === "and" || name === "or") {
if (field.value.kind !== "ListValue")
fail("QUERY_UNSUPPORTED_FEATURE", "Boolean predicate structure is fixed in the document", field);
children.push({
kind: name,
children: field.value.values.map((v) => aggregatePredicate(row, v, keys, available)),
});
} else if (name === "not")
children.push({ kind: "not", child: aggregatePredicate(row, field.value, keys, available) });
else if (name === "count")
children.push(
...(comparisons(field.value, (operator, value) => ({
kind: "compare",
expression: reduction(row, "count", [], field, []),
operator,
value,
})) as QueryAggregatePredicate[]),
);
else {
const visit = (n: ValueNode, path: string[]) => {
const entries = pairs(n);
if (entries.some((f) => ops.has(f.name.value))) {
if (name === "group" && !keys.some((k) => k.path.join(".") === path.join(".")))
fail("QUERY_GROUP_FIELD_UNAVAILABLE", `${path.join(".")} is not a grouping key`, n);
const expr =
name === "group"
? expression(row, path, ["predicate"], n, available)
: reduction(row, name as AggregateOperator, path, n, ["predicate"], available);
children.push(
...(comparisons(n, (operator, value) => ({
kind: "compare",
expression: expr,
operator,
value,
})) as QueryAggregatePredicate[]),
);
} else for (const entry of entries) visit(entry.value, [...path, entry.name.value]);
};
visit(field.value, []);
}
}
return { kind: "and", children };
};
const predicate = (row: QueryRow, node: ValueNode, available?: Availability): QueryPredicate => {
const children: QueryPredicate[] = [];
const visit = (current: QueryRow, n: ValueNode, base: string[]) => {
for (const field of pairs(n)) {
const name = field.name.value;
if (name === "and" || name === "or") {
if (field.value.kind !== "ListValue")
fail("QUERY_UNSUPPORTED_FEATURE", "Boolean predicate structure is fixed in the document", field);
const branches = field.value.values.map((v) => {
const before = children.length;
visit(current, v, base);
return { kind: "and", children: children.splice(before) } as QueryPredicate;
});
children.push({ kind: name, children: branches });
} else if (name === "not") {
const before = children.length;
visit(current, field.value, base);
children.push({ kind: "not", child: { kind: "and", children: children.splice(before) } });
} else if (current.kind === "pair" && (name === "source" || name === "target"))
visit(current[name], field.value, [...base, name]);
else if (name === "_qx") {
for (const meta of pairs(field.value)) {
if (meta.name.value !== "relations") {
const expr = expression(row, [...base, "_qx", meta.name.value], ["predicate"], meta, available);
children.push(
...(comparisons(meta.value, (operator, value) => ({
kind: "compare",
expression: expr,
operator,
value,
})) as QueryPredicate[]),
);
continue;
}
for (const edge of pairs(meta.value)) {
if (current.kind !== "object")
fail("QUERY_CONTRACT", "A pair is not an object with graph relationships", edge);
const member = relationships(current).find((m) => m.displayName === edge.name.value)!;
requireAvailable([...base, member.displayName], available, edge);
mark(current.interfaceRevisionId, member.displayName, "predicate", edge);
const common = {
path: walk(row, base, ["predicate"], edge).path,
interfaceRevisionId: current.interfaceRevisionId,
memberId: member.id,
targetInterfaceRevisionId: env.target(member),
};
for (const op of pairs(edge.value)) {
if (op.name.value === "aggregate") {
const config = pairs(op.value),
where = config.find((f) => f.name.value === "where"),
having = config.find((f) => f.name.value === "having")!;
children.push({
kind: "reduce",
...common,
where: where ? predicate(env.relational.relationRow(member), where.value) : undefined,
having: aggregatePredicate(env.relational.relationRow(member), having.value),
});
} else if (op.name.value === "isNull")
children.push({ kind: "relation", ...common, operator: "isNull", value: queryArgument(op.value) });
else
children.push({
kind: "relation",
...common,
operator: op.name.value as "some" | "none" | "is",
predicate: predicate(env.relational.relationRow(member), op.value),
});
}
}
}
} else {
const expr = expression(row, [...base, name], ["predicate"], field, available);
children.push(
...(comparisons(field.value, (operator, value) => ({
kind: "compare",
expression: expr,
operator,
value,
})) as QueryPredicate[]),
);
}
}
};
visit(row, node, []);
return { kind: "and", children };
};
const plan = (
owner: InterfaceRevisionId,
member: RelationshipInterfaceMember,
set: SelectionSetNode,
selection: (set: SelectionSetNode, typeName: string) => QuerySelection[],
): QueryRelationalPlan => {
const result: QueryRelationalPlan = { stages: [], terminals: [] };
const add = (row: QueryRow, operation: QueryRelationalPlan["stages"][number]["operation"], input?: number) => {
if (++stageCount > 256) throw new QueryCompileError("QUERY_WORK_LIMIT", "Query exceeds 256 relational stages");
const id = result.stages.length;
result.stages.push({ id, row, operation, input });
return id;
};
const root = env.relational.relationRow(member);
const initial = add(root, {
kind: "source",
interfaceRevisionId: owner,
memberId: member.id,
targetInterfaceRevisionId: env.target(member),
});
mark(owner, member.displayName, "select", set);
const bound = (node: FieldNode) => {
const args = node.arguments ?? [],
bounds = args.filter((a) => a.name.value === "first" || a.name.value === "all");
if (bounds.length !== 1) fail("QUERY_ROW_LIMIT", "Specify exactly one first or all for groups", node);
if (bounds[0]!.name.value === "all" && args.some((a) => a.name.value === "after"))
fail("QUERY_UNSUPPORTED_FEATURE", "Bounded-all cannot continue", node);
for (const b of bounds)
if (
b.value.kind !== "Variable" &&
(b.value.kind !== "IntValue" || Number(b.value.value) < 1 || Number(b.value.value) > declaration.budgets.rows)
)
fail("QUERY_ROW_LIMIT", "Group page exceeds row budget", b);
};
const reductions = (
row: QueryRow,
set: SelectionSetNode,
available: Availability,
): QueryRelationalTerminal["reductions"] => {
const output: QueryRelationalTerminal["reductions"] = [];
const visit = (set: SelectionSetNode, path: string[], response: string[], op: AggregateOperator) => {
for (const n of fields(set)) {
const key = n.alias?.value ?? n.name.value,
next = [...path, n.name.value],
out = [...response, key];
if (n.selectionSet) visit(n.selectionSet, next, out, op);
else output.push({ path: out, reduction: reduction(row, op, next, n, [], available) });
}
};
for (const n of fields(set)) {
const op = n.name.value as AggregateOperator,
key = n.alias?.value ?? n.name.value;
if (op === "count") output.push({ path: [key], reduction: reduction(row, op, [], n, []) });
else if (n.selectionSet) visit(n.selectionSet, [], [key], op);
}
return output;
};
const groupsProjection = (row: QueryRow, set: SelectionSetNode, keys: QueryKey[], available: Availability) => {
let values: QueryRelationalTerminal["reductions"] = [];
for (const entries of fields(set))
if (entries.name.value === "entries" && entries.selectionSet)
for (const n of fields(entries.selectionSet)) {
if (n.name.value === "aggregate" && n.selectionSet)
values.push(
...reductions(row, n.selectionSet, available).map((v) => ({
...v,
path: [entries.alias?.value ?? entries.name.value, n.alias?.value ?? n.name.value, ...v.path],
})),
);
if (n.name.value === "group" && n.selectionSet) {
const visit = (set: SelectionSetNode, path: string[]) => {
for (const f of fields(set)) {
const next = [...path, f.name.value];
if (f.selectionSet) visit(f.selectionSet, next);
else if (!keys.some((k) => k.path.join(".") === next.join(".")))
fail("QUERY_GROUP_FIELD_UNAVAILABLE", `${next.join(".")} is not a selected group key`, f);
}
};
visit(n.selectionSet, []);
}
}
return values;
};
const run = (row: QueryRow, stage: number, set: SelectionSetNode, path: string[], available?: Availability) => {
for (const node of fields(set)) {
if (!node.selectionSet) continue;
if (node.directives?.length)
fail("QUERY_UNSUPPORTED_FEATURE", "Relational stages cannot be conditionally selected", node);
const name = node.name.value,
outputPath = [...path, node.alias?.value ?? name];
const args = new Map((node.arguments ?? []).map((a) => [a.name.value, a.value]));
if (name === "filter")
run(
row,
add(row, { kind: "filter", predicate: predicate(row, args.get("where")!, available) }, stage),
node.selectionSet,
outputPath,
available,
);
else if (name === "distinct") {
const keys = keyList(row, args.get("by")!, "distinct", available);
run(row, add(row, { kind: "distinct", keys }, stage), node.selectionSet, outputPath, keys);
} else if (name === "expand") {
const expand = (current: QueryRow, set: SelectionSetNode, memberPath: string[], output: string[]) => {
for (const n of fields(set)) {
if (!n.selectionSet) continue;
const member = relationships(current).find((m) => m.displayName === n.name.value);
const next = [...memberPath, n.name.value],
out = [...output, n.alias?.value ?? n.name.value];
if (member && (member.cardinality === "many" || member.cardinality === "many-unique")) {
requireAvailable(next, available, n);
if (current.kind !== "object") fail("QUERY_CONTRACT", "Expected object expansion source", n);
const walked = walk(row, memberPath, ["select"], n);
mark(current.interfaceRevisionId, member.displayName, "select", n);
const target = env.relational.relationRow(member),
pair: QueryRow = { kind: "pair", source: row, target };
const newAvailable = available
? [
...available.map((k) => ({
...k,
path: ["source", ...k.path],
expression: { ...k.expression, path: [{ kind: "source" as const }, ...k.expression.path] },
})),
{
path: ["target", "_qx", "entry"],
expression: {
path: [{ kind: "target" as const }],
leaf: { kind: "entry" as const },
type: valueType.string,
},
},
]
: undefined;
run(
pair,
add(
pair,
{
kind: "expand",
path: walked.path,
interfaceRevisionId: current.interfaceRevisionId,
memberId: member.id,
targetInterfaceRevisionId: env.target(member),
},
stage,
),
n.selectionSet,
out,
newAvailable,
);
} else {
const f = rowFields(current).get(n.name.value);
if (f?.kind === "row") expand(f.row, n.selectionSet, next, out);
else fail("QUERY_EXPANSION_REQUIRED", "Expected a declared expansion path", n);
}
}
};
expand(row, node.selectionSet, [], outputPath);
} else if (name === "aggregate" || name === "groups") {
if (name === "groups") bound(node);
const keys = name === "groups" ? keyList(row, args.get("by")!, "group", available) : [];
const terminal: QueryRelationalTerminal = {
stage,
path: outputPath,
kind: name,
keys,
reductions:
name === "aggregate"
? reductions(row, node.selectionSet, available)
: groupsProjection(row, node.selectionSet, keys, available),
order: [],
residual: false,
where: args.has("where") ? predicate(row, args.get("where")!, available) : undefined,
having: args.has("having") ? aggregatePredicate(row, args.get("having")!, keys, available) : undefined,
first: args.has("first") ? queryArgument(args.get("first")!) : undefined,
all: args.has("all") ? queryArgument(args.get("all")!) : undefined,
after: args.has("after") ? queryArgument(args.get("after")!) : undefined,
selection: selection(
node.selectionSet,
name === "aggregate"
? env.relational.rowset(row).getFields().aggregate!.type.toString().replace(/!$/u, "")
: env.relational.rowset(row).getFields().groups!.type.toString().replace(/!$/u, ""),
),
};
const order = args.get("orderBy");
if (order) {
if (order.kind !== "ListValue")
fail("QUERY_UNSUPPORTED_FEATURE", "Ordering is a fixed list of field directions", order);
for (const item of order.values) {
const start = terminal.order.length;
const visit = (n: ValueNode, path: string[]) => {
if (n.kind === "EnumValue") {
const [op, ...operand] = path;
let expr: QueryExpression | QueryReduction;
if (op === "group") {
if (!keys.some((k) => k.path.join(".") === operand.join(".")))
fail("QUERY_GROUP_FIELD_UNAVAILABLE", "Sort key was not grouped", n);
expr = expression(row, operand, ["order"], n, available);
} else expr = reduction(row, op as AggregateOperator, operand, n, ["order"], available);
terminal.order.push({ expression: expr, descending: n.value === "DESC" });
} else for (const f of pairs(n)) visit(f.value, [...path, f.name.value]);
};
visit(item, []);
if (terminal.order.length !== start + 1)
fail("QUERY_ORDER_INVALID", "Each orderBy element must name exactly one field", item);
}
}
result.terminals.push(terminal);
} else fail("QUERY_UNSUPPORTED_FEATURE", `Unsupported relational stage ${name}`, node);
}
};
run(root, initial, set, []);
for (const terminal of result.terminals) {
let stage: QueryRelationalPlan["stages"][number] | undefined = result.stages[terminal.stage];
let rpc = needsRpc(terminal);
while (stage) {
rpc ||= needsRpc(stage.operation);
stage = stage.input === undefined ? undefined : result.stages[stage.input];
}
terminal.residual = rpc;
if (rpc && terminal.after)
fail(
"QUERY_UNSUPPORTED_FEATURE",
"RPC relational inputs require a bounded first/all window without continuation",
set,
);
}
return result;
};
return { expression, predicate, aggregatePredicate, keyList, plan, needsRpc };
}
+164
View File
@@ -0,0 +1,164 @@
import { create } from "@bufbuild/protobuf";
import * as wire from "../gen/camino/schema_pb.js";
import type { QueryArgument, QuerySelection } from "./types.js";
import type {
QueryAggregatePredicate,
QueryExpression,
QueryPathStep,
QueryPredicate,
QueryReduction,
QueryRelationalPlan,
QueryRow,
} from "./relational.js";
export function relationalWire(
argument: (v: QueryArgument) => wire.QueryArgument,
selection: (v: QuerySelection) => wire.QuerySelection,
) {
const path = (p: QueryPathStep): wire.QueryPathStep =>
create(wire.QueryPathStepSchema, {
step: p.kind === "relation" ? { case: "relation", value: p } : { case: p.kind, value: true },
});
const expression = (e: QueryExpression) =>
create(wire.QueryExpressionSchema, {
path: e.path.map(path),
valueTypeJson: JSON.stringify(e.type),
leaf:
e.leaf.kind === "field"
? { case: "field", value: e.leaf }
: e.leaf.kind === "ref"
? { case: "ref", value: e.leaf.interfaceRevisionId }
: { case: e.leaf.kind, value: true },
});
const reduction = (r: QueryReduction) =>
create(wire.QueryReductionSchema, {
operator: {
count: wire.QueryAggregateOperator.COUNT,
countPresent: wire.QueryAggregateOperator.COUNT_PRESENT,
sum: wire.QueryAggregateOperator.SUM,
avg: wire.QueryAggregateOperator.AVG,
min: wire.QueryAggregateOperator.MIN,
max: wire.QueryAggregateOperator.MAX,
}[r.operator],
operand: r.operand && expression(r.operand),
valueTypeJson: JSON.stringify(r.type),
});
const operand = (v: QueryExpression | QueryReduction) =>
create(wire.QueryOperandSchema, {
operand:
"operator" in v ? { case: "reduction", value: reduction(v) } : { case: "expression", value: expression(v) },
});
const predicate = (p: QueryPredicate | QueryAggregatePredicate): wire.QueryPredicate => {
switch (p.kind) {
case "and":
case "or":
return create(wire.QueryPredicateSchema, {
predicate: { case: p.kind, value: { children: p.children.map(predicate) } },
});
case "not":
return create(wire.QueryPredicateSchema, { predicate: { case: "not", value: predicate(p.child) } });
case "compare":
return create(wire.QueryPredicateSchema, {
predicate: {
case: "compare",
value: {
operand: operand(p.expression),
value: argument(p.value),
operator: {
eq: wire.QueryComparisonOperator.EQ,
in: wire.QueryComparisonOperator.IN,
isNull: wire.QueryComparisonOperator.IS_NULL,
lt: wire.QueryComparisonOperator.LT,
lte: wire.QueryComparisonOperator.LTE,
gt: wire.QueryComparisonOperator.GT,
gte: wire.QueryComparisonOperator.GTE,
}[p.operator],
},
},
});
case "relation":
return create(wire.QueryPredicateSchema, {
predicate: {
case: "relation",
value: {
path: p.path.map(path),
relation: p,
operator: {
some: wire.QueryRelationPredicateOperator.SOME,
none: wire.QueryRelationPredicateOperator.NONE,
is: wire.QueryRelationPredicateOperator.IS,
isNull: wire.QueryRelationPredicateOperator.IS_NULL,
}[p.operator],
predicate: p.predicate && predicate(p.predicate),
value: p.value && argument(p.value),
},
},
});
case "reduce":
return create(wire.QueryPredicateSchema, {
predicate: {
case: "reduce",
value: {
path: p.path.map(path),
relation: p,
where: p.where && predicate(p.where),
having: predicate(p.having),
},
},
});
}
};
const row = (r: QueryRow): wire.QueryRow =>
create(wire.QueryRowSchema, {
row:
r.kind === "object"
? {
case: "object",
value: {
interfaceRevisionId: r.interfaceRevisionId,
membership: !!r.membership,
keyType: r.membership?.keyType,
},
}
: { case: "pair", value: { source: row(r.source), target: row(r.target) } },
});
const plan = (p: QueryRelationalPlan) =>
create(wire.QueryRelationalPlanSchema, {
stages: p.stages.map((s) => {
const op = s.operation;
const operation: wire.QueryRelationalStage["operation"] =
op.kind === "source"
? { case: "source", value: create(wire.QueryRelationPathSchema, op) }
: op.kind === "filter"
? { case: "filter", value: predicate(op.predicate) }
: op.kind === "expand"
? {
case: "expand",
value: create(wire.QueryExpansionSchema, { path: op.path.map(path), relation: op }),
}
: {
case: "distinct",
value: create(wire.QueryDistinctSchema, {
keys: op.keys.map((k) => ({ ...k, expression: expression(k.expression) })),
}),
};
return create(wire.QueryRelationalStageSchema, { id: s.id, input: s.input, row: row(s.row), operation });
}),
terminals: p.terminals.map((t) => ({
stage: t.stage,
path: t.path,
groups: t.kind === "groups",
keys: t.keys.map((k) => ({ ...k, expression: expression(k.expression) })),
reductions: t.reductions.map((r) => ({ ...r, reduction: reduction(r.reduction) })),
where: t.where && predicate(t.where),
having: t.having && predicate(t.having),
order: t.order.map((o) => ({ operand: operand(o.expression), descending: o.descending })),
first: t.first && argument(t.first),
all: t.all && argument(t.all),
after: t.after && argument(t.after),
selection: t.selection.map(selection),
residual: t.residual,
})),
});
return { predicate, plan };
}
+497
View File
@@ -0,0 +1,497 @@
import {
GraphQLBoolean,
GraphQLString,
GraphQLInt,
GraphQLScalarType,
GraphQLObjectType,
GraphQLInputObjectType,
GraphQLList,
GraphQLNonNull,
type GraphQLInputType,
type GraphQLOutputType,
type GraphQLInputFieldConfigMap,
type GraphQLFieldConfigMap,
type GraphQLEnumType,
type DocumentNode,
type SelectionSetNode,
type FragmentDefinitionNode,
} from "graphql";
import { createHash } from "node:crypto";
import {
valueType,
type ValueType,
type InterfaceRevision,
type InterfaceRevisionId,
type RelationshipInterfaceMember,
} from "../capability-model/types.js";
import {
aggregateType,
aggregateOperators,
queryKeyType,
type QueryRow,
type AggregateOperator,
} from "./relational.js";
import { QueryCompileError } from "./types.js";
export const rowKey = (row: QueryRow): string => JSON.stringify(row);
export const objectRow = (
interfaceRevisionId: InterfaceRevisionId,
membership?: { keyType?: "string" | "boolean" | "int64" },
): QueryRow => ({ kind: "object", interfaceRevisionId, ...(membership ? { membership } : {}) });
export type SchemaRowField = { kind: "leaf"; type: ValueType } | { kind: "row"; row: QueryRow; optional: boolean };
export interface RelationalSchemaEnvironment {
contracts: Map<InterfaceRevisionId, InterfaceRevision>;
target(member: RelationshipInterfaceMember): InterfaceRevisionId;
name(id: InterfaceRevisionId): string;
scalar(type: ValueType): GraphQLInputType & GraphQLOutputType;
comparison(type: ValueType): GraphQLInputObjectType;
scalarTypes: Map<string, ValueType>;
direction: GraphQLEnumType;
cursor: GraphQLScalarType;
pageInfo: GraphQLObjectType;
}
export function relationalSchema(env: RelationalSchemaEnvironment, document?: DocumentNode) {
const outputCache = new Map<string, GraphQLObjectType>();
const inputCache = new Map<string, GraphQLInputObjectType>();
const referenceCache = new Map<string, GraphQLScalarType>();
const rowsets = new Map<string, QueryRow>();
const expansionPaths = new Map<string, Map<string, string[]>>();
const roles = new Map<
string,
{ kind: "rowset" | "aggregate" | "groups" | "keys" | "expand" | "relations"; row: QueryRow }
>();
const many = (member: RelationshipInterfaceMember) =>
member.cardinality === "many" || member.cardinality === "many-unique";
const relationRow = (member: RelationshipInterfaceMember) =>
objectRow(env.target(member), { keyType: member.keyType });
const relations = (row: QueryRow) =>
row.kind === "object"
? env.contracts
.get(row.interfaceRevisionId)!
.members.filter(
(member): member is RelationshipInterfaceMember => member.kind === "relationship" && !!member.queryRead,
)
: [];
const fields = (row: QueryRow): Map<string, SchemaRowField> => {
if (row.kind === "pair")
return new Map([
["source", { kind: "row", row: row.source, optional: false }],
["target", { kind: "row", row: row.target, optional: false }],
]);
return new Map(
env.contracts.get(row.interfaceRevisionId)!.members.flatMap((member): [string, SchemaRowField][] => {
if (member.kind === "operation" || !member.queryRead) return [];
if (member.kind === "value") return [[member.displayName, { kind: "leaf", type: member.valueType }]];
if (many(member)) return [];
return [
[
member.displayName,
{ kind: "row", row: objectRow(env.target(member)), optional: member.cardinality === "optional-one" },
],
];
}),
);
};
const refs = (id: InterfaceRevisionId) => {
let result = referenceCache.get(id);
if (!result) {
result = new GraphQLScalarType({
name: `QxRef_${env.name(id)}`,
parseValue: (value) => value,
parseLiteral() {
throw new Error("Managed references must be supplied as typed variables");
},
});
referenceCache.set(id, result);
env.scalarTypes.set(result.name, valueType.interfaceRef(id));
}
return result;
};
const scalar = (type: ValueType): GraphQLInputType & GraphQLOutputType => {
if (type.kind === "optional") return scalar(type.value);
return type.kind === "object-ref" && type.expectation.kind === "interface"
? refs(type.expectation.interfaceRevisionId)
: env.scalar(type);
};
const leafFields = (row: QueryRow) => {
const result = new Map<string, ValueType>();
if (row.kind === "object") result.set("ref", valueType.interfaceRef(row.interfaceRevisionId));
if (row.kind === "pair" || row.membership) result.set("entry", valueType.string);
if (row.kind === "object" && row.membership?.keyType)
result.set(
"mapKey",
row.membership.keyType === "boolean"
? valueType.bool
: row.membership.keyType === "int64"
? valueType.int64
: valueType.string,
);
return result;
};
const typeName = (role: string, row: QueryRow, suffix = "") =>
`Qx${role}_${createHash("sha256")
.update(rowKey(row) + suffix)
.digest("hex")
.slice(0, 16)}`;
const out = (role: string, row: QueryRow, make: () => GraphQLFieldConfigMap<unknown, unknown>, suffix = "") => {
const key = typeName(role, row, suffix);
let result = outputCache.get(key);
if (!result) {
result = new GraphQLObjectType({ name: key, fields: make });
outputCache.set(key, result);
}
return result;
};
const inp = (role: string, row: QueryRow, make: () => GraphQLInputFieldConfigMap, suffix = "") => {
const key = typeName(role, row, suffix);
let result = inputCache.get(key);
if (!result) {
result = new GraphQLInputObjectType({ name: key, fields: make });
inputCache.set(key, result);
}
return result;
};
const compare = (type: ValueType) => {
const base = type.kind === "optional" ? type.value : type;
if (base.kind !== "object-ref") return env.comparison(type);
return inp(
"RefCompare",
objectRow(
base.expectation.kind === "interface"
? base.expectation.interfaceRevisionId
: (() => {
throw new Error("Expected interface reference");
})(),
),
() => ({ eq: { type: scalar(type) }, in: { type: new GraphQLList(new GraphQLNonNull(scalar(type))) } }),
);
};
// Recursive interface graphs are interned. Synthetic pair graphs are finite and document-driven.
const columns = (
row: QueryRow,
operator?: Exclude<AggregateOperator, "count">,
nullable = false,
): GraphQLObjectType =>
out(
"Columns",
row,
() => {
const result: GraphQLFieldConfigMap<unknown, unknown> = {};
for (const [name, field] of fields(row)) {
if (field.kind === "row")
result[name] = { type: new GraphQLNonNull(columns(field.row, operator, nullable || field.optional)) };
else {
let type = field.type;
if (operator) {
try {
type = aggregateType(operator, type);
} catch {
continue;
}
} else if (!queryKeyType(type)) continue;
result[name] = {
type:
type.kind === "optional" || (!operator && nullable) ? scalar(type) : new GraphQLNonNull(scalar(type)),
};
}
}
const metadata: GraphQLFieldConfigMap<unknown, unknown> = {};
for (const [name, type] of leafFields(row)) {
if (operator && operator !== "countPresent") continue;
const t = scalar(operator ? aggregateType(operator, type) : type);
metadata[name] = { type: !operator && nullable ? t : new GraphQLNonNull(t) };
}
if (Object.keys(metadata).length)
result._qx = { type: new GraphQLNonNull(out("ColumnsMeta", row, () => metadata, `${operator}:${nullable}`)) };
// A type may have no matching scalar at this node; a private schema sentinel
// keeps it valid while compiler validation forbids selecting that sentinel.
if (!Object.keys(result).length) result._unavailable = { type: GraphQLBoolean };
return result;
},
`${operator}:${nullable}`,
);
const aggregate = (row: QueryRow): GraphQLObjectType => {
const result = out("Aggregate", row, () =>
Object.fromEntries(
aggregateOperators.map((op) => [
op,
{ type: new GraphQLNonNull(op === "count" ? env.scalar(valueType.uint64) : columns(row, op)) },
]),
),
);
roles.set(result.name, { kind: "aggregate", row });
return result;
};
const keys = (row: QueryRow): GraphQLInputObjectType =>
inp("Keys", row, () => {
const result: GraphQLInputFieldConfigMap = {};
for (const [name, field] of fields(row)) {
if (field.kind === "row") result[name] = { type: keys(field.row) };
else if (queryKeyType(field.type)) result[name] = { type: GraphQLBoolean };
}
const meta = Object.fromEntries([...leafFields(row)].map(([name]) => [name, { type: GraphQLBoolean }]));
if (Object.keys(meta).length) result._qx = { type: inp("KeyMeta", row, () => meta) };
return result;
});
const comparisons = (
row: QueryRow,
operator?: Exclude<AggregateOperator, "count">,
ordering = false,
): GraphQLInputObjectType =>
inp(
"Expressions",
row,
() => {
const result: GraphQLInputFieldConfigMap = {};
for (const [name, field] of fields(row)) {
if (field.kind === "row") result[name] = { type: comparisons(field.row, operator, ordering) };
else {
let type = field.type;
if (operator) {
try {
type = aggregateType(operator, type);
} catch {
continue;
}
}
result[name] = { type: ordering ? env.direction : compare(type) };
}
}
const meta: GraphQLInputFieldConfigMap = {};
for (const [name, type] of leafFields(row))
if (!operator || operator === "countPresent")
meta[name] = { type: ordering ? env.direction : compare(operator ? aggregateType(operator, type) : type) };
if (Object.keys(meta).length)
result._qx = { type: inp("ExpressionMeta", row, () => meta, `${operator}:${ordering}`) };
if (!Object.keys(result).length) result._unavailable = { type: GraphQLBoolean };
return result;
},
`${operator}:${ordering}`,
);
const having = (row: QueryRow, ordering = false): GraphQLInputObjectType =>
inp(
"Having",
row,
() => {
const result: GraphQLInputFieldConfigMap = { group: { type: comparisons(row, undefined, ordering) } };
for (const op of aggregateOperators)
result[op] = {
type:
op === "count" ? (ordering ? env.direction : compare(valueType.uint64)) : comparisons(row, op, ordering),
};
if (!ordering)
Object.assign(result, {
and: { type: new GraphQLList(new GraphQLNonNull(having(row))) },
or: { type: new GraphQLList(new GraphQLNonNull(having(row))) },
not: { type: having(row) },
});
return result;
},
String(ordering),
);
const where = (row: QueryRow): GraphQLInputObjectType =>
inp("Where", row, () => {
const result: GraphQLInputFieldConfigMap = {
and: { type: new GraphQLList(new GraphQLNonNull(where(row))) },
or: { type: new GraphQLList(new GraphQLNonNull(where(row))) },
not: { type: where(row) },
};
for (const [name, field] of fields(row)) {
if (row.kind === "object" && field.kind === "row") continue;
if (name in result) throw new QueryCompileError("QUERY_SCHEMA_NAME", `Reserved predicate name ${name}`);
result[name] = { type: field.kind === "row" ? where(field.row) : compare(field.type) };
}
result._qx = {
type: inp("WhereMeta", row, () => {
const meta: GraphQLInputFieldConfigMap = {};
for (const [name, type] of leafFields(row)) meta[name] = { type: compare(type) };
const edges = relations(row);
if (edges.length)
meta.relations = {
type: inp("WhereRelations", row, () =>
Object.fromEntries(
edges.map((member) => {
const target = relationRow(member);
return [
member.displayName,
{
type: inp(
"RelationPredicate",
target,
(): GraphQLInputFieldConfigMap =>
many(member)
? {
some: { type: where(target) },
none: { type: where(target) },
aggregate: {
type: inp("ReductionPredicate", target, () => ({
where: { type: where(target) },
having: { type: new GraphQLNonNull(having(target)) },
})),
},
}
: { is: { type: where(target) }, isNull: { type: GraphQLBoolean } },
rowKey(row) + member.id,
),
},
];
}),
),
),
};
return meta;
}),
};
return result;
});
const fragments = new Map(
(document?.definitions ?? [])
.filter((d): d is FragmentDefinitionNode => d.kind === "FragmentDefinition")
.map((d) => [d.name.value, d]),
);
let visited = 0;
const selections = (set: SelectionSetNode, active: ReadonlySet<string> = new Set()): import("graphql").FieldNode[] =>
set.selections.flatMap((node) => {
if (++visited > 10000)
throw new QueryCompileError("QUERY_WORK_LIMIT", "Query schema discovery exceeds 10000 nodes");
if (node.kind === "Field") return [node];
if (node.kind === "FragmentSpread") {
if (active.has(node.name.value) || active.size >= 128)
throw new QueryCompileError("QUERY_VALIDATION", "Cyclic or excessively nested fragments");
const fragment = fragments.get(node.name.value);
return fragment ? selections(fragment.selectionSet, new Set([...active, node.name.value])) : [];
}
return [];
});
const discoverExpansion = (root: QueryRow, current: QueryRow, set: SelectionSetNode, path: string[] = []) => {
for (const node of selections(set)) {
if (!node.selectionSet) continue;
const member = relations(current).find((m) => m.displayName === node.name.value);
const next = [...path, node.name.value];
if (member && many(member)) {
const map = expansionPaths.get(rowKey(root)) ?? new Map();
map.set(next.join("."), next);
expansionPaths.set(rowKey(root), map);
discoverRows({ kind: "pair", source: root, target: relationRow(member) }, node.selectionSet);
} else {
const field = fields(current).get(node.name.value);
if (field?.kind === "row") discoverExpansion(root, field.row, node.selectionSet, next);
}
}
};
const discoverRows = (row: QueryRow, set: SelectionSetNode) => {
rowsets.set(rowKey(row), row);
if (rowsets.size > 256) throw new QueryCompileError("QUERY_WORK_LIMIT", "Query exceeds 256 row shapes");
for (const node of selections(set))
if (node.selectionSet) {
if (node.name.value === "expand") discoverExpansion(row, row, node.selectionSet);
else if (node.name.value === "filter" || node.name.value === "distinct") discoverRows(row, node.selectionSet);
}
};
const discoverObject = (row: QueryRow, set: SelectionSetNode) => {
for (const node of selections(set))
if (node.selectionSet) {
if (node.name.value === "_qx") {
for (const meta of selections(node.selectionSet))
if (meta.name.value === "relations" && meta.selectionSet)
for (const edge of selections(meta.selectionSet)) {
const member = relations(row).find((m) => m.displayName === edge.name.value);
if (member && many(member) && edge.selectionSet) discoverRows(relationRow(member), edge.selectionSet);
}
} else {
const member = relations(row).find((m) => m.displayName === node.name.value);
if (member) {
if (!many(member)) discoverObject(objectRow(env.target(member)), node.selectionSet);
else
for (const entries of selections(node.selectionSet))
if (entries.name.value === "entries" && entries.selectionSet)
for (const child of selections(entries.selectionSet))
if (child.name.value === "node" && child.selectionSet)
discoverObject(objectRow(env.target(member)), child.selectionSet);
}
}
}
};
const discover = (root: InterfaceRevisionId) => {
for (const operation of document?.definitions ?? [])
if (operation.kind === "OperationDefinition")
for (const node of selections(operation.selectionSet))
if (node.name.value === "root" && node.selectionSet) discoverObject(objectRow(root), node.selectionSet);
};
const expand = (root: QueryRow, current: QueryRow, path: string[] = []): GraphQLObjectType =>
out(
"Expand",
current,
() => {
const result: GraphQLFieldConfigMap<unknown, unknown> = {};
const paths = [...(expansionPaths.get(rowKey(root))?.values() ?? [])].filter((p) =>
path.every((part, i) => p[i] === part),
);
for (const name of new Set(paths.map((p) => p[path.length]).filter((v): v is string => !!v))) {
const member = relations(current).find((m) => m.displayName === name);
if (member && many(member))
result[name] = {
type: new GraphQLNonNull(rowset({ kind: "pair", source: root, target: relationRow(member) })),
};
else {
const field = fields(current).get(name);
if (field?.kind === "row")
result[name] = { type: new GraphQLNonNull(expand(root, field.row, [...path, name])) };
}
}
if (!Object.keys(result).length) result._unavailable = { type: GraphQLBoolean };
return result;
},
rowKey(root) + JSON.stringify(path),
);
const rowset = (row: QueryRow): GraphQLObjectType => {
const result = out("Rows", row, () => {
const entry = out("GroupEntry", row, () => ({
key: { type: new GraphQLNonNull(GraphQLString) },
cursor: { type: env.cursor },
group: { type: new GraphQLNonNull(columns(row)) },
aggregate: { type: new GraphQLNonNull(aggregate(row)) },
}));
const connection = out("Groups", row, () => ({
entries: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(entry))) },
pageInfo: { type: new GraphQLNonNull(env.pageInfo) },
}));
roles.set(connection.name, { kind: "groups", row });
return {
filter: { type: new GraphQLNonNull(rowset(row)), args: { where: { type: new GraphQLNonNull(where(row)) } } },
distinct: { type: new GraphQLNonNull(rowset(row)), args: { by: { type: new GraphQLNonNull(keys(row)) } } },
expand: { type: new GraphQLNonNull(expand(row, row)) },
aggregate: { type: new GraphQLNonNull(aggregate(row)), args: { where: { type: where(row) } } },
groups: {
type: new GraphQLNonNull(connection),
args: {
by: { type: new GraphQLNonNull(keys(row)) },
where: { type: where(row) },
having: { type: having(row) },
orderBy: { type: new GraphQLList(new GraphQLNonNull(having(row, true))) },
first: { type: GraphQLInt },
all: { type: GraphQLInt },
after: { type: env.cursor },
},
},
};
});
roles.set(result.name, { kind: "rowset", row });
return result;
};
const helpers = (id: InterfaceRevisionId): GraphQLObjectType | undefined => {
const row = objectRow(id),
edges = relations(row).filter(many);
if (!edges.length) return undefined;
const result = out("Relations", row, () =>
Object.fromEntries(
edges.map((member) => [member.displayName, { type: new GraphQLNonNull(rowset(relationRow(member))) }]),
),
);
roles.set(result.name, { kind: "relations", row });
return result;
};
return { refs, fields, relations, relationRow, where, rowset, helpers, roles, discover, scalar, compare };
}
+149
View File
@@ -0,0 +1,149 @@
import type { InterfaceRevisionId, MemberId, ValueType } from "../capability-model/types.js";
import { valueType } from "../capability-model/types.js";
import { QueryCompileError, type QueryArgument, type QueryUse } from "./types.js";
/** A row is an input membership, not a newly allocated Camino object. */
export type QueryRow =
| {
kind: "object";
interfaceRevisionId: InterfaceRevisionId;
membership?: { keyType?: "string" | "boolean" | "int64" };
}
| { kind: "pair"; source: QueryRow; target: QueryRow };
export type AggregateOperator = "count" | "countPresent" | "sum" | "avg" | "min" | "max";
export const aggregateOperators: readonly AggregateOperator[] = ["count", "countPresent", "sum", "avg", "min", "max"];
export function aggregateType(operator: AggregateOperator, input?: ValueType): ValueType {
if (operator === "count") return valueType.uint64;
const base = input?.kind === "optional" ? input.value : input;
if (operator === "countPresent" && (base?.kind === "scalar" || base?.kind === "object-ref")) return valueType.uint64;
if (base?.kind !== "scalar")
throw new QueryCompileError("QUERY_AGGREGATE_TYPE", `${operator} requires a supported scalar operand`);
const numeric = ["int32", "uint32", "int64", "uint64", "double"].includes(base.name);
if ((operator === "min" || operator === "max") && (numeric || base.name === "string"))
return valueType.optional(base);
if (numeric && operator === "avg") return valueType.optional(valueType.double);
if (numeric && operator === "sum")
return valueType.optional(
base.name === "double" ? valueType.double : base.name.startsWith("u") ? valueType.uint64 : valueType.int64,
);
throw new QueryCompileError("QUERY_AGGREGATE_TYPE", `${operator} does not accept ${base.name}`);
}
export function queryKeyType(type: ValueType): boolean {
if (type.kind === "optional") return queryKeyType(type.value);
return type.kind === "object-ref" || (type.kind === "scalar" && type.name !== "bytes");
}
/** Paths are resolved once by the compiler. Runtime never resolves authored names. */
export type QueryPathStep =
| { kind: "source" | "target" }
| {
kind: "relation";
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
targetInterfaceRevisionId: InterfaceRevisionId;
optional: boolean;
};
export interface QueryExpression {
path: QueryPathStep[];
leaf:
| { kind: "field"; interfaceRevisionId: InterfaceRevisionId; memberId: MemberId }
| { kind: "ref"; interfaceRevisionId: InterfaceRevisionId }
| { kind: "entry" | "mapKey" };
type: ValueType;
}
export interface QueryReduction {
operator: AggregateOperator;
operand?: QueryExpression;
type: ValueType;
}
export type QueryPredicate =
| { kind: "and" | "or"; children: QueryPredicate[] }
| { kind: "not"; child: QueryPredicate }
| {
kind: "compare";
expression: QueryExpression;
operator: "eq" | "in" | "isNull" | "lt" | "lte" | "gt" | "gte";
value: QueryArgument;
}
| {
kind: "relation";
path: QueryPathStep[];
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
targetInterfaceRevisionId: InterfaceRevisionId;
operator: "some" | "none" | "is" | "isNull";
predicate?: QueryPredicate;
value?: QueryArgument;
}
| {
kind: "reduce";
path: QueryPathStep[];
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
targetInterfaceRevisionId: InterfaceRevisionId;
where?: QueryPredicate;
having: QueryAggregatePredicate;
};
export type QueryAggregatePredicate =
| { kind: "and" | "or"; children: QueryAggregatePredicate[] }
| { kind: "not"; child: QueryAggregatePredicate }
| {
kind: "compare";
expression: QueryExpression | QueryReduction;
operator: "eq" | "in" | "isNull" | "lt" | "lte" | "gt" | "gte";
value: QueryArgument;
};
export interface QueryKey {
path: string[];
expression: QueryExpression;
}
export interface QueryRelationalStage {
id: number;
input?: number;
row: QueryRow;
operation:
| {
kind: "source";
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
targetInterfaceRevisionId: InterfaceRevisionId;
}
| { kind: "filter"; predicate: QueryPredicate }
| {
kind: "expand";
path: QueryPathStep[];
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
targetInterfaceRevisionId: InterfaceRevisionId;
}
| { kind: "distinct"; keys: QueryKey[] };
}
export interface QueryRelationalTerminal {
stage: number;
path: string[];
kind: "aggregate" | "groups";
keys: QueryKey[];
reductions: { path: string[]; reduction: QueryReduction }[];
where?: QueryPredicate;
having?: QueryAggregatePredicate;
order: { expression: QueryExpression | QueryReduction; descending: boolean }[];
first?: QueryArgument;
all?: QueryArgument;
after?: QueryArgument;
/** Projection tree remains separate from the operator plan. */
selection: import("./types.js").QuerySelection[];
residual: boolean;
}
export interface QueryRelationalPlan {
stages: QueryRelationalStage[];
terminals: QueryRelationalTerminal[];
}
export type QueryEffectRecorder = (
id: InterfaceRevisionId,
name: string,
use: QueryUse,
node: import("graphql").ASTNode,
) => void;
+250
View File
@@ -0,0 +1,250 @@
import {
GraphQLBoolean,
GraphQLString,
GraphQLInt,
GraphQLFloat,
GraphQLScalarType,
GraphQLObjectType,
GraphQLInputObjectType,
GraphQLEnumType,
GraphQLList,
GraphQLNonNull,
GraphQLSchema,
type GraphQLOutputType,
type GraphQLInputType,
type GraphQLFieldConfigMap,
type GraphQLInputFieldConfigMap,
type DocumentNode,
} from "graphql";
import { createHash } from "node:crypto";
import type {
InterfaceRevision,
InterfaceRevisionId,
ValueType,
RelationshipInterfaceMember,
} from "../capability-model/types.js";
import { QueryCompileError, type QueryDeclaration } from "./types.js";
import { relationalSchema, objectRow } from "./relational-schema.js";
// Validate losslessly; callers carry decimal strings across JSON transports.
function integerParser(name: string, min: bigint, max: bigint, value: unknown): string {
if (
typeof value !== "string" &&
typeof value !== "bigint" &&
!(typeof value === "number" && Number.isSafeInteger(value))
)
throw new Error(`${name} requires a canonical integer`);
const text = String(value);
if (!/^-?(0|[1-9][0-9]*)$/.test(text) || text === "-0") throw new Error(`${name} requires a canonical integer`);
const n = BigInt(text);
if (n < min || n > max) throw new Error(`${name} out of range`);
return text;
}
const integerScalar = (name: string, min: bigint, max: bigint) => {
const parse = (v: unknown) => integerParser(name, min, max, v);
return new GraphQLScalarType({
name,
serialize: parse,
parseValue: parse,
parseLiteral(node) {
if (node.kind !== "IntValue" && node.kind !== "StringValue") throw new Error(`${name} requires an integer`);
return parse(node.value);
},
});
};
export const queryScalars = {
bool: GraphQLBoolean,
string: GraphQLString,
int32: GraphQLInt,
int64: integerScalar("Int64", -(1n << 63n), (1n << 63n) - 1n),
uint32: integerScalar("UInt32", 0n, (1n << 32n) - 1n),
uint64: integerScalar("UInt64", 0n, (1n << 64n) - 1n),
double: GraphQLFloat,
bytes: new GraphQLScalarType({ name: "Bytes" }),
};
export const cursorScalar = new GraphQLScalarType({
name: "Cursor",
parseValue(value) {
if (typeof value !== "string" || value.length > 4096) throw new Error("Invalid cursor");
return value;
},
});
export function querySchema(
declaration: QueryDeclaration,
interfaces: readonly InterfaceRevision[],
document?: DocumentNode,
) {
const contracts = new Map(interfaces.map((entry) => [entry.revisionId, entry]));
const objects = new Map<InterfaceRevisionId, GraphQLObjectType>();
const orders = new Map<InterfaceRevisionId, GraphQLInputObjectType>();
const byName = new Map<string, InterfaceRevision>();
const comparisons = new Map<string, GraphQLInputObjectType>();
const scalarTypes = new Map<string, ValueType>(
Object.entries(queryScalars).map(([name, type]) => [type.name, { kind: "scalar", name } as ValueType]),
);
scalarTypes.set("Cursor", { kind: "scalar", name: "string" });
const pageInfo = new GraphQLObjectType({
name: "QxPageInfo",
fields: {
hasNextPage: { type: new GraphQLNonNull(GraphQLBoolean) },
endCursor: { type: cursorScalar },
},
});
const direction = new GraphQLEnumType({ name: "QxDirection", values: { ASC: {}, DESC: {} } });
const contract = (id: InterfaceRevisionId) => {
const found = contracts.get(id);
if (!found || found.template) throw new QueryCompileError("QUERY_CONTRACT", `Expected closed interface ${id}`);
return found;
};
const name = (id: InterfaceRevisionId) => {
const revision = contract(id);
const result =
revision.displayName +
(revision.application ? `_${createHash("sha256").update(id).digest("hex").slice(0, 12)}` : "");
if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(result) || result.startsWith("__") || result.startsWith("Qx"))
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Unsupported query interface name ${result}`);
const existing = byName.get(result);
if (existing && existing.revisionId !== id)
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Conflicting query interface name ${result}`);
byName.set(result, revision);
return result;
};
const target = (member: RelationshipInterfaceMember): InterfaceRevisionId => {
if (member.target.kind === "interface") return member.target.interfaceRevisionId;
const atomId = member.target.atomId;
const views = declaration.views.filter((entry) => entry.atomId === atomId);
if (views.length !== 1)
throw new QueryCompileError(
"QUERY_VIEW_REQUIRED",
`Declare exactly one interface view for ${member.target.atomId}`,
);
return views[0]!.interfaceRevisionId;
};
const scalar = (type: ValueType): GraphQLOutputType & GraphQLInputType => {
if (type.kind === "optional") return scalar(type.value);
if (type.kind !== "scalar")
throw new QueryCompileError("QUERY_TYPE", "Only scalar/optional scalar query fields are supported");
return queryScalars[type.name];
};
const comparison = (type: ValueType) => {
const base = type.kind === "optional" ? type.value : type;
const value = scalar(base);
const key = String(value);
let result = comparisons.get(key);
if (!result) {
const fields: GraphQLInputFieldConfigMap = { eq: { type: value }, isNull: { type: GraphQLBoolean } };
if (base.kind === "scalar" && base.name !== "bytes")
fields.in = { type: new GraphQLList(new GraphQLNonNull(value)) };
if (base.kind === "scalar" && base.name !== "bool" && base.name !== "bytes")
for (const op of ["lt", "lte", "gt", "gte"]) fields[op] = { type: value };
result = new GraphQLInputObjectType({ name: `QxCompare${key}`, fields });
comparisons.set(key, result);
}
return result;
};
const relational = relationalSchema(
{ contracts, target, name, scalar, comparison, scalarTypes, direction, cursor: cursorScalar, pageInfo },
document,
);
relational.discover(declaration.root);
const filter = (id: InterfaceRevisionId) => relational.where(objectRow(id));
const order = (id: InterfaceRevisionId) => {
let result = orders.get(id);
if (!result) {
const fields: GraphQLInputFieldConfigMap = {};
for (const member of contract(id).members) {
if (member.kind !== "value" || !member.queryRead) continue;
const t = member.valueType.kind === "optional" ? member.valueType.value : member.valueType;
if (t.kind === "scalar" && t.name !== "bytes") fields[member.displayName] = { type: direction };
}
if (Object.keys(fields).length === 0) return undefined;
result = new GraphQLInputObjectType({ name: `${name(id)}Order`, fields });
orders.set(id, result);
}
return result;
};
const object = (id: InterfaceRevisionId): GraphQLObjectType => {
const existing = objects.get(id);
if (existing) return existing;
const result = new GraphQLObjectType({
name: name(id),
fields: () => {
const helpers = relational.helpers(id);
const metadata = new GraphQLObjectType({
name: `QxMetadata_${name(id)}`,
fields: {
ref: { type: new GraphQLNonNull(relational.refs(id)) },
...(helpers ? { relations: { type: new GraphQLNonNull(helpers) } } : {}),
},
});
const fields: GraphQLFieldConfigMap<unknown, unknown> = { _qx: { type: new GraphQLNonNull(metadata) } };
for (const member of contract(id).members) {
if (member.kind === "operation" || !member.queryRead) continue;
if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(member.displayName) || member.displayName.startsWith("_"))
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Unsupported query field name ${member.displayName}`);
if (member.kind === "value") {
const type = scalar(member.valueType);
fields[member.displayName] = {
type: member.valueType.kind === "optional" ? type : new GraphQLNonNull(type),
};
} else {
const targetId = target(member),
node = object(targetId);
if (member.cardinality === "exactly-one" || member.cardinality === "optional-one")
fields[member.displayName] = {
type: member.cardinality === "exactly-one" ? new GraphQLNonNull(node) : node,
};
else {
const entry = new GraphQLObjectType({
name: `${name(id)}_${member.displayName}_Entry`,
fields: {
key: { type: new GraphQLNonNull(GraphQLString) },
...(member.keyType
? {
mapKey: {
type: new GraphQLNonNull(
queryScalars[member.keyType === "boolean" ? "bool" : member.keyType],
),
},
}
: {}),
cursor: { type: cursorScalar },
node: { type: new GraphQLNonNull(node) },
},
});
const connection = new GraphQLObjectType({
name: `${name(id)}_${member.displayName}_Connection`,
fields: {
entries: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(entry))) },
pageInfo: { type: new GraphQLNonNull(pageInfo) },
},
});
const ordering = order(targetId);
fields[member.displayName] = {
type: new GraphQLNonNull(connection),
args: {
first: { type: GraphQLInt },
all: { type: GraphQLInt },
after: { type: cursorScalar },
where: { type: filter(targetId) },
...(ordering ? { orderBy: { type: new GraphQLList(new GraphQLNonNull(ordering)) } } : {}),
},
};
}
}
}
return fields;
},
});
objects.set(id, result);
return result;
};
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: "QxQuery",
fields: { root: { type: new GraphQLNonNull(object(declaration.root)) } },
}),
});
return { schema, byName, target, contracts, relational, scalarTypes };
}
+136
View File
@@ -0,0 +1,136 @@
import { capabilityId, type InterfaceRevision, type InterfaceRevisionId } from "../capability-model/types.js";
import {
TypeSubstitution,
bindTypeParameters,
type ClosedTypeArgument,
type GenericTypeEnvironment,
} from "../capability-model/generics.js";
import { GenericSourceTypes } from "../capability-language/generic-types.js";
import { compileQuery } from "./compile.js";
import { QueryCompileError, type QueryDeclaration, type QueryTemplate } from "./types.js";
export function specializeQueryTemplate(
template: QueryTemplate,
arguments_: ClosedTypeArgument[],
environment: GenericTypeEnvironment,
interfaces: () => readonly InterfaceRevision[],
identity: { id: string; displayName: string },
): QueryDeclaration {
const obligations: NonNullable<QueryDeclaration["argumentRequirements"]> = [];
const checked = {
...environment,
implementsInterface: (
target: Parameters<GenericTypeEnvironment["implementsInterface"]>[0],
required: InterfaceRevisionId,
) => {
obligations.push({ target, required });
return environment.implementsInterface(target, required);
},
};
const substitution = new TypeSubstitution({
...checked,
arguments: bindTypeParameters(template.parameters, arguments_, checked, template.declaration.displayName),
});
return {
...structuredClone(template.declaration),
...identity,
root: substitution.application(template.root),
views: template.views
.map((view) => {
const target = substitution.object(view.target);
const interfaceRevisionId = substitution.application(view.interface);
if (target.kind === "interface") {
if (target.interfaceRevisionId !== interfaceRevisionId)
throw new QueryCompileError(
"QUERY_VIEW_REQUIRED",
"An interface argument must use its exact selected query view",
);
return undefined;
}
return { atomId: target.atomId, interfaceRevisionId };
})
.filter((view): view is NonNullable<typeof view> => !!view),
allowances: template.allowances.map((allowance) => {
const interfaceRevisionId = substitution.application(allowance.interface);
const member = interfaces()
.find((entry) => entry.revisionId === interfaceRevisionId)
?.members.find((entry) => entry.displayName === allowance.memberName);
if (!member) throw new QueryCompileError("QUERY_ALLOWANCE", `Unknown template field ${allowance.memberName}`);
return { interfaceRevisionId, memberId: member.id, uses: allowance.uses, reason: allowance.reason };
}),
application: { templateId: template.declaration.id, arguments: structuredClone(arguments_) },
argumentRequirements: obligations,
};
}
/** Check a document with only its declared bounds available, even if no concrete
* specialization is installed. No concrete atom fields can leak into this check. */
export async function checkQueryTemplate(
template: QueryTemplate,
interfaces: readonly InterfaceRevision[],
read: (name: string) => Promise<string>,
) {
const types = new GenericSourceTypes(new Map());
for (const contract of interfaces) types.register(contract.displayName, contract);
const arguments_: ClosedTypeArgument[] = template.parameters.map((parameter) => {
if (parameter.kind !== "object")
throw new QueryCompileError(
"QUERY_TEMPLATE_PARAMETER",
"Query templates currently require object parameters with explicit interface views; scalar/value polymorphism is not queryable",
);
return {
kind: "object",
target: { kind: "atom", atomId: capabilityId.atom(`query-bound:${template.declaration.id}:${parameter.id}`) },
};
});
const bindings = new Map(template.parameters.map((parameter, index) => [parameter.id, arguments_[index]!]));
const substitution = new TypeSubstitution({ ...types.environment(), arguments: bindings });
const evidence = new Map(
arguments_.map((argument, index) => {
const parameter = template.parameters[index]!;
const target = argument.kind === "object" && argument.target.kind === "atom" ? argument.target.atomId : "";
return [
target,
new Set(
parameter.kind === "object" ? parameter.implements.map((bound) => substitution.application(bound)) : [],
),
];
}),
);
const implies = (offered: string, required: string, seen = new Set<string>()): boolean => {
if (offered === required) return true;
if (seen.has(offered)) return false;
seen.add(offered);
const contract = types.definitions.get(offered);
return (contract?.requiredInterfaces ?? []).some((parent) => implies(parent, required, seen));
};
const environment = {
...types.environment(),
implementsInterface: (
target: Parameters<GenericTypeEnvironment["implementsInterface"]>[0],
required: InterfaceRevisionId,
) =>
target.kind === "atom" && [...(evidence.get(target.atomId) ?? [])].some((offered) => implies(offered, required)),
};
const declaration = specializeQueryTemplate(
template,
arguments_,
environment,
() => [...types.definitions.values()],
template.declaration,
);
for (const view of declaration.views)
if (!environment.implementsInterface({ kind: "atom", atomId: view.atomId }, view.interfaceRevisionId))
throw new QueryCompileError(
"QUERY_TEMPLATE_BOUND",
`The declared bounds do not guarantee the selected view ${view.interfaceRevisionId}`,
);
for (const application of types.applications.values())
for (const obligation of application.argumentRequirements ?? [])
if (!environment.implementsInterface(obligation.target, obligation.required))
throw new QueryCompileError(
"QUERY_TEMPLATE_BOUND",
`Template arguments do not guarantee ${obligation.required}`,
);
return compileQuery(declaration, [...types.definitions.values()], read);
}
+111
View File
@@ -0,0 +1,111 @@
import type { AtomId, InterfaceRevisionId, MemberId, ValueType } from "../capability-model/types.js";
import type {
TypeParameter,
InterfaceApplicationExpression,
ObjectTypeExpression,
ClosedTypeArgument,
} from "../capability-model/generics.js";
export type QueryUse = "select" | "predicate" | "order" | "aggregate" | "group" | "distinct";
export interface QueryAllowance {
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
uses: QueryUse[];
reason: string;
}
export interface QueryBudgets {
rows: number;
depth: number;
resultBytes: number;
candidates: number;
rpcCalls: number;
concurrency: number;
deadlineMs: number;
}
export const defaultQueryBudgets: Readonly<QueryBudgets> = Object.freeze({
rows: 100,
depth: 8,
resultBytes: 1048576,
candidates: 100,
rpcCalls: 200,
concurrency: 8,
deadlineMs: 10000,
});
export interface QueryDeclaration {
id: string;
displayName: string;
root: InterfaceRevisionId;
document: string;
operation: string;
fragments: string[];
views: { atomId: AtomId; interfaceRevisionId: InterfaceRevisionId }[];
allowances: QueryAllowance[];
budgets: QueryBudgets;
watch: boolean;
polling?: { intervalMs: number; reason: string };
application?: { templateId: string; arguments: ClosedTypeArgument[] };
argumentRequirements?: {
target: import("../capability-model/types.js").ObjectExpectation;
required: InterfaceRevisionId;
}[];
}
/** Source-only template; neither parameters nor synthetic bound witnesses are installed. */
export interface QueryTemplate {
declaration: Omit<QueryDeclaration, "root" | "views" | "allowances">;
parameters: TypeParameter[];
root: InterfaceApplicationExpression;
views: { target: ObjectTypeExpression; interface: InterfaceApplicationExpression }[];
allowances: { interface: InterfaceApplicationExpression; memberName: string; uses: QueryUse[]; reason: string }[];
}
export interface QueryFieldEffect {
interfaceRevisionId: InterfaceRevisionId;
memberId: MemberId;
uses: QueryUse[];
execution: "native" | "rpc-permitted";
}
export interface QuerySourceLocation {
file: string;
line: number;
column: number;
}
export class QueryCompileError extends Error {
constructor(
readonly code: string,
message: string,
readonly location?: QuerySourceLocation,
) {
super(`${code}${location ? ` ${location.file}:${location.line}:${location.column}` : ""}: ${message}`);
this.name = "QueryCompileError";
}
}
export interface CheckedQuery {
declaration: QueryDeclaration;
definitionDigest: string;
/** Fixed, validated document: never supplied by runtime callers. */
document: string;
schema: string;
variables: ValueType;
output: ValueType;
effects: QueryFieldEffect[];
selection: QuerySelection[];
variableDefaults: Record<string, QueryArgument>;
sourceFiles: string[];
}
export type QueryArgument =
| { kind: "variable"; name: string }
| { kind: "literal"; value: string | number | boolean | null }
| { kind: "list"; values: QueryArgument[] }
| { kind: "object"; fields: Record<string, QueryArgument> };
export interface QuerySelection {
name: string;
key: string;
interfaceRevisionId?: InterfaceRevisionId;
memberId?: MemberId;
targetInterfaceRevisionId?: InterfaceRevisionId;
conditions: { include: boolean; value: QueryArgument }[];
arguments: Record<string, QueryArgument>;
selection: QuerySelection[];
relational?: import("./relational.js").QueryRelationalPlan;
predicate?: import("./relational.js").QueryPredicate;
}
+2 -3
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { parseQuixosLock } from "./parser.js";
import { loadQuixosLock } from "./loader.js";
const fileName = process.argv[2];
if (!fileName || process.argv.length !== 3) {
@@ -8,7 +7,7 @@ if (!fileName || process.argv.length !== 3) {
process.exit(2);
}
const result = parseQuixosLock(await readFile(fileName, "utf8"), fileName);
const result = await loadQuixosLock(fileName);
if (!result.ok) {
for (const diagnostic of result.diagnostics) {
console.error(
+18 -1
View File
@@ -1,6 +1,7 @@
token literal names:
null
'quixos-lock'
'fragment'
'version'
'quixos'
'source'
@@ -8,6 +9,12 @@ null
'package'
'repository'
'commit'
'policy'
'ref'
'pinned'
'track-release'
'track-development'
'import'
'{'
'}'
';'
@@ -21,6 +28,7 @@ null
token symbolic names:
null
QUIXOS_LOCK
FRAGMENT
VERSION
QUIXOS
SOURCE
@@ -28,6 +36,12 @@ INTERFACE
PACKAGE
REPOSITORY
COMMIT
POLICY
REF
PINNED
TRACK_RELEASE
TRACK_DEVELOPMENT
IMPORT
LBRACE
RBRACE
SEMI
@@ -42,11 +56,14 @@ rule names:
document
quixosEntry
resourceEntry
importEntry
resourceKind
sourceBlock
quixosSourceBlock
sourcePolicy
identifier
stringLiteral
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]
+40 -26
View File
@@ -1,28 +1,42 @@
QUIXOS_LOCK=1
VERSION=2
QUIXOS=3
SOURCE=4
INTERFACE=5
PACKAGE=6
REPOSITORY=7
COMMIT=8
LBRACE=9
RBRACE=10
SEMI=11
INTEGER=12
IDENTIFIER=13
STRING_LITERAL=14
LINE_COMMENT=15
BLOCK_COMMENT=16
WS=17
FRAGMENT=2
VERSION=3
QUIXOS=4
SOURCE=5
INTERFACE=6
PACKAGE=7
REPOSITORY=8
COMMIT=9
POLICY=10
REF=11
PINNED=12
TRACK_RELEASE=13
TRACK_DEVELOPMENT=14
IMPORT=15
LBRACE=16
RBRACE=17
SEMI=18
INTEGER=19
IDENTIFIER=20
STRING_LITERAL=21
LINE_COMMENT=22
BLOCK_COMMENT=23
WS=24
'quixos-lock'=1
'version'=2
'quixos'=3
'source'=4
'interface'=5
'package'=6
'repository'=7
'commit'=8
'{'=9
'}'=10
';'=11
'fragment'=2
'version'=3
'quixos'=4
'source'=5
'interface'=6
'package'=7
'repository'=8
'commit'=9
'policy'=10
'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
VERSION=2
QUIXOS=3
SOURCE=4
INTERFACE=5
PACKAGE=6
REPOSITORY=7
COMMIT=8
LBRACE=9
RBRACE=10
SEMI=11
INTEGER=12
IDENTIFIER=13
STRING_LITERAL=14
LINE_COMMENT=15
BLOCK_COMMENT=16
WS=17
FRAGMENT=2
VERSION=3
QUIXOS=4
SOURCE=5
INTERFACE=6
PACKAGE=7
REPOSITORY=8
COMMIT=9
POLICY=10
REF=11
PINNED=12
TRACK_RELEASE=13
TRACK_DEVELOPMENT=14
IMPORT=15
LBRACE=16
RBRACE=17
SEMI=18
INTEGER=19
IDENTIFIER=20
STRING_LITERAL=21
LINE_COMMENT=22
BLOCK_COMMENT=23
WS=24
'quixos-lock'=1
'version'=2
'quixos'=3
'source'=4
'interface'=5
'package'=6
'repository'=7
'commit'=8
'{'=9
'}'=10
';'=11
'fragment'=2
'version'=3
'quixos'=4
'source'=5
'interface'=6
'package'=7
'repository'=8
'commit'=9
'policy'=10
'ref'=11
'pinned'=12
'track-release'=13
'track-development'=14
'import'=15
'{'=16
'}'=17
';'=18
+126 -87
View File
@@ -5,37 +5,47 @@ import { Token } from "antlr4ng";
export class QuixosLockLexer extends antlr.Lexer {
public static readonly QUIXOS_LOCK = 1;
public static readonly VERSION = 2;
public static readonly QUIXOS = 3;
public static readonly SOURCE = 4;
public static readonly INTERFACE = 5;
public static readonly PACKAGE = 6;
public static readonly REPOSITORY = 7;
public static readonly COMMIT = 8;
public static readonly LBRACE = 9;
public static readonly RBRACE = 10;
public static readonly SEMI = 11;
public static readonly INTEGER = 12;
public static readonly IDENTIFIER = 13;
public static readonly STRING_LITERAL = 14;
public static readonly LINE_COMMENT = 15;
public static readonly BLOCK_COMMENT = 16;
public static readonly WS = 17;
public static readonly FRAGMENT = 2;
public static readonly VERSION = 3;
public static readonly QUIXOS = 4;
public static readonly SOURCE = 5;
public static readonly INTERFACE = 6;
public static readonly PACKAGE = 7;
public static readonly REPOSITORY = 8;
public static readonly COMMIT = 9;
public static readonly POLICY = 10;
public static readonly REF = 11;
public static readonly PINNED = 12;
public static readonly TRACK_RELEASE = 13;
public static readonly TRACK_DEVELOPMENT = 14;
public static readonly IMPORT = 15;
public static readonly LBRACE = 16;
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 = [
"DEFAULT_TOKEN_CHANNEL", "HIDDEN"
];
public static readonly literalNames = [
null, "'quixos-lock'", "'version'", "'quixos'", "'source'", "'interface'",
"'package'", "'repository'", "'commit'", "'{'", "'}'", "';'"
null, "'quixos-lock'", "'fragment'", "'version'", "'quixos'", "'source'",
"'interface'", "'package'", "'repository'", "'commit'", "'policy'",
"'ref'", "'pinned'", "'track-release'", "'track-development'", "'import'",
"'{'", "'}'", "';'"
];
public static readonly symbolicNames = [
null, "QUIXOS_LOCK", "VERSION", "QUIXOS", "SOURCE", "INTERFACE",
"PACKAGE", "REPOSITORY", "COMMIT", "LBRACE", "RBRACE", "SEMI", "INTEGER",
"IDENTIFIER", "STRING_LITERAL", "LINE_COMMENT", "BLOCK_COMMENT",
"WS"
null, "QUIXOS_LOCK", "FRAGMENT", "VERSION", "QUIXOS", "SOURCE",
"INTERFACE", "PACKAGE", "REPOSITORY", "COMMIT", "POLICY", "REF",
"PINNED", "TRACK_RELEASE", "TRACK_DEVELOPMENT", "IMPORT", "LBRACE",
"RBRACE", "SEMI", "INTEGER", "IDENTIFIER", "STRING_LITERAL", "LINE_COMMENT",
"BLOCK_COMMENT", "WS"
];
public static readonly modeNames = [
@@ -43,9 +53,10 @@ export class QuixosLockLexer extends antlr.Lexer {
];
public static readonly ruleNames = [
"QUIXOS_LOCK", "VERSION", "QUIXOS", "SOURCE", "INTERFACE", "PACKAGE",
"REPOSITORY", "COMMIT", "LBRACE", "RBRACE", "SEMI", "INTEGER", "IDENTIFIER",
"STRING_LITERAL", "ESC", "HEX", "LINE_COMMENT", "BLOCK_COMMENT",
"QUIXOS_LOCK", "FRAGMENT", "VERSION", "QUIXOS", "SOURCE", "INTERFACE",
"PACKAGE", "REPOSITORY", "COMMIT", "POLICY", "REF", "PINNED", "TRACK_RELEASE",
"TRACK_DEVELOPMENT", "IMPORT", "LBRACE", "RBRACE", "SEMI", "INTEGER",
"IDENTIFIER", "STRING_LITERAL", "ESC", "HEX", "LINE_COMMENT", "BLOCK_COMMENT",
"WS",
];
@@ -68,70 +79,98 @@ export class QuixosLockLexer extends antlr.Lexer {
public get modeNames(): string[] { return QuixosLockLexer.modeNames; }
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,
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,
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,
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,
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,
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,
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,
11,12,11,118,1,12,1,12,5,12,123,8,12,10,12,12,12,126,9,12,1,13,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,
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,
16,1,16,5,16,154,8,16,10,16,12,16,157,9,16,1,16,1,16,1,17,1,17,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,
17,1,18,4,18,176,8,18,11,18,12,18,177,1,18,1,18,1,166,0,19,1,1,3,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
4,0,48,57,65,90,95,95,97,122,4,0,10,10,13,13,34,34,92,92,8,0,34,
34,47,47,92,92,98,98,102,102,110,110,114,114,116,116,3,0,48,57,65,
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,
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,
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,
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,
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,
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,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,
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,
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,
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,46,47,5,108,0,0,47,48,5,111,0,0,48,49,5,99,0,0,49,50,5,107,0,0,
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,
55,5,115,0,0,55,56,5,105,0,0,56,57,5,111,0,0,57,58,5,110,0,0,58,
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,
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,0,67,68,5,111,0,0,68,69,5,117,0,0,69,70,5,114,0,0,70,71,5,99,0,
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,
76,5,116,0,0,76,77,5,101,0,0,77,78,5,114,0,0,78,79,5,102,0,0,79,
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,
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,
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,
0,92,93,5,101,0,0,93,94,5,112,0,0,94,95,5,111,0,0,95,96,5,115,0,
0,96,97,5,105,0,0,97,98,5,116,0,0,98,99,5,111,0,0,99,100,5,114,0,
0,100,101,5,121,0,0,101,14,1,0,0,0,102,103,5,99,0,0,103,104,5,111,
0,0,104,105,5,109,0,0,105,106,5,109,0,0,106,107,5,105,0,0,107,108,
5,116,0,0,108,16,1,0,0,0,109,110,5,123,0,0,110,18,1,0,0,0,111,112,
5,125,0,0,112,20,1,0,0,0,113,114,5,59,0,0,114,22,1,0,0,0,115,117,
7,0,0,0,116,115,1,0,0,0,117,118,1,0,0,0,118,116,1,0,0,0,118,119,
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,
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,126,124,1,0,0,0,127,132,5,34,0,0,128,131,3,29,14,0,129,131,8,
3,0,0,130,128,1,0,0,0,130,129,1,0,0,0,131,134,1,0,0,0,132,130,1,
0,0,0,132,133,1,0,0,0,133,135,1,0,0,0,134,132,1,0,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,
117,0,0,140,141,3,31,15,0,141,142,3,31,15,0,142,143,3,31,15,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,
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,
5,47,0,0,151,155,1,0,0,0,152,154,8,6,0,0,153,152,1,0,0,0,154,157,
1,0,0,0,155,153,1,0,0,0,155,156,1,0,0,0,156,158,1,0,0,0,157,155,
1,0,0,0,158,159,6,16,0,0,159,34,1,0,0,0,160,161,5,47,0,0,161,162,
5,42,0,0,162,166,1,0,0,0,163,165,9,0,0,0,164,163,1,0,0,0,165,168,
1,0,0,0,166,167,1,0,0,0,166,164,1,0,0,0,167,169,1,0,0,0,168,166,
1,0,0,0,169,170,5,42,0,0,170,171,5,47,0,0,171,172,1,0,0,0,172,173,
6,17,0,0,173,36,1,0,0,0,174,176,7,7,0,0,175,174,1,0,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,
6,18,0,0,180,38,1,0,0,0,9,0,118,124,130,132,145,155,166,177,1,6,
29,15,31,16,33,17,35,18,37,19,39,20,41,21,43,0,45,0,47,22,49,23,
51,24,1,0,8,1,0,48,57,3,0,65,90,95,95,97,122,4,0,48,57,65,90,95,
95,97,122,4,0,10,10,13,13,34,34,92,92,8,0,34,34,47,47,92,92,98,98,
102,102,110,110,114,114,116,116,3,0,48,57,65,70,97,102,2,0,10,10,
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,
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,
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,
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,
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,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,
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,19,132,1,0,0,0,21,139,1,0,0,0,23,143,1,0,0,0,25,150,1,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,
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,
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,
0,0,53,54,5,113,0,0,54,55,5,117,0,0,55,56,5,105,0,0,56,57,5,120,
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,
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,
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,
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,
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,
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,
82,83,5,113,0,0,83,84,5,117,0,0,84,85,5,105,0,0,85,86,5,120,0,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,
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,
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,
116,0,0,99,100,5,101,0,0,100,101,5,114,0,0,101,102,5,102,0,0,102,
103,5,97,0,0,103,104,5,99,0,0,104,105,5,101,0,0,105,12,1,0,0,0,106,
107,5,112,0,0,107,108,5,97,0,0,108,109,5,99,0,0,109,110,5,107,0,
0,110,111,5,97,0,0,111,112,5,103,0,0,112,113,5,101,0,0,113,14,1,
0,0,0,114,115,5,114,0,0,115,116,5,101,0,0,116,117,5,112,0,0,117,
118,5,111,0,0,118,119,5,115,0,0,119,120,5,105,0,0,120,121,5,116,
0,0,121,122,5,111,0,0,122,123,5,114,0,0,123,124,5,121,0,0,124,16,
1,0,0,0,125,126,5,99,0,0,126,127,5,111,0,0,127,128,5,109,0,0,128,
129,5,109,0,0,129,130,5,105,0,0,130,131,5,116,0,0,131,18,1,0,0,0,
132,133,5,112,0,0,133,134,5,111,0,0,134,135,5,108,0,0,135,136,5,
105,0,0,136,137,5,99,0,0,137,138,5,121,0,0,138,20,1,0,0,0,139,140,
5,114,0,0,140,141,5,101,0,0,141,142,5,102,0,0,142,22,1,0,0,0,143,
144,5,112,0,0,144,145,5,105,0,0,145,146,5,110,0,0,146,147,5,110,
0,0,147,148,5,101,0,0,148,149,5,100,0,0,149,24,1,0,0,0,150,151,5,
116,0,0,151,152,5,114,0,0,152,153,5,97,0,0,153,154,5,99,0,0,154,
155,5,107,0,0,155,156,5,45,0,0,156,157,5,114,0,0,157,158,5,101,0,
0,158,159,5,108,0,0,159,160,5,101,0,0,160,161,5,97,0,0,161,162,5,
115,0,0,162,163,5,101,0,0,163,26,1,0,0,0,164,165,5,116,0,0,165,166,
5,114,0,0,166,167,5,97,0,0,167,168,5,99,0,0,168,169,5,107,0,0,169,
170,5,45,0,0,170,171,5,100,0,0,171,172,5,101,0,0,172,173,5,118,0,
0,173,174,5,101,0,0,174,175,5,108,0,0,175,176,5,111,0,0,176,177,
5,112,0,0,177,178,5,109,0,0,178,179,5,101,0,0,179,180,5,110,0,0,
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
];
+381 -86
View File
@@ -11,44 +11,58 @@ type int = number;
export class QuixosLockParser extends antlr.Parser {
public static readonly QUIXOS_LOCK = 1;
public static readonly VERSION = 2;
public static readonly QUIXOS = 3;
public static readonly SOURCE = 4;
public static readonly INTERFACE = 5;
public static readonly PACKAGE = 6;
public static readonly REPOSITORY = 7;
public static readonly COMMIT = 8;
public static readonly LBRACE = 9;
public static readonly RBRACE = 10;
public static readonly SEMI = 11;
public static readonly INTEGER = 12;
public static readonly IDENTIFIER = 13;
public static readonly STRING_LITERAL = 14;
public static readonly LINE_COMMENT = 15;
public static readonly BLOCK_COMMENT = 16;
public static readonly WS = 17;
public static readonly FRAGMENT = 2;
public static readonly VERSION = 3;
public static readonly QUIXOS = 4;
public static readonly SOURCE = 5;
public static readonly INTERFACE = 6;
public static readonly PACKAGE = 7;
public static readonly REPOSITORY = 8;
public static readonly COMMIT = 9;
public static readonly POLICY = 10;
public static readonly REF = 11;
public static readonly PINNED = 12;
public static readonly TRACK_RELEASE = 13;
public static readonly TRACK_DEVELOPMENT = 14;
public static readonly IMPORT = 15;
public static readonly LBRACE = 16;
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_quixosEntry = 1;
public static readonly RULE_resourceEntry = 2;
public static readonly RULE_resourceKind = 3;
public static readonly RULE_sourceBlock = 4;
public static readonly RULE_identifier = 5;
public static readonly RULE_stringLiteral = 6;
public static readonly RULE_importEntry = 3;
public static readonly RULE_resourceKind = 4;
public static readonly RULE_sourceBlock = 5;
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 = [
null, "'quixos-lock'", "'version'", "'quixos'", "'source'", "'interface'",
"'package'", "'repository'", "'commit'", "'{'", "'}'", "';'"
null, "'quixos-lock'", "'fragment'", "'version'", "'quixos'", "'source'",
"'interface'", "'package'", "'repository'", "'commit'", "'policy'",
"'ref'", "'pinned'", "'track-release'", "'track-development'", "'import'",
"'{'", "'}'", "';'"
];
public static readonly symbolicNames = [
null, "QUIXOS_LOCK", "VERSION", "QUIXOS", "SOURCE", "INTERFACE",
"PACKAGE", "REPOSITORY", "COMMIT", "LBRACE", "RBRACE", "SEMI", "INTEGER",
"IDENTIFIER", "STRING_LITERAL", "LINE_COMMENT", "BLOCK_COMMENT",
"WS"
null, "QUIXOS_LOCK", "FRAGMENT", "VERSION", "QUIXOS", "SOURCE",
"INTERFACE", "PACKAGE", "REPOSITORY", "COMMIT", "POLICY", "REF",
"PINNED", "TRACK_RELEASE", "TRACK_DEVELOPMENT", "IMPORT", "LBRACE",
"RBRACE", "SEMI", "INTEGER", "IDENTIFIER", "STRING_LITERAL", "LINE_COMMENT",
"BLOCK_COMMENT", "WS"
];
public static readonly ruleNames = [
"document", "quixosEntry", "resourceEntry", "resourceKind", "sourceBlock",
"identifier", "stringLiteral",
"document", "quixosEntry", "resourceEntry", "importEntry", "resourceKind",
"sourceBlock", "quixosSourceBlock", "sourcePolicy", "identifier",
"stringLiteral",
];
public get grammarFileName(): string { return "QuixosLock.g4"; }
@@ -72,33 +86,62 @@ export class QuixosLockParser extends antlr.Parser {
try {
this.enterOuterAlt(localContext, 1);
{
this.state = 14;
this.state = 20;
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.errorHandler.sync(this);
_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();
}
break;
default:
throw new antlr.NoViableAltException(this);
}
this.state = 24;
}
this.state = 34;
this.errorHandler.sync(this);
_la = this.tokenStream.LA(1);
}
this.state = 25;
this.state = 35;
this.match(QuixosLockParser.RBRACE);
this.state = 26;
this.state = 36;
this.match(QuixosLockParser.EOF);
}
}
@@ -121,12 +164,12 @@ export class QuixosLockParser extends antlr.Parser {
try {
this.enterOuterAlt(localContext, 1);
{
this.state = 28;
this.state = 38;
this.match(QuixosLockParser.QUIXOS);
this.state = 29;
this.state = 39;
this.match(QuixosLockParser.SOURCE);
this.state = 30;
this.sourceBlock();
this.state = 40;
this.quixosSourceBlock();
}
}
catch (re) {
@@ -148,13 +191,13 @@ export class QuixosLockParser extends antlr.Parser {
try {
this.enterOuterAlt(localContext, 1);
{
this.state = 32;
this.state = 42;
this.resourceKind();
this.state = 33;
this.state = 43;
this.identifier();
this.state = 34;
this.state = 44;
this.match(QuixosLockParser.SOURCE);
this.state = 35;
this.state = 45;
this.sourceBlock();
}
}
@@ -171,16 +214,43 @@ export class QuixosLockParser extends antlr.Parser {
}
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 {
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;
try {
this.enterOuterAlt(localContext, 1);
{
this.state = 37;
this.state = 51;
_la = this.tokenStream.LA(1);
if(!(_la === 5 || _la === 6)) {
if(!(_la === 6 || _la === 7)) {
this.errorHandler.recoverInline(this);
}
else {
@@ -204,25 +274,25 @@ export class QuixosLockParser extends antlr.Parser {
}
public sourceBlock(): SourceBlockContext {
let localContext = new SourceBlockContext(this.context, this.state);
this.enterRule(localContext, 8, QuixosLockParser.RULE_sourceBlock);
this.enterRule(localContext, 10, QuixosLockParser.RULE_sourceBlock);
try {
this.enterOuterAlt(localContext, 1);
{
this.state = 39;
this.state = 53;
this.match(QuixosLockParser.LBRACE);
this.state = 40;
this.state = 54;
this.match(QuixosLockParser.REPOSITORY);
this.state = 41;
this.state = 55;
this.stringLiteral();
this.state = 42;
this.state = 56;
this.match(QuixosLockParser.SEMI);
this.state = 43;
this.state = 57;
this.match(QuixosLockParser.COMMIT);
this.state = 44;
this.state = 58;
this.stringLiteral();
this.state = 45;
this.state = 59;
this.match(QuixosLockParser.SEMI);
this.state = 46;
this.state = 60;
this.match(QuixosLockParser.RBRACE);
}
}
@@ -239,13 +309,102 @@ export class QuixosLockParser extends antlr.Parser {
}
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 {
let localContext = new IdentifierContext(this.context, this.state);
this.enterRule(localContext, 10, QuixosLockParser.RULE_identifier);
this.enterRule(localContext, 16, QuixosLockParser.RULE_identifier);
try {
this.enterOuterAlt(localContext, 1);
{
this.state = 48;
this.state = 82;
this.match(QuixosLockParser.IDENTIFIER);
}
}
@@ -264,11 +423,11 @@ export class QuixosLockParser extends antlr.Parser {
}
public stringLiteral(): StringLiteralContext {
let localContext = new StringLiteralContext(this.context, this.state);
this.enterRule(localContext, 12, QuixosLockParser.RULE_stringLiteral);
this.enterRule(localContext, 18, QuixosLockParser.RULE_stringLiteral);
try {
this.enterOuterAlt(localContext, 1);
{
this.state = 50;
this.state = 84;
this.match(QuixosLockParser.STRING_LITERAL);
}
}
@@ -287,21 +446,31 @@ export class QuixosLockParser extends antlr.Parser {
}
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,
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
];
private static __ATN: antlr.ATN;
@@ -339,15 +508,33 @@ export class DocumentContext extends antlr.ParserRuleContext {
public LBRACE(): antlr.TerminalNode {
return this.getToken(QuixosLockParser.LBRACE, 0)!;
}
public quixosEntry(): QuixosEntryContext {
return this.getRuleContext(0, QuixosEntryContext)!;
}
public RBRACE(): antlr.TerminalNode {
return this.getToken(QuixosLockParser.RBRACE, 0)!;
}
public EOF(): antlr.TerminalNode {
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(i: number): ResourceEntryContext | null;
public resourceEntry(i?: number): ResourceEntryContext[] | ResourceEntryContext | null {
@@ -380,8 +567,8 @@ export class QuixosEntryContext extends antlr.ParserRuleContext {
public SOURCE(): antlr.TerminalNode {
return this.getToken(QuixosLockParser.SOURCE, 0)!;
}
public sourceBlock(): SourceBlockContext {
return this.getRuleContext(0, SourceBlockContext)!;
public quixosSourceBlock(): QuixosSourceBlockContext {
return this.getRuleContext(0, QuixosSourceBlockContext)!;
}
public override get ruleIndex(): number {
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 {
public constructor(parent: antlr.ParserRuleContext | null, invokingState: number) {
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 {
public constructor(parent: antlr.ParserRuleContext | null, invokingState: number) {
super(parent, invokingState);
@@ -5,8 +5,11 @@ import { AbstractParseTreeVisitor } from "antlr4ng";
import { DocumentContext } from "./QuixosLockParser.js";
import { QuixosEntryContext } from "./QuixosLockParser.js";
import { ResourceEntryContext } from "./QuixosLockParser.js";
import { ImportEntryContext } from "./QuixosLockParser.js";
import { ResourceKindContext } from "./QuixosLockParser.js";
import { SourceBlockContext } from "./QuixosLockParser.js";
import { QuixosSourceBlockContext } from "./QuixosLockParser.js";
import { SourcePolicyContext } from "./QuixosLockParser.js";
import { IdentifierContext } from "./QuixosLockParser.js";
import { StringLiteralContext } from "./QuixosLockParser.js";
@@ -37,6 +40,12 @@ export class QuixosLockVisitor<Result> extends AbstractParseTreeVisitor<Result>
* @return the visitor 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`.
* @param ctx the parse tree
@@ -49,6 +58,18 @@ export class QuixosLockVisitor<Result> extends AbstractParseTreeVisitor<Result>
* @return the visitor 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`.
* @param ctx the parse tree
+1
View File
@@ -1,2 +1,3 @@
export * from "./loader.js";
export * from "./parser.js";
export * from "./types.js";
+142
View File
@@ -0,0 +1,142 @@
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,
);
};
+217 -34
View File
@@ -10,16 +10,13 @@ import {
import { QuixosLockLexer } from "./generated/QuixosLockLexer.js";
import {
QuixosLockParser,
type QuixosSourceBlockContext,
type SourceBlockContext,
} from "./generated/QuixosLockParser.js";
import type {
GitSource,
LockedResource,
QuixosRepositoryLock,
} from "./types.js";
import type { GitSource, LockedResource, QuixosLockDocument, QuixosRepositoryLock, QuixosSource } from "./types.js";
export type QuixosLockDiagnostic = {
phase: "syntax" | "validation";
phase: "syntax" | "validation" | "resolution";
code: string;
message: string;
fileName: string;
@@ -32,6 +29,10 @@ export type QuixosLockParseResult =
| { ok: true; lock: QuixosRepositoryLock; diagnostics: [] }
| { ok: false; diagnostics: QuixosLockDiagnostic[] };
export type QuixosLockDocumentParseResult =
| { ok: true; document: QuixosLockDocument; diagnostics: [] }
| { ok: false; diagnostics: QuixosLockDiagnostic[] };
class SyntaxErrorListener extends BaseErrorListener {
constructor(
private readonly fileName: string,
@@ -59,8 +60,7 @@ class SyntaxErrorListener extends BaseErrorListener {
}
}
const stringValue = (context: { getText(): string }): string =>
JSON.parse(context.getText()) as string;
const stringValue = (context: { getText(): string }): string => JSON.parse(context.getText()) as string;
const lowerSource = (context: SourceBlockContext): GitSource => ({
resolver: "git",
@@ -68,28 +68,74 @@ const lowerSource = (context: SourceBlockContext): GitSource => ({
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 = (
diagnostics: QuixosLockDiagnostic[],
fileName: string,
code: string,
message: string,
path?: string,
) => diagnostics.push({
line = 1,
column = 0,
) =>
diagnostics.push({
phase: "validation",
code,
message,
fileName,
line: 1,
column: 0,
line,
column,
path,
});
});
const validateSource = (
source: GitSource,
path: string,
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 = (source: GitSource, path: string, fileName: string, diagnostics: QuixosLockDiagnostic[]) => {
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(source.commit)) {
issue(
diagnostics,
@@ -141,10 +187,53 @@ const validateSource = (
}
};
export const parseQuixosLock = (
source: string,
fileName = "<memory>",
): QuixosLockParseResult => {
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, fileName = "<memory>"): QuixosLockDocumentParseResult => {
const diagnostics: QuixosLockDiagnostic[] = [];
const listener = new SyntaxErrorListener(fileName, diagnostics);
const lexer = new QuixosLockLexer(CharStream.fromString(source));
@@ -167,8 +256,42 @@ export const parseQuixosLock = (
);
}
const quixos = lowerSource(tree.quixosEntry().sourceBlock());
validateSource(quixos, "quixos", fileName, diagnostics);
const fragment = Boolean(tree.FRAGMENT());
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 bindings = new Set<string>();
@@ -195,7 +318,54 @@ export const parseQuixosLock = (
? { ok: false, diagnostics }
: {
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: [],
};
};
@@ -207,20 +377,33 @@ const sourceLines = (source: GitSource, indentation: string): string[] => [
`${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 => {
const lines = [
"quixos-lock version 1 {",
" quixos source {",
...sourceLines(lock.quixos, " "),
" }",
];
const lines = ["quixos-lock version 1 {", " quixos source {", ...quixosSourceLines(lock.quixos, " "), " }"];
for (const resource of lock.resources) {
lines.push(
"",
` ${resource.kind} ${resource.binding} source {`,
...sourceLines(resource.source, " "),
" }",
);
lines.push("", ` ${resource.kind} ${resource.binding} source {`, ...sourceLines(resource.source, " "), " }");
}
lines.push("}", "");
return lines.join("\n");
};
export const formatQuixosLockDocument = (document: QuixosLockDocument): string => {
const lines = [`quixos-lock${document.kind === "fragment" ? " fragment" : ""} version 1 {`];
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");
+37 -3
View File
@@ -4,6 +4,23 @@ export type GitSource = {
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 LockedResource = {
@@ -12,10 +29,28 @@ export type LockedResource = {
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 = {
formatVersion: 1;
quixos: GitSource;
quixos: QuixosSource;
resources: LockedResource[];
sourceFiles?: string[];
};
export type NixGitInput = {
@@ -27,8 +62,7 @@ export type NixGitInput = {
export const RETENTION_TAG_PREFIX = "refs/tags/quixos-reachability/";
export const retentionTagForCommit = (commit: string): string =>
`${RETENTION_TAG_PREFIX}${commit.toLowerCase()}`;
export const retentionTagForCommit = (commit: string): string => `${RETENTION_TAG_PREFIX}${commit.toLowerCase()}`;
export const nixGitInput = (source: GitSource): NixGitInput => ({
type: "git",
+116
View File
@@ -0,0 +1,116 @@
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)));
});
+40
View File
@@ -0,0 +1,40 @@
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/);
});
+83
View File
@@ -0,0 +1,83 @@
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),
),
);
});
+44
View File
@@ -0,0 +1,44 @@
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/);
});
+36
View File
@@ -0,0 +1,36 @@
import test from "node:test";
import assert from "node:assert/strict";
import { bundlePolicyErrors } from "../src/bindings/bundle-policy.js";
import { addImplementation } from "../src/capability-language/implementation-edit.js";
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/);
});
+57
View File
@@ -0,0 +1,57 @@
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);
});
+220
View File
@@ -0,0 +1,220 @@
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\)/,
);
});
+29
View File
@@ -0,0 +1,29 @@
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/);
});
+194 -65
View File
@@ -1,17 +1,90 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { test } from "node:test";
import { compileCapabilitySource } from "../src/capability-language/index.js";
import { compileCapabilityResourceSource, compileCapabilitySource } from "../src/capability-language/index.js";
import {
capabilityFixturePath,
capabilityFixtureSource,
capabilityResourceSources,
compileCapabilityFixture,
fixtureId,
} from "./fixtures/capability-model.js";
test("ANTLR parses and validates a complete capability workspace", () => {
const result = compileCapabilitySource(
capabilityFixtureSource,
capabilityFixturePath,
test("RPC record fields retain declared reference targets and reject duplicate fields", () => {
const compile = (fields: string) =>
compileCapabilityResourceSource(
`
external atom Board id "atom:board";
package Runtime id "package:runtime" revision "package:runtime@1" {
function move id "export:move" : record { ${fields} } -> unit;
}`,
{ source: { repository: "https://example.test/runtime.git", commit: "a".repeat(40) } },
);
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);
if (!result.ok) return;
assert.equal(result.workspace.workspaceId, fixtureId.workspace);
@@ -21,27 +94,25 @@ test("ANTLR parses and validates a complete capability workspace", () => {
assert.equal("id" in result.workspace.conformances[0]!, false);
});
test("conformances do not accept authored IDs", () => {
const result = compileCapabilitySource(
capabilityFixtureSource.replace(
test("conformances preserve authored migration lineage IDs", () => {
const result = compileCapabilityFixture({
workspace: capabilityFixtureSource.replace(
"conform Project as Named {",
'conform Project as Named id "conformance:project:named" {',
),
"conformance-id.qx",
);
assert.equal(result.ok, false);
if (result.ok) return;
assert.ok(result.diagnostics.some((entry) => entry.phase === "syntax"));
});
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.workspace.conformances[0]!.id, "conformance:project:named");
});
test("the v1 language has no implicit relationship materialization rule", () => {
const result = compileCapabilitySource(
capabilityFixtureSource.replace(
const result = compileCapabilityFixture({
workspace: capabilityFixtureSource.replace(
/\n}\s*$/,
'\n materialize ProjectOwner.owner if absent with Person;\n}\n',
"\n materialize ProjectOwner.owner if absent with Person;\n}\n",
),
"materialize.qx",
);
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.ok(result.diagnostics.some((entry) => entry.phase === "syntax"));
@@ -51,45 +122,41 @@ test("forward declarations make source order irrelevant", () => {
const source = capabilityFixtureSource
.replace(/ atom Project[^\n]*\n/, "")
.replace(/ atom Person[^\n]*\n/, "")
.replace(
/\n}\s*$/,
'\n atom Project id "atom:project";\n atom Person id "atom:person";\n}\n',
);
assert.equal(compileCapabilitySource(source, "reordered.qx").ok, true);
.replace(/\n}\s*$/, '\n atom Project id "atom:project";\n atom Person id "atom:person";\n}\n');
assert.equal(compileCapabilityFixture({ workspace: source }).ok, true);
});
test("interfaces can declare ordinary call operations", () => {
const source = 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(
const workspace = capabilityFixtureSource.replace(
"bind summary.get to package TodoRuntime.summaryGet",
"bind summarize.call to package TodoRuntime.summaryGet",
).replace(
"operation summaryGet id \"export:todo-runtime:summary-get\" : unit -> string",
"operation summaryGet id \"export:todo-runtime:summary-get\" : string -> string",
);
const result = compileCapabilitySource(source, "operation-member.qx");
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" : string -> string',
);
const result = compileCapabilityFixture({ workspace, summary, todo });
assert.equal(result.ok, true);
if (!result.ok) return;
const member = result.workspace.interfaceImports
.find((entry) => entry.displayName === "Summary")?.members[0];
const member = result.workspace.interfaceImports.find((entry) => entry.displayName === "Summary")?.members[0];
assert.equal(member?.kind, "operation");
});
test("state defaults accept recursive JSON values", () => {
const source = capabilityFixtureSource.replace(
' policy optimistic-register default "Untitled project";',
/policy optimistic-register default "Untitled project";/,
` policy optimistic-register default "Untitled project";
shared state ProjectMetadata id "slot:project:metadata" on Project : message "example.Metadata"
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);
if (!result.ok) return;
const metadata = result.workspace.sharedAttachments.find(
(entry) => entry.id === "slot:project:metadata",
);
const metadata = result.workspace.sharedAttachments.find((entry) => entry.id === "slot:project:metadata");
assert.equal(metadata?.kind, "state");
if (metadata?.kind !== "state") return;
assert.deepEqual(metadata.defaultValue, {
@@ -101,48 +168,110 @@ test("state defaults accept recursive JSON values", () => {
});
test("syntax errors retain source locations", () => {
const result = compileCapabilitySource(
capabilityFixtureSource.replace("atom Project", "atom Project ???"),
"broken.qx",
);
const result = compileCapabilityFixture({
workspace: capabilityFixtureSource.replace("atom Project", "atom Project ???"),
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.ok(result.diagnostics.some((entry) =>
entry.phase === "syntax" && entry.fileName === "broken.qx" && entry.line > 0
));
assert.ok(result.diagnostics.some((entry) => entry.phase === "syntax" && entry.line > 0));
});
test("unknown authoring names are lowering errors", () => {
const result = compileCapabilitySource(
capabilityFixtureSource.replace(
"to state ProjectTitle.read",
"to state NotAState.read",
),
"unknown-name.qx",
);
const result = compileCapabilityFixture({
workspace: capabilityFixtureSource.replace("to state ProjectTitle.read", "to state NotAState.read"),
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.ok(result.diagnostics.some((entry) =>
entry.phase === "lowering" &&
entry.code === "unknown-symbol" &&
entry.message.includes("NotAState")
));
assert.ok(
result.diagnostics.some(
(entry) => entry.phase === "lowering" && entry.code === "unknown-symbol" && entry.message.includes("NotAState"),
),
);
});
test("well-formed but invalid programs report semantic paths", () => {
const result = compileCapabilitySource(
capabilityFixtureSource.replace(
const result = compileCapabilityFixture({
workspace: capabilityFixtureSource.replace(
"bind name.get to state ProjectTitle.read",
"bind name.get to state ProjectTitle.write",
),
"invalid-binding.qx",
);
});
assert.equal(result.ok, false);
if (result.ok) return;
const diagnostic = result.diagnostics.find(
(entry) => entry.code === "invalid-state-binding",
);
const diagnostic = result.diagnostics.find((entry) => entry.code === "invalid-state-binding");
assert.ok(diagnostic);
assert.equal(diagnostic.phase, "validation");
assert.ok(diagnostic.path?.includes("operationBindings"));
});
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"),
),
);
});
+96 -79
View File
@@ -9,37 +9,53 @@ import {
type CapabilityValidationIssueCode,
type WorkspaceRevision,
} from "../src/capability-model/index.js";
import {
fixtureId,
makeValidCapabilityWorkspace,
} from "./fixtures/capability-model.js";
import { fixtureId, makeValidCapabilityWorkspace } from "./fixtures/capability-model.js";
const expectIssue = (
workspace: WorkspaceRevision,
code: CapabilityValidationIssueCode,
) => {
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 = (workspace: WorkspaceRevision, code: CapabilityValidationIssueCode) => {
const issues = validateWorkspaceRevision(workspace);
assert.ok(
issues.some((entry) => entry.code === code),
`Expected ${code}, got:\n${issues
.map((entry) => `${entry.code} ${entry.path}: ${entry.message}`)
.join("\n")}`,
`Expected ${code}, got:\n${issues.map((entry) => `${entry.code} ${entry.path}: ${entry.message}`).join("\n")}`,
);
assert.equal(compileWorkspaceRevision(workspace).ok, false);
};
test("constructor dependency input contracts match the selected constructor", () => {
const workspace = makeValidCapabilityWorkspace();
const port = workspace.packageImports
.flatMap((pkg) => pkg.exports)
.flatMap((entry) => entry.dependencyPorts)
.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 = (
workspace: WorkspaceRevision,
identity: Pick<(typeof workspace.conformances)[number], "atomId" | "interfaceRevisionId">,
) => {
const result = workspace.conformances.find((entry) =>
entry.atomId === identity.atomId &&
entry.interfaceRevisionId === identity.interfaceRevisionId
);
assert.ok(
result,
`Missing fixture conformance ${identity.atomId} as ${identity.interfaceRevisionId}`,
const result = workspace.conformances.find(
(entry) => entry.atomId === identity.atomId && entry.interfaceRevisionId === identity.interfaceRevisionId,
);
assert.ok(result, `Missing fixture conformance ${identity.atomId} as ${identity.interfaceRevisionId}`);
return result;
};
@@ -50,12 +66,7 @@ test("the representative v1 workspace compiles to native and package plans", ()
assert.equal(compiled.ok, true);
if (!compiled.ok) return;
const title = resolveOperationPlan(
compiled.plan,
fixtureId.project,
fixtureId.namedV1,
fixtureId.namedGet,
);
const title = resolveOperationPlan(compiled.plan, fixtureId.project, fixtureId.namedV1, fixtureId.namedGet);
assert.equal(title?.kind, "state");
if (title?.kind === "state") {
assert.equal(title.binding.slotId, fixtureId.projectTitle);
@@ -63,12 +74,7 @@ test("the representative v1 workspace compiles to native and package plans", ()
assert.deepEqual(title.attachment.owner, { kind: "workspace" });
}
const owner = resolveOperationPlan(
compiled.plan,
fixtureId.project,
fixtureId.ownedV1,
fixtureId.ownerResolve,
);
const owner = resolveOperationPlan(compiled.plan, fixtureId.project, fixtureId.ownedV1, fixtureId.ownerResolve);
assert.equal(owner?.kind, "edge");
if (owner?.kind === "edge") {
assert.equal(owner.binding.edgeTypeId, fixtureId.projectOwner);
@@ -78,12 +84,7 @@ test("the representative v1 workspace compiles to native and package plans", ()
});
}
const summary = resolveOperationPlan(
compiled.plan,
fixtureId.project,
fixtureId.summaryV1,
fixtureId.summaryGet,
);
const summary = resolveOperationPlan(compiled.plan, fixtureId.project, fixtureId.summaryV1, fixtureId.summaryGet);
assert.equal(summary?.kind, "package");
if (summary?.kind === "package") {
assert.equal(summary.packageRevision.revisionId, fixtureId.todoRuntimeV1);
@@ -92,7 +93,7 @@ test("the representative v1 workspace compiles to native and package plans", ()
summary.dependencies.map((entry) => [entry.port.id, entry.binding.kind]),
[
[fixtureId.titlePort, "state"],
[fixtureId.namedPort, "receiver-interface"],
[fixtureId.namedPort, "interface"],
[fixtureId.personPort, "constructor"],
],
);
@@ -106,10 +107,7 @@ test("the closure is an exact tree-shaking boundary", () => {
const closure = computeCapabilityClosure(result.plan, [
{ atomId: fixtureId.project, interfaceRevisionId: fixtureId.summaryV1 },
]);
assert.deepEqual(closure.conformances, [
fixtureId.projectNamedConformance,
fixtureId.projectSummaryConformance,
]);
assert.deepEqual(closure.conformances, [fixtureId.projectNamedConformance, fixtureId.projectSummaryConformance]);
assert.deepEqual(closure.packageRevisionIds, [fixtureId.todoRuntimeV1]);
assert.deepEqual(closure.attachmentIds, [fixtureId.projectTitle]);
assert.deepEqual(closure.constructorAtomIds, [fixtureId.person]);
@@ -120,18 +118,12 @@ test("compiled plans are snapshots, not mutable authoring state", () => {
const result = compileWorkspaceRevision(workspace);
assert.equal(result.ok, true);
if (!result.ok) return;
conformance(workspace, fixtureId.projectNamedConformance)
.operationBindings[0]!.binding = {
conformance(workspace, fixtureId.projectNamedConformance).operationBindings[0]!.binding = {
kind: "state",
slotId: fixtureId.personName,
primitive: "read",
};
const resolved = resolveOperationPlan(
result.plan,
fixtureId.project,
fixtureId.namedV1,
fixtureId.namedGet,
);
const resolved = resolveOperationPlan(result.plan, fixtureId.project, fixtureId.namedV1, fixtureId.namedGet);
assert.equal(resolved?.kind, "state");
if (resolved?.kind === "state") {
assert.equal(resolved.binding.slotId, fixtureId.projectTitle);
@@ -144,41 +136,76 @@ test("a conformance binds every operation exactly once", () => {
expectIssue(missing, "missing-operation-binding");
const duplicate = makeValidCapabilityWorkspace();
const bindings = conformance(
duplicate,
fixtureId.projectNamedConformance,
).operationBindings;
const bindings = conformance(duplicate, fixtureId.projectNamedConformance).operationBindings;
bindings.push(structuredClone(bindings[0]!));
expectIssue(duplicate, "duplicate-operation-binding");
});
test("private attachments are visible only to their owning conformance", () => {
const workspace = makeValidCapabilityWorkspace();
const binding = conformance(
workspace,
fixtureId.projectNamedConformance,
).operationBindings[0]!.binding;
const binding = conformance(workspace, fixtureId.projectNamedConformance).operationBindings[0]!.binding;
assert.equal(binding.kind, "state");
if (binding.kind !== "state") return;
binding.slotId = fixtureId.personName;
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", () => {
const stateWorkspace = makeValidCapabilityWorkspace();
const state = conformance(
stateWorkspace,
fixtureId.projectNamedConformance,
).operationBindings[0]!.binding;
const state = conformance(stateWorkspace, fixtureId.projectNamedConformance).operationBindings[0]!.binding;
assert.equal(state.kind, "state");
if (state.kind === "state") state.primitive = "write";
expectIssue(stateWorkspace, "invalid-state-binding");
const edgeWorkspace = makeValidCapabilityWorkspace();
const edge = conformance(
edgeWorkspace,
fixtureId.projectOwnedConformance,
).operationBindings[0]!.binding;
const edge = conformance(edgeWorkspace, fixtureId.projectOwnedConformance).operationBindings[0]!.binding;
assert.equal(edge.kind, "edge");
if (edge.kind === "edge") edge.primitive = "connect";
expectIssue(edgeWorkspace, "invalid-edge-binding");
@@ -186,18 +213,13 @@ test("native state and edge providers must match operation shape", () => {
test("package dependencies are complete, exact, and explicitly injected", () => {
const missing = makeValidCapabilityWorkspace();
const packageBinding = conformance(
missing,
fixtureId.projectSummaryConformance,
).operationBindings[0]!.binding;
const packageBinding = conformance(missing, fixtureId.projectSummaryConformance).operationBindings[0]!.binding;
assert.equal(packageBinding.kind, "package");
if (packageBinding.kind === "package") packageBinding.dependencies.pop();
expectIssue(missing, "invalid-dependency-binding");
const wrongType = makeValidCapabilityWorkspace();
const summaryExport = wrongType.packageImports[0]!.exports.find(
(entry) => entry.id === fixtureId.summaryGetExport,
);
const summaryExport = wrongType.packageImports[0]!.exports.find((entry) => entry.id === fixtureId.summaryGetExport);
assert.ok(summaryExport);
summaryExport.dependencyPorts[0]!.requirement = {
kind: "state",
@@ -209,19 +231,14 @@ test("package dependencies are complete, exact, and explicitly injected", () =>
test("package receiver requirements cannot depend on themselves", () => {
const workspace = makeValidCapabilityWorkspace();
const summaryExport = workspace.packageImports[0]!.exports.find(
(entry) => entry.id === fixtureId.summaryGetExport,
);
const summaryExport = workspace.packageImports[0]!.exports.find((entry) => entry.id === fixtureId.summaryGetExport);
assert.ok(summaryExport && summaryExport.kind === "operation");
summaryExport.receiverRequirement = {
kind: "all-interfaces",
interfaceRevisionIds: [fixtureId.summaryV1],
};
summaryExport.dependencyPorts = [];
const packageBinding = conformance(
workspace,
fixtureId.projectSummaryConformance,
).operationBindings[0]!.binding;
const packageBinding = conformance(workspace, fixtureId.projectSummaryConformance).operationBindings[0]!.binding;
assert.equal(packageBinding.kind, "package");
if (packageBinding.kind === "package") packageBinding.dependencies = [];
expectIssue(workspace, "cyclic-conformance-requirement");

Some files were not shown because too many files have changed in this diff Show More