Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/DIAGNOSTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions fixtures/diagnostics/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
18 changes: 18 additions & 0 deletions fixtures/diagnostics/null-tool-argument.guard
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 9 additions & 2 deletions packages/guardstep/src/compiler/semantic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type InferredType =
| TypeReference
| { readonly kind: "boolean" }
| { readonly kind: "number" }
| { readonly kind: "null" }
| { readonly kind: "unknown" };

const typeKey = (type: InferredType): string => {
Expand All @@ -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[] = [];
Expand Down Expand Up @@ -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" };
Expand Down
83 changes: 83 additions & 0 deletions packages/guardstep/src/test/null-arguments.test.ts
Original file line number Diff line number Diff line change
@@ -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<String>", "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" });
});