Skip to content

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
mainfrom
botkowski/syn015-localstorage-bypass
Open

feat(compiler): SYN015 — warn on localStorage/sessionStorage access that bypasses the storage capability model (?bs 0.7+)#162
marcelofarias wants to merge 18 commits into
mainfrom
botkowski/syn015-localstorage-bypass

Conversation

@marcelofarias

Copy link
Copy Markdown
Owner

Summary

  • Adds SYN015: warns when a fn body accesses localStorage.* or sessionStorage.* at ?bs 0.7+
  • Both globals are persistent same-origin storage invisible to botscript's capability model: reads {} / writes {} labels cover declared resource identifiers, not the Web Storage API globals. A fn that calls localStorage.getItem(key) has an undeclared persistent state dependency — no reads {} declaration covers it, callers cannot see it, and it outlives the fn invocation (visible across unrelated calls and tabs on the same origin).
  • Same detection architecture as existing SYN-series checks: single dispatch loop, unsafe-range suppression, fn/function declaration exclusion.

Changes

File Change
packages/compiler/src/error-codes.ts Add SYN015 entry (rule, idiom, rewrite, example)
packages/compiler/src/passes/syn-check.ts Add case "localStorage": case "sessionStorage": in dispatch loop — fires on any member access (. or ?.), excludes obj.localStorage.*, bare references, and fn declarations
packages/compiler/tests/syn015-check.test.ts 18 tests: both globals, setItem/getItem/removeItem/clear/.length, optional chaining, unsafe suppression, member-of-local exclusion, bare reference exclusion, fn declaration exclusion, multi-warning count, severity, message content
packages/compiler/tests/error-codes.test.ts Add SYN015 to exhaustive allowlist
packages/mcp/src/explanations.ts Add SYN015 long-form explanation
packages/mcp/tests/server.test.ts Add SYN015 to KNOWN_CODES list
AGENTS.md Add SYN015 row to diagnostic table
README.md Add SYN015 to explain tool code list

Test plan

  • pnpm -r build && pnpm test — 1300 tests pass (46 test files)
  • SYN015 fires on localStorage.getItem(key)
  • SYN015 fires on localStorage.setItem(key, val)
  • SYN015 fires on sessionStorage.getItem(key)
  • SYN015 fires on sessionStorage.setItem(key, val)
  • SYN015 fires on localStorage.removeItem(key)
  • SYN015 fires on localStorage.clear()
  • SYN015 fires on localStorage?.getItem(key) (optional chaining)
  • SYN015 fires on localStorage.length (property access, not method call)
  • SYN015 does NOT fire below ?bs 0.7
  • SYN015 does NOT fire inside unsafe {} blocks or unsafe "reason" fn bodies
  • SYN015 does NOT fire on obj.localStorage.getItem(...) (member of a local)
  • SYN015 does NOT fire on bare localStorage reference (no following .)
  • SYN015 does NOT fire on fn localStorage(...) botscript declarations
  • SYN015 fires twice when both globals are accessed in the same fn

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / sessionStorage while 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 thread packages/mcp/src/explanations.ts Outdated
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 thread packages/compiler/src/error-codes.ts Outdated
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.",
Comment thread AGENTS.md Outdated
| 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) }`. |

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread packages/compiler/src/passes/syn-check.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

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) }`,

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment on lines +811 to +812
start: tok.start,
end: tok.end,
Comment thread packages/compiler/src/passes/syn-check.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Marcelo Farias and others added 5 commits June 14, 2026 19:39
…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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

- 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Comment thread packages/mcp/src/explanations.ts Outdated
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). " +
Comment thread AGENTS.md Outdated
| 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 thread packages/compiler/src/error-codes.ts Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

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);
});
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.

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 thread packages/mcp/src/explanations.ts Outdated
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" +
Comment thread AGENTS.md Outdated
| 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) }`. |
… 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Comment on lines 328 to 333
export function collectFnBodyLocalNames(
tokens: Token[],
fn: FnDecl,
inner: FnDecl[],
opts?: { topLevelOnly?: boolean },
): Set<string> {
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants