diff --git a/AGENTS.md b/AGENTS.md index ba0d01d..9904157 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,8 +194,18 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | RES001 | (0.3+) `Result.try` / `Result.tryAsync` with no body. | `Result.try { }`. | | 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. | +| INT002 | (0.7+) A fn declares `intent: "pure"` but its body directly references a stdlib capability (e.g. `http.get`, `fs.read`). Pure intent is enforced at the body level as well as the header. | Remove the stdlib call from the body, or change the intent. | +| CAP003 | (0.9+, warning) A fn is declared `unsafe "reason" fn name(…)` and also has a `uses { … }` clause. The compiler cannot prove the capability is actually reached — the assertion is programmer-owned. Non-blocking; the fn compiles. | Remove the `uses {}` clause if it is not needed, or document why the assertion is trusted. | +| EFF002 | (0.7+) A callback parameter declares `uses { … }` capabilities beyond what the outer fn declares. A fn that claims `uses { net }` cannot safely accept a callback that also writes to `fs` — the outer declaration would be a lie. | Extend the outer fn's `uses {}` to cover the callback's full capability set, or narrow the callback's annotation. | +| EFF003 | (0.9+) A callback parameter declares `reads { … }` labels not covered by the outer fn's `reads {}`. Same structural rule as EFF002 applied to resource read dependencies. | Add the missing label(s) to the outer fn's `reads {}`, or narrow the callback annotation. | +| EFF004 | (0.9+) A callback parameter declares `writes { … }` labels not covered by the outer fn's `writes {}`. | Add the missing label(s) to the outer fn's `writes {}`, or narrow the callback annotation. | +| DEP001 | (0.9+) A fn's body (or a callee in the same file) reads a resource label not declared in the fn's own `reads {}`. Transitivity is enforced: if `loadUser` calls `fetchRow` which reads `userDb`, `loadUser` must also declare `reads { userDb }`. | Add the missing label(s) to `reads {}`, or remove the undeclared read. | +| DEP002 | (0.9+) Same as DEP001 but for `writes {}` labels. A fn whose callee writes a resource must declare that write in its own header. | Add the missing label(s) to `writes {}`, or remove the undeclared write. | +| 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. | +| 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. | 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, diff --git a/README.md b/README.md index 3914489..15c7b88 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`) 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`, `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 38c84ff..13c5da6 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -412,6 +412,44 @@ const E: Record = { " else ok(s)\n" + "}", }, + VER001: { + code: "VER001", + title: "reads {} / writes {} declared below the ?bs 0.9 enforcement floor — annotation is unenforced", + rule: + "DEP001/DEP002 (reads/writes transitivity) are enforced from `?bs 0.9`; a non-empty `reads {}` or " + + "`writes {}` clause on a file pinned below 0.9 is accepted but not verified — it is documentation only", + idiom: + "annotate now if you intend to enforce later, but know that reviewers reading the header " + + "cannot assume the compiler has checked it; upgrade the pin to `?bs 0.9` to activate enforcement", + rewrite: + "upgrade pin to `?bs 0.9` to activate DEP001/DEP002 enforcement", + example: + "// before — reads {} at ?bs 0.8 is documentation only (VER001 warning)\n" + + "?bs 0.8\n" + + "fn loadUser(id: string) reads { userDb } -> string = id\n\n" + + "// after — enforcement active\n" + + "?bs 0.9\n" + + "fn loadUser(id: string) reads { userDb } -> string = id", + }, + VER002: { + code: "VER002", + title: "throws {} declared below the ?bs 0.9 enforcement floor — annotation is unenforced", + rule: + "THR001 (throws transitivity) is enforced from `?bs 0.9`; a non-empty " + + "`throws {}` clause on a file pinned below 0.9 is accepted but not verified — it is documentation only", + idiom: + "annotate now if you intend to enforce later, but know that reviewers reading the header " + + "cannot assume the compiler has checked it; upgrade the pin to `?bs 0.9` to activate enforcement", + rewrite: + "upgrade pin to `?bs 0.9` to activate THR001 enforcement", + example: + "// before — throws {} at ?bs 0.8 is documentation only (VER002 warning)\n" + + "?bs 0.8\n" + + "fn loadUser(id: string) throws { NetworkError } -> string = id\n\n" + + "// after — enforcement active\n" + + "?bs 0.9\n" + + "fn loadUser(id: string) throws { NetworkError } -> string = id", + }, }; export function getErrorCode(code: string): ErrorCodeEntry | undefined { diff --git a/packages/compiler/src/passes/ver-check.ts b/packages/compiler/src/passes/ver-check.ts new file mode 100644 index 0000000..cd1e83e --- /dev/null +++ b/packages/compiler/src/passes/ver-check.ts @@ -0,0 +1,108 @@ +/** + * Version-floor warning for unenforced effect declarations (?bs < 0.9). + * + * Effect annotations are parsed and accepted at any version, but enforcement + * only kicks in from `?bs 0.9`: + * + * - `reads {}` / `writes {}` + DEP001/DEP002: enforced from `?bs 0.9` + * - `throws {}` + THR001: enforced from `?bs 0.9` + * + * When a non-empty clause is present on a file pinned below its enforcement + * floor, the compiler accepts it silently — the annotation is documentation, + * not a verified claim. A reviewer reading the header would reasonably assume + * the compiler has verified the transitivity claim; it has not. + * + * VER001 A non-empty `reads {}` or `writes {}` clause is declared on a fn + * whose file is pinned below `?bs 0.9`. DEP001/DEP002 are not + * enforced; the annotation is documentation only. + * + * VER002 A non-empty `throws {}` clause is declared on a fn whose file is + * pinned below `?bs 0.9`. THR001 is not enforced; the annotation + * is documentation only. + * + * Both VER001 and VER002 are warnings (non-blocking) — the intended pattern + * of "annotate first, then upgrade the pin" is valid. The warning makes the + * lack of enforcement visible so reviewers are not given false assurance. + * + * Only non-empty clauses are flagged. An empty `reads {}` / `throws {}` on + * an old-pin file is likely an intentional forward-declaration placeholder + * and does not create false assurance. + * + * ?bs 0.9+ This pass is a no-op (enforcement is active, no warning needed). + */ + +import type { Diagnostic } from "../diagnostics.js"; +import { getErrorCode } from "../error-codes.js"; +import { parseProgram } from "../parser/parse.js"; +import { locationOf } from "./_location.js"; +import { atLeast, type VersionInfo } from "./version.js"; + +export interface VerCheckResult { + code: string; + warnings: ReadonlyArray; +} + +export function passVerCheck(src: string, version: VersionInfo): VerCheckResult { + // Enforcement is active at 0.9 — no warning needed. + if (atLeast(version.resolved, "0.9")) return { code: src, warnings: [] }; + + const allowGenerics = atLeast(version.resolved, "0.4"); + const program = parseProgram(src, { allowGenerics, includeNestedFns: true }); + const warnings: Diagnostic[] = []; + + const ver001 = getErrorCode("VER001")!; + const ver002 = getErrorCode("VER002")!; + + for (const { decl } of program.fns) { + const hasUnenforcedReads = (decl.reads?.length ?? 0) > 0; + const hasUnenforcedWrites = (decl.writes?.length ?? 0) > 0; + const hasUnenforcedThrows = (decl.throws?.length ?? 0) > 0; + + if (hasUnenforcedReads || hasUnenforcedWrites) { + const { line, column } = locationOf(src, decl.fnKeywordStart); + const clauses: string[] = []; + if (hasUnenforcedReads) clauses.push(`reads { ${decl.reads!.join(", ")} }`); + if (hasUnenforcedWrites) clauses.push(`writes { ${decl.writes!.join(", ")} }`); + const clauseStr = clauses.join(" / "); + + warnings.push({ + code: "VER001", + severity: "warning", + file: null, + line, + column, + start: decl.fnKeywordStart, + end: decl.nameStart + decl.name.length, + message: + `fn '${decl.name}' declares ${clauseStr} at ?bs ${version.resolved} — ` + + `DEP001/DEP002 enforcement requires ?bs 0.9; this annotation is unenforced`, + rule: ver001.rule, + idiom: ver001.idiom, + rewrite: ver001.rewrite, + }); + } + + if (hasUnenforcedThrows) { + const { line, column } = locationOf(src, decl.fnKeywordStart); + const throwsStr = `throws { ${decl.throws!.join(", ")} }`; + + warnings.push({ + code: "VER002", + severity: "warning", + file: null, + line, + column, + start: decl.fnKeywordStart, + end: decl.nameStart + decl.name.length, + message: + `fn '${decl.name}' declares ${throwsStr} at ?bs ${version.resolved} — ` + + `THR001 enforcement requires ?bs 0.9; this annotation is unenforced`, + rule: ver002.rule, + idiom: ver002.idiom, + rewrite: ver002.rewrite, + }); + } + } + + return { code: src, warnings }; +} diff --git a/packages/compiler/src/transform.ts b/packages/compiler/src/transform.ts index c1ead85..d48d7bb 100644 --- a/packages/compiler/src/transform.ts +++ b/packages/compiler/src/transform.ts @@ -7,6 +7,7 @@ import { passCapCheck } from "./passes/cap-check.js"; import { passFn } from "./passes/fn.js"; import { passImports } from "./passes/imports.js"; import { passCapAssert } from "./passes/cap-assert.js"; +import { passVerCheck } from "./passes/ver-check.js"; import { passDepCheck } from "./passes/dep-check.js"; import { passThrCheck } from "./passes/thr-check.js"; import { passEffCheck } from "./passes/eff-check.js"; @@ -62,6 +63,11 @@ const PASS_PIPELINE: ReadonlyArray = [ // message "intent says pure but has uses { net }" is seen before the // transitive capability walk, which produces noisier output. { name: "intentCheck", fn: passIntentCheck, minVersion: "0.7" }, + // verCheck: non-blocking warning (VER001/VER002) when reads/writes/throws + // annotations are declared below their enforcement floor (?bs 0.9). Runs + // early so it can see the full, unmodified header. No-op at 0.9+ because + // the enforcement passes (depCheck, thrCheck) already validate the claims. + { name: "verCheck", fn: passVerCheck }, // effCheck: header-level check that the outer fn's capabilities cover the // effect annotations on its callback parameters (EFF002). Runs alongside // intentCheck — both are header consistency checks before the body walk. diff --git a/packages/compiler/tests/ver-check.test.ts b/packages/compiler/tests/ver-check.test.ts new file mode 100644 index 0000000..d61a75e --- /dev/null +++ b/packages/compiler/tests/ver-check.test.ts @@ -0,0 +1,267 @@ +/** + * Tests for VER001 / VER002: unenforced effect annotations below ?bs 0.9. + * + * Both codes are warnings (non-blocking). Compilation succeeds; warnings are + * returned in TransformResult.warnings. + * + * VER001 reads {} / writes {} present but DEP001/DEP002 is not enforced + * (file pinned below ?bs 0.9). + * VER002 throws {} present but THR001 is not enforced (file pinned + * below ?bs 0.9). + */ + +import { describe, expect, it } from "vitest"; +import { transform } from "../src/transform.js"; + +// --------------------------------------------------------------------------- +// VER001 — reads / writes unenforced +// --------------------------------------------------------------------------- + +describe("VER001: fires as a warning for reads/writes below 0.9", () => { + it("emits VER001 for non-empty reads at ?bs 0.8", () => { + const src = + "?bs 0.8\n" + + "fn loadUser(id: string) reads { db } -> string {\n" + + " id\n" + + "}\n"; + const result = transform(src); + const warns = result.warnings.filter((w) => w.code === "VER001"); + expect(warns).toHaveLength(1); + expect(warns[0]!.severity).toBe("warning"); + expect(warns[0]!.message).toMatch(/loadUser/); + expect(warns[0]!.message).toMatch(/reads \{ db \}/); + expect(warns[0]!.message).toMatch(/0\.8/); + expect(warns[0]!.message).toMatch(/DEP001\/DEP002/); + expect(warns[0]!.message).toMatch(/unenforced/); + }); + + it("emits VER001 for non-empty writes at ?bs 0.8", () => { + const src = + "?bs 0.8\n" + + "fn saveUser(id: string) writes { db } -> string {\n" + + " id\n" + + "}\n"; + const result = transform(src); + const warns = result.warnings.filter((w) => w.code === "VER001"); + expect(warns).toHaveLength(1); + expect(warns[0]!.message).toMatch(/writes \{ db \}/); + }); + + it("includes both reads and writes in message when both present", () => { + const src = + "?bs 0.8\n" + + "fn syncUser(id: string) reads { cache } writes { db } -> string {\n" + + " id\n" + + "}\n"; + const result = transform(src); + const warns = result.warnings.filter((w) => w.code === "VER001"); + expect(warns).toHaveLength(1); + expect(warns[0]!.message).toMatch(/reads \{ cache \}/); + expect(warns[0]!.message).toMatch(/writes \{ db \}/); + }); + + it("does not throw — compilation succeeds", () => { + const src = + "?bs 0.8\n" + + "fn loadUser(id: string) reads { db } -> string {\n" + + " id\n" + + "}\n"; + expect(() => transform(src)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// VER002 — throws unenforced +// --------------------------------------------------------------------------- + +describe("VER002: fires as a warning for throws below 0.9", () => { + it("emits VER002 for non-empty throws at ?bs 0.8", () => { + const src = + "?bs 0.8\n" + + "fn loadUser(id: string) throws { NetworkError } -> string {\n" + + " id\n" + + "}\n"; + const result = transform(src); + const warns = result.warnings.filter((w) => w.code === "VER002"); + expect(warns).toHaveLength(1); + expect(warns[0]!.severity).toBe("warning"); + expect(warns[0]!.message).toMatch(/loadUser/); + expect(warns[0]!.message).toMatch(/throws \{ NetworkError \}/); + expect(warns[0]!.message).toMatch(/0\.8/); + expect(warns[0]!.message).toMatch(/THR001/); + expect(warns[0]!.message).toMatch(/unenforced/); + }); + + it("does not throw — compilation succeeds", () => { + const src = + "?bs 0.8\n" + + "fn loadUser(id: string) throws { NetworkError } -> string {\n" + + " id\n" + + "}\n"; + expect(() => transform(src)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Both VER001 and VER002 together +// --------------------------------------------------------------------------- + +describe("VER001 + VER002: fires both when fn has reads/writes and throws", () => { + it("emits both warnings for a fn with reads and throws", () => { + const src = + "?bs 0.8\n" + + "fn loadUser(id: string) reads { db } throws { NetworkError } -> string {\n" + + " id\n" + + "}\n"; + const result = transform(src); + const v1 = result.warnings.filter((w) => w.code === "VER001"); + const v2 = result.warnings.filter((w) => w.code === "VER002"); + expect(v1).toHaveLength(1); + expect(v2).toHaveLength(1); + }); + + it("fires one warning per fn, one fn per code", () => { + const src = + "?bs 0.8\n" + + "fn fnA(x: string) reads { db } -> string {\n" + + " x\n" + + "}\n" + + "fn fnB(x: string) throws { NetworkError } -> string {\n" + + " x\n" + + "}\n"; + const result = transform(src); + const v1 = result.warnings.filter((w) => w.code === "VER001"); + const v2 = result.warnings.filter((w) => w.code === "VER002"); + expect(v1).toHaveLength(1); + expect(v1[0]!.message).toMatch(/fnA/); + expect(v2).toHaveLength(1); + expect(v2[0]!.message).toMatch(/fnB/); + }); +}); + +// --------------------------------------------------------------------------- +// Empty clauses — no false positives +// --------------------------------------------------------------------------- + +describe("VER001 / VER002: empty clauses do not fire", () => { + it("does not warn for empty reads at ?bs 0.8", () => { + const src = + "?bs 0.8\n" + + "fn loadUser(id: string) reads {} -> string {\n" + + " id\n" + + "}\n"; + const result = transform(src); + const warns = result.warnings.filter((w) => w.code === "VER001"); + expect(warns).toHaveLength(0); + }); + + it("does not warn for empty writes at ?bs 0.8", () => { + const src = + "?bs 0.8\n" + + "fn saveUser(id: string) writes {} -> string {\n" + + " id\n" + + "}\n"; + const result = transform(src); + const warns = result.warnings.filter((w) => w.code === "VER001"); + expect(warns).toHaveLength(0); + }); + + it("does not warn for empty throws at ?bs 0.8", () => { + const src = + "?bs 0.8\n" + + "fn loadUser(id: string) throws {} -> string {\n" + + " id\n" + + "}\n"; + const result = transform(src); + const warns = result.warnings.filter((w) => w.code === "VER002"); + expect(warns).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Version gate — silent at ?bs 0.9+ +// --------------------------------------------------------------------------- + +describe("VER001 / VER002: silent at ?bs 0.9", () => { + it("does not warn for reads at ?bs 0.9 (enforcement is active)", () => { + const src = + "?bs 0.9\n" + + "fn loadUser(id: string) reads { db } -> string {\n" + + " id\n" + + "}\n"; + const result = transform(src); + const warns = result.warnings.filter((w) => w.code === "VER001" || w.code === "VER002"); + expect(warns).toHaveLength(0); + }); + + it("does not warn for throws at ?bs 0.9 (enforcement is active)", () => { + const src = + "?bs 0.9\n" + + "fn loadUser(id: string) throws { NetworkError } -> string {\n" + + " id\n" + + "}\n"; + const result = transform(src); + const warns = result.warnings.filter((w) => w.code === "VER001" || w.code === "VER002"); + expect(warns).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Nested fn declarations +// --------------------------------------------------------------------------- + +describe("VER001 / VER002: warns on nested fn declarations", () => { + it("emits VER001 for nested fn with reads below 0.9", () => { + const src = + "?bs 0.8\n" + + "fn outer(id: string) -> string {\n" + + " fn inner(x: string) reads { db } -> string { x }\n" + + " inner(id)\n" + + "}\n"; + const result = transform(src); + const warns = result.warnings.filter((w) => w.code === "VER001"); + expect(warns).toHaveLength(1); + expect(warns[0]!.message).toMatch(/inner/); + expect(warns[0]!.message).toMatch(/reads \{ db \}/); + }); + + it("emits VER002 for nested fn with throws below 0.9", () => { + const src = + "?bs 0.8\n" + + "fn outer(id: string) -> string {\n" + + " fn inner(x: string) throws { NetworkError } -> string { x }\n" + + " inner(id)\n" + + "}\n"; + const result = transform(src); + const warns = result.warnings.filter((w) => w.code === "VER002"); + expect(warns).toHaveLength(1); + expect(warns[0]!.message).toMatch(/inner/); + expect(warns[0]!.message).toMatch(/throws \{ NetworkError \}/); + }); +}); + +// --------------------------------------------------------------------------- +// No false positives for fns without effect annotations +// --------------------------------------------------------------------------- + +describe("VER001 / VER002: no false positives", () => { + it("does not warn for plain fn at ?bs 0.8", () => { + const src = + "?bs 0.8\n" + + "fn add(a: number, b: number) -> number = pure { a + b }\n"; + const result = transform(src); + const warns = result.warnings.filter((w) => w.code === "VER001" || w.code === "VER002"); + expect(warns).toHaveLength(0); + }); + + it("does not warn for fn with only uses clause at ?bs 0.8", () => { + const src = + "?bs 0.8\n" + + "fn fetch(url: string) uses { net } -> string {\n" + + " http.get(url)\n" + + "}\n"; + const result = transform(src); + const warns = result.warnings.filter((w) => w.code === "VER001" || w.code === "VER002"); + expect(warns).toHaveLength(0); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index c1ab83e..fbef3b5 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -556,6 +556,59 @@ export const EXPLANATIONS: Readonly> = { ") throws { NetworkError } -> void { handler(items[0]) }\n", }, }, + VER001: { + code: "VER001", + title: "reads {} / writes {} declared below the ?bs 0.9 enforcement floor", + body: + "From `?bs 0.9`, the compiler enforces that `reads {}` / `writes {}` annotations are " + + "transitively consistent across same-file calls (DEP001/DEP002). Below that version, " + + "the annotations are parsed and accepted silently — they are documentation, not verified claims.\n\n" + + "VER001 fires as a **warning** (non-blocking) when a non-empty `reads {}` or `writes {}` " + + "clause is declared on a fn in a file pinned below `?bs 0.9`. A reviewer reading the " + + "header would reasonably assume the compiler has checked the transitivity claim — it has not.\n\n" + + "The most common scenario: a team in mid-upgrade writes `reads { userDb }` annotations " + + "while still on `?bs 0.8`, intending to enforce later. VER001 makes the lack of " + + "enforcement visible so reviewers are not given false assurance.\n\n" + + "**Empty clauses are not flagged.** `reads {}` (no labels) on an old-pin file is likely " + + "an intentional forward-declaration placeholder and does not create false assurance.\n\n" + + "The fix is to upgrade the `?bs` pin to `0.9` (which activates DEP001/DEP002 enforcement) " + + "or to leave the annotation in place knowing it is documentation-only until the upgrade.", + example: { + fails: + "?bs 0.8\n" + + "fn loadUser(id: string) reads { userDb } -> string = id\n", + passes: + "?bs 0.9\n" + + "fn loadUser(id: string) reads { userDb } -> string = id\n", + }, + }, + VER002: { + code: "VER002", + title: "throws {} declared below the ?bs 0.9 enforcement floor", + body: + "From `?bs 0.9`, the compiler enforces that `throws {}` annotations are transitively " + + "consistent across same-file calls (THR001). Below that version, the annotations are " + + "parsed and accepted silently — they are documentation, not verified claims.\n\n" + + "VER002 fires as a **warning** (non-blocking) when a non-empty `throws {}` clause is " + + "declared on a fn in a file pinned below `?bs 0.9`. A reviewer reading the header would " + + "reasonably assume the compiler has checked the transitivity claim — it has not.\n\n" + + "The most common scenario: a team writes `throws { NetworkError }` annotations while " + + "still on `?bs 0.8`, intending to enforce at upgrade time. When they finally pin to " + + "`?bs 0.9`, they may discover the entire call graph needs new declarations — a large, " + + "surprising diff. VER002 makes this risk visible before the upgrade.\n\n" + + "**Empty clauses are not flagged.** `throws {}` (no types) on an old-pin file is likely " + + "an intentional forward-declaration placeholder and does not create false assurance.\n\n" + + "The fix is to upgrade the `?bs` pin to `0.9` (which activates THR001 enforcement) " + + "or to leave the annotation knowing it is documentation-only until the upgrade.", + example: { + fails: + "?bs 0.8\n" + + "fn loadUser(id: string) throws { NetworkError } -> string = id\n", + passes: + "?bs 0.9\n" + + "fn loadUser(id: string) throws { NetworkError } -> string = id\n", + }, + }, }; export const KNOWN_CODES = Object.keys(EXPLANATIONS).sort(); diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index 36a8a44..80c470f 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -67,6 +67,8 @@ describe("botscript-mcp explanations", () => { "UNS002", "UNS003", "UNS004", + "VER001", + "VER002", ]); });