diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72d1b6e9..aa4f3f95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,21 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm -r run build - run: pnpm -r run typecheck - - run: pnpm -r run test # amico-run suite (incl. S31 grep rule) + extension unit suite + - run: pnpm -r run test # amico-run suite (incl. S31 grep rule) + extension unit suite + @amicode/schema conformance + - name: amico-validate — shipped configs conform + linked bin works (0.1c gate) + run: | + # Exercise the LINKED bin via a dependent (the bin links into amico-run / + # extension, not the root) so a broken bin entry reds. (Jack's #30.) + pnpm --filter amicode-v2 exec amico-validate --help + # File validations via the launcher (root-relative paths, real bin target). + V="packages/schema/launcher/amico-validate" + $V packages/extension/scripts/lab.toml.example --schema lab + $V packages/extension/demo/run/run.toml + $V packages/extension/demo/run/result.toml + $V packages/extension/demo/run/FINISHED + # the non-filename schemas (AC5: all five) via --schema + $V packages/schema/test/fixtures/valid/solvespec.toml --schema solvespec + $V packages/schema/test/fixtures/valid/catalog-entry.toml --schema catalog-entry boot-smoke: strategy: matrix: diff --git a/packages/schema/esbuild.config.mjs b/packages/schema/esbuild.config.mjs index 53d3720a..9e0bdbc8 100644 --- a/packages/schema/esbuild.config.mjs +++ b/packages/schema/esbuild.config.mjs @@ -1,16 +1,19 @@ import { build } from 'esbuild' +import { chmodSync } from 'node:fs' // The library is consumed as TS source (main = src/index.ts; consumers bundle it -// via their own esbuild). We still bundle here as a build-time smoke check that -// the dep graph (ajv + ajv-formats + the JSON schemas) bundles cleanly into a -// single ESM module — the same way the extension/CLI will inline it. +// via their own esbuild). We bundle two artifacts here: +// - dist/index.js: a smoke check that the dep graph (ajv + ajv-formats + the +// JSON schemas) bundles cleanly into a single ESM module. +// - dist/amico-validate.js: the standalone validator CLI (0.1c). +const common = { bundle: true, platform: 'node', target: 'node20', format: 'esm', sourcemap: true, logLevel: 'info' } + +await build({ ...common, entryPoints: ['src/index.ts'], outfile: 'dist/index.js' }) + await build({ - entryPoints: ['src/index.ts'], - bundle: true, - platform: 'node', - target: 'node20', - format: 'esm', - outfile: 'dist/index.js', - sourcemap: true, - logLevel: 'info', + ...common, + entryPoints: ['src/cli.ts'], + outfile: 'dist/amico-validate.js', + banner: { js: '#!/usr/bin/env node' }, }) +chmodSync('dist/amico-validate.js', 0o755) diff --git a/packages/schema/launcher/amico-validate b/packages/schema/launcher/amico-validate new file mode 100755 index 00000000..f2e08531 --- /dev/null +++ b/packages/schema/launcher/amico-validate @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Thin launcher: resolve node, exec the bundled validator. Committed (exists at +# install time) so the `amico-validate` bin links on a clean install — the dist +# bundle is built by `pnpm run build`, not present until then. Mirrors amico-run. +set -euo pipefail +SOURCE="${BASH_SOURCE[0]}" +while [ -h "$SOURCE" ]; do # resolve symlink chains (node_modules/.bin) + DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" +done +DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" +if ! command -v node >/dev/null 2>&1; then + echo "amico-validate: node >= 20 not found on PATH (install node or fix PATH)" >&2 + exit 64 +fi +BUNDLE="$DIR/../dist/amico-validate.js" +if [ ! -f "$BUNDLE" ]; then + echo "amico-validate: bundle missing ($BUNDLE) — run 'pnpm --filter @amicode/schema build'" >&2 + exit 64 +fi +exec node "$BUNDLE" "$@" diff --git a/packages/schema/package.json b/packages/schema/package.json index 1cab0211..0d1604a6 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -5,6 +5,7 @@ "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", + "bin": { "amico-validate": "./launcher/amico-validate" }, "engines": { "node": ">=20" }, "scripts": { "build": "node esbuild.config.mjs", diff --git a/packages/schema/src/cli.ts b/packages/schema/src/cli.ts new file mode 100644 index 00000000..27cab38d --- /dev/null +++ b/packages/schema/src/cli.ts @@ -0,0 +1,50 @@ +// amico-validate — validate an amico config/artifact file against the shared +// schema set. A thin CLI over @amicode/schema's validate() — NO parallel +// validation logic. Exit 0 = valid, 64 = invalid or usage error (mirrors +// amico-run's config-error exit convention, Q85). +import { validateFile, kindForFilename, SCHEMA_KINDS, type SchemaKind } from "./index.js"; + +const USAGE = `usage: amico-validate [--schema ] + role is inferred from its name (run.toml, result.toml, lab.toml, FINISHED); + pass --schema for solvespec / catalog-entry or any non-standard filename. + kinds: ${SCHEMA_KINDS.join(", ")} +exit: 0 valid · 64 invalid or usage error`; + +export function main(argv: string[]): number { + let file: string | undefined; + let schema: string | undefined; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--help" || a === "-h") { console.log(USAGE); return 0; } + if (a === "--schema") { + schema = argv[++i]; + if (schema === undefined) { console.error(`amico-validate: --schema requires a value\n${USAGE}`); return 64; } + } else if (a.startsWith("-")) { + console.error(`amico-validate: unknown flag ${a}\n${USAGE}`); return 64; + } else if (file !== undefined) { + console.error(`amico-validate: multiple files given\n${USAGE}`); return 64; + } else { + file = a; + } + } + if (file === undefined) { console.error(`amico-validate: no file given\n${USAGE}`); return 64; } + + const inferred = schema ?? kindForFilename(file); + if (inferred === undefined) { + console.error(`amico-validate: cannot infer schema for ${file} — pass --schema <${SCHEMA_KINDS.join("|")}>`); + return 64; + } + if (!SCHEMA_KINDS.includes(inferred as SchemaKind)) { + console.error(`amico-validate: unknown schema "${inferred}" (kinds: ${SCHEMA_KINDS.join(", ")})`); + return 64; + } + const kind = inferred as SchemaKind; + + const r = validateFile(file, kind); + if (r.ok) { console.log(`OK ${file} (${kind})`); return 0; } + console.error(`INVALID ${file} (${kind}):`); + for (const e of r.errors) console.error(` ${e}`); + return 64; +} + +process.exit(main(process.argv.slice(2))); diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 647197ce..8fc5e8be 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -42,6 +42,19 @@ export const SUPPORTED_SCHEMA_VERSIONS = ["1"] as const; export interface Validation { ok: boolean; errors: string[] } +/** Resolve a schema kind from a file's basename, for the fixed-filename artifacts + * (run.toml, result.toml, lab.toml, FINISHED). Returns undefined for files + * with no canonical name (SolveSpec, catalog-entry) — those need an explicit + * --schema. The amico-validate CLI uses this for file-role resolution. */ +export function kindForFilename(filePath: string): SchemaKind | undefined { + const base = filePath.replace(/^.*[\\/]/, ""); + if (base === "run.toml") return "run"; + if (base === "result.toml") return "result"; + if (base === "lab.toml") return "lab"; + if (base === "FINISHED") return "finished"; + return undefined; +} + const ajv = new Ajv({ allErrors: true, strict: false }); addFormats(ajv); const compiled = new Map(); diff --git a/packages/schema/test/cli.test.ts b/packages/schema/test/cli.test.ts new file mode 100644 index 00000000..15915286 --- /dev/null +++ b/packages/schema/test/cli.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdtempSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; + +const here = dirname(fileURLToPath(import.meta.url)); +const pkg = join(here, ".."); +const BUNDLE = join(pkg, "dist", "amico-validate.js"); +const validDir = join(here, "fixtures", "valid"); +const invalidDir = join(here, "fixtures", "invalid"); +const KINDS = ["run", "result", "lab", "solvespec", "catalog-entry", "finished"]; + +beforeAll(() => { execFileSync("node", [join(pkg, "esbuild.config.mjs")], { cwd: pkg }); }); + +function run(args: string[]): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync("node", [BUNDLE, ...args], { encoding: "utf8" }); + return { code: 0, stdout, stderr: "" }; + } catch (e) { + const err = e as { status?: number; stdout?: string; stderr?: string }; + return { code: err.status ?? 1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; + } +} + +describe("amico-validate CLI", () => { + it("valid fixtures → exit 0 (every kind, via --schema)", () => { + for (const k of KINDS) { + const r = run([join(validDir, `${k}.toml`), "--schema", k]); + expect(r.code, `${k}: ${r.stderr}`).toBe(0); + } + }); + it("invalid fixtures → exit 64 + field-precise stderr (every kind)", () => { + for (const k of KINDS) { + const r = run([join(invalidDir, `${k}.toml`), "--schema", k]); + expect(r.code, k).toBe(64); + expect(r.stderr).toContain("INVALID"); + } + }); + it("a committed WRONG-TYPE fixture fails field-precise (AC7 class matrix is self-contained)", () => { + const r = run([join(invalidDir, "result-wrongtype.toml"), "--schema", "result"]); + expect(r.code).toBe(64); + expect(r.stderr).toContain("/fidelity: must be number"); + }); + it("file-role resolution by basename for the fixed-filename schemas (no --schema)", () => { + expect(run([join(validDir, "run.toml")]).code).toBe(0); + expect(run([join(validDir, "result.toml")]).code).toBe(0); + expect(run([join(invalidDir, "result.toml")]).code).toBe(64); // missing schema_version + }); + it("FINISHED resolves by exact basename (no extension)", () => { + const f = join(mkdtempSync(join(tmpdir(), "fin-")), "FINISHED"); + copyFileSync(join(validDir, "finished.toml"), f); + expect(run([f]).code).toBe(0); + }); + it("a non-filename schema without --schema cannot infer → 64", () => { + const r = run([join(validDir, "solvespec.toml")]); // solvespec.toml is not a canonical name + expect(r.code).toBe(64); + expect(r.stderr).toContain("cannot infer"); + }); + it("field-precise stderr names the offending key + path", () => { + const r = run([join(invalidDir, "lab.toml"), "--schema", "lab"]); + expect(r.stderr).toContain("/transmon/levels"); + }); + it("usage / bad-arg errors exit 64", () => { + expect(run([]).code).toBe(64); // no file + expect(run(["a.toml", "b.toml"]).code).toBe(64); // multiple files + expect(run(["f.toml", "--schema", "bogus"]).code).toBe(64); // unknown schema + expect(run(["f.toml", "--nope"]).code).toBe(64); // unknown flag + }); + it("--help exits 0", () => expect(run(["--help"]).code).toBe(0)); +}); diff --git a/packages/schema/test/fixtures/invalid/catalog-entry.toml b/packages/schema/test/fixtures/invalid/catalog-entry.toml new file mode 100644 index 00000000..07771b32 --- /dev/null +++ b/packages/schema/test/fixtures/invalid/catalog-entry.toml @@ -0,0 +1,4 @@ +schema_version = "1" +run_id = "r1" +lab_id = "default" +fidelity = 0.99 diff --git a/packages/schema/test/fixtures/invalid/finished.toml b/packages/schema/test/fixtures/invalid/finished.toml new file mode 100644 index 00000000..fa2646a6 --- /dev/null +++ b/packages/schema/test/fixtures/invalid/finished.toml @@ -0,0 +1,2 @@ +status = "halfway" +exit_code = 0 diff --git a/packages/schema/test/fixtures/invalid/lab.toml b/packages/schema/test/fixtures/invalid/lab.toml new file mode 100644 index 00000000..08fd529e --- /dev/null +++ b/packages/schema/test/fixtures/invalid/lab.toml @@ -0,0 +1,8 @@ +schema_version = "1" +[lab] +name = "x" +[transmon] +omega_GHz = 5.0 +delta_GHz = 0.2 +levels = 99 +drive_max_GHz = 0.2 diff --git a/packages/schema/test/fixtures/invalid/result-wrongtype.toml b/packages/schema/test/fixtures/invalid/result-wrongtype.toml new file mode 100644 index 00000000..c07bead4 --- /dev/null +++ b/packages/schema/test/fixtures/invalid/result-wrongtype.toml @@ -0,0 +1,3 @@ +schema_version = "1" +fidelity = "high" +iterations = 60 diff --git a/packages/schema/test/fixtures/invalid/result.toml b/packages/schema/test/fixtures/invalid/result.toml new file mode 100644 index 00000000..287f8ea2 --- /dev/null +++ b/packages/schema/test/fixtures/invalid/result.toml @@ -0,0 +1,2 @@ +fidelity = 0.99 +iterations = 60 diff --git a/packages/schema/test/fixtures/invalid/run.toml b/packages/schema/test/fixtures/invalid/run.toml new file mode 100644 index 00000000..807bf22f --- /dev/null +++ b/packages/schema/test/fixtures/invalid/run.toml @@ -0,0 +1,8 @@ +schema_version = "1" +script_path = "/s.jl" +lab = "default" +lab_id = "default" +created_at = "2026-06-15T00:00:00Z" +orchestrator_version = "0.1.0" +[julia] +binary = "julia" diff --git a/packages/schema/test/fixtures/invalid/solvespec.toml b/packages/schema/test/fixtures/invalid/solvespec.toml new file mode 100644 index 00000000..b9c76067 --- /dev/null +++ b/packages/schema/test/fixtures/invalid/solvespec.toml @@ -0,0 +1,4 @@ +schema_version = "1" +script_path = "/s.jl" +lab_id = "default" +rogue_key = true