feat(compiler): SYN025/SYN026 — warn on requestAnimationFrame/requestIdleCallback (scheduling bypass, closes #179)#181
Open
marcelofarias wants to merge 5 commits into
Open
Conversation
…IdleCallback (scheduling bypass, closes #179) 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 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds two new structural warnings to the compiler’s syn-check pass to flag browser scheduling APIs (requestAnimationFrame / requestIdleCallback) that defer work outside a function’s declared capability surface, and wires them through the diagnostic registry, MCP explain, tests, and docs.
Changes:
- Implement
SYN025/SYN026warnings inpackages/compiler/src/passes/syn-check.tsfor bare global calls in?bs 0.7+(suppressed inunsafecontexts, excludes member calls and declarations). - Register both diagnostics in
packages/compiler/src/error-codes.tsand add MCP long-form explanations inpackages/mcp/src/explanations.ts(+ tests updated). - Add compiler tests and a React example file documenting the recommended “extract work; caller schedules” pattern.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Adds SYN025/SYN026 to the documented MCP explain coverage list. |
| packages/mcp/tests/server.test.ts | Extends the KNOWN_CODES allowlist to include SYN025/SYN026. |
| packages/mcp/src/explanations.ts | Adds long-form explain entries and fails/passes examples for SYN025/SYN026. |
| packages/compiler/tests/syn025-026-check.test.ts | New tests covering triggering + non-triggering scenarios for both warnings. |
| packages/compiler/tests/error-codes.test.ts | Updates the compiler diagnostic allowlist to include SYN025/SYN026. |
| packages/compiler/src/passes/syn-check.ts | Implements the new token-scan detection + warning emission for both globals. |
| packages/compiler/src/error-codes.ts | Adds registry entries (rule/idiom/rewrite/example) for SYN025/SYN026. |
| examples/react-app/src/scheduling.bs | Adds an example file describing the safe scheduling pattern. |
| AGENTS.md | Documents SYN025/SYN026 in the diagnostic code table. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+28
to
+33
| it("fires SYN025 on requestAnimationFrame?.(cb)", () => { | ||
| const src = | ||
| "?bs 0.7\n" + | ||
| "fn scheduleRender(frame: number) -> void {\n" + | ||
| " requestAnimationFrame?.()\n" + | ||
| "}\n"; |
…g test - 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.
Comment on lines
+62
to
+70
| 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); | ||
| }); |
Comment on lines
+176
to
+184
| 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); | ||
| }); |
Comment on lines
+196
to
+202
| 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); | ||
| }); |
…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 <noreply@anthropic.com>
…and exclusion test for SYN025, bare-reference exclusion test for SYN026 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 <noreply@anthropic.com>
| "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 " + |
| "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" + |
Comment on lines
+3
to
+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. | ||
|
|
… wire scheduling.bs into example build - 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 <noreply@anthropic.com>
Comment on lines
+1127
to
+1144
| 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
+1446
to
+1469
| "// 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", |
Comment on lines
+1755
to
+1756
| `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
+13
to
+18
| // Safe: cleanup work extracted and callable directly or via requestIdleCallback | ||
| fn clearStaleCache(cache: Map<string, any>) -> void { | ||
| cache.clear() | ||
| } | ||
|
|
||
| export { applyFrame, clearStaleCache }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
SYN025to the compiler's syn-check pass: fires whenrequestAnimationFrame(cb)is called (bare, not preceded by./?.) inside a?bs 0.7+fn bodySYN026forrequestIdleCallback(cb)— same detection patternobj.requestAnimationFrame(...)),fn/function/function*declarations with those names, and object method shorthandsunsafe {}andunsafe "reason" fnbodiesexplain, tests, and docsRelation to existing checks
setTimeout/setInterval/queueMicrotaskrequestAnimationFrame(browser-only, pre-repaint)requestIdleCallback(browser-only, idle period)Test plan
requestAnimationFrame(cb),requestAnimationFrame?.(cb)at?bs 0.7+requestIdleCallback(cb),requestIdleCallback?.(cb)at?bs 0.7+?bs 0.7, insideunsafe, member calls, fn declarations, generator declarationsexamples/react-app/src/scheduling.bs🤖 Generated with Claude Code