diff --git a/flake.nix b/flake.nix index 2e42917..7f2a1a8 100644 --- a/flake.nix +++ b/flake.nix @@ -29,6 +29,7 @@ pkgs.jujutsu pkgs.gnutar pkgs.util-linux + pkgs.nix ]; buildPhase = '' runHook preBuild diff --git a/src/capability-language/component-nix.ts b/src/capability-language/component-nix.ts new file mode 100644 index 0000000..201eded --- /dev/null +++ b/src/capability-language/component-nix.ts @@ -0,0 +1,52 @@ +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +const execFile = promisify(callback); +export const nixLiteral = (value: string): string => JSON.stringify(value).replace(/\$\{/g, "\\${"); + +/** Recognize the deliberately small scaffold edit surface, never evaluate Nix. + * Authors may replace it with arbitrary Nix; then scaffolding declines the edit. */ +export async function componentEntries(source: string): Promise> { + const { stdout } = await execFile("nix-instantiate", ["--parse", "--expr", source], { maxBuffer: 1024 * 1024 }); + const tokens = stdout.match(/"(?:\\.|[^"\\])*"|[A-Za-z_][A-Za-z0-9_-]*|[{}=;]/g) ?? []; + if (stdout.replace(/"(?:\\.|[^"\\])*"|[A-Za-z_][A-Za-z0-9_-]*|[{}=;]|\s+/g, "")) + throw Error("components.nix is outside the supported literal-entry shape"); + let cursor = 0; + const expect = (token: string) => { + if (tokens[cursor++] !== token) throw Error("components.nix is outside the supported literal-entry shape"); + }; + const string = () => { + const token = tokens[cursor++]; + if (!token?.startsWith('"')) throw Error("Component entries must use literal strings"); + const json = token.replace(/(\\*)\$\{/g, (_match, escapes: string) => { + if (escapes.length % 2 === 0) throw Error("Component entries must use literal strings"); + return escapes.slice(1) + "${"; + }); + return JSON.parse(json) as string; + }; + const entries: Record = Object.create(null); + expect("{"); + while (tokens[cursor] !== "}") { + const id = tokens[cursor]?.startsWith('"') ? string() : tokens[cursor++]; + if (!id) throw Error("Missing component identity"); + if (Object.hasOwn(entries, id)) throw Error("Duplicate component entry"); + expect("="); + expect("{"); + const properties: Record = Object.create(null); + while (tokens[cursor] !== "}") { + const key = tokens[cursor]?.startsWith('"') ? string() : tokens[cursor++]; + if (!key || !["entry", "export"].includes(key) || Object.hasOwn(properties, key)) + throw Error("Unsupported component entry property"); + expect("="); + properties[key] = string(); + expect(";"); + } + expect("}"); + expect(";"); + if (!properties.entry || !properties.export) throw Error("Component entry needs entry and export"); + entries[id] = { entry: properties.entry, export: properties.export }; + } + expect("}"); + if (cursor !== tokens.length || !/\n}\s*$/.test(source)) + throw Error("components.nix is outside the supported scaffold shape"); + return entries; +} diff --git a/src/capability-language/component-scaffold.ts b/src/capability-language/component-scaffold.ts index 9d3646b..3b317eb 100644 --- a/src/capability-language/component-scaffold.ts +++ b/src/capability-language/component-scaffold.ts @@ -3,6 +3,7 @@ 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"; +import { componentEntries, nixLiteral } from "./component-nix.js"; /** Add one ordinary component entry without regenerating existing application code. */ export async function scaffoldComponent( @@ -46,12 +47,13 @@ export async function scaffoldComponent( ) throw Error("Component requires a unique name and ID"); } - const filename = "quixos.components.json"; + const filename = "components.nix"; const before = await fs.readFile(path.join(root, prefix, filename), "utf8"); - const entries: Record = JSON.parse(before); + const entries = await componentEntries(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 insertion = ` ${nixLiteral(spec.id)} = { entry = ${nixLiteral(`src/components/${spec.name}.tsx`)}; export = ${nixLiteral(spec.name)}; };\n`; + const after = before.replace(/\n}(\s*)$/, (_match, trailing) => "\n" + insertion + "}" + trailing); const parentID = parent.children.find((node) => node.kind === "stringLiteral")!; return { kind: "package", @@ -69,10 +71,10 @@ export async function scaffoldComponent( }, ], }, - { file: prefix + filename, expected: before, replace: JSON.stringify(entries, null, 2) + "\n" }, + { file: prefix + filename, expected: before, replace: after }, { 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`, + create: `import type { Modules } from "../gen/client.js";\n\n// Add explicit query admission and named interface operations from the generated client.\nexport const ${spec.name}: Modules[${JSON.stringify(spec.name)}] = ({subject}) =>

{subject.object}

;\n`, }, ], }; diff --git a/src/capability-language/execution-scaffold.ts b/src/capability-language/execution-scaffold.ts index 424252a..3a11e02 100644 --- a/src/capability-language/execution-scaffold.ts +++ b/src/capability-language/execution-scaffold.ts @@ -37,9 +37,25 @@ 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"); + create("components.nix", "{\n}\n"); + create( + "package.json", + json({ + name: `@quixos/${spec.name.toLowerCase()}`, + private: true, + type: "module", + packageManager: "yarn@4.18.0", + scripts: { typecheck: "qx-workspace check" }, + }), + ); + create( + ".gitignore", + "node_modules/\ndist/\nsrc/gen/\n.quixos/\nresult\n.yarn/cache/\n.yarn/install-state.gz\n.yarn/unplugged/\n", + ); + create( + ".yarnrc.yml", + 'enableScripts: true\n\ngenerateDefaultNix: false\n\nindividualNixPackaging: true\n\nnodeLinker: node-modules\n\nsupportedArchitectures:\n os:\n - current\n - linux\n cpu:\n - current\n - x64\n - arm64\n libc:\n - current\n - glibc\n\nplugins:\n - checksum: 271de9a785321a37c564b8e2a6b5cd689850b20572d5fee8ec0a6b8e7a5d4cfb59e533524af2b4a537e00da12828abd062eaec6978556e3dd9149a020db2591b\n path: .yarn/plugins/yarn-plugin-nixify.cjs\n spec: "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js"\n', + ); for (const lane of ["browser", "server", "remote"]) create( `src/${lane}.ts`, @@ -56,22 +72,36 @@ export async function executionScaffold( flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; in { - 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"; - bindingOutput = "src/gen/qx.ts"; - }; - remote = { entry = "src/remote.ts"; bindingOutput = "src/gen/remote.ts"; }; - }; + devShells.default = (import (helpers + "/quixos-package-helpers.nix")).mkCaminoYarnShell { inherit pkgs; }; + quixosPackages.checkedArtifacts = context: + import ./package.nix { inherit pkgs context; camino = import (helpers + "/quixos-package-helpers.nix"); }; + }); +} +`, + ); + create( + "package.nix", + `{ pkgs, camino, context }: +let + plan = builtins.fromJSON (builtins.readFile context.plan); + package = pkgs.lib.findFirst (entry: entry.revision == context.packageRevisionId) + (throw "Package missing from checked plan") plan.definition.packages; + portable = builtins.filter (entry: entry.execution.kind != "remote") package.exports; + remote = builtins.filter (entry: entry.execution.kind == "remote") package.exports; + modules = import ./components.nix; + dependencies = if builtins.pathExists ./yarn-project.nix + then camino.mkCaminoYarnDependencies { inherit pkgs; src = ./.; } else null; +in camino.assembleCaminoPackage { + inherit pkgs context; + artifacts = + pkgs.lib.optional (portable != []) (camino.mkCaminoTypeScriptPackage { + inherit pkgs context dependencies; src = ./.; + entry = "src/server.ts"; entries.browser = "src/browser.ts"; + targets = pkgs.lib.unique (pkgs.lib.concatMap (entry: entry.execution.targets) portable); + }) ++ pkgs.lib.optional (remote != []) (camino.mkCaminoTypeScriptService { + inherit pkgs context dependencies; src = ./.; entry = "src/remote.ts"; + }) ++ pkgs.lib.optional (modules != {}) (camino.mkCaminoReactPackage { + inherit pkgs context dependencies modules; src = ./.; }); } `, @@ -163,7 +193,7 @@ export async function executionScaffold( 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( + !/^(?:package\.qx|(?:package|components|flake|yarn-project)\.nix|package\.json|yarn\.lock|\.yarnrc\.yml|\.gitignore|\.yarn\/plugins\/[A-Za-z0-9_.-]+\.cjs|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") diff --git a/src/capability-language/structural-plan.ts b/src/capability-language/structural-plan.ts index 80abff8..fe5cfae 100644 --- a/src/capability-language/structural-plan.ts +++ b/src/capability-language/structural-plan.ts @@ -28,7 +28,7 @@ type Change = { file: string; before: string | null; after: string; mode: number type Journal = { schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[] }; const safeFile = (file: string) => { if ( - !/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|graphql|lock|ts|tsx|css|mjs|json|nix|txtpb|md))$/.test( + !/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc\.yml|\.yarn\/plugins\/[A-Za-z0-9_-][A-Za-z0-9_.-]*\.cjs|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|graphql|lock|ts|tsx|css|mjs|json|nix|txtpb|md))$/.test( file, ) || file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part)) @@ -141,7 +141,7 @@ export const planStructure = async (rootPath: string, request: StructuralRequest 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) + if (path.basename(change.file) === "quixos.lock" && !parseQuixosLockDocument(change.after, change.file).ok) throw new Error(`Invalid lock syntax in ${change.file}`); } } else { diff --git a/test/component-nix.test.ts b/test/component-nix.test.ts new file mode 100644 index 0000000..6582e57 --- /dev/null +++ b/test/component-nix.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { componentEntries } from "../src/capability-language/component-nix.js"; +test("component edit surface parses literal Nix without evaluating arbitrary expressions", async () => { + assert.deepEqual( + { + ...(await componentEntries( + `{\n # retained author comment\n "component:card" = { export = "Card"; entry = "src/card.tsx"; };\n}\n`, + )), + }, + { "component:card": { entry: "src/card.tsx", export: "Card" } }, + ); + await assert.rejects( + componentEntries('let x = builtins.abort "must not evaluate"; in x'), + /supported literal-entry shape|literal strings|supported scaffold shape/, + ); + await assert.rejects( + componentEntries( + '{\n "component:card" = { entry = "${builtins.abort "must not evaluate"}"; export = "Card"; };\n}\n', + ), + /literal strings|supported literal-entry shape/, + ); + await assert.rejects( + componentEntries('{\n "component:card" = { entry = "src/card.tsx"; export = "Card"; extra = true; };\n}\n'), + /Unsupported component entry property/, + ); +}); diff --git a/test/component-scaffold.test.ts b/test/component-scaffold.test.ts index 45c7805..e416f41 100644 --- a/test/component-scaffold.test.ts +++ b/test/component-scaffold.test.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import { execFile as callback } from "node:child_process"; import { promisify } from "node:util"; +import { componentEntries } from "../src/capability-language/component-nix.js"; import { scaffoldRecipe } from "../src/capability-language/scaffold-recipes.js"; import { planStructure, applyStructure } from "../src/capability-language/structural-plan.js"; const execFile = promisify(callback); @@ -31,12 +32,23 @@ test("checked component scaffolding composes with execution handlers without rep id: "component:card", declaration: 'module Card id "component:card" implements View.render;', }); - assert.deepEqual(JSON.parse(await fs.readFile(path.join(root, "quixos.components.json"), "utf8")), { - "component:card": { entry: "src/components/Card.tsx", export: "Card" }, - }); + assert.deepEqual( + { ...(await componentEntries(await fs.readFile(path.join(root, "components.nix"), "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"); + const flake = path.join(root, "flake.nix"); + await fs.appendFile(flake, "\n# retained authored toolchain configuration\n"); + const retainedFlake = await fs.readFile(flake, "utf8"); + const entriesFile = path.join(root, "components.nix"); + await fs.writeFile( + entriesFile, + (await fs.readFile(entriesFile, "utf8")).replace("{\n", "{\n # retained entry comment\n"), + ); await apply("function", { name: "echo", id: "echo", @@ -48,8 +60,10 @@ test("checked component scaffolding composes with execution handlers without rep declaration: 'module Details id "component:details" implements View.render;', }); assert.equal(await fs.readFile(card, "utf8"), retained); + assert.equal(await fs.readFile(flake, "utf8"), retainedFlake); + assert.match(await fs.readFile(entriesFile, "utf8"), /retained entry comment/); 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/); + assert.match(await fs.readFile(path.join(root, "flake.nix"), "utf8"), /import \.\/package\.nix/); await assert.rejects( apply("component", { name: "Card", @@ -67,6 +81,17 @@ test("checked component scaffolding composes with execution handlers without rep }), /exactly one/, ); + const beforeUnsupported = await fs.readFile(packageFile, "utf8"); + await fs.writeFile(entriesFile, '{\n custom = builtins.abort "must not evaluate";\n}\n'); + await assert.rejects( + apply("component", { + name: "Unsupported", + id: "component:unsupported", + declaration: 'module Unsupported id "component:unsupported" implements View.render;', + }), + /literal-entry shape/, + ); + assert.equal(await fs.readFile(packageFile, "utf8"), beforeUnsupported); }); test("controller scaffolding preserves existing registrations and rejects duplicate identities", async (context) => { diff --git a/test/scaffold-recipes.test.ts b/test/scaffold-recipes.test.ts index 7406b0c..ef3f61d 100644 --- a/test/scaffold-recipes.test.ts +++ b/test/scaffold-recipes.test.ts @@ -47,7 +47,7 @@ test("execution scaffolds route typed handlers by declared effect and target wit assert.match(await read("src/impl/asyncWork.ts"), /RemoteImplementation/); assert.match(await read("src/impl/both.ts"), /PortableImplementation/); assert.match(await read("flake.nix"), /checkedArtifacts/); - assert.match(await read("flake.nix"), /entries.browser/); + assert.match(await read("package.nix"), /entries.browser/); 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/); diff --git a/test/structural-plan.test.ts b/test/structural-plan.test.ts index d57c6a5..8ecaedc 100644 --- a/test/structural-plan.test.ts +++ b/test/structural-plan.test.ts @@ -61,4 +61,28 @@ test("structural plans validate the graph, journal originals, and reject stale e 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" })); + const plugin = ".yarn/plugins/yarn-plugin-nixify.cjs"; + await applyStructure( + await planStructure(root, { + kind: "workspace", + validation: "syntax", + files: [ + { file: plugin, create: "module.exports = {};\n" }, + { file: "yarn.lock", create: "__metadata:\n version: 8\n" }, + { file: "flake.lock", create: '{"version":7}\n' }, + ], + }), + ); + assert.equal(await fs.readFile(path.join(root, plugin), "utf8"), "module.exports = {};\n"); + for (const file of [".yarn/../escape.cjs", ".yarn/cache/entry.cjs", ".git/hooks/hook.cjs"]) { + await assert.rejects( + () => + planStructure(root, { + kind: "workspace", + validation: "syntax", + files: [{ file, create: "" }], + }), + /Unsafe scaffold path/, + ); + } });