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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope.
| RES001 | (0.3+) `Result.try` / `Result.tryAsync` with no body. | `Result.try { <body that may throw> }`. |
| 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
range (`BSnnn` for general parse errors, `CAPnnn` for capability checks,
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ 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(...))`, `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
file) and B declares `reads { x }` (or `writes { x }`), then A must also
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
Expand Down
27 changes: 27 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,33 @@ const E: Record<string, ErrorCodeEntry> = {
"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(...))`, `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)` (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:
"// before — parseConfig constructs NetworkError but declares throws { ParseError }\n" +
"?bs 0.9\n" +
"fn parseConfig(s: string) throws { ParseError } -> Result<string, string> {\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<string, string> {\n" +
" if (bad) err(NetworkError(\"timed out\"))\n" +
" else ok(s)\n" +
"}",
},
};

export function getErrorCode(code: string): ErrorCodeEntry | undefined {
Expand Down
143 changes: 119 additions & 24 deletions packages/compiler/src/passes/thr-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,25 @@
* 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(...))`,
* `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.
*
* 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";
Expand All @@ -31,7 +29,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
Expand Down Expand Up @@ -115,16 +114,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;
}

Expand All @@ -143,7 +149,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);

Expand All @@ -160,11 +166,6 @@ function mkError(src: string, rec: FnRecord, missingLabels: string[]): Botscript
? 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);
Expand All @@ -177,9 +178,13 @@ function mkError(src: string, rec: FnRecord, missingLabels: string[]): Botscript
? `'${leaf}' which throws { ${firstLabel} }`
: `${displayPath} — '${leaf}' throws { ${firstLabel} }`;

const declSuffix =
rec.declaredThrows.size === 0
? `has no throws clause`
: `has throws { ${[...rec.declaredThrows].sort().join(", ")} } but not { ${missingLabels.join(", ")} }`;
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;
Expand All @@ -200,3 +205,93 @@ function mkError(src: string, rec: FnRecord, missingLabels: string[]): Botscript

return new BotscriptError([diagnostic]);
}

/**
* 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[],
fn: FnDecl,
inner: FnDecl[],
declaredThrows: Set<string>,
src: string,
): BotscriptError | null {
const entry = getErrorCode("THR002")!;

// Cursor-based inner-fn exclusion.
const open: FnDecl[] = [];
let nextInner = 0;

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]!);
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 proposed = [...new Set([...declaredThrows, typeName])].sort().join(", ");

return new BotscriptError([{
code: "THR002",
severity: "error" as const,
file: null,
line,
column,
start: tok.start,
end: tok.end,
message:
Comment on lines +275 to +286
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,
Comment on lines +286 to +290
idiom: entry.idiom,
rewrite: `fn ${fn.name}(...) throws { ${proposed} } -> ...`,
}]);
}

return null;
}
Loading
Loading