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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope.
| THR001 | (0.9+) A fn's body (or a same-file callee) throws an exception type not declared in the fn's `throws {}`. Transitivity is enforced: if `loadUser` calls `fetchRow throws { NetworkError }`, `loadUser` must also declare `throws { NetworkError }`. | Add the missing type(s) to `throws {}`, or add a `match` / `unsafe` to suppress the propagation. |
| 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. |
| THR003 | (0.9+) A callback parameter declares `throws { … }` types not covered by the outer fn's `throws {}`. Same structural rule as THR001 applied to callback parameters. | Add the missing type(s) to the outer fn's `throws {}`, or narrow the callback annotation. |
| MAT001 | (0.9+) A `match` expression handles `ok` or `err` tag patterns but omits the opposing tag without a wildcard `_` arm. An incomplete Result match is a silent no-op for the missing path. | Add the missing `ok { ... } -> ...` or `err { ... } -> ...` arm, or add a wildcard `_ -> ...` arm. |
| VER001 | (warning, < 0.9) A non-empty `reads {}` or `writes {}` clause is declared on a fn in a file pinned below `?bs 0.9`. DEP001/DEP002 enforcement is not active; the annotation is documentation only. Non-blocking. | Upgrade the pin to `?bs 0.9` to activate enforcement, or leave it knowing it is unenforced. |
| VER002 | (warning, < 0.9) A non-empty `throws {}` clause is declared on a fn in a file pinned below `?bs 0.9`. THR001 enforcement is not active; the annotation is documentation only. Non-blocking. | Upgrade the pin to `?bs 0.9` to activate enforcement, or leave it knowing it is unenforced. |

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

- **MAT001 — non-exhaustive Result match.**
From `?bs 0.9`, a `match` expression that explicitly handles the `ok` or `err`
tag must also handle the opposing tag (or include a wildcard `_` arm). Fires
when the `ok`/`err` tag vocabulary is used but one side is left unhandled.
Suppression: add the missing arm explicitly, or use a wildcard `_` arm.
The check is scoped to the `ok`/`err` vocabulary — user-defined tagged unions
with other tag names are unaffected.
Comment on lines +25 to +31

- **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 any stable diagnostic code (`BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`, `DEP002`, `EFF002`–`EFF004`, `FMT001`, `INT001`, `INT002`, `RES001`, `SYN001`, `THR001`–`THR003`, `UNS001`–`UNS004`, `VER001`–`VER002`) 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`, `MAT001`, `RES001`, `SYN001`, `THR001`–`THR003`, `UNS001`–`UNS004`, `VER001`–`VER002`) 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
28 changes: 28 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,34 @@ const E: Record<string, ErrorCodeEntry> = {
" handler(items[0])\n" +
"}",
},
MAT001: {
code: "MAT001",
title: "non-exhaustive match on Result — missing ok or err arm",
rule:
"a match expression that explicitly handles the `ok` or `err` tag must also handle the other; " +
"add the missing arm or a wildcard `_` to make the match exhaustive",
idiom:
"prefer explicit `ok` and `err` arms over a wildcard when the error type carries useful context — " +
"a wildcard silently discards the payload",
rewrite:
"add the missing 'ok { v } -> ...' or 'err { e } -> ...' arm, or a '_ -> ...' wildcard",
example:
"// before — match on Result is missing the err arm\n" +
"?bs 0.9\n" +
"fn fetchUser(id: string) uses { net } -> string {\n" +
" match http.get(`/users/${id}`) {\n" +
" ok { value } -> value.body // MAT001: missing err arm\n" +
" }\n" +
"}\n\n" +
"// after\n" +
"?bs 0.9\n" +
"fn fetchUser(id: string) uses { net } -> Result<string, string> {\n" +
" match http.get(`/users/${id}`) {\n" +
" ok { value } -> ok(value.body)\n" +
" err { e } -> err(e.message)\n" +
" }\n" +
"}",
},
THR001: {
code: "THR001",
title: "fn transitively throws an exception type not declared in its header",
Expand Down
74 changes: 74 additions & 0 deletions packages/compiler/src/passes/mat-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Match exhaustiveness check (?bs 0.9+).
*
* Enforces that any match expression explicitly handling the `ok` or `err`
* tag vocabulary also handles the opposing tag (or has a wildcard arm).
*
* MAT001 non-exhaustive Result match: a match has an `ok` arm but no
* `err` arm (or vice versa) and no wildcard `_` arm.
*
* The check is scoped to the `ok`/`err` tag vocabulary — it fires only when
* at least one of those tags is explicitly named in an arm. User-defined
* tagged unions with different tag names are not affected.
*
* Over-exhaustive matches (both ok and err arms plus wildcard) are clean.
*/

import { BotscriptError } from "../diagnostics.js";
import { getErrorCode } from "../error-codes.js";
import { lex } from "../parser/lex.js";
import { parseMatch } from "../parser/parse-match.js";
import { locationOf } from "./_location.js";
import { atLeast, type VersionInfo } from "./version.js";

export function passMatCheck(src: string, version: VersionInfo): string {
if (!atLeast(version.resolved, "0.9")) return src;

const tokens = lex(src);
const entry = getErrorCode("MAT001")!;

for (let i = 0; i < tokens.length; i++) {
const t = tokens[i]!;
if (t.kind !== "keyword" || t.keyword !== "match") continue;
const expr = parseMatch(tokens, i);
if (!expr) continue;

let hasOk = false;
let hasErr = false;
let hasWildcard = false;

for (const arm of expr.arms) {
if (arm.pattern.kind === "wildcard") { hasWildcard = true; break; }
if (arm.pattern.kind === "tag") {
if (arm.pattern.tag === "ok") hasOk = true;
if (arm.pattern.tag === "err") hasErr = true;
}
}

if (hasWildcard) continue;
if (!hasOk && !hasErr) continue;
if (hasOk && hasErr) continue;

const matchStart = tokens[expr.start]!.start;
const { line, column } = locationOf(src, matchStart);
const missing = hasOk ? "err" : "ok";
const missingPattern = missing === "err" ? "'err { e } -> ...'" : "'ok { v } -> ...'";
const rewrite = `add ${missingPattern} arm or a '_ -> ...' wildcard`;

throw new BotscriptError([{
code: "MAT001",
severity: "error",
file: null,
line,
column,
start: matchStart,
end: tokens[expr.start]!.end,
message: `non-exhaustive match with ok/err arms: missing '${missing}' arm — add '${missing} { ... } -> ...' or a wildcard '_ -> ...' arm`,
rule: entry.rule,
idiom: entry.idiom,
rewrite,
}]);
}

return src;
}
4 changes: 4 additions & 0 deletions packages/compiler/src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { passThrCheck } from "./passes/thr-check.js";
import { passEffCheck } from "./passes/eff-check.js";
import { passIntentCheck } from "./passes/intent-check.js";
import { passMatch } from "./passes/match.js";
import { passMatCheck } from "./passes/mat-check.js";
import { passPrimer } from "./passes/primer.js";
import { passResultTry } from "./passes/result-try.js";
import { passTaggedUnion } from "./passes/tagged-union.js";
Expand Down Expand Up @@ -77,6 +78,9 @@ const PASS_PIPELINE: ReadonlyArray<PipelineEntry> = [
{ name: "depCheck", fn: passDepCheck, minVersion: "0.9" },
// thrCheck: transitivity enforcement for throws {} annotations (THR001).
{ name: "thrCheck", fn: passThrCheck, minVersion: "0.9" },
// matCheck: exhaustiveness check on Result match (MAT001) — fires when a
// match explicitly handles ok or err but omits the other without a wildcard.
{ name: "matCheck", fn: passMatCheck, minVersion: "0.9" },
Comment on lines +81 to +83
// capAssert: non-blocking warning (CAP003) when a `uses {}` claim appears on
// an `unsafe fn` — the claim is programmer-asserted, not compiler-proven.
// Runs before capCheck so capCheck still validates the claim's content.
Expand Down
147 changes: 147 additions & 0 deletions packages/compiler/tests/mat-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* Tests for MAT001: non-exhaustive Result match (?bs 0.9+).
*
* Fires when a match has an ok arm but no err arm (or vice versa) and no wildcard.
*/

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

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

// ---------------------------------------------------------------------------
// MAT001: missing err arm
// ---------------------------------------------------------------------------

describe("MAT001: missing err arm", () => {
it("fires when a match has ok arm but no err arm", () => {
const src =
"?bs 0.9\n" +
"fn fetchData(url: string) uses { net } -> string {\n" +
" match http.get(url) {\n" +
" ok { value } -> value\n" +
" }\n" +
"}\n";
expect(() => compile(src)).toThrow("MAT001");
expect(() => compile(src)).toThrow(/missing 'err' arm/);
});

it("fires when await-wrapped call is the scrutinee and err arm is missing", () => {
const src =
"?bs 0.9\n" +
"fn fetchData(url: string) uses { net } -> string {\n" +
" match await http.get(url) {\n" +
" ok { value } -> value\n" +
" }\n" +
"}\n";
expect(() => compile(src)).toThrow("MAT001");
});
});

// ---------------------------------------------------------------------------
// MAT001: missing ok arm
// ---------------------------------------------------------------------------

describe("MAT001: missing ok arm", () => {
it("fires when a match has err arm but no ok arm", () => {
const src =
"?bs 0.9\n" +
"fn fetchData(url: string) uses { net } -> string {\n" +
" match http.get(url) {\n" +
" err { e } -> e\n" +
" }\n" +
"}\n";
expect(() => compile(src)).toThrow("MAT001");
expect(() => compile(src)).toThrow(/missing 'ok' arm/);
});
});

// ---------------------------------------------------------------------------
// MAT001: suppression
// ---------------------------------------------------------------------------

describe("MAT001: suppressed when exhaustive", () => {
it("does not fire when both ok and err arms are present", () => {
const src =
"?bs 0.9\n" +
"fn fetchData(url: string) uses { net } -> Result<string, string> {\n" +
" match http.get(url) {\n" +
" ok { value } -> ok(value)\n" +
" err { e } -> err(e)\n" +
" }\n" +
"}\n";
expect(() => compile(src)).not.toThrow();
});

it("does not fire when a wildcard arm covers the missing err", () => {
const src =
"?bs 0.9\n" +
"fn fetchData(url: string) uses { net } -> Result<string, string> {\n" +
" match http.get(url) {\n" +
" ok { value } -> ok(value)\n" +
" _ -> err(\"failed\")\n" +
" }\n" +
"}\n";
expect(() => compile(src)).not.toThrow();
});

it("does not fire when a wildcard arm covers the missing ok", () => {
const src =
"?bs 0.9\n" +
"fn fetchData(url: string) uses { net } -> string {\n" +
" match http.get(url) {\n" +
" err { e } -> e\n" +
" _ -> \"default\"\n" +
" }\n" +
"}\n";
expect(() => compile(src)).not.toThrow();
});

it("does not fire for a match with no ok/err tag arms (non-ok/err patterns)", () => {
const src =
"?bs 0.9\n" +
"fn classify(x: number) -> string {\n" +
" match x {\n" +
" 1 -> \"one\"\n" +
" _ -> \"other\"\n" +
" }\n" +
"}\n";
expect(() => compile(src)).not.toThrow();
});

it("does not fire below ?bs 0.9", () => {
const src =
"?bs 0.8\n" +
"fn fetchData(url: string) uses { net } -> string {\n" +
" match http.get(url) {\n" +
" ok { value } -> value\n" +
" }\n" +
"}\n";
expect(() => compile(src)).not.toThrow();
});
});

// ---------------------------------------------------------------------------
// MAT001: diagnostic fields
// ---------------------------------------------------------------------------

describe("MAT001: diagnostic code and message", () => {
it("throws BotscriptError with MAT001 code", () => {
const src =
"?bs 0.9\n" +
"fn fetchData(url: string) uses { net } -> string {\n" +
" match http.get(url) {\n" +
" ok { value } -> value\n" +
" }\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("MAT001");
}
});
});
43 changes: 43 additions & 0 deletions packages/mcp/src/explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,49 @@ export const EXPLANATIONS: Readonly<Record<string, Explanation>> = {
"fn loadUser(id: string) reads { cache } -> string = getFromCache(id)\n",
},
},
MAT001: {
code: "MAT001",
title: "non-exhaustive match on Result — missing ok or err arm",
body:
"From `?bs 0.9`, a `match` expression that explicitly handles the `ok` or `err` tag " +
Comment on lines +445 to +449
"must also handle the opposing tag (or include a wildcard `_` arm).\n\n" +
"This fires when you match on a Result value but leave one path unhandled:\n\n" +
"```\n" +
"// MAT001: missing 'err' arm\n" +
"match http.get(url) {\n" +
" ok { value } -> value.body\n" +
"}\n\n" +
"// MAT001: missing 'ok' arm\n" +
"match result {\n" +
" err { e } -> err(e)\n" +
"}\n" +
"```\n\n" +
"**Suppression mechanisms (in order of preference):**\n\n" +
"1. **Add the missing arm** — handle both arms explicitly (add whichever is absent):\n" +
" ```\n // missing err arm:\n match http.get(url) {\n ok { value } -> ok(value.body)\n err { e } -> err(e.message)\n }\n\n // missing ok arm:\n match result {\n ok { v } -> v.body\n err { e } -> err(e)\n }\n ```\n\n" +
"2. **Wildcard arm** — use `_` when you want to coerce or ignore the missing case:\n" +
" ```\n match http.get(url) {\n ok { value } -> ok(value.body)\n _ -> err(\"request failed\")\n }\n ```\n\n" +
"The check is scoped to the `ok`/`err` tag vocabulary — it fires only when at least one " +
"of those tags is explicitly named in an arm. User-defined tagged unions with different " +
"tag names are not affected.",
Comment on lines +462 to +469
example: {
fails:
"?bs 0.9\n" +
"fn fetchData(url: string) uses { net } -> string {\n" +
" match http.get(url) {\n" +
" ok { value } -> value.body\n" +
" }\n" +
"}\n",
passes:
"?bs 0.9\n" +
"fn fetchData(url: string) uses { net } -> Result<string, string> {\n" +
" match http.get(url) {\n" +
" ok { value } -> ok(value.body)\n" +
" err { e } -> err(e.message)\n" +
" }\n" +
"}\n",
},
},
DEP002: {
code: "DEP002",
title: "fn transitively writes a resource category not declared in its header",
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/tests/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe("botscript-mcp explanations", () => {
"FMT001",
"INT001",
"INT002",
"MAT001",
"RES001",
"SYN001",
"THR001",
Expand Down
Loading