diff --git a/AGENTS.md b/AGENTS.md index 6bb47fdb..b6f2fd53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,6 +200,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | SYN003 | (0.7+, warning) A fn body contains a `console.*` call (console.log, console.error, etc.). Direct console output bypasses the `stdout`/`stderr` capability model — the compiler cannot enforce or surface the output declaration for callers. | Replace `console.log(...)` with `stdout.write(...)` and add `uses { stdout }` to the fn header; replace `console.error(...)` with `stderr.write(...)` and add `uses { stderr }`. | | SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers, there is no capability or resource declaration that covers it, and the fn has an undeclared dependency on deployment configuration. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `env`. `obj.process.env` (member access on a local), `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass config and secrets as explicit fn parameters so the dependency is visible in the call signature; if env access is required at the load site, wrap in `unsafe "reads deployment env" { }`. | | SYN006 | (0.7+, warning) A fn body calls `process.exit()`, `process?.exit()`, or `process.exit?.()`. All forms terminate the entire host process — not just the fn, not just the bot. They produce no return value, bypass `Result` propagation, `throws {}`, `match`, and any caller recovery path. No capability declaration covers them. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `exit` then `(` or `?.(`. `obj.process.exit(...)`, `process.exit` without `(`, and `process.exitCode` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Return `err(...)` and let the caller decide whether to terminate. If `process.exit` is genuinely required at a bootstrap entry point, wrap in `unsafe "exits on invalid config" { process.exit(1) }`. | +| SYN008 | (0.7+, warning) A fn body calls or constructs a `WebSocket` via `new WebSocket(url)`, `WebSocket(url)`, TypeScript instantiation forms like `new WebSocket(url)`, or optional-call-with-type-args `WebSocket?.(url)`. All forms open persistent bidirectional connections at runtime but are invisible to CAP001 (which checks `http.*` member calls). A fn that calls or constructs a WebSocket has an undeclared `net` dependency. Detection: `WebSocket` not preceded by `.`/`?.`, followed by `(`, `?.(`, `(`, or `?.(`. `obj.WebSocket(...)` and bare `WebSocket` references are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap the constructor in `unsafe "wraps WebSocket directly" { new WebSocket(url) }` to make the escape hatch visible in the diff; note that `uses { net }` alone will trigger CAP002 without a stdlib `http.*` call; write a thin `$require("net")`-checked wrapper for full capability tracking. | | 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. | | INT003 | (0.7+) A fn declares `intent: "idempotent"` but also has `uses { random }` or `uses { time }`. Both capabilities produce different values on each call, making the function non-idempotent. Only `random` and `time` are flagged; other capabilities are not structurally flagged by this check (INT003 is a narrow heuristic, not a proof of idempotence). | Remove `random`/`time` from `uses {}`, or change the intent. | | INT004 | (0.7+) A fn declares `intent: "idempotent"` but its body directly references `random` or `time` without declaring them. Under-declaration variant of INT003 — fires when INT003 does not. | Remove the non-idempotent call from the body, or declare the capability and remove the idempotent intent. | diff --git a/README.md b/README.md index 1bf8d55c..cf476bd2 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, warnings: [...] }` on success, or `{ ok: false, diagnostics: [...] }` on failure. `warnings` is an array of non-blocking diagnostics (e.g. CAP003). | -| `explain` | `{ code: string }` | Long-form explanation for any stable diagnostic code (`ALI001`, `ALI002`, `ALI003`, `BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`, `DEP002`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN005`, `SYN006`, `THR001`–`THR004`, `UNS001`–`UNS005`, `VER001`–`VER003`) plus a fails/passes example pair. | +| `explain` | `{ code: string }` | Long-form explanation for any stable diagnostic code (`ALI001`, `ALI002`, `ALI003`, `BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`, `DEP002`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN005`, `SYN006`, `SYN008`, `THR001`–`THR004`, `UNS001`–`UNS005`, `VER001`–`VER003`) 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 aa8aa867..6a2bfb6c 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -611,6 +611,41 @@ const E: Record = { " return ok(undefined)\n" + "}", }, + SYN008: { + code: "SYN008", + title: "new WebSocket() / WebSocket() call bypasses the net capability model", + rule: + "`new WebSocket(url)`, `WebSocket(url)`, and TypeScript instantiation forms like " + + "`new WebSocket(url)` open persistent bidirectional connections at runtime but are " + + "invisible to botscript's capability model: CAP001 checks for `http.*` member calls, " + + "not the `WebSocket` global. A fn that constructs a WebSocket has an undeclared network " + + "dependency — the capability model cannot see it (there is no `http.*` call for CAP001 to track), " + + "no audit tool can observe the dependency from the fn header, and the connection outlives the fn's return value.", + idiom: + "wrap the `WebSocket` constructor in `unsafe \"wraps WebSocket directly\" { new WebSocket(url) }` " + + "to make the escape hatch visible in the diff; for full capability tracking, write a thin " + + "wrapper fn that calls `$require(\"net\")` before constructing the socket", + rewrite: + "// before — WebSocket is invisible to the capability model\n" + + "fn openFeed(url: string) -> WebSocket {\n" + + " return new WebSocket(url) // SYN008\n" + + "}\n\n" + + "// after — escape hatch justified in the diff\n" + + "fn openFeed(url: string) -> WebSocket {\n" + + ' return unsafe "wraps WebSocket for streaming feed" { new WebSocket(url) }\n' + + "}", + example: + "// SYN008: WebSocket bypasses the net capability model\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket(url) // SYN008\n" + + " ws.onmessage = (e) => handle(e.data)\n" + + "}\n\n" + + "// fix: wrap in unsafe with a justification\n" + + "fn subscribe(url: string) -> void {\n" + + ' const ws = unsafe "wraps WebSocket for live updates" { new WebSocket(url) }\n' + + " ws.onmessage = (e) => handle(e.data)\n" + + "}", + }, DEP001: { code: "DEP001", title: "fn transitively reads a resource category not declared in its header", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index eb1c94ed..d811ddaf 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -23,6 +23,20 @@ * depends on runtime deployment values that callers cannot see, * audit, or mock in tests. The idiomatic fix is to pass config * and secrets as explicit fn parameters. + * + * SYN006 A `process.exit()` call was detected in a fn body (?bs 0.7+). + * `process.exit()` terminates the entire host process — not just the + * fn, not just the bot. It produces no return value and bypasses + * Result propagation, throws {}, match, and any caller recovery + * path. The idiomatic fix is `return err(...)` so the caller can + * decide whether to terminate. + * + * SYN008 A `new WebSocket(...)` or `WebSocket(...)` call was detected in a + * fn body (?bs 0.7+). WebSocket opens a persistent bidirectional + * connection that is invisible to CAP001 (which checks `http.*` + * member calls). A fn that constructs a WebSocket has an undeclared + * `net` dependency. Wrap in `unsafe "reason" { }`; for full + * capability tracking, write a thin `$require("net")`-checked wrapper. */ import type { Diagnostic } from "../diagnostics.js"; @@ -55,8 +69,9 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const syn003 = getErrorCode("SYN003")!; const syn005 = getErrorCode("SYN005")!; const syn006 = getErrorCode("SYN006")!; + const syn008 = getErrorCode("SYN008")!; - // Collect char-offset ranges where SYN002/SYN003/SYN005/SYN006 are suppressed: + // Collect char-offset ranges where SYN002/SYN003/SYN005/SYN006/SYN008 are suppressed: // 1. `unsafe "reason" { ... }` expression blocks — explicit acknowledgment. // 2. `unsafe "reason" fn` bodies — the entire body is exempt, including any // non-unsafe nested fns declared inside it (matching uns-check's pattern). @@ -70,7 +85,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const nesting = computeNesting(program.fns.map((f) => f.decl)); for (const { decl } of program.fns) { - // An `unsafe "reason" fn` body is an explicit acknowledgment — skip SYN002/SYN003/SYN005. + // An `unsafe "reason" fn` body is an explicit acknowledgment — all SYN checks are skipped. // The range-based suppression above also covers nested non-unsafe fns within it, // so this early-continue is kept purely as an optimisation. if (decl.unsafeReason !== undefined) continue; @@ -396,6 +411,115 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult rewrite: syn006.rewrite, }); } + + // SYN008: WebSocket constructor/call detection. + // Fires when a fn body contains `new WebSocket(...)`, `WebSocket(...)`, + // TypeScript instantiation form `new WebSocket(...)` / `WebSocket(...)`, + // optional-call `WebSocket?.(...)`, or optional-call-with-type-args `WebSocket?.(...)`. + // All forms open a persistent bidirectional network connection at runtime + // but are invisible to CAP001 (which checks `http.*` member calls only). + // Suppressed inside `unsafe { }` blocks and `unsafe fn` bodies. + nextInner = 0; + const open8: typeof inner = []; + for (let i = bodyStart; i < decl.tokenEnd; i++) { + while (open8.length > 0 && open8[open8.length - 1]!.tokenEnd <= i) open8.pop(); + while (nextInner < inner.length && inner[nextInner]!.tokenStart <= i) { + open8.push(inner[nextInner]!); + nextInner++; + } + if (open8.length > 0) continue; + + const tok8 = tokens[i]; + if (!tok8 || tok8.kind !== "ident" || tok8.text !== "WebSocket") continue; + + // Exclude: `obj.WebSocket(...)` or `obj?.WebSocket(...)` — member call on a local. + const prevIdx8 = prevSignificant(tokens, i - 1); + const prev8 = tokens[prevIdx8]; + if (prev8 && ((prev8.kind === "punct" && prev8.text === ".") || prev8.kind === "questionDot")) + continue; + + // Must be followed by `(`, `?.(`, `(`, or `?.(` — confirming this is a + // constructor or direct call (including optional-call-with-type-args), not a bare `WebSocket` reference. + // parenIdx8 is normalized to the actual `(` token index for all forms. + const afterWsFirstIdx = nextSignificant(tokens, i + 1); + const afterWs = tokens[afterWsFirstIdx]; + if (!afterWs) continue; + + let parenIdx8: number; + + if (afterWs.kind === "operator" && afterWs.text === "<") { + // TypeScript instantiation form: `WebSocket(...)` or `new WebSocket(...)` + let anglDepth = 1; + let j = afterWsFirstIdx + 1; + while (j < decl.tokenEnd && anglDepth > 0) { + const at = tokens[j]; + if (!at) { j++; continue; } + if (at.kind === "operator" && at.text === "<") anglDepth++; + else if (at.kind === "operator" && (at.text === ">" || at.text === ">>" || at.text === ">>>")) + anglDepth = Math.max(0, anglDepth - at.text.length); + j++; + } + parenIdx8 = nextSignificant(tokens, j); + } else if (afterWs.kind === "questionDot") { + // `WebSocket?.(...)` or `WebSocket?.(...)` — optional call + let afterQD8 = nextSignificant(tokens, afterWsFirstIdx + 1); + const afterQDTok8 = tokens[afterQD8]; + if (afterQDTok8 && afterQDTok8.kind === "operator" && afterQDTok8.text === "<") { + let qdAnglDepth = 1; + let k = afterQD8 + 1; + while (k < decl.tokenEnd && qdAnglDepth > 0) { + const at8 = tokens[k]; + if (!at8) { k++; continue; } + if (at8.kind === "operator" && at8.text === "<") qdAnglDepth++; + else if (at8.kind === "operator" && (at8.text === ">" || at8.text === ">>" || at8.text === ">>>")) + qdAnglDepth = Math.max(0, qdAnglDepth - at8.text.length); + k++; + } + afterQD8 = nextSignificant(tokens, k); + } + parenIdx8 = afterQD8; + } else if (afterWs.kind === "open" && afterWs.text === "(") { + parenIdx8 = afterWsFirstIdx; + } else { + continue; + } + + const parenTok8 = tokens[parenIdx8]; + if (!parenTok8 || !(parenTok8.kind === "open" && parenTok8.text === "(")) continue; + + // Exclude object/class method shorthands: `{ WebSocket(url) { ... } }` + const closeParenIdx8 = parenTok8.matchedAt; + if (closeParenIdx8 !== undefined) { + const afterParenIdx8 = nextSignificant(tokens, closeParenIdx8 + 1); + const afterParen8 = tokens[afterParenIdx8]; + if ( + afterParen8 && + ((afterParen8.kind === "open" && afterParen8.text === "{") || + afterParen8.kind === "fatArrow" || + (afterParen8.kind === "punct" && afterParen8.text === ":")) + ) continue; + } + + // Suppression check: unsafe block or unsafe fn body + if (isInsideRange(tok8.start, unsafeRanges)) continue; + + const loc8 = locationOf(src, tok8.start); + warnings.push({ + code: "SYN008", + severity: "warning", + file: null, + line: loc8.line, + column: loc8.column, + start: tok8.start, + end: tok8.end, + message: + `fn '${decl.name}' calls or constructs a WebSocket — bypasses the net capability model; ` + + `wrap in unsafe "wraps WebSocket directly" { WebSocket(...) } or write a $require("net")-checked wrapper`, + rule: syn008.rule, + idiom: syn008.idiom, + rewrite: syn008.rewrite, + }); + } } return { code: src, warnings }; diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index 0eb75b0a..b319ce98 100644 --- a/packages/compiler/tests/error-codes.test.ts +++ b/packages/compiler/tests/error-codes.test.ts @@ -15,7 +15,7 @@ describe("error-code registry", () => { "INT001", "INT002", "INT003", "INT004", "INT005", "MAT001", "MAT002", "MAT003", "MAT004", "RES001", "RES002", - "SYN001", "SYN002", "SYN003", "SYN005", "SYN006", + "SYN001", "SYN002", "SYN003", "SYN005", "SYN006", "SYN008", "THR001", "THR002", "THR003", "THR004", "UNS001", "UNS002", "UNS003", "UNS004", "UNS005", "VER001", "VER002", "VER003", diff --git a/packages/compiler/tests/syn008-check.test.ts b/packages/compiler/tests/syn008-check.test.ts new file mode 100644 index 00000000..47d7b75a --- /dev/null +++ b/packages/compiler/tests/syn008-check.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from "vitest"; +import { transform } from "../src/index.js"; + +describe("SYN008 — WebSocket bypasses the net capability model", () => { + it("fires on new WebSocket(url)", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("fires on bare WebSocket(url) call without new", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = WebSocket(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("fires on TypeScript instantiation form new WebSocket(url)", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("fires on nested generic form new WebSocket>(url)", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket>(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("fires on WebSocket?.(url) optional call", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = WebSocket?.(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("fires on WebSocket?.(url) optional call with type arguments", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = WebSocket?.(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("does not fire below ?bs 0.7", () => { + const src = + "?bs 0.6\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("does not fire when WebSocket is inside an unsafe block", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + ' const ws = unsafe "wraps WebSocket for live feed" { new WebSocket(url) }\n' + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("does not fire when WebSocket is inside an unsafe fn body", () => { + const src = + "?bs 0.7\n" + + 'unsafe "wraps WebSocket" fn subscribe(url: string) -> void {\n' + + " const ws = new WebSocket(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("does not fire on obj.WebSocket(url) — member call on a local", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = factory.WebSocket(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("does not fire on bare WebSocket reference without call", () => { + const src = + "?bs 0.7\n" + + "fn isSupported() -> boolean {\n" + + " return typeof WebSocket !== 'undefined'\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("warning carries correct code and severity", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket(url)\n" + + "}\n"; + const result = transform(src); + const w = result.warnings.find((w) => w.code === "SYN008"); + expect(w).toBeDefined(); + expect(w!.severity).toBe("warning"); + }); + + it("does NOT fire on WebSocket() as an object-literal method shorthand inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn makeClient() -> any {\n" + + " return { WebSocket(url: string) { return url } }\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("fires on WebSocket>>(url) — depth-3 generic with >>> composite token", () => { + const src = + "?bs 0.7\n" + + "fn connect(url: string) -> void {\n" + + " const ws = new WebSocket>>(url)\n" + + "}\n"; + const result = transform(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index fe445593..23289744 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -812,6 +812,50 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN008: { + code: "SYN008", + title: "new WebSocket() / WebSocket() call bypasses the net capability model", + body: + "Botscript's capability model maps network access to `uses { net }` via the `http` stdlib " + + "namespace. CAP001 enforces this by checking for `http.*` member calls (e.g. `http.get()`, " + + "`http.post()`). But the `WebSocket` global opens a persistent bidirectional connection without " + + "any `http.*` call — CAP001 never fires, and the capability model cannot require `uses { net }` for it.\n\n" + + "The impact is more severe than the `fetch` global bypass:\n" + + "- A WebSocket connection persists beyond the fn's return, making the connection lifetime " + + "invisible to the caller\n" + + "- A fn that constructs a WebSocket has a live, long-lived network dependency that callers " + + "cannot see, audit, or mock in tests\n" + + "- Audit tools and orchestrators that read `uses { net }` declarations will miss the dependency\n" + + "- The connection can receive data and trigger callbacks after the constructing fn has returned\n\n" + + "This is the same bypass class as bare `fetch(...)` calls: network effects that sidestep the " + + "declared capability surface.\n\n" + + "**Fix:** wrap the `WebSocket` constructor in `unsafe \"wraps WebSocket directly\" { new WebSocket(url) }` " + + "to make the escape hatch visible in the diff. You can add `uses { net }` to the fn header, but without " + + "an `http.*` call the compiler cannot verify the net usage and CAP002 (over-declared capability) will fire — " + + "the stdlib currently has no WebSocket wrapper. For full capability tracking, write a thin wrapper fn that " + + "calls `$require(\"net\")` before constructing the socket.\n\n" + + "SYN008 fires at `?bs 0.7+` as a non-blocking warning. Detection is token-based: " + + "`WebSocket` not preceded by `.`/`?.`, followed by `(`, `?.(`, `(`, or `?.(`. " + + "All five surface forms are detected: `new WebSocket(url)`, `WebSocket(url)` (without `new`), " + + "`new WebSocket(url)` (generic instantiation), `WebSocket?.(url)` (optional call), and " + + "`WebSocket?.(url)` (optional-call-with-type-args). " + + "`obj.WebSocket(...)` (method call on a local object) is excluded. " + + "Calls inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", + example: { + fails: + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket(url)\n" + + " ws.onmessage = (e) => handle(e.data)\n" + + "}\n", + passes: + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + ' const ws = unsafe "wraps WebSocket for live updates" { new WebSocket(url) }\n' + + " ws.onmessage = (e) => handle(e.data)\n" + + "}\n", + }, + }, DEP001: { code: "DEP001", title: "fn transitively reads 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 57e87d71..793f8369 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -75,6 +75,7 @@ describe("botscript-mcp explanations", () => { "SYN003", "SYN005", "SYN006", + "SYN008", "THR001", "THR002", "THR003",