Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[important] the gate covers lab/manifest/result/FINISHED but not solvespec or catalog-entry — AC5 requires the non-filename schemas via --schema, and the DoD is "all five." Both fixtures are committed; add them:

Suggested change
$V packages/extension/demo/run/FINISHED
$V packages/extension/demo/run/FINISHED
$V packages/schema/test/fixtures/valid/solvespec.toml --schema solvespec
$V packages/schema/test/fixtures/valid/catalog-entry.toml --schema catalog-entry

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the gate now also validates solvespec + catalog-entry via --schema (the committed valid fixtures), so all five are covered (AC5 / DoD).

# 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:
Expand Down
25 changes: 14 additions & 11 deletions packages/schema/esbuild.config.mjs
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 22 additions & 0 deletions packages/schema/launcher/amico-validate
Original file line number Diff line number Diff line change
@@ -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" "$@"
1 change: 1 addition & 0 deletions packages/schema/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions packages/schema/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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 <file> [--schema <kind>]
<file> 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)));
13 changes: 13 additions & 0 deletions packages/schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SchemaKind, ValidateFunction>();
Expand Down
72 changes: 72 additions & 0 deletions packages/schema/test/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
4 changes: 4 additions & 0 deletions packages/schema/test/fixtures/invalid/catalog-entry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
schema_version = "1"
run_id = "r1"
lab_id = "default"
fidelity = 0.99
2 changes: 2 additions & 0 deletions packages/schema/test/fixtures/invalid/finished.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
status = "halfway"
exit_code = 0
8 changes: 8 additions & 0 deletions packages/schema/test/fixtures/invalid/lab.toml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions packages/schema/test/fixtures/invalid/result-wrongtype.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
schema_version = "1"
fidelity = "high"
iterations = 60
2 changes: 2 additions & 0 deletions packages/schema/test/fixtures/invalid/result.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
fidelity = 0.99
iterations = 60
8 changes: 8 additions & 0 deletions packages/schema/test/fixtures/invalid/run.toml
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 4 additions & 0 deletions packages/schema/test/fixtures/invalid/solvespec.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
schema_version = "1"
script_path = "/s.jl"
lab_id = "default"
rogue_key = true
Loading