From 05e91ee409a5027948fd4e263e2083119d338485 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 20 May 2026 18:59:41 -0300 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20THR003=20=E2=80=94=20under-declared?= =?UTF-8?q?=20throws=20from=20callback=20parameter=20throws=20annotations?= =?UTF-8?q?=20(=3Fbs=200.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a function-typed parameter carries `throws { X }`, the containing fn can exercise that exception through any call to the callback. THR003 fires when the outer fn's own `throws {}` does not cover the callback's declared throws — closing the "callback throws-leak" vector, analogous to EFF003/EFF004 for reads/writes. - parse-fn.ts: strip `throws {}` from callback param types in buildArgsTs, collect into FnDecl.paramThrows (same pattern as paramCaps for uses {}) - thr-check.ts: THR003 check — union paramThrows, diff against declared throws, emit diagnostic with rewrite hint - error-codes.ts: THR003 entry with rule/idiom/rewrite/example - explanations.ts: THR003 long-form explanation; add to KNOWN_CODES test - 9 new tests in thr-check.test.ts; 586/586 pass Closes #73. Co-Authored-By: Botkowski --- CHANGELOG.md | 8 ++ packages/compiler/src/error-codes.ts | 31 +++++++ packages/compiler/src/parser/parse-fn.ts | 28 +++++- packages/compiler/src/passes/thr-check.ts | 62 ++++++++++++- packages/compiler/tests/thr-check.test.ts | 108 ++++++++++++++++++++++ packages/mcp/src/explanations.ts | 37 ++++++++ packages/mcp/tests/server.test.ts | 1 + 7 files changed, 270 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc4f9f9..5d1802a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,14 @@ goes behind a new pin. cannot match. Indirect patterns (`err(e)` where `e`'s type is inferred) are out of scope. +- **THR003 — under-declared throws from callback parameter throws annotations.** + From `?bs 0.9`, the compiler fires when a function-typed parameter carries + `throws { X }` but the containing fn does not declare `throws { X }` in + its own header. Calling the callback exercises that throw, so the outer + fn's throws surface must cover it. Direct analogue of EFF003/EFF004 for + the throws dimension. `throws {}` is stripped from emitted TypeScript + (same as `uses {}`, `reads {}`, `writes {}`). Over-declaration is always allowed. + - **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 ffe7ddd..3bd0b1f 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -384,6 +384,37 @@ const E: Record = { " else ok(s)\n" + "}", }, + THR003: { + code: "THR003", + title: "fn under-declares throws implied by callback parameter throws annotations", + rule: + "if a function-typed parameter carries `throws { X }`, the containing fn can exercise that " + + "exception through any call to the callback — so the fn's own `throws {}` must cover it; " + + "a fn's throws surface is the union of its own declared throws and the throws its callback " + + "parameters may exercise", + idiom: + "add the callback parameter's throws labels to the containing fn's own `throws { }` clause; " + + "this is the direct analogue of EFF003/EFF004 for the throws surface", + rewrite: + "fn name(...) throws { …existing, CallbackThrown } -> ...", + example: + "// before — process accepts a handler that throws { NetworkError } but doesn't declare it\n" + + "?bs 0.9\n" + + "fn process(\n" + + " items: string[],\n" + + " handler: fn(string) throws { NetworkError } -> void\n" + + ") -> void { // THR003: missing throws { NetworkError }\n" + + " handler(items[0])\n" + + "}\n\n" + + "// after\n" + + "?bs 0.9\n" + + "fn process(\n" + + " items: string[],\n" + + " handler: fn(string) throws { NetworkError } -> void\n" + + ") throws { NetworkError } -> void {\n" + + " handler(items[0])\n" + + "}", + }, }; export function getErrorCode(code: string): ErrorCodeEntry | undefined { diff --git a/packages/compiler/src/parser/parse-fn.ts b/packages/compiler/src/parser/parse-fn.ts index d8d91fb..daea093 100644 --- a/packages/compiler/src/parser/parse-fn.ts +++ b/packages/compiler/src/parser/parse-fn.ts @@ -73,6 +73,14 @@ export interface FnDecl { * Example: `(cb: () writes { metrics } -> void)` → `["metrics"]` */ paramWrites: string[]; + /** + * Union of all exception type names declared in `throws { … }` annotations on + * function-typed parameters. Empty when no parameter carries a throws annotation. + * Used by `passThrCheck` (THR003). Gated on `?bs 0.9`. + * + * Example: `(handler: (s: string) throws { NetworkError } -> void)` → `["NetworkError"]` + */ + paramThrows: string[]; capabilities: string[]; /** * Optional declarative read-dependency list, e.g. `reads { cache, db }`. Each @@ -264,7 +272,7 @@ export function parseFn( if (!argsOpen || argsOpen.kind !== "open" || argsOpen.text !== "(" || argsOpen.matchedAt === undefined) return null; const argsClose = argsOpen.matchedAt; const args = sliceText(tokens, i, argsClose + 1); - const { text: argsTs, paramCaps, paramReads, paramWrites } = buildArgsTs(tokens, i, argsClose + 1, opts.src); + const { text: argsTs, paramCaps, paramReads, paramWrites, paramThrows } = buildArgsTs(tokens, i, argsClose + 1, opts.src); i = argsClose + 1; i = skipTrivia(tokens, i); @@ -521,6 +529,7 @@ export function parseFn( paramCaps, paramReads, paramWrites, + paramThrows, capabilities, reads, writes, @@ -710,11 +719,12 @@ function buildArgsTs( from: number, to: number, src?: string, -): { text: string; paramCaps: string[]; paramReads: string[]; paramWrites: string[] } { +): { text: string; paramCaps: string[]; paramReads: string[]; paramWrites: string[]; paramThrows: string[] } { let out = ""; const paramCaps: string[] = []; const paramReads: string[] = []; const paramWrites: string[] = []; + const paramThrows: string[] = []; let i = from; while (i < to) { const t = tokens[i]!; @@ -754,10 +764,22 @@ function buildArgsTs( continue; } } + // Strip `throws { types }` and collect the declared exception types. + if (t.kind === "ident" && t.text === "throws") { + const j = skipTrivia(tokens, i + 1); + const open = tokens[j]; + if (open && open.kind === "open" && open.text === "{" && open.matchedAt !== undefined) { + const types = parseLabelList(tokens, j + 1, open.matchedAt, src); + for (const ty of types) paramThrows.push(ty); + i = open.matchedAt + 1; + while (i < to && tokens[i]?.kind === "whitespace") i++; + continue; + } + } out += t.text; i++; } - return { text: out, paramCaps, paramReads, paramWrites }; + return { text: out, paramCaps, paramReads, paramWrites, paramThrows }; } function sliceText(tokens: Token[], from: number, to: number): string { diff --git a/packages/compiler/src/passes/thr-check.ts b/packages/compiler/src/passes/thr-check.ts index 56a9d71..c325626 100644 --- a/packages/compiler/src/passes/thr-check.ts +++ b/packages/compiler/src/passes/thr-check.ts @@ -2,8 +2,9 @@ * Throws declaration check (?bs 0.9+). * * Enforces transitivity of `throws { ... }` annotations across same-file - * function calls, and checks that a fn's body does not directly construct - * error types absent from its own `throws {}` declaration. + * function calls, checks that a fn's body does not directly construct error + * types absent from its own `throws {}` declaration, and ensures that + * callback parameters' throws annotations are reflected in the containing fn. * * THR001 throws under-declared: fn A calls fn B which (transitively) * declares `throws { X }` that A does not declare. For a direct @@ -18,6 +19,12 @@ * (`err(e)` where e's type is inferred) are out of scope — token-based * detection only. * + * THR003 callback throws not covered: a function-typed parameter carries + * `throws { X }` but the containing fn's own `throws {}` does not + * include X. Calling the callback can surface X, so the outer fn's + * throws surface must cover it (direct analogue of EFF003/EFF004 for + * the throws dimension). + * * 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. @@ -131,6 +138,17 @@ export function passThrCheck(src: string, version: VersionInfo): string { if (err) throw err; } + // THR003: fn's throws surface must cover all throws declared on callback parameters. + for (const decl of decls) { + if (decl.paramThrows.length === 0) continue; + const declared = new Set(decl.throws ?? []); + const missing = [...new Set(decl.paramThrows)] + .filter((t) => !declared.has(t)) + .sort(); + if (missing.length === 0) continue; + throw mkThr003Error(src, decl, missing, declared); + } + return src; } @@ -300,3 +318,43 @@ function checkBodyErrors( return null; } + +function mkThr003Error( + src: string, + decl: FnDecl, + missingThrows: string[], + declared: Set, +): BotscriptError { + const entry = getErrorCode("THR003")!; + const { line, column } = locationOf(src, decl.fnKeywordStart); + const nameEnd = decl.nameStart + decl.name.length; + + const currentDeclStr = + declared.size === 0 + ? "no throws clause" + : `throws { ${[...declared].sort().join(", ")} }`; + const proposed = [...new Set([...declared, ...missingThrows])].sort().join(", "); + const missingStr = missingThrows.join(", "); + + const otherMissing = missingThrows.slice(1); + const otherTail = + otherMissing.length > 0 + ? `; also missing: ${otherMissing.map((t) => `"${t}"`).join(", ")}` + : ""; + + return new BotscriptError([{ + code: "THR003", + severity: "error" as const, + file: null, + line, + column, + start: decl.fnKeywordStart, + end: nameEnd, + message: + `fn '${decl.name}' accepts callback parameter(s) that declare throws { ${missingStr} } ` + + `but '${decl.name}' declares ${currentDeclStr}${otherTail}`, + rule: entry.rule, + idiom: entry.idiom, + rewrite: `fn ${decl.name}(...) throws { ${proposed} } -> ...`, + }]); +} diff --git a/packages/compiler/tests/thr-check.test.ts b/packages/compiler/tests/thr-check.test.ts index dd19886..b9072a1 100644 --- a/packages/compiler/tests/thr-check.test.ts +++ b/packages/compiler/tests/thr-check.test.ts @@ -294,3 +294,111 @@ describe("THR002: body constructs undeclared error type (0.9+)", () => { expect(() => compile(src)).not.toThrow(); }); }); + +describe("THR003 — callback parameter throws not covered by containing fn", () => { + it("fires when callback parameter declares throws { X } but outer fn has no throws clause", () => { + const src = + "?bs 0.9\n" + + "fn process(\n" + + " items: string[],\n" + + " handler: (s: string) throws { NetworkError } -> void\n" + + ") -> void {\n" + + " handler(items[0])\n" + + "}\n"; + expect(() => compile(src)).toThrow("THR003"); + expect(() => compile(src)).toThrow(/process/); + expect(() => compile(src)).toThrow(/NetworkError/); + }); + + it("fires when callback throws X but outer fn declares throws { Y } (missing X)", () => { + const src = + "?bs 0.9\n" + + "fn apply(\n" + + " f: (s: string) throws { IoError } -> string\n" + + ") throws { ParseError } -> string {\n" + + " f(\"x\")\n" + + "}\n"; + expect(() => compile(src)).toThrow("THR003"); + expect(() => compile(src)).toThrow(/IoError/); + }); + + it("does not fire when outer fn's throws is a superset of callback throws", () => { + const src = + "?bs 0.9\n" + + "fn process(\n" + + " items: string[],\n" + + " handler: (s: string) throws { NetworkError } -> void\n" + + ") throws { NetworkError } -> void {\n" + + " handler(items[0])\n" + + "}\n"; + expect(() => compile(src)).not.toThrow(); + }); + + it("does not fire when outer fn over-declares (superset)", () => { + const src = + "?bs 0.9\n" + + "fn wrap(\n" + + " f: () throws { IoError } -> void\n" + + ") throws { IoError, ParseError } -> void {\n" + + " f()\n" + + "}\n"; + expect(() => compile(src)).not.toThrow(); + }); + + it("does not fire when callback parameter has no throws annotation", () => { + const src = + "?bs 0.9\n" + + "fn run(\n" + + " action: (s: string) -> void\n" + + ") -> void {\n" + + " action(\"x\")\n" + + "}\n"; + expect(() => compile(src)).not.toThrow(); + }); + + it("does not fire below ?bs 0.9", () => { + const src = + "?bs 0.8\n" + + "fn process(\n" + + " handler: (s: string) throws { NetworkError } -> void\n" + + ") -> void {\n" + + " handler(\"x\")\n" + + "}\n"; + expect(() => compile(src)).not.toThrow(); + }); + + it("strips throws {} from callback parameter type in emitted TypeScript", () => { + const src = + "?bs 0.9\n" + + "fn wrap(\n" + + " f: (s: string) throws { IoError } -> string\n" + + ") throws { IoError } -> string {\n" + + " f(\"x\")\n" + + "}\n"; + const out = compile(src); + expect(out).not.toContain("throws"); + expect(out).not.toContain("IoError"); + expect(out).toContain("=> string"); + }); + + it("collects throws from multiple callback parameters, fires on first missing", () => { + const src = + "?bs 0.9\n" + + "fn both(\n" + + " a: () throws { IoError } -> void,\n" + + " b: () throws { ParseError } -> void\n" + + ") -> void {\n" + + " a()\n" + + "}\n"; + expect(() => compile(src)).toThrow("THR003"); + }); + + it("bs explain THR003 entry exists with rule, idiom, and rewrite", async () => { + const { getErrorCode } = await import("../src/error-codes.js"); + const entry = getErrorCode("THR003"); + expect(entry).toBeDefined(); + expect(entry!.rule).toMatch(/throws/); + expect(entry!.idiom).toBeDefined(); + expect(entry!.rewrite).toBeDefined(); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index e6f59d1..30119dd 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -521,6 +521,43 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + THR003: { + code: "THR003", + title: "fn under-declares throws implied by callback parameter throws annotations", + body: + "THR003 fires from `?bs 0.9` when a function-typed parameter carries `throws { X }` " + + "but the containing fn does not declare `throws { X }` in its own header.\n\n" + + "This is the direct analogue of EFF003 (reads on callback) and EFF004 (writes on callback), " + + "applied to the throws surface. When a fn calls its callback parameter, it can exercise the " + + "callback's declared throws — so the outer fn's own `throws {}` must be a superset of all " + + "callback parameters' throws annotations.\n\n" + + "**Why it matters:** a reviewer reading the outer fn's header sees no throws declaration and " + + "has no warning that calling it may produce the error type. Callers that match exhaustively on " + + "the outer fn's return type will have no arm for the undeclared exception — it becomes dead " + + "code or a silent gap.\n\n" + + "**Fix:** add the callback parameter's throws labels to the containing fn's own `throws { }` clause.\n\n" + + "Over-declaration is allowed — if the containing fn declares more throws types than it can " + + "actually exercise, that is harmless (same policy as THR001/THR002).\n\n" + + "THR003 is gated on `?bs 0.9`. Files pinned to earlier versions are unaffected.", + example: { + fails: + "?bs 0.9\n" + + "fn process(\n" + + " items: string[],\n" + + " handler: fn(string) throws { NetworkError } -> void\n" + + ") -> void {\n" + + " handler(items[0])\n" + + "}\n", + passes: + "?bs 0.9\n" + + "fn process(\n" + + " items: string[],\n" + + " handler: fn(string) throws { NetworkError } -> void\n" + + ") throws { NetworkError } -> void {\n" + + " handler(items[0])\n" + + "}\n", + }, + }, }; export const KNOWN_CODES = Object.keys(EXPLANATIONS).sort(); diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index 868b550..36a8a44 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -62,6 +62,7 @@ describe("botscript-mcp explanations", () => { "SYN001", "THR001", "THR002", + "THR003", "UNS001", "UNS002", "UNS003", From 7b0979d4c3f4cee72554a5346fc64d014316d6ac Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 20 May 2026 22:40:06 -0300 Subject: [PATCH 2/6] fix(thr-check): fix THR003 duplicate info in message; update buildArgsTs doc - mkThr003Error: use firstMissing for primary message + otherTail for extras (previously missingStr listed all missing AND otherTail repeated all-but-first) - buildArgsTs: update docstring from "Two" to "Three transformations" to include the throws {} stripping added for THR003 Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/parser/parse-fn.ts | 8 +++++--- packages/compiler/src/passes/thr-check.ts | 5 ++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/parser/parse-fn.ts b/packages/compiler/src/parser/parse-fn.ts index daea093..de6ea7d 100644 --- a/packages/compiler/src/parser/parse-fn.ts +++ b/packages/compiler/src/parser/parse-fn.ts @@ -706,13 +706,15 @@ function skipTrivia(tokens: Token[], i: number): number { * 3. `reads { label, … }` and `writes { label, … }` annotations on * function-typed parameters are stripped and collected into `paramReads` / * `paramWrites`. + * 4. `throws { Type, … }` annotations on function-typed parameters are stripped + * from the emitted text and their type names collected into `paramThrows`. * * The stripping is position-independent: any effect annotation inside the args * list is treated as a parameter effect annotation. This is safe because the * stripping only activates on the specific `uses { ... }` / `reads { ... }` / - * `writes { ... }` syntax pattern — a keyword/ident token immediately followed - * by a `{...}` block — not on bare `reads` or `writes` identifiers elsewhere in - * TypeScript type positions (e.g. `reads` as a field name in an object type). + * `writes { ... }` / `throws { ... }` syntax pattern — a keyword/ident token + * immediately followed by a `{...}` block — not on bare identifiers elsewhere + * in TypeScript type positions (e.g. `reads` as a field name in an object type). */ function buildArgsTs( tokens: Token[], diff --git a/packages/compiler/src/passes/thr-check.ts b/packages/compiler/src/passes/thr-check.ts index c325626..5940717 100644 --- a/packages/compiler/src/passes/thr-check.ts +++ b/packages/compiler/src/passes/thr-check.ts @@ -334,8 +334,7 @@ function mkThr003Error( ? "no throws clause" : `throws { ${[...declared].sort().join(", ")} }`; const proposed = [...new Set([...declared, ...missingThrows])].sort().join(", "); - const missingStr = missingThrows.join(", "); - + const firstMissing = missingThrows[0]!; const otherMissing = missingThrows.slice(1); const otherTail = otherMissing.length > 0 @@ -351,7 +350,7 @@ function mkThr003Error( start: decl.fnKeywordStart, end: nameEnd, message: - `fn '${decl.name}' accepts callback parameter(s) that declare throws { ${missingStr} } ` + + `fn '${decl.name}' accepts callback parameter(s) that declare throws { ${firstMissing} } ` + `but '${decl.name}' declares ${currentDeclStr}${otherTail}`, rule: entry.rule, idiom: entry.idiom, From 9211d8ab9f133b39b2049082ed5418a85411b2e7 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 21 May 2026 02:42:13 -0300 Subject: [PATCH 3/6] =?UTF-8?q?fix(thr003):=20address=20Copilot=20review?= =?UTF-8?q?=20comments=20=E2=80=94=20fix=20fn-type=20syntax=20in=20example?= =?UTF-8?q?s=20and=20simplify=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `fn(string)` with `(s: string)` in paramThrows example, error-codes example, and explanations example — `fn` is a declaration keyword, not valid as a callback type literal - Fix safety comment in buildArgsTs: `throws` is not a botscript keyword (it's ident-tokenized); reword to accurately describe why stripping is safe - Simplify THR003 message: show all missing throws at once via `missingStr` rather than first + `otherTail` duplication; update test description to match Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/error-codes.ts | 4 ++-- packages/compiler/src/passes/thr-check.ts | 11 +++-------- packages/compiler/tests/thr-check.test.ts | 2 +- packages/mcp/src/explanations.ts | 4 ++-- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 3bd0b1f..822ea97 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -402,7 +402,7 @@ const E: Record = { "?bs 0.9\n" + "fn process(\n" + " items: string[],\n" + - " handler: fn(string) throws { NetworkError } -> void\n" + + " handler: (s: string) throws { NetworkError } -> void\n" + ") -> void { // THR003: missing throws { NetworkError }\n" + " handler(items[0])\n" + "}\n\n" + @@ -410,7 +410,7 @@ const E: Record = { "?bs 0.9\n" + "fn process(\n" + " items: string[],\n" + - " handler: fn(string) throws { NetworkError } -> void\n" + + " handler: (s: string) throws { NetworkError } -> void\n" + ") throws { NetworkError } -> void {\n" + " handler(items[0])\n" + "}", diff --git a/packages/compiler/src/passes/thr-check.ts b/packages/compiler/src/passes/thr-check.ts index 5940717..be3b716 100644 --- a/packages/compiler/src/passes/thr-check.ts +++ b/packages/compiler/src/passes/thr-check.ts @@ -334,12 +334,7 @@ function mkThr003Error( ? "no throws clause" : `throws { ${[...declared].sort().join(", ")} }`; const proposed = [...new Set([...declared, ...missingThrows])].sort().join(", "); - const firstMissing = missingThrows[0]!; - const otherMissing = missingThrows.slice(1); - const otherTail = - otherMissing.length > 0 - ? `; also missing: ${otherMissing.map((t) => `"${t}"`).join(", ")}` - : ""; + const missingStr = missingThrows.join(", "); return new BotscriptError([{ code: "THR003", @@ -350,8 +345,8 @@ function mkThr003Error( start: decl.fnKeywordStart, end: nameEnd, message: - `fn '${decl.name}' accepts callback parameter(s) that declare throws { ${firstMissing} } ` + - `but '${decl.name}' declares ${currentDeclStr}${otherTail}`, + `fn '${decl.name}' accepts callback parameter(s) that together declare throws { ${missingStr} } ` + + `but '${decl.name}' declares ${currentDeclStr}`, rule: entry.rule, idiom: entry.idiom, rewrite: `fn ${decl.name}(...) throws { ${proposed} } -> ...`, diff --git a/packages/compiler/tests/thr-check.test.ts b/packages/compiler/tests/thr-check.test.ts index b9072a1..ef8aa06 100644 --- a/packages/compiler/tests/thr-check.test.ts +++ b/packages/compiler/tests/thr-check.test.ts @@ -381,7 +381,7 @@ describe("THR003 — callback parameter throws not covered by containing fn", () expect(out).toContain("=> string"); }); - it("collects throws from multiple callback parameters, fires on first missing", () => { + it("collects throws from multiple callback parameters, fires when any are missing", () => { const src = "?bs 0.9\n" + "fn both(\n" + diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 30119dd..8fe14f6 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -544,7 +544,7 @@ export const EXPLANATIONS: Readonly> = { "?bs 0.9\n" + "fn process(\n" + " items: string[],\n" + - " handler: fn(string) throws { NetworkError } -> void\n" + + " handler: (s: string) throws { NetworkError } -> void\n" + ") -> void {\n" + " handler(items[0])\n" + "}\n", @@ -552,7 +552,7 @@ export const EXPLANATIONS: Readonly> = { "?bs 0.9\n" + "fn process(\n" + " items: string[],\n" + - " handler: fn(string) throws { NetworkError } -> void\n" + + " handler: (s: string) throws { NetworkError } -> void\n" + ") throws { NetworkError } -> void {\n" + " handler(items[0])\n" + "}\n", From ddeb421ffdca5cc87f581a32a903a01d292caf89 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 21 May 2026 06:43:40 -0300 Subject: [PATCH 4/6] fix(thr003): thread src into buildArgsTs; distinguish empty throws {} from missing clause - buildArgsTs now accepts an optional src param and threads it to parseLabelList, so malformed callback-throws annotations produce SYN001 consistently (same as header throws {} clauses) - mkThr003Error now uses decl.throws === undefined vs .length === 0 to distinguish "no throws clause" from "throws {} (empty)" in the diagnostic message, preventing the ambiguous "no throws clause" output for explicit-but-empty declarations Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/thr-check.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/thr-check.ts b/packages/compiler/src/passes/thr-check.ts index be3b716..c0bd39f 100644 --- a/packages/compiler/src/passes/thr-check.ts +++ b/packages/compiler/src/passes/thr-check.ts @@ -330,9 +330,11 @@ function mkThr003Error( const nameEnd = decl.nameStart + decl.name.length; const currentDeclStr = - declared.size === 0 + decl.throws === undefined ? "no throws clause" - : `throws { ${[...declared].sort().join(", ")} }`; + : declared.size === 0 + ? "throws {} (empty)" + : `throws { ${[...declared].sort().join(", ")} }`; const proposed = [...new Set([...declared, ...missingThrows])].sort().join(", "); const missingStr = missingThrows.join(", "); From 4506b6122a27c32a21c232cea70e1ba9d14adf4f Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 21 May 2026 14:40:31 -0300 Subject: [PATCH 5/6] =?UTF-8?q?docs(parse-fn):=20fix=20doc=20comment=20?= =?UTF-8?q?=E2=80=94=20Four=20transformations,=20not=20Three?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Botkowski --- packages/compiler/src/parser/parse-fn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/src/parser/parse-fn.ts b/packages/compiler/src/parser/parse-fn.ts index de6ea7d..6e7cf67 100644 --- a/packages/compiler/src/parser/parse-fn.ts +++ b/packages/compiler/src/parser/parse-fn.ts @@ -699,7 +699,7 @@ function skipTrivia(tokens: Token[], i: number): number { /** * Build the TypeScript-compatible args string from the args token range. * - * Three transformations applied: + * Four transformations applied: * 1. Botscript `->` (arrow token) → TypeScript `=>` (function type arrow). * 2. `uses { cap, … }` annotations on function-typed parameters are stripped * from the emitted text and their capability names collected into `paramCaps`. From be924501171343fb01749d41c7d96eb0b4f7372b Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 21 May 2026 18:39:38 -0300 Subject: [PATCH 6/6] fix: correct THR003 diagnostic grammar for missing throws clause "declares no throws clause" was grammatically awkward; now uses "has no throws clause" for the undefined-throws branch. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/thr-check.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/thr-check.ts b/packages/compiler/src/passes/thr-check.ts index c0bd39f..1b2c336 100644 --- a/packages/compiler/src/passes/thr-check.ts +++ b/packages/compiler/src/passes/thr-check.ts @@ -348,7 +348,7 @@ function mkThr003Error( end: nameEnd, message: `fn '${decl.name}' accepts callback parameter(s) that together declare throws { ${missingStr} } ` + - `but '${decl.name}' declares ${currentDeclStr}`, + `but '${decl.name}' ${decl.throws === undefined ? "has no throws clause" : `declares ${currentDeclStr}`}`, rule: entry.rule, idiom: entry.idiom, rewrite: `fn ${decl.name}(...) throws { ${proposed} } -> ...`,