← Files aictrl.devARCHIVED FILE
validate.mjs
6.42 KB · Sep 30, 2026 · 22:54 UTC
#!/usr/bin/env node
/**
* validate.mjs — offline pre-apply validator for an aictrl workflow file.
*
* Runs the SAME layer-1 gate the apply-loader runs (JSON Schema, draft 2020-12,
* against the bundled reference/workflow.schema.json) PLUS the static DAG checks
* the schema can't express (duplicate node ids, edge endpoints, acyclicity, loop
* nesting depth and the nested-maxIterations product bound).
*
* What it can NOT check offline (by design):
* - whether a `template:` / `workflow:` ref exists in YOUR org — those resolve
* org-scoped at apply time (fail-closed), so only the apply / a server
* dry-run confirms them.
* - CEL semantics of `when`/`until`/`while`/gate conditions (evaluated at run).
* A green run here means "structurally correct and DAG-sound"; the apply
* (git-sync of `.aictrl/workflows/<name>.yaml`) is the definitive gate.
*
* Usage (run from your repo root):
* node path/to/validate.mjs .aictrl/workflows/my-workflow.yaml
*
* Deps are dev-only and NOT bundled with the skill (same ones the apply-loader
* uses for layer-1). If they're missing the script tells you what to install:
* npm i -D ajv ajv-formats js-yaml
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
import { dirname, join, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const SCHEMA_PATH = join(here, 'reference', 'workflow.schema.json');
const V1_SCHEMA_PATH = join(here, 'reference', 'v1', 'workflow.schema.json');
const file = process.argv[2];
if (!file) {
console.error('usage: node validate.mjs <workflow.yaml|.yml|.json>');
process.exit(2);
}
// Resolve the (dev-only) parser + validator from the HOST repo's node_modules,
// not this skill directory. Run from your repo root so resolution works.
const hostRequire = createRequire(pathToFileURL(join(process.cwd(), 'package.json')));
let AjvMod, addFormatsMod, yaml;
try {
AjvMod = hostRequire('ajv/dist/2020.js'); // Draft 2020-12
yaml = hostRequire('js-yaml');
try { addFormatsMod = hostRequire('ajv-formats'); } catch { addFormatsMod = null; }
} catch {
console.error('Missing validator dependencies (dev-only; not bundled with the skill).');
console.error('Install them in this repo, then re-run from the repo root:');
console.error(' npm i -D ajv ajv-formats js-yaml');
process.exit(2);
}
const Ajv = AjvMod.default || AjvMod;
const addFormats = addFormatsMod ? (addFormatsMod.default || addFormatsMod) : null;
// Parse the workflow file (YAML or JSON — the loader accepts both).
let doc;
try {
const raw = readFileSync(resolve(file), 'utf8');
// js-yaml v4 `load()` is SAFE by default — it uses DEFAULT_SCHEMA, which has no
// type-constructing tags (the unsafe loader / `safeLoad` were removed in v4).
// Workflow files are plain data (maps/lists/scalars); no custom schema is used.
doc = file.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
} catch (e) {
console.error(`Could not read/parse ${file}: ${e.message}`);
process.exit(2);
}
const errors = [];
// ---- Layer 1: JSON Schema (identical config to the apply-loader) ------------
const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
const v1Schema = JSON.parse(readFileSync(V1_SCHEMA_PATH, 'utf8'));
const ajv = new Ajv({ strict: false, allErrors: true });
if (addFormats) addFormats(ajv);
ajv.addSchema(v1Schema);
const validate = ajv.compile(schema);
if (!validate(doc)) {
for (const e of validate.errors) {
const params = e.params && Object.keys(e.params).length ? ' ' + JSON.stringify(e.params) : '';
errors.push(`schema ${e.instancePath || '/'} ${e.message}${params}`);
}
}
// ---- Static DAG checks (semantic; NOT in the schema — mirror validateDag) ----
function hasCycle(ids, edges) {
const adj = new Map(ids.map((id) => [id, []]));
for (const e of edges || []) {
if (e && adj.has(e.from) && adj.has(e.to)) adj.get(e.from).push(e.to);
}
const state = new Map(); // 0=unvisited,1=in-stack,2=done
const dfs = (u) => {
state.set(u, 1);
for (const v of adj.get(u) || []) {
if (state.get(v) === 1) return true;
if (!state.get(v) && dfs(v)) return true;
}
state.set(u, 2);
return false;
};
return ids.some((id) => !state.get(id) && dfs(id));
}
// Returns the worst-case path product of nested maxIterations within `nodes`.
function checkGraph(nodes = [], edges = [], depth = 1, where = 'nodes') {
if (!Array.isArray(nodes)) return 1;
const ids = [];
const seen = new Set();
for (const n of nodes) {
if (!n || typeof n !== 'object' || n.id == null) continue;
if (seen.has(n.id)) errors.push(`dag ${where} duplicate node id "${n.id}"`);
seen.add(n.id);
ids.push(n.id);
}
for (const e of edges || []) {
if (e?.from != null && !seen.has(e.from)) errors.push(`dag ${where} edge.from "${e.from}" is not a node id at this level`);
if (e?.to != null && !seen.has(e.to)) errors.push(`dag ${where} edge.to "${e.to}" is not a node id at this level`);
}
if (hasCycle(ids, edges)) errors.push(`dag ${where} graph is cyclic (edges must form a DAG)`);
let worst = 1;
for (const n of nodes) {
if (n?.type !== 'loop') continue;
if (depth >= 3 && n.body) errors.push(`dag loop "${n.id ?? '?'}" nesting depth exceeds the max of 3`);
const childProduct = checkGraph(n.body?.nodes, n.body?.edges, depth + 1, `loop "${n.id ?? '?'}".body`);
const factor = (typeof n.maxIterations === 'number' ? n.maxIterations : 1) * childProduct;
if (factor > 1000) errors.push(`dag loop "${n.id ?? '?'}" product of nested maxIterations (${factor}) exceeds 1000`);
worst = Math.max(worst, factor);
}
return worst;
}
if (doc && typeof doc === 'object') checkGraph(doc.nodes, doc.edges);
// ---- Report ------------------------------------------------------------------
if (errors.length === 0) {
console.log(`✓ ${file} — schema-valid (layer 1) and passes static DAG checks.`);
console.log(' Not checked offline: template:/workflow: refs are resolved org-scoped at APPLY');
console.log(' time (fail-closed if absent), and CEL semantics are evaluated at run time.');
console.log(' The apply (git-sync) is the definitive gate.');
process.exit(0);
}
console.error(`✗ ${file} — ${errors.length} problem(s):`);
for (const e of errors) console.error(` - ${e}`);
console.error('\nFix these, then re-run. (schema = layer-1 structure; dag = graph/loop semantics)');
process.exit(1);
SHA-256: c81d4549a8cfb7800d293313cda4b67ef6696cdda33753eb7cd7df51e8279b83