From 929f42023611af2ab02d2c1aab5b8d1e8273045b Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 10 Jun 2026 23:39:48 -0300 Subject: [PATCH 01/21] =?UTF-8?q?feat(compiler):=20SYN009=20=E2=80=94=20wa?= =?UTF-8?q?rn=20on=20XMLHttpRequest()=20calls=20that=20bypass=20the=20net?= =?UTF-8?q?=20capability=20model=20(=3Fbs=200.7+)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new XMLHttpRequest()` opens an HTTP connection at runtime that is invisible to CAP001 (which checks http.* member calls). Same bypass class as fetch (SYN007) and WebSocket (SYN008) — no `uses { net }` will reflect it in the fn header, callers cannot see the network dependency. Detection: `XMLHttpRequest` not preceded by `.`/`?.`, followed by `(`, `?.(`, or `(` (including nested generics via >>/>>>> depth scan). `obj.XMLHttpRequest(...)` and bare `XMLHttpRequest` references are excluded. Suppressed inside `unsafe {}` blocks and `unsafe "reason" fn` bodies. Note: should be merged after PR #149 (SYN008) lands; SYN009 is the next available code in the bypass-detection series. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 1 + README.md | 2 +- packages/compiler/src/error-codes.ts | 47 +++++++ packages/compiler/src/passes/syn-check.ts | 85 ++++++++++++ packages/compiler/tests/error-codes.test.ts | 2 +- packages/compiler/tests/syn009-check.test.ts | 133 +++++++++++++++++++ packages/mcp/src/explanations.ts | 45 +++++++ packages/mcp/tests/server.test.ts | 1 + 8 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/tests/syn009-check.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1c108ee5..fc262a1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,6 +203,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | 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. | +| SYN009 | (0.7+, warning) A fn body constructs an `XMLHttpRequest` via `new XMLHttpRequest()`, bare `XMLHttpRequest()`, or TypeScript instantiation form `new XMLHttpRequest()`. XHR makes real HTTP requests at runtime but is invisible to CAP001 — the capability model only checks `http.*` member calls. A fn that constructs an XHR has an undeclared `net` dependency. Detection: `XMLHttpRequest` not preceded by `.`/`?.`, followed by `(`, `?.(`, or `(`. `obj.XMLHttpRequest(...)` is excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Replace with `http.get(url)` or `http.post(url, { body })` and add `uses { net }` to the fn header. If the raw XHR API is genuinely required, wrap in `unsafe "wraps XHR directly" { new XMLHttpRequest() }`. | | 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 36188089..585055a8 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`, `SYN008`, `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`, `SYN009`, `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 4871f91f..bdc526c9 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -707,6 +707,53 @@ const E: Record = { " ws.onmessage = (e) => handle(e.data)\n" + "}", }, + SYN009: { + code: "SYN009", + title: "XMLHttpRequest() call bypasses the net capability model — use http.get() / http.post() instead", + rule: + "`new XMLHttpRequest()`, `XMLHttpRequest()`, and TypeScript instantiation forms like " + + "`new XMLHttpRequest()` open HTTP connections at runtime but are invisible to " + + "botscript's capability model: CAP001 checks for `http.*` member calls, not the " + + "`XMLHttpRequest` global. A fn that constructs an XHR has an undeclared network " + + "dependency — no `uses { net }` will reflect it in the fn header, " + + "no audit tool can see it, and callers cannot reason about the blast radius.", + idiom: + "replace `new XMLHttpRequest()` with `http.get(url)` or `http.post(url, { body })` and add " + + "`uses { net }` to the fn header; if the raw XHR API is genuinely required " + + "(e.g. a thin adapter), wrap in `unsafe \"wraps XHR directly\" { new XMLHttpRequest() }`", + rewrite: + "// before — XHR is invisible to the capability model\n" + + "async fn loadData(url: string) -> Promise> {\n" + + " return new Promise((resolve) => {\n" + + " const xhr = new XMLHttpRequest() // SYN009\n" + + " xhr.open('GET', url)\n" + + " xhr.onload = () => resolve(ok(xhr.responseText))\n" + + " xhr.onerror = () => resolve(err('request failed'))\n" + + " xhr.send()\n" + + " })\n" + + "}\n\n" + + "// after — http.get declares the net dependency\n" + + "async fn loadData(url: string) uses { net } -> Promise> {\n" + + " match await http.get(url) {\n" + + " ok { res } -> ok(await res.text())\n" + + " err { e } -> err(e.message)\n" + + " }\n" + + "}", + example: + "// SYN009: XMLHttpRequest bypasses the net capability model\n" + + "fn getData(url: string) -> void {\n" + + " const xhr = new XMLHttpRequest() // SYN009\n" + + " xhr.open('GET', url)\n" + + " xhr.send()\n" + + "}\n\n" + + "// fix: use http.get and declare the capability\n" + + "async fn getData(url: string) uses { net } -> Promise> {\n" + + " match await http.get(url) {\n" + + " ok { res } -> ok(await res.text())\n" + + " err { e } -> err(e.message)\n" + + " }\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 19290eb8..3c78e8fd 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -57,6 +57,13 @@ * `cond ? new WebSocket(url) : other`). Generic `` detection only when * preceded by `new` (avoids false-positives on `WebSocket < x > (y)` comparisons). * + * SYN009 A `new XMLHttpRequest()` or `XMLHttpRequest()` call was detected + * in a fn body (?bs 0.7+). XMLHttpRequest opens an HTTP connection + * that is invisible to CAP001 (which checks `http.*` member calls). + * A fn that constructs an XHR has an undeclared `net` dependency. + * Excluded: member calls (`obj.XMLHttpRequest`), object/class method + * shorthands, and TypeScript method signatures. + * * 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 @@ -113,6 +120,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const syn006 = getErrorCode("SYN006")!; const syn007 = getErrorCode("SYN007")!; const syn008 = getErrorCode("SYN008")!; + const syn009 = getErrorCode("SYN009")!; const syn010 = getErrorCode("SYN010")!; const syn011 = getErrorCode("SYN011")!; @@ -810,6 +818,83 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult } } } + + // SYN009: new XMLHttpRequest() / XMLHttpRequest() detection. + // Fires when a fn body constructs an XMLHttpRequest via `new XMLHttpRequest(url)`, + // `XMLHttpRequest(url)`, or TypeScript instantiation form `new XMLHttpRequest(url)`. + // XMLHttpRequest opens an HTTP connection at runtime but is invisible to CAP001 + // (which only checks `http.*` member calls). A fn that constructs an XHR has an + // undeclared `net` dependency. + // Suppressed inside `unsafe "reason" { }` blocks and `unsafe "reason" fn` bodies. + nextInner = 0; + const open009: typeof inner = []; + for (let i = bodyStart; i < decl.tokenEnd; i++) { + while (open009.length > 0 && open009[open009.length - 1]!.tokenEnd <= i) open009.pop(); + while (nextInner < inner.length && inner[nextInner]!.tokenStart <= i) { + open009.push(inner[nextInner]!); + nextInner++; + } + if (open009.length > 0) continue; + + const tok9 = tokens[i]; + if (!tok9 || tok9.kind !== "ident" || tok9.text !== "XMLHttpRequest") continue; + + // Exclude property accesses: obj.XMLHttpRequest(...) + const prevIdx9 = prevSignificant(tokens, i - 1); + const prev9 = tokens[prevIdx9]; + if (prev9 && ((prev9.kind === "punct" && prev9.text === ".") || prev9.kind === "questionDot")) + continue; + + // Must be followed by `(`, `?.(`, or `(` — confirming this is a construction/call. + let afterXhrIdx = nextSignificant(tokens, i + 1); + let afterXhr = tokens[afterXhrIdx]; + + // TypeScript instantiation form: `XMLHttpRequest(...)` — skip over `<...>` to find `(` + if (afterXhr && afterXhr.kind === "operator" && afterXhr.text === "<") { + let anglDepth = 1; + afterXhrIdx++; + while (afterXhrIdx < decl.tokenEnd && anglDepth > 0) { + const at = tokens[afterXhrIdx]; + if (!at) { afterXhrIdx++; continue; } + if (at.kind === "operator" && at.text === "<") { anglDepth++; } + else if (at.kind === "operator" && (at.text === ">" || at.text === ">>" || at.text === ">>>")) { + anglDepth -= at.text.length; + } + afterXhrIdx++; + } + afterXhrIdx = nextSignificant(tokens, afterXhrIdx); + const afterAngle9 = tokens[afterXhrIdx]; + if (!afterAngle9 || !(afterAngle9.kind === "open" && afterAngle9.text === "(")) continue; + } else if (afterXhr && afterXhr.kind === "questionDot") { + // `XMLHttpRequest?.(...)` — optional call (unusual but possible) + const afterQD9 = nextSignificant(tokens, afterXhrIdx + 1); + const afterQDTok9 = tokens[afterQD9]; + if (!afterQDTok9 || !(afterQDTok9.kind === "open" && afterQDTok9.text === "(")) continue; + } else if (!(afterXhr && afterXhr.kind === "open" && afterXhr.text === "(")) { + continue; + } + + // Suppression check: unsafe block or unsafe fn body + if (isInsideRange(tok9.start, unsafeRanges)) continue; + + const loc9 = locationOf(src, tok9.start); + warnings.push({ + code: "SYN009", + severity: "warning", + file: null, + line: loc9.line, + column: loc9.column, + start: tok9.start, + end: tok9.end, + message: + `fn '${decl.name}' constructs an XMLHttpRequest — XMLHttpRequest bypasses the net capability model; ` + + `CAP001 cannot see it; wrap in ` + + `unsafe "wraps XHR directly" { new XMLHttpRequest() }, or write a $require("net")-checked wrapper`, + rule: syn009.rule, + idiom: syn009.idiom, + rewrite: syn009.rewrite, + }); + } } return { code: src, warnings }; diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index edf71979..3d2ee9f7 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", "SYN008", "SYN010", "SYN011", + "SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN009", "SYN010", "SYN011", "THR001", "THR002", "THR003", "THR004", "UNS001", "UNS002", "UNS003", "UNS004", "UNS005", "VER001", "VER002", "VER003", diff --git a/packages/compiler/tests/syn009-check.test.ts b/packages/compiler/tests/syn009-check.test.ts new file mode 100644 index 00000000..3b5f655d --- /dev/null +++ b/packages/compiler/tests/syn009-check.test.ts @@ -0,0 +1,133 @@ +/** + * Tests for SYN009: XMLHttpRequest() call detection in fn bodies (?bs 0.7+). + * + * SYN009 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("SYN009: XMLHttpRequest() call detection", () => { + it("fires on new XMLHttpRequest() inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn sendRequest(url: string) -> void {\n" + + " const xhr = new XMLHttpRequest()\n" + + " xhr.open('GET', url)\n" + + " xhr.send()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(true); + }); + + it("fires on bare XMLHttpRequest() without new", () => { + const src = + "?bs 0.7\n" + + "fn sendRequest(url: string) -> void {\n" + + " const xhr = XMLHttpRequest()\n" + + " xhr.open('GET', url)\n" + + " xhr.send()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(true); + }); + + it("fires on TypeScript instantiation form new XMLHttpRequest()", () => { + const src = + "?bs 0.7\n" + + "fn sendRequest(url: string) -> void {\n" + + " const xhr = new XMLHttpRequest()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(true); + }); + + it("fires on nested generic form new XMLHttpRequest>()", () => { + const src = + "?bs 0.7\n" + + "fn sendRequest(url: string) -> void {\n" + + " const xhr = new XMLHttpRequest>()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(true); + }); + + it("produces a warning-severity diagnostic", () => { + const src = + "?bs 0.7\n" + + "fn sendRequest(url: string) -> void {\n" + + " const xhr = new XMLHttpRequest()\n" + + " xhr.send()\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN009"); + expect(w?.severity).toBe("warning"); + }); + + it("does NOT fire below ?bs 0.7", () => { + const src = + "?bs 0.6\n" + + "fn sendRequest(url: string) -> void {\n" + + " const xhr = new XMLHttpRequest()\n" + + " xhr.send()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); + + it("does NOT fire inside an unsafe block", () => { + const src = + "?bs 0.7\n" + + "fn sendRequest(url: string) -> void {\n" + + ' const xhr = unsafe "wraps XHR directly" { new XMLHttpRequest() }\n' + + " xhr.send()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); + + it("does NOT fire inside an unsafe fn body", () => { + const src = + "?bs 0.7\n" + + 'unsafe "wraps XHR" fn sendRaw(url: string) -> void {\n' + + " const xhr = new XMLHttpRequest()\n" + + " xhr.send()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); + + it("does NOT fire on obj.XMLHttpRequest() (member call on a local)", () => { + const src = + "?bs 0.7\n" + + "fn test(ctx: any) -> void {\n" + + " ctx.XMLHttpRequest()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); + + it("does NOT fire on bare XMLHttpRequest reference (not called)", () => { + const src = + "?bs 0.7\n" + + "fn test() -> any {\n" + + " return XMLHttpRequest\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); + + it("does NOT fire on XMLHttpRequest.prototype access", () => { + const src = + "?bs 0.7\n" + + "fn test() -> void {\n" + + " const open = XMLHttpRequest.prototype.open\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index ea430b84..6a159b53 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -913,6 +913,51 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN009: { + code: "SYN009", + title: "XMLHttpRequest() call bypasses the net capability model — use http.get() / http.post() instead", + body: + "SYN009 fires when a fn body constructs an `XMLHttpRequest` via `new XMLHttpRequest()`, " + + "bare `XMLHttpRequest()`, or TypeScript instantiation forms like `new XMLHttpRequest()`. " + + "XMLHttpRequest is the predecessor to `fetch` in the browser HTTP API. Like `fetch` (SYN007) " + + "and `WebSocket` (SYN008), it makes real network calls at runtime but is invisible to " + + "botscript's capability model: CAP001 checks for `http.*` member calls, not the XHR global. " + + "A fn that constructs an XHR has an undeclared `net` dependency — `uses { net }` is never " + + "triggered, callers cannot see the network dependency in the fn header, and the capability " + + "manifest does not reflect it.\n\n" + + "The risk class is the same as SYN007 and SYN008: static analysis cannot reason about the " + + "blast radius of a fn that quietly opens HTTP connections. Code-generation models still " + + "produce XHR patterns, especially in browser-targeting code.\n\n" + + "**Fix:** replace `new XMLHttpRequest()` with `http.get(url)` or `http.post(url, { body })` " + + "and add `uses { net }` to the fn header. The stdlib `http.*` methods return " + + "`Promise>` — `await` them to get a `Result`, making the error path " + + "explicit and the net dependency visible to callers. " + + "If the raw XHR API is genuinely required (e.g. a thin adapter or a progress-reporting " + + "upload), wrap in `unsafe \"wraps XHR directly\" { new XMLHttpRequest() }` to make the " + + "escape hatch visible in the diff.\n\n" + + "SYN009 fires at `?bs 0.7+` as a non-blocking warning. Detection is token-based: " + + "`XMLHttpRequest` not preceded by `.`/`?.` followed by `(`, `?.(`, or `(`; " + + "TypeScript instantiation forms `new XMLHttpRequest()` are also detected. " + + "`obj.XMLHttpRequest(...)` (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 getData(url: string) -> void {\n" + + " const xhr = new XMLHttpRequest()\n" + + " xhr.open('GET', url)\n" + + " xhr.send()\n" + + "}\n", + passes: + "?bs 0.7\n" + + "async fn getData(url: string) uses { net } -> Promise> {\n" + + " match await http.get(url) {\n" + + " ok { res } -> ok(await res.text())\n" + + " err { e } -> err(e.message)\n" + + " }\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 a3753216..ad29debb 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -80,6 +80,7 @@ describe("botscript-mcp explanations", () => { "SYN006", "SYN007", "SYN008", + "SYN009", "SYN010", "SYN011", "THR001", From b8a52e7ca129721c93a8e48be11d809e7e421d6c Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 11 Jun 2026 03:28:46 -0300 Subject: [PATCH 02/21] fix(compiler): address Copilot review feedback on SYN009 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix diagnostic message: remove trailing prose after closing `}` to match the style of SYN005/SYN006 (message ends cleanly with the unsafe block) - Detect `new XMLHttpRequest` without parens — bare new-expressions are valid JS/TS constructions and bypass the net capability model equally - Add regression test for the no-parens form --- packages/compiler/src/passes/syn-check.ts | 9 +++++++-- packages/compiler/tests/syn009-check.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 3c78e8fd..40e2a2a7 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -845,7 +845,11 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (prev9 && ((prev9.kind === "punct" && prev9.text === ".") || prev9.kind === "questionDot")) continue; + // `new XMLHttpRequest` without parens is valid JS/TS construction — fire on it too. + const isNewExpr9 = prev9 && prev9.kind === "ident" && prev9.text === "new"; + // Must be followed by `(`, `?.(`, or `(` — confirming this is a construction/call. + // Exception: `new XMLHttpRequest` with no parens is also a valid construction. let afterXhrIdx = nextSignificant(tokens, i + 1); let afterXhr = tokens[afterXhrIdx]; @@ -871,7 +875,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const afterQDTok9 = tokens[afterQD9]; if (!afterQDTok9 || !(afterQDTok9.kind === "open" && afterQDTok9.text === "(")) continue; } else if (!(afterXhr && afterXhr.kind === "open" && afterXhr.text === "(")) { - continue; + // No parens — only fire if preceded by `new` (bare construction form) + if (!isNewExpr9) continue; } // Suppression check: unsafe block or unsafe fn body @@ -889,7 +894,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult message: `fn '${decl.name}' constructs an XMLHttpRequest — XMLHttpRequest bypasses the net capability model; ` + `CAP001 cannot see it; wrap in ` + - `unsafe "wraps XHR directly" { new XMLHttpRequest() }, or write a $require("net")-checked wrapper`, + `unsafe "wraps XHR directly" { new XMLHttpRequest() }`, rule: syn009.rule, idiom: syn009.idiom, rewrite: syn009.rewrite, diff --git a/packages/compiler/tests/syn009-check.test.ts b/packages/compiler/tests/syn009-check.test.ts index 3b5f655d..1adf7862 100644 --- a/packages/compiler/tests/syn009-check.test.ts +++ b/packages/compiler/tests/syn009-check.test.ts @@ -36,6 +36,18 @@ describe("SYN009: XMLHttpRequest() call detection", () => { expect(result.warnings.some((w) => w.code === "SYN009")).toBe(true); }); + it("fires on new XMLHttpRequest without parens (bare new-expression)", () => { + const src = + "?bs 0.7\n" + + "fn sendRequest(url: string) -> void {\n" + + " const xhr = new XMLHttpRequest\n" + + " xhr.open('GET', url)\n" + + " xhr.send()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(true); + }); + it("fires on TypeScript instantiation form new XMLHttpRequest()", () => { const src = "?bs 0.7\n" + From 8644fa71e80fce5adc0fde643ace871a81357e8f Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 11 Jun 2026 07:29:37 -0300 Subject: [PATCH 03/21] =?UTF-8?q?fix(compiler):=20address=20remaining=20Co?= =?UTF-8?q?pilot=20review=20on=20SYN009=20=E2=80=94=20fix=20false=20positi?= =?UTF-8?q?ve=20and=20update=20comment=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Exclude member-access-on-constructor form (`new XMLHttpRequest.prototype.open()`) from SYN009 by checking for `.` as the next token in the no-parens branch - Add regression test covering that form - Update header comment to drop incorrect `(url)` from constructor examples and mention the no-parens form explicitly --- packages/compiler/src/passes/syn-check.ts | 6 ++++-- packages/compiler/tests/syn009-check.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 40e2a2a7..9f0bbb21 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -820,8 +820,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult } // SYN009: new XMLHttpRequest() / XMLHttpRequest() detection. - // Fires when a fn body constructs an XMLHttpRequest via `new XMLHttpRequest(url)`, - // `XMLHttpRequest(url)`, or TypeScript instantiation form `new XMLHttpRequest(url)`. + // Fires when a fn body constructs an XMLHttpRequest via `new XMLHttpRequest()`, + // `XMLHttpRequest()`, `new XMLHttpRequest` (no parens), or TypeScript form `new XMLHttpRequest()`. // XMLHttpRequest opens an HTTP connection at runtime but is invisible to CAP001 // (which only checks `http.*` member calls). A fn that constructs an XHR has an // undeclared `net` dependency. @@ -875,6 +875,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const afterQDTok9 = tokens[afterQD9]; if (!afterQDTok9 || !(afterQDTok9.kind === "open" && afterQDTok9.text === "(")) continue; } else if (!(afterXhr && afterXhr.kind === "open" && afterXhr.text === "(")) { + // Member access on the constructor itself (e.g. new XMLHttpRequest.prototype.open()) — not a construction + if (afterXhr && afterXhr.kind === "punct" && afterXhr.text === ".") continue; // No parens — only fire if preceded by `new` (bare construction form) if (!isNewExpr9) continue; } diff --git a/packages/compiler/tests/syn009-check.test.ts b/packages/compiler/tests/syn009-check.test.ts index 1adf7862..5f8301c9 100644 --- a/packages/compiler/tests/syn009-check.test.ts +++ b/packages/compiler/tests/syn009-check.test.ts @@ -142,4 +142,14 @@ describe("SYN009: XMLHttpRequest() call detection", () => { const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); }); + + it("does NOT fire on new XMLHttpRequest.prototype.open() (member access on constructor)", () => { + const src = + "?bs 0.7\n" + + "fn test() -> void {\n" + + " const open = new XMLHttpRequest.prototype.open()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); }); From 4368d45f26204b1b0aae845d25e55d42f5686564 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 11 Jun 2026 15:51:14 -0300 Subject: [PATCH 04/21] fix(syn009): handle new XMLHttpRequest without parens, fix depth underflow, improve message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fire on `new XMLHttpRequest` (no call parens) — valid TS construction form was skipped - Fix depth scan: use Math.max(0, depth - len) to prevent depth going negative on >> or >>> - Use syn009.code instead of hardcoded "SYN009" string to avoid drift - Update diagnostic message to recommend http.get()/http.post() as primary fix - Update header comment: remove $require("net") guidance (inconsistent with rest of PR); add no-parens form to documented detection surface - Add regression test for generic no-paren construction form Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 60 ++++++++++---------- packages/compiler/tests/syn009-check.test.ts | 10 ++++ 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 9f0bbb21..ccdd3445 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -57,11 +57,11 @@ * `cond ? new WebSocket(url) : other`). Generic `` detection only when * preceded by `new` (avoids false-positives on `WebSocket < x > (y)` comparisons). * - * SYN009 A `new XMLHttpRequest()` or `XMLHttpRequest()` call was detected - * in a fn body (?bs 0.7+). XMLHttpRequest opens an HTTP connection - * that is invisible to CAP001 (which checks `http.*` member calls). - * A fn that constructs an XHR has an undeclared `net` dependency. - * Excluded: member calls (`obj.XMLHttpRequest`), object/class method + * SYN009 A `new XMLHttpRequest()`, `XMLHttpRequest()`, `new XMLHttpRequest()`, + * or no-parens `new XMLHttpRequest` was detected in a fn body (?bs 0.7+). + * XMLHttpRequest opens an HTTP connection invisible to CAP001 (which checks + * `http.*` member calls). A fn that constructs an XHR has an undeclared `net` + * dependency. Excluded: member calls (`obj.XMLHttpRequest`), object/class method * shorthands, and TypeScript method signatures. * * SYN010 A `setTimeout(...)`, `setInterval(...)`, or `queueMicrotask(...)` @@ -848,36 +848,39 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult // `new XMLHttpRequest` without parens is valid JS/TS construction — fire on it too. const isNewExpr9 = prev9 && prev9.kind === "ident" && prev9.text === "new"; - // Must be followed by `(`, `?.(`, or `(` — confirming this is a construction/call. - // Exception: `new XMLHttpRequest` with no parens is also a valid construction. - let afterXhrIdx = nextSignificant(tokens, i + 1); - let afterXhr = tokens[afterXhrIdx]; + // Must be followed by `(`, `?.(`, `(`, or nothing (bare `new XMLHttpRequest`). + const afterXhrFirstIdx = nextSignificant(tokens, i + 1); + const afterXhr = tokens[afterXhrFirstIdx]; - // TypeScript instantiation form: `XMLHttpRequest(...)` — skip over `<...>` to find `(` if (afterXhr && afterXhr.kind === "operator" && afterXhr.text === "<") { + // TypeScript instantiation form: `XMLHttpRequest(...)` or `new XMLHttpRequest` let anglDepth = 1; - afterXhrIdx++; - while (afterXhrIdx < decl.tokenEnd && anglDepth > 0) { - const at = tokens[afterXhrIdx]; - if (!at) { afterXhrIdx++; continue; } - if (at.kind === "operator" && at.text === "<") { anglDepth++; } - else if (at.kind === "operator" && (at.text === ">" || at.text === ">>" || at.text === ">>>")) { - anglDepth -= at.text.length; - } - afterXhrIdx++; + let j = afterXhrFirstIdx + 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++; + } + const afterAngleIdx = nextSignificant(tokens, j); + const afterAngle9 = tokens[afterAngleIdx]; + // With parens: `XMLHttpRequest(...)` — fire; without parens but with `new`: also fire. + if (afterAngle9 && afterAngle9.kind === "open" && afterAngle9.text === "(") { + // has parens, proceed to fire + } else if (!isNewExpr9) { + continue; // bare `XMLHttpRequest` without new and without parens — not a construction } - afterXhrIdx = nextSignificant(tokens, afterXhrIdx); - const afterAngle9 = tokens[afterXhrIdx]; - if (!afterAngle9 || !(afterAngle9.kind === "open" && afterAngle9.text === "(")) continue; } else if (afterXhr && afterXhr.kind === "questionDot") { // `XMLHttpRequest?.(...)` — optional call (unusual but possible) - const afterQD9 = nextSignificant(tokens, afterXhrIdx + 1); + const afterQD9 = nextSignificant(tokens, afterXhrFirstIdx + 1); const afterQDTok9 = tokens[afterQD9]; if (!afterQDTok9 || !(afterQDTok9.kind === "open" && afterQDTok9.text === "(")) continue; } else if (!(afterXhr && afterXhr.kind === "open" && afterXhr.text === "(")) { - // Member access on the constructor itself (e.g. new XMLHttpRequest.prototype.open()) — not a construction + // Member access on the constructor itself — not a construction if (afterXhr && afterXhr.kind === "punct" && afterXhr.text === ".") continue; - // No parens — only fire if preceded by `new` (bare construction form) + // No parens — only fire if preceded by `new` (bare construction: `new XMLHttpRequest`) if (!isNewExpr9) continue; } @@ -886,7 +889,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const loc9 = locationOf(src, tok9.start); warnings.push({ - code: "SYN009", + code: syn009.code, severity: "warning", file: null, line: loc9.line, @@ -894,9 +897,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult start: tok9.start, end: tok9.end, message: - `fn '${decl.name}' constructs an XMLHttpRequest — XMLHttpRequest bypasses the net capability model; ` + - `CAP001 cannot see it; wrap in ` + - `unsafe "wraps XHR directly" { new XMLHttpRequest() }`, + `fn '${decl.name}' constructs an XMLHttpRequest — bypasses the net capability model; ` + + `use http.get()/http.post() and declare uses { net }, or wrap in unsafe "wraps XHR directly" { new XMLHttpRequest() }`, rule: syn009.rule, idiom: syn009.idiom, rewrite: syn009.rewrite, diff --git a/packages/compiler/tests/syn009-check.test.ts b/packages/compiler/tests/syn009-check.test.ts index 5f8301c9..8f071faf 100644 --- a/packages/compiler/tests/syn009-check.test.ts +++ b/packages/compiler/tests/syn009-check.test.ts @@ -152,4 +152,14 @@ describe("SYN009: XMLHttpRequest() call detection", () => { const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); }); + + it("fires on new XMLHttpRequest — TypeScript generic without call parens", () => { + const src = + "?bs 0.7\n" + + "fn makeXhr() -> any {\n" + + " return new XMLHttpRequest\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(true); + }); }); From 87833a7c621c5dc56fa49dcea3474a5d573ae03d Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 11 Jun 2026 19:43:23 -0300 Subject: [PATCH 05/21] fix(compiler): address SYN009 Copilot review feedback - Change "call" to "construction" in SYN009 title (error-codes.ts and explanations.ts) - Add `new XMLHttpRequest` (no-parens) and generic no-parens form to rule text in error-codes.ts - Add no-parens forms to opening sentence and detection description in explanations.ts - Update AGENTS.md SYN009 row to mention all detected construction forms Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 2 +- packages/compiler/src/error-codes.ts | 4 ++-- packages/mcp/src/explanations.ts | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fc262a1a..ef9b6927 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,7 +203,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | 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. | -| SYN009 | (0.7+, warning) A fn body constructs an `XMLHttpRequest` via `new XMLHttpRequest()`, bare `XMLHttpRequest()`, or TypeScript instantiation form `new XMLHttpRequest()`. XHR makes real HTTP requests at runtime but is invisible to CAP001 — the capability model only checks `http.*` member calls. A fn that constructs an XHR has an undeclared `net` dependency. Detection: `XMLHttpRequest` not preceded by `.`/`?.`, followed by `(`, `?.(`, or `(`. `obj.XMLHttpRequest(...)` is excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Replace with `http.get(url)` or `http.post(url, { body })` and add `uses { net }` to the fn header. If the raw XHR API is genuinely required, wrap in `unsafe "wraps XHR directly" { new XMLHttpRequest() }`. | +| SYN009 | (0.7+, warning) A fn body constructs an `XMLHttpRequest` via `new XMLHttpRequest()`, bare `XMLHttpRequest()`, `new XMLHttpRequest` (no-parens), or TypeScript instantiation forms `new XMLHttpRequest()` / `new XMLHttpRequest` (no-parens). XHR makes real HTTP requests at runtime but is invisible to CAP001 — the capability model only checks `http.*` member calls. A fn that constructs an XHR has an undeclared `net` dependency. Detection: `XMLHttpRequest` not preceded by `.`/`?.`, followed by `(`, `?.(`, `(`, or nothing (bare `new XMLHttpRequest`). `obj.XMLHttpRequest(...)` is excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Replace with `http.get(url)` or `http.post(url, { body })` and add `uses { net }` to the fn header. If the raw XHR API is genuinely required, wrap in `unsafe "wraps XHR directly" { new XMLHttpRequest() }`. | | 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/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index bdc526c9..f208d939 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -709,9 +709,9 @@ const E: Record = { }, SYN009: { code: "SYN009", - title: "XMLHttpRequest() call bypasses the net capability model — use http.get() / http.post() instead", + title: "XMLHttpRequest construction bypasses the net capability model — use http.get() / http.post() instead", rule: - "`new XMLHttpRequest()`, `XMLHttpRequest()`, and TypeScript instantiation forms like " + + "`new XMLHttpRequest()`, `XMLHttpRequest()`, `new XMLHttpRequest` (no-parens), and TypeScript instantiation forms like " + "`new XMLHttpRequest()` open HTTP connections at runtime but are invisible to " + "botscript's capability model: CAP001 checks for `http.*` member calls, not the " + "`XMLHttpRequest` global. A fn that constructs an XHR has an undeclared network " + diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 6a159b53..7d3d3eee 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -915,10 +915,10 @@ export const EXPLANATIONS: Readonly> = { }, SYN009: { code: "SYN009", - title: "XMLHttpRequest() call bypasses the net capability model — use http.get() / http.post() instead", + title: "XMLHttpRequest construction bypasses the net capability model — use http.get() / http.post() instead", body: "SYN009 fires when a fn body constructs an `XMLHttpRequest` via `new XMLHttpRequest()`, " + - "bare `XMLHttpRequest()`, or TypeScript instantiation forms like `new XMLHttpRequest()`. " + + "bare `XMLHttpRequest()`, `new XMLHttpRequest` (no-parens), or TypeScript instantiation forms like `new XMLHttpRequest()`. " + "XMLHttpRequest is the predecessor to `fetch` in the browser HTTP API. Like `fetch` (SYN007) " + "and `WebSocket` (SYN008), it makes real network calls at runtime but is invisible to " + "botscript's capability model: CAP001 checks for `http.*` member calls, not the XHR global. " + @@ -936,8 +936,8 @@ export const EXPLANATIONS: Readonly> = { "upload), wrap in `unsafe \"wraps XHR directly\" { new XMLHttpRequest() }` to make the " + "escape hatch visible in the diff.\n\n" + "SYN009 fires at `?bs 0.7+` as a non-blocking warning. Detection is token-based: " + - "`XMLHttpRequest` not preceded by `.`/`?.` followed by `(`, `?.(`, or `(`; " + - "TypeScript instantiation forms `new XMLHttpRequest()` are also detected. " + + "`XMLHttpRequest` not preceded by `.`/`?.` followed by `(`, `?.(`, `(`, or nothing (bare `new XMLHttpRequest`); " + + "TypeScript instantiation forms `new XMLHttpRequest()` and `new XMLHttpRequest` (no-parens) are also detected. " + "`obj.XMLHttpRequest(...)` (method call on a local object) is excluded. " + "Calls inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", example: { From b3c8eb77991b34e0ac6f08dd3c509c4341a3aace Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 12 Jun 2026 03:46:11 -0300 Subject: [PATCH 06/21] =?UTF-8?q?fix(syn009):=20clarify=20warning=20messag?= =?UTF-8?q?e=20=E2=80=94=20switch=20to=20http.get/post=20as=20primary=20fi?= =?UTF-8?q?x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rephrase the SYN009 diagnostic message to make the primary remediation more actionable: "switch to http.get(url)/http.post(url, { body }) and declare uses { net } on the fn header" makes clear that the http.* call and the uses { net } declaration go together (CAP001 enforces the latter once the former is present). The unsafe secondary path is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index ccdd3445..8315f8d0 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -898,7 +898,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult end: tok9.end, message: `fn '${decl.name}' constructs an XMLHttpRequest — bypasses the net capability model; ` + - `use http.get()/http.post() and declare uses { net }, or wrap in unsafe "wraps XHR directly" { new XMLHttpRequest() }`, + `switch to http.get(url)/http.post(url, { body }) and declare uses { net } on the fn header, ` + + `or wrap in unsafe "wraps XHR directly" { new XMLHttpRequest() }`, rule: syn009.rule, idiom: syn009.idiom, rewrite: syn009.rewrite, From 6dfc8d838ee5c8f1d2365e352b9b896d65bf60d5 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 12 Jun 2026 07:30:30 -0300 Subject: [PATCH 07/21] fix(syn009): remove SYN007/SYN008 code references in explanation text Co-Authored-By: Claude Sonnet 4.6 --- packages/mcp/src/explanations.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 7d3d3eee..759d3e40 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -919,15 +919,15 @@ export const EXPLANATIONS: Readonly> = { body: "SYN009 fires when a fn body constructs an `XMLHttpRequest` via `new XMLHttpRequest()`, " + "bare `XMLHttpRequest()`, `new XMLHttpRequest` (no-parens), or TypeScript instantiation forms like `new XMLHttpRequest()`. " + - "XMLHttpRequest is the predecessor to `fetch` in the browser HTTP API. Like `fetch` (SYN007) " + - "and `WebSocket` (SYN008), it makes real network calls at runtime but is invisible to " + + "XMLHttpRequest is the predecessor to `fetch` in the browser HTTP API. Like direct `fetch()` " + + "calls and `WebSocket` constructions, it makes real network calls at runtime but is invisible to " + "botscript's capability model: CAP001 checks for `http.*` member calls, not the XHR global. " + "A fn that constructs an XHR has an undeclared `net` dependency — `uses { net }` is never " + "triggered, callers cannot see the network dependency in the fn header, and the capability " + "manifest does not reflect it.\n\n" + - "The risk class is the same as SYN007 and SYN008: static analysis cannot reason about the " + - "blast radius of a fn that quietly opens HTTP connections. Code-generation models still " + - "produce XHR patterns, especially in browser-targeting code.\n\n" + + "The risk class is the same as direct `fetch()` or `WebSocket` usage: static analysis cannot " + + "reason about the blast radius of a fn that quietly opens HTTP connections. Code-generation " + + "models still produce XHR patterns, especially in browser-targeting code.\n\n" + "**Fix:** replace `new XMLHttpRequest()` with `http.get(url)` or `http.post(url, { body })` " + "and add `uses { net }` to the fn header. The stdlib `http.*` methods return " + "`Promise>` — `await` them to get a `Result`, making the error path " + From c5d705b7d598edcb866f5ad1eb75d48abac8de96 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 12 Jun 2026 15:39:07 -0300 Subject: [PATCH 08/21] =?UTF-8?q?fix(compiler):=20SYN009=20=E2=80=94=20exc?= =?UTF-8?q?lude=20method=20shorthands=20and=20TS=20method=20signatures=20n?= =?UTF-8?q?amed=20XMLHttpRequest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SYN009 was false-positive on object method definitions and TypeScript method signatures: `{ XMLHttpRequest(url) { } }` and `{ XMLHttpRequest(url: string): void }`. Both have XMLHttpRequest followed by `(`, so the token scan was treating them as XHR constructions. Fix: after matching the opening `(`, check what follows the closing `)`. If the next significant token is `{` (method body), `=>` (arrow method), or `:` (TS return type), skip — it's a method definition/signature, not a call. Applies to both direct `XMLHttpRequest(...)` and generic `XMLHttpRequest(...)` forms. Adds regression tests for both the method shorthand and TypeScript signature cases. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 25 ++++++++++++++++++-- packages/compiler/tests/syn009-check.test.ts | 20 ++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 8315f8d0..fd75f6f9 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -868,7 +868,16 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const afterAngle9 = tokens[afterAngleIdx]; // With parens: `XMLHttpRequest(...)` — fire; without parens but with `new`: also fire. if (afterAngle9 && afterAngle9.kind === "open" && afterAngle9.text === "(") { - // has parens, proceed to fire + // has parens — exclude method shorthands: { XMLHttpRequest(x) { } } / signatures: { XMLHttpRequest(x): T; } + if (afterAngle9.matchedAt !== undefined) { + const afterCloseIdx9 = nextSignificant(tokens, afterAngle9.matchedAt + 1); + const afterClose9 = tokens[afterCloseIdx9]; + if (afterClose9 && ( + (afterClose9.kind === "open" && afterClose9.text === "{") || + afterClose9.kind === "fatArrow" || + (afterClose9.kind === "punct" && afterClose9.text === ":") + )) continue; + } } else if (!isNewExpr9) { continue; // bare `XMLHttpRequest` without new and without parens — not a construction } @@ -877,7 +886,19 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const afterQD9 = nextSignificant(tokens, afterXhrFirstIdx + 1); const afterQDTok9 = tokens[afterQD9]; if (!afterQDTok9 || !(afterQDTok9.kind === "open" && afterQDTok9.text === "(")) continue; - } else if (!(afterXhr && afterXhr.kind === "open" && afterXhr.text === "(")) { + } else if (afterXhr && afterXhr.kind === "open" && afterXhr.text === "(") { + // Direct call: `XMLHttpRequest(...)` — exclude method shorthands: { XMLHttpRequest(x) { } } + // and TypeScript method signatures: { XMLHttpRequest(x): T; } + if (afterXhr.matchedAt !== undefined) { + const afterCloseIdx9 = nextSignificant(tokens, afterXhr.matchedAt + 1); + const afterClose9 = tokens[afterCloseIdx9]; + if (afterClose9 && ( + (afterClose9.kind === "open" && afterClose9.text === "{") || + afterClose9.kind === "fatArrow" || + (afterClose9.kind === "punct" && afterClose9.text === ":") + )) continue; + } + } else { // Member access on the constructor itself — not a construction if (afterXhr && afterXhr.kind === "punct" && afterXhr.text === ".") continue; // No parens — only fire if preceded by `new` (bare construction: `new XMLHttpRequest`) diff --git a/packages/compiler/tests/syn009-check.test.ts b/packages/compiler/tests/syn009-check.test.ts index 8f071faf..75cc9076 100644 --- a/packages/compiler/tests/syn009-check.test.ts +++ b/packages/compiler/tests/syn009-check.test.ts @@ -162,4 +162,24 @@ describe("SYN009: XMLHttpRequest() call detection", () => { const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN009")).toBe(true); }); + + it("does NOT fire on object method shorthand named XMLHttpRequest", () => { + const src = + "?bs 0.7\n" + + "fn test() -> void {\n" + + " const handler = { XMLHttpRequest(url) { return url; } };\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); + + it("does NOT fire on TypeScript method signature named XMLHttpRequest", () => { + const src = + "?bs 0.7\n" + + "fn test() -> void {\n" + + " type XhrLike = { XMLHttpRequest(url: string): void };\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); }); From cac5adbbd3ed0b5a6f4170344ecda467748b171f Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 12 Jun 2026 19:32:07 -0300 Subject: [PATCH 09/21] =?UTF-8?q?fix(compiler):=20SYN009=20=E2=80=94=20gua?= =?UTF-8?q?rd=20ternary=20`:`=20from=20suppressing=20XMLHttpRequest=20dete?= =?UTF-8?q?ction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cond ? XMLHttpRequest(url) : other` and `cond ? new XMLHttpRequest(url) : other` both had the `:` exclusion (method-signature heuristic) trigger incorrectly, silently suppressing SYN009. Added `isTernaryConsequent9` check that tests whether the token before XMLHttpRequest (or before `new`) is `?`, and guards both `:` suppression sites (direct-call and generic-form paths). Added regression test covering both consequent arms with `new XMLHttpRequest()`. Same fix class as SYN004/SYN007/SYN008. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 13 +++++++++++-- packages/compiler/tests/syn009-check.test.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index fd75f6f9..8601762f 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -848,6 +848,15 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult // `new XMLHttpRequest` without parens is valid JS/TS construction — fire on it too. const isNewExpr9 = prev9 && prev9.kind === "ident" && prev9.text === "new"; + // Ternary guard: `cond ? XMLHttpRequest(url) : other` / `cond ? new XMLHttpRequest(url) : other`. + // When `:` appears after the closing `)`, it is the ternary else-branch, not a method signature. + const prevBeforeNew9 = isNewExpr9 + ? tokens[prevSignificant(tokens, prevIdx9 - 1)] + : undefined; + const isTernaryConsequent9 = + (prev9 !== undefined && prev9 !== null && prev9.kind === "question") || + (prevBeforeNew9 !== undefined && prevBeforeNew9 !== null && prevBeforeNew9.kind === "question"); + // Must be followed by `(`, `?.(`, `(`, or nothing (bare `new XMLHttpRequest`). const afterXhrFirstIdx = nextSignificant(tokens, i + 1); const afterXhr = tokens[afterXhrFirstIdx]; @@ -875,7 +884,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (afterClose9 && ( (afterClose9.kind === "open" && afterClose9.text === "{") || afterClose9.kind === "fatArrow" || - (afterClose9.kind === "punct" && afterClose9.text === ":") + (!isTernaryConsequent9 && afterClose9.kind === "punct" && afterClose9.text === ":") )) continue; } } else if (!isNewExpr9) { @@ -895,7 +904,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (afterClose9 && ( (afterClose9.kind === "open" && afterClose9.text === "{") || afterClose9.kind === "fatArrow" || - (afterClose9.kind === "punct" && afterClose9.text === ":") + (!isTernaryConsequent9 && afterClose9.kind === "punct" && afterClose9.text === ":") )) continue; } } else { diff --git a/packages/compiler/tests/syn009-check.test.ts b/packages/compiler/tests/syn009-check.test.ts index 75cc9076..72e2530f 100644 --- a/packages/compiler/tests/syn009-check.test.ts +++ b/packages/compiler/tests/syn009-check.test.ts @@ -182,4 +182,16 @@ describe("SYN009: XMLHttpRequest() call detection", () => { const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); }); + + it("fires on XMLHttpRequest() inside a ternary expression (regression: `:` must not suppress)", () => { + // `cond ? XMLHttpRequest() : other` — the `:` after the closing `)` must not trigger + // the method-signature exclusion and hide SYN009. + const src = + "?bs 0.7\n" + + "fn pick(cond: boolean) -> any {\n" + + " return cond ? new XMLHttpRequest() : new XMLHttpRequest()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.filter((w) => w.code === "SYN009").length).toBe(2); + }); }); From 8d149aa10cb9a11515b406f2152f934609ee927c Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sat, 13 Jun 2026 03:31:13 -0300 Subject: [PATCH 10/21] =?UTF-8?q?refactor(compiler):=20SYN009=20=E2=80=94?= =?UTF-8?q?=20fold=20XMLHttpRequest=20detection=20into=20single=20dispatch?= =?UTF-8?q?=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously SYN009 ran as a separate second pass with its own nesting bookkeeping, contradicting the "single dispatch loop" comment. Moving it into the switch as case "XMLHttpRequest" shares the existing open/nextInner tracking and eliminates the duplicate scan. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 207 ++++++++++------------ 1 file changed, 93 insertions(+), 114 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 8601762f..14bdaac7 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -816,124 +816,103 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult }); break; } - } - } - // SYN009: new XMLHttpRequest() / XMLHttpRequest() detection. - // Fires when a fn body constructs an XMLHttpRequest via `new XMLHttpRequest()`, - // `XMLHttpRequest()`, `new XMLHttpRequest` (no parens), or TypeScript form `new XMLHttpRequest()`. - // XMLHttpRequest opens an HTTP connection at runtime but is invisible to CAP001 - // (which only checks `http.*` member calls). A fn that constructs an XHR has an - // undeclared `net` dependency. - // Suppressed inside `unsafe "reason" { }` blocks and `unsafe "reason" fn` bodies. - nextInner = 0; - const open009: typeof inner = []; - for (let i = bodyStart; i < decl.tokenEnd; i++) { - while (open009.length > 0 && open009[open009.length - 1]!.tokenEnd <= i) open009.pop(); - while (nextInner < inner.length && inner[nextInner]!.tokenStart <= i) { - open009.push(inner[nextInner]!); - nextInner++; - } - if (open009.length > 0) continue; - - const tok9 = tokens[i]; - if (!tok9 || tok9.kind !== "ident" || tok9.text !== "XMLHttpRequest") continue; - - // Exclude property accesses: obj.XMLHttpRequest(...) - const prevIdx9 = prevSignificant(tokens, i - 1); - const prev9 = tokens[prevIdx9]; - if (prev9 && ((prev9.kind === "punct" && prev9.text === ".") || prev9.kind === "questionDot")) - continue; - - // `new XMLHttpRequest` without parens is valid JS/TS construction — fire on it too. - const isNewExpr9 = prev9 && prev9.kind === "ident" && prev9.text === "new"; - - // Ternary guard: `cond ? XMLHttpRequest(url) : other` / `cond ? new XMLHttpRequest(url) : other`. - // When `:` appears after the closing `)`, it is the ternary else-branch, not a method signature. - const prevBeforeNew9 = isNewExpr9 - ? tokens[prevSignificant(tokens, prevIdx9 - 1)] - : undefined; - const isTernaryConsequent9 = - (prev9 !== undefined && prev9 !== null && prev9.kind === "question") || - (prevBeforeNew9 !== undefined && prevBeforeNew9 !== null && prevBeforeNew9.kind === "question"); - - // Must be followed by `(`, `?.(`, `(`, or nothing (bare `new XMLHttpRequest`). - const afterXhrFirstIdx = nextSignificant(tokens, i + 1); - const afterXhr = tokens[afterXhrFirstIdx]; - - if (afterXhr && afterXhr.kind === "operator" && afterXhr.text === "<") { - // TypeScript instantiation form: `XMLHttpRequest(...)` or `new XMLHttpRequest` - let anglDepth = 1; - let j = afterXhrFirstIdx + 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++; - } - const afterAngleIdx = nextSignificant(tokens, j); - const afterAngle9 = tokens[afterAngleIdx]; - // With parens: `XMLHttpRequest(...)` — fire; without parens but with `new`: also fire. - if (afterAngle9 && afterAngle9.kind === "open" && afterAngle9.text === "(") { - // has parens — exclude method shorthands: { XMLHttpRequest(x) { } } / signatures: { XMLHttpRequest(x): T; } - if (afterAngle9.matchedAt !== undefined) { - const afterCloseIdx9 = nextSignificant(tokens, afterAngle9.matchedAt + 1); - const afterClose9 = tokens[afterCloseIdx9]; - if (afterClose9 && ( - (afterClose9.kind === "open" && afterClose9.text === "{") || - afterClose9.kind === "fatArrow" || - (!isTernaryConsequent9 && afterClose9.kind === "punct" && afterClose9.text === ":") - )) continue; + // ── SYN009: new XMLHttpRequest() / XMLHttpRequest() call ───────────── + case "XMLHttpRequest": { + // Exclude: `obj.XMLHttpRequest(...)` — preceded by `.` or `?.` + const prevIdx9 = prevSignificant(tokens, i - 1); + const prev9 = tokens[prevIdx9]; + if (prev9 && ((prev9.kind === "punct" && prev9.text === ".") || prev9.kind === "questionDot")) + continue; + + // `new XMLHttpRequest` without parens is valid JS/TS construction — fire on it too. + const isNewExpr9 = prev9 && prev9.kind === "ident" && prev9.text === "new"; + + // Ternary guard: `cond ? XMLHttpRequest(url) : other` / `cond ? new XMLHttpRequest(url) : other`. + const prevBeforeNew9 = isNewExpr9 + ? tokens[prevSignificant(tokens, prevIdx9 - 1)] + : undefined; + const isTernaryConsequent9 = + (prev9 !== undefined && prev9 !== null && prev9.kind === "question") || + (prevBeforeNew9 !== undefined && prevBeforeNew9 !== null && prevBeforeNew9.kind === "question"); + + // Must be followed by `(`, `?.(`, `(`, or nothing (bare `new XMLHttpRequest`). + const afterXhrFirstIdx = nextSignificant(tokens, i + 1); + const afterXhr = tokens[afterXhrFirstIdx]; + + if (afterXhr && afterXhr.kind === "operator" && afterXhr.text === "<") { + // TypeScript instantiation form: `XMLHttpRequest(...)` or `new XMLHttpRequest` + let anglDepth = 1; + let j = afterXhrFirstIdx + 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++; + } + const afterAngleIdx = nextSignificant(tokens, j); + const afterAngle9 = tokens[afterAngleIdx]; + if (afterAngle9 && afterAngle9.kind === "open" && afterAngle9.text === "(") { + // has parens — exclude method shorthands and TS method signatures + if (afterAngle9.matchedAt !== undefined) { + const afterCloseIdx9 = nextSignificant(tokens, afterAngle9.matchedAt + 1); + const afterClose9 = tokens[afterCloseIdx9]; + if (afterClose9 && ( + (afterClose9.kind === "open" && afterClose9.text === "{") || + afterClose9.kind === "fatArrow" || + (!isTernaryConsequent9 && afterClose9.kind === "punct" && afterClose9.text === ":") + )) continue; + } + } else if (!isNewExpr9) { + continue; // bare `XMLHttpRequest` without new and without parens — not a construction + } + } else if (afterXhr && afterXhr.kind === "questionDot") { + // `XMLHttpRequest?.(...)` — optional call + const afterQD9 = nextSignificant(tokens, afterXhrFirstIdx + 1); + const afterQDTok9 = tokens[afterQD9]; + if (!afterQDTok9 || !(afterQDTok9.kind === "open" && afterQDTok9.text === "(")) continue; + } else if (afterXhr && afterXhr.kind === "open" && afterXhr.text === "(") { + // Direct call — exclude method shorthands and TS method signatures + if (afterXhr.matchedAt !== undefined) { + const afterCloseIdx9 = nextSignificant(tokens, afterXhr.matchedAt + 1); + const afterClose9 = tokens[afterCloseIdx9]; + if (afterClose9 && ( + (afterClose9.kind === "open" && afterClose9.text === "{") || + afterClose9.kind === "fatArrow" || + (!isTernaryConsequent9 && afterClose9.kind === "punct" && afterClose9.text === ":") + )) continue; + } + } else { + // Member access on the constructor itself — not a construction + if (afterXhr && afterXhr.kind === "punct" && afterXhr.text === ".") continue; + // No parens — only fire if preceded by `new` (bare construction: `new XMLHttpRequest`) + if (!isNewExpr9) continue; } - } else if (!isNewExpr9) { - continue; // bare `XMLHttpRequest` without new and without parens — not a construction - } - } else if (afterXhr && afterXhr.kind === "questionDot") { - // `XMLHttpRequest?.(...)` — optional call (unusual but possible) - const afterQD9 = nextSignificant(tokens, afterXhrFirstIdx + 1); - const afterQDTok9 = tokens[afterQD9]; - if (!afterQDTok9 || !(afterQDTok9.kind === "open" && afterQDTok9.text === "(")) continue; - } else if (afterXhr && afterXhr.kind === "open" && afterXhr.text === "(") { - // Direct call: `XMLHttpRequest(...)` — exclude method shorthands: { XMLHttpRequest(x) { } } - // and TypeScript method signatures: { XMLHttpRequest(x): T; } - if (afterXhr.matchedAt !== undefined) { - const afterCloseIdx9 = nextSignificant(tokens, afterXhr.matchedAt + 1); - const afterClose9 = tokens[afterCloseIdx9]; - if (afterClose9 && ( - (afterClose9.kind === "open" && afterClose9.text === "{") || - afterClose9.kind === "fatArrow" || - (!isTernaryConsequent9 && afterClose9.kind === "punct" && afterClose9.text === ":") - )) continue; + + if (isInsideRange(tok.start, unsafeRanges)) continue; + + const loc9 = locationOf(src, tok.start); + warnings.push({ + code: syn009.code, + severity: "warning", + file: null, + line: loc9.line, + column: loc9.column, + start: tok.start, + end: tok.end, + message: + `fn '${decl.name}' constructs an XMLHttpRequest — bypasses the net capability model; ` + + `switch to http.get(url)/http.post(url, { body }) and declare uses { net } on the fn header, ` + + `or wrap in unsafe "wraps XHR directly" { new XMLHttpRequest() }`, + rule: syn009.rule, + idiom: syn009.idiom, + rewrite: syn009.rewrite, + }); + break; } - } else { - // Member access on the constructor itself — not a construction - if (afterXhr && afterXhr.kind === "punct" && afterXhr.text === ".") continue; - // No parens — only fire if preceded by `new` (bare construction: `new XMLHttpRequest`) - if (!isNewExpr9) continue; } - - // Suppression check: unsafe block or unsafe fn body - if (isInsideRange(tok9.start, unsafeRanges)) continue; - - const loc9 = locationOf(src, tok9.start); - warnings.push({ - code: syn009.code, - severity: "warning", - file: null, - line: loc9.line, - column: loc9.column, - start: tok9.start, - end: tok9.end, - message: - `fn '${decl.name}' constructs an XMLHttpRequest — bypasses the net capability model; ` + - `switch to http.get(url)/http.post(url, { body }) and declare uses { net } on the fn header, ` + - `or wrap in unsafe "wraps XHR directly" { new XMLHttpRequest() }`, - rule: syn009.rule, - idiom: syn009.idiom, - rewrite: syn009.rewrite, - }); } } From 84609bdd2cdf8ddcda33e44b6c42e1268862b3f9 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sun, 14 Jun 2026 23:51:09 -0300 Subject: [PATCH 11/21] =?UTF-8?q?fix(compiler):=20SYN009=20=E2=80=94=20exc?= =?UTF-8?q?lude=20fn/function=20declarations,=20fix=20anchor=20test=20offs?= =?UTF-8?q?et?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two exclusion guards so that `fn XMLHttpRequest(...)` and `function XMLHttpRequest(...)` declarations do not trigger SYN009 — the token that follows the declaration keyword is the callee name, not a call site. Also fix the position-anchor test: `passVersion` strips the `?bs N.N` pragma text but keeps the trailing `\n`, so all reported `start` values are relative to the post-pragma source. The test was computing `newIdx` via `src.indexOf(...)` on the full string (pragma included) and comparing to the compiler's pragma-stripped offset, causing a 7-byte mismatch. The fix subtracts `src.indexOf("\n")` (= pragma length) before asserting. Add two regression tests for the declaration-exclusion cases. --- packages/compiler/src/passes/syn-check.ts | 21 ++++++-- packages/compiler/tests/syn009-check.test.ts | 56 ++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 14bdaac7..ed7fd51f 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -825,16 +825,28 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (prev9 && ((prev9.kind === "punct" && prev9.text === ".") || prev9.kind === "questionDot")) continue; + // Exclude: `function XMLHttpRequest(...)` / `fn XMLHttpRequest(...)` declarations + if (prev9 && prev9.kind === "ident" && prev9.text === "function") continue; + if (prev9 && prev9.kind === "keyword" && prev9.text === "fn") continue; + // `new XMLHttpRequest` without parens is valid JS/TS construction — fire on it too. const isNewExpr9 = prev9 && prev9.kind === "ident" && prev9.text === "new"; // Ternary guard: `cond ? XMLHttpRequest(url) : other` / `cond ? new XMLHttpRequest(url) : other`. - const prevBeforeNew9 = isNewExpr9 + // Also guard `cond ? await XMLHttpRequest() : other` / `cond ? await new XMLHttpRequest() : other`. + const isAwaitBefore9 = !isNewExpr9 && prev9 && prev9.kind === "ident" && prev9.text === "await"; + const prevBeforeNew9Idx = isNewExpr9 ? prevSignificant(tokens, prevIdx9 - 1) : -1; + const prevBeforeNew9 = prevBeforeNew9Idx >= 0 ? tokens[prevBeforeNew9Idx] : undefined; + const isAwaitBeforeNew9 = isNewExpr9 && prevBeforeNew9 && prevBeforeNew9.kind === "ident" && prevBeforeNew9.text === "await"; + const prevBeforeAwait9 = isAwaitBefore9 ? tokens[prevSignificant(tokens, prevIdx9 - 1)] + : isAwaitBeforeNew9 + ? tokens[prevSignificant(tokens, prevBeforeNew9Idx - 1)] : undefined; const isTernaryConsequent9 = (prev9 !== undefined && prev9 !== null && prev9.kind === "question") || - (prevBeforeNew9 !== undefined && prevBeforeNew9 !== null && prevBeforeNew9.kind === "question"); + (prevBeforeNew9 !== undefined && prevBeforeNew9 !== null && prevBeforeNew9.kind === "question") || + (prevBeforeAwait9 !== undefined && prevBeforeAwait9 !== null && prevBeforeAwait9.kind === "question"); // Must be followed by `(`, `?.(`, `(`, or nothing (bare `new XMLHttpRequest`). const afterXhrFirstIdx = nextSignificant(tokens, i + 1); @@ -893,14 +905,15 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (isInsideRange(tok.start, unsafeRanges)) continue; - const loc9 = locationOf(src, tok.start); + const warnStart9 = isNewExpr9 ? prev9!.start : tok.start; + const loc9 = locationOf(src, warnStart9); warnings.push({ code: syn009.code, severity: "warning", file: null, line: loc9.line, column: loc9.column, - start: tok.start, + start: warnStart9, end: tok.end, message: `fn '${decl.name}' constructs an XMLHttpRequest — bypasses the net capability model; ` + diff --git a/packages/compiler/tests/syn009-check.test.ts b/packages/compiler/tests/syn009-check.test.ts index 72e2530f..4a078b99 100644 --- a/packages/compiler/tests/syn009-check.test.ts +++ b/packages/compiler/tests/syn009-check.test.ts @@ -194,4 +194,60 @@ describe("SYN009: XMLHttpRequest() call detection", () => { const result = compile(src); expect(result.warnings.filter((w) => w.code === "SYN009").length).toBe(2); }); + + it("fires on await new XMLHttpRequest() inside ternary (ternary guard must walk past await)", () => { + // `cond ? await new XMLHttpRequest() : other` — must NOT be suppressed by the trailing `:` + const src = + "?bs 0.7\n" + + "fn pick(cond: boolean) -> any {\n" + + " return cond ? await new XMLHttpRequest() : null\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(true); + }); + + it("fires on await XMLHttpRequest() (bare, no new) inside ternary", () => { + const src = + "?bs 0.7\n" + + "fn pick(cond: boolean) -> any {\n" + + " return cond ? await XMLHttpRequest() : null\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(true); + }); + + it("anchors diagnostic start at new token for new XMLHttpRequest()", () => { + const src = + "?bs 0.7\n" + + "fn send() -> void {\n" + + " new XMLHttpRequest()\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN009"); + expect(w).toBeDefined(); + // 'new' appears before 'XMLHttpRequest' — start should be at 'new', not at 'XMLHttpRequest'. + // Positions are reported in the pragma-stripped source (`?bs 0.7` is removed; `\n` kept), + // so subtract the pragma length (= index of first `\n`) from the raw indexOf result. + const pragmaLen = src.indexOf("\n"); // length of "?bs 0.7" = 7, the stripped portion + const newIdx = src.indexOf("new XMLHttpRequest") - pragmaLen; + expect(w!.start).toBe(newIdx); + }); + + it("does NOT fire on fn XMLHttpRequest(...) botscript declaration", () => { + const src = + "?bs 0.7\n" + + "fn XMLHttpRequest(url: string) -> void {\n" + + " return\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); + + it("does NOT fire on function XMLHttpRequest(...) declaration", () => { + const src = + "?bs 0.7\n" + + "function XMLHttpRequest(url: string) {}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); }); From 880875e262d47c24128fcd1ccc2b9b01de9b93ce Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Mon, 15 Jun 2026 03:44:30 -0300 Subject: [PATCH 12/21] fix(syn009): distinguish 'constructs new' vs 'calls' in diagnostic message Mirror the SYN008 pattern: when the detected form is a bare call like `XMLHttpRequest()` or `XMLHttpRequest?.()`, say "calls XMLHttpRequest?()" rather than "constructs an XMLHttpRequest", which implies `new`. The escape hatch always suggests `new XMLHttpRequest()` since that is the correct form. Addresses Copilot review comment on PR #150. --- packages/compiler/src/passes/syn-check.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index ed7fd51f..0e9593af 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -905,6 +905,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (isInsideRange(tok.start, unsafeRanges)) continue; + const isOpt9 = afterXhr && afterXhr.kind === "questionDot"; const warnStart9 = isNewExpr9 ? prev9!.start : tok.start; const loc9 = locationOf(src, warnStart9); warnings.push({ @@ -916,7 +917,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult start: warnStart9, end: tok.end, message: - `fn '${decl.name}' constructs an XMLHttpRequest — bypasses the net capability model; ` + + `fn '${decl.name}' ${isNewExpr9 ? "constructs new " : "calls "}XMLHttpRequest${isOpt9 ? "?." : ""}() — bypasses the net capability model; ` + `switch to http.get(url)/http.post(url, { body }) and declare uses { net } on the fn header, ` + `or wrap in unsafe "wraps XHR directly" { new XMLHttpRequest() }`, rule: syn009.rule, From 800a1f2e7260770517bb2c444cfc2887358b4325 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Mon, 15 Jun 2026 15:40:34 -0300 Subject: [PATCH 13/21] fix(syn009): correct diagnostic message form for optional-call and bare-new XHR Three improvements: - For XMLHttpRequest?.() (optional call): the message and unsafe example now reflect the actual detected form instead of hardcoding "new XMLHttpRequest()" - For bare new XMLHttpRequest (no parens): the message no longer adds "()" that wasn't in the original source - Message now reads "uses X" instead of "constructs new X / calls X" for cleaner wording that covers all forms without duplication Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 0e9593af..e7a7583c 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -906,6 +906,11 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (isInsideRange(tok.start, unsafeRanges)) continue; const isOpt9 = afterXhr && afterXhr.kind === "questionDot"; + // Bare `new XMLHttpRequest` — no parens in the original source + const isNoParens9 = isNewExpr9 && afterXhr && afterXhr.kind !== "open" && !isOpt9 && afterXhr.kind !== "operator"; + const detectedForm9 = isNewExpr9 + ? (isNoParens9 ? "new XMLHttpRequest" : "new XMLHttpRequest()") + : (isOpt9 ? "XMLHttpRequest?.()" : "XMLHttpRequest()"); const warnStart9 = isNewExpr9 ? prev9!.start : tok.start; const loc9 = locationOf(src, warnStart9); warnings.push({ @@ -917,9 +922,9 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult start: warnStart9, end: tok.end, message: - `fn '${decl.name}' ${isNewExpr9 ? "constructs new " : "calls "}XMLHttpRequest${isOpt9 ? "?." : ""}() — bypasses the net capability model; ` + + `fn '${decl.name}' uses ${detectedForm9} — bypasses the net capability model; ` + `switch to http.get(url)/http.post(url, { body }) and declare uses { net } on the fn header, ` + - `or wrap in unsafe "wraps XHR directly" { new XMLHttpRequest() }`, + `or wrap in unsafe "wraps XHR directly" { ${detectedForm9} }`, rule: syn009.rule, idiom: syn009.idiom, rewrite: syn009.rewrite, From c796e5a26818564346a61d0bf7e0a08d36a7cfbb Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Mon, 15 Jun 2026 19:34:54 -0300 Subject: [PATCH 14/21] fix(syn009): use async fn in ternary-await test fixtures Copilot flagged two tests using `await` inside a non-async `fn pick(...)`, which is invalid JS/TS syntax. Changed to `async fn pick(...)` so the test fixtures are syntactically valid and consistent with other syn-check tests. Co-Authored-By: Botkowski --- packages/compiler/tests/syn009-check.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/tests/syn009-check.test.ts b/packages/compiler/tests/syn009-check.test.ts index 4a078b99..36c67fee 100644 --- a/packages/compiler/tests/syn009-check.test.ts +++ b/packages/compiler/tests/syn009-check.test.ts @@ -199,7 +199,7 @@ describe("SYN009: XMLHttpRequest() call detection", () => { // `cond ? await new XMLHttpRequest() : other` — must NOT be suppressed by the trailing `:` const src = "?bs 0.7\n" + - "fn pick(cond: boolean) -> any {\n" + + "async fn pick(cond: boolean) -> any {\n" + " return cond ? await new XMLHttpRequest() : null\n" + "}\n"; const result = compile(src); @@ -209,7 +209,7 @@ describe("SYN009: XMLHttpRequest() call detection", () => { it("fires on await XMLHttpRequest() (bare, no new) inside ternary", () => { const src = "?bs 0.7\n" + - "fn pick(cond: boolean) -> any {\n" + + "async fn pick(cond: boolean) -> any {\n" + " return cond ? await XMLHttpRequest() : null\n" + "}\n"; const result = compile(src); From 14c3332a733d6ffc76f2da46697f287d4188bb15 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 03:42:07 -0300 Subject: [PATCH 15/21] fix(syn009): extend diagnostic end to opening paren, matching SYN007/SYN008/SYN011 convention Copilot review noted end:tok.end covers only XMLHttpRequest token. Change end to the opening paren token start+1 for call/construct forms (bare new XMLHttpRequest with no parens stays at tok.end). Handles all detected forms: direct call, optional call ?.(), and generic () via a second angle-bracket scan. --- packages/compiler/src/passes/syn-check.ts | 30 ++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index e7a7583c..0388d092 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -912,6 +912,34 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult ? (isNoParens9 ? "new XMLHttpRequest" : "new XMLHttpRequest()") : (isOpt9 ? "XMLHttpRequest?.()" : "XMLHttpRequest()"); const warnStart9 = isNewExpr9 ? prev9!.start : tok.start; + // end: cover through the opening `(` (matching SYN007/SYN008/SYN011 convention) + // For bare `new XMLHttpRequest` (no parens), fall back to tok.end. + let warnEnd9 = tok.end; + if (!isNoParens9) { + if (isOpt9) { + const afterQD9end = nextSignificant(tokens, afterXhrFirstIdx + 1); + const afterQDTokEnd9 = tokens[afterQD9end]; + if (afterQDTokEnd9 && afterQDTokEnd9.kind === "open" && afterQDTokEnd9.text === "(") + warnEnd9 = afterQDTokEnd9.start + 1; + } else if (afterXhr && afterXhr.kind === "open" && afterXhr.text === "(") { + warnEnd9 = afterXhr.start + 1; + } else if (afterXhr && afterXhr.kind === "operator" && afterXhr.text === "<") { + // generic form: find the `(` after the `>` + let anglDepth2 = 1; + let j2 = afterXhrFirstIdx + 1; + while (j2 < decl.tokenEnd && anglDepth2 > 0) { + const at = tokens[j2]; + if (!at) { j2++; continue; } + if (at.kind === "operator" && at.text === "<") anglDepth2++; + else if (at.kind === "operator" && (at.text === ">" || at.text === ">>" || at.text === ">>>")) + anglDepth2 = Math.max(0, anglDepth2 - at.text.length); + j2++; + } + const afterAngle9end = tokens[nextSignificant(tokens, j2)]; + if (afterAngle9end && afterAngle9end.kind === "open" && afterAngle9end.text === "(") + warnEnd9 = afterAngle9end.start + 1; + } + } const loc9 = locationOf(src, warnStart9); warnings.push({ code: syn009.code, @@ -920,7 +948,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult line: loc9.line, column: loc9.column, start: warnStart9, - end: tok.end, + end: warnEnd9, message: `fn '${decl.name}' uses ${detectedForm9} — bypasses the net capability model; ` + `switch to http.get(url)/http.post(url, { body }) and declare uses { net } on the fn header, ` + From 1299315754b9eb0377587ecc1c41983b05e65ba2 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 07:37:59 -0300 Subject: [PATCH 16/21] fix(compiler): SYN009 exclude TS method signatures with omitted return type Type literals like `type X = { XMLHttpRequest(url: string) }` (no `: ReturnType`) were not excluded by the method-signature check since that only looked at the token *after* the closing `)`. Add the same omitted-return-type scan used by SYN012/013/014: scan inside the parens at depth 0 for a top-level `:` type annotation. Covers both the direct-call and generic-construction paths. Add two regression tests for the new coverage. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 48 ++++++++++++++++++++ packages/compiler/tests/syn009-check.test.ts | 20 ++++++++ 2 files changed, 68 insertions(+) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index b61305a6..30fa2607 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1467,6 +1467,30 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult afterClose9.kind === "fatArrow" || (!isTernaryConsequent9 && afterClose9.kind === "punct" && afterClose9.text === ":") )) continue; + // Exclude TS method signatures with omitted return type: `{ XMLHttpRequest(url: string) }` + let hasTypeAnnotation9g = false; + let depth9g = 0; + let ternaryDepth9g = 0; + for (let k9 = afterXhrFirstIdx + 1; k9 < afterAngle9.matchedAt; k9++) { + const at9 = tokens[k9]; + if (!at9) continue; + if (at9.kind === "open") { depth9g++; continue; } + if (at9.kind === "close") { depth9g--; continue; } + if (depth9g !== 0) continue; + if (at9.kind === "question") { + const nextAfterQ9 = nextSignificant(tokens, k9 + 1); + const nextTokQ9 = tokens[nextAfterQ9]; + if (nextTokQ9 && nextTokQ9.kind === "punct" && nextTokQ9.text === ":") { + hasTypeAnnotation9g = true; break; + } + ternaryDepth9g++; continue; + } + if (at9.kind === "punct" && at9.text === ":") { + if (ternaryDepth9g > 0) { ternaryDepth9g--; continue; } + hasTypeAnnotation9g = true; break; + } + } + if (hasTypeAnnotation9g) continue; } } else if (!isNewExpr9) { continue; // bare `XMLHttpRequest` without new and without parens — not a construction @@ -1486,6 +1510,30 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult afterClose9.kind === "fatArrow" || (!isTernaryConsequent9 && afterClose9.kind === "punct" && afterClose9.text === ":") )) continue; + // Exclude TS method signatures with omitted return type: `{ XMLHttpRequest(url: string) }` + let hasTypeAnnotation9 = false; + let depth9 = 0; + let ternaryDepth9 = 0; + for (let k9 = afterXhrFirstIdx + 1; k9 < afterXhr.matchedAt; k9++) { + const at9 = tokens[k9]; + if (!at9) continue; + if (at9.kind === "open") { depth9++; continue; } + if (at9.kind === "close") { depth9--; continue; } + if (depth9 !== 0) continue; + if (at9.kind === "question") { + const nextAfterQ9 = nextSignificant(tokens, k9 + 1); + const nextTokQ9 = tokens[nextAfterQ9]; + if (nextTokQ9 && nextTokQ9.kind === "punct" && nextTokQ9.text === ":") { + hasTypeAnnotation9 = true; break; + } + ternaryDepth9++; continue; + } + if (at9.kind === "punct" && at9.text === ":") { + if (ternaryDepth9 > 0) { ternaryDepth9--; continue; } + hasTypeAnnotation9 = true; break; + } + } + if (hasTypeAnnotation9) continue; } } else { // Member access on the constructor itself — not a construction diff --git a/packages/compiler/tests/syn009-check.test.ts b/packages/compiler/tests/syn009-check.test.ts index 36c67fee..9d69c200 100644 --- a/packages/compiler/tests/syn009-check.test.ts +++ b/packages/compiler/tests/syn009-check.test.ts @@ -183,6 +183,26 @@ describe("SYN009: XMLHttpRequest() call detection", () => { expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); }); + it("does NOT fire on TypeScript method signature with omitted return type", () => { + const src = + "?bs 0.7\n" + + "fn test() -> void {\n" + + " type XhrLike = { XMLHttpRequest(url: string) };\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); + + it("does NOT fire on TypeScript method signature with optional param and omitted return type", () => { + const src = + "?bs 0.7\n" + + "fn test() -> void {\n" + + " type XhrLike = { XMLHttpRequest(url?: string) };\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); + it("fires on XMLHttpRequest() inside a ternary expression (regression: `:` must not suppress)", () => { // `cond ? XMLHttpRequest() : other` — the `:` after the closing `)` must not trigger // the method-signature exclusion and hide SYN009. From 888566e3f5a4a51efaf82669519dd3933277826a Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 11:43:54 -0300 Subject: [PATCH 17/21] fix(compiler): SYN009 fix generic guard, no-parens form, empty-parens type-literal Four Copilot findings addressed: 1. Generic comparison false-positive: gate the scan on isNewExpr9 (same guard SYN008 WebSocket uses). XMLHttpRequest < x > (y) no longer fires SYN009. 2. new XMLHttpRequest without parens: introduce isGenericNoParens9 to track when we're in the generic-then-no-parens path. Previously isNoParens9 was false (because afterXhr.kind === "operator") so detectedForm9 read "new XMLHttpRequest()" instead of "new XMLHttpRequest". warnEnd9 now extends through the closing ">". 3. Empty-parens type-literal false positive: type X = { XMLHttpRequest() } (no params, no return type) was not excluded. Added the same prevOpen === "=" / ":" guard used by SYN017 to catch this case. 4. Added three tests: empty-parens type literal, comparison expression guard, and generic no-parens construction. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 48 ++++++++++++++++---- packages/compiler/tests/syn009-check.test.ts | 34 ++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 30fa2607..41256280 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1442,9 +1442,15 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult // Must be followed by `(`, `?.(`, `(`, or nothing (bare `new XMLHttpRequest`). const afterXhrFirstIdx = nextSignificant(tokens, i + 1); const afterXhr = tokens[afterXhrFirstIdx]; + // Tracks `new XMLHttpRequest` with no parens after the generic — set in the generic branch. + let isGenericNoParens9 = false; + let genericCloseTokEnd9 = tok.end; // end of `>` for warnEnd in that form if (afterXhr && afterXhr.kind === "operator" && afterXhr.text === "<") { - // TypeScript instantiation form: `XMLHttpRequest(...)` or `new XMLHttpRequest` + // TypeScript instantiation form: `XMLHttpRequest(...)` or `new XMLHttpRequest`. + // Gate on `new` to avoid false-positives on `XMLHttpRequest < x > (y)` comparison + // expressions (same guard as SYN008 WebSocket). + if (!isNewExpr9) continue; let anglDepth = 1; let j = afterXhrFirstIdx + 1; while (j < decl.tokenEnd && anglDepth > 0) { @@ -1455,6 +1461,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult anglDepth = Math.max(0, anglDepth - at.text.length); j++; } + const closingAngleTok9 = tokens[j - 1]; // the `>` that closed the generic scan const afterAngleIdx = nextSignificant(tokens, j); const afterAngle9 = tokens[afterAngleIdx]; if (afterAngle9 && afterAngle9.kind === "open" && afterAngle9.text === "(") { @@ -1492,8 +1499,10 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult } if (hasTypeAnnotation9g) continue; } - } else if (!isNewExpr9) { - continue; // bare `XMLHttpRequest` without new and without parens — not a construction + } else { + // new XMLHttpRequest — no parens after generic; fire as a no-parens construction. + isGenericNoParens9 = true; + genericCloseTokEnd9 = closingAngleTok9?.end ?? tok.end; } } else if (afterXhr && afterXhr.kind === "questionDot") { // `XMLHttpRequest?.(...)` — optional call @@ -1534,6 +1543,27 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult } } if (hasTypeAnnotation9) continue; + // Exclude TS type-literal method signatures with empty parens: + // `type X = { XMLHttpRequest() }` / `{ XMLHttpRequest(); }` — empty parens, no return type. + { + let closeBrace9 = afterClose9; + if (closeBrace9 && closeBrace9.kind === "punct" && + (closeBrace9.text === ";" || closeBrace9.text === ",")) { + const nextAfterSepIdx9 = nextSignificant(tokens, afterCloseIdx9 + 1); + closeBrace9 = tokens[nextAfterSepIdx9]; + } + if (closeBrace9 && closeBrace9.kind === "close" && closeBrace9.text === "}" && + closeBrace9.matchedAt !== undefined) { + const openBraceIdx9 = closeBrace9.matchedAt; + const prevOpenIdx9 = prevSignificant(tokens, openBraceIdx9 - 1); + const prevOpen9 = tokens[prevOpenIdx9]; + const firstInsideIdx9 = nextSignificant(tokens, openBraceIdx9 + 1); + if (firstInsideIdx9 === i && prevOpen9 && ( + prevOpen9.kind === "eq" || + (prevOpen9.kind === "punct" && prevOpen9.text === ":") + )) continue; + } + } } } else { // Member access on the constructor itself — not a construction @@ -1545,15 +1575,17 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (isInsideRange(tok.start, unsafeRanges)) continue; const isOpt9 = afterXhr && afterXhr.kind === "questionDot"; - // Bare `new XMLHttpRequest` — no parens in the original source - const isNoParens9 = isNewExpr9 && afterXhr && afterXhr.kind !== "open" && !isOpt9 && afterXhr.kind !== "operator"; + // `new XMLHttpRequest` without parens: bare construction OR generic with no parens after `>`. + const isNoParens9 = isGenericNoParens9 || + (isNewExpr9 && afterXhr && afterXhr.kind !== "open" && !isOpt9 && afterXhr.kind !== "operator"); const detectedForm9 = isNewExpr9 ? (isNoParens9 ? "new XMLHttpRequest" : "new XMLHttpRequest()") : (isOpt9 ? "XMLHttpRequest?.()" : "XMLHttpRequest()"); const warnStart9 = isNewExpr9 ? prev9!.start : tok.start; - // end: cover through the opening `(` (matching SYN007/SYN008/SYN011 convention) + // end: cover through the opening `(` (matching SYN007/SYN008/SYN011 convention). // For bare `new XMLHttpRequest` (no parens), fall back to tok.end. - let warnEnd9 = tok.end; + // For `new XMLHttpRequest` (generic, no parens), end at the closing `>`. + let warnEnd9 = isGenericNoParens9 ? genericCloseTokEnd9 : tok.end; if (!isNoParens9) { if (isOpt9) { const afterQD9end = nextSignificant(tokens, afterXhrFirstIdx + 1); @@ -1563,7 +1595,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult } else if (afterXhr && afterXhr.kind === "open" && afterXhr.text === "(") { warnEnd9 = afterXhr.start + 1; } else if (afterXhr && afterXhr.kind === "operator" && afterXhr.text === "<") { - // generic form: find the `(` after the `>` + // generic form with parens: find the `(` after the `>` for warnEnd let anglDepth2 = 1; let j2 = afterXhrFirstIdx + 1; while (j2 < decl.tokenEnd && anglDepth2 > 0) { diff --git a/packages/compiler/tests/syn009-check.test.ts b/packages/compiler/tests/syn009-check.test.ts index 9d69c200..3fe0b195 100644 --- a/packages/compiler/tests/syn009-check.test.ts +++ b/packages/compiler/tests/syn009-check.test.ts @@ -270,4 +270,38 @@ describe("SYN009: XMLHttpRequest() call detection", () => { const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); }); + + it("does NOT fire on TS type-literal method signature with empty parens — type X = { XMLHttpRequest() }", () => { + const src = + "?bs 0.7\n" + + "fn test() -> void {\n" + + " type XhrLike = { XMLHttpRequest() };\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); + + it("does NOT fire on XMLHttpRequest < x > (y) comparison expression (false-positive guard)", () => { + const src = + "?bs 0.7\n" + + "fn compare(XMLHttpRequest: number, x: number, y: number) -> boolean {\n" + + " return XMLHttpRequest < x > (y)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); + + it("fires on new XMLHttpRequest without parens — generic no-parens construction", () => { + const src = + "?bs 0.7\n" + + "fn makeXhr() -> any {\n" + + " const x = new XMLHttpRequest\n" + + " return x\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(true); + const w = result.warnings.find((w) => w.code === "SYN009"); + expect(w?.message).toContain("new XMLHttpRequest"); + expect(w?.message).not.toContain("new XMLHttpRequest()"); + }); }); From 5f7aec611437312cad4f4fee230ea2abccd93186 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 15:41:51 -0300 Subject: [PATCH 18/21] =?UTF-8?q?fix(compiler):=20correct=20SYN009=20descr?= =?UTF-8?q?iption=20=E2=80=94=20XHR=20construction=20doesn't=20open=20a=20?= =?UTF-8?q?connection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new XMLHttpRequest() constructs an object; the connection opens on .open()/.send(). Co-Authored-By: Botkowski --- packages/compiler/src/error-codes.ts | 2 +- packages/compiler/src/passes/syn-check.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index d5a923ee..03687698 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -712,7 +712,7 @@ const E: Record = { title: "XMLHttpRequest construction bypasses the net capability model — use http.get() / http.post() instead", rule: "`new XMLHttpRequest()`, `XMLHttpRequest()`, `new XMLHttpRequest` (no-parens), and TypeScript instantiation forms like " + - "`new XMLHttpRequest()` open HTTP connections at runtime but are invisible to " + + "`new XMLHttpRequest()` construct an XHR object that can open HTTP connections at runtime but are invisible to " + "botscript's capability model: CAP001 checks for `http.*` member calls, not the " + "`XMLHttpRequest` global. A fn that constructs an XHR has an undeclared network " + "dependency — no `uses { net }` will reflect it in the fn header, " + diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 41256280..a2b6f72b 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -59,7 +59,7 @@ * * SYN009 A `new XMLHttpRequest()`, `XMLHttpRequest()`, `new XMLHttpRequest()`, * or no-parens `new XMLHttpRequest` was detected in a fn body (?bs 0.7+). - * XMLHttpRequest opens an HTTP connection invisible to CAP001 (which checks + * XMLHttpRequest constructs an XHR object that can open HTTP connections, invisible to CAP001 (which checks * `http.*` member calls). A fn that constructs an XHR has an undeclared `net` * dependency. Excluded: member calls (`obj.XMLHttpRequest`), object/class method * shorthands, and TypeScript method signatures. From a586ea829860b1fae149b405738e298df5cb8f50 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 19:43:48 -0300 Subject: [PATCH 19/21] =?UTF-8?q?fix(compiler):=20SYN009=20=E2=80=94=20bai?= =?UTF-8?q?l=20on=20unterminated=20generic;=20update=20header=20comment=20?= =?UTF-8?q?and=20rule=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes addressing latest Copilot review comments: 1. Unterminated generic bail-out: add `if (anglDepth > 0) continue` after the `` scan loop so malformed source like `new XMLHttpRequest<` (never finds closing `>`) does not produce a false-positive SYN009 warning. Matches the existing SYN008 WebSocket behavior. 2. Header comment: document the no-parens generic form `new XMLHttpRequest` alongside the no-parens bare form `new XMLHttpRequest`. 3. error-codes.ts rule text: include `new XMLHttpRequest` (no-parens generic) in the listed detected forms; reword "can open HTTP connections" to the accurate "can be used to make HTTP requests". Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/error-codes.ts | 2 +- packages/compiler/src/passes/syn-check.ts | 6 +++++- packages/compiler/tests/syn009-check.test.ts | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 03687698..c0700b37 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -712,7 +712,7 @@ const E: Record = { title: "XMLHttpRequest construction bypasses the net capability model — use http.get() / http.post() instead", rule: "`new XMLHttpRequest()`, `XMLHttpRequest()`, `new XMLHttpRequest` (no-parens), and TypeScript instantiation forms like " + - "`new XMLHttpRequest()` construct an XHR object that can open HTTP connections at runtime but are invisible to " + + "`new XMLHttpRequest()` and `new XMLHttpRequest` (no-parens generic) construct an XHR object that can be used to make HTTP requests but are invisible to " + "botscript's capability model: CAP001 checks for `http.*` member calls, not the " + "`XMLHttpRequest` global. A fn that constructs an XHR has an undeclared network " + "dependency — no `uses { net }` will reflect it in the fn header, " + diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index a2b6f72b..4c900c6a 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -58,7 +58,8 @@ * preceded by `new` (avoids false-positives on `WebSocket < x > (y)` comparisons). * * SYN009 A `new XMLHttpRequest()`, `XMLHttpRequest()`, `new XMLHttpRequest()`, - * or no-parens `new XMLHttpRequest` was detected in a fn body (?bs 0.7+). + * `new XMLHttpRequest` (no-parens generic), or bare `new XMLHttpRequest` + * was detected in a fn body (?bs 0.7+). * XMLHttpRequest constructs an XHR object that can open HTTP connections, invisible to CAP001 (which checks * `http.*` member calls). A fn that constructs an XHR has an undeclared `net` * dependency. Excluded: member calls (`obj.XMLHttpRequest`), object/class method @@ -1461,6 +1462,9 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult anglDepth = Math.max(0, anglDepth - at.text.length); j++; } + // If the angle-bracket scan never found the closing `>`, the source is malformed; + // bail out to avoid false-positives on unterminated generics (matches SYN008 behavior). + if (anglDepth > 0) continue; const closingAngleTok9 = tokens[j - 1]; // the `>` that closed the generic scan const afterAngleIdx = nextSignificant(tokens, j); const afterAngle9 = tokens[afterAngleIdx]; diff --git a/packages/compiler/tests/syn009-check.test.ts b/packages/compiler/tests/syn009-check.test.ts index 3fe0b195..dab262af 100644 --- a/packages/compiler/tests/syn009-check.test.ts +++ b/packages/compiler/tests/syn009-check.test.ts @@ -304,4 +304,18 @@ describe("SYN009: XMLHttpRequest() call detection", () => { expect(w?.message).toContain("new XMLHttpRequest"); expect(w?.message).not.toContain("new XMLHttpRequest()"); }); + + it("does NOT fire on malformed/unterminated generic new XMLHttpRequest< — bail out safely", () => { + // An unterminated `<...` scan should not produce a false-positive. + // The angle-bracket scan exits when it reaches tokenEnd with depth > 0; the check bails out. + const src = + "?bs 0.7\n" + + "fn bad() -> any {\n" + + " const x = 1\n" + + " return x\n" + + "}\n"; + // No unterminated generic in this minimal fn — just verify the check doesn't throw. + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); }); From 47c52159eb7523c3621cc12849f3b9fd5a032051 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 23:34:09 -0300 Subject: [PATCH 20/21] test(syn009): add optional-call test; fix unterminated-generic test source Copilot review feedback on PR #150: 1. Add test for XMLHttpRequest?.( optional-call form (was implemented in syn-check.ts but had no coverage). 2. Fix the unterminated-generic bail-out test: the source snippet had no XMLHttpRequest at all, so it wasn't exercising the anglDepth > 0 bail-out path. Updated to include `new XMLHttpRequest) so the test actually hits the intended code path. Co-Authored-By: Botkowski --- packages/compiler/tests/syn009-check.test.ts | 23 +++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/compiler/tests/syn009-check.test.ts b/packages/compiler/tests/syn009-check.test.ts index dab262af..f15fbb6d 100644 --- a/packages/compiler/tests/syn009-check.test.ts +++ b/packages/compiler/tests/syn009-check.test.ts @@ -305,16 +305,27 @@ describe("SYN009: XMLHttpRequest() call detection", () => { expect(w?.message).not.toContain("new XMLHttpRequest()"); }); + it("fires on XMLHttpRequest?.( optional-call form", () => { + const src = + "?bs 0.7\n" + + "fn sendRequest(url: string) -> void {\n" + + " const xhr = XMLHttpRequest?.()\n" + + " xhr?.open('GET', url)\n" + + " xhr?.send()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(true); + const w = result.warnings.find((w) => w.code === "SYN009"); + expect(w?.message).toContain("XMLHttpRequest?.()"); + }); + it("does NOT fire on malformed/unterminated generic new XMLHttpRequest< — bail out safely", () => { - // An unterminated `<...` scan should not produce a false-positive. - // The angle-bracket scan exits when it reaches tokenEnd with depth > 0; the check bails out. + // The angle-bracket scan should bail out (anglDepth > 0) without producing a false-positive. const src = "?bs 0.7\n" + - "fn bad() -> any {\n" + - " const x = 1\n" + - " return x\n" + + "fn bad() -> void {\n" + + " const x = new XMLHttpRequest w.code === "SYN009")).toBe(false); }); From 17dbf722d700562f27bae1736c84bfd187cd9e2b Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 18 Jun 2026 19:42:27 -0300 Subject: [PATCH 21/21] fix(syn009): exclude function* XMLHttpRequest() generator declarations SYN009 excluded `function XMLHttpRequest(...)` and `fn XMLHttpRequest(...)` but not the generator form `function* XMLHttpRequest(...)`. In a generator declaration, the prev significant token before the name is `*`, not `function`, so the existing check did not suppress the warning. Adds isFunctionStarDecl() call matching the same exclusion pattern used by SYN007, SYN008, SYN014-016. Adds regression test. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 3 ++- packages/compiler/tests/syn009-check.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 4c900c6a..da50125e 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1417,9 +1417,10 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (prev9 && ((prev9.kind === "punct" && prev9.text === ".") || prev9.kind === "questionDot")) continue; - // Exclude: `function XMLHttpRequest(...)` / `fn XMLHttpRequest(...)` declarations + // Exclude: `function XMLHttpRequest(...)` / `fn XMLHttpRequest(...)` / `function* XMLHttpRequest(...)` declarations if (prev9 && prev9.kind === "ident" && prev9.text === "function") continue; if (prev9 && prev9.kind === "keyword" && prev9.text === "fn") continue; + if (isFunctionStarDecl(tokens, prevIdx9)) continue; // `new XMLHttpRequest` without parens is valid JS/TS construction — fire on it too. const isNewExpr9 = prev9 && prev9.kind === "ident" && prev9.text === "new"; diff --git a/packages/compiler/tests/syn009-check.test.ts b/packages/compiler/tests/syn009-check.test.ts index f15fbb6d..ac3c4e43 100644 --- a/packages/compiler/tests/syn009-check.test.ts +++ b/packages/compiler/tests/syn009-check.test.ts @@ -271,6 +271,14 @@ describe("SYN009: XMLHttpRequest() call detection", () => { expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); }); + it("does NOT fire on function* XMLHttpRequest(...) generator declaration", () => { + const src = + "?bs 0.7\n" + + "function* XMLHttpRequest(url: string) { yield url }\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN009")).toBe(false); + }); + it("does NOT fire on TS type-literal method signature with empty parens — type X = { XMLHttpRequest() }", () => { const src = "?bs 0.7\n" +