From e753ffe6018fa03ae145269ddc29418b8a631e87 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Mon, 14 Sep 2026 20:35:53 -0700 Subject: [PATCH] Simplify workspace authoring and isolate managed component styles --- package.json | 5 +- src/bindings/bundle-policy.ts | 41 ++++++++ src/bindings/index.ts | 6 ++ src/capability-language/authoring-check.ts | 7 ++ .../implementation-edit.ts | 29 ++++++ src/capability-language/migration-seal.ts | 23 +++++ src/capability-language/scaffold-recipes.ts | 93 +++++++++---------- src/capability-language/structural-plan.ts | 20 +--- src/capability-language/tool-cli.ts | 54 +++++++++-- test/bundle-policy.test.ts | 21 +++++ test/scaffold-recipes.test.ts | 34 +++---- yarn-project.nix | 4 + yarn.lock | 36 +++++++ 13 files changed, 278 insertions(+), 95 deletions(-) create mode 100644 src/bindings/bundle-policy.ts create mode 100644 src/capability-language/implementation-edit.ts create mode 100644 src/capability-language/migration-seal.ts create mode 100644 test/bundle-policy.test.ts diff --git a/package.json b/package.json index f16822c..bb98102 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,11 @@ "packageManager": "yarn@4.18.0", "type": "module", "bin": { - "quixos-qx": "dist/src/capability-language/tool-cli.js", - "quixos-codegen-ts": "dist/src/bindings/cli.js", "quixos-capability-compile": "dist/src/capability-language/cli.js", + "quixos-codegen-ts": "dist/src/bindings/cli.js", "quixos-descriptor-check": "dist/src/descriptor-check.js", "quixos-lock-check": "dist/src/resource-lock/cli.js", + "quixos-qx": "dist/src/capability-language/tool-cli.js", "quixos-resource-compile": "dist/src/capability-language/resource-cli.js", "quixos-workspace-compile": "dist/src/capability-language/workspace-cli.js" }, @@ -29,6 +29,7 @@ "typecheck": "yarn generate && tsc --noEmit" }, "dependencies": { + "@babel/parser": "^7.28.0", "@bufbuild/protobuf": "^2.12.1", "@bufbuild/protoc-gen-es": "^2.12.1", "antlr4ng": "^3.0.16" diff --git a/src/bindings/bundle-policy.ts b/src/bindings/bundle-policy.ts new file mode 100644 index 0000000..c63e82f --- /dev/null +++ b/src/bindings/bundle-policy.ts @@ -0,0 +1,41 @@ +import {parse} from "@babel/parser"; +import fs from "node:fs/promises"; +import path from "node:path"; + +/** Enforce the authored bundled-code contract, not a security sandbox. */ +export function bundlePolicyErrors(source: string, filename: string): string[] { + const ast = parse(source, {sourceType: "module", plugins: ["typescript", "jsx"]}); + const errors: string[] = []; + const visit = (value: unknown) => { + if (Array.isArray(value)) {value.forEach(visit); return;} + if (!value || typeof value !== "object") return; + const n = value as Record; + if (typeof n.type !== "string") return; + const fail = (message: string) => errors.push(`${filename}:${n.loc?.start.line ?? 1}: ${message}`); + if (n.type === "MetaProperty" && n.meta.name === "import") fail("Bundled package code cannot use import.meta; import packaged assets statically"); + if (n.type === "Identifier" && ["__dirname", "__filename"].includes(n.name)) fail("Bundled package code cannot depend on module filesystem locations"); + if (["CallExpression", "NewExpression"].includes(n.type)) { + if (n.callee?.type === "Identifier" && ["eval", "Function"].includes(n.callee.name)) fail("Dynamic code generation is unsupported in bundled package code"); + if ((n.callee?.type === "Import" || (n.callee?.type === "Identifier" && n.callee.name === "require")) && + (n.arguments.length !== 1 || n.arguments[0].type !== "StringLiteral")) fail("Module imports must have a static string specifier"); + } + if (n.type === "ImportExpression" && n.source.type !== "StringLiteral") fail("Module imports must have a static string specifier"); + Object.values(n).forEach(visit); + }; + visit(ast); + return errors; +} +export async function checkBundleSources(directory: string): Promise { + const errors: string[] = []; + async function walk(current: string) { + for (const entry of await fs.readdir(current, {withFileTypes: true})) { + if (["gen", "node_modules"].includes(entry.name)) continue; + const file = path.join(current, entry.name); + if (entry.isSymbolicLink()) throw new Error(`Authored source symlinks are unsupported: ${file}`); + if (entry.isDirectory()) await walk(file); + else if (/\.[cm]?[jt]sx?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) errors.push(...bundlePolicyErrors(await fs.readFile(file, "utf8"), file)); + } + } + await walk(directory); + if (errors.length) throw new Error(errors.join("\n")); +} diff --git a/src/bindings/index.ts b/src/bindings/index.ts index ba87ac4..0eda305 100644 --- a/src/bindings/index.ts +++ b/src/bindings/index.ts @@ -18,6 +18,12 @@ export type TypeScriptBindingOptions = { /** Each export must implement MessageBinding, providing both TS type and wire codec. */ messages?: Record; }; +export function generatePackageDescriptor(schema: BindingSchema, revisionId: string): string { + const pkg = schema.packages.find(entry => entry.revisionId === revisionId); + if (!pkg) throw new Error(`Unknown package revision ${revisionId}`); + return `# Generated from the checked package contract\npackage_id: ${JSON.stringify(pkg.packageId)}\npackage_revision_id: ${JSON.stringify(pkg.revisionId)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` + + pkg.exports.map(entry => `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.displayName)} }\n`).join(""); +} const q = JSON.stringify; const object = (entries: [string, string][]) => `{ ${entries.map(([key, value]) => `${q(key)}: ${value}`).join("; ")} }`; const unit = (type: ValueType) => type.kind === "builtin" && type.name === "unit"; diff --git a/src/capability-language/authoring-check.ts b/src/capability-language/authoring-check.ts index 73a3de0..656bab8 100644 --- a/src/capability-language/authoring-check.ts +++ b/src/capability-language/authoring-check.ts @@ -10,6 +10,8 @@ import { planEvolution, type WorkspaceRevision, type EvolutionReview } from "../ export const checkRecordName = (directory: string) => createHash("sha256").update(directory).digest("hex") + ".json"; export async function checkAuthoring(start: string, output: string, options: { baseline?: string; reviews?: string; contractOnly?: boolean } = {}) { + const started = performance.now(); + const timings: Record = {}; const context = await authoringContext(start); const location = await fs.realpath(start); const directory = location === context.workbench ? "root" : path.relative(context.workbench, location); @@ -29,13 +31,16 @@ export async function checkAuthoring(start: string, output: string, options: { b throw error; }); const converged = JSON.parse(captured.stdout) as Awaited>; + timings.captureMs = Math.round(performance.now() - started); if (!converged.candidate) { report.phase = converged.worklist.find(entry => entry.phase !== "dependency")?.phase ?? "convergence"; throw new Error(converged.worklist.map(entry => `${entry.directory} [${entry.phase}]: ${entry.message}`).join("\n")); } report.commit = converged.candidate.commit; report.phase = "verification"; + const buildStarted = performance.now(); report.artifactPath = await buildImmutableCandidate(converged.candidate, resource.kind, path.join(output, "nix.log"), options.contractOnly); + timings.immutableCheckMs = Math.round(performance.now() - buildStarted); const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8"); await fs.writeFile(path.join(output, "candidate.json"), candidateText); report.compilation = "passed"; @@ -59,6 +64,8 @@ export async function checkAuthoring(start: string, output: string, options: { b } if (!report.blockers.length) report.phase = options.contractOnly ? "contract-only" : "checked"; } catch (error) { report.blockers.push(String(error instanceof Error ? error.message : error)); } + timings.totalMs = Math.round(performance.now() - started); + Object.assign(report, {timings}); await fs.writeFile(path.join(output, "report.json"), JSON.stringify(report, null, 2)); if (options.contractOnly) return report; const records = path.join(context.workbench, ".quixos/checks"); diff --git a/src/capability-language/implementation-edit.ts b/src/capability-language/implementation-edit.ts new file mode 100644 index 0000000..abc9962 --- /dev/null +++ b/src/capability-language/implementation-edit.ts @@ -0,0 +1,29 @@ +import {parse} from "@babel/parser"; +type Node = {type: string; start: number; end: number; [key: string]: unknown}; +const node = (value: unknown): value is Node => !!value && typeof value === "object" && typeof (value as Node).type === "string"; + +/** One requested insertion into ordinary authored TypeScript, not regeneration. + * Ambiguous/custom wiring is left alone with an actionable error. */ +export function addImplementation(text: string, factory: "createRuntime" | "serveMigration", key: string, importPath: string, migrationOnly = false): string { + const ast = parse(text, {sourceType: "module", plugins: ["typescript"]}); + const objects: Node[] = []; + const names = new Set(); + const visit = (value: unknown) => { + if (Array.isArray(value)) { value.forEach(visit); return; } + if (!node(value)) return; + if (value.type === "Identifier") names.add(String(value.name)); + if (value.type === "CallExpression" && node(value.callee) && value.callee.type === "Identifier" && value.callee.name === factory && + Array.isArray(value.arguments) && value.arguments.length === 1 && node(value.arguments[0]) && value.arguments[0].type === "ObjectExpression") objects.push(value.arguments[0]); + Object.values(value).forEach(visit); + }; + visit(ast); + if (objects.length !== 1) throw new Error(`Cannot safely add implementation: expected one ${factory}({...}) literal. Wire the handler in your authored server code instead.`); + const object = objects[0]; + if ((object.properties as Node[]).some(p => node(p.key) && !p.computed && (p.key.name === key || p.key.value === key))) throw new Error(`Implementation already exists for ${key}`); + let alias = "qxImplementation"; + for (let index = 1; names.has(alias); index++) alias = `qxImplementation${index}`; + const value = migrationOnly ? 'async () => { throw new Error("Migration-only export"); }' : alias; + const insertion = object.start + 1; + const edited = text.slice(0, insertion) + `\n ${JSON.stringify(key)}: ${value},\n` + text.slice(insertion); + return (migrationOnly ? "" : `import {handler as ${alias}} from ${JSON.stringify(importPath)};\n`) + edited; +} diff --git a/src/capability-language/migration-seal.ts b/src/capability-language/migration-seal.ts new file mode 100644 index 0000000..0fa196e --- /dev/null +++ b/src/capability-language/migration-seal.ts @@ -0,0 +1,23 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import {contentDigest} from "../capability-model/evolution.js"; +import {validateMigrationCatalog, type MigrationCatalog} from "../capability-model/migrations.js"; +import {applyStructure, planStructure} from "./structural-plan.js"; + +/** Explicitly acknowledge edited migration code. This does not change retained + * contracts or grant activation approval; the immutable checker verifies it. */ +export async function sealMigrations(directory: string) { + const root = await fs.realpath(directory); + const file = "quixos.migrations.json"; + const before = await fs.readFile(path.join(root, file), "utf8"); + const catalog = JSON.parse(before) as MigrationCatalog; + for (const migration of catalog.migrations) { + const implementation = await fs.realpath(path.join(root, migration.implementation.file)); + if (!implementation.startsWith(root + path.sep)) throw new Error("Migration implementation escapes package"); + migration.implementation.digest = contentDigest(await fs.readFile(implementation, "utf8")); + } + validateMigrationCatalog(catalog); + return applyStructure(await planStructure(root, {kind: "package", validation: "syntax", files: [ + {file, expected: before, replace: JSON.stringify(catalog, null, 2) + "\n"}, + ]})); +} diff --git a/src/capability-language/scaffold-recipes.ts b/src/capability-language/scaffold-recipes.ts index 70f50e3..057f3d3 100644 --- a/src/capability-language/scaffold-recipes.ts +++ b/src/capability-language/scaffold-recipes.ts @@ -5,9 +5,10 @@ import {validateMigrationCatalog, type MigrationCatalog, type MigrationDeclarati import {formatQuixosLock, type GitSource} from "../resource-lock/index.js"; import {parseQx, walkSyntax} from "./source.js"; import type {StructuralRequest} from "./structural-plan.js"; +import {addImplementation} from "./implementation-edit.js"; type Source = {repository: string; commit: string}; -type Registry = {generatedBy: "qx-scaffold-v1"; name: string; id: string; revision: string; exports: {name: string; id: string; file: string; migration?: boolean}[]}; +type 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"; source: Source; directory?: string; name?: string; id?: string; revision?: string; @@ -16,9 +17,9 @@ export type ScaffoldRecipe = { nixifyPluginUrl?: string; migration?: Omit & {contracts: Record}; }; -const marker = "// Generated by qx-scaffold-v1\n"; 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"); const url = new URL(value.repository); if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit)) throw new Error("Scaffolds require credential-free HTTPS sources and full exact commits"); return {resolver: "git", ...value}; @@ -38,15 +39,16 @@ const ownedJson = async (root: string, file: string): Promise => { }; /** Recipes describe structural edits; planStructure owns validation/journaling. - * Implementation files are created once and never rewritten by refresh. */ + * Implementation files are created once; later edits preserve authored wiring. */ export const scaffoldRecipe = async (root: string, command: "package" | "function" | "migration" | "refresh", spec: ScaffoldRecipe): 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."); 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}/` : ""; 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 registry: Registry; + let packageModel: PackageEditModel; let catalog: MigrationCatalog & {generatedBy: "qx-scaffold-v1"}; if (command === "package") { const name = safeName(spec.name); @@ -54,19 +56,19 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio 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); - registry = {generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: []}; + packageModel = {generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: []}; catalog = {generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: []}; - if (react) registry.exports.push({name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts"}); + 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${react ? " && node scripts/build-component.mjs" : ""}`, typecheck: "tsc --noEmit"}, dependencies: {"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`}, + scripts: {build: "tsc -p tsconfig.json", typecheck: "tsc --noEmit"}, dependencies: {"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`}, devDependencies: {"@types/node": "^24", typescript: "^7.0.2", ...(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", `// Props are opaque at the platform boundary until capability generics exist.\nexport default function Component(_props: {camino: unknown; render: unknown; dispatch: (action: unknown) => void}) {\n return

${name}

Edit this component, then run qx-workspace check.

;\n}\n`); - create("src/impl/sourceGet.ts", `import {readFile} from "node:fs/promises";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => readFile(new URL("./component.mjs", import.meta.url), "utf8");\n`); - create("scripts/build-component.mjs", `import {build} from "esbuild";\nconst platform = new Map([\n ["react", "/__quixos/platform/react/v18.mjs"],\n ["react/jsx-runtime", "/__quixos/platform/react-jsx-runtime/v18.mjs"],\n ["react/jsx-dev-runtime", "/__quixos/platform/react-jsx-dev-runtime/v18.mjs"],\n ["@quixos/web-studio-react-runtime", "/__quixos/platform/web-studio-react-runtime/v1.mjs"],\n]);\nawait build({entryPoints: ["dist/component.js"], outfile: "dist/component.mjs", bundle: true, format: "esm", platform: "browser", target: "es2022", plugins: [{name: "quixos-platform", setup(api) {api.onResolve({filter: /.*/}, ({path}) => platform.has(path) ? {path: platform.get(path), external: true} : undefined);}}]});\n`); + create("src/impl/sourceGet.ts", `import componentSource from "../component.js?browser-source";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => componentSource;\n`); + create("src/browser-assets.d.ts", `declare module "*?browser-source" { const source: string; export default source; }\n`); } create(".gitignore", "node_modules/\ndist/\n.quixos/\nresult\n.yarn/install-state.gz\n"); create(".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`); @@ -74,7 +76,7 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio 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"})); + create("quixos.check.json", json({backend: "typescript", bindingOutput: "src/gen/qx.ts", ...(react ? {options: {messages: {"org.quixos.web-studio.ReactProps": {module: "@quixos/camino-package-runtime", export: "opaqueReactPropsBinding"}}}} : {})})); create("flake.nix", `{ inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))}; inputs.nixpkgs.follows = "protocol/nixpkgs"; @@ -83,47 +85,34 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio outputs = inputs@{ self, protocol, nixpkgs, flake-utils, helpers, ... }: (import (toString helpers + "/quixos-package-helpers.nix")).mkCaminoTsYarnNixifyFlake { inherit inputs nixpkgs flake-utils; packageRoot = ./.; - bundle = { entry = "dist/server.js"; }; + 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"; ${react ? 'extraFiles = [ { source = "dist/component.mjs"; target = "component.mjs"; } ];' : ""} }; + installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; }; }; }\n`); } else { - registry = await ownedJson(root, prefix + "quixos.scaffold.json"); catalog = await ownedJson(root, prefix + "quixos.migrations.json"); - // package.qx is authoritative. The registry remembers implementation paths, - // not a second declaration list that can erase an author's new exports. + // 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 refresh an invalid package.qx; fix the reported syntax first"); + if (syntax.diagnostics.length) throw new Error("Cannot scaffold into an invalid package.qx; fix the reported syntax first"); const declaration = [...walkSyntax(syntax.root)].find(node => node.kind === "packageResourceDecl"); if (!declaration) throw new Error("Expected a package declaration"); const text = (node: typeof declaration) => authored.slice(node.start, node.end); const literals = declaration.children.filter(node => node.kind === "stringLiteral"); - registry.name = text(declaration.children.find(node => node.kind === "identifier")!); - registry.id = JSON.parse(text(literals[0])); - registry.revision = JSON.parse(text(literals[1])); - const previousExports = registry.exports; - registry.exports = []; + 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 (registry.exports.some(entry => entry.id === id || entry.name === name)) throw new Error("Duplicate package export name or ID"); - const old = previousExports.find(entry => entry.id === id); - const file = old?.file ?? `src/impl/${name}.ts`; - if (!old) { - const exists = await fs.access(path.join(root, prefix, file)).then(() => true, () => false); - if (!exists) { - const derived = [...walkSyntax(node)].some(child => child.kind === "eventClause"); - create(file, `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`); - } - } - registry.exports.push({...old, name, id, file}); + 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} : {})}); } - if (command !== "refresh") { + { const name = safeName(spec.name); - if (!spec.id || registry.exports.some((entry) => entry.id === spec.id || entry.name === name)) throw new Error("New export requires a unique name and ID"); + if (!spec.id || 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)); @@ -134,12 +123,12 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio 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: registry.id}, source: declaration}]}); + 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); - registry.exports.push({name, id: spec.id, file, ...(command === "migration" ? {migration: true} : {})}); + 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; @@ -150,19 +139,25 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio catalog.migrations.push({...transition, implementation: {exportId: spec.id, file, digest: contentDigest(implementation)}}); } } - if (command === "refresh") for (const migration of catalog.migrations) { - const file = path.join(root, prefix, migration.implementation.file); - if (!(await fs.realpath(file)).startsWith(`${await fs.realpath(path.join(root, prefix))}/`)) throw new Error("Migration implementation escapes package"); - migration.implementation.digest = contentDigest(await fs.readFile(file, "utf8")); - } } - validateMigrationCatalog(catalog, new Set(registry.exports.map((entry) => entry.id))); - generated("quixos.scaffold.json", json(registry)); + validateMigrationCatalog(catalog, new Set(packageModel.exports.map((entry) => entry.id))); generated("quixos.migrations.json", json(catalog)); - generated("src/server.ts", marker + `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` + registry.exports.filter((entry) => !entry.migration).map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") + - `servePackageRuntime(createRuntime({\n` + registry.exports.map((entry) => ` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${registry.exports.filter((value) => !value.migration).indexOf(entry)}`},`).join("\n") + `\n}));\n`); - const migrations = registry.exports.filter((entry) => entry.migration); - generated("src/migrate.ts", marker + `import {serveMigration} from "@quixos/camino-package-runtime";\n` + migrations.map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") + `await serveMigration({${migrations.map((entry, index) => `${JSON.stringify(entry.id)}: impl${index}`).join(", ")}});\n`); - generated("descriptor.quixos-package.txtpb", `# Generated by qx-scaffold-v1\npackage_id: ${JSON.stringify(registry.id)}\npackage_revision_id: ${JSON.stringify(registry.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` + registry.exports.map((entry) => `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`).join("")); - return {kind: "package", source: spec.source, resourceRoot: spec.directory, files}; + 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("")); + return {kind: "package", source: spec.source, resourceRoot: spec.directory, validation: "syntax", files}; }; diff --git a/src/capability-language/structural-plan.ts b/src/capability-language/structural-plan.ts index c81f101..2e8fbc8 100644 --- a/src/capability-language/structural-plan.ts +++ b/src/capability-language/structural-plan.ts @@ -17,7 +17,7 @@ export type StructuralRequest = { source?: {repository: string; commit: string}; resourceRoot?: string; validation?: "syntax" | "resource-graph"; - files: ({file: string; edits: StructuralEdit[]} | {file: string; create: string} | {file: string; generated: string})[]; + files: ({file: string; edits: StructuralEdit[]} | {file: string; create: string} | {file: string; generated: string} | {file: string; expected: string; replace: string})[]; }; type Change = {file: string; before: string | null; after: string; mode: number}; type Journal = {schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[]}; @@ -70,6 +70,9 @@ export const planStructure = async (rootPath: string, request: StructuralRequest if ("create" in input) { if (before !== null || typeof input.create !== "string") throw new Error("Scaffold creation cannot replace an existing file"); after = input.create; + } else if ("replace" in input) { + if (before !== input.expected || typeof input.replace !== "string") throw new Error(`Stale imperative edit: ${input.file}`); + after = input.replace; } else if ("generated" in input) { const generated = (text: string) => text.startsWith("// Generated by qx-scaffold-v1\n") || text.startsWith("# Generated by qx-scaffold-v1\n") || (() => {try {return JSON.parse(text).generatedBy === "qx-scaffold-v1";} catch {return false;}})(); if (typeof input.generated !== "string" || !generated(input.generated) || (before !== null && !generated(before))) throw new Error("Only scaffold-owned generated files may be regenerated"); @@ -79,17 +82,6 @@ export const planStructure = async (rootPath: string, request: StructuralRequest after = input.edits.reduce(editStructure, before); } if (Buffer.byteLength(after) > 1024 * 1024) throw new Error("Scaffold file exceeds 1 MiB"); - if (input.file.endsWith("package.qx")) { - let registry; - try { registry = JSON.parse(await fs.readFile(path.join(root, path.dirname(input.file), "quixos.scaffold.json"), "utf8")); } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } - if (registry?.generatedBy === "qx-scaffold-v1") { - const declaration = parseQx(after).root.children.find(node => node.kind === "packageResourceDecl"); - const literal = declaration?.children.find(node => node.kind === "stringLiteral"); - if (literal && JSON.parse(after.slice(literal.start, literal.end)) !== registry.id) - throw new Error("Cannot change a scaffold-owned package identity independently of its registry; create a new managed package instead"); - } - } const mode = before === null ? 0o644 : (await fs.stat(path.join(root, input.file))).mode & 0o777; changes.push({file: input.file, before, after, mode}); await containedParent(snapshot.directory, input.file); @@ -109,9 +101,7 @@ export const planStructure = async (rootPath: string, request: StructuralRequest if (request.kind === "workspace") await compileWorkspaceRepository({rootDirectory: resourceRoot, resolveResource}); else if (["package", "interface"].includes(request.kind) && request.source) { const compiled = await compileCapabilityResourceRepository({rootDirectory: resourceRoot, kind: request.kind as "package" | "interface", source: {resolver: "git", ...request.source}, resolveResource}); - let scaffoldOwned = false; - try { scaffoldOwned = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.scaffold.json"), "utf8")).generatedBy === "qx-scaffold-v1"; } catch { /* ordinary resource, no generated package scaffolding */ } - if (scaffoldOwned && compiled.resource.kind === "package") { + 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)}, diff --git a/src/capability-language/tool-cli.ts b/src/capability-language/tool-cli.ts index 4449bb0..e96fc20 100644 --- a/src/capability-language/tool-cli.ts +++ b/src/capability-language/tool-cli.ts @@ -11,6 +11,9 @@ 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 {checkBundleSources} from "../bindings/bundle-policy.js"; +import {sealMigrations} from "./migration-seal.js"; +import {generatePackageDescriptor} from "../bindings/index.js"; import {buildCheckedPackage, buildImmutableCandidate, snapshotCommit} from "./checked-build.js"; import {formatQuixosLock, loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js"; import { walkSyntax } from "./source.js"; @@ -38,6 +41,32 @@ const planSummary = (plan: Awaited>) => ({ const main = async () => { const [command, ...args] = process.argv.slice(2); + if (command === "package-identity") { + if (args.length !== 1) throw new Error("usage: quixos-qx package-identity PACKAGE_QX"); + const authored = await readFile(args[0], "utf8"); + const syntax = parseQx(authored); + const declarations = [...walkSyntax(syntax.root)].filter(node => node.kind === "packageResourceDecl"); + if (syntax.diagnostics.length || declarations.length !== 1) throw new Error("Expected one valid package declaration"); + const literals = declarations[0].children.filter(node => node.kind === "stringLiteral"); + process.stdout.write(JSON.stringify({id: JSON.parse(authored.slice(literals[0].start, literals[0].end)), + revision: JSON.parse(authored.slice(literals[1].start, literals[1].end))}) + "\n"); + return; + } + if (command === "package-descriptor") { + if (args.length !== 2) throw new Error("usage: quixos-qx package-descriptor SCHEMA REVISION_ID"); + process.stdout.write(generatePackageDescriptor(JSON.parse(await readFile(args[0], "utf8")), args[1])); + return; + } + if (command === "migration-seal") { + if (args.length !== 1) throw new Error("usage: quixos-qx migration-seal PACKAGE_DIRECTORY"); + process.stdout.write(JSON.stringify(await sealMigrations(args[0])) + "\n"); + return; + } + if (command === "bundle-policy") { + if (args.length !== 1) throw new Error("usage: quixos-qx bundle-policy SOURCE_DIRECTORY"); + await checkBundleSources(args[0]); + return; + } if (command === "worklist" && !args.includes("--help")) { if (args.length !== 1) throw new Error("usage: quixos-qx worklist WORKBENCH"); process.stdout.write(`${JSON.stringify(await authoringWorklist(args[0]), null, 2)}\n`); @@ -62,10 +91,10 @@ const main = async () => { if (!args.length || args.length > 2) throw new Error("usage: quixos-qx converge WORKBENCH [REGISTERED_DIRECTORY] (join package writers first)"); const context = await authoringContext(args[0]); if (command === "converge") { - const result = spawnSync("flock", ["--exclusive", "--nonblock", "--conflict-exit-code", "75", path.join(context.workbench, ".quixos/converge.lock"), + const result = spawnSync("flock", ["--exclusive", "--timeout", "120", "--conflict-exit-code", "75", path.join(context.workbench, ".quixos/converge.lock"), process.execPath, process.argv[1], "_converge", context.workbench, ...(args[1] ? [args[1]] : [])], {stdio: "inherit"}); if (result.error) throw result.error; - if (result.status === 75) process.stderr.write("Another source coordinator is running; retry when it finishes.\n"); + if (result.status === 75) process.stderr.write("Timed out after 120 seconds waiting for source capture; inspect the active coordinator. No build lock is held.\n"); process.exitCode = result.status ?? 1; return; } const result = await convergeAuthoring(context.workbench, args[1]); @@ -130,7 +159,7 @@ const main = async () => { const parsed = parseQuixosLockDocument(await readFile(path.join(root, file), "utf8")); if (parsed.ok && parsed.document.resources.some(entry => entry.kind === kind && entry.binding === name)) target = file; } - const request: StructuralRequest = {kind: entrypoint, source: await authorSource(root), files: [ + const request: StructuralRequest = {kind: entrypoint, source: await authorSource(root), validation: "syntax", files: [ {file: `${entrypoint}.qx`, edits: [{operation: "import", kind: resourceKind, name}]}, {file: target, edits: [{operation: "dependency", kind: resourceKind, name, source: {repository, commit}}]}, ]}; @@ -180,12 +209,17 @@ const main = async () => { process.stdout.write(`${JSON.stringify(publish ? await applyPinUpgrades(plan, resume, undefined, {acceptEdits}) : plan, null, 2)}\n`); return; } - if (["scaffold-package", "scaffold-function", "scaffold-migration", "scaffold-refresh"].includes(command)) { + 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."); + if (["scaffold-package", "scaffold-function", "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 ?? "")) { - const registry = JSON.parse(await readFile(path.join(root, "quixos.scaffold.json"), "utf8")); - spec = {source: await authorSource(root), name: specFile, id: `export:${registry.name}:${specFile}`}; + const authored = parseQx(await readFile(path.join(root, "package.qx"), "utf8")); + const declarationNode = [...walkSyntax(authored.root)].find(node => node.kind === "packageResourceDecl"); + const nameNode = declarationNode?.children.find(node => node.kind === "identifier"); + if (!nameNode) throw new Error("Expected a package declaration"); + const name = authored.source.slice(nameNode.start, nameNode.end); + spec = {source: await authorSource(root), name: specFile, id: `export:${name}:${specFile}`}; const declaration = flags.indexOf("--declaration"); if (declaration >= 0) { if (!flags[declaration + 1]) throw new Error("--declaration requires a QX declaration file"); @@ -201,9 +235,9 @@ const main = async () => { } } else spec = await readSpec(specFile) as ScaffoldRecipe; if (!root || !specFile || flags.some((flag) => !["--write", "--install"].includes(flag)) || (flags.includes("--install") && !flags.includes("--write"))) throw new Error("usage: quixos-qx scaffold-package|function|migration|refresh ROOT SPEC_JSON [--write [--install]]"); - const request = await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration" | "refresh", spec); - const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP); - const applied = flags.includes("--write") ? await applyStructure(plan) : undefined; + const plan = command === "scaffold-install" ? undefined : await planStructure(root, + await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration", spec), process.env.QUIXOS_SNAPSHOT_MAP); + 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")); @@ -218,7 +252,7 @@ const main = async () => { 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({...planSummary(plan), applied}, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({...plan ? planSummary(plan) : {installed: true}, applied}, null, 2)}\n`); return; } if (command === "scaffold-structure") { diff --git a/test/bundle-policy.test.ts b/test/bundle-policy.test.ts new file mode 100644 index 0000000..b328036 --- /dev/null +++ b/test/bundle-policy.test.ts @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {bundlePolicyErrors} from "../src/bindings/bundle-policy.js"; +import {addImplementation} from "../src/capability-language/implementation-edit.js"; + +test("bundled source policy rejects location and dynamic-loading assumptions, not static assets or runtime I/O", () => { + for (const code of ['new URL("../x", import.meta.url)', '__dirname', '__filename', 'import(name)', 'require(name)', 'eval(code)', 'new Function(code)']) + assert.ok(bundlePolicyErrors(code, "source.ts").length, code); + assert.deepEqual(bundlePolicyErrors('import source from "./component.js?browser-source"; import fs from "node:fs"; fs.readFile(userSelectedPath);', "source.ts"), []); + assert.deepEqual(bundlePolicyErrors('// import.meta.url\nconst text = "__dirname";', "source.ts"), []); +}); + +test("imperative handler insertion preserves arbitrary existing code and rejects ambiguous targets", () => { + const original = 'const keep = "createRuntime({fake:1})";\nservePackageRuntime(createRuntime({ existing: customHandler }));\n'; + const edited = addImplementation(original, "createRuntime", "newHandler", "./impl/new.js"); + assert.match(edited, /existing: customHandler/); + assert.match(edited, /const keep =/); + assert.match(edited, /"newHandler": qxImplementation/); + assert.throws(() => addImplementation(original, "createRuntime", "existing", "./x.js"), /already exists/); + assert.throws(() => addImplementation('createRuntime(one);', "createRuntime", "x", "./x.js"), /Cannot safely/); +}); diff --git a/test/scaffold-recipes.test.ts b/test/scaffold-recipes.test.ts index 5f3e68a..9ffc174 100644 --- a/test/scaffold-recipes.test.ts +++ b/test/scaffold-recipes.test.ts @@ -8,6 +8,7 @@ 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"; const execFile = promisify(callback); test("React preset applies its browser build script and shared-platform imports", async (context) => { @@ -17,11 +18,14 @@ test("React preset applies its browser build script and shared-platform imports" 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, "scripts/build-component.mjs"), "utf8"), /__quixos\/platform\/react/); + assert.match(await fs.readFile(path.join(root, "src/impl/sourceGet.ts"), "utf8"), /component.js\?browser-source/); + assert.match(await fs.readFile(path.join(root, "flake.nix"), "utf8"), /browserSources = true/); + assert.equal(JSON.parse(await fs.readFile(path.join(root, "quixos.check.json"), "utf8")).options.messages["org.quixos.web-studio.ReactProps"].export, "opaqueReactPropsBinding"); + await assert.rejects(fs.access(path.join(root, "quixos.scaffold.json"))); assert.equal(JSON.parse(await fs.readFile(path.join(root, "tsconfig.json"), "utf8")).compilerOptions.jsx, "react-jsx"); }); -test("package/function/migration scaffolds register implementations and refresh code digests without overwriting code", async (context) => { +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"]); @@ -38,31 +42,23 @@ test("package/function/migration scaffolds register implementations and refresh 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 apply("refresh", base); + 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"))); - const bindings = await fs.readFile(path.join(root, base.directory, "src/gen/qx.ts"), "utf8"); - assert.match(bindings, /export:play/); assert.match(await fs.readFile(path.join(root, base.directory, "src/migrate.ts"), "utf8"), /export:upgrade/); - if (process.env.QX_SCAFFOLD_TEST_SDK) { - const sdk = await fs.realpath(process.env.QX_SCAFFOLD_TEST_SDK); - const packageRoot = path.join(root, base.directory); - await fs.mkdir(path.join(packageRoot, "node_modules/@quixos"), {recursive: true}); - await fs.symlink(sdk, path.join(packageRoot, "node_modules/@quixos/camino-package-runtime")); - await fs.symlink(path.join(sdk, "node_modules/@types"), path.join(packageRoot, "node_modules/@types")); - await execFile(path.join(sdk, "node_modules/.bin/tsc"), ["--noEmit"], {cwd: packageRoot}); - await execFile("nix-instantiate", ["--parse", path.join(packageRoot, "flake.nix")]); - } await assert.rejects(() => apply("function", {...base, name: "play", id: "export:play"}), /unique/); 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')); - await apply("refresh", base); - assert.match(await fs.readFile(path.join(root, base.directory, "src/server.ts"), "utf8"), /"authored":/); - assert.match(await fs.readFile(path.join(root, base.directory, "src/impl/authored.ts"), "utf8"), /Implement authored/); + 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 assert.rejects(() => planStructure(root, {kind: "package", source, resourceRoot: base.directory, validation: "syntax", files: [{ + 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" {}'}], - }]}), /scaffold-owned package identity/); + }]}); }); diff --git a/yarn-project.nix b/yarn-project.nix index 32b128f..b773583 100644 --- a/yarn-project.nix +++ b/yarn-project.nix @@ -156,6 +156,10 @@ let overriddenProject = optionalOverride overrideAttrs project; cacheEntries = { +"@babel/helper-string-parser@npm:7.29.7" = { filename = "@babel-helper-string-parser-npm-7.29.7-87998d618e-194bc0f171.zip"; hash = "sha512-GUvA8XFuOW1f/eVq1hGXRfuVV2YsmGEVkOXkVJBng6TMshzpMFa462mkkJBEg05F2W5QrGlbvp4yIWSP4DPAbA=="; }; +"@babel/helper-validator-identifier@npm:7.29.7" = { filename = "@babel-helper-validator-identifier-npm-7.29.7-9939aac13d-4795354e7a.zip"; hash = "sha512-R5U1Tnrg3K+nLeHNBOxRJS3BSYUXFwvq8BngPv/Ft78TxrIaOUmnfge4Elvn8QbtETE1DY69RWauh0CUpybWKw=="; }; +"@babel/parser@npm:7.29.8" = { filename = "@babel-parser-npm-7.29.8-d8ac19f8b3-acc890c5e6.zip"; hash = "sha512-rMiQxeam3UCGOke1C6wRHXGF7m+74WPr4R1SFIVMoq25AUYq1NcYplCQ74S9IjDp6KtFouDKzMaF8fV6sLseKA=="; }; +"@babel/types@npm:7.29.8" = { filename = "@babel-types-npm-7.29.8-3f9597fc63-be7c279f0a.zip"; hash = "sha512-vnwnnwq/KghsYz4htJx8qAJ10FKDzFomi2enCMmRS9DJRPFCKz6zyzdoKir11WCr9SDM+bAbU+y/5rcfvD/d5g=="; }; "@bufbuild/protobuf@npm:2.14.1" = { filename = "@bufbuild-protobuf-npm-2.14.1-78e9ea56a2-3f913aca03.zip"; hash = "sha512-P5E6ygPfhBnEsh2ADqVNf2yDalND5l1vaGJ57DVmxJB3KZOO8vlKXifZLo0/StzW4CcRi6Kbp2TSHhxjfN3HAg=="; }; "@bufbuild/protoc-gen-es@npm:2.14.1" = { filename = "@bufbuild-protoc-gen-es-npm-2.14.1-230b1181a2-79f9fad1d5.zip"; hash = "sha512-efn60dVmXaptZUmyh4oPY/6/rbGHuFNAg/vrI4Ydwizz6mmmYKEvD45Ps8AtJO6ALxcYRBVt5C+0sXEuOgZ1aQ=="; }; "@bufbuild/protoplugin@npm:2.14.1" = { filename = "@bufbuild-protoplugin-npm-2.14.1-ca1a20a987-6a727aa5a8.zip"; hash = "sha512-anJ6pahI4FA13tkPUOE65Sx3NXutalhHalbU/3ANyBPKxmTgzDC1IBNU5cFY6//rANyDUxbWaqLDhKLp2qXf4w=="; }; diff --git a/yarn.lock b/yarn.lock index 2c3508a..1ee94ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,41 @@ __metadata: version: 10 cacheKey: 10c0 +"@babel/helper-string-parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-string-parser@npm:7.29.7" + checksum: 10c0/194bc0f1716e396d5ffde56ad6119745fb9557662c98611590e5e454906783a4ccb21ce93056b8eb69a4909044834e45d96e50ac695bbe9e3221648fe033c06c + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-identifier@npm:7.29.7" + checksum: 10c0/4795354e7ae0dcafa72de1cd04ec51252dc1498517170beaf019e03effc5b7bf13c6b21a3949a77e07b8125be7f106ed1131350d8ebd4566ae874094a726d62b + languageName: node + linkType: hard + +"@babel/parser@npm:^7.28.0": + version: 7.29.8 + resolution: "@babel/parser@npm:7.29.8" + dependencies: + "@babel/types": "npm:^7.29.8" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/acc890c5e6a6dd40863a47b50bac111d7185ee6fbbe163ebe11d5214854ca2adb901462ad4d718a65090ef84bd2230e9e8ab45a2e0caccc685f1f57ab0bb1e28 + languageName: node + linkType: hard + +"@babel/types@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/types@npm:7.29.8" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6 + languageName: node + linkType: hard + "@bufbuild/protobuf@npm:2.14.1, @bufbuild/protobuf@npm:^2.12.1": version: 2.14.1 resolution: "@bufbuild/protobuf@npm:2.14.1" @@ -44,6 +79,7 @@ __metadata: version: 0.0.0-use.local resolution: "@quixos/quixos-protocol@workspace:." dependencies: + "@babel/parser": "npm:^7.28.0" "@bufbuild/protobuf": "npm:^2.12.1" "@bufbuild/protoc-gen-es": "npm:^2.12.1" "@types/node": "npm:^24"