Publish quixos-nix-helpers from e5aec2bb26110ed474929f353ee51ff46d4f20d6

This commit is contained in:
Quixos Subtree Publisher
2026-10-01 11:03:19 +00:00
21 changed files with 578 additions and 150 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"sourceRepo": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos",
"sourceCommit": "54e443af5dd963f1fa6f83f062f4943b46ad2535",
"sourceCommit": "e5aec2bb26110ed474929f353ee51ff46d4f20d6",
"sourcePath": "quixos-instance/quixos-nix-helpers",
"exportName": "quixos-nix-helpers",
"mirrorRemote": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git"
+21 -2
View File
@@ -8,9 +8,9 @@ receipts. It has no migration step or package-defined shell hooks.
```nix
# Inside a per-system flake output:
quixosPackages.checkedArtifacts =
{ plan, generator, core, sharedRuntime, typescript, packageRevisionId }:
{ buildContract, sourceDigest, generator, core, sharedRuntime, typescript, packageRevisionId, ... }:
helpers.mkCaminoArtifacts {
inherit pkgs plan core sharedRuntime typescript packageRevisionId;
inherit pkgs buildContract sourceDigest core sharedRuntime typescript packageRevisionId;
protocol = generator;
src = ./.;
portable = {
@@ -22,6 +22,25 @@ quixosPackages.checkedArtifacts =
};
```
`buildContract` is a canonical, source-free capability slice. `sourceDigest`
records the exact selected package source in the final installation manifest;
it does not enter the reusable compilation group. Ordinary source dependencies,
SDKs and React props companions remain explicit Nix inputs. Raw QX and resource
lock files enter through the checked contract and evaluated recipe, and are
excluded from the implementation source tree. The `plan` argument on the low-level
helper is a composition convenience for fixtures; it is projected before building.
Package recipes receive the contract directly and can be checked without a workspace.
Inside `checkedArtifacts = context: ...`, the package helpers expose
`camino.packageSet { inherit nixpkgs system context; }` as the usual package set
selection. It shares the workspace composition's evaluated Nixpkgs when the
source content, system, and default import arguments match. Pass ordinary Nixpkgs
arguments such as `config` or `overlays` to use a separate import. A direct
`import nixpkgs { inherit system; }` also works, with additional evaluation cost.
The implementation groups depend on the complete execution compiler bundle and
JavaScript build drivers by content; unrelated checker CLI and publication
metadata changes do not enter those inputs.
The handler entry exports `registry = createRegistry(handlers)` from the generated
bindings. One registry contains many exports. The builder always supplies a server
implementation for the generated process service. A service-only package instead
+61 -8
View File
@@ -2,7 +2,25 @@
{
pkgs,
src,
plan,
plan ? null,
buildContract ?
(import ./package-context.nix {
inherit
pkgs
protocol
plan
packageRevisionId
;
}).buildContract,
sourceDigest ?
(import ./package-context.nix {
inherit
pkgs
protocol
plan
packageRevisionId
;
}).sourceDigest,
protocol,
core,
sharedRuntime,
@@ -16,19 +34,25 @@
group ? false,
}:
let
compiler = "${protocol}/libexec/quixos-protocol/execution-world.mjs";
buildTools = import ./build-tools.nix;
# The standalone bundle is the complete code dependency of this driver.
# Changes to unrelated checker CLIs must not invalidate every package group.
compiler = builtins.path {
path = "${protocol}/libexec/quixos-protocol/execution-world.mjs";
name = "execution-world.mjs";
};
config = pkgs.writeText "camino-artifact-build.json" (
builtins.toJSON {
inherit
plan
buildContract
compiler
packageRevisionId
core
sharedRuntime
nodeModules
service
group
;
group = true;
components =
if components == null then
null
@@ -39,7 +63,10 @@ let
bindingOutput = components.bindingOutput or "src/gen/client.ts";
contracts = components.contracts or [ ];
};
source = src;
source = import ./implementation-source.nix {
inherit (pkgs) lib;
inherit src;
};
portable =
if portable == null then
null
@@ -71,7 +98,33 @@ let
serviceAdapter = ./portable-service.mjs;
}
);
compiled =
pkgs.runCommand "camino-compiled-artifacts"
{
passthru.buildConfig = config;
QUIXOS_TRACE_PROFILE = "1";
}
''
${pkgs.nodejs_24}/bin/node ${buildTools}/build-artifacts.mjs ${config}
'';
in
pkgs.runCommand "camino-checked-artifacts" { passthru.buildConfig = config; } ''
${pkgs.nodejs_24}/bin/node ${./.}/build-artifacts.mjs ${config}
''
if group then
compiled
else
(import ./assemble-package.nix {
inherit pkgs;
artifacts = [ compiled ];
context = {
inherit
buildContract
sourceDigest
packageRevisionId
sharedRuntime
;
generator = protocol;
};
})
// {
caminoGroup = compiled;
buildConfig = config;
}
+14 -20
View File
@@ -6,9 +6,10 @@ import { buildService } from "./build-service.mjs";
const config = JSON.parse(await fs.readFile(process.argv[2], "utf8"));
const compiler = await import(pathToFileURL(config.compiler));
const plan = JSON.parse(await fs.readFile(config.plan, "utf8"));
const pkg = plan.definition.packages.find((pkg) => pkg.revision === config.packageRevisionId);
if (!pkg || compiler.hexDigest(plan.definition) !== plan.digest) throw Error("Invalid package assembly context");
const contract = JSON.parse(await fs.readFile(config.buildContract, "utf8"));
compiler.checkPackageBuildContract(contract);
const pkg = contract.definition.package;
if (pkg.revision !== config.packageRevisionId) throw Error("Invalid package assembly context");
const output = process.env.out;
if (!output) throw Error("Package assembly requires an output directory");
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
@@ -23,18 +24,17 @@ const recordFile = async (name, mediaType) => {
};
await fs.mkdir(path.join(output, "share/quixos/generated"), { recursive: true });
for (const root of config.groups) {
const manifest = compiler.parsePackageArtifacts(
const manifest = compiler.parseArtifactGroup(
JSON.parse(await fs.readFile(path.join(root, "share/quixos/package-artifacts.json"), "utf8")),
);
if (
manifest.packageRevisionId !== pkg.revision ||
manifest.bindingDigest !== pkg.bindingDigest ||
manifest.sourceDigest !== pkg.sourceDigest ||
JSON.stringify(manifest.versions) !== JSON.stringify(plan.definition.versions)
manifest.bindingDigest !== contract.digest ||
JSON.stringify(manifest.versions) !== JSON.stringify(contract.definition.versions)
)
throw Error("Artifact group belongs to a different checked package");
const receipt = JSON.parse(await fs.readFile(path.join(root, "share/quixos/build-receipt.json"), "utf8"));
const expected = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest);
const expected = compiler.expectedGroupReceipt(contract, manifest, generatorDigest);
if (JSON.stringify(receipt) !== JSON.stringify(expected.receipt))
throw Error("Artifact group has an invalid build receipt");
const read = await compiler.artifactFileReader({ [pkg.revision]: root });
@@ -80,24 +80,18 @@ if (!implementations.some((entry) => entry.compatibility === "quixos-service-v1"
const manifest = compiler.parsePackageArtifacts({
schemaVersion: 1,
packageRevisionId: pkg.revision,
bindingDigest: pkg.bindingDigest,
sourceDigest: pkg.sourceDigest,
versions: plan.definition.versions,
bindingDigest: contract.digest,
sourceDigest: config.sourceDigest,
versions: contract.definition.versions,
implementations,
...(components.length ? { components } : {}),
});
const { receipt, sources } = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest);
const { receipt, sources } = compiler.expectedPackageReceipt(contract, manifest, config.sourceDigest, generatorDigest);
for (const [name, bytes] of sources) {
const actual = await fs.readFile(path.join(output, name));
if (!Buffer.from(bytes).equals(actual)) throw Error("Assembly lacks checked generated source " + name);
}
await fs.writeFile(path.join(output, "share/quixos/package-artifacts.json"), JSON.stringify(manifest, null, 2));
await fs.writeFile(path.join(output, "share/quixos/build-receipt.json"), JSON.stringify(receipt, null, 2));
// Check package coverage with the same public validator used at installation.
const packagePlan = { definition: { ...plan.definition, packages: [pkg] }, digest: "" };
packagePlan.digest = compiler.hexDigest(packagePlan.definition);
await compiler.checkExecutionWorld(
packagePlan,
[manifest],
await compiler.artifactFileReader({ [pkg.revision]: output }),
);
// Source selection is attested here; compilation groups retain their own receipt.
compiler.checkPackageCoverage({ ...pkg, bindingDigest: contract.digest, sourceDigest: config.sourceDigest }, manifest);
+13 -3
View File
@@ -4,12 +4,22 @@
artifacts,
}:
let
buildTools = import ./build-tools.nix;
groups = map (artifact: artifact.caminoGroup or artifact) artifacts;
compiler = builtins.path {
path = "${context.generator}/libexec/quixos-protocol/execution-world.mjs";
name = "execution-world.mjs";
};
config = pkgs.writeText "camino-package-assembly.json" (
builtins.toJSON {
inherit groups;
inherit (context) plan packageRevisionId sharedRuntime;
compiler = "${context.generator}/libexec/quixos-protocol/execution-world.mjs";
inherit (context)
buildContract
sourceDigest
packageRevisionId
sharedRuntime
;
inherit compiler;
node = "${pkgs.nodejs_24}/bin/node";
esbuild = "${pkgs.esbuild}/bin/esbuild";
shell = "${pkgs.runtimeShell}";
@@ -25,5 +35,5 @@ pkgs.runCommand "camino-package"
};
}
''
${pkgs.nodejs_24}/bin/node ${./.}/assemble-package.mjs ${config}
${pkgs.nodejs_24}/bin/node ${buildTools}/assemble-package.mjs ${config}
''
+62 -48
View File
@@ -1,4 +1,5 @@
import { installDependencies } from "./install-dependencies.mjs";
import { measureBuild } from "./build-profile.mjs";
import { buildService } from "./build-service.mjs";
import { buildComponents } from "./build-components.mjs";
/** Trusted Nix build driver. No package-defined build hooks run between generation and witnessing. */
@@ -10,9 +11,10 @@ import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const config = JSON.parse(await fs.readFile(process.argv[2], "utf8"));
const compiler = await import(pathToFileURL(config.compiler));
const plan = JSON.parse(await fs.readFile(config.plan, "utf8"));
const pkg = plan.definition.packages.find((p) => p.revision === config.packageRevisionId);
if (!pkg || compiler.hexDigest(plan.definition) !== plan.digest) throw Error("Invalid checked execution plan/package");
const contract = JSON.parse(await fs.readFile(config.buildContract, "utf8"));
compiler.checkPackageBuildContract(contract);
const pkg = contract.definition.package;
if (pkg.revision !== config.packageRevisionId) throw Error("Invalid build contract package");
const output = process.env.out;
if (!output) throw Error("Missing Nix output");
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
@@ -55,6 +57,7 @@ const targets = portable ? [...new Set([...portable.targets, "server"])] : [];
const builds = targets.map((target) => ({ target, mode: target, settings: portable }));
if (config.remote) builds.push({ target: "server", mode: "remote", settings: config.remote });
for (const { target, mode, settings } of builds) {
const executionTarget = target === "browser" ? "target:org.quixos.execution.javascript@1" : "server";
if (!["browser", "server"].includes(target)) throw Error(`Unsupported JS build target ${target}`);
const work = path.resolve(`work-${mode}`);
await fs.cp(config.source, work, { recursive: true, dereference: false });
@@ -85,53 +88,65 @@ for (const { target, mode, settings } of builds) {
const bindingPath = relative(settings.bindingOutput);
const source =
mode === "remote"
? compiler.generateRemoteTypeScriptBindings(plan.definition.bindingSchema, pkg.revision)
: compiler.generatePortableTypeScriptBindings(plan.definition.bindingSchema, pkg.revision, { target });
? compiler.generateRemoteTypeScriptBindings(contract.definition.bindings, pkg.revision)
: compiler.generatePortableTypeScriptBindings(contract.definition.bindings, pkg.revision, {
target: executionTarget,
});
await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true });
await fs.writeFile(path.join(work, bindingPath), source);
generated.set(mode, { file: path.join(work, bindingPath), source });
await fs.writeFile(path.join(output, "share/quixos/generated", `${mode}.ts`), source);
await fs.writeFile(
path.join(
output,
mode === "remote" ? "share/quixos/generated/remote.ts" : compiler.generatedBindingPath(executionTarget),
),
source,
);
const entry = relative(settings.entries?.[target] ?? settings.entry);
execFileSync(
config.node,
[
config.tsc,
"--noEmit",
"--strict",
"--skipLibCheck",
"false",
"--target",
"es2024",
"--lib",
"es2024,dom,esnext.disposable",
"--module",
"nodenext",
"--moduleResolution",
"nodenext",
entry,
],
{ cwd: work, stdio: "inherit" },
measureBuild("portable-typecheck", { package: pkg.revision, target, mode }, () =>
execFileSync(
config.node,
[
config.tsc,
"--noEmit",
"--strict",
"--skipLibCheck",
"false",
"--target",
"es2024",
"--lib",
"es2024,dom,esnext.disposable",
"--module",
"nodenext",
"--moduleResolution",
"nodenext",
entry,
],
{ cwd: work, stdio: "inherit" },
),
);
const bundlePath =
mode === "remote" ? "share/quixos/remote/server/module.mjs" : `share/quixos/portable/${target}/module.mjs`;
await fs.mkdir(path.dirname(path.join(output, bundlePath)), { recursive: true });
const coreImport = target === "browser" ? coreURL : coreServer;
const metadata = path.join(work, "bundle-meta.json");
execFileSync(
config.esbuild,
[
entry,
"--bundle",
`--platform=${target === "browser" ? "browser" : "node"}`,
"--format=esm",
"--minify-whitespace",
"--target=es2024",
`--outfile=${path.join(output, bundlePath)}`,
`--metafile=${metadata}`,
`--alias:@quixos/camino-replica-core=${coreImport}`,
`--external:${coreImport}`,
],
{ cwd: work, stdio: "inherit" },
measureBuild("portable-bundle", { package: pkg.revision, target, mode }, () =>
execFileSync(
config.esbuild,
[
entry,
"--bundle",
`--platform=${target === "browser" ? "browser" : "node"}`,
"--format=esm",
"--minify-whitespace",
"--target=es2024",
`--outfile=${path.join(output, bundlePath)}`,
`--metafile=${metadata}`,
`--alias:@quixos/camino-replica-core=${coreImport}`,
`--external:${coreImport}`,
],
{ cwd: work, stdio: "inherit" },
),
);
const meta = JSON.parse(await fs.readFile(metadata, "utf8"));
const built = Object.values(meta.outputs).find((o) => o.entryPoint);
@@ -152,12 +167,12 @@ for (const { target, mode, settings } of builds) {
.filter((e) =>
mode === "remote"
? e.execution.kind === "remote"
: e.execution.kind !== "remote" && e.execution.targets.includes(target),
: e.execution.kind !== "remote" && e.execution.targets.includes(executionTarget),
)
.map((e) => e.id);
const body = {
id: `${mode}-module`,
target,
target: executionTarget,
compatibility: "quixos-js-module-v1",
path: bundlePath,
ports: "quixos-transaction-ports-v1",
@@ -170,22 +185,21 @@ for (const { target, mode, settings } of builds) {
const components =
config.group && !config.components
? []
: await buildComponents({ config, compiler, plan, pkg, output, recordFile, generated });
: await buildComponents({ config, compiler, contract, pkg, output, recordFile, generated });
if (!config.group || config.service.entry)
await buildService({ config, compiler, pkg, output, coreServer, sharedFiles, implementations, recordFile });
const manifest = compiler.parsePackageArtifacts({
const manifest = compiler.parseArtifactGroup({
schemaVersion: 1,
packageRevisionId: pkg.revision,
bindingDigest: pkg.bindingDigest,
sourceDigest: pkg.sourceDigest,
versions: plan.definition.versions,
bindingDigest: contract.digest,
versions: contract.definition.versions,
implementations,
...(components.length ? { components } : {}),
});
for (const { file, source } of generated.values())
if ((await fs.readFile(file, "utf8")) !== source) throw Error("Generated bindings changed during build");
const { receipt, sources } = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest);
const { receipt, sources } = compiler.expectedGroupReceipt(contract, manifest, generatorDigest);
for (const [file, bytes] of sources)
if (!Buffer.from(bytes).equals(await fs.readFile(path.join(output, file))))
throw Error("Receipt generation differs from compiled bindings");
+60 -58
View File
@@ -1,10 +1,13 @@
import { installDependencies } from "./install-dependencies.mjs";
import { checkComponentTypes } from "./component-types.mjs";
import { measureBuild } from "./build-profile.mjs";
import fs from "node:fs/promises";
import path from "node:path";
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
export async function buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }) {
export async function buildComponents({ config, compiler, contract, pkg, output, recordFile, generated }) {
const settings = config.components;
if (!settings) {
if (pkg.components?.length) throw Error("Declared components require checked component build settings");
@@ -100,12 +103,14 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec
await fs.writeFile(
shim,
`export * from ${JSON.stringify(url)};` +
(name === "@quixos/camino-react" ? "" : `export {default} from ${JSON.stringify(url)};`),
(["@quixos/camino-react", "@quixos/camino-react/ui"].includes(name)
? ""
: `export {default} from ${JSON.stringify(url)};`),
);
sharedAliases.push("--alias:" + name + "=" + shim, "--external:" + url);
}
const bindingPath = compiler.artifactPath(settings.bindingOutput),
bindings = compiler.generateComponentClientBindings(plan, pkg.revision),
bindings = contract.definition.component,
assets = compiler.componentAssetDeclarations();
await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true });
await fs.writeFile(path.join(work, bindingPath), bindings);
@@ -123,10 +128,9 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec
for (const root of settings.contracts ?? []) {
const companion = JSON.parse(await fs.readFile(path.join(root, "react-contracts.json"), "utf8"));
if (companion.schemaVersion !== 1) throw Error("Unsupported React props companion");
const contracts = plan.definition.workspace.interfaceImports.filter(
const contracts = contract.definition.moduleInterfaces.filter(
(iface) =>
iface.revisionId === companion.interfaceRevisionId ||
iface.application?.definitionId === companion.interfaceRevisionId,
iface.revisionId === companion.interfaceRevisionId || iface.definitionId === companion.interfaceRevisionId,
);
if (!contracts.length) continue;
for (const [memberId, entry] of Object.entries(companion.members)) {
@@ -136,12 +140,7 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec
`import type {${entry.export} as ${alias}} from ${JSON.stringify(path.join(root, "types", compiler.artifactPath(entry.path)))};`,
);
for (const iface of contracts) {
if (
!(iface.template?.members ?? iface.members).some(
(member) => member.kind === "module" && member.id === memberId,
)
)
throw Error("Props companion names an unknown module member");
if (!iface.members.includes(memberId)) throw Error("Props companion names an unknown module member");
const key = JSON.stringify([iface.revisionId, memberId]);
if (seenProps.has(key)) throw Error("Duplicate React props companion for " + key);
seenProps.add(key);
@@ -155,46 +154,41 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec
propsImports.join("\n") +
`\nexport {};\ndeclare module ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))} { interface ModuleProps {${propsMembers.join("\n")}} }\n`,
);
const components = [];
const witnesses = [];
for (const [id, entry] of entries) {
const declaration = declarations.find((component) => component.id === id),
source = compiler.artifactPath(entry.entry);
if (!/^[$A-Z_a-z][$\w]*$/.test(entry.export)) throw Error("Invalid component export name");
const key = digest(Buffer.from(id)).slice(0, 24),
directory = "share/quixos/components/" + key;
const key = digest(Buffer.from(id)).slice(0, 24);
const witness = path.join(work, "component-check-" + key + ".tsx");
const module = pkg.modules.find((module) => module.id === id);
await fs.writeFile(
witness,
`import type {Modules} from ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))};import {${entry.export} as Component} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};const checked:Modules[${JSON.stringify(declaration.displayName)}]=Component;export default checked;\n` +
(module.contract === "org.quixos.react.subject-only/1"
? `import type {ComponentProps} from "@quixos/camino-react";declare const subject:ComponentProps<${JSON.stringify(declaration.subject)}>['subject'];const mount = <Component subject={subject}/>;\n`
? `import type {ComponentProps} from "react";declare const props:Pick<ComponentProps<Modules[${JSON.stringify(declaration.displayName)}]>,"subject"|"input">;const mount = <Component {...props}/>;\n`
: ""),
);
execFileSync(
config.node,
[
config.tsc,
"--noEmit",
"--strict",
"--skipLibCheck",
"false",
"--target",
"es2024",
"--lib",
"es2024,dom,dom.iterable,esnext.disposable",
"--module",
"nodenext",
"--moduleResolution",
"nodenext",
"--jsx",
"react-jsx",
path.basename(witness),
assetPath,
propsPath,
],
{ cwd: work, stdio: "inherit" },
);
witnesses.push(path.basename(witness));
}
checkComponentTypes({
node: config.node,
tsc: config.tsc,
work,
witnesses,
assets: assetPath,
props: propsPath,
packageRevision: pkg.revision,
});
const components = [];
const { checkControls } = await import(pathToFileURL(path.join(settings.sdk, "lint-controls.mjs")));
const { default: ts } = await import(pathToFileURL(path.resolve(path.dirname(config.tsc), "../lib/typescript.js")));
const checkedControls = new Set();
for (const [id, entry] of entries) {
const declaration = declarations.find((component) => component.id === id),
source = compiler.artifactPath(entry.entry),
key = digest(Buffer.from(id)).slice(0, 24),
directory = "share/quixos/components/" + key;
const metadata = path.join(work, "component-meta-" + key + ".json");
const facade = "component-entry-" + key + ".ts";
await fs.writeFile(
@@ -202,27 +196,35 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec
`export {${entry.export}} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};\n`,
);
await fs.mkdir(path.join(output, directory), { recursive: true });
execFileSync(
config.esbuild,
[
facade,
"--bundle",
"--platform=browser",
"--format=esm",
"--target=es2024",
"--jsx=automatic",
'--define:process.env.NODE_ENV="production"',
"--metafile=" + metadata,
"--outfile=" + path.join(output, directory, "module.mjs"),
...sharedAliases,
...["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"].map(
(extension) => "--loader:." + extension + "=dataurl",
),
],
{ cwd: work, stdio: "inherit" },
measureBuild("component-bundle", { package: pkg.revision, component: id }, () =>
execFileSync(
config.esbuild,
[
facade,
"--bundle",
"--platform=browser",
"--format=esm",
"--target=es2024",
"--jsx=automatic",
'--define:process.env.NODE_ENV="production"',
"--metafile=" + metadata,
"--outfile=" + path.join(output, directory, "module.mjs"),
...sharedAliases,
...["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"].map(
(extension) => "--loader:." + extension + "=dataurl",
),
],
{ cwd: work, stdio: "inherit" },
),
);
const meta = JSON.parse(await fs.readFile(metadata, "utf8")),
outputs = Object.entries(meta.outputs);
for (const file of Object.keys(meta.inputs)) {
if (!/\.[cm]?[jt]sx?$/.test(file) || file.includes("node_modules/") || checkedControls.has(file)) continue;
checkedControls.add(file);
const errors = checkControls(ts, await fs.readFile(path.resolve(work, file), "utf8"), file);
if (errors.length) throw Error(errors.join("\n"));
}
const main = outputs.find(([file]) => file.endsWith("/module.mjs"))?.[1];
if (!main?.exports.includes(entry.export)) throw Error("Component bundle lacks declared export");
if (
+12
View File
@@ -0,0 +1,12 @@
/** Timing is log data, never part of an artifact or its witness. */
export function measureBuild(phase, details, action) {
const started = performance.now();
try {
return action();
} finally {
if (process.env.QUIXOS_TRACE_PROFILE === "1")
console.error(
`[qx-build-profile] ${JSON.stringify({ phase, ms: Math.round(performance.now() - started), ...details })}`,
);
}
}
+16 -4
View File
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { installDependencies } from "./install-dependencies.mjs";
const config = JSON.parse(await fs.readFile(process.argv[2], "utf8"));
const resource = JSON.parse(await fs.readFile(config.interface, "utf8"));
if (resource.kind !== "interface") throw Error("React props belong to an interface resource");
@@ -29,14 +30,24 @@ const metadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error)
throw error;
});
await fs.writeFile(packageFile, JSON.stringify({ ...metadata, type: "module" }));
if (config.dependencies) await fs.symlink(config.dependencies, path.join(work, "node_modules"));
const modules = path.join(output, "node_modules");
await fs.mkdir(modules, { recursive: true });
await installDependencies({
destination: modules,
application: config.dependencies,
selected: config.reactSdk ? { "@quixos/camino-react": config.reactSdk } : {},
reserved: ["@quixos/camino-react"],
});
await fs.symlink(modules, path.join(work, "node_modules"));
const members = Object.create(null);
let index = 0;
for (const [id, entry] of Object.entries(config.members)) {
const member = (iface.template?.members ?? iface.members).find((member) => member.id === id);
if (
member?.kind !== "module" ||
!["org.quixos.react.component/1", "org.quixos.react.subject-only/1"].includes(member.contract)
!["org.quixos.react.component/1", "org.quixos.react.component/2", "org.quixos.react.subject-only/1"].includes(
member.contract,
)
)
throw Error("Props entry is not a React module member: " + id);
if (
@@ -52,7 +63,7 @@ for (const [id, entry] of Object.entries(config.members)) {
`import type {${entry.export} as Props} from ${typeImport};
type Assert<T extends true> = T;
type ObjectProps = Assert<Props extends object ? true : false>;
type ReservedSubject = Assert<"subject" extends keyof Props ? false : true>;
type ReservedRendererProps = Assert<Extract<keyof Props, "subject" | "input" | "navigation" | "usingComponent" | "provider" | "coverage" | "componentCodeFallback"> extends never ? true : false>;
${member.contract === "org.quixos.react.subject-only/1" ? "type SubjectOnly = Assert<{} extends Props ? true : false>;" : ""}
export type {Props};\n`,
);
@@ -69,6 +80,8 @@ execFileSync(
"--strict",
"--target",
"es2024",
"--lib",
"es2024,dom,dom.iterable,esnext.disposable",
"--module",
"nodenext",
"--moduleResolution",
@@ -88,7 +101,6 @@ for (const file of declarations) {
await fs.copyFile(path.join(work, file), path.join(output, "types", file));
}
await fs.writeFile(path.join(output, "package.json"), JSON.stringify({ type: "module" }));
if (config.dependencies) await fs.symlink(config.dependencies, path.join(output, "node_modules"));
await fs.writeFile(
path.join(output, "react-contracts.json"),
JSON.stringify({ schemaVersion: 1, interfaceRevisionId: iface.revisionId, members }, null, 2),
+13
View File
@@ -0,0 +1,13 @@
# Standalone build drivers import sibling JavaScript modules. Their code is an
# input; Nix helper APIs and test sources do not belong to that dependency.
builtins.path {
path = ./.;
name = "camino-build-tools";
filter =
filename: type:
let
name = builtins.baseNameOf filename;
in
type == "directory"
|| (builtins.match ".*\\.mjs" name != null && builtins.match ".*\\.test\\.mjs" name == null);
}
+49
View File
@@ -0,0 +1,49 @@
import path from "node:path";
import { createRequire } from "node:module";
const [tsc, count, ...args] = process.argv.slice(2);
const ts = createRequire(import.meta.url)(path.resolve(path.dirname(tsc), "../lib/typescript.js"));
const parsed = ts.parseCommandLine(args);
const witnesses = parsed.fileNames.slice(0, Number(count));
const shared = parsed.fileNames.slice(Number(count));
const host = ts.createCompilerHost(parsed.options, true);
// These programs only discover source closures; they do not bind or check.
// Sharing immutable parsed source files avoids reading/parsing the same libs
// for every witness. Only the final combined program performs typechecking.
const sources = new Map();
const getSourceFile = host.getSourceFile;
host.getSourceFile = (filename, ...options) => {
if (!sources.has(filename)) sources.set(filename, getSourceFile(filename, ...options));
return sources.get(filename);
};
for (const method of ["fileExists", "directoryExists", "getDirectories", "readFile", "realpath"]) {
if (!host[method]) continue;
const original = host[method],
cache = new Map();
host[method] = (filename) => {
if (!cache.has(filename)) cache.set(filename, original(filename));
return cache.get(filename);
};
}
const closures = witnesses.map(
(witness) => new Set(ts.createProgram([witness, ...shared], parsed.options, host).getSourceFiles()),
);
const common = new Set([...closures[0]].filter((source) => closures.every((closure) => closure.has(source))));
const ambient = (node) =>
ts.isModuleDeclaration(node) || ts.isNamespaceExportDeclaration(node) || ts.forEachChild(node, ambient);
// A file that is visible to just some witnesses must not contribute globals
// or ambient module declarations to the others. Otherwise a combined success
// could hide an independent failure. Script files are conservatively unsafe.
if (
closures.some((closure) =>
[...closure].some((source) => !common.has(source) && (!ts.isExternalModule(source) || ambient(source))),
)
)
process.exit(2);
const program = ts.createProgram(parsed.fileNames, parsed.options, host);
const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)];
if (diagnostics.length) {
console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, host));
process.exit(1);
}
+51
View File
@@ -0,0 +1,51 @@
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { measureBuild } from "./build-profile.mjs";
// Component witnesses share bindings and usually the same source graph.
// Check a bounded batch once when distinct source closures cannot supply
// ambient declarations to one another. Otherwise use the original checks.
export function checkComponentTypes({ node, tsc, work, witnesses, assets, props, packageRevision }) {
const checkTypes = (files, combined = false) =>
execFileSync(
node,
[
...(combined ? [fileURLToPath(new URL("./component-type-batch.mjs", import.meta.url))] : []),
tsc,
...(combined ? [String(files.length)] : []),
"--noEmit",
"--strict",
"--skipLibCheck",
"false",
"--target",
"es2024",
"--lib",
"es2024,dom,dom.iterable,esnext.disposable",
"--module",
"nodenext",
"--moduleResolution",
"nodenext",
"--jsx",
"react-jsx",
...files,
assets,
props,
],
{ cwd: work, stdio: combined ? "pipe" : "inherit" },
);
measureBuild("component-typecheck", { package: packageRevision, components: witnesses.length }, () => {
for (let start = 0; start < witnesses.length; start += 1000) {
const batch = witnesses.slice(start, start + 1000);
if (batch.length === 1) checkTypes(batch);
else {
try {
checkTypes(batch, true);
} catch (error) {
if (!(error.status > 0) && error.code !== "ENOBUFS") throw error;
console.error("Component batch requires independent witness checks");
for (const witness of batch) checkTypes([witness]);
}
}
}
});
}
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { checkComponentTypes } from "./component-types.mjs";
const tsc = process.env.QUIXOS_TYPESCRIPT;
if (!tsc) throw Error("Pass the selected TypeScript compiler path");
async function fixture(context, left, right) {
const work = await mkdtemp(path.join(os.tmpdir(), "component-types-"));
context.after(() => rm(work, { recursive: true, force: true }));
await Promise.all(
Object.entries({ "left.ts": left, "right.ts": right, "assets.d.ts": "", "props.d.ts": "" }).map(([file, source]) =>
writeFile(path.join(work, file), source),
),
);
return {
node: process.execPath,
tsc,
work,
witnesses: ["left.ts", "right.ts"],
assets: "assets.d.ts",
props: "props.d.ts",
packageRevision: "test",
};
}
test("shared component typechecking accepts all valid witnesses", async (context) => {
checkComponentTypes(await fixture(context, "export const left: string = 'ok';", "export const right: number = 1;"));
});
test("independently valid ambient declarations use the full individual check fallback", async (context) => {
checkComponentTypes(
await fixture(
context,
"export {}; declare global { interface Application { value: string; } }",
"export {}; declare global { interface Application { value: number; } }",
),
);
});
test("a type error in any witness still rejects the artifact", async (context) => {
const input = await fixture(context, "export const left: string = 'ok';", "export const right: string = 1;");
assert.throws(() => checkComponentTypes(input));
});
test("one witness's globals cannot make an independently invalid witness pass", async (context) => {
const input = await fixture(
context,
"export {}; declare global { interface Application { value: string; } }",
"export const right: Application = {value: 'ok'};",
);
assert.throws(() => checkComponentTypes(input));
});
test("an ambient module from another source closure cannot resolve a missing import", async (context) => {
const input = await fixture(
context,
"declare module 'missing-component-package' { export const value: string; }",
"import {value} from 'missing-component-package'; export const right = value;",
);
assert.throws(() => checkComponentTypes(input));
});
+30
View File
@@ -0,0 +1,30 @@
# QX and recursive pins enter through the checked contract and build recipe.
# The compiler receives authored implementation files, not resource provenance.
{ lib, src }:
builtins.path {
path = src;
name = "camino-implementation-source";
filter =
path: type:
let
name = builtins.baseNameOf path;
in
if type == "directory" then
!(builtins.elem name [
".git"
".jj"
"node_modules"
])
else
!(builtins.elem name [
"flake.nix"
"flake.lock"
"quixos.lock"
"quixos.check.json"
"quixos.react.json"
"yarn-project.nix"
"components.nix"
"contracts.nix"
])
&& !(lib.hasSuffix ".qx" name);
}
+2
View File
@@ -0,0 +1,2 @@
# The selected protocol owns semantic projection; builders receive its result.
args@{ protocol, ... }: import "${protocol}/share/package-context.nix" args
+57
View File
@@ -0,0 +1,57 @@
{
pkgs,
nixpkgs,
system,
}:
let
packageSet = (import ./quixos-package-helpers.nix).packageSet;
shared = {
marker = "shared";
};
environment = {
inherit system;
nixpkgsSource = toString nixpkgs;
pkgs = shared;
narHash = nixpkgs.narHash;
};
standard = {
inherit nixpkgs system;
context.buildEnvironment = environment;
};
fallback = packageSet { inherit nixpkgs system; };
in
assert packageSet standard == shared;
assert
packageSet (
standard
// {
context.buildEnvironment = environment // {
nixpkgsSource = "same-content-under-another-name";
};
}
) == shared;
assert
(packageSet (
standard
// {
context.buildEnvironment = environment // {
system = "another-system";
};
}
)).path == fallback.path;
assert
(packageSet (
standard
// {
context.buildEnvironment = environment // {
nixpkgsSource = "another-source";
narHash = "another-hash";
};
}
)).path == fallback.path;
assert
(packageSet (standard // { overlays = [ (_: _: { marker = "overlay"; }) ]; })).marker == "overlay";
assert (packageSet (standard // { config.allowBroken = true; })).config.allowBroken;
pkgs.runCommand "camino-package-set-check" { } ''
touch "$out"
''
+34
View File
@@ -847,6 +847,40 @@ let
in
{
mkCaminoReactPackage = import ./react-package.nix;
# Share the composition's package set only for the same exact Nixpkgs source,
# system and default import arguments. Config/overlay/cross-build choices use
# ordinary Nixpkgs evaluation. No package implementation is substituted.
packageSet =
args@{
nixpkgs,
system,
context ? { },
...
}:
let
authoredArgs = builtins.removeAttrs args [
"nixpkgs"
"context"
];
importArgs = {
config = { };
overlays = [ ];
}
// authoredArgs;
environment = context.buildEnvironment or null;
in
if
builtins.attrNames authoredArgs == [ "system" ]
&& environment != null
&& environment.system == system
&& (
environment.nixpkgsSource == toString nixpkgs
|| ((nixpkgs.narHash or null) != null && nixpkgs.narHash == (environment.narHash or null))
)
then
environment.pkgs
else
import nixpkgs importArgs;
mkCaminoReactContracts = import ./react-contracts.nix;
mkCaminoYarnDependencies = import ./yarn-dependencies.nix;
mkCaminoYarnShell = import ./yarn-shell.nix;
+20 -3
View File
@@ -6,16 +6,33 @@
dependencies ? null,
}:
let
buildTools = import ./build-tools.nix;
config = pkgs.writeText "camino-react-contracts.json" (
builtins.toJSON {
source = src;
interface = context.interface;
source = import ./implementation-source.nix {
inherit (pkgs) lib;
inherit src;
};
interface = builtins.toFile "camino-interface-contract.json" (
builtins.unsafeDiscardStringContext (
builtins.toJSON (
let
resource = builtins.fromJSON (builtins.readFile context.interface);
in
{
kind = resource.kind;
revision = builtins.removeAttrs resource.revision [ "source" ];
}
)
)
);
inherit members dependencies;
reactSdk = context.reactSdk or null;
node = "${pkgs.nodejs_24}/bin/node";
tsc = "${context.typescript}/node_modules/typescript/bin/tsc";
}
);
in
pkgs.runCommand "camino-react-contracts" { } ''
${pkgs.nodejs_24}/bin/node ${./.}/build-react-contracts.mjs ${config}
${pkgs.nodejs_24}/bin/node ${buildTools}/build-react-contracts.mjs ${config}
''
+1 -1
View File
@@ -10,7 +10,7 @@ let
group = import ./artifacts.nix {
inherit pkgs src;
inherit (context)
plan
buildContract
core
sharedRuntime
typescript
+1 -1
View File
@@ -15,7 +15,7 @@ let
group = import ./artifacts.nix {
inherit pkgs src;
inherit (context)
plan
buildContract
core
sharedRuntime
typescript
+1 -1
View File
@@ -11,7 +11,7 @@ let
group = import ./artifacts.nix {
inherit pkgs src service;
inherit (context)
plan
buildContract
core
sharedRuntime
typescript