diff --git a/AGENTS.md b/AGENTS.md index 9904157..457e4ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a3bd03..18e56ad 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. +- **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. + - **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/README.md b/README.md index 15c7b88..187e456 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 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. diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 13c5da6..9bf81d2 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -364,6 +364,34 @@ const E: Record = { " 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 {\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", diff --git a/packages/compiler/src/passes/mat-check.ts b/packages/compiler/src/passes/mat-check.ts new file mode 100644 index 0000000..772c81d --- /dev/null +++ b/packages/compiler/src/passes/mat-check.ts @@ -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; +} diff --git a/packages/compiler/src/transform.ts b/packages/compiler/src/transform.ts index d48d7bb..be3788a 100644 --- a/packages/compiler/src/transform.ts +++ b/packages/compiler/src/transform.ts @@ -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"; @@ -77,6 +78,9 @@ const PASS_PIPELINE: ReadonlyArray = [ { 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" }, // 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. diff --git a/packages/compiler/tests/mat-check.test.ts b/packages/compiler/tests/mat-check.test.ts new file mode 100644 index 0000000..a68f3dc --- /dev/null +++ b/packages/compiler/tests/mat-check.test.ts @@ -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 {\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 {\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"); + } + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index fbef3b5..699133c 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -442,6 +442,49 @@ export const EXPLANATIONS: Readonly> = { "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 " + + "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.", + 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 {\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", diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index 80c470f..4e89963 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -58,6 +58,7 @@ describe("botscript-mcp explanations", () => { "FMT001", "INT001", "INT002", + "MAT001", "RES001", "SYN001", "THR001",