Skip to content
Open
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
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
18 changes: 18 additions & 0 deletions examples/react-app/src/scheduling.bs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
?bs 0.7

// SYN025/SYN026 — safe scheduling pattern
// requestAnimationFrame and requestIdleCallback schedule callbacks outside
// the capability surface. The fix: extract the side-effectful work into
// a separately declared fn, then let the caller decide when to schedule it.

Comment on lines +3 to +7
// Safe: the work fn is declared separately, no implicit scheduling
fn applyFrame(el: any, opacity: number) -> void {
el.style.opacity = opacity
}

// Safe: cleanup work extracted and callable directly or via requestIdleCallback
fn clearStaleCache(cache: Map<string, any>) -> void {
cache.clear()
}

export { applyFrame, clearStaleCache };
Comment on lines +13 to +18
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(cb) }` 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(cb) }` when direct use is required",
rewrite:
"// before — idle callback hides side effects from callers\n" +
"fn deferCleanup() uses { fs } -> void {\n" +
" requestIdleCallback(() => fs.delete(\"/tmp/cache\")) // SYN026\n" +
"}\n\n" +
"// after — extract the work into a separate fn; caller decides when to schedule it\n" +
"fn cleanup() uses { fs } -> void {\n" +
" fs.delete(\"/tmp/cache\")\n" +
"}",
example:
"// SYN026: idle callback hides a filesystem effect from callers\n" +
"fn deferCleanup() uses { fs } -> void {\n" +
" requestIdleCallback(() => fs.delete(\"/tmp/cache\")) // SYN026\n" +
"}\n\n" +
"// fix: extract the work into a separate fn\n" +
"fn cleanup() uses { fs } -> void {\n" +
" fs.delete(\"/tmp/cache\")\n" +
"}",
Comment on lines +1127 to +1144
},
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}(cb) }`,
Comment on lines +1755 to +1756
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