diff --git a/AGENTS.md b/AGENTS.md index 84d6d6ed..1c108ee5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,6 +202,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers and to static analysis; no capability or resource declaration covers it, so 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) }`. | | SYN007 | (0.7+, warning) A fn body calls `fetch(url)` or `fetch?.(url)`. `fetch` makes HTTP requests at runtime but is invisible to CAP001, which only checks `http.*` member calls. CAP001 cannot infer or require `uses { net }` from `fetch` calls, so callers cannot rely on CAP001 to detect a missing declaration. Detection: `fetch` not preceded by `.`/`?.`, followed by `(` or `?.(`. Member calls (`obj.fetch(...)`), object method shorthands (`{ fetch(url) {} }`), TypeScript method signatures, fn/function declarations named `fetch`, and bare `fetch` references are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Replace `fetch(url)` with `http.get(url)` / `http.post(url, { body })` and add `uses { net }` to the fn header. If the native fetch API is required, wrap in `unsafe "calls fetch directly" { fetch(url) }`. | +| SYN008 | (0.7+, warning) A fn body constructs or calls `WebSocket` via `new WebSocket(url)`, `WebSocket(url)`, `WebSocket?.(url)`, or TypeScript generic forms `new WebSocket(url)`. `WebSocket` opens a persistent bidirectional connection at runtime but is invisible to CAP001, which only checks `http.*` member calls. A fn that constructs a WebSocket has an undeclared network dependency — no `uses {}` declaration covers it. Generic scan (``) is gated on `new` to avoid false-positives on comparison expressions like `WebSocket < x > (y)`. Member calls (`obj.WebSocket(...)`), object method shorthands, and fn declarations named `WebSocket` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap the construction in `unsafe "wraps WebSocket for " { new WebSocket(url) }` to make the network dependency visible in the diff and to callers reading the fn. | | SYN010 | (0.7+, warning) A fn body calls `setTimeout(...)`, `setInterval(...)`, or `queueMicrotask(...)`. These globals schedule callbacks that run after the fn returns — any effects inside those callbacks are invisible to callers: no capability declaration, no `writes {}` label, and no `throws {}` entry can cover them. Detection: identifier not preceded by `.`/`?.`, followed by `(` or `?.(`. Member calls (`obj.setTimeout(...)`) and bare references (without `(`) are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Make the timing explicit: return a `Promise` the caller awaits, or return a teardown function so the caller controls the lifecycle. If a timer is genuinely required, wrap in `unsafe "schedules deferred effect" { setTimeout(...) }`. | | SYN011 | (0.7+, warning) A fn body calls `import(specifier)` — the dynamic import form. Dynamic imports load a module at runtime whose capability surface is unbounded: CAP001 checks for stdlib namespace calls, not dynamic module loads. A fn that calls `import()` has an undeclared capability surface proportional to everything the dynamically loaded module might do at runtime. Detection: `import` token not preceded by `.`/`?.`, followed by `(` or `?.(`. `import.meta` (followed by `.`) is excluded. Object method shorthands and `fn import(...)` declarations are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | If the module is known at compile time, use a static top-level `import { ... } from` declaration instead. If dynamic loading is required, wrap in `unsafe "loads plugin dynamically" { import(specifier) }`. | | 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. | diff --git a/README.md b/README.md index 7df89d5b..36188089 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`–`DEP004`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN004`, `SYN005`, `SYN006`, `SYN007`, `SYN010`, `SYN011`, `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`–`DEP004`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN004`, `SYN005`, `SYN006`, `SYN007`, `SYN008`, `SYN010`, `SYN011`, `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 5147caa3..4871f91f 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -674,6 +674,39 @@ const E: Record = { " return http.get(`/api/users/${id}`)\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 — no `uses {}` declaration covers it, and no audit tool can observe it from the fn header.", + idiom: + "wrap the `WebSocket` constructor in `unsafe \"\" { new WebSocket(url) }` " + + "to make the escape hatch visible in the diff", + 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" + + "}", + }, SYN010: { code: "SYN010", title: "setTimeout / setInterval / queueMicrotask defers side effects outside the fn's capability surface", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 78ecac10..19290eb8 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -45,6 +45,18 @@ * signatures (`{ fetch(url): T; }`). The `:` exclusion is guarded * against ternary consequents (`cond ? fetch(url) : other`). * + * SYN008 A `new WebSocket(url)` / `WebSocket(url)` call was detected in a fn body (?bs 0.7+). + * `WebSocket` opens a persistent bidirectional connection at runtime but is + * 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 that no capability declaration can see. + * Excluded: member calls (`obj.WebSocket`), `function`/`fn` declarations named + * `WebSocket`, object/class method shorthands, and TypeScript method + * signatures (`{ WebSocket(url): T; }`). The `:` exclusion is guarded + * against ternary consequents (`cond ? WebSocket(url) : other`, including + * `cond ? new WebSocket(url) : other`). Generic `` detection only when + * preceded by `new` (avoids false-positives on `WebSocket < x > (y)` comparisons). + * * SYN010 A `setTimeout(...)`, `setInterval(...)`, or `queueMicrotask(...)` * call was detected in a fn body (?bs 0.7+). These globals schedule * callbacks to run after the current fn returns — any effects inside @@ -100,6 +112,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const syn005 = getErrorCode("SYN005")!; const syn006 = getErrorCode("SYN006")!; const syn007 = getErrorCode("SYN007")!; + const syn008 = getErrorCode("SYN008")!; const syn010 = getErrorCode("SYN010")!; const syn011 = getErrorCode("SYN011")!; @@ -588,6 +601,92 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult break; } + // ── SYN008: new WebSocket() / WebSocket() call ─────────────────────── + case "WebSocket": { + const prevIdx8 = prevSignificant(tokens, i - 1); + const prev8 = tokens[prevIdx8]; + + // Exclude: `obj.WebSocket(...)` — preceded by `.` or `?.` + if (prev8 && ((prev8.kind === "punct" && prev8.text === ".") || prev8.kind === "questionDot")) + continue; + + // Exclude: function/fn declarations named WebSocket + if (prev8 && prev8.kind === "ident" && prev8.text === "function") continue; + if (prev8 && prev8.kind === "keyword" && prev8.text === "fn") continue; + + const hasNew8 = prev8 && prev8.kind === "ident" && prev8.text === "new"; + // For ternary guard: check if token before WebSocket (or before `new`) is `?` + const prevBeforeNew8 = hasNew8 + ? tokens[prevSignificant(tokens, prevIdx8 - 1)] + : undefined; + const isTernaryConsequent8 = (prev8 !== undefined && prev8 !== null && prev8.kind === "question") || + (prevBeforeNew8 !== undefined && prevBeforeNew8 !== null && prevBeforeNew8.kind === "question"); + + const nextIdx8 = nextSignificant(tokens, i + 1); + const next8 = tokens[nextIdx8]; + + let isOpt8 = false; + let callIdx8 = nextIdx8; + + if (next8 && next8.kind === "questionDot") { + // WebSocket?.( — optional call (no generic scan to avoid false-positives) + isOpt8 = true; + callIdx8 = nextSignificant(tokens, nextIdx8 + 1); + } else if (hasNew8 && next8 && next8.kind === "operator" && next8.text === "<") { + // new WebSocket( — generic scan only when `new` precedes, preventing + // `WebSocket < x > (y)` comparison expressions from false-firing. + let depth = 1; + let j = nextIdx8 + 1; + while (j < tokens.length && depth > 0) { + const t = tokens[j]; + if (!t) break; + if (t.kind === "operator" && t.text === "<") depth++; + else if (t.kind === "operator" && (t.text === ">" || t.text === ">>" || t.text === ">>>")) + depth = Math.max(0, depth - t.text.length); + j++; + } + callIdx8 = nextSignificant(tokens, j); + } + + const callTok8 = tokens[callIdx8]; + if (!callTok8 || !(callTok8.kind === "open" && callTok8.text === "(")) continue; + + // Exclude method shorthands and TS method signatures: { WebSocket(url) { ... } } / { WebSocket(url): T; } + // Guard the `:` check against ternary consequents: `cond ? WebSocket(url) : other` + if (callTok8.matchedAt !== undefined) { + const afterCloseIdx8 = nextSignificant(tokens, callTok8.matchedAt + 1); + const afterClose8 = tokens[afterCloseIdx8]; + if (afterClose8 && ( + (afterClose8.kind === "open" && afterClose8.text === "{") || + afterClose8.kind === "fatArrow" || + (!isTernaryConsequent8 && afterClose8.kind === "punct" && afterClose8.text === ":") + )) continue; + } + + if (isInsideRange(tok.start, unsafeRanges)) continue; + + const callSep8 = isOpt8 ? "?." : ""; + const warnStart8 = hasNew8 ? prev8!.start : tok.start; + const loc8 = locationOf(src, warnStart8); + warnings.push({ + code: "SYN008", + severity: "warning", + file: null, + line: loc8.line, + column: loc8.column, + start: warnStart8, + end: callTok8.start + 1, + message: + `fn '${decl.name}' ${hasNew8 ? "constructs new " : "calls "}WebSocket${callSep8}() — ` + + `WebSocket opens a network connection invisible to the capability model; ` + + `wrap in unsafe "wraps WebSocket for " { ${hasNew8 ? "new " : ""}WebSocket${isOpt8 ? "?." : ""}(url) }`, + rule: syn008.rule, + idiom: syn008.idiom, + rewrite: syn008.rewrite, + }); + break; + } + // ── SYN011: dynamic import() call ──────────────────────────────────── case "import": { // Exclude: `obj.import(...)` — preceded by `.` or `?.` diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index 0b4e6936..edf71979 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", "SYN004", "SYN005", "SYN006", "SYN007", "SYN010", "SYN011", + "SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN010", "SYN011", "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..508c9dd1 --- /dev/null +++ b/packages/compiler/tests/syn008-check.test.ts @@ -0,0 +1,181 @@ +/** + * Tests for SYN008: WebSocket() call detection (?bs 0.7+). + * + * SYN008 is a non-blocking warning — transform must not throw. + */ + +import { describe, it, expect } from "vitest"; +import { transform } from "../src/index.js"; + +function compile(src: string) { + return transform(src, {}); +} + +describe("SYN008: WebSocket() call detection", () => { + 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 = compile(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("fires on bare WebSocket(url) without new", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = WebSocket(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("fires on TypeScript 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 = compile(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 = compile(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("fires on optional-call WebSocket?.(url)", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = WebSocket?.(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(true); + }); + + it("does NOT fire inside an unsafe block", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + ' const ws = unsafe "wraps WebSocket for live updates" { new WebSocket(url) }\n' + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("does NOT fire 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 = compile(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("does NOT fire on member call obj.WebSocket(url)", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(ctx: any, url: string) -> void {\n" + + " const ws = ctx.WebSocket(url)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("does NOT fire on bare WebSocket reference (not called)", () => { + const src = + "?bs 0.7\n" + + "fn getClass() -> any {\n" + + " return WebSocket\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + 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 = compile(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("severity is warning (non-blocking)", () => { + const src = + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + " const ws = new WebSocket(url)\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN008"); + expect(w?.severity).toBe("warning"); + }); + + it("does NOT fire on object method shorthand named WebSocket", () => { + const src = + "?bs 0.7\n" + + "fn test() -> void {\n" + + " const handler = { WebSocket(url) { return url; } };\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("does NOT false-fire on WebSocket < x > (y) comparison expression", () => { + // The generic scan only activates when preceded by `new`. Without `new`, `WebSocket < x > (y)` + // is a comparison expression and must not trigger SYN008. + const src = + "?bs 0.7\n" + + "fn compare(x: number, y: number) -> boolean {\n" + + " const WebSocket = 42\n" + + " return WebSocket < x > (y)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + it("fires on WebSocket() inside a ternary expression (regression: `:` must not suppress)", () => { + // `cond ? WebSocket(a) : WebSocket(b)` — the `:` after WebSocket(a)'s closing `)` + // used to incorrectly match the method-shorthand exclusion, hiding SYN008. + const src = + "?bs 0.7\n" + + "fn pick(cond: boolean, a: string, b: string) -> any {\n" + + " return cond ? WebSocket(a) : WebSocket(b)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.filter((w) => w.code === "SYN008").length).toBe(2); + }); + + it("fires on new WebSocket() inside a ternary expression (regression: `:` must not suppress)", () => { + const src = + "?bs 0.7\n" + + "fn pick(cond: boolean, a: string, b: string) -> any {\n" + + " return cond ? new WebSocket(a) : new WebSocket(b)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.filter((w) => w.code === "SYN008").length).toBe(2); + }); + + it("fires once per distinct WebSocket construction in the same fn", () => { + const src = + "?bs 0.7\n" + + "fn openTwo(a: string, b: string) -> void {\n" + + " const ws1 = new WebSocket(a)\n" + + " const ws2 = new WebSocket(b)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.filter((w) => w.code === "SYN008").length).toBe(2); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 4c9d707c..ea430b84 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -881,6 +881,38 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN008: { + code: "SYN008", + title: "WebSocket construction bypasses the net capability model", + body: + "Botscript's capability model is static: the compiler reads the fn header and infers what that fn " + + "may do — network access, resource reads/writes, error types. The `WebSocket` global bypasses this " + + "by opening a persistent bidirectional connection at runtime that CAP001 cannot see.\n\n" + + "CAP001 checks for `http.*` member calls (the stdlib's declared network surface). `WebSocket` is a " + + "global — constructing one does not require a `uses { net }` declaration, and the compiler cannot " + + "enforce that callers know the fn has a network dependency.\n\n" + + "This is the same bypass class as `fetch()` (bare global that CAP001 misses): real network effects " + + "invisible to the declared capability surface.\n\n" + + "**Fix:** wrap the construction in `unsafe \"wraps WebSocket for \" { new WebSocket(url) }` " + + "to make the escape hatch visible in the diff and to callers reading the fn.\n\n" + + "SYN008 fires at `?bs 0.7+` as a non-blocking warning. Detection is token-based: `WebSocket` not " + + "preceded by `.`/`?.` (member call exclusion), followed by `(`, `?.(`, or — when preceded by " + + "`new` — `(` (TypeScript generic instantiation). Generic scanning is gated on `new` to avoid " + + "false-positives on comparison expressions like `WebSocket < x > (y)`. " + + "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" + + "}\n", + passes: + "?bs 0.7\n" + + "fn subscribe(url: string) -> void {\n" + + ' const ws = unsafe "wraps WebSocket for live updates" { new WebSocket(url) }\n' + + "}\n", + }, + }, SYN010: { code: "SYN010", title: "setTimeout / setInterval / queueMicrotask defers side effects outside the fn's capability surface", diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index 7e2ad742..a3753216 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -79,6 +79,7 @@ describe("botscript-mcp explanations", () => { "SYN005", "SYN006", "SYN007", + "SYN008", "SYN010", "SYN011", "THR001",