feat(compiler): SYN009 — warn on XMLHttpRequest() calls that bypass the net capability model (?bs 0.7+)#150
Open
marcelofarias wants to merge 22 commits into
Open
feat(compiler): SYN009 — warn on XMLHttpRequest() calls that bypass the net capability model (?bs 0.7+)#150marcelofarias wants to merge 22 commits into
marcelofarias wants to merge 22 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a new compiler warning SYN009 to detect XMLHttpRequest constructions/calls inside function bodies (at ?bs 0.7+) because they can perform network I/O while bypassing the declared net capability surface, and wires that diagnostic through the registry, MCP explain output, docs, and tests.
Changes:
- Adds SYN009 to the compiler’s diagnostic registry and the syntax-check pass (
syn-check) with unsafe suppression support. - Adds MCP long-form explanation text for SYN009 and registers it in MCP/server code lists.
- Adds a dedicated SYN009 compiler test suite and updates exhaustive diagnostic allowlists / documentation tables.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Adds SYN009 to the MCP explain tool’s documented stable-code list. |
| packages/mcp/tests/server.test.ts | Adds SYN009 to the MCP server’s known codes list for explanations. |
| packages/mcp/src/explanations.ts | Adds long-form explain content and examples for SYN009. |
| packages/compiler/tests/syn009-check.test.ts | Introduces targeted SYN009 detection/suppression tests. |
| packages/compiler/tests/error-codes.test.ts | Adds SYN009 to the compiler’s exhaustive error-code allowlist. |
| packages/compiler/src/passes/syn-check.ts | Implements SYN009 token-based detection and unsafe suppression. |
| packages/compiler/src/error-codes.ts | Adds the SYN009 registry entry (rule/idiom/rewrite/example). |
| AGENTS.md | Documents SYN009 in the diagnostic table. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
8 tasks
marcelofarias
pushed a commit
that referenced
this pull request
Jun 12, 2026
…k calls in fn bodies (?bs 0.7+)
- Adds **SYN010**: a new warning that fires when a fn body calls `setTimeout(...)`,
`setInterval(...)`, or `queueMicrotask(...)` at `?bs 0.7+`
- 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
- Same bypass class as `fetch` (SYN007) and `WebSocket` (SYN008): real side effects
that sidestep the declared capability surface, but deferred rather than immediate
- Suppressed inside `unsafe "reason" { }` blocks and `unsafe "reason" fn` bodies
- Member calls (`obj.setTimeout(...)`) and bare references (without `(`) are excluded
- Note: should be merged after PR #150 (SYN009) lands; SYN010 is the next available code
Changes:
- error-codes.ts: add SYN010 entry with rule/idiom/rewrite/example
- syn-check.ts: add SYN010 detection; add SYN006 + SYN010 to module header comment;
fix outdated unsafe-fn skip comment
- tests/syn010-check.test.ts: 12 tests covering all three globals, optional-call form,
severity, version gating, unsafe suppression, member-call exclusion, bare reference
- tests/error-codes.test.ts: add SYN010 to exhaustive allowlist
- mcp/src/explanations.ts: add SYN010 long-form explanation with fails/passes examples
- mcp/tests/server.test.ts: add SYN010 to KNOWN_CODES list
- AGENTS.md: add SYN010 row to diagnostic table
- README.md: add SYN010 to explain tool code list
marcelofarias
added a commit
that referenced
this pull request
Jun 12, 2026
…k calls in fn bodies (?bs 0.7+)
* feat(compiler): SYN010 — warn on setTimeout/setInterval/queueMicrotask calls in fn bodies (?bs 0.7+)
- Adds **SYN010**: a new warning that fires when a fn body calls `setTimeout(...)`,
`setInterval(...)`, or `queueMicrotask(...)` at `?bs 0.7+`
- 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
- Same bypass class as `fetch` (SYN007) and `WebSocket` (SYN008): real side effects
that sidestep the declared capability surface, but deferred rather than immediate
- Suppressed inside `unsafe "reason" { }` blocks and `unsafe "reason" fn` bodies
- Member calls (`obj.setTimeout(...)`) and bare references (without `(`) are excluded
- Note: should be merged after PR #150 (SYN009) lands; SYN010 is the next available code
Changes:
- error-codes.ts: add SYN010 entry with rule/idiom/rewrite/example
- syn-check.ts: add SYN010 detection; add SYN006 + SYN010 to module header comment;
fix outdated unsafe-fn skip comment
- tests/syn010-check.test.ts: 12 tests covering all three globals, optional-call form,
severity, version gating, unsafe suppression, member-call exclusion, bare reference
- tests/error-codes.test.ts: add SYN010 to exhaustive allowlist
- mcp/src/explanations.ts: add SYN010 long-form explanation with fails/passes examples
- mcp/tests/server.test.ts: add SYN010 to KNOWN_CODES list
- AGENTS.md: add SYN010 row to diagnostic table
- README.md: add SYN010 to explain tool code list
* fix(syn010): address Copilot review — hoist TIMER_GLOBALS, fix param name and reason string
- Hoist TIMER_GLOBALS to module scope (was re-created per fn, wasteful in hot pass)
- Rename `fn` parameter to `callback` in unsafe-fn test (fn is a reserved keyword)
- Fix unsafe reason string: "suspends execution" → "schedules deferred effect" (consistent with idiom)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(syn010): address remaining Copilot feedback — false positive on declarations and explanation codes
- syn-check: exclude function declarations named setTimeout/setInterval/queueMicrotask
(prev significant token is `function`) — these are definitions, not calls
- syn-check: exclude object/class method shorthands where after closing `)` is `{` or `:`
- tests: add two tests covering function-declaration and object-method-shorthand exclusions
- explanations: remove SYN007/SYN008 code references in SYN010 body; reference by name instead
to avoid dangling code cross-references until those PRs land
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(compiler): address SYN010 Copilot review feedback — exclude fn keyword declarations
- syn-check.ts: also skip `fn setTimeout(...) -> void {}` botscript-style nested
function declarations (prev token is `keyword`/`fn`), not just JS `function`
declarations — prevents false positives on nested fn declarations inside fn bodies
- syn010-check.test.ts: add test asserting SYN010 does NOT fire on
`fn setTimeout(delay: number) -> void { }` inside a fn body
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Marcelo Farias <mfarias@Marcelos-MacBook-Pro-4.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment on lines
+821
to
+823
| "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. " + |
Comment on lines
+827
to
+829
| "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" + |
Comment on lines
+479
to
+481
|
|
||
| // Suppression check: unsafe block or unsafe fn body | ||
| if (isInsideRange(tok9.start, unsafeRanges)) continue; |
Comment on lines
+1479
to
+1489
| } 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; | ||
| } |
Comment on lines
+176
to
+184
| 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); | ||
| }); |
…n 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 <noreply@anthropic.com>
Comment on lines
+1577
to
+1579
| const afterAngle9end = tokens[nextSignificant(tokens, j2)]; | ||
| if (afterAngle9end && afterAngle9end.kind === "open" && afterAngle9end.text === "(") | ||
| warnEnd9 = afterAngle9end.start + 1; |
Comment on lines
+1591
to
+1594
| 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, ` + | ||
| `or wrap in unsafe "wraps XHR directly" { ${detectedForm9} }`, |
… type-literal
Four Copilot findings addressed:
1. Generic comparison false-positive: gate the <T> scan on isNewExpr9 (same guard
SYN008 WebSocket uses). XMLHttpRequest < x > (y) no longer fires SYN009.
2. new XMLHttpRequest<T> 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 <noreply@anthropic.com>
| 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>()` open HTTP connections at runtime but are invisible to " + |
| * | ||
| * SYN009 A `new XMLHttpRequest()`, `XMLHttpRequest()`, `new XMLHttpRequest<T>()`, | ||
| * or no-parens `new XMLHttpRequest` was detected in a fn body (?bs 0.7+). | ||
| * XMLHttpRequest opens an HTTP connection invisible to CAP001 (which checks |
…open a connection new XMLHttpRequest() constructs an object; the connection opens on .open()/.send(). Co-Authored-By: Botkowski <noreply@anthropic.com>
Comment on lines
+60
to
+62
| * SYN009 A `new XMLHttpRequest()`, `XMLHttpRequest()`, `new XMLHttpRequest<T>()`, | ||
| * or no-parens `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 |
Comment on lines
+1454
to
+1466
| 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 closingAngleTok9 = tokens[j - 1]; // the `>` that closed the generic scan | ||
| const afterAngleIdx = nextSignificant(tokens, j); | ||
| const afterAngle9 = tokens[afterAngleIdx]; |
Comment on lines
+713
to
+715
| rule: | ||
| "`new XMLHttpRequest()`, `XMLHttpRequest()`, `new XMLHttpRequest` (no-parens), and TypeScript instantiation forms like " + | ||
| "`new XMLHttpRequest<T>()` construct an XHR object that can open HTTP connections at runtime but are invisible to " + |
…omment and rule text Three fixes addressing latest Copilot review comments: 1. Unterminated generic bail-out: add `if (anglDepth > 0) continue` after the `<T>` 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<T>` alongside the no-parens bare form `new XMLHttpRequest`. 3. error-codes.ts rule text: include `new XMLHttpRequest<T>` (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 <noreply@anthropic.com>
| const result = compile(src); | ||
| expect(result.warnings.some((w) => w.code === "SYN009")).toBe(true); | ||
| }); | ||
|
|
Comment on lines
+308
to
+320
| 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); | ||
| }); |
…ource 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<string` (missing closing >) so the test actually hits the intended code path. Co-Authored-By: Botkowski <noreply@botkowski.ai>
Comment on lines
+1627
to
+1630
| 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, ` + | ||
| `or wrap in unsafe "wraps XHR directly" { ${detectedForm9} }`, |
Comment on lines
+65
to
+66
| * dependency. Excluded: member calls (`obj.XMLHttpRequest`), object/class method | ||
| * shorthands, and TypeScript method signatures. |
Comment on lines
+1420
to
+1423
| // Exclude: `function XMLHttpRequest(...)` / `fn XMLHttpRequest(...)` declarations | ||
| if (prev9 && prev9.kind === "ident" && prev9.text === "function") continue; | ||
| if (prev9 && prev9.kind === "keyword" && prev9.text === "fn") continue; | ||
|
|
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 <noreply@anthropic.com>
| 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 " + |
| expect(w?.message).not.toContain("new XMLHttpRequest()"); | ||
| }); | ||
|
|
||
| it("fires on XMLHttpRequest?.( optional-call form", () => { |
| * `cond ? new WebSocket(url) : other`). Generic `<T>` detection only when | ||
| * preceded by `new` (avoids false-positives on `WebSocket < x > (y)` comparisons). | ||
| * | ||
| * SYN009 A `new XMLHttpRequest()`, `XMLHttpRequest()`, `new XMLHttpRequest<T>()`, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
XMLHttpRequestvianew XMLHttpRequest(), bareXMLHttpRequest(), or TypeScript instantiation formnew XMLHttpRequest<T>()at?bs 0.7+XMLHttpRequestopens an HTTP connection at runtime but is invisible to CAP001 (which only checkshttp.*member calls). A fn that constructs an XHR has an undeclarednetdependency: nouses { net }in the header, no signal to callers, no capability manifest entryfetch(SYN007) andWebSocket(SYN008): real network effects, invisible to the declared capability surfacefetch(SYN007), there is no stdlib XHR wrapper — the idiomatic fix isunsafe "reason" { new XMLHttpRequest() }or migrating tohttp.get()/http.post()unsafe "reason" { }blocks andunsafe "reason" fnbodiesChanges
packages/compiler/src/error-codes.tspackages/compiler/src/passes/syn-check.tsXMLHttpRequestnot preceded by./?., followed by(,?.(, or<T>((with>>/>>>depth scan); unsafe suppressionpackages/compiler/tests/syn009-check.test.tspackages/compiler/tests/error-codes.test.tspackages/mcp/src/explanations.tspackages/mcp/tests/server.test.tsAGENTS.mdREADME.mdexplaintool code listTest plan
pnpm -r buildpassesnpx vitest run— 11/11 SYN009 tests pass, 1160 total tests passnew XMLHttpRequest()XMLHttpRequest()(withoutnew)new XMLHttpRequest<Event>()new XMLHttpRequest<EventSource<Event>>()?bs 0.7unsafe {}orunsafe fnobj.XMLHttpRequest(...)(member call on a local)XMLHttpRequestreference (not called)XMLHttpRequest.prototype.open(property access, not call)explaintool returns long-form for SYN009🤖 Generated with Claude Code