Skip to content
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope.
| SYN003 | (0.7+, warning) A fn body contains a `console.*` call (console.log, console.error, etc.). Direct console output bypasses the `stdout`/`stderr` capability model — the compiler cannot enforce or surface the output declaration for callers. | Replace `console.log(...)` with `stdout.write(...)` and add `uses { stdout }` to the fn header; replace `console.error(...)` with `stderr.write(...)` and add `uses { stderr }`. |
| SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers, there is no capability or resource declaration that covers it, and the fn has an undeclared dependency on deployment configuration. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `env`. `obj.process.env` (member access on a local), `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass config and secrets as explicit fn parameters so the dependency is visible in the call signature; if env access is required at the load site, wrap in `unsafe "reads deployment env" { }`. |
| SYN006 | (0.7+, warning) A fn body calls `process.exit()`, `process?.exit()`, or `process.exit?.()`. All forms terminate the entire host process — not just the fn, not just the bot. They produce no return value, bypass `Result` propagation, `throws {}`, `match`, and any caller recovery path. No capability declaration covers them. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `exit` then `(` or `?.(`. `obj.process.exit(...)`, `process.exit` without `(`, and `process.exitCode` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Return `err(...)` and let the caller decide whether to terminate. If `process.exit` is genuinely required at a bootstrap entry point, wrap in `unsafe "exits on invalid config" { process.exit(1) }`. |
| 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() }`. |
| 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. |
| INT003 | (0.7+) A fn declares `intent: "idempotent"` but also has `uses { random }` or `uses { time }`. Both capabilities produce different values on each call, making the function non-idempotent. Only `random` and `time` are flagged; other capabilities are not structurally flagged by this check (INT003 is a narrow heuristic, not a proof of idempotence). | Remove `random`/`time` from `uses {}`, or change the intent. |
| INT004 | (0.7+) A fn declares `intent: "idempotent"` but its body directly references `random` or `time` without declaring them. Under-declaration variant of INT003 — fires when INT003 does not. | Remove the non-idempotent call from the body, or declare the capability and remove the idempotent intent. |
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`, `DEP002`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN005`, `SYN006`, `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`, `DEP002`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN005`, `SYN006`, `SYN009`, `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 @@ -611,6 +611,53 @@ const E: Record<string, ErrorCodeEntry> = {
" return ok(undefined)\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>()` 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<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" +
"}",
},
DEP001: {
code: "DEP001",
title: "fn transitively reads a resource category not declared in its header",
Expand Down
106 changes: 104 additions & 2 deletions packages/compiler/src/passes/syn-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@
* depends on runtime deployment values that callers cannot see,
* audit, or mock in tests. The idiomatic fix is to pass config
* and secrets as explicit fn parameters.
*
* SYN006 A `process.exit()` call was detected in a fn body (?bs 0.7+).
* `process.exit()` terminates the entire host process — not just the
* fn, not just the bot. It produces no return value and bypasses
* Result propagation, throws {}, match, and any caller recovery
* path. The idiomatic fix is `return err(...)` so the caller can
* decide whether to terminate.
*
* SYN009 A `new XMLHttpRequest()`, `XMLHttpRequest()`, or `new XMLHttpRequest`
* (no-parens form) 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. Use `http.get()`/`http.post()` + `uses { net }` instead,
* or wrap in `unsafe "reason" { }`.
*/

import type { Diagnostic } from "../diagnostics.js";
Expand Down Expand Up @@ -55,8 +69,9 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
const syn003 = getErrorCode("SYN003")!;
const syn005 = getErrorCode("SYN005")!;
const syn006 = getErrorCode("SYN006")!;
const syn009 = getErrorCode("SYN009")!;

// Collect char-offset ranges where SYN002/SYN003/SYN005/SYN006 are suppressed:
// Collect char-offset ranges where SYN002/SYN003/SYN005/SYN006/SYN009 are suppressed:
// 1. `unsafe "reason" { ... }` expression blocks — explicit acknowledgment.
// 2. `unsafe "reason" fn` bodies — the entire body is exempt, including any
// non-unsafe nested fns declared inside it (matching uns-check's pattern).
Expand All @@ -70,7 +85,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
const nesting = computeNesting(program.fns.map((f) => f.decl));

for (const { decl } of program.fns) {
// An `unsafe "reason" fn` body is an explicit acknowledgment — skip SYN002/SYN003/SYN005.
// An `unsafe "reason" fn` body is an explicit acknowledgment — all SYN checks are skipped.
// The range-based suppression above also covers nested non-unsafe fns within it,
// so this early-continue is kept purely as an optimisation.
if (decl.unsafeReason !== undefined) continue;
Expand Down Expand Up @@ -396,6 +411,93 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
rewrite: syn006.rewrite,
});
}

// 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<T>()`.
// 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";

// Must be followed by `(`, `?.(`, `<T>(`, 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<T>(...)` or `new XMLHttpRequest<T>`
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<T>(...)` — 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<T>` 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 === "(")) {
// 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",
Comment thread
marcelofarias marked this conversation as resolved.
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,
});
}
}

return { code: src, warnings };
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/tests/error-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe("error-code registry", () => {
"INT001", "INT002", "INT003", "INT004", "INT005",
"MAT001", "MAT002", "MAT003", "MAT004",
"RES001", "RES002",
"SYN001", "SYN002", "SYN003", "SYN005", "SYN006",
"SYN001", "SYN002", "SYN003", "SYN005", "SYN006", "SYN009",
"THR001", "THR002", "THR003", "THR004",
"UNS001", "UNS002", "UNS003", "UNS004", "UNS005",
"VER001", "VER002", "VER003",
Expand Down
Loading