Simplify workspace authoring and isolate managed component styles

This commit is contained in:
Timothy J. Aveni
2026-09-14 20:35:53 -07:00
parent 14b0ef25c3
commit e753ffe601
13 changed files with 278 additions and 95 deletions
+3 -2
View File
@@ -5,11 +5,11 @@
"packageManager": "yarn@4.18.0", "packageManager": "yarn@4.18.0",
"type": "module", "type": "module",
"bin": { "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-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-descriptor-check": "dist/src/descriptor-check.js",
"quixos-lock-check": "dist/src/resource-lock/cli.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-resource-compile": "dist/src/capability-language/resource-cli.js",
"quixos-workspace-compile": "dist/src/capability-language/workspace-cli.js" "quixos-workspace-compile": "dist/src/capability-language/workspace-cli.js"
}, },
@@ -29,6 +29,7 @@
"typecheck": "yarn generate && tsc --noEmit" "typecheck": "yarn generate && tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@babel/parser": "^7.28.0",
"@bufbuild/protobuf": "^2.12.1", "@bufbuild/protobuf": "^2.12.1",
"@bufbuild/protoc-gen-es": "^2.12.1", "@bufbuild/protoc-gen-es": "^2.12.1",
"antlr4ng": "^3.0.16" "antlr4ng": "^3.0.16"
+41
View File
@@ -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<string, any>;
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<void> {
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"));
}
+6
View File
@@ -18,6 +18,12 @@ export type TypeScriptBindingOptions = {
/** Each export must implement MessageBinding<T>, providing both TS type and wire codec. */ /** Each export must implement MessageBinding<T>, providing both TS type and wire codec. */
messages?: Record<string, { module: string; export: string }>; messages?: Record<string, { module: string; export: string }>;
}; };
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 q = JSON.stringify;
const object = (entries: [string, string][]) => `{ ${entries.map(([key, value]) => `${q(key)}: ${value}`).join("; ")} }`; const object = (entries: [string, string][]) => `{ ${entries.map(([key, value]) => `${q(key)}: ${value}`).join("; ")} }`;
const unit = (type: ValueType) => type.kind === "builtin" && type.name === "unit"; const unit = (type: ValueType) => type.kind === "builtin" && type.name === "unit";
@@ -10,6 +10,8 @@ import { planEvolution, type WorkspaceRevision, type EvolutionReview } from "../
export const checkRecordName = (directory: string) => createHash("sha256").update(directory).digest("hex") + ".json"; 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 } = {}) { export async function checkAuthoring(start: string, output: string, options: { baseline?: string; reviews?: string; contractOnly?: boolean } = {}) {
const started = performance.now();
const timings: Record<string, number> = {};
const context = await authoringContext(start); const context = await authoringContext(start);
const location = await fs.realpath(start); const location = await fs.realpath(start);
const directory = location === context.workbench ? "root" : path.relative(context.workbench, location); 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; throw error;
}); });
const converged = JSON.parse(captured.stdout) as Awaited<ReturnType<typeof convergeAuthoring>>; const converged = JSON.parse(captured.stdout) as Awaited<ReturnType<typeof convergeAuthoring>>;
timings.captureMs = Math.round(performance.now() - started);
if (!converged.candidate) { if (!converged.candidate) {
report.phase = converged.worklist.find(entry => entry.phase !== "dependency")?.phase ?? "convergence"; 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")); throw new Error(converged.worklist.map(entry => `${entry.directory} [${entry.phase}]: ${entry.message}`).join("\n"));
} }
report.commit = converged.candidate.commit; report.commit = converged.candidate.commit;
report.phase = "verification"; report.phase = "verification";
const buildStarted = performance.now();
report.artifactPath = await buildImmutableCandidate(converged.candidate, resource.kind, path.join(output, "nix.log"), options.contractOnly); 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"); const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8");
await fs.writeFile(path.join(output, "candidate.json"), candidateText); await fs.writeFile(path.join(output, "candidate.json"), candidateText);
report.compilation = "passed"; 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"; if (!report.blockers.length) report.phase = options.contractOnly ? "contract-only" : "checked";
} catch (error) { report.blockers.push(String(error instanceof Error ? error.message : error)); } } 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)); await fs.writeFile(path.join(output, "report.json"), JSON.stringify(report, null, 2));
if (options.contractOnly) return report; if (options.contractOnly) return report;
const records = path.join(context.workbench, ".quixos/checks"); const records = path.join(context.workbench, ".quixos/checks");
@@ -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<string>();
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;
}
+23
View File
@@ -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"},
]}));
}
+44 -49
View File
@@ -5,9 +5,10 @@ import {validateMigrationCatalog, type MigrationCatalog, type MigrationDeclarati
import {formatQuixosLock, type GitSource} from "../resource-lock/index.js"; import {formatQuixosLock, type GitSource} from "../resource-lock/index.js";
import {parseQx, walkSyntax} from "./source.js"; import {parseQx, walkSyntax} from "./source.js";
import type {StructuralRequest} from "./structural-plan.js"; import type {StructuralRequest} from "./structural-plan.js";
import {addImplementation} from "./implementation-edit.js";
type Source = {repository: string; commit: string}; 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 = { export type ScaffoldRecipe = {
template?: "typescript" | "typescript-react"; template?: "typescript" | "typescript-react";
source: Source; directory?: string; name?: string; id?: string; revision?: string; source: Source; directory?: string; name?: string; id?: string; revision?: string;
@@ -16,9 +17,9 @@ export type ScaffoldRecipe = {
nixifyPluginUrl?: string; nixifyPluginUrl?: string;
migration?: Omit<MigrationDeclaration, "implementation"> & {contracts: Record<string, unknown>}; migration?: Omit<MigrationDeclaration, "implementation"> & {contracts: Record<string, unknown>};
}; };
const marker = "// Generated by qx-scaffold-v1\n";
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
const source = (value: Source): GitSource => { 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); 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"); 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}; return {resolver: "git", ...value};
@@ -38,15 +39,16 @@ const ownedJson = async <T>(root: string, file: string): Promise<T> => {
}; };
/** Recipes describe structural edits; planStructure owns validation/journaling. /** 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<StructuralRequest> => { export const scaffoldRecipe = async (root: string, command: "package" | "function" | "migration" | "refresh", spec: ScaffoldRecipe): Promise<StructuralRequest> => {
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); 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"); 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 prefix = spec.directory ? `${spec.directory}/` : "";
const files: StructuralRequest["files"] = []; const files: StructuralRequest["files"] = [];
const create = (file: string, content: string) => files.push({file: prefix + file, create: content}); 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}); 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"}; let catalog: MigrationCatalog & {generatedBy: "qx-scaffold-v1"};
if (command === "package") { if (command === "package") {
const name = safeName(spec.name); const name = safeName(spec.name);
@@ -54,19 +56,19 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
const react = spec.template === "typescript-react"; 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"); 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); 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: []}; 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("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("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", 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"} : {})}})); 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"]})); 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) { 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 <section><h1>${name}</h1><p>Edit this component, then run qx-workspace check.</p></section>;\n}\n`); 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 <section><h1>${name}</h1><p>Edit this component, then run qx-workspace check.</p></section>;\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("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("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/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(".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`); 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); 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"); 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})); 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", `{ create("flake.nix", `{
inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))}; inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))};
inputs.nixpkgs.follows = "protocol/nixpkgs"; 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, ... }: outputs = inputs@{ self, protocol, nixpkgs, flake-utils, helpers, ... }:
(import (toString helpers + "/quixos-package-helpers.nix")).mkCaminoTsYarnNixifyFlake { (import (toString helpers + "/quixos-package-helpers.nix")).mkCaminoTsYarnNixifyFlake {
inherit inputs nixpkgs flake-utils; packageRoot = ./.; 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"; 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`); }\n`);
} else { } else {
registry = await ownedJson<Registry>(root, prefix + "quixos.scaffold.json");
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json"); catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
// package.qx is authoritative. The registry remembers implementation paths, // Derive the edit model from authored declarations; it is never persisted.
// not a second declaration list that can erase an author's new exports.
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8"); const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
const syntax = parseQx(authored); 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"); const declaration = [...walkSyntax(syntax.root)].find(node => node.kind === "packageResourceDecl");
if (!declaration) throw new Error("Expected a package declaration"); if (!declaration) throw new Error("Expected a package declaration");
const text = (node: typeof declaration) => authored.slice(node.start, node.end); const text = (node: typeof declaration) => authored.slice(node.start, node.end);
const literals = declaration.children.filter(node => node.kind === "stringLiteral"); const literals = declaration.children.filter(node => node.kind === "stringLiteral");
registry.name = text(declaration.children.find(node => node.kind === "identifier")!); packageModel = {generatedBy: "qx-scaffold-v1", name: text(declaration.children.find(node => node.kind === "identifier")!),
registry.id = JSON.parse(text(literals[0])); id: JSON.parse(text(literals[0])), revision: JSON.parse(text(literals[1])), exports: []};
registry.revision = JSON.parse(text(literals[1]));
const previousExports = registry.exports;
registry.exports = [];
for (const node of walkSyntax(declaration)) { for (const node of walkSyntax(declaration)) {
if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)) continue; if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)) continue;
const name = safeName(text(node.children.find(child => child.kind === "identifier")!)); const name = safeName(text(node.children.find(child => child.kind === "identifier")!));
const id = JSON.parse(text(node.children.find(child => child.kind === "stringLiteral")!)); 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"); if (packageModel.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 migration = catalog.migrations.find(entry => entry.implementation.exportId === id);
const file = old?.file ?? `src/impl/${name}.ts`; packageModel.exports.push({name, id, file: migration?.implementation.file ?? `src/impl/${name}.ts`, ...(migration ? {migration: true} : {})});
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 (command !== "refresh") { {
const name = safeName(spec.name); 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 declaration = spec.declaration ?? `function ${name} id ${JSON.stringify(spec.id)} : unit -> unit;`;
const parsed = parseQx(`package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`); const parsed = parseQx(`package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`);
const exports = [...walkSyntax(parsed.root)].filter((node) => ["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)); 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 (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 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"); || 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 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<void> => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\n` const implementation = command === "migration" ? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise<void> => { 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`; : `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); 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 (command === "migration") {
if (!spec.migration) throw new Error("Migration scaffold requires retained contracts and an explicit transition"); if (!spec.migration) throw new Error("Migration scaffold requires retained contracts and an explicit transition");
const {contracts, ...transition} = spec.migration; 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)}}); 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))); validateMigrationCatalog(catalog, new Set(packageModel.exports.map((entry) => entry.id)));
generated("quixos.scaffold.json", json(registry));
generated("quixos.migrations.json", json(catalog)); 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("") + if (command !== "package") {
`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 entry = packageModel.exports.at(-1)!;
const migrations = registry.exports.filter((entry) => entry.migration); const file = prefix + "src/server.ts";
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`); const before = await fs.readFile(path.join(root, file), "utf8");
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("")); files.push({file, expected: before, replace: addImplementation(before, "createRuntime", entry.name, `./${entry.file.slice(4, -3)}.js`, !!entry.migration)});
return {kind: "package", source: spec.source, resourceRoot: spec.directory, files}; 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};
}; };
+5 -15
View File
@@ -17,7 +17,7 @@ export type StructuralRequest = {
source?: {repository: string; commit: string}; source?: {repository: string; commit: string};
resourceRoot?: string; resourceRoot?: string;
validation?: "syntax" | "resource-graph"; 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 Change = {file: string; before: string | null; after: string; mode: number};
type Journal = {schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[]}; 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 ("create" in input) {
if (before !== null || typeof input.create !== "string") throw new Error("Scaffold creation cannot replace an existing file"); if (before !== null || typeof input.create !== "string") throw new Error("Scaffold creation cannot replace an existing file");
after = input.create; 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) { } 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;}})(); 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"); 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); after = input.edits.reduce(editStructure, before);
} }
if (Buffer.byteLength(after) > 1024 * 1024) throw new Error("Scaffold file exceeds 1 MiB"); 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; const mode = before === null ? 0o644 : (await fs.stat(path.join(root, input.file))).mode & 0o777;
changes.push({file: input.file, before, after, mode}); changes.push({file: input.file, before, after, mode});
await containedParent(snapshot.directory, input.file); 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}); if (request.kind === "workspace") await compileWorkspaceRepository({rootDirectory: resourceRoot, resolveResource});
else if (["package", "interface"].includes(request.kind) && request.source) { 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}); const compiled = await compileCapabilityResourceRepository({rootDirectory: resourceRoot, kind: request.kind as "package" | "interface", source: {resolver: "git", ...request.source}, resolveResource});
let scaffoldOwned = false; if (compiled.resource.kind === "package") {
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") {
const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8")); const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8"));
const artifacts = [ const artifacts = [
{file: configuration.bindingOutput as string, after: generateTypeScriptBindings(bindingSchema(compiled), compiled.resource.revision.revisionId, configuration.options)}, {file: configuration.bindingOutput as string, after: generateTypeScriptBindings(bindingSchema(compiled), compiled.resource.revision.revisionId, configuration.options)},
+44 -10
View File
@@ -11,6 +11,9 @@ import os from "node:os";
import {spawnSync} from "node:child_process"; import {spawnSync} from "node:child_process";
import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js"; import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js";
import {scaffoldRecipe, type ScaffoldRecipe} from "./scaffold-recipes.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 {buildCheckedPackage, buildImmutableCandidate, snapshotCommit} from "./checked-build.js";
import {formatQuixosLock, loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js"; import {formatQuixosLock, loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js";
import { walkSyntax } from "./source.js"; import { walkSyntax } from "./source.js";
@@ -38,6 +41,32 @@ const planSummary = (plan: Awaited<ReturnType<typeof planStructure>>) => ({
const main = async () => { const main = async () => {
const [command, ...args] = process.argv.slice(2); 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 (command === "worklist" && !args.includes("--help")) {
if (args.length !== 1) throw new Error("usage: quixos-qx worklist WORKBENCH"); if (args.length !== 1) throw new Error("usage: quixos-qx worklist WORKBENCH");
process.stdout.write(`${JSON.stringify(await authoringWorklist(args[0]), null, 2)}\n`); 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)"); 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]); const context = await authoringContext(args[0]);
if (command === "converge") { 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"}); process.execPath, process.argv[1], "_converge", context.workbench, ...(args[1] ? [args[1]] : [])], {stdio: "inherit"});
if (result.error) throw result.error; 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; process.exitCode = result.status ?? 1; return;
} }
const result = await convergeAuthoring(context.workbench, args[1]); 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")); 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; 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: `${entrypoint}.qx`, edits: [{operation: "import", kind: resourceKind, name}]},
{file: target, edits: [{operation: "dependency", kind: resourceKind, name, source: {repository, commit}}]}, {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`); process.stdout.write(`${JSON.stringify(publish ? await applyPinUpgrades(plan, resume, undefined, {acceptEdits}) : plan, null, 2)}\n`);
return; 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; const [root, specFile, ...flags] = args;
let spec: ScaffoldRecipe; let spec: ScaffoldRecipe;
if (command === "scaffold-function" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(specFile ?? "")) { 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")); const authored = parseQx(await readFile(path.join(root, "package.qx"), "utf8"));
spec = {source: await authorSource(root), name: specFile, id: `export:${registry.name}:${specFile}`}; 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"); const declaration = flags.indexOf("--declaration");
if (declaration >= 0) { if (declaration >= 0) {
if (!flags[declaration + 1]) throw new Error("--declaration requires a QX declaration file"); 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; } 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]]"); 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 = command === "scaffold-install" ? undefined : await planStructure(root,
const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP); await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration", spec), process.env.QUIXOS_SNAPSHOT_MAP);
const applied = flags.includes("--write") ? await applyStructure(plan) : undefined; const applied = plan && flags.includes("--write") ? await applyStructure(plan) : undefined;
if (flags.includes("--install")) { if (flags.includes("--install")) {
const cwd = path.resolve(root, spec.directory ?? ""); const cwd = path.resolve(root, spec.directory ?? "");
const toolchain = JSON.parse(await readFile(path.join(cwd, "quixos.toolchain.json"), "utf8")); 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]}); 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"); 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; return;
} }
if (command === "scaffold-structure") { if (command === "scaffold-structure") {
+21
View File
@@ -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/);
});
+15 -19
View File
@@ -8,6 +8,7 @@ import {promisify} from "node:util";
import {scaffoldRecipe} from "../src/capability-language/scaffold-recipes.js"; import {scaffoldRecipe} from "../src/capability-language/scaffold-recipes.js";
import {planStructure, applyStructure} from "../src/capability-language/structural-plan.js"; import {planStructure, applyStructure} from "../src/capability-language/structural-plan.js";
import {contentDigest} from "../src/capability-model/evolution.js"; import {contentDigest} from "../src/capability-model/evolution.js";
import {sealMigrations} from "../src/capability-language/migration-seal.js";
const execFile = promisify(callback); const execFile = promisify(callback);
test("React preset applies its browser build script and shared-platform imports", async (context) => { 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 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}}); 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)); 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"); 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-")); const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-recipes-"));
context.after(() => fs.rm(root, {recursive: true, force: true})); context.after(() => fs.rm(root, {recursive: true, force: true}));
await execFile("git", ["-C", root, "init"]); 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}}}); 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"); const migrationFile = path.join(root, base.directory, "src/migrations/upgrade.ts");
await fs.appendFile(migrationFile, "\n// authored migration change\n"); 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); assert.equal(await fs.readFile(filename, "utf8"), edited);
const catalog = JSON.parse(await fs.readFile(path.join(root, base.directory, "quixos.migrations.json"), "utf8")); 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.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/); 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/); await assert.rejects(() => apply("function", {...base, name: "play", id: "export:play"}), /unique/);
const declarations = path.join(root, base.directory, "package.qx"); 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 fs.writeFile(declarations, (await fs.readFile(declarations, "utf8")).replace(/}\s*$/, ' function authored id "export:authored" : unit -> unit;\n}\n'));
await apply("refresh", base); const server = path.join(root, base.directory, "src/server.ts");
assert.match(await fs.readFile(path.join(root, base.directory, "src/server.ts"), "utf8"), /"authored":/); await fs.appendFile(server, "\n// authored comment must survive\n");
assert.match(await fs.readFile(path.join(root, base.directory, "src/impl/authored.ts"), "utf8"), /Implement authored/); 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); 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"}, file: `${base.directory}/package.qx`, edits: [{operation: "replace", target: {kind: "packageResourceDecl", id: "package:chess"},
source: 'package Other id "package:other" revision "package:other@1" {}'}], source: 'package Other id "package:other" revision "package:other@1" {}'}],
}]}), /scaffold-owned package identity/); }]});
}); });
+4
View File
@@ -156,6 +156,10 @@ let
overriddenProject = optionalOverride overrideAttrs project; overriddenProject = optionalOverride overrideAttrs project;
cacheEntries = { 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/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/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=="; }; "@bufbuild/protoplugin@npm:2.14.1" = { filename = "@bufbuild-protoplugin-npm-2.14.1-ca1a20a987-6a727aa5a8.zip"; hash = "sha512-anJ6pahI4FA13tkPUOE65Sx3NXutalhHalbU/3ANyBPKxmTgzDC1IBNU5cFY6//rANyDUxbWaqLDhKLp2qXf4w=="; };
+36
View File
@@ -5,6 +5,41 @@ __metadata:
version: 10 version: 10
cacheKey: 10c0 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": "@bufbuild/protobuf@npm:2.14.1, @bufbuild/protobuf@npm:^2.12.1":
version: 2.14.1 version: 2.14.1
resolution: "@bufbuild/protobuf@npm:2.14.1" resolution: "@bufbuild/protobuf@npm:2.14.1"
@@ -44,6 +79,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@quixos/quixos-protocol@workspace:." resolution: "@quixos/quixos-protocol@workspace:."
dependencies: dependencies:
"@babel/parser": "npm:^7.28.0"
"@bufbuild/protobuf": "npm:^2.12.1" "@bufbuild/protobuf": "npm:^2.12.1"
"@bufbuild/protoc-gen-es": "npm:^2.12.1" "@bufbuild/protoc-gen-es": "npm:^2.12.1"
"@types/node": "npm:^24" "@types/node": "npm:^24"