diff --git a/AGENTS.md b/AGENTS.md index 6bb47fdb..d2224cc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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()` / `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() }`. | | 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. | diff --git a/README.md b/README.md index 1bf8d55c..d8ad42e4 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`, `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. diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index aa8aa867..3feabd2d 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -611,6 +611,53 @@ const E: Record = { " 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()` 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" + + "}", + }, DEP001: { code: "DEP001", title: "fn transitively reads a resource category not declared in its header", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index eb1c94ed..7d9434ab 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -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"; @@ -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). @@ -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; @@ -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()`. + // 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 `(`, `?.(`, `(`, 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, proceed to fire + } 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 === "(")) { + // 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, + }); + } } return { code: src, warnings }; diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index 0eb75b0a..1f673910 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", "SYN005", "SYN006", + "SYN001", "SYN002", "SYN003", "SYN005", "SYN006", "SYN009", "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..8f071faf --- /dev/null +++ b/packages/compiler/tests/syn009-check.test.ts @@ -0,0 +1,165 @@ +/** + * 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); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index fe445593..921feb01 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -812,6 +812,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", + }, + }, DEP001: { code: "DEP001", title: "fn transitively reads a resource category not declared in its header", diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index 57e87d71..2b534f1f 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -75,6 +75,7 @@ describe("botscript-mcp explanations", () => { "SYN003", "SYN005", "SYN006", + "SYN009", "THR001", "THR002", "THR003",