feat(compiler): SYN015 — warn on localStorage/sessionStorage access that bypasses the storage capability model (?bs 0.7+)#162
Open
marcelofarias wants to merge 18 commits into
Open
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new compiler syntax warning SYN015 to flag localStorage.* / sessionStorage.* access inside fn bodies at ?bs 0.7+, since these Web Storage globals create undeclared state dependencies that are not represented by reads {} / writes {} declarations. The PR also wires SYN015 through the error-code registry, MCP explain support, docs, and a dedicated test suite.
Changes:
- Add SYN015 to the compiler SYN dispatch loop, emitting a warning on any member access off
localStorage/sessionStoragewhile respecting existing suppression/exclusion rules (unsafe ranges, member-of-local, fn decl exclusion). - Register SYN015 across the compiler registry and MCP explanation surfaces (error code entry + long-form explanation + known-code coverage).
- Add comprehensive tests for detection behavior and registry exhaustiveness.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Add SYN015 to the MCP explain tool’s documented code list. |
| AGENTS.md | Document SYN015 in the diagnostic table. |
| packages/mcp/tests/server.test.ts | Extend the expected known-code list to include SYN015. |
| packages/mcp/src/explanations.ts | Add long-form MCP explain entry for SYN015 (title/body/examples). |
| packages/compiler/tests/syn015-check.test.ts | New test suite covering SYN015 detection, exclusions, and message/severity expectations. |
| packages/compiler/tests/error-codes.test.ts | Add SYN015 to the compiler error-code allowlist. |
| packages/compiler/src/passes/syn-check.ts | Implement SYN015 detection and warning emission in the SYN scan pass. |
| packages/compiler/src/error-codes.ts | Add SYN015 registry entry (rule/idiom/rewrite/example). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+801
to
+804
| `fn '${decl.name}' accesses ${storageName15} — ` + | ||
| `${storageName15} is persistent same-origin storage invisible to the capability model; ` + | ||
| `no reads {} / writes {} label covers it; ` + | ||
| `pass a storage abstraction as a parameter or wrap in unsafe "accesses ${storageName15} for <reason>" { ${storageName15}.method(...) }`, |
Comment on lines
+1005
to
+1008
| "`localStorage.setItem(key, val)` writes persistent same-origin state at runtime but declares " + | ||
| "nothing about it in its header. Callers cannot see the dependency, and no audit tool can observe " + | ||
| "it from the fn signature. The persistent state also outlives the fn invocation: a side effect " + | ||
| "written in one call is visible in a completely unrelated future call, across any tab on the same origin.\n\n" + |
Comment on lines
+783
to
+788
| rule: | ||
| "`localStorage.*` and `sessionStorage.*` accesses are persistent same-origin storage operations " + | ||
| "invisible to botscript's capability model: `reads {}` / `writes {}` labels cover declared resource identifiers, " + | ||
| "not the Web Storage API globals. A fn that reads or writes `localStorage`/`sessionStorage` has " + | ||
| "undeclared persistent state dependencies — no `reads {}` / `writes {}` declaration in the fn header " + | ||
| "covers the access, and callers cannot observe or audit the dependency from the fn's declared surface.", |
| | SYN008 | (0.7+, warning) A fn body constructs or calls `WebSocket` via `new WebSocket(url)`, `WebSocket(url)`, `WebSocket?.(url)`, or TypeScript generic forms `new WebSocket<T>(url)`. `WebSocket` opens a persistent bidirectional connection at runtime but is invisible to CAP001, which only checks `http.*` member calls. A fn that constructs a WebSocket has an undeclared network dependency — no `uses {}` declaration covers it. Generic scan (`<T>`) is gated on `new` to avoid false-positives on comparison expressions like `WebSocket < x > (y)`. Member calls (`obj.WebSocket(...)`), object method shorthands, and fn declarations named `WebSocket` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap the construction in `unsafe "wraps WebSocket for <reason>" { new WebSocket(url) }` to make the network dependency visible in the diff and to callers reading the fn. | | ||
| | SYN010 | (0.7+, warning) A fn body calls `setTimeout(...)`, `setInterval(...)`, or `queueMicrotask(...)`. These globals schedule callbacks that run after the fn returns — any effects inside those callbacks are invisible to callers: no capability declaration, no `writes {}` label, and no `throws {}` entry can cover them. Detection: identifier not preceded by `.`/`?.`, followed by `(` or `?.(`. Member calls (`obj.setTimeout(...)`) and bare references (without `(`) are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Make the timing explicit: return a `Promise` the caller awaits, or return a teardown function so the caller controls the lifecycle. If a timer is genuinely required, wrap in `unsafe "schedules deferred effect" { setTimeout(...) }`. | | ||
| | SYN011 | (0.7+, warning) A fn body calls `import(specifier)` — the dynamic import form. Dynamic imports load a module at runtime whose capability surface is unbounded: CAP001 checks for stdlib namespace calls, not dynamic module loads. A fn that calls `import()` has an undeclared capability surface proportional to everything the dynamically loaded module might do at runtime. Detection: `import` token not preceded by `.`/`?.`, followed by `(` or `?.(`. `import.meta` (followed by `.`) is excluded. Object method shorthands and `fn import(...)` declarations are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | If the module is known at compile time, use a static top-level `import { ... } from` declaration instead. If dynamic loading is required, wrap in `unsafe "loads plugin dynamically" { import(specifier) }`. | | ||
| | SYN015 | (0.7+, warning) A fn body accesses `localStorage.*` or `sessionStorage.*` — any member access (`.`, `?.`) on either Web Storage global. `localStorage` and `sessionStorage` are persistent same-origin storage: reads and writes are invisible to botscript's capability model (`reads {}` / `writes {}` labels cover declared resource identifiers, not the Web Storage API). A fn that accesses storage has undeclared persistent state dependencies invisible to callers and audit tooling. Detection: `localStorage`/`sessionStorage` ident not preceded by `.`/`?.`, followed by `.` or `?.`. Fn/function declarations named `localStorage`/`sessionStorage` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass a `Storage`-compatible object as an explicit fn parameter so callers control what storage is accessed and tests can inject a mock. If direct global access is required, wrap in `unsafe "reads/writes localStorage for <reason>" { localStorage.getItem(key) }`. | |
Comment on lines
+801
to
+805
| message: | ||
| `fn '${decl.name}' accesses ${storageName15} — ` + | ||
| `${storageName15} is same-origin storage invisible to the capability model; ` + | ||
| `no reads {} / writes {} label covers it; ` + | ||
| `pass a storage abstraction as a parameter or wrap in unsafe "accesses ${storageName15} for <reason>" { ${storageName15}.getItem(key) }`, |
62278fb to
88254ae
Compare
Comment on lines
+811
to
+812
| start: tok.start, | ||
| end: tok.end, |
…hat bypasses the storage capability model (?bs 0.7+)
- localStorage.* and sessionStorage.* reads/writes are persistent same-origin
storage operations invisible to botscript's reads {} / writes {} model.
CAP001 covers stdlib namespace calls; the Web Storage API globals are not
part of the stdlib namespace system.
- Detection: localStorage/sessionStorage ident not preceded by ./?., followed
by . or ?. confirming a member access on the global. Bare references (without
a following .) and member-of-a-local (obj.localStorage.*) are excluded.
- Idiomatic fix: pass a Storage-compatible object as an explicit fn parameter
so callers control what storage is accessed and tests can inject a mock.
- Escape hatch: wrap in unsafe "accesses localStorage for <reason>" { ... }.
- Same architecture as existing SYN-series checks: single dispatch loop,
unsafe-range suppression, fn/function declaration exclusion.
- 18 tests covering both globals, all method forms, optional chaining, unsafe
suppression, member-of-local exclusion, bare reference exclusion, fn
declaration exclusion, multiple warnings, severity, and message content.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sistent Copilot correctly flagged that calling sessionStorage "persistent" is inaccurate — it's scoped to the current tab and cleared when the tab closes. localStorage is persistent across sessions and shared across tabs; sessionStorage is neither. Updated diagnostic message, error-code rule text, MCP long-form explanation, and AGENTS.md table to distinguish the two globals accurately while keeping the core warning motivation intact. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tic message example - Block comment: "Both globals are persistent same-origin storage" was wrong for sessionStorage (per-tab, not cross-session); reword and add the distinction inline - Diagnostic message: replace `.method(...)` placeholder with `.getItem(key)` — the check fires on any member access including property reads (.length), so a concrete method call example is clearer than a generic `.method(...)` template Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nostic message hint The previous message always suggested `getItem(key)` as the unsafe example, which is misleading for setItem, clear(), length, and other accesses. Co-Authored-By: Botkowski <noreply@anthropic.com>
…concrete getItem example Two Copilot review fixes: - end: tok.end → end: next15!.end so the diagnostic range includes the `.`/`?.` operator, consistent with how SYN016 (indexedDB) and SYN005 (process.env) report their ranges. - Unsafe escape-hatch example in the message changed from the non-API `.method(...)` placeholder to `.getItem(key)`, a real Web Storage method, to avoid misleading users hitting the warning on property access or non-getItem calls. Co-Authored-By: Botkowski <noreply@anthropic.com>
c880eb0 to
6469a4d
Compare
Comment on lines
+917
to
+921
| // Exclude: `obj.localStorage.*` — preceded by `.` or `?.` | ||
| if (prev15 && ((prev15.kind === "punct" && prev15.text === ".") || prev15.kind === "questionDot")) | ||
| continue; | ||
|
|
||
| // Exclude: `function localStorage(...)` / `fn localStorage(...)` declarations |
SYN015 was firing as a false positive when a parameter or local variable was named localStorage or sessionStorage (e.g., the idiomatic fix pattern of passing a Storage-compatible object as a parameter). Uses collectTopLevelParamNames + collectFnBodyLocalNames to build a per-fn exclusion set before the SYN015 check. Co-Authored-By: Botkowski <noreply@anthropic.com>
- Read the member token after `.`/`?.` and extend the diagnostic range to include it (was ending at the `.` operator; now ends at e.g. `getItem`). - Use the actual member name in the warning message instead of hardcoding `.getItem(key)` — a `setItem` access now says `localStorage.setItem` rather than incorrectly suggesting `getItem` as the escape-hatch example. - Generalize the static idiom in error-codes.ts to note that `sessionStorage` and other methods (setItem, removeItem, clear) are substitutable. - Add a test asserting `localStorage.setItem` appears in the message for a `setItem` access. Addresses Copilot review comments on PR #162.
Comment on lines
+1285
to
+1299
| // Must be followed by `.` or `?.` — confirming this is a property/method access | ||
| // on the storage object (not a bare reference or assignment target). | ||
| const nextIdx15 = nextSignificant(tokens, i + 1); | ||
| const next15 = tokens[nextIdx15]; | ||
| if (!next15 || !( | ||
| (next15.kind === "punct" && next15.text === ".") || | ||
| next15.kind === "questionDot" | ||
| )) continue; | ||
|
|
||
| if (isInsideRange(tok.start, unsafeRanges)) continue; | ||
|
|
||
| const storageName15 = tok.text; | ||
| const sep15 = (next15 && next15.kind === "questionDot") ? "?." : "."; | ||
| const memberIdx15 = nextSignificant(tokens, nextIdx15 + 1); | ||
| const memberTok15 = tokens[memberIdx15]; |
Comment on lines
+264
to
+288
| it("fires on localStorage?.[key] (optional computed member) without <member> placeholder", () => { | ||
| const src = | ||
| "?bs 0.7\n" + | ||
| "fn getByKey(key: string) -> any {\n" + | ||
| " return localStorage?.[key]\n" + | ||
| "}\n"; | ||
| const result = compile(src); | ||
| expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); | ||
| const w = result.warnings.find((w) => w.code === "SYN015"); | ||
| expect(w?.message).not.toContain("<member>"); | ||
| expect(w?.message).toContain("localStorage?.["); | ||
| }); | ||
|
|
||
| it("fires on sessionStorage?.[key] (optional computed member) without <member> placeholder", () => { | ||
| const src = | ||
| "?bs 0.7\n" + | ||
| "fn getByKey(key: string) -> any {\n" + | ||
| " return sessionStorage?.[key]\n" + | ||
| "}\n"; | ||
| const result = compile(src); | ||
| expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); | ||
| const w = result.warnings.find((w) => w.code === "SYN015"); | ||
| expect(w?.message).not.toContain("<member>"); | ||
| expect(w?.message).toContain("sessionStorage?.["); | ||
| }); |
…access The prior gate only passed `.` and `?.` tokens after localStorage/sessionStorage, so localStorage[key] (direct bracket access) was silently skipped. localStorage?.[key] was already handled (?. passes the gate, then [ is caught as computed in memberTok15). Fix: extend the gate to also allow `[` as the next token; update the computed- access branch to derive the bracket start from nextIdx15 (direct) vs memberIdx15 (optional chain). Also add tests for localStorage[key] / sessionStorage[key] and update the doc comment to enumerate all detected forms. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment on lines
+1126
to
+1128
| "**Detection:** the check looks for a `localStorage` or `sessionStorage` ident token not preceded " + | ||
| "by `.`/`?.` (which would make it a member of another object), followed by `.` or `?.` " + | ||
| "(confirming this is an access on the storage global, not a bare reference or a declaration). " + |
| | SYN012 | (0.7+, warning) A fn body constructs an `EventSource` via `new EventSource(url)`, bare `EventSource(url)`, `EventSource?.(url)`, or TypeScript instantiation form `new EventSource<T>(url)`. EventSource opens a persistent server-sent-events (SSE) connection at runtime but is invisible to CAP001 — the capability model only checks `http.*` member calls. Detection: `EventSource` not preceded by `.`/`?.`, followed by `(`, `?.(`, or `<T>(` (generic scan gated on `new`). Member calls, `function`/`fn` declarations, object method shorthands, and TS method signatures are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap in `unsafe "wraps EventSource for <reason>" { new EventSource(url) }` to make the escape hatch visible in the diff. | | ||
| | SYN013 | (0.7+, warning) A fn body constructs a `Worker` or `SharedWorker` via `new Worker(scriptURL)`, bare `Worker(scriptURL)`, `Worker?.(scriptURL)`, `new SharedWorker(scriptURL)`, `SharedWorker?.(scriptURL)`, or TypeScript instantiation forms. Worker construction spawns a new JS execution context whose capability surface is unbounded — the worker script can make network requests, access storage, and perform any operation, none of which is visible in the spawning fn's `uses {}`, `reads {}`, or `writes {}` declarations. CAP001 cannot infer any capability from worker construction. Detection: `Worker` or `SharedWorker` not preceded by `.`/`?.`, followed by `(`, `?.(`, or `<T>(` (generic scan gated on `new`). Member calls, `function`/`fn`/`function*` declarations, object method shorthands, and TS method signatures are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap in `unsafe "<reason>" { new Worker(scriptURL) }` with a reason that documents what capabilities the worker script is expected to use. | | ||
| | SYN014 | (0.7+, warning) A fn body calls `new BroadcastChannel(name)`, `BroadcastChannel(name)`, or TypeScript instantiation form `new BroadcastChannel<T>(name)`. `BroadcastChannel` opens a cross-context message channel at runtime — any tab, window, or worker on the same origin can post to or receive from it — invisible to CAP001. A fn that constructs a BroadcastChannel has an undeclared cross-context messaging dependency. Detection: `BroadcastChannel` not preceded by `.`/`?.`, followed by `(` or `?.(` — or `<T>(` when preceded by `new` (generic scan is gated on `new` to avoid `<`/`>` comparison false-positives). Member calls (`obj.BroadcastChannel(...)`), object method shorthands, TypeScript method signatures, and `fn`/`function` declarations named `BroadcastChannel` are excluded. The `:` exclusion is guarded against ternary consequents. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap in `unsafe "<reason>" { new BroadcastChannel(name) }` (or matching call form) to make the cross-context messaging dependency visible in the diff. | | ||
| | SYN015 | (0.7+, warning) A fn body accesses `localStorage.*` or `sessionStorage.*` — any member access (`.`, `?.`) on either Web Storage global. Both globals are same-origin storage invisible to botscript's capability model (`reads {}` / `writes {}` labels cover declared resource identifiers, not the Web Storage API). `localStorage` persists across browser sessions and is shared across all tabs on the same origin; `sessionStorage` is per-tab and cleared when the tab closes, but is equally invisible to the model. A fn that accesses either global has undeclared state dependencies invisible to callers and audit tooling. Detection: `localStorage`/`sessionStorage` ident not preceded by `.`/`?.`, followed by `.` or `?.`. Fn/function/function* declarations named `localStorage`/`sessionStorage` are excluded. Parameters and `const`/`let`/`var` locals within the fn body that shadow the global name are also excluded (e.g. `fn f(localStorage: Storage)` does not warn). `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass a `Storage`-compatible object as an explicit fn parameter so callers control what storage is accessed and tests can inject a mock. If direct global access is required, wrap in `unsafe "reads/writes localStorage for <reason>" { localStorage.getItem(key) }`. | |
Comment on lines
+883
to
+884
| "`localStorage.*` and `sessionStorage.*` accesses are same-origin storage operations " + | ||
| "invisible to botscript's capability model: `reads {}` / `writes {}` labels cover declared resource identifiers, " + |
…scriptions localStorage[key] / sessionStorage[key] fire SYN015 but were omitted from the rule text, MCP explanation, and AGENTS.md table. Align all three. Co-Authored-By: Botkowski <noreply@anthropic.com>
Comment on lines
+1279
to
+1296
| // Exclude: local bindings — parameters or `const/let/var` named `localStorage`/`sessionStorage`. | ||
| // Computed lazily: only scan the body when we actually encounter a candidate. | ||
| if (localBindings === null) { | ||
| localBindings = collectTopLevelParamNames(decl.args); | ||
| for (const n of collectFnBodyLocalNames(tokens, decl, inner)) localBindings.add(n); | ||
| } | ||
| if (localBindings.has(tok.text)) continue; | ||
|
|
||
| // Must be followed by `.`, `?.`, or `[` — confirming this is a property/method | ||
| // access on the storage object (not a bare reference or assignment target). | ||
| // `[` covers computed access: localStorage[key] / sessionStorage[key]. | ||
| const nextIdx15 = nextSignificant(tokens, i + 1); | ||
| const next15 = tokens[nextIdx15]; | ||
| if (!next15 || !( | ||
| (next15.kind === "punct" && next15.text === ".") || | ||
| next15.kind === "questionDot" || | ||
| (next15.kind === "open" && next15.text === "[") | ||
| )) continue; |
Comment on lines
+1281
to
+1285
| if (localBindings === null) { | ||
| localBindings = collectTopLevelParamNames(decl.args); | ||
| for (const n of collectFnBodyLocalNames(tokens, decl, inner)) localBindings.add(n); | ||
| } | ||
| if (localBindings.has(tok.text)) continue; |
…ngs; scope to top-level bindings
Two fixes:
1. Reorder: the member-access check (must be followed by '.' / '?.' / '[')
now runs before the localBindings body scan, so we avoid scanning the
entire function body for bare localStorage references.
2. Top-level only: switch to topLevelOnly: true in collectFnBodyLocalNames
so block-scoped shadows inside if/for/etc. blocks do not suppress SYN015
for the whole function. e.g. `if (x) { const localStorage = mock }` no
longer silences the outer-scope `localStorage.getItem(...)` access.
Add collectFnBodyLocalNames topLevelOnly option: tracks brace depth
(excluding inner-function bodies already skipped by the existing open-set
mechanism) and skips bindings at depth > 1 when requested.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| if (tok.text !== "const" && tok.text !== "let" && tok.text !== "var") continue; | ||
| // When topLevelOnly, skip bindings inside nested blocks (if, for, etc.). | ||
| // Depth 1 = directly inside the function body; depth > 1 = nested block. | ||
| if (topLevelOnly && blockDepth > 1) continue; |
Copilot review feedback on PR #162: when topLevelOnly is enabled, var declarations inside nested blocks (if, for, etc.) were incorrectly skipped. var is function-scoped in JS/TS, so var localStorage inside an if block shadows the global for the entire function. Now only const/let bindings are skipped at depth > 1; var is always tracked. Adds a regression test: var localStorage inside an if block suppresses SYN015 for the whole function body. Co-Authored-By: Botkowski <noreply@botkowski.ai>
Comment on lines
+1295
to
+1299
| if (localBindings === null) { | ||
| localBindings = collectTopLevelParamNames(decl.args); | ||
| for (const n of collectFnBodyLocalNames(tokens, decl, inner, { topLevelOnly: true })) localBindings.add(n); | ||
| } | ||
| if (localBindings.has(tok.text)) continue; |
Comment on lines
+314
to
+324
| it("DOES fire when localStorage is shadowed only in an inner block — outer-scope access is real", () => { | ||
| // A block-scoped shadow inside `if` should not suppress SYN015 for outer-scope access. | ||
| const src = | ||
| "?bs 0.7\n" + | ||
| "fn mixed(cond: boolean) -> string {\n" + | ||
| " if (cond) { const localStorage = 'mock' }\n" + | ||
| " return localStorage.getItem('key') ?? ''\n" + | ||
| "}\n"; | ||
| const result = compile(src); | ||
| expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); | ||
| }); |
8 tasks
Remove collectFnBodyLocalNames from SYN015 — tracking body-local const/let/var requires a full scope walk and caused a class of false negatives (block-scoped shadow inside `if` suppressing the outer-scope global access). Only parameter names are now suppressed, matching SYN016 / SYN024 behavior. Also fixes a test defect: the var-in-nested-block test used `fn test(...)` which is a botscript keyword — the parser silently dropped the fn and SYN checks never ran. Renamed to `fn check(...)`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment on lines
+69
to
+72
| * Excluded: member calls (`obj.localStorage.*`), `fn`/`function` declarations named | ||
| * `localStorage`/`sessionStorage`, local bindings (parameters or `const`/`let`/`var` | ||
| * declared within the fn body), and object method shorthands. | ||
| * The check fires on any member access (`.` or `?.`): both reads and writes. |
Comment on lines
+1292
to
+1295
| // Body-local binding shadowing is intentionally NOT tracked here — block-scope | ||
| // awareness would require a full scope walk, and the parameter case (a fn that | ||
| // accepts a Storage-compatible mock) is the only practically occurring one. | ||
| // This mirrors SYN016 / SYN024 which also do not track local shadows. |
Comment on lines
+1129
to
+1131
| "Fn/function/function* declarations named `localStorage`/`sessionStorage` are excluded. " + | ||
| "Parameters and `const`/`let`/`var` locals within the fn body that shadow the global name are also " + | ||
| "excluded — e.g. `fn f(localStorage: Storage)` or `const localStorage = mock` will not warn.\n\n" + |
| | SYN012 | (0.7+, warning) A fn body constructs an `EventSource` via `new EventSource(url)`, bare `EventSource(url)`, `EventSource?.(url)`, or TypeScript instantiation form `new EventSource<T>(url)`. EventSource opens a persistent server-sent-events (SSE) connection at runtime but is invisible to CAP001 — the capability model only checks `http.*` member calls. Detection: `EventSource` not preceded by `.`/`?.`, followed by `(`, `?.(`, or `<T>(` (generic scan gated on `new`). Member calls, `function`/`fn` declarations, object method shorthands, and TS method signatures are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap in `unsafe "wraps EventSource for <reason>" { new EventSource(url) }` to make the escape hatch visible in the diff. | | ||
| | SYN013 | (0.7+, warning) A fn body constructs a `Worker` or `SharedWorker` via `new Worker(scriptURL)`, bare `Worker(scriptURL)`, `Worker?.(scriptURL)`, `new SharedWorker(scriptURL)`, `SharedWorker?.(scriptURL)`, or TypeScript instantiation forms. Worker construction spawns a new JS execution context whose capability surface is unbounded — the worker script can make network requests, access storage, and perform any operation, none of which is visible in the spawning fn's `uses {}`, `reads {}`, or `writes {}` declarations. CAP001 cannot infer any capability from worker construction. Detection: `Worker` or `SharedWorker` not preceded by `.`/`?.`, followed by `(`, `?.(`, or `<T>(` (generic scan gated on `new`). Member calls, `function`/`fn`/`function*` declarations, object method shorthands, and TS method signatures are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap in `unsafe "<reason>" { new Worker(scriptURL) }` with a reason that documents what capabilities the worker script is expected to use. | | ||
| | SYN014 | (0.7+, warning) A fn body calls `new BroadcastChannel(name)`, `BroadcastChannel(name)`, or TypeScript instantiation form `new BroadcastChannel<T>(name)`. `BroadcastChannel` opens a cross-context message channel at runtime — any tab, window, or worker on the same origin can post to or receive from it — invisible to CAP001. A fn that constructs a BroadcastChannel has an undeclared cross-context messaging dependency. Detection: `BroadcastChannel` not preceded by `.`/`?.`, followed by `(` or `?.(` — or `<T>(` when preceded by `new` (generic scan is gated on `new` to avoid `<`/`>` comparison false-positives). Member calls (`obj.BroadcastChannel(...)`), object method shorthands, TypeScript method signatures, and `fn`/`function` declarations named `BroadcastChannel` are excluded. The `:` exclusion is guarded against ternary consequents. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap in `unsafe "<reason>" { new BroadcastChannel(name) }` (or matching call form) to make the cross-context messaging dependency visible in the diff. | | ||
| | SYN015 | (0.7+, warning) A fn body accesses `localStorage.*`, `localStorage[key]`, `sessionStorage.*`, or `sessionStorage[key]` — any member or computed access (`.`, `?.`, `[`) on either Web Storage global. Both globals are same-origin storage invisible to botscript's capability model (`reads {}` / `writes {}` labels cover declared resource identifiers, not the Web Storage API). `localStorage` persists across browser sessions and is shared across all tabs on the same origin; `sessionStorage` is per-tab and cleared when the tab closes, but is equally invisible to the model. A fn that accesses either global has undeclared state dependencies invisible to callers and audit tooling. Detection: `localStorage`/`sessionStorage` ident not preceded by `.`/`?.`, followed by `.`, `?.`, or `[`. Fn/function/function* declarations named `localStorage`/`sessionStorage` are excluded. Parameters and `const`/`let`/`var` locals within the fn body that shadow the global name are also excluded (e.g. `fn f(localStorage: Storage)` does not warn). `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass a `Storage`-compatible object as an explicit fn parameter so callers control what storage is accessed and tests can inject a mock. If direct global access is required, wrap in `unsafe "reads/writes localStorage for <reason>" { localStorage.getItem(key) }`. | |
4 tasks
… shadows are suppressed The implementation was simplified to suppress only fn parameter shadows of localStorage/sessionStorage (collectTopLevelParamNames). Three doc surfaces still claimed body-local const/let/var bindings were also excluded, which is incorrect and contradicted by the tests. Update syn-check.ts header comment, MCP explanation, and AGENTS.md to match actual behavior. Also removes a SYN024 reference from the syn-check.ts inline comment — SYN024 was not yet merged into this branch's base. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment on lines
328
to
333
| export function collectFnBodyLocalNames( | ||
| tokens: Token[], | ||
| fn: FnDecl, | ||
| inner: FnDecl[], | ||
| opts?: { topLevelOnly?: boolean }, | ||
| ): Set<string> { |
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
localStorage.*orsessionStorage.*at?bs 0.7+reads {}/writes {}labels cover declared resource identifiers, not the Web Storage API globals. A fn that callslocalStorage.getItem(key)has an undeclared persistent state dependency — noreads {}declaration covers it, callers cannot see it, and it outlives the fn invocation (visible across unrelated calls and tabs on the same origin).Changes
packages/compiler/src/error-codes.tspackages/compiler/src/passes/syn-check.tscase "localStorage": case "sessionStorage":in dispatch loop — fires on any member access (.or?.), excludesobj.localStorage.*, bare references, and fn declarationspackages/compiler/tests/syn015-check.test.tspackages/compiler/tests/error-codes.test.tspackages/mcp/src/explanations.tspackages/mcp/tests/server.test.tsAGENTS.mdREADME.mdexplaintool code listTest plan
pnpm -r build && pnpm test— 1300 tests pass (46 test files)localStorage.getItem(key)localStorage.setItem(key, val)sessionStorage.getItem(key)sessionStorage.setItem(key, val)localStorage.removeItem(key)localStorage.clear()localStorage?.getItem(key)(optional chaining)localStorage.length(property access, not method call)?bs 0.7unsafe {}blocks orunsafe "reason" fnbodiesobj.localStorage.getItem(...)(member of a local)localStoragereference (no following.)fn localStorage(...)botscript declarations🤖 Generated with Claude Code