Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
929f420
feat(compiler): SYN009 — warn on XMLHttpRequest() calls that bypass t…
Jun 11, 2026
b8a52e7
fix(compiler): address Copilot review feedback on SYN009
Jun 11, 2026
8644fa7
fix(compiler): address remaining Copilot review on SYN009 — fix false…
Jun 11, 2026
4368d45
fix(syn009): handle new XMLHttpRequest<T> without parens, fix depth u…
Jun 11, 2026
87833a7
fix(compiler): address SYN009 Copilot review feedback
Jun 11, 2026
b3c8eb7
fix(syn009): clarify warning message — switch to http.get/post as pri…
Jun 12, 2026
6dfc8d8
fix(syn009): remove SYN007/SYN008 code references in explanation text
Jun 12, 2026
c5d705b
fix(compiler): SYN009 — exclude method shorthands and TS method signa…
Jun 12, 2026
cac5adb
fix(compiler): SYN009 — guard ternary `:` from suppressing XMLHttpReq…
Jun 12, 2026
8d149aa
refactor(compiler): SYN009 — fold XMLHttpRequest detection into singl…
Jun 13, 2026
84609bd
fix(compiler): SYN009 — exclude fn/function declarations, fix anchor …
Jun 15, 2026
880875e
fix(syn009): distinguish 'constructs new' vs 'calls' in diagnostic me…
Jun 15, 2026
800a1f2
fix(syn009): correct diagnostic message form for optional-call and ba…
Jun 15, 2026
c796e5a
fix(syn009): use async fn in ternary-await test fixtures
Jun 15, 2026
14c3332
fix(syn009): extend diagnostic end to opening paren, matching SYN007/…
Jun 17, 2026
b2465c3
Merge origin/main into syn009-xhr-bypass
Jun 17, 2026
1299315
fix(compiler): SYN009 exclude TS method signatures with omitted retur…
Jun 17, 2026
888566e
fix(compiler): SYN009 fix generic guard, no-parens form, empty-parens…
Jun 17, 2026
5f7aec6
fix(compiler): correct SYN009 description — XHR construction doesn't …
Jun 17, 2026
a586ea8
fix(compiler): SYN009 — bail on unterminated generic; update header c…
Jun 17, 2026
47c5215
test(syn009): add optional-call test; fix unterminated-generic test s…
Jun 18, 2026
17dbf72
fix(syn009): exclude function* XMLHttpRequest() generator declarations
Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(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 (`<T>`) 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 <reason>" { 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()`, `new XMLHttpRequest` (no-parens), or TypeScript instantiation forms `new XMLHttpRequest<T>()` / `new XMLHttpRequest<T>` (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 `(`, `?.(`, `<T>(`, 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) }`. |
| SYN012 | (0.7+, warning) A fn body constructs an `EventSource` via `new EventSource(url)`, bare `EventSource(url)`, `EventSource?.(url)`, or TypeScript instantiation form `new EventSource<T>(url)`. EventSource opens a persistent server-sent-events (SSE) connection at runtime but is invisible to CAP001 — the capability model only checks `http.*` member calls. Detection: `EventSource` not preceded by `.`/`?.`, followed by `(`, `?.(`, or `<T>(` (generic scan gated on `new`). Member calls, `function`/`fn` declarations, object method shorthands, and TS method signatures are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap in `unsafe "wraps EventSource for <reason>" { new EventSource(url) }` to make the escape hatch visible in the diff. |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `SYN012`, `SYN013`, `SYN014`, `SYN016`, `SYN018`, `SYN022`, `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`, `SYN012`, `SYN013`, `SYN014`, `SYN016`, `SYN018`, `SYN022`, `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.
Expand Down
47 changes: 47 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,53 @@ const E: Record<string, ErrorCodeEntry> = {
" ws.onmessage = (e) => handle(e.data)\n" +
"}",
},
SYN009: {
code: "SYN009",
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<T>()` and `new XMLHttpRequest<T>` (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, " +
"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<Result<string, string>> {\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<Result<string, string>> {\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<Result<string, string>> {\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",
Expand Down
Loading