From e8766989fb18df865ba172aacbaf7cecfbad8e15 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Tue, 19 May 2026 05:16:48 -0300 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20THR002=20=E2=80=94=20undeclared?= =?UTF-8?q?=20error=20type=20construction=20(=3Fbs=200.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fires when a fn body contains err(TypeName(...)) or err(new TypeName(...)) where TypeName (CapCase ident) is absent from the fn's own throws {} clause. Producer-side complement to THR001: ensures the fn declares what it actually constructs, not just what transitively bubbles up. Callers' exhaustive match arms for the undeclared type would otherwise be permanently dead code. Scope: token-based, direct construction only. Indirect patterns (err(e) where e's type is inferred) are out of scope. Version-gated at ?bs 0.9. 11 new tests in thr-check.test.ts, 574/574 pass. Closes #65. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 14 +++ packages/compiler/src/error-codes.ts | 26 +++++ packages/compiler/src/passes/thr-check.ts | 130 +++++++++++++++++++--- packages/compiler/tests/thr-check.test.ts | 121 +++++++++++++++++++- packages/mcp/src/explanations.ts | 58 ++++++++++ packages/mcp/tests/server.test.ts | 2 + 6 files changed, 333 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d10b979..eef8aaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ goes behind a new pin. ## ?bs 0.9 — unreleased ### Added +- **THR001 — `throws {}` transitivity enforcement.** + From `?bs 0.9`, the compiler enforces that if fn A calls fn B (in the same + file) and B declares `throws { X }`, then A must also declare `throws { X }`. + The rule applies transitively to any call depth. Reading A's header now tells + you the complete exception surface without tracing the call graph manually. + Over-declaration is always allowed (conservative headers are harmless). + +- **THR002 — undeclared error type construction.** + From `?bs 0.9`, the compiler fires when a fn body contains + `err(TypeName(...))` or `err(new TypeName(...))` where TypeName (CapCase + ident) is absent from the fn's own `throws { }` clause. Catches the case + where a fn produces an error type its callers cannot match. Indirect + patterns (`err(e)` where `e`'s type is inferred) are out of scope. + - **DEP001 / DEP002 — `reads {}` / `writes {}` transitivity enforcement.** From `?bs 0.9`, the compiler enforces that if fn A calls fn B (in the same file) and B declares `reads { x }` (or `writes { x }`), then A must also diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 1e15f65..edb00d6 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -385,6 +385,32 @@ const E: Record = { "fn fetchRemote(id: string) throws { HttpError } -> string = id\n" + "fn loadUser(id: string) throws { HttpError } -> string = fetchRemote(id)", }, + THR002: { + code: "THR002", + title: "fn body constructs an error type not present in its throws declaration", + rule: + "if a fn body contains `err(TypeName(...))` or `err(new TypeName(...))` where TypeName " + + "(CapCase ident) is not in the fn's own `throws { }` set, the fn is producing an error " + + "callers cannot match — they will never see a TypeName arm", + idiom: + "add the constructed error type to the fn's `throws { }` clause so callers can exhaustively match it; " + + "indirect patterns like `err(e)` are out of scope — only direct constructor calls are checked", + rewrite: + "fn name(...) throws { …existing, UndeclaredError } -> ...", + example: + "// before — parseConfig constructs NetworkError but declares throws { ParseError }\n" + + "?bs 0.9\n" + + "fn parseConfig(s: string) throws { ParseError } -> Result {\n" + + " if (bad) err(NetworkError(\"timed out\")) // THR002: NetworkError not declared\n" + + " else ok(s)\n" + + "}\n\n" + + "// after\n" + + "?bs 0.9\n" + + "fn parseConfig(s: string) throws { ParseError, NetworkError } -> Result {\n" + + " if (bad) err(NetworkError(\"timed out\"))\n" + + " else ok(s)\n" + + "}", + }, }; export function getErrorCode(code: string): ErrorCodeEntry | undefined { diff --git a/packages/compiler/src/passes/thr-check.ts b/packages/compiler/src/passes/thr-check.ts index 0b7d4ea..2ab4f39 100644 --- a/packages/compiler/src/passes/thr-check.ts +++ b/packages/compiler/src/passes/thr-check.ts @@ -2,27 +2,24 @@ * Throws declaration check (?bs 0.9+). * * Enforces transitivity of `throws { ... }` annotations across same-file - * function calls. - * - * Rule: if fn A calls fn B (defined in the same file) and B declares - * `throws { X }`, then A must also declare `throws { X }`. - * - * This makes the failure surface of each fn complete from a caller's - * perspective — reading A's header tells you every exception type A (or - * anything it calls) may throw, without tracing through the call graph. + * function calls, and checks that a fn's body does not directly construct + * error types absent from its own `throws {}` declaration. * * THR001 throws under-declared: fn A calls fn B which (transitively) * declares `throws { X }` that A does not declare. For a direct * call the diagnostic says "'B' which throws { X }"; for a multi-hop * chain it names the path, e.g. "B -> C — 'C' throws { X }". * - * Only same-file call resolution is performed (same as cap-check / dep-check). - * Over-declaration is intentionally NOT checked — a caller may conservatively - * declare more exception types than it strictly needs. + * THR002 undeclared error construction: fn body contains `err(TypeName(...))` + * or `err(new TypeName(...))` where TypeName (CapCase ident) is not in + * the fn's own `throws {}` set. Catches the case where a fn returns + * an error type it never declared, leaving callers' exhaustive match + * arms permanently dead. Indirect patterns (`err(e)` where e's type + * is inferred) are out of scope — token-based detection only. * - * NOTE: This pass enforces transitivity only — it does NOT verify that a fn's - * body actually throws the types it declares (a leaf fn can lie). Body-level - * soundness requires the effect inference pass; see issue #14. + * Only same-file call resolution is performed for THR001 (same as cap-check / + * dep-check). Over-declaration is intentionally NOT checked — a caller may + * conservatively declare more exception types than it strictly needs. */ import { BotscriptError } from "../diagnostics.js"; @@ -115,16 +112,23 @@ export function passThrCheck(src: string, version: VersionInfo): string { } } - // Validate: declared throws must cover transitive throws. + // THR001: declared throws must cover transitive throws. for (const rec of records.values()) { const missing = [...rec.transitiveThrows.keys()] .filter((l) => !rec.declaredThrows.has(l)) .sort(); if (missing.length > 0) { - throw mkError(src, rec, missing); + throw mkThr001Error(src, rec, missing); } } + // THR002: fn body must not directly construct undeclared error types. + for (const rec of records.values()) { + const inner = innerByDecl.get(rec.decl) ?? []; + const err = checkBodyErrors(tokens, rec.decl, inner, rec.declaredThrows, src); + if (err) throw err; + } + return src; } @@ -143,7 +147,7 @@ function formatPath(path: ThrPath): string { return segments.join(" -> "); } -function mkError(src: string, rec: FnRecord, missingLabels: string[]): BotscriptError { +function mkThr001Error(src: string, rec: FnRecord, missingLabels: string[]): BotscriptError { const entry = getErrorCode("THR001")!; const { line, column } = locationOf(src, rec.decl.fnKeywordStart); @@ -200,3 +204,95 @@ function mkError(src: string, rec: FnRecord, missingLabels: string[]): Botscript return new BotscriptError([diagnostic]); } + +/** + * THR002: scan fn body for `err(TypeName(...))` or `err(new TypeName(...))` + * where TypeName (CapCase ident) is not in the fn's own `throws {}` set. + * Returns a BotscriptError on the first violation found, or null. + */ +function checkBodyErrors( + tokens: Token[], + fn: FnDecl, + inner: FnDecl[], + declaredThrows: Set, + src: string, +): BotscriptError | null { + const entry = getErrorCode("THR002")!; + + // Cursor-based inner-fn exclusion. + const open: FnDecl[] = []; + let nextInner = 0; + + for (let i = fn.tokenStart; i < fn.tokenEnd; i++) { + while (open.length > 0 && open[open.length - 1]!.tokenEnd <= i) open.pop(); + while (nextInner < inner.length && inner[nextInner]!.tokenStart <= i) { + open.push(inner[nextInner]!); + nextInner++; + } + if (open.length > 0) continue; + + const tok = tokens[i]; + // Look for `err` ident — must not be a property access. + if (!tok || tok.kind !== "ident" || tok.text !== "err") continue; + + const prevIdx = prevSignificant(tokens, i - 1); + const prev = tokens[prevIdx]; + if (prev && ((prev.kind === "punct" && prev.text === ".") || prev.kind === "questionDot")) continue; + + // Next must be `(` + const parenIdx = nextSignificant(tokens, i + 1); + const parenTok = tokens[parenIdx]; + if (!parenTok || parenTok.kind !== "open" || parenTok.text !== "(") continue; + + // Look at the first argument. + let argIdx = nextSignificant(tokens, parenIdx + 1); + let argTok = tokens[argIdx]; + + // Handle `err(new TypeName(...))` — skip `new` ident. + if (argTok && argTok.kind === "ident" && argTok.text === "new") { + argIdx = nextSignificant(tokens, argIdx + 1); + argTok = tokens[argIdx]; + } + + if (!argTok || argTok.kind !== "ident") continue; + const typeName = argTok.text; + + // CapCase: first character is an uppercase letter. + if (!/^[A-Z]/.test(typeName)) continue; + + // The token after the type name must be `(` (constructor call) or `)` (bare ref). + const afterIdx = nextSignificant(tokens, argIdx + 1); + const after = tokens[afterIdx]; + if (!after) continue; + const isCtor = after.kind === "open" && after.text === "("; + const isRef = after.kind === "close" && after.text === ")"; + if (!isCtor && !isRef) continue; + + // Already declared — fine. + if (declaredThrows.has(typeName)) continue; + + const { line, column } = locationOf(src, tok.start); + const currentDecl = + declaredThrows.size === 0 ? "(none)" : [...declaredThrows].join(", "); + const proposed = [...new Set([...declaredThrows, typeName])].join(", "); + const nameEnd = fn.nameStart + fn.name.length; + + return new BotscriptError([{ + code: "THR002", + severity: "error" as const, + file: null, + line, + column, + start: fn.fnKeywordStart, + end: nameEnd, + message: + `fn '${fn.name}' constructs err(${typeName}...) but '${typeName}' ` + + `is not in throws { ${currentDecl} }`, + rule: entry.rule, + idiom: entry.idiom, + rewrite: `fn ${fn.name}(...) throws { ${proposed} } -> ...`, + }]); + } + + return null; +} diff --git a/packages/compiler/tests/thr-check.test.ts b/packages/compiler/tests/thr-check.test.ts index 50c9470..7485a85 100644 --- a/packages/compiler/tests/thr-check.test.ts +++ b/packages/compiler/tests/thr-check.test.ts @@ -1,7 +1,8 @@ /** - * Tests for throws {} transitivity enforcement (?bs 0.9+). + * Tests for throws {} enforcement (?bs 0.9+). * * THR001: fn A calls fn B which throws { X }, but A doesn't declare throws { X }. + * THR002: fn body constructs err(TypeName(...)) where TypeName is not in throws {}. */ import { describe, expect, it } from "vitest"; @@ -174,3 +175,121 @@ describe("THR001: throws under-declared (0.9+)", () => { expect(() => compile(src)).not.toThrow(); }); }); + +// --------------------------------------------------------------------------- +// THR002: undeclared error construction +// --------------------------------------------------------------------------- + +describe("THR002: body constructs undeclared error type (0.9+)", () => { + it("fires when body calls err(CapCase(...)) not in throws {}", () => { + const src = + "?bs 0.9\n" + + "fn parseConfig(s: string) throws { ParseError } -> Result {\n" + + " if (bad) err(NetworkError(\"timed out\"))\n" + + " else ok(s)\n" + + "}\n"; + expect(() => compile(src)).toThrow("THR002"); + expect(() => compile(src)).toThrow(/NetworkError/); + }); + + it("fires when body calls err(CapCase) bare ref not in throws {}", () => { + const src = + "?bs 0.9\n" + + "fn build(s: string) -> Result {\n" + + " if (bad) err(BuildError)\n" + + " else ok(s)\n" + + "}\n"; + expect(() => compile(src)).toThrow("THR002"); + expect(() => compile(src)).toThrow(/BuildError/); + }); + + it("fires when body calls err(new CapCase(...)) not in throws {}", () => { + const src = + "?bs 0.9\n" + + "fn connect(url: string) throws { TimeoutError } -> Result {\n" + + " if (bad) err(new NetworkError(\"conn refused\"))\n" + + " else ok(url)\n" + + "}\n"; + expect(() => compile(src)).toThrow("THR002"); + expect(() => compile(src)).toThrow(/NetworkError/); + }); + + it("does not fire when the error type is declared in throws {}", () => { + const src = + "?bs 0.9\n" + + "fn parseConfig(s: string) throws { ParseError } -> Result {\n" + + " if (bad) err(ParseError(\"invalid\"))\n" + + " else ok(s)\n" + + "}\n"; + expect(() => compile(src)).not.toThrow(); + }); + + it("does not fire for lowercase err(e) patterns (indirect, out of scope)", () => { + const src = + "?bs 0.9\n" + + "fn wrap(s: string) -> Result {\n" + + " const e = \"something failed\"\n" + + " err(e)\n" + + "}\n"; + expect(() => compile(src)).not.toThrow(); + }); + + it("does not fire when the fn has no throws {} but body uses err(lowercase)", () => { + const src = + "?bs 0.9\n" + + "fn fail(s: string) -> Result = err(s)\n"; + expect(() => compile(src)).not.toThrow(); + }); + + it("does not fire below ?bs 0.9", () => { + const src = + "?bs 0.8\n" + + "fn parseConfig(s: string) throws { ParseError } -> Result {\n" + + " if (bad) err(NetworkError(\"timed out\"))\n" + + " else ok(s)\n" + + "}\n"; + expect(() => compile(src)).not.toThrow(); + }); + + it("does not fire for err as a property access (obj.err(...))", () => { + const src = + "?bs 0.9\n" + + "fn handle(logger: { err: (x: string) => void }, s: string) -> string {\n" + + " logger.err(BadInput(\"oops\"))\n" + + " s\n" + + "}\n"; + expect(() => compile(src)).not.toThrow(); + }); + + it("does not fire when declared throws covers multiple error types including the constructed one", () => { + const src = + "?bs 0.9\n" + + "fn fetch(url: string) throws { HttpError, NetworkError } -> Result {\n" + + " if (slow) err(NetworkError(\"timeout\"))\n" + + " else err(HttpError(\"404\"))\n" + + "}\n"; + expect(() => compile(src)).not.toThrow(); + }); + + it("includes the undeclared type name in the error message", () => { + const src = + "?bs 0.9\n" + + "fn parse(s: string) -> Result {\n" + + " err(SyntaxError(\"bad input\"))\n" + + "}\n"; + expect(() => compile(src)).toThrow(/SyntaxError/); + expect(() => compile(src)).toThrow(/THR002/); + }); + + it("does not fire for err call inside an inner fn that itself declares the type", () => { + const src = + "?bs 0.9\n" + + "fn outer(s: string) -> string {\n" + + " fn inner(x: string) throws { ParseError } -> Result {\n" + + " err(ParseError(\"bad\"))\n" + + " }\n" + + " s\n" + + "}\n"; + expect(() => compile(src)).not.toThrow(); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index e20893a..78fea74 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -463,6 +463,64 @@ export const EXPLANATIONS: Readonly> = { "fn recordEvent(id: string) writes { metrics } -> void { updateMetrics(id); }\n", }, }, + THR001: { + code: "THR001", + title: "fn transitively throws an exception type not declared in its header", + body: + "THR001 fires from `?bs 0.9` when fn A calls fn B (directly or transitively, same file) " + + "and B declares `throws { X }` that A's own `throws { }` clause does not include.\n\n" + + "The throws declaration is the caller's contract: reading A's header should tell you every " + + "exception type A (or anything it calls) may produce. Without the transitivity rule, callers " + + "of A see an incomplete failure surface — they match on A's declared throws and miss the " + + "types that bubble up from deeper in the call graph.\n\n" + + "Over-declaration is intentionally allowed: a fn may declare `throws { X, Y }` even if it " + + "only calls fns that throw `{ X }`. Conservative declarations are safe; under-declarations " + + "are not.\n\n" + + "THR001 is gated on `?bs 0.9`. Files pinned to earlier versions are unaffected.", + example: { + fails: + "?bs 0.9\n" + + "fn fetchRemote(id: string) throws { HttpError } -> string = id\n" + + "fn loadUser(id: string) -> string = fetchRemote(id)\n", + passes: + "?bs 0.9\n" + + "fn fetchRemote(id: string) throws { HttpError } -> string = id\n" + + "fn loadUser(id: string) throws { HttpError } -> string = fetchRemote(id)\n", + }, + }, + THR002: { + code: "THR002", + title: "fn body constructs an error type absent from its throws declaration", + body: + "THR002 fires from `?bs 0.9` when a fn body contains `err(TypeName(...))` or " + + "`err(new TypeName(...))` where TypeName (a CapCase identifier) is not present in " + + "the fn's own `throws { }` clause.\n\n" + + "This is the producer-side complement to THR001. THR001 ensures callers propagate the " + + "throws surface; THR002 ensures the fn actually declares what it produces. Without it, " + + "a fn can silently return an error type its callers cannot match — exhaustive match arms " + + "for the undeclared type will be permanently dead code.\n\n" + + "**Scope:** token-based detection only. Direct construction patterns are caught:\n" + + "- `err(HttpError(msg))` → detects `HttpError`\n" + + "- `err(new ParseError(...))` → detects `ParseError`\n" + + "- `err(BuildError)` → detects `BuildError` (bare ref, not a call)\n\n" + + "Indirect patterns (`err(e)` where `e` carries a type) require inference and are " + + "intentionally out of scope.\n\n" + + "THR002 is gated on `?bs 0.9`. Files pinned to earlier versions are unaffected.", + example: { + fails: + "?bs 0.9\n" + + "fn parseConfig(s: string) throws { ParseError } -> Result {\n" + + " if (bad) err(NetworkError(\"timed out\"))\n" + + " else ok(s)\n" + + "}\n", + passes: + "?bs 0.9\n" + + "fn parseConfig(s: string) throws { ParseError, NetworkError } -> Result {\n" + + " if (bad) err(NetworkError(\"timed out\"))\n" + + " else ok(s)\n" + + "}\n", + }, + }, THR003: { code: "THR003", title: "outer fn declares narrower throws than a callback parameter", diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index 1dbf6ca..36a8a44 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -60,6 +60,8 @@ describe("botscript-mcp explanations", () => { "INT002", "RES001", "SYN001", + "THR001", + "THR002", "THR003", "UNS001", "UNS002", From c3675cd686017cb254525fd8b5ff0d7ac515e9ae Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 20 May 2026 05:16:23 -0300 Subject: [PATCH 02/10] fix(thr-check): import Token and callgraph helpers after extraction to _callgraph.ts After the _callgraph.ts refactor (rebased from throws-header), thr-check.ts still relied on locally-defined nextSignificant/prevSignificant and Token that no longer exist. Add the missing imports from _callgraph.js and lex.js. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/thr-check.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/thr-check.ts b/packages/compiler/src/passes/thr-check.ts index 2ab4f39..8505416 100644 --- a/packages/compiler/src/passes/thr-check.ts +++ b/packages/compiler/src/passes/thr-check.ts @@ -28,7 +28,8 @@ import { parseProgram } from "../parser/parse.js"; import type { FnDecl } from "../parser/parse-fn.js"; import { atLeast, type VersionInfo } from "./version.js"; import { locationOf } from "./_location.js"; -import { computeNesting, collectCallees } from "./_callgraph.js"; +import type { Token } from "../parser/lex.js"; +import { computeNesting, collectCallees, nextSignificant, prevSignificant } from "./_callgraph.js"; // --------------------------------------------------------------------------- // Types From ea6f656a3f39efd9f9ba3cc09e1f51fa2f3f6af9 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 20 May 2026 09:08:42 -0300 Subject: [PATCH 03/10] fix(thr-check): scan body from bodyTokenStart; align THR002 span to err token - checkBodyErrors() now starts at fn.bodyTokenStart ?? fn.tokenStart instead of fn.tokenStart, matching the pattern used by collectCallees in _callgraph.ts. This avoids false THR002 hits on CapCase names in parameter types or the return type annotation (e.g. a param typed NetworkError would have incorrectly triggered the check). - THR002 diagnostic now uses tok.start/end as both the line/column anchor and the reported span, so editors highlight the actual err() call rather than the fn header. Previously line/column pointed at err but start/end pointed at the fn keyword, which would produce confusing double-location diagnostics. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/thr-check.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/passes/thr-check.ts b/packages/compiler/src/passes/thr-check.ts index 8505416..14b57b1 100644 --- a/packages/compiler/src/passes/thr-check.ts +++ b/packages/compiler/src/passes/thr-check.ts @@ -224,7 +224,7 @@ function checkBodyErrors( const open: FnDecl[] = []; let nextInner = 0; - for (let i = fn.tokenStart; i < fn.tokenEnd; i++) { + for (let i = fn.bodyTokenStart ?? fn.tokenStart; i < fn.tokenEnd; i++) { while (open.length > 0 && open[open.length - 1]!.tokenEnd <= i) open.pop(); while (nextInner < inner.length && inner[nextInner]!.tokenStart <= i) { open.push(inner[nextInner]!); @@ -276,7 +276,6 @@ function checkBodyErrors( const currentDecl = declaredThrows.size === 0 ? "(none)" : [...declaredThrows].join(", "); const proposed = [...new Set([...declaredThrows, typeName])].join(", "); - const nameEnd = fn.nameStart + fn.name.length; return new BotscriptError([{ code: "THR002", @@ -284,8 +283,8 @@ function checkBodyErrors( file: null, line, column, - start: fn.fnKeywordStart, - end: nameEnd, + start: tok.start, + end: tok.end, message: `fn '${fn.name}' constructs err(${typeName}...) but '${typeName}' ` + `is not in throws { ${currentDecl} }`, From f2100c33dbe54147b9375178281b71234c2f230f Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 20 May 2026 14:42:39 -0300 Subject: [PATCH 04/10] docs(thr-check): document bare err(TypeName) pattern in THR002 descriptions All comment/doc strings for THR002 previously listed only err(TypeName(...)) and err(new TypeName(...)); the bare-reference form err(TypeName) (where the ident is followed directly by ')') is also detected. Updated file header, checkBodyErrors docstring, error-codes.ts rule/idiom, CHANGELOG, and test file comment to reflect the full detection surface. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 9 +++++---- packages/compiler/src/error-codes.ts | 9 +++++---- packages/compiler/src/passes/thr-check.ts | 19 ++++++++++--------- packages/compiler/tests/thr-check.test.ts | 3 ++- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eef8aaa..8a3bd03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,11 @@ goes behind a new pin. - **THR002 — undeclared error type construction.** From `?bs 0.9`, the compiler fires when a fn body contains - `err(TypeName(...))` or `err(new TypeName(...))` where TypeName (CapCase - ident) is absent from the fn's own `throws { }` clause. Catches the case - where a fn produces an error type its callers cannot match. Indirect - patterns (`err(e)` where `e`'s type is inferred) are out of scope. + `err(TypeName(...))`, `err(new TypeName(...))`, or bare `err(TypeName)` + where TypeName (CapCase ident) is absent from the fn's own `throws { }` + clause. Catches the case where a fn produces an error type its callers + cannot match. Indirect patterns (`err(e)` where `e`'s type is inferred) + are out of scope. - **DEP001 / DEP002 — `reads {}` / `writes {}` transitivity enforcement.** From `?bs 0.9`, the compiler enforces that if fn A calls fn B (in the same diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index edb00d6..38c84ff 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -389,12 +389,13 @@ const E: Record = { code: "THR002", title: "fn body constructs an error type not present in its throws declaration", rule: - "if a fn body contains `err(TypeName(...))` or `err(new TypeName(...))` where TypeName " + - "(CapCase ident) is not in the fn's own `throws { }` set, the fn is producing an error " + - "callers cannot match — they will never see a TypeName arm", + "if a fn body contains `err(TypeName(...))`, `err(new TypeName(...))`, or bare `err(TypeName)` " + + "where TypeName (CapCase ident) is not in the fn's own `throws { }` set, the fn is producing an " + + "error callers cannot match — they will never see a TypeName arm", idiom: "add the constructed error type to the fn's `throws { }` clause so callers can exhaustively match it; " + - "indirect patterns like `err(e)` are out of scope — only direct constructor calls are checked", + "indirect patterns like `err(e)` (where e's type is inferred) are out of scope — only direct " + + "constructor calls and bare CapCase references are checked", rewrite: "fn name(...) throws { …existing, UndeclaredError } -> ...", example: diff --git a/packages/compiler/src/passes/thr-check.ts b/packages/compiler/src/passes/thr-check.ts index 14b57b1..209873f 100644 --- a/packages/compiler/src/passes/thr-check.ts +++ b/packages/compiler/src/passes/thr-check.ts @@ -10,12 +10,13 @@ * call the diagnostic says "'B' which throws { X }"; for a multi-hop * chain it names the path, e.g. "B -> C — 'C' throws { X }". * - * THR002 undeclared error construction: fn body contains `err(TypeName(...))` - * or `err(new TypeName(...))` where TypeName (CapCase ident) is not in - * the fn's own `throws {}` set. Catches the case where a fn returns - * an error type it never declared, leaving callers' exhaustive match - * arms permanently dead. Indirect patterns (`err(e)` where e's type - * is inferred) are out of scope — token-based detection only. + * THR002 undeclared error construction: fn body contains `err(TypeName(...))`, + * `err(new TypeName(...))`, or bare `err(TypeName)` where TypeName + * (CapCase ident) is not in the fn's own `throws {}` set. Catches the + * case where a fn returns an error type it never declared, leaving + * callers' exhaustive match arms permanently dead. Indirect patterns + * (`err(e)` where e's type is inferred) are out of scope — token-based + * detection only. * * Only same-file call resolution is performed for THR001 (same as cap-check / * dep-check). Over-declaration is intentionally NOT checked — a caller may @@ -207,9 +208,9 @@ function mkThr001Error(src: string, rec: FnRecord, missingLabels: string[]): Bot } /** - * THR002: scan fn body for `err(TypeName(...))` or `err(new TypeName(...))` - * where TypeName (CapCase ident) is not in the fn's own `throws {}` set. - * Returns a BotscriptError on the first violation found, or null. + * THR002: scan fn body for `err(TypeName(...))`, `err(new TypeName(...))`, or + * bare `err(TypeName)` where TypeName (CapCase ident) is not in the fn's own + * `throws {}` set. Returns a BotscriptError on the first violation found, or null. */ function checkBodyErrors( tokens: Token[], diff --git a/packages/compiler/tests/thr-check.test.ts b/packages/compiler/tests/thr-check.test.ts index 7485a85..dd19886 100644 --- a/packages/compiler/tests/thr-check.test.ts +++ b/packages/compiler/tests/thr-check.test.ts @@ -2,7 +2,8 @@ * Tests for throws {} enforcement (?bs 0.9+). * * THR001: fn A calls fn B which throws { X }, but A doesn't declare throws { X }. - * THR002: fn body constructs err(TypeName(...)) where TypeName is not in throws {}. + * THR002: fn body constructs err(TypeName(...)), err(new TypeName(...)), or bare + * err(TypeName) where TypeName (CapCase) is not in throws {}. */ import { describe, expect, it } from "vitest"; From 6146251cf76d8f73b0e774bab5a196ada2f6e1b8 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 20 May 2026 18:44:20 -0300 Subject: [PATCH 05/10] fix(thr-check): sort missing/proposed; fix empty-throws wording; expand THR002 explanation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - THR001: sort missing labels for deterministic diagnostic output - THR001/THR002: render empty throws as "no throws clause" instead of "(none)" or empty-joined string — clearer prose, not pseudo-syntax - THR002: sort proposed labels in rewrite hint - explanations.ts: mention bare err(TypeName) in THR002 opening sentence Co-Authored-By: Botkowski --- packages/compiler/src/passes/thr-check.ts | 10 ++++++---- packages/mcp/src/explanations.ts | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/passes/thr-check.ts b/packages/compiler/src/passes/thr-check.ts index 209873f..c454a34 100644 --- a/packages/compiler/src/passes/thr-check.ts +++ b/packages/compiler/src/passes/thr-check.ts @@ -274,9 +274,11 @@ function checkBodyErrors( if (declaredThrows.has(typeName)) continue; const { line, column } = locationOf(src, tok.start); - const currentDecl = - declaredThrows.size === 0 ? "(none)" : [...declaredThrows].join(", "); - const proposed = [...new Set([...declaredThrows, typeName])].join(", "); + const currentDeclStr = + declaredThrows.size === 0 + ? "no throws clause" + : `throws { ${[...declaredThrows].sort().join(", ")} }`; + const proposed = [...new Set([...declaredThrows, typeName])].sort().join(", "); return new BotscriptError([{ code: "THR002", @@ -288,7 +290,7 @@ function checkBodyErrors( end: tok.end, message: `fn '${fn.name}' constructs err(${typeName}...) but '${typeName}' ` + - `is not in throws { ${currentDecl} }`, + `is not in ${currentDeclStr}`, rule: entry.rule, idiom: entry.idiom, rewrite: `fn ${fn.name}(...) throws { ${proposed} } -> ...`, diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 78fea74..c1ab83e 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -492,9 +492,9 @@ export const EXPLANATIONS: Readonly> = { code: "THR002", title: "fn body constructs an error type absent from its throws declaration", body: - "THR002 fires from `?bs 0.9` when a fn body contains `err(TypeName(...))` or " + - "`err(new TypeName(...))` where TypeName (a CapCase identifier) is not present in " + - "the fn's own `throws { }` clause.\n\n" + + "THR002 fires from `?bs 0.9` when a fn body contains `err(TypeName(...))`, " + + "`err(new TypeName(...))`, or bare `err(TypeName)` where TypeName (a CapCase " + + "identifier) is not present in the fn's own `throws { }` clause.\n\n" + "This is the producer-side complement to THR001. THR001 ensures callers propagate the " + "throws surface; THR002 ensures the fn actually declares what it produces. Without it, " + "a fn can silently return an error type its callers cannot match — exhaustive match arms " + From 83ab102d1707311a515710d453823f7fbf9f6f94 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 20 May 2026 22:42:13 -0300 Subject: [PATCH 06/10] fix(thr-check): fix THR001/THR002 diagnostic wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - THR001: replace "declares " with "only declares throws {…}" or "has no throws clause" to avoid the awkward "declares no throws clause" phrasing flagged by Copilot - THR002: when fn has no throws clause, emit "but has no throws clause" instead of "is not in no throws clause" (grammatically broken) Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/thr-check.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/passes/thr-check.ts b/packages/compiler/src/passes/thr-check.ts index c454a34..cff0571 100644 --- a/packages/compiler/src/passes/thr-check.ts +++ b/packages/compiler/src/passes/thr-check.ts @@ -183,9 +183,13 @@ function mkThr001Error(src: string, rec: FnRecord, missingLabels: string[]): Bot ? `'${leaf}' which throws { ${firstLabel} }` : `${displayPath} — '${leaf}' throws { ${firstLabel} }`; + const declSuffix = + rec.declaredThrows.size === 0 + ? `has no throws clause` + : `only declares ${currentDeclStr}`; const message = `fn '${rec.decl.name}'${transitively} calls ${callDescription}, ` + - `but '${rec.decl.name}' declares ${currentDeclStr}${otherTail}`; + `but '${rec.decl.name}' ${declSuffix}${otherTail}`; const callPath = `call path: ${pathStr}`; const nameEnd = rec.decl.nameStart + rec.decl.name.length; @@ -289,8 +293,9 @@ function checkBodyErrors( start: tok.start, end: tok.end, message: - `fn '${fn.name}' constructs err(${typeName}...) but '${typeName}' ` + - `is not in ${currentDeclStr}`, + declaredThrows.size === 0 + ? `fn '${fn.name}' constructs err(${typeName}...) but has no throws clause` + : `fn '${fn.name}' constructs err(${typeName}...) but '${typeName}' is not declared in throws { ${[...declaredThrows].sort().join(", ")} }`, rule: entry.rule, idiom: entry.idiom, rewrite: `fn ${fn.name}(...) throws { ${proposed} } -> ...`, From 6aeb362898840fd17d274d6eafe92f83020fdec0 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 21 May 2026 02:44:31 -0300 Subject: [PATCH 07/10] fix(thr002): remove dead currentDeclStr variable from checkBodyErrors Variable was computed but never referenced in the diagnostic message construction. Message already handles the empty-throws case inline on the conditional expression directly below. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/thr-check.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/compiler/src/passes/thr-check.ts b/packages/compiler/src/passes/thr-check.ts index cff0571..56a9d71 100644 --- a/packages/compiler/src/passes/thr-check.ts +++ b/packages/compiler/src/passes/thr-check.ts @@ -278,10 +278,6 @@ function checkBodyErrors( if (declaredThrows.has(typeName)) continue; const { line, column } = locationOf(src, tok.start); - const currentDeclStr = - declaredThrows.size === 0 - ? "no throws clause" - : `throws { ${[...declaredThrows].sort().join(", ")} }`; const proposed = [...new Set([...declaredThrows, typeName])].sort().join(", "); return new BotscriptError([{ From f60aa16fb978b41146f15ece2dcf366e2a25dc23 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 22 May 2026 06:49:39 -0300 Subject: [PATCH 08/10] docs: add THR002 to AGENTS.md diagnostic table and README explain row Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index cc1a83f..7adfce0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,6 +194,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | RES001 | (0.3+) `Result.try` / `Result.tryAsync` with no body. | `Result.try { }`. | | INT001 | (0.7+) A fn declares `intent: "pure"` but also has `uses { … }`. (0.8+) Also fires when `intent: "pure"` is combined with `reads { … }` or `writes { … }`. Pure functions may not declare capabilities or resource dependencies. | Either drop the conflicting header clause(s) or change the intent to reflect the actual behaviour. | | SYN001 | Duplicate fn header clause (e.g. two `reads { }` on the same fn, or two `intent:`), or a label inside `reads {}` / `writes {}` that is not a plain identifier. `parseFn` is version-agnostic, so SYN001 fires whenever a duplicate clause is written regardless of the `?bs` pin. | Declare each header clause once; merge label lists rather than repeating the clause; use bare identifiers (not quoted strings) as labels. | +| THR002 | (0.9+) A fn body directly constructs `err(TypeName(...))`, `err(new TypeName(...))`, or `err(TypeName)` where `TypeName` (CapCase) is not declared in the fn's own `throws {}` clause. Producer-side complement to THR001. | Add `TypeName` to the fn's `throws {}`, or change the error construction to use a declared type. | When you add a new compiler error, allocate the next free code in the same range (`BSnnn` for general parse errors, `CAPnnn` for capability checks, diff --git a/README.md b/README.md index 386f21b..976a041 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ claude mcp add botscript -- npx -y @mbfarias/botscript-mcp | ----------- | -------------------------------------- | --------------------------------------------------------------------------------------------------- | | `primer` | (no args) | The canonical language primer (same text the `?primer` directive emits). | | `transform` | `{ source: string, filename?: string }` | `{ ok: true, code, forms, version }` on success, or `{ ok: false, diagnostics: [...] }` on failure. | -| `explain` | `{ code: string }` | Long-form explanation for a stable diagnostic code (`BS001`, `BS002`, `CAP001`, `CAP002`, `UNS001`–`UNS004`, `RES001`, `FMT001`, `INT001`, `SYN001`) plus a fails/passes example pair. | +| `explain` | `{ code: string }` | Long-form explanation for a stable diagnostic code (`BS001`, `BS002`, `CAP001`, `CAP002`, `UNS001`–`UNS004`, `RES001`, `FMT001`, `INT001`, `SYN001`, `THR002`) plus a fails/passes example pair. | A bot's loop becomes deterministic: `transform` → if `ok=false`, read `diagnostics[0].code` → `explain(code)` → apply `rewrite` → `transform` again. From a05fed9318064a9aaf193868003a9584dd672145 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 22 May 2026 10:49:00 -0300 Subject: [PATCH 09/10] fix(thr-check): address Copilot review on PR #66 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix THR001 message: "only declares throws { X }" → "has throws { X } but not { Y }" to avoid redundant "declares" + "throws" phrasing - Add THR001 row to AGENTS.md diagnostic codes table (was missing alongside THR002) - Add THR002 test: fn with no throws clause constructs err(TypeName) → message says "has no throws clause" - 608/608 tests pass --- AGENTS.md | 1 + packages/compiler/src/passes/thr-check.ts | 7 +------ packages/compiler/tests/thr-check.test.ts | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7adfce0..ba0d01d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,6 +194,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | RES001 | (0.3+) `Result.try` / `Result.tryAsync` with no body. | `Result.try { }`. | | INT001 | (0.7+) A fn declares `intent: "pure"` but also has `uses { … }`. (0.8+) Also fires when `intent: "pure"` is combined with `reads { … }` or `writes { … }`. Pure functions may not declare capabilities or resource dependencies. | Either drop the conflicting header clause(s) or change the intent to reflect the actual behaviour. | | SYN001 | Duplicate fn header clause (e.g. two `reads { }` on the same fn, or two `intent:`), or a label inside `reads {}` / `writes {}` that is not a plain identifier. `parseFn` is version-agnostic, so SYN001 fires whenever a duplicate clause is written regardless of the `?bs` pin. | Declare each header clause once; merge label lists rather than repeating the clause; use bare identifiers (not quoted strings) as labels. | +| THR001 | (0.9+) A fn (or a same-file callee, transitively) throws an exception type not declared in the fn's `throws {}`. If `loadUser` calls `fetchRow throws { NetworkError }`, `loadUser` must also declare `throws { NetworkError }`. | Add the missing type(s) to `throws {}` at each level of the call chain. | | THR002 | (0.9+) A fn body directly constructs `err(TypeName(...))`, `err(new TypeName(...))`, or `err(TypeName)` where `TypeName` (CapCase) is not declared in the fn's own `throws {}` clause. Producer-side complement to THR001. | Add `TypeName` to the fn's `throws {}`, or change the error construction to use a declared type. | When you add a new compiler error, allocate the next free code in the same diff --git a/packages/compiler/src/passes/thr-check.ts b/packages/compiler/src/passes/thr-check.ts index 56a9d71..a6a66b2 100644 --- a/packages/compiler/src/passes/thr-check.ts +++ b/packages/compiler/src/passes/thr-check.ts @@ -166,11 +166,6 @@ function mkThr001Error(src: string, rec: FnRecord, missingLabels: string[]): Bot ? formatPath(firstPath.next) : pathStr; - const currentDeclStr = - rec.declaredThrows.size === 0 - ? "no throws clause" - : `throws { ${[...rec.declaredThrows].sort().join(", ")} }`; - const proposed = [...new Set([...rec.declaredThrows, ...missingLabels])].sort().join(", "); const otherMissing = missingLabels.slice(1); @@ -186,7 +181,7 @@ function mkThr001Error(src: string, rec: FnRecord, missingLabels: string[]): Bot const declSuffix = rec.declaredThrows.size === 0 ? `has no throws clause` - : `only declares ${currentDeclStr}`; + : `has throws { ${[...rec.declaredThrows].sort().join(", ")} } but not { ${missingLabels.join(", ")} }`; const message = `fn '${rec.decl.name}'${transitively} calls ${callDescription}, ` + `but '${rec.decl.name}' ${declSuffix}${otherTail}`; diff --git a/packages/compiler/tests/thr-check.test.ts b/packages/compiler/tests/thr-check.test.ts index dd19886..99fe9ba 100644 --- a/packages/compiler/tests/thr-check.test.ts +++ b/packages/compiler/tests/thr-check.test.ts @@ -282,6 +282,22 @@ describe("THR002: body constructs undeclared error type (0.9+)", () => { expect(() => compile(src)).toThrow(/THR002/); }); + it("fires with 'has no throws clause' message when fn has no throws annotation", () => { + const src = + "?bs 0.9\n" + + "fn parse(s: string) -> Result {\n" + + " err(ParseError(\"bad\"))\n" + + "}\n"; + try { + compile(src); + expect.fail("should have thrown"); + } catch (e) { + const err = e as { diagnostics?: Array<{ code: string; message: string }> }; + expect(err.diagnostics?.[0]?.code).toBe("THR002"); + expect(err.diagnostics?.[0]?.message).toMatch(/has no throws clause/); + } + }); + it("does not fire for err call inside an inner fn that itself declares the type", () => { const src = "?bs 0.9\n" + From 7c9f741c26856581b7a88f8508c94d813f39a673 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 22 May 2026 14:40:33 -0300 Subject: [PATCH 10/10] docs: fix README explain codes list to match actual KNOWN_CODES --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 976a041..3914489 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ claude mcp add botscript -- npx -y @mbfarias/botscript-mcp | ----------- | -------------------------------------- | --------------------------------------------------------------------------------------------------- | | `primer` | (no args) | The canonical language primer (same text the `?primer` directive emits). | | `transform` | `{ source: string, filename?: string }` | `{ ok: true, code, forms, version }` on success, or `{ ok: false, diagnostics: [...] }` on failure. | -| `explain` | `{ code: string }` | Long-form explanation for a stable diagnostic code (`BS001`, `BS002`, `CAP001`, `CAP002`, `UNS001`–`UNS004`, `RES001`, `FMT001`, `INT001`, `SYN001`, `THR002`) plus a fails/passes example pair. | +| `explain` | `{ code: string }` | Long-form explanation for any stable diagnostic code (`BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`, `DEP002`, `EFF002`–`EFF004`, `FMT001`, `INT001`, `INT002`, `RES001`, `SYN001`, `THR001`–`THR003`, `UNS001`–`UNS004`) plus a fails/passes example pair. | A bot's loop becomes deterministic: `transform` → if `ok=false`, read `diagnostics[0].code` → `explain(code)` → apply `rewrite` → `transform` again.