From c194e5b80a367fdf51654507810ce8a18e9eef28 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 20:42:16 +0900 Subject: [PATCH 1/5] feat(interactive): add P0-A feature gate and resumable IR lowering --- src/compiler/interactive/feature-gate.mjs | 57 ++++++++ src/compiler/interactive/lower.mjs | 93 ++++++++++++++ src/compiler/interactive/validate-ir.mjs | 150 ++++++++++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 src/compiler/interactive/feature-gate.mjs create mode 100644 src/compiler/interactive/lower.mjs create mode 100644 src/compiler/interactive/validate-ir.mjs diff --git a/src/compiler/interactive/feature-gate.mjs b/src/compiler/interactive/feature-gate.mjs new file mode 100644 index 0000000..79d54fe --- /dev/null +++ b/src/compiler/interactive/feature-gate.mjs @@ -0,0 +1,57 @@ +/** + * M1-only Interactive feature gate. The Standard analyzer remains the single + * authority for DSL syntax, graph, dataflow, and existing FK_C/FK_R codes. + * Runtime binding checks (including checkStep) are enforced at start in M4. + */ +export const IMPLEMENTED_INTERACTIVE_STAGE = 'P0-A'; +const STAGES = Object.freeze(['P0-A', 'P0-B', 'P1']); +const ENABLED = new Set(['condition', 'terminal', 'agent']); + +function interactiveDiagnostic(analysis, code, message, index, node) { + const location = index === undefined ? { line: 1, column: 1 } + : analysis.literal.locations.get('/nodes/' + index) ?? { line: 1, column: 1 }; + return Object.freeze({ + code, severity: 'error', message, file: analysis.snapshot.absFilename, + line: location.line, column: location.column, + ...(node ? { nodeId: node.id } : {}), + }); +} + +/** Return the original analyzer diagnostics unchanged, appending only stage-gate errors. */ +export function gateInteractiveFeatures(analysis, options = {}) { + if (!analysis || !analysis.snapshot || !Array.isArray(analysis.diagnostics)) { + throw new TypeError('A completed analyzeSource() result is required'); + } + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw new TypeError('Interactive feature options must be an object'); + } + const featureStage = options.featureStage ?? IMPLEMENTED_INTERACTIVE_STAGE; + const extras = []; + if (!STAGES.includes(featureStage)) { + extras.push(interactiveDiagnostic(analysis, 'FK_I_INVALID_STAGE', + 'Unknown Interactive feature stage: ' + String(featureStage))); + } else if (featureStage !== IMPLEMENTED_INTERACTIVE_STAGE) { + extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_STAGE', + 'This build implements only P0-A; caller cannot enable ' + featureStage)); + } + + // Never lower partially validated DSL, and never replace Standard diagnostics. + const standardValid = !!analysis.graph && !!analysis.literal && !!analysis.dataflow + && !analysis.diagnostics.some(item => item.severity === 'error'); + if (standardValid) { + analysis.literal.definition.nodes.forEach((node, index) => { + if (!ENABLED.has(node.type)) { + extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_NODE', + 'P0-A does not support node type ' + String(node.type), index, node)); + } else if (node.type === 'agent' && node.verify != null) { + extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_BINDING', + 'P0-A Agent verify requires P0-B; it must not be skipped', index, node)); + } + }); + } + const diagnostics = Object.freeze([...analysis.diagnostics, ...extras]); + return Object.freeze({ + ok: standardValid && extras.length === 0, + featureStage, diagnostics, + }); +} diff --git a/src/compiler/interactive/lower.mjs b/src/compiler/interactive/lower.mjs new file mode 100644 index 0000000..e73524c --- /dev/null +++ b/src/compiler/interactive/lower.mjs @@ -0,0 +1,93 @@ +import { gateInteractiveFeatures } from './feature-gate.mjs'; +import { P0A_PHASES, interactiveBlockId, phaseContract, expectedBlockTargets, validateInteractiveIR } from './validate-ir.mjs'; + +function copyData(value) { + // Analyzer-owned DSL literals/when ASTs are JSON-safe. Preserve own __proto__ + // keys as data and do not resolve any missing runtime input to null. + return JSON.parse(JSON.stringify(value)); +} +function deepFreeze(value) { + if (value !== null && typeof value === 'object') { + for (const key of Object.keys(value)) deepFreeze(value[key]); + Object.freeze(value); + } + return value; +} + +/** + * Lower only fully validated ORIGINAL FlowKit analysis, not Stage2's parser. + * Pure compile-time output: no filesystem writes, calls to models, or runtime IO. + * The M2 emitter will turn this explicit IR into Program ABI V1 JavaScript. + */ +export function lowerInteractive(analysis, options = {}) { + const gate = gateInteractiveFeatures(analysis, options); + if (!gate.ok) return Object.freeze({ ir: null, diagnostics: gate.diagnostics }); + const definitions = analysis.literal.definition.nodes; + const nodes = Object.create(null); + for (const [index, node] of definitions.entries()) { + if (!analysis.graph.byId.has(node.id)) { + throw new Error('Validated graph is missing node ' + node.id); + } + let metadata; + if (node.type === 'condition') { + metadata = { + name: node.name, + branches: node.branches.map((branch, branchIndex) => { + const pointer = '/nodes/' + index + '/branches/' + branchIndex + '/when'; + const ast = analysis.conditions.get(pointer); + if (!ast) throw new Error('Missing validated when AST: ' + pointer); + return { when: branch.when, ast: copyData(ast), next: branch.next }; + }), + }; + } else if (node.type === 'agent') { + metadata = { + name: node.name, next: node.next, + ...(node.agent === undefined ? {} : { agent: node.agent }), + ...(node.skill === undefined ? {} : { skill: node.skill }), + ...(node.integrity === undefined ? {} : { integrity: node.integrity }), + inputs: node.inputs ?? [], output: node.output, + retries: node.retries ?? 2, overview: node.overview === true, + }; + } else { + metadata = { name: node.name, status: node.status }; + } + nodes[node.id] = { kind: node.type, metadata: copyData(metadata) }; + } + const first = definitions[0].id; + const ir = { + schemaVersion: 1, featureStage: 'P0-A', + workflowId: analysis.literal.definition.id, + workflowVersion: analysis.literal.definition.version, + sourceHash: analysis.snapshot.sourceHash, + entryBlockId: interactiveBlockId(first, 'enter'), + nodes, blocks: [], + // Preserve Standard dataflow as diagnostic evidence; it is not a second + // source of routing and it is not used to prefill missing Agent inputs. + dataflow: Object.fromEntries(definitions.map(node => [node.id, { + reachingKeys: [...(analysis.dataflow.inputs.get(node.id) ?? [])].sort(), + possibleOutputKeys: [...(analysis.dataflow.outputs.get(node.id) ?? [])].sort(), + }])), + graphEdges: analysis.graph.executionEdges.map(edge => ({ + from: edge.from, to: edge.to, role: edge.role, + })), + }; + for (const node of definitions) { + for (const phase of P0A_PHASES[node.type]) { + const contract = phaseContract(node.type, phase); + const block = { + id: interactiveBlockId(node.id, phase), + nodeId: node.id, phase, + legalTargets: expectedBlockTargets(ir, node.id, phase), + eventKinds: contract.eventKinds, + proposalKinds: contract.proposalKinds, + ...(phase === 'awaiting' ? { acceptTarget: interactiveBlockId(node.id, 'validate') } : {}), + }; + ir.blocks.push(block); + } + } + const checked = validateInteractiveIR(ir); + if (!checked.ok) { + return Object.freeze({ ir: null, diagnostics: Object.freeze([...gate.diagnostics, ...checked.diagnostics]) }); + } + return Object.freeze({ ir: deepFreeze(ir), diagnostics: gate.diagnostics }); +} diff --git a/src/compiler/interactive/validate-ir.mjs b/src/compiler/interactive/validate-ir.mjs new file mode 100644 index 0000000..c614603 --- /dev/null +++ b/src/compiler/interactive/validate-ir.mjs @@ -0,0 +1,150 @@ +/** + * P0-A resumable IR phase matrix. The lowerer and its validator share this + * table; the Coordinator must NOT reconstruct graph routing from source DSL. + */ +export const P0A_PHASES = Object.freeze({ + condition: Object.freeze(['enter', 'route']), + terminal: Object.freeze(['enter', 'route']), + agent: Object.freeze(['enter', 'prepare', 'dispatch-ready', 'awaiting', 'validate', 'commit', 'route']), +}); + +const RULES = Object.freeze({ + condition: Object.freeze({ + enter: [['internal-step', 'deterministic-failure'], ['move', 'raise-error']], + route: [['internal-step', 'deterministic-failure'], ['move', 'raise-error']], + }), + terminal: Object.freeze({ + enter: [['internal-step', 'deterministic-failure'], ['move', 'raise-error']], + route: [['internal-step', 'deterministic-failure'], ['terminal', 'raise-error']], + }), + agent: Object.freeze({ + enter: [['internal-step', 'deterministic-failure'], ['move', 'raise-error']], + prepare: [['internal-step', 'pending-prepared', 'deterministic-failure'], ['prepare-pending', 'raise-error']], + 'dispatch-ready': [['pending-prepared', 'deterministic-failure'], ['publish-pending', 'raise-error']], + awaiting: [[], []], // Coordinator accepts a receipt transaction; advance() is never called. + validate: [ + ['agent-result', 'agent-call-failed', 'validation-passed', 'validation-rejected', 'deterministic-failure'], + ['retry-agent', 'commit-candidate', 'raise-error'], + ], + commit: [['internal-step', 'deterministic-failure'], ['commit-state', 'raise-error']], + route: [['internal-step', 'deterministic-failure'], ['move', 'raise-error']], + }), +}); + +export const interactiveBlockId = (nodeId, phase) => nodeId + '::' + phase; + +export function phaseContract(kind, phase) { + const record = RULES[kind]?.[phase]; + if (!record) return null; + return { eventKinds: [...record[0]], proposalKinds: [...record[1]] }; +} + +/** This derives edges from already-lowered node metadata, not from the DSL parser. */ +export function expectedBlockTargets(ir, nodeId, phase) { + const record = ir.nodes?.[nodeId]; + if (!record) return null; + const metadata = record.metadata; + const target = (id, p) => interactiveBlockId(id, p); + if (phase === 'enter') return [target(nodeId, record.kind === 'agent' ? 'prepare' : 'route')]; + if (record.kind === 'agent') { + if (phase === 'prepare') return [target(nodeId, 'dispatch-ready')]; + if (phase === 'dispatch-ready') return [target(nodeId, 'awaiting')]; + if (phase === 'awaiting') return [target(nodeId, 'validate')]; + if (phase === 'validate') return [target(nodeId, 'prepare'), target(nodeId, 'commit')]; + if (phase === 'commit') return [target(nodeId, 'route')]; + if (phase === 'route') return [target(metadata.next, 'enter')]; + } + if (record.kind === 'condition' && phase === 'route') { + return [...new Set(metadata.branches.map(branch => target(branch.next, 'enter')))]; + } + if (record.kind === 'terminal' && phase === 'route') return []; + return null; +} + +function sameArray(actual, expected) { + return Array.isArray(actual) && actual.length === expected.length + && actual.every((value, index) => value === expected[index]); +} + +/** + * Internal M1 check, not a security boundary for untrusted compiled programs. + * M2 additionally validates the Program ABI and identity at load and execution. + */ +export function validateInteractiveIR(ir) { + const errors = []; + const bad = message => errors.push(Object.freeze({ + code: 'FK_I_INVALID_IR', severity: 'error', message, + })); + if (!ir || typeof ir !== 'object' || ir.schemaVersion !== 1 || ir.featureStage !== 'P0-A' + || !Array.isArray(ir.blocks) || !ir.nodes || typeof ir.nodes !== 'object') { + return { ok: false, diagnostics: Object.freeze([{ code: 'FK_I_INVALID_IR', severity: 'error', message: 'Invalid IR envelope' }]) }; + } + const ids = Object.keys(ir.nodes); + if (!ids.length || ids.length > 500 || ir.blocks.length > 3500) bad('Invalid P0-A IR size'); + if (ir.entryBlockId !== interactiveBlockId(ids[0], 'enter')) bad('Invalid entry block'); + if (!/^[a-f0-9]{64}$/.test(ir.sourceHash ?? '')) bad('Invalid source hash'); + const table = new Map(); + for (const block of ir.blocks) { + if (!block || typeof block.id !== 'string') { bad('Malformed block'); continue; } + if (table.has(block.id)) bad('Duplicate block: ' + block.id); + table.set(block.id, block); + } + let expectedCount = 0; + for (const id of ids) { + const record = ir.nodes[id]; + if (!record || !Object.hasOwn(P0A_PHASES, record.kind)) { + bad('Unsupported node kind: ' + id); continue; + } + const phases = P0A_PHASES[record.kind]; + expectedCount += phases.length; + const metadata = record.metadata; + if (!metadata || typeof metadata !== 'object') { bad('Missing metadata: ' + id); continue; } + if (record.kind === 'condition') { + if (!Array.isArray(metadata.branches) || !metadata.branches.length + || metadata.branches.some(branch => !branch || typeof branch.when !== 'string' + || !branch.ast || typeof branch.ast.kind !== 'string' || typeof branch.next !== 'string')) { + bad('Malformed condition branches: ' + id); + } + } + if (record.kind === 'agent' && (!Array.isArray(metadata.inputs) || !metadata.output + || !Number.isInteger(metadata.retries) || metadata.retries < 0 || metadata.retries > 10)) { + bad('Malformed Agent metadata: ' + id); + } + if (record.kind === 'terminal' && !['succeeded', 'failed'].includes(metadata.status)) { + bad('Invalid terminal status: ' + id); + } + for (const phase of phases) { + const blockId = interactiveBlockId(id, phase); + const block = table.get(blockId); + const contract = phaseContract(record.kind, phase); + const targets = expectedBlockTargets(ir, id, phase); + if (!block) { bad('Missing block: ' + blockId); continue; } + if (block.nodeId !== id || block.phase !== phase || block.id !== blockId + || !sameArray(block.eventKinds, contract.eventKinds) + || !sameArray(block.proposalKinds, contract.proposalKinds) + || !targets || !sameArray(block.legalTargets, targets)) { + bad('Illegal phase contract or edge: ' + blockId); + } + const acceptTarget = phase === 'awaiting' ? interactiveBlockId(id, 'validate') : undefined; + if (block.acceptTarget !== acceptTarget) bad('Invalid receipt accept target: ' + blockId); + } + } + if (ir.blocks.length !== expectedCount) bad('Unexpected block count'); + for (const block of ir.blocks) { + if (!block || !Array.isArray(block.legalTargets)) continue; + for (const target of block.legalTargets) { + if (!table.has(target)) bad('Unknown block target: ' + target); + } + } + // A reachable Program is required: no hidden phases may bypass the entry. + const reached = new Set(); + const pending = [ir.entryBlockId]; + while (pending.length) { + const next = pending.pop(); + if (reached.has(next)) continue; + reached.add(next); + for (const target of table.get(next)?.legalTargets ?? []) if (table.has(target)) pending.push(target); + } + for (const id of table.keys()) if (!reached.has(id)) bad('Unreachable phase: ' + id); + return Object.freeze({ ok: errors.length === 0, diagnostics: Object.freeze(errors) }); +} From 63b0caf270c823896230bb5450df888d667bedb8 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 20:43:54 +0900 Subject: [PATCH 2/5] test(interactive): cover real DSL lowering and IR validation in M1 --- package.json | 2 +- scripts/package-files.mjs | 1 + tests/interactive/compiler-front.test.mjs | 99 +++++++++++++++++++++ tests/interactive/ir.test.mjs | 102 ++++++++++++++++++++++ 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 tests/interactive/compiler-front.test.mjs create mode 100644 tests/interactive/ir.test.mjs diff --git a/package.json b/package.json index bb5d6f0..07488d6 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ ], "scripts": { "build": "node scripts/build.mjs", - "test": "node --test tests/dsl.test.mjs tests/contracts.test.mjs tests/compiler/*.test.mjs tests/when/*.test.mjs tests/runtime/*.test.mjs tests/cli/*.test.mjs tests/integrations/*.test.mjs tests/package/*.test.mjs", + "test": "node --test tests/dsl.test.mjs tests/contracts.test.mjs tests/compiler/*.test.mjs tests/interactive/*.test.mjs tests/when/*.test.mjs tests/runtime/*.test.mjs tests/cli/*.test.mjs tests/integrations/*.test.mjs tests/package/*.test.mjs", "test:types": "node scripts/test-types.mjs", "check:package": "node scripts/check-package.mjs", "test:package": "node scripts/test-package.mjs", diff --git a/scripts/package-files.mjs b/scripts/package-files.mjs index 7aaf66a..498d1c0 100644 --- a/scripts/package-files.mjs +++ b/scripts/package-files.mjs @@ -8,6 +8,7 @@ export const packageFiles = [ ['types/capability-graph.d.ts', 'dist/capability-graph.d.ts'], ...['contracts', 'control', 'skill-ref'].map(name => [`src/shared/${name}.mjs`, `dist/shared/${name}.mjs`]), ...['source', 'extract', 'validate', 'skill-check', 'analyze', 'graph-algorithms', 'graph', 'dataflow', 'values', 'paths', 'mermaid', 'emit-plan', 'helpers', 'emit', 'compile', 'api', 'output'].map(name => [`src/compiler/${name}.mjs`, `dist/compiler/${name}.mjs`]), + ...['feature-gate', 'lower', 'validate-ir'].map(name => [`src/compiler/interactive/${name}.mjs`, `dist/compiler/interactive/${name}.mjs`]), ...['parser', 'evaluate'].map(name => [`src/when/${name}.mjs`, `dist/when/${name}.mjs`]), ...['context', 'execute', 'load', 'source', 'skills', 'skill-remote', 'run', 'aot', 'dump', 'dump-channel', 'dump-hook'].map(name => [`src/runtime/${name}.mjs`, `dist/runtime/${name}.mjs`]), ...['dsl', 'index', 'compiler-api'].map(name => [`types/${name}.d.ts`, `dist/${name}.d.ts`]), diff --git a/tests/interactive/compiler-front.test.mjs b/tests/interactive/compiler-front.test.mjs new file mode 100644 index 0000000..bdf1b5c --- /dev/null +++ b/tests/interactive/compiler-front.test.mjs @@ -0,0 +1,99 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { analyzeSource } from '../../src/compiler/analyze.mjs'; +import { gateInteractiveFeatures } from '../../src/compiler/interactive/feature-gate.mjs'; +import { lowerInteractive } from '../../src/compiler/interactive/lower.mjs'; + +const imports = "import {defineWorkflow,node,action,verification,condition,terminal} from '@devcodex-labs/flowkit/dsl';"; +const workflow = nodes => imports + '\nexport default defineWorkflow({id:"m1-demo",version:"1.0.0",name:"M1",nodes:[' + nodes.join(',') + ']});'; +const done = "terminal('done',{name:'Done',status:'succeeded'})"; +const failed = "terminal('failed',{name:'Failed',status:'failed'})"; +const condition = "condition('gate',{name:'Gate',branches:[{when:'ready',next:'worker'},{when:'true',next:'failed'}]})"; +const worker = "node('worker',{name:'Worker',agent:'writer',inputs:['request'],output:{type:'object',properties:{result:{type:'string'}}},next:'done'})"; +const analyze = (text, suffix = 'js') => analyzeSource(text, { + filename: 'virtual.workflow.' + suffix, projectRoot: process.cwd(), skillCheck: false, +}); + +test('M1 uses the real Standard analyzer for JS and TS, preserving diagnostics', async () => { + const source = workflow([condition, worker, done, failed]); + const analysis = await analyze(source); + assert.equal(analysis.diagnostics.some(d => d.severity === 'error'), false, + JSON.stringify(analysis.diagnostics)); + assert.ok(analysis.graph); + assert.ok(analysis.conditions.get('/nodes/0/branches/0/when')); + assert.ok(analysis.dataflow.inputs instanceof Map); + const result = lowerInteractive(analysis); + assert.ok(result.ir); + assert.deepEqual(result.diagnostics, analysis.diagnostics); + assert.equal(result.ir.workflowId, 'm1-demo'); + assert.equal(result.ir.nodes.worker.metadata.retries, 2); + assert.deepEqual(result.ir.nodes.worker.metadata.inputs, ['request']); + const typed = source.replace("status:'succeeded'", "status:'succeeded' as const"); + const tsResult = await analyze(typed, 'ts'); + assert.equal(tsResult.diagnostics.some(d => d.severity === 'error'), false, + JSON.stringify(tsResult.diagnostics)); + assert.equal(lowerInteractive(tsResult).ir.nodes.done.kind, 'terminal'); +}); + +test('a valid Standard Action or Verification is rejected by the P0-A gate', async () => { + const actionText = workflow([ + "action('write',{name:'Write',action:'publish',writes:['result'],next:'done'})", done, + ]); + const actionAnalysis = await analyze(actionText); + assert.equal(actionAnalysis.diagnostics.some(d => d.severity === 'error'), false, + JSON.stringify(actionAnalysis.diagnostics)); + const actionGate = lowerInteractive(actionAnalysis); + assert.equal(actionGate.ir, null); + assert.ok(actionGate.diagnostics.some(d => d.code === 'FK_I_UNSUPPORTED_NODE' && d.nodeId === 'write')); + const verificationAnalysis = await analyze(workflow([ + "verification('verify',{name:'Verify',verifier:'check',onSuccess:'done',onFailure:'failed'})", + done, failed, + ])); + assert.equal(verificationAnalysis.diagnostics.some(d => d.severity === 'error'), false, + JSON.stringify(verificationAnalysis.diagnostics)); + assert.equal(lowerInteractive(verificationAnalysis).ir, null); +}); + +test('P0-A explicitly rejects Agent verify; it must never silently skip the callback', async () => { + const text = workflow([worker.replace("next:'done'", "verify:'safety',next:'done'"), done]); + const a = await analyze(text); + assert.equal(a.diagnostics.some(d => d.severity === 'error'), false, + JSON.stringify(a.diagnostics)); + const { ir, diagnostics } = lowerInteractive(a); + assert.equal(ir, null); + assert.ok(diagnostics.some(d => d.code === 'FK_I_UNSUPPORTED_BINDING')); +}); + +test('featureStage is bounded by the built-in implemented stage, not caller options', async () => { + const a = await analyze(workflow([done])); + assert.equal(gateInteractiveFeatures(a).ok, true); + for (const stage of ['P0-B', 'P1']) { + const result = lowerInteractive(a, { featureStage: stage }); + assert.equal(result.ir, null); + assert.ok(result.diagnostics.some(d => d.code === 'FK_I_UNSUPPORTED_STAGE')); + } + assert.equal(lowerInteractive(a, { featureStage: 'P2' }).diagnostics + .some(d => d.code === 'FK_I_INVALID_STAGE'), true); +}); + +test('invalid Standard Graph is rejected before lowering; original FK_C diagnostics survive', async () => { + const a = await analyze(workflow([ + "node('worker',{name:'Worker',agent:'writer',output:{type:'object'},next:'missing'})", done, + ])); + assert.ok(a.diagnostics.some(d => d.code === 'FK_C015')); + const result = lowerInteractive(a); + assert.equal(result.ir, null); + assert.deepEqual(result.diagnostics, a.diagnostics); +}); + +test('pure Condition/Terminal does not require any Agent runtime binding', async () => { + const a = await analyze(workflow([ + "condition('gate',{name:'Gate',branches:[{when:'ready',next:'done'},{when:'true',next:'failed'}]})", + done, failed, + ])); + assert.equal(a.diagnostics.some(d => d.severity === 'error'), false, + JSON.stringify(a.diagnostics)); + const result = lowerInteractive(a); + assert.ok(result.ir); + assert.equal(Object.values(result.ir.nodes).some(n => n.kind === 'agent'), false); +}); diff --git a/tests/interactive/ir.test.mjs b/tests/interactive/ir.test.mjs new file mode 100644 index 0000000..134078d --- /dev/null +++ b/tests/interactive/ir.test.mjs @@ -0,0 +1,102 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { analyzeSource } from '../../src/compiler/analyze.mjs'; +import { lowerInteractive } from '../../src/compiler/interactive/lower.mjs'; +import { validateInteractiveIR, interactiveBlockId } from '../../src/compiler/interactive/validate-ir.mjs'; +import { evaluateWhenAst } from '../../src/when/evaluate.mjs'; + +const imports = "import {defineWorkflow,node,condition,terminal} from '@devcodex-labs/flowkit/dsl';"; +const declarations = [ + "condition('gate',{name:'Gate',branches:[{when:'ready && review.approved',next:'worker'},{when:'true',next:'failed'}]})", + "node('worker',{name:'Worker',agent:'writer',inputs:['missing'],output:{type:'object',properties:{answer:{type:'string'}}},retries:2,overview:true,next:'done'})", + "terminal('done',{name:'Done',status:'succeeded'})", + "terminal('failed',{name:'Failed',status:'failed'})", +]; +const source = (nodes = declarations) => imports + + '\nexport default defineWorkflow({id:"ir-test",version:"1.0.0",name:"IR",nodes:[' + + nodes.join(',') + ']});'; +const analyze = text => analyzeSource(text, { + filename: 'ir.workflow.js', projectRoot: process.cwd(), skillCheck: false, +}); +const make = async () => lowerInteractive(await analyze(source())).ir; + +test('stable IR from original Graph, Dataflow and when AST includes all P0-A phases', async () => { + const first = await make(); + const second = await make(); + assert.ok(first); + assert.deepEqual(first, second); + assert.equal(JSON.stringify(first), JSON.stringify(second)); + assert.equal(validateInteractiveIR(first).ok, true); + assert.equal(first.entryBlockId, 'gate::enter'); + assert.equal(first.blocks.length, 13); + assert.ok(Object.isFrozen(first.blocks)); + assert.ok(Object.isFrozen(first.nodes.worker.metadata)); + const graph = first.graphEdges; + assert.ok(graph.some(e => e.from === 'gate' && e.to === 'worker')); + assert.ok(graph.some(e => e.from === 'worker' && e.to === 'done')); + assert.ok(first.dataflow.worker); + assert.deepEqual(first.nodes.worker.metadata.inputs, ['missing']); + assert.equal(first.nodes.worker.metadata.retries, 2); + assert.equal(first.nodes.worker.metadata.overview, true); + const c = first.blocks.find(b => b.id === 'gate::route'); + assert.deepEqual(c.legalTargets, ['worker::enter', 'failed::enter']); + assert.ok(c.proposalKinds.includes('raise-error')); // missing bool -> FK_R002 + const awaiting = first.blocks.find(b => b.id === 'worker::awaiting'); + assert.deepEqual(awaiting.proposalKinds, []); + assert.equal(awaiting.acceptTarget, 'worker::validate'); + assert.ok(first.blocks.find(b => b.id === 'worker::validate').legalTargets.includes('worker::prepare')); +}); + +test('original parseWhen AST retains own-path booleans and short-circuit behavior', async () => { + const ir = await make(); + const ast = ir.nodes.gate.metadata.branches[0].ast; + assert.equal(evaluateWhenAst(ast, { ready: true, review: { approved: true } }), true); + assert.equal(evaluateWhenAst(ast, { ready: false }), false); + assert.throws(() => evaluateWhenAst(ast, {}), error => error.code === 'FK_R002'); +}); + +test('IR validator rejects injected edges, events, duplicate blocks and missing phases', async () => { + const original = await make(); + const mutated = () => JSON.parse(JSON.stringify(original)); + const badEdge = mutated(); + badEdge.blocks.find(b => b.id === 'gate::route').legalTargets[0] = 'wrong::enter'; + assert.equal(validateInteractiveIR(badEdge).ok, false); + const badEvent = mutated(); + badEvent.blocks.find(b => b.id === 'worker::awaiting').eventKinds.push('external-cancel'); + assert.equal(validateInteractiveIR(badEvent).ok, false); + const duplicate = mutated(); + duplicate.blocks.push(duplicate.blocks[0]); + assert.equal(validateInteractiveIR(duplicate).ok, false); + const absent = mutated(); + absent.blocks = absent.blocks.filter(b => b.id !== 'worker::commit'); + assert.equal(validateInteractiveIR(absent).ok, false); + const changedTarget = mutated(); + changedTarget.nodes.worker.metadata.next = 'unknown'; + assert.equal(validateInteractiveIR(changedTarget).ok, false); + const changedPhase = mutated(); + changedPhase.blocks.find(b => b.id === 'worker::validate').phase = 'awaiting'; + assert.equal(validateInteractiveIR(changedPhase).ok, false); +}); + +test('source-byte change changes source identity, without changing node block IDs', async () => { + const base = await make(); + const adjusted = lowerInteractive(await analyze(source() + '\n// harmless source comment')).ir; + assert.ok(adjusted); + assert.notEqual(base.sourceHash, adjusted.sourceHash); + assert.equal(base.entryBlockId, adjusted.entryBlockId); + assert.equal(interactiveBlockId('worker', 'validate'), 'worker::validate'); +}); + +test('only explicit own properties are preserved in Agent schema metadata', async () => { + const ownSchema = source([ + "node('worker',{name:'Worker',agent:'writer',output:{type:'object',properties:{'__proto__':{type:'string'},normal:{type:'number'}}},next:'done'})", + declarations[2], + ]); + const analysis = await analyze(ownSchema); + assert.equal(analysis.diagnostics.some(d => d.severity === 'error'), false, + JSON.stringify(analysis.diagnostics)); + const ir = lowerInteractive(analysis).ir; + assert.ok(ir); + assert.equal(Object.hasOwn(ir.nodes.worker.metadata.output.properties, '__proto__'), true); + assert.equal(Object.getPrototypeOf(ir.nodes.worker.metadata.output.properties), Object.prototype); +}); From c4f6d650c3209ba707551f6fa2b8d1af257bd68b Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 20:48:10 +0900 Subject: [PATCH 3/5] fix(interactive): keep M1 IR validator total on corrupt branch metadata --- src/compiler/interactive/lower.mjs | 2 +- src/compiler/interactive/validate-ir.mjs | 3 ++- tests/interactive/ir.test.mjs | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/compiler/interactive/lower.mjs b/src/compiler/interactive/lower.mjs index e73524c..0411072 100644 --- a/src/compiler/interactive/lower.mjs +++ b/src/compiler/interactive/lower.mjs @@ -65,7 +65,7 @@ export function lowerInteractive(analysis, options = {}) { // source of routing and it is not used to prefill missing Agent inputs. dataflow: Object.fromEntries(definitions.map(node => [node.id, { reachingKeys: [...(analysis.dataflow.inputs.get(node.id) ?? [])].sort(), - possibleOutputKeys: [...(analysis.dataflow.outputs.get(node.id) ?? [])].sort(), + reachingKeysAfter: [...(analysis.dataflow.outputs.get(node.id) ?? [])].sort(), }])), graphEdges: analysis.graph.executionEdges.map(edge => ({ from: edge.from, to: edge.to, role: edge.role, diff --git a/src/compiler/interactive/validate-ir.mjs b/src/compiler/interactive/validate-ir.mjs index c614603..fd76a5f 100644 --- a/src/compiler/interactive/validate-ir.mjs +++ b/src/compiler/interactive/validate-ir.mjs @@ -55,7 +55,8 @@ export function expectedBlockTargets(ir, nodeId, phase) { if (phase === 'route') return [target(metadata.next, 'enter')]; } if (record.kind === 'condition' && phase === 'route') { - return [...new Set(metadata.branches.map(branch => target(branch.next, 'enter')))]; + if (!Array.isArray(metadata.branches)) return null; // reject corrupt IR without throwing + return [...new Set(metadata.branches.map(branch => target(branch?.next, 'enter')))]; } if (record.kind === 'terminal' && phase === 'route') return []; return null; diff --git a/tests/interactive/ir.test.mjs b/tests/interactive/ir.test.mjs index 134078d..c1896e2 100644 --- a/tests/interactive/ir.test.mjs +++ b/tests/interactive/ir.test.mjs @@ -76,6 +76,9 @@ test('IR validator rejects injected edges, events, duplicate blocks and missing const changedPhase = mutated(); changedPhase.blocks.find(b => b.id === 'worker::validate').phase = 'awaiting'; assert.equal(validateInteractiveIR(changedPhase).ok, false); + const missingBranches = mutated(); + missingBranches.nodes.gate.metadata.branches = null; + assert.equal(validateInteractiveIR(missingBranches).ok, false); }); test('source-byte change changes source identity, without changing node block IDs', async () => { From c8040fd8b1c156293ed2031d0ab78610f5cea885 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 20:51:18 +0900 Subject: [PATCH 4/5] fix(interactive): align M1 diagnostics with confirmed FK_I catalog --- src/compiler/interactive/feature-gate.mjs | 8 ++++---- src/compiler/interactive/validate-ir.mjs | 4 ++-- tests/interactive/compiler-front.test.mjs | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/compiler/interactive/feature-gate.mjs b/src/compiler/interactive/feature-gate.mjs index 79d54fe..fbd30a2 100644 --- a/src/compiler/interactive/feature-gate.mjs +++ b/src/compiler/interactive/feature-gate.mjs @@ -28,10 +28,10 @@ export function gateInteractiveFeatures(analysis, options = {}) { const featureStage = options.featureStage ?? IMPLEMENTED_INTERACTIVE_STAGE; const extras = []; if (!STAGES.includes(featureStage)) { - extras.push(interactiveDiagnostic(analysis, 'FK_I_INVALID_STAGE', + extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_GRAPH', 'Unknown Interactive feature stage: ' + String(featureStage))); } else if (featureStage !== IMPLEMENTED_INTERACTIVE_STAGE) { - extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_STAGE', + extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_GRAPH', 'This build implements only P0-A; caller cannot enable ' + featureStage)); } @@ -41,10 +41,10 @@ export function gateInteractiveFeatures(analysis, options = {}) { if (standardValid) { analysis.literal.definition.nodes.forEach((node, index) => { if (!ENABLED.has(node.type)) { - extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_NODE', + extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_GRAPH', 'P0-A does not support node type ' + String(node.type), index, node)); } else if (node.type === 'agent' && node.verify != null) { - extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_BINDING', + extras.push(interactiveDiagnostic(analysis, 'FK_I_UNSUPPORTED_GRAPH', 'P0-A Agent verify requires P0-B; it must not be skipped', index, node)); } }); diff --git a/src/compiler/interactive/validate-ir.mjs b/src/compiler/interactive/validate-ir.mjs index fd76a5f..0b3dec2 100644 --- a/src/compiler/interactive/validate-ir.mjs +++ b/src/compiler/interactive/validate-ir.mjs @@ -74,11 +74,11 @@ function sameArray(actual, expected) { export function validateInteractiveIR(ir) { const errors = []; const bad = message => errors.push(Object.freeze({ - code: 'FK_I_INVALID_IR', severity: 'error', message, + code: 'FK_I_INVALID_TRANSITION', severity: 'error', message, })); if (!ir || typeof ir !== 'object' || ir.schemaVersion !== 1 || ir.featureStage !== 'P0-A' || !Array.isArray(ir.blocks) || !ir.nodes || typeof ir.nodes !== 'object') { - return { ok: false, diagnostics: Object.freeze([{ code: 'FK_I_INVALID_IR', severity: 'error', message: 'Invalid IR envelope' }]) }; + return { ok: false, diagnostics: Object.freeze([{ code: 'FK_I_INVALID_TRANSITION', severity: 'error', message: 'Invalid IR envelope' }]) }; } const ids = Object.keys(ir.nodes); if (!ids.length || ids.length > 500 || ir.blocks.length > 3500) bad('Invalid P0-A IR size'); diff --git a/tests/interactive/compiler-front.test.mjs b/tests/interactive/compiler-front.test.mjs index bdf1b5c..ea3827a 100644 --- a/tests/interactive/compiler-front.test.mjs +++ b/tests/interactive/compiler-front.test.mjs @@ -44,7 +44,7 @@ test('a valid Standard Action or Verification is rejected by the P0-A gate', asy JSON.stringify(actionAnalysis.diagnostics)); const actionGate = lowerInteractive(actionAnalysis); assert.equal(actionGate.ir, null); - assert.ok(actionGate.diagnostics.some(d => d.code === 'FK_I_UNSUPPORTED_NODE' && d.nodeId === 'write')); + assert.ok(actionGate.diagnostics.some(d => d.code === 'FK_I_UNSUPPORTED_GRAPH' && d.nodeId === 'write')); const verificationAnalysis = await analyze(workflow([ "verification('verify',{name:'Verify',verifier:'check',onSuccess:'done',onFailure:'failed'})", done, failed, @@ -61,7 +61,7 @@ test('P0-A explicitly rejects Agent verify; it must never silently skip the call JSON.stringify(a.diagnostics)); const { ir, diagnostics } = lowerInteractive(a); assert.equal(ir, null); - assert.ok(diagnostics.some(d => d.code === 'FK_I_UNSUPPORTED_BINDING')); + assert.ok(diagnostics.some(d => d.code === 'FK_I_UNSUPPORTED_GRAPH')); }); test('featureStage is bounded by the built-in implemented stage, not caller options', async () => { @@ -70,10 +70,10 @@ test('featureStage is bounded by the built-in implemented stage, not caller opti for (const stage of ['P0-B', 'P1']) { const result = lowerInteractive(a, { featureStage: stage }); assert.equal(result.ir, null); - assert.ok(result.diagnostics.some(d => d.code === 'FK_I_UNSUPPORTED_STAGE')); + assert.ok(result.diagnostics.some(d => d.code === 'FK_I_UNSUPPORTED_GRAPH')); } assert.equal(lowerInteractive(a, { featureStage: 'P2' }).diagnostics - .some(d => d.code === 'FK_I_INVALID_STAGE'), true); + .some(d => d.code === 'FK_I_UNSUPPORTED_GRAPH'), true); }); test('invalid Standard Graph is rejected before lowering; original FK_C diagnostics survive', async () => { From b10f09d8c063b66d03008236fddf4ade27387504 Mon Sep 17 00:00:00 2001 From: rocky Date: Tue, 10 Nov 2026 20:53:46 +0900 Subject: [PATCH 5/5] feat(interactive): capture static Agent ControlFrame rules in M1 IR --- src/compiler/interactive/lower.mjs | 3 ++- src/compiler/interactive/validate-ir.mjs | 21 +++++++++++++++++++++ tests/interactive/ir.test.mjs | 7 +++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/compiler/interactive/lower.mjs b/src/compiler/interactive/lower.mjs index 0411072..03af862 100644 --- a/src/compiler/interactive/lower.mjs +++ b/src/compiler/interactive/lower.mjs @@ -1,5 +1,5 @@ import { gateInteractiveFeatures } from './feature-gate.mjs'; -import { P0A_PHASES, interactiveBlockId, phaseContract, expectedBlockTargets, validateInteractiveIR } from './validate-ir.mjs'; +import { P0A_PHASES, interactiveBlockId, phaseContract, expectedBlockTargets, agentFrameRule, validateInteractiveIR } from './validate-ir.mjs'; function copyData(value) { // Analyzer-owned DSL literals/when ASTs are JSON-safe. Preserve own __proto__ @@ -61,6 +61,7 @@ export function lowerInteractive(analysis, options = {}) { sourceHash: analysis.snapshot.sourceHash, entryBlockId: interactiveBlockId(first, 'enter'), nodes, blocks: [], + frameRules: definitions.filter(node => node.type === 'agent').map(node => agentFrameRule(node.id)), // Preserve Standard dataflow as diagnostic evidence; it is not a second // source of routing and it is not used to prefill missing Agent inputs. dataflow: Object.fromEntries(definitions.map(node => [node.id, { diff --git a/src/compiler/interactive/validate-ir.mjs b/src/compiler/interactive/validate-ir.mjs index 0b3dec2..43fcb8f 100644 --- a/src/compiler/interactive/validate-ir.mjs +++ b/src/compiler/interactive/validate-ir.mjs @@ -33,6 +33,14 @@ const RULES = Object.freeze({ export const interactiveBlockId = (nodeId, phase) => nodeId + '::' + phase; +// A static rule, not an actual persisted ControlFrame (dynamic values belong to M4). +const AGENT_FRAME_FIELDS = Object.freeze(['kind', 'nodeId', 'activation', 'attempt', 'phase', 'reasons', 'overviewStep']); +export function agentFrameRule(nodeId) { + return {kind: 'agent', nodeId, pushBlockId: interactiveBlockId(nodeId, 'enter'), + popBlockId: interactiveBlockId(nodeId, 'route'), initialAttempt: 1, + fields: [...AGENT_FRAME_FIELDS]}; +} + export function phaseContract(kind, phase) { const record = RULES[kind]?.[phase]; if (!record) return null; @@ -84,6 +92,19 @@ export function validateInteractiveIR(ir) { if (!ids.length || ids.length > 500 || ir.blocks.length > 3500) bad('Invalid P0-A IR size'); if (ir.entryBlockId !== interactiveBlockId(ids[0], 'enter')) bad('Invalid entry block'); if (!/^[a-f0-9]{64}$/.test(ir.sourceHash ?? '')) bad('Invalid source hash'); + const expectedFrames = ids.filter(id => ir.nodes[id]?.kind === 'agent').map(agentFrameRule); + if (!Array.isArray(ir.frameRules) || ir.frameRules.length !== expectedFrames.length) { + bad('Invalid control-frame rule count'); + } else { + for (const [index, expected] of expectedFrames.entries()) { + const actual = ir.frameRules[index]; + if (!actual || actual.kind !== expected.kind || actual.nodeId !== expected.nodeId + || actual.pushBlockId !== expected.pushBlockId || actual.popBlockId !== expected.popBlockId + || actual.initialAttempt !== expected.initialAttempt || !sameArray(actual.fields, expected.fields)) { + bad('Invalid control-frame rule for Agent ' + expected.nodeId); + } + } + } const table = new Map(); for (const block of ir.blocks) { if (!block || typeof block.id !== 'string') { bad('Malformed block'); continue; } diff --git a/tests/interactive/ir.test.mjs b/tests/interactive/ir.test.mjs index c1896e2..b6a31ed 100644 --- a/tests/interactive/ir.test.mjs +++ b/tests/interactive/ir.test.mjs @@ -36,6 +36,10 @@ test('stable IR from original Graph, Dataflow and when AST includes all P0-A pha assert.ok(graph.some(e => e.from === 'worker' && e.to === 'done')); assert.ok(first.dataflow.worker); assert.deepEqual(first.nodes.worker.metadata.inputs, ['missing']); + assert.equal(first.frameRules.length, 1); + assert.equal(first.frameRules[0].pushBlockId, 'worker::enter'); + assert.equal(first.frameRules[0].popBlockId, 'worker::route'); + assert.ok(first.frameRules[0].fields.includes('overviewStep')); assert.equal(first.nodes.worker.metadata.retries, 2); assert.equal(first.nodes.worker.metadata.overview, true); const c = first.blocks.find(b => b.id === 'gate::route'); @@ -73,6 +77,9 @@ test('IR validator rejects injected edges, events, duplicate blocks and missing const changedTarget = mutated(); changedTarget.nodes.worker.metadata.next = 'unknown'; assert.equal(validateInteractiveIR(changedTarget).ok, false); + const badFrame = mutated(); + badFrame.frameRules[0].popBlockId = 'unknown::route'; + assert.equal(validateInteractiveIR(badFrame).ok, false); const changedPhase = mutated(); changedPhase.blocks.find(b => b.id === 'worker::validate').phase = 'awaiting'; assert.equal(validateInteractiveIR(changedPhase).ok, false);