diff --git a/AGENTS.md b/AGENTS.md index c3e501ba..996f23be 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()`, `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) }`. | | 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(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 `(` (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 " { new EventSource(url) }` to make the escape hatch visible in the diff. | diff --git a/README.md b/README.md index c4740a04..9231acc6 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`, `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. diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 643c4b11..c0700b37 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 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()` 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, " + + "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 525dfc8b..da50125e 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -57,6 +57,14 @@ * `cond ? new WebSocket(url) : other`). Generic `` detection only when * preceded by `new` (avoids false-positives on `WebSocket < x > (y)` comparisons). * + * SYN009 A `new XMLHttpRequest()`, `XMLHttpRequest()`, `new XMLHttpRequest()`, + * `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 + * 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 @@ -194,6 +202,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")!; const syn012 = getErrorCode("SYN012")!; @@ -1399,6 +1408,233 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult }); break; } + + // ── 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; + + // 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"; + + // Ternary guard: `cond ? XMLHttpRequest(url) : other` / `cond ? new XMLHttpRequest(url) : other`. + // 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") || + (prevBeforeAwait9 !== undefined && prevBeforeAwait9 !== null && prevBeforeAwait9.kind === "question"); + + // 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`. + // 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) { + 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++; + } + // 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]; + 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; + // 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 { + // 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 + 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; + // 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; + // 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 + if (afterXhr && afterXhr.kind === "punct" && afterXhr.text === ".") continue; + // No parens — only fire if preceded by `new` (bare construction: `new XMLHttpRequest`) + if (!isNewExpr9) continue; + } + + if (isInsideRange(tok.start, unsafeRanges)) continue; + + const isOpt9 = afterXhr && afterXhr.kind === "questionDot"; + // `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). + // For bare `new XMLHttpRequest` (no parens), fall back to 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); + 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 with parens: find the `(` after the `>` for warnEnd + 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, + severity: "warning", + file: null, + line: loc9.line, + column: loc9.column, + start: warnStart9, + 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, ` + + `or wrap in unsafe "wraps XHR directly" { ${detectedForm9} }`, + rule: syn009.rule, + idiom: syn009.idiom, + rewrite: syn009.rewrite, + }); + break; + } } } } diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index 607ed593..70aed9da 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", "SYN012", "SYN013", "SYN014", "SYN016", "SYN018", "SYN022", + "SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN009", "SYN010", "SYN011", "SYN012", "SYN013", "SYN014", "SYN016", "SYN018", "SYN022", "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..ac3c4e43 --- /dev/null +++ b/packages/compiler/tests/syn009-check.test.ts @@ -0,0 +1,340 @@ +/** + * 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 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" + + "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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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. + 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); + }); + + 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" + + "async 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" + + "async 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); + }); + + 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" + + "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()"); + }); + + 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", () => { + // The angle-bracket scan should bail out (anglDepth > 0) without producing a false-positive. + const src = + "?bs 0.7\n" + + "fn bad() -> void {\n" + + " const x = new XMLHttpRequest w.code === "SYN009")).toBe(false); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 964643a2..04e7b609 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 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()`, `new XMLHttpRequest` (no-parens), or TypeScript instantiation forms like `new XMLHttpRequest()`. " + + "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 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 " + + "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 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: { + 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 ed60cab7..828719be 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", "SYN012",