diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md index 12a7164..8e0bb9a 100644 --- a/docs/DIAGNOSTICS.md +++ b/docs/DIAGNOSTICS.md @@ -16,6 +16,7 @@ The initial proof gates are: | Code | Condition | | --- | --- | +| `GS1101` | duplicate declaration or entry, including limits, tool arguments, and model context keys | | `GS1204` | unsupported duration unit | | `GS2002` | tool call without a declared capability | | `GS2003` | failure outside the workflow failure set | @@ -26,6 +27,14 @@ The initial proof gates are: When adding or intentionally changing a diagnostic, update the smallest relevant `.guard` fixture and its manifest entry in the same pull request. +## Duplicate entries + +Each limit name, named tool argument, and model context key may appear only +once in its block or call. A repeated key produces `GS1101` at that key's +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. + ## Comparison operands Ordering operators (`<`, `<=`, `>`, `>=`) require matching numeric or diff --git a/fixtures/diagnostics/duplicate-cost.guard b/fixtures/diagnostics/duplicate-cost.guard new file mode 100644 index 0000000..2e3d3da --- /dev/null +++ b/fixtures/diagnostics/duplicate-cost.guard @@ -0,0 +1,13 @@ +enum Failure { ASSERT DURATION COST } +record Input { text: String } +workflow Example(input: Input) -> Input fails Failure { + capabilities {} + limits { + tool_calls <= 0 + model_calls <= 0 + duration <= 1s else DURATION + cost <= 0 USD else COST + cost <= 1 USD else COST + } + return input +} diff --git a/fixtures/diagnostics/duplicate-duration.guard b/fixtures/diagnostics/duplicate-duration.guard new file mode 100644 index 0000000..a4c88fb --- /dev/null +++ b/fixtures/diagnostics/duplicate-duration.guard @@ -0,0 +1,13 @@ +enum Failure { ASSERT DURATION COST } +record Input { text: String } +workflow Example(input: Input) -> Input fails Failure { + capabilities {} + limits { + tool_calls <= 0 + model_calls <= 0 + duration <= 1s else DURATION + duration <= 2s else DURATION + cost <= 0 USD else COST + } + return input +} diff --git a/fixtures/diagnostics/duplicate-model-calls.guard b/fixtures/diagnostics/duplicate-model-calls.guard new file mode 100644 index 0000000..5fb3779 --- /dev/null +++ b/fixtures/diagnostics/duplicate-model-calls.guard @@ -0,0 +1,13 @@ +enum Failure { ASSERT DURATION COST } +record Input { text: String } +workflow Example(input: Input) -> Input fails Failure { + capabilities {} + limits { + tool_calls <= 0 + model_calls <= 0 + model_calls <= 1 + duration <= 1s else DURATION + cost <= 0 USD else COST + } + return input +} diff --git a/fixtures/diagnostics/duplicate-model-context.guard b/fixtures/diagnostics/duplicate-model-context.guard new file mode 100644 index 0000000..00964aa --- /dev/null +++ b/fixtures/diagnostics/duplicate-model-context.guard @@ -0,0 +1,21 @@ +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 + } + result = generate Input using model("test") { + instructions: "Return the input." + context: { + text: input.text + text: "overridden" + } + } + on error => fail ERROR + on invalid => fail INVALID + return result +} diff --git a/fixtures/diagnostics/duplicate-tool-argument.guard b/fixtures/diagnostics/duplicate-tool-argument.guard new file mode 100644 index 0000000..ee348c2 --- /dev/null +++ b/fixtures/diagnostics/duplicate-tool-argument.guard @@ -0,0 +1,17 @@ +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: input.text, text: "overridden") + on timeout => fail TIMEOUT + on error => fail ERROR + on invalid => fail INVALID + return result +} diff --git a/fixtures/diagnostics/duplicate-tool-calls.guard b/fixtures/diagnostics/duplicate-tool-calls.guard new file mode 100644 index 0000000..1dfa12c --- /dev/null +++ b/fixtures/diagnostics/duplicate-tool-calls.guard @@ -0,0 +1,13 @@ +enum Failure { ASSERT DURATION COST } +record Input { text: String } +workflow Example(input: Input) -> Input fails Failure { + capabilities {} + limits { + tool_calls <= 0 + tool_calls <= 1 + model_calls <= 0 + duration <= 1s else DURATION + cost <= 0 USD else COST + } + return input +} diff --git a/fixtures/diagnostics/manifest.json b/fixtures/diagnostics/manifest.json index 51671e6..f57433a 100644 --- a/fixtures/diagnostics/manifest.json +++ b/fixtures/diagnostics/manifest.json @@ -113,6 +113,144 @@ } } ] + }, + { + "id": "duplicate-tool-calls", + "file": "duplicate-tool-calls.guard", + "diagnostics": [ + { + "code": "GS1101", + "severity": "error", + "message": "Duplicate limit 'tool_calls'", + "range": { + "start": { + "offset": 177, + "line": 7, + "column": 5 + }, + "end": { + "offset": 187, + "line": 7, + "column": 15 + } + } + } + ] + }, + { + "id": "duplicate-model-calls", + "file": "duplicate-model-calls.guard", + "diagnostics": [ + { + "code": "GS1101", + "severity": "error", + "message": "Duplicate limit 'model_calls'", + "range": { + "start": { + "offset": 198, + "line": 8, + "column": 5 + }, + "end": { + "offset": 209, + "line": 8, + "column": 16 + } + } + } + ] + }, + { + "id": "duplicate-duration", + "file": "duplicate-duration.guard", + "diagnostics": [ + { + "code": "GS1101", + "severity": "error", + "message": "Duplicate limit 'duration'", + "range": { + "start": { + "offset": 231, + "line": 9, + "column": 5 + }, + "end": { + "offset": 239, + "line": 9, + "column": 13 + } + } + } + ] + }, + { + "id": "duplicate-cost", + "file": "duplicate-cost.guard", + "diagnostics": [ + { + "code": "GS1101", + "severity": "error", + "message": "Duplicate limit 'cost'", + "range": { + "start": { + "offset": 259, + "line": 10, + "column": 5 + }, + "end": { + "offset": 263, + "line": 10, + "column": 9 + } + } + } + ] + }, + { + "id": "duplicate-tool-argument", + "file": "duplicate-tool-argument.guard", + "diagnostics": [ + { + "code": "GS1101", + "severity": "error", + "message": "Duplicate tool argument 'text'", + "range": { + "start": { + "offset": 371, + "line": 12, + "column": 40 + }, + "end": { + "offset": 375, + "line": 12, + "column": 44 + } + } + } + ] + }, + { + "id": "duplicate-model-context", + "file": "duplicate-model-context.guard", + "diagnostics": [ + { + "code": "GS1101", + "severity": "error", + "message": "Duplicate model context key 'text'", + "range": { + "start": { + "offset": 396, + "line": 15, + "column": 7 + }, + "end": { + "offset": 400, + "line": 15, + "column": 11 + } + } + } + ] } ] } diff --git a/packages/guardstep/src/compiler/parser.ts b/packages/guardstep/src/compiler/parser.ts index 37378c8..c5f3abe 100644 --- a/packages/guardstep/src/compiler/parser.ts +++ b/packages/guardstep/src/compiler/parser.ts @@ -162,6 +162,7 @@ export class Parser { private parseLimits(): WorkflowLimits { this.consumeValue("{", "Expected '{' after limits"); + const names = new Set(); let toolCalls: number | undefined; let modelCalls: number | undefined; let duration: WorkflowLimits["duration"] | undefined; @@ -169,6 +170,8 @@ export class Parser { while (!this.checkValue("}")) { const limit = this.consumeKind("identifier", "Expected limit name"); + if (names.has(limit.value)) this.fail(limit, "GS1101", `Duplicate limit '${limit.value}'`); + names.add(limit.value); this.consumeValue("<=", "Expected '<=' after limit name"); const maximum = Number(this.consumeKind("number", "Expected numeric limit").value); if (limit.value === "tool_calls") toolCalls = maximum; @@ -213,8 +216,11 @@ export class Parser { const tool = this.parseQualifiedName(); this.consumeValue("(", "Expected '(' after tool name"); const argumentsMap: Record = {}; + const names = new Set(); while (!this.checkValue(")")) { const name = this.consumeKind("identifier", "Expected tool argument name"); + if (names.has(name.value)) this.fail(name, "GS1101", `Duplicate tool argument '${name.value}'`); + names.add(name.value); this.consumeValue(":", "Expected ':' after tool argument name"); argumentsMap[name.value] = this.parseExpression(); if (!this.matchValue(",")) break; @@ -264,8 +270,11 @@ export class Parser { this.consumeValue(":", "Expected ':' after context"); this.consumeValue("{", "Expected '{' before model context"); const context: Record = {}; + const names = new Set(); while (!this.checkValue("}")) { const name = this.consumeKind("identifier", "Expected context field name"); + if (names.has(name.value)) this.fail(name, "GS1101", `Duplicate model context key '${name.value}'`); + names.add(name.value); this.consumeValue(":", "Expected ':' after context field name"); context[name.value] = this.parseExpression(); this.matchValue(","); diff --git a/packages/guardstep/src/test/duplicate-entries.test.ts b/packages/guardstep/src/test/duplicate-entries.test.ts new file mode 100644 index 0000000..173db81 --- /dev/null +++ b/packages/guardstep/src/test/duplicate-entries.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { compileSource } from "../compiler/index.js"; + +const source = `enum Failure { DENIED TIMEOUT ERROR INVALID DURATION COST } +record Input { text: String } +tool echo(first: String, second: String) -> Input +workflow Example(input: Input) -> Input fails Failure { + capabilities { echo else DENIED } + limits { + cost <= 0 USD else COST + duration <= 1s else DURATION + model_calls <= 2 + tool_calls <= 2 + } + first = call echo(second: "suffix", first: input.text) + on timeout => fail TIMEOUT + on error => fail ERROR + on invalid => fail INVALID + second = call echo(first: first.text, second: "another") + on timeout => fail TIMEOUT + on error => fail ERROR + on invalid => fail INVALID + generated = generate Input using model("test") { + instructions: "Return the input." + context: { second: second.text, first: first.text } + } + on error => fail ERROR + on invalid => fail INVALID + result = generate Input using model("test") { + instructions: "Return the input." + context: { first: generated.text second: second.text } + } + on error => fail ERROR + on invalid => fail INVALID + return result +} +`; + +test("unique limits may be reordered and repeated in another workflow", () => { + const ir = compileSource({ + source: source + source.slice(source.indexOf("workflow")).replace("workflow Example", "workflow Another"), + sourcePath: "unique.guard", + }); + assert.equal(ir.workflows.length, 2); + for (const workflow of ir.workflows) { + assert.deepEqual(workflow.limits, { + tool_calls: 2, + model_calls: 2, + duration: { maximum_ms: 1000, error: "DURATION" }, + cost: { maximum: 0, currency: "USD", error: "COST" }, + }); + } +}); + +test("unique argument and context keys retain source order and may recur in other steps", () => { + const ir = compileSource({ source, sourcePath: "unique.guard" }); + const [first, second, generated, result] = ir.workflows[0]!.steps; + assert.ok(first?.kind === "tool" && second?.kind === "tool"); + assert.deepEqual(Object.keys(first.arguments), ["second", "first"]); + assert.deepEqual(Object.keys(second.arguments), ["first", "second"]); + assert.deepEqual(first.arguments.second, { kind: "literal", value: "suffix" }); + assert.deepEqual(second.arguments.second, { kind: "literal", value: "another" }); + assert.ok(generated?.kind === "model" && result?.kind === "model"); + assert.deepEqual(Object.keys(generated.context), ["second", "first"]); + assert.deepEqual(Object.keys(result.context), ["first", "second"]); + assert.deepEqual(result.context.first, { + kind: "member", target: { kind: "identifier", name: "generated" }, property: "text", + }); +});