159 lines
8.6 KiB
TypeScript
159 lines
8.6 KiB
TypeScript
import fs from "node:fs/promises";
|
|||
|
|
import path from "node:path";
|
||
|
|
import type { ScaffoldRecipe } from "./scaffold-recipes.js";
|
||
|
|
import type { StructuralRequest } from "./structural-plan.js";
|
||
|
|
import { parseQx, walkSyntax } from "./source.js";
|
||
|
|
import { addImplementation } from "./implementation-edit.js";
|
||
|
|
import { formatQuixosLock } from "../resource-lock/index.js";
|
||
|
|
const json = (value: unknown) => JSON.stringify(value, null, 2) + "\n";
|
||
|
|
const q = (value: string) => JSON.stringify(value);
|
||
|
|
const nixString = (value: string) => q(value).replaceAll("${", "\\${");
|
||
|
|
export async function isExecutionScaffold(root: string, prefix: string): Promise<boolean> {
|
||
|
|
try {
|
||
|
|
return JSON.parse(await fs.readFile(path.join(root, prefix, "quixos.check.json"), "utf8")).backend === "execution";
|
||
|
|
} catch (error) {
|
||
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/** Insert ordinary authored code; declarations remain the only export inventory. */
|
||
|
|
export async function executionScaffold(
|
||
|
|
root: string,
|
||
|
|
command: string,
|
||
|
|
spec: ScaffoldRecipe,
|
||
|
|
prefix: string,
|
||
|
|
nixSource: (source: NonNullable<ScaffoldRecipe["tools"]>["helpers"]) => string,
|
||
|
|
): Promise<StructuralRequest> {
|
||
|
|
if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id)
|
||
|
|
throw new Error("Execution scaffold requires a simple name and an ID");
|
||
|
|
if (spec.initialFiles) throw new Error("Edit execution source after scaffolding; initialFiles is not supported");
|
||
|
|
const files: StructuralRequest["files"] = [];
|
||
|
|
const create = (file: string, content: string) => files.push({ file: prefix + file, create: content });
|
||
|
|
if (command === "package") {
|
||
|
|
if (!spec.tools || !spec.revision) throw new Error("Execution package requires exact tool sources and a revision");
|
||
|
|
create("package.qx", `package ${spec.name} id ${q(spec.id)} revision ${q(spec.revision)} {\n}\n`);
|
||
|
|
create(
|
||
|
|
"quixos.lock",
|
||
|
|
formatQuixosLock({ formatVersion: 1, quixos: { resolver: "git", ...spec.tools.quixos }, resources: [] }),
|
||
|
|
);
|
||
|
|
create("quixos.check.json", json({ backend: "execution" }));
|
||
|
|
create("package.json", json({ name: `@quixos/${spec.name.toLowerCase()}`, private: true, type: "module" }));
|
||
|
|
create(".gitignore", "node_modules/\ndist/\nsrc/gen/\n.quixos/\nresult\n");
|
||
|
|
for (const lane of ["browser", "server", "remote"])
|
||
|
|
create(
|
||
|
|
`src/${lane}.ts`,
|
||
|
|
`import { createRegistry } from "./gen/${lane === "remote" ? "remote" : "qx"}.js";\nexport const registry = createRegistry({});\n`,
|
||
|
|
);
|
||
|
|
create(
|
||
|
|
"flake.nix",
|
||
|
|
`{
|
||
|
|
inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))};
|
||
|
|
inputs.nixpkgs.follows = "protocol/nixpkgs";
|
||
|
|
inputs.flake-utils.follows = "protocol/flake-utils";
|
||
|
|
inputs.helpers = { url = ${nixString(nixSource(spec.tools.helpers))}; flake = false; };
|
||
|
|
outputs = { nixpkgs, flake-utils, helpers, ... }:
|
||
|
|
flake-utils.lib.eachDefaultSystem (system: let
|
||
|
|
pkgs = import nixpkgs { inherit system; };
|
||
|
|
in {
|
||
|
|
quixosPackages.checkedArtifacts = { plan, generator, core, sharedRuntime, typescript, packageRevisionId }:
|
||
|
|
(import (helpers + "/portable-artifacts.nix")) {
|
||
|
|
inherit pkgs plan core sharedRuntime typescript packageRevisionId;
|
||
|
|
protocol = generator;
|
||
|
|
src = ./.;
|
||
|
|
portable = {
|
||
|
|
entry = "src/server.ts";
|
||
|
|
entries.browser = "src/browser.ts";
|
||
|
|
bindingOutput = "src/gen/qx.ts";
|
||
|
|
};
|
||
|
|
remote = { entry = "src/remote.ts"; bindingOutput = "src/gen/remote.ts"; };
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
`,
|
||
|
|
);
|
||
|
|
create(
|
||
|
|
"README.md",
|
||
|
|
`# ${spec.name}\n\nThis execution package exports checked browser/server modules and a process service.\nUse scaffold function with an explicit execution contract to add a typed handler.\nSnapshot/transaction targets choose browser.ts and/or server.ts; remote handlers\nuse remote.ts. Shared helpers are ordinary imports. Generated bindings are build\noutputs, not authored source. The coordinator supplies the checked workspace plan\nand runtime dependencies to checkedArtifacts.\n\nThe execution scaffold currently requires the execution artifact build path;\nlegacy checkedServer verification and activation do not install these artifacts.\nA source-only structural check is not runtime qualification.\n`,
|
||
|
|
);
|
||
|
|
} else if (command === "function") {
|
||
|
|
if (!spec.declaration)
|
||
|
|
throw new Error("Execution functions require a declaration with explicit execution effect and targets");
|
||
|
|
const wrapped = `package Scaffold id "scaffold" revision "scaffold@1" { ${spec.declaration} }`;
|
||
|
|
const parsed = parseQx(wrapped);
|
||
|
|
const pkg = parsed.root.children.find((node) => node.kind === "packageResourceDecl");
|
||
|
|
const exports = [...walkSyntax(parsed.root)].filter((node) =>
|
||
|
|
["packageFunctionExport", "packageOperationExport"].includes(node.kind),
|
||
|
|
);
|
||
|
|
const declaration = exports[0];
|
||
|
|
if (
|
||
|
|
parsed.diagnostics.length ||
|
||
|
|
exports.length !== 1 ||
|
||
|
|
!declaration ||
|
||
|
|
parsed.root.children.length !== 1 ||
|
||
|
|
pkg?.children.filter((node) => node.kind === "packageExport").length !== 1 ||
|
||
|
|
pkg?.children.some((node) => !["identifier", "stringLiteral", "packageExport"].includes(node.kind))
|
||
|
|
)
|
||
|
|
throw new Error("Expected one function or operation export with an execution contract");
|
||
|
|
const text = (node: { start: number; end: number }) => wrapped.slice(node.start, node.end);
|
||
|
|
const name = declaration.children.find((node) => node.kind === "identifier");
|
||
|
|
const id = declaration.children.find((node) => node.kind === "stringLiteral");
|
||
|
|
const execution = declaration.children.find((node) => node.kind === "executionClause");
|
||
|
|
if (!name || text(name) !== spec.name || !id || JSON.parse(text(id)) !== spec.id || !execution)
|
||
|
|
throw new Error("Declaration name/ID must match; an explicit execution contract is required");
|
||
|
|
const tokens = parsed.tokens
|
||
|
|
.filter((token) => !token.trivia && token.start >= execution.start && token.end <= execution.end)
|
||
|
|
.map((token) => token.text);
|
||
|
|
const remote = tokens[1] === "remote";
|
||
|
|
const targets = remote
|
||
|
|
? ["remote"]
|
||
|
|
: [...walkSyntax(execution)].filter((node) => node.kind === "identifier").map(text);
|
||
|
|
if (
|
||
|
|
!targets.length ||
|
||
|
|
new Set(targets).size !== targets.length ||
|
||
|
|
targets.some((target) => !(remote ? ["remote"] : ["browser", "server"]).includes(target))
|
||
|
|
)
|
||
|
|
throw new Error("Execution scaffold supports browser/server portable targets and remote service handlers");
|
||
|
|
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
|
||
|
|
const syntax = parseQx(authored);
|
||
|
|
const packageNode = syntax.root.children.find((node) => node.kind === "packageResourceDecl");
|
||
|
|
if (syntax.diagnostics.length || !packageNode) throw new Error("Fix package.qx before adding a handler");
|
||
|
|
for (const node of [...walkSyntax(packageNode)].filter((node) =>
|
||
|
|
["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind),
|
||
|
|
)) {
|
||
|
|
const identifier = node.children.find((child) => child.kind === "identifier");
|
||
|
|
const literal = node.children.find((child) => child.kind === "stringLiteral");
|
||
|
|
if (
|
||
|
|
(identifier && authored.slice(identifier.start, identifier.end) === spec.name) ||
|
||
|
|
(literal && JSON.parse(authored.slice(literal.start, literal.end)) === spec.id)
|
||
|
|
)
|
||
|
|
throw new Error("New export requires a unique name and ID");
|
||
|
|
}
|
||
|
|
const packageID = packageNode.children.find((node) => node.kind === "stringLiteral")!;
|
||
|
|
files.push({
|
||
|
|
file: prefix + "package.qx",
|
||
|
|
edits: [
|
||
|
|
{
|
||
|
|
operation: "append",
|
||
|
|
parent: { kind: "packageResourceDecl", id: JSON.parse(authored.slice(packageID.start, packageID.end)) },
|
||
|
|
source: spec.declaration,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
});
|
||
|
|
create(
|
||
|
|
`src/impl/${spec.name}.ts`,
|
||
|
|
`import type { ${remote ? "Remote" : "Portable"}Implementation } from "../gen/${remote ? "remote" : "qx"}.js";\nexport const handler: ${remote ? "Remote" : "Portable"}Implementation[${q(spec.name)}] = async (_context) => { throw new Error(${q("Implement " + spec.name)}); };\n`,
|
||
|
|
);
|
||
|
|
for (const target of targets) {
|
||
|
|
const file = prefix + `src/${target}.ts`;
|
||
|
|
const before = await fs.readFile(path.join(root, file), "utf8");
|
||
|
|
files.push({
|
||
|
|
file,
|
||
|
|
expected: before,
|
||
|
|
replace: addImplementation(before, "createRegistry", spec.name, `./impl/${spec.name}.js`),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} else
|
||
|
|
throw new Error("Execution packages use ordinary factory transactions; migration scaffolding is not supported");
|
||
|
|
return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files };
|
||
|
|
}
|