diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index 69ee2c5d7..69e91e6fc 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -18,6 +18,7 @@ "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "js-yaml": "^5.4.1", + "re2js": "^2.8.6", "yaml": "^2.5.1" }, "bin": { @@ -2659,6 +2660,15 @@ "node": ">= 0.10" } }, + "node_modules/re2js": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-2.8.6.tgz", + "integrity": "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index d42192835..aabb9d864 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -51,6 +51,7 @@ "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "js-yaml": "^5.4.1", + "re2js": "^2.8.6", "yaml": "^2.5.1" }, "devDependencies": { diff --git a/packages/sdk/src/compile.ts b/packages/sdk/src/compile.ts index cc1eb1739..8a861a765 100644 --- a/packages/sdk/src/compile.ts +++ b/packages/sdk/src/compile.ts @@ -17,6 +17,8 @@ import { parse as parseYaml } from 'yaml'; import { parseBudget, toKernelBudget } from './budget.js'; import { bindingDependencies } from './input-binding.js'; +import { namedGateFailure } from './named-gates.js'; +import { lowerNamedGates } from './named-gate-lowering.js'; import type { AgentStepSpec, DeterministicStepSpec, @@ -146,7 +148,7 @@ export function compileSpec(spec: unknown): CompiledFlowSpec { } } const validation: ValidationResult = validateSpec(snapshot); - if (!validation.ok) throw new CompileError(validation.errors); + if (!validation.ok) throw new CompileError(validation.errors, namedGateFailure(validation.errors)); const input = snapshot as CompiledFlowSpec; // Preserve named declarations and selectors through authoring normalization. @@ -322,7 +324,7 @@ export function toKernelSpec(flow: FlowSpec): KernelRunSpec { // reverts the other, and `validateSpec` and `flows check` would both still // look correct. See ops/reviews/20260903-pr139-repair-0903.md section 10. ...(compiled.triggers?.length ? { triggers: compiled.triggers.map(toKernelTrigger) } : {}), - steps: compiled.steps.map((step) => toKernelStep(resolveNamedAgent(step, compiled.agents))), + steps: lowerNamedGates(compiled.steps).map((step) => toKernelStep(resolveNamedAgent(step, compiled.agents))), ...(compiled.budget !== undefined ? { budget: toKernelBudget(compiled.budget) } : {}), }; } diff --git a/packages/sdk/src/failure-kinds.ts b/packages/sdk/src/failure-kinds.ts index a2be476a4..7b2917d9c 100644 --- a/packages/sdk/src/failure-kinds.ts +++ b/packages/sdk/src/failure-kinds.ts @@ -1,8 +1,10 @@ import { PLUGIN_FAILURE_KINDS } from './plugin-manifest.js'; +import { NAMED_GATE_FAILURE_KINDS } from './named-gates.js'; const SHARED_SPEC_FAILURE_KINDS = ['invalid_spec'] as const; /** Environment refusal kinds produced after spec validation succeeds. */ const PREFLIGHT_ENVIRONMENT_FAILURE_KINDS = [ + ...NAMED_GATE_FAILURE_KINDS, ...PLUGIN_FAILURE_KINDS, 'helper_slack.credential_missing', 'helper_slack.mount_required', diff --git a/packages/sdk/src/gate-contract.ts b/packages/sdk/src/gate-contract.ts index 9596b2c7a..cc45be5ae 100644 --- a/packages/sdk/src/gate-contract.ts +++ b/packages/sdk/src/gate-contract.ts @@ -1,4 +1,5 @@ import type { StepSpec } from './spec.js'; +import { isNamedGate } from './named-gates.js'; export type JournalGateCheck = | 'completion' @@ -54,6 +55,12 @@ export function acceptsAnyOutput(schema: unknown): boolean { /** Describe the exact named checks the existing kernel applies to a step. */ export function inspectStepGate(step: StepSpec): StepGateInspection { const checks: JournalGateCheck[] = []; + if (isNamedGate(step.verification)) { + checks.push('exit_code'); + if (step.verification.type === 'references_input') checks.push('output_contains'); + if (step.verification.type === 'word_count_bounds') checks.push('json_schema'); + return { stepId: step.id, kind: 'data', checks, evaluator: 'kernel', preflightable: true, replayable: true }; + } if (step.type === 'deterministic') checks.push('exit_code'); if (step.verification?.type === 'output_contains') checks.push('output_contains'); if (step.verification?.type === 'json_schema') checks.push('json_schema'); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 603844374..b3ead2c60 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -33,6 +33,11 @@ export type { KernelVerificationSpec, LlmStepSpec, NamedAgentSpec, + NamedDataGate, + ReferencesInputGate, + SubprocessGate, + WordCountBoundsGate, + RegexMatchGate, OutputContainsGate, OutputBinding, OutputVerificationSpec, diff --git a/packages/sdk/src/named-gate-lowering.ts b/packages/sdk/src/named-gate-lowering.ts new file mode 100644 index 000000000..750ce9289 --- /dev/null +++ b/packages/sdk/src/named-gate-lowering.ts @@ -0,0 +1,147 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { deflateRawSync } from 'node:zlib'; +import { bindingDependencies } from './input-binding.js'; +import { isNamedGate, regexFlags } from './named-gates.js'; +import type { NamedDataGate, StepSpec, VerificationSpec } from './spec.js'; + +const quote = (text: string): string => `'${text.replaceAll("'", "'\\''")}'`; +let re2Source: string | undefined; + +/** Embed the pinned engine in the command: execution does not resolve npm or SDK paths. */ +function embeddedRE2(): string { + re2Source ??= deflateRawSync(readFileSync(createRequire(import.meta.url).resolve('re2js'))).toString('base64'); + return `const re2={};new Function('exports',require('node:zlib').inflateRawSync(Buffer.from(${JSON.stringify(re2Source)},'base64')).toString())(re2);`; +} + +/** + * A gate is a nested deterministic step in the same journal/DAG. Keep the + * producer's identity/output, and add a barrier to every dependent (including + * implicit input edges). Internal bindings select the whole envelope so no + * invented output-path schema is needed. Missing paths fail in the gate. + */ +export function lowerNamedGates(steps: readonly StepSpec[]): StepSpec[] { + const used = new Set(steps.map(step => step.id)); + const barriers = new Map(); + for (const step of steps) { + if (!isNamedGate(step.verification)) continue; + let id = `${step.id}.gate`; + while (used.has(id)) id += '.gate'; + used.add(id); + barriers.set(step.id, id); + } + if (barriers.size === 0) return [...steps]; + const output: StepSpec[] = []; + for (const step of steps) { + const dependencies = [...new Set([...(step.dependsOn ?? []), ...bindingDependencies(step.input)])]; + const dependsOn = [...new Set([...dependencies, ...dependencies.flatMap(id => barriers.get(id) ?? [])])]; + const producer = dependsOn.length ? { ...step, dependsOn } : step; + const gate = step.verification; + if (!isNamedGate(gate)) { + output.push(producer); + continue; + } + // The original output is still checked for a successful process/worker + // completion. This schema permits an internal whole-output binding. + output.push({ ...producer, verification: { type: 'json_schema', schema: true } }); + const input = { + output: { step: step.id }, + ...(gate.type === 'references_input' ? { reference: step.input![gate.input_key]! } : {}), + }; + output.push({ + id: barriers.get(step.id)!, type: 'deterministic', + dependsOn: [...new Set([step.id, ...bindingDependencies(input)])], input, + command: gateCommand(gate, step.type === 'deterministic'), + verification: gateVerification(gate), maxIterations: step.maxIterations ?? 1, + ...(step.requirements === undefined ? {} : { requirements: step.requirements }), + }); + } + return output; +} + +function gateCommand(gate: NamedDataGate, deterministic: boolean): string { + const path = gate.type === 'subprocess_gate' ? gate.from_output + : gate.type === 'word_count_bounds' ? undefined : gate.in_output_at; + // Only compiler-owned code is serialized. Author strings are JSON literals; + // upstream output travels exclusively through FLOWS_INPUT, never shell text. + const setup = `const cp=require('node:child_process'); +const input=JSON.parse(process.env.FLOWS_INPUT); +let value=input.output; +const path=${JSON.stringify(path ?? null)}; +if(path!==null){for(const key of path){ +if(value===null||typeof value!=='object'||!Object.hasOwn(value,key)|| +(typeof key==='number'?!Array.isArray(value):Array.isArray(value)))process.exit(1); +value=value[key]; +}}else if(${deterministic})value=value.stdout_tail; +const text=typeof value==='string'?value:JSON.stringify(value); +if(typeof text!=='string')process.exit(1); +`; + let body: string; + switch (gate.type) { + case 'references_input': + body = `const reference=input.reference; +if(typeof reference!=='string'||reference.length===0||!text.includes(reference))process.exit(1); +process.stdout.write('references_input:pass');`; + break; + case 'subprocess_gate': + body = `if(text.includes('\\0'))process.exit(1); +const result=cp.spawnSync('/bin/sh',['-c',${JSON.stringify(gate.command)}],{ +env:{...process.env,INPUT:text},stdio:'inherit'}); +process.exit(result.status===0?0:1);`; + break; + case 'word_count_bounds': + body = `const result=cp.spawnSync('wc',['-w'],{input:text,encoding:'utf8',env:{...process.env,LC_ALL:'C'}}); +if(result.status!==0)process.exit(1); +const count=result.stdout.trim(); +if(!/^[0-9]+$/.test(count))process.exit(1); +process.stdout.write(BigInt(count).toString());`; + break; + case 'regex_match': + body = `${embeddedRE2()} +process.exit(re2.RE2JS.compile(${JSON.stringify(gate.pattern)},${regexFlags(gate.flags ?? '')}).matcher(text).find()?0:1);`; + break; + } + return `node -e ${quote(setup + body)}`; +} + +function gateVerification(gate: NamedDataGate): VerificationSpec { + if (gate.type === 'references_input') { + // output_contains cannot carry a runtime binding. The command resolves and + // checks the literal substring, then emits this fixed, unforgeable receipt. + return { type: 'output_contains', value: 'references_input:pass' }; + } + if (gate.type === 'word_count_bounds') { + // Deterministic output is an envelope with STRING stdout, not parsed JSON. + // A bounded decimal language enforces the same inclusive numeric range + // with the existing json_schema primitive and retains the actual count. + return { type: 'json_schema', schema: { + type: 'object', required: ['stdout_tail'], properties: { + stdout_tail: { type: 'string', pattern: decimalRange(gate.min ?? 0, gate.max ?? Number.MAX_SAFE_INTEGER) }, + }, + } }; + } + return { type: 'exit_code' }; +} + +/** Compact decimal range, bounded by 16 digits rather than by range width. */ +function decimalRange(min: number, max: number): string { + const patterns: string[] = []; + function between(low: string, high: string, prefix: string): void { + if (low === high) { patterns.push(prefix + low); return; } + if (/^0+$/.test(low) && /^9+$/.test(high)) { + patterns.push(`${prefix}[0-9]{${low.length}}`); return; + } + const a = Number(low[0]), b = Number(high[0]); + if (a === b) { between(low.slice(1), high.slice(1), prefix + a); return; } + const rest = low.length - 1; + between(low.slice(1), '9'.repeat(rest), prefix + a); + if (a + 1 <= b - 1) patterns.push(`${prefix}[${a + 1}-${b - 1}]${rest ? `[0-9]{${rest}}` : ''}`); + between('0'.repeat(rest), high.slice(1), prefix + b); + } + for (let digits = String(min).length; digits <= String(max).length; digits++) { + const low = digits === String(min).length ? String(min) : '1' + '0'.repeat(digits - 1); + const high = digits === String(max).length ? String(max) : '9'.repeat(digits); + between(low, high, ''); + } + return `^(?:${patterns.join('|')})$`; +} diff --git a/packages/sdk/src/named-gates.ts b/packages/sdk/src/named-gates.ts new file mode 100644 index 000000000..01d61c18e --- /dev/null +++ b/packages/sdk/src/named-gates.ts @@ -0,0 +1,76 @@ +import { RE2JS } from 're2js'; +import type { NamedDataGate, VerificationSpec } from './spec.js'; + +export const NAMED_GATE_FAILURE_KINDS = [ + 'unknown_gate_kind', 'gate_pattern_invalid', 'gate_command_missing', 'gate_bound_invalid', +] as const; +export type NamedGateFailureKind = typeof NAMED_GATE_FAILURE_KINDS[number]; + +export const NAMED_GATE_KEYS: Record = { + references_input: ['type', 'input_key', 'in_output_at'], + subprocess_gate: ['type', 'command', 'from_output'], + word_count_bounds: ['type', 'min', 'max'], + regex_match: ['type', 'pattern', 'in_output_at', 'flags'], +}; + +export function isNamedGate(gate: VerificationSpec | undefined): gate is NamedDataGate { + return gate !== undefined && Object.hasOwn(NAMED_GATE_KEYS, gate.type); +} + +export function regexFlags(flags: string): number { + return (flags.includes('i') ? RE2JS.CASE_INSENSITIVE : 0) + | (flags.includes('m') ? RE2JS.MULTILINE : 0) + | (flags.includes('s') ? RE2JS.DOTALL : 0); +} + +/** Called only on snapshotted data, including by the public validator. */ +export function namedGateErrors(gate: Record, input: unknown, at: string): string[] { + const errors: string[] = []; + for (const key of ['in_output_at', 'from_output']) { + const path = gate[key]; + if (path !== undefined && (!Array.isArray(path) || !path.every(segment => + typeof segment === 'string' || (Number.isSafeInteger(segment) && (segment as number) >= 0)))) { + errors.push(`${at}.${key}: expected an array of object keys or non-negative integer indices`); + } + } + switch (gate.type) { + case 'references_input': + if (typeof gate.input_key !== 'string' || !gate.input_key.trim() + || input === null || typeof input !== 'object' || !Object.hasOwn(input, gate.input_key)) { + errors.push(`${at}.input_key: must name a declared input binding`); + } + break; + case 'subprocess_gate': + if (typeof gate.command !== 'string' || !gate.command.trim() || gate.command.includes('\0')) { + errors.push(`${at}.command: gate_command_missing: expected a non-empty shell command`); + } + break; + case 'word_count_bounds': { + const { min, max } = gate; + if ([min, max].some(n => n !== undefined && (!Number.isSafeInteger(n) || (n as number) < 0)) + || (typeof min === 'number' && typeof max === 'number' && min > max)) { + errors.push(`${at}: gate_bound_invalid: min and max must be non-negative safe integers with min <= max`); + } + break; + } + case 'regex_match': + try { + if (typeof gate.pattern !== 'string' || gate.pattern.length > 8192) { + throw new Error('pattern must be a string of at most 8192 characters'); + } + const flags = gate.flags ?? ''; + if (typeof flags !== 'string' || !/^(?!.*(.).*\1)[ims]*$/.test(flags)) { + throw new Error('flags must be a non-repeating subset of i, m, s'); + } + RE2JS.compile(gate.pattern, regexFlags(flags)); + } catch (error) { + errors.push(`${at}: gate_pattern_invalid: ${error instanceof Error ? error.message : 'invalid RE2 pattern'}`); + } + break; + } + return errors; +} + +export function namedGateFailure(errors: readonly string[]): NamedGateFailureKind | undefined { + return NAMED_GATE_FAILURE_KINDS.find(kind => errors.some(error => error.includes(`${kind}:`))); +} diff --git a/packages/sdk/src/preflight.ts b/packages/sdk/src/preflight.ts index 69c67bf64..069bd2135 100644 --- a/packages/sdk/src/preflight.ts +++ b/packages/sdk/src/preflight.ts @@ -8,6 +8,7 @@ import type { TriggerSource } from '@relayflows/surface'; import { acceptsAnyOutput, inspectStepGate, type StepGateInspection } from './gate-contract.js'; import { compileSpec, CompileError } from './compile.js'; import { helperCall } from './yaml-helpers.js'; +import { isNamedGate, NAMED_GATE_FAILURE_KINDS, type NamedGateFailureKind } from './named-gates.js'; import type { PreflightFailureKind, PreflightWarningKind, @@ -187,6 +188,8 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul // constructs preflight input through a different path still classifies. const kind: PreflightDiagnostic['kind'] = error instanceof CompileError && error.kind === 'budget_syntax_invalid' ? 'budget_syntax_invalid' + : error instanceof CompileError && NAMED_GATE_FAILURE_KINDS.includes(error.kind as NamedGateFailureKind) + ? error.kind as NamedGateFailureKind : error instanceof BudgetSyntaxError ? 'budget_syntax_invalid' : 'invalid_spec'; @@ -238,6 +241,7 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul } for (const step of compiled.steps) { + probeNamedGate(step, options.probes, diagnostics); warnOnVacuousGate(step, diagnostics); warnOnUnprovableEffects(step, options.probes, diagnostics); if (step.type === 'deterministic') continue; @@ -589,6 +593,20 @@ function firstCommandWord(command: string): string | undefined { return match?.[1] ?? match?.[2] ?? match?.[3]; } +function probeNamedGate(step: StepSpec, probes: PreflightProbes, diagnostics: PreflightDiagnostic[]): void { + const gate = step.verification; + if (!isNamedGate(gate)) return; + const commands = ['node', ...(gate.type === 'word_count_bounds' ? ['wc'] : [])]; + if (gate.type === 'subprocess_gate') commands.push(firstCommandWord(gate.command) ?? ''); + for (const command of commands) { + let exists = false; + try { exists = command !== '' && probes.command(command); } catch { /* Fail closed on an unprovable gate. */ } + if (exists) continue; + diagnostics.push({ severity: 'refusal', kind: 'gate_command_missing', stepId: step.id, + message: `Step "${step.id}" ${gate.type} command "${command}" does not resolve as an executable.` }); + } +} + /** Helper preflight never evaluates the authored body. Dynamic uses are checked at call time. */ export function preflightHelpers( definition: { header?: { tools?: { slack?: boolean } }; body?: Function }, diff --git a/packages/sdk/src/spec.ts b/packages/sdk/src/spec.ts index 42c072019..f9658df0c 100644 --- a/packages/sdk/src/spec.ts +++ b/packages/sdk/src/spec.ts @@ -32,7 +32,7 @@ export interface FlowsJson { * Verification is control flow, not decoration (kernel DESIGN.md ยง3). * v0 gates are deterministic so verification is kernel-side and replayable. */ -export type VerificationGateType = 'exit_code' | 'output_contains' | 'json_schema'; +export type VerificationGateType = VerificationSpec['type']; /** * `exit_code == 0` โ€” the implicit gate for deterministic steps. v0 judges @@ -56,8 +56,35 @@ export interface JsonSchemaGate { schema: boolean | Record; } -export type VerificationSpec = ExitCodeGate | OutputContainsGate | JsonSchemaGate; -export type OutputVerificationSpec = OutputContainsGate | JsonSchemaGate; +export interface ReferencesInputGate { + type: 'references_input'; + input_key: string; + in_output_at?: Array; +} + +export interface SubprocessGate { + type: 'subprocess_gate'; + command: string; + from_output?: Array; +} + +export interface WordCountBoundsGate { + type: 'word_count_bounds'; + min?: number; + max?: number; +} + +export interface RegexMatchGate { + type: 'regex_match'; + pattern: string; + in_output_at?: Array; + /** Only i, m, and s; evaluated by a non-backtracking RE2 engine. */ + flags?: string; +} + +export type NamedDataGate = ReferencesInputGate | SubprocessGate | WordCountBoundsGate | RegexMatchGate; +export type OutputVerificationSpec = OutputContainsGate | JsonSchemaGate | NamedDataGate; +export type VerificationSpec = ExitCodeGate | OutputVerificationSpec; /** * Agent-step recovery modes (RFC Appendix A rule 4). Default is `reset`. diff --git a/packages/sdk/src/validate.ts b/packages/sdk/src/validate.ts index 34e52904f..9d12ccdfe 100644 --- a/packages/sdk/src/validate.ts +++ b/packages/sdk/src/validate.ts @@ -22,6 +22,7 @@ import { modelNameError } from './model-name.js'; import { unknownKeyErrors } from './unknown-keys.js'; import { stepDependencyErrors } from './step-dependencies.js'; import { inputBindingErrors } from './input-binding.js'; +import { NAMED_GATE_KEYS, namedGateErrors } from './named-gates.js'; import { AGENT_DECLARATION_FIELDS, FLOW_FIELDS, @@ -71,6 +72,7 @@ function isCanonicalPathSurface(value: unknown): value is string { // silently discarded field โ€” silently dropping `dependsOn` loses ordering. const BUDGET_KEYS = ['maxTokensIn', 'maxTokensOut', 'maxDollars', 'maxTokens', 'maxWallclockMs', 'window', 'pricing'] as const; const VERIFICATION_KEYS: Record = { + ...NAMED_GATE_KEYS, exit_code: ['type', 'expect'], output_contains: ['type', 'value'], json_schema: ['type', 'schema'], @@ -363,7 +365,7 @@ class Validator { } if (st['verification'] !== undefined) { - this.validateVerification(st['verification'], `${at}.verification`, type); + this.validateVerification(st['verification'], `${at}.verification`, type, st['input']); } if (st['maxIterations'] !== undefined && !isPosInt(st['maxIterations'])) { @@ -381,13 +383,14 @@ class Validator { } } - private validateVerification(v: unknown, at: string, stepType: StepType): void { + private validateVerification(v: unknown, at: string, stepType: StepType, input: unknown): void { if (!isObject(v)) { this.fail(`${at}: expected an object`); return; } const gate = v as unknown as VerificationSpec & { expect?: unknown }; - const gateKeys = typeof gate.type === 'string' ? VERIFICATION_KEYS[gate.type] : undefined; + const gateKeys = typeof gate.type === 'string' && Object.hasOwn(VERIFICATION_KEYS, gate.type) + ? VERIFICATION_KEYS[gate.type] : undefined; if (gateKeys !== undefined) { this.checkKeys(v, gateKeys, at); } @@ -414,8 +417,10 @@ class Validator { } catch (error) { this.fail(error instanceof Error ? error.message : `${at}.schema: expected JSON-compatible data`); } + } else if (Object.hasOwn(NAMED_GATE_KEYS, gate.type)) { + for (const error of namedGateErrors(v, input, at)) this.fail(error); } else { - this.fail(`${at}.type: expected exit_code | output_contains | json_schema`); + this.fail(`${at}.type: unknown_gate_kind: expected exit_code | output_contains | json_schema | references_input | subprocess_gate | word_count_bounds | regex_match`); } } diff --git a/packages/sdk/tests/preflight.test.ts b/packages/sdk/tests/preflight.test.ts index 5670ea2e9..501a0d9c3 100644 --- a/packages/sdk/tests/preflight.test.ts +++ b/packages/sdk/tests/preflight.test.ts @@ -108,17 +108,17 @@ describe('preflight: CLI resolution and refusal predicates', () => { } as FlowSpec['steps'][number]], }); - for (const verification of [ - (value: unknown) => value, - { type: 'expression', expression: 'length < 200' }, - { type: 'json_schema', schema: { type: 'definitely-not-a-json-schema-type' } }, + for (const { verification, kind } of [ + { verification: (value: unknown) => value, kind: 'invalid_spec' as const }, + { verification: { type: 'expression', expression: 'length < 200' }, kind: 'unknown_gate_kind' as const }, + { verification: { type: 'json_schema', schema: { type: 'definitely-not-a-json-schema-type' } }, kind: 'invalid_spec' as const }, ]) { const result = preflight(candidate(verification), { probes: injected }); expect(result).toMatchObject({ ok: false, gates: [], resolutions: [], - diagnostics: [{ severity: 'refusal', kind: 'invalid_spec' }], + diagnostics: [{ severity: 'refusal', kind }], }); } expect(probeCount).toBe(0); @@ -395,6 +395,19 @@ describe('preflight: CLI resolution and refusal predicates', () => { compileYaml('version: 0.1.0\nsteps:\n - id: notify\n slack:\n post:\n channel: "#test"\n text: hi\n'), { probes: probes({ helper: () => false }) }, ), + // Named-data-gate refusal coverage: each of the four kinds surfaces + // through the public preflight boundary โ€” three are compile-time + // (`unknown_gate_kind`, `gate_pattern_invalid`, `gate_bound_invalid`) + // and one is probe-time (`gate_command_missing`). + preflight(flow({ id: 'a', type: 'llm', prompt: 'p', cli: 'x', + verification: { type: 'not_a_real_gate' } as never }), { probes: probes() }), + preflight(flow({ id: 'a', type: 'llm', prompt: 'p', cli: 'x', + verification: { type: 'regex_match', pattern: '(?=lookahead)' } }), { probes: probes() }), + preflight(flow({ id: 'a', type: 'llm', prompt: 'p', cli: 'x', + verification: { type: 'word_count_bounds', min: 100, max: 10 } }), { probes: probes() }), + preflight(flow({ id: 'a', type: 'llm', prompt: 'p', cli: 'x', + verification: { type: 'subprocess_gate', command: '/nonexistent-gate-binary-xxx' } }), + { probes: probes({ command: (c) => c !== '/nonexistent-gate-binary-xxx' }) }), ]; const refusalKinds = scenarios.flatMap((result) => result.diagnostics) .filter((diagnostic) => diagnostic.severity === 'refusal') diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index 634bbdbf6..b20bf50f0 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -28,7 +28,8 @@ "tests/journal-client-loopback.ts", "tests/close-pr-flow.test.ts", "tests/direct-input.test.ts", - "tests/fixtures/needs-human.flow.ts" + "tests/fixtures/needs-human.flow.ts", + "tests/named-gates.test.ts" ], "exclude": ["node_modules", "dist"] }