diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 50fa3e3..aca7f50 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -82,6 +82,11 @@ The compiler emits an IR bundle plus JSON Schemas and metadata. Provider credent ## Runtime semantics +The current branching source preview is documented in [BRANCHING.md](BRANCHING.md). +It uses lexical block environments, one shared execution budget and IR v2 for +control flow, while retaining v1 output for linear programs. See +[RFC 0002](rfcs/0002-typed-workflow-branching.md) for the pending design review. + ### Execution states `pending`, `running`, `waiting`, `succeeded`, `failed`, and `cancelled`. diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md new file mode 100644 index 0000000..0650816 --- /dev/null +++ b/docs/BRANCHING.md @@ -0,0 +1,68 @@ +# Workflow branching (source preview) + +This checkout implements [RFC 0002](rfcs/0002-typed-workflow-branching.md), pending +public review in [#51](https://github.com/haseebahmed248/GuardStep/issues/51). +It is not available in `guardstep@0.1.0-alpha.1`. This is experimental, not a +production-readiness claim. + +## Run the keyless example + +From the repository root with Node.js 22 or newer: + +```sh +npm ci +npm run build +./gs check examples/branching/decide.guard +./gs run examples/branching/decide.guard +./gs test examples/branching/decide.guard +``` + +The adjacent input and host files are discovered automatically. No model key, +Ollama server, account or paid service is needed: the model is a deterministic +fixture. The five cases cover empty-input failure, cached early return, +model-only execution, tool-then-model execution and permission denial. + +The default output is `{ "text": "[mock model] [tool] hello" }`. Changing the +input text to `cached` returns without calling any adapter. `local` calls only +the mock model; empty text fails with `EMPTY`. + +## Semantics + +- `if condition { ... }`, optional `else { ... }`, nested branches and `else if`. +- Conditions must be pure booleans. Comparisons and existing list predicates + work; truthiness and calls inside conditions do not. +- `return value` succeeds with the declared output record. `fail CODE` fails + with a member of the workflow's failure enum. Both terminate the whole run. +- Every path must return or fail. An if without else may fall through to a + later return. Statements after an unconditional terminal are rejected. +- Variables are local to each arm. Arms can read outer bindings, but cannot + shadow them or export new bindings. Siblings may reuse local names. +- All arms are checked, even with literal conditions. No constant folding, + correlated-condition proof, union narrowing, nullable types or implicit joins. +- Capabilities and limits are declared once at workflow scope. Runtime grants + are checked only for selected tool calls. Counters, time and costs accumulate + across selected blocks; they do not reset on entry or exit. +- Static call limits use the maximum per path, including shared continuation, + rather than adding mutually exclusive arms. Model cost is accounted after a + completed call; the cap cannot undo a charge already incurred. + +## Version and contract compatibility + +Linear sources still emit IR v1 with unchanged IDs and generated contracts. +Branching or standalone fail emits IR v2. `compileSource` now returns +`WorkflowIr` (`WorkflowIrV1 | WorkflowIrV2`); callers requiring v1 must narrow +`schema_version`. The runtime and generator accept both versions. Unsupported +versions and v2 steps mislabeled as v1 are rejected before execution. + +Use a compiler and runtime from the same checkout for this preview. The old npm +runtime predates v2 and is not a compatible consumer. IR is trusted compiler +output, not a safe format for executing arbitrary untrusted JSON or adapters. + +Nested step IDs include the branch index and arm, such as +`Decide/branch:0/else/model:reply`. Generated model contracts visit every arm +with its lexical context and give branch-local models path-qualified names. +Existing event schema v1 is unchanged; there is no new branch event. Selected +effects retain their qualified step IDs, and terminal failures emit `run.failed`. + +Run `npm run check` and `npm run check:generated` to verify both checked-in +examples and their typed host contracts. diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md index 8e0bb9a..7462b43 100644 --- a/docs/DIAGNOSTICS.md +++ b/docs/DIAGNOSTICS.md @@ -23,7 +23,9 @@ The initial proof gates are: | `GS2004` | effect attempted inside a pure expression | | `GS2102` | invalid field access | | `GS2105` | incompatible expression operands, including unsupported ordering | -| `GS2202` | workflow without a return | +| `GS2201` | non-boolean assertion or branch condition | +| `GS2202` | missing final-result path or incompatible return type | +| `GS2203` | unreachable statement after return, fail, or an exhaustive terminal branch | When adding or intentionally changing a diagnostic, update the smallest relevant `.guard` fixture and its manifest entry in the same pull request. diff --git a/docs/rfcs/0002-typed-workflow-branching.md b/docs/rfcs/0002-typed-workflow-branching.md new file mode 100644 index 0000000..d33443d --- /dev/null +++ b/docs/rfcs/0002-typed-workflow-branching.md @@ -0,0 +1,126 @@ +# RFC 0002: typed workflow branching + +Status: proposed; implementation is subject to review, not a released guarantee. +Related: #20. Public discussion: [#51](https://github.com/haseebahmed248/GuardStep/issues/51). +Public review period: September 7–14, 2026. The founding maintainer +will record the outcome and unresolved concerns here before merging the feature. + +## Motivation + +Document Q&A should be able to return early or fail when retrieval is empty, +without asking a host adapter to choose the workflow's control flow. Decisions +must preserve the same type checks, authority and budgets as linear steps. + +## Syntax and supported subset + +Complete minimal example: + +```guard +enum Failure { DENIED DURATION COST } +record Input { text: String } +workflow Decide(input: Input) -> Input fails Failure { + capabilities {} + limits { + tool_calls <= 0 + model_calls <= 0 + duration <= 1s else DURATION + cost <= 0 USD else COST + } + if input.text == "" { + fail DENIED + } else { + return input + } +} +``` + +Conditions use existing pure boolean expressions. Braces are mandatory. Nested +`if`, optional `else`, and `else if` are supported. `fail CODE` terminates the +whole workflow with a value from its declared failure enum. An omitted else is +an empty fallthrough branch. Policy blocks remain workflow-level only. + +Variables introduced in an arm are local to that arm and its nested blocks. +They cannot shadow visible variables. Sibling arms may use the same names, even +with different types; neither binding exists after the branch. Every return +must match the declared output record. No implicit branch-result assignments. + +There is **no union/optional type narrowing in this slice**: those types do not +exist in the executable language. An enum equality test does not change a +record's fields or make a String nullable. Narrowing needs a separate type-system +proposal; this implementation must not pretend it is available. + +## Final paths and diagnostics + +All syntactic paths must end in `return` or `fail`. Both arms are checked even +for literal conditions; no constant folding or correlated-condition proof. + +- `if true { return input }` without a final return/else is incomplete (GS2202). +- A non-boolean condition is rejected (GS2201). +- A statement after return/fail or an if whose arms both terminate is unreachable + (GS2203). A statement after an if with a fallthrough arm is reachable. +- Returning a different record in one arm is incompatible (GS2202). +- Referencing an arm-local value after its block is unknown (GS2101). +- An undeclared failure is rejected (GS2003), even in an unselected arm. +- Effects inside conditions remain forbidden (GS2004). + +## IR and compatibility + +Linear programs keep their existing version 1 JSON and step IDs. Programs using +`if` or standalone `fail` emit version 2. Version 2 adds a `branch` node with +`condition`, `then`, `else`, source range and step ID, plus a `fail` node with its +error code. No closures, evaluated source, adapter calls or executable JS in IR. + +Branch and v2 assertion IDs use deterministic statement indices within their enclosing block; +arm paths qualify all nested IDs, so sibling assignment names cannot collide. +The compiler API returns `WorkflowIr` (v1 | v2); explicit v1 consumers must check +the version. The updated runtime accepts v1/v2 and rejects unsupported versions +before any effects. Older runtimes must not be used with v2; the current npm +alpha's old runtime is not a v2 consumer. Execution-event schema stays v1; +selected effects carry arm-qualified IDs, and fail uses `run.failed`. + +Generated domain/workflow/tool contracts stay unchanged for linear inputs. Model +contracts include calls in every arm, inferred in their lexical environment; +branch-local model symbols include the branch path. Unchosen arms do not invoke +their host or model adapter. + +## Authority and budgets + +All arms require declared capabilities statically. Runtime grants are checked +only when a tool is actually reached. Runtime counters, cost and absolute +deadline are shared by all selected blocks; entering a branch never resets them. + +Static call limits use a conservative maximum over paths, not a sum of mutually +exclusive arms. A terminating arm does not also pay for later continuation +steps. Sequential branches can accumulate calls. Analysis summarizes maximum +and fallthrough counts separately in linear time, without enumerating paths. + +Budget enforcement runs at control-flow boundaries and before terminal results. +An already exhausted deadline cannot dispatch a new effect, including at exact +deadline equality. The deadline helper rechecks at dispatch to cover time spent +evaluating arguments and scheduled microtasks. Cost may exceed the cap during +one already-started model call; its measured charge prevents later effects. +This is not a prepaid billing guarantee or a sandbox for untrusted adapters. + +## Alternatives and open concerns + +- Host-only decisions hide control flow from checking and budget analysis. +- A match expression would need exhaustiveness and result-type rules beyond + this small subset; a future RFC can add it. +- Exporting branch-local values requires a join/definite-assignment design. +- Keeping IR v1 would let old consumers misinterpret new node kinds. +- Dedicated branch trace events are deferred; assess whether arm-qualified + effect IDs plus terminal events are enough for consumers. +- No loops, retries, try/catch, unions, literal narrowing or branch result values. + +## Conformance and rollout + +Test true/false/nested/else-if/failure paths; early returns; incomplete and +unreachable paths; incompatible results and local scopes; worst-path budgets; +denied grants; accumulated time/cost/call limits; deterministic roundtripped IR; +generated model contracts and CLI check/run/test. Retain all linear fixtures. +Ship source documentation and a runnable keyless example. Do not publish a new +npm version or merge this proposal as accepted until the review outcome is recorded. + +## Outcome + +Pending public review. No acceptance decision has been recorded. diff --git a/examples/branching/decide.generated.ts b/examples/branching/decide.generated.ts new file mode 100644 index 0000000..b7c2db8 --- /dev/null +++ b/examples/branching/decide.generated.ts @@ -0,0 +1,133 @@ +// Generated by GuardStep. Do not edit manually. +// Generator: GuardStep 0.1.0-alpha.1 +// Source: decide.guard +// Source SHA-256: sha256:9ee78af661506b1138986bf6c62c64edd62b3fda61136399ab39bfb9c3b73c04 + +import type { Pricing as __GuardStepPricing } from "guardstep/runtime"; + +export type Failure = + | "EMPTY" + | "DENIED" + | "TIMEOUT" + | "ERROR" + | "INVALID" + | "DURATION" + | "COST"; + +export interface Message { + readonly "text": string; +} + +export interface EchoToolInvocation { + readonly runId: string; + readonly stepId: string; + readonly tool: "echo"; + readonly arguments: { + readonly "text": string; + }; + readonly signal: AbortSignal; +} + +export type EchoToolResult = + | { + readonly status: "succeeded"; + readonly value: Message; + readonly elapsedMs: number; + readonly eventData?: Readonly>; + } + | { + readonly status: "failed"; + readonly kind: "timeout" | "error"; + readonly code?: string; + readonly elapsedMs: number; + }; + +export interface DecideBranch0ElseBranch0ElseBranch0ThenReplyModelInvocation { + readonly runId: string; + readonly stepId: "Decide/branch:0/else/branch:0/else/branch:0/then/model:reply"; + readonly profile: "mock"; + readonly instructions: string; + readonly context: { + readonly "text": string; + }; + readonly outputSchema: Readonly>; + readonly signal: AbortSignal; +} + +export type DecideBranch0ElseBranch0ElseBranch0ThenReplyModelResult = + | { + readonly status: "succeeded"; + readonly value: Message; + readonly usage: { + readonly input_tokens: number; + readonly output_tokens: number; + }; + readonly elapsedMs: number; + } + | { + readonly status: "failed"; + readonly code: string; + readonly elapsedMs: number; + }; + +export interface DecideBranch0ElseBranch0ElseBranch0ElseReplyModelInvocation { + readonly runId: string; + readonly stepId: "Decide/branch:0/else/branch:0/else/branch:0/else/model:reply"; + readonly profile: "mock"; + readonly instructions: string; + readonly context: { + readonly "text": string; + }; + readonly outputSchema: Readonly>; + readonly signal: AbortSignal; +} + +export type DecideBranch0ElseBranch0ElseBranch0ElseReplyModelResult = + | { + readonly status: "succeeded"; + readonly value: Message; + readonly usage: { + readonly input_tokens: number; + readonly output_tokens: number; + }; + readonly elapsedMs: number; + } + | { + readonly status: "failed"; + readonly code: string; + readonly elapsedMs: number; + }; + +export type GuardStepToolInvocation = EchoToolInvocation; +export type GuardStepToolResult = EchoToolResult; +export type GuardStepModelInvocation = DecideBranch0ElseBranch0ElseBranch0ThenReplyModelInvocation | DecideBranch0ElseBranch0ElseBranch0ElseReplyModelInvocation; +export type GuardStepModelResult = DecideBranch0ElseBranch0ElseBranch0ThenReplyModelResult | DecideBranch0ElseBranch0ElseBranch0ElseReplyModelResult; + +export interface GuardStepWorkflowContracts { + readonly "Decide": { + readonly input: Message; + readonly output: Message; + readonly failure: Failure; + }; +} + +export type GuardStepWorkflowName = keyof GuardStepWorkflowContracts; +export type GuardStepWorkflowInput = GuardStepWorkflowContracts[Name]["input"]; +export type GuardStepWorkflowOutput = GuardStepWorkflowContracts[Name]["output"]; +export type GuardStepWorkflowFailure = GuardStepWorkflowContracts[Name]["failure"]; + +export type GuardStepCapability = + | "echo"; + +export interface GuardStepHost { + readonly schemaVersion: 1; + readonly workflow?: GuardStepWorkflowName; + readonly grantedCapabilities: readonly GuardStepCapability[]; + readonly pricing: __GuardStepPricing; + readonly tools: { + invoke(request: GuardStepToolInvocation): Promise; + }; + readonly model: { + generate(request: GuardStepModelInvocation): Promise; + }; +} diff --git a/examples/branching/decide.guard b/examples/branching/decide.guard new file mode 100644 index 0000000..2ec3499 --- /dev/null +++ b/examples/branching/decide.guard @@ -0,0 +1,39 @@ +enum Failure { EMPTY DENIED TIMEOUT ERROR INVALID DURATION COST } +record Message { text: String } +tool echo(text: String) -> Message + +workflow Decide(input: Message) -> Message fails Failure { + capabilities { echo else DENIED } + limits { + tool_calls <= 1 + model_calls <= 1 + duration <= 1s else DURATION + cost <= 0 USD else COST + } + + if input.text == "" { + fail EMPTY + } else if input.text == "cached" { + return input + } else if input.text == "local" { + reply = generate Message using model("mock") { + instructions: "Echo the supplied text." + context: { text: input.text } + } + on error => fail ERROR + on invalid => fail INVALID + return reply + } else { + original = call echo(text: input.text) + on timeout => fail TIMEOUT + on error => fail ERROR + on invalid => fail INVALID + reply = generate Message using model("mock") { + instructions: "Echo the supplied text." + context: { text: original.text } + } + on error => fail ERROR + on invalid => fail INVALID + return reply + } +} diff --git a/examples/branching/decide.host.mjs b/examples/branching/decide.host.mjs new file mode 100644 index 0000000..ffeefc9 --- /dev/null +++ b/examples/branching/decide.host.mjs @@ -0,0 +1,31 @@ +// @ts-check + +/** @satisfies {import("./decide.generated.js").GuardStepHost} */ +const host = { + schemaVersion: 1, + workflow: "Decide", + grantedCapabilities: ["echo"], + pricing: { + currency: "USD", + input_usd_per_million: 0, + output_usd_per_million: 0, + source: "keyless-branch-demo", + effective_date: "2026-09-07", + }, + tools: { + async invoke({ arguments: args }) { + return { status: "succeeded", value: { text: `[tool] ${args.text}` }, elapsedMs: 0 }; + }, + }, + model: { + async generate({ context }) { + // This is a deterministic fixture, not an external LLM request. + return { + status: "succeeded", value: { text: `[mock model] ${context.text}` }, + usage: { input_tokens: 0, output_tokens: 0 }, elapsedMs: 0, + }; + }, + }, +}; + +export default host; diff --git a/examples/branching/decide.input.json b/examples/branching/decide.input.json new file mode 100644 index 0000000..a558b1c --- /dev/null +++ b/examples/branching/decide.input.json @@ -0,0 +1 @@ +{ "text": "hello" } diff --git a/examples/branching/decide.test.mjs b/examples/branching/decide.test.mjs new file mode 100644 index 0000000..aca9199 --- /dev/null +++ b/examples/branching/decide.test.mjs @@ -0,0 +1,23 @@ +import host from "./decide.host.mjs"; + +const cases = [ + { id: "empty-fails", text: "", grants: [], output: undefined, errorCode: "EMPTY", effects: [] }, + { id: "cached-returns-without-effects", text: "cached", grants: [], output: "cached", effects: [] }, + { id: "local-model-only", text: "local", grants: [], output: "[mock model] local", effects: ["model.started", "model.succeeded"] }, + { id: "tool-then-model", text: "hello", grants: ["echo"], output: "[mock model] [tool] hello", effects: ["capability.checked", "tool.started", "tool.succeeded", "model.started", "model.succeeded"] }, + { id: "selected-tool-denied", text: "hello", grants: [], errorCode: "DENIED", effects: ["capability.checked"] }, +]; + +export default { + schemaVersion: 1, + workflow: "Decide", + cases: cases.map(({ id, text, grants, output, errorCode, effects }) => ({ + id, input: { text }, grantedCapabilities: grants, + pricing: host.pricing, tools: host.tools, model: host.model, + expect: { + status: errorCode === undefined ? "succeeded" : "failed", + ...(errorCode === undefined ? { output: { text: output } } : { errorCode }), + eventTypes: ["run.started", ...effects, errorCode === undefined ? "run.succeeded" : "run.failed"], + }, + })), +}; diff --git a/examples/branching/tsconfig.json b/examples/branching/tsconfig.json new file mode 100644 index 0000000..347a207 --- /dev/null +++ b/examples/branching/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "checkJs": true + }, + "include": ["decide.generated.ts", "decide.host.mjs"] +} diff --git a/fixtures/diagnostics/incomplete-branch.guard b/fixtures/diagnostics/incomplete-branch.guard new file mode 100644 index 0000000..84f153a --- /dev/null +++ b/fixtures/diagnostics/incomplete-branch.guard @@ -0,0 +1,14 @@ +enum Failure { STOP DURATION COST } +record Input { text: String } +workflow Incomplete(input: Input) -> Input fails Failure { + capabilities {} + limits { + tool_calls <= 0 + model_calls <= 0 + duration <= 1s else DURATION + cost <= 0 USD else COST + } + if input.text == "yes" { + return input + } +} diff --git a/fixtures/diagnostics/manifest.json b/fixtures/diagnostics/manifest.json index f57433a..2139a1e 100644 --- a/fixtures/diagnostics/manifest.json +++ b/fixtures/diagnostics/manifest.json @@ -1,6 +1,42 @@ { "schemaVersion": 1, "fixtures": [ + { + "id": "incomplete-branch", + "file": "incomplete-branch.guard", + "diagnostics": [{ + "code": "GS2202", "severity": "error", + "message": "Workflow 'Incomplete' must return or fail on every path", + "range": { + "start": { "offset": 66, "line": 3, "column": 1 }, + "end": { "offset": 309, "line": 14, "column": 2 } + } + }] + }, + { + "id": "non-boolean-branch", + "file": "non-boolean-branch.guard", + "diagnostics": [{ + "code": "GS2201", "severity": "error", + "message": "Branch condition must be boolean, received String", + "range": { + "start": { "offset": 262, "line": 11, "column": 3 }, + "end": { "offset": 323, "line": 15, "column": 4 } + } + }] + }, + { + "id": "unreachable-branch", + "file": "unreachable-branch.guard", + "diagnostics": [{ + "code": "GS2203", "severity": "error", + "message": "Unreachable step after a terminal return, fail, or branch", + "range": { + "start": { "offset": 336, "line": 16, "column": 3 }, + "end": { "offset": 348, "line": 16, "column": 15 } + } + }] + }, { "id": "undeclared-capability", "file": "undeclared-capability.guard", diff --git a/fixtures/diagnostics/non-boolean-branch.guard b/fixtures/diagnostics/non-boolean-branch.guard new file mode 100644 index 0000000..6b58d6a --- /dev/null +++ b/fixtures/diagnostics/non-boolean-branch.guard @@ -0,0 +1,16 @@ +enum Failure { STOP DURATION COST } +record Input { text: String } +workflow NonBoolean(input: Input) -> Input fails Failure { + capabilities {} + limits { + tool_calls <= 0 + model_calls <= 0 + duration <= 1s else DURATION + cost <= 0 USD else COST + } + if input.text { + return input + } else { + fail STOP + } +} diff --git a/fixtures/diagnostics/unreachable-branch.guard b/fixtures/diagnostics/unreachable-branch.guard new file mode 100644 index 0000000..9dbd764 --- /dev/null +++ b/fixtures/diagnostics/unreachable-branch.guard @@ -0,0 +1,17 @@ +enum Failure { STOP DURATION COST } +record Input { text: String } +workflow Unreachable(input: Input) -> Input fails Failure { + capabilities {} + limits { + tool_calls <= 0 + model_calls <= 0 + duration <= 1s else DURATION + cost <= 0 USD else COST + } + if input.text == "yes" { + return input + } else { + fail STOP + } + return input +} diff --git a/package.json b/package.json index 0f2221a..ed55173 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ ], "scripts": { "build": "npm run build --workspace guardstep", - "check": "npm run check --workspace guardstep && npm run build --silent && tsc --project examples/document-qa/tsconfig.json", - "check:generated": "npm run build --silent && ./gs generate examples/document-qa/answer.guard --check", + "check": "npm run check --workspace guardstep && npm run build --silent && tsc --project examples/document-qa/tsconfig.json && tsc --project examples/branching/tsconfig.json", + "check:generated": "npm run build --silent && ./gs generate examples/document-qa/answer.guard --check && ./gs generate examples/branching/decide.guard --check", "check:release": "node scripts/verify-release.mjs", "demo:ollama": "npm run build --silent && ./gs run examples/document-qa/answer.guard --host examples/document-qa/answer.ollama.host.mjs", "test": "npm run test --workspace guardstep", diff --git a/packages/guardstep/src/codegen/typescript.ts b/packages/guardstep/src/codegen/typescript.ts index 54ceed3..d1e6dc7 100644 --- a/packages/guardstep/src/codegen/typescript.ts +++ b/packages/guardstep/src/codegen/typescript.ts @@ -5,8 +5,10 @@ import type { ModelStep, ToolDeclaration, TypeReference, - WorkflowIrV1, + WorkflowIr, + WorkflowStep, } from "../ir/index.js"; +import { assertSupportedIr } from "../ir/control-flow.js"; import { GUARDSTEP_VERSION } from "../version.js"; export class TypeScriptGenerationError extends Error { @@ -57,7 +59,7 @@ const pascalCase = (value: string): string => const toolSymbol = (tool: ToolDeclaration): string => `${pascalCase(tool.name)}Tool`; const modelSymbol = (workflowName: string, step: ModelStep): string => - `${pascalCase(workflowName)}${pascalCase(step.assign)}Model`; + `${pascalCase(workflowName)}${pascalCase(step.step_id.split("/").slice(1, -1).join("/"))}${pascalCase(step.assign)}Model`; type InferredType = | TypeReference @@ -73,7 +75,7 @@ const renderInferredType = (type: InferredType): string => { const inferExpressionType = ( expression: Expression, environment: ReadonlyMap, - ir: WorkflowIrV1, + ir: WorkflowIr, ): InferredType => { if (expression.kind === "identifier") { const variable = environment.get(expression.name); @@ -105,31 +107,37 @@ interface ModelContract { readonly context: Readonly>; } -const collectModelContracts = (ir: WorkflowIrV1): readonly ModelContract[] => { +const collectModelContracts = (ir: WorkflowIr): readonly ModelContract[] => { const contracts: ModelContract[] = []; for (const workflow of ir.workflows) { const environment = new Map([ [workflow.input.parameter, { kind: "named", name: workflow.input.type }], ]); - for (const step of workflow.steps) { - if (step.kind === "tool") { - const tool = ir.declarations.tools.find(({ name }) => name === step.tool); - if (tool === undefined) throw new TypeScriptGenerationError(`IR references unknown tool '${step.tool}'`); - environment.set(step.assign, tool.output); - } else if (step.kind === "model") { - contracts.push({ - symbol: modelSymbol(workflow.name, step), - step, - context: Object.fromEntries( - Object.entries(step.context).map(([name, expression]) => [ - name, - inferExpressionType(expression, environment, ir), - ]), - ), - }); - environment.set(step.assign, { kind: "named", name: step.output_type }); + const visit = (steps: readonly WorkflowStep[], environment: Map): void => { + for (const step of steps) { + if (step.kind === "tool") { + const tool = ir.declarations.tools.find(({ name }) => name === step.tool); + if (tool === undefined) throw new TypeScriptGenerationError(`IR references unknown tool '${step.tool}'`); + environment.set(step.assign, tool.output); + } else if (step.kind === "model") { + contracts.push({ + symbol: modelSymbol(workflow.name, step), + step, + context: Object.fromEntries( + Object.entries(step.context).map(([name, expression]) => [ + name, + inferExpressionType(expression, environment, ir), + ]), + ), + }); + environment.set(step.assign, { kind: "named", name: step.output_type }); + } else if (step.kind === "branch") { + visit(step.then, new Map(environment)); + visit(step.else, new Map(environment)); + } } - } + }; + visit(workflow.steps, environment); } return contracts; }; @@ -200,7 +208,7 @@ const renderUnion = (values: readonly string[], indent = " "): string => ? "never" : `\n${values.map((value) => `${indent}| ${JSON.stringify(value)}`).join("\n")}`; -const validateNames = (ir: WorkflowIrV1, modelContracts: readonly ModelContract[]): void => { +const validateNames = (ir: WorkflowIr, modelContracts: readonly ModelContract[]): void => { const declaredNames = [ ...ir.declarations.enums.map(({ name }) => name), ...ir.declarations.records.map(({ name }) => name), @@ -242,7 +250,8 @@ const validateNames = (ir: WorkflowIrV1, modelContracts: readonly ModelContract[ } }; -export const generateTypeScript = (ir: WorkflowIrV1): string => { +export const generateTypeScript = (ir: WorkflowIr): string => { + assertSupportedIr(ir); const modelContracts = collectModelContracts(ir); validateNames(ir, modelContracts); const lines: string[] = [ diff --git a/packages/guardstep/src/compiler/compiler.ts b/packages/guardstep/src/compiler/compiler.ts index 0f06147..69bb7ed 100644 --- a/packages/guardstep/src/compiler/compiler.ts +++ b/packages/guardstep/src/compiler/compiler.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; -import type { WorkflowIrV1 } from "../ir/index.js"; +import type { WorkflowIr, WorkflowIrV1 } from "../ir/index.js"; import { GuardStepDiagnosticError } from "./diagnostics.js"; import { Lexer } from "./lexer.js"; import { Parser } from "./parser.js"; @@ -11,14 +11,15 @@ export interface CompileOptions { readonly sourcePath: string; } -export const compileSource = ({ source, sourcePath }: CompileOptions): WorkflowIrV1 => { +export const compileSource = ({ source, sourcePath }: CompileOptions): WorkflowIr => { const tokens = new Lexer(source, sourcePath).tokenize(); const parsed = new Parser(tokens, sourcePath).parse(); const diagnostics = new SemanticAnalyzer(parsed, sourcePath).analyze(); if (diagnostics.length > 0) throw new GuardStepDiagnosticError(diagnostics); - const ir: WorkflowIrV1 = { - schema_version: 1, + const hasControlFlow = parsed.workflows.some((workflow) => + workflow.steps.some((step) => step.kind === "branch" || step.kind === "fail")); + const common = { source: { path: sourcePath, sha256: `sha256:${createHash("sha256").update(source).digest("hex")}`, @@ -30,6 +31,10 @@ export const compileSource = ({ source, sourcePath }: CompileOptions): WorkflowI }, workflows: parsed.workflows, }; + // The version-1 cast is safe after excluding all control-flow roots above. + const ir: WorkflowIr = hasControlFlow + ? { schema_version: 2, ...common } + : { schema_version: 1, ...common } as WorkflowIrV1; const shapeErrors = validateIrShape(ir); if (shapeErrors.length > 0) throw new Error(`Compiler produced invalid IR: ${shapeErrors.join("; ")}`); diff --git a/packages/guardstep/src/compiler/parser.ts b/packages/guardstep/src/compiler/parser.ts index c5f3abe..4daaa43 100644 --- a/packages/guardstep/src/compiler/parser.ts +++ b/packages/guardstep/src/compiler/parser.ts @@ -1,5 +1,7 @@ import type { AssertionStep, + BranchStep, + FailStep, CapabilityPolicy, EnumDeclaration, Expression, @@ -116,14 +118,8 @@ export class Parser { } else if (this.matchValue("limits")) { if (limits !== undefined) this.fail(this.previous(), "GS1003", "Duplicate limits block"); limits = this.parseLimits(); - } else if (this.matchValue("require")) { - steps.push(this.parseAssertion(this.previous())); - } else if (this.matchValue("return")) { - steps.push(this.parseReturn(this.previous())); - } else if (this.checkKind("identifier") && this.peek(1).value === "=") { - steps.push(this.parseAssignment()); } else { - this.fail(this.current(), "GS1004", "Expected workflow policy block or step"); + steps.push(this.parseStep()); } } @@ -138,14 +134,62 @@ export class Parser { failures: failures.value, capabilities, limits, - steps: steps.map((step) => ({ - ...step, - step_id: `${name.value}/${step.step_id}`, - })), + steps: this.qualifySteps(steps, name.value), source: this.range(start, end), }; } + private qualifySteps( + steps: readonly WorkflowStep[], + prefix: string, + controlFlow = steps.some((step) => step.kind === "branch" || step.kind === "fail"), + ): readonly WorkflowStep[] { + return steps.map((step, index) => { + const localId = step.kind === "branch" ? `branch:${index}` + : controlFlow && step.kind === "assertion" ? `assertion:${step.error}:${index}` : step.step_id; + const stepId = `${prefix}/${localId}`; + if (step.kind !== "branch") return { ...step, step_id: stepId }; + return { + ...step, step_id: stepId, + then: this.qualifySteps(step.then, `${stepId}/then`, controlFlow), + else: this.qualifySteps(step.else, `${stepId}/else`, controlFlow), + }; + }); + } + + private parseStep(): WorkflowStep { + // Preserve existing assignment identifiers even when they spell new keywords. + if (this.checkKind("identifier") && this.peek(1).value === "=") return this.parseAssignment(); + if (this.matchValue("require")) return this.parseAssertion(this.previous()); + if (this.matchValue("return")) return this.parseReturn(this.previous()); + if (this.matchValue("fail")) return this.parseFail(this.previous()); + if (this.matchValue("if")) return this.parseBranch(this.previous()); + this.fail(this.current(), "GS1004", "Expected workflow step"); + } + + private parseBlock(): readonly WorkflowStep[] { + this.consumeValue("{", "Expected '{' before branch body"); + const steps: WorkflowStep[] = []; + while (!this.checkValue("}")) steps.push(this.parseStep()); + this.consumeValue("}", "Expected '}' after branch body"); + return steps; + } + + private parseBranch(start: Token): BranchStep { + const condition = this.parseExpression(); + const yes = this.parseBlock(); + let no: readonly WorkflowStep[] = []; + if (this.matchValue("else")) { + no = this.matchValue("if") ? [this.parseBranch(this.previous())] : this.parseBlock(); + } + return { kind: "branch", step_id: "branch", condition, then: yes, else: no, source: this.range(start, this.previous()) }; + } + + private parseFail(start: Token): FailStep { + const error = this.consumeKind("identifier", "Expected workflow failure code"); + return { kind: "fail", step_id: `fail:${error.value}`, error: error.value, source: this.range(start, error) }; + } + private parseCapabilities(): readonly CapabilityPolicy[] { this.consumeValue("{", "Expected '{' after capabilities"); const capabilities: CapabilityPolicy[] = []; diff --git a/packages/guardstep/src/compiler/semantic.ts b/packages/guardstep/src/compiler/semantic.ts index 567afc8..4275c6a 100644 --- a/packages/guardstep/src/compiler/semantic.ts +++ b/packages/guardstep/src/compiler/semantic.ts @@ -3,8 +3,10 @@ import type { SourceRange, TypeReference, WorkflowDeclaration, - WorkflowIrV1, + WorkflowIr, + WorkflowStep, } from "../ir/index.js"; +import { assertSupportedIr, summarizeFlow } from "../ir/control-flow.js"; import type { Diagnostic } from "./diagnostics.js"; import type { ParsedProgram } from "./parser.js"; @@ -149,9 +151,9 @@ export class SemanticAnalyzer { const variables = new Map([ [workflow.input.parameter, { kind: "named", name: workflow.input.type }], ]); - let returnCount = 0; - const toolStepCount = workflow.steps.filter(({ kind }) => kind === "tool").length; - const modelStepCount = workflow.steps.filter(({ kind }) => kind === "model").length; + const flow = summarizeFlow(workflow.steps); + const toolStepCount = flow.maximum.tool; + const modelStepCount = flow.maximum.model; if (toolStepCount > workflow.limits.tool_calls) { this.error( "GS2005", @@ -167,86 +169,102 @@ export class SemanticAnalyzer { ); } - for (const [index, step] of workflow.steps.entries()) { - if (returnCount > 0) { - this.error("GS2203", "A workflow cannot contain a step after return", step.source); - } - if (step.kind === "tool") { - const tool = this.tools.get(step.tool); - if (tool === undefined) { - this.error("GS2001", `Call references unknown tool '${step.tool}'`, step.source); - } - if (!capabilityNames.includes(step.tool)) { - this.error("GS2002", `Tool '${step.tool}' is called without a declared capability`, step.source); - } - validateFailure(step.timeout_error, step.source); - validateFailure(step.error_error, step.source); - validateFailure(step.invalid_error, step.source); - if (variables.has(step.assign)) { - this.error("GS2103", `Variable '${step.assign}' is already defined`, step.source); + const validateSteps = (steps: readonly WorkflowStep[], variables: Map): boolean => { + let terminated = false; + for (const step of steps) { + if (terminated) { + this.error("GS2203", "Unreachable step after a terminal return, fail, or branch", step.source); } - if (tool !== undefined) { - const supplied = Object.keys(step.arguments); - const expected = tool.parameters.map(({ name }) => name); - for (const name of expected) { - if (!(name in step.arguments)) this.error("GS2104", `Missing argument '${name}' for ${tool.name}`, step.source); + if (step.kind === "branch") { + const condition = this.inferExpression(step.condition, variables, step.source); + if (condition.kind !== "boolean" && condition.kind !== "unknown") { + this.error("GS2201", `Branch condition must be boolean, received ${typeKey(condition)}`, step.source); } - for (const name of supplied) { - if (!expected.includes(name)) this.error("GS2104", `Unknown argument '${name}' for ${tool.name}`, step.source); + const yesTerminates = validateSteps(step.then, new Map(variables)); + const noTerminates = validateSteps(step.else, new Map(variables)); + terminated = terminated || (yesTerminates && noTerminates); + } else if (step.kind === "fail") { + validateFailure(step.error, step.source); + terminated = true; + } else if (step.kind === "tool") { + const tool = this.tools.get(step.tool); + if (tool === undefined) { + this.error("GS2001", `Call references unknown tool '${step.tool}'`, step.source); } - for (const parameter of tool.parameters) { - const argument = step.arguments[parameter.name]; - if (argument !== undefined) { - const actual = this.inferExpression(argument, variables, step.source); - if (!sameType(actual, parameter.type)) { - this.error( - "GS2105", - `Argument '${parameter.name}' expects ${typeKey(parameter.type)}, received ${typeKey(actual)}`, - step.source, - ); + if (!capabilityNames.includes(step.tool)) { + this.error("GS2002", `Tool '${step.tool}' is called without a declared capability`, step.source); + } + validateFailure(step.timeout_error, step.source); + validateFailure(step.error_error, step.source); + validateFailure(step.invalid_error, step.source); + if (variables.has(step.assign)) { + this.error("GS2103", `Variable '${step.assign}' is already defined`, step.source); + } + if (tool !== undefined) { + const supplied = Object.keys(step.arguments); + const expected = tool.parameters.map(({ name }) => name); + for (const name of expected) { + if (!(name in step.arguments)) this.error("GS2104", `Missing argument '${name}' for ${tool.name}`, step.source); + } + for (const name of supplied) { + if (!expected.includes(name)) this.error("GS2104", `Unknown argument '${name}' for ${tool.name}`, step.source); + } + for (const parameter of tool.parameters) { + const argument = step.arguments[parameter.name]; + if (argument !== undefined) { + const actual = this.inferExpression(argument, variables, step.source); + if (!sameType(actual, parameter.type)) { + this.error( + "GS2105", + `Argument '${parameter.name}' expects ${typeKey(parameter.type)}, received ${typeKey(actual)}`, + step.source, + ); + } } } + variables.set(step.assign, tool.output); + } + } else if (step.kind === "model") { + if (this.typeDeclarations.get(step.output_type) !== "record") { + this.error("GS1202", `Generated type '${step.output_type}' must be a record`, step.source); + } + validateFailure(step.error_error, step.source); + validateFailure(step.invalid_error, step.source); + if (variables.has(step.assign)) { + this.error("GS2103", `Variable '${step.assign}' is already defined`, step.source); + } + for (const expression of Object.values(step.context)) { + this.inferExpression(expression, variables, step.source); + } + variables.set(step.assign, { kind: "named", name: step.output_type }); + } else if (step.kind === "assertion") { + validateFailure(step.error, step.source); + const condition = this.inferExpression(step.condition, variables, step.source); + if (condition.kind !== "boolean" && condition.kind !== "unknown") { + this.error("GS2201", `Assertion must be boolean, received ${typeKey(condition)}`, step.source); + } + } else { + terminated = true; + const returned = this.inferExpression(step.value, variables, step.source); + const expected: TypeReference = { kind: "named", name: workflow.output }; + if (!sameType(returned, expected)) { + this.error( + "GS2202", + `Workflow returns ${typeKey(returned)}, expected ${workflow.output}`, + step.source, + ); } - variables.set(step.assign, tool.output); - } - } else if (step.kind === "model") { - if (this.typeDeclarations.get(step.output_type) !== "record") { - this.error("GS1202", `Generated type '${step.output_type}' must be a record`, step.source); - } - validateFailure(step.error_error, step.source); - validateFailure(step.invalid_error, step.source); - if (variables.has(step.assign)) { - this.error("GS2103", `Variable '${step.assign}' is already defined`, step.source); - } - for (const expression of Object.values(step.context)) { - this.inferExpression(expression, variables, step.source); - } - variables.set(step.assign, { kind: "named", name: step.output_type }); - } else if (step.kind === "assertion") { - validateFailure(step.error, step.source); - const condition = this.inferExpression(step.condition, variables, step.source); - if (condition.kind !== "boolean" && condition.kind !== "unknown") { - this.error("GS2201", `Assertion must be boolean, received ${typeKey(condition)}`, step.source); - } - } else { - returnCount += 1; - const returned = this.inferExpression(step.value, variables, step.source); - const expected: TypeReference = { kind: "named", name: workflow.output }; - if (!sameType(returned, expected)) { - this.error( - "GS2202", - `Workflow returns ${typeKey(returned)}, expected ${workflow.output}`, - step.source, - ); - } - if (index !== workflow.steps.length - 1) { - this.error("GS2203", "Return must be the final workflow step", step.source); } } - } + return terminated; + }; - if (returnCount === 0) this.error("GS2202", `Workflow '${workflow.name}' has no return`, workflow.source); - if (returnCount > 1) this.error("GS2202", `Workflow '${workflow.name}' has multiple returns`, workflow.source); + if (!validateSteps(workflow.steps, variables)) { + const hasControlFlow = workflow.steps.some((step) => step.kind === "branch" || step.kind === "fail"); + this.error("GS2202", hasControlFlow + ? `Workflow '${workflow.name}' must return or fail on every path` + : `Workflow '${workflow.name}' has no return`, workflow.source); + } } private inferExpression( @@ -360,9 +378,11 @@ export class SemanticAnalyzer { } } -export const validateIrShape = (ir: WorkflowIrV1): readonly string[] => { +export const validateIrShape = (ir: WorkflowIr): readonly string[] => { const errors: string[] = []; - if (ir.schema_version !== 1) errors.push("schema_version must be 1"); + try { assertSupportedIr(ir); } catch (error) { + errors.push(error instanceof Error ? error.message : "Unsupported IR"); + } if (ir.workflows.length === 0) errors.push("IR must contain at least one workflow"); if (!/^sha256:[0-9a-f]{64}$/.test(ir.source.sha256)) errors.push("source.sha256 is invalid"); return errors; diff --git a/packages/guardstep/src/ir/control-flow.ts b/packages/guardstep/src/ir/control-flow.ts new file mode 100644 index 0000000..390c3dc --- /dev/null +++ b/packages/guardstep/src/ir/control-flow.ts @@ -0,0 +1,61 @@ +import type { WorkflowIr, WorkflowStep } from "./types.js"; + +interface Calls { + readonly tool: number; + readonly model: number; +} + +interface FlowSummary { + readonly maximum: Calls; + readonly continuing: Calls | undefined; +} + +const zero = (): Calls => ({ tool: 0, model: 0 }); +const add = (a: Calls, b: Calls): Calls => ({ tool: a.tool + b.tool, model: a.model + b.model }); +const max = (a: Calls, b: Calls): Calls => ({ tool: Math.max(a.tool, b.tool), model: Math.max(a.model, b.model) }); + +/** Conservative per-resource path bounds, without enumerating combinations of arms. */ +export const summarizeFlow = (steps: readonly WorkflowStep[]): FlowSummary => { + let maximum = zero(); + let continuing: Calls | undefined = zero(); + for (const step of steps) { + if (continuing === undefined) break; + if (step.kind === "branch") { + const yes = summarizeFlow(step.then); + const no = summarizeFlow(step.else); + maximum = max(maximum, add(continuing, max(yes.maximum, no.maximum))); + const armContinuation = yes.continuing === undefined ? no.continuing + : no.continuing === undefined ? yes.continuing : max(yes.continuing, no.continuing); + continuing = armContinuation === undefined ? undefined : add(continuing, armContinuation); + } else if (step.kind === "return" || step.kind === "fail") { + maximum = max(maximum, continuing); + continuing = undefined; + } else { + continuing = add(continuing, { tool: Number(step.kind === "tool"), model: Number(step.kind === "model") }); + maximum = max(maximum, continuing); + } + } + return { maximum, continuing }; +}; + +/** Version/node compatibility guard, not a validator for untrusted arbitrary JSON. */ +export const assertSupportedIr = (ir: WorkflowIr): void => { + if (ir.schema_version !== 1 && ir.schema_version !== 2) { + throw new Error("Unsupported workflow IR version; expected 1 or 2"); + } + const pending: WorkflowStep[] = ir.workflows.flatMap((workflow) => [...workflow.steps]); + while (pending.length > 0) { + const step = pending.pop()!; + switch (step.kind) { + case "branch": + if (ir.schema_version !== 2) throw new Error("Branch steps require workflow IR version 2"); + pending.push(...step.then, ...step.else); + break; + case "fail": + if (ir.schema_version !== 2) throw new Error("Fail steps require workflow IR version 2"); + break; + case "tool": case "model": case "assertion": case "return": break; + default: throw new Error("Unsupported workflow IR step"); + } + } +}; diff --git a/packages/guardstep/src/ir/types.ts b/packages/guardstep/src/ir/types.ts index 83f95b6..633fbe1 100644 --- a/packages/guardstep/src/ir/types.ts +++ b/packages/guardstep/src/ir/types.ts @@ -121,9 +121,26 @@ export interface ReturnStep { readonly source: SourceRange; } -export type WorkflowStep = ToolStep | ModelStep | AssertionStep | ReturnStep; +export interface FailStep { + readonly kind: "fail"; + readonly step_id: string; + readonly error: string; + readonly source: SourceRange; +} -export interface WorkflowDeclaration { +export interface BranchStep { + readonly kind: "branch"; + readonly step_id: string; + readonly condition: Expression; + readonly then: readonly WorkflowStep[]; + readonly else: readonly WorkflowStep[]; + readonly source: SourceRange; +} + +export type LinearWorkflowStep = ToolStep | ModelStep | AssertionStep | ReturnStep; +export type WorkflowStep = LinearWorkflowStep | FailStep | BranchStep; + +export interface WorkflowDeclaration { readonly name: string; readonly input: { readonly parameter: string; @@ -133,7 +150,7 @@ export interface WorkflowDeclaration { readonly failures: string; readonly capabilities: readonly CapabilityPolicy[]; readonly limits: WorkflowLimits; - readonly steps: readonly WorkflowStep[]; + readonly steps: readonly Step[]; readonly source: SourceRange; } @@ -148,5 +165,12 @@ export interface WorkflowIrV1 { readonly records: readonly RecordDeclaration[]; readonly tools: readonly ToolDeclaration[]; }; + readonly workflows: readonly WorkflowDeclaration[]; +} + +export interface WorkflowIrV2 extends Omit { + readonly schema_version: 2; readonly workflows: readonly WorkflowDeclaration[]; } + +export type WorkflowIr = WorkflowIrV1 | WorkflowIrV2; diff --git a/packages/guardstep/src/runtime/contracts.ts b/packages/guardstep/src/runtime/contracts.ts index b4034be..2091066 100644 --- a/packages/guardstep/src/runtime/contracts.ts +++ b/packages/guardstep/src/runtime/contracts.ts @@ -1,4 +1,4 @@ -import type { WorkflowIrV1 } from "../ir/index.js"; +import type { WorkflowIr } from "../ir/index.js"; export interface TokenUsage { readonly input_tokens: number; @@ -90,7 +90,7 @@ export interface PortableEvent { } export interface ExecuteOptions { - readonly ir: WorkflowIrV1; + readonly ir: WorkflowIr; readonly workflow: string; readonly runId: string; readonly input: unknown; diff --git a/packages/guardstep/src/runtime/deadline.ts b/packages/guardstep/src/runtime/deadline.ts index 41652af..e5d5f30 100644 --- a/packages/guardstep/src/runtime/deadline.ts +++ b/packages/guardstep/src/runtime/deadline.ts @@ -50,10 +50,21 @@ export const invokeBeforeDeadline = async ( ): Promise> => { const startedAt = clock.now(); const controller = new AbortController(); + if (startedAt >= deadlineAt) { + controller.abort(); + return { status: "deadline_exceeded", measuredElapsedMs: 0 }; + } const invocation = Promise.resolve() - .then(async () => await invoke(controller.signal)) + .then(async () => { + if (controller.signal.aborted || clock.now() >= deadlineAt) { + controller.abort(); + return { status: "deadline_exceeded", measuredElapsedMs: elapsedSince(clock, startedAt) } as const; + } + const value = await invoke(controller.signal); + return { status: "returned", value, measuredElapsedMs: elapsedSince(clock, startedAt) } as const; + }) .then, DeadlineOutcome>( - (value) => ({ status: "returned", value, measuredElapsedMs: elapsedSince(clock, startedAt) }), + (outcome) => outcome, () => ({ status: "threw", measuredElapsedMs: elapsedSince(clock, startedAt) }), ); diff --git a/packages/guardstep/src/runtime/engine.ts b/packages/guardstep/src/runtime/engine.ts index bc520b5..5a4251f 100644 --- a/packages/guardstep/src/runtime/engine.ts +++ b/packages/guardstep/src/runtime/engine.ts @@ -1,4 +1,5 @@ -import type { TypeReference, WorkflowDeclaration } from "../ir/index.js"; +import type { TypeReference, WorkflowDeclaration, WorkflowStep } from "../ir/index.js"; +import { assertSupportedIr } from "../ir/control-flow.js"; import type { ExecuteOptions, ModelResult, @@ -26,6 +27,12 @@ const calculateCost = (usage: TokenUsage, pricing: Pricing): number => const named = (name: string): TypeReference => ({ kind: "named", name }); const isNonNegativeFinite = (value: number): boolean => Number.isFinite(value) && value >= 0; +interface ExecutionFrame { + readonly steps: readonly WorkflowStep[]; + readonly environment: Map; + index: number; +} + const validatePricing = (pricing: Pricing, expectedCurrency: string): void => { if (pricing.currency !== expectedCurrency) { throw new RuntimeConfigurationError( @@ -49,6 +56,7 @@ const validatePricing = (pricing: Pricing, expectedCurrency: string): void => { }; export const executeWorkflow = async (options: ExecuteOptions): Promise => { + assertSupportedIr(options.ir); const workflow = options.ir.workflows.find(({ name }) => name === options.workflow); if (workflow === undefined) throw new Error(`Workflow not found: ${options.workflow}`); @@ -62,7 +70,9 @@ export const executeWorkflow = async (options: ExecuteOptions): Promise([[workflow.input.parameter, options.input]]); + const frames: ExecutionFrame[] = [ + { steps: workflow.steps, index: 0, environment: new Map([[workflow.input.parameter, options.input]]) }, + ]; const capabilityPolicies = new Map( workflow.capabilities.map((capability) => [capability.name, capability]), ); @@ -124,12 +134,26 @@ export const executeWorkflow = async (options: ExecuteOptions): Promise 0) { + const frame = frames[frames.length - 1]!; + const step = frame.steps[frame.index++]; + if (step === undefined) { + frames.pop(); + continue; + } + const environment = frame.environment; + if (step.kind === "tool" || step.kind === "model" || step.kind === "branch" || step.kind === "fail") { const budgetFailure = enforceDeferredBudgets(); if (budgetFailure !== undefined) return budgetFailure; } + if (step.kind === "branch") { + const selected = values.requireBoolean(values.evaluate(step.condition, environment)); + frames.push({ steps: selected ? step.then : step.else, index: 0, environment: new Map(environment) }); + continue; + } + if (step.kind === "fail") return fail(step.error); + if (step.kind === "tool") { const capability = capabilityPolicies.get(step.tool); if (capability === undefined) throw new Error(`Compiled IR omitted capability for ${step.tool}`); @@ -327,6 +351,8 @@ export const executeWorkflow = async (options: ExecuteOptions): Promise => @@ -19,7 +19,7 @@ export class ValueSystem { private readonly enums = new Map(); private readonly enumValues = new Set(); - constructor(ir: WorkflowIrV1) { + constructor(ir: WorkflowIr) { for (const record of ir.declarations.records) this.records.set(record.name, record); for (const declaration of ir.declarations.enums) { this.enums.set(declaration.name, declaration); diff --git a/packages/guardstep/src/test/branching.test.ts b/packages/guardstep/src/test/branching.test.ts new file mode 100644 index 0000000..52f92a5 --- /dev/null +++ b/packages/guardstep/src/test/branching.test.ts @@ -0,0 +1,325 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { compileSource, GuardStepDiagnosticError } from "../compiler/index.js"; +import { generateTypeScript } from "../codegen/index.js"; +import type { WorkflowIr, WorkflowIrV2, WorkflowStep } from "../ir/index.js"; +import { summarizeFlow } from "../ir/control-flow.js"; +import { executeWorkflow } from "../runtime/index.js"; +import type { ExecuteOptions, RuntimeClock } from "../runtime/index.js"; +import { invokeBeforeDeadline } from "../runtime/deadline.js"; + +const source = (body: string, tools = 2, models = 2): string => ` +enum Failure { DENIED TIMEOUT ERROR INVALID DURATION COST STOP } +record Input { text: String } +record Other { text: String } +tool echo(text: String) -> Input +tool other(text: String) -> Other +workflow Decide(input: Input) -> Input fails Failure { + capabilities { echo else DENIED other else DENIED } + limits { + tool_calls <= ${tools} + model_calls <= ${models} + duration <= 100ms else DURATION + cost <= 1 USD else COST + } + ${body} +}`; + +const call = (name: string, tool = "echo"): string => + `${name} = call ${tool}(text: input.text) on timeout => fail TIMEOUT on error => fail ERROR on invalid => fail INVALID`; +const model = (name: string, context = "input.text"): string => + `${name} = generate Input using model("local") { instructions: "Echo" context: { text: ${context} } } on error => fail ERROR on invalid => fail INVALID`; +const compile = (body: string, tools = 2, models = 2): WorkflowIr => + compileSource({ source: source(body, tools, models), sourcePath: "branch.guard" }); +const invalid = (body: string, code: string): void => { + assert.throws(() => compile(body), (error: unknown) => { + assert.ok(error instanceof GuardStepDiagnosticError); + const diagnostic = error.diagnostics.find((entry) => entry.code === code); + assert.ok(diagnostic, JSON.stringify(error.diagnostics)); + assert.equal(diagnostic.sourcePath, "branch.guard"); + assert.ok(diagnostic.range.start.line > 0); + assert.ok(diagnostic.range.end.offset >= diagnostic.range.start.offset); + if (diagnostic.range.end.offset === diagnostic.range.start.offset) { + assert.equal(diagnostic.range.start.offset, source(body).length, "Only EOF should have an empty range"); + } + return true; + }); +}; + +const options = (ir: WorkflowIr, text = "yes"): ExecuteOptions => ({ + ir, workflow: "Decide", runId: "branch-test", input: { text }, + grantedCapabilities: new Set(["echo", "other"]), + pricing: { currency: "USD", input_usd_per_million: 1_000_000, output_usd_per_million: 0, source: "test", effective_date: "2026-09-07" }, + tools: { invoke: async ({ arguments: args }) => ({ status: "succeeded", value: { text: args.text }, elapsedMs: 0 }) }, + model: { generate: async ({ context }) => ({ status: "succeeded", value: { text: context.text }, usage: { input_tokens: 0, output_tokens: 0 }, elapsedMs: 0 }) }, +}); +const failure = async (opts: ExecuteOptions, code: string): Promise => { + const run = await executeWorkflow(opts); + assert.equal(run.status, "failed"); + if (run.status !== "failed") assert.fail("Expected failure"); + assert.equal(run.error_code, code); + assert.equal(run.events.filter((event) => event.type === "run.failed").length, 1); + assert.equal(run.events.at(-1)?.type, "run.failed"); +}; + +for (const [body, code] of [ + ['if input.text { return input } else { fail STOP }', "GS2201"], + ['if input.text == "yes" { return input }', "GS2202"], + ['if true { return input }', "GS2202"], + ['if true { return input } else { if false { fail STOP } }', "GS2202"], + ['if true { return input } else { fail STOP } return input', "GS2203"], + ['if true { fail STOP return input } else { return input }', "GS2203"], + ['fail STOP return input', "GS2203"], + ['if false { fail MISSING } else { return input }', "GS2003"], + [`if true { ${call("result", "other")} return result } else { return input }`, "GS2202"], + [`if true { ${call("result")} } return result`, "GS2101"], + [`if true { ${call("result")} return result } else { return result }`, "GS2101"], + [`if true { ${call("input")} return input } else { return input }`, "GS2103"], + ['if call echo(text: input.text) { return input } else { return input }', "GS2004"], + ['if true { capabilities {} return input } else { return input }', "GS1004"], + ['if true { limits {} return input } else { return input }', "GS1004"], + ['if true { return input', "GS1004"], +] as const) { + test(`branch diagnostics ${code}: ${body}`, () => invalid(body, code)); +} + +test("branches preserve declared capability checking even in the unselected arm", () => { + const program = source(`if false { ${call("result")} return result } else { return input }`) + .replace("echo else DENIED", ""); + assert.throws(() => compileSource({ source: program, sourcePath: "branch.guard" }), + (error: unknown) => error instanceof GuardStepDiagnosticError && error.diagnostics.some((d) => d.code === "GS2002")); +}); + +test("v2 lowering is deterministic, serializable, and uses distinct arm paths", async () => { + const body = `if input.text == "yes" { ${call("result")} return result } else { ${call("result")} return result }`; + const ir = compile(body, 1); + assert.equal(ir.schema_version, 2); + assert.deepEqual(ir, compile(body, 1)); + const roundtrip: WorkflowIr = JSON.parse(JSON.stringify(ir)); + assert.deepEqual(roundtrip, ir); + const branch = ir.workflows[0]!.steps[0]!; + assert.equal(branch.kind, "branch"); + if (branch.kind !== "branch") assert.fail("Expected branch"); + assert.equal(branch.step_id, "Decide/branch:0"); + assert.equal(branch.then[0]!.step_id, "Decide/branch:0/then/tool:result"); + assert.equal(branch.else[0]!.step_id, "Decide/branch:0/else/tool:result"); + for (const text of ["yes", "no"]) { + let calls = 0; + const opts = options(roundtrip, text); + const run = await executeWorkflow({ ...opts, tools: { invoke: async (request) => { + calls++; + assert.match(request.stepId, text === "yes" ? /\/then\// : /\/else\//); + return opts.tools.invoke(request); + } } }); + assert.equal(calls, 1); + assert.equal(run.status, "succeeded"); + if (run.status === "succeeded") assert.deepEqual(run.output, { text }); + } +}); + +test("nested else-if and explicit failure terminate the whole workflow", async () => { + const ir = compile('if input.text == "yes" { if true { return input } else { fail ERROR } } else if input.text == "no" { fail STOP } else { return input }'); + for (const text of ["yes", "other"]) assert.equal((await executeWorkflow(options(ir, text))).status, "succeeded"); + await failure(options(ir, "no"), "STOP"); + await failure(options(compile("fail STOP")), "STOP"); +}); + +test("an early return skips continuation effects", async () => { + const ir = compile(`if input.text == "yes" { return input } ${call("result")} return result`, 1); + await executeWorkflow({ ...options(ir), tools: { invoke: async () => assert.fail("must not dispatch") } }); + assert.equal((await executeWorkflow(options(ir, "no"))).status, "succeeded"); +}); + +test("arm-local bindings can be reused after the branch without escaping", async () => { + const ir = compile(`if true { ${call("result", "other")} } ${call("result")} return result`); + assert.equal((await executeWorkflow(options(ir))).status, "succeeded"); +}); + +test("path bounds count mutually exclusive arms and exclude terminal continuations", () => { + const body = `if input.text == "yes" { ${call("a")} ${call("b")} return b } ${call("c")} ${call("d")} return d`; + assert.deepEqual(summarizeFlow(compile(body, 2).workflows[0]!.steps).maximum, { tool: 2, model: 0 }); + const accumulated = `if true { ${call("a")} } if false { ${call("b")} } return input`; + assert.throws(() => compile(accumulated, 1), GuardStepDiagnosticError); + const models = `if true { ${model("a")} return a } else { ${model("b")} return b }`; + assert.deepEqual(summarizeFlow(compile(models, 0, 1).workflows[0]!.steps).maximum, { tool: 0, model: 1 }); + assert.throws(() => compile(`if true { ${model("a")} } ${model("b")} return b`, 0, 1), GuardStepDiagnosticError); +}); + +test("many sequential branches are summarized without enumerating paths", () => { + const body = Array.from({ length: 100 }, () => "if true {} else {}").join("\n") + "\nreturn input"; + assert.deepEqual(summarizeFlow(compile(body, 0, 0).workflows[0]!.steps).maximum, { tool: 0, model: 0 }); +}); + +test("runtime grants apply only to selected effects", async () => { + const ir = compile(`if input.text == "yes" { ${call("result")} return result } else { return input }`); + const denied = { ...options(ir), grantedCapabilities: new Set(), tools: { invoke: async () => assert.fail("denied tool ran") } }; + await failure(denied, "DENIED"); + assert.equal((await executeWorkflow({ ...denied, input: { text: "no" } })).status, "succeeded"); +}); + +test("selected branch failures retain tool, model, assertion and validation mappings", async () => { + const toolIr = compile(`if true { ${call("a")} return a } else { return input }`); + for (const kind of ["timeout", "error"] as const) { + await failure({ ...options(toolIr), tools: { invoke: async () => ({ status: "failed", kind, elapsedMs: 0 }) } }, kind === "timeout" ? "TIMEOUT" : "ERROR"); + } + await failure({ ...options(toolIr), tools: { invoke: async () => ({ status: "succeeded", value: {}, elapsedMs: 0 }) } }, "INVALID"); + const modelIr = compile(`if true { ${model("a")} return a } else { return input }`); + await failure({ ...options(modelIr), model: { generate: async () => ({ status: "failed", code: "private", elapsedMs: 0 }) } }, "ERROR"); + await failure({ ...options(modelIr), model: { generate: async () => ({ status: "succeeded", value: {}, usage: { input_tokens: 0, output_tokens: 0 }, elapsedMs: 0 }) } }, "INVALID"); + await failure(options(compile("if true { require false else STOP return input } else { return input }")), "STOP"); +}); + +const withCallLimit = (ir: WorkflowIr, field: "tool_calls" | "model_calls"): WorkflowIrV2 => ({ + ...ir, schema_version: 2, + workflows: ir.workflows.map((workflow) => ({ ...workflow, limits: { ...workflow.limits, [field]: 1 } })), +}); + +test("runtime counters are shared across branch entry and exit", async () => { + let tools = 0; + const toolIr = withCallLimit(compile(`${call("a")} if true { ${call("b")} return b } else { return a }`), "tool_calls"); + const toolOptions = options(toolIr); + await failure({ ...toolOptions, tools: { invoke: async (request) => { tools++; return toolOptions.tools.invoke(request); } } }, "TOOL_CALL_LIMIT_EXCEEDED"); + assert.equal(tools, 1); + let models = 0; + const modelIr = withCallLimit(compile(`if true { ${model("a")} } ${model("b")} return b`), "model_calls"); + const modelOptions = options(modelIr); + await failure({ ...modelOptions, model: { generate: async (request) => { models++; return modelOptions.model.generate(request); } } }, "MODEL_CALL_LIMIT_EXCEEDED"); + assert.equal(models, 1); +}); + +test("cost and accounted time do not reset in branches or before explicit failure", async () => { + const ir = compile(`if true { ${model("a")} } if true { ${call("b")} return b } else { fail STOP }`); + for (const [elapsedMs, inputTokens, code] of [[101, 0, "DURATION"], [0, 2, "COST"]] as const) { + const opts = { ...options(ir), + tools: { invoke: async () => assert.fail("over-budget effect ran") }, + model: { generate: async () => ({ status: "succeeded" as const, value: { text: "yes" }, usage: { input_tokens: inputTokens, output_tokens: 0 }, elapsedMs }) }, + }; + await failure(opts, code); + await failure({ ...opts, ir: compile(`if true { ${model("a")} fail STOP } else { return input }`) }, code); + } +}); + +test("two selected arms accumulate model charges", async () => { + let calls = 0; + const ir = compile(`if true { ${model("a")} } if true { ${model("b")} } return input`); + await failure({ ...options(ir), model: { generate: async () => { + calls++; + return { status: "succeeded", value: { text: "yes" }, usage: { input_tokens: 1, output_tokens: 0 }, elapsedMs: 0 }; + } } }, "COST"); + assert.equal(calls, 2); +}); + +test("a selected hanging model is aborted by the shared workflow deadline", async () => { + let aborted = false; + const ir = compile(`if true { ${model("a")} return a } else { return input }`); + await failure({ ...options(ir), model: { generate: async ({ signal }) => { + signal.addEventListener("abort", () => { aborted = true; }, { once: true }); + return new Promise(() => {}); + } } }, "DURATION"); + assert.equal(aborted, true); +}); + +test("deadline equality after a branch effect cannot dispatch the next adapter", async () => { + let now = 0; + let calls = 0; + const clock: RuntimeClock = { now: () => now, schedule: () => () => {} }; + const ir = compile(`if true { ${call("a")} } if true { ${call("b")} return b } else { return input }`); + await failure({ ...options(ir), clock, tools: { invoke: async () => { + calls++; + now = 100; + return { status: "succeeded", value: { text: "yes" }, elapsedMs: 100 }; + } } }, "DURATION"); + assert.equal(calls, 1); +}); + +test("deadline helper checks expiry both before scheduling and at microtask dispatch", async () => { + let now = 10; + const clock: RuntimeClock = { now: () => now, schedule: () => () => {} }; + assert.equal((await invokeBeforeDeadline(clock, 10, async () => assert.fail("expired adapter ran"))).status, "deadline_exceeded"); + now = 0; + const pending = invokeBeforeDeadline(clock, 10, async () => assert.fail("late adapter ran")); + now = 10; + assert.equal((await pending).status, "deadline_exceeded"); +}); + +test("runtime rejects unsupported IR versions and v1 control flow before effects", async () => { + const ir = compile("if true { return input } else { fail STOP }"); + for (const schema_version of [1, 99]) { + const invalidIr = { ...ir, schema_version } as unknown as WorkflowIr; + await assert.rejects(executeWorkflow(options(invalidIr)), /IR version|version 2/); + assert.throws(() => generateTypeScript(invalidIr), /IR version|version 2/); + } +}); + +test("generated model contracts follow lexical environments in sibling and nested arms", () => { + const ir = compile(`if true { ${call("local")} if true { ${model("answer", "local.text")} return answer } else { return input } } else { ${model("answer")} return answer }`); + const generated = generateTypeScript(ir); + assert.equal(generated, generateTypeScript(ir)); + assert.match(generated, /DecideBranch0ThenBranch1ThenAnswerModelInvocation/); + assert.match(generated, /DecideBranch0ElseAnswerModelInvocation/); + assert.match(generated, /readonly "text": string;/); + assert.match(generated, /Decide\/branch:0\/then\/branch:1\/then\/model:answer/); +}); + +test("all nested step IDs are unique when siblings reuse assignment names", () => { + const ir = compile(`if true { ${model("answer")} return answer } else { ${model("answer")} return answer }`); + const ids: string[] = []; + const walk = (steps: readonly WorkflowStep[]): void => { + for (const step of steps) { ids.push(step.step_id); if (step.kind === "branch") { walk(step.then); walk(step.else); } } + }; + walk(ir.workflows[0]!.steps); + assert.equal(new Set(ids).size, ids.length); +}); + +test("new control-flow keywords do not break legacy assignment identifiers", async () => { + for (const name of ["if", "fail"]) { + const ir = compile(`${call(name)} return ${name}`, 1, 0); + assert.equal(ir.schema_version, 1); + assert.equal((await executeWorkflow(options(ir))).status, "succeeded"); + } +}); + +test("a final return at the exact accounting boundary remains allowed", async () => { + let now = 0; + const ir = compile(`if true { ${call("a")} return a } else { return input }`); + const run = await executeWorkflow({ + ...options(ir), clock: { now: () => now, schedule: () => () => {} }, + tools: { invoke: async () => { + now = 100; + return { status: "succeeded", value: { text: "yes" }, elapsedMs: 100 }; + } }, + }); + assert.equal(run.status, "succeeded"); +}); + +test("time and cost are rechecked when returning from nested branches", async () => { + const ir = compile(`if true { if true { ${model("a")} return a } else { fail STOP } } else { return input }`); + await failure({ ...options(ir), model: { generate: async () => ({ + status: "succeeded", value: { text: "yes" }, usage: { input_tokens: 2, output_tokens: 0 }, elapsedMs: 0, + }) } }, "COST"); + await failure({ ...options(ir), model: { generate: async () => ({ + status: "succeeded", value: { text: "yes" }, usage: { input_tokens: 0, output_tokens: 0 }, elapsedMs: 101, + }) } }, "DURATION"); +}); + +test("v2 assertions on the same source line have distinct IDs", () => { + const ir = compile("require true else STOP require true else STOP if true { require true else STOP require true else STOP return input } else { fail STOP }"); + const steps = ir.workflows[0]!.steps; + assert.notEqual(steps[0]!.step_id, steps[1]!.step_id); + const branch = steps[2]!; + if (branch.kind !== "branch") assert.fail("Expected branch"); + assert.notEqual(branch.then[0]!.step_id, branch.then[1]!.step_id); +}); + +test("adapter-reported duration accumulates across sibling branches", async () => { + const ir = compile(`if true { ${model("a")} } if true { ${model("b")} } ${call("c")} return c`); + let models = 0; + await failure({ ...options(ir), + tools: { invoke: async () => assert.fail("effect ran after accumulated duration was exhausted") }, + model: { generate: async () => { + models++; + return { status: "succeeded", value: { text: "yes" }, usage: { input_tokens: 0, output_tokens: 0 }, elapsedMs: 60 }; + } }, + }, "DURATION"); + assert.equal(models, 2); +}); diff --git a/packages/guardstep/src/test/cli.test.ts b/packages/guardstep/src/test/cli.test.ts index fa35476..b8377fc 100644 --- a/packages/guardstep/src/test/cli.test.ts +++ b/packages/guardstep/src/test/cli.test.ts @@ -93,3 +93,16 @@ test("diagnostics are source-located and produce a non-zero exit", () => { rmSync(directory, { recursive: true, force: true }); } }); + +test("branching example compiles to v2 and runs through the CLI and fixture runner", () => { + const example = `${repositoryRoot}examples/branching/decide.guard`; + for (const command of ["check", "compile", "run", "test"] as const) { + const result = runCli([command, example]); + assert.equal(result.status, 0, result.stderr); + if (command === "compile") assert.equal(JSON.parse(result.stdout).schema_version, 2); + if (command === "run") assert.deepEqual(JSON.parse(result.stdout).output, { text: "[mock model] [tool] hello" }); + if (command === "test") assert.match(result.stdout, /5\/5 scenarios passed/); + } + const generated = runCli(["generate", example, "--check"]); + assert.equal(generated.status, 0, generated.stderr); +}); diff --git a/packages/guardstep/src/test/codegen.test.ts b/packages/guardstep/src/test/codegen.test.ts index 168e4cc..2e86c75 100644 --- a/packages/guardstep/src/test/codegen.test.ts +++ b/packages/guardstep/src/test/codegen.test.ts @@ -5,7 +5,7 @@ import test from "node:test"; import { generateTypeScript, TypeScriptGenerationError } from "../codegen/index.js"; import { compileSource } from "../compiler/index.js"; -import type { WorkflowIrV1 } from "../ir/index.js"; +import type { WorkflowIr } from "../ir/index.js"; import { GUARDSTEP_VERSION } from "../version.js"; const repositoryRoot = fileURLToPath(new URL("../../../../", import.meta.url)); @@ -37,7 +37,7 @@ test("generates deterministic portable TypeScript contracts", () => { }); test("rejects declarations that collide with generated API names", () => { - const conflicting: WorkflowIrV1 = { + const conflicting: WorkflowIr = { ...ir, declarations: { ...ir.declarations, diff --git a/packages/guardstep/src/test/runtime.test.ts b/packages/guardstep/src/test/runtime.test.ts index 01a3f3c..5cf41b0 100644 --- a/packages/guardstep/src/test/runtime.test.ts +++ b/packages/guardstep/src/test/runtime.test.ts @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { compileSource } from "../compiler/index.js"; -import type { WorkflowIrV1 } from "../ir/index.js"; +import type { WorkflowIr } from "../ir/index.js"; import { executeWorkflow, RuntimeConfigurationError, @@ -23,7 +23,9 @@ const pricing = { effective_date: "2026-08-31", }; -const withDuration = (maximumMs: number): WorkflowIrV1 => ({ +const withDuration = (maximumMs: number): WorkflowIr => { + if (ir.schema_version !== 1) assert.fail("Expected legacy linear IR"); + return { ...ir, workflows: ir.workflows.map((workflow) => ({ ...workflow, @@ -32,7 +34,8 @@ const withDuration = (maximumMs: number): WorkflowIrV1 => ({ duration: { ...workflow.limits.duration, maximum_ms: maximumMs }, }, })), -}); + }; +}; test("rejects invalid public input before emitting workflow events", async () => { await assert.rejects( diff --git a/packages/guardstep/src/testing/runner.ts b/packages/guardstep/src/testing/runner.ts index b0716b3..7ecaf81 100644 --- a/packages/guardstep/src/testing/runner.ts +++ b/packages/guardstep/src/testing/runner.ts @@ -1,11 +1,11 @@ import { isDeepStrictEqual } from "node:util"; -import type { WorkflowIrV1 } from "../ir/index.js"; +import type { WorkflowIr } from "../ir/index.js"; import { executeWorkflow } from "../runtime/index.js"; import type { GuardTestCaseResult, GuardTestSuite } from "./contracts.js"; export const runTestSuite = async ( - ir: WorkflowIrV1, + ir: WorkflowIr, suite: GuardTestSuite, ): Promise => { if (suite.schemaVersion !== 1) throw new Error(`Unsupported test suite version: ${suite.schemaVersion}`); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 1740ec7..6d74b01 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; import { + copyFileSync, lstatSync, mkdtempSync, mkdirSync, @@ -132,6 +133,12 @@ try { ); run(process.execPath, [importCheckPath], consumerRoot); + // Exercise v2 with the actual installed artifact, not workspace source imports. + for (const filename of ["decide.guard", "decide.host.mjs", "decide.input.json", "decide.test.mjs"]) { + copyFileSync(join(repositoryRoot, "examples", "branching", filename), join(consumerRoot, filename)); + } + run(process.execPath, [join(installedRoot, "dist", "cli", "main.js"), "generate", "decide.guard"], consumerRoot); + const typeCheckPath = join(consumerRoot, "verify-types.ts"); writeFileSync( typeCheckPath, @@ -153,7 +160,7 @@ try { strict: true, target: "ES2022", }, - files: ["verify-types.ts"], + files: ["verify-types.ts", "decide.generated.ts"], }, null, 2, @@ -186,6 +193,16 @@ try { if (output !== installedManifest.version) { fail(`${binaryName} reported ${JSON.stringify(output)} instead of ${installedManifest.version}`); } + const execution = JSON.parse(run(executablePath, ["run", "decide.guard"], consumerRoot, { + shell: process.platform === "win32", + })); + if (execution.status !== "succeeded" || execution.output?.text !== "[mock model] [tool] hello") { + fail(`${binaryName} did not execute the installed v2 branching example`); + } + const scenarios = run(executablePath, ["test", "decide.guard"], consumerRoot, { + shell: process.platform === "win32", + }); + if (!scenarios.includes("5/5 scenarios passed.")) fail(`${binaryName} did not pass the branching fixtures`); } console.log(