diff --git a/src/capability-language/component-scaffold.ts b/src/capability-language/component-scaffold.ts new file mode 100644 index 0000000..ff25544 --- /dev/null +++ b/src/capability-language/component-scaffold.ts @@ -0,0 +1,79 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { parseQx, walkSyntax } from "./source.js"; +import type { ScaffoldRecipe } from "./scaffold-recipes.js"; +import type { StructuralRequest } from "./structural-plan.js"; + +/** Add one ordinary component entry without regenerating existing application code. */ +export async function scaffoldComponent( + root: string, + prefix: string, + spec: ScaffoldRecipe, +): Promise { + if (!spec.declaration || !spec.name || !/^[A-Z][A-Za-z0-9_]*$/.test(spec.name) || !spec.id) + throw Error("Component scaffold requires a PascalCase name, ID and QX declaration"); + const source = `package Draft id "draft" revision "draft@1" { ${spec.declaration} }`; + const syntax = parseQx(source); + const declarations = [...walkSyntax(syntax.root)].filter((node) => node.kind === "componentDecl"); + const pkg = syntax.root.children.find((node) => node.kind === "packageResourceDecl"); + if ( + syntax.diagnostics.length || + syntax.root.children.length !== 1 || + declarations.length !== 1 || + pkg?.children.filter((node) => node.kind === "packageExport").length !== 1 + ) + throw Error("Expected exactly one component declaration"); + const declaration = declarations[0]!; + const identifier = declaration.children.find((node) => node.kind === "identifier"); + const id = declaration.children.find((node) => node.kind === "stringLiteral"); + if ( + !identifier || + source.slice(identifier.start, identifier.end) !== spec.name || + !id || + JSON.parse(source.slice(id.start, id.end)) !== spec.id + ) + throw Error("Component declaration name/ID must match its registration"); + const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8"); + const existing = parseQx(authored); + const parent = existing.root.children.find((node) => node.kind === "packageResourceDecl"); + if (existing.diagnostics.length || !parent) throw Error("Fix package.qx before adding a component"); + for (const item of parent.children.filter((node) => node.kind === "packageExport").flatMap((node) => node.children)) { + const name = item.children.find((node) => node.kind === "identifier"); + const literal = item.children.find((node) => node.kind === "stringLiteral"); + if ( + (name && authored.slice(name.start, name.end) === spec.name) || + (literal && JSON.parse(authored.slice(literal.start, literal.end)) === spec.id) + ) + throw Error("Component requires a unique name and ID"); + } + const filename = "quixos.components.json"; + const before = await fs.readFile(path.join(root, prefix, filename), "utf8"); + const entries: Record = JSON.parse(before); + if (!entries || typeof entries !== "object" || Array.isArray(entries) || Object.hasOwn(entries, spec.id)) + throw Error("Invalid or duplicate component entry"); + entries[spec.id] = { entry: `src/components/${spec.name}.tsx`, export: spec.name }; + const parentID = parent.children.find((node) => node.kind === "stringLiteral")!; + return { + kind: "package", + source: spec.source, + resourceRoot: spec.directory, + validation: "syntax", + files: [ + { + file: prefix + "package.qx", + edits: [ + { + operation: "append", + parent: { kind: "packageResourceDecl", id: JSON.parse(authored.slice(parentID.start, parentID.end)) }, + source: spec.declaration, + }, + ], + }, + { file: prefix + filename, expected: before, replace: JSON.stringify(entries, null, 2) + "\n" }, + { + file: prefix + `src/components/${spec.name}.tsx`, + create: `import type { Components } from "../gen/client.js";\n\n// Add explicit query admission and named interface operations from the generated client.\nexport const ${spec.name}: Components[${JSON.stringify(spec.name)}] = ({subject}) =>

{subject.object}

;\n`, + }, + ], + }; +} diff --git a/src/capability-language/controller-scaffold.ts b/src/capability-language/controller-scaffold.ts new file mode 100644 index 0000000..d2e532f --- /dev/null +++ b/src/capability-language/controller-scaffold.ts @@ -0,0 +1,42 @@ +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"; +export async function scaffoldController( + root: string, + prefix: string, + spec: ScaffoldRecipe, +): Promise { + const entry = { id: spec.id, ...spec.controller }; + const fields = ["id", "subject", "snapshot", "remote", "input"]; + if ( + Object.keys(entry).length !== fields.length || + fields.some((key) => typeof (entry as Record)[key] !== "string") || + !["unit", "value"].includes(entry.input ?? "") + ) + throw Error("Controller requires id, subject, snapshot, remote and input (unit/value)"); + const file = prefix + "quixos.controllers.json"; + let before: string | undefined; + try { + before = await fs.readFile(path.join(root, file), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const catalog = before ? JSON.parse(before) : { schemaVersion: 1, controllers: [] }; + if ( + catalog.schemaVersion !== 1 || + !Array.isArray(catalog.controllers) || + catalog.controllers.length >= 1000 || + catalog.controllers.some((c: { id: string }) => c.id === entry.id) + ) + throw Error("Invalid catalog or duplicate controller ID"); + catalog.controllers.push(entry); + const content = JSON.stringify(catalog, null, 2) + "\n"; + return { + kind: "package", + source: spec.source, + resourceRoot: spec.directory, + validation: "syntax", + files: [before === undefined ? { file, create: content } : { file, expected: before, replace: content }], + }; +} diff --git a/src/capability-language/execution-scaffold.ts b/src/capability-language/execution-scaffold.ts index 64d40ba..9ec6fb5 100644 --- a/src/capability-language/execution-scaffold.ts +++ b/src/capability-language/execution-scaffold.ts @@ -26,7 +26,7 @@ export async function executionScaffold( ): Promise { 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"); + if (spec.initialFiles && command !== "package") throw new Error("Initial files are only for new packages"); const files: StructuralRequest["files"] = []; const create = (file: string, content: string) => files.push({ file: prefix + file, create: content }); if (command === "package") { @@ -37,6 +37,7 @@ export async function executionScaffold( formatQuixosLock({ formatVersion: 1, quixos: { resolver: "git", ...spec.tools.quixos }, resources: [] }), ); create("quixos.check.json", json({ backend: "execution" })); + create("quixos.components.json", "{}\n"); 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"]) @@ -55,11 +56,15 @@ export async function executionScaffold( flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; in { - quixosPackages.checkedArtifacts = { plan, generator, core, sharedRuntime, typescript, packageRevisionId }: + quixosPackages.checkedArtifacts = { plan, generator, core, sharedRuntime, typescript, packageRevisionId, react ? null }: (import (helpers + "/artifacts.nix")) { inherit pkgs plan core sharedRuntime typescript packageRevisionId; protocol = generator; src = ./.; + components = let entries = builtins.fromJSON (builtins.readFile ./quixos.components.json); in + if entries == {} then null else + if react == null then throw "Component entries require the workspace-selected React runtime" else + { inherit (react) sdk runtime; inherit entries; }; portable = { entry = "src/server.ts"; entries.browser = "src/browser.ts"; @@ -73,7 +78,7 @@ export async function executionScaffold( ); 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`, + `# ${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\nRun qx-workspace check from the workspace root to compile exact source and build\nchecked artifacts. Activate the resulting report separately through the workspace\nhost. A source-only structural check is not runtime qualification.\n`, ); } else if (command === "function") { if (!spec.declaration) @@ -154,5 +159,20 @@ export async function executionScaffold( } } else throw new Error("Execution packages use ordinary factory transactions; migration scaffolding is not supported"); + if (spec.initialFiles) { + for (const [file, content] of Object.entries(spec.initialFiles)) { + if ( + typeof content !== "string" || + !/^(?:package\.qx|quixos\.components\.json|quixos\.controllers\.json|README\.md|(?:src|queries)\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_.-]+)$/.test( + file, + ) || + file.split("/").some((part) => part === ".." || part === "." || part === "gen") + ) + throw new Error("Not an initial authored execution file: " + file); + const previous = files.findIndex((entry) => entry.file === prefix + file); + if (previous >= 0) files.splice(previous, 1); + create(file, content); + } + } return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files }; } diff --git a/src/capability-language/scaffold-recipes.ts b/src/capability-language/scaffold-recipes.ts index 37883ec..a0a8f15 100644 --- a/src/capability-language/scaffold-recipes.ts +++ b/src/capability-language/scaffold-recipes.ts @@ -1,29 +1,12 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { contentDigest } from "../capability-model/evolution.js"; -import { - validateMigrationCatalog, - type MigrationCatalog, - type MigrationDeclaration, -} from "../capability-model/migrations.js"; -import { formatQuixosLock, type GitSource } from "../resource-lock/index.js"; -import { parseQx, walkSyntax } from "./source.js"; +import type { GitSource } from "../resource-lock/index.js"; import type { StructuralRequest } from "./structural-plan.js"; -import { addImplementation } from "./implementation-edit.js"; -import { reactPlatformTypes } from "../bindings/react-platform.js"; import { executionScaffold, isExecutionScaffold } from "./execution-scaffold.js"; +import { scaffoldController } from "./controller-scaffold.js"; +import { scaffoldComponent } from "./component-scaffold.js"; import { scaffoldQuery } from "./query-scaffold.js"; - type Source = { repository: string; commit: string }; -type PackageEditModel = { - generatedBy: "qx-scaffold-v1"; - name: string; - id: string; - revision: string; - exports: { name: string; id: string; file: string; migration?: boolean }[]; -}; export type ScaffoldRecipe = { - template?: "typescript" | "typescript-react" | "typescript-execution"; + template?: "typescript-execution" | "typescript-component"; source: Source; directory?: string; name?: string; @@ -31,16 +14,13 @@ export type ScaffoldRecipe = { revision?: string; declaration?: string; queryDocument?: string; - /** Initial authored files for a composed recipe; only used for a new package. */ initialFiles?: Record; - tools?: { quixos: Source; protocol: Source; helpers: Source; sdk: Source }; - nixifyPluginUrl?: string; - migration?: Omit & { contracts: Record }; + controller?: { subject: string; snapshot: string; remote: string; input: "unit" | "value" }; + tools?: { quixos: Source; protocol: Source; helpers: Source; sdk?: Source }; }; -const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; const source = (value: Source): GitSource => { if (!value || typeof value.repository !== "string" || typeof value.commit !== "string") - throw new Error("Scaffold requires an exact source identity; use qx-workspace from a registered repository"); + throw Error("Scaffold requires an exact source identity; use a registered repository"); const url = new URL(value.repository); if ( url.protocol !== "https:" || @@ -50,383 +30,36 @@ const source = (value: Source): GitSource => { 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"); + throw Error("Scaffolds require credential-free HTTPS sources and full exact commits"); return { resolver: "git", ...value }; }; const nixSource = (value: Source) => `git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`; -const nixString = (value: string) => JSON.stringify(value).replaceAll("${", "\\${"); -const safeName = (name: string | undefined): string => { - if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("Scaffold requires a simple authored name"); - return name; -}; -const ownedJson = async (root: string, file: string): Promise => { - const target = path.join(root, file); - if (!(await fs.realpath(target)).startsWith(`${await fs.realpath(root)}/`)) - throw new Error("Scaffold input escapes repository"); - const value = JSON.parse(await fs.readFile(target, "utf8")); - if (value.generatedBy !== "qx-scaffold-v1") throw new Error(`Not scaffold-owned: ${file}`); - return value; -}; - -/** Recipes describe structural edits; planStructure owns validation/journaling. - * Implementation files are created once; later edits preserve authored wiring. */ -export const scaffoldRecipe = async ( +/** One-shot edits; structural planning retains interrupted work and authored files. */ +export async function scaffoldRecipe( root: string, - command: "package" | "function" | "migration" | "refresh" | "query", + command: "package" | "function" | "component" | "controller" | "query" | "migration" | "refresh", spec: ScaffoldRecipe, -): Promise => { +): Promise { 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.", + throw Error( + "Scaffold refresh is removed. Edit authored code and run check; add entries with scaffold function/component/query.", + ); + if (command === "migration") + throw Error( + "Old-workspace migration scaffolding is retired. Current disposable workspaces do not require migration code.", ); source(spec.source); if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory)) - throw new Error("Scaffold directory must be contained"); - const prefix = spec.directory ? `${spec.directory}/` : ""; + throw Error("Scaffold directory must be contained"); + if (spec.template && !["typescript-execution", "typescript-component"].includes(spec.template)) + throw Error("Use typescript-execution or typescript-component; old server/source-string presets are retired"); + const prefix = spec.directory ? spec.directory + "/" : ""; + if (command !== "package" && !(await isExecutionScaffold(root, prefix))) + throw Error("Scaffolding requires a checked execution package"); + if (spec.tools) Object.values(spec.tools).forEach(source); + if (command === "controller") return scaffoldController(root, prefix, spec); + if (command === "component") return scaffoldComponent(root, prefix, spec); if (command === "query") return scaffoldQuery(root, prefix, spec); - if ( - spec.template === "typescript-execution" || - (command !== "package" && (await isExecutionScaffold(root, prefix))) - ) { - if (spec.tools) Object.values(spec.tools).forEach(source); - return executionScaffold(root, command, spec, prefix, nixSource); - } - const files: StructuralRequest["files"] = []; - const create = (file: string, content: string) => files.push({ file: prefix + file, create: content }); - const generated = (file: string, content: string) => files.push({ file: prefix + file, generated: content }); - let packageModel: PackageEditModel; - let catalog: MigrationCatalog & { generatedBy: "qx-scaffold-v1" }; - if (command === "package") { - const name = safeName(spec.name); - if (spec.template && !["typescript", "typescript-react"].includes(spec.template)) - throw new Error("Unknown package template"); - const react = spec.template === "typescript-react"; - if (!spec.id || !spec.revision || !spec.tools) - throw new Error("Package scaffold requires id, revision, and exact quixos/protocol/helpers/sdk tool sources"); - Object.values(spec.tools).forEach(source); - packageModel = { generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: [] }; - catalog = { generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: [] }; - if (react) - packageModel.exports.push({ name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts" }); - create( - "package.qx", - `package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n${react ? ` function sourceGet id ${JSON.stringify(`export:${name}:source`)} : unit -> string;\n` : ""}}\n`, - ); - create("quixos.lock", formatQuixosLock({ formatVersion: 1, quixos: source(spec.tools.quixos), resources: [] })); - create( - "package.json", - json({ - name: `@quixos/${name.toLowerCase()}`, - version: "0.1.0", - private: true, - type: "module", - packageManager: "yarn@4.18.0", - scripts: { build: "tsc -p tsconfig.json", typecheck: "tsc --noEmit" }, - dependencies: { - "@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`, - }, - devDependencies: { - "@types/node": "^24", - typescript: "^7.0.2", - ...(react ? { react: "^18.3.1", "@types/react": "^18.3.12", esbuild: "^0.25.12" } : {}), - }, - }), - ); - create( - "tsconfig.json", - json({ - compilerOptions: { - target: "ES2023", - module: "NodeNext", - moduleResolution: "NodeNext", - strict: true, - types: ["node", ...(react ? ["react"] : [])], - outDir: "dist", - rootDir: "src", - skipLibCheck: true, - ...(react ? { jsx: "react-jsx", esModuleInterop: true } : {}), - }, - include: ["src/**/*.ts", "src/**/*.tsx"], - }), - ); - if (react) { - create( - "src/component.tsx", - `// Add a checked props export, select it in quixos.check.json options.react,\n// then use its generated ReactResults type. Scaffold bundle does this for you.\nexport default function Component(_props: {camino: Record; render: unknown; dispatch: (action: unknown) => void}) {\n return

${name}

;\n}\n`, - ); - create( - "src/impl/sourceGet.ts", - `import componentSource from "../component.js?browser-source";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => componentSource;\n`, - ); - create( - "src/browser-assets.d.ts", - `declare module "*?browser-source" { const source: string; export default source; }\ndeclare module "*.css" {}\n`, - ); - create("src/gen/web-studio-react-runtime.d.ts", reactPlatformTypes); - } - create(".gitignore", "node_modules/\ndist/\n.quixos/\nresult\n.yarn/install-state.gz\n"); - create( - ".yarnrc.yml", - `nodeLinker: node-modules\nenableScripts: true\nnpmMinimalAgeGate: 0\napprovedGitRepositories:\n - ${JSON.stringify(spec.tools.sdk.repository)}\nsupportedArchitectures:\n os: [current, linux]\n cpu: [current, x64, arm64]\n libc: [current, glibc]\n`, - ); - const nixifyPluginUrl = - spec.nixifyPluginUrl ?? - "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js"; - const plugin = new URL(nixifyPluginUrl); - if ( - plugin.protocol !== "https:" || - plugin.username || - plugin.password || - plugin.search || - plugin.hash || - !/\/commit\/[a-f0-9]{40,64}\//.test(plugin.pathname) - ) - throw new Error("Nixify plugin must have an exact credential-free HTTPS commit URL"); - generated("quixos.toolchain.json", json({ generatedBy: "qx-scaffold-v1", nixifyPluginUrl })); - create( - "quixos.check.json", - json({ - backend: "typescript", - bindingOutput: "src/gen/qx.ts", - ...(react - ? { - options: { react: { propsExports: [] } }, - } - : {}), - }), - ); - create( - "flake.nix", - `{ - inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))}; - inputs.nixpkgs.follows = "protocol/nixpkgs"; - inputs.flake-utils.follows = "protocol/flake-utils"; - inputs.helpers = { url = ${nixString(nixSource(spec.tools.helpers))}; flake = false; }; - outputs = inputs@{ self, protocol, nixpkgs, flake-utils, helpers, ... }: - (import (toString helpers + "/quixos-package-helpers.nix")).mkCaminoTsYarnNixifyFlake { - inherit inputs nixpkgs flake-utils; packageRoot = ./.; - bundle = { entry = ${JSON.stringify(react ? "src/server.ts" : "dist/server.js")}; ${react ? "browserSources = true;" : ""} }; - migrationEntrypoint = "dist/migrate.js"; - installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; }; - }; -}\n`, - ); - } else { - catalog = await ownedJson(root, prefix + "quixos.migrations.json"); - // Derive the edit model from authored declarations; it is never persisted. - const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8"); - const syntax = parseQx(authored); - if (syntax.diagnostics.length) - throw new Error("Cannot scaffold into an invalid package.qx; fix the reported syntax first"); - const declaration = [...walkSyntax(syntax.root)].find((node) => node.kind === "packageResourceDecl"); - if (!declaration) throw new Error("Expected a package declaration"); - const text = (node: typeof declaration) => authored.slice(node.start, node.end); - const literals = declaration.children.filter((node) => node.kind === "stringLiteral"); - packageModel = { - generatedBy: "qx-scaffold-v1", - name: text(declaration.children.find((node) => node.kind === "identifier")!), - id: JSON.parse(text(literals[0])), - revision: JSON.parse(text(literals[1])), - exports: [], - }; - for (const node of walkSyntax(declaration)) { - if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)) - continue; - const name = safeName(text(node.children.find((child) => child.kind === "identifier")!)); - const id = JSON.parse(text(node.children.find((child) => child.kind === "stringLiteral")!)); - if (packageModel.exports.some((entry) => entry.id === id || entry.name === name)) - throw new Error("Duplicate package export name or ID"); - const migration = catalog.migrations.find((entry) => entry.implementation.exportId === id); - packageModel.exports.push({ - name, - id, - file: migration?.implementation.file ?? `src/impl/${name}.ts`, - ...(migration ? { migration: true } : {}), - }); - } - { - const name = safeName(spec.name); - if (!spec.id || packageModel.exports.some((entry) => entry.id === spec.id || entry.name === name)) - throw new Error("New export requires a unique name and ID"); - const declaration = spec.declaration ?? `function ${name} id ${JSON.stringify(spec.id)} : unit -> unit;`; - const parsed = parseQx(`package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`); - const exports = [...walkSyntax(parsed.root)].filter((node) => - ["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind), - ); - if (parsed.diagnostics.length || exports.length !== 1) - throw new Error("Expected one valid package export declaration"); - const wrapped = `package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`; - const node = exports[0]; - const derived = [...walkSyntax(node)].some((entry) => entry.kind === "eventClause"); - if (command === "migration" && (node.kind !== "packageFunctionExport" || spec.declaration)) - throw new Error( - "Migration exports use the scaffold's unit function declaration and dedicated migration entrypoint", - ); - if ( - wrapped.slice( - node.children.find((child) => child.kind === "identifier")!.start, - node.children.find((child) => child.kind === "identifier")!.end, - ) !== name || - JSON.parse( - wrapped.slice( - node.children.find((child) => child.kind === "stringLiteral")!.start, - node.children.find((child) => child.kind === "stringLiteral")!.end, - ), - ) !== spec.id - ) - throw new Error("Declaration name/ID must match its registration"); - files.push({ - file: prefix + "package.qx", - edits: [ - { operation: "append", parent: { kind: "packageResourceDecl", id: packageModel.id }, source: declaration }, - ], - }); - const file = `src/${command === "migration" ? "migrations" : "impl"}/${name}.ts`; - const implementation = - command === "migration" - ? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\n` - : `import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`; - create(file, implementation); - packageModel.exports.push({ name, id: spec.id, file, ...(command === "migration" ? { migration: true } : {}) }); - if (command === "migration") { - if (!spec.migration) - throw new Error("Migration scaffold requires retained contracts and an explicit transition"); - const { contracts, ...transition } = spec.migration; - for (const [digest, contract] of Object.entries(contracts)) { - if ( - contentDigest(contract) !== digest || - (catalog.contracts[digest] && contentDigest(catalog.contracts[digest]) !== digest) - ) - throw new Error("Retained migration contract mismatch"); - catalog.contracts[digest] = contract; - } - catalog.migrations.push({ - ...transition, - implementation: { exportId: spec.id, file, digest: contentDigest(implementation) }, - }); - } - } - } - validateMigrationCatalog(catalog, new Set(packageModel.exports.map((entry) => entry.id))); - generated("quixos.migrations.json", json(catalog)); - if (command !== "package") { - const entry = packageModel.exports.at(-1)!; - const file = prefix + "src/server.ts"; - const before = await fs.readFile(path.join(root, file), "utf8"); - files.push({ - file, - expected: before, - replace: addImplementation( - before, - "createRuntime", - entry.name, - `./${entry.file.slice(4, -3)}.js`, - !!entry.migration, - ), - }); - if (entry.migration) { - const file = prefix + "src/migrate.ts"; - const before = await fs.readFile(path.join(root, file), "utf8"); - files.push({ - file, - expected: before, - replace: addImplementation(before, "serveMigration", entry.id, `./${entry.file.slice(4, -3)}.js`), - }); - } - } else { - create( - "src/server.ts", - `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` + - packageModel.exports - .filter((entry) => !entry.migration) - .map( - (entry, index) => - `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`, - ) - .join("") + - `servePackageRuntime(createRuntime({\n` + - packageModel.exports - .map( - (entry) => - ` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${packageModel.exports.filter((value) => !value.migration).indexOf(entry)}`},`, - ) - .join("\n") + - `\n}));\n`, - ); - const migrations = packageModel.exports.filter((entry) => entry.migration); - create( - "src/migrate.ts", - `import {serveMigration} from "@quixos/camino-package-runtime";\n` + - migrations - .map( - (entry, index) => - `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`, - ) - .join("") + - `await serveMigration({${migrations.map((entry, index) => `${JSON.stringify(entry.id)}: impl${index}`).join(", ")}});\n`, - ); - } - generated( - "descriptor.quixos-package.txtpb", - `# Generated by qx-scaffold-v1\npackage_id: ${JSON.stringify(packageModel.id)}\npackage_revision_id: ${JSON.stringify(packageModel.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` + - packageModel.exports - .map( - (entry) => - `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`, - ) - .join(""), - ); - if (spec.initialFiles) { - if (command !== "package") throw new Error("initialFiles is only valid when creating a package"); - const authored = spec.initialFiles["package.qx"]; - if (authored !== undefined) { - const parsed = parseQx(authored); - const declaration = [...walkSyntax(parsed.root)].find((n) => n.kind === "packageResourceDecl"); - const literals = declaration?.children.filter((n) => n.kind === "stringLiteral") ?? []; - const identifier = declaration?.children.find((n) => n.kind === "identifier"); - if ( - parsed.diagnostics.length || - literals.length !== 2 || - !identifier || - authored.slice(identifier.start, identifier.end) !== spec.name || - JSON.parse(authored.slice(literals[0].start, literals[0].end)) !== spec.id || - JSON.parse(authored.slice(literals[1].start, literals[1].end)) !== spec.revision - ) - throw new Error("Initial package declaration must match the provisioned name, ID and revision"); - } - for (const [file, content] of Object.entries(spec.initialFiles)) { - if ( - typeof content !== "string" || - ["quixos.lock", "flake.nix", "quixos.toolchain.json", "package.json"].includes(file) - ) - throw new Error(`Not an initial authored file: ${file}`); - if (file === "quixos.check.json") { - const check = JSON.parse(content); - if ( - check.backend !== "typescript" || - check.bindingOutput !== "src/gen/qx.ts" || - Object.keys(check).some((key) => !["backend", "bindingOutput", "options"].includes(key)) - ) - throw new Error( - "Initial check configuration may customize binding options, not the scaffold verification backend or output", - ); - } - const existing = files.findIndex((entry) => entry.file === prefix + file); - if (existing >= 0) files.splice(existing, 1); - create(file, content); - } - // A composed React recipe supplies its own modules and complete server. - if (reactRecipe(spec)) { - for (const file of ["src/component.tsx", "src/impl/sourceGet.ts", "descriptor.quixos-package.txtpb"]) - if (!(file in spec.initialFiles)) { - const index = files.findIndex((entry) => entry.file === prefix + file); - if (index >= 0) files.splice(index, 1); - } - } - } - return { kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files }; -}; - -const reactRecipe = (spec: ScaffoldRecipe) => - spec.template === "typescript-react" && spec.initialFiles?.["package.qx"] && spec.initialFiles?.["src/server.ts"]; + return executionScaffold(root, command, spec, prefix, nixSource); +} diff --git a/src/capability-language/structural-plan.ts b/src/capability-language/structural-plan.ts index f831f1c..5759058 100644 --- a/src/capability-language/structural-plan.ts +++ b/src/capability-language/structural-plan.ts @@ -168,16 +168,21 @@ export const planStructure = async (rootPath: string, request: StructuralRequest }); if (compiled.resource.kind === "package") { const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8")); - const artifacts = [ - { - file: configuration.bindingOutput as string, - after: generateTypeScriptBindings( - bindingSchema(compiled), - compiled.resource.revision.revisionId, - configuration.options, - ), - }, - ]; + // Execution bindings require the complete workspace plan and are owned + // by checkedArtifacts, not a package-only structural preview. + const artifacts = + configuration.backend === "execution" + ? [] + : [ + { + file: configuration.bindingOutput as string, + after: generateTypeScriptBindings( + bindingSchema(compiled), + compiled.resource.revision.revisionId, + configuration.options, + ), + }, + ]; for (const artifact of artifacts) { const file = request.resourceRoot ? `${request.resourceRoot}/${artifact.file}` : artifact.file; safeFile(file); diff --git a/src/capability-language/tool-cli.ts b/src/capability-language/tool-cli.ts index 257e48a..fd614ee 100644 --- a/src/capability-language/tool-cli.ts +++ b/src/capability-language/tool-cli.ts @@ -12,7 +12,6 @@ import os from "node:os"; import { spawnSync } from "node:child_process"; import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js"; import { scaffoldRecipe, type ScaffoldRecipe } from "./scaffold-recipes.js"; -import { isExecutionScaffold } from "./execution-scaffold.js"; import { checkBundleSources } from "../bindings/bundle-policy.js"; import { sealMigrations } from "./migration-seal.js"; import { generatePackageDescriptor } from "../bindings/index.js"; @@ -386,12 +385,19 @@ const main = async () => { } 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.", + "Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependencies are provided by checkedArtifacts.", ); + if (command === "scaffold-install") + throw Error("Execution dependencies are supplied by checkedArtifacts; run workspace-root check instead"); if ( - ["scaffold-package", "scaffold-function", "scaffold-query", "scaffold-migration", "scaffold-install"].includes( - command, - ) + [ + "scaffold-package", + "scaffold-function", + "scaffold-query", + "scaffold-component", + "scaffold-controller", + "scaffold-migration", + ].includes(command) ) { const [root, specFile, ...flags] = args; let spec: ScaffoldRecipe; @@ -425,54 +431,24 @@ const main = async () => { flags.some((flag) => !["--write", "--install"].includes(flag)) || (flags.includes("--install") && !flags.includes("--write")) ) - throw new Error( - "usage: quixos-qx scaffold-package|function|query|migration ROOT SPEC_JSON [--write [--install]]", - ); + throw new Error("usage: quixos-qx scaffold-package|function|query|component|controller ROOT SPEC_JSON [--write]"); const plan = command === "scaffold-install" ? undefined : await planStructure( root, - await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "query" | "migration", spec), + await scaffoldRecipe( + root, + command.slice(9) as "package" | "function" | "query" | "component" | "controller" | "migration", + spec, + ), process.env.QUIXOS_SNAPSHOT_MAP, ); - if ( - flags.includes("--install") && - (spec.template === "typescript-execution" || - (await isExecutionScaffold(root, spec.directory ? `${spec.directory}/` : ""))) - ) - throw new Error( - "Execution package dependencies are supplied by checkedArtifacts; omit --install and run workspace-root check after authoring.", + if (flags.includes("--install")) + throw Error( + "Execution dependencies are supplied by checkedArtifacts; run workspace-root check instead of scaffold install", ); const applied = plan && flags.includes("--write") ? await applyStructure(plan) : undefined; - if (flags.includes("--install")) { - const cwd = path.resolve(root, spec.directory ?? ""); - const toolchain = JSON.parse(await readFile(path.join(cwd, "quixos.toolchain.json"), "utf8")); - if (toolchain.generatedBy !== "qx-scaffold-v1" || typeof toolchain.nixifyPluginUrl !== "string") - throw new Error("Missing scaffold toolchain"); - for (const [executable, args] of [ - ["corepack", ["yarn", "plugin", "import", toolchain.nixifyPluginUrl]], - ["corepack", ["yarn", "config", "set", "generateDefaultNix", "false"]], - ["corepack", ["yarn", "config", "set", "individualNixPackaging", "true"]], - ["corepack", ["yarn", "install"]], - ] as const) { - const result = spawnSync(executable, [...args], { cwd, stdio: ["inherit", 2, 2] }); - if (result.error || result.status !== 0) - throw new Error( - `Scaffold files retained; ${executable} ${args.join(" ")} failed: ${result.error?.message ?? result.status}`, - ); - } - try { - await readFile(path.join(cwd, "yarn-project.nix")); - } catch { - throw new Error( - "Nixify did not generate yarn-project.nix. It skips repositories under the OS temporary directory; use an ordinary workspace checkout and retry installation.", - ); - } - await snapshotCommit(cwd); - const locked = spawnSync("nix", ["flake", "lock"], { cwd, stdio: ["inherit", 2, 2] }); - if (locked.error || locked.status !== 0) throw new Error("Scaffold files retained; nix flake lock failed"); - } process.stdout.write( `${JSON.stringify({ ...(plan ? planSummary(plan) : { installed: true }), applied }, null, 2)}\n`, ); diff --git a/src/execution-world/index.ts b/src/execution-world/index.ts index b9acf1d..84752e7 100644 --- a/src/execution-world/index.ts +++ b/src/execution-world/index.ts @@ -15,3 +15,5 @@ export * from "./projections.js"; export * from "./queries.js"; export { generatePortableClientBindings } from "../bindings/portable-client.js"; export * from "./candidate.js"; + +export { reviewSemanticMajors } from "../capability-model/evolution.js"; diff --git a/src/query/compile.ts b/src/query/compile.ts index 61f9a3e..87ec603 100644 --- a/src/query/compile.ts +++ b/src/query/compile.ts @@ -462,12 +462,15 @@ export async function compileQuery( const definitionDigest = createHash("sha256") .update( canonicalJson({ - semantics: 2, + semantics: 3, declaration, normalized, - interfaces: [...generated.byName.values()].sort((a, b) => - a.revisionId < b.revisionId ? -1 : a.revisionId > b.revisionId ? 1 : 0, - ), + // Source provenance belongs to the checked installation. Identical public + // contracts keep their query identity when retained in another repository. + // Closed generic identity remains explicit in revisionId/application. + interfaces: [...generated.byName.values()] + .map(({ source: _source, ...contract }) => contract) + .sort((a, b) => (a.revisionId < b.revisionId ? -1 : a.revisionId > b.revisionId ? 1 : 0)), }), ) .digest("hex"); diff --git a/test/component-scaffold.test.ts b/test/component-scaffold.test.ts new file mode 100644 index 0000000..aa0c4c2 --- /dev/null +++ b/test/component-scaffold.test.ts @@ -0,0 +1,99 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { scaffoldRecipe } from "../src/capability-language/scaffold-recipes.js"; +import { planStructure, applyStructure } from "../src/capability-language/structural-plan.js"; +const execFile = promisify(callback); +test("checked component scaffolding composes with execution handlers without replacing authored files", async (context) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-component-scaffold-")); + context.after(() => fs.rm(root, { recursive: true, force: true })); + await execFile("git", ["-C", root, "init"]); + const source = { repository: "https://example.test/component.git", commit: "a".repeat(40) }; + const apply = async ( + command: Parameters[1], + spec: Omit[2], "source">, + ) => applyStructure(await planStructure(root, await scaffoldRecipe(root, command, { ...spec, source }))); + await apply("package", { + name: "Widgets", + id: "package:widgets", + revision: "package:widgets@1", + template: "typescript-component", + tools: { quixos: source, protocol: source, helpers: source, sdk: source }, + }); + const packageFile = path.join(root, "package.qx"); + await fs.writeFile(packageFile, "import interface Data;\n" + (await fs.readFile(packageFile, "utf8"))); + await apply("component", { + name: "Card", + id: "component:card", + declaration: 'component Card id "component:card" subject Data runtime react;', + }); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(root, "quixos.components.json"), "utf8")), { + "component:card": { entry: "src/components/Card.tsx", export: "Card" }, + }); + const card = path.join(root, "src/components/Card.tsx"); + await fs.appendFile(card, "\n// authored extension\n"); + const retained = await fs.readFile(card, "utf8"); + await apply("function", { + name: "echo", + id: "echo", + declaration: 'function echo id "echo" : string -> string execution snapshot targets [browser,server];', + }); + await apply("component", { + name: "Details", + id: "component:details", + declaration: 'component Details id "component:details" subject Data runtime react;', + }); + assert.equal(await fs.readFile(card, "utf8"), retained); + assert.match(await fs.readFile(path.join(root, "src/server.ts"), "utf8"), /echo/); + assert.match(await fs.readFile(path.join(root, "flake.nix"), "utf8"), /react \? null/); + await assert.rejects( + apply("component", { + name: "Card", + id: "component:other", + declaration: 'component Card id "component:other" subject Data runtime react;', + }), + /unique/, + ); + await assert.rejects( + apply("component", { + name: "Injected", + id: "component:inject", + declaration: + 'component Injected id "component:inject" subject Data runtime react; function sneak id "sneak" : unit -> unit;', + }), + /exactly one/, + ); +}); + +test("controller scaffolding preserves existing registrations and rejects duplicate identities", async (context) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-controller-scaffold-")); + context.after(() => fs.rm(root, { recursive: true, force: true })); + await execFile("git", ["-C", root, "init"]); + const source = { repository: "https://example.test/adapter.git", commit: "a".repeat(40) }; + const apply = async ( + command: Parameters[1], + spec: Omit[2], "source">, + ) => applyStructure(await planStructure(root, await scaffoldRecipe(root, command, { ...spec, source }))); + await apply("package", { + name: "Adapter", + id: "adapter", + revision: "adapter@1", + tools: { quixos: source, protocol: source, helpers: source }, + }); + const controller = { + subject: "display@1", + snapshot: "display:requested", + remote: "display:apply", + input: "value" as const, + }; + await apply("controller", { id: "display:controller", controller }); + await apply("controller", { id: "display:monitor", controller: { ...controller, snapshot: "display:status" } }); + const entries = JSON.parse(await fs.readFile(path.join(root, "quixos.controllers.json"), "utf8")).controllers; + assert.equal(entries.length, 2); + assert.deepEqual(entries[0], { id: "display:controller", ...controller }); + await assert.rejects(apply("controller", { id: "display:controller", controller }), /duplicate/); +}); diff --git a/test/query-structured.test.ts b/test/query-structured.test.ts index 7709bc5..ea8f30f 100644 --- a/test/query-structured.test.ts +++ b/test/query-structured.test.ts @@ -84,3 +84,21 @@ test("explicit budgets can exceed defaults without exceeding wire bounds", () => assert.equal(result.resource.revision.queries![0]!.budgets.rows, 5000); assert.equal(compile("4294967296").ok, false); }); + +test("query identity follows public contracts, not source retention commits", async () => { + const document = async () => "query Rows {root {objects(first:10) {entries {node {range {start}}}}}}"; + const before = await compileQuery(declaration, [iface], document); + const retained = { + ...iface, + source: { ...iface.source, repository: "https://other.test/interfaces.git", commit: "b".repeat(40) }, + }; + assert.equal((await compileQuery(declaration, [retained], document)).definitionDigest, before.definitionDigest); + const changed = structuredClone(retained); + changed.members[0]!.operations[0]!.id = "changed:get" as (typeof changed.members)[0]["operations"][0]["id"]; + assert.notEqual((await compileQuery(declaration, [changed], document)).definitionDigest, before.definitionDigest); + assert.notEqual( + (await compileQuery({ ...declaration, budgets: { ...declaration.budgets, rows: 17 } }, [retained], document)) + .definitionDigest, + before.definitionDigest, + ); +}); diff --git a/test/scaffold-recipes.test.ts b/test/scaffold-recipes.test.ts index 478881c..7406b0c 100644 --- a/test/scaffold-recipes.test.ts +++ b/test/scaffold-recipes.test.ts @@ -7,122 +7,9 @@ import { execFile as callback } from "node:child_process"; import { promisify } from "node:util"; import { scaffoldRecipe } from "../src/capability-language/scaffold-recipes.js"; import { planStructure, applyStructure } from "../src/capability-language/structural-plan.js"; -import { contentDigest } from "../src/capability-model/evolution.js"; -import { sealMigrations } from "../src/capability-language/migration-seal.js"; import { reactPlatformTypes } from "../src/bindings/react-platform.js"; const execFile = promisify(callback); -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(await fs.readFile(path.join(root, "src/gen/web-studio-react-runtime.d.ts"), "utf8"), reactPlatformTypes); - assert.match(await fs.readFile(path.join(root, "src/browser-assets.d.ts"), "utf8"), /declare module "\*\.css"/); - assert.deepEqual( - JSON.parse(await fs.readFile(path.join(root, "quixos.check.json"), "utf8")).options.react.propsExports, - [], - ); - 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-")); - context.after(() => fs.rm(root, { recursive: true, force: true })); - await execFile("git", ["-C", root, "init"]); - await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n"); - const source = { repository: "https://example.test/chess.git", commit: "a".repeat(40) }; - const base = { source, directory: "packages/Chess" }; - const apply = async (command: Parameters[1], input: Parameters[2]) => - applyStructure(await planStructure(root, await scaffoldRecipe(root, command, input))); - await apply("package", { - ...base, - name: "Chess", - id: "package:chess", - revision: "package:chess@1", - tools: { quixos: source, protocol: source, helpers: source, sdk: source }, - }); - await apply("function", { ...base, name: "play", id: "export:play" }); - const filename = path.join(root, base.directory, "src/impl/play.ts"); - const edited = - 'import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["play"] = async () => null;\n'; - await fs.writeFile(filename, edited); - const old = { version: 1 }, - next = { version: 2 }, - from = contentDigest(old), - to = contentDigest(next); - await apply("migration", { - ...base, - name: "upgrade", - id: "export:upgrade", - migration: { - id: "upgrade-v2", - scopeId: "board", - from, - to, - predecessors: [], - ports: [], - contracts: { [from]: old, [to]: next }, - }, - }); - const migrationFile = path.join(root, base.directory, "src/migrations/upgrade.ts"); - await fs.appendFile(migrationFile, "\n// authored migration change\n"); - await sealMigrations(path.join(root, base.directory)); - assert.equal(await fs.readFile(filename, "utf8"), edited); - const catalog = JSON.parse(await fs.readFile(path.join(root, base.directory, "quixos.migrations.json"), "utf8")); - assert.equal(catalog.migrations[0].implementation.digest, contentDigest(await fs.readFile(migrationFile, "utf8"))); - assert.match(await fs.readFile(path.join(root, base.directory, "src/migrate.ts"), "utf8"), /export:upgrade/); - 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" {}', - }, - ], - }, - ], - }); -}); - test("execution scaffolds route typed handlers by declared effect and target without legacy lifecycle files", async (context) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-execution-recipe-")); context.after(() => fs.rm(root, { recursive: true, force: true })); @@ -164,7 +51,7 @@ test("execution scaffolds route typed handlers by declared effect and target wit await assert.rejects(add("both", "transaction targets [browser, server]"), /unique/); await assert.rejects(add("invalid", "snapshot targets [remote]"), /browser\/server/); await assert.rejects(apply("function", { name: "missing", id: "export:missing" }), /explicit execution/); - await assert.rejects(apply("migration", { name: "migration", id: "export:migration" }), /not supported/); + await assert.rejects(apply("migration", { name: "migration", id: "export:migration" }), /retired/); }); test("query scaffold preserves placement and inserts its named GraphQL document atomically", async (context) => { @@ -176,6 +63,7 @@ test("query scaffold preserves placement and inserts its named GraphQL document path.join(root, "package.qx"), 'import interface Task;\npackage Tasks id "tasks" revision "tasks@1" {}\n', ); + await fs.writeFile(path.join(root, "quixos.check.json"), JSON.stringify({ backend: "execution" })); const request = (name: string, placement: string) => ({ source, name,