diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md index 7462b43..33365eb 100644 --- a/docs/DIAGNOSTICS.md +++ b/docs/DIAGNOSTICS.md @@ -22,7 +22,7 @@ The initial proof gates are: | `GS2003` | failure outside the workflow failure set | | `GS2004` | effect attempted inside a pure expression | | `GS2102` | invalid field access | -| `GS2105` | incompatible expression operands, including unsupported ordering | +| `GS2105` | incompatible tool arguments or expression operands, including unsupported ordering | | `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 | @@ -37,6 +37,13 @@ source location before its value can replace the earlier entry. This applies even when both values are identical. Unique entries may be reordered, and names may be reused in separate calls, context blocks, or workflow limits. +## Null arguments + +`null` is inferred separately from `String` and is rejected for non-nullable +tool parameters with `GS2105`. The string `"null"` remains an ordinary string. +Explicit null values in model context remain supported; this does not add +nullable type declarations or change the grammar. + ## Comparison operands Ordering operators (`<`, `<=`, `>`, `>=`) require matching numeric or diff --git a/fixtures/diagnostics/manifest.json b/fixtures/diagnostics/manifest.json index 2139a1e..903e56f 100644 --- a/fixtures/diagnostics/manifest.json +++ b/fixtures/diagnostics/manifest.json @@ -1,6 +1,29 @@ { "schemaVersion": 1, "fixtures": [ + { + "id": "null-tool-argument", + "file": "null-tool-argument.guard", + "diagnostics": [ + { + "code": "GS2105", + "severity": "error", + "message": "Argument 'text' expects String, received null", + "range": { + "start": { + "offset": 335, + "line": 13, + "column": 3 + }, + "end": { + "offset": 454, + "line": 16, + "column": 31 + } + } + } + ] + }, { "id": "incomplete-branch", "file": "incomplete-branch.guard", diff --git a/fixtures/diagnostics/null-tool-argument.guard b/fixtures/diagnostics/null-tool-argument.guard new file mode 100644 index 0000000..c6a766b --- /dev/null +++ b/fixtures/diagnostics/null-tool-argument.guard @@ -0,0 +1,18 @@ +enum Failure { DENIED TIMEOUT ERROR INVALID DURATION COST } +record Input { text: String } +tool echo(text: String) -> Input + +workflow Example(input: Input) -> Input fails Failure { + capabilities { echo else DENIED } + limits { + tool_calls <= 1 + model_calls <= 0 + duration <= 1s else DURATION + cost <= 0 USD else COST + } + result = call echo(text: null) + on timeout => fail TIMEOUT + on error => fail ERROR + on invalid => fail INVALID + return result +} diff --git a/packages/guardstep/src/compiler/semantic.ts b/packages/guardstep/src/compiler/semantic.ts index 4275c6a..3ddb4da 100644 --- a/packages/guardstep/src/compiler/semantic.ts +++ b/packages/guardstep/src/compiler/semantic.ts @@ -14,6 +14,7 @@ type InferredType = | TypeReference | { readonly kind: "boolean" } | { readonly kind: "number" } + | { readonly kind: "null" } | { readonly kind: "unknown" }; const typeKey = (type: InferredType): string => { @@ -22,8 +23,13 @@ const typeKey = (type: InferredType): string => { return type.kind; }; -const sameType = (left: InferredType, right: InferredType): boolean => - left.kind === "unknown" || right.kind === "unknown" || typeKey(left) === typeKey(right); +const sameType = (left: InferredType, right: InferredType): boolean => { + if (left.kind === "unknown" || right.kind === "unknown") return true; + if (left.kind === "named" && right.kind === "named") return left.name === right.name; + if (left.kind === "list" && right.kind === "list") return sameType(left.element, right.element); + // Diagnostic display names are not type identities (e.g. a record named null). + return left.kind === right.kind; +}; export class SemanticAnalyzer { private readonly diagnostics: Diagnostic[] = []; @@ -273,6 +279,7 @@ export class SemanticAnalyzer { range: SourceRange, ): InferredType { if (expression.kind === "literal") { + if (expression.value === null) return { kind: "null" }; if (typeof expression.value === "number") return { kind: "number" }; if (typeof expression.value === "boolean") return { kind: "boolean" }; return { kind: "named", name: "String" }; diff --git a/packages/guardstep/src/test/null-arguments.test.ts b/packages/guardstep/src/test/null-arguments.test.ts new file mode 100644 index 0000000..561e15b --- /dev/null +++ b/packages/guardstep/src/test/null-arguments.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { compileSource, GuardStepDiagnosticError } from "../compiler/index.js"; +import { generateTypeScript } from "../codegen/index.js"; +import { executeWorkflow } from "../runtime/index.js"; + +const source = readFileSync(new URL("../../../../fixtures/diagnostics/null-tool-argument.guard", import.meta.url), "utf8"); +const compile = (text: string) => compileSource({ source: text, sourcePath: "null-arguments.guard" }); +const pricing = { + currency: "USD", input_usd_per_million: 0, output_usd_per_million: 0, + source: "test", effective_date: "2026-09-15", +}; + +for (const type of ["String", "Url", "Input", "List", "null"]) { + test(`rejects null for a non-nullable ${type} tool argument`, () => { + const text = `record null { text: String }\n${source.replace("tool echo(text: String)", `tool echo(text: ${type})`)}`; + assert.throws(() => compile(text), (error) => { + assert.ok(error instanceof GuardStepDiagnosticError); + assert.equal(error.diagnostics.length, 1); + assert.equal(error.diagnostics[0]?.code, "GS2105"); + assert.equal(error.diagnostics[0]?.message, `Argument 'text' expects ${type}, received null`); + return true; + }); + }); +} + +for (const argument of ['"hello"', '"null"', "input.text"]) { + test(`preserves String tool argument ${argument}`, async () => { + const ir = compile(source.replace("text: null", `text: ${argument}`)); + let calls = 0; + let received: unknown; + const run = await executeWorkflow({ + ir, workflow: "Example", runId: "string-argument", input: { text: "input value" }, + grantedCapabilities: new Set(["echo"]), pricing, + tools: { invoke: async (request) => { + calls += 1; + received = request.arguments.text; + return { status: "succeeded", value: { text: "ok" }, elapsedMs: 0 }; + } }, + model: { generate: async () => assert.fail("Model must not run") }, + }); + assert.equal(calls, 1); + assert.equal(received, argument === "input.text" ? "input value" : JSON.parse(argument)); + assert.equal(run.status, "succeeded"); + }); +} + +test("preserves null model context through IR, code generation and runtime", async () => { + const ir = compile(`enum Failure { ERROR INVALID DURATION COST } +record Input { text: String } +workflow Example(input: Input) -> Input fails Failure { + capabilities {} + limits { + tool_calls <= 0 + model_calls <= 1 + duration <= 1s else DURATION + cost <= 0 USD else COST + } + answer = generate Input using model("test") { + instructions: "Return a valid result." + context: { missing: null literal: "null" text: input.text } + } on error => fail ERROR on invalid => fail INVALID + return answer +}`); + assert.equal(ir.schema_version, 1); + assert.match(generateTypeScript(ir), /readonly "missing": null;/); + assert.match(generateTypeScript(ir), /readonly "literal": string;/); + let received: unknown; + const run = await executeWorkflow({ + ir: JSON.parse(JSON.stringify(ir)), workflow: "Example", runId: "null-context", + input: { text: "hello" }, grantedCapabilities: new Set(), pricing, + tools: { invoke: async () => assert.fail("Tool must not run") }, + model: { generate: async (request) => { + received = request.context; + return { status: "succeeded", value: { text: "ok" }, elapsedMs: 0, + usage: { input_tokens: 0, output_tokens: 0 } }; + } }, + }); + assert.equal(run.status, "succeeded"); + assert.deepEqual(received, { missing: null, literal: "null", text: "hello" }); +});