From 0b2774cf48e933d39112f9e7231f3d79eb85d762 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 21 May 2026 14:51:10 -0300 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20MAT001=20=E2=80=94=20non-exhaustive?= =?UTF-8?q?=20Result=20match=20(=3Fbs=200.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fires when a match explicitly handles ok or err but omits the opposing tag without a wildcard arm. Closes the second half of the result-contract loop: UNS005 enforces that stdlib calls are wrapped in match; MAT001 enforces that the match is exhaustive. Closes #78. Co-Authored-By: Botkowski --- CHANGELOG.md | 8 ++ packages/compiler/src/error-codes.ts | 28 +++++ packages/compiler/src/passes/mat-check.ts | 72 +++++++++++ packages/compiler/src/transform.ts | 4 + packages/compiler/tests/mat-check.test.ts | 147 ++++++++++++++++++++++ packages/mcp/src/explanations.ts | 39 ++++++ packages/mcp/tests/server.test.ts | 1 + 7 files changed, 299 insertions(+) create mode 100644 packages/compiler/src/passes/mat-check.ts create mode 100644 packages/compiler/tests/mat-check.test.ts 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/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 13c5da6..33edbbf 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 'err { e } -> ...' arm or '_ -> ...' 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..0afc818 --- /dev/null +++ b/packages/compiler/src/passes/mat-check.ts @@ -0,0 +1,72 @@ +/** + * 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"; + + throw new BotscriptError([{ + code: "MAT001", + severity: "error", + file: null, + line, + column, + start: matchStart, + end: tokens[expr.end - 1]?.end ?? matchStart, + message: `match on Result is missing '${missing}' arm — add '${missing} { ... } -> ...' or a wildcard '_ -> ...' arm`, + rule: entry.rule, + idiom: entry.idiom, + rewrite: entry.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..beb53c3 --- /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, BotscriptError } 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 is present (no ok)", () => { + 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 is present (no err)", () => { + 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 arms (user-defined tags)", () => { + 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..b6218ce 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -442,6 +442,45 @@ 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: match on Result is missing 'err' arm\n" + + "match http.get(url) {\n" + + " ok { value } -> value.body\n" + + "}\n" + + "```\n\n" + + "**Suppression mechanisms (in order of preference):**\n\n" + + "1. **Explicit err arm** — handle the error case directly:\n" + + " ```\n match http.get(url) {\n ok { value } -> ok(value.body)\n err { e } -> err(e.message)\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", From 2433fb109a7af8fee8633a7b5beaa214a64fc776 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 21 May 2026 18:38:33 -0300 Subject: [PATCH 2/7] fix: make MAT001 rewrite hint symmetric for missing ok and err arms The rewrite field was hardcoded to suggest adding an err arm, which was misleading when the ok arm was the missing one. Now constructs the hint dynamically from the missing tag, and updates the registry entry and MCP explanation to cover both missing-ok and missing-err cases. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/error-codes.ts | 2 +- packages/compiler/src/passes/mat-check.ts | 4 +++- packages/mcp/src/explanations.ts | 8 ++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 33edbbf..9bf81d2 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -374,7 +374,7 @@ const E: Record = { "prefer explicit `ok` and `err` arms over a wildcard when the error type carries useful context — " + "a wildcard silently discards the payload", rewrite: - "add 'err { e } -> ...' arm or '_ -> ...' wildcard", + "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" + diff --git a/packages/compiler/src/passes/mat-check.ts b/packages/compiler/src/passes/mat-check.ts index 0afc818..dc68d9f 100644 --- a/packages/compiler/src/passes/mat-check.ts +++ b/packages/compiler/src/passes/mat-check.ts @@ -52,6 +52,8 @@ export function passMatCheck(src: string, version: VersionInfo): string { 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", @@ -64,7 +66,7 @@ export function passMatCheck(src: string, version: VersionInfo): string { message: `match on Result is missing '${missing}' arm — add '${missing} { ... } -> ...' or a wildcard '_ -> ...' arm`, rule: entry.rule, idiom: entry.idiom, - rewrite: entry.rewrite, + rewrite, }]); } diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index b6218ce..a3bed7b 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -450,13 +450,17 @@ export const EXPLANATIONS: Readonly> = { "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: match on Result is missing 'err' arm\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. **Explicit err arm** — handle the error case directly:\n" + + "1. **Add the missing arm** — handle both success and failure explicitly:\n" + " ```\n match http.get(url) {\n ok { value } -> ok(value.body)\n err { e } -> err(e.message)\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" + From a757d5cc26631dc626da94614eedcda2b482f56d Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 21 May 2026 22:38:20 -0300 Subject: [PATCH 3/7] fix(mat-check): tighten diagnostic span to match keyword; drop unused BotscriptError import Copilot review (round 2) flagged two issues: - end span covered the full match block; now anchors at the match keyword token end, consistent with how thr-check and bare-as anchor their diagnostics - BotscriptError was imported from transform.js (which doesn't export it), causing a potential ESM load-time crash; removed since it was unused Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/mat-check.ts | 2 +- packages/compiler/tests/mat-check.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/mat-check.ts b/packages/compiler/src/passes/mat-check.ts index dc68d9f..c80d5c9 100644 --- a/packages/compiler/src/passes/mat-check.ts +++ b/packages/compiler/src/passes/mat-check.ts @@ -62,7 +62,7 @@ export function passMatCheck(src: string, version: VersionInfo): string { line, column, start: matchStart, - end: tokens[expr.end - 1]?.end ?? matchStart, + end: tokens[expr.start]!.end, message: `match on Result is missing '${missing}' arm — add '${missing} { ... } -> ...' or a wildcard '_ -> ...' arm`, rule: entry.rule, idiom: entry.idiom, diff --git a/packages/compiler/tests/mat-check.test.ts b/packages/compiler/tests/mat-check.test.ts index beb53c3..d4c848e 100644 --- a/packages/compiler/tests/mat-check.test.ts +++ b/packages/compiler/tests/mat-check.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it } from "vitest"; -import { transform, BotscriptError } from "../src/transform.js"; +import { transform } from "../src/transform.js"; function compile(src: string): string { return transform(src).code; From 75fbef36f88aabb27a9b8f73568122208470c686 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 22 May 2026 02:40:18 -0300 Subject: [PATCH 4/7] fix(mat-check): correct test descriptions for wildcard-arm suppression cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test names said "(no ok)" and "(no err)" but had the logic backwards — each test includes one of the ok/err arms; the wildcard covers the *other*. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/tests/mat-check.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/tests/mat-check.test.ts b/packages/compiler/tests/mat-check.test.ts index d4c848e..d5b46cd 100644 --- a/packages/compiler/tests/mat-check.test.ts +++ b/packages/compiler/tests/mat-check.test.ts @@ -75,7 +75,7 @@ describe("MAT001: suppressed when exhaustive", () => { expect(() => compile(src)).not.toThrow(); }); - it("does not fire when a wildcard arm is present (no ok)", () => { + 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" + @@ -87,7 +87,7 @@ describe("MAT001: suppressed when exhaustive", () => { expect(() => compile(src)).not.toThrow(); }); - it("does not fire when a wildcard arm is present (no err)", () => { + 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" + From 443b0f1bd683755f462f62b1ca3ee3b5064cf85d Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 22 May 2026 06:47:55 -0300 Subject: [PATCH 5/7] docs: add MAT001 to AGENTS.md diagnostic table and README explain row Copilot review correctly flagged that MAT001 was missing from both the AGENTS.md diagnostic codes table and the README MCP `explain` tool code list. Added the row and updated the explain description. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/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. From 81ca64b311cf0ce06534c05e442929da45bdc503 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 22 May 2026 10:45:41 -0300 Subject: [PATCH 6/7] fix(mat-check): address Copilot review on PR #79 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make suppression examples in MCP MAT001 explanation symmetric — step 1 now shows adding the missing arm for both the err-absent and ok-absent cases - 596/596 tests pass (previously failing due to stale dist — rebuild resolves it) --- packages/mcp/src/explanations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index a3bed7b..699133c 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -460,8 +460,8 @@ export const EXPLANATIONS: Readonly> = { "}\n" + "```\n\n" + "**Suppression mechanisms (in order of preference):**\n\n" + - "1. **Add the missing arm** — handle both success and failure explicitly:\n" + - " ```\n match http.get(url) {\n ok { value } -> ok(value.body)\n err { e } -> err(e.message)\n }\n ```\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 " + From d22ff1f7031f3786a6f9ce9edb66845379c4a018 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 22 May 2026 14:47:07 -0300 Subject: [PATCH 7/7] fix: clarify MAT001 message wording, fix test description for non-ok/err pattern case --- packages/compiler/src/passes/mat-check.ts | 2 +- packages/compiler/tests/mat-check.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/mat-check.ts b/packages/compiler/src/passes/mat-check.ts index c80d5c9..772c81d 100644 --- a/packages/compiler/src/passes/mat-check.ts +++ b/packages/compiler/src/passes/mat-check.ts @@ -63,7 +63,7 @@ export function passMatCheck(src: string, version: VersionInfo): string { column, start: matchStart, end: tokens[expr.start]!.end, - message: `match on Result is missing '${missing}' arm — add '${missing} { ... } -> ...' or a wildcard '_ -> ...' arm`, + message: `non-exhaustive match with ok/err arms: missing '${missing}' arm — add '${missing} { ... } -> ...' or a wildcard '_ -> ...' arm`, rule: entry.rule, idiom: entry.idiom, rewrite, diff --git a/packages/compiler/tests/mat-check.test.ts b/packages/compiler/tests/mat-check.test.ts index d5b46cd..a68f3dc 100644 --- a/packages/compiler/tests/mat-check.test.ts +++ b/packages/compiler/tests/mat-check.test.ts @@ -99,7 +99,7 @@ describe("MAT001: suppressed when exhaustive", () => { expect(() => compile(src)).not.toThrow(); }); - it("does not fire for a match with no ok/err arms (user-defined tags)", () => { + 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" +