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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ goes behind a new pin.
- EFF004 fires when a callback's declared writes are not a subset of the outer
fn's declared writes.

- **THR003 — `throws {}` on callback parameters.**
From `?bs 0.9`, when a function-typed parameter carries a `throws { X }` annotation,
the containing fn must declare at least those exception types in its own `throws {}`
clause. Calling the callback can surface X — the outer fn cannot advertise a narrower
throws surface than it can exercise. Closes the "callback throws-leak" vector, completing
the trilogy with EFF002 (`uses {}`) and EFF003/EFF004 (`reads {}`/`writes {}`).
- `throws {}` annotations on callback parameter types are stripped from emitted TypeScript.
- THR003 fires when any callback parameter's declared throws are not a subset of the
outer fn's declared throws.

- **CAP003 — capability asserted in unsafe fn (non-blocking warning).**
From `?bs 0.9`, the compiler emits a `warning` (not an error) when a
`uses {}` declaration appears on an `unsafe fn`. The capability inference
Expand Down
28 changes: 28 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,34 @@ const E: Record<string, ErrorCodeEntry> = {
"fn updateMetrics(id: string) writes { metrics } -> void { }\n" +
"fn recordEvent(id: string) writes { metrics } -> void { updateMetrics(id); }",
},
THR003: {
code: "THR003",
title: "outer fn declares narrower throws than a callback parameter",
rule:
"if a function-typed parameter declares `throws { X }`, the containing fn must declare at least those " +
"exception types — calling the callback can surface X, so the outer fn's throws surface must cover it",
idiom:
"a fn's throws surface is the union of its own declared throws and the throws its callback parameters may exercise",
rewrite:
"fn name(handler: () throws { X } -> T) throws { …existing, X } -> ...",
example:
"// before — accepts a throwing callback but outer fn declares no throws\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 — outer fn declares the throws its callback may exercise\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" +
"}",
},
THR001: {
code: "THR001",
title: "fn transitively throws an exception type not declared in its header",
Expand Down
41 changes: 26 additions & 15 deletions packages/compiler/src/parser/parse-fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ export interface FnDecl {
* Example: `(cb: () writes { metrics } -> void)` → `["metrics"]`
*/
paramWrites: string[];
/**
* Union of all exception types declared in `throws { … }` annotations on
* function-typed parameters. Empty when no parameter carries a throws annotation.
* Used by `passEffCheck` (THR003). Gated on `?bs 0.9`.
*
* Example: `(handler: () throws { NetworkError } -> void)` → `["NetworkError"]`
*/
paramThrows: string[];
capabilities: string[];
/**
* Optional declarative read-dependency list, e.g. `reads { cache, db }`. Each
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -521,6 +529,7 @@ export function parseFn(
paramCaps,
paramReads,
paramWrites,
paramThrows,
capabilities,
reads,
writes,
Expand Down Expand Up @@ -694,27 +703,26 @@ function skipTrivia(tokens: Token[], i: number): number {
* 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`.
* 3. `reads { label, … }` and `writes { label, … }` annotations on
* function-typed parameters are stripped and collected into `paramReads` /
* `paramWrites`.
* 3. `reads { label, … }`, `writes { label, … }`, and `throws { label, … }`
* annotations on function-typed parameters are stripped and collected into
* `paramReads` / `paramWrites` / `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).
* stripping only activates on the specific keyword/ident immediately followed
* by a `{...}` block — not on bare identifiers in TypeScript type positions.
*/
function buildArgsTs(
tokens: Token[],
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]!;
Expand All @@ -724,13 +732,14 @@ function buildArgsTs(
i++;
continue;
}
// Strip effect annotations (`uses`/`reads`/`writes`) and collect their labels.
// Note: `uses` is a lexer keyword (kind="keyword"), but `reads` and `writes`
// are treated as identifiers (kind="ident") by the lexer.
// Strip effect annotations (`uses`/`reads`/`writes`/`throws`) and collect labels.
// Note: `uses` is a lexer keyword (kind="keyword"); `reads`, `writes`, and
// `throws` are all treated as identifiers (kind="ident") by the lexer.
const isUses = t.kind === "keyword" && t.keyword === "uses";
const isReads = t.kind === "ident" && t.text === "reads";
const isWrites = t.kind === "ident" && t.text === "writes";
if (isUses || isReads || isWrites) {
const isThrows = t.kind === "ident" && t.text === "throws";
if (isUses || isReads || isWrites || isThrows) {
const j = skipTrivia(tokens, i + 1);
const open = tokens[j];
if (open && open.kind === "open" && open.text === "{" && open.matchedAt !== undefined) {
Expand All @@ -743,8 +752,10 @@ function buildArgsTs(
const labels = parseLabelList(tokens, j + 1, open.matchedAt, src);
if (isReads) {
for (const l of labels) paramReads.push(l);
} else {
} else if (isWrites) {
for (const l of labels) paramWrites.push(l);
} else {
for (const l of labels) paramThrows.push(l);
}
}
i = open.matchedAt + 1;
Expand All @@ -757,7 +768,7 @@ function buildArgsTs(
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 {
Expand Down
37 changes: 35 additions & 2 deletions packages/compiler/src/passes/eff-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@
* requires closure-level type inference and is out of scope for
* this pass. It is reserved for a future version.
*
* ?bs 0.9 EFF003 / EFF004 added. Same principle as EFF002 but for
* `reads {}` and `writes {}` annotations on callback parameters.
* ?bs 0.9 EFF003 / EFF004 / THR003 added. Same principle as EFF002 but for
* `reads {}`, `writes {}`, and `throws {}` annotations on callbacks.
*
* EFF003 A function-typed parameter carries `reads { labels }`,
* but the containing fn does not declare those read labels.
*
* EFF004 A function-typed parameter carries `writes { labels }`,
* but the containing fn does not declare those write labels.
*
* THR003 A function-typed parameter carries `throws { X }`,
* but the containing fn does not declare `throws { X }`.
* Calling the callback can surface X — the outer fn's
* throws surface must cover it.
*
* Background (issue #56):
* The pure-intent check (INT001/INT002) and the capability check (CAP001)
* both miss the case where an effectful closure is passed to a combinator
Expand Down Expand Up @@ -135,6 +140,34 @@ export function passEffCheck(src: string, version: VersionInfo): string {
});
}
}

// THR003: throws {} on callback not propagated to outer fn.
if (decl.paramThrows.length > 0) {
const declaredThrows = new Set(decl.throws ?? []);
const uniqueMissing = [...new Set(decl.paramThrows.filter((t) => !declaredThrows.has(t)))];
if (uniqueMissing.length > 0) {
const entry = getErrorCode("THR003")!;
const loc = locationOf(src, decl.fnKeywordStart);
const paramThrowsStr = [...new Set(decl.paramThrows)].join(", ");
diagnostics.push({
code: "THR003",
severity: "error",
file: null,
line: loc.line,
column: loc.column,
start: decl.fnKeywordStart,
end: decl.fnKeywordStart + decl.name.length + 3,
message:
`fn '${decl.name}' accepts callback parameter(s) that declare throws { ${paramThrowsStr} } ` +
`but only declares throws ${declaredThrows.size > 0 ? `{ ${[...declaredThrows].join(", ")} }` : "{}"} — ` +
`missing: { ${uniqueMissing.join(", ")} }`,
rule: entry.rule,
idiom: entry.idiom,
rewrite:
`fn ${decl.name}(...) throws { ${[...declaredThrows, ...uniqueMissing].join(", ")} } -> ...`,
});
}
}
}
}

Expand Down
152 changes: 152 additions & 0 deletions packages/compiler/tests/thr003-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Tests for THR003: callback throws annotation not propagated to outer fn (?bs 0.9+).
*
* Fires when a function-typed parameter declares `throws { X }` but the containing
* fn does not declare `throws { X }`. Calling the callback can surface X, so the
* outer fn's throws surface must cover it (same principle as EFF003/EFF004).
*/

import { describe, expect, it } from "vitest";
import { transform } from "../src/transform.js";

function compile(src: string): string {
return transform(src).code;
}

// ---------------------------------------------------------------------------
// THR003: missing throws from callback parameter
// ---------------------------------------------------------------------------

describe("THR003: callback throws not propagated", () => {
it("fires when callback declares throws and outer fn declares none", () => {
const src =
"?bs 0.9\n" +
"fn process(\n" +
" items: string[],\n" +
" handler: fn(string) throws { NetworkError } -> void\n" +
") -> void {\n" +
" handler(items[0])\n" +
"}\n";
expect(() => compile(src)).toThrow("THR003");
expect(() => compile(src)).toThrow(/NetworkError/);
});

it("fires when callback throws is a superset of outer fn throws", () => {
const src =
"?bs 0.9\n" +
"fn retry(\n" +
" action: fn() throws { NetworkError, TimeoutError } -> string\n" +
") throws { NetworkError } -> string {\n" +
" action()\n" +
"}\n";
expect(() => compile(src)).toThrow("THR003");
expect(() => compile(src)).toThrow(/TimeoutError/);
});

it("fires with multiple callback params each declaring throws", () => {
const src =
"?bs 0.9\n" +
"fn run(\n" +
" a: fn() throws { AuthError } -> void,\n" +
" b: fn() throws { NetworkError } -> void\n" +
") -> void {\n" +
" a()\n" +
" b()\n" +
"}\n";
expect(() => compile(src)).toThrow("THR003");
});
});

// ---------------------------------------------------------------------------
// THR003: suppressed when throws surface is covered
// ---------------------------------------------------------------------------

describe("THR003: suppressed when covered", () => {
it("does not fire when outer fn declares the callback throws", () => {
const src =
"?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";
expect(() => compile(src)).not.toThrow();
});

it("does not fire when outer fn over-declares throws", () => {
const src =
"?bs 0.9\n" +
"fn withRetry(\n" +
" action: fn() throws { NetworkError } -> string\n" +
") throws { NetworkError, TimeoutError } -> string {\n" +
" action()\n" +
"}\n";
expect(() => compile(src)).not.toThrow();
});

it("does not fire when callback has no throws annotation", () => {
const src =
"?bs 0.9\n" +
"fn withRetry(\n" +
" action: fn() -> string\n" +
") -> string {\n" +
" action()\n" +
"}\n";
expect(() => compile(src)).not.toThrow();
});

it("does not fire below ?bs 0.9", () => {
const src =
"?bs 0.8\n" +
"fn process(\n" +
" items: string[],\n" +
" handler: fn(string) throws { NetworkError } -> void\n" +
") -> void {\n" +
" handler(items[0])\n" +
"}\n";
expect(() => compile(src)).not.toThrow();
});
});

// ---------------------------------------------------------------------------
// THR003: throws annotation stripped from emitted TypeScript
// ---------------------------------------------------------------------------

describe("THR003: throws stripped from emitted TS", () => {
it("strips throws from callback parameter type in emitted TypeScript", () => {
const src =
"?bs 0.9\n" +
"fn process(\n" +
" handler: fn(string) throws { NetworkError } -> void\n" +
") throws { NetworkError } -> void {\n" +
" handler(\"x\")\n" +
"}\n";
const out = compile(src);
expect(out).not.toContain("throws");
expect(out).toContain("(string) =>");
});
});

// ---------------------------------------------------------------------------
// THR003: diagnostic fields
// ---------------------------------------------------------------------------

describe("THR003: diagnostic code", () => {
it("throws with THR003 code in diagnostics", () => {
const src =
"?bs 0.9\n" +
"fn process(\n" +
" handler: fn(string) throws { NetworkError } -> void\n" +
") -> void {\n" +
" handler(\"x\")\n" +
"}\n";
try {
compile(src);
expect.fail("should have thrown");
} catch (e) {
const err = e as { diagnostics?: Array<{ code: string }> };
expect(err.diagnostics?.[0]?.code).toBe("THR003");
}
});
});
Loading
Loading