Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope.
| SYN004 | (0.7+, warning) A fn body calls `eval(...)` / `eval?.(...)` (global eval not preceded by `.`/`?.`) or calls `Function(...)` / `Function?.(...)` / `new Function(...)` (Function constructor not preceded by `.`/`?.`). All forms execute strings as code at runtime — every static capability check (CAP001/CAP002), resource declaration (reads/writes), and safety check (SYN002/SYN003) can be bypassed by routing any unsafe pattern through eval or the Function constructor. Suppressed inside `unsafe {}` blocks and `unsafe fn` bodies. `.eval(...)` (method call on a local) and `Function.*` member accesses are excluded. | Refactor the eval-based pattern to use explicit code paths. If eval is genuinely required (e.g. sandboxed interpreter), wrap in `unsafe "<reason>" { eval(...) }`. |
| SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers and to static analysis; no capability or resource declaration covers it, so 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) }`. |
| 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) }`. |
| 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) }`. |
| 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. |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ claude mcp add botscript -- npx -y @mbfarias/botscript-mcp
| ----------- | -------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `primer` | (no args) | The canonical language primer (same text the `?primer` directive emits). |
| `transform` | `{ source: string, filename?: string }` | `{ ok: true, code, forms, version, warnings: [...] }` on success, or `{ ok: false, diagnostics: [...] }` on failure. `warnings` is an array of non-blocking diagnostics (e.g. CAP003). |
| `explain` | `{ code: string }` | Long-form explanation for any stable diagnostic code (`ALI001`, `ALI002`, `ALI003`, `BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`–`DEP004`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN004`, `SYN005`, `SYN006`, `SYN010`, `SYN011`, `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`, `SYN010`, `SYN011`, `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
32 changes: 32 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,38 @@ const E: Record<string, ErrorCodeEntry> = {
" return ok(undefined)\n" +
"}",
},
SYN007: {
code: "SYN007",
title: "fetch() call bypasses the net capability model",
rule:
"`fetch(url)` and `fetch?.(url)` make HTTP requests at runtime but are invisible to " +
"botscript's capability model: CAP001 checks for `http.*` member calls, not the `fetch` " +
"global. A fn that calls `fetch` has an undeclared network dependency — CAP001 cannot " +
"infer or require `uses { net }` from `fetch` calls, so callers and audit tooling cannot " +
"rely on CAP001 to detect a missing declaration.",
idiom:
"replace `fetch(url)` with `http.get(url)` (or `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) }`',
rewrite:
"// before — fetch is invisible to the capability model\n" +
"fn getUser(id: string) -> Promise<User> {\n" +
" return fetch(`/api/users/${id}`).then(r => r.json()) // SYN007\n" +
"}\n\n" +
"// after — declared network dependency\n" +
"fn getUser(id: string) uses { net } -> Promise<User> {\n" +
" return http.get(`/api/users/${id}`)\n" +
"}",
example:
"// SYN007: fetch bypasses the net capability model\n" +
"fn getUser(id: string) -> Promise<User> {\n" +
" return fetch(`/api/users/${id}`).then(r => r.json()) // SYN007\n" +
"}\n\n" +
"// fix: declare the dependency\n" +
"fn getUser(id: string) uses { net } -> Promise<User> {\n" +
" return http.get(`/api/users/${id}`)\n" +
"}",
},
SYN010: {
code: "SYN010",
title: "setTimeout / setInterval / queueMicrotask defers side effects outside the fn's capability surface",
Expand Down
78 changes: 78 additions & 0 deletions packages/compiler/src/passes/syn-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@
* path. The idiomatic fix is `return err(...)` so the caller can
* decide whether to terminate.
*
* SYN007 A `fetch(url)` or `fetch?.(url)` call was detected in a fn body (?bs 0.7+).
* `fetch` makes HTTP requests at runtime but is invisible to botscript's
* capability model: CAP001 checks for `http.*` member calls, not the `fetch`
* global. A fn that calls `fetch` has an undeclared network dependency.
* Excluded: member calls (`obj.fetch`), function/fn declarations named
* `fetch`, object/class method shorthands, and TypeScript method
* signatures (`{ fetch(url): T; }`). The `:` exclusion is guarded
* against ternary consequents (`cond ? fetch(url) : other`).
*
* 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
Expand Down Expand Up @@ -90,6 +99,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
const syn004 = getErrorCode("SYN004")!;
const syn005 = getErrorCode("SYN005")!;
const syn006 = getErrorCode("SYN006")!;
const syn007 = getErrorCode("SYN007")!;
const syn010 = getErrorCode("SYN010")!;
const syn011 = getErrorCode("SYN011")!;

Expand Down Expand Up @@ -510,6 +520,74 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
break;
}

// ── SYN007: fetch() call ─────────────────────────────────────────────
case "fetch": {
const prevIdx7 = prevSignificant(tokens, i - 1);
const prev7 = tokens[prevIdx7];

// Exclude: `obj.fetch(...)` — preceded by `.` or `?.`
if (prev7 && ((prev7.kind === "punct" && prev7.text === ".") || prev7.kind === "questionDot"))
continue;

// Exclude: function/fn declarations named fetch
if (prev7 && prev7.kind === "ident" && prev7.text === "function") continue;
if (prev7 && prev7.kind === "keyword" && prev7.text === "fn") continue;

// Must be followed by `(` or `?.(` — confirming this is a call.
const nextIdx7 = nextSignificant(tokens, i + 1);
const next7 = tokens[nextIdx7];

let callIdx7 = nextIdx7;
let isOpt7 = false;
if (next7 && next7.kind === "questionDot") {
isOpt7 = true;
callIdx7 = nextSignificant(tokens, nextIdx7 + 1);
}
const callTok7 = tokens[callIdx7];
if (!callTok7 || !(callTok7.kind === "open" && callTok7.text === "(")) continue;

// Exclude method shorthands and TS method signatures: { fetch(url) { } } / { fetch(url): T; }
// Guard the `:` check against ternary consequents: `cond ? fetch(url) : other`
// Also handles `cond ? await fetch(url) : other` — if prev is `await`, look one further back.
const prevBeforeAwait7 = (prev7 && prev7.kind === "ident" && prev7.text === "await")
? tokens[prevSignificant(tokens, prevIdx7 - 1)]
: undefined;
const isTernaryConsequent7 = (prev7 !== undefined && prev7 !== null && prev7.kind === "question") ||
(prevBeforeAwait7 !== undefined && prevBeforeAwait7 !== null && prevBeforeAwait7.kind === "question");
if (callTok7.matchedAt !== undefined) {
const afterCloseIdx7 = nextSignificant(tokens, callTok7.matchedAt + 1);
const afterClose7 = tokens[afterCloseIdx7];
if (afterClose7 && (
(afterClose7.kind === "open" && afterClose7.text === "{") ||
afterClose7.kind === "fatArrow" ||
(!isTernaryConsequent7 && afterClose7.kind === "punct" && afterClose7.text === ":")
)) continue;
}

if (isInsideRange(tok.start, unsafeRanges)) continue;

const callSep7 = isOpt7 ? "?." : "";
const loc7 = locationOf(src, tok.start);
warnings.push({
code: "SYN007",
severity: "warning",
file: null,
line: loc7.line,
column: loc7.column,
start: tok.start,
end: callTok7.start + 1,
message:
`fn '${decl.name}' calls fetch${callSep7}() — ` +
`fetch makes an HTTP request invisible to the capability model; ` +
`replace with http.get(url)/http.post(url, { body }) and add uses { net }, ` +
`or wrap in unsafe "calls fetch directly" { fetch(url) }`,
rule: syn007.rule,
idiom: syn007.idiom,
rewrite: syn007.rewrite,
});
break;
}

// ── SYN011: dynamic import() call ────────────────────────────────────
case "import": {
// Exclude: `obj.import(...)` — preceded by `.` or `?.`
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", "SYN004", "SYN005", "SYN006", "SYN010", "SYN011",
"SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN010", "SYN011",
"THR001", "THR002", "THR003", "THR004",
"UNS001", "UNS002", "UNS003", "UNS004", "UNS005",
"VER001", "VER002", "VER003",
Expand Down
172 changes: 172 additions & 0 deletions packages/compiler/tests/syn007-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Tests for SYN007: fetch() call detection (?bs 0.7+).
*
* SYN007 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("SYN007: fetch() call detection", () => {
it("fires on bare fetch(url) inside a fn body", () => {
const src =
"?bs 0.7\n" +
"fn getUser(id: string) -> any {\n" +
" return fetch(`/api/users/${id}`)\n" +
"}\n";
const result = compile(src);
expect(result.warnings.some((w) => w.code === "SYN007")).toBe(true);
});

it("fires on awaited fetch(url)", () => {
const src =
"?bs 0.7\n" +
"async fn getUser(url: string) -> any {\n" +
" const resp = await fetch(url)\n" +
" return resp\n" +
"}\n";
const result = compile(src);
expect(result.warnings.some((w) => w.code === "SYN007")).toBe(true);
});

it("fires on optional-call form fetch?.(url)", () => {
const src =
"?bs 0.7\n" +
"async fn getUser(url: string) -> any {\n" +
" return fetch?.(url)\n" +
"}\n";
const result = compile(src);
expect(result.warnings.some((w) => w.code === "SYN007")).toBe(true);
});

it("does NOT fire inside an unsafe block", () => {
const src =
"?bs 0.7\n" +
"async fn getUser(url: string) -> any {\n" +
' return unsafe "calls fetch directly" { fetch(url) }\n' +
"}\n";
const result = compile(src);
expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false);
});

it("does NOT fire inside an unsafe fn body", () => {
const src =
"?bs 0.7\n" +
'unsafe "calls fetch directly" async fn getUser(url: string) -> any {\n' +
" return fetch(url)\n" +
"}\n";
const result = compile(src);
expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false);
});

it("does NOT fire on member call obj.fetch(url)", () => {
const src =
"?bs 0.7\n" +
"fn getUser(client: any) -> any {\n" +
" return client.fetch('/api')\n" +
"}\n";
const result = compile(src);
expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false);
});

it("does NOT fire on bare fetch reference (not called)", () => {
const src =
"?bs 0.7\n" +
"fn getFetch() -> any {\n" +
" return fetch\n" +
"}\n";
const result = compile(src);
expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false);
});

it("does NOT fire below ?bs 0.7", () => {
const src =
"?bs 0.6\n" +
"fn getUser(url: string) -> any {\n" +
" return fetch(url)\n" +
"}\n";
const result = compile(src);
expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false);
});

it("severity is warning (non-blocking)", () => {
const src =
"?bs 0.7\n" +
"fn getUser(url: string) -> any {\n" +
" return fetch(url)\n" +
"}\n";
const result = compile(src);
const w = result.warnings.find((w) => w.code === "SYN007");
expect(w?.severity).toBe("warning");
});

it("does NOT fire on object method shorthand named fetch", () => {
const src =
"?bs 0.7\n" +
"fn test() -> void {\n" +
" const client = { fetch(url) { return url; } };\n" +
"}\n";
const result = compile(src);
expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false);
});

it("does NOT fire on TypeScript method signature named fetch", () => {
const src =
"?bs 0.7\n" +
"fn test() -> void {\n" +
" type Client = { fetch(url: string): Promise<Response> };\n" +
"}\n";
const result = compile(src);
expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false);
});

it("does NOT fire on a botscript fn declaration named fetch", () => {
const src =
"?bs 0.7\n" +
"fn fetch(url: string) -> any {\n" +
" return url\n" +
"}\n";
const result = compile(src);
expect(result.warnings.some((w) => w.code === "SYN007")).toBe(false);
});

it("fires on fetch() inside a ternary expression (regression: `:` must not suppress)", () => {
// `cond ? fetch(a) : fetch(b)` — the `:` after fetch(a)'s closing `)` used to
// incorrectly match the TS method-signature exclusion, hiding SYN007.
const src =
"?bs 0.7\n" +
"fn pick(cond: boolean, a: string, b: string) -> any {\n" +
" return cond ? fetch(a) : fetch(b)\n" +
"}\n";
const result = compile(src);
expect(result.warnings.filter((w) => w.code === "SYN007").length).toBe(2);
});

it("fires on await fetch() inside a ternary expression (regression: await must not hide ternary context)", () => {
// `cond ? await fetch(a) : other` — prev token before fetch is `await`, not `?`.
// The ternary guard must look one token further back when prev is `await`.
const src =
"?bs 0.7\n" +
"async fn pick(cond: boolean, a: string, b: string) -> any {\n" +
" return cond ? await fetch(a) : await fetch(b)\n" +
"}\n";
const result = compile(src);
expect(result.warnings.filter((w) => w.code === "SYN007").length).toBe(2);
});

it("fires once per distinct fetch() call in the same fn", () => {
const src =
"?bs 0.7\n" +
"async fn loadBoth(a: string, b: string) -> any {\n" +
" const r1 = await fetch(a)\n" +
" const r2 = await fetch(b)\n" +
" return { r1, r2 }\n" +
"}\n";
const result = compile(src);
expect(result.warnings.filter((w) => w.code === "SYN007").length).toBe(2);
});
});
Loading
Loading