Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9867bf4552 | |||
| 483bc68a94 | |||
| 1e25f391e7 | |||
| 4e17693d82 | |||
| ce793cc54f |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git",
|
||||
"sourceCommit": "b384206b9c01a9ac50030a583d028b71291bc8c5",
|
||||
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
|
||||
"sourceCommit": "0e6bb95d903004916bd93670497ab310a076a28b",
|
||||
"sourcePath": "quixos-protocol",
|
||||
"exportName": "quixos-protocol",
|
||||
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
|
||||
|
||||
@@ -42,6 +42,12 @@ 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`.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
pkgs.diffutils
|
||||
pkgs.esbuild
|
||||
pkgs.protobuf
|
||||
pkgs.git
|
||||
];
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
@@ -46,6 +47,8 @@
|
||||
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/capability-language/tool-cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-qx.mjs
|
||||
runHook postBuild
|
||||
'';
|
||||
doCheck = true;
|
||||
@@ -62,6 +65,10 @@
|
||||
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"
|
||||
@@ -90,6 +97,7 @@ exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-lock-check.
|
||||
EOF
|
||||
chmod +x "$out/bin/quixos-lock-check"
|
||||
mkdir -p "$out/libexec/quixos-protocol"
|
||||
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"
|
||||
@@ -101,6 +109,25 @@ EOF
|
||||
};
|
||||
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 = [
|
||||
|
||||
@@ -4,6 +4,15 @@ document
|
||||
: workspaceDecl EOF
|
||||
| interfaceResourceDecl EOF
|
||||
| packageResourceDecl EOF
|
||||
| fragmentDecl EOF
|
||||
;
|
||||
|
||||
fragmentDecl
|
||||
: FRAGMENT LBRACE workspaceItem* RBRACE
|
||||
;
|
||||
|
||||
sourceImportDecl
|
||||
: IMPORT stringLiteral SEMI
|
||||
;
|
||||
|
||||
workspaceDecl
|
||||
@@ -12,7 +21,8 @@ workspaceDecl
|
||||
;
|
||||
|
||||
workspaceItem
|
||||
: atomDecl
|
||||
: sourceImportDecl
|
||||
| atomDecl
|
||||
| resourceImportDecl
|
||||
| sharedAttachmentDecl
|
||||
| conformanceDecl
|
||||
@@ -88,6 +98,7 @@ targetConstraint
|
||||
|
||||
packageResourceDecl
|
||||
: resourcePreamble* PACKAGE identifier ID stringLiteral REVISION stringLiteral
|
||||
(SEMANTIC_MAJOR INTEGER)?
|
||||
LBRACE packageExport* RBRACE
|
||||
;
|
||||
|
||||
@@ -142,7 +153,7 @@ 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
|
||||
| CONSTRUCTOR identifier ID stringLiteral COLON identifier (INPUT valueType)? SEMI
|
||||
;
|
||||
|
||||
primitiveList
|
||||
@@ -183,11 +194,13 @@ 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 (ID stringLiteral)? (SEMANTIC_MAJOR INTEGER)?
|
||||
LBRACE conformanceItem* RBRACE
|
||||
;
|
||||
|
||||
conformanceItem
|
||||
@@ -320,6 +333,7 @@ stringLiteral
|
||||
;
|
||||
|
||||
WORKSPACE: 'workspace';
|
||||
FRAGMENT: 'fragment';
|
||||
IMPORT: 'import';
|
||||
EXTERNAL: 'external';
|
||||
ATOM: 'atom';
|
||||
@@ -332,6 +346,7 @@ OPERATION: 'operation';
|
||||
FUNCTION: 'function';
|
||||
CONSTRUCTOR: 'constructor';
|
||||
CONSTRUCTS: 'constructs';
|
||||
INPUT: 'input';
|
||||
CONFORM: 'conform';
|
||||
AS: 'as';
|
||||
BIND: 'bind';
|
||||
@@ -354,6 +369,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';
|
||||
@@ -423,6 +443,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);
|
||||
|
||||
+6
-3
@@ -5,13 +5,16 @@
|
||||
"packageManager": "yarn@4.18.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"quixos-qx": "dist/src/capability-language/tool-cli.js",
|
||||
"quixos-codegen-ts": "dist/src/bindings/cli.js",
|
||||
"quixos-capability-compile": "dist/src/capability-language/cli.js",
|
||||
"quixos-resource-compile": "dist/src/capability-language/resource-cli.js",
|
||||
"quixos-workspace-compile": "dist/src/capability-language/workspace-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-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",
|
||||
|
||||
@@ -15,6 +15,8 @@ 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);
|
||||
}
|
||||
@@ -108,12 +110,32 @@ message ResolveEdgeResponse { repeated CaminoEdge edges = 1; }
|
||||
message DisconnectEdgeRequest { string edge_id = 1; }
|
||||
message DisconnectEdgeResponse { string edge_id = 1; }
|
||||
|
||||
message CollectionEntry {
|
||||
// Existing entry identity to preserve; empty allocates a new canonical edge.
|
||||
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;
|
||||
@@ -141,6 +163,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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -9,13 +9,37 @@ 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,6 +48,7 @@ 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;
|
||||
@@ -38,6 +63,7 @@ message WatchRequest {
|
||||
string object_id = 3;
|
||||
map<string, camino.Value> input = 4;
|
||||
repeated quixos.InjectedDependency dependencies = 5;
|
||||
InvocationContext context = 6;
|
||||
}
|
||||
|
||||
message DerivedDependency {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { generateTypeScriptBindings } from "./index.js";
|
||||
|
||||
const main = async () => {
|
||||
const [schema, revision, output, options, ...rest] = process.argv.slice(2);
|
||||
if (!schema || !revision || !output || rest.length) throw new Error(
|
||||
"usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]");
|
||||
const generated = generateTypeScriptBindings(JSON.parse(await readFile(schema, "utf8")), revision,
|
||||
options ? JSON.parse(await readFile(options, "utf8")) : {});
|
||||
await writeFile(output, generated);
|
||||
};
|
||||
main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { InterfaceRevision, PackageRevision, ValueType, DependencyPort } from "../capability-model/types.js";
|
||||
import type { CompiledCapabilityResourceRepository } from "../capability-language/assembly.js";
|
||||
|
||||
/** Portable generator input. New backends consume this instead of the parser or TS runtime. */
|
||||
export type BindingSchema = {
|
||||
format: "quixos-bindings";
|
||||
version: 1;
|
||||
interfaces: InterfaceRevision[];
|
||||
packages: PackageRevision[];
|
||||
};
|
||||
export const bindingSchema = (compiled: CompiledCapabilityResourceRepository): BindingSchema => ({
|
||||
format: "quixos-bindings", version: 1,
|
||||
interfaces: compiled.resources.flatMap((node) => node.resource.kind === "interface" ? [node.resource.revision] : []),
|
||||
packages: compiled.resources.flatMap((node) => node.resource.kind === "package" ? [node.resource.revision] : []),
|
||||
});
|
||||
export type TypeScriptBindingOptions = {
|
||||
runtimeModule?: string;
|
||||
/** Each export must implement MessageBinding<T>, providing both TS type and wire codec. */
|
||||
messages?: Record<string, { module: string; export: string }>;
|
||||
};
|
||||
const q = JSON.stringify;
|
||||
const object = (entries: [string, string][]) => `{ ${entries.map(([key, value]) => `${q(key)}: ${value}`).join("; ")} }`;
|
||||
const unit = (type: ValueType) => type.kind === "builtin" && type.name === "unit";
|
||||
|
||||
export const generateTypeScriptBindings = (
|
||||
schema: BindingSchema, packageRevisionId: string, options: TypeScriptBindingOptions = {},
|
||||
) => {
|
||||
if (schema.format !== "quixos-bindings" || schema.version !== 1) throw new Error("Unsupported binding schema version");
|
||||
const pkg = schema.packages.find((entry) => entry.revisionId === packageRevisionId);
|
||||
if (!pkg) throw new Error(`Unknown package revision ${packageRevisionId}`);
|
||||
const messages = new Map<string, string>();
|
||||
const type = (value: ValueType): string => {
|
||||
switch (value.kind) {
|
||||
case "builtin": return value.name === "unit" ? "null" : "QxWatchHandle";
|
||||
case "scalar": return ({ bool: "boolean", bytes: "Uint8Array", string: "string", int64: "bigint", uint64: "bigint",
|
||||
double: "number", int32: "number", uint32: "number" })[value.name];
|
||||
case "object-ref": return `QxObjectRef<${q(value.expectation.kind === "atom" ? `atom:${value.expectation.atomId}` : `interface:${value.expectation.interfaceRevisionId}`)}>`;
|
||||
case "optional": return `(${type(value.value)} | null)`;
|
||||
case "list": return `Array<${type(value.value)}>`;
|
||||
case "message": {
|
||||
if (!options.messages?.[value.descriptorId]) throw new Error(`Missing TypeScript message binding for ${value.descriptorId}`);
|
||||
if (!messages.has(value.descriptorId)) messages.set(value.descriptorId, `message${messages.size}`);
|
||||
return `BindingValue<typeof ${messages.get(value.descriptorId)}>`;
|
||||
}
|
||||
}
|
||||
};
|
||||
const ref = (target: { kind: "atom"; atomId: string } | { kind: "interface"; interfaceRevisionId: string }) =>
|
||||
`QxObjectRef<${q(target.kind === "atom" ? `atom:${target.atomId}` : `interface:${target.interfaceRevisionId}`)}>`;
|
||||
const params = (input: ValueType) => unit(input) ? "" : `input: ${type(input)}`;
|
||||
const port = (entry: DependencyPort): { type: string; spec: unknown } => {
|
||||
const requirement = entry.requirement;
|
||||
switch (requirement.kind) {
|
||||
case "state": {
|
||||
const methods = requirement.primitives.map((primitive): [string, string] => {
|
||||
if (primitive === "read") return ["get", `() => Promise<${type(requirement.valueType)}>`];
|
||||
if (primitive === "write") return ["set", `(value: ${type(requirement.valueType)}) => Promise<void>`];
|
||||
throw new Error(`State primitive ${primitive} is not supported by the TypeScript runtime binding yet`);
|
||||
});
|
||||
return { type: object(methods), spec: { ...requirement, id: entry.id } };
|
||||
}
|
||||
case "edge": {
|
||||
const methods = requirement.primitives.map((primitive): [string, string] => {
|
||||
if (primitive === "resolve") return [primitive, `() => Promise<Array<${ref(requirement.target)}>>`];
|
||||
if (primitive === "connect" || primitive === "disconnect") return [primitive, `(target: ${ref(requirement.target)}) => Promise<void>`];
|
||||
throw new Error(`Edge primitive ${primitive} is not supported by the TypeScript runtime binding yet`);
|
||||
});
|
||||
if (requirement.primitives.includes("resolve")) methods.push(["collection", `() => Promise<RelationshipCollection<${ref(requirement.target)}>>`]);
|
||||
if (["resolve", "connect", "disconnect"].every((primitive) => requirement.primitives.includes(primitive as "resolve"))) methods.push(["replace", `(entries: RelationshipEntry<${ref(requirement.target)}>[], expectedRevision: bigint) => Promise<RelationshipCollection<${ref(requirement.target)}>>`]);
|
||||
return { type: object(methods), spec: { kind: "edge", id: entry.id, primitives: requirement.primitives } };
|
||||
}
|
||||
case "interface": {
|
||||
const contract = schema.interfaces.find((candidate) => candidate.revisionId === requirement.interfaceRevisionId);
|
||||
if (!contract) throw new Error(`Missing imported interface contract ${requirement.interfaceRevisionId}`);
|
||||
// Streaming ports need a future streaming ABI; ordinary calls are fully typed today.
|
||||
const operations = contract.members.flatMap((member) => member.operations.filter((operation) => operation.mode === "call")
|
||||
.map((operation) => ({ ...operation, name: `${member.displayName}.${operation.displayName}` })));
|
||||
return { type: object(operations.map((operation) => [operation.name, `(${params(operation.inputType)}) => Promise<${type(operation.outputType)}>`])),
|
||||
spec: { kind: "interface", id: entry.id, operations: Object.fromEntries(operations.map((operation) => [operation.name, operation])) } };
|
||||
}
|
||||
case "constructor": {
|
||||
const input = requirement.inputType;
|
||||
if (!input) throw new Error(`Constructor port ${entry.id} needs an explicit input contract: add 'input TYPE' after ${requirement.atomId} in QX`);
|
||||
return { type: object([["construct", `(${params(input)}) => Promise<${ref({ kind: "atom", atomId: requirement.atomId })}>`]]),
|
||||
spec: { kind: "constructor", id: entry.id, inputType: input } };
|
||||
}
|
||||
}
|
||||
};
|
||||
const exports = [...pkg.exports].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
||||
const names = new Set<string>();
|
||||
const specs: Record<string, unknown> = {};
|
||||
const contexts: [string, string][] = [];
|
||||
const handlers: [string, string][] = [];
|
||||
for (const entry of exports) {
|
||||
if (names.has(entry.displayName)) throw new Error(`Duplicate export name ${entry.displayName}`);
|
||||
names.add(entry.displayName);
|
||||
const ports = entry.dependencyPorts.map((dependency) => ({ name: dependency.displayName, ...port(dependency) }));
|
||||
if (new Set(ports.map((p) => p.name)).size !== ports.length) throw new Error(`Duplicate dependency name in ${entry.displayName}`);
|
||||
const receiver = entry.kind === "constructor" ? ref({ kind: "atom", atomId: entry.constructsAtom }) :
|
||||
entry.kind === "operation" && entry.receiverRequirement.kind === "exact-atom" ?
|
||||
ref({ kind: "atom", atomId: entry.receiverRequirement.atomId }) :
|
||||
entry.kind === "operation" && entry.receiverRequirement.kind === "all-interfaces" ?
|
||||
`QxObjectRef<${entry.receiverRequirement.interfaceRevisionIds.map((id) => q(`interface:${id}`)).join(" | ") || "never"}>` : "QxObjectRef<string>";
|
||||
const contextShape = object([["objectId", receiver], ["input", type(entry.inputType)],
|
||||
["ports", object(ports.map((port) => [port.name, port.type]))]]);
|
||||
contexts.push([entry.displayName, `${contextShape} & QxContextLifecycle<${contextShape} & {signal?: AbortSignal}>`]);
|
||||
const event = entry.kind === "operation" ? entry.eventType : undefined;
|
||||
const contextType = `Contexts[${q(entry.displayName)}]`;
|
||||
const outputType = type(event ?? entry.outputType);
|
||||
// Watch-start handlers produce events through the runtime's derived stream protocol.
|
||||
handlers.push([entry.displayName, event ? `QxDerived<${contextType}, ${outputType}>` :
|
||||
`QxHandler<${contextType}, ${outputType}>${entry.kind === "operation" && entry.mode === "call" ? ` | QxDerived<${contextType}, ${outputType}>` : ""}`]);
|
||||
specs[entry.displayName] = { inputType: entry.inputType, outputType: entry.outputType,
|
||||
...(event ? { eventType: event } : {}), ports: Object.fromEntries(ports.map((port) => [port.name, port.spec])) };
|
||||
}
|
||||
const imports = [...messages].map(([id, alias]) => {
|
||||
const binding = options.messages![id]!;
|
||||
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(binding.export)) throw new Error(`Invalid message binding export ${binding.export}`);
|
||||
return `import { ${binding.export} as ${alias} } from ${q(binding.module)};`;
|
||||
});
|
||||
const signatures = `${object(contexts)} ${object(handlers)}`;
|
||||
const typeImports = ["BindingValue", "QxObjectRef", "QxWatchHandle", "QxHandler", "QxDerived", "QxContextLifecycle", "RelationshipCollection", "RelationshipEntry"].filter((name) => new RegExp(`\\b${name}\\b`).test(signatures));
|
||||
return `// Generated by quixos-codegen-ts. Do not edit. Binding ABI version 1.\n` +
|
||||
`import { ${exports.length ? "bindQxHandler, " : ""}${[...typeImports, "QxHandlerSpec", "QxMessages"].map((name) => `type ${name}`).join(", ")} } from ${q(options.runtimeModule ?? "@quixos/camino-package-runtime")};\n` +
|
||||
imports.join("\n") + `\nexport const packageRevisionId = ${q(pkg.revisionId)};\n` +
|
||||
`export type Contexts = ${object(contexts)};\nexport type Implementation = ${object(handlers)};\n` +
|
||||
`const messages = { ${[...messages].map(([id, alias]) => `${q(id)}: ${alias}`).join(", ")} } satisfies QxMessages;\n` +
|
||||
`const specs = ${JSON.stringify(specs, null, 2)} satisfies Record<string, QxHandlerSpec>;\n` +
|
||||
`export const createRuntime = (implementation: Implementation) => ({\n packageRevisionId,\n exports: {\n` +
|
||||
exports.map((entry) => ` ${q(entry.id)}: bindQxHandler(specs[${q(entry.displayName)}], implementation[${q(entry.displayName)}], messages),`).join("\n") +
|
||||
`\n },\n});\n`;
|
||||
};
|
||||
@@ -1,8 +1,11 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { readFile, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { loadQxSources, resolveQxSources } from "./source-loader.js";
|
||||
import {
|
||||
capabilityId,
|
||||
compileWorkspaceRevision,
|
||||
validateMigrationCatalog,
|
||||
contentDigest,
|
||||
type AtomDefinition,
|
||||
type CompiledWorkspaceRevision,
|
||||
type InterfaceRevision,
|
||||
@@ -227,6 +230,21 @@ const createResourceGraphResolver = (
|
||||
);
|
||||
}
|
||||
assertImportsMatchLock(manifestPath, compiled.resource.imports, lockResult.lock.resources);
|
||||
if (compiled.resource.kind === "package") {
|
||||
let catalogText: string | undefined;
|
||||
try { catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8"); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
if (catalogText !== undefined) {
|
||||
const catalog = validateMigrationCatalog(JSON.parse(catalogText), new Set(compiled.resource.revision.exports.map((entry) => entry.id)));
|
||||
const root = await realpath(snapshot.directory);
|
||||
for (const migration of catalog.migrations) {
|
||||
const implementation = await realpath(path.join(root, migration.implementation.file));
|
||||
if (!implementation.startsWith(`${root}${path.sep}`)) throw new Error("Migration implementation escapes its package");
|
||||
if (contentDigest(await readFile(implementation, "utf8")) !== migration.implementation.digest) throw new Error(`Migration implementation digest mismatch: ${migration.id}`);
|
||||
}
|
||||
compiled.resource.revision.migrationCatalog = catalog;
|
||||
}
|
||||
}
|
||||
return {
|
||||
key,
|
||||
kind: locked.kind,
|
||||
@@ -286,6 +304,8 @@ export const compileWorkspaceRepository = async (options: {
|
||||
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"),
|
||||
@@ -303,10 +323,11 @@ export const compileWorkspaceRepository = async (options: {
|
||||
const nodes = await resolver.nodes();
|
||||
const environment = environmentFor(directPairs, nodes);
|
||||
const workspacePath = path.join(options.rootDirectory, "workspace.qx");
|
||||
const workspaceSource = await readFile(workspacePath, "utf8");
|
||||
const compiled = compileCapabilitySource(workspaceSource, workspacePath, environment);
|
||||
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));
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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, generateTypeScriptBindings, type BindingSchema, type TypeScriptBindingOptions } from "../bindings/index.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 };
|
||||
};
|
||||
|
||||
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 diagnostics = "";
|
||||
let treeDigest: string | undefined;
|
||||
try {
|
||||
const root = await snapshotRepository(options.root, path.join(temporary, "root"));
|
||||
treeDigest = root.treeDigest;
|
||||
const map = options.publishedOnly ? {resources: []} : await localResourceSnapshots(options.root, options.snapshotMap);
|
||||
const resources = [];
|
||||
for (const [index, entry] of map.resources.entries()) {
|
||||
const snapshot = await snapshotRepository(entry.directory, path.join(temporary, `dependency-${index}`));
|
||||
resources.push({...entry, directory: snapshot.directory});
|
||||
}
|
||||
const snapshotMap = path.join(temporary, "snapshots.json");
|
||||
await fs.writeFile(snapshotMap, JSON.stringify({resources}));
|
||||
const resolveResource = await createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resolved"), snapshotMap});
|
||||
const compiled = await compileCapabilityResourceRepository({rootDirectory: root.directory, kind: options.kind, source: {resolver: "git", ...options.source}, resolveResource});
|
||||
if (compiled.resource.kind === "package") {
|
||||
const configuration = JSON.parse(await fs.readFile(path.join(root.directory, "quixos.check.json"), "utf8"));
|
||||
const output = configuration.bindingOutput as string;
|
||||
if (configuration.backend !== "typescript" || !/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.ts$/.test(output)) throw new Error("Unsupported candidate checker configuration");
|
||||
const modules = path.join(root.source, "node_modules");
|
||||
await fs.access(path.join(modules, ".bin/tsc"));
|
||||
await fs.symlink(modules, path.join(root.directory, "node_modules"), "dir");
|
||||
const destination = path.join(root.directory, output);
|
||||
await fs.mkdir(path.dirname(destination), {recursive: true});
|
||||
await fs.writeFile(destination, generateTypeScriptBindings(bindingSchema(compiled), compiled.resource.revision.revisionId, configuration.options));
|
||||
diagnostics = (await execFile(path.join(modules, ".bin/tsc"), ["--noEmit", "--pretty", "false"], {cwd: root.directory, maxBuffer: 16 * 1024 * 1024})).stdout;
|
||||
}
|
||||
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));
|
||||
diagnostics += (error as {stdout?: string; stderr?: string}).stdout ?? "";
|
||||
diagnostics += (error as {stderr?: string}).stderr ?? "";
|
||||
} finally {await fs.rm(temporary, {recursive: true, force: true});}
|
||||
const result = {candidateOnly: true, activationEvidence: false, treeDigest, blockers, diagnostics};
|
||||
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 }) => {
|
||||
// A new output directory is the whole artifact boundary; never overwrite a prior check.
|
||||
await fs.mkdir(options.output, { mode: 0o700 });
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "quixos-candidate-"));
|
||||
const blockers: string[] = [];
|
||||
const checks: unknown[] = [];
|
||||
const snapshots = [];
|
||||
try {
|
||||
const root = await snapshotRepository(options.root, path.join(temporary, "root"));
|
||||
snapshots.push(root);
|
||||
const map = await localResourceSnapshots(options.root, options.snapshotMap);
|
||||
const resources = [];
|
||||
for (const [index, entry] of map.resources.entries()) {
|
||||
const source = entry.directory;
|
||||
const snapshot = await snapshotRepository(source, path.join(temporary, `resource-${index}`));
|
||||
snapshots.push(snapshot);
|
||||
resources.push({ ...entry, directory: snapshot.directory });
|
||||
}
|
||||
const mapFile = path.join(temporary, "snapshots.json");
|
||||
await fs.writeFile(mapFile, JSON.stringify({ resources }));
|
||||
const resolveResource = await createGitCapabilityResolver({ checkoutRoot: path.join(temporary, "resolved"), snapshotMap: mapFile });
|
||||
const compiled = await compileWorkspaceRepository({ rootDirectory: root.directory, 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);
|
||||
const schema: BindingSchema = { format: "quixos-bindings", version: 1, interfaces: compiled.workspace.interfaceImports, packages: compiled.workspace.packageImports };
|
||||
for (const resource of compiled.resources.filter((entry) => entry.kind === "package")) {
|
||||
if (resource.resource.kind !== "package") continue;
|
||||
const revision = resource.resource.revision;
|
||||
let config: { backend: string; bindingOutput: string; options?: TypeScriptBindingOptions };
|
||||
try { config = JSON.parse(await fs.readFile(path.join(resource.directory, "quixos.check.json"), "utf8")); }
|
||||
catch { blockers.push(`No candidate checker configured for ${revision.revisionId} (quixos.check.json)`); continue; }
|
||||
if (config.backend !== "typescript" || !/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.ts$/.test(config.bindingOutput)
|
||||
|| config.bindingOutput.split("/").includes("..")) { blockers.push(`Unsupported checker or binding path for ${revision.revisionId}`); continue; }
|
||||
const sourceSnapshot = snapshots.find((entry) => entry.directory === resource.directory);
|
||||
const dependencyRoot = sourceSnapshot?.source ?? resource.directory;
|
||||
const modules = path.join(dependencyRoot, "node_modules");
|
||||
try { await fs.access(path.join(modules, ".bin", "tsc")); }
|
||||
catch { blockers.push(`Missing installed TypeScript checker/dependencies for ${revision.revisionId}; install its locked development dependencies first`); continue; }
|
||||
if (sourceSnapshot) await fs.symlink(modules, path.join(resource.directory, "node_modules"), "dir");
|
||||
const generated = generateTypeScriptBindings(schema, revision.revisionId, config.options);
|
||||
const destination = path.join(resource.directory, config.bindingOutput);
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||||
await fs.writeFile(destination, generated);
|
||||
let success = false, diagnostics = "";
|
||||
try { diagnostics = (await execFile(path.join(modules, ".bin", "tsc"), ["--noEmit", "--pretty", "false", "--listFiles"], { cwd: resource.directory, maxBuffer: 16 * 1024 * 1024 })).stdout; success = true; }
|
||||
catch (error) { const result = error as Error & {stdout?: string; stderr?: string}; diagnostics = `${result.stdout ?? ""}\n${result.stderr ?? result.message}`; }
|
||||
const typeInputs = [];
|
||||
for (const line of diagnostics.split(/\r?\n/)) if (path.isAbsolute(line) && /\.[cm]?tsx?$/.test(line)) {
|
||||
try { typeInputs.push({file: line, digest: bytesDigest(await fs.readFile(line))}); } catch { success = false; }
|
||||
}
|
||||
const checker = await fs.realpath(path.join(modules, ".bin", "tsc"));
|
||||
const check = { packageRevisionId: revision.revisionId, success, bindingSchemaDigest: contentDigest(schema), generatedDigest: contentDigest(generated),
|
||||
checkerDigest: contentDigest({ executable: bytesDigest(await fs.readFile(checker)), typeInputs }), diagnostics };
|
||||
checks.push(check);
|
||||
if (!success) blockers.push(`Typecheck failed for ${revision.revisionId}`);
|
||||
}
|
||||
const result = { schemaVersion: 1, candidateOnly: true, activationEvidence: false,
|
||||
sourceDigest: contentDigest(snapshots.map(({source, treeDigest}) => ({source, treeDigest}))),
|
||||
snapshots: snapshots.map(({source, treeDigest}) => ({source, treeDigest})), evolution, checks, blockers,
|
||||
note: "Local source-tree checks do not certify old Git revisions. Publication must repin the DAG and recheck final immutable artifacts." };
|
||||
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, 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 }); }
|
||||
};
|
||||
@@ -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 () => {
|
||||
@@ -45,6 +52,7 @@ const main = async () => {
|
||||
}
|
||||
const {
|
||||
checkOnly,
|
||||
resource,
|
||||
workspaceId,
|
||||
workspaceRevisionId,
|
||||
sourceRootCommit,
|
||||
@@ -59,9 +67,16 @@ 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}` : ""}`;
|
||||
@@ -70,6 +85,25 @@ const main = async () => {
|
||||
);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
};
|
||||
if (resource) {
|
||||
const result = compileCapabilityResourceSource(source, {
|
||||
source: {
|
||||
repository: "https://compiler.invalid/resource.git",
|
||||
commit: "0000000000000000000000000000000000000000",
|
||||
},
|
||||
fileName,
|
||||
});
|
||||
if (!result.ok) {
|
||||
reportDiagnostics(result.diagnostics);
|
||||
return;
|
||||
}
|
||||
if (!checkOnly) process.stdout.write(`${JSON.stringify(result.resource, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
const result = compileCapabilitySource(source, fileName);
|
||||
if (!result.ok) {
|
||||
reportDiagnostics(result.diagnostics);
|
||||
return;
|
||||
}
|
||||
const workspace = workspaceId && workspaceRevisionId
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,197 +1,211 @@
|
||||
WORKSPACE=1
|
||||
IMPORT=2
|
||||
EXTERNAL=3
|
||||
ATOM=4
|
||||
INTERFACE=5
|
||||
INTERFACES=6
|
||||
PACKAGE=7
|
||||
VALUE=8
|
||||
RELATION=9
|
||||
OPERATION=10
|
||||
FUNCTION=11
|
||||
CONSTRUCTOR=12
|
||||
CONSTRUCTS=13
|
||||
CONFORM=14
|
||||
AS=15
|
||||
BIND=16
|
||||
TO=17
|
||||
PRIVATE=18
|
||||
SHARED=19
|
||||
STATE=20
|
||||
EDGE=21
|
||||
PROJECTION=22
|
||||
WITH=23
|
||||
USING=24
|
||||
VIA=25
|
||||
MATERIALIZE=26
|
||||
IF=27
|
||||
ABSENT=28
|
||||
ON=29
|
||||
POLICY=30
|
||||
DEFAULT=31
|
||||
SOURCE=32
|
||||
REPOSITORY=33
|
||||
COMMIT=34
|
||||
REVISION=35
|
||||
ID=36
|
||||
DOC=37
|
||||
MODE=38
|
||||
EMITS=39
|
||||
RECEIVER=40
|
||||
REQUIRES=41
|
||||
ANY=42
|
||||
GET=43
|
||||
SET=44
|
||||
WATCH=45
|
||||
START=46
|
||||
STOP=47
|
||||
READ=48
|
||||
WRITE=49
|
||||
RESOLVE=50
|
||||
CONNECT=51
|
||||
DISCONNECT=52
|
||||
CALL=53
|
||||
WATCH_START=54
|
||||
WATCH_STOP=55
|
||||
SUBSCRIBE=56
|
||||
UNSUBSCRIBE=57
|
||||
OPTIMISTIC_REGISTER=58
|
||||
CRDT=59
|
||||
OPTIONAL_ONE=60
|
||||
EXACTLY_ONE=61
|
||||
MANY_UNIQUE=62
|
||||
MANY=63
|
||||
ORDERED=64
|
||||
UNIT=65
|
||||
WATCH_HANDLE=66
|
||||
MESSAGE=67
|
||||
ATOM_REF=68
|
||||
INTERFACE_REF=69
|
||||
OPTIONAL=70
|
||||
LIST=71
|
||||
BOOL=72
|
||||
BYTES=73
|
||||
DOUBLE=74
|
||||
INT32=75
|
||||
INT64=76
|
||||
STRING=77
|
||||
UINT32=78
|
||||
UINT64=79
|
||||
TRUE=80
|
||||
FALSE=81
|
||||
NULL=82
|
||||
ARROW=83
|
||||
COLON=84
|
||||
SEMI=85
|
||||
COMMA=86
|
||||
DOT=87
|
||||
LBRACE=88
|
||||
RBRACE=89
|
||||
LBRACK=90
|
||||
RBRACK=91
|
||||
LPAREN=92
|
||||
RPAREN=93
|
||||
LT=94
|
||||
GT=95
|
||||
INTEGER=96
|
||||
JSON_NUMBER=97
|
||||
IDENTIFIER=98
|
||||
STRING_LITERAL=99
|
||||
LINE_COMMENT=100
|
||||
BLOCK_COMMENT=101
|
||||
WS=102
|
||||
FRAGMENT=2
|
||||
IMPORT=3
|
||||
EXTERNAL=4
|
||||
ATOM=5
|
||||
INTERFACE=6
|
||||
INTERFACES=7
|
||||
PACKAGE=8
|
||||
VALUE=9
|
||||
RELATION=10
|
||||
OPERATION=11
|
||||
FUNCTION=12
|
||||
CONSTRUCTOR=13
|
||||
CONSTRUCTS=14
|
||||
INPUT=15
|
||||
CONFORM=16
|
||||
AS=17
|
||||
BIND=18
|
||||
TO=19
|
||||
PRIVATE=20
|
||||
SHARED=21
|
||||
STATE=22
|
||||
EDGE=23
|
||||
PROJECTION=24
|
||||
WITH=25
|
||||
USING=26
|
||||
VIA=27
|
||||
MATERIALIZE=28
|
||||
IF=29
|
||||
ABSENT=30
|
||||
ON=31
|
||||
POLICY=32
|
||||
DEFAULT=33
|
||||
SOURCE=34
|
||||
REPOSITORY=35
|
||||
COMMIT=36
|
||||
REVISION=37
|
||||
SEMANTIC_MAJOR=38
|
||||
ON_DELETE=39
|
||||
RETAIN_OTHER=40
|
||||
KEYED=41
|
||||
PUBLIC_TRAVERSAL=42
|
||||
ID=43
|
||||
DOC=44
|
||||
MODE=45
|
||||
EMITS=46
|
||||
RECEIVER=47
|
||||
REQUIRES=48
|
||||
ANY=49
|
||||
GET=50
|
||||
SET=51
|
||||
WATCH=52
|
||||
START=53
|
||||
STOP=54
|
||||
READ=55
|
||||
WRITE=56
|
||||
RESOLVE=57
|
||||
CONNECT=58
|
||||
DISCONNECT=59
|
||||
CALL=60
|
||||
WATCH_START=61
|
||||
WATCH_STOP=62
|
||||
SUBSCRIBE=63
|
||||
UNSUBSCRIBE=64
|
||||
OPTIMISTIC_REGISTER=65
|
||||
CRDT=66
|
||||
OPTIONAL_ONE=67
|
||||
EXACTLY_ONE=68
|
||||
MANY_UNIQUE=69
|
||||
MANY=70
|
||||
ORDERED=71
|
||||
UNIT=72
|
||||
WATCH_HANDLE=73
|
||||
MESSAGE=74
|
||||
ATOM_REF=75
|
||||
INTERFACE_REF=76
|
||||
OPTIONAL=77
|
||||
LIST=78
|
||||
BOOL=79
|
||||
BYTES=80
|
||||
DOUBLE=81
|
||||
INT32=82
|
||||
INT64=83
|
||||
STRING=84
|
||||
UINT32=85
|
||||
UINT64=86
|
||||
TRUE=87
|
||||
FALSE=88
|
||||
NULL=89
|
||||
ARROW=90
|
||||
COLON=91
|
||||
SEMI=92
|
||||
COMMA=93
|
||||
DOT=94
|
||||
LBRACE=95
|
||||
RBRACE=96
|
||||
LBRACK=97
|
||||
RBRACK=98
|
||||
LPAREN=99
|
||||
RPAREN=100
|
||||
LT=101
|
||||
GT=102
|
||||
INTEGER=103
|
||||
JSON_NUMBER=104
|
||||
IDENTIFIER=105
|
||||
STRING_LITERAL=106
|
||||
LINE_COMMENT=107
|
||||
BLOCK_COMMENT=108
|
||||
WS=109
|
||||
'workspace'=1
|
||||
'import'=2
|
||||
'external'=3
|
||||
'atom'=4
|
||||
'interface'=5
|
||||
'interfaces'=6
|
||||
'package'=7
|
||||
'value'=8
|
||||
'relation'=9
|
||||
'operation'=10
|
||||
'function'=11
|
||||
'constructor'=12
|
||||
'constructs'=13
|
||||
'conform'=14
|
||||
'as'=15
|
||||
'bind'=16
|
||||
'to'=17
|
||||
'private'=18
|
||||
'shared'=19
|
||||
'state'=20
|
||||
'edge'=21
|
||||
'projection'=22
|
||||
'with'=23
|
||||
'using'=24
|
||||
'via'=25
|
||||
'materialize'=26
|
||||
'if'=27
|
||||
'absent'=28
|
||||
'on'=29
|
||||
'policy'=30
|
||||
'default'=31
|
||||
'source'=32
|
||||
'repository'=33
|
||||
'commit'=34
|
||||
'revision'=35
|
||||
'id'=36
|
||||
'doc'=37
|
||||
'mode'=38
|
||||
'emits'=39
|
||||
'receiver'=40
|
||||
'requires'=41
|
||||
'any'=42
|
||||
'get'=43
|
||||
'set'=44
|
||||
'watch'=45
|
||||
'start'=46
|
||||
'stop'=47
|
||||
'read'=48
|
||||
'write'=49
|
||||
'resolve'=50
|
||||
'connect'=51
|
||||
'disconnect'=52
|
||||
'call'=53
|
||||
'watch-start'=54
|
||||
'watch-stop'=55
|
||||
'subscribe'=56
|
||||
'unsubscribe'=57
|
||||
'optimistic-register'=58
|
||||
'crdt'=59
|
||||
'optional-one'=60
|
||||
'exactly-one'=61
|
||||
'many-unique'=62
|
||||
'many'=63
|
||||
'ordered'=64
|
||||
'unit'=65
|
||||
'watch-handle'=66
|
||||
'message'=67
|
||||
'atom-ref'=68
|
||||
'interface-ref'=69
|
||||
'optional'=70
|
||||
'list'=71
|
||||
'bool'=72
|
||||
'bytes'=73
|
||||
'double'=74
|
||||
'int32'=75
|
||||
'int64'=76
|
||||
'string'=77
|
||||
'uint32'=78
|
||||
'uint64'=79
|
||||
'true'=80
|
||||
'false'=81
|
||||
'null'=82
|
||||
'->'=83
|
||||
':'=84
|
||||
';'=85
|
||||
','=86
|
||||
'.'=87
|
||||
'{'=88
|
||||
'}'=89
|
||||
'['=90
|
||||
']'=91
|
||||
'('=92
|
||||
')'=93
|
||||
'<'=94
|
||||
'>'=95
|
||||
'fragment'=2
|
||||
'import'=3
|
||||
'external'=4
|
||||
'atom'=5
|
||||
'interface'=6
|
||||
'interfaces'=7
|
||||
'package'=8
|
||||
'value'=9
|
||||
'relation'=10
|
||||
'operation'=11
|
||||
'function'=12
|
||||
'constructor'=13
|
||||
'constructs'=14
|
||||
'input'=15
|
||||
'conform'=16
|
||||
'as'=17
|
||||
'bind'=18
|
||||
'to'=19
|
||||
'private'=20
|
||||
'shared'=21
|
||||
'state'=22
|
||||
'edge'=23
|
||||
'projection'=24
|
||||
'with'=25
|
||||
'using'=26
|
||||
'via'=27
|
||||
'materialize'=28
|
||||
'if'=29
|
||||
'absent'=30
|
||||
'on'=31
|
||||
'policy'=32
|
||||
'default'=33
|
||||
'source'=34
|
||||
'repository'=35
|
||||
'commit'=36
|
||||
'revision'=37
|
||||
'semantic-major'=38
|
||||
'on-delete'=39
|
||||
'retain-other'=40
|
||||
'keyed'=41
|
||||
'public-traversal'=42
|
||||
'id'=43
|
||||
'doc'=44
|
||||
'mode'=45
|
||||
'emits'=46
|
||||
'receiver'=47
|
||||
'requires'=48
|
||||
'any'=49
|
||||
'get'=50
|
||||
'set'=51
|
||||
'watch'=52
|
||||
'start'=53
|
||||
'stop'=54
|
||||
'read'=55
|
||||
'write'=56
|
||||
'resolve'=57
|
||||
'connect'=58
|
||||
'disconnect'=59
|
||||
'call'=60
|
||||
'watch-start'=61
|
||||
'watch-stop'=62
|
||||
'subscribe'=63
|
||||
'unsubscribe'=64
|
||||
'optimistic-register'=65
|
||||
'crdt'=66
|
||||
'optional-one'=67
|
||||
'exactly-one'=68
|
||||
'many-unique'=69
|
||||
'many'=70
|
||||
'ordered'=71
|
||||
'unit'=72
|
||||
'watch-handle'=73
|
||||
'message'=74
|
||||
'atom-ref'=75
|
||||
'interface-ref'=76
|
||||
'optional'=77
|
||||
'list'=78
|
||||
'bool'=79
|
||||
'bytes'=80
|
||||
'double'=81
|
||||
'int32'=82
|
||||
'int64'=83
|
||||
'string'=84
|
||||
'uint32'=85
|
||||
'uint64'=86
|
||||
'true'=87
|
||||
'false'=88
|
||||
'null'=89
|
||||
'->'=90
|
||||
':'=91
|
||||
';'=92
|
||||
','=93
|
||||
'.'=94
|
||||
'{'=95
|
||||
'}'=96
|
||||
'['=97
|
||||
']'=98
|
||||
'('=99
|
||||
')'=100
|
||||
'<'=101
|
||||
'>'=102
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,197 +1,211 @@
|
||||
WORKSPACE=1
|
||||
IMPORT=2
|
||||
EXTERNAL=3
|
||||
ATOM=4
|
||||
INTERFACE=5
|
||||
INTERFACES=6
|
||||
PACKAGE=7
|
||||
VALUE=8
|
||||
RELATION=9
|
||||
OPERATION=10
|
||||
FUNCTION=11
|
||||
CONSTRUCTOR=12
|
||||
CONSTRUCTS=13
|
||||
CONFORM=14
|
||||
AS=15
|
||||
BIND=16
|
||||
TO=17
|
||||
PRIVATE=18
|
||||
SHARED=19
|
||||
STATE=20
|
||||
EDGE=21
|
||||
PROJECTION=22
|
||||
WITH=23
|
||||
USING=24
|
||||
VIA=25
|
||||
MATERIALIZE=26
|
||||
IF=27
|
||||
ABSENT=28
|
||||
ON=29
|
||||
POLICY=30
|
||||
DEFAULT=31
|
||||
SOURCE=32
|
||||
REPOSITORY=33
|
||||
COMMIT=34
|
||||
REVISION=35
|
||||
ID=36
|
||||
DOC=37
|
||||
MODE=38
|
||||
EMITS=39
|
||||
RECEIVER=40
|
||||
REQUIRES=41
|
||||
ANY=42
|
||||
GET=43
|
||||
SET=44
|
||||
WATCH=45
|
||||
START=46
|
||||
STOP=47
|
||||
READ=48
|
||||
WRITE=49
|
||||
RESOLVE=50
|
||||
CONNECT=51
|
||||
DISCONNECT=52
|
||||
CALL=53
|
||||
WATCH_START=54
|
||||
WATCH_STOP=55
|
||||
SUBSCRIBE=56
|
||||
UNSUBSCRIBE=57
|
||||
OPTIMISTIC_REGISTER=58
|
||||
CRDT=59
|
||||
OPTIONAL_ONE=60
|
||||
EXACTLY_ONE=61
|
||||
MANY_UNIQUE=62
|
||||
MANY=63
|
||||
ORDERED=64
|
||||
UNIT=65
|
||||
WATCH_HANDLE=66
|
||||
MESSAGE=67
|
||||
ATOM_REF=68
|
||||
INTERFACE_REF=69
|
||||
OPTIONAL=70
|
||||
LIST=71
|
||||
BOOL=72
|
||||
BYTES=73
|
||||
DOUBLE=74
|
||||
INT32=75
|
||||
INT64=76
|
||||
STRING=77
|
||||
UINT32=78
|
||||
UINT64=79
|
||||
TRUE=80
|
||||
FALSE=81
|
||||
NULL=82
|
||||
ARROW=83
|
||||
COLON=84
|
||||
SEMI=85
|
||||
COMMA=86
|
||||
DOT=87
|
||||
LBRACE=88
|
||||
RBRACE=89
|
||||
LBRACK=90
|
||||
RBRACK=91
|
||||
LPAREN=92
|
||||
RPAREN=93
|
||||
LT=94
|
||||
GT=95
|
||||
INTEGER=96
|
||||
JSON_NUMBER=97
|
||||
IDENTIFIER=98
|
||||
STRING_LITERAL=99
|
||||
LINE_COMMENT=100
|
||||
BLOCK_COMMENT=101
|
||||
WS=102
|
||||
FRAGMENT=2
|
||||
IMPORT=3
|
||||
EXTERNAL=4
|
||||
ATOM=5
|
||||
INTERFACE=6
|
||||
INTERFACES=7
|
||||
PACKAGE=8
|
||||
VALUE=9
|
||||
RELATION=10
|
||||
OPERATION=11
|
||||
FUNCTION=12
|
||||
CONSTRUCTOR=13
|
||||
CONSTRUCTS=14
|
||||
INPUT=15
|
||||
CONFORM=16
|
||||
AS=17
|
||||
BIND=18
|
||||
TO=19
|
||||
PRIVATE=20
|
||||
SHARED=21
|
||||
STATE=22
|
||||
EDGE=23
|
||||
PROJECTION=24
|
||||
WITH=25
|
||||
USING=26
|
||||
VIA=27
|
||||
MATERIALIZE=28
|
||||
IF=29
|
||||
ABSENT=30
|
||||
ON=31
|
||||
POLICY=32
|
||||
DEFAULT=33
|
||||
SOURCE=34
|
||||
REPOSITORY=35
|
||||
COMMIT=36
|
||||
REVISION=37
|
||||
SEMANTIC_MAJOR=38
|
||||
ON_DELETE=39
|
||||
RETAIN_OTHER=40
|
||||
KEYED=41
|
||||
PUBLIC_TRAVERSAL=42
|
||||
ID=43
|
||||
DOC=44
|
||||
MODE=45
|
||||
EMITS=46
|
||||
RECEIVER=47
|
||||
REQUIRES=48
|
||||
ANY=49
|
||||
GET=50
|
||||
SET=51
|
||||
WATCH=52
|
||||
START=53
|
||||
STOP=54
|
||||
READ=55
|
||||
WRITE=56
|
||||
RESOLVE=57
|
||||
CONNECT=58
|
||||
DISCONNECT=59
|
||||
CALL=60
|
||||
WATCH_START=61
|
||||
WATCH_STOP=62
|
||||
SUBSCRIBE=63
|
||||
UNSUBSCRIBE=64
|
||||
OPTIMISTIC_REGISTER=65
|
||||
CRDT=66
|
||||
OPTIONAL_ONE=67
|
||||
EXACTLY_ONE=68
|
||||
MANY_UNIQUE=69
|
||||
MANY=70
|
||||
ORDERED=71
|
||||
UNIT=72
|
||||
WATCH_HANDLE=73
|
||||
MESSAGE=74
|
||||
ATOM_REF=75
|
||||
INTERFACE_REF=76
|
||||
OPTIONAL=77
|
||||
LIST=78
|
||||
BOOL=79
|
||||
BYTES=80
|
||||
DOUBLE=81
|
||||
INT32=82
|
||||
INT64=83
|
||||
STRING=84
|
||||
UINT32=85
|
||||
UINT64=86
|
||||
TRUE=87
|
||||
FALSE=88
|
||||
NULL=89
|
||||
ARROW=90
|
||||
COLON=91
|
||||
SEMI=92
|
||||
COMMA=93
|
||||
DOT=94
|
||||
LBRACE=95
|
||||
RBRACE=96
|
||||
LBRACK=97
|
||||
RBRACK=98
|
||||
LPAREN=99
|
||||
RPAREN=100
|
||||
LT=101
|
||||
GT=102
|
||||
INTEGER=103
|
||||
JSON_NUMBER=104
|
||||
IDENTIFIER=105
|
||||
STRING_LITERAL=106
|
||||
LINE_COMMENT=107
|
||||
BLOCK_COMMENT=108
|
||||
WS=109
|
||||
'workspace'=1
|
||||
'import'=2
|
||||
'external'=3
|
||||
'atom'=4
|
||||
'interface'=5
|
||||
'interfaces'=6
|
||||
'package'=7
|
||||
'value'=8
|
||||
'relation'=9
|
||||
'operation'=10
|
||||
'function'=11
|
||||
'constructor'=12
|
||||
'constructs'=13
|
||||
'conform'=14
|
||||
'as'=15
|
||||
'bind'=16
|
||||
'to'=17
|
||||
'private'=18
|
||||
'shared'=19
|
||||
'state'=20
|
||||
'edge'=21
|
||||
'projection'=22
|
||||
'with'=23
|
||||
'using'=24
|
||||
'via'=25
|
||||
'materialize'=26
|
||||
'if'=27
|
||||
'absent'=28
|
||||
'on'=29
|
||||
'policy'=30
|
||||
'default'=31
|
||||
'source'=32
|
||||
'repository'=33
|
||||
'commit'=34
|
||||
'revision'=35
|
||||
'id'=36
|
||||
'doc'=37
|
||||
'mode'=38
|
||||
'emits'=39
|
||||
'receiver'=40
|
||||
'requires'=41
|
||||
'any'=42
|
||||
'get'=43
|
||||
'set'=44
|
||||
'watch'=45
|
||||
'start'=46
|
||||
'stop'=47
|
||||
'read'=48
|
||||
'write'=49
|
||||
'resolve'=50
|
||||
'connect'=51
|
||||
'disconnect'=52
|
||||
'call'=53
|
||||
'watch-start'=54
|
||||
'watch-stop'=55
|
||||
'subscribe'=56
|
||||
'unsubscribe'=57
|
||||
'optimistic-register'=58
|
||||
'crdt'=59
|
||||
'optional-one'=60
|
||||
'exactly-one'=61
|
||||
'many-unique'=62
|
||||
'many'=63
|
||||
'ordered'=64
|
||||
'unit'=65
|
||||
'watch-handle'=66
|
||||
'message'=67
|
||||
'atom-ref'=68
|
||||
'interface-ref'=69
|
||||
'optional'=70
|
||||
'list'=71
|
||||
'bool'=72
|
||||
'bytes'=73
|
||||
'double'=74
|
||||
'int32'=75
|
||||
'int64'=76
|
||||
'string'=77
|
||||
'uint32'=78
|
||||
'uint64'=79
|
||||
'true'=80
|
||||
'false'=81
|
||||
'null'=82
|
||||
'->'=83
|
||||
':'=84
|
||||
';'=85
|
||||
','=86
|
||||
'.'=87
|
||||
'{'=88
|
||||
'}'=89
|
||||
'['=90
|
||||
']'=91
|
||||
'('=92
|
||||
')'=93
|
||||
'<'=94
|
||||
'>'=95
|
||||
'fragment'=2
|
||||
'import'=3
|
||||
'external'=4
|
||||
'atom'=5
|
||||
'interface'=6
|
||||
'interfaces'=7
|
||||
'package'=8
|
||||
'value'=9
|
||||
'relation'=10
|
||||
'operation'=11
|
||||
'function'=12
|
||||
'constructor'=13
|
||||
'constructs'=14
|
||||
'input'=15
|
||||
'conform'=16
|
||||
'as'=17
|
||||
'bind'=18
|
||||
'to'=19
|
||||
'private'=20
|
||||
'shared'=21
|
||||
'state'=22
|
||||
'edge'=23
|
||||
'projection'=24
|
||||
'with'=25
|
||||
'using'=26
|
||||
'via'=27
|
||||
'materialize'=28
|
||||
'if'=29
|
||||
'absent'=30
|
||||
'on'=31
|
||||
'policy'=32
|
||||
'default'=33
|
||||
'source'=34
|
||||
'repository'=35
|
||||
'commit'=36
|
||||
'revision'=37
|
||||
'semantic-major'=38
|
||||
'on-delete'=39
|
||||
'retain-other'=40
|
||||
'keyed'=41
|
||||
'public-traversal'=42
|
||||
'id'=43
|
||||
'doc'=44
|
||||
'mode'=45
|
||||
'emits'=46
|
||||
'receiver'=47
|
||||
'requires'=48
|
||||
'any'=49
|
||||
'get'=50
|
||||
'set'=51
|
||||
'watch'=52
|
||||
'start'=53
|
||||
'stop'=54
|
||||
'read'=55
|
||||
'write'=56
|
||||
'resolve'=57
|
||||
'connect'=58
|
||||
'disconnect'=59
|
||||
'call'=60
|
||||
'watch-start'=61
|
||||
'watch-stop'=62
|
||||
'subscribe'=63
|
||||
'unsubscribe'=64
|
||||
'optimistic-register'=65
|
||||
'crdt'=66
|
||||
'optional-one'=67
|
||||
'exactly-one'=68
|
||||
'many-unique'=69
|
||||
'many'=70
|
||||
'ordered'=71
|
||||
'unit'=72
|
||||
'watch-handle'=73
|
||||
'message'=74
|
||||
'atom-ref'=75
|
||||
'interface-ref'=76
|
||||
'optional'=77
|
||||
'list'=78
|
||||
'bool'=79
|
||||
'bytes'=80
|
||||
'double'=81
|
||||
'int32'=82
|
||||
'int64'=83
|
||||
'string'=84
|
||||
'uint32'=85
|
||||
'uint64'=86
|
||||
'true'=87
|
||||
'false'=88
|
||||
'null'=89
|
||||
'->'=90
|
||||
':'=91
|
||||
';'=92
|
||||
','=93
|
||||
'.'=94
|
||||
'{'=95
|
||||
'}'=96
|
||||
'['=97
|
||||
']'=98
|
||||
'('=99
|
||||
')'=100
|
||||
'<'=101
|
||||
'>'=102
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ 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";
|
||||
@@ -74,6 +76,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
|
||||
|
||||
@@ -22,6 +22,7 @@ const checkoutName = (kind: string, repository: string, commit: string) => {
|
||||
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);
|
||||
@@ -52,6 +53,7 @@ export const createGitCapabilityResolver = async (options: {
|
||||
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 () => {
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from "./parser.js";
|
||||
export * from "./assembly.js";
|
||||
export * from "./source.js";
|
||||
export * from "./source-loader.js";
|
||||
export * from "./scaffold.js";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/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";
|
||||
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 files: { path: string; source: string }[] = JSON.parse(input);
|
||||
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));
|
||||
@@ -724,7 +724,8 @@ const lowerDependencyPort = (
|
||||
? {
|
||||
id,
|
||||
displayName: name,
|
||||
requirement: { kind: "constructor", atomId },
|
||||
requirement: { kind: "constructor", atomId,
|
||||
...(context.valueType() ? { inputType: lowerValueType(state, context.valueType()) } : {}) },
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
@@ -893,6 +894,7 @@ const lowerPackage = (
|
||||
return {
|
||||
packageId: capabilityId.package(stringValue(context.stringLiteral(0))),
|
||||
revisionId: state.packages.get(alias)!.revisionId,
|
||||
...(context.INTEGER() ? { semanticMajor: Number(context.INTEGER()!.getText()) } : {}),
|
||||
displayName: alias,
|
||||
source,
|
||||
exports,
|
||||
@@ -937,12 +939,16 @@ const lowerEdgeEndpoint = (
|
||||
return constraint
|
||||
? {
|
||||
projectionId: capabilityId.edgeProjection(
|
||||
stringValue(context.stringLiteral()),
|
||||
stringValue(context.stringLiteral(0)),
|
||||
),
|
||||
displayName: identifier(context.identifier()),
|
||||
constraint,
|
||||
cardinality: lowerCardinality(context.cardinality()),
|
||||
ordered: Boolean(context.ORDERED()),
|
||||
...(context.ON_DELETE() ? { onDelete: stringValue(context.stringLiteral(1)) as EdgeEndpoint["onDelete"] } : {}),
|
||||
...(context.RETAIN_OTHER() ? { retainOther: true } : {}),
|
||||
...(context.KEYED() ? {keyType: stringValue(context.stringLiteral(context.ON_DELETE() ? 2 : 1)) as EdgeEndpoint["keyType"]} : {}),
|
||||
...(context.PUBLIC_TRAVERSAL() ? {publicTraversal: true} : {}),
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
@@ -1405,6 +1411,8 @@ const lowerConformance = (
|
||||
return {
|
||||
atomId,
|
||||
interfaceRevisionId: interfaceSymbol.revisionId,
|
||||
...(context.stringLiteral() ? { id: capabilityId.conformance(stringValue(context.stringLiteral()!)) } : {}),
|
||||
...(context.INTEGER() ? { semanticMajor: Number(context.INTEGER()!.getText()) } : {}),
|
||||
privateAttachments,
|
||||
operationBindings,
|
||||
relationshipMaterializations,
|
||||
@@ -1678,22 +1686,22 @@ const lowerWorkspace = (
|
||||
};
|
||||
};
|
||||
|
||||
const parseDocument = (
|
||||
export const parseDocument = (
|
||||
source: string,
|
||||
fileName: string,
|
||||
): { tree: DocumentContext; diagnostics: CapabilitySourceDiagnostic[] } => {
|
||||
): { tree: DocumentContext; tokens: CommonTokenStream; diagnostics: CapabilitySourceDiagnostic[] } => {
|
||||
const diagnostics: CapabilitySourceDiagnostic[] = [];
|
||||
const listener = new SyntaxErrorListener(fileName, diagnostics);
|
||||
const lexer = new QuixosCapabilityLexer(CharStream.fromString(source));
|
||||
lexer.removeErrorListeners();
|
||||
lexer.addErrorListener(listener);
|
||||
const parser = new QuixosCapabilityParser(
|
||||
new CommonTokenStream(lexer),
|
||||
);
|
||||
const tokens = new CommonTokenStream(lexer);
|
||||
const parser = new QuixosCapabilityParser(tokens);
|
||||
parser.removeErrorListeners();
|
||||
parser.addErrorListener(listener);
|
||||
const tree = parser.document();
|
||||
return { tree, diagnostics };
|
||||
tokens.fill();
|
||||
return { tree, tokens, diagnostics };
|
||||
};
|
||||
|
||||
const newLoweringState = (
|
||||
@@ -1945,6 +1953,11 @@ export const compileCapabilitySource = (
|
||||
}
|
||||
|
||||
const state = newLoweringState(fileName, diagnostics);
|
||||
for (const item of workspaceContext.workspaceItem()) {
|
||||
if (item.sourceImportDecl()) loweringIssue(state, item, "unresolved-source-import",
|
||||
"Local imports require the workspace repository compiler");
|
||||
}
|
||||
if (diagnostics.length) return { ok: false, diagnostics };
|
||||
const lowered = lowerWorkspace(state, workspaceContext, environment);
|
||||
if (diagnostics.length > 0) {
|
||||
return { ok: false, diagnostics };
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
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, checkResourceCandidate, checkWorkspaceCandidate} from "./candidate-check.js";
|
||||
import {compileWorkspaceRepository, compileCapabilityResourceRepository, type ResolvedCapabilityResource} from "./assembly.js";
|
||||
import {createGitCapabilityResolver} from "./git-resolver.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", ["remote", "get-url", "origin"]),
|
||||
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", ["remote", "get-url", "origin"]) !== 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)");
|
||||
// Publication checks consume already-published dependency revisions, never
|
||||
// workbench dirty overlays masquerading as those immutable identities.
|
||||
const snapshotMap = `${output}-published-dependencies.json`;
|
||||
await fs.writeFile(snapshotMap, JSON.stringify({resources: []}), {flag: "wx"});
|
||||
const result = node.kind === "workspace" ? await checkWorkspaceCandidate({root, output, snapshotMap, baseline: spec.baseline, reviews: spec.reviews})
|
||||
: await checkResourceCandidate({root, output, kind: node.kind, source: node.source, snapshotMap});
|
||||
if (result.blockers.length) throw new Error(`Refactor required in ${node.directory}: ${result.blockers.join("; ")}`);
|
||||
const evolution = (result as {evolution?: {reviews: {accepted: boolean}[]}}).evolution;
|
||||
if (evolution?.reviews.some((review) => !review.accepted)) throw new Error("Explicit semantic-major review required before publishing the workspace");
|
||||
},
|
||||
async snapshot(root) {
|
||||
// Unlike checking, publication deliberately captures the working copy.
|
||||
await command(root, "jj", ["status"]);
|
||||
const conflicts = await command(root, "jj", ["resolve", "--list"]);
|
||||
if (conflicts) throw new Error("Resolve source conflicts before publication");
|
||||
const commit = await command(root, "jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||
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 lock = await fs.open(path.join(directory, "writer.lock"), "wx", 0o600);
|
||||
const id = journalId ?? randomUUID();
|
||||
if (!/^[a-f0-9-]{36}$/.test(id)) {await lock.close(); await fs.unlink(path.join(directory, "writer.lock")); throw new Error("Invalid upgrade journal ID");}
|
||||
const filename = path.join(directory, `${id}.json`);
|
||||
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", ["remote", "get-url", "origin"]) !== 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});}
|
||||
finally {await lock.close(); await fs.unlink(path.join(directory, "writer.lock"));}
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { bindingSchema } from "../bindings/index.js";
|
||||
import {
|
||||
compileCapabilityResourceRepository,
|
||||
type ResolvedCapabilityResource,
|
||||
@@ -10,7 +11,7 @@ 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] [--graph-out PATH]
|
||||
[--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.`;
|
||||
@@ -21,6 +22,7 @@ const parseArgs = (args: string[]) => {
|
||||
const key = args[index];
|
||||
const value = args[index + 1];
|
||||
if (!key?.startsWith("--") || !value) throw new Error(usage);
|
||||
if (!["--root", "--kind", "--repository", "--commit", "--checkout-root", "--snapshot-map", "--snapshot-only", "--graph-out", "--schema-out"].includes(key) || values.has(key)) throw new Error(usage);
|
||||
values.set(key, value);
|
||||
}
|
||||
const rootDirectory = values.get("--root");
|
||||
@@ -35,6 +37,7 @@ const parseArgs = (args: string[]) => {
|
||||
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,
|
||||
@@ -42,7 +45,9 @@ const parseArgs = (args: string[]) => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -70,6 +75,7 @@ const main = async () => {
|
||||
const resolveResource = await createGitCapabilityResolver({
|
||||
checkoutRoot: options.checkoutRoot,
|
||||
snapshotMap: options.snapshotMap,
|
||||
snapshotOnly: options.snapshotOnly,
|
||||
});
|
||||
const compiled = await compileCapabilityResourceRepository({
|
||||
rootDirectory: options.rootDirectory,
|
||||
@@ -92,6 +98,7 @@ const main = async () => {
|
||||
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`);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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";
|
||||
|
||||
type Source = {repository: string; commit: string};
|
||||
type Registry = {generatedBy: "qx-scaffold-v1"; name: string; id: string; revision: string; exports: {name: string; id: string; file: string; migration?: boolean}[]};
|
||||
export type ScaffoldRecipe = {
|
||||
source: Source; directory?: string; name?: string; id?: string; revision?: string;
|
||||
declaration?: string;
|
||||
tools?: {quixos: Source; protocol: Source; helpers: Source; sdk: Source};
|
||||
nixifyPluginUrl?: string;
|
||||
migration?: Omit<MigrationDeclaration, "implementation"> & {contracts: Record<string, unknown>};
|
||||
};
|
||||
const marker = "// Generated by qx-scaffold-v1\n";
|
||||
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
|
||||
const source = (value: Source): GitSource => {
|
||||
const url = new URL(value.repository);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit)) throw new Error("Scaffolds require credential-free HTTPS sources and full exact commits");
|
||||
return {resolver: "git", ...value};
|
||||
};
|
||||
const nixSource = (value: Source) => `git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`;
|
||||
const nixString = (value: string) => JSON.stringify(value).replaceAll("${", "\\${");
|
||||
const safeName = (name: string | undefined): string => {
|
||||
if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("Scaffold requires a simple authored name");
|
||||
return name;
|
||||
};
|
||||
const ownedJson = async <T>(root: string, file: string): Promise<T> => {
|
||||
const target = path.join(root, file);
|
||||
if (!(await fs.realpath(target)).startsWith(`${await fs.realpath(root)}/`)) throw new Error("Scaffold input escapes repository");
|
||||
const value = JSON.parse(await fs.readFile(target, "utf8"));
|
||||
if (value.generatedBy !== "qx-scaffold-v1") throw new Error(`Not scaffold-owned: ${file}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Recipes describe structural edits; planStructure owns validation/journaling.
|
||||
* Implementation files are created once and never rewritten by refresh. */
|
||||
export const scaffoldRecipe = async (root: string, command: "package" | "function" | "migration" | "refresh", spec: ScaffoldRecipe): Promise<StructuralRequest> => {
|
||||
source(spec.source);
|
||||
if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory)) throw new Error("Scaffold directory must be contained");
|
||||
const prefix = spec.directory ? `${spec.directory}/` : "";
|
||||
const files: StructuralRequest["files"] = [];
|
||||
const create = (file: string, content: string) => files.push({file: prefix + file, create: content});
|
||||
const generated = (file: string, content: string) => files.push({file: prefix + file, generated: content});
|
||||
let registry: Registry;
|
||||
let catalog: MigrationCatalog & {generatedBy: "qx-scaffold-v1"};
|
||||
if (command === "package") {
|
||||
const name = safeName(spec.name);
|
||||
if (!spec.id || !spec.revision || !spec.tools) throw new Error("Package scaffold requires id, revision, and exact quixos/protocol/helpers/sdk tool sources");
|
||||
Object.values(spec.tools).forEach(source);
|
||||
registry = {generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: []};
|
||||
catalog = {generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: []};
|
||||
create("package.qx", `package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\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"}}));
|
||||
create("tsconfig.json", json({compilerOptions: {target: "ES2023", module: "NodeNext", moduleResolution: "NodeNext", strict: true, outDir: "dist", skipLibCheck: true}, include: ["src/**/*.ts"]}));
|
||||
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/generated-bindings.ts"}));
|
||||
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 = ./.;
|
||||
bindings = { system, ... }: {
|
||||
generator = (builtins.getAttr system protocol.packages).default;
|
||||
repository = ${nixString(spec.source.repository)};
|
||||
commit = self.rev or (throw "Publish an exact package revision before building an activation artifact");
|
||||
packageRevisionId = ${nixString(spec.revision)};
|
||||
output = "src/generated-bindings.ts";
|
||||
resources = map (entry: entry // { directory = builtins.fetchGit { url = entry.repository; rev = entry.commit; ref = "refs/tags/quixos-reachability/" + entry.commit; }; }) (builtins.fromJSON (builtins.readFile ./quixos.resources.json)).resources;
|
||||
};
|
||||
bundle = { entry = "dist/server.js"; };
|
||||
migrationEntrypoint = "dist/migrate.js";
|
||||
installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; };
|
||||
};
|
||||
}\n`);
|
||||
generated("quixos.resources.json", json({generatedBy: "qx-scaffold-v1", resources: []}));
|
||||
} else {
|
||||
registry = await ownedJson<Registry>(root, prefix + "quixos.scaffold.json");
|
||||
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
|
||||
if (command !== "refresh") {
|
||||
const name = safeName(spec.name);
|
||||
if (!spec.id || registry.exports.some((entry) => entry.id === spec.id || entry.name === name)) throw new Error("New export requires a unique name and ID");
|
||||
const declaration = spec.declaration ?? `function ${name} id ${JSON.stringify(spec.id)} : unit -> unit;`;
|
||||
const parsed = parseQx(`package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`);
|
||||
const exports = [...walkSyntax(parsed.root)].filter((node) => ["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind));
|
||||
if (parsed.diagnostics.length || exports.length !== 1) throw new Error("Expected one valid package export declaration");
|
||||
const wrapped = `package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`;
|
||||
const node = exports[0];
|
||||
const derived = [...walkSyntax(node)].some((entry) => entry.kind === "eventClause");
|
||||
if (command === "migration" && (node.kind !== "packageFunctionExport" || spec.declaration)) throw new Error("Migration exports use the scaffold's unit function declaration and dedicated migration entrypoint");
|
||||
if (wrapped.slice(node.children.find((child) => child.kind === "identifier")!.start, node.children.find((child) => child.kind === "identifier")!.end) !== name
|
||||
|| JSON.parse(wrapped.slice(node.children.find((child) => child.kind === "stringLiteral")!.start, node.children.find((child) => child.kind === "stringLiteral")!.end)) !== spec.id) throw new Error("Declaration name/ID must match its registration");
|
||||
files.push({file: prefix + "package.qx", edits: [{operation: "append", parent: {kind: "packageResourceDecl", id: registry.id}, source: declaration}]});
|
||||
const file = `src/${command === "migration" ? "migrations" : "impl"}/${name}.ts`;
|
||||
const implementation = command === "migration" ? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise<void> => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\n`
|
||||
: `import type {Implementation} from "../generated-bindings.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`;
|
||||
create(file, implementation);
|
||||
registry.exports.push({name, id: spec.id, file, ...(command === "migration" ? {migration: true} : {})});
|
||||
if (command === "migration") {
|
||||
if (!spec.migration) throw new Error("Migration scaffold requires retained contracts and an explicit transition");
|
||||
const {contracts, ...transition} = spec.migration;
|
||||
for (const [digest, contract] of Object.entries(contracts)) {
|
||||
if (contentDigest(contract) !== digest || (catalog.contracts[digest] && contentDigest(catalog.contracts[digest]) !== digest)) throw new Error("Retained migration contract mismatch");
|
||||
catalog.contracts[digest] = contract;
|
||||
}
|
||||
catalog.migrations.push({...transition, implementation: {exportId: spec.id, file, digest: contentDigest(implementation)}});
|
||||
}
|
||||
}
|
||||
if (command === "refresh") for (const migration of catalog.migrations) {
|
||||
const file = path.join(root, prefix, migration.implementation.file);
|
||||
if (!(await fs.realpath(file)).startsWith(`${await fs.realpath(path.join(root, prefix))}/`)) throw new Error("Migration implementation escapes package");
|
||||
migration.implementation.digest = contentDigest(await fs.readFile(file, "utf8"));
|
||||
}
|
||||
}
|
||||
validateMigrationCatalog(catalog, new Set(registry.exports.map((entry) => entry.id)));
|
||||
generated("quixos.scaffold.json", json(registry));
|
||||
generated("quixos.migrations.json", json(catalog));
|
||||
generated("src/server.ts", marker + `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./generated-bindings.js";\n` + registry.exports.filter((entry) => !entry.migration).map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") +
|
||||
`servePackageRuntime(createRuntime({\n` + registry.exports.map((entry) => ` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${registry.exports.filter((value) => !value.migration).indexOf(entry)}`},`).join("\n") + `\n}));\n`);
|
||||
const migrations = registry.exports.filter((entry) => entry.migration);
|
||||
generated("src/migrate.ts", marker + `import {serveMigration} from "@quixos/camino-package-runtime";\n` + migrations.map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") + `await serveMigration({${migrations.map((entry, index) => `${JSON.stringify(entry.id)}: impl${index}`).join(", ")}});\n`);
|
||||
generated("descriptor.quixos-package.txtpb", `# Generated by qx-scaffold-v1\npackage_id: ${JSON.stringify(registry.id)}\npackage_revision_id: ${JSON.stringify(registry.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` + registry.exports.map((entry) => `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`).join(""));
|
||||
return {kind: "package", source: spec.source, resourceRoot: spec.directory, files};
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { lstat, open, rename, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { addWorkspaceImport } from "./source.js";
|
||||
import { readQxSource } from "./source-loader.js";
|
||||
import { compileWorkspaceRepository, type CapabilityRepositoryResolver } from "./assembly.js";
|
||||
|
||||
export const planAtomScaffold = (workspace: string, name: string, id: string) => {
|
||||
if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) throw new Error("Atom name must be PascalCase");
|
||||
if (!id.trim()) throw new Error("Atom ID must not be empty");
|
||||
const fileName = `${name}.qx`;
|
||||
return {
|
||||
before: workspace,
|
||||
workspace: addWorkspaceImport(workspace, fileName),
|
||||
fileName,
|
||||
fragment: `fragment {\n atom ${name} id ${JSON.stringify(id)};\n}\n`,
|
||||
};
|
||||
};
|
||||
|
||||
/** Validate an in-memory proposal before creating files. Never replace a fragment. */
|
||||
export const scaffoldAtom = async (options: {
|
||||
root: string; name: string; id: string; write: boolean; resolveResource: CapabilityRepositoryResolver;
|
||||
}) => {
|
||||
const before = await readQxSource(options.root, "workspace.qx");
|
||||
const plan = planAtomScaffold(before, options.name, options.id);
|
||||
const fragmentPath = path.join(options.root, plan.fileName);
|
||||
try {
|
||||
await lstat(fragmentPath);
|
||||
throw new Error(`Scaffold target already exists: ${plan.fileName}`);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
const observed = new Map<string, string>();
|
||||
await compileWorkspaceRepository({ rootDirectory: options.root, resolveResource: options.resolveResource,
|
||||
readSource: async (name) => {
|
||||
if (name === "workspace.qx") return plan.workspace;
|
||||
if (name === plan.fileName) return plan.fragment;
|
||||
const text = await readQxSource(options.root, name);
|
||||
observed.set(name, text);
|
||||
return text;
|
||||
} });
|
||||
if (!options.write) return plan;
|
||||
const workspacePath = path.join(options.root, "workspace.qx");
|
||||
const temporary = path.join(options.root, `.qx-scaffold-${randomUUID()}.tmp`);
|
||||
let createdFragment = false;
|
||||
let createdTemporary = false;
|
||||
try {
|
||||
const fragment = await open(fragmentPath, "wx");
|
||||
createdFragment = true;
|
||||
try { await fragment.writeFile(plan.fragment); } finally { await fragment.close(); }
|
||||
const file = await open(temporary, "wx", (await lstat(workspacePath)).mode);
|
||||
createdTemporary = true;
|
||||
try { await file.writeFile(plan.workspace); } finally { await file.close(); }
|
||||
observed.set("workspace.qx", before);
|
||||
for (const [name, text] of observed) {
|
||||
if (await readQxSource(options.root, name) !== text) throw new Error(`QX source changed during scaffolding: ${name}`);
|
||||
}
|
||||
await rename(temporary, workspacePath);
|
||||
createdTemporary = false;
|
||||
createdFragment = false;
|
||||
} finally {
|
||||
if (createdTemporary) await unlink(temporary);
|
||||
if (createdFragment) await unlink(fragmentPath);
|
||||
}
|
||||
return plan;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { lstat, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { parseQx, validateQxImportPath, walkSyntax } from "./source.js";
|
||||
|
||||
export const readQxSource = async (root: string, name: string) => {
|
||||
validateQxImportPath(name);
|
||||
let current = path.resolve(root);
|
||||
const segments = name.split("/");
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
current = path.join(current, segment);
|
||||
const stat = await lstat(current);
|
||||
if (stat.isSymbolicLink() || (index === segments.length - 1 ? !stat.isFile() : !stat.isDirectory()))
|
||||
throw new Error(`QX imports must be ordinary files beneath ordinary directories: ${name}`);
|
||||
}
|
||||
return readFile(current, "utf8");
|
||||
};
|
||||
|
||||
/** Local imports share one workspace scope; paths are always repository-relative. */
|
||||
export const resolveQxSources = async (
|
||||
read: (name: string) => Promise<string>, entry = "workspace.qx",
|
||||
) => {
|
||||
const files = new Map<string, string>();
|
||||
const active: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const segments: { start: number; end: number; fileName: string; sourceStart: number }[] = [];
|
||||
let source = "";
|
||||
const append = (fileName: string, text: string, sourceStart: number) => {
|
||||
segments.push({ start: source.length, end: source.length + text.length, fileName, sourceStart });
|
||||
source += text;
|
||||
};
|
||||
const visit = async (name: string, root: boolean) => {
|
||||
validateQxImportPath(name);
|
||||
if (active.includes(name)) throw new Error(`QX import cycle: ${[...active, name].join(" -> ")}`);
|
||||
if (visited.has(name)) return;
|
||||
const text = await read(name);
|
||||
const syntax = parseQx(text, name);
|
||||
if (syntax.diagnostics.length) throw new Error(syntax.diagnostics.map((d) =>
|
||||
`${name}:${d.line}:${d.column + 1}: ${d.message}`).join("\n"));
|
||||
const declaration = syntax.root.children[0]!;
|
||||
if (declaration.kind !== (root ? "workspaceDecl" : "fragmentDecl"))
|
||||
throw new Error(`${name}: expected ${root ? "workspace" : "fragment"} document`);
|
||||
files.set(name, text);
|
||||
active.push(name);
|
||||
visited.add(name);
|
||||
const braces = syntax.tokens.filter((token) => !token.trivia);
|
||||
let cursor = root ? 0 : braces.find((token) => token.kind === "LBRACE")!.end;
|
||||
const end = root ? text.length : braces.filter((token) => token.kind === "RBRACE").at(-1)!.start;
|
||||
for (const node of walkSyntax(declaration)) {
|
||||
if (node.kind !== "sourceImportDecl") continue;
|
||||
append(name, text.slice(cursor, node.start), cursor);
|
||||
const literal = node.children.find((child) => child.kind === "stringLiteral")!;
|
||||
// Separators prevent adjacent tokens/comments joining across files.
|
||||
append(name, "\n", node.start);
|
||||
await visit(JSON.parse(text.slice(literal.start, literal.end)), false);
|
||||
append(name, "\n", node.end);
|
||||
cursor = node.end;
|
||||
}
|
||||
append(name, text.slice(cursor, end), cursor);
|
||||
active.pop();
|
||||
};
|
||||
await visit(entry, true);
|
||||
return {
|
||||
source, sourceFiles: [...files.keys()], files,
|
||||
originalPosition(line: number, column: number) {
|
||||
const lines = source.split("\n");
|
||||
const offset = lines.slice(0, line - 1).reduce((sum, value) => sum + value.length + 1, 0) +
|
||||
[...(lines[line - 1] ?? "")].slice(0, column).join("").length;
|
||||
const segment = segments.find((entry) => entry.start <= offset && entry.end > offset);
|
||||
if (!segment) return { fileName: entry, line, column };
|
||||
const prefix = files.get(segment.fileName)!.slice(0, segment.sourceStart + offset - segment.start);
|
||||
return { fileName: segment.fileName, line: prefix.split("\n").length,
|
||||
column: prefix.length - prefix.lastIndexOf("\n") - 1 };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const loadQxSources = (root: string) => resolveQxSources((name) => readQxSource(root, name));
|
||||
@@ -0,0 +1,128 @@
|
||||
import { ParserRuleContext } from "antlr4ng";
|
||||
import { parseDocument } from "./parser.js";
|
||||
import { QuixosCapabilityParser } from "./generated/QuixosCapabilityParser.js";
|
||||
|
||||
/** Offsets and columns use UTF-16, as do JavaScript and LSP. End is exclusive. */
|
||||
export type SourceRange = { start: number; end: number };
|
||||
export type SourceEdit = SourceRange & { text: string };
|
||||
export type SyntaxNode = SourceRange & { kind: string; children: SyntaxNode[] };
|
||||
|
||||
export const parseQx = (source: string, fileName = "<memory>") => {
|
||||
const parsed = parseDocument(source, fileName);
|
||||
// ANTLR indexes Unicode code points; editors index UTF-16 code units.
|
||||
const offsets = [0];
|
||||
for (const character of source) offsets.push(offsets[offsets.length - 1]! + character.length);
|
||||
const offset = (index: number) => offsets[Math.max(0, index)] ?? source.length;
|
||||
const node = (context: ParserRuleContext): SyntaxNode => ({
|
||||
kind: QuixosCapabilityParser.ruleNames[context.ruleIndex]!,
|
||||
start: offset(context.start?.start ?? 0),
|
||||
end: offset((context.stop?.stop ?? -1) + 1),
|
||||
children: context.children.flatMap((child) => child instanceof ParserRuleContext ? [node(child)] : []),
|
||||
});
|
||||
return {
|
||||
source, fileName,
|
||||
root: node(parsed.tree),
|
||||
diagnostics: parsed.diagnostics.map((diagnostic) => {
|
||||
const line = source.split("\n")[diagnostic.line - 1] ?? "";
|
||||
return { ...diagnostic, column: [...line].slice(0, diagnostic.column).join("").length };
|
||||
}),
|
||||
tokens: parsed.tokens.getTokens().filter((token) => token.type !== -1).map((token) => ({
|
||||
kind: QuixosCapabilityParser.symbolicNames[token.type] ?? "token",
|
||||
start: offset(token.start), end: offset(token.stop + 1),
|
||||
text: token.text ?? "", trivia: token.channel !== 0,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
/** Edits refer to one immutable source snapshot; overlapping edits are errors. */
|
||||
export const applySourceEdits = (source: string, edits: readonly SourceEdit[]) => {
|
||||
const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
|
||||
let end = 0;
|
||||
let previousStart = -1;
|
||||
let result = "";
|
||||
for (const edit of sorted) {
|
||||
if (!Number.isInteger(edit.start) || !Number.isInteger(edit.end) ||
|
||||
edit.start < end || edit.start === previousStart || edit.end < edit.start || edit.end > source.length) {
|
||||
throw new Error("Invalid or overlapping source edits");
|
||||
}
|
||||
result += source.slice(end, edit.start) + edit.text;
|
||||
end = edit.end;
|
||||
previousStart = edit.start;
|
||||
}
|
||||
return result + source.slice(end);
|
||||
};
|
||||
|
||||
export const walkSyntax = function* (node: SyntaxNode): Generator<SyntaxNode> {
|
||||
yield node;
|
||||
for (const child of node.children) yield* walkSyntax(child);
|
||||
};
|
||||
|
||||
export const lintQx = (source: string, fileName = "<memory>") => {
|
||||
const syntax = parseQx(source, fileName);
|
||||
const diagnostics = syntax.diagnostics.map((entry) => ({ ...entry, severity: "error" as "error" | "warning" }));
|
||||
if (diagnostics.length) return diagnostics;
|
||||
const seen = new Set<string>();
|
||||
for (const node of walkSyntax(syntax.root)) {
|
||||
if (node.kind !== "sourceImportDecl") continue;
|
||||
const literal = node.children.find((child) => child.kind === "stringLiteral")!;
|
||||
const importPath: string = JSON.parse(source.slice(literal.start, literal.end));
|
||||
let message: string | undefined;
|
||||
let code = "invalid-source-import";
|
||||
try { validateQxImportPath(importPath); } catch (error) { message = (error as Error).message; }
|
||||
if (!message && seen.has(importPath)) { code = "duplicate-source-import"; message = `Repeated local import ${importPath}`; }
|
||||
seen.add(importPath);
|
||||
if (message) {
|
||||
const prefix = source.slice(0, node.start);
|
||||
diagnostics.push({ phase: "syntax", code, message, fileName,
|
||||
line: prefix.split("\n").length, column: prefix.length - prefix.lastIndexOf("\n") - 1,
|
||||
severity: code === "duplicate-source-import" ? "warning" : "error" });
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
};
|
||||
|
||||
/** Conservative formatter: indentation only, preserving strings and comments verbatim. */
|
||||
export const formatQx = (source: string) => {
|
||||
const syntax = parseQx(source);
|
||||
if (syntax.diagnostics.length) throw new Error("Cannot format QX with syntax errors");
|
||||
const edits: SourceEdit[] = [];
|
||||
let depth = 0;
|
||||
let lineStart = 0;
|
||||
for (const token of syntax.tokens) {
|
||||
if (token.kind === "WS") continue;
|
||||
lineStart = source.lastIndexOf("\n", token.start - 1) + 1;
|
||||
if (/^[ \t]*$/.test(source.slice(lineStart, token.start))) {
|
||||
const indentation = " ".repeat(Math.max(0, depth - (token.kind === "RBRACE" ? 1 : 0)));
|
||||
if (source.slice(lineStart, token.start) !== indentation)
|
||||
edits.push({ start: lineStart, end: token.start, text: indentation });
|
||||
}
|
||||
if (!token.trivia) {
|
||||
if (token.kind === "LBRACE") depth++;
|
||||
if (token.kind === "RBRACE") depth--;
|
||||
}
|
||||
}
|
||||
return applySourceEdits(source, edits);
|
||||
};
|
||||
|
||||
export const addWorkspaceImport = (source: string, importPath: string) => {
|
||||
validateQxImportPath(importPath);
|
||||
const syntax = parseQx(source);
|
||||
if (syntax.diagnostics.length || syntax.root.children[0]?.kind !== "workspaceDecl")
|
||||
throw new Error("Expected a syntactically valid workspace");
|
||||
for (const node of walkSyntax(syntax.root)) {
|
||||
if (node.kind === "sourceImportDecl") {
|
||||
const literal = node.children.find((child) => child.kind === "stringLiteral")!;
|
||||
if (JSON.parse(source.slice(literal.start, literal.end)) === importPath) return source;
|
||||
}
|
||||
}
|
||||
const brace = syntax.tokens.find((token) => token.kind === "LBRACE")!;
|
||||
const newline = source.includes("\r\n") ? "\r\n" : "\n";
|
||||
return applySourceEdits(source, [{ start: brace.end, end: brace.end,
|
||||
text: `${newline} import ${JSON.stringify(importPath)};` }]);
|
||||
};
|
||||
|
||||
export const validateQxImportPath = (value: string) => {
|
||||
if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.qx$/.test(value) ||
|
||||
value.split("/").some((part) => part === "." || part === ".."))
|
||||
throw new Error(`Invalid repository-relative QX import path: ${value}`);
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
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"]);
|
||||
|
||||
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 {
|
||||
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 ?? node.start, end: wrapper?.end ?? node.end, text: edit.operation === "replace" ? edit.source : ""}]);
|
||||
}
|
||||
const checked = parseQx(result);
|
||||
if (checked.diagnostics.length) throw new Error(`Invalid structural change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const scaffoldResourceSource = (kind: "interface" | "package", name: string, id: string, revision: string) => {
|
||||
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || !id || !revision) throw new Error("Resource scaffold requires a PascalCase name and explicit identities");
|
||||
const source = `${kind} ${name} id ${JSON.stringify(id)} revision ${JSON.stringify(revision)} {\n}\n`;
|
||||
if (parseQx(source).diagnostics.length) throw new Error("Invalid resource scaffold");
|
||||
return source;
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
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";
|
||||
|
||||
export type StructuralRequest = {
|
||||
kind: "workspace" | "interface" | "package";
|
||||
source?: {repository: string; commit: string};
|
||||
resourceRoot?: string;
|
||||
files: ({file: string; edits: StructuralEdit[]} | {file: string; create: string} | {file: string; generated: string})[];
|
||||
};
|
||||
type Change = {file: string; before: string | null; after: string; mode: number};
|
||||
type Journal = {schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[]};
|
||||
const safeFile = (file: string) => {
|
||||
if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|lock|ts|tsx|json|nix|txtpb))$/.test(file)
|
||||
|| file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))) throw new Error(`Unsafe scaffold path ${file}`);
|
||||
};
|
||||
const read = async (root: string, file: string): Promise<string | null> => {
|
||||
safeFile(file);
|
||||
const target = path.join(root, file);
|
||||
try {
|
||||
const metadata = await fs.lstat(target);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink() || !(await fs.realpath(target)).startsWith(`${root}/`)) throw new Error(`Scaffold target is not a contained regular file: ${file}`);
|
||||
return await fs.readFile(target, "utf8");
|
||||
} catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
|
||||
};
|
||||
const containedParent = async (root: string, file: string) => {
|
||||
let current = root;
|
||||
for (const part of file.split("/").slice(0, -1)) {
|
||||
current = path.join(current, part);
|
||||
await fs.mkdir(current).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; });
|
||||
const metadata = await fs.lstat(current);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("Scaffold parent must be a real directory");
|
||||
}
|
||||
};
|
||||
const durableJson = async (file: string, value: unknown) => {
|
||||
const temporary = `${file}.${randomUUID()}.tmp`;
|
||||
const handle = await fs.open(temporary, "wx", 0o600);
|
||||
try { await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`); await handle.sync(); } finally { await handle.close(); }
|
||||
await fs.rename(temporary, file);
|
||||
const directory = await fs.open(path.dirname(file), "r");
|
||||
try { await directory.sync(); } finally { await directory.close(); }
|
||||
};
|
||||
|
||||
/** Validate the entire edited resource graph in a private snapshot before writes. */
|
||||
export const planStructure = async (rootPath: string, request: StructuralRequest, snapshotMap?: string) => {
|
||||
const root = await fs.realpath(rootPath);
|
||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-structure-"));
|
||||
try {
|
||||
const snapshot = await snapshotRepository(root, path.join(temporary, "source"));
|
||||
const observed = await Promise.all(snapshot.files.map(async ({name}) => ({file: name, digest: contentDigest(await fs.readFile(path.join(snapshot.directory, name), "utf8"))})));
|
||||
const changes: Change[] = [];
|
||||
if (!Array.isArray(request.files) || !request.files.length || request.files.length > 100) throw new Error("Structural plan requires 1–100 files");
|
||||
for (const input of request.files) {
|
||||
safeFile(input.file);
|
||||
if (changes.some((entry) => entry.file === input.file)) throw new Error("Repeated structural file target");
|
||||
const before = await read(root, input.file);
|
||||
let after: string;
|
||||
if ("create" in input) {
|
||||
if (before !== null || typeof input.create !== "string") throw new Error("Scaffold creation cannot replace an existing file");
|
||||
after = input.create;
|
||||
} else if ("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);
|
||||
}
|
||||
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});
|
||||
let scaffoldOwned = false;
|
||||
try { scaffoldOwned = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.scaffold.json"), "utf8")).generatedBy === "qx-scaffold-v1"; } catch { /* ordinary resource, no generated package scaffolding */ }
|
||||
if (scaffoldOwned && 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)},
|
||||
{file: "quixos.resources.json", after: JSON.stringify({generatedBy: "qx-scaffold-v1", resources: compiled.resources.filter((entry) => entry.directory !== resourceRoot).map((entry) => ({kind: entry.kind, repository: entry.source.repository, commit: entry.source.commit}))}, null, 2) + "\n"},
|
||||
];
|
||||
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: "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");
|
||||
// Never steal a possibly live writer's lock. A process crash requires the
|
||||
// operator to verify that writer is gone, remove this lock, then resume its
|
||||
// journal. This is deliberately fail-closed instead of guessing from a PID.
|
||||
const handle = await fs.open(lock, "wx", 0o600).catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Another scaffold writer or interrupted writer owns ${lock}; verify it has exited before removing its lock and resuming`);
|
||||
throw error;
|
||||
});
|
||||
try { await handle.writeFile(JSON.stringify({pid: process.pid})); await handle.sync(); return await work(); }
|
||||
finally { await handle.close(); await fs.unlink(lock); }
|
||||
};
|
||||
export const resumeStructure = async (rootPath: string, id: string) => {
|
||||
const root = await fs.realpath(rootPath);
|
||||
return withStructureLock(root, () => replayStructure(root, id));
|
||||
};
|
||||
export const applyStructure = async (plan: Awaited<ReturnType<typeof planStructure>>, id: string = randomUUID()) => withStructureLock(plan.root, async () => {
|
||||
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid scaffold journal ID");
|
||||
const directory = path.join(plan.root, ".quixos", "scaffolds");
|
||||
try {
|
||||
const existing = JSON.parse(await fs.readFile(path.join(directory, `${id}.json`), "utf8")) as Journal;
|
||||
if (existing.root !== plan.root || contentDigest(existing.changes) !== plan.digest) throw new Error("Scaffold journal identity conflict");
|
||||
for (const entry of plan.observed) if (!plan.changes.some((change) => change.file === entry.file) && contentDigest(await fs.readFile(path.join(plan.root, entry.file), "utf8")) !== entry.digest) throw new Error(`Stale scaffold validation input: ${entry.file}`);
|
||||
return replayStructure(plan.root, id);
|
||||
} catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;}
|
||||
// An unfinished journal must be recovered before another structural mutation.
|
||||
for (const file of await fs.readdir(directory)) if (file.endsWith(".json")) {
|
||||
const prior = JSON.parse(await fs.readFile(path.join(directory, file), "utf8")) as Journal;
|
||||
if (prior.phase !== "complete") throw new Error(`Unfinished scaffold ${prior.id}; resume it first`);
|
||||
}
|
||||
for (const entry of plan.changes) if (await read(plan.root, entry.file) !== entry.before) throw new Error(`Stale scaffold plan: ${entry.file}`);
|
||||
for (const entry of plan.observed) if (contentDigest(await fs.readFile(path.join(plan.root, entry.file), "utf8")) !== entry.digest) throw new Error(`Stale scaffold validation input: ${entry.file}`);
|
||||
await durableJson(path.join(directory, `${id}.json`), {schemaVersion: 1, id, root: plan.root, phase: "prepared", changes: plan.changes} satisfies Journal);
|
||||
return replayStructure(plan.root, id);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
|
||||
import { parseQx, formatQx, lintQx } from "./source.js";
|
||||
import { scaffoldAtom } from "./scaffold.js";
|
||||
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||
import { planEvolution } from "../capability-model/index.js";
|
||||
import { checkWorkspaceCandidate, checkResourceCandidate, 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";
|
||||
|
||||
const main = async () => {
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
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 === "check-resource") {
|
||||
const [root, kind, repository, commit, output, ...extra] = args;
|
||||
if (!root || !output || !["package", "interface"].includes(kind) || extra.length) throw new Error("usage: quixos-qx check-resource ROOT package|interface REPOSITORY COMMIT OUTPUT");
|
||||
const result = await checkResourceCandidate({root, kind: kind as "package" | "interface", source: {repository, commit}, output, publishedOnly: true});
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
if (result.blockers.length) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (["scaffold-package", "scaffold-function", "scaffold-migration", "scaffold-refresh"].includes(command)) {
|
||||
const [root, specFile, ...flags] = args;
|
||||
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 spec = JSON.parse(await readFile(specFile, "utf8")) as ScaffoldRecipe;
|
||||
const request = await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration" | "refresh", spec);
|
||||
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
|
||||
const applied = 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"]], ["corepack", ["yarn", "typecheck"]], ["nix", ["flake", "lock"]]] 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}`);
|
||||
}
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify({...plan, 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 = JSON.parse(await readFile(spec, "utf8")) as StructuralRequest;
|
||||
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({...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 (command === "check") {
|
||||
const [root, output, ...flags] = args;
|
||||
if (!root || !output || flags.length % 2) throw new Error("usage: quixos-qx check ROOT OUTPUT [--snapshot-map FILE] [--baseline FILE] [--reviews FILE]");
|
||||
const values = new Map<string, string>();
|
||||
for (let index = 0; index < flags.length; index += 2) {
|
||||
if (!["--snapshot-map", "--baseline", "--reviews"].includes(flags[index])) throw new Error(`Unknown check option ${flags[index]}`);
|
||||
values.set(flags[index], flags[index + 1]);
|
||||
}
|
||||
const result = await checkWorkspaceCandidate({ root, output, snapshotMap: values.get("--snapshot-map"), baseline: values.get("--baseline"), reviews: values.get("--reviews") });
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
if (result.blockers.length) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
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; });
|
||||
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import process from "node:process";
|
||||
import { compileWorkspaceRepository } from "./assembly.js";
|
||||
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||
import { planEvolution, runtimeContracts, type EvolutionReview, type WorkspaceRevision } from "../capability-model/index.js";
|
||||
|
||||
const usage = `usage: quixos-workspace-compile --root DIRECTORY --checkout-root DIRECTORY
|
||||
[--snapshot-map PATH] [--graph-out PATH] [--workspace-id ID] [--workspace-revision-id ID]
|
||||
[--source-root-commit GIT_REV]
|
||||
[--source-root-commit GIT_REV] [--baseline PLAN_JSON] [--evolution-out PATH]
|
||||
[--reviews REVIEW_JSON]
|
||||
|
||||
Resolves a workspace's recursive resource-lock graph, clones every exact
|
||||
resource revision, validates standalone interface/package manifests, and emits
|
||||
@@ -32,6 +34,9 @@ const parseArgs = (args: string[]) => {
|
||||
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"),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -77,7 +82,14 @@ const main = async () => {
|
||||
})),
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(assembled.workspace, null, 2)}\n`);
|
||||
const candidate = { ...assembled.workspace, executionContracts: runtimeContracts(assembled.workspace) };
|
||||
if (options.evolutionOut) {
|
||||
const baseline = options.baseline ? JSON.parse(await readFile(options.baseline, "utf8")) as WorkspaceRevision : null;
|
||||
const reviews = options.reviews ? JSON.parse(await readFile(options.reviews, "utf8")) as EvolutionReview[] : [];
|
||||
if (!Array.isArray(reviews)) throw new Error("Review file must contain an array");
|
||||
await writeFile(options.evolutionOut, `${JSON.stringify(planEvolution(baseline, candidate, { reviews }), null, 2)}\n`);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(candidate, null, 2)}\n`);
|
||||
};
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
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")
|
||||
.map(([key, entry]) => [key, 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 };
|
||||
export const storageContracts = (workspace: WorkspaceRevision): StorageContract[] => {
|
||||
const result: StorageContract[] = [];
|
||||
const add = (attachment: PersistentAttachment, ownerId: string) => {
|
||||
const definition = semantic(attachment);
|
||||
result.push({ id: attachment.id, ownerId, kind: attachment.kind, definition, digest: contentDigest({ ownerId, definition }) });
|
||||
};
|
||||
for (const attachment of workspace.sharedAttachments) add(attachment, "legacy:workspace");
|
||||
for (const conformance of workspace.conformances) for (const attachment of conformance.privateAttachments) add(attachment, conformanceIdentity(conformance));
|
||||
return sorted(result, (entry) => entry.id);
|
||||
};
|
||||
|
||||
type GraphNode = { value: unknown; dependencies: Set<string>; reviewProviders: Set<string> };
|
||||
export type RuntimeContract = { groupId: string; packageId: string; packageRevisionId: string; digest: string; reviewProviders: string[]; dependencies: Array<{ id: string; digest: string }> };
|
||||
|
||||
/** Build only outbound execution dependencies. Incoming callers never retain or invalidate a provider. */
|
||||
export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[] => {
|
||||
const nodes = new Map<string, GraphNode>();
|
||||
const node = (key: string, value: unknown) => {
|
||||
const result = { value: semantic(value), dependencies: new Set<string>(), reviewProviders: new Set<string>() };
|
||||
nodes.set(key, result);
|
||||
return result;
|
||||
};
|
||||
const conformanceKey = (atom: string, iface: string) => `conformance:${atom}:${iface}`;
|
||||
for (const storage of storageContracts(workspace)) node(`attachment:${storage.id}`, storage);
|
||||
for (const iface of workspace.interfaceImports) node(`interface:${iface.revisionId}`, {
|
||||
...iface, members: sorted(iface.members, (entry) => entry.id).map((entry) => ({ ...entry, operations: sorted(entry.operations, (operation) => operation.id) })),
|
||||
});
|
||||
for (const pkg of workspace.packageImports) node(`package:${pkg.revisionId}`, {
|
||||
...pkg, semanticMajor: pkg.semanticMajor ?? 1,
|
||||
exports: sorted(pkg.exports, (entry) => entry.id).map((entry) => ({ ...entry, dependencyPorts: sorted(entry.dependencyPorts, (port) => port.id) })),
|
||||
});
|
||||
const dependency = (parent: GraphNode, binding: DependencyBinding, atomId: string, reviews?: Set<string>) => {
|
||||
if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`);
|
||||
if (binding.kind === "edge") parent.dependencies.add(`attachment:${binding.edgeTypeId}`);
|
||||
if (binding.kind === "constructor") {
|
||||
parent.dependencies.add(`constructor:${binding.atomId}`);
|
||||
const ctor = workspace.constructors.find((entry) => entry.atomId === binding.atomId);
|
||||
const pkg = workspace.packageImports.find((entry) => entry.revisionId === ctor?.packageRevisionId);
|
||||
if (pkg) reviews?.add(pkg.packageId);
|
||||
}
|
||||
if (binding.kind !== "constructor" && binding.via) parent.dependencies.add(`attachment:${binding.via.edgeTypeId}`);
|
||||
if (binding.kind === "interface") {
|
||||
parent.dependencies.add(`interface:${binding.interfaceRevisionId}`);
|
||||
// An edge traversal may select any matching target. Conservatively include every possible witness.
|
||||
for (const conformance of workspace.conformances) {
|
||||
if (conformance.interfaceRevisionId === binding.interfaceRevisionId && (binding.via || conformance.atomId === atomId)) {
|
||||
parent.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
|
||||
reviews?.add(conformanceIdentity(conformance));
|
||||
for (const operation of conformance.operationBindings) {
|
||||
if (operation.binding.kind !== "package") continue;
|
||||
const revisionId = operation.binding.packageRevisionId;
|
||||
const pkg = workspace.packageImports.find((entry) => entry.revisionId === revisionId);
|
||||
if (pkg) reviews?.add(pkg.packageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const binding = (parent: GraphNode, value: Binding, atomId: string, context: unknown) => {
|
||||
if (value.kind !== "package") { dependency(parent, value, atomId); return; }
|
||||
const packageNode = nodes.get(`package:${value.packageRevisionId}`)!;
|
||||
parent.dependencies.add(`package:${value.packageRevisionId}`);
|
||||
const normalized = { ...value, dependencies: sorted(value.dependencies, (entry) => entry.portId) };
|
||||
const key = `binding:${contentDigest({ atomId, context, value: semantic(normalized) })}`;
|
||||
const bound = node(key, { atomId, context, binding: normalized });
|
||||
packageNode.dependencies.add(key);
|
||||
for (const port of value.dependencies) dependency(bound, port.binding, atomId, packageNode.reviewProviders);
|
||||
};
|
||||
for (const conformance of workspace.conformances) {
|
||||
const parent = node(conformanceKey(conformance.atomId, conformance.interfaceRevisionId), {
|
||||
id: conformanceIdentity(conformance), semanticMajor: conformance.semanticMajor ?? 1,
|
||||
atomId: conformance.atomId, interfaceRevisionId: conformance.interfaceRevisionId,
|
||||
operations: sorted(conformance.operationBindings, (entry) => entry.operationId),
|
||||
materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId),
|
||||
});
|
||||
parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`);
|
||||
for (const attachment of conformance.privateAttachments) parent.dependencies.add(`attachment:${attachment.id}`);
|
||||
for (const operation of conformance.operationBindings) binding(parent, operation.binding, conformance.atomId, {
|
||||
conformanceId: conformanceIdentity(conformance), semanticMajor: conformance.semanticMajor ?? 1, operationId: operation.operationId,
|
||||
});
|
||||
for (const materialization of conformance.relationshipMaterializations) {
|
||||
parent.dependencies.add(`constructor:${materialization.constructorAtomId}`);
|
||||
parent.dependencies.add(`attachment:${materialization.edgeTypeId}`);
|
||||
}
|
||||
}
|
||||
for (const constructor of workspace.constructors) {
|
||||
const parent = node(`constructor:${constructor.atomId}`, constructor);
|
||||
binding(parent, { kind: "package", ...constructor }, constructor.atomId, { constructor: constructor.atomId });
|
||||
}
|
||||
const counts = new Map<string, number>();
|
||||
for (const pkg of workspace.packageImports) counts.set(pkg.packageId, (counts.get(pkg.packageId) ?? 0) + 1);
|
||||
return sorted(workspace.packageImports.map((pkg): RuntimeContract => {
|
||||
const visited = new Set<string>();
|
||||
const walk = (key: string) => {
|
||||
if (visited.has(key)) return;
|
||||
const entry = nodes.get(key);
|
||||
if (!entry) throw new Error(`Unresolved execution dependency ${key}`);
|
||||
visited.add(key);
|
||||
for (const target of entry.dependencies) walk(target);
|
||||
};
|
||||
walk(`package:${pkg.revisionId}`);
|
||||
const dependencies = [...visited].sort().map((id) => ({ id, digest: contentDigest(nodes.get(id)!.value) }));
|
||||
return { groupId: counts.get(pkg.packageId) === 1 ? pkg.packageId : `${pkg.packageId}#${pkg.revisionId}`,
|
||||
packageId: pkg.packageId, packageRevisionId: pkg.revisionId, digest: contentDigest(dependencies),
|
||||
reviewProviders: [...nodes.get(`package:${pkg.revisionId}`)!.reviewProviders].sort(), dependencies };
|
||||
}), (entry) => entry.groupId);
|
||||
};
|
||||
|
||||
export type EvolutionReview = { requirementDigest: string; decision: "changed" | "accepted-unchanged"; rationale: string; agentId: string };
|
||||
export type ReviewRequirement = { consumerId: string; providerId: string; oldMajor: number; newMajor: number; requirementDigest: string };
|
||||
export type RuntimeAction = { groupId: string; action: "keep" | "start" | "replace" | "retire"; previous?: RuntimeContract; candidate?: RuntimeContract; reasons: string[] };
|
||||
export type EvolutionReport = {
|
||||
schemaVersion: 1; baselineDigest: string | null; candidateDigest: string; checkerVersion: string;
|
||||
runtimeActions: RuntimeAction[];
|
||||
storageChanges: Array<{ id: string; kind: "add" | "remove" | "change"; previous?: StorageContract; candidate?: StorageContract }>;
|
||||
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",
|
||||
...(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, reviews, packageChecks: runtimeActions.filter((entry) => entry.candidate && entry.action !== "keep")
|
||||
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })), blockers };
|
||||
};
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./types.js";
|
||||
export * from "./validation.js";
|
||||
export * from "./evolution.js";
|
||||
export * from "./migrations.js";
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { contentDigest } from "./evolution.js";
|
||||
import type { PersistentAttachment } from "./types.js";
|
||||
|
||||
/** Portable storage shape: local owner/slot/projection identities are supplied
|
||||
* by the consuming workspace's explicit bindings, never baked into this hash. */
|
||||
export const migrationPortContract = (attachment: PersistentAttachment): unknown => {
|
||||
if (attachment.kind === "state") return {kind: "state", valueType: attachment.valueType, storagePolicy: attachment.storagePolicy,
|
||||
...(attachment.defaultValue === undefined ? {} : {defaultValue: attachment.defaultValue})};
|
||||
const endpoint = (value: typeof attachment.endpoints[number]) => ({constraint: value.constraint, cardinality: value.cardinality,
|
||||
ordered: value.ordered, onDelete: value.onDelete ?? "restrict", retainOther: value.retainOther ?? false,
|
||||
...(value.keyType ? {keyType: value.keyType} : {}), ...(value.publicTraversal ? {publicTraversal: true} : {})});
|
||||
return {kind: "edge", first: endpoint(attachment.endpoints[0]), second: endpoint(attachment.endpoints[1])};
|
||||
};
|
||||
|
||||
export type MigrationDeclaration = {
|
||||
id: string;
|
||||
scopeId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
implementation: { exportId: string; file: string; digest: string };
|
||||
predecessors: string[];
|
||||
ports: { name: string; view: "old" | "new"; access: ("read" | "write" | "create" | "edge")[]; contractDigest: string }[];
|
||||
preservesOldReaders?: boolean;
|
||||
preservesOldWriters?: boolean;
|
||||
};
|
||||
export type MigrationCatalog = {
|
||||
schemaVersion: 1;
|
||||
contracts: Record<string, unknown>;
|
||||
migrations: MigrationDeclaration[];
|
||||
};
|
||||
export const validateMigrationCatalog = (value: unknown, exportIds?: ReadonlySet<string>): MigrationCatalog => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Migration catalog must be an object");
|
||||
const catalog = value as MigrationCatalog;
|
||||
if (catalog.schemaVersion !== 1 || !catalog.contracts || Array.isArray(catalog.contracts) || !Array.isArray(catalog.migrations)) throw new Error("Unsupported migration catalog");
|
||||
for (const [digest, contract] of Object.entries(catalog.contracts)) if (contentDigest(contract) !== digest) throw new Error(`Migration contract digest mismatch: ${digest}`);
|
||||
const ids = new Set<string>();
|
||||
for (const migration of catalog.migrations) {
|
||||
if (!migration.id || !migration.scopeId || ids.has(migration.id)) throw new Error("Migration IDs must be stable and unique");
|
||||
ids.add(migration.id);
|
||||
if (!catalog.contracts[migration.from] || !catalog.contracts[migration.to] || migration.from === migration.to) throw new Error(`Migration ${migration.id} requires distinct retained source and target contracts`);
|
||||
if (!migration.implementation?.exportId || !/^sha256:[0-9a-f]{64}$/.test(migration.implementation.digest)) throw new Error(`Migration ${migration.id} requires an exact implementation digest`);
|
||||
if (!migration.implementation.file || migration.implementation.file.startsWith("/") || migration.implementation.file.split(/[\\/]/).some((part) => !part || part === "." || part === "..")) throw new Error("Migration implementation must be a relative package file");
|
||||
if (exportIds && !exportIds.has(migration.implementation.exportId)) throw new Error(`Migration ${migration.id} refers to an undeclared package export`);
|
||||
if (!Array.isArray(migration.predecessors) || !Array.isArray(migration.ports)) throw new Error(`Migration ${migration.id} requires predecessors and ports`);
|
||||
if (new Set(migration.predecessors).size !== migration.predecessors.length) throw new Error(`Duplicate predecessor in ${migration.id}`);
|
||||
for (const promise of [migration.preservesOldReaders, migration.preservesOldWriters]) if (promise !== undefined && typeof promise !== "boolean") throw new Error("Migration compatibility promises must be booleans");
|
||||
const ports = new Set<string>();
|
||||
for (const port of migration.ports) {
|
||||
if (!port.name || ports.has(port.name) || !["old", "new"].includes(port.view) || !Array.isArray(port.access) || !port.access.length
|
||||
|| port.access.some((access) => !["read", "write", "create", "edge"].includes(access)) || !catalog.contracts[port.contractDigest]) throw new Error(`Invalid migration port in ${migration.id}`);
|
||||
if (port.view === "old" && port.access.some((access) => access !== "read")) throw new Error("Old migration views are read-only");
|
||||
ports.add(port.name);
|
||||
}
|
||||
}
|
||||
for (const migration of catalog.migrations) for (const predecessor of migration.predecessors) if (!ids.has(predecessor)) throw new Error(`Missing retained predecessor ${predecessor}`);
|
||||
// Catalogs are retained across releases. Reject impossible histories at
|
||||
// publication/check time, not only when someone tries to select a path.
|
||||
const remaining = new Map(catalog.migrations.map((entry) => [entry.id, new Set(entry.predecessors)]));
|
||||
const ready = [...remaining].filter(([, dependencies]) => dependencies.size === 0).map(([id]) => id);
|
||||
for (let index = 0; index < ready.length; index++) {
|
||||
remaining.delete(ready[index]);
|
||||
for (const [id, dependencies] of remaining) if (dependencies.delete(ready[index]) && dependencies.size === 0) ready.push(id);
|
||||
}
|
||||
if (remaining.size) throw new Error(`Cyclic migration predecessors: ${[...remaining.keys()].join(", ")}`);
|
||||
return catalog;
|
||||
};
|
||||
|
||||
export type MigrationSelection = {
|
||||
scopeId: string; from: string; to: string; path: string[];
|
||||
bindings: Record<string, string>;
|
||||
};
|
||||
/** Explicit paths, not shortest-path guesses. Receipts identify code plus local scope mapping. */
|
||||
export const selectMigrationPath = (catalog: MigrationCatalog, selection: MigrationSelection, previousReceipts: ReadonlyMap<string, string> = new Map()) => {
|
||||
validateMigrationCatalog(catalog);
|
||||
let current = selection.from;
|
||||
const seen = new Set<string>();
|
||||
const transitions = [];
|
||||
for (const id of selection.path) {
|
||||
const declaration = catalog.migrations.find((entry) => entry.id === id);
|
||||
if (!declaration || declaration.scopeId !== selection.scopeId || declaration.from !== current || seen.has(id)) throw new Error(`Invalid selected migration transition ${id}`);
|
||||
for (const predecessor of declaration.predecessors) if (!seen.has(predecessor) && !previousReceipts.has(predecessor)) throw new Error(`Unsatisfied predecessor ${predecessor}`);
|
||||
const usedBindings: Record<string, string> = {};
|
||||
for (const port of declaration.ports) {
|
||||
if (!selection.bindings[port.name]) throw new Error(`Missing local migration binding ${port.name}`);
|
||||
usedBindings[port.name] = selection.bindings[port.name];
|
||||
}
|
||||
const digest = contentDigest({ declaration, bindings: usedBindings });
|
||||
const previous = previousReceipts.get(id);
|
||||
if (previous && previous !== digest) throw new Error(`Migration identity ${id} was previously used with different code or scope`);
|
||||
transitions.push({ declaration, bindings: usedBindings, digest, alreadyApplied: Boolean(previous) });
|
||||
seen.add(id);
|
||||
current = declaration.to;
|
||||
}
|
||||
if (current !== selection.to) throw new Error("Selected migration path does not cover the target storage contract");
|
||||
return transitions;
|
||||
};
|
||||
@@ -7,6 +7,7 @@ type OpaqueId<Kind extends string> = string & {
|
||||
export type WorkspaceId = OpaqueId<"WorkspaceId">;
|
||||
export type 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">;
|
||||
@@ -31,6 +32,7 @@ export const capabilityId = {
|
||||
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),
|
||||
@@ -192,11 +194,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 {
|
||||
@@ -245,7 +251,7 @@ export type DependencyPortRequirement =
|
||||
kind: "interface";
|
||||
interfaceRevisionId: InterfaceRevisionId;
|
||||
}
|
||||
| { kind: "constructor"; atomId: AtomId };
|
||||
| { kind: "constructor"; atomId: AtomId; inputType?: ValueType };
|
||||
|
||||
export interface DependencyPort {
|
||||
id: DependencyPortId;
|
||||
@@ -283,11 +289,14 @@ export type PackageExport =
|
||||
| PackageConstructorExport;
|
||||
|
||||
export interface PackageRevision {
|
||||
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 =
|
||||
@@ -353,6 +362,9 @@ export interface OperationBinding {
|
||||
}
|
||||
|
||||
export interface Conformance {
|
||||
/** Absent only for legacy assemblies awaiting explicit ownership enrollment. */
|
||||
id?: ConformanceId;
|
||||
semanticMajor?: number;
|
||||
atomId: AtomId;
|
||||
interfaceRevisionId: InterfaceRevisionId;
|
||||
privateAttachments: PersistentAttachment[];
|
||||
|
||||
@@ -36,6 +36,8 @@ import type {
|
||||
import { valueType } from "./types.js";
|
||||
|
||||
export type CapabilityValidationIssueCode =
|
||||
| "invalid-semantic-major"
|
||||
| "duplicate-conformance-id"
|
||||
| "required-value"
|
||||
| "invalid-source"
|
||||
| "invalid-value-type"
|
||||
@@ -562,6 +564,9 @@ const collectIdentityIndexes = (
|
||||
const packages = new Map<string, PackageIndexEntry>();
|
||||
for (const [packageIndex, revision] of workspace.packageImports.entries()) {
|
||||
const path = `packageImports[${packageIndex}]`;
|
||||
if (revision.semanticMajor !== undefined && (!Number.isSafeInteger(revision.semanticMajor) || revision.semanticMajor < 1)) {
|
||||
issue(issues, "invalid-semantic-major", `${path}.semanticMajor`, "Semantic major must be a positive safe integer");
|
||||
}
|
||||
requireText(issues, revision.packageId, `${path}.packageId`, "Package ID");
|
||||
requireText(
|
||||
issues,
|
||||
@@ -616,8 +621,17 @@ const collectIdentityIndexes = (
|
||||
|
||||
const conformances = new Map<string, Conformance>();
|
||||
const conformancePaths = new Map<string, string>();
|
||||
const conformanceIds = new Set<string>();
|
||||
for (const [index, conformance] of workspace.conformances.entries()) {
|
||||
const path = `conformances[${index}]`;
|
||||
if (conformance.semanticMajor !== undefined && (!Number.isSafeInteger(conformance.semanticMajor) || conformance.semanticMajor < 1)) {
|
||||
issue(issues, "invalid-semantic-major", `${path}.semanticMajor`, "Semantic major must be a positive safe integer");
|
||||
}
|
||||
if (conformance.id !== undefined) {
|
||||
requireText(issues, conformance.id, `${path}.id`, "Conformance ID");
|
||||
if (conformanceIds.has(conformance.id)) issue(issues, "duplicate-conformance-id", `${path}.id`, `Duplicate conformance ID ${conformance.id}`);
|
||||
conformanceIds.add(conformance.id);
|
||||
}
|
||||
const key = conformanceKey(conformance.atomId, conformance.interfaceRevisionId);
|
||||
if (conformances.has(key)) {
|
||||
issue(
|
||||
@@ -763,6 +777,10 @@ const validateAttachments = (
|
||||
);
|
||||
}
|
||||
validateValueType(issues, attachment.valueType, `${path}.valueType`, indexes);
|
||||
const containsReference = (type: ValueType): boolean => type.kind === "object-ref" || ((type.kind === "optional" || type.kind === "list") && containsReference(type.value));
|
||||
if (containsReference(attachment.valueType) || (attachment.storagePolicy.kind === "crdt-document" && containsReference(attachment.storagePolicy.updateType))) {
|
||||
issue(issues, "invalid-attachment", `${path}.valueType`, "Managed object references belong in graph relationships, not ordinary state");
|
||||
}
|
||||
if (attachment.storagePolicy.kind === "crdt-document") {
|
||||
validateValueType(
|
||||
issues,
|
||||
@@ -794,10 +812,17 @@ const validateAttachments = (
|
||||
}
|
||||
for (const [endpointIndex, endpoint] of attachment.endpoints.entries()) {
|
||||
const endpointPath = `${path}.endpoints[${endpointIndex}]`;
|
||||
if (endpoint.keyType !== undefined && (!["string", "boolean", "int64"].includes(endpoint.keyType) || endpoint.ordered || !["many", "many-unique"].includes(endpoint.cardinality))) {
|
||||
issue(issues, "invalid-attachment", `${endpointPath}.keyType`, "Keyed projections require string/boolean/int64 keys, many cardinality, and no ordering");
|
||||
}
|
||||
requireText(issues, endpoint.projectionId, `${endpointPath}.projectionId`, "Projection ID");
|
||||
if (endpoint.onDelete !== undefined && !["restrict", "detach", "cascade-other"].includes(endpoint.onDelete)) {
|
||||
issue(issues, "invalid-attachment", `${endpointPath}.onDelete`, "Deletion policy must be restrict, detach, or cascade-other");
|
||||
}
|
||||
requireText(issues, endpoint.displayName, `${endpointPath}.displayName`, "Projection name");
|
||||
validateConstraint(issues, endpoint.constraint, `${endpointPath}.constraint`, indexes);
|
||||
}
|
||||
if (attachment.endpoints.every((endpoint) => endpoint.keyType)) issue(issues, "invalid-attachment", `${path}.endpoints`, "A v0 map has one keyed projection and one unkeyed inverse");
|
||||
if (owner.kind === "conformance") {
|
||||
if (
|
||||
!attachment.endpoints.some((endpoint) =>
|
||||
@@ -928,6 +953,7 @@ const validatePackages = (
|
||||
}
|
||||
break;
|
||||
case "constructor":
|
||||
if (port.requirement.inputType) validateValueType(issues, port.requirement.inputType, `${portPath}.requirement.inputType`, indexes);
|
||||
if (!indexes.atoms.has(port.requirement.atomId)) {
|
||||
issue(
|
||||
issues,
|
||||
@@ -988,7 +1014,6 @@ const validateTraversal = (
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
validateAttachmentAccess(issues, attachment, conformance, `${path}.edgeTypeId`);
|
||||
const projection = edgeProjection(attachment.attachment, traversal.projectionId);
|
||||
if (!projection) {
|
||||
issue(
|
||||
@@ -999,6 +1024,7 @@ const validateTraversal = (
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (!projection.endpoint.publicTraversal) validateAttachmentAccess(issues, attachment, conformance, `${path}.edgeTypeId`);
|
||||
if (!atomSatisfiesConstraint(atomId, projection.endpoint.constraint, indexes.conformances)) {
|
||||
issue(
|
||||
issues,
|
||||
@@ -1260,6 +1286,14 @@ const validateBoundDependencies = (
|
||||
`Atom ${requirement.atomId} has no constructor binding`,
|
||||
);
|
||||
}
|
||||
if (requirement.inputType) {
|
||||
const selected = params.indexes.constructors.get(requirement.atomId);
|
||||
const implementation = selected ? params.indexes.packages.get(selected.packageRevisionId)?.exports.get(selected.exportId) : undefined;
|
||||
if (implementation && !valueTypesEqual(requirement.inputType, implementation.inputType)) {
|
||||
issue(issues, "invalid-dependency-binding", `${path}.binding.atomId`,
|
||||
`Constructor port requires input ${typeLabel(requirement.inputType)}, but selected constructor accepts ${typeLabel(implementation.inputType)}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
+129
-8
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf";
|
||||
* Describes the file camino/schema.proto.
|
||||
*/
|
||||
export const file_camino_schema: GenFile = /*@__PURE__*/
|
||||
fileDesc("ChNjYW1pbm8vc2NoZW1hLnByb3RvEgZjYW1pbm8iNwoOQXRvbURlZmluaXRpb24SDwoHYXRvbV9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkiQQoPQXRvbUNvbmZvcm1hbmNlEg8KB2F0b21faWQYASABKAkSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAIgASgJIqQBCg9TdGF0ZUF0dGFjaG1lbnQSDwoHc2xvdF9pZBgBIAEoCRIYChBhdHRhY2hlZF9hdG9tX2lkGAIgASgJEhQKDGRpc3BsYXlfbmFtZRgDIAEoCRIXCg92YWx1ZV90eXBlX2pzb24YBCABKAkSGwoTc3RvcmFnZV9wb2xpY3lfanNvbhgFIAEoCRIaChJkZWZhdWx0X3ZhbHVlX2pzb24YBiABKAkiUAoSRW5kcG9pbnRDb25zdHJhaW50EhEKB2F0b21faWQYASABKAlIABIfChVpbnRlcmZhY2VfcmV2aXNpb25faWQYAiABKAlIAEIGCgRraW5kIqYBCgxFZGdlRW5kcG9pbnQSFQoNcHJvamVjdGlvbl9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSLgoKY29uc3RyYWludBgDIAEoCzIaLmNhbWluby5FbmRwb2ludENvbnN0cmFpbnQSKAoLY2FyZGluYWxpdHkYBCABKA4yEy5jYW1pbm8uQ2FyZGluYWxpdHkSDwoHb3JkZXJlZBgFIAEoCCKHAQoORWRnZUF0dGFjaG1lbnQSFAoMZWRnZV90eXBlX2lkGAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRIjCgVmaXJzdBgDIAEoCzIULmNhbWluby5FZGdlRW5kcG9pbnQSJAoGc2Vjb25kGAQgASgLMhQuY2FtaW5vLkVkZ2VFbmRwb2ludCLsAQoPUGVyc2lzdGVuY2VQbGFuEhQKDHdvcmtzcGFjZV9pZBgBIAEoCRIdChV3b3Jrc3BhY2VfcmV2aXNpb25faWQYAiABKAkSJQoFYXRvbXMYAyADKAsyFi5jYW1pbm8uQXRvbURlZmluaXRpb24SLQoMY29uZm9ybWFuY2VzGAQgAygLMhcuY2FtaW5vLkF0b21Db25mb3JtYW5jZRInCgZzdGF0ZXMYBSADKAsyFy5jYW1pbm8uU3RhdGVBdHRhY2htZW50EiUKBWVkZ2VzGAYgAygLMhYuY2FtaW5vLkVkZ2VBdHRhY2htZW50KmgKC0NhcmRpbmFsaXR5EhsKF0NBUkRJTkFMSVRZX1VOU1BFQ0lGSUVEEAASEAoMT1BUSU9OQUxfT05FEAESDwoLRVhBQ1RMWV9PTkUQAhIICgRNQU5ZEAMSDwoLTUFOWV9VTklRVUUQBGIGcHJvdG8z");
|
||||
fileDesc("ChNjYW1pbm8vc2NoZW1hLnByb3RvEgZjYW1pbm8iNwoOQXRvbURlZmluaXRpb24SDwoHYXRvbV9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkiWQoPQXRvbUNvbmZvcm1hbmNlEg8KB2F0b21faWQYASABKAkSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAIgASgJEhYKDmNvbmZvcm1hbmNlX2lkGAMgASgJIsIBCg9TdGF0ZUF0dGFjaG1lbnQSDwoHc2xvdF9pZBgBIAEoCRIYChBhdHRhY2hlZF9hdG9tX2lkGAIgASgJEhQKDGRpc3BsYXlfbmFtZRgDIAEoCRIXCg92YWx1ZV90eXBlX2pzb24YBCABKAkSGwoTc3RvcmFnZV9wb2xpY3lfanNvbhgFIAEoCRIaChJkZWZhdWx0X3ZhbHVlX2pzb24YBiABKAkSHAoUb3duZXJfY29uZm9ybWFuY2VfaWQYByABKAkiUAoSRW5kcG9pbnRDb25zdHJhaW50EhEKB2F0b21faWQYASABKAlIABIfChVpbnRlcmZhY2VfcmV2aXNpb25faWQYAiABKAlIAEIGCgRraW5kIvsBCgxFZGdlRW5kcG9pbnQSFQoNcHJvamVjdGlvbl9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSLgoKY29uc3RyYWludBgDIAEoCzIaLmNhbWluby5FbmRwb2ludENvbnN0cmFpbnQSKAoLY2FyZGluYWxpdHkYBCABKA4yEy5jYW1pbm8uQ2FyZGluYWxpdHkSDwoHb3JkZXJlZBgFIAEoCBIRCglvbl9kZWxldGUYBiABKAkSFAoMcmV0YWluX290aGVyGAcgASgIEhAKCGtleV90eXBlGAggASgJEhgKEHB1YmxpY190cmF2ZXJzYWwYCSABKAgipQEKDkVkZ2VBdHRhY2htZW50EhQKDGVkZ2VfdHlwZV9pZBgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAkSIwoFZmlyc3QYAyABKAsyFC5jYW1pbm8uRWRnZUVuZHBvaW50EiQKBnNlY29uZBgEIAEoCzIULmNhbWluby5FZGdlRW5kcG9pbnQSHAoUb3duZXJfY29uZm9ybWFuY2VfaWQYBSABKAki7AEKD1BlcnNpc3RlbmNlUGxhbhIUCgx3b3Jrc3BhY2VfaWQYASABKAkSHQoVd29ya3NwYWNlX3JldmlzaW9uX2lkGAIgASgJEiUKBWF0b21zGAMgAygLMhYuY2FtaW5vLkF0b21EZWZpbml0aW9uEi0KDGNvbmZvcm1hbmNlcxgEIAMoCzIXLmNhbWluby5BdG9tQ29uZm9ybWFuY2USJwoGc3RhdGVzGAUgAygLMhcuY2FtaW5vLlN0YXRlQXR0YWNobWVudBIlCgVlZGdlcxgGIAMoCzIWLmNhbWluby5FZGdlQXR0YWNobWVudCpoCgtDYXJkaW5hbGl0eRIbChdDQVJESU5BTElUWV9VTlNQRUNJRklFRBAAEhAKDE9QVElPTkFMX09ORRABEg8KC0VYQUNUTFlfT05FEAISCAoETUFOWRADEg8KC01BTllfVU5JUVVFEARiBnByb3RvMw");
|
||||
|
||||
/**
|
||||
* @generated from message camino.AtomDefinition
|
||||
@@ -47,6 +47,11 @@ export type AtomConformance = Message<"camino.AtomConformance"> & {
|
||||
* @generated from field: string interface_revision_id = 2;
|
||||
*/
|
||||
interfaceRevisionId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string conformance_id = 3;
|
||||
*/
|
||||
conformanceId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -89,6 +94,11 @@ export type StateAttachment = Message<"camino.StateAttachment"> & {
|
||||
* @generated from field: string default_value_json = 6;
|
||||
*/
|
||||
defaultValueJson: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string owner_conformance_id = 7;
|
||||
*/
|
||||
ownerConformanceId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -155,6 +165,32 @@ export type EdgeEndpoint = Message<"camino.EdgeEndpoint"> & {
|
||||
* @generated from field: bool ordered = 5;
|
||||
*/
|
||||
ordered: boolean;
|
||||
|
||||
/**
|
||||
* Empty means restrict. Direction is the endpoint being deleted.
|
||||
*
|
||||
* @generated from field: string on_delete = 6;
|
||||
*/
|
||||
onDelete: string;
|
||||
|
||||
/**
|
||||
* @generated from field: bool retain_other = 7;
|
||||
*/
|
||||
retainOther: boolean;
|
||||
|
||||
/**
|
||||
* Empty for sets/lists, otherwise string, boolean, or int64 map keys.
|
||||
*
|
||||
* @generated from field: string key_type = 8;
|
||||
*/
|
||||
keyType: string;
|
||||
|
||||
/**
|
||||
* Explicit read-only dependency injection traversal, not mutation authority.
|
||||
*
|
||||
* @generated from field: bool public_traversal = 9;
|
||||
*/
|
||||
publicTraversal: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -187,6 +223,11 @@ export type EdgeAttachment = Message<"camino.EdgeAttachment"> & {
|
||||
* @generated from field: camino.EdgeEndpoint second = 4;
|
||||
*/
|
||||
second?: EdgeEndpoint | undefined;
|
||||
|
||||
/**
|
||||
* @generated from field: string owner_conformance_id = 5;
|
||||
*/
|
||||
ownerConformanceId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Message } from "@bufbuild/protobuf";
|
||||
* Describes the file quixos/runtime.proto.
|
||||
*/
|
||||
export const file_quixos_runtime: GenFile = /*@__PURE__*/
|
||||
fileDesc("ChRxdWl4b3MvcnVudGltZS5wcm90bxIOcXVpeG9zLnJ1bnRpbWUiMQoQSGFuZHNoYWtlUmVxdWVzdBIdChVvcmNoX3Byb3RvY29sX3ZlcnNpb24YASABKAkiZgoRSGFuZHNoYWtlUmVzcG9uc2USGwoTcGFja2FnZV9yZXZpc2lvbl9pZBgBIAEoCRIgChhydW50aW1lX3Byb3RvY29sX3ZlcnNpb24YAiABKAkSEgoKZXhwb3J0X2lkcxgDIAMoCSKLAgoNSW52b2tlUmVxdWVzdBIVCg1pbnZvY2F0aW9uX2lkGAEgASgJEigKBmV4cG9ydBgCIAEoCzIYLnF1aXhvcy5QYWNrYWdlRXhwb3J0UmVmEhEKCW9iamVjdF9pZBgDIAEoCRI3CgVpbnB1dBgEIAMoCzIoLnF1aXhvcy5ydW50aW1lLkludm9rZVJlcXVlc3QuSW5wdXRFbnRyeRIwCgxkZXBlbmRlbmNpZXMYBSADKAsyGi5xdWl4b3MuSW5qZWN0ZWREZXBlbmRlbmN5GjsKCklucHV0RW50cnkSCwoDa2V5GAEgASgJEhwKBXZhbHVlGAIgASgLMg0uY2FtaW5vLlZhbHVlOgI4ASKDAQoOSW52b2tlUmVzcG9uc2USCgoCb2sYASABKAgSHQoGcmVzdWx0GAIgASgLMg0uY2FtaW5vLlZhbHVlEg0KBWVycm9yGAMgASgJEjcKDGRlcGVuZGVuY2llcxgEIAMoCzIhLnF1aXhvcy5ydW50aW1lLkRlcml2ZWREZXBlbmRlbmN5IokCCgxXYXRjaFJlcXVlc3QSFQoNaW52b2NhdGlvbl9pZBgBIAEoCRIoCgZleHBvcnQYAiABKAsyGC5xdWl4b3MuUGFja2FnZUV4cG9ydFJlZhIRCglvYmplY3RfaWQYAyABKAkSNgoFaW5wdXQYBCADKAsyJy5xdWl4b3MucnVudGltZS5XYXRjaFJlcXVlc3QuSW5wdXRFbnRyeRIwCgxkZXBlbmRlbmNpZXMYBSADKAsyGi5xdWl4b3MuSW5qZWN0ZWREZXBlbmRlbmN5GjsKCklucHV0RW50cnkSCwoDa2V5GAEgASgJEhwKBXZhbHVlGAIgASgLMg0uY2FtaW5vLlZhbHVlOgI4ASJiChFEZXJpdmVkRGVwZW5kZW5jeRIMCgRraW5kGAEgASgJEhEKCW9iamVjdF9pZBgCIAEoCRIVCg1hdHRhY2htZW50X2lkGAMgASgJEhUKDXByb2plY3Rpb25faWQYBCABKAkilQEKCldhdGNoRXZlbnQSEAoId2F0Y2hfaWQYASABKAkSHAoFdmFsdWUYAiABKAsyDS5jYW1pbm8uVmFsdWUSNwoMZGVwZW5kZW5jaWVzGAMgAygLMiEucXVpeG9zLnJ1bnRpbWUuRGVyaXZlZERlcGVuZGVuY3kSDQoFZXJyb3IYBCABKAkSDwoHaW5pdGlhbBgFIAEoCDLwAQoOUGFja2FnZVJ1bnRpbWUSUAoJSGFuZHNoYWtlEiAucXVpeG9zLnJ1bnRpbWUuSGFuZHNoYWtlUmVxdWVzdBohLnF1aXhvcy5ydW50aW1lLkhhbmRzaGFrZVJlc3BvbnNlEkcKBkludm9rZRIdLnF1aXhvcy5ydW50aW1lLkludm9rZVJlcXVlc3QaHi5xdWl4b3MucnVudGltZS5JbnZva2VSZXNwb25zZRJDCgVXYXRjaBIcLnF1aXhvcy5ydW50aW1lLldhdGNoUmVxdWVzdBoaLnF1aXhvcy5ydW50aW1lLldhdGNoRXZlbnQwAWIGcHJvdG8z", [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
|
||||
@@ -127,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
|
||||
@@ -157,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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -164,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
|
||||
@@ -196,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
|
||||
@@ -233,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
|
||||
@@ -263,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);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { compileCapabilityResourceSource } from "../src/capability-language/parser.js";
|
||||
import { generateTypeScriptBindings, type BindingSchema } from "../src/bindings/index.js";
|
||||
const source = { repository: "https://example.test/p.git", commit: "1".repeat(40) };
|
||||
const schemaFor = (body: string): BindingSchema => {
|
||||
const compiled = compileCapabilityResourceSource(`external atom Thing id "thing"; package Demo id "demo" revision "demo@1" { ${body} }`, { source });
|
||||
assert.equal(compiled.ok, true, JSON.stringify(compiled.diagnostics));
|
||||
if (!compiled.ok || compiled.resource.kind !== "package") throw new Error("expected package");
|
||||
return { format: "quixos-bindings", version: 1, interfaces: [], packages: [compiled.resource.revision] };
|
||||
};
|
||||
test("generator uses exact IDs, restricted ports, nominal references, and lossless scalar types", () => {
|
||||
const schema = schemaFor('operation run id "run-id" : list<int64> -> optional<atom-ref<Thing>> mode call receiver atom Thing requires { state payload id "payload-id" : bytes [read]; };');
|
||||
const generated = generateTypeScriptBindings(schema, "demo@1");
|
||||
assert.match(generated, /Array<bigint>/);
|
||||
assert.match(generated, /Promise<Uint8Array>/);
|
||||
assert.match(generated, /QxObjectRef<"atom:thing">/);
|
||||
assert.doesNotMatch(generated, /"set":/);
|
||||
assert.match(generated, /"run-id": bindQxHandler/);
|
||||
assert.equal(generateTypeScriptBindings(schema, "demo@1"), generated);
|
||||
});
|
||||
test("missing external types and constructor signatures fail generation", () => {
|
||||
const schema = schemaFor('function run id "run" : unit -> message "example.Payload";');
|
||||
assert.throws(() => generateTypeScriptBindings(schema, "demo@1"), /Missing TypeScript message binding/);
|
||||
assert.match(generateTypeScriptBindings(schema, "demo@1", { messages: { "example.Payload": { module: "./payload.js", export: "payload" } } }), /BindingValue<typeof message0>/);
|
||||
const constructor = schemaFor('function run id "run" : unit -> unit requires { constructor thing id "ctor" : Thing; };');
|
||||
assert.throws(() => generateTypeScriptBindings(constructor, "demo@1"), /explicit input contract/);
|
||||
const typed = schemaFor('function run id "run" : unit -> unit requires { constructor thing id "ctor" : Thing input string; };');
|
||||
assert.match(generateTypeScriptBindings(typed, "demo@1"), /construct.*input: string/);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFile as callback } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { snapshotRepository, checkWorkspaceCandidate } from "../src/capability-language/candidate-check.js";
|
||||
const execFile = promisify(callback);
|
||||
|
||||
test("candidate snapshots include dirty and new files without changing Git history or index", async (context) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "qx-candidate-test-"));
|
||||
context.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const root = path.join(directory, "source");
|
||||
await fs.mkdir(root);
|
||||
const git = (...args: string[]) => execFile("git", ["-C", root, ...args]);
|
||||
await git("init");
|
||||
await fs.writeFile(path.join(root, "tracked"), "original");
|
||||
await fs.writeFile(path.join(root, ".gitignore"), "ignored\n");
|
||||
await git("add", ".");
|
||||
const index = await fs.readFile(path.join(root, ".git/index"));
|
||||
await fs.writeFile(path.join(root, "tracked"), "edited");
|
||||
await fs.writeFile(path.join(root, "new"), "new content");
|
||||
await fs.writeFile(path.join(root, "ignored"), "not source");
|
||||
const snapshot = await snapshotRepository(root, path.join(directory, "snapshot"));
|
||||
assert.equal(await fs.readFile(path.join(snapshot.directory, "tracked"), "utf8"), "edited");
|
||||
assert.equal(await fs.readFile(path.join(snapshot.directory, "new"), "utf8"), "new content");
|
||||
await assert.rejects(fs.access(path.join(snapshot.directory, "ignored")));
|
||||
assert.deepEqual(await fs.readFile(path.join(root, ".git/index")), index);
|
||||
await assert.rejects(git("rev-parse", "--verify", "HEAD"), "no commit was created");
|
||||
await fs.symlink("tracked", path.join(root, "link"));
|
||||
await assert.rejects(snapshotRepository(root, path.join(directory, "rejected")), /regular file/);
|
||||
});
|
||||
|
||||
test("candidate check produces explicitly non-activation evidence and never overwrites a report", async (context) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "qx-check-test-"));
|
||||
context.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const root = path.join(directory, "source"), output = path.join(directory, "check");
|
||||
await fs.mkdir(root);
|
||||
await execFile("git", ["-C", root, "init"]);
|
||||
await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${"a".repeat(40)}"; } }`);
|
||||
await fs.writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"b".repeat(40)}" { atom Subject id "atom:subject"; }`);
|
||||
const result = await checkWorkspaceCandidate({root, output});
|
||||
assert.equal(result.candidateOnly, true);
|
||||
assert.equal(result.activationEvidence, false);
|
||||
assert.deepEqual(result.blockers, []);
|
||||
const report = await fs.readFile(path.join(output, "report.json"));
|
||||
await assert.rejects(checkWorkspaceCandidate({root, output}), /EEXIST/);
|
||||
assert.deepEqual(await fs.readFile(path.join(output, "report.json")), report);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
const cli = resolve(process.cwd(), "dist/src/capability-language/cli.js");
|
||||
|
||||
const runResourceCheck = (source: string) => spawnSync(
|
||||
process.execPath,
|
||||
[cli, "--resource", "--check", "-"],
|
||||
{ input: source, encoding: "utf8" },
|
||||
);
|
||||
|
||||
test("capability CLI checks a standalone interface resource", () => {
|
||||
const result = runResourceCheck(`interface WeatherBase id "interface:weather-base" revision "interface:weather-base@1" {
|
||||
value temperature id "member:weather:temperature" : double {
|
||||
get id "operation:weather:temperature:get";
|
||||
}
|
||||
}\n`);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, "");
|
||||
});
|
||||
|
||||
test("capability CLI reports invalid standalone interface members", () => {
|
||||
const result = runResourceCheck(`interface WeatherBase id "interface:weather-base" revision "interface:weather-base@1" {
|
||||
value temperature : definitely-not-a-type;
|
||||
}\n`);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /syntax-error/);
|
||||
});
|
||||
@@ -101,16 +101,16 @@ 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", () => {
|
||||
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" {',
|
||||
),
|
||||
});
|
||||
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", () => {
|
||||
|
||||
@@ -14,6 +14,14 @@ import {
|
||||
makeValidCapabilityWorkspace,
|
||||
} from "./fixtures/capability-model.js";
|
||||
|
||||
test("ordinary state rejects managed references even under list/optional wrappers", () => {
|
||||
const workspace = makeValidCapabilityWorkspace();
|
||||
const state = [...workspace.sharedAttachments, ...workspace.conformances.flatMap((entry) => entry.privateAttachments)].find((entry) => entry.kind === "state")!;
|
||||
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,
|
||||
@@ -28,6 +36,18 @@ const expectIssue = (
|
||||
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">,
|
||||
@@ -181,6 +201,11 @@ test("related-object dependency views cannot traverse another conformance's priv
|
||||
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("native state and edge providers must match operation shape", () => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { capabilityId as id, valueType, validateWorkspaceRevision } from "../src/capability-model/index.js";
|
||||
import { contentDigest, planEvolution, runtimeContracts, storageContracts } from "../src/capability-model/evolution.js";
|
||||
import { capabilityFixtureSource, capabilityResourceSources, compileCapabilityFixture, makeValidCapabilityWorkspace } from "./fixtures/capability-model.js";
|
||||
|
||||
test("QX carries stable conformance IDs and implementation semantic majors", () => {
|
||||
const result = compileCapabilityFixture({
|
||||
workspace: capabilityFixtureSource.replace("conform Project as Named {", 'conform Project as Named id "conformance:project:named" semantic-major 3 {'),
|
||||
todo: capabilityResourceSources.todo.replace('revision "package:todo-runtime@1" {', 'revision "package:todo-runtime@1" semantic-major 2 {'),
|
||||
});
|
||||
assert.ok(result.ok, JSON.stringify(result));
|
||||
assert.equal(result.workspace.conformances[0]!.id, "conformance:project:named");
|
||||
assert.equal(result.workspace.conformances[0]!.semanticMajor, 3);
|
||||
assert.equal(result.workspace.packageImports[0]!.semanticMajor, 2);
|
||||
});
|
||||
|
||||
test("invalid majors and duplicate owner identities are rejected", () => {
|
||||
const workspace = makeValidCapabilityWorkspace();
|
||||
workspace.packageImports[0]!.semanticMajor = 0;
|
||||
workspace.conformances[0]!.semanticMajor = 1.5;
|
||||
workspace.conformances[0]!.id = id.conformance("same");
|
||||
workspace.conformances[1]!.id = id.conformance("same");
|
||||
const issues = validateWorkspaceRevision(workspace);
|
||||
assert.equal(issues.filter((entry) => entry.code === "invalid-semantic-major").length, 2);
|
||||
assert.ok(issues.some((entry) => entry.code === "duplicate-conformance-id"));
|
||||
});
|
||||
|
||||
test("hashing is canonical and rejects values outside the persisted JSON contract", () => {
|
||||
assert.equal(contentDigest({ z: 1, a: { y: 3, b: 2 } }), contentDigest({ a: { b: 2, y: 3 }, z: 1 }));
|
||||
assert.notEqual(contentDigest([1, 2]), contentDigest([2, 1]));
|
||||
assert.throws(() => contentDigest(NaN));
|
||||
assert.throws(() => contentDigest(new Date()));
|
||||
});
|
||||
|
||||
test("a workspace root change and display names do not restart package runtimes", () => {
|
||||
const before = makeValidCapabilityWorkspace();
|
||||
const after = structuredClone(before);
|
||||
after.id = id.workspaceRevision("next");
|
||||
after.sourceRootCommit = "f".repeat(40);
|
||||
after.atoms[0]!.displayName = "Renamed";
|
||||
after.sharedAttachments[0]!.displayName = "Renamed storage";
|
||||
after.conformances.reverse();
|
||||
after.interfaceImports.reverse();
|
||||
const report = planEvolution(before, after, { allowLegacy: true });
|
||||
assert.deepEqual(report.runtimeActions.map((entry) => entry.action), ["keep"]);
|
||||
assert.deepEqual(report.storageChanges, []);
|
||||
assert.deepEqual(report.packageChecks, []);
|
||||
});
|
||||
|
||||
test("consumer changes preserve unrelated resource owners", () => {
|
||||
const before = makeValidCapabilityWorkspace();
|
||||
before.packageImports.push({ packageId: id.package("package:resource-owner"), revisionId: id.packageRevision("package:resource-owner@1"),
|
||||
displayName: "ResourceOwner", source: { repository: "https://example.org/owner.git", commit: "a".repeat(40) },
|
||||
exports: [{ kind: "function", id: id.packageExport("export:owner:ping"), displayName: "ping", inputType: valueType.unit, outputType: valueType.unit, dependencyPorts: [] }] });
|
||||
const after = structuredClone(before);
|
||||
after.packageImports[0]!.source.commit = "e".repeat(40);
|
||||
const actions = planEvolution(before, after, { allowLegacy: true }).runtimeActions;
|
||||
assert.equal(actions.find((entry) => entry.groupId === "package:resource-owner")!.action, "keep");
|
||||
assert.equal(actions.find((entry) => entry.groupId === "package:todo-runtime")!.action, "replace");
|
||||
});
|
||||
|
||||
test("storage defaults and ownership changes invalidate consumers without requiring a major", () => {
|
||||
const before = makeValidCapabilityWorkspace();
|
||||
const after = structuredClone(before);
|
||||
const slot = after.sharedAttachments[0]!;
|
||||
assert.equal(slot.kind, "state");
|
||||
if (slot.kind === "state") slot.defaultValue = "Different default";
|
||||
const report = planEvolution(before, after, { allowLegacy: true });
|
||||
assert.equal(report.storageChanges.length, 1);
|
||||
assert.equal(report.runtimeActions[0]!.action, "replace");
|
||||
assert.deepEqual(report.reviews, []);
|
||||
assert.notDeepEqual(storageContracts(before), storageContracts(after));
|
||||
});
|
||||
|
||||
test("semantic review receipts cover exact consumer/provider contracts", () => {
|
||||
const before = makeValidCapabilityWorkspace();
|
||||
before.conformances[0]!.id = id.conformance("conformance:project:named");
|
||||
const after = structuredClone(before);
|
||||
after.conformances[0]!.semanticMajor = 2;
|
||||
const report = planEvolution(before, after, { allowLegacy: true });
|
||||
assert.equal(report.reviews.length, 1);
|
||||
assert.equal(report.reviews[0]!.accepted, false);
|
||||
const receipt = { requirementDigest: report.reviews[0]!.requirementDigest, decision: "accepted-unchanged" as const,
|
||||
rationale: "Reviewed the semantic change against summary behavior", agentId: "workspace-agent" };
|
||||
assert.equal(planEvolution(before, after, { allowLegacy: true, reviews: [receipt] }).reviews[0]!.accepted, true);
|
||||
after.packageImports[0]!.source.commit = "f".repeat(40);
|
||||
assert.equal(planEvolution(before, after, { allowLegacy: true, reviews: [receipt] }).reviews[0]!.accepted, false);
|
||||
});
|
||||
|
||||
test("evolution enrollment is explicit and never erases legacy ownership", () => {
|
||||
const workspace = makeValidCapabilityWorkspace();
|
||||
const report = planEvolution(workspace, workspace);
|
||||
assert.ok(report.blockers.some((entry) => entry.includes("workspace-shared")));
|
||||
assert.ok(report.blockers.some((entry) => entry.includes("authored ID")));
|
||||
assert.equal(runtimeContracts(workspace).length, 1);
|
||||
assert.throws(() => planEvolution(workspace, { ...workspace, workspaceId: id.workspace("other") }), /different workspace/);
|
||||
});
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
external atom Thing id "atom:example:thing";
|
||||
|
||||
package TypedExample id "package:typed-example" revision "package:typed-example@1" {
|
||||
constructor createThing id "export:typed-example:create" constructs Thing : unit;
|
||||
operation payloadGet id "export:typed-example:payload" : unit -> bytes mode call receiver atom Thing requires {
|
||||
state payload id "port:typed-example:payload" : bytes [read];
|
||||
};
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
quixos-lock version 1 {
|
||||
quixos source {
|
||||
repository "https://example.test/quixos.git";
|
||||
commit "1111111111111111111111111111111111111111";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { contentDigest, validateMigrationCatalog, selectMigrationPath, type MigrationCatalog } from "../src/capability-model/index.js";
|
||||
const before = { fields: ["name"] }, after = { fields: ["first", "last"] };
|
||||
const from = contentDigest(before), to = contentDigest(after);
|
||||
const catalog = (): MigrationCatalog => ({ schemaVersion: 1, contracts: { [from]: before, [to]: after }, migrations: [{
|
||||
id: "split-name", scopeId: "person-name", from, to,
|
||||
implementation: { exportId: "migrate-name", file: "src/migrate-name.ts", digest: contentDigest("implementation") }, predecessors: [],
|
||||
ports: [{ name: "source", view: "old", access: ["read"], contractDigest: from }, { name: "target", view: "new", access: ["write"], contractDigest: to }],
|
||||
}] });
|
||||
test("published migrations retain exact old contracts and explicit local bindings", () => {
|
||||
const value = validateMigrationCatalog(catalog(), new Set(["migrate-name"]));
|
||||
const selection = { scopeId: "person-name", from, to, path: ["split-name"], bindings: { source: "old-slot", target: "new-slot" } };
|
||||
const result = selectMigrationPath(value, selection);
|
||||
assert.equal(result[0].alreadyApplied, false);
|
||||
assert.equal(selectMigrationPath(value, selection, new Map([["split-name", result[0].digest]]))[0].alreadyApplied, true);
|
||||
const changed = catalog(); changed.migrations[0].implementation.digest = contentDigest("new code");
|
||||
assert.throws(() => selectMigrationPath(changed, selection, new Map([["split-name", result[0].digest]])), /different code/);
|
||||
assert.throws(() => selectMigrationPath(value, { ...selection, path: [] }), /does not cover/);
|
||||
assert.throws(() => selectMigrationPath(value, { ...selection, bindings: {} }), /Missing local/);
|
||||
});
|
||||
test("old migration views cannot be writable and contract hashes are verified", () => {
|
||||
const value = catalog(); value.migrations[0].ports[0].access = ["write"];
|
||||
assert.throws(() => validateMigrationCatalog(value), /read-only/);
|
||||
const corrupt = catalog(); corrupt.contracts[from] = {};
|
||||
assert.throws(() => validateMigrationCatalog(corrupt), /digest mismatch/);
|
||||
});
|
||||
|
||||
test("migration history rejects cycles and non-boolean compatibility promises", () => {
|
||||
const cyclic = catalog();
|
||||
cyclic.migrations[0].predecessors = ["split-name"];
|
||||
assert.throws(() => validateMigrationCatalog(cyclic), /Cyclic/);
|
||||
const misleading = catalog();
|
||||
(misleading.migrations[0] as unknown as {preservesOldReaders: string}).preservesOldReaders = "false";
|
||||
assert.throws(() => validateMigrationCatalog(misleading), /must be booleans/);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {promisify} from "node:util";
|
||||
import {execFile as callback} from "node:child_process";
|
||||
import {planPinUpgrades, applyPinUpgrades, type UpgradeEffects} from "../src/capability-language/pin-upgrades.js";
|
||||
const execFile = promisify(callback);
|
||||
|
||||
test("pin upgrades publish children before parent locks and resume without republishing completed nodes", async (context) => {
|
||||
const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-upgrade-test-"));
|
||||
context.after(() => fs.rm(workbench, {recursive: true, force: true}));
|
||||
const from = "a".repeat(40), to = "b".repeat(40), framework = "c".repeat(40);
|
||||
const sources = [{kind: "workspace" as const, directory: "root", source: {repository: "https://example.test/workspace.git", commit: from}},
|
||||
{kind: "interface" as const, directory: "resources/Named", source: {repository: "https://example.test/named.git", commit: from}}];
|
||||
const lock = `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${framework}"; }`;
|
||||
for (const node of sources) {
|
||||
const directory = path.join(workbench, node.directory);
|
||||
await fs.mkdir(directory, {recursive: true});
|
||||
await execFile("git", ["-C", directory, "init"]);
|
||||
await execFile("git", ["-C", directory, "remote", "add", "origin", node.source.repository]);
|
||||
await fs.writeFile(path.join(directory, ".gitignore"), ".quixos/\n");
|
||||
await fs.writeFile(path.join(directory, "quixos.lock"), lock + (node.kind === "workspace" ? ` interface Named source { repository "${sources[1].source.repository}"; commit "${from}"; }` : "") + " }");
|
||||
}
|
||||
await fs.writeFile(path.join(workbench, "resources/Named/interface.qx"), 'interface Named id "interface:named" revision "interface:named@1" { value name id "member:name" : string { get id "op:get"; } }');
|
||||
await fs.writeFile(path.join(workbench, "root/workspace.qx"), `workspace W id "workspace:w" revision "workspace:w@1" commit "${from}" { import interface Named; atom A id "atom:a"; }`);
|
||||
const snapshotMap = path.join(workbench, "snapshots.json");
|
||||
await fs.writeFile(snapshotMap, JSON.stringify({resources: [{kind: "interface", repository: sources[1].source.repository, commit: to, directory: path.join(workbench, "resources/Named")}]}));
|
||||
const oldMap = process.env.QUIXOS_SNAPSHOT_MAP;
|
||||
process.env.QUIXOS_SNAPSHOT_MAP = snapshotMap;
|
||||
context.after(() => {if (oldMap === undefined) delete process.env.QUIXOS_SNAPSHOT_MAP; else process.env.QUIXOS_SNAPSHOT_MAP = oldMap;});
|
||||
const plan = await planPinUpgrades(workbench, {nodes: sources});
|
||||
await fs.mkdir(path.join(workbench, ".quixos"), {recursive: true});
|
||||
await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [{...sources[1], source: {resolver: "git", ...sources[1].source}}]}));
|
||||
assert.deepEqual(plan.nodes.map((node) => node.directory), ["resources/Named", "root"]);
|
||||
let fail = true;
|
||||
const published: string[] = [];
|
||||
const effects: UpgradeEffects = {
|
||||
check: async (node) => {if (node.kind === "workspace" && fail) throw new Error("refactor required");},
|
||||
snapshot: async () => to,
|
||||
publish: async (root) => {published.push(path.relative(workbench, root));},
|
||||
};
|
||||
await assert.rejects(() => applyPinUpgrades(plan, undefined, effects), /refactor required/);
|
||||
assert.deepEqual(published, ["resources/Named"]);
|
||||
assert.match(await fs.readFile(path.join(workbench, "root/quixos.lock"), "utf8"), new RegExp(to));
|
||||
const id = (await fs.readdir(path.join(workbench, ".quixos/upgrades"))).find((name) => name.endsWith(".json"))!.slice(0, -5);
|
||||
fail = false;
|
||||
await fs.appendFile(path.join(workbench, "root/workspace.qx"), "\n// explicit refactor\n");
|
||||
await assert.rejects(() => applyPinUpgrades(plan, id, effects), /accept-edits/);
|
||||
const result = await applyPinUpgrades(plan, id, effects, {acceptEdits: true});
|
||||
assert.equal(result.activated, false);
|
||||
assert.deepEqual(published, ["resources/Named", "root"]);
|
||||
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
||||
assert.equal(graph.resources.length, 1);
|
||||
assert.equal(graph.resources[0].source.commit, to);
|
||||
const next = await planPinUpgrades(workbench, {nodes: [sources[0], {...sources[1], source: graph.resources[0].source}]});
|
||||
assert.deepEqual(next.nodes.find((node) => node.kind === "workspace")!.dependencies, ["resources/Named"]);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { mkdtemp, writeFile, readFile, rm, symlink } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { parseQx, formatQx, lintQx, applySourceEdits, addWorkspaceImport, walkSyntax,
|
||||
resolveQxSources, readQxSource, compileCapabilitySource, scaffoldAtom, compileWorkspaceRepository } from "../src/capability-language/index.js";
|
||||
|
||||
const workspace = `workspace Test id "w" revision "w@1" commit "${"1".repeat(40)}" {\n// keep 🐈 comment\n}\n`;
|
||||
test("lint reports invalid and redundant imports without resolving repositories", () => {
|
||||
const source = workspace.replace("}\n", 'import "a.qx"; import "a.qx"; import "../bad.qx";\n}\n');
|
||||
assert.deepEqual(lintQx(source).map((entry) => [entry.code, entry.severity]), [
|
||||
["duplicate-source-import", "warning"], ["invalid-source-import", "error"],
|
||||
]);
|
||||
});
|
||||
test("lossless tokens, UTF-16 ranges, and safe source edits", () => {
|
||||
const text = workspace.replace("}\n", 'atom Cat id "🐈";\n}\n');
|
||||
const parsed = parseQx(text);
|
||||
assert.deepEqual(parsed.diagnostics, []);
|
||||
assert.equal(parsed.tokens.map((token) => token.text).join(""), text);
|
||||
const atom = [...walkSyntax(parsed.root)].find((node) => node.kind === "atomDecl")!;
|
||||
assert.equal(text.slice(atom.start, atom.end), 'atom Cat id "🐈";');
|
||||
assert.equal(applySourceEdits(text, [{ ...atom, text: 'atom Dog id "dog";' }]), text.replace('atom Cat id "🐈";', 'atom Dog id "dog";'));
|
||||
assert.throws(() => applySourceEdits(text, [{ start: 0, end: 4, text: "" }, { start: 3, end: 7, text: "" }]), /overlapping/);
|
||||
assert.throws(() => applySourceEdits(text, [{ start: -1, end: 0, text: "" }]), /Invalid/);
|
||||
});
|
||||
test("formatting preserves comments and strings, is idempotent, and rejects invalid source", () => {
|
||||
const text = workspace.replace("}\n", 'atom Cat id "{ cat }"; /* multiline\n unchanged */\n}\n');
|
||||
const formatted = formatQx(text);
|
||||
assert.match(formatted, / atom Cat id "\{ cat \}"; \/\* multiline\n unchanged \*\//);
|
||||
assert.equal(formatQx(formatted), formatted);
|
||||
assert.deepEqual(parseQx(formatted).diagnostics, []);
|
||||
assert.throws(() => formatQx("workspace {"), /syntax errors/);
|
||||
});
|
||||
test("imports preserve root text, deduplicate diamonds, reject cycles, and compile together", async () => {
|
||||
const root = addWorkspaceImport(addWorkspaceImport(workspace, "a.qx"), "b.qx");
|
||||
assert.equal(addWorkspaceImport(root, "a.qx"), root);
|
||||
assert.match(root, /keep 🐈 comment/);
|
||||
const files: Record<string, string> = { "workspace.qx": root,
|
||||
"a.qx": 'fragment { import "shared.qx"; atom A id "a"; }',
|
||||
"b.qx": 'fragment { import "shared.qx"; atom B id "b"; }',
|
||||
"shared.qx": 'fragment { atom Shared id "shared"; }' };
|
||||
const result = await resolveQxSources(async (name) => files[name]!);
|
||||
assert.equal(result.sourceFiles.length, 4);
|
||||
const compiled = compileCapabilitySource(result.source);
|
||||
assert.equal(compiled.ok, true, JSON.stringify(compiled.diagnostics));
|
||||
if (compiled.ok) assert.equal(compiled.workspace.atoms.length, 3);
|
||||
const unresolved = compileCapabilitySource(root);
|
||||
assert.equal(unresolved.ok, false);
|
||||
assert.match(JSON.stringify(unresolved.diagnostics), /unresolved-source-import/);
|
||||
files["shared.qx"] = 'fragment { import "a.qx"; }';
|
||||
await assert.rejects(resolveQxSources(async (name) => files[name]!), /cycle/);
|
||||
assert.throws(() => addWorkspaceImport(workspace, "../x.qx"), /Invalid/);
|
||||
});
|
||||
test("repository scaffolding validates before writing and refuses duplicates and symlinks", async (t) => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "qx-scaffold-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await writeFile(path.join(root, "workspace.qx"), workspace);
|
||||
await writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/q.git"; commit "${"1".repeat(40)}"; } }`);
|
||||
const resolveResource = async (): Promise<never> => { throw new Error("unexpected resource"); };
|
||||
const options = { root, name: "Cat", id: "cat", write: false, resolveResource };
|
||||
await scaffoldAtom(options);
|
||||
assert.equal(await readFile(path.join(root, "workspace.qx"), "utf8"), workspace);
|
||||
await assert.rejects(readFile(path.join(root, "Cat.qx")), /ENOENT/);
|
||||
await scaffoldAtom({ ...options, write: true });
|
||||
const compiled = await compileWorkspaceRepository({ rootDirectory: root, resolveResource });
|
||||
assert.equal(compiled.workspace.atoms[0]!.id, "cat");
|
||||
await assert.rejects(scaffoldAtom({ ...options, write: true }), /already exists/);
|
||||
await assert.rejects(scaffoldAtom({ ...options, name: "Other", write: true }), /Duplicate|duplicate/);
|
||||
await assert.rejects(readFile(path.join(root, "Other.qx")), /ENOENT/);
|
||||
await symlink(path.join(root, "Cat.qx"), path.join(root, "link.qx"));
|
||||
await assert.rejects(readQxSource(root, "link.qx"), /ordinary files/);
|
||||
});
|
||||
test("lowering diagnostics map to the imported file", async () => {
|
||||
const files: Record<string, string> = { "workspace.qx": addWorkspaceImport(workspace, "bad.qx"),
|
||||
"bad.qx": 'fragment {\n conform Missing as Nope {}\n}' };
|
||||
const sources = await resolveQxSources(async (name) => files[name]!);
|
||||
const compiled = compileCapabilitySource(sources.source);
|
||||
assert.equal(compiled.ok, false);
|
||||
const diagnostic = compiled.diagnostics[0]!;
|
||||
const position = sources.originalPosition(diagnostic.line, diagnostic.column);
|
||||
assert.equal(position.fileName, "bad.qx");
|
||||
assert.equal(position.line, 2);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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 {scaffoldRecipe} from "../src/capability-language/scaffold-recipes.js";
|
||||
import {planStructure, applyStructure} from "../src/capability-language/structural-plan.js";
|
||||
import {contentDigest} from "../src/capability-model/evolution.js";
|
||||
const execFile = promisify(callback);
|
||||
|
||||
test("package/function/migration scaffolds register implementations and refresh code digests without overwriting code", async (context) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-recipes-"));
|
||||
context.after(() => fs.rm(root, {recursive: true, force: true}));
|
||||
await execFile("git", ["-C", root, "init"]);
|
||||
await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n");
|
||||
const source = {repository: "https://example.test/chess.git", commit: "a".repeat(40)};
|
||||
const base = {source, directory: "packages/Chess"};
|
||||
const apply = async (command: Parameters<typeof scaffoldRecipe>[1], input: Parameters<typeof scaffoldRecipe>[2]) => applyStructure(await planStructure(root, await scaffoldRecipe(root, command, input)));
|
||||
await apply("package", {...base, name: "Chess", id: "package:chess", revision: "package:chess@1", tools: {quixos: source, protocol: source, helpers: source, sdk: source}});
|
||||
await apply("function", {...base, name: "play", id: "export:play"});
|
||||
const filename = path.join(root, base.directory, "src/impl/play.ts");
|
||||
const edited = 'import type {Implementation} from "../generated-bindings.js";\nexport const handler: Implementation["play"] = async () => null;\n';
|
||||
await fs.writeFile(filename, edited);
|
||||
const old = {version: 1}, next = {version: 2}, from = contentDigest(old), to = contentDigest(next);
|
||||
await apply("migration", {...base, name: "upgrade", id: "export:upgrade", migration: {id: "upgrade-v2", scopeId: "board", from, to, predecessors: [], ports: [], contracts: {[from]: old, [to]: next}}});
|
||||
const migrationFile = path.join(root, base.directory, "src/migrations/upgrade.ts");
|
||||
await fs.appendFile(migrationFile, "\n// authored migration change\n");
|
||||
await apply("refresh", base);
|
||||
assert.equal(await fs.readFile(filename, "utf8"), edited);
|
||||
const catalog = JSON.parse(await fs.readFile(path.join(root, base.directory, "quixos.migrations.json"), "utf8"));
|
||||
assert.equal(catalog.migrations[0].implementation.digest, contentDigest(await fs.readFile(migrationFile, "utf8")));
|
||||
const bindings = await fs.readFile(path.join(root, base.directory, "src/generated-bindings.ts"), "utf8");
|
||||
assert.match(bindings, /export:play/);
|
||||
assert.match(await fs.readFile(path.join(root, base.directory, "src/migrate.ts"), "utf8"), /export:upgrade/);
|
||||
if (process.env.QX_SCAFFOLD_TEST_SDK) {
|
||||
const sdk = await fs.realpath(process.env.QX_SCAFFOLD_TEST_SDK);
|
||||
const packageRoot = path.join(root, base.directory);
|
||||
await fs.mkdir(path.join(packageRoot, "node_modules/@quixos"), {recursive: true});
|
||||
await fs.symlink(sdk, path.join(packageRoot, "node_modules/@quixos/camino-package-runtime"));
|
||||
await fs.symlink(path.join(sdk, "node_modules/@types"), path.join(packageRoot, "node_modules/@types"));
|
||||
await execFile(path.join(sdk, "node_modules/.bin/tsc"), ["--noEmit"], {cwd: packageRoot});
|
||||
await execFile("nix-instantiate", ["--parse", path.join(packageRoot, "flake.nix")]);
|
||||
}
|
||||
await assert.rejects(() => apply("function", {...base, name: "play", id: "export:play"}), /unique/);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { editStructure, scaffoldResourceSource } from "../src/capability-language/structural-edits.js";
|
||||
|
||||
test("package scaffolding and function edits preserve surrounding source and use exact selectors", () => {
|
||||
const source = `// 🧭 resource comment\n${scaffoldResourceSource("package", "Chess", "package:chess", "package:chess@1")}`;
|
||||
const functionSource = 'function evaluate id "export:evaluate" : string -> string;';
|
||||
const appended = editStructure(source, {operation: "append", parent: {kind: "packageResourceDecl", id: "package:chess"}, source: functionSource});
|
||||
assert.ok(appended.startsWith("// 🧭 resource comment\n"));
|
||||
assert.ok(appended.includes(functionSource));
|
||||
const replaced = editStructure(appended, {operation: "replace", target: {kind: "packageFunctionExport", id: "export:evaluate"}, source: 'function evaluate id "export:evaluate" : unit -> string;'});
|
||||
assert.ok(replaced.includes(": unit -> string;"));
|
||||
assert.ok(!editStructure(replaced, {operation: "remove", target: {kind: "packageFunctionExport", id: "export:evaluate"}}).includes("evaluate"));
|
||||
assert.throws(() => editStructure(source, {operation: "remove", target: {kind: "packageResourceDecl", id: "package:chess@1"}}), /exactly once/);
|
||||
assert.throws(() => editStructure(source, {operation: "append", parent: {kind: "packageResourceDecl"}, source: "not valid QX"}), /Invalid structural/);
|
||||
});
|
||||
|
||||
test("dependency scaffolding validates exact sources and preserves unrelated lock comments", () => {
|
||||
const source = `// 🧭 lock\nquixos-lock version 1 {\n quixos source { repository "https://example.test/quixos.git"; commit "${"a".repeat(40)}"; }\n // retained comment\n}\n`;
|
||||
const dependency = {operation: "dependency" as const, kind: "package" as const, name: "Chess", source: {repository: "https://example.test/chess.git", commit: "b".repeat(40)}};
|
||||
const appended = editStructure(source, dependency);
|
||||
assert.ok(appended.includes("// retained comment"));
|
||||
assert.ok(appended.startsWith("// 🧭 lock\n"));
|
||||
assert.throws(() => editStructure(source, {...dependency, source: {...dependency.source, commit: "main"}}), /Invalid dependency/);
|
||||
const changed = editStructure(appended, {...dependency, source: {...dependency.source, commit: "c".repeat(40)}});
|
||||
assert.ok(!changed.includes("b".repeat(40)));
|
||||
assert.ok(!editStructure(changed, {...dependency, source: null}).includes("package Chess"));
|
||||
});
|
||||
|
||||
test("conformance enrollment, major edits, imports, and private attachment removal preserve valid structure", () => {
|
||||
const source = `fragment { conform Game as Playable { private state Board id "slot:board" on Game : string policy optimistic-register; } }`;
|
||||
const selector = {kind: "conformanceDecl", names: ["Game", "Playable"]};
|
||||
const enrolled = editStructure(source, {operation: "conformance-id", target: selector, id: "conformance:playable"});
|
||||
assert.ok(enrolled.includes('as Playable id "conformance:playable"'));
|
||||
const major = editStructure(enrolled, {operation: "semantic-major", target: {kind: "conformanceDecl", id: "conformance:playable"}, major: 2});
|
||||
assert.ok(major.includes("semantic-major 2"));
|
||||
assert.throws(() => editStructure(major, {operation: "conformance-id", target: selector, id: "different"}), /Cannot change/);
|
||||
const removed = editStructure(major, {operation: "remove", target: {kind: "stateDecl", id: "slot:board"}});
|
||||
assert.ok(!removed.includes("private"));
|
||||
const imported = editStructure(removed, {operation: "import", kind: "interface", name: "Playable"});
|
||||
assert.equal(editStructure(imported, {operation: "import", kind: "interface", name: "Playable"}), imported);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { execFile as callback } from "node:child_process";
|
||||
import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "../src/capability-language/structural-plan.js";
|
||||
const execFile = promisify(callback);
|
||||
|
||||
test("structural plans validate the graph, journal originals, and reject stale edits", async (context) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-structural-test-"));
|
||||
context.after(() => fs.rm(root, {recursive: true, force: true}));
|
||||
await execFile("git", ["-C", root, "init"]);
|
||||
await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n");
|
||||
await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${"a".repeat(40)}"; } }`);
|
||||
const before = `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"b".repeat(40)}" { atom Subject id "atom:subject"; }`;
|
||||
await fs.writeFile(path.join(root, "workspace.qx"), before);
|
||||
const request: StructuralRequest = {kind: "workspace", files: [{file: "workspace.qx", edits: [{operation: "append", parent: {kind: "workspaceDecl"}, source: 'atom Game id "atom:game";'}]}]};
|
||||
const plan = await planStructure(root, request);
|
||||
assert.equal(await fs.readFile(path.join(root, "workspace.qx"), "utf8"), before);
|
||||
await fs.writeFile(path.join(root, "workspace.qx"), `${before}\n// newer edit`);
|
||||
await assert.rejects(() => applyStructure(plan), /Stale scaffold/);
|
||||
await fs.writeFile(path.join(root, "workspace.qx"), before);
|
||||
const applied = await applyStructure(plan);
|
||||
assert.equal(applied.phase, "complete");
|
||||
assert.ok((await fs.readFile(path.join(root, "workspace.qx"), "utf8")).includes("atom Game"));
|
||||
const journal = JSON.parse(await fs.readFile(applied.journalPath, "utf8"));
|
||||
assert.equal(journal.changes[0].before, before);
|
||||
assert.equal((await resumeStructure(root, applied.id)).phase, "complete");
|
||||
// Simulate a process interrupted after one file was written, before recording
|
||||
// completion. Replay accepts the already-written exact after image.
|
||||
journal.phase = "prepared";
|
||||
await fs.writeFile(applied.journalPath, JSON.stringify(journal));
|
||||
assert.equal((await resumeStructure(root, applied.id)).phase, "complete");
|
||||
await assert.rejects(() => planStructure(root, request), /Duplicate|duplicate/);
|
||||
});
|
||||
@@ -52,8 +52,10 @@ __metadata:
|
||||
typescript: "npm:^7.0.2"
|
||||
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-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
|
||||
languageName: unknown
|
||||
|
||||
Reference in New Issue
Block a user