From 19297c87f633c56da6044613d8b55dde3b8c7833 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 18 Jun 2026 19:53:03 -0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(compiler):=20SYN025/SYN026=20=E2=80=94?= =?UTF-8?q?=20warn=20on=20requestAnimationFrame/requestIdleCallback=20(sch?= =?UTF-8?q?eduling=20bypass,=20closes=20#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestAnimationFrame and requestIdleCallback schedule callbacks to run after the current fn returns — the same invisible-capability problem as setTimeout/setInterval/queueMicrotask (SYN010). Any effects inside those callbacks are invisible to callers: no uses {}, reads {}, writes {}, or throws {} declaration reflects them. Detection mirrors SYN010: fires on bare calls not preceded by ./?., excludes fn/function/function* declarations, method shorthands, and unsafe {} blocks. 17 tests across two descriptors. Wired through error-codes registry, MCP explain, AGENTS.md, README.md, and a react-app scheduling.bs example showing the safe alternative pattern. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 2 + README.md | 2 +- examples/react-app/src/scheduling.bs | 18 ++ packages/compiler/src/error-codes.ts | 58 +++++ packages/compiler/src/passes/syn-check.ts | 79 ++++++ packages/compiler/tests/error-codes.test.ts | 2 +- .../compiler/tests/syn025-026-check.test.ts | 231 ++++++++++++++++++ packages/mcp/src/explanations.ts | 83 +++++++ packages/mcp/tests/server.test.ts | 2 + 9 files changed, 475 insertions(+), 2 deletions(-) create mode 100644 examples/react-app/src/scheduling.bs create mode 100644 packages/compiler/tests/syn025-026-check.test.ts diff --git a/AGENTS.md b/AGENTS.md index 01484c34..4fa0c851 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 " { 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 " { 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. for " { navigator. }`. | +| 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. | diff --git a/README.md b/README.md index 9a05c460..2d83e4d1 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`–`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. diff --git a/examples/react-app/src/scheduling.bs b/examples/react-app/src/scheduling.bs new file mode 100644 index 00000000..edc0ddbf --- /dev/null +++ b/examples/react-app/src/scheduling.bs @@ -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. + +// 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) -> void { + cache.clear() +} + +export { applyFrame, clearStaleCache }; diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 870026a4..89862adc 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -1085,6 +1085,64 @@ const E: Record = { " 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: + "pass the work as a return value the caller can schedule, 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 — return the work; 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" + + "}", + }, 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 d3d1ad24..b546ec78 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -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. @@ -218,6 +234,7 @@ const CONSOLE_OUTPUT_METHODS = new Set([ ]); const TIMER_GLOBALS = new Set(["setTimeout", "setInterval", "queueMicrotask"]); +const SCHEDULING_GLOBALS = new Set(["requestAnimationFrame", "requestIdleCallback"]); // process.* members covered by SYN022 (env → SYN005, exit → SYN006 are handled separately) const SYN022_PROCESS_MEMBERS = new Set([ "argv", "cwd", "platform", "arch", "pid", "ppid", @@ -255,6 +272,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. @@ -1683,6 +1702,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) }`, + rule: synRaf.rule, + idiom: synRaf.idiom, + rewrite: synRaf.rewrite, + }); + break; + } + // ── SYN010: setTimeout / setInterval / queueMicrotask ──────────────── default: { if (!TIMER_GLOBALS.has(tok.text)) continue; diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index 5d061660..22e719dc 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", "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", diff --git a/packages/compiler/tests/syn025-026-check.test.ts b/packages/compiler/tests/syn025-026-check.test.ts new file mode 100644 index 00000000..2dea55b7 --- /dev/null +++ b/packages/compiler/tests/syn025-026-check.test.ts @@ -0,0 +1,231 @@ +/** + * Tests for SYN025 (requestAnimationFrame) and SYN026 (requestIdleCallback) + * scheduling-bypass detection. + */ + +import { describe, expect, it } from "vitest"; +import { transform } from "../src/index.js"; + +function compile(src: string) { + return transform(src); +} + +// --------------------------------------------------------------------------- +// SYN025 — requestAnimationFrame +// --------------------------------------------------------------------------- + +describe("SYN025 — requestAnimationFrame scheduling bypass (?bs 0.7+)", () => { + it("fires SYN025 on requestAnimationFrame(cb)", () => { + const src = + "?bs 0.7\n" + + "fn scheduleRender(frame: number) -> void {\n" + + " requestAnimationFrame(() => frame + 1)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN025")).toBe(true); + }); + + it("fires SYN025 on requestAnimationFrame?.(cb)", () => { + const src = + "?bs 0.7\n" + + "fn scheduleRender(frame: number) -> void {\n" + + " requestAnimationFrame?.()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN025")).toBe(true); + }); + + it("SYN025 warning mentions requestAnimationFrame and fn name", () => { + const src = + "?bs 0.7\n" + + "fn myFn() -> void {\n" + + " requestAnimationFrame(cb)\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN025"); + expect(w).toBeDefined(); + expect(w!.message).toContain("requestAnimationFrame"); + expect(w!.message).toContain("myFn"); + expect(w!.severity).toBe("warning"); + }); + + it("does NOT fire SYN025 below ?bs 0.7", () => { + const src = + "?bs 0.1\n" + + "fn f() {\n" + + " requestAnimationFrame(cb)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN025")).toBe(false); + }); + + it("does NOT fire SYN025 inside unsafe {} block", () => { + const src = + "?bs 0.7\n" + + "fn scheduleRender() -> void {\n" + + " unsafe \"schedules animation frame\" { requestAnimationFrame(cb) }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN025")).toBe(false); + }); + + it("does NOT fire SYN025 on obj.requestAnimationFrame(cb)", () => { + const src = + "?bs 0.7\n" + + "fn f(obj: any) -> void {\n" + + " obj.requestAnimationFrame(cb)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN025")).toBe(false); + }); + + it("does NOT fire SYN025 on bare requestAnimationFrame reference (no call)", () => { + const src = + "?bs 0.7\n" + + "fn f() -> any {\n" + + " return requestAnimationFrame\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN025")).toBe(false); + }); + + it("does NOT fire SYN025 on fn requestAnimationFrame() declaration", () => { + const src = + "?bs 0.7\n" + + "fn requestAnimationFrame(cb: any) -> number = 0\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN025")).toBe(false); + }); + + it("does NOT fire SYN025 on function requestAnimationFrame() declaration", () => { + const src = + "?bs 0.7\n" + + "function requestAnimationFrame(cb: any) { return 0 }\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN025")).toBe(false); + }); + + it("does NOT fire SYN025 on function* requestAnimationFrame() generator declaration", () => { + const src = + "?bs 0.7\n" + + "function* requestAnimationFrame(cb: any) { yield 0 }\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN025")).toBe(false); + }); + + it("SYN025 carries rule and rewrite from registry", () => { + const src = + "?bs 0.7\n" + + "fn f() -> void { requestAnimationFrame(cb) }\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN025"); + expect(w?.rule).toBeTruthy(); + expect(w?.rewrite).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// SYN026 — requestIdleCallback +// --------------------------------------------------------------------------- + +describe("SYN026 — requestIdleCallback scheduling bypass (?bs 0.7+)", () => { + it("fires SYN026 on requestIdleCallback(cb)", () => { + const src = + "?bs 0.7\n" + + "fn deferCleanup() -> void {\n" + + " requestIdleCallback(() => 0)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN026")).toBe(true); + }); + + it("fires SYN026 on requestIdleCallback?.(cb)", () => { + const src = + "?bs 0.7\n" + + "fn deferCleanup() -> void {\n" + + " requestIdleCallback?.(cb)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN026")).toBe(true); + }); + + it("SYN026 warning mentions requestIdleCallback and fn name", () => { + const src = + "?bs 0.7\n" + + "fn myFn() -> void {\n" + + " requestIdleCallback(cb)\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN026"); + expect(w).toBeDefined(); + expect(w!.message).toContain("requestIdleCallback"); + expect(w!.message).toContain("myFn"); + expect(w!.severity).toBe("warning"); + }); + + it("does NOT fire SYN026 below ?bs 0.7", () => { + const src = + "?bs 0.1\n" + + "fn f() {\n" + + " requestIdleCallback(cb)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN026")).toBe(false); + }); + + it("does NOT fire SYN026 inside unsafe {} block", () => { + const src = + "?bs 0.7\n" + + "fn deferWork() -> void {\n" + + " unsafe \"schedules idle callback\" { requestIdleCallback(cb) }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN026")).toBe(false); + }); + + it("does NOT fire SYN026 on obj.requestIdleCallback(cb)", () => { + const src = + "?bs 0.7\n" + + "fn f(obj: any) -> void {\n" + + " obj.requestIdleCallback(cb)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN026")).toBe(false); + }); + + it("does NOT fire SYN026 on function* requestIdleCallback() generator declaration", () => { + const src = + "?bs 0.7\n" + + "function* requestIdleCallback(cb: any) { yield 0 }\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN026")).toBe(false); + }); + + it("SYN026 carries rule and rewrite from registry", () => { + const src = + "?bs 0.7\n" + + "fn f() -> void { requestIdleCallback(cb) }\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN026"); + expect(w?.rule).toBeTruthy(); + expect(w?.rewrite).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// Both in same fn +// --------------------------------------------------------------------------- + +describe("SYN025 + SYN026 together", () => { + it("fires both SYN025 and SYN026 when both appear in the same fn", () => { + const src = + "?bs 0.7\n" + + "fn bothSchedulers() -> void {\n" + + " requestAnimationFrame(render)\n" + + " requestIdleCallback(cleanup)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN025")).toBe(true); + expect(result.warnings.some((w) => w.code === "SYN026")).toBe(true); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 53475fb5..34a47e1e 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1386,6 +1386,89 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN025: { + code: "SYN025", + title: "requestAnimationFrame schedules a callback outside the fn's capability surface", + body: + "SYN025 fires when a fn body calls `requestAnimationFrame(cb)` or `requestAnimationFrame?.(cb)` in `?bs 0.7+`.\n\n" + + "**Why it matters:** `requestAnimationFrame` schedules `cb` to run before the next browser repaint — after the current fn has returned. " + + "Any effects inside that callback (network requests, storage writes, console output) are invisible to callers: " + + "no `uses {}`, `reads {}`, `writes {}`, or `throws {}` declaration in the fn header covers them. " + + "A fn that appears to return `void` may actually schedule network requests or state mutations that fire later.\n\n" + + "**Detected forms:** `requestAnimationFrame(cb)`, `requestAnimationFrame?.(cb)`.\n\n" + + "**Not detected:** `obj.requestAnimationFrame(cb)` (member call on a local binding), " + + "`function requestAnimationFrame(...) {}` / `fn requestAnimationFrame(...)` declarations, " + + "and method shorthands inside object literals or classes.\n\n" + + "**Fix (preferred):** extract the deferred work into a separate fn the caller can schedule:\n\n" + + "```\n" + + "// SYN025 — before\n" + + "fn scheduleRender(frame: number) uses { net } -> void {\n" + + " requestAnimationFrame(() => http.get(\"/render/\" + frame)) // SYN025\n" + + "}\n\n" + + "// after — extract the work; caller controls scheduling\n" + + "fn render(frame: number) uses { net } -> void {\n" + + " http.get(\"/render/\" + frame)\n" + + "}\n" + + "```\n\n" + + "**Fix (escape hatch):** if animation frame scheduling is required at this layer:\n" + + "`unsafe \"schedules animation frame callback\" { requestAnimationFrame(cb) }`\n\n" + + "SYN025 fires at `?bs 0.7+` as a non-blocking warning. " + + "Calls inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", + example: { + fails: + "?bs 0.7\n" + + "fn scheduleRender(frame: number) uses { net } -> void {\n" + + " requestAnimationFrame(() => http.get(\"/render/\" + frame))\n" + + "}\n", + passes: + "?bs 0.7\n" + + "fn render(frame: number) uses { net } -> void {\n" + + " http.get(\"/render/\" + frame)\n" + + "}\n", + }, + }, + SYN026: { + code: "SYN026", + title: "requestIdleCallback schedules a callback outside the fn's capability surface", + body: + "SYN026 fires when a fn body calls `requestIdleCallback(cb)` or `requestIdleCallback?.(cb)` in `?bs 0.7+`.\n\n" + + "**Why it matters:** `requestIdleCallback` schedules `cb` to run during a browser idle period — after the current fn has returned. " + + "Any effects inside that callback (network requests, storage writes, console output) are invisible to callers: " + + "no `uses {}`, `reads {}`, `writes {}`, or `throws {}` declaration in the fn header covers them. " + + "The callback timing is non-deterministic (it fires when the browser decides it is idle), " + + "making the side effects even harder to reason about than `setTimeout`.\n\n" + + "**Detected forms:** `requestIdleCallback(cb)`, `requestIdleCallback?.(cb)`.\n\n" + + "**Not detected:** `obj.requestIdleCallback(cb)` (member call on a local binding), " + + "`function requestIdleCallback(...) {}` / `fn requestIdleCallback(...)` declarations, " + + "and method shorthands inside object literals or classes.\n\n" + + "**Fix (preferred):** extract the deferred work into a separate fn the caller can schedule:\n\n" + + "```\n" + + "// SYN026 — before\n" + + "fn deferCleanup() uses { fs } -> void {\n" + + " requestIdleCallback(() => fs.delete(\"/tmp/cache\")) // SYN026\n" + + "}\n\n" + + "// after — extract the work; caller controls scheduling\n" + + "fn cleanup() uses { fs } -> void {\n" + + " fs.delete(\"/tmp/cache\")\n" + + "}\n" + + "```\n\n" + + "**Fix (escape hatch):** if idle-period scheduling is required at this layer:\n" + + "`unsafe \"schedules idle callback\" { requestIdleCallback(cb) }`\n\n" + + "SYN026 fires at `?bs 0.7+` as a non-blocking warning. " + + "Calls inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", + example: { + fails: + "?bs 0.7\n" + + "fn deferCleanup() uses { fs } -> void {\n" + + " requestIdleCallback(() => fs.delete(\"/tmp/cache\"))\n" + + "}\n", + passes: + "?bs 0.7\n" + + "fn cleanup() uses { fs } -> void {\n" + + " fs.delete(\"/tmp/cache\")\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 eb13233a..7c8adcda 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -91,6 +91,8 @@ describe("botscript-mcp explanations", () => { "SYN019", "SYN022", "SYN023", + "SYN025", + "SYN026", "THR001", "THR002", "THR003", From a2ef8ae388a75d9c35ab4cfa4f744f0b72d0fa71 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 19 Jun 2026 03:31:35 -0300 Subject: [PATCH 2/5] fix(SYN025/SYN026): remove dead SCHEDULING_GLOBALS set; fix misleading test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `SCHEDULING_GLOBALS` const (declared but never referenced — the `case "requestAnimationFrame": case "requestIdleCallback":` dispatch does not use it, making it dead code that could confuse future readers). - Fix the `requestAnimationFrame?.(cb)` test: the source had no-arg form `requestAnimationFrame?.()` which doesn't match the test description; updated to use `requestAnimationFrame?.(cb)` with an actual callback arg and a typed parameter so the intent is clear. Addresses Copilot review suggestions on PR #181. --- packages/compiler/src/passes/syn-check.ts | 1 - packages/compiler/tests/syn025-026-check.test.ts | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index b546ec78..c07ce717 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -234,7 +234,6 @@ const CONSOLE_OUTPUT_METHODS = new Set([ ]); const TIMER_GLOBALS = new Set(["setTimeout", "setInterval", "queueMicrotask"]); -const SCHEDULING_GLOBALS = new Set(["requestAnimationFrame", "requestIdleCallback"]); // process.* members covered by SYN022 (env → SYN005, exit → SYN006 are handled separately) const SYN022_PROCESS_MEMBERS = new Set([ "argv", "cwd", "platform", "arch", "pid", "ppid", diff --git a/packages/compiler/tests/syn025-026-check.test.ts b/packages/compiler/tests/syn025-026-check.test.ts index 2dea55b7..179c6efb 100644 --- a/packages/compiler/tests/syn025-026-check.test.ts +++ b/packages/compiler/tests/syn025-026-check.test.ts @@ -25,11 +25,11 @@ describe("SYN025 — requestAnimationFrame scheduling bypass (?bs 0.7+)", () => expect(result.warnings.some((w) => w.code === "SYN025")).toBe(true); }); - it("fires SYN025 on requestAnimationFrame?.(cb)", () => { + it("fires SYN025 on requestAnimationFrame?.(cb) — optional-call form with callback", () => { const src = "?bs 0.7\n" + - "fn scheduleRender(frame: number) -> void {\n" + - " requestAnimationFrame?.()\n" + + "fn scheduleRender(cb: () -> void) -> void {\n" + + " requestAnimationFrame?.(cb)\n" + "}\n"; const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN025")).toBe(true); From b147b34a6a1e568a77c54ed72f344f19d439f853 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 19 Jun 2026 07:27:02 -0300 Subject: [PATCH 3/5] test(SYN025/SYN026): add missing unsafe-fn suppression and exclusion regression tests Copilot re-review flagged three gaps in coverage: - unsafe "reason" fn suppression path not tested for either SYN025 or SYN026 - SYN026 missing: fn declaration, function declaration, and method-shorthand exclusion tests Brings SYN025/026 test coverage in line with SYN010 (the precedent for this family). Co-Authored-By: Claude Sonnet 4.6 --- .../compiler/tests/syn025-026-check.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/compiler/tests/syn025-026-check.test.ts b/packages/compiler/tests/syn025-026-check.test.ts index 179c6efb..a2cc206a 100644 --- a/packages/compiler/tests/syn025-026-check.test.ts +++ b/packages/compiler/tests/syn025-026-check.test.ts @@ -69,6 +69,16 @@ describe("SYN025 — requestAnimationFrame scheduling bypass (?bs 0.7+)", () => expect(result.warnings.some((w) => w.code === "SYN025")).toBe(false); }); + it("does NOT fire SYN025 inside an unsafe fn body", () => { + const src = + "?bs 0.7\n" + + "unsafe \"schedules animation frame\" fn scheduleRender(cb: () -> void) -> void {\n" + + " requestAnimationFrame(cb)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN025")).toBe(false); + }); + it("does NOT fire SYN025 on obj.requestAnimationFrame(cb)", () => { const src = "?bs 0.7\n" + @@ -183,6 +193,16 @@ describe("SYN026 — requestIdleCallback scheduling bypass (?bs 0.7+)", () => { expect(result.warnings.some((w) => w.code === "SYN026")).toBe(false); }); + it("does NOT fire SYN026 inside an unsafe fn body", () => { + const src = + "?bs 0.7\n" + + "unsafe \"schedules idle callback\" fn deferWork(cb: () -> void) -> void {\n" + + " requestIdleCallback(cb)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN026")).toBe(false); + }); + it("does NOT fire SYN026 on obj.requestIdleCallback(cb)", () => { const src = "?bs 0.7\n" + @@ -193,6 +213,22 @@ describe("SYN026 — requestIdleCallback scheduling bypass (?bs 0.7+)", () => { expect(result.warnings.some((w) => w.code === "SYN026")).toBe(false); }); + it("does NOT fire SYN026 on fn requestIdleCallback() declaration", () => { + const src = + "?bs 0.7\n" + + "fn requestIdleCallback(cb: any) -> number = 0\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN026")).toBe(false); + }); + + it("does NOT fire SYN026 on function requestIdleCallback() declaration", () => { + const src = + "?bs 0.7\n" + + "function requestIdleCallback(cb: any) { return 0 }\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN026")).toBe(false); + }); + it("does NOT fire SYN026 on function* requestIdleCallback() generator declaration", () => { const src = "?bs 0.7\n" + @@ -201,6 +237,16 @@ describe("SYN026 — requestIdleCallback scheduling bypass (?bs 0.7+)", () => { expect(result.warnings.some((w) => w.code === "SYN026")).toBe(false); }); + it("does NOT fire SYN026 on object method shorthand named requestIdleCallback", () => { + const src = + "?bs 0.7\n" + + "fn test(cb: () -> void) -> any {\n" + + " return { requestIdleCallback(cb: any) { cb() } }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN026")).toBe(false); + }); + it("SYN026 carries rule and rewrite from registry", () => { const src = "?bs 0.7\n" + From 99a17b46302103b503e6d03ae639446cdd34c96d Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 19 Jun 2026 11:35:24 -0300 Subject: [PATCH 4/5] =?UTF-8?q?test(compiler):=20address=20Copilot=20SYN02?= =?UTF-8?q?5/026=20review=20=E2=80=94=20add=20method-shorthand=20exclusion?= =?UTF-8?q?=20test=20for=20SYN025,=20bare-reference=20exclusion=20test=20f?= =?UTF-8?q?or=20SYN026?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot flagged two missing coverage paths: - SYN025 had no test for object method shorthands named requestAnimationFrame - SYN026 had no test for bare reference (no call) which should not fire Both SYN025 and SYN026 already had tests for unsafe fn body suppression and all declaration exclusions (fn/function/function*); the previous-version Copilot comments about missing those are resolved by existing code. Co-Authored-By: Claude Sonnet 4.6 --- .../compiler/tests/syn025-026-check.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/compiler/tests/syn025-026-check.test.ts b/packages/compiler/tests/syn025-026-check.test.ts index a2cc206a..f388f5eb 100644 --- a/packages/compiler/tests/syn025-026-check.test.ts +++ b/packages/compiler/tests/syn025-026-check.test.ts @@ -123,6 +123,16 @@ describe("SYN025 — requestAnimationFrame scheduling bypass (?bs 0.7+)", () => expect(result.warnings.some((w) => w.code === "SYN025")).toBe(false); }); + it("does NOT fire SYN025 on object method shorthand named requestAnimationFrame", () => { + const src = + "?bs 0.7\n" + + "fn test(cb: () -> void) -> any {\n" + + " return { requestAnimationFrame(cb: any) { cb() } }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN025")).toBe(false); + }); + it("SYN025 carries rule and rewrite from registry", () => { const src = "?bs 0.7\n" + @@ -213,6 +223,16 @@ describe("SYN026 — requestIdleCallback scheduling bypass (?bs 0.7+)", () => { expect(result.warnings.some((w) => w.code === "SYN026")).toBe(false); }); + it("does NOT fire SYN026 on bare requestIdleCallback reference (no call)", () => { + const src = + "?bs 0.7\n" + + "fn f() -> any {\n" + + " return requestIdleCallback\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN026")).toBe(false); + }); + it("does NOT fire SYN026 on fn requestIdleCallback() declaration", () => { const src = "?bs 0.7\n" + From c254c06cce7b6b86ee8d76ee3aaa03c530fbe8c7 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Fri, 19 Jun 2026 15:33:29 -0300 Subject: [PATCH 5/5] =?UTF-8?q?fix(compiler):=20address=20Copilot=20SYN025?= =?UTF-8?q?/026=20review=20=E2=80=94=20fix=20idiom=20wording,=20wire=20sch?= =?UTF-8?q?eduling.bs=20into=20example=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SYN025 idiom: "pass as return value" → "extract into a separate fn the caller passes to requestAnimationFrame" - SYN026 rewrite: "return the work" → "extract the work into a separate fn" - Import scheduling.bs from main.tsx so vite compiles the example end-to-end Co-Authored-By: Claude Sonnet 4.6 --- examples/react-app/src/main.tsx | 1 + packages/compiler/src/error-codes.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/react-app/src/main.tsx b/examples/react-app/src/main.tsx index 65d10309..2dbc499a 100644 --- a/examples/react-app/src/main.tsx +++ b/examples/react-app/src/main.tsx @@ -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"); diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 89862adc..9bdcac2c 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -1093,7 +1093,7 @@ const E: Record = { "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: - "pass the work as a return value the caller can schedule, or wrap in " + + "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" + @@ -1129,7 +1129,7 @@ const E: Record = { "fn deferCleanup() uses { fs } -> void {\n" + " requestIdleCallback(() => fs.delete(\"/tmp/cache\")) // SYN026\n" + "}\n\n" + - "// after — return the work; caller decides when to schedule it\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" + "}",