From 7705bbd6756bf629635adad37d4ab3ebb08d84fd Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 12 Jun 2026 15:35:08 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(compiler):=20SYN008=20=E2=80=94=20warn?= =?UTF-8?q?=20on=20WebSocket()=20calls=20that=20bypass=20the=20net=20capab?= =?UTF-8?q?ility=20model=20(=3Fbs=200.7+)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 not visible in the fn header. Key improvements over prior attempt: - Generic `` scan gated on `new` — prevents `WebSocket < x > (y)` comparison expressions from false-firing as generic instantiations - Contextual warning message: "constructs new" vs "calls" based on presence of `new` - 14 tests covering all call forms, unsafe suppression, comparison false-positive, member call exclusion, method shorthand exclusion, version gate, severity Closes #153 Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 1 + README.md | 2 +- packages/compiler/src/error-codes.ts | 33 ++++ packages/compiler/src/passes/syn-check.ts | 89 +++++++++++ packages/compiler/tests/error-codes.test.ts | 2 +- packages/compiler/tests/syn008-check.test.ts | 159 +++++++++++++++++++ packages/mcp/src/explanations.ts | 32 ++++ packages/mcp/tests/server.test.ts | 1 + 8 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/tests/syn008-check.test.ts 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..948480e4 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -708,6 +708,39 @@ const E: Record = { " return () => clearInterval(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 \"wraps WebSocket directly\" { 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" + + "}", + }, SYN011: { code: "SYN011", title: "dynamic import() call bypasses the module capability model", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 78ecac10..92dd67eb 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -45,6 +45,15 @@ * 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 method shorthands. 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 +109,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 +598,85 @@ 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"; + + 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: { WebSocket(url) { ... } } + 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" || + (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(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..88699472 --- /dev/null +++ b/packages/compiler/tests/syn008-check.test.ts @@ -0,0 +1,159 @@ +/** + * 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) -> 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\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN008")).toBe(false); + }); + + 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", From dfccab559cdf32c9b4b64c5c754d1bc73723323c Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 12 Jun 2026 19:29:04 -0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(compiler):=20SYN008=20=E2=80=94=20guard?= =?UTF-8?q?=20ternary=20`:`=20from=20suppressing=20WebSocket()=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot flagged three issues: 1. Method-shorthand exclusion used `:` without guarding against ternary consequents. `cond ? WebSocket(url) : other` (and the `new` variant) would silently suppress SYN008. Fixed by checking `prev8.kind === "question"` and `prevBeforeNew8.kind === "question"`, mirroring the SYN004 pattern. 2. Member-call test had undeclared `url` variable. Added `url: string` to the fn parameter list so the test isolates the intended exclusion. 3. Comparison regression test was incomplete: only had `WebSocket < x` without the `> (y)` suffix, so it wouldn't catch a future regression where the generic-scan path processes the full expression. Completed the input. 4. Added two ternary regression tests (bare call and `new` form). 5. Updated header comment to document TS method signature exclusion and ternary guard. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 16 +++++++++--- packages/compiler/tests/syn008-check.test.ts | 26 ++++++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 92dd67eb..a8599908 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -51,7 +51,10 @@ * 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 method shorthands. Generic `` detection only when + * `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(...)` @@ -612,6 +615,12 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult 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]; @@ -642,14 +651,15 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const callTok8 = tokens[callIdx8]; if (!callTok8 || !(callTok8.kind === "open" && callTok8.text === "(")) continue; - // Exclude method shorthands: { WebSocket(url) { ... } } + // 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" || - (afterClose8.kind === "punct" && afterClose8.text === ":") + (!isTernaryConsequent8 && afterClose8.kind === "punct" && afterClose8.text === ":") )) continue; } diff --git a/packages/compiler/tests/syn008-check.test.ts b/packages/compiler/tests/syn008-check.test.ts index 88699472..508c9dd1 100644 --- a/packages/compiler/tests/syn008-check.test.ts +++ b/packages/compiler/tests/syn008-check.test.ts @@ -85,7 +85,7 @@ describe("SYN008: WebSocket() call detection", () => { it("does NOT fire on member call obj.WebSocket(url)", () => { const src = "?bs 0.7\n" + - "fn subscribe(ctx: any) -> void {\n" + + "fn subscribe(ctx: any, url: string) -> void {\n" + " const ws = ctx.WebSocket(url)\n" + "}\n"; const result = compile(src); @@ -140,12 +140,34 @@ describe("SYN008: WebSocket() call detection", () => { "?bs 0.7\n" + "fn compare(x: number, y: number) -> boolean {\n" + " const WebSocket = 42\n" + - " return WebSocket < x\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" + From 827a653f158756be1923ada1dd5d4ca1229aea70 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sat, 13 Jun 2026 03:28:20 -0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(compiler):=20SYN008=20=E2=80=94=20prese?= =?UTF-8?q?rve=20optional-call=20form=20in=20message,=20sort=20registry=20?= =?UTF-8?q?entry=20before=20SYN010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Warning message for WebSocket?.(url) now suggests WebSocket?.(url) inside unsafe block (not WebSocket(url)), preserving the optional-call semantics - Move SYN008 registry entry to its correct numeric position (before SYN010) Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/error-codes.ts | 66 +++++++++++------------ packages/compiler/src/passes/syn-check.ts | 2 +- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 948480e4..ca7a2d14 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 \"wraps WebSocket directly\" { 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", @@ -708,39 +741,6 @@ const E: Record = { " return () => clearInterval(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 \"wraps WebSocket directly\" { 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" + - "}", - }, SYN011: { code: "SYN011", title: "dynamic import() call bypasses the module capability model", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index a8599908..19290eb8 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -679,7 +679,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult 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(url) }`, + `wrap in unsafe "wraps WebSocket for " { ${hasNew8 ? "new " : ""}WebSocket${isOpt8 ? "?." : ""}(url) }`, rule: syn008.rule, idiom: syn008.idiom, rewrite: syn008.rewrite, From d79fcbc5212b9c0155be6d18fdf0281a5a11c703 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sat, 13 Jun 2026 07:35:10 -0300 Subject: [PATCH 4/4] fix(compiler): use placeholder in SYN008 idiom text Hard-coded reason string ("wraps WebSocket directly") in the idiom is inconsistent with the rest of the registry that uses explicit placeholders to signal where the developer fills in the justification. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/error-codes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index ca7a2d14..4871f91f 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -684,7 +684,7 @@ const E: Record = { "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 \"wraps WebSocket directly\" { new WebSocket(url) }` " + + "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" +