Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 332b84f258 | |||
| 4982cb7e9f | |||
| 14b0ef25c3 | |||
| c16271510a | |||
| 8aac091f6c | |||
| 01ca965c7f | |||
| fae4e48f72 | |||
| 6ecf565549 | |||
| 73cdeadc2c | |||
| 16b28f1bc4 | |||
| dcef58d6c5 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
|
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
|
||||||
"sourceCommit": "e25eee6ce4f13702b2454a9354bc79a29eb2e4a1",
|
"sourceCommit": "fc13e9b8411c2210b12db5e558e2b4fa7785505e",
|
||||||
"sourcePath": "quixos-protocol",
|
"sourcePath": "quixos-protocol",
|
||||||
"exportName": "quixos-protocol",
|
"exportName": "quixos-protocol",
|
||||||
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
|
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
pkgs.esbuild
|
pkgs.esbuild
|
||||||
pkgs.protobuf
|
pkgs.protobuf
|
||||||
pkgs.git
|
pkgs.git
|
||||||
|
pkgs.jujutsu
|
||||||
|
pkgs.gnutar
|
||||||
|
pkgs.util-linux
|
||||||
];
|
];
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
@@ -29,6 +32,7 @@
|
|||||||
export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$PWD/proto''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}"
|
export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$PWD/proto''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}"
|
||||||
patchShebangs node_modules/.bin node_modules/@bufbuild/protoc-gen-es/bin
|
patchShebangs node_modules/.bin node_modules/@bufbuild/protoc-gen-es/bin
|
||||||
yarn build
|
yarn build
|
||||||
|
esbuild dist/src/bindings/client.js --bundle --platform=node --target=node24 --format=esm --outfile=client-codegen.mjs
|
||||||
diff --recursive --unified "$TMPDIR/generated-before/proto" src/gen
|
diff --recursive --unified "$TMPDIR/generated-before/proto" src/gen
|
||||||
diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated
|
diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated
|
||||||
diff --recursive --unified "$TMPDIR/generated-before/lock" src/resource-lock/generated
|
diff --recursive --unified "$TMPDIR/generated-before/lock" src/resource-lock/generated
|
||||||
@@ -60,6 +64,9 @@
|
|||||||
installPhase = ''
|
installPhase = ''
|
||||||
runHook preInstall
|
runHook preInstall
|
||||||
mkdir -p "$out"
|
mkdir -p "$out"
|
||||||
|
install -Dm644 nix/checked-package.nix "$out/share/checked-package.nix"
|
||||||
|
substitute nix/checked-candidate.nix "$out/share/checked-candidate.nix" \
|
||||||
|
--replace-fail '@nixpkgs@' '${pkgs.path}'
|
||||||
cp --reflink=auto --recursive grammar "$out/grammar"
|
cp --reflink=auto --recursive grammar "$out/grammar"
|
||||||
cp --reflink=auto --recursive proto "$out/proto"
|
cp --reflink=auto --recursive proto "$out/proto"
|
||||||
cp --reflink=auto --recursive dist "$out/dist"
|
cp --reflink=auto --recursive dist "$out/dist"
|
||||||
@@ -97,6 +104,7 @@ exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-lock-check.
|
|||||||
EOF
|
EOF
|
||||||
chmod +x "$out/bin/quixos-lock-check"
|
chmod +x "$out/bin/quixos-lock-check"
|
||||||
mkdir -p "$out/libexec/quixos-protocol"
|
mkdir -p "$out/libexec/quixos-protocol"
|
||||||
|
install -m644 client-codegen.mjs "$out/libexec/quixos-protocol/client-codegen.mjs"
|
||||||
install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/"
|
install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/"
|
||||||
install -m644 quixos-descriptor-check.mjs "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs"
|
install -m644 quixos-descriptor-check.mjs "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs"
|
||||||
install -m644 quixos-capability-compile.mjs "$out/libexec/quixos-protocol/quixos-capability-compile.mjs"
|
install -m644 quixos-capability-compile.mjs "$out/libexec/quixos-protocol/quixos-capability-compile.mjs"
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# All source trees and recursive locks are fetched by Nix at exact retained
|
||||||
|
# revisions. No authoring-directory overlay or externally assembled schema.
|
||||||
|
{ repository, commit, kind, generator, contractOnly ? false, system ? builtins.currentSystem }:
|
||||||
|
let
|
||||||
|
pkgs = import (/. + "@nixpkgs@") { inherit system; };
|
||||||
|
protocol = builtins.storePath generator;
|
||||||
|
sourceKey = source: "${source.kind}:${source.repository}@${source.commit}";
|
||||||
|
fetch = source: builtins.fetchGit {
|
||||||
|
url = source.repository;
|
||||||
|
rev = source.commit;
|
||||||
|
ref = "refs/tags/quixos-reachability/${source.commit}";
|
||||||
|
shallow = true;
|
||||||
|
};
|
||||||
|
load = ancestors: source:
|
||||||
|
if builtins.elem (sourceKey source) ancestors then
|
||||||
|
throw "Source dependency cycle at ${sourceKey source}"
|
||||||
|
else if builtins.length ancestors >= 100 then
|
||||||
|
throw "Source dependency depth exceeds 100"
|
||||||
|
else let
|
||||||
|
directory = fetch source;
|
||||||
|
lockFile = pkgs.runCommand "qx-source-lock.json" { } ''
|
||||||
|
${protocol}/bin/quixos-lock-check ${directory}/quixos.lock > "$out"
|
||||||
|
'';
|
||||||
|
lock = builtins.fromJSON (builtins.readFile lockFile);
|
||||||
|
children = map (entry: load (ancestors ++ [ (sourceKey source) ]) (entry.source // { inherit (entry) kind; })) lock.resources;
|
||||||
|
in source // { inherit directory children; };
|
||||||
|
root = load [ ] { inherit kind repository commit; };
|
||||||
|
flatten = node: [ node ] ++ pkgs.lib.concatMap flatten node.children;
|
||||||
|
nodes = builtins.attrValues (builtins.listToAttrs (map (node: {
|
||||||
|
name = sourceKey node; value = node;
|
||||||
|
}) (flatten root)));
|
||||||
|
snapshots = pkgs.writeText "qx-nix-source-graph.json" (builtins.toJSON {
|
||||||
|
resources = map (node: { inherit (node) kind repository commit directory; })
|
||||||
|
(builtins.filter (node: node.kind != "workspace") nodes);
|
||||||
|
});
|
||||||
|
compile = node: pkgs.runCommand "qx-${node.kind}-contract" { } ''
|
||||||
|
mkdir -p "$out"
|
||||||
|
${if node.kind == "workspace" then ''
|
||||||
|
${protocol}/bin/quixos-workspace-compile --root ${node.directory} \
|
||||||
|
--source-root-commit ${pkgs.lib.escapeShellArg node.commit} \
|
||||||
|
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} \
|
||||||
|
--graph-out "$out/graph.json" > "$out/candidate.json"
|
||||||
|
'' else ''
|
||||||
|
${protocol}/bin/quixos-resource-compile --root ${node.directory} \
|
||||||
|
--kind ${node.kind} --repository ${pkgs.lib.escapeShellArg node.repository} \
|
||||||
|
--commit ${pkgs.lib.escapeShellArg node.commit} \
|
||||||
|
--checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} --snapshot-only true \
|
||||||
|
--graph-out "$out/graph.json" --schema-out "$out/bindings.json" > "$out/candidate.json"
|
||||||
|
''}
|
||||||
|
'';
|
||||||
|
contract = compile root;
|
||||||
|
checkPackage = node: let
|
||||||
|
compiled = compile node;
|
||||||
|
candidate = builtins.fromJSON (builtins.readFile "${compiled}/candidate.json");
|
||||||
|
package = builtins.getFlake ("git+${node.repository}?rev=${node.commit}&ref=refs/tags/quixos-reachability/${node.commit}");
|
||||||
|
checked = package.quixosPackages.${system}.checkedServer or
|
||||||
|
(throw "Package ${node.repository} lacks checkedServer; use the supported package scaffold.");
|
||||||
|
artifact = checked {
|
||||||
|
schema = "${compiled}/bindings.json";
|
||||||
|
generator = protocol;
|
||||||
|
packageRevisionId = candidate.revision.revisionId;
|
||||||
|
};
|
||||||
|
in { packageRevisionId = candidate.revision.revisionId; artifactPath = artifact; };
|
||||||
|
checks = if contractOnly then [ ] else map checkPackage (builtins.filter (node: node.kind == "package") nodes);
|
||||||
|
manifest = pkgs.writeText "qx-candidate-checks.json" (builtins.toJSON checks);
|
||||||
|
in pkgs.runCommand (if contractOnly then "qx-contract" else "qx-checked-candidate") { } ''
|
||||||
|
mkdir -p "$out"
|
||||||
|
cp ${contract}/candidate.json "$out/candidate.json"
|
||||||
|
cp ${contract}/graph.json "$out/graph.json"
|
||||||
|
cp ${manifest} "$out/checks.json"
|
||||||
|
${pkgs.lib.optionalString (kind != "workspace") ''cp ${contract}/bindings.json "$out/bindings.json"''}
|
||||||
|
${pkgs.lib.optionalString (kind == "package") ''
|
||||||
|
${protocol}/bin/quixos-codegen-ts ${contract}/bindings.json \
|
||||||
|
${pkgs.lib.escapeShellArg (builtins.fromJSON (builtins.readFile "${contract}/candidate.json")).revision.revisionId} \
|
||||||
|
"$out/bindings.ts" ${pkgs.lib.optionalString (builtins.pathExists (root.directory + "/bindings.json")) (toString root.directory + "/bindings.json")}
|
||||||
|
''}
|
||||||
|
''
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# One derivation path for provisional checking and activation. The source and
|
||||||
|
# schema come from exact committed inputs resolved by the Quixos compiler.
|
||||||
|
{ source, schema, generator, packageRevisionId, system ? builtins.currentSystem }:
|
||||||
|
let
|
||||||
|
packageSource = builtins.path {
|
||||||
|
path = /. + source;
|
||||||
|
name = "quixos-package-source";
|
||||||
|
filter = path: _: let name = baseNameOf path; in name != ".git" && name != ".jj";
|
||||||
|
};
|
||||||
|
package = builtins.getFlake ("path:" + builtins.unsafeDiscardStringContext (toString packageSource));
|
||||||
|
checked = package.quixosPackages.${system}.checkedServer or
|
||||||
|
(throw "Package ${packageRevisionId} lacks checkedServer; use the supported package scaffold.");
|
||||||
|
in checked {
|
||||||
|
inherit packageRevisionId;
|
||||||
|
schema = builtins.path { path = /. + schema; name = "candidate-package-bindings.json"; };
|
||||||
|
generator = builtins.storePath generator;
|
||||||
|
}
|
||||||
+3
-2
@@ -5,11 +5,11 @@
|
|||||||
"packageManager": "yarn@4.18.0",
|
"packageManager": "yarn@4.18.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"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-capability-compile": "dist/src/capability-language/cli.js",
|
||||||
|
"quixos-codegen-ts": "dist/src/bindings/cli.js",
|
||||||
"quixos-descriptor-check": "dist/src/descriptor-check.js",
|
"quixos-descriptor-check": "dist/src/descriptor-check.js",
|
||||||
"quixos-lock-check": "dist/src/resource-lock/cli.js",
|
"quixos-lock-check": "dist/src/resource-lock/cli.js",
|
||||||
|
"quixos-qx": "dist/src/capability-language/tool-cli.js",
|
||||||
"quixos-resource-compile": "dist/src/capability-language/resource-cli.js",
|
"quixos-resource-compile": "dist/src/capability-language/resource-cli.js",
|
||||||
"quixos-workspace-compile": "dist/src/capability-language/workspace-cli.js"
|
"quixos-workspace-compile": "dist/src/capability-language/workspace-cli.js"
|
||||||
},
|
},
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"typecheck": "yarn generate && tsc --noEmit"
|
"typecheck": "yarn generate && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.28.0",
|
||||||
"@bufbuild/protobuf": "^2.12.1",
|
"@bufbuild/protobuf": "^2.12.1",
|
||||||
"@bufbuild/protoc-gen-es": "^2.12.1",
|
"@bufbuild/protoc-gen-es": "^2.12.1",
|
||||||
"antlr4ng": "^3.0.16"
|
"antlr4ng": "^3.0.16"
|
||||||
|
|||||||
@@ -72,6 +72,20 @@ message GetWorkspaceResponse {
|
|||||||
string workspace_id = 1;
|
string workspace_id = 1;
|
||||||
string workspace_revision_id = 2;
|
string workspace_revision_id = 2;
|
||||||
string source_root_commit = 3;
|
string source_root_commit = 3;
|
||||||
|
// Checked constructors whose wire input can be empty. Web Studio intersects
|
||||||
|
// this with its temporary Createable marker; the marker is not a factory.
|
||||||
|
repeated string empty_input_constructible_atom_ids = 4;
|
||||||
|
repeated CapabilityInputContract capability_inputs = 5;
|
||||||
|
repeated ConstructorInputContract constructor_inputs = 6;
|
||||||
|
}
|
||||||
|
message CapabilityInputContract {
|
||||||
|
string interface_revision_id = 1;
|
||||||
|
string operation_id = 2;
|
||||||
|
string type_json = 3;
|
||||||
|
}
|
||||||
|
message ConstructorInputContract {
|
||||||
|
string atom_id = 1;
|
||||||
|
string type_json = 2;
|
||||||
}
|
}
|
||||||
message ListActivationsRequest {}
|
message ListActivationsRequest {}
|
||||||
message ListPackageDescriptorsRequest {}
|
message ListPackageDescriptorsRequest {}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {parse} from "@babel/parser";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/** Enforce the authored bundled-code contract, not a security sandbox. */
|
||||||
|
export function bundlePolicyErrors(source: string, filename: string): string[] {
|
||||||
|
const ast = parse(source, {sourceType: "module", plugins: ["typescript", "jsx"]});
|
||||||
|
const errors: string[] = [];
|
||||||
|
const visit = (value: unknown) => {
|
||||||
|
if (Array.isArray(value)) {value.forEach(visit); return;}
|
||||||
|
if (!value || typeof value !== "object") return;
|
||||||
|
const n = value as Record<string, any>;
|
||||||
|
if (typeof n.type !== "string") return;
|
||||||
|
const fail = (message: string) => errors.push(`${filename}:${n.loc?.start.line ?? 1}: ${message}`);
|
||||||
|
if (n.type === "MetaProperty" && n.meta.name === "import") fail("Bundled package code cannot use import.meta; import packaged assets statically");
|
||||||
|
if (n.type === "Identifier" && ["__dirname", "__filename"].includes(n.name)) fail("Bundled package code cannot depend on module filesystem locations");
|
||||||
|
if (["CallExpression", "NewExpression"].includes(n.type)) {
|
||||||
|
if (n.callee?.type === "Identifier" && ["eval", "Function"].includes(n.callee.name)) fail("Dynamic code generation is unsupported in bundled package code");
|
||||||
|
if ((n.callee?.type === "Import" || (n.callee?.type === "Identifier" && n.callee.name === "require")) &&
|
||||||
|
(n.arguments.length !== 1 || n.arguments[0].type !== "StringLiteral")) fail("Module imports must have a static string specifier");
|
||||||
|
}
|
||||||
|
if (n.type === "ImportExpression" && n.source.type !== "StringLiteral") fail("Module imports must have a static string specifier");
|
||||||
|
Object.values(n).forEach(visit);
|
||||||
|
};
|
||||||
|
visit(ast);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
export async function checkBundleSources(directory: string): Promise<void> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
async function walk(current: string) {
|
||||||
|
for (const entry of await fs.readdir(current, {withFileTypes: true})) {
|
||||||
|
if (["gen", "node_modules"].includes(entry.name)) continue;
|
||||||
|
const file = path.join(current, entry.name);
|
||||||
|
if (entry.isSymbolicLink()) throw new Error(`Authored source symlinks are unsupported: ${file}`);
|
||||||
|
if (entry.isDirectory()) await walk(file);
|
||||||
|
else if (/\.[cm]?[jt]sx?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) errors.push(...bundlePolicyErrors(await fs.readFile(file, "utf8"), file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await walk(directory);
|
||||||
|
if (errors.length) throw new Error(errors.join("\n"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type {InterfaceRevision, ValueType} from "../capability-model/types.js";
|
||||||
|
|
||||||
|
/** Host clients have no package receiver, but must use the same checked
|
||||||
|
* interface signatures and argument framing as generated package ports. */
|
||||||
|
export const generateClientContracts = (interfaces: InterfaceRevision[], messages: Record<string, string>) => {
|
||||||
|
const type = (value: ValueType): string => {
|
||||||
|
switch (value.kind) {
|
||||||
|
case "builtin": return value.name === "unit" ? "undefined" : "string";
|
||||||
|
case "scalar": return ({bool: "boolean", string: "string", bytes: "Uint8Array", int32: "number", uint32: "number", double: "number", int64: "bigint", uint64: "bigint"})[value.name];
|
||||||
|
case "object-ref": return `{readonly $quixosRef: string}`;
|
||||||
|
case "optional": return `(${type(value.value)} | null)`;
|
||||||
|
case "list": return `Array<${type(value.value)}>`;
|
||||||
|
case "record": return `{${Object.entries(value.fields).map(([name, field]) => `${JSON.stringify(name)}${field.kind === "optional" ? "?" : ""}: ${type(field)}`).join("; ")}}`;
|
||||||
|
case "message": {
|
||||||
|
const binding = messages[value.descriptorId];
|
||||||
|
if (!binding) throw new Error(`Missing host message type ${value.descriptorId}`);
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const operations = interfaces.flatMap(iface => iface.members.flatMap(member => member.operations
|
||||||
|
.filter(operation => operation.mode === "call").map(operation => ({...operation, interfaceRevisionId: iface.revisionId}))));
|
||||||
|
return `// Generated from checked QX interfaces. Regenerate with scripts/generate-platform-contracts.mjs.\n` +
|
||||||
|
`export type PlatformInputs = {\n${operations.map(operation => ` ${JSON.stringify(operation.id)}: ${type(operation.inputType)};`).join("\n")}\n};\n` +
|
||||||
|
`export const platformOperations = ${JSON.stringify(Object.fromEntries(operations.map(operation => [operation.id, {
|
||||||
|
interfaceRevisionId: operation.interfaceRevisionId,
|
||||||
|
input: operation.inputType.kind === "builtin" && operation.inputType.name === "unit" ? "unit" : ["record", "message"].includes(operation.inputType.kind) ? "fields" : "value",
|
||||||
|
}])), null, 2)} as const;\n`;
|
||||||
|
};
|
||||||
@@ -18,6 +18,12 @@ export type TypeScriptBindingOptions = {
|
|||||||
/** Each export must implement MessageBinding<T>, providing both TS type and wire codec. */
|
/** Each export must implement MessageBinding<T>, providing both TS type and wire codec. */
|
||||||
messages?: Record<string, { module: string; export: string }>;
|
messages?: Record<string, { module: string; export: string }>;
|
||||||
};
|
};
|
||||||
|
export function generatePackageDescriptor(schema: BindingSchema, revisionId: string): string {
|
||||||
|
const pkg = schema.packages.find(entry => entry.revisionId === revisionId);
|
||||||
|
if (!pkg) throw new Error(`Unknown package revision ${revisionId}`);
|
||||||
|
return `# Generated from the checked package contract\npackage_id: ${JSON.stringify(pkg.packageId)}\npackage_revision_id: ${JSON.stringify(pkg.revisionId)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` +
|
||||||
|
pkg.exports.map(entry => `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.displayName)} }\n`).join("");
|
||||||
|
}
|
||||||
const q = JSON.stringify;
|
const q = JSON.stringify;
|
||||||
const object = (entries: [string, string][]) => `{ ${entries.map(([key, value]) => `${q(key)}: ${value}`).join("; ")} }`;
|
const object = (entries: [string, string][]) => `{ ${entries.map(([key, value]) => `${q(key)}: ${value}`).join("; ")} }`;
|
||||||
const unit = (type: ValueType) => type.kind === "builtin" && type.name === "unit";
|
const unit = (type: ValueType) => type.kind === "builtin" && type.name === "unit";
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ export const compileWorkspaceRepository = async (options: {
|
|||||||
: {}),
|
: {}),
|
||||||
...(options.workspaceRevisionId
|
...(options.workspaceRevisionId
|
||||||
? { id: capabilityId.workspaceRevision(options.workspaceRevisionId) }
|
? { id: capabilityId.workspaceRevision(options.workspaceRevisionId) }
|
||||||
: {}),
|
: options.sourceRootCommit ? { id: capabilityId.workspaceRevision(`workspace-revision:${options.workspaceId ?? compiled.workspace.workspaceId}:${options.sourceRootCommit}`) } : {}),
|
||||||
...(options.sourceRootCommit
|
...(options.sourceRootCommit
|
||||||
? { sourceRootCommit: options.sourceRootCommit }
|
? { sourceRootCommit: options.sourceRootCommit }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { authoringContext } from "./authoring-context.js";
|
||||||
|
import type { convergeAuthoring } from "./authoring-converge.js";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { buildImmutableCandidate, checkerIdentity } from "./checked-build.js";
|
||||||
|
import { planEvolution, type WorkspaceRevision, type EvolutionReview } from "../capability-model/index.js";
|
||||||
|
|
||||||
|
export const checkRecordName = (directory: string) => createHash("sha256").update(directory).digest("hex") + ".json";
|
||||||
|
export async function checkAuthoring(start: string, output: string, options: { baseline?: string; reviews?: string; contractOnly?: boolean } = {}) {
|
||||||
|
const started = performance.now();
|
||||||
|
const timings: Record<string, number> = {};
|
||||||
|
const context = await authoringContext(start);
|
||||||
|
const location = await fs.realpath(start);
|
||||||
|
const directory = location === context.workbench ? "root" : path.relative(context.workbench, location);
|
||||||
|
const resource = context.resources.find(entry => entry.directory === directory);
|
||||||
|
if (!resource) throw new Error("Run check from a registered repository root or the workbench");
|
||||||
|
await fs.mkdir(output, { mode: 0o700 });
|
||||||
|
const report: { directory: string; checker: string; candidateOnly: true; activationEvidence: false; commit?: string; artifactPath?: string; blockers: string[]; phase: string; output: string; compilation?: "passed"; activationReadiness?: "preserve" | "migration-required" | "blocked"; migrationRequired?: string[] } = {
|
||||||
|
directory, checker: checkerIdentity(), candidateOnly: true, activationEvidence: false, blockers: [], phase: "convergence", output,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
// Serialize only source capture, not the potentially slow Nix build.
|
||||||
|
// Repository-scoped agents can check separate immutable candidates in parallel.
|
||||||
|
const captured = await promisify(callback)("quixos-qx", ["converge", context.workbench, directory], {
|
||||||
|
maxBuffer: 4 * 1024 * 1024, env: {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"},
|
||||||
|
}).catch(error => {
|
||||||
|
if (typeof error.stdout === "string" && error.stdout.trim().startsWith("{")) return {stdout: error.stdout};
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const converged = JSON.parse(captured.stdout) as Awaited<ReturnType<typeof convergeAuthoring>>;
|
||||||
|
timings.captureMs = Math.round(performance.now() - started);
|
||||||
|
if (!converged.candidate) {
|
||||||
|
report.phase = converged.worklist.find(entry => entry.phase !== "dependency")?.phase ?? "convergence";
|
||||||
|
throw new Error(converged.worklist.map(entry => `${entry.directory} [${entry.phase}]: ${entry.message}`).join("\n"));
|
||||||
|
}
|
||||||
|
report.commit = converged.candidate.commit;
|
||||||
|
report.phase = "verification";
|
||||||
|
const buildStarted = performance.now();
|
||||||
|
report.artifactPath = await buildImmutableCandidate(converged.candidate, resource.kind, path.join(output, "nix.log"), options.contractOnly);
|
||||||
|
timings.immutableCheckMs = Math.round(performance.now() - buildStarted);
|
||||||
|
const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8");
|
||||||
|
await fs.writeFile(path.join(output, "candidate.json"), candidateText);
|
||||||
|
report.compilation = "passed";
|
||||||
|
if (resource.kind === "workspace" && !options.contractOnly) {
|
||||||
|
report.phase = "evolution";
|
||||||
|
let baseline = options.baseline;
|
||||||
|
if (!baseline) {
|
||||||
|
try {
|
||||||
|
const host = JSON.parse(await fs.readFile("/etc/quixos/workspace-source.json", "utf8"));
|
||||||
|
if (await fs.realpath(host.workbenchRoot) === context.workbench) baseline = JSON.parse(await fs.readFile(path.join(host.runtimeClosureRoot, "manifest.json"), "utf8")).workspacePlanPath;
|
||||||
|
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||||
|
}
|
||||||
|
const before = baseline ? JSON.parse(await fs.readFile(baseline, "utf8")) as WorkspaceRevision : null;
|
||||||
|
const reviews = options.reviews ? JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[] : [];
|
||||||
|
const evolution = planEvolution(before, JSON.parse(candidateText), { reviews });
|
||||||
|
await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2));
|
||||||
|
report.blockers.push(...evolution.blockers);
|
||||||
|
report.migrationRequired = evolution.migrationRequired;
|
||||||
|
report.activationReadiness = evolution.blockers.length ? "blocked" : evolution.migrationRequired.length ? "migration-required" : "preserve";
|
||||||
|
if (evolution.migrationRequired.length) report.blockers.push(`Explicit migration required for: ${evolution.migrationRequired.join(", ")}. Compilation passed; supply a migration path before cutover.`);
|
||||||
|
}
|
||||||
|
if (!report.blockers.length) report.phase = options.contractOnly ? "contract-only" : "checked";
|
||||||
|
} catch (error) { report.blockers.push(String(error instanceof Error ? error.message : error)); }
|
||||||
|
timings.totalMs = Math.round(performance.now() - started);
|
||||||
|
Object.assign(report, {timings});
|
||||||
|
await fs.writeFile(path.join(output, "report.json"), JSON.stringify(report, null, 2));
|
||||||
|
if (options.contractOnly) return report;
|
||||||
|
const records = path.join(context.workbench, ".quixos/checks");
|
||||||
|
await fs.mkdir(records, { recursive: true });
|
||||||
|
const remember = async (value: typeof report) => {
|
||||||
|
const filename = path.join(records, checkRecordName(value.directory)), temporary = `${filename}.${randomUUID()}.tmp`;
|
||||||
|
await fs.writeFile(temporary, JSON.stringify(value, null, 2), { flag: "wx", mode: 0o600 });
|
||||||
|
await fs.rename(temporary, filename);
|
||||||
|
};
|
||||||
|
if (report.artifactPath) {
|
||||||
|
const graph = JSON.parse(await fs.readFile(path.join(report.artifactPath, "graph.json"), "utf8"));
|
||||||
|
for (const checked of graph.resources) {
|
||||||
|
const managed = context.resources.find(entry => entry.kind === checked.kind && entry.source?.repository === checked.source.repository);
|
||||||
|
if (managed && managed.directory !== directory) await remember({...report, directory: managed.directory, commit: checked.source.commit, blockers: [], phase: "checked"});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await remember(report);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { readFile, realpath } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { loadQuixosLock, type GitSource } from "../resource-lock/index.js";
|
||||||
|
|
||||||
|
export type AuthoringResource = {
|
||||||
|
kind: "workspace" | "interface" | "package";
|
||||||
|
directory: string;
|
||||||
|
resourceId?: string;
|
||||||
|
source?: GitSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Registration, not directory co-location, defines the editable selection. */
|
||||||
|
export async function authoringContext(start: string) {
|
||||||
|
let workbench = await realpath(start);
|
||||||
|
for (;;) {
|
||||||
|
let text: string | undefined;
|
||||||
|
try { text = await readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"); }
|
||||||
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||||
|
if (text !== undefined) {
|
||||||
|
const graph = JSON.parse(text) as { resources: AuthoringResource[] };
|
||||||
|
if (!Array.isArray(graph.resources)) throw new Error("Managed resource inventory is malformed");
|
||||||
|
const resources: AuthoringResource[] = [{ kind: "workspace", directory: "root" }];
|
||||||
|
const identities = new Set<string>(), directories = new Set<string>(["root"]);
|
||||||
|
for (const entry of graph.resources) {
|
||||||
|
// Compiler graphs carry resolved paths; the authoring API presents
|
||||||
|
// stable workbench-relative names and validates containment here.
|
||||||
|
if (typeof entry.directory === "string" && path.isAbsolute(entry.directory)) entry.directory = path.relative(workbench, entry.directory);
|
||||||
|
if (!["interface", "package"].includes(entry.kind) || !entry.source ||
|
||||||
|
!/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(entry.directory)) {
|
||||||
|
throw new Error("Invalid managed resource registration");
|
||||||
|
}
|
||||||
|
const identity = `${entry.kind}\0${entry.resourceId ?? entry.source.repository}`;
|
||||||
|
if (identities.has(identity) || directories.has(entry.directory)) {
|
||||||
|
throw new Error(`Multiple editable selections for ${entry.resourceId ?? entry.source.repository}`);
|
||||||
|
}
|
||||||
|
identities.add(identity); directories.add(entry.directory);
|
||||||
|
resources.push({kind: entry.kind, directory: entry.directory, resourceId: entry.resourceId, source: entry.source});
|
||||||
|
}
|
||||||
|
return { workbench, resources, async baseline() {
|
||||||
|
const result = await loadQuixosLock(path.join(workbench, "root/quixos.lock"));
|
||||||
|
if (!result.ok) throw new Error(`Workspace source baseline is invalid: ${result.diagnostics.map(d => d.message).join("; ")}`);
|
||||||
|
return result.lock.quixos;
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
const parent = path.dirname(workbench);
|
||||||
|
if (parent === workbench) throw new Error("Not in a managed workbench; select one with --workbench DIRECTORY");
|
||||||
|
workbench = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { readFile, writeFile, rename, rm, realpath } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { authoringContext } from "./authoring-context.js";
|
||||||
|
import { snapshotCommit } from "./checked-build.js";
|
||||||
|
import { loadQuixosLock, parseQuixosLockDocument, formatQuixosLockDocument, retentionTagForCommit, type GitSource } from "../resource-lock/index.js";
|
||||||
|
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
const command = async (cwd: string, executable: string, args: string[]) => (await execFile(executable, args, {
|
||||||
|
cwd, maxBuffer: 4 * 1024 * 1024,
|
||||||
|
env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0", GIT_TERMINAL_PROMPT: "0" },
|
||||||
|
})).stdout.trim();
|
||||||
|
const identity = (kind: string, repository: string) => `${kind}\0${repository}`;
|
||||||
|
|
||||||
|
export type AuthoringBlocker = { directory: string; phase: "resolution" | "dependency" | "source" | "publication" | "concurrent-edit"; message: string };
|
||||||
|
|
||||||
|
/** Source retention only. Neither successful convergence nor an empty source
|
||||||
|
* worklist grants typechecking, semantic review or activation approval.
|
||||||
|
* Caller serializes coordinators; package authors may still be editing. */
|
||||||
|
export async function convergeAuthoring(start: string, target = "root") {
|
||||||
|
const context = await authoringContext(start);
|
||||||
|
const graphFile = path.join(context.workbench, ".quixos/resource-graph.json");
|
||||||
|
const graphBefore = await readFile(graphFile, "utf8");
|
||||||
|
const blockers: AuthoringBlocker[] = [];
|
||||||
|
const nodes = new Map<string, { directory: string; kind: string; source: GitSource; dependencies: string[] }>();
|
||||||
|
const selected = new Map<string, string>();
|
||||||
|
for (const entry of context.resources) {
|
||||||
|
const root = path.join(context.workbench, entry.directory);
|
||||||
|
let repository = entry.source?.repository;
|
||||||
|
try {
|
||||||
|
if (await realpath(root) !== root) throw new Error(`Managed checkout crosses a symlink: ${entry.directory}`);
|
||||||
|
// Transport rewrites must not become committed source identities.
|
||||||
|
const origin = await command(root, "git", ["config", "--get", "remote.origin.url"]);
|
||||||
|
if (repository && origin !== repository) throw new Error(`Origin differs from registered source for ${entry.directory}`);
|
||||||
|
repository ??= origin;
|
||||||
|
} catch (error) {
|
||||||
|
blockers.push({directory: entry.directory, phase: "source", message: String(error).slice(0, 2000)});
|
||||||
|
if (!repository) throw error; // The root has no separate registered source.
|
||||||
|
}
|
||||||
|
const key = identity(entry.kind, repository);
|
||||||
|
if (selected.has(key)) throw new Error(`More than one editable checkout for ${repository}`);
|
||||||
|
selected.set(key, entry.directory);
|
||||||
|
nodes.set(entry.directory, { ...entry, source: { resolver: "git", repository, commit: entry.source?.commit ?? "" }, dependencies: [] });
|
||||||
|
}
|
||||||
|
for (const node of nodes.values()) {
|
||||||
|
try {
|
||||||
|
const lock = await loadQuixosLock(path.join(context.workbench, node.directory, "quixos.lock"));
|
||||||
|
if (!lock.ok) throw new Error(lock.diagnostics.map(d => `${d.fileName}: ${d.message}`).join("\n"));
|
||||||
|
node.dependencies = [...new Set(lock.lock.resources.flatMap(entry => {
|
||||||
|
const directory = selected.get(identity(entry.kind, entry.source.repository));
|
||||||
|
return directory ? [directory] : [];
|
||||||
|
}))];
|
||||||
|
} catch (error) { blockers.push({ directory: node.directory, phase: "resolution", message: String(error) }); }
|
||||||
|
}
|
||||||
|
const complete = new Map<string, GitSource>(), active = new Set<string>();
|
||||||
|
const visited = new Set<string>();
|
||||||
|
if (!nodes.has(target)) throw new Error(`Not a registered repository: ${target}`);
|
||||||
|
const visit = async (directory: string): Promise<boolean> => {
|
||||||
|
visited.add(directory);
|
||||||
|
if (complete.has(directory)) return true;
|
||||||
|
if (blockers.some(entry => entry.directory === directory)) return false;
|
||||||
|
if (active.has(directory)) { blockers.push({ directory, phase: "dependency", message: `Source dependency cycle: ${[...active, directory].join(" -> ")}` }); return false; }
|
||||||
|
active.add(directory);
|
||||||
|
const node = nodes.get(directory)!;
|
||||||
|
for (const dependency of node.dependencies) if (!await visit(dependency)) {
|
||||||
|
blockers.push({ directory, phase: "dependency", message: `Waiting for ${dependency}` }); active.delete(directory); return false;
|
||||||
|
}
|
||||||
|
const root = path.join(context.workbench, directory);
|
||||||
|
let phase: AuthoringBlocker["phase"] = "source";
|
||||||
|
try {
|
||||||
|
const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
|
||||||
|
if (!lock.ok) throw new Error("Lock changed during convergence; retry after joining writers");
|
||||||
|
for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) {
|
||||||
|
const filename = path.join(root, file), before = await readFile(filename, "utf8");
|
||||||
|
const parsed = parseQuixosLockDocument(before, file);
|
||||||
|
if (!parsed.ok) throw new Error(`Invalid lock ${file}`);
|
||||||
|
let changed = false;
|
||||||
|
for (const dependency of parsed.document.resources) {
|
||||||
|
const target = selected.get(identity(dependency.kind, dependency.source.repository));
|
||||||
|
const source = target ? complete.get(target) : undefined;
|
||||||
|
if (target && !source) throw new Error(`Dependencies changed during convergence (${dependency.binding}); join writers and retry`);
|
||||||
|
if (source && source.commit !== dependency.source.commit) { dependency.source = source; changed = true; }
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
const temporary = `${filename}.${randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
await writeFile(temporary, formatQuixosLockDocument(parsed.document), { flag: "wx" });
|
||||||
|
if (await readFile(filename, "utf8") !== before) throw new Error(`Concurrent edit to ${file}; retry after joining writers`);
|
||||||
|
await rename(temporary, filename);
|
||||||
|
} finally { await rm(temporary, { force: true }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const commit = await snapshotCommit(root);
|
||||||
|
phase = "publication";
|
||||||
|
const ref = retentionTagForCommit(commit);
|
||||||
|
const remote = await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref]);
|
||||||
|
if (remote && remote.split(/\s+/)[0] !== commit) throw new Error(`Conflicting immutable retention ref ${ref}`);
|
||||||
|
if (!remote) await command(root, "git", ["push", node.source.repository, `${commit}:${ref}`]);
|
||||||
|
if ((await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref])).split(/\s+/)[0] !== commit) throw new Error("Published source retention was not observed");
|
||||||
|
complete.set(directory, { ...node.source, commit });
|
||||||
|
} catch (error) { blockers.push({ directory, phase, message: String(error).slice(0, 4000) }); }
|
||||||
|
active.delete(directory);
|
||||||
|
return complete.has(directory);
|
||||||
|
};
|
||||||
|
// Include newly created, not-yet-imported resources, then the root.
|
||||||
|
if (target === "root") for (const directory of [...nodes.keys()].filter(d => d !== "root")) await visit(directory);
|
||||||
|
await visit(target);
|
||||||
|
for (let index = blockers.length - 1; index >= 0; index--) if (!visited.has(blockers[index].directory)) blockers.splice(index, 1);
|
||||||
|
for (const [directory, source] of complete) {
|
||||||
|
try { if (await snapshotCommit(path.join(context.workbench, directory)) !== source.commit) throw new Error("Source advanced while converging; join writers and retry"); }
|
||||||
|
catch (error) { blockers.push({ directory, phase: "concurrent-edit", message: String(error) }); }
|
||||||
|
}
|
||||||
|
// Persist successful selections even if another repository is still broken.
|
||||||
|
// Recovery must not depend on all parents succeeding in the same invocation.
|
||||||
|
const graph = JSON.parse(graphBefore);
|
||||||
|
for (const resource of graph.resources) resource.directory = path.relative(context.workbench, path.resolve(context.workbench, resource.directory));
|
||||||
|
const replacements = new Map<string, string>();
|
||||||
|
for (const resource of graph.resources) {
|
||||||
|
const source = complete.get(resource.directory);
|
||||||
|
if (!source) continue;
|
||||||
|
const key = `${resource.kind}\0${source.repository}\0${source.commit}`;
|
||||||
|
replacements.set(resource.key, key);
|
||||||
|
if (resource.source.commit !== source.commit) delete resource.revisionId;
|
||||||
|
resource.source = source; resource.key = key;
|
||||||
|
}
|
||||||
|
for (const resource of graph.resources) for (const dependency of resource.dependencies ?? []) {
|
||||||
|
dependency.resourceKey = replacements.get(dependency.resourceKey) ?? dependency.resourceKey;
|
||||||
|
}
|
||||||
|
for (const direct of graph.directResources ?? []) direct.resourceKey = replacements.get(direct.resourceKey) ?? direct.resourceKey;
|
||||||
|
// Inventory is a projection of actual locks, including newly added/removed
|
||||||
|
// imports. Never require a successful parent compilation to repair it.
|
||||||
|
for (const [directory] of complete) {
|
||||||
|
const lock = await loadQuixosLock(path.join(context.workbench, directory, "quixos.lock"));
|
||||||
|
if (!lock.ok) continue;
|
||||||
|
const dependencies = lock.lock.resources.map(dependency => ({
|
||||||
|
binding: `${dependency.kind}\0${dependency.binding}`,
|
||||||
|
resourceKey: `${dependency.kind}\0${dependency.source.repository}\0${dependency.source.commit}`,
|
||||||
|
}));
|
||||||
|
if (directory === "root") {
|
||||||
|
graph.quixos = lock.lock.quixos;
|
||||||
|
graph.directResources = lock.lock.resources.map((dependency, index) => ({
|
||||||
|
kind: dependency.kind, binding: dependency.binding, resourceKey: dependencies[index].resourceKey,
|
||||||
|
...(selected.has(identity(dependency.kind, dependency.source.repository))
|
||||||
|
? {directory: selected.get(identity(dependency.kind, dependency.source.repository))} : {}),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const resource = graph.resources.find((entry: {directory: string}) => entry.directory === directory);
|
||||||
|
if (resource) resource.dependencies = dependencies;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const graphAfter = JSON.stringify(graph, null, 2) + "\n";
|
||||||
|
if (graphBefore !== graphAfter) {
|
||||||
|
const temporary = `${graphFile}.${randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
await writeFile(temporary, graphAfter, { flag: "wx", mode: 0o600 });
|
||||||
|
if (await readFile(graphFile, "utf8") !== graphBefore) throw new Error("Managed inventory changed during convergence; source is retained, retry after joining writers");
|
||||||
|
await rename(temporary, graphFile);
|
||||||
|
} finally { await rm(temporary, { force: true }); }
|
||||||
|
}
|
||||||
|
return { workbench: context.workbench, converged: blockers.length === 0,
|
||||||
|
candidate: blockers.length ? null : complete.get(target) ?? null,
|
||||||
|
retained: [...complete].map(([directory, source]) => ({ directory, source })),
|
||||||
|
worklist: blockers, verificationEvidence: false, activated: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { realpath } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { parseQx, walkSyntax } from "./source.js";
|
||||||
|
import { authoringContext } from "./authoring-context.js";
|
||||||
|
import { readQxSource } from "./source-loader.js";
|
||||||
|
import {loadQuixosLock} from "../resource-lock/index.js";
|
||||||
|
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
const git = async (root: string, args: string[]) => (await execFile("git", ["-C", root, ...args], {
|
||||||
|
maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
||||||
|
})).stdout;
|
||||||
|
const message = (error: unknown) => String(error instanceof Error ? error.message : error).slice(0, 2000);
|
||||||
|
|
||||||
|
/** Syntax-only contract inspection is deliberately NOT verification evidence.
|
||||||
|
* Each file can recover independently; current valid files always win. */
|
||||||
|
export async function inspectAuthoringRepository(root: string, historyLimit = 100) {
|
||||||
|
const names = (await git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]))
|
||||||
|
.split("\0").filter(name => name.endsWith(".qx"));
|
||||||
|
if (names.length > 128) throw new Error("Repository inspection exceeds 128 QX files; split the resource into smaller repositories");
|
||||||
|
const files = [];
|
||||||
|
for (const name of [...new Set(names)].sort()) {
|
||||||
|
let source = "", errors: unknown[] = [], revision: string | null = null;
|
||||||
|
try {
|
||||||
|
source = await readQxSource(root, name);
|
||||||
|
if (source.length > 262144) throw new Error(`Inspection file exceeds 256 KiB: ${name}`);
|
||||||
|
errors = parseQx(source, name).diagnostics;
|
||||||
|
} catch (error) { errors = [{ message: message(error) }]; }
|
||||||
|
const currentErrors = errors;
|
||||||
|
if (errors.length) {
|
||||||
|
// Git can traverse jj's immutable commit DAG without mutating/snapshotting @.
|
||||||
|
const head = await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"],
|
||||||
|
{ cwd: root, env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1" } }).then(r => r.stdout.trim(), () => "HEAD");
|
||||||
|
const commits = await git(root, ["rev-list", `--max-count=${historyLimit}`, head, "--", name]).catch(() => "");
|
||||||
|
for (const commit of commits.trim().split("\n").filter(Boolean)) {
|
||||||
|
const historical = await git(root, ["show", `${commit}:${name}`]).catch(() => null);
|
||||||
|
if (historical === null || historical.length > 262144) continue;
|
||||||
|
if (!parseQx(historical, name).diagnostics.length) {
|
||||||
|
source = historical; revision = commit; errors = []; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const syntax = errors.length ? null : parseQx(source, name);
|
||||||
|
files.push({ file: name, status: errors.length ? "unavailable" : revision ? "historical" : "current",
|
||||||
|
revision, currentErrors: currentErrors.slice(0, 20), omittedErrors: Math.max(0, currentErrors.length - 20),
|
||||||
|
declarations: syntax ? [...walkSyntax(syntax.root)]
|
||||||
|
.filter(node => /^(?:interface|package|atom|state|edge|method|function|event|conformance)\w*Decl$/.test(node.kind))
|
||||||
|
.map(node => ({ kind: node.kind, source: source.slice(node.start, node.end) })) : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { verificationEvidence: false as const, resolutionChecked: false as const, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inspectWorkbench(start: string, selector?: string) {
|
||||||
|
const context = await authoringContext(start);
|
||||||
|
if (!selector || selector === ".") {
|
||||||
|
const relative = path.relative(context.workbench, await realpath(start));
|
||||||
|
selector = context.resources.find(entry => relative === entry.directory || relative.startsWith(entry.directory + path.sep))?.directory ?? "root";
|
||||||
|
}
|
||||||
|
const lock = await loadQuixosLock(path.join(context.workbench, "root/quixos.lock"));
|
||||||
|
const aliases = lock.ok ? lock.lock.resources.filter(entry => entry.binding === selector) : [];
|
||||||
|
const selected = context.resources.filter(entry => !selector || selector === entry.directory ||
|
||||||
|
selector === entry.resourceId || selector === path.basename(entry.directory) || aliases.some(alias => alias.kind === entry.kind && alias.source.repository === entry.source?.repository));
|
||||||
|
if (!selected.length) throw new Error(`No registered resource matches ${selector}`);
|
||||||
|
if (selector && selected.length > 1) throw new Error(`Ambiguous resource ${selector}; use its resource ID or directory`);
|
||||||
|
const resources = [];
|
||||||
|
for (const entry of selected) {
|
||||||
|
try {
|
||||||
|
const root = path.join(context.workbench, entry.directory);
|
||||||
|
if (await realpath(root) !== root) throw new Error("Managed checkout crosses a symlink");
|
||||||
|
resources.push({ ...entry, ...await inspectAuthoringRepository(root) });
|
||||||
|
} catch (error) { resources.push({ ...entry, error: message(error) }); }
|
||||||
|
}
|
||||||
|
return { workbench: context.workbench, verificationEvidence: false, resources };
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { authoringContext } from "./authoring-context.js";
|
||||||
|
import { inspectAuthoringRepository } from "./authoring-inspect.js";
|
||||||
|
import { checkRecordName } from "./authoring-check.js";
|
||||||
|
import { loadQuixosLock } from "../resource-lock/index.js";
|
||||||
|
import { checkerIdentity } from "./checked-build.js";
|
||||||
|
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
export async function authoringWorklist(start: string) {
|
||||||
|
const context = await authoringContext(start);
|
||||||
|
const entries: {directory: string; resourceId?: string; phase: string; message: string; next: string}[] = [];
|
||||||
|
const dependencies = new Map<string, string[]>();
|
||||||
|
for (const resource of context.resources) {
|
||||||
|
const root = path.join(context.workbench, resource.directory);
|
||||||
|
const add = (phase: string, message: string) => entries.push({directory: resource.directory, resourceId: resource.resourceId, phase, message,
|
||||||
|
next: phase === "syntax" ? `qx-workspace inspect ${resource.directory}` : phase === "evolution" ? "Inspect evolution.json in the check output; resolve its named migration/review requirements before cutover" : `cd ${resource.directory} && qx-workspace check`});
|
||||||
|
try {
|
||||||
|
if (await fs.realpath(root) !== root) throw new Error("Registered checkout crosses a symlink");
|
||||||
|
const inspected = await inspectAuthoringRepository(root);
|
||||||
|
for (const file of inspected.files) if (file.currentErrors.length) add("syntax", `${file.file}: ${JSON.stringify(file.currentErrors)}${file.status === "historical" ? `; historical contract available at ${file.revision}` : ""}`);
|
||||||
|
const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
|
||||||
|
if (!lock.ok) add("resolution", lock.diagnostics.map(entry => `${entry.fileName}: ${entry.message}`).join("\n"));
|
||||||
|
else dependencies.set(resource.directory, lock.lock.resources.flatMap(dependency => {
|
||||||
|
const selected = context.resources.find(entry => entry.kind === dependency.kind && entry.source?.repository === dependency.source.repository);
|
||||||
|
if (selected?.source && selected.source.commit !== dependency.source.commit) add("propagation", `Dependency ${dependency.binding} has advanced; check will repin it automatically`);
|
||||||
|
return selected ? [selected.directory] : [];
|
||||||
|
}));
|
||||||
|
let record;
|
||||||
|
try { record = JSON.parse(await fs.readFile(path.join(context.workbench, ".quixos/checks", checkRecordName(resource.directory)), "utf8")); }
|
||||||
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||||
|
if (!record?.commit) {
|
||||||
|
if (record?.blockers?.length) add(record.phase, record.blockers.join("\n"));
|
||||||
|
else add("unchecked", "No immutable candidate check recorded yet");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (record.checker !== checkerIdentity()) add("unchecked", "The installed checker changed since the last check");
|
||||||
|
const env = {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"};
|
||||||
|
const commit = (await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], {cwd: root, env})).stdout.trim();
|
||||||
|
const dirty = await execFile("git", ["diff", "--quiet", "--no-ext-diff", record.commit, "--"], {cwd: root}).then(() => false, () => true);
|
||||||
|
const untracked = (await execFile("git", ["ls-files", "--others", "--exclude-standard"], {cwd: root})).stdout;
|
||||||
|
if (commit !== record.commit || dirty || untracked) add("unchecked", `Edits are newer than the last check (${record.commit.slice(0, 12)})`);
|
||||||
|
else if (record.blockers?.length) add(record.phase, record.blockers.join("\n"));
|
||||||
|
} catch (error) { add("inspection", String(error).slice(0, 3000)); }
|
||||||
|
}
|
||||||
|
// Fixed-point propagation, independent of registration order.
|
||||||
|
const blocked = new Set(entries.map(entry => entry.directory));
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const [directory, required] of dependencies) if (!blocked.has(directory)) {
|
||||||
|
const waiting = required.filter(dependency => blocked.has(dependency));
|
||||||
|
if (waiting.length) { blocked.add(directory); changed = true; entries.push({directory, phase: "dependency", message: `Waiting for ${waiting.join(", ")}`, next: "Resolve the named repositories, then rerun check"}); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { workbench: context.workbench, verificationEvidence: false, worklist: entries,
|
||||||
|
note: "Derived authoring guidance, not activation approval. Independent repositories can be delegated separately; join writers before a root check." };
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import { createHash } from "node:crypto";
|
|||||||
import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js";
|
import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js";
|
||||||
import { createGitCapabilityResolver } from "./git-resolver.js";
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
import { contentDigest, planEvolution, type EvolutionReview, type WorkspaceRevision } from "../capability-model/index.js";
|
import { contentDigest, planEvolution, type EvolutionReview, type WorkspaceRevision } from "../capability-model/index.js";
|
||||||
import { bindingSchema, generateTypeScriptBindings, type BindingSchema, type TypeScriptBindingOptions } from "../bindings/index.js";
|
import { bindingSchema } from "../bindings/index.js";
|
||||||
|
import {snapshotCommit, checkoutCommit, buildCheckedPackage} from "./checked-build.js";
|
||||||
const execFile = promisify(execFileCallback);
|
const execFile = promisify(execFileCallback);
|
||||||
const bytesDigest = (value: Uint8Array) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
const bytesDigest = (value: Uint8Array) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||||
|
|
||||||
@@ -64,116 +65,87 @@ export const snapshotRepository = async (source: string, destination: string) =>
|
|||||||
return { source: root, directory: destination, treeDigest: contentDigest(contents), files: contents };
|
return { source: root, directory: destination, treeDigest: contentDigest(contents), files: contents };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Local mirrors accelerate resolution, but only their committed locked trees
|
||||||
|
// may stand in for published dependencies. Never relabel dirty files as a pin.
|
||||||
|
async function committedResolver(root: string, temporary: string, filename?: string, publishedOnly = false) {
|
||||||
|
const map = publishedOnly ? {resources: []} : await localResourceSnapshots(root, filename);
|
||||||
|
const resources = [];
|
||||||
|
for (const [index, entry] of map.resources.entries()) {
|
||||||
|
const directory = path.join(temporary, `dependency-${index}`);
|
||||||
|
await checkoutCommit(entry.directory, entry.commit, directory);
|
||||||
|
resources.push({...entry, directory});
|
||||||
|
}
|
||||||
|
const snapshotMap = path.join(temporary, "snapshots.json");
|
||||||
|
await fs.writeFile(snapshotMap, JSON.stringify({resources}));
|
||||||
|
return createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resolved"), snapshotMap});
|
||||||
|
}
|
||||||
|
|
||||||
export const checkResourceCandidate = async (options: {root: string; output: string; kind: "package" | "interface"; source: {repository: string; commit: string}; snapshotMap?: string; publishedOnly?: boolean}) => {
|
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});
|
await fs.mkdir(options.output, {mode: 0o700});
|
||||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-resource-check-"));
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-resource-check-"));
|
||||||
const blockers: string[] = [];
|
const blockers: string[] = [];
|
||||||
let diagnostics = "";
|
let treeDigest: string | undefined, commit: string | undefined, artifactPath: string | undefined;
|
||||||
let treeDigest: string | undefined;
|
|
||||||
try {
|
try {
|
||||||
const root = await snapshotRepository(options.root, path.join(temporary, "root"));
|
commit = await snapshotCommit(options.root);
|
||||||
treeDigest = root.treeDigest;
|
treeDigest = (await snapshotRepository(options.root, path.join(temporary, "observed"))).treeDigest;
|
||||||
const map = options.publishedOnly ? {resources: []} : await localResourceSnapshots(options.root, options.snapshotMap);
|
const root = path.join(temporary, "source");
|
||||||
const resources = [];
|
await checkoutCommit(options.root, commit, root);
|
||||||
for (const [index, entry] of map.resources.entries()) {
|
const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap, options.publishedOnly);
|
||||||
const snapshot = await snapshotRepository(entry.directory, path.join(temporary, `dependency-${index}`));
|
const compiled = await compileCapabilityResourceRepository({rootDirectory: root, kind: options.kind, source: {resolver: "git", repository: options.source.repository, commit}, resolveResource});
|
||||||
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") {
|
if (compiled.resource.kind === "package") {
|
||||||
const configuration = JSON.parse(await fs.readFile(path.join(root.directory, "quixos.check.json"), "utf8"));
|
const schema = path.join(temporary, "bindings.json");
|
||||||
const output = configuration.bindingOutput as string;
|
await fs.writeFile(schema, JSON.stringify(bindingSchema(compiled)));
|
||||||
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");
|
artifactPath = await buildCheckedPackage(root, schema, compiled.resource.revision.revisionId);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
if (await snapshotCommit(options.root) !== commit) throw new Error("Source changed during verification; run the check again");
|
||||||
await fs.writeFile(path.join(options.output, "candidate.json"), JSON.stringify(compiled.resource, null, 2));
|
await fs.writeFile(path.join(options.output, "candidate.json"), JSON.stringify(compiled.resource, null, 2));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
blockers.push(error instanceof Error ? error.message : String(error));
|
blockers.push(error instanceof Error ? error.message : String(error));
|
||||||
diagnostics += (error as {stdout?: string; stderr?: string}).stdout ?? "";
|
|
||||||
diagnostics += (error as {stderr?: string}).stderr ?? "";
|
|
||||||
} finally {await fs.rm(temporary, {recursive: true, force: true});}
|
} finally {await fs.rm(temporary, {recursive: true, force: true});}
|
||||||
const result = {candidateOnly: true, activationEvidence: false, treeDigest, blockers, diagnostics};
|
const result = {candidateOnly: true, activationEvidence: false, commit, treeDigest, artifactPath, blockers,
|
||||||
|
note: "Checked immutable candidate; cutover independently checks current migration/review requirements. No publication or activation performed."};
|
||||||
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
|
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const checkWorkspaceCandidate = async (options: { root: string; output: string; snapshotMap?: string; baseline?: string; reviews?: string }) => {
|
export const checkWorkspaceCandidate = async (options: {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});
|
||||||
await fs.mkdir(options.output, { mode: 0o700 });
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-workspace-check-"));
|
||||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "quixos-candidate-"));
|
const blockers: string[] = [], checks: {packageRevisionId: string; artifactPath: string}[] = [];
|
||||||
const blockers: string[] = [];
|
let commit: string | undefined;
|
||||||
const checks: unknown[] = [];
|
|
||||||
const snapshots = [];
|
|
||||||
try {
|
try {
|
||||||
const root = await snapshotRepository(options.root, path.join(temporary, "root"));
|
commit = await snapshotCommit(options.root);
|
||||||
snapshots.push(root);
|
for (const entry of (await localResourceSnapshots(options.root, options.snapshotMap)).resources) {
|
||||||
const map = await localResourceSnapshots(options.root, options.snapshotMap);
|
const current = await snapshotCommit(entry.directory);
|
||||||
const resources = [];
|
const tree = async (revision: string) => (await execFile("git", ["rev-parse", `${revision}^{tree}`], {cwd: entry.directory})).stdout.trim();
|
||||||
for (const [index, entry] of map.resources.entries()) {
|
if (await tree(current) !== await tree(entry.commit)) throw new Error(`Edited resource is not in the root's locked candidate: ${entry.directory}. Check that resource, then run qx-workspace resource upgrade --publish to propagate its revision.`);
|
||||||
const 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");
|
const root = path.join(temporary, "source");
|
||||||
await fs.writeFile(mapFile, JSON.stringify({ resources }));
|
await checkoutCommit(options.root, commit, root);
|
||||||
const resolveResource = await createGitCapabilityResolver({ checkoutRoot: path.join(temporary, "resolved"), snapshotMap: mapFile });
|
const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap);
|
||||||
const compiled = await compileWorkspaceRepository({ rootDirectory: root.directory, resolveResource });
|
const compiled = await compileWorkspaceRepository({rootDirectory: root, sourceRootCommit: commit, resolveResource});
|
||||||
const baseline = options.baseline ? JSON.parse(await fs.readFile(options.baseline, "utf8")) as WorkspaceRevision : null;
|
const 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 reviews = options.reviews ? JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[] : [];
|
||||||
const evolution = planEvolution(baseline, compiled.workspace, { reviews });
|
const evolution = planEvolution(baseline, compiled.workspace, {reviews});
|
||||||
blockers.push(...evolution.blockers);
|
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")) {
|
||||||
for (const resource of compiled.resources.filter((entry) => entry.kind === "package")) {
|
// Per-package recursive schema, identical to host activation, not unrelated
|
||||||
if (resource.resource.kind !== "package") continue;
|
// workspace declarations that would unnecessarily invalidate build caches.
|
||||||
const revision = resource.resource.revision;
|
const candidate = await compileCapabilityResourceRepository({rootDirectory: resource.directory, kind: "package", source: resource.source, resolveResource});
|
||||||
let config: { backend: string; bindingOutput: string; options?: TypeScriptBindingOptions };
|
const schema = path.join(temporary, "bindings.json");
|
||||||
try { config = JSON.parse(await fs.readFile(path.join(resource.directory, "quixos.check.json"), "utf8")); }
|
await fs.writeFile(schema, JSON.stringify(bindingSchema(candidate)));
|
||||||
catch { blockers.push(`No candidate checker configured for ${revision.revisionId} (quixos.check.json)`); continue; }
|
const artifactPath = await buildCheckedPackage(resource.directory, schema, candidate.resource.revision.revisionId);
|
||||||
if (config.backend !== "typescript" || !/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.ts$/.test(config.bindingOutput)
|
checks.push({packageRevisionId: candidate.resource.revision.revisionId, artifactPath});
|
||||||
|| 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,
|
if (await snapshotCommit(options.root) !== commit) throw new Error("Source changed during verification; run the check again");
|
||||||
sourceDigest: contentDigest(snapshots.map(({source, treeDigest}) => ({source, treeDigest}))),
|
const result = {schemaVersion: 1, candidateOnly: true, activationEvidence: false, commit, evolution, checks, blockers,
|
||||||
snapshots: snapshots.map(({source, treeDigest}) => ({source, treeDigest})), evolution, checks, blockers,
|
note: "Checks the committed root and its exact locked dependencies. Resource edits must be verified and repinned before they enter this candidate. No publication or activation performed."};
|
||||||
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, "candidate.json"), JSON.stringify(compiled.workspace, null, 2));
|
||||||
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
|
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const result = { schemaVersion: 1, candidateOnly: true, activationEvidence: false, checks, blockers: [...blockers, error instanceof Error ? error.message : String(error)] };
|
const result = {schemaVersion: 1, candidateOnly: true, activationEvidence: false, commit, checks, blockers: [...blockers, error instanceof Error ? error.message : String(error)]};
|
||||||
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
|
await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2));
|
||||||
return result;
|
return result;
|
||||||
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
} finally {await fs.rm(temporary, {recursive: true, force: true});}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {execFile as callback, spawn} from "node:child_process";
|
||||||
|
import { createWriteStream } from "node:fs";
|
||||||
|
import {promisify} from "node:util";
|
||||||
|
import {fileURLToPath} from "node:url";
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
const environment = () => ({...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", GIT_TERMINAL_PROMPT: "0"});
|
||||||
|
export const checkerIdentity = () => process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
|
||||||
|
/** Checking snapshots jj, but never publishes or activates the working copy. */
|
||||||
|
export async function snapshotCommit(root: string): Promise<string> {
|
||||||
|
const run = async (...args: string[]) => (await execFile("jj", args, {cwd: root, env: environment()})).stdout.trim();
|
||||||
|
await run("status");
|
||||||
|
// jj resolve --list exits 1 on a clean revision. Query structured revision
|
||||||
|
// metadata instead of depending on diagnostic wording or swallowing errors.
|
||||||
|
if (await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "conflict") !== "false") throw new Error("Resolve source conflicts before verification");
|
||||||
|
const commit = await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id");
|
||||||
|
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Verification requires an exact jj commit");
|
||||||
|
await execFile("git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"], {cwd: root, env: environment()});
|
||||||
|
const {stdout} = await execFile("git", ["ls-files", "--others", "--exclude-standard", "-z"], {cwd: root});
|
||||||
|
if (stdout) throw new Error("Source contains files not captured by jj; inspect jj tracking before verification");
|
||||||
|
return commit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Use Git's actual committed tree, never dirty overlays labelled as old pins. */
|
||||||
|
export async function checkoutCommit(root: string, commit: string, destination: string) {
|
||||||
|
await fs.mkdir(destination, {recursive: true});
|
||||||
|
const archive = await execFile("git", ["archive", "--format=tar", commit], {cwd: root, encoding: "buffer", maxBuffer: 128 * 1024 * 1024});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn("tar", ["-xf", "-", "-C", destination], {stdio: ["pipe", "ignore", "pipe"]});
|
||||||
|
let error = "";
|
||||||
|
child.stderr.on("data", chunk => {error += chunk;});
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", code => code === 0 ? resolve() : reject(new Error(`Cannot extract committed source: ${error}`)));
|
||||||
|
child.stdin.on("error", reject);
|
||||||
|
child.stdin.end(archive.stdout);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by provisional checks, template publication and host activation.
|
||||||
|
* The Nix derivation is the cached check; its metadata is internal provenance,
|
||||||
|
* not a certificate authored or approved by the workspace agent.
|
||||||
|
*/
|
||||||
|
export async function buildCheckedPackage(source: string, schema: string, packageRevisionId: string): Promise<string> {
|
||||||
|
const generator = process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Run verification with the installed Quixos tooling (its exact Nix checker is required)");
|
||||||
|
const builder = path.join(generator, "share/checked-package.nix");
|
||||||
|
await fs.access(builder);
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn("nix", ["build", "--impure", "--file", builder,
|
||||||
|
"--argstr", "source", source, "--argstr", "schema", schema,
|
||||||
|
"--argstr", "generator", generator, "--argstr", "packageRevisionId", packageRevisionId,
|
||||||
|
"--no-link", "--print-out-paths", "-L"], {env: environment(), stdio: ["ignore", "pipe", "inherit"]});
|
||||||
|
let output = "";
|
||||||
|
child.stdout.on("data", chunk => {output += chunk;});
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", code => {
|
||||||
|
const artifact = output.trim();
|
||||||
|
if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(artifact)) reject(new Error(`Checked Nix build failed (${code}); see build diagnostics above`));
|
||||||
|
else resolve(artifact);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact remote source DAG; the Nix checker owns schema construction and all
|
||||||
|
* nested fetches. Keep build noise in a named log, with a bounded failure tail. */
|
||||||
|
export async function buildImmutableCandidate(source: { repository: string; commit: string }, kind: "workspace" | "interface" | "package", logFile: string, contractOnly = false): Promise<string> {
|
||||||
|
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(source.commit)) throw new Error("An immutable candidate requires an exact commit");
|
||||||
|
const url = new URL(source.repository);
|
||||||
|
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("Candidate origin must be credential-free HTTPS");
|
||||||
|
const generator = process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Use the installed Quixos checker");
|
||||||
|
const builder = path.join(generator, "share/checked-candidate.nix");
|
||||||
|
await fs.access(builder);
|
||||||
|
const log = createWriteStream(logFile, { flags: "wx", mode: 0o600 });
|
||||||
|
await new Promise<void>((resolve, reject) => { log.once("open", () => resolve()); log.once("error", reject); });
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
let output = "", tail = "", failure: Error | undefined;
|
||||||
|
const child = spawn("nix", ["build", "--impure", "--file", builder,
|
||||||
|
"--argstr", "repository", source.repository, "--argstr", "commit", source.commit,
|
||||||
|
"--argstr", "kind", kind, "--argstr", "generator", generator,
|
||||||
|
"--arg", "contractOnly", contractOnly ? "true" : "false",
|
||||||
|
"--no-link", "--print-out-paths", "-L"], { env: environment(), stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
log.on("error", error => { failure = error; child.kill(); });
|
||||||
|
child.stdout.on("data", chunk => { output += chunk; });
|
||||||
|
child.stderr.on("data", chunk => { log.write(chunk); tail = (tail + String(chunk)).slice(-6000); });
|
||||||
|
child.on("error", error => { failure = error; });
|
||||||
|
child.on("close", code => log.end(() => {
|
||||||
|
if (failure) reject(failure);
|
||||||
|
else if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(output.trim())) reject(new Error(`Candidate Nix check failed (${code}). Full log: ${logFile}\n${tail}`));
|
||||||
|
else resolve(output.trim());
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
/** Kernel-owned lock: a crashed coordinator cannot leave a stale ownership file.
|
||||||
|
* The persistent file is just an inode; EOF releases the helper's lock. */
|
||||||
|
export async function withFileLock<T>(filename: string, work: () => Promise<T>): Promise<T> {
|
||||||
|
const child = spawn("flock", ["--exclusive", "--timeout", "120", "--conflict-exit-code", "75", filename,
|
||||||
|
process.execPath, "-e", 'process.stdout.write("locked\\n"); process.stdin.resume();'], {stdio: ["pipe", "pipe", "pipe"]});
|
||||||
|
let diagnostics = "";
|
||||||
|
child.stdin.on("error", () => { /* acquisition/exit handling reports helper failure */ });
|
||||||
|
child.stderr.on("data", chunk => { diagnostics = (diagnostics + String(chunk)).slice(-2000); });
|
||||||
|
const closed = new Promise<void>((resolve) => { child.once("close", () => resolve()); });
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let output = "";
|
||||||
|
child.once("error", reject);
|
||||||
|
child.once("exit", code => reject(new Error(code === 75 ? "Timed out after 120 seconds waiting for another authoring command; inspect that command before retrying" : `Cannot acquire authoring lock: ${diagnostics}`)));
|
||||||
|
child.stdout.on("data", chunk => { output += chunk; if (output.includes("locked\n")) resolve(); });
|
||||||
|
});
|
||||||
|
return await work();
|
||||||
|
} finally { child.stdin.end(); await closed; }
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import childProcess from "node:child_process";
|
import childProcess from "node:child_process";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { mkdir, readFile, realpath } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, realpath, rename, rm, stat } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import type { CapabilityRepositoryResolver } from "./assembly.js";
|
import type { CapabilityRepositoryResolver } from "./assembly.js";
|
||||||
@@ -61,7 +61,27 @@ export const createGitCapabilityResolver = async (options: {
|
|||||||
checkoutRoot,
|
checkoutRoot,
|
||||||
checkoutName(kind, source.repository, source.commit),
|
checkoutName(kind, source.repository, source.commit),
|
||||||
);
|
);
|
||||||
await execFile("git", [
|
const verify = async (checkout: string) => {
|
||||||
|
const { stdout } = await execFile("git", ["-C", checkout, "rev-parse", "HEAD"]);
|
||||||
|
if (stdout.trim().toLowerCase() !== source.commit.toLowerCase()) {
|
||||||
|
throw new Error(`Locked commit mismatch for ${source.repository}: wanted ${source.commit}, fetched ${stdout.trim()}`);
|
||||||
|
}
|
||||||
|
const { stdout: changes } = await execFile("git", ["-C", checkout, "status", "--porcelain", "--untracked-files=all"]);
|
||||||
|
if (changes.trim()) throw new Error(`Dependency checkout was modified: ${checkout}`);
|
||||||
|
};
|
||||||
|
// Only complete, checked clones become visible under the deterministic name.
|
||||||
|
// Concurrent resolvers may fetch independently, but cannot observe a partial clone.
|
||||||
|
if (await stat(directory).then(() => true, (error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === "ENOENT") return false;
|
||||||
|
throw error;
|
||||||
|
})) {
|
||||||
|
await verify(directory);
|
||||||
|
return { directory };
|
||||||
|
}
|
||||||
|
const staging = await mkdtemp(path.join(checkoutRoot, ".fetch-"));
|
||||||
|
const checkout = path.join(staging, "checkout");
|
||||||
|
try {
|
||||||
|
await execFile("git", [
|
||||||
"-c",
|
"-c",
|
||||||
"advice.detachedHead=false",
|
"advice.detachedHead=false",
|
||||||
"clone",
|
"clone",
|
||||||
@@ -71,18 +91,21 @@ export const createGitCapabilityResolver = async (options: {
|
|||||||
"--branch",
|
"--branch",
|
||||||
`quixos-reachability/${source.commit.toLowerCase()}`,
|
`quixos-reachability/${source.commit.toLowerCase()}`,
|
||||||
source.repository,
|
source.repository,
|
||||||
directory,
|
checkout,
|
||||||
]);
|
]);
|
||||||
const { stdout } = await execFile("git", ["-C", directory, "rev-parse", "HEAD"]);
|
await verify(checkout);
|
||||||
if (stdout.trim().toLowerCase() !== source.commit.toLowerCase()) {
|
try { await rename(checkout, directory); }
|
||||||
throw new Error(
|
catch (error) {
|
||||||
`Locked commit mismatch for ${source.repository}: ` +
|
if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
|
||||||
`wanted ${source.commit}, fetched ${stdout.trim()}`,
|
await verify(directory);
|
||||||
);
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(staging, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
return { directory };
|
return { directory };
|
||||||
})();
|
})();
|
||||||
checkouts.set(key, pending);
|
checkouts.set(key, pending);
|
||||||
return await pending;
|
try { return await pending; }
|
||||||
|
catch (error) { checkouts.delete(key); throw error; }
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {parse} from "@babel/parser";
|
||||||
|
type Node = {type: string; start: number; end: number; [key: string]: unknown};
|
||||||
|
const node = (value: unknown): value is Node => !!value && typeof value === "object" && typeof (value as Node).type === "string";
|
||||||
|
|
||||||
|
/** One requested insertion into ordinary authored TypeScript, not regeneration.
|
||||||
|
* Ambiguous/custom wiring is left alone with an actionable error. */
|
||||||
|
export function addImplementation(text: string, factory: "createRuntime" | "serveMigration", key: string, importPath: string, migrationOnly = false): string {
|
||||||
|
const ast = parse(text, {sourceType: "module", plugins: ["typescript"]});
|
||||||
|
const objects: Node[] = [];
|
||||||
|
const names = new Set<string>();
|
||||||
|
const visit = (value: unknown) => {
|
||||||
|
if (Array.isArray(value)) { value.forEach(visit); return; }
|
||||||
|
if (!node(value)) return;
|
||||||
|
if (value.type === "Identifier") names.add(String(value.name));
|
||||||
|
if (value.type === "CallExpression" && node(value.callee) && value.callee.type === "Identifier" && value.callee.name === factory &&
|
||||||
|
Array.isArray(value.arguments) && value.arguments.length === 1 && node(value.arguments[0]) && value.arguments[0].type === "ObjectExpression") objects.push(value.arguments[0]);
|
||||||
|
Object.values(value).forEach(visit);
|
||||||
|
};
|
||||||
|
visit(ast);
|
||||||
|
if (objects.length !== 1) throw new Error(`Cannot safely add implementation: expected one ${factory}({...}) literal. Wire the handler in your authored server code instead.`);
|
||||||
|
const object = objects[0];
|
||||||
|
if ((object.properties as Node[]).some(p => node(p.key) && !p.computed && (p.key.name === key || p.key.value === key))) throw new Error(`Implementation already exists for ${key}`);
|
||||||
|
let alias = "qxImplementation";
|
||||||
|
for (let index = 1; names.has(alias); index++) alias = `qxImplementation${index}`;
|
||||||
|
const value = migrationOnly ? 'async () => { throw new Error("Migration-only export"); }' : alias;
|
||||||
|
const insertion = object.start + 1;
|
||||||
|
const edited = text.slice(0, insertion) + `\n ${JSON.stringify(key)}: ${value},\n` + text.slice(insertion);
|
||||||
|
return (migrationOnly ? "" : `import {handler as ${alias}} from ${JSON.stringify(importPath)};\n`) + edited;
|
||||||
|
}
|
||||||
@@ -2,12 +2,19 @@
|
|||||||
// Data only: never load files, resolve repositories, or evaluate code.
|
// Data only: never load files, resolve repositories, or evaluate code.
|
||||||
import { parseQx } from "./source.js";
|
import { parseQx } from "./source.js";
|
||||||
import { parseQuixosLockDocument } from "../resource-lock/parser.js";
|
import { parseQuixosLockDocument } from "../resource-lock/parser.js";
|
||||||
|
import { instantiateWorkspaceIdentity } from "./structural-edits.js";
|
||||||
let input = "";
|
let input = "";
|
||||||
for await (const chunk of process.stdin) {
|
for await (const chunk of process.stdin) {
|
||||||
input += chunk;
|
input += chunk;
|
||||||
if (Buffer.byteLength(input) > 6 * 1024 * 1024) throw new Error("Inspection input exceeds limit");
|
if (Buffer.byteLength(input) > 6 * 1024 * 1024) throw new Error("Inspection input exceeds limit");
|
||||||
}
|
}
|
||||||
const files: { path: string; source: string }[] = JSON.parse(input);
|
const document = JSON.parse(input);
|
||||||
|
if (!Array.isArray(document) && document?.operation === "instantiate-workspace") {
|
||||||
|
if (typeof document.source !== "string" || document.source.length > 262144 || typeof document.workspaceId !== "string") throw new Error("Invalid template identity request");
|
||||||
|
process.stdout.write(JSON.stringify({ source: instantiateWorkspaceIdentity(document.source, document.workspaceId) }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const files: { path: string; source: string }[] = document;
|
||||||
if (!Array.isArray(files) || files.length > 128) throw new Error("Too many source files");
|
if (!Array.isArray(files) || files.length > 128) throw new Error("Too many source files");
|
||||||
const result = files.map(({ path, source }) => {
|
const result = files.map(({ path, source }) => {
|
||||||
if (typeof path !== "string" || typeof source !== "string" || source.length > 262144)
|
if (typeof path !== "string" || typeof source !== "string" || source.length > 262144)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {contentDigest} from "../capability-model/evolution.js";
|
||||||
|
import {validateMigrationCatalog, type MigrationCatalog} from "../capability-model/migrations.js";
|
||||||
|
import {applyStructure, planStructure} from "./structural-plan.js";
|
||||||
|
|
||||||
|
/** Explicitly acknowledge edited migration code. This does not change retained
|
||||||
|
* contracts or grant activation approval; the immutable checker verifies it. */
|
||||||
|
export async function sealMigrations(directory: string) {
|
||||||
|
const root = await fs.realpath(directory);
|
||||||
|
const file = "quixos.migrations.json";
|
||||||
|
const before = await fs.readFile(path.join(root, file), "utf8");
|
||||||
|
const catalog = JSON.parse(before) as MigrationCatalog;
|
||||||
|
for (const migration of catalog.migrations) {
|
||||||
|
const implementation = await fs.realpath(path.join(root, migration.implementation.file));
|
||||||
|
if (!implementation.startsWith(root + path.sep)) throw new Error("Migration implementation escapes package");
|
||||||
|
migration.implementation.digest = contentDigest(await fs.readFile(implementation, "utf8"));
|
||||||
|
}
|
||||||
|
validateMigrationCatalog(catalog);
|
||||||
|
return applyStructure(await planStructure(root, {kind: "package", validation: "syntax", files: [
|
||||||
|
{file, expected: before, replace: JSON.stringify(catalog, null, 2) + "\n"},
|
||||||
|
]}));
|
||||||
|
}
|
||||||
@@ -7,9 +7,12 @@ import {promisify} from "node:util";
|
|||||||
import {loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js";
|
import {loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js";
|
||||||
import {contentDigest} from "../capability-model/evolution.js";
|
import {contentDigest} from "../capability-model/evolution.js";
|
||||||
import {planStructure, applyStructure, type StructuralRequest} from "./structural-plan.js";
|
import {planStructure, applyStructure, type StructuralRequest} from "./structural-plan.js";
|
||||||
import {snapshotRepository, checkResourceCandidate, checkWorkspaceCandidate} from "./candidate-check.js";
|
import {snapshotRepository} from "./candidate-check.js";
|
||||||
import {compileWorkspaceRepository, compileCapabilityResourceRepository, type ResolvedCapabilityResource} from "./assembly.js";
|
import {compileWorkspaceRepository, compileCapabilityResourceRepository, type ResolvedCapabilityResource} from "./assembly.js";
|
||||||
import {createGitCapabilityResolver} from "./git-resolver.js";
|
import {createGitCapabilityResolver} from "./git-resolver.js";
|
||||||
|
import {snapshotCommit, buildImmutableCandidate} from "./checked-build.js";
|
||||||
|
import {planEvolution, type WorkspaceRevision, type EvolutionReview} from "../capability-model/index.js";
|
||||||
|
import {withFileLock} from "./file-lock.js";
|
||||||
const execFile = promisify(callback);
|
const execFile = promisify(callback);
|
||||||
type Source = {repository: string; commit: string};
|
type Source = {repository: string; commit: string};
|
||||||
export type UpgradeNode = {kind: "workspace" | "package" | "interface"; directory: string; source: Source};
|
export type UpgradeNode = {kind: "workspace" | "package" | "interface"; directory: string; source: Source};
|
||||||
@@ -47,7 +50,7 @@ export const discoverUpgradeSpec = async (workbench: string): Promise<UpgradeSpe
|
|||||||
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
||||||
const root = await location(workbench, "root");
|
const root = await location(workbench, "root");
|
||||||
const nodes: UpgradeNode[] = [{kind: "workspace", directory: "root", source: {
|
const nodes: UpgradeNode[] = [{kind: "workspace", directory: "root", source: {
|
||||||
repository: await command(root, "git", ["remote", "get-url", "origin"]),
|
repository: await command(root, "git", ["config", "--get", "remote.origin.url"]),
|
||||||
commit: await command(root, "jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"]),
|
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,
|
}}, ...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))}))];
|
directory: path.relative(workbench, path.resolve(workbench, entry.directory))}))];
|
||||||
@@ -75,7 +78,7 @@ export const planPinUpgrades = async (workbenchPath: string, spec: UpgradeSpec):
|
|||||||
const nodes: NodePlan[] = [];
|
const nodes: NodePlan[] = [];
|
||||||
for (const node of spec.nodes) {
|
for (const node of spec.nodes) {
|
||||||
const root = await location(workbench, node.directory);
|
const root = await location(workbench, node.directory);
|
||||||
if (await command(root, "git", ["remote", "get-url", "origin"]) !== node.source.repository) throw new Error(`Upgrade origin differs from selected source: ${node.directory}`);
|
if (await command(root, "git", ["config", "--get", "remote.origin.url"]) !== node.source.repository) throw new Error(`Upgrade origin differs from selected source: ${node.directory}`);
|
||||||
const loaded = await loadQuixosLock(path.join(root, "quixos.lock"));
|
const loaded = await loadQuixosLock(path.join(root, "quixos.lock"));
|
||||||
if (!loaded.ok) throw new Error(`Invalid lock in ${node.directory}: ${loaded.diagnostics.map((entry) => entry.message).join("; ")}`);
|
if (!loaded.ok) 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));
|
const dependencies = loaded.lock.resources.map((resource) => keys.get(sourceKey(resource))).filter((value): value is string => Boolean(value));
|
||||||
@@ -102,22 +105,24 @@ export type UpgradeEffects = {
|
|||||||
const effects: UpgradeEffects = {
|
const effects: UpgradeEffects = {
|
||||||
async check(node, root, output, spec) {
|
async check(node, root, output, spec) {
|
||||||
if (node.kind === "workspace" && !spec.baseline && !spec.bootstrap) throw new Error("Upgrading a workspace requires its checked active baseline for major-review checks (or explicit bootstrap:true for a new workspace)");
|
if (node.kind === "workspace" && !spec.baseline && !spec.bootstrap) throw new Error("Upgrading a workspace requires its checked active baseline for major-review checks (or explicit bootstrap:true for a new workspace)");
|
||||||
// Publication checks consume already-published dependency revisions, never
|
// Explicit baseline upgrades use the same immutable Nix checker. Retaining
|
||||||
// workbench dirty overlays masquerading as those immutable identities.
|
// an unverified source is safe and must precede a remote flake fetch.
|
||||||
const snapshotMap = `${output}-published-dependencies.json`;
|
await fs.mkdir(output);
|
||||||
await fs.writeFile(snapshotMap, JSON.stringify({resources: []}), {flag: "wx"});
|
const commit = await snapshotCommit(root);
|
||||||
const result = node.kind === "workspace" ? await checkWorkspaceCandidate({root, output, snapshotMap, baseline: spec.baseline, reviews: spec.reviews})
|
await effects.publish(root, commit);
|
||||||
: await checkResourceCandidate({root, output, kind: node.kind, source: node.source, snapshotMap});
|
const artifact = await buildImmutableCandidate({...node.source, commit}, node.kind, path.join(output, "nix.log"));
|
||||||
if (result.blockers.length) throw new Error(`Refactor required in ${node.directory}: ${result.blockers.join("; ")}`);
|
const candidate = await fs.readFile(path.join(artifact, "candidate.json"), "utf8");
|
||||||
const evolution = (result as {evolution?: {reviews: {accepted: boolean}[]}}).evolution;
|
await fs.writeFile(path.join(output, "candidate.json"), candidate);
|
||||||
if (evolution?.reviews.some((review) => !review.accepted)) throw new Error("Explicit semantic-major review required before publishing the workspace");
|
if (node.kind === "workspace") {
|
||||||
|
const baseline = spec.baseline ? JSON.parse(await fs.readFile(spec.baseline, "utf8")) as WorkspaceRevision : null;
|
||||||
|
const reviews = spec.reviews ? JSON.parse(await fs.readFile(spec.reviews, "utf8")) as EvolutionReview[] : [];
|
||||||
|
const evolution = planEvolution(baseline, JSON.parse(candidate), {reviews});
|
||||||
|
await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2));
|
||||||
|
if (evolution.blockers.length) throw new Error(`Refactor required in ${node.directory}: ${evolution.blockers.join("; ")}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async snapshot(root) {
|
async snapshot(root) {
|
||||||
// Unlike checking, publication deliberately captures the working copy.
|
const commit = await snapshotCommit(root);
|
||||||
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");
|
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Publication did not resolve an exact commit");
|
||||||
await command(root, "git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"]);
|
await command(root, "git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"]);
|
||||||
const tracked = new Set((await command(root, "git", ["ls-tree", "-r", "--name-only", "-z", commit])).split("\0"));
|
const tracked = new Set((await command(root, "git", ["ls-tree", "-r", "--name-only", "-z", commit])).split("\0"));
|
||||||
@@ -142,17 +147,17 @@ export const applyPinUpgrades = async (plan: UpgradePlan, journalId?: string, im
|
|||||||
const directory = path.join(plan.workbench, ".quixos", "upgrades");
|
const directory = path.join(plan.workbench, ".quixos", "upgrades");
|
||||||
await fs.mkdir(directory, {recursive: true, mode: 0o700});
|
await fs.mkdir(directory, {recursive: true, mode: 0o700});
|
||||||
if (await fs.realpath(directory) !== directory) throw new Error("Upgrade journals must not cross symlinks");
|
if (await fs.realpath(directory) !== directory) throw new Error("Upgrade journals must not cross symlinks");
|
||||||
const lock = await fs.open(path.join(directory, "writer.lock"), "wx", 0o600);
|
|
||||||
const id = journalId ?? randomUUID();
|
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");}
|
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid upgrade journal ID");
|
||||||
const filename = path.join(directory, `${id}.json`);
|
const filename = path.join(directory, `${id}.json`);
|
||||||
|
return withFileLock(path.join(directory, "writer.lock"), async () => {
|
||||||
try {
|
try {
|
||||||
const journal: Journal = journalId ? JSON.parse(await fs.readFile(filename, "utf8")) : {schemaVersion: 1, plan, steps: []};
|
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 (journal.plan.digest !== plan.digest) throw new Error("Upgrade journal belongs to another plan");
|
||||||
if (!journalId) await writeJournal(filename, journal);
|
if (!journalId) await writeJournal(filename, journal);
|
||||||
for (const node of plan.nodes) {
|
for (const node of plan.nodes) {
|
||||||
const root = await location(plan.workbench, node.directory);
|
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");
|
if (await command(root, "git", ["config", "--get", "remote.origin.url"]) !== node.source.repository) throw new Error("Upgrade remote changed after planning");
|
||||||
let step = journal.steps.find((entry) => entry.directory === node.directory);
|
let step = journal.steps.find((entry) => entry.directory === node.directory);
|
||||||
if (step?.phase === "published") continue;
|
if (step?.phase === "published") continue;
|
||||||
if (!step) {
|
if (!step) {
|
||||||
@@ -245,5 +250,5 @@ export const applyPinUpgrades = async (plan: UpgradePlan, journalId?: string, im
|
|||||||
}
|
}
|
||||||
return {id, journal: filename, revisions: journal.steps.map((step) => ({directory: step.directory, commit: step.commit})), activated: false};
|
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});}
|
} 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"));}
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import {validateMigrationCatalog, type MigrationCatalog, type MigrationDeclarati
|
|||||||
import {formatQuixosLock, type GitSource} from "../resource-lock/index.js";
|
import {formatQuixosLock, type GitSource} from "../resource-lock/index.js";
|
||||||
import {parseQx, walkSyntax} from "./source.js";
|
import {parseQx, walkSyntax} from "./source.js";
|
||||||
import type {StructuralRequest} from "./structural-plan.js";
|
import type {StructuralRequest} from "./structural-plan.js";
|
||||||
|
import {addImplementation} from "./implementation-edit.js";
|
||||||
|
|
||||||
type Source = {repository: string; commit: string};
|
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}[]};
|
type Registry = {generatedBy: "qx-scaffold-v1"; name: string; id: string; revision: string; exports: {name: string; id: string; file: string; migration?: boolean}[]};
|
||||||
export type ScaffoldRecipe = {
|
export type ScaffoldRecipe = {
|
||||||
|
template?: "typescript" | "typescript-react";
|
||||||
source: Source; directory?: string; name?: string; id?: string; revision?: string;
|
source: Source; directory?: string; name?: string; id?: string; revision?: string;
|
||||||
declaration?: string;
|
declaration?: string;
|
||||||
tools?: {quixos: Source; protocol: Source; helpers: Source; sdk: Source};
|
tools?: {quixos: Source; protocol: Source; helpers: Source; sdk: Source};
|
||||||
@@ -18,6 +20,7 @@ export type ScaffoldRecipe = {
|
|||||||
const marker = "// Generated by qx-scaffold-v1\n";
|
const marker = "// Generated by qx-scaffold-v1\n";
|
||||||
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
|
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
|
||||||
const source = (value: Source): GitSource => {
|
const source = (value: Source): GitSource => {
|
||||||
|
if (!value || typeof value.repository !== "string" || typeof value.commit !== "string") throw new Error("Scaffold requires an exact source identity; use qx-workspace from a registered repository");
|
||||||
const url = new URL(value.repository);
|
const url = new URL(value.repository);
|
||||||
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit)) throw new Error("Scaffolds require credential-free HTTPS sources and full exact commits");
|
if (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};
|
return {resolver: "git", ...value};
|
||||||
@@ -39,6 +42,7 @@ const ownedJson = async <T>(root: string, file: string): Promise<T> => {
|
|||||||
/** Recipes describe structural edits; planStructure owns validation/journaling.
|
/** Recipes describe structural edits; planStructure owns validation/journaling.
|
||||||
* Implementation files are created once and never rewritten by refresh. */
|
* 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> => {
|
export const scaffoldRecipe = async (root: string, command: "package" | "function" | "migration" | "refresh", spec: ScaffoldRecipe): Promise<StructuralRequest> => {
|
||||||
|
if (command === "refresh") throw new Error("Scaffold refresh has been removed. Edit declarations and typed server wiring directly, then run qx-workspace check. Use scaffold function to add a declaration and handler together.");
|
||||||
source(spec.source);
|
source(spec.source);
|
||||||
if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory)) throw new Error("Scaffold directory must be contained");
|
if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory)) throw new Error("Scaffold directory must be contained");
|
||||||
const prefix = spec.directory ? `${spec.directory}/` : "";
|
const prefix = spec.directory ? `${spec.directory}/` : "";
|
||||||
@@ -49,23 +53,31 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
|||||||
let catalog: MigrationCatalog & {generatedBy: "qx-scaffold-v1"};
|
let catalog: MigrationCatalog & {generatedBy: "qx-scaffold-v1"};
|
||||||
if (command === "package") {
|
if (command === "package") {
|
||||||
const name = safeName(spec.name);
|
const name = safeName(spec.name);
|
||||||
|
if (spec.template && !["typescript", "typescript-react"].includes(spec.template)) throw new Error("Unknown package template");
|
||||||
|
const react = spec.template === "typescript-react";
|
||||||
if (!spec.id || !spec.revision || !spec.tools) throw new Error("Package scaffold requires id, revision, and exact quixos/protocol/helpers/sdk tool sources");
|
if (!spec.id || !spec.revision || !spec.tools) throw new Error("Package scaffold requires id, revision, and exact quixos/protocol/helpers/sdk tool sources");
|
||||||
Object.values(spec.tools).forEach(source);
|
Object.values(spec.tools).forEach(source);
|
||||||
registry = {generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: []};
|
registry = {generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: []};
|
||||||
catalog = {generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: []};
|
catalog = {generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: []};
|
||||||
create("package.qx", `package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`);
|
if (react) registry.exports.push({name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts"});
|
||||||
|
create("package.qx", `package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n${react ? ` function sourceGet id ${JSON.stringify(`export:${name}:source`)} : unit -> string;\n` : ""}}\n`);
|
||||||
create("quixos.lock", formatQuixosLock({formatVersion: 1, quixos: source(spec.tools.quixos), resources: []}));
|
create("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",
|
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}`},
|
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"}}));
|
devDependencies: {"@types/node": "^24", typescript: "^7.0.2", ...(react ? {react: "^18.3.1", "@types/react": "^18.3.12", esbuild: "^0.25.12"} : {})}}));
|
||||||
create("tsconfig.json", json({compilerOptions: {target: "ES2023", module: "NodeNext", moduleResolution: "NodeNext", strict: true, outDir: "dist", skipLibCheck: true}, include: ["src/**/*.ts"]}));
|
create("tsconfig.json", json({compilerOptions: {target: "ES2023", module: "NodeNext", moduleResolution: "NodeNext", strict: true, types: ["node", ...(react ? ["react"] : [])], outDir: "dist", rootDir: "src", skipLibCheck: true, ...(react ? {jsx: "react-jsx", esModuleInterop: true} : {})}, include: ["src/**/*.ts", "src/**/*.tsx"]}));
|
||||||
|
if (react) {
|
||||||
|
create("src/component.tsx", `// Props are opaque at the platform boundary until capability generics exist.\nexport default function Component(_props: {camino: unknown; render: unknown; dispatch: (action: unknown) => void}) {\n return <section><h1>${name}</h1><p>Edit this component, then run qx-workspace check.</p></section>;\n}\n`);
|
||||||
|
create("src/impl/sourceGet.ts", `import componentSource from "../component.js?browser-source";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => componentSource;\n`);
|
||||||
|
create("src/browser-assets.d.ts", `declare module "*?browser-source" { const source: string; export default source; }\n`);
|
||||||
|
}
|
||||||
create(".gitignore", "node_modules/\ndist/\n.quixos/\nresult\n.yarn/install-state.gz\n");
|
create(".gitignore", "node_modules/\ndist/\n.quixos/\nresult\n.yarn/install-state.gz\n");
|
||||||
create(".yarnrc.yml", `nodeLinker: node-modules\nenableScripts: true\nnpmMinimalAgeGate: 0\napprovedGitRepositories:\n - ${JSON.stringify(spec.tools.sdk.repository)}\nsupportedArchitectures:\n os: [current, linux]\n cpu: [current, x64, arm64]\n libc: [current, glibc]\n`);
|
create(".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 nixifyPluginUrl = spec.nixifyPluginUrl ?? "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js";
|
||||||
const plugin = new URL(nixifyPluginUrl);
|
const plugin = new URL(nixifyPluginUrl);
|
||||||
if (plugin.protocol !== "https:" || plugin.username || plugin.password || plugin.search || plugin.hash || !/\/commit\/[a-f0-9]{40,64}\//.test(plugin.pathname)) throw new Error("Nixify plugin must have an exact credential-free HTTPS commit URL");
|
if (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}));
|
generated("quixos.toolchain.json", json({generatedBy: "qx-scaffold-v1", nixifyPluginUrl}));
|
||||||
create("quixos.check.json", json({backend: "typescript", bindingOutput: "src/generated-bindings.ts"}));
|
create("quixos.check.json", json({backend: "typescript", bindingOutput: "src/gen/qx.ts", ...(react ? {options: {messages: {"org.quixos.web-studio.ReactProps": {module: "@quixos/camino-package-runtime", export: "opaqueReactPropsBinding"}}}} : {})}));
|
||||||
create("flake.nix", `{
|
create("flake.nix", `{
|
||||||
inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))};
|
inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))};
|
||||||
inputs.nixpkgs.follows = "protocol/nixpkgs";
|
inputs.nixpkgs.follows = "protocol/nixpkgs";
|
||||||
@@ -74,24 +86,32 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
|||||||
outputs = inputs@{ self, protocol, nixpkgs, flake-utils, helpers, ... }:
|
outputs = inputs@{ self, protocol, nixpkgs, flake-utils, helpers, ... }:
|
||||||
(import (toString helpers + "/quixos-package-helpers.nix")).mkCaminoTsYarnNixifyFlake {
|
(import (toString helpers + "/quixos-package-helpers.nix")).mkCaminoTsYarnNixifyFlake {
|
||||||
inherit inputs nixpkgs flake-utils; packageRoot = ./.;
|
inherit inputs nixpkgs flake-utils; packageRoot = ./.;
|
||||||
bindings = { system, ... }: {
|
bundle = { entry = "dist/server.js"; ${react ? "browserSources = true;" : ""} };
|
||||||
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";
|
migrationEntrypoint = "dist/migrate.js";
|
||||||
installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; };
|
installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; };
|
||||||
};
|
};
|
||||||
}\n`);
|
}\n`);
|
||||||
generated("quixos.resources.json", json({generatedBy: "qx-scaffold-v1", resources: []}));
|
|
||||||
} else {
|
} else {
|
||||||
registry = await ownedJson<Registry>(root, prefix + "quixos.scaffold.json");
|
|
||||||
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
|
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
|
||||||
if (command !== "refresh") {
|
// Derive the edit model from authored declarations; it is never persisted.
|
||||||
|
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
|
||||||
|
const syntax = parseQx(authored);
|
||||||
|
if (syntax.diagnostics.length) throw new Error("Cannot scaffold into an invalid package.qx; fix the reported syntax first");
|
||||||
|
const declaration = [...walkSyntax(syntax.root)].find(node => node.kind === "packageResourceDecl");
|
||||||
|
if (!declaration) throw new Error("Expected a package declaration");
|
||||||
|
const text = (node: typeof declaration) => authored.slice(node.start, node.end);
|
||||||
|
const literals = declaration.children.filter(node => node.kind === "stringLiteral");
|
||||||
|
registry = {generatedBy: "qx-scaffold-v1", name: text(declaration.children.find(node => node.kind === "identifier")!),
|
||||||
|
id: JSON.parse(text(literals[0])), revision: JSON.parse(text(literals[1])), exports: []};
|
||||||
|
for (const node of walkSyntax(declaration)) {
|
||||||
|
if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)) continue;
|
||||||
|
const name = safeName(text(node.children.find(child => child.kind === "identifier")!));
|
||||||
|
const id = JSON.parse(text(node.children.find(child => child.kind === "stringLiteral")!));
|
||||||
|
if (registry.exports.some(entry => entry.id === id || entry.name === name)) throw new Error("Duplicate package export name or ID");
|
||||||
|
const migration = catalog.migrations.find(entry => entry.implementation.exportId === id);
|
||||||
|
registry.exports.push({name, id, file: migration?.implementation.file ?? `src/impl/${name}.ts`, ...(migration ? {migration: true} : {})});
|
||||||
|
}
|
||||||
|
{
|
||||||
const name = safeName(spec.name);
|
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");
|
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 declaration = spec.declaration ?? `function ${name} id ${JSON.stringify(spec.id)} : unit -> unit;`;
|
||||||
@@ -107,7 +127,7 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
|||||||
files.push({file: prefix + "package.qx", edits: [{operation: "append", parent: {kind: "packageResourceDecl", id: registry.id}, source: declaration}]});
|
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 file = `src/${command === "migration" ? "migrations" : "impl"}/${name}.ts`;
|
||||||
const implementation = command === "migration" ? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise<void> => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\n`
|
const implementation = 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`;
|
: `import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`;
|
||||||
create(file, implementation);
|
create(file, implementation);
|
||||||
registry.exports.push({name, id: spec.id, file, ...(command === "migration" ? {migration: true} : {})});
|
registry.exports.push({name, id: spec.id, file, ...(command === "migration" ? {migration: true} : {})});
|
||||||
if (command === "migration") {
|
if (command === "migration") {
|
||||||
@@ -120,19 +140,25 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
|||||||
catalog.migrations.push({...transition, implementation: {exportId: spec.id, file, digest: contentDigest(implementation)}});
|
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)));
|
validateMigrationCatalog(catalog, new Set(registry.exports.map((entry) => entry.id)));
|
||||||
generated("quixos.scaffold.json", json(registry));
|
|
||||||
generated("quixos.migrations.json", json(catalog));
|
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("") +
|
if (command !== "package") {
|
||||||
|
const entry = registry.exports.at(-1)!;
|
||||||
|
const file = prefix + "src/server.ts";
|
||||||
|
const before = await fs.readFile(path.join(root, file), "utf8");
|
||||||
|
files.push({file, expected: before, replace: addImplementation(before, "createRuntime", entry.name, `./${entry.file.slice(4, -3)}.js`, !!entry.migration)});
|
||||||
|
if (entry.migration) {
|
||||||
|
const file = prefix + "src/migrate.ts";
|
||||||
|
const before = await fs.readFile(path.join(root, file), "utf8");
|
||||||
|
files.push({file, expected: before, replace: addImplementation(before, "serveMigration", entry.id, `./${entry.file.slice(4, -3)}.js`)});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
generated("src/server.ts", marker + `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` + registry.exports.filter((entry) => !entry.migration).map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") +
|
||||||
`servePackageRuntime(createRuntime({\n` + registry.exports.map((entry) => ` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${registry.exports.filter((value) => !value.migration).indexOf(entry)}`},`).join("\n") + `\n}));\n`);
|
`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);
|
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("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(""));
|
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};
|
return {kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ const selectable = new Set(["workspaceDecl", "fragmentDecl", "interfaceResourceD
|
|||||||
"valueMember", "relationshipMember", "operationMember", "packageOperationExport", "packageFunctionExport", "packageConstructorExport",
|
"valueMember", "relationshipMember", "operationMember", "packageOperationExport", "packageFunctionExport", "packageConstructorExport",
|
||||||
"conformanceDecl", "stateDecl", "edgeDecl", "constructorBindingDecl", "resourceImportDecl", "sourceImportDecl", "operationBindingDecl"]);
|
"conformanceDecl", "stateDecl", "edgeDecl", "constructorBindingDecl", "resourceImportDecl", "sourceImportDecl", "operationBindingDecl"]);
|
||||||
|
|
||||||
|
/** Bind only the template root identity; schema/atom identities are reusable.
|
||||||
|
* The revision's real identity is derived from its containing commit at compile time. */
|
||||||
|
export const instantiateWorkspaceIdentity = (source: string, workspaceId: string): string => {
|
||||||
|
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(workspaceId)) throw new Error("Workspace identity must be a UUID");
|
||||||
|
const syntax = parseQx(source);
|
||||||
|
if (syntax.diagnostics.length) throw new Error("Cannot instantiate a malformed template workspace");
|
||||||
|
const declaration = syntax.root.children.find(node => node.kind === "workspaceDecl");
|
||||||
|
const literals = declaration?.children.filter(node => node.kind === "stringLiteral");
|
||||||
|
if (!literals || literals.length !== 3) throw new Error("Template must contain a workspace declaration");
|
||||||
|
return applySourceEdits(source, [
|
||||||
|
{ ...literals[0], text: JSON.stringify(workspaceId) },
|
||||||
|
{ ...literals[1], text: JSON.stringify(`workspace-revision:${workspaceId}:source`) },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
const select = (source: string, selector: StructuralSelector): {node: SyntaxNode; syntax: ReturnType<typeof parseQx>} => {
|
const select = (source: string, selector: StructuralSelector): {node: SyntaxNode; syntax: ReturnType<typeof parseQx>} => {
|
||||||
if (!selectable.has(selector.kind)) throw new Error(`Unsupported structural selector ${selector.kind}`);
|
if (!selectable.has(selector.kind)) throw new Error(`Unsupported structural selector ${selector.kind}`);
|
||||||
const syntax = parseQx(source);
|
const syntax = parseQx(source);
|
||||||
@@ -117,10 +132,16 @@ export const editStructure = (source: string, edit: StructuralEdit): string => {
|
|||||||
if (!closing) throw new Error("Append requires a declaration with a body");
|
if (!closing) throw new Error("Append requires a declaration with a body");
|
||||||
result = applySourceEdits(source, [{start: closing.start, end: closing.start, text: `\n${edit.source}\n`}]);
|
result = applySourceEdits(source, [{start: closing.start, end: closing.start, text: `\n${edit.source}\n`}]);
|
||||||
} else {
|
} else {
|
||||||
|
// A resource parser node includes imports/external declarations preceding
|
||||||
|
// its header. Replacing the declaration must not delete that preamble.
|
||||||
|
const identifier = node.children.find(child => child.kind === "identifier");
|
||||||
|
const header = ["packageResourceDecl", "interfaceResourceDecl"].includes(node.kind) && identifier
|
||||||
|
? syntax.tokens.filter(token => token.start >= node.start && token.end <= identifier.start &&
|
||||||
|
token.kind === (node.kind === "packageResourceDecl" ? "PACKAGE" : "INTERFACE")).at(-1)?.start : undefined;
|
||||||
const wrapper = edit.operation === "remove" && ["stateDecl", "edgeDecl"].includes(node.kind)
|
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]
|
? [...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;
|
: undefined;
|
||||||
result = applySourceEdits(source, [{start: wrapper?.start ?? node.start, end: wrapper?.end ?? node.end, text: edit.operation === "replace" ? edit.source : ""}]);
|
result = applySourceEdits(source, [{start: wrapper?.start ?? header ?? node.start, end: wrapper?.end ?? node.end, text: edit.operation === "replace" ? edit.source : ""}]);
|
||||||
}
|
}
|
||||||
const checked = parseQx(result);
|
const checked = parseQx(result);
|
||||||
if (checked.diagnostics.length) throw new Error(`Invalid structural change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
|
if (checked.diagnostics.length) throw new Error(`Invalid structural change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
|
||||||
|
|||||||
@@ -8,17 +8,21 @@ import { snapshotRepository, localResourceSnapshots } from "./candidate-check.js
|
|||||||
import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js";
|
import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js";
|
||||||
import { createGitCapabilityResolver } from "./git-resolver.js";
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
import {bindingSchema, generateTypeScriptBindings} from "../bindings/index.js";
|
import {bindingSchema, generateTypeScriptBindings} from "../bindings/index.js";
|
||||||
|
import { parseQx } from "./source.js";
|
||||||
|
import { parseQuixosLockDocument } from "../resource-lock/index.js";
|
||||||
|
import { withFileLock } from "./file-lock.js";
|
||||||
|
|
||||||
export type StructuralRequest = {
|
export type StructuralRequest = {
|
||||||
kind: "workspace" | "interface" | "package";
|
kind: "workspace" | "interface" | "package";
|
||||||
source?: {repository: string; commit: string};
|
source?: {repository: string; commit: string};
|
||||||
resourceRoot?: string;
|
resourceRoot?: string;
|
||||||
files: ({file: string; edits: StructuralEdit[]} | {file: string; create: string} | {file: string; generated: string})[];
|
validation?: "syntax" | "resource-graph";
|
||||||
|
files: ({file: string; edits: StructuralEdit[]} | {file: string; create: string} | {file: string; generated: string} | {file: string; expected: string; replace: string})[];
|
||||||
};
|
};
|
||||||
type Change = {file: string; before: string | null; after: string; mode: number};
|
type Change = {file: string; before: string | null; after: string; mode: number};
|
||||||
type Journal = {schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[]};
|
type Journal = {schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[]};
|
||||||
const safeFile = (file: string) => {
|
const safeFile = (file: string) => {
|
||||||
if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|lock|ts|tsx|json|nix|txtpb))$/.test(file)
|
if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|lock|ts|tsx|mjs|json|nix|txtpb))$/.test(file)
|
||||||
|| file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))) throw new Error(`Unsafe scaffold path ${file}`);
|
|| file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))) throw new Error(`Unsafe scaffold path ${file}`);
|
||||||
};
|
};
|
||||||
const read = async (root: string, file: string): Promise<string | null> => {
|
const read = async (root: string, file: string): Promise<string | null> => {
|
||||||
@@ -50,6 +54,7 @@ const durableJson = async (file: string, value: unknown) => {
|
|||||||
|
|
||||||
/** Validate the entire edited resource graph in a private snapshot before writes. */
|
/** Validate the entire edited resource graph in a private snapshot before writes. */
|
||||||
export const planStructure = async (rootPath: string, request: StructuralRequest, snapshotMap?: string) => {
|
export const planStructure = async (rootPath: string, request: StructuralRequest, snapshotMap?: string) => {
|
||||||
|
if (request.validation && !["syntax", "resource-graph"].includes(request.validation)) throw new Error("Unknown structural validation mode");
|
||||||
const root = await fs.realpath(rootPath);
|
const root = await fs.realpath(rootPath);
|
||||||
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-structure-"));
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-structure-"));
|
||||||
try {
|
try {
|
||||||
@@ -65,6 +70,9 @@ export const planStructure = async (rootPath: string, request: StructuralRequest
|
|||||||
if ("create" in input) {
|
if ("create" in input) {
|
||||||
if (before !== null || typeof input.create !== "string") throw new Error("Scaffold creation cannot replace an existing file");
|
if (before !== null || typeof input.create !== "string") throw new Error("Scaffold creation cannot replace an existing file");
|
||||||
after = input.create;
|
after = input.create;
|
||||||
|
} else if ("replace" in input) {
|
||||||
|
if (before !== input.expected || typeof input.replace !== "string") throw new Error(`Stale imperative edit: ${input.file}`);
|
||||||
|
after = input.replace;
|
||||||
} else if ("generated" in input) {
|
} else if ("generated" in input) {
|
||||||
const generated = (text: string) => text.startsWith("// Generated by qx-scaffold-v1\n") || text.startsWith("# Generated by qx-scaffold-v1\n") || (() => {try {return JSON.parse(text).generatedBy === "qx-scaffold-v1";} catch {return false;}})();
|
const generated = (text: string) => 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");
|
if (typeof input.generated !== "string" || !generated(input.generated) || (before !== null && !generated(before))) throw new Error("Only scaffold-owned generated files may be regenerated");
|
||||||
@@ -79,6 +87,12 @@ export const planStructure = async (rootPath: string, request: StructuralRequest
|
|||||||
await containedParent(snapshot.directory, input.file);
|
await containedParent(snapshot.directory, input.file);
|
||||||
await fs.writeFile(path.join(snapshot.directory, input.file), after);
|
await fs.writeFile(path.join(snapshot.directory, input.file), after);
|
||||||
}
|
}
|
||||||
|
if (request.validation === "syntax") {
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.file.endsWith(".qx") && parseQx(change.after, change.file).diagnostics.length) throw new Error(`Invalid QX syntax in ${change.file}`);
|
||||||
|
if (change.file.endsWith(".lock") && !parseQuixosLockDocument(change.after, change.file).ok) throw new Error(`Invalid lock syntax in ${change.file}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const localMap = path.join(temporary, "local-resources.json");
|
const localMap = path.join(temporary, "local-resources.json");
|
||||||
await fs.writeFile(localMap, JSON.stringify(await localResourceSnapshots(root, snapshotMap)));
|
await fs.writeFile(localMap, JSON.stringify(await localResourceSnapshots(root, snapshotMap)));
|
||||||
const resolveResource = await createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resources"), snapshotMap: localMap});
|
const resolveResource = await createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resources"), snapshotMap: localMap});
|
||||||
@@ -87,13 +101,10 @@ export const planStructure = async (rootPath: string, request: StructuralRequest
|
|||||||
if (request.kind === "workspace") await compileWorkspaceRepository({rootDirectory: resourceRoot, resolveResource});
|
if (request.kind === "workspace") await compileWorkspaceRepository({rootDirectory: resourceRoot, resolveResource});
|
||||||
else if (["package", "interface"].includes(request.kind) && request.source) {
|
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});
|
const compiled = await compileCapabilityResourceRepository({rootDirectory: resourceRoot, kind: request.kind as "package" | "interface", source: {resolver: "git", ...request.source}, resolveResource});
|
||||||
let scaffoldOwned = false;
|
if (compiled.resource.kind === "package") {
|
||||||
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 configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8"));
|
||||||
const artifacts = [
|
const artifacts = [
|
||||||
{file: configuration.bindingOutput as string, after: generateTypeScriptBindings(bindingSchema(compiled), compiled.resource.revision.revisionId, configuration.options)},
|
{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) {
|
for (const artifact of artifacts) {
|
||||||
const file = request.resourceRoot ? `${request.resourceRoot}/${artifact.file}` : artifact.file;
|
const file = request.resourceRoot ? `${request.resourceRoot}/${artifact.file}` : artifact.file;
|
||||||
@@ -108,11 +119,12 @@ export const planStructure = async (rootPath: string, request: StructuralRequest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else throw new Error("Resource plans require kind and exact authored source identity");
|
else throw new Error("Resource plans require kind and exact authored source identity");
|
||||||
|
}
|
||||||
if (changes.length > 100) throw new Error("Structural plan including generated artifacts exceeds 100 files");
|
if (changes.length > 100) throw new Error("Structural plan including generated artifacts exceeds 100 files");
|
||||||
// Validation may fetch dependencies; reject edits made while it was running.
|
// Validation may fetch dependencies; reject edits made while it was running.
|
||||||
for (const entry of changes) if (await read(root, entry.file) !== entry.before) throw new Error(`Source changed while planning: ${entry.file}`);
|
for (const entry of changes) 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}`);
|
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};
|
return {root, changes, observed, digest: contentDigest(changes), validation: request.validation ?? "resource-graph" as const};
|
||||||
} finally { await fs.rm(temporary, {recursive: true, force: true}); }
|
} finally { await fs.rm(temporary, {recursive: true, force: true}); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -154,15 +166,7 @@ const replayStructure = async (rootPath: string, id: string) => {
|
|||||||
const withStructureLock = async <T>(root: string, work: () => Promise<T>) => {
|
const withStructureLock = async <T>(root: string, work: () => Promise<T>) => {
|
||||||
await containedParent(root, ".quixos/scaffolds/placeholder.json");
|
await containedParent(root, ".quixos/scaffolds/placeholder.json");
|
||||||
const lock = path.join(root, ".quixos", "scaffolds", "writer.lock");
|
const lock = path.join(root, ".quixos", "scaffolds", "writer.lock");
|
||||||
// Never steal a possibly live writer's lock. A process crash requires the
|
return withFileLock(lock, work);
|
||||||
// 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) => {
|
export const resumeStructure = async (rootPath: string, id: string) => {
|
||||||
const root = await fs.realpath(rootPath);
|
const root = await fs.realpath(rootPath);
|
||||||
|
|||||||
@@ -1,19 +1,191 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
|
import { readFile, writeFile, mkdtemp, rm, realpath } from "node:fs/promises";
|
||||||
import { parseQx, formatQx, lintQx } from "./source.js";
|
import { parseQx, formatQx, lintQx } from "./source.js";
|
||||||
import { scaffoldAtom } from "./scaffold.js";
|
import { scaffoldAtom } from "./scaffold.js";
|
||||||
import { createGitCapabilityResolver } from "./git-resolver.js";
|
import { createGitCapabilityResolver } from "./git-resolver.js";
|
||||||
import { planEvolution } from "../capability-model/index.js";
|
import { planEvolution } from "../capability-model/index.js";
|
||||||
import { checkWorkspaceCandidate, checkResourceCandidate, snapshotRepository } from "./candidate-check.js";
|
import { snapshotRepository } from "./candidate-check.js";
|
||||||
import {planPinUpgrades, applyPinUpgrades, discoverUpgradeSpec, type UpgradeSpec} from "./pin-upgrades.js";
|
import {planPinUpgrades, applyPinUpgrades, discoverUpgradeSpec, type UpgradeSpec} from "./pin-upgrades.js";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import {spawnSync} from "node:child_process";
|
import {spawnSync} from "node:child_process";
|
||||||
import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js";
|
import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js";
|
||||||
import {scaffoldRecipe, type ScaffoldRecipe} from "./scaffold-recipes.js";
|
import {scaffoldRecipe, type ScaffoldRecipe} from "./scaffold-recipes.js";
|
||||||
|
import {checkBundleSources} from "../bindings/bundle-policy.js";
|
||||||
|
import {sealMigrations} from "./migration-seal.js";
|
||||||
|
import {generatePackageDescriptor} from "../bindings/index.js";
|
||||||
|
import {buildCheckedPackage, buildImmutableCandidate, snapshotCommit} from "./checked-build.js";
|
||||||
|
import {formatQuixosLock, loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js";
|
||||||
|
import { walkSyntax } from "./source.js";
|
||||||
|
import { inspectWorkbench } from "./authoring-inspect.js";
|
||||||
|
import { authoringContext } from "./authoring-context.js";
|
||||||
|
import { convergeAuthoring } from "./authoring-converge.js";
|
||||||
|
import { checkAuthoring } from "./authoring-check.js";
|
||||||
|
import { authoringWorklist } from "./authoring-worklist.js";
|
||||||
|
|
||||||
|
const authorSource = async (root: string) => {
|
||||||
|
const result = spawnSync("git", ["config", "--get", "remote.origin.url"], {cwd: root, encoding: "utf8"});
|
||||||
|
if (result.error || result.status !== 0) throw new Error("Managed resource has no origin");
|
||||||
|
return {repository: result.stdout.trim(), commit: await snapshotCommit(root)};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readSpec = async (value: string) => {
|
||||||
|
if (value === "-") { let input = ""; for await (const chunk of process.stdin) { input += chunk; if (input.length > 1024 * 1024) throw new Error("Scaffold specification exceeds 1 MiB"); } return JSON.parse(input); }
|
||||||
|
return JSON.parse(value.trimStart().startsWith("{") ? value : await readFile(value, "utf8"));
|
||||||
|
};
|
||||||
|
const planSummary = (plan: Awaited<ReturnType<typeof planStructure>>) => ({
|
||||||
|
root: plan.root, validation: plan.validation,
|
||||||
|
changes: plan.changes.map(change => ({file: change.file, beforeBytes: change.before?.length ?? 0, afterBytes: change.after?.length ?? 0})),
|
||||||
|
note: "Structural plan only, not implementation verification. Run qx-workspace check while iterating.",
|
||||||
|
});
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
const [command, ...args] = process.argv.slice(2);
|
const [command, ...args] = process.argv.slice(2);
|
||||||
|
if (command === "package-identity") {
|
||||||
|
if (args.length !== 1) throw new Error("usage: quixos-qx package-identity PACKAGE_QX");
|
||||||
|
const authored = await readFile(args[0], "utf8");
|
||||||
|
const syntax = parseQx(authored);
|
||||||
|
const declarations = [...walkSyntax(syntax.root)].filter(node => node.kind === "packageResourceDecl");
|
||||||
|
if (syntax.diagnostics.length || declarations.length !== 1) throw new Error("Expected one valid package declaration");
|
||||||
|
const literals = declarations[0].children.filter(node => node.kind === "stringLiteral");
|
||||||
|
process.stdout.write(JSON.stringify({id: JSON.parse(authored.slice(literals[0].start, literals[0].end)),
|
||||||
|
revision: JSON.parse(authored.slice(literals[1].start, literals[1].end))}) + "\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "package-descriptor") {
|
||||||
|
if (args.length !== 2) throw new Error("usage: quixos-qx package-descriptor SCHEMA REVISION_ID");
|
||||||
|
process.stdout.write(generatePackageDescriptor(JSON.parse(await readFile(args[0], "utf8")), args[1]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "migration-seal") {
|
||||||
|
if (args.length !== 1) throw new Error("usage: quixos-qx migration-seal PACKAGE_DIRECTORY");
|
||||||
|
process.stdout.write(JSON.stringify(await sealMigrations(args[0])) + "\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "bundle-policy") {
|
||||||
|
if (args.length !== 1) throw new Error("usage: quixos-qx bundle-policy SOURCE_DIRECTORY");
|
||||||
|
await checkBundleSources(args[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "worklist" && !args.includes("--help")) {
|
||||||
|
if (args.length !== 1) throw new Error("usage: quixos-qx worklist WORKBENCH");
|
||||||
|
process.stdout.write(`${JSON.stringify(await authoringWorklist(args[0]), null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (["author-check", "author-contract"].includes(command) && !args.includes("--help")) {
|
||||||
|
const [root, output, ...flags] = args;
|
||||||
|
if (!root || !output) throw new Error("usage: quixos-qx author-check ROOT OUTPUT [--baseline FILE] [--reviews FILE]");
|
||||||
|
const options: {baseline?: string; reviews?: string} = {};
|
||||||
|
for (let index = 0; index < flags.length; index += 2) {
|
||||||
|
if (!flags[index + 1]) throw new Error("Missing check option value");
|
||||||
|
if (flags[index] === "--baseline") options.baseline = flags[index + 1];
|
||||||
|
else if (flags[index] === "--reviews") options.reviews = flags[index + 1];
|
||||||
|
else throw new Error(`Unknown check option ${flags[index]}`);
|
||||||
|
}
|
||||||
|
const result = await checkAuthoring(root, output, {...options, contractOnly: command === "author-contract"});
|
||||||
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
if (result.blockers.length) process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (["converge", "_converge"].includes(command) && !args.includes("--help")) {
|
||||||
|
if (!args.length || args.length > 2) throw new Error("usage: quixos-qx converge WORKBENCH [REGISTERED_DIRECTORY] (join package writers first)");
|
||||||
|
const context = await authoringContext(args[0]);
|
||||||
|
if (command === "converge") {
|
||||||
|
const result = spawnSync("flock", ["--exclusive", "--timeout", "120", "--conflict-exit-code", "75", path.join(context.workbench, ".quixos/converge.lock"),
|
||||||
|
process.execPath, process.argv[1], "_converge", context.workbench, ...(args[1] ? [args[1]] : [])], {stdio: "inherit"});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status === 75) process.stderr.write("Timed out after 120 seconds waiting for source capture; inspect the active coordinator. No build lock is held.\n");
|
||||||
|
process.exitCode = result.status ?? 1; return;
|
||||||
|
}
|
||||||
|
const result = await convergeAuthoring(context.workbench, args[1]);
|
||||||
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
if (!result.converged) process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "check-committed" && !args.includes("--help")) {
|
||||||
|
const [kind, repository, commit, log, ...extra] = args;
|
||||||
|
if (!["workspace", "interface", "package"].includes(kind) || !log || extra.length) throw new Error("usage: quixos-qx check-committed workspace|interface|package REPOSITORY COMMIT LOG_FILE");
|
||||||
|
process.stdout.write(`${await buildImmutableCandidate({repository, commit}, kind as "workspace" | "interface" | "package", log)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!command || command === "--help" || args.includes("--help")) {
|
||||||
|
process.stdout.write("quixos-qx: author-check, author-contract, converge, worklist, inspect, resources, check-committed, source-baseline, scaffold-package, scaffold-interface, scaffold-function, scaffold-dependency, scaffold-structure, scaffold-resume, pin-upgrade, parse, lint, format\n" +
|
||||||
|
"inspect WORKBENCH [RESOURCE] shows provisional contracts, with explicit historical fallback; never verification evidence.\n" +
|
||||||
|
"resources WORKBENCH lists registered editable repositories. Use qx-workspace for the workspace authoring workflow.\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "inspect" || command === "resources") {
|
||||||
|
if (!args[0] || args.length > (command === "inspect" ? 2 : 1)) throw new Error(`usage: quixos-qx ${command} WORKBENCH${command === "inspect" ? " [RESOURCE]" : ""}`);
|
||||||
|
const result = command === "inspect" ? await inspectWorkbench(args[0], args[1]) : await authoringContext(args[0]);
|
||||||
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "source-baseline") {
|
||||||
|
if (args.length !== 1) throw new Error("usage: quixos-qx source-baseline WORKBENCH");
|
||||||
|
process.stdout.write(`${JSON.stringify(await (await authoringContext(args[0])).baseline())}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "scaffold-dependency") {
|
||||||
|
const [root, kind, name, ...remaining] = args;
|
||||||
|
let repository: string | undefined, commit: string | undefined;
|
||||||
|
const flags = [...remaining];
|
||||||
|
if (flags.length && !flags[0].startsWith("--")) { repository = flags.shift(); commit = flags.shift(); }
|
||||||
|
else if (root && ["interface", "package"].includes(kind) && name) {
|
||||||
|
const context = await authoringContext(root);
|
||||||
|
const alias = await realpath(path.join(context.workbench, `${kind}s`, name)).catch(() => null);
|
||||||
|
const matches = context.resources.filter(entry => entry.kind === kind && (entry.resourceId === name || path.basename(entry.directory) === name || path.join(context.workbench, entry.directory) === alias));
|
||||||
|
if (matches.length !== 1 || !matches[0].source) throw new Error(`Select exactly one registered ${kind} with qx-workspace resources; no match for ${name}`);
|
||||||
|
const selected = matches[0];
|
||||||
|
let source = selected.source!;
|
||||||
|
if (flags.includes("--write")) {
|
||||||
|
const retained = spawnSync("quixos-qx", ["converge", context.workbench, selected.directory], {encoding: "utf8", env: {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"}});
|
||||||
|
if (retained.error || retained.status !== 0) throw new Error(`Dependency source needs attention: ${retained.error?.message ?? retained.stdout ?? retained.stderr}`);
|
||||||
|
source = JSON.parse(retained.stdout).candidate;
|
||||||
|
}
|
||||||
|
repository = source.repository; commit = source.commit;
|
||||||
|
}
|
||||||
|
if (!root || !["interface", "package"].includes(kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name ?? "") || !repository || !commit || flags.some(flag => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-dependency ROOT interface|package NAME REPOSITORY COMMIT [--write]");
|
||||||
|
const resourceKind = kind as "package" | "interface";
|
||||||
|
let entrypoint: "workspace" | "package" | "interface" | undefined;
|
||||||
|
for (const candidate of ["workspace", "package", "interface"] as const) {
|
||||||
|
try {await readFile(path.join(root, `${candidate}.qx`)); if (entrypoint) throw new Error("Ambiguous repository entrypoint"); entrypoint = candidate;}
|
||||||
|
catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;}
|
||||||
|
}
|
||||||
|
if (!entrypoint) throw new Error("No QX repository entrypoint");
|
||||||
|
const lock = await loadQuixosLock(path.join(root, "quixos.lock"));
|
||||||
|
if (!lock.ok) throw new Error("Invalid resource lock");
|
||||||
|
let target = "quixos.lock";
|
||||||
|
for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) {
|
||||||
|
const parsed = parseQuixosLockDocument(await readFile(path.join(root, file), "utf8"));
|
||||||
|
if (parsed.ok && parsed.document.resources.some(entry => entry.kind === kind && entry.binding === name)) target = file;
|
||||||
|
}
|
||||||
|
const request: StructuralRequest = {kind: entrypoint, source: await authorSource(root), validation: "syntax", files: [
|
||||||
|
{file: `${entrypoint}.qx`, edits: [{operation: "import", kind: resourceKind, name}]},
|
||||||
|
{file: target, edits: [{operation: "dependency", kind: resourceKind, name, source: {repository, commit}}]},
|
||||||
|
]};
|
||||||
|
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
|
||||||
|
process.stdout.write(`${JSON.stringify({...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "scaffold-interface") {
|
||||||
|
const [root, specFile, ...flags] = args;
|
||||||
|
if (!root || !specFile || flags.some(flag => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-interface ROOT SPEC_JSON [--write]");
|
||||||
|
const spec = await readSpec(specFile) as ScaffoldRecipe;
|
||||||
|
if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id || !spec.revision || !spec.tools?.quixos) throw new Error("Interface scaffold requires name, id, revision and Quixos toolchain source");
|
||||||
|
const request: StructuralRequest = {kind: "interface", source: spec.source, files: [
|
||||||
|
{file: "interface.qx", create: `interface ${spec.name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`},
|
||||||
|
{file: "quixos.lock", create: formatQuixosLock({formatVersion: 1, quixos: {resolver: "git", ...spec.tools.quixos}, resources: []})},
|
||||||
|
{file: ".gitignore", create: ".quixos/\n"},
|
||||||
|
]};
|
||||||
|
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
|
||||||
|
process.stdout.write(`${JSON.stringify({...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "build-package") {
|
||||||
|
if (args.length !== 3) throw new Error("usage: quixos-qx build-package COMMITTED_SOURCE SCHEMA PACKAGE_REVISION_ID");
|
||||||
|
process.stdout.write(`${await buildCheckedPackage(args[0], args[1], args[2])}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (command === "source-digest") {
|
if (command === "source-digest") {
|
||||||
if (!args[0] || args.length !== 1) throw new Error("usage: quixos-qx source-digest ROOT");
|
if (!args[0] || args.length !== 1) throw new Error("usage: quixos-qx source-digest ROOT");
|
||||||
const temporary = await mkdtemp(path.join(os.tmpdir(), "qx-source-digest-"));
|
const temporary = await mkdtemp(path.join(os.tmpdir(), "qx-source-digest-"));
|
||||||
@@ -37,40 +209,60 @@ const main = async () => {
|
|||||||
process.stdout.write(`${JSON.stringify(publish ? await applyPinUpgrades(plan, resume, undefined, {acceptEdits}) : plan, null, 2)}\n`);
|
process.stdout.write(`${JSON.stringify(publish ? await applyPinUpgrades(plan, resume, undefined, {acceptEdits}) : plan, null, 2)}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command === "check-resource") {
|
if (command === "scaffold-refresh") throw new Error("Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependency installation uses scaffold install.");
|
||||||
const [root, kind, repository, commit, output, ...extra] = args;
|
if (["scaffold-package", "scaffold-function", "scaffold-migration", "scaffold-install"].includes(command)) {
|
||||||
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;
|
const [root, specFile, ...flags] = args;
|
||||||
|
let spec: ScaffoldRecipe;
|
||||||
|
if (command === "scaffold-function" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(specFile ?? "")) {
|
||||||
|
const authored = parseQx(await readFile(path.join(root, "package.qx"), "utf8"));
|
||||||
|
const declarationNode = [...walkSyntax(authored.root)].find(node => node.kind === "packageResourceDecl");
|
||||||
|
const nameNode = declarationNode?.children.find(node => node.kind === "identifier");
|
||||||
|
if (!nameNode) throw new Error("Expected a package declaration");
|
||||||
|
const name = authored.source.slice(nameNode.start, nameNode.end);
|
||||||
|
spec = {source: await authorSource(root), name: specFile, id: `export:${name}:${specFile}`};
|
||||||
|
const declaration = flags.indexOf("--declaration");
|
||||||
|
if (declaration >= 0) {
|
||||||
|
if (!flags[declaration + 1]) throw new Error("--declaration requires a QX declaration file");
|
||||||
|
spec.declaration = await readFile(flags[declaration + 1], "utf8");
|
||||||
|
const parsed = parseQx(`package Draft id "package:draft" revision "package:draft@1" { ${spec.declaration} }`);
|
||||||
|
if (parsed.diagnostics.length) throw new Error(parsed.diagnostics.map(d => d.message).join("\n"));
|
||||||
|
const exported = [...walkSyntax(parsed.root)].filter(node => ["packageOperationExport", "packageFunctionExport", "packageConstructorExport"].includes(node.kind));
|
||||||
|
if (exported.length !== 1) throw new Error("--declaration must contain exactly one function, operation or constructor export");
|
||||||
|
const literal = exported[0].children.find(node => node.kind === "stringLiteral");
|
||||||
|
if (!literal) throw new Error("Declaration requires an authored export ID");
|
||||||
|
spec.id = JSON.parse(parsed.source.slice(literal.start, literal.end));
|
||||||
|
flags.splice(declaration, 2);
|
||||||
|
}
|
||||||
|
} else spec = await readSpec(specFile) as ScaffoldRecipe;
|
||||||
if (!root || !specFile || flags.some((flag) => !["--write", "--install"].includes(flag)) || (flags.includes("--install") && !flags.includes("--write"))) throw new Error("usage: quixos-qx scaffold-package|function|migration|refresh ROOT SPEC_JSON [--write [--install]]");
|
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 plan = command === "scaffold-install" ? undefined : await planStructure(root,
|
||||||
const request = await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration" | "refresh", spec);
|
await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration", spec), process.env.QUIXOS_SNAPSHOT_MAP);
|
||||||
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
|
const applied = plan && flags.includes("--write") ? await applyStructure(plan) : undefined;
|
||||||
const applied = flags.includes("--write") ? await applyStructure(plan) : undefined;
|
|
||||||
if (flags.includes("--install")) {
|
if (flags.includes("--install")) {
|
||||||
const cwd = path.resolve(root, spec.directory ?? "");
|
const cwd = path.resolve(root, spec.directory ?? "");
|
||||||
const toolchain = JSON.parse(await readFile(path.join(cwd, "quixos.toolchain.json"), "utf8"));
|
const toolchain = JSON.parse(await readFile(path.join(cwd, "quixos.toolchain.json"), "utf8"));
|
||||||
if (toolchain.generatedBy !== "qx-scaffold-v1" || typeof toolchain.nixifyPluginUrl !== "string") throw new Error("Missing scaffold toolchain");
|
if (toolchain.generatedBy !== "qx-scaffold-v1" || typeof toolchain.nixifyPluginUrl !== "string") 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) {
|
for (const [executable, args] of [["corepack", ["yarn", "plugin", "import", toolchain.nixifyPluginUrl]], ["corepack", ["yarn", "config", "set", "generateDefaultNix", "false"]], ["corepack", ["yarn", "config", "set", "individualNixPackaging", "true"]], ["corepack", ["yarn", "install"]]] as const) {
|
||||||
const result = spawnSync(executable, [...args], {cwd, stdio: ["inherit", 2, 2]});
|
const result = spawnSync(executable, [...args], {cwd, stdio: ["inherit", 2, 2]});
|
||||||
if (result.error || result.status !== 0) throw new Error(`Scaffold files retained; ${executable} ${args.join(" ")} failed: ${result.error?.message ?? result.status}`);
|
if (result.error || result.status !== 0) throw new Error(`Scaffold files retained; ${executable} ${args.join(" ")} failed: ${result.error?.message ?? result.status}`);
|
||||||
}
|
}
|
||||||
|
try {await readFile(path.join(cwd, "yarn-project.nix"));}
|
||||||
|
catch {throw new Error("Nixify did not generate yarn-project.nix. It skips repositories under the OS temporary directory; use an ordinary workspace checkout and retry installation.");}
|
||||||
|
await snapshotCommit(cwd);
|
||||||
|
const locked = spawnSync("nix", ["flake", "lock"], {cwd, stdio: ["inherit", 2, 2]});
|
||||||
|
if (locked.error || locked.status !== 0) throw new Error("Scaffold files retained; nix flake lock failed");
|
||||||
}
|
}
|
||||||
process.stdout.write(`${JSON.stringify({...plan, applied}, null, 2)}\n`);
|
process.stdout.write(`${JSON.stringify({...plan ? planSummary(plan) : {installed: true}, applied}, null, 2)}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command === "scaffold-structure") {
|
if (command === "scaffold-structure") {
|
||||||
const [root, spec, ...flags] = args;
|
const [root, spec, ...flags] = args;
|
||||||
if (!root || !spec || flags.some((flag) => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-structure ROOT SPEC_JSON [--write]");
|
if (!root || !spec || flags.some((flag) => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-structure ROOT SPEC_JSON [--write]");
|
||||||
const request = JSON.parse(await readFile(spec, "utf8")) as StructuralRequest;
|
const request = await readSpec(spec) as StructuralRequest;
|
||||||
|
if (request.kind !== "workspace" && !request.source) request.source = await authorSource(root);
|
||||||
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
|
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP);
|
||||||
const applied = flags.includes("--write") ? await applyStructure(plan) : undefined;
|
const applied = flags.includes("--write") ? await applyStructure(plan) : undefined;
|
||||||
process.stdout.write(`${JSON.stringify({...plan, applied}, null, 2)}\n`);
|
process.stdout.write(`${JSON.stringify({...planSummary(plan), applied}, null, 2)}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command === "scaffold-resume") {
|
if (command === "scaffold-resume") {
|
||||||
@@ -79,19 +271,7 @@ const main = async () => {
|
|||||||
process.stdout.write(`${JSON.stringify(await resumeStructure(root, id), null, 2)}\n`);
|
process.stdout.write(`${JSON.stringify(await resumeStructure(root, id), null, 2)}\n`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command === "check") {
|
if (["check", "check-resource"].includes(command)) throw new Error("Use qx-workspace check in the registered repository; handwritten source/snapshot-map candidates are no longer an authoring check path");
|
||||||
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") {
|
if (command === "evolution") {
|
||||||
const [baseline, candidate, reviews, ...extra] = args;
|
const [baseline, candidate, reviews, ...extra] = args;
|
||||||
if (!baseline || !candidate || extra.length) throw new Error("usage: quixos-qx evolution BASELINE_JSON CANDIDATE_JSON [REVIEWS_JSON]");
|
if (!baseline || !candidate || extra.length) throw new Error("usage: quixos-qx evolution BASELINE_JSON CANDIDATE_JSON [REVIEWS_JSON]");
|
||||||
|
|||||||
@@ -20,13 +20,37 @@ const semantic = (value: unknown): unknown => {
|
|||||||
if (Array.isArray(value)) return value.map(semantic);
|
if (Array.isArray(value)) return value.map(semantic);
|
||||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value)
|
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value)
|
||||||
.filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation")
|
.filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation")
|
||||||
.map(([key, entry]) => [key, semantic(entry)]));
|
// These are authored data/maps, not schema nodes. A user field literally
|
||||||
|
// named displayName or documentation is semantic and must stay in the hash.
|
||||||
|
.map(([key, entry]) => [key, key === "defaultValue" || key === "fields" ? entry : semantic(entry)]));
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
const sorted = <T>(entries: readonly T[], key: (entry: T) => string) => [...entries].sort((a, b) => compareText(key(a), key(b)));
|
const sorted = <T>(entries: readonly T[], key: (entry: T) => string) => [...entries].sort((a, b) => compareText(key(a), key(b)));
|
||||||
export const conformanceIdentity = (entry: Conformance): string => entry.id ?? `legacy:${entry.atomId}:${entry.interfaceRevisionId}`;
|
export const conformanceIdentity = (entry: Conformance): string => entry.id ?? `legacy:${entry.atomId}:${entry.interfaceRevisionId}`;
|
||||||
|
|
||||||
export type StorageContract = { id: string; ownerId: string; kind: "state" | "edge"; digest: string; definition: unknown };
|
export type StorageContract = { id: string; ownerId: string; kind: "state" | "edge"; digest: string; definition: unknown };
|
||||||
|
/** Automatic evolution preserves values; it never interprets migration code or
|
||||||
|
* guesses that a new nominal message descriptor means the same representation. */
|
||||||
|
export const storageChangeRequiresMigration = (previous: StorageContract | undefined, next: StorageContract | undefined,
|
||||||
|
oldAtomIds: ReadonlySet<string>): boolean => {
|
||||||
|
if (!next) return true;
|
||||||
|
const after = next.definition as PersistentAttachment;
|
||||||
|
if (!previous) {
|
||||||
|
if (after.kind === "state") return oldAtomIds.has(after.attachedTo) && after.defaultValue === undefined && after.valueType.kind !== "optional";
|
||||||
|
return after.endpoints.some(endpoint => endpoint.cardinality === "exactly-one" &&
|
||||||
|
(endpoint.constraint.kind !== "atom" || oldAtomIds.has(endpoint.constraint.atomId)));
|
||||||
|
}
|
||||||
|
if (previous.ownerId !== next.ownerId || previous.kind !== next.kind) return true;
|
||||||
|
const before = previous.definition as PersistentAttachment;
|
||||||
|
if (before.kind === "state" && after.kind === "state") {
|
||||||
|
// Capture materializes old defaults, so changing a default affects only
|
||||||
|
// newly constructed objects, not existing sparse state.
|
||||||
|
const {defaultValue: _beforeDefault, ...beforeStorage} = before;
|
||||||
|
const {defaultValue: _afterDefault, ...afterStorage} = after;
|
||||||
|
return canonicalJson(beforeStorage) !== canonicalJson(afterStorage);
|
||||||
|
}
|
||||||
|
return canonicalJson(before) !== canonicalJson(after);
|
||||||
|
};
|
||||||
export const storageContracts = (workspace: WorkspaceRevision): StorageContract[] => {
|
export const storageContracts = (workspace: WorkspaceRevision): StorageContract[] => {
|
||||||
const result: StorageContract[] = [];
|
const result: StorageContract[] = [];
|
||||||
const add = (attachment: PersistentAttachment, ownerId: string) => {
|
const add = (attachment: PersistentAttachment, ownerId: string) => {
|
||||||
@@ -141,7 +165,8 @@ export type RuntimeAction = { groupId: string; action: "keep" | "start" | "repla
|
|||||||
export type EvolutionReport = {
|
export type EvolutionReport = {
|
||||||
schemaVersion: 1; baselineDigest: string | null; candidateDigest: string; checkerVersion: string;
|
schemaVersion: 1; baselineDigest: string | null; candidateDigest: string; checkerVersion: string;
|
||||||
runtimeActions: RuntimeAction[];
|
runtimeActions: RuntimeAction[];
|
||||||
storageChanges: Array<{ id: string; kind: "add" | "remove" | "change"; previous?: StorageContract; candidate?: StorageContract }>;
|
storageChanges: Array<{ id: string; kind: "add" | "remove" | "change"; requiresMigration: boolean; previous?: StorageContract; candidate?: StorageContract }>;
|
||||||
|
migrationRequired: string[];
|
||||||
reviews: Array<ReviewRequirement & { accepted: boolean }>;
|
reviews: Array<ReviewRequirement & { accepted: boolean }>;
|
||||||
packageChecks: Array<{ groupId: string; contractDigest: string }>;
|
packageChecks: Array<{ groupId: string; contractDigest: string }>;
|
||||||
blockers: string[];
|
blockers: string[];
|
||||||
@@ -175,6 +200,7 @@ export const planEvolution = (baseline: WorkspaceRevision | null, candidate: Wor
|
|||||||
for (const id of [...new Set([...beforeStorage.keys(), ...afterStorage.keys()])].sort()) {
|
for (const id of [...new Set([...beforeStorage.keys(), ...afterStorage.keys()])].sort()) {
|
||||||
const previous = beforeStorage.get(id), next = afterStorage.get(id);
|
const previous = beforeStorage.get(id), next = afterStorage.get(id);
|
||||||
if (previous?.digest !== next?.digest) storageChanges.push({ id, kind: !previous ? "add" : !next ? "remove" : "change",
|
if (previous?.digest !== next?.digest) storageChanges.push({ id, kind: !previous ? "add" : !next ? "remove" : "change",
|
||||||
|
requiresMigration: storageChangeRequiresMigration(previous, next, new Set(baseline?.atoms.map(atom => atom.id) ?? [])),
|
||||||
...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}) });
|
...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}) });
|
||||||
}
|
}
|
||||||
const providers = (workspace: WorkspaceRevision) => [
|
const providers = (workspace: WorkspaceRevision) => [
|
||||||
@@ -201,6 +227,6 @@ export const planEvolution = (baseline: WorkspaceRevision | null, candidate: Wor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { schemaVersion: 1, baselineDigest: baseline ? contentDigest(baseline) : null, candidateDigest, checkerVersion,
|
return { schemaVersion: 1, baselineDigest: baseline ? contentDigest(baseline) : null, candidateDigest, checkerVersion,
|
||||||
runtimeActions, storageChanges, reviews, packageChecks: runtimeActions.filter((entry) => entry.candidate && entry.action !== "keep")
|
runtimeActions, storageChanges, migrationRequired: storageChanges.filter(entry => entry.requiresMigration).map(entry => entry.id), reviews, packageChecks: runtimeActions.filter((entry) => entry.candidate && entry.action !== "keep")
|
||||||
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })), blockers };
|
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })), blockers };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1467,8 +1467,12 @@ const validateConformances = (
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
validateAttachmentAccess(issues, attachment, conformance, `${bindingPath}.binding.edgeTypeId`);
|
|
||||||
const projection = edgeProjection(attachment.attachment, binding.projectionId);
|
const projection = edgeProjection(attachment.attachment, binding.projectionId);
|
||||||
|
// An owned endpoint may explicitly export read-only traversal, including
|
||||||
|
// a native inverse relationship view. This never exports mutation rights.
|
||||||
|
if (!(binding.primitive === "resolve" && projection?.endpoint.publicTraversal)) {
|
||||||
|
validateAttachmentAccess(issues, attachment, conformance, `${bindingPath}.binding.edgeTypeId`);
|
||||||
|
}
|
||||||
const relationshipMember = operationEntry.member.kind === "relationship"
|
const relationshipMember = operationEntry.member.kind === "relationship"
|
||||||
? operationEntry.member
|
? operationEntry.member
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
+78
-11
File diff suppressed because one or more lines are too long
@@ -0,0 +1,84 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { convergeAuthoring } from "../src/capability-language/authoring-converge.js";
|
||||||
|
import { loadQuixosLock } from "../src/resource-lock/index.js";
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
|
||||||
|
test("source convergence propagates nested edits and unchanged snapshots reach a fixed point", async context => {
|
||||||
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-converge-test-"));
|
||||||
|
context.after(() => fs.rm(temporary, { recursive: true, force: true }));
|
||||||
|
const workbench = path.join(temporary, "workbench"), remotes = path.join(temporary, "remotes");
|
||||||
|
await fs.mkdir(path.join(workbench, ".quixos"), { recursive: true });
|
||||||
|
await fs.mkdir(remotes);
|
||||||
|
const origin = "https://convergence.example.test/";
|
||||||
|
const previous = Object.fromEntries(["GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"].map(key => [key, process.env[key]]));
|
||||||
|
process.env.GIT_CONFIG_COUNT = "1";
|
||||||
|
process.env.GIT_CONFIG_KEY_0 = `url.file://${remotes}/.insteadOf`;
|
||||||
|
process.env.GIT_CONFIG_VALUE_0 = origin;
|
||||||
|
context.after(() => { for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } });
|
||||||
|
const resources = [];
|
||||||
|
let dependency = "";
|
||||||
|
for (const [directory, kind, name] of [["resources/Base", "interface", "Base"], ["resources/Consumer", "package", "Consumer"], ["root", "workspace", "Root"]]) {
|
||||||
|
const root = path.join(workbench, directory);
|
||||||
|
await fs.mkdir(root, { recursive: true });
|
||||||
|
await execFile("jj", ["git", "init", "--colocate", root]);
|
||||||
|
await execFile("git", ["init", "--bare", path.join(remotes, name)]);
|
||||||
|
const repository = `${origin}${name}`;
|
||||||
|
await execFile("git", ["-C", root, "remote", "add", "origin", repository]);
|
||||||
|
await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } ${dependency} }`);
|
||||||
|
await fs.writeFile(path.join(root, `${kind}.qx`), "draft");
|
||||||
|
await execFile("jj", ["status"], { cwd: root });
|
||||||
|
const commit = (await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], { cwd: root })).stdout.trim();
|
||||||
|
if (kind !== "workspace") resources.push({ directory, kind, resourceId: `${kind}:${name}`, source: { resolver: "git", repository, commit } });
|
||||||
|
dependency = `${kind} ${name} source { repository "${repository}"; commit "${commit}"; }`;
|
||||||
|
}
|
||||||
|
await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({ resources }));
|
||||||
|
const first = await convergeAuthoring(workbench);
|
||||||
|
assert.deepEqual(first.worklist, []);
|
||||||
|
assert.equal(first.converged, true);
|
||||||
|
assert.equal(first.verificationEvidence, false, "source retention never approves even syntactically invalid code");
|
||||||
|
await fs.writeFile(path.join(workbench, "resources/Base/interface.qx"), "edited draft");
|
||||||
|
const second = await convergeAuthoring(workbench);
|
||||||
|
assert.deepEqual(second.worklist, []);
|
||||||
|
assert.notEqual(second.candidate?.commit, first.candidate?.commit);
|
||||||
|
const consumer = await loadQuixosLock(path.join(workbench, "resources/Consumer/quixos.lock"));
|
||||||
|
assert.ok(consumer.ok);
|
||||||
|
assert.equal(consumer.lock.resources[0].source.commit, second.retained.find(entry => entry.directory === "resources/Base")?.source.commit);
|
||||||
|
const third = await convergeAuthoring(workbench);
|
||||||
|
assert.deepEqual(third, second);
|
||||||
|
const base = path.join(workbench, "resources/Base");
|
||||||
|
const consumerRoot = path.join(workbench, "resources/Consumer");
|
||||||
|
const goodLock = await fs.readFile(path.join(consumerRoot, "quixos.lock"), "utf8");
|
||||||
|
await fs.writeFile(path.join(consumerRoot, "quixos.lock"), "broken draft");
|
||||||
|
const scoped = await convergeAuthoring(workbench, "resources/Base");
|
||||||
|
assert.deepEqual(scoped.worklist, [], "an unrelated malformed consumer cannot block a provider check");
|
||||||
|
await fs.writeFile(path.join(base, "interface.qx"), "another edit");
|
||||||
|
const partial = await convergeAuthoring(workbench);
|
||||||
|
assert.equal(partial.converged, false);
|
||||||
|
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
||||||
|
assert.equal(graph.resources[0].source.commit, partial.retained.find(entry => entry.directory === "resources/Base")?.source.commit);
|
||||||
|
await fs.writeFile(path.join(consumerRoot, "quixos.lock"), goodLock);
|
||||||
|
const resumed = await convergeAuthoring(workbench);
|
||||||
|
assert.deepEqual(resumed.worklist, []);
|
||||||
|
assert.deepEqual(await convergeAuthoring(workbench), resumed);
|
||||||
|
await execFile("git", ["-C", base, "remote", "set-url", "origin", origin + "Wrong"]);
|
||||||
|
const mismatch = await convergeAuthoring(workbench);
|
||||||
|
assert.ok(mismatch.worklist.some(entry => entry.phase === "source" && /Origin differs/.test(entry.message)));
|
||||||
|
await execFile("git", ["-C", base, "remote", "set-url", "origin", origin + "Base"]);
|
||||||
|
await fs.rename(path.join(remotes, "Base"), path.join(remotes, "Base-offline"));
|
||||||
|
const offline = await convergeAuthoring(workbench);
|
||||||
|
assert.equal(offline.candidate, null);
|
||||||
|
assert.ok(offline.worklist.some(entry => entry.phase === "publication"));
|
||||||
|
await fs.rename(path.join(remotes, "Base-offline"), path.join(remotes, "Base"));
|
||||||
|
assert.equal((await convergeAuthoring(workbench)).converged, true);
|
||||||
|
const consumerSource = resumed.retained.find(entry => entry.directory === "resources/Consumer")!.source;
|
||||||
|
await fs.writeFile(path.join(base, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } package Consumer source { repository "${consumerSource.repository}"; commit "${consumerSource.commit}"; } }`);
|
||||||
|
const cycle = await convergeAuthoring(workbench);
|
||||||
|
assert.equal(cycle.candidate, null);
|
||||||
|
assert.ok(cycle.worklist.some(entry => /Source dependency cycle/.test(entry.message)));
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, writeFile, rm, symlink } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { inspectAuthoringRepository } from "../src/capability-language/authoring-inspect.js";
|
||||||
|
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
test("inspection keeps current files and labels historical recovery without granting verification", async context => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "qx-inspection-test-"));
|
||||||
|
context.after(() => rm(root, { recursive: true, force: true }));
|
||||||
|
const git = (...args: string[]) => execFile("git", ["-C", root, ...args]);
|
||||||
|
await git("init");
|
||||||
|
await writeFile(path.join(root, "interface.qx"), 'interface Example id "interface:example" revision "interface:example@1" {}');
|
||||||
|
await git("add", ".");
|
||||||
|
await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract");
|
||||||
|
const commit = (await git("rev-parse", "HEAD")).stdout.trim();
|
||||||
|
await writeFile(path.join(root, "interface.qx"), "interface broken {{{");
|
||||||
|
await writeFile(path.join(root, "new.qx"), 'interface New id "interface:new" revision "interface:new@1" {}');
|
||||||
|
const inspected = await inspectAuthoringRepository(root);
|
||||||
|
assert.equal(inspected.verificationEvidence, false);
|
||||||
|
assert.equal(inspected.resolutionChecked, false);
|
||||||
|
assert.equal(inspected.files[0].status, "historical");
|
||||||
|
assert.equal(inspected.files[0].revision, commit);
|
||||||
|
assert.ok(inspected.files[0].currentErrors.length);
|
||||||
|
assert.match(inspected.files[0].declarations[0].source, /Example/);
|
||||||
|
assert.equal(inspected.files[1].status, "current");
|
||||||
|
assert.match(inspected.files[1].declarations[0].source, /New/);
|
||||||
|
assert.equal((await git("rev-parse", "HEAD")).stdout.trim(), commit);
|
||||||
|
await symlink(path.join(root, "interface.qx"), path.join(root, "linked.qx"));
|
||||||
|
const linked = (await inspectAuthoringRepository(root)).files.find(file => file.file === "linked.qx")!;
|
||||||
|
assert.equal(linked.status, "unavailable");
|
||||||
|
assert.deepEqual(linked.declarations, []);
|
||||||
|
assert.match(JSON.stringify(linked.currentErrors), /ordinary files/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {execFile as callback} from "node:child_process";
|
||||||
|
import {promisify} from "node:util";
|
||||||
|
import {authoringWorklist} from "../src/capability-language/authoring-worklist.js";
|
||||||
|
import {checkRecordName} from "../src/capability-language/authoring-check.js";
|
||||||
|
import {checkerIdentity, snapshotCommit} from "../src/capability-language/checked-build.js";
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
|
||||||
|
test("worklist grows and clears from current source, dependency and checker observations", async context => {
|
||||||
|
const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-worklist-test-"));
|
||||||
|
context.after(() => fs.rm(workbench, {recursive: true, force: true}));
|
||||||
|
const root = path.join(workbench, "root"), provider = path.join(workbench, "resources/Base");
|
||||||
|
await fs.mkdir(root); await fs.mkdir(provider, {recursive: true});
|
||||||
|
await fs.mkdir(path.join(workbench, ".quixos/checks"), {recursive: true});
|
||||||
|
const header = `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; }`;
|
||||||
|
for (const directory of [root, provider]) await execFile("jj", ["git", "init", "--colocate", directory]);
|
||||||
|
await fs.writeFile(path.join(provider, "interface.qx"), 'interface Base id "interface:base" revision "interface:base@1" {}');
|
||||||
|
await fs.writeFile(path.join(provider, "quixos.lock"), `${header} }`);
|
||||||
|
const baseCommit = await snapshotCommit(provider);
|
||||||
|
await fs.writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"b".repeat(40)}" { import interface Base; }`);
|
||||||
|
await fs.writeFile(path.join(root, "quixos.lock"), `${header} interface Base source { repository "https://example.test/base"; commit "${baseCommit}"; } }`);
|
||||||
|
const rootCommit = await snapshotCommit(root);
|
||||||
|
await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [
|
||||||
|
{kind: "interface", directory: provider, source: {resolver: "git", repository: "https://example.test/base", commit: baseCommit}},
|
||||||
|
]}));
|
||||||
|
assert.equal((await authoringWorklist(workbench)).worklist.filter(entry => entry.phase === "unchecked").length, 2);
|
||||||
|
const remember = (directory: string, commit: string, checker = checkerIdentity()) => fs.writeFile(
|
||||||
|
path.join(workbench, ".quixos/checks", checkRecordName(directory)), JSON.stringify({commit, checker, phase: "checked", blockers: []}));
|
||||||
|
await remember("root", rootCommit); await remember("resources/Base", baseCommit);
|
||||||
|
assert.deepEqual((await authoringWorklist(workbench)).worklist, []);
|
||||||
|
await fs.writeFile(path.join(provider, "interface.qx"), "interface broken {{{");
|
||||||
|
const broken = await authoringWorklist(workbench);
|
||||||
|
assert.ok(broken.worklist.some(entry => entry.directory === "resources/Base" && entry.phase === "syntax" && /historical/.test(entry.message)));
|
||||||
|
assert.ok(broken.worklist.some(entry => entry.directory === "root" && entry.phase === "dependency"));
|
||||||
|
await fs.writeFile(path.join(provider, "interface.qx"), 'interface Base id "interface:base" revision "interface:base@1" {}');
|
||||||
|
assert.deepEqual((await authoringWorklist(workbench)).worklist, []);
|
||||||
|
await remember("resources/Base", baseCommit, "old-checker");
|
||||||
|
assert.ok((await authoringWorklist(workbench)).worklist.some(entry => /checker changed/.test(entry.message)));
|
||||||
|
await fs.writeFile(path.join(workbench, ".quixos/checks", checkRecordName("resources/Base")), JSON.stringify({phase: "publication", blockers: ["source retention unavailable"]}));
|
||||||
|
assert.ok((await authoringWorklist(workbench)).worklist.some(entry => entry.phase === "publication" && /source retention/.test(entry.message)));
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {bundlePolicyErrors} from "../src/bindings/bundle-policy.js";
|
||||||
|
import {addImplementation} from "../src/capability-language/implementation-edit.js";
|
||||||
|
|
||||||
|
test("bundled source policy rejects location and dynamic-loading assumptions, not static assets or runtime I/O", () => {
|
||||||
|
for (const code of ['new URL("../x", import.meta.url)', '__dirname', '__filename', 'import(name)', 'require(name)', 'eval(code)', 'new Function(code)'])
|
||||||
|
assert.ok(bundlePolicyErrors(code, "source.ts").length, code);
|
||||||
|
assert.deepEqual(bundlePolicyErrors('import source from "./component.js?browser-source"; import fs from "node:fs"; fs.readFile(userSelectedPath);', "source.ts"), []);
|
||||||
|
assert.deepEqual(bundlePolicyErrors('// import.meta.url\nconst text = "__dirname";', "source.ts"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imperative handler insertion preserves arbitrary existing code and rejects ambiguous targets", () => {
|
||||||
|
const original = 'const keep = "createRuntime({fake:1})";\nservePackageRuntime(createRuntime({ existing: customHandler }));\n';
|
||||||
|
const edited = addImplementation(original, "createRuntime", "newHandler", "./impl/new.js");
|
||||||
|
assert.match(edited, /existing: customHandler/);
|
||||||
|
assert.match(edited, /const keep =/);
|
||||||
|
assert.match(edited, /"newHandler": qxImplementation/);
|
||||||
|
assert.throws(() => addImplementation(original, "createRuntime", "existing", "./x.js"), /already exists/);
|
||||||
|
assert.throws(() => addImplementation('createRuntime(one);', "createRuntime", "x", "./x.js"), /Cannot safely/);
|
||||||
|
});
|
||||||
@@ -37,7 +37,7 @@ test("candidate check produces explicitly non-activation evidence and never over
|
|||||||
context.after(() => fs.rm(directory, { recursive: true, force: true }));
|
context.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||||
const root = path.join(directory, "source"), output = path.join(directory, "check");
|
const root = path.join(directory, "source"), output = path.join(directory, "check");
|
||||||
await fs.mkdir(root);
|
await fs.mkdir(root);
|
||||||
await execFile("git", ["-C", root, "init"]);
|
await execFile("jj", ["git", "init", "--colocate", root]);
|
||||||
await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${"a".repeat(40)}"; } }`);
|
await fs.writeFile(path.join(root, "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"; }`);
|
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});
|
const result = await checkWorkspaceCandidate({root, output});
|
||||||
|
|||||||
@@ -208,6 +208,23 @@ test("related-object dependency views cannot traverse another conformance's priv
|
|||||||
assert.equal(validateWorkspaceRevision(workspace).some((entry) => entry.code === "private-attachment-access"), false, "Only an explicitly exported read-only traversal crosses ownership");
|
assert.equal(validateWorkspaceRevision(workspace).some((entry) => entry.code === "private-attachment-access"), false, "Only an explicitly exported read-only traversal crosses ownership");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("public traversal permits a native inverse read without exporting mutation authority", () => {
|
||||||
|
const workspace = makeValidCapabilityWorkspace();
|
||||||
|
const owned = conformance(workspace, fixtureId.projectOwnedConformance);
|
||||||
|
const index = owned.privateAttachments.findIndex(entry => entry.kind === "edge" && entry.id === fixtureId.projectOwner);
|
||||||
|
const edge = owned.privateAttachments.splice(index, 1)[0];
|
||||||
|
assert.ok(edge?.kind === "edge");
|
||||||
|
conformance(workspace, fixtureId.projectNamedConformance).privateAttachments.push(edge);
|
||||||
|
expectIssue(workspace, "private-attachment-access");
|
||||||
|
edge.endpoints.find(endpoint => endpoint.projectionId === fixtureId.projectOwnerProjection)!.publicTraversal = true;
|
||||||
|
const readPath = `conformances[${workspace.conformances.indexOf(owned)}].operationBindings[0]`;
|
||||||
|
assert.equal(validateWorkspaceRevision(workspace).some(issue => issue.code === "private-attachment-access" && issue.path.startsWith(readPath)), false);
|
||||||
|
const binding = owned.operationBindings[0].binding;
|
||||||
|
assert.ok(binding.kind === "edge");
|
||||||
|
binding.primitive = "connect";
|
||||||
|
expectIssue(workspace, "private-attachment-access");
|
||||||
|
});
|
||||||
|
|
||||||
test("native state and edge providers must match operation shape", () => {
|
test("native state and edge providers must match operation shape", () => {
|
||||||
const stateWorkspace = makeValidCapabilityWorkspace();
|
const stateWorkspace = makeValidCapabilityWorkspace();
|
||||||
const state = conformance(
|
const state = conformance(
|
||||||
|
|||||||
+23
-1
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { capabilityId as id, valueType, validateWorkspaceRevision } from "../src/capability-model/index.js";
|
import { capabilityId as id, valueType, validateWorkspaceRevision } from "../src/capability-model/index.js";
|
||||||
import { contentDigest, planEvolution, runtimeContracts, storageContracts } from "../src/capability-model/evolution.js";
|
import { contentDigest, planEvolution, runtimeContracts, storageContracts, storageChangeRequiresMigration } from "../src/capability-model/evolution.js";
|
||||||
import { capabilityFixtureSource, capabilityResourceSources, compileCapabilityFixture, makeValidCapabilityWorkspace } from "./fixtures/capability-model.js";
|
import { capabilityFixtureSource, capabilityResourceSources, compileCapabilityFixture, makeValidCapabilityWorkspace } from "./fixtures/capability-model.js";
|
||||||
|
|
||||||
test("QX carries stable conformance IDs and implementation semantic majors", () => {
|
test("QX carries stable conformance IDs and implementation semantic majors", () => {
|
||||||
@@ -68,6 +68,7 @@ test("storage defaults and ownership changes invalidate consumers without requir
|
|||||||
if (slot.kind === "state") slot.defaultValue = "Different default";
|
if (slot.kind === "state") slot.defaultValue = "Different default";
|
||||||
const report = planEvolution(before, after, { allowLegacy: true });
|
const report = planEvolution(before, after, { allowLegacy: true });
|
||||||
assert.equal(report.storageChanges.length, 1);
|
assert.equal(report.storageChanges.length, 1);
|
||||||
|
assert.deepEqual(report.migrationRequired, []);
|
||||||
assert.equal(report.runtimeActions[0]!.action, "replace");
|
assert.equal(report.runtimeActions[0]!.action, "replace");
|
||||||
assert.deepEqual(report.reviews, []);
|
assert.deepEqual(report.reviews, []);
|
||||||
assert.notDeepEqual(storageContracts(before), storageContracts(after));
|
assert.notDeepEqual(storageContracts(before), storageContracts(after));
|
||||||
@@ -96,3 +97,24 @@ test("evolution enrollment is explicit and never erases legacy ownership", () =>
|
|||||||
assert.equal(runtimeContracts(workspace).length, 1);
|
assert.equal(runtimeContracts(workspace).length, 1);
|
||||||
assert.throws(() => planEvolution(workspace, { ...workspace, workspaceId: id.workspace("other") }), /different workspace/);
|
assert.throws(() => planEvolution(workspace, { ...workspace, workspaceId: id.workspace("other") }), /different workspace/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("automatic preservation distinguishes additions and defaults from incompatible storage", () => {
|
||||||
|
const workspace = makeValidCapabilityWorkspace();
|
||||||
|
const before = storageContracts(workspace).find(entry => entry.kind === "state")!;
|
||||||
|
const next = structuredClone(before);
|
||||||
|
const value = next.definition as Record<string, unknown>;
|
||||||
|
value.defaultValue = {displayName: "important user data"};
|
||||||
|
assert.equal(storageChangeRequiresMigration(before, next, new Set()), false);
|
||||||
|
next.ownerId = "another-owner";
|
||||||
|
assert.equal(storageChangeRequiresMigration(before, next, new Set()), true);
|
||||||
|
assert.equal(storageChangeRequiresMigration(before, undefined, new Set()), true);
|
||||||
|
delete value.defaultValue;
|
||||||
|
assert.equal(storageChangeRequiresMigration(undefined, next, new Set([value.attachedTo as string])), true);
|
||||||
|
assert.equal(storageChangeRequiresMigration(undefined, next, new Set()), false);
|
||||||
|
const slot = workspace.sharedAttachments.find(entry => entry.kind === "state")!;
|
||||||
|
if (slot.kind !== "state") throw new Error("fixture slot");
|
||||||
|
slot.defaultValue = {displayName: "one"};
|
||||||
|
const first = storageContracts(workspace);
|
||||||
|
slot.defaultValue = {displayName: "two"};
|
||||||
|
assert.notDeepEqual(storageContracts(workspace), first, "user data must not be stripped as schema metadata");
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {mkdtemp, rm} from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import {spawn} from "node:child_process";
|
||||||
|
import {once} from "node:events";
|
||||||
|
import {withFileLock} from "../src/capability-language/file-lock.js";
|
||||||
|
|
||||||
|
test("authoring lock excludes concurrent mutations and survives owner death", async context => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "qx-lock-test-"));
|
||||||
|
context.after(() => rm(root, {recursive: true, force: true}));
|
||||||
|
const filename = path.join(root, "lock");
|
||||||
|
const events: string[] = [];
|
||||||
|
let queued: Promise<void>;
|
||||||
|
await withFileLock(filename, async () => {
|
||||||
|
queued = withFileLock(filename, async () => {events.push("second");});
|
||||||
|
events.push("first");
|
||||||
|
});
|
||||||
|
await queued!;
|
||||||
|
assert.deepEqual(events, ["first", "second"]);
|
||||||
|
const module = new URL("../src/capability-language/file-lock.js", import.meta.url).href;
|
||||||
|
const owner = spawn(process.execPath, ["--input-type=module", "-e",
|
||||||
|
`import {withFileLock} from ${JSON.stringify(module)}; await withFileLock(${JSON.stringify(filename)}, async () => {process.stdout.write('ready'); await new Promise(() => {});});`],
|
||||||
|
{stdio: ["ignore", "pipe", "pipe"]});
|
||||||
|
context.after(() => owner.kill("SIGKILL"));
|
||||||
|
await once(owner.stdout, "data");
|
||||||
|
const exited = once(owner, "exit");
|
||||||
|
owner.kill("SIGKILL");
|
||||||
|
await exited;
|
||||||
|
let acquired = false;
|
||||||
|
await withFileLock(filename, async () => {acquired = true;});
|
||||||
|
assert.equal(acquired, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { createGitCapabilityResolver } from "../src/capability-language/git-resolver.js";
|
||||||
|
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
test("dependency resolution is reusable across processes, concurrent and rejects modified checkouts", async context => {
|
||||||
|
const temporary = await mkdtemp(path.join(os.tmpdir(), "qx-resolver-test-"));
|
||||||
|
context.after(() => rm(temporary, { recursive: true, force: true }));
|
||||||
|
const origin = path.join(temporary, "origin");
|
||||||
|
await mkdir(origin);
|
||||||
|
const git = (...args: string[]) => execFile("git", ["-C", origin, ...args]);
|
||||||
|
await git("init");
|
||||||
|
await writeFile(path.join(origin, "interface.qx"), "contract");
|
||||||
|
await git("add", ".");
|
||||||
|
await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract");
|
||||||
|
const commit = (await git("rev-parse", "HEAD")).stdout.trim();
|
||||||
|
await git("tag", `quixos-reachability/${commit}`);
|
||||||
|
const source = { resolver: "git" as const, repository: origin, commit };
|
||||||
|
const options = { checkoutRoot: path.join(temporary, "cache") };
|
||||||
|
const a = await createGitCapabilityResolver(options);
|
||||||
|
const b = await createGitCapabilityResolver(options);
|
||||||
|
const [first, second] = await Promise.all([a(source, "interface"), b(source, "interface")]);
|
||||||
|
assert.equal(first.directory, second.directory);
|
||||||
|
const c = await createGitCapabilityResolver(options);
|
||||||
|
assert.equal((await c(source, "interface")).directory, first.directory);
|
||||||
|
await writeFile(path.join(first.directory, "interface.qx"), "changed");
|
||||||
|
const d = await createGitCapabilityResolver(options);
|
||||||
|
await assert.rejects(d(source, "interface"), /modified/);
|
||||||
|
// A failed request is not memoized forever; repair can be observed on retry.
|
||||||
|
await writeFile(path.join(first.directory, "interface.qx"), "contract");
|
||||||
|
assert.equal((await d(source, "interface")).directory, first.directory);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile as callback } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
const execFile = promisify(callback);
|
||||||
|
|
||||||
|
test("Nix checks a retained immutable source without a local overlay and reuses the result", {skip: !process.env.QX_CHECK_GENERATOR}, async context => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-nix-candidate-test-"));
|
||||||
|
context.after(() => fs.rm(root, {recursive: true, force: true}));
|
||||||
|
const git = (...args: string[]) => execFile("git", ["-C", root, ...args]);
|
||||||
|
await git("init");
|
||||||
|
await fs.writeFile(path.join(root, "interface.qx"), 'interface Example id "interface:example" revision "interface:example@1" {}');
|
||||||
|
await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } }`);
|
||||||
|
await git("add", ".");
|
||||||
|
await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract");
|
||||||
|
const commit = (await git("rev-parse", "HEAD")).stdout.trim();
|
||||||
|
await git("tag", `quixos-reachability/${commit}`);
|
||||||
|
const generator = process.env.QX_CHECK_GENERATOR!;
|
||||||
|
const repository = "https://immutable-candidate.example.test/contract.git";
|
||||||
|
const env = {...process.env, GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `url.file://${root}.insteadOf`, GIT_CONFIG_VALUE_0: repository};
|
||||||
|
const build = async () => (await execFile("nix", ["build", "--impure", "--file", path.join(generator, "share/checked-candidate.nix"),
|
||||||
|
"--argstr", "repository", repository, "--argstr", "commit", commit,
|
||||||
|
"--argstr", "kind", "interface", "--argstr", "generator", generator,
|
||||||
|
"--option", "substitute", "false", "--no-link", "--print-out-paths"], {env, maxBuffer: 4 * 1024 * 1024})).stdout.trim();
|
||||||
|
const output = await build();
|
||||||
|
const candidate = JSON.parse(await fs.readFile(path.join(output, "candidate.json"), "utf8"));
|
||||||
|
assert.equal(candidate.revision.source.commit, commit);
|
||||||
|
await fs.writeFile(path.join(root, "interface.qx"), "broken draft");
|
||||||
|
assert.equal(await build(), output);
|
||||||
|
assert.deepEqual(JSON.parse(await fs.readFile(path.join(output, "checks.json"), "utf8")), []);
|
||||||
|
});
|
||||||
@@ -8,6 +8,49 @@ import {execFile as callback} from "node:child_process";
|
|||||||
import {planPinUpgrades, applyPinUpgrades, type UpgradeEffects} from "../src/capability-language/pin-upgrades.js";
|
import {planPinUpgrades, applyPinUpgrades, type UpgradeEffects} from "../src/capability-language/pin-upgrades.js";
|
||||||
const execFile = promisify(callback);
|
const execFile = promisify(callback);
|
||||||
|
|
||||||
|
test("real jj snapshots and immutable Git publication propagate a changed interface into the root", {skip: !process.env.QX_CHECK_GENERATOR}, async (context) => {
|
||||||
|
const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-real-upgrade-"));
|
||||||
|
context.after(() => fs.rm(workbench, {recursive: true, force: true}));
|
||||||
|
// Exercise the actual effects with local bare remotes, without external writes.
|
||||||
|
const environment = {
|
||||||
|
GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `url.file://${workbench}/remotes/.insteadOf`,
|
||||||
|
GIT_CONFIG_VALUE_0: "https://upgrade.test/", QUIXOS_JJ_NO_CHECKPOINT: "1",
|
||||||
|
QUIXOS_CHECK_GENERATOR: process.env.QX_CHECK_GENERATOR!,
|
||||||
|
};
|
||||||
|
const previous = Object.fromEntries(Object.keys(environment).map(key => [key, process.env[key]]));
|
||||||
|
Object.assign(process.env, environment);
|
||||||
|
context.after(() => {for (const [key, value] of Object.entries(previous)) if (value === undefined) delete process.env[key]; else process.env[key] = value;});
|
||||||
|
const run = async (cwd: string, command: string, args: string[]) => (await execFile(command, args, {cwd})).stdout.trim();
|
||||||
|
await fs.mkdir(path.join(workbench, "remotes"));
|
||||||
|
const nodes = [];
|
||||||
|
for (const [kind, directory, remote] of [["interface", "resources/Named", "named.git"], ["workspace", "root", "workspace.git"]] as const) {
|
||||||
|
const root = path.join(workbench, directory);
|
||||||
|
await fs.mkdir(root, {recursive: true});
|
||||||
|
await run(workbench, "git", ["init", "--bare", path.join(workbench, "remotes", remote)]);
|
||||||
|
await run(root, "jj", ["git", "init", "--colocate"]);
|
||||||
|
await run(root, "git", ["remote", "add", "origin", `https://upgrade.test/${remote}`]);
|
||||||
|
await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n");
|
||||||
|
const child = nodes[0];
|
||||||
|
await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://upgrade.test/quixos.git"; commit "${"a".repeat(40)}"; } ${child ? `interface Named source { repository "${child.source.repository}"; commit "${child.source.commit}"; }` : ""} }`);
|
||||||
|
await fs.writeFile(path.join(root, `${kind}.qx`), kind === "interface" ? 'interface Named id "interface:named" revision "interface:named@1" {}' : `workspace W id "workspace:w" revision "workspace:w@1" commit "${"a".repeat(40)}" { import interface Named; atom A id "atom:a"; }`);
|
||||||
|
await run(root, "jj", ["describe", "-m", "Initial source"]);
|
||||||
|
const commit = await run(root, "jj", ["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
||||||
|
await run(root, "git", ["push", "origin", `${commit}:refs/tags/quixos-reachability/${commit}`]);
|
||||||
|
nodes.push({kind, directory, source: {repository: `https://upgrade.test/${remote}`, commit}});
|
||||||
|
}
|
||||||
|
await fs.mkdir(path.join(workbench, ".quixos"));
|
||||||
|
await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [nodes[0]]}));
|
||||||
|
await fs.appendFile(path.join(workbench, nodes[0].directory, "interface.qx"), "\n// incremental author edit\n");
|
||||||
|
const plan = await planPinUpgrades(workbench, {nodes, bootstrap: true});
|
||||||
|
const result = await applyPinUpgrades(plan);
|
||||||
|
assert.equal(result.activated, false);
|
||||||
|
const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"));
|
||||||
|
const commit = graph.resources[0].source.commit;
|
||||||
|
assert.notEqual(commit, nodes[0].source.commit);
|
||||||
|
assert.match(await fs.readFile(path.join(workbench, "root/quixos.lock"), "utf8"), new RegExp(commit));
|
||||||
|
assert.match(await run(workbench, "git", ["--git-dir", path.join(workbench, "remotes/named.git"), "show-ref"]), new RegExp(commit));
|
||||||
|
});
|
||||||
|
|
||||||
test("pin upgrades publish children before parent locks and resume without republishing completed nodes", async (context) => {
|
test("pin upgrades publish children before parent locks and resume without republishing completed nodes", async (context) => {
|
||||||
const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-upgrade-test-"));
|
const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-upgrade-test-"));
|
||||||
context.after(() => fs.rm(workbench, {recursive: true, force: true}));
|
context.after(() => fs.rm(workbench, {recursive: true, force: true}));
|
||||||
|
|||||||
@@ -8,9 +8,24 @@ import {promisify} from "node:util";
|
|||||||
import {scaffoldRecipe} from "../src/capability-language/scaffold-recipes.js";
|
import {scaffoldRecipe} from "../src/capability-language/scaffold-recipes.js";
|
||||||
import {planStructure, applyStructure} from "../src/capability-language/structural-plan.js";
|
import {planStructure, applyStructure} from "../src/capability-language/structural-plan.js";
|
||||||
import {contentDigest} from "../src/capability-model/evolution.js";
|
import {contentDigest} from "../src/capability-model/evolution.js";
|
||||||
|
import {sealMigrations} from "../src/capability-language/migration-seal.js";
|
||||||
const execFile = promisify(callback);
|
const execFile = promisify(callback);
|
||||||
|
|
||||||
test("package/function/migration scaffolds register implementations and refresh code digests without overwriting code", async (context) => {
|
test("React preset applies its browser build script and shared-platform imports", async (context) => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-react-recipe-"));
|
||||||
|
context.after(() => fs.rm(root, {recursive: true, force: true}));
|
||||||
|
await execFile("git", ["-C", root, "init"]);
|
||||||
|
const source = {repository: "https://example.test/react.git", commit: "a".repeat(40)};
|
||||||
|
const request = await scaffoldRecipe(root, "package", {source, name: "React", id: "package:react", revision: "package:react@1", template: "typescript-react", tools: {quixos: source, protocol: source, helpers: source, sdk: source}});
|
||||||
|
await applyStructure(await planStructure(root, request));
|
||||||
|
assert.match(await fs.readFile(path.join(root, "src/impl/sourceGet.ts"), "utf8"), /component.js\?browser-source/);
|
||||||
|
assert.match(await fs.readFile(path.join(root, "flake.nix"), "utf8"), /browserSources = true/);
|
||||||
|
assert.equal(JSON.parse(await fs.readFile(path.join(root, "quixos.check.json"), "utf8")).options.messages["org.quixos.web-studio.ReactProps"].export, "opaqueReactPropsBinding");
|
||||||
|
await assert.rejects(fs.access(path.join(root, "quixos.scaffold.json")));
|
||||||
|
assert.equal(JSON.parse(await fs.readFile(path.join(root, "tsconfig.json"), "utf8")).compilerOptions.jsx, "react-jsx");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imperative scaffolds preserve authored wiring; migration sealing is explicit and separate", async (context) => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-recipes-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-recipes-"));
|
||||||
context.after(() => fs.rm(root, {recursive: true, force: true}));
|
context.after(() => fs.rm(root, {recursive: true, force: true}));
|
||||||
await execFile("git", ["-C", root, "init"]);
|
await execFile("git", ["-C", root, "init"]);
|
||||||
@@ -21,27 +36,29 @@ test("package/function/migration scaffolds register implementations and refresh
|
|||||||
await apply("package", {...base, name: "Chess", id: "package:chess", revision: "package:chess@1", tools: {quixos: source, protocol: source, helpers: source, sdk: source}});
|
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"});
|
await apply("function", {...base, name: "play", id: "export:play"});
|
||||||
const filename = path.join(root, base.directory, "src/impl/play.ts");
|
const filename = path.join(root, base.directory, "src/impl/play.ts");
|
||||||
const edited = 'import type {Implementation} from "../generated-bindings.js";\nexport const handler: Implementation["play"] = async () => null;\n';
|
const edited = 'import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["play"] = async () => null;\n';
|
||||||
await fs.writeFile(filename, edited);
|
await fs.writeFile(filename, edited);
|
||||||
const old = {version: 1}, next = {version: 2}, from = contentDigest(old), to = contentDigest(next);
|
const old = {version: 1}, 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}}});
|
await apply("migration", {...base, name: "upgrade", id: "export:upgrade", migration: {id: "upgrade-v2", scopeId: "board", from, to, predecessors: [], ports: [], contracts: {[from]: old, [to]: next}}});
|
||||||
const migrationFile = path.join(root, base.directory, "src/migrations/upgrade.ts");
|
const migrationFile = path.join(root, base.directory, "src/migrations/upgrade.ts");
|
||||||
await fs.appendFile(migrationFile, "\n// authored migration change\n");
|
await fs.appendFile(migrationFile, "\n// authored migration change\n");
|
||||||
await apply("refresh", base);
|
await sealMigrations(path.join(root, base.directory));
|
||||||
assert.equal(await fs.readFile(filename, "utf8"), edited);
|
assert.equal(await fs.readFile(filename, "utf8"), edited);
|
||||||
const catalog = JSON.parse(await fs.readFile(path.join(root, base.directory, "quixos.migrations.json"), "utf8"));
|
const catalog = JSON.parse(await fs.readFile(path.join(root, base.directory, "quixos.migrations.json"), "utf8"));
|
||||||
assert.equal(catalog.migrations[0].implementation.digest, contentDigest(await fs.readFile(migrationFile, "utf8")));
|
assert.equal(catalog.migrations[0].implementation.digest, contentDigest(await fs.readFile(migrationFile, "utf8")));
|
||||||
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/);
|
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/);
|
await assert.rejects(() => apply("function", {...base, name: "play", id: "export:play"}), /unique/);
|
||||||
|
const declarations = path.join(root, base.directory, "package.qx");
|
||||||
|
await fs.writeFile(declarations, (await fs.readFile(declarations, "utf8")).replace(/}\s*$/, ' function authored id "export:authored" : unit -> unit;\n}\n'));
|
||||||
|
const server = path.join(root, base.directory, "src/server.ts");
|
||||||
|
await fs.appendFile(server, "\n// authored comment must survive\n");
|
||||||
|
await apply("function", {...base, name: "another", id: "export:another"});
|
||||||
|
assert.match(await fs.readFile(server, "utf8"), /authored comment must survive/);
|
||||||
|
await assert.rejects(() => apply("refresh", base), /removed/);
|
||||||
|
await assert.rejects(fs.access(path.join(root, base.directory, "src/impl/authored.ts")));
|
||||||
|
assert.equal(await fs.readFile(filename, "utf8"), edited);
|
||||||
|
await planStructure(root, {kind: "package", source, resourceRoot: base.directory, validation: "syntax", files: [{
|
||||||
|
file: `${base.directory}/package.qx`, edits: [{operation: "replace", target: {kind: "packageResourceDecl", id: "package:chess"},
|
||||||
|
source: 'package Other id "package:other" revision "package:other@1" {}'}],
|
||||||
|
}]});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { editStructure, scaffoldResourceSource } from "../src/capability-language/structural-edits.js";
|
import { editStructure, scaffoldResourceSource, instantiateWorkspaceIdentity } from "../src/capability-language/structural-edits.js";
|
||||||
|
|
||||||
|
test("template identity binding changes only the workspace header and is idempotent", () => {
|
||||||
|
const source = '// workspace fake id "do-not-touch"\nworkspace Todo id "workspace:todo" revision "workspace:todo@1" commit "' + "a".repeat(40) + '" { atom Task id "atom:task"; }';
|
||||||
|
const id = "00000000-0000-0000-0000-000000000123";
|
||||||
|
const bound = instantiateWorkspaceIdentity(source, id);
|
||||||
|
assert.match(bound, /atom Task id "atom:task"/);
|
||||||
|
assert.ok(bound.startsWith('// workspace fake id "do-not-touch"'));
|
||||||
|
assert.ok(bound.includes(`workspace Todo id "${id}"`));
|
||||||
|
assert.equal(instantiateWorkspaceIdentity(bound, id), bound);
|
||||||
|
assert.throws(() => instantiateWorkspaceIdentity(source, "not-a-workspace"), /UUID/);
|
||||||
|
});
|
||||||
|
|
||||||
test("package scaffolding and function edits preserve surrounding source and use exact selectors", () => {
|
test("package scaffolding and function edits preserve surrounding source and use exact selectors", () => {
|
||||||
const source = `// 🧭 resource comment\n${scaffoldResourceSource("package", "Chess", "package:chess", "package:chess@1")}`;
|
const source = `// 🧭 resource comment\n${scaffoldResourceSource("package", "Chess", "package:chess", "package:chess@1")}`;
|
||||||
@@ -13,6 +24,9 @@ test("package scaffolding and function edits preserve surrounding source and use
|
|||||||
assert.ok(!editStructure(replaced, {operation: "remove", target: {kind: "packageFunctionExport", id: "export:evaluate"}}).includes("evaluate"));
|
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: "remove", target: {kind: "packageResourceDecl", id: "package:chess@1"}}), /exactly once/);
|
||||||
assert.throws(() => editStructure(source, {operation: "append", parent: {kind: "packageResourceDecl"}, source: "not valid QX"}), /Invalid structural/);
|
assert.throws(() => editStructure(source, {operation: "append", parent: {kind: "packageResourceDecl"}, source: "not valid QX"}), /Invalid structural/);
|
||||||
|
const prefixed = `import interface Board;\nexternal atom Game id "atom:game";\n${source}`;
|
||||||
|
const replacedPackage = editStructure(prefixed, {operation: "replace", target: {kind: "packageResourceDecl", id: "package:chess"}, source: scaffoldResourceSource("package", "Chess", "package:chess", "package:chess@2")});
|
||||||
|
assert.ok(replacedPackage.startsWith('import interface Board;\nexternal atom Game id "atom:game";'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("dependency scaffolding validates exact sources and preserves unrelated lock comments", () => {
|
test("dependency scaffolding validates exact sources and preserves unrelated lock comments", () => {
|
||||||
|
|||||||
@@ -34,4 +34,13 @@ test("structural plans validate the graph, journal originals, and reject stale e
|
|||||||
await fs.writeFile(applied.journalPath, JSON.stringify(journal));
|
await fs.writeFile(applied.journalPath, JSON.stringify(journal));
|
||||||
assert.equal((await resumeStructure(root, applied.id)).phase, "complete");
|
assert.equal((await resumeStructure(root, applied.id)).phase, "complete");
|
||||||
await assert.rejects(() => planStructure(root, request), /Duplicate|duplicate/);
|
await assert.rejects(() => planStructure(root, request), /Duplicate|duplicate/);
|
||||||
|
// Cross-repository edits may temporarily refer to an unfinished provider.
|
||||||
|
const provisional: StructuralRequest = {kind: "workspace", validation: "syntax", files: [{file: "workspace.qx", edits: [
|
||||||
|
{operation: "import", kind: "interface", name: "NotImplementedYet"},
|
||||||
|
]}]};
|
||||||
|
const draft = await planStructure(root, provisional);
|
||||||
|
assert.equal(draft.validation, "syntax");
|
||||||
|
await applyStructure(draft);
|
||||||
|
assert.match(await fs.readFile(path.join(root, "workspace.qx"), "utf8"), /import interface NotImplementedYet/);
|
||||||
|
await assert.rejects(() => planStructure(root, {...provisional, validation: "resource-graph"}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ let
|
|||||||
overriddenProject = optionalOverride overrideAttrs project;
|
overriddenProject = optionalOverride overrideAttrs project;
|
||||||
|
|
||||||
cacheEntries = {
|
cacheEntries = {
|
||||||
|
"@babel/helper-string-parser@npm:7.29.7" = { filename = "@babel-helper-string-parser-npm-7.29.7-87998d618e-194bc0f171.zip"; hash = "sha512-GUvA8XFuOW1f/eVq1hGXRfuVV2YsmGEVkOXkVJBng6TMshzpMFa462mkkJBEg05F2W5QrGlbvp4yIWSP4DPAbA=="; };
|
||||||
|
"@babel/helper-validator-identifier@npm:7.29.7" = { filename = "@babel-helper-validator-identifier-npm-7.29.7-9939aac13d-4795354e7a.zip"; hash = "sha512-R5U1Tnrg3K+nLeHNBOxRJS3BSYUXFwvq8BngPv/Ft78TxrIaOUmnfge4Elvn8QbtETE1DY69RWauh0CUpybWKw=="; };
|
||||||
|
"@babel/parser@npm:7.29.8" = { filename = "@babel-parser-npm-7.29.8-d8ac19f8b3-acc890c5e6.zip"; hash = "sha512-rMiQxeam3UCGOke1C6wRHXGF7m+74WPr4R1SFIVMoq25AUYq1NcYplCQ74S9IjDp6KtFouDKzMaF8fV6sLseKA=="; };
|
||||||
|
"@babel/types@npm:7.29.8" = { filename = "@babel-types-npm-7.29.8-3f9597fc63-be7c279f0a.zip"; hash = "sha512-vnwnnwq/KghsYz4htJx8qAJ10FKDzFomi2enCMmRS9DJRPFCKz6zyzdoKir11WCr9SDM+bAbU+y/5rcfvD/d5g=="; };
|
||||||
"@bufbuild/protobuf@npm:2.14.1" = { filename = "@bufbuild-protobuf-npm-2.14.1-78e9ea56a2-3f913aca03.zip"; hash = "sha512-P5E6ygPfhBnEsh2ADqVNf2yDalND5l1vaGJ57DVmxJB3KZOO8vlKXifZLo0/StzW4CcRi6Kbp2TSHhxjfN3HAg=="; };
|
"@bufbuild/protobuf@npm:2.14.1" = { filename = "@bufbuild-protobuf-npm-2.14.1-78e9ea56a2-3f913aca03.zip"; hash = "sha512-P5E6ygPfhBnEsh2ADqVNf2yDalND5l1vaGJ57DVmxJB3KZOO8vlKXifZLo0/StzW4CcRi6Kbp2TSHhxjfN3HAg=="; };
|
||||||
"@bufbuild/protoc-gen-es@npm:2.14.1" = { filename = "@bufbuild-protoc-gen-es-npm-2.14.1-230b1181a2-79f9fad1d5.zip"; hash = "sha512-efn60dVmXaptZUmyh4oPY/6/rbGHuFNAg/vrI4Ydwizz6mmmYKEvD45Ps8AtJO6ALxcYRBVt5C+0sXEuOgZ1aQ=="; };
|
"@bufbuild/protoc-gen-es@npm:2.14.1" = { filename = "@bufbuild-protoc-gen-es-npm-2.14.1-230b1181a2-79f9fad1d5.zip"; hash = "sha512-efn60dVmXaptZUmyh4oPY/6/rbGHuFNAg/vrI4Ydwizz6mmmYKEvD45Ps8AtJO6ALxcYRBVt5C+0sXEuOgZ1aQ=="; };
|
||||||
"@bufbuild/protoplugin@npm:2.14.1" = { filename = "@bufbuild-protoplugin-npm-2.14.1-ca1a20a987-6a727aa5a8.zip"; hash = "sha512-anJ6pahI4FA13tkPUOE65Sx3NXutalhHalbU/3ANyBPKxmTgzDC1IBNU5cFY6//rANyDUxbWaqLDhKLp2qXf4w=="; };
|
"@bufbuild/protoplugin@npm:2.14.1" = { filename = "@bufbuild-protoplugin-npm-2.14.1-ca1a20a987-6a727aa5a8.zip"; hash = "sha512-anJ6pahI4FA13tkPUOE65Sx3NXutalhHalbU/3ANyBPKxmTgzDC1IBNU5cFY6//rANyDUxbWaqLDhKLp2qXf4w=="; };
|
||||||
|
|||||||
@@ -5,6 +5,41 @@ __metadata:
|
|||||||
version: 10
|
version: 10
|
||||||
cacheKey: 10c0
|
cacheKey: 10c0
|
||||||
|
|
||||||
|
"@babel/helper-string-parser@npm:^7.29.7":
|
||||||
|
version: 7.29.7
|
||||||
|
resolution: "@babel/helper-string-parser@npm:7.29.7"
|
||||||
|
checksum: 10c0/194bc0f1716e396d5ffde56ad6119745fb9557662c98611590e5e454906783a4ccb21ce93056b8eb69a4909044834e45d96e50ac695bbe9e3221648fe033c06c
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier@npm:^7.29.7":
|
||||||
|
version: 7.29.7
|
||||||
|
resolution: "@babel/helper-validator-identifier@npm:7.29.7"
|
||||||
|
checksum: 10c0/4795354e7ae0dcafa72de1cd04ec51252dc1498517170beaf019e03effc5b7bf13c6b21a3949a77e07b8125be7f106ed1131350d8ebd4566ae874094a726d62b
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@babel/parser@npm:^7.28.0":
|
||||||
|
version: 7.29.8
|
||||||
|
resolution: "@babel/parser@npm:7.29.8"
|
||||||
|
dependencies:
|
||||||
|
"@babel/types": "npm:^7.29.8"
|
||||||
|
bin:
|
||||||
|
parser: ./bin/babel-parser.js
|
||||||
|
checksum: 10c0/acc890c5e6a6dd40863a47b50bac111d7185ee6fbbe163ebe11d5214854ca2adb901462ad4d718a65090ef84bd2230e9e8ab45a2e0caccc685f1f57ab0bb1e28
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@babel/types@npm:^7.29.8":
|
||||||
|
version: 7.29.8
|
||||||
|
resolution: "@babel/types@npm:7.29.8"
|
||||||
|
dependencies:
|
||||||
|
"@babel/helper-string-parser": "npm:^7.29.7"
|
||||||
|
"@babel/helper-validator-identifier": "npm:^7.29.7"
|
||||||
|
checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@bufbuild/protobuf@npm:2.14.1, @bufbuild/protobuf@npm:^2.12.1":
|
"@bufbuild/protobuf@npm:2.14.1, @bufbuild/protobuf@npm:^2.12.1":
|
||||||
version: 2.14.1
|
version: 2.14.1
|
||||||
resolution: "@bufbuild/protobuf@npm:2.14.1"
|
resolution: "@bufbuild/protobuf@npm:2.14.1"
|
||||||
@@ -44,6 +79,7 @@ __metadata:
|
|||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@quixos/quixos-protocol@workspace:."
|
resolution: "@quixos/quixos-protocol@workspace:."
|
||||||
dependencies:
|
dependencies:
|
||||||
|
"@babel/parser": "npm:^7.28.0"
|
||||||
"@bufbuild/protobuf": "npm:^2.12.1"
|
"@bufbuild/protobuf": "npm:^2.12.1"
|
||||||
"@bufbuild/protoc-gen-es": "npm:^2.12.1"
|
"@bufbuild/protoc-gen-es": "npm:^2.12.1"
|
||||||
"@types/node": "npm:^24"
|
"@types/node": "npm:^24"
|
||||||
|
|||||||
Reference in New Issue
Block a user