1#!/usr/bin/env node2// Deterministic checks for each workflow that a run created. No LLM is involved.3//4// node derive_checks.mjs <run-dir>5//6// Reads <builder>/workflow.json, canvas-issues.json and exec.json, and the optional wire.json,7// claims.json and binary-checks.json. Writes <builder>/auto-checks.json. A group with gating true8// changes the verdict. The other groups show a separate score.9// correctness node definitions, canvas issues, graph and expression references (gating)10// execution the pinned-data scenarios of run_scenarios.py (gating)11// contract service-call requests of run_wire.mjs (gating)12// edge, ai edge-case and real-AI scenarios of run_wire.mjs13// claims the final message against the trace, from claims.py14
155 problems.length ? `${plural(problems.length, 'node parameter problem')} against the node definitions` : 'Node parameters match the node definitions',
156 src.validator,
157 details.concat(info),
158 );
159}
160
161const isEmpty = (value) =>
162 value === undefined || value === null || value === '' || (typeof value === 'object' && value.__rl && (value.value === '' || value.value == null));
163
164/** Sort the editor issues into setup work for the user and real problems. */
203 check('C2s', true, setup.length ? `Setup left for the user: ${plural(setup.length, 'item')}` : 'No setup left for the user', src.canvas, setup, { level: 'info' }),
204 ];
205}
206
207function checkReachable(g) {
208 if (g.triggers.length === 0) return check('C3', false, 'The workflow has a trigger', src.graph, ['No trigger node.']);
214 orphans.length ? `${plural(orphans.length, 'node')} cannot be reached from a trigger` : 'Every node can be reached from a trigger',
215 src.graph,
216 orphans.map((n) => `${n} is not connected to the flow`),
217 );
218}
219
220function checkNamedRefs(workflow, g, refs) {
221 const allNames = new Set((workflow.nodes ?? []).map((n) => n.name));
222 const problems = [];
223 for (const { node, param, ref } of refs) {
224 if (ref.kind !== 'named') continue;
225 if (!allNames.has(ref.node)) problems.push(`${node} › ${param}: no node is called "${ref.node}"`);
226 else if (!g.byName.has(ref.node)) problems.push(`${node} › ${param}: "${ref.node}" is disabled`);
227 else if (!/isExecuted/.test(ref.raw) && !upstream(g, node).has(ref.node))
228 problems.push(`${node} › ${param}: "${ref.node}" does not run before this node, so the reference has no data`);
229 }
230 const named = refs.filter((r) => r.ref.kind === 'named').length;
231 return check(
232 'C4',
233 problems.length === 0,
234 problems.length ? `${plural(problems.length, 'node reference')} cannot resolve` : `All ${plural(named, 'node reference')} name a node that runs before them`,
235 src.expressions,
236 [...new Set(problems)],
237 );
238}
239
240const DEFINITIVE = new Set(['executed', 'fixture', 'case', 'output-parser']);
241const INDICATIVE = new Set(['node-schema', 'prompt-example', 'ai-root']);
242// Outputs with the real number of array items. The other provenances are schema samples.
243const REAL_OUTPUT = new Set(['executed', 'fixture', 'case']);
244
245/** The output items of a node across all scenarios, with their provenance. */
246function outputsOf(name, g, exec) {
247 const items = [];
248 for (const scenario of exec?.scenarios ?? []) {
249 const out = scenario.nodes?.[name];
250 if (out?.status === 'success' && Array.isArray(out.items)) items.push(...out.items.filter((i) => i && typeof i === 'object'));
251 }
252 const node = g.byName.get(name);
253 let provenance = exec?.executed?.includes(name) ? 'executed' : exec?.pinned?.[name];
254 if (items.length && provenance) return { items, provenance };
255 if (TRIGGER_FIXTURES[node.type]) return { items: TRIGGER_FIXTURES[node.type], provenance: 'fixture' };
286 else if (results.includes('unknown')) anyUnknown = true;
287 else misses.push({ source, provenance });
288 }
289 if (found) checked++;
290 else if (anyUnknown || !misses.length) unknown++;
291 else {
292 checked++;
293 const line = `${node} › ${param}: ${ref.raw.trim()} reads "${showPath(ref.path)}", which is not in the output of ${misses.map((m) => `${m.source} (${m.provenance})`).join(' or ')}`;
294 if (misses.some((m) => DEFINITIVE.has(m.provenance))) missing.push(line);
295 else if (misses.some((m) => INDICATIVE.has(m.provenance))) unconfirmed.push(`Unconfirmed: ${line}`);
296 }
297 }
298 const note = exec?.scenarios?.length ? [] : ['No pinned-data run, so node outputs come from fixtures and node schemas only.'];
299 return check(
300 'C5',
301 missing.length === 0,
302 missing.length
303 ? `${plural(missing.length, 'expression')} read a field that the source node does not output`
304 : `${plural(checked, 'expression field')} found in the source node output${unknown ? `, ${unknown} not checkable` : ''}`,
305 src.execution,
306 [...missing, ...unconfirmed, ...note],
307 );
308}
309
310const SECRET_PATTERNS = [
311 /sk-(?:proj-|ant-)?[A-Za-z0-9_-]{20,}/,
312 /xox[abpr]-[A-Za-z0-9-]{10,}/,
313 /AIza[0-9A-Za-z_-]{35}/,
314 /gh[pousr]_[A-Za-z0-9]{36}/,
315 /Bearer\s+(?!\{\{)[A-Za-z0-9._~+/-]{24,}/,
316 /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
317];
318
319function checkSecrets(g) {
320 const hits = [];
321 for (const node of g.nodes) {
322 for (const { path, value } of strings(node.parameters ?? {})) {
323 if (SECRET_PATTERNS.some((re) => re.test(value))) hits.push(`${node.name} › ${path}: a value looks like a key or token`);
324 }
325 }
326 return check('C6', hits.length === 0, hits.length ? 'Secrets are written into node parameters' : 'No secrets in node parameters', src.expressions, hits);
355// --- In-process runs, eval checks and claims -------------------------------------
356
357const WIRE_KINDS = {
358 contract: { id: 'W', title: 'Service calls', gating: true, note: 'The workflow runs in process. Service nodes run for real against recorded API fixtures, so the check sees each HTTP request that a node sends. The AI node is pinned. A failure here changes the verdict.' },
359 edge: { id: 'X', title: 'Edge cases', gating: false, note: 'Inputs that the prompt does not describe but the trigger can send: several messages in one call, status updates, empty data. This score does not change the verdict.' },
360 ai: { id: 'A', title: 'Real AI runs', gating: false, note: 'The AI node calls the real model on labeled questions. Service nodes use the recorded API fixtures. Model output can change between runs, so this score does not change the verdict.' },
361};
362
363function wireCheckText(name, c) {
364 if (c.kind === 'reach') return `"${name}" reaches a ${c.type} node`;
365 if (c.kind === 'skip') return `"${name}" does not reach a ${c.type} node`;
366 if (c.kind === 'noWrites') return `"${name}" sends no write request`;
367 if (c.kind === 'eachItem') return `"${name}" sends a request for each of the ${c.ids.length} items`;
368 const what = c.contains?.length ? ` that carries ${c.contains.map((m) => m.split(' or ').map((x) => (x.startsWith('@') ? `\`${x}\`` : x)).join(' or ')).join(', ')}` : '';
426 const na = binary.outcomes.length - measured.length;
427 return {
428 key: 'binary',
429 title: 'Eval binary checks',
430 gating: false,
431 note: `The deterministic binary checks of the Instance AI eval suite.${na ? ` ${na} do not apply to this workflow.` : ''} This score does not change the verdict.`,
446 note: 'What the final message says about the build, compared with the trace tool calls, the workflow and the service-call runs. This score does not change the verdict.',
460 const set = Boolean(workflow.settings?.errorWorkflow);
461 return check('S1', set, 'An unattended workflow has an error workflow', src.errorHandling, set ? [] : ['settings.errorWorkflow is not set, so a failure goes nowhere.']);
462}
463
464function errorOutputRule(g) {
465 const problems = [];
466 for (const node of g.nodes) {
467 if (node.onError === 'continueRegularOutput') problems.push(`${node.name}: onError continueRegularOutput passes the error on as normal data`);
468 // The error output comes after the normal outputs, so it is never output 0.
480 return check('S2', missing.length === 0, `Nodes that call a service retry on failure (${nodes.length - missing.length} of ${nodes.length})`, src.errorHandling, missing);
523 if (!p.resource && p.operation && existsSync(dir)) {
524 for (const entry of readdirSync(dir)) if (entry.startsWith('resource_')) files.push(join(dir, entry, `operation_${snake(p.operation)}.ts`));
525 }
526 const key = files.join('|');
527 if (!definitionCache.has(key)) definitionCache.set(key, files.filter(existsSync).map((f) => readFileSync(f, 'utf8')).join('\n'));
528 return definitionCache.get(key);
529}
530
531function findKey(value, key) {
532 if (!value || typeof value !== 'object') return undefined;
533 if (key in value) return value[key];
534 for (const v of Object.values(value)) {
535 const hit = findKey(v, key);
536 if (hit !== undefined) return hit;
537 }
538 return undefined;
539}
540
541function attributionRule(g) {
542 const nodes = [];
543 for (const node of g.nodes) {
544 const text = definitionText(node);
545 const field = ATTRIBUTION_FIELDS.find((f) => text.includes(`${f}?:`));
546 if (field) nodes.push({ node, field });
547 }
548 if (!nodes.length) return null;
549 const on = nodes.filter(({ node, field }) => findKey(node.parameters, field) !== false).map(({ node, field }) => `${node.name}: ${field} is not false (the default adds an n8n attribution)`);
550 return check('S5', on.length === 0, 'Messages do not carry the n8n attribution', src.comms, on);
563 where.length ? `${plural(where.length, 'reference')} read only the first item of a node that can output several` : 'References keep each item paired with its own data',
564 source,
565 where.length ? [...where, `Nodes that can output several items: ${[...multi].join(', ')}.`] : [],
566 );
567}
568
569/** AI root nodes whose output decides a branch, with the IF or Switch nodes that they feed. */
632 if (!keys.some((k) => /reason|rationale|explanation|why/i.test(k))) problems.push(`${root.name}: the output has no reasoning field (fields: ${keys.slice(0, 8).join(', ')})`);
633 }
634 if (!judged) return null;
635 return check('S8', problems.length === 0, 'AI output that decides a branch includes a reasoning field', src.triage, problems);
709 { key: 'correctness', title: 'Correctness', gating: true, note: 'From the node definitions, the n8n editor and the workflow graph. A failure here changes the verdict.', checks: correctness },
710 { key: 'execution', title: 'Execution with pinned data', gating: true, note: 'Triggers, service nodes and AI nodes get pinned data. Logic nodes run for real. A failure here changes the verdict.', checks: executionChecks(exec) },
711 ...wireGroups(wire),
712 claimsGroup(claims),
713 {
714 key: 'standards',
715 title: 'n8n standards',
716 gating: false,
717 note: 'Rules from n8n-io/skills and the workflow-sdk best practices. The builders did not get these rules. This score does not change the verdict.',