Skip to content
Open
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope.
| SYN019 | (0.7+, warning) A fn body calls `crypto.getRandomValues(buf)` or `crypto.randomUUID()` (including optional-chain forms `crypto?.getRandomValues(buf)` and optional-call forms `crypto.getRandomValues?.(buf)`). These calls generate cryptographic randomness at runtime but are invisible to botscript's capability model: `uses { random }` covers `random.*` stdlib calls (`random.next()`, `random.int()`), not the `crypto` global. A fn that calls these methods has an undeclared randomness dependency — tests cannot control the output and callers cannot observe the dependency from the fn header. Detection: `crypto` ident not preceded by `.`/`?.`, followed by `.` or `?.`, followed by `getRandomValues` or `randomUUID`, followed by `(` or `?.(`. Member calls (`obj.crypto.getRandomValues(...)`), non-randomness members (e.g. `crypto.subtle.digest(...)`), bare references, and `fn`/`function` declarations named `crypto` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Use `random.next()` or `random.int(min, max)` from the `random` stdlib with `uses { random }` when general randomness is sufficient. If cryptographic randomness or UUIDs are genuinely required, wrap in `unsafe "uses crypto for <reason>" { crypto.getRandomValues(buf) }`. |
| SYN022 | (0.7+, warning) A fn body accesses `process.argv`, `process.cwd()`, `process.platform`, `process.arch`, `process.pid`, `process.ppid`, `process.version`, `process.versions`, `process.hrtime()`, `process.uptime()`, `process.memoryUsage()`, `process.cpuUsage()`, or `process.resourceUsage()`. These read ambient Node.js process state at runtime — OS identity, process tree, memory, CPU — invisible to botscript's capability model. Unlike `process.env` (SYN005) and `process.exit` (SYN006), which cover configuration and termination respectively, SYN022 targets the remaining ambient introspection surface. Detection: `process` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member in the ambient-state set, optionally followed by `(` or `?.(`. Bare `process.*` references without a trailing `(` still fire. `obj.process.*` member calls, `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. `process.env` and `process.exit` are handled by SYN005/SYN006 and excluded here. | Pass the required ambient value as an explicit fn parameter so the dependency is visible in the call signature and tests can inject a mock. If direct `process.*` access is required at a bootstrap entry point, wrap in `unsafe "reads process state for <reason>" { process.argv }`. |
| SYN023 | (0.7+, warning) A fn body accesses a high-concern `navigator.*` member: `geolocation`, `clipboard`, `mediaDevices`, `serviceWorker`, `permissions`, `onLine`, `userAgent`, `language`, `languages`, `platform`, `hardwareConcurrency`, `deviceMemory`, `connection`, or `wakeLock`. These expose ambient browser capability state — location, clipboard, media devices, background service workers, network connectivity, browser identity, and hardware specs — invisible to botscript's capability model. Detection: `navigator` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member in the high-concern set. `obj.navigator.*` member calls, `fn`/`function`/`function*` declarations named `navigator`, members not in the listed set, `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass the required value as an explicit fn parameter so callers can see the dependency and tests can inject a mock. If direct `navigator.*` access is required, wrap in `unsafe "accesses navigator.<member> for <reason>" { navigator.<member> }`. |
| SYN025 | (0.7+, warning) A fn body calls `requestAnimationFrame(cb)` or `requestAnimationFrame?.(cb)`. `requestAnimationFrame` schedules the callback to run before the next browser repaint — after the current fn returns. Any effects inside the callback are invisible to callers: no `uses {}`, `reads {}`, `writes {}`, or `throws {}` declaration covers them. Detection: `requestAnimationFrame` ident not preceded by `.`/`?.`, followed by `(` or `?.(`. `obj.requestAnimationFrame(cb)`, `fn`/`function`/`function*` declarations named `requestAnimationFrame`, and method shorthands are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Extract the deferred work into a separate fn the caller can schedule. If animation frame scheduling is required at this layer, wrap in `unsafe "schedules animation frame callback" { requestAnimationFrame(cb) }`. |
| SYN026 | (0.7+, warning) A fn body calls `requestIdleCallback(cb)` or `requestIdleCallback?.(cb)`. `requestIdleCallback` schedules the callback to run during a browser idle period — after the current fn returns. Any effects inside the callback are invisible to callers. Callback timing is non-deterministic (fires when the browser decides it is idle). Detection: same pattern as SYN025. `obj.requestIdleCallback(cb)`, `fn`/`function`/`function*` declarations, and method shorthands are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Extract the deferred work into a separate fn the caller can schedule. If idle-period scheduling is required, wrap in `unsafe "schedules idle callback" { requestIdleCallback(cb) }`. |
| 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`–`DEP004`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN004`, `SYN005`, `SYN006`, `SYN007`, `SYN008`, `SYN010`, `SYN011`, `SYN012`, `SYN013`, `SYN014`, `SYN016`, `SYN017`, `SYN018`, `SYN019`, `SYN022`, `SYN023`, `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`, `SYN010`, `SYN011`, `SYN012`, `SYN013`, `SYN014`, `SYN016`, `SYN017`, `SYN018`, `SYN019`, `SYN022`, `SYN023`, `SYN025`, `SYN026`, `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
1 change: 1 addition & 0 deletions examples/react-app/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

import { App } from "./App.bs";
import "./scheduling.bs"; // SYN025/026 example: ensure the file is compiled

const root = document.getElementById("root");
if (!root) throw new Error("missing #root");
Expand Down
34 changes: 34 additions & 0 deletions examples/react-app/src/scheduling.bs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
?bs 0.7

// SYN025/SYN026 — scheduling bypass examples.
// requestAnimationFrame and requestIdleCallback schedule callbacks outside
// the capability surface, making effects invisible to callers.

// ── SYN025 fires here ─────────────────────────────────────────────────────
fn scheduleRender(frame: number) uses { net } -> void {
requestAnimationFrame(() => http.get("/render/" + frame)) // SYN025
}

// ── Fix: extract the work; caller decides when to schedule it ──────────────
fn render(frame: number) uses { net } -> void {
http.get("/render/" + frame)
}

// ── SYN026 fires here ─────────────────────────────────────────────────────
fn deferFlush(data: string) uses { net } -> void {
requestIdleCallback(() => http.post("/analytics", { body: data })) // SYN026
}

// ── Fix: extract the work; caller decides when to schedule it ──────────────
fn flush(data: string) uses { net } -> void {
http.post("/analytics", { body: data })
}

// ── Unsafe escape hatch when direct scheduling is required ─────────────────
fn scheduleRenderDirect(cb: () -> void) -> void {
unsafe "schedules animation frame for render pipeline" {
requestAnimationFrame(cb)
}
}

export { scheduleRender, render, deferFlush, flush, scheduleRenderDirect };
58 changes: 58 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,64 @@ const E: Record<string, ErrorCodeEntry> = {
" return userAgent\n" +
"}",
},
SYN025: {
code: "SYN025",
title: "requestAnimationFrame schedules a callback outside the fn's capability surface",
rule:
"`requestAnimationFrame(cb)` schedules `cb` to run before the next browser repaint — after the current fn has returned. " +
"Any effects inside `cb` are invisible to callers: no capability declaration, no `writes {}` label, no `throws {}` entry reflects them. " +
"The fn appears to return nothing; the real work happens asynchronously in a future animation frame.",
idiom:
"extract the deferred work into a separate fn the caller can pass to requestAnimationFrame, or wrap in " +
"`unsafe \"schedules animation frame callback\" { requestAnimationFrame(callback) }` when direct use is required",
rewrite:
"// before — animation frame callback hides side effects from callers\n" +
"fn scheduleRender(frame: number) uses { net } -> void {\n" +
" requestAnimationFrame(() => http.get(\"/render/\" + frame)) // SYN025\n" +
"}\n\n" +
"// after — extract the side-effectful work; let the caller schedule it\n" +
"fn render(frame: number) uses { net } -> void {\n" +
" http.get(\"/render/\" + frame)\n" +
"}",
example:
"// SYN025: animation frame callback hides a network effect from callers\n" +
"fn scheduleRender(frame: number) uses { net } -> void {\n" +
" requestAnimationFrame(() => http.get(\"/render/\" + frame)) // SYN025\n" +
"}\n\n" +
"// fix: extract the work into a separate fn\n" +
"fn render(frame: number) uses { net } -> void {\n" +
" http.get(\"/render/\" + frame)\n" +
"}",
},
SYN026: {
code: "SYN026",
title: "requestIdleCallback schedules a callback outside the fn's capability surface",
rule:
"`requestIdleCallback(cb)` schedules `cb` to run during a browser idle period — after the current fn has returned. " +
"Any effects inside `cb` are invisible to callers: no capability declaration, no `writes {}` label, no `throws {}` entry reflects them. " +
"The fn appears to return nothing; the real work happens asynchronously when the browser is idle.",
idiom:
"extract the deferred work into a separately declared fn the caller passes to `requestIdleCallback`, or wrap in " +
"`unsafe \"schedules idle callback\" { requestIdleCallback(callback) }` when direct use is required",
rewrite:
"// before — idle callback hides side effects from callers\n" +
"fn deferFlush(data: string) uses { net } -> void {\n" +
" requestIdleCallback(() => http.post(\"/analytics\", { body: data })) // SYN026\n" +
"}\n\n" +
"// after — extract the work into a separate fn; caller decides when to schedule it\n" +
"fn flush(data: string) uses { net } -> void {\n" +
" http.post(\"/analytics\", { body: data })\n" +
"}",
example:
"// SYN026: idle callback hides a network effect from callers\n" +
"fn deferFlush(data: string) uses { net } -> void {\n" +
" requestIdleCallback(() => http.post(\"/analytics\", { body: data })) // SYN026\n" +
"}\n\n" +
"// fix: extract the work into a separate fn\n" +
"fn flush(data: string) uses { net } -> void {\n" +
" http.post(\"/analytics\", { body: data })\n" +
"}",
},
DEP001: {
code: "DEP001",
title: "fn transitively reads a resource category not declared in its header",
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 @@ -182,6 +182,22 @@
* named `navigator`, member accesses not in the high-concern list above.
* `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed.
*
* SYN025 A `requestAnimationFrame(cb)` call was detected in a fn body (?bs 0.7+).
* `requestAnimationFrame` schedules `cb` to run before the next browser repaint —
* after the current fn has returned. Any effects inside the callback are invisible to
* callers: no capability declaration, no `writes {}` label, no `throws {}` entry covers them.
* Excluded: member calls (`obj.requestAnimationFrame`), `fn`/`function`/`function*`
* declarations named `requestAnimationFrame`, and object/class method shorthands.
* `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed.
*
* SYN026 A `requestIdleCallback(cb)` call was detected in a fn body (?bs 0.7+).
* `requestIdleCallback` schedules `cb` to run during a browser idle period —
* after the current fn has returned. Any effects inside the callback are invisible to
* callers: no capability declaration, no `writes {}` label, no `throws {}` entry covers them.
* Excluded: member calls (`obj.requestIdleCallback`), `fn`/`function`/`function*`
* declarations named `requestIdleCallback`, and object/class method shorthands.
* `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed.
*
* All checks share a single token scan per fn body. The outer loop runs once,
* skipping nested fn bodies once. Per-token dispatch is a switch on tok.text
* after a kind==="ident" guard.
Expand Down Expand Up @@ -255,6 +271,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
const syn019 = getErrorCode("SYN019")!;
const syn022 = getErrorCode("SYN022")!;
const syn023 = getErrorCode("SYN023")!;
const syn025 = getErrorCode("SYN025")!;
const syn026 = getErrorCode("SYN026")!;

// Collect char-offset ranges where all SYN checks are suppressed:
// 1. `unsafe "reason" { ... }` expression blocks — explicit acknowledgment.
Expand Down Expand Up @@ -1683,6 +1701,66 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
break;
}

// ── SYN025/SYN026: requestAnimationFrame / requestIdleCallback ──────────
case "requestAnimationFrame":
case "requestIdleCallback": {
const isRAF = tok.text === "requestAnimationFrame";
const synRaf = isRAF ? syn025 : syn026;

// Exclude property accesses: obj.requestAnimationFrame(...)
const prevIdxRaf = prevSignificant(tokens, i - 1);
const prevRaf = tokens[prevIdxRaf];
if (prevRaf && ((prevRaf.kind === "punct" && prevRaf.text === ".") || prevRaf.kind === "questionDot"))
continue;

// Exclude function/fn/function* declarations
if (prevRaf && prevRaf.kind === "ident" && prevRaf.text === "function") continue;
if (prevRaf && prevRaf.kind === "keyword" && prevRaf.text === "fn") continue;
if (isFunctionStarDecl(tokens, prevIdxRaf)) continue;

// Must be followed by `(` or `?.(`
let afterIdxRaf = nextSignificant(tokens, i + 1);
let afterTokRaf = tokens[afterIdxRaf];
if (afterTokRaf && afterTokRaf.kind === "questionDot") {
afterIdxRaf = nextSignificant(tokens, afterIdxRaf + 1);
afterTokRaf = tokens[afterIdxRaf];
}
if (!afterTokRaf || !(afterTokRaf.kind === "open" && afterTokRaf.text === "(")) continue;

// Exclude method shorthands and class methods
const closeParenIdxRaf = afterTokRaf.matchedAt;
if (closeParenIdxRaf !== undefined) {
const afterParenRaf = tokens[nextSignificant(tokens, closeParenIdxRaf + 1)];
if (
afterParenRaf &&
((afterParenRaf.kind === "open" && afterParenRaf.text === "{") ||
(afterParenRaf.kind === "punct" && afterParenRaf.text === ":"))
) continue;
}

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

const locRaf = locationOf(src, tok.start);
warnings.push({
code: isRAF ? "SYN025" : "SYN026",
severity: "warning",
file: null,
line: locRaf.line,
column: locRaf.column,
start: tok.start,
end: tok.end,
message:
`fn '${decl.name}' calls ${tok.text}() — ` +
`${tok.text} schedules a callback that runs after the fn returns (${isRAF ? "before the next repaint" : "during a browser idle period"}); ` +
`any effects inside that callback are invisible to callers and cannot be declared in the fn header; ` +
`wrap in unsafe "${isRAF ? "schedules animation frame callback" : "schedules idle callback"}" { ${tok.text}(...) }`,
rule: synRaf.rule,
idiom: synRaf.idiom,
rewrite: synRaf.rewrite,
});
break;
}

// ── SYN010: setTimeout / setInterval / queueMicrotask ────────────────
default: {
if (!TIMER_GLOBALS.has(tok.text)) continue;
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", "SYN007", "SYN008", "SYN010", "SYN011", "SYN012", "SYN013", "SYN014", "SYN016", "SYN017", "SYN018", "SYN019", "SYN022", "SYN023",
"SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN010", "SYN011", "SYN012", "SYN013", "SYN014", "SYN016", "SYN017", "SYN018", "SYN019", "SYN022", "SYN023", "SYN025", "SYN026",
"THR001", "THR002", "THR003", "THR004",
"UNS001", "UNS002", "UNS003", "UNS004", "UNS005",
"VER001", "VER002", "VER003",
Expand Down
Loading
Loading