diff --git a/src/capability-language/query-scaffold.ts b/src/capability-language/query-scaffold.ts new file mode 100644 index 0000000..541941d --- /dev/null +++ b/src/capability-language/query-scaffold.ts @@ -0,0 +1,79 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { parse } from "graphql"; +import type { ScaffoldRecipe } from "./scaffold-recipes.js"; +import type { StructuralRequest } from "./structural-plan.js"; +import { parseQx, walkSyntax } from "./source.js"; + +/** Insert the authored query and its document together; placement never changes implicitly. */ +export async function scaffoldQuery(root: string, prefix: string, spec: ScaffoldRecipe): Promise { + if ( + !spec.name || + !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || + !spec.id || + !spec.declaration || + typeof spec.queryDocument !== "string" + ) + throw new Error("Query scaffold requires name, id, declaration and queryDocument"); + const source = `package Draft id "draft" revision "draft@1" { ${spec.declaration} }`; + const syntax = parseQx(source); + const pkg = syntax.root.children.find((node) => node.kind === "packageResourceDecl"); + const queries = [...walkSyntax(syntax.root)].filter((node) => node.kind === "packageQuery"); + const query = queries[0]; + if ( + syntax.diagnostics.length || + syntax.root.children.length !== 1 || + !pkg || + queries.length !== 1 || + !query || + pkg.children.filter((node) => node.kind === "packageExport").length !== 1 || + pkg.children.some((node) => !["identifier", "stringLiteral", "packageExport"].includes(node.kind)) + ) + throw new Error("Expected one QX query declaration"); + const identifier = query.children.find((node) => node.kind === "identifier")!; + const literals = query.children + .filter((node) => node.kind === "stringLiteral") + .map((node) => JSON.parse(source.slice(node.start, node.end)) as string); + const [id, document, operation] = literals; + if (source.slice(identifier.start, identifier.end) !== spec.name || id !== spec.id) + throw new Error("Query declaration name/ID must match its registration"); + if (!document || !/^queries\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.graphql$/.test(document)) + throw new Error("Query document must be a contained queries/*.graphql path"); + const graphql = parse(spec.queryDocument); + const operations = graphql.definitions.filter((definition) => definition.kind === "OperationDefinition"); + if (operations.length !== 1 || operations[0]!.operation !== "query" || operations[0]!.name?.value !== operation) + throw new Error("GraphQL document must contain the declared named query operation"); + 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 new Error("Fix package.qx before adding a query"); + for (const node of parent.children.filter((node) => node.kind === "packageExport").flatMap((node) => node.children)) { + const name = node.children.find((child) => child.kind === "identifier"); + const literal = node.children.find((child) => child.kind === "stringLiteral"); + if ( + (name && authored.slice(name.start, name.end) === spec.name) || + (literal && JSON.parse(authored.slice(literal.start, literal.end)) === spec.id) + ) + throw new Error("New query requires a unique name and ID"); + } + 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 + document, create: spec.queryDocument }, + ], + }; +} diff --git a/src/capability-language/scaffold-recipes.ts b/src/capability-language/scaffold-recipes.ts index 1b18c97..37883ec 100644 --- a/src/capability-language/scaffold-recipes.ts +++ b/src/capability-language/scaffold-recipes.ts @@ -12,6 +12,7 @@ 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 { scaffoldQuery } from "./query-scaffold.js"; type Source = { repository: string; commit: string }; type PackageEditModel = { @@ -29,6 +30,7 @@ export type ScaffoldRecipe = { id?: string; 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 }; @@ -71,7 +73,7 @@ const ownedJson = async (root: string, file: string): Promise => { * Implementation files are created once; later edits preserve authored wiring. */ export const scaffoldRecipe = async ( root: string, - command: "package" | "function" | "migration" | "refresh", + command: "package" | "function" | "migration" | "refresh" | "query", spec: ScaffoldRecipe, ): Promise => { if (command === "refresh") @@ -82,6 +84,7 @@ export const scaffoldRecipe = async ( 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}/` : ""; + if (command === "query") return scaffoldQuery(root, prefix, spec); if ( spec.template === "typescript-execution" || (command !== "package" && (await isExecutionScaffold(root, prefix))) diff --git a/src/capability-language/tool-cli.ts b/src/capability-language/tool-cli.ts index c407d07..257e48a 100644 --- a/src/capability-language/tool-cli.ts +++ b/src/capability-language/tool-cli.ts @@ -12,6 +12,7 @@ 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"; @@ -201,7 +202,7 @@ const main = async () => { } 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" + + "quixos-qx: author-check, author-contract, converge, worklist, inspect, resources, check-committed, source-baseline, scaffold-package, scaffold-interface, scaffold-function, scaffold-query, 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", ); @@ -387,7 +388,11 @@ const main = async () => { throw new Error( "Refresh is no longer required: edit declarations and typed implementation wiring, then run qx-workspace check. Dependency installation uses scaffold install.", ); - if (["scaffold-package", "scaffold-function", "scaffold-migration", "scaffold-install"].includes(command)) { + if ( + ["scaffold-package", "scaffold-function", "scaffold-query", "scaffold-migration", "scaffold-install"].includes( + command, + ) + ) { const [root, specFile, ...flags] = args; let spec: ScaffoldRecipe; if (command === "scaffold-function" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(specFile ?? "")) { @@ -421,16 +426,24 @@ const main = async () => { (flags.includes("--install") && !flags.includes("--write")) ) throw new Error( - "usage: quixos-qx scaffold-package|function|migration|refresh ROOT SPEC_JSON [--write [--install]]", + "usage: quixos-qx scaffold-package|function|query|migration ROOT SPEC_JSON [--write [--install]]", ); const plan = command === "scaffold-install" ? undefined : await planStructure( root, - await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration", spec), + await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "query" | "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.", + ); const applied = plan && flags.includes("--write") ? await applyStructure(plan) : undefined; if (flags.includes("--install")) { const cwd = path.resolve(root, spec.directory ?? ""); diff --git a/test/scaffold-recipes.test.ts b/test/scaffold-recipes.test.ts index f1442cf..478881c 100644 --- a/test/scaffold-recipes.test.ts +++ b/test/scaffold-recipes.test.ts @@ -166,3 +166,60 @@ test("execution scaffolds route typed handlers by declared effect and target wit await assert.rejects(apply("function", { name: "missing", id: "export:missing" }), /explicit execution/); await assert.rejects(apply("migration", { name: "migration", id: "export:migration" }), /not supported/); }); + +test("query scaffold preserves placement and inserts its named GraphQL document atomically", async (context) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-query-recipe-")); + context.after(() => fs.rm(root, { recursive: true, force: true })); + await execFile("git", ["-C", root, "init"]); + const source = { repository: "https://example.test/query.git", commit: "a".repeat(40) }; + await fs.writeFile( + path.join(root, "package.qx"), + 'import interface Task;\npackage Tasks id "tasks" revision "tasks@1" {}\n', + ); + const request = (name: string, placement: string) => ({ + source, + name, + id: `tasks:${name}`, + declaration: `query ${name} id "tasks:${name}" root Task document "queries/${name}.graphql" operation "${name}" { ${placement} max rows 100; }`, + queryDocument: `query ${name} { root { title } }\n`, + }); + for (const [name, placement] of [ + ["Inputs", "replica;"], + ["Screen", "local from Inputs;"], + ["Confirmed", ""], + ]) { + const spec = request(name, placement); + const recipe = await scaffoldRecipe(root, "query", spec); + const planned = await planStructure(root, recipe); + await assert.rejects(fs.access(path.join(root, `queries/${name}.graphql`)), "Preview must not write files"); + await applyStructure(planned); + assert.equal(await fs.readFile(path.join(root, `queries/${name}.graphql`), "utf8"), spec.queryDocument); + assert.ok((await fs.readFile(path.join(root, "package.qx"), "utf8")).includes(placement)); + await assert.rejects(scaffoldRecipe(root, "query", spec), /unique/); + } + const bad = request("Bad", "replica;"); + await assert.rejects( + scaffoldRecipe(root, "query", { + ...bad, + declaration: bad.declaration.replace("queries/Bad.graphql", "../Bad.graphql"), + }), + /contained/, + ); + await assert.rejects( + scaffoldRecipe(root, "query", { ...bad, queryDocument: "query Other { root { title } }" }), + /declared named query/, + ); + await assert.rejects( + scaffoldRecipe(root, "query", { ...bad, queryDocument: "mutation Bad { title }" }), + /declared named query/, + ); + await assert.rejects( + scaffoldRecipe(root, "query", { ...bad, declaration: bad.declaration + ' function x id "x" : unit -> unit;' }), + /one QX query/, + ); + await fs.mkdir(path.join(root, "queries"), { recursive: true }); + await fs.writeFile(path.join(root, "queries/Bad.graphql"), "# authored content\n"); + const before = await fs.readFile(path.join(root, "package.qx"), "utf8"); + await assert.rejects(planStructure(root, await scaffoldRecipe(root, "query", bad)), /existing file/); + assert.equal(await fs.readFile(path.join(root, "package.qx"), "utf8"), before); +});