From bc1bae2d30f25cc5bfd7bf17444ce3d1df631f10 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sat, 13 Jun 2026 19:41:10 -0300 Subject: [PATCH 01/17] =?UTF-8?q?feat(compiler):=20SYN015=20=E2=80=94=20wa?= =?UTF-8?q?rn=20on=20localStorage/sessionStorage=20access=20that=20bypasse?= =?UTF-8?q?s=20the=20storage=20capability=20model=20(=3Fbs=200.7+)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 " { ... }. - 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 --- AGENTS.md | 1 + README.md | 2 +- packages/compiler/src/error-codes.ts | 33 +++ packages/compiler/src/passes/syn-check.ts | 58 ++++++ packages/compiler/tests/error-codes.test.ts | 2 +- packages/compiler/tests/syn015-check.test.ts | 200 +++++++++++++++++++ packages/mcp/src/explanations.ts | 47 +++++ packages/mcp/tests/server.test.ts | 1 + 8 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/tests/syn015-check.test.ts diff --git a/AGENTS.md b/AGENTS.md index 75e60603..cb4a4515 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,6 +206,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | 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) }`. | | SYN014 | (0.7+, warning) A fn body calls `new BroadcastChannel(name)`, `BroadcastChannel(name)`, or TypeScript instantiation form `new BroadcastChannel(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 `(` 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 "" { 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. `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 " { localStorage.getItem(key) }`. | | SYN016 | (0.7+, warning) A fn body accesses `indexedDB.*` — any member access (`.`, `?.`) on the `indexedDB` global. `indexedDB` is same-origin persistent database storage invisible to botscript's capability model (`reads {}` / `writes {}` labels cover declared resource identifiers, not the Web Storage API). Unlike `localStorage`, `indexedDB` is asynchronous and has no practical size limit, making invisible access higher-impact. A fn that accesses `indexedDB` has undeclared persistent state dependencies invisible to callers and audit tooling. Detection: `indexedDB` ident not preceded by `.`/`?.`, followed by `.` or `?.`. Bare references and `fn`/`function` declarations named `indexedDB` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass an `IDBDatabase` handle as an explicit fn parameter so callers control what database is accessed and tests can inject a mock. If direct global access is required, wrap in `unsafe "reads/writes indexedDB for " { indexedDB.open(name) }`. | | 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. | diff --git a/README.md b/README.md index 48a5e081..3efbc968 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`, `SYN014`, `SYN016`, `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`, `SYN014`, `SYN015`, `SYN016`, `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/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 7532e49b..51b3f301 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -812,6 +812,39 @@ const E: Record = { " return bc\n" + "}", }, + SYN015: { + code: "SYN015", + title: "localStorage / sessionStorage access bypasses the storage capability model", + 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.", + idiom: + "pass a storage abstraction (e.g. a `Storage` interface) as an explicit fn parameter so callers " + + "control what storage is accessed, the dependency is visible in the fn signature, and tests can inject a mock; " + + "if direct access is genuinely required, wrap in " + + "`unsafe \"reads/writes localStorage for \" { localStorage.getItem(key) }`", + rewrite: + "// before — localStorage access invisible to the capability model\n" + + "fn getToken() -> string | null {\n" + + " return localStorage.getItem(\"auth_token\") // SYN015\n" + + "}\n\n" + + "// after — storage passed as a parameter; dependency visible in the signature\n" + + "fn getToken(storage: Storage) -> string | null {\n" + + " return storage.getItem(\"auth_token\")\n" + + "}", + example: + "// SYN015: localStorage access bypasses the storage capability model\n" + + "fn saveUser(user: User) -> void {\n" + + " localStorage.setItem(\"user\", JSON.stringify(user)) // SYN015\n" + + "}\n\n" + + "// fix: pass storage as a parameter\n" + + "fn saveUser(storage: Storage, user: User) -> void {\n" + + " storage.setItem(\"user\", JSON.stringify(user))\n" + + "}", + }, SYN016: { code: "SYN016", title: "indexedDB access bypasses the storage capability model", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index caae0303..84b9a52d 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -57,6 +57,16 @@ * `cond ? new WebSocket(url) : other`). Generic `` detection only when * preceded by `new` (avoids false-positives on `WebSocket < x > (y)` comparisons). * + * SYN015 A `localStorage.*` or `sessionStorage.*` access was detected in a fn body (?bs 0.7+). + * Both globals 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 globals. A fn that accesses `localStorage` or + * `sessionStorage` has undeclared persistent state dependencies invisible to callers and + * audit tooling. + * Excluded: member calls (`obj.localStorage.*`), `fn`/`function` declarations named + * `localStorage`/`sessionStorage`, and object method shorthands. + * The check fires on any member access (`.` or `?.`): both reads and writes. + * * SYN010 A `setTimeout(...)`, `setInterval(...)`, or `queueMicrotask(...)` * call was detected in a fn body (?bs 0.7+). These globals schedule * callbacks to run after the current fn returns — any effects inside @@ -138,6 +148,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const syn010 = getErrorCode("SYN010")!; const syn011 = getErrorCode("SYN011")!; const syn014 = getErrorCode("SYN014")!; + const syn015 = getErrorCode("SYN015")!; const syn016 = getErrorCode("SYN016")!; // Collect char-offset ranges where all SYN checks are suppressed: @@ -896,6 +907,53 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult break; } + // ── SYN015: localStorage / sessionStorage access ───────────────────── + case "localStorage": + case "sessionStorage": { + const prevIdx15 = prevSignificant(tokens, i - 1); + const prev15 = tokens[prevIdx15]; + + // Exclude: `obj.localStorage.*` — preceded by `.` or `?.` + if (prev15 && ((prev15.kind === "punct" && prev15.text === ".") || prev15.kind === "questionDot")) + continue; + + // Exclude: `function localStorage(...)` / `fn localStorage(...)` declarations + if (prev15 && prev15.kind === "ident" && prev15.text === "function") continue; + if (prev15 && prev15.kind === "keyword" && prev15.text === "fn") continue; + + // 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 loc15 = locationOf(src, tok.start); + warnings.push({ + code: "SYN015", + severity: "warning", + file: null, + line: loc15.line, + column: loc15.column, + start: tok.start, + end: tok.end, + message: + `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 " { ${storageName15}.method(...) }`, + rule: syn015.rule, + idiom: syn015.idiom, + rewrite: syn015.rewrite, + }); + break; + } + // ── SYN016: indexedDB.* access ─────────────────────────────────────── case "indexedDB": { // Exclude: `obj.indexedDB` — preceded by `.` or `?.` diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index c68f6089..50faa610 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", "SYN014", "SYN016", + "SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN010", "SYN011", "SYN014", "SYN015", "SYN016", "THR001", "THR002", "THR003", "THR004", "UNS001", "UNS002", "UNS003", "UNS004", "UNS005", "VER001", "VER002", "VER003", diff --git a/packages/compiler/tests/syn015-check.test.ts b/packages/compiler/tests/syn015-check.test.ts new file mode 100644 index 00000000..048b5808 --- /dev/null +++ b/packages/compiler/tests/syn015-check.test.ts @@ -0,0 +1,200 @@ +/** + * Tests for SYN015: localStorage / sessionStorage access detection (?bs 0.7+). + * + * SYN015 is a non-blocking warning — transform must not throw. + */ + +import { describe, it, expect } from "vitest"; +import { transform } from "../src/index.js"; + +function compile(src: string) { + return transform(src, {}); +} + +describe("SYN015: localStorage / sessionStorage access detection", () => { + it("fires on localStorage.getItem(...) inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn getToken() -> any {\n" + + " return localStorage.getItem(\"auth\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); + }); + + it("fires on localStorage.setItem(...) inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn saveToken(token: string) -> void {\n" + + " localStorage.setItem(\"auth\", token)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); + }); + + it("fires on sessionStorage.getItem(...) inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn getSession(key: string) -> any {\n" + + " return sessionStorage.getItem(key)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); + }); + + it("fires on sessionStorage.setItem(...) inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn saveSession(key: string, val: string) -> void {\n" + + " sessionStorage.setItem(key, val)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); + }); + + it("fires on localStorage.removeItem(...)", () => { + const src = + "?bs 0.7\n" + + "fn clearToken() -> void {\n" + + " localStorage.removeItem(\"auth\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); + }); + + it("fires on localStorage.clear()", () => { + const src = + "?bs 0.7\n" + + "fn clearAll() -> void {\n" + + " localStorage.clear()\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); + }); + + it("fires on localStorage?.getItem(...) optional chaining", () => { + const src = + "?bs 0.7\n" + + "fn getToken() -> any {\n" + + " return localStorage?.getItem(\"auth\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); + }); + + it("fires on localStorage.length property access", () => { + const src = + "?bs 0.7\n" + + "fn storageSize() -> any {\n" + + " return localStorage.length\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); + }); + + it("does NOT fire below ?bs 0.7", () => { + const src = + "?bs 0.6\n" + + "fn getToken() -> any {\n" + + " return localStorage.getItem(\"auth\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + }); + + it("does NOT fire inside unsafe {} block", () => { + const src = + "?bs 0.7\n" + + "fn getToken() -> any {\n" + + " return unsafe \"reads auth token from localStorage\" { localStorage.getItem(\"auth\") }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + }); + + it("does NOT fire inside unsafe fn body", () => { + const src = + "?bs 0.7\n" + + "unsafe \"accesses localStorage directly\" fn getToken() -> any {\n" + + " return localStorage.getItem(\"auth\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + }); + + it("does NOT fire on obj.localStorage.getItem(...) — member of a local", () => { + const src = + "?bs 0.7\n" + + "fn getToken(obj: any) -> any {\n" + + " return obj.localStorage.getItem(\"auth\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + }); + + it("does NOT fire on bare localStorage reference (not accessed)", () => { + const src = + "?bs 0.7\n" + + "fn getStorage() -> any {\n" + + " return localStorage\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + }); + + it("does NOT fire on fn localStorage(...) botscript declaration", () => { + const src = + "?bs 0.7\n" + + "fn localStorage(key: string) -> any {\n" + + " return key\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + }); + + it("fires twice when both localStorage and sessionStorage are accessed", () => { + const src = + "?bs 0.7\n" + + "fn syncStorage(key: string) -> void {\n" + + " const val = localStorage.getItem(key)\n" + + " sessionStorage.setItem(key, val)\n" + + "}\n"; + const result = compile(src); + const codes = result.warnings.filter((w) => w.code === "SYN015"); + expect(codes.length).toBe(2); + }); + + it("severity is 'warning'", () => { + const src = + "?bs 0.7\n" + + "fn getToken() -> any {\n" + + " return localStorage.getItem(\"auth\")\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN015"); + expect(w?.severity).toBe("warning"); + }); + + it("message mentions localStorage and the capability model", () => { + const src = + "?bs 0.7\n" + + "fn getToken() -> any {\n" + + " return localStorage.getItem(\"auth\")\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN015"); + expect(w?.message).toContain("localStorage"); + expect(w?.message).toContain("capability model"); + }); + + it("message mentions sessionStorage for sessionStorage access", () => { + const src = + "?bs 0.7\n" + + "fn getSession(key: string) -> any {\n" + + " return sessionStorage.getItem(key)\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN015"); + expect(w?.message).toContain("sessionStorage"); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index a8b48f60..2fde3c09 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1031,6 +1031,53 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN015: { + code: "SYN015", + title: "localStorage / sessionStorage access bypasses the storage capability model", + body: + "SYN015 fires when a fn body accesses `localStorage.*` or `sessionStorage.*` — " + + "any member access (`.getItem`, `.setItem`, `.removeItem`, `.clear`, `.key`, `.length`, etc.) " + + "on either global storage object.\n\n" + + "**Why it matters:** `reads {}` and `writes {}` labels in botscript cover declared resource identifiers " + + "(e.g. `reads { db }`, `writes { cache }`). The Web Storage API globals — `localStorage` and " + + "`sessionStorage` — are not part of the stdlib namespace system. A fn that calls " + + "`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" + + "**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). " + + "Fn/function declarations named `localStorage`/`sessionStorage` are excluded.\n\n" + + "**Fix (preferred):** pass a `Storage`-compatible object as an explicit fn parameter. This makes " + + "the dependency visible in the fn signature and tests can inject a mock:\n\n" + + "```\n" + + "// SYN015\n" + + "fn getToken() -> string | null {\n" + + " return localStorage.getItem(\"auth_token\")\n" + + "}\n\n" + + "// fix — storage is now an explicit parameter\n" + + "fn getToken(storage: Storage) -> string | null {\n" + + " return storage.getItem(\"auth_token\")\n" + + "}\n" + + "```\n\n" + + "**Fix (escape hatch):** if direct access is genuinely required, wrap in an `unsafe` block:\n" + + "`unsafe \"reads auth token from localStorage\" { localStorage.getItem(\"auth_token\") }`\n\n" + + "SYN015 fires at `?bs 0.7+` as a non-blocking warning. " + + "Accesses inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", + example: { + fails: + "?bs 0.7\n" + + "fn saveUser(user: User) -> void {\n" + + " localStorage.setItem(\"user\", JSON.stringify(user))\n" + + "}\n", + passes: + "?bs 0.7\n" + + "fn saveUser(storage: Storage, user: User) -> void {\n" + + " storage.setItem(\"user\", JSON.stringify(user))\n" + + "}\n", + }, + }, SYN016: { code: "SYN016", title: "indexedDB access bypasses the storage capability model", diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index e31c7c2b..e6201c02 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -83,6 +83,7 @@ describe("botscript-mcp explanations", () => { "SYN010", "SYN011", "SYN014", + "SYN015", "SYN016", "THR001", "THR002", From a232932a13d835e1131de939beb07a62f6fb2c17 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sat, 13 Jun 2026 23:26:48 -0300 Subject: [PATCH 02/17] fix(syn015): clarify sessionStorage is per-tab, not cross-session persistent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 2 +- packages/compiler/src/error-codes.ts | 7 ++++--- packages/compiler/src/passes/syn-check.ts | 2 +- packages/mcp/src/explanations.ts | 8 +++++--- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cb4a4515..650e5665 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,7 +206,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | 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) }`. | | SYN014 | (0.7+, warning) A fn body calls `new BroadcastChannel(name)`, `BroadcastChannel(name)`, or TypeScript instantiation form `new BroadcastChannel(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 `(` 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 "" { 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. `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 " { localStorage.getItem(key) }`. | +| 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 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 " { localStorage.getItem(key) }`. | | SYN016 | (0.7+, warning) A fn body accesses `indexedDB.*` — any member access (`.`, `?.`) on the `indexedDB` global. `indexedDB` is same-origin persistent database storage invisible to botscript's capability model (`reads {}` / `writes {}` labels cover declared resource identifiers, not the Web Storage API). Unlike `localStorage`, `indexedDB` is asynchronous and has no practical size limit, making invisible access higher-impact. A fn that accesses `indexedDB` has undeclared persistent state dependencies invisible to callers and audit tooling. Detection: `indexedDB` ident not preceded by `.`/`?.`, followed by `.` or `?.`. Bare references and `fn`/`function` declarations named `indexedDB` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass an `IDBDatabase` handle as an explicit fn parameter so callers control what database is accessed and tests can inject a mock. If direct global access is required, wrap in `unsafe "reads/writes indexedDB for " { indexedDB.open(name) }`. | | 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. | diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 51b3f301..c561624f 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -816,11 +816,12 @@ const E: Record = { code: "SYN015", title: "localStorage / sessionStorage access bypasses the storage capability model", rule: - "`localStorage.*` and `sessionStorage.*` accesses are persistent same-origin storage operations " + + "`localStorage.*` and `sessionStorage.*` accesses are 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.", + "undeclared 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. " + + "(`localStorage` persists across browser sessions; `sessionStorage` is scoped to the current tab and cleared when the tab closes.)", idiom: "pass a storage abstraction (e.g. a `Storage` interface) as an explicit fn parameter so callers " + "control what storage is accessed, the dependency is visible in the fn signature, and tests can inject a mock; " + diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 84b9a52d..a759a61e 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -944,7 +944,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult end: tok.end, message: `fn '${decl.name}' accesses ${storageName15} — ` + - `${storageName15} is persistent same-origin storage invisible to the capability model; ` + + `${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 " { ${storageName15}.method(...) }`, rule: syn015.rule, diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 2fde3c09..41cbd8e1 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1041,10 +1041,12 @@ export const EXPLANATIONS: Readonly> = { "**Why it matters:** `reads {}` and `writes {}` labels in botscript cover declared resource identifiers " + "(e.g. `reads { db }`, `writes { cache }`). The Web Storage API globals — `localStorage` and " + "`sessionStorage` — are not part of the stdlib namespace system. A fn that calls " + - "`localStorage.setItem(key, val)` writes persistent same-origin state at runtime but declares " + + "`localStorage.setItem(key, val)` writes 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" + + "it from the fn signature. The state outlives the fn invocation: a side effect written in one call " + + "is visible in a completely unrelated future call. `localStorage` is persistent across browser " + + "sessions and shared across all tabs on the same origin; `sessionStorage` is per-tab and cleared " + + "when the tab closes, but still invisible to the capability model.\n\n" + "**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). " + From fa682a110d357cecbc28b528e4f1b2e2f1fc261f Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sun, 14 Jun 2026 03:30:16 -0300 Subject: [PATCH 03/17] =?UTF-8?q?fix(syn015):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20block=20comment=20accuracy,=20diagnostic=20messa?= =?UTF-8?q?ge=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/compiler/src/passes/syn-check.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index a759a61e..5eb8794a 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -58,11 +58,12 @@ * preceded by `new` (avoids false-positives on `WebSocket < x > (y)` comparisons). * * SYN015 A `localStorage.*` or `sessionStorage.*` access was detected in a fn body (?bs 0.7+). - * Both globals are persistent same-origin storage — reads and writes are invisible to + * Both globals are same-origin storage whose reads and writes are invisible to * botscript's capability model: `reads {}` / `writes {}` labels cover declared resource * identifiers, not the Web Storage API globals. A fn that accesses `localStorage` or - * `sessionStorage` has undeclared persistent state dependencies invisible to callers and - * audit tooling. + * `sessionStorage` has undeclared state dependencies invisible to callers and audit + * tooling. (`localStorage` persists across browser sessions; `sessionStorage` is + * per-tab and cleared when the tab closes.) * Excluded: member calls (`obj.localStorage.*`), `fn`/`function` declarations named * `localStorage`/`sessionStorage`, and object method shorthands. * The check fires on any member access (`.` or `?.`): both reads and writes. @@ -946,7 +947,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult `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 " { ${storageName15}.method(...) }`, + `pass a storage abstraction as a parameter or wrap in unsafe "accesses ${storageName15} for " { ${storageName15}.getItem(key) }`, rule: syn015.rule, idiom: syn015.idiom, rewrite: syn015.rewrite, From dffb84258792a97bda2ea48d01ed171dbd7df63b Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sun, 14 Jun 2026 07:33:00 -0300 Subject: [PATCH 04/17] =?UTF-8?q?fix(syn015):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20use=20generic=20method(...)=20in=20diagnostic=20?= =?UTF-8?q?message=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/compiler/src/passes/syn-check.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 5eb8794a..546fa5f6 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -947,7 +947,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult `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 " { ${storageName15}.getItem(key) }`, + `pass a storage abstraction as a parameter or wrap in unsafe "accesses ${storageName15} for " { ${storageName15}.method(...) }`, rule: syn015.rule, idiom: syn015.idiom, rewrite: syn015.rewrite, From 6469a4d08964d47c6d03bb3e53ee3b1a5a4d258a Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sun, 14 Jun 2026 11:34:45 -0300 Subject: [PATCH 05/17] =?UTF-8?q?fix(compiler):=20SYN015=20=E2=80=94=20ext?= =?UTF-8?q?end=20range=20to=20include=20access=20operator,=20use=20concret?= =?UTF-8?q?e=20getItem=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/compiler/src/passes/syn-check.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 546fa5f6..3e44d9b2 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -942,12 +942,12 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult line: loc15.line, column: loc15.column, start: tok.start, - end: tok.end, + end: next15!.end, 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 " { ${storageName15}.method(...) }`, + `pass a storage abstraction as a parameter or wrap in unsafe "accesses ${storageName15} for " { ${storageName15}.getItem(key) }`, rule: syn015.rule, idiom: syn015.idiom, rewrite: syn015.rewrite, From 8a80ad66e05c23f3624e07a9d235988013e8671f Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sun, 14 Jun 2026 23:39:58 -0300 Subject: [PATCH 06/17] fix(syn015): exclude local bindings named localStorage/sessionStorage 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 --- packages/compiler/src/passes/syn-check.ts | 8 ++++- packages/compiler/tests/syn015-check.test.ts | 31 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 3e44d9b2..4d649156 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -115,7 +115,7 @@ import type { Diagnostic } from "../diagnostics.js"; import { getErrorCode } from "../error-codes.js"; import { parseProgram } from "../parser/parse.js"; import { locationOf } from "./_location.js"; -import { computeNesting, prevSignificant, nextSignificant } from "./_callgraph.js"; +import { computeNesting, prevSignificant, nextSignificant, collectTopLevelParamNames, collectFnBodyLocalNames } from "./_callgraph.js"; import { atLeast, type VersionInfo } from "./version.js"; import { collectUnsafeBlockRanges, isInsideRange } from "./_unsafe-ranges.js"; @@ -175,6 +175,9 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const open: typeof inner = []; let nextInner = 0; + const localBindings = collectTopLevelParamNames(decl.args); + for (const n of collectFnBodyLocalNames(tokens, decl, inner)) localBindings.add(n); + const bodyStart = decl.bodyTokenStart ?? decl.tokenStart; // Single dispatch loop: nesting bookkeeping runs once per token position. @@ -922,6 +925,9 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (prev15 && prev15.kind === "ident" && prev15.text === "function") continue; if (prev15 && prev15.kind === "keyword" && prev15.text === "fn") continue; + // Exclude: local bindings — parameters or `const/let/var` named `localStorage`/`sessionStorage` + 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). const nextIdx15 = nextSignificant(tokens, i + 1); diff --git a/packages/compiler/tests/syn015-check.test.ts b/packages/compiler/tests/syn015-check.test.ts index 048b5808..662fa6e7 100644 --- a/packages/compiler/tests/syn015-check.test.ts +++ b/packages/compiler/tests/syn015-check.test.ts @@ -197,4 +197,35 @@ describe("SYN015: localStorage / sessionStorage access detection", () => { const w = result.warnings.find((w) => w.code === "SYN015"); expect(w?.message).toContain("sessionStorage"); }); + + it("does NOT fire when localStorage is a function parameter name", () => { + const src = + "?bs 0.7\n" + + "fn getToken(localStorage: Storage) -> any {\n" + + " return localStorage.getItem(\"auth\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + }); + + it("does NOT fire when sessionStorage is a function parameter name", () => { + const src = + "?bs 0.7\n" + + "fn getSession(sessionStorage: Storage) -> any {\n" + + " return sessionStorage.getItem(\"key\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + }); + + it("does NOT fire when localStorage is a local const binding", () => { + const src = + "?bs 0.7\n" + + "fn getToken() -> any {\n" + + " const localStorage = window.localStorage\n" + + " return localStorage.getItem(\"auth\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + }); }); From 1efc3e15e332dde59f33ae606d825c6646afe965 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Mon, 15 Jun 2026 03:37:47 -0300 Subject: [PATCH 07/17] fix(syn015): include actual member name in diagnostic range and message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- packages/compiler/src/error-codes.ts | 3 ++- packages/compiler/src/passes/syn-check.ts | 10 +++++++--- packages/compiler/tests/syn015-check.test.ts | 11 +++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index c561624f..7f3ec47b 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -826,7 +826,8 @@ const E: Record = { "pass a storage abstraction (e.g. a `Storage` interface) as an explicit fn parameter so callers " + "control what storage is accessed, the dependency is visible in the fn signature, and tests can inject a mock; " + "if direct access is genuinely required, wrap in " + - "`unsafe \"reads/writes localStorage for \" { localStorage.getItem(key) }`", + "`unsafe \"reads/writes storage for \" { localStorage.getItem(key) }` " + + "(substitute `sessionStorage` and the actual method — e.g. `setItem(key, val)`, `removeItem(key)`, `clear()` — as appropriate)", rewrite: "// before — localStorage access invisible to the capability model\n" + "fn getToken() -> string | null {\n" + diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 4d649156..f63e2258 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -940,6 +940,10 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (isInsideRange(tok.start, unsafeRanges)) continue; const storageName15 = tok.text; + const memberIdx15 = nextSignificant(tokens, nextIdx15 + 1); + const memberTok15 = tokens[memberIdx15]; + const memberName15 = (memberTok15 && memberTok15.kind === "ident") ? memberTok15.text : ""; + const rangeEnd15 = (memberTok15 && memberTok15.kind === "ident") ? memberTok15.end : next15!.end; const loc15 = locationOf(src, tok.start); warnings.push({ code: "SYN015", @@ -948,12 +952,12 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult line: loc15.line, column: loc15.column, start: tok.start, - end: next15!.end, + end: rangeEnd15, message: - `fn '${decl.name}' accesses ${storageName15} — ` + + `fn '${decl.name}' accesses ${storageName15}.${memberName15} — ` + `${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 " { ${storageName15}.getItem(key) }`, + `pass a storage abstraction as a parameter or wrap in unsafe "accesses ${storageName15} for " { ${storageName15}.${memberName15}(...) }`, rule: syn015.rule, idiom: syn015.idiom, rewrite: syn015.rewrite, diff --git a/packages/compiler/tests/syn015-check.test.ts b/packages/compiler/tests/syn015-check.test.ts index 662fa6e7..cce73706 100644 --- a/packages/compiler/tests/syn015-check.test.ts +++ b/packages/compiler/tests/syn015-check.test.ts @@ -218,6 +218,17 @@ describe("SYN015: localStorage / sessionStorage access detection", () => { expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); }); + it("message includes the actual member name for non-getItem accesses", () => { + const src = + "?bs 0.7\n" + + "fn save(key: string, val: string) -> void {\n" + + " localStorage.setItem(key, val)\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN015"); + expect(w?.message).toContain("localStorage.setItem"); + }); + it("does NOT fire when localStorage is a local const binding", () => { const src = "?bs 0.7\n" + From 86ed355781d461d39a097346775d74e75bd5003c Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Mon, 15 Jun 2026 15:37:31 -0300 Subject: [PATCH 08/17] fix(syn015): correct unsafe example for property accesses and indexedDB generator exclusion Two fixes: - SYN015 diagnostic message always showed `storage.member(...)` with parens even for property reads like `localStorage.length`. Now checks whether the member is followed by `(` and omits the `(...)` for property accesses. - indexedDB generator-function exclusion used kind === "punct" for the `*` token, but the lexer emits `*` as kind === "operator". Changed to match the correct token kind so function* indexedDB(...) declarations are properly excluded. - Updated SYN015 file header comment to document the local-binding exclusion. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index f63e2258..e3fb7803 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -65,7 +65,8 @@ * tooling. (`localStorage` persists across browser sessions; `sessionStorage` is * per-tab and cleared when the tab closes.) * Excluded: member calls (`obj.localStorage.*`), `fn`/`function` declarations named - * `localStorage`/`sessionStorage`, and object method shorthands. + * `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. * * SYN010 A `setTimeout(...)`, `setInterval(...)`, or `queueMicrotask(...)` @@ -942,8 +943,15 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const storageName15 = tok.text; const memberIdx15 = nextSignificant(tokens, nextIdx15 + 1); const memberTok15 = tokens[memberIdx15]; - const memberName15 = (memberTok15 && memberTok15.kind === "ident") ? memberTok15.text : ""; + const memberName15 = (memberTok15 && memberTok15.kind === "ident") ? memberTok15.text : ""; const rangeEnd15 = (memberTok15 && memberTok15.kind === "ident") ? memberTok15.end : next15!.end; + // Determine if this is a call (followed by `(`) or a property access (e.g. `.length`). + const afterMemberIdx15 = nextSignificant(tokens, memberIdx15 + 1); + const afterMember15 = tokens[afterMemberIdx15]; + const isCall15 = afterMember15 && ((afterMember15.kind === "open" && afterMember15.text === "(") || afterMember15.kind === "questionDot"); + const unsafeExample15 = isCall15 + ? `${storageName15}.${memberName15}(...)` + : `${storageName15}.${memberName15}`; const loc15 = locationOf(src, tok.start); warnings.push({ code: "SYN015", @@ -957,7 +965,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult `fn '${decl.name}' accesses ${storageName15}.${memberName15} — ` + `${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 " { ${storageName15}.${memberName15}(...) }`, + `pass a storage abstraction as a parameter or wrap in unsafe "accesses ${storageName15} for " { ${unsafeExample15} }`, rule: syn015.rule, idiom: syn015.idiom, rewrite: syn015.rewrite, @@ -976,8 +984,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult // Exclude: fn/function/function* declarations named indexedDB if (prev16 && prev16.kind === "keyword" && prev16.text === "fn") continue; if (prev16 && prev16.kind === "ident" && prev16.text === "function") continue; - // Generator: `function* indexedDB` — prev token is `*`, token before that is `function` - if (prev16 && prev16.kind === "punct" && prev16.text === "*") { + // Generator: `function* indexedDB` — prev token is `*` (operator kind), token before that is `function` + if (prev16 && prev16.kind === "operator" && prev16.text === "*") { const prevPrevIdx16 = prevSignificant(tokens, prevIdx16 - 1); const prevPrev16 = tokens[prevPrevIdx16]; if (prevPrev16 && prevPrev16.kind === "ident" && prevPrev16.text === "function") continue; From 5c51b345eaf75a4d6b1ea0a0c10e2950d1e8fb66 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Mon, 15 Jun 2026 19:42:46 -0300 Subject: [PATCH 09/17] fix(syn015): exclude generator declarations; fix optional-chain separator and isCall detection Three Copilot review issues: - function* localStorage(...) not excluded: prev is `*` (operator), not `function` (ident). Added backward scan to detect and skip generator declarations. - isCall15 treated any `?.` after the member as a call. Changed to detect `?.(` specifically (the pattern used elsewhere in this file), so property accesses like `.length?.toString` are not misclassified as calls. - Message and unsafe example always showed `.` separator regardless of whether source used `?.`. Added sep15 var to reflect the actual access form in both. - Added tests for function/function* localStorage declarations and optional-chain access. Co-Authored-By: Botkowski --- packages/compiler/src/passes/syn-check.ts | 25 +++++++++++---- packages/compiler/tests/syn015-check.test.ts | 33 ++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index e3fb7803..62b0c522 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -922,9 +922,14 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (prev15 && ((prev15.kind === "punct" && prev15.text === ".") || prev15.kind === "questionDot")) continue; - // Exclude: `function localStorage(...)` / `fn localStorage(...)` declarations + // Exclude: `function localStorage(...)` / `function* localStorage(...)` / `fn localStorage(...)` declarations if (prev15 && prev15.kind === "ident" && prev15.text === "function") continue; if (prev15 && prev15.kind === "keyword" && prev15.text === "fn") continue; + // `function* localStorage(...)` — prev is `*` (operator), before that is `function` + if (prev15 && prev15.kind === "operator" && prev15.text === "*") { + const prevBeforeStar15 = tokens[prevSignificant(tokens, prevIdx15 - 1)]; + if (prevBeforeStar15 && prevBeforeStar15.kind === "ident" && prevBeforeStar15.text === "function") continue; + } // Exclude: local bindings — parameters or `const/let/var` named `localStorage`/`sessionStorage` if (localBindings.has(tok.text)) continue; @@ -941,17 +946,25 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult 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]; const memberName15 = (memberTok15 && memberTok15.kind === "ident") ? memberTok15.text : ""; const rangeEnd15 = (memberTok15 && memberTok15.kind === "ident") ? memberTok15.end : next15!.end; - // Determine if this is a call (followed by `(`) or a property access (e.g. `.length`). + // Determine if this is a call: `(` = direct call; `?.(` = optional call; `?.` alone = optional property access. const afterMemberIdx15 = nextSignificant(tokens, memberIdx15 + 1); const afterMember15 = tokens[afterMemberIdx15]; - const isCall15 = afterMember15 && ((afterMember15.kind === "open" && afterMember15.text === "(") || afterMember15.kind === "questionDot"); + let isCall15 = false; + if (afterMember15 && afterMember15.kind === "open" && afterMember15.text === "(") { + isCall15 = true; + } else if (afterMember15 && afterMember15.kind === "questionDot") { + // Optional call `?.(` — check next token is `(` + const afterQD15 = tokens[nextSignificant(tokens, afterMemberIdx15 + 1)]; + isCall15 = !!(afterQD15 && afterQD15.kind === "open" && afterQD15.text === "("); + } const unsafeExample15 = isCall15 - ? `${storageName15}.${memberName15}(...)` - : `${storageName15}.${memberName15}`; + ? `${storageName15}${sep15}${memberName15}(...)` + : `${storageName15}${sep15}${memberName15}`; const loc15 = locationOf(src, tok.start); warnings.push({ code: "SYN015", @@ -962,7 +975,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult start: tok.start, end: rangeEnd15, message: - `fn '${decl.name}' accesses ${storageName15}.${memberName15} — ` + + `fn '${decl.name}' accesses ${storageName15}${sep15}${memberName15} — ` + `${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 " { ${unsafeExample15} }`, diff --git a/packages/compiler/tests/syn015-check.test.ts b/packages/compiler/tests/syn015-check.test.ts index cce73706..1b35a511 100644 --- a/packages/compiler/tests/syn015-check.test.ts +++ b/packages/compiler/tests/syn015-check.test.ts @@ -239,4 +239,37 @@ describe("SYN015: localStorage / sessionStorage access detection", () => { const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); }); + + it("does NOT fire on function localStorage(...) JS declaration", () => { + const src = + "?bs 0.7\n" + + "fn useStorage() -> void {\n" + + " function localStorage(key: string) { return key }\n" + + " localStorage(\"x\")\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + }); + + it("does NOT fire on function* localStorage(...) generator declaration", () => { + const src = + "?bs 0.7\n" + + "fn useStorage() -> void {\n" + + " function* localStorage(key: string) { yield key }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + }); + + it("fires on localStorage?.getItem() (optional chaining)", () => { + const src = + "?bs 0.7\n" + + "fn getToken() -> any {\n" + + " return localStorage?.getItem(\"auth\")\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).toContain("localStorage?.getItem"); + }); }); From cac5335e6fa070b0d69f0f099c95b6718ad7879a Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Tue, 16 Jun 2026 07:41:47 -0300 Subject: [PATCH 10/17] fix(syn015): lazy localBindings scan; replace duplicate test; document local-binding exclusion - Make `localBindings` computation lazy: `collectFnBodyLocalNames` is now only called when a `localStorage`/`sessionStorage` token is actually encountered in a fn body, preserving the single-scan-per-fn design - Replace duplicate `localStorage?.getItem` test with `sessionStorage?.getItem` to add coverage for the optional-chain form on sessionStorage - Add local-binding exclusion note to MCP explanation detection section - Update AGENTS.md SYN015 row to mention parameter/local shadowing exclusion Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 2 +- packages/compiler/src/passes/syn-check.ts | 12 +++++++++--- packages/compiler/tests/syn015-check.test.ts | 6 +++--- packages/mcp/src/explanations.ts | 4 +++- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 650e5665..c920b513 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,7 +206,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | 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) }`. | | SYN014 | (0.7+, warning) A fn body calls `new BroadcastChannel(name)`, `BroadcastChannel(name)`, or TypeScript instantiation form `new BroadcastChannel(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 `(` 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 "" { 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 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 " { localStorage.getItem(key) }`. | +| 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 " { localStorage.getItem(key) }`. | | SYN016 | (0.7+, warning) A fn body accesses `indexedDB.*` — any member access (`.`, `?.`) on the `indexedDB` global. `indexedDB` is same-origin persistent database storage invisible to botscript's capability model (`reads {}` / `writes {}` labels cover declared resource identifiers, not the Web Storage API). Unlike `localStorage`, `indexedDB` is asynchronous and has no practical size limit, making invisible access higher-impact. A fn that accesses `indexedDB` has undeclared persistent state dependencies invisible to callers and audit tooling. Detection: `indexedDB` ident not preceded by `.`/`?.`, followed by `.` or `?.`. Bare references and `fn`/`function` declarations named `indexedDB` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass an `IDBDatabase` handle as an explicit fn parameter so callers control what database is accessed and tests can inject a mock. If direct global access is required, wrap in `unsafe "reads/writes indexedDB for " { indexedDB.open(name) }`. | | 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. | diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 62b0c522..7f3e1f71 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -176,8 +176,9 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const open: typeof inner = []; let nextInner = 0; - const localBindings = collectTopLevelParamNames(decl.args); - for (const n of collectFnBodyLocalNames(tokens, decl, inner)) localBindings.add(n); + // Lazily computed on first localStorage/sessionStorage hit to avoid scanning + // every fn body unconditionally — see case "localStorage"/"sessionStorage" below. + let localBindings: Set | null = null; const bodyStart = decl.bodyTokenStart ?? decl.tokenStart; @@ -931,7 +932,12 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (prevBeforeStar15 && prevBeforeStar15.kind === "ident" && prevBeforeStar15.text === "function") continue; } - // Exclude: local bindings — parameters or `const/let/var` named `localStorage`/`sessionStorage` + // 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 diff --git a/packages/compiler/tests/syn015-check.test.ts b/packages/compiler/tests/syn015-check.test.ts index 1b35a511..a428d2ec 100644 --- a/packages/compiler/tests/syn015-check.test.ts +++ b/packages/compiler/tests/syn015-check.test.ts @@ -72,11 +72,11 @@ describe("SYN015: localStorage / sessionStorage access detection", () => { expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); }); - it("fires on localStorage?.getItem(...) optional chaining", () => { + it("fires on sessionStorage?.getItem(...) optional chaining", () => { const src = "?bs 0.7\n" + - "fn getToken() -> any {\n" + - " return localStorage?.getItem(\"auth\")\n" + + "fn getToken(key: string) -> any {\n" + + " return sessionStorage?.getItem(key)\n" + "}\n"; const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 41cbd8e1..2afa18ee 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1050,7 +1050,9 @@ export const EXPLANATIONS: Readonly> = { "**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). " + - "Fn/function declarations named `localStorage`/`sessionStorage` are excluded.\n\n" + + "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" + "**Fix (preferred):** pass a `Storage`-compatible object as an explicit fn parameter. This makes " + "the dependency visible in the fn signature and tests can inject a mock:\n\n" + "```\n" + From 4bbb5831c8970f9e8754c3c0738b48fa191a9dfa Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 03:49:27 -0300 Subject: [PATCH 11/17] =?UTF-8?q?fix(compiler):=20SYN015=20=E2=80=94=20han?= =?UTF-8?q?dle=20computed=20member=20access=20localStorage=3F.[key]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect `localStorage[key]` / `localStorage?.[key]` (computed member) instead of emitting `` placeholder in the diagnostic message - Scan past the closing `]` to correctly determine what follows the computed member (fixes wrong `isCall15` for keys like `localStorage[fn]()`) - Add tests: localStorage?.[key] and sessionStorage?.[key] fire without `` placeholder; replace duplicate optional-chaining slot Co-Authored-By: Botkowski --- packages/compiler/src/passes/syn-check.ts | 34 +++++++++++++++++--- packages/compiler/tests/syn015-check.test.ts | 22 ++++++++++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 7f3e1f71..f7db1648 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -955,17 +955,41 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const sep15 = (next15 && next15.kind === "questionDot") ? "?." : "."; const memberIdx15 = nextSignificant(tokens, nextIdx15 + 1); const memberTok15 = tokens[memberIdx15]; - const memberName15 = (memberTok15 && memberTok15.kind === "ident") ? memberTok15.text : ""; - const rangeEnd15 = (memberTok15 && memberTok15.kind === "ident") ? memberTok15.end : next15!.end; + + // Computed member access: localStorage[key] or localStorage?.[key] + const isComputed15 = !!(memberTok15 && memberTok15.kind === "open" && memberTok15.text === "["); + let memberName15: string; + let rangeEnd15: number; + let afterMemberScanIdx15: number; + if (isComputed15) { + memberName15 = "[…]"; + // Scan past the closing `]` to find what follows the computed member access. + let depth15 = 1; + let j15 = memberIdx15 + 1; + while (j15 < decl.tokenEnd && depth15 > 0) { + const t15 = tokens[j15]; + if (!t15) break; + if (t15.kind === "open" && t15.text === "[") depth15++; + else if (t15.kind === "close" && t15.text === "]") depth15--; + j15++; + } + const closeBracket15 = tokens[j15 - 1]; + rangeEnd15 = closeBracket15 ? closeBracket15.end : next15!.end; + afterMemberScanIdx15 = nextSignificant(tokens, j15); + } else { + memberName15 = (memberTok15 && memberTok15.kind === "ident") ? memberTok15.text : ""; + rangeEnd15 = (memberTok15 && memberTok15.kind === "ident") ? memberTok15.end : next15!.end; + afterMemberScanIdx15 = nextSignificant(tokens, memberIdx15 + 1); + } + // Determine if this is a call: `(` = direct call; `?.(` = optional call; `?.` alone = optional property access. - const afterMemberIdx15 = nextSignificant(tokens, memberIdx15 + 1); - const afterMember15 = tokens[afterMemberIdx15]; + const afterMember15 = tokens[afterMemberScanIdx15]; let isCall15 = false; if (afterMember15 && afterMember15.kind === "open" && afterMember15.text === "(") { isCall15 = true; } else if (afterMember15 && afterMember15.kind === "questionDot") { // Optional call `?.(` — check next token is `(` - const afterQD15 = tokens[nextSignificant(tokens, afterMemberIdx15 + 1)]; + const afterQD15 = tokens[nextSignificant(tokens, afterMemberScanIdx15 + 1)]; isCall15 = !!(afterQD15 && afterQD15.kind === "open" && afterQD15.text === "("); } const unsafeExample15 = isCall15 diff --git a/packages/compiler/tests/syn015-check.test.ts b/packages/compiler/tests/syn015-check.test.ts index a428d2ec..89609e96 100644 --- a/packages/compiler/tests/syn015-check.test.ts +++ b/packages/compiler/tests/syn015-check.test.ts @@ -261,15 +261,29 @@ describe("SYN015: localStorage / sessionStorage access detection", () => { expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); }); - it("fires on localStorage?.getItem() (optional chaining)", () => { + it("fires on localStorage?.[key] (optional computed member) without placeholder", () => { const src = "?bs 0.7\n" + - "fn getToken() -> any {\n" + - " return localStorage?.getItem(\"auth\")\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(""); + expect(w?.message).toContain("localStorage?.["); + }); + + it("fires on sessionStorage?.[key] (optional computed member) without 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).toContain("localStorage?.getItem"); + expect(w?.message).not.toContain(""); + expect(w?.message).toContain("sessionStorage?.["); }); }); From b2f0e59e672de0db47359a35ca97401a24475e85 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 11:37:46 -0300 Subject: [PATCH 12/17] fix(compiler): SYN015 detect localStorage[key] non-optional computed 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 --- packages/compiler/src/passes/syn-check.ts | 38 ++++++++++++-------- packages/compiler/tests/syn015-check.test.ts | 24 +++++++++++++ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 12d301a2..d4b605a8 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -57,13 +57,15 @@ * `cond ? new WebSocket(url) : other`). Generic `` detection only when * preceded by `new` (avoids false-positives on `WebSocket < x > (y)` comparisons). * - * SYN015 A `localStorage.*` or `sessionStorage.*` access was detected in a fn body (?bs 0.7+). - * Both globals are same-origin storage whose reads and writes are invisible to - * botscript's capability model: `reads {}` / `writes {}` labels cover declared resource - * identifiers, not the Web Storage API globals. A fn that accesses `localStorage` or - * `sessionStorage` has undeclared state dependencies invisible to callers and audit - * tooling. (`localStorage` persists across browser sessions; `sessionStorage` is - * per-tab and cleared when the tab closes.) + * SYN015 A `localStorage.*` / `localStorage[key]` or `sessionStorage.*` / `sessionStorage[key]` + * access was detected in a fn body (?bs 0.7+). Both globals are same-origin storage + * whose reads and writes are invisible to botscript's capability model: `reads {}` / + * `writes {}` labels cover declared resource identifiers, not the Web Storage API globals. + * A fn that accesses `localStorage` or `sessionStorage` has undeclared state dependencies + * invisible to callers and audit tooling. (`localStorage` persists across browser sessions; + * `sessionStorage` is per-tab and cleared when the tab closes.) + * Detected forms: `localStorage.key`, `localStorage?.key`, `localStorage[key]`, + * `localStorage?.[key]` (and equivalents for `sessionStorage`). * 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. @@ -1282,32 +1284,38 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult } 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). + // 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 === "questionDot" || + (next15.kind === "open" && next15.text === "[") )) continue; if (isInsideRange(tok.start, unsafeRanges)) continue; const storageName15 = tok.text; - const sep15 = (next15 && next15.kind === "questionDot") ? "?." : "."; + const isDirect15 = next15.kind === "open" && next15.text === "["; + const sep15 = next15.kind === "questionDot" ? "?." : isDirect15 ? "" : "."; const memberIdx15 = nextSignificant(tokens, nextIdx15 + 1); const memberTok15 = tokens[memberIdx15]; - // Computed member access: localStorage[key] or localStorage?.[key] - const isComputed15 = !!(memberTok15 && memberTok15.kind === "open" && memberTok15.text === "["); + // Computed member access: localStorage[key], localStorage?.[key], sessionStorage[key] + const isComputedViaOptChain15 = !!(memberTok15 && memberTok15.kind === "open" && memberTok15.text === "["); + const isComputed15 = isDirect15 || isComputedViaOptChain15; let memberName15: string; let rangeEnd15: number; let afterMemberScanIdx15: number; if (isComputed15) { memberName15 = "[…]"; - // Scan past the closing `]` to find what follows the computed member access. + // Start scanning from the `[` — which is at nextIdx15 for direct access + // (localStorage[key]) or at memberIdx15 for optional-chain (localStorage?.[key]). + const bracketIdx15 = isDirect15 ? nextIdx15 : memberIdx15; let depth15 = 1; - let j15 = memberIdx15 + 1; + let j15 = bracketIdx15 + 1; while (j15 < decl.tokenEnd && depth15 > 0) { const t15 = tokens[j15]; if (!t15) break; diff --git a/packages/compiler/tests/syn015-check.test.ts b/packages/compiler/tests/syn015-check.test.ts index 89609e96..2b36a066 100644 --- a/packages/compiler/tests/syn015-check.test.ts +++ b/packages/compiler/tests/syn015-check.test.ts @@ -286,4 +286,28 @@ describe("SYN015: localStorage / sessionStorage access detection", () => { expect(w?.message).not.toContain(""); expect(w?.message).toContain("sessionStorage?.["); }); + + it("fires on localStorage[key] — non-optional computed member access", () => { + 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).toContain("localStorage["); + }); + + it("fires on sessionStorage[key] — non-optional computed member access", () => { + 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).toContain("sessionStorage["); + }); }); From f0b52b7d0a82fb6236260dde7873657008cb86c1 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 15:43:32 -0300 Subject: [PATCH 13/17] fix(compiler): document computed member access in SYN015 detection descriptions 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 --- AGENTS.md | 2 +- packages/compiler/src/error-codes.ts | 2 +- packages/mcp/src/explanations.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 31abbb9a..3a48c31b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,7 +208,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | 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(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 `(` (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 " { 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 `(` (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 "" { 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(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 `(` 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 "" { 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 " { localStorage.getItem(key) }`. | +| 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 " { localStorage.getItem(key) }`. | | SYN016 | (0.7+, warning) A fn body accesses `indexedDB.*` — any member access (`.`, `?.`) on the `indexedDB` global. `indexedDB` is same-origin persistent database storage invisible to botscript's capability model (`reads {}` / `writes {}` labels cover declared resource identifiers, not the Web Storage API). Unlike `localStorage`, `indexedDB` is asynchronous and has no practical size limit, making invisible access higher-impact. A fn that accesses `indexedDB` has undeclared persistent state dependencies invisible to callers and audit tooling. Detection: `indexedDB` ident not preceded by `.`/`?.`, followed by `.` or `?.`. Bare references and `fn`/`function` declarations named `indexedDB` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass an `IDBDatabase` handle as an explicit fn parameter so callers control what database is accessed and tests can inject a mock. If direct global access is required, wrap in `unsafe "reads/writes indexedDB for " { indexedDB.open(name) }`. | | SYN018 | (0.7+, warning) A fn body calls `Math.random()`, `Math?.random()`, or `Math.random?.()`. `Math.random` generates a random float at runtime but is invisible to botscript's capability model: `uses { random }` covers `random.*` stdlib namespace calls, not the `Math` global. A fn that calls `Math.random()` has an undeclared randomness dependency — callers cannot see it, tests cannot mock or suppress it the way they can the `random` stdlib. Detection: `Math` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member is `random`, followed by `(` or `?.(`. Bare `Math.random` references without a trailing `(` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Replace `Math.random()` with `random.next()` and add `uses { random }` to the fn header. If `Math.random` is required, wrap in `unsafe "uses Math.random for " { Math.random() }`. | | 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 }`. | diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 648d4333..c781944b 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -880,7 +880,7 @@ const E: Record = { code: "SYN015", title: "localStorage / sessionStorage access bypasses the storage capability model", rule: - "`localStorage.*` and `sessionStorage.*` accesses are same-origin storage operations " + + "`localStorage.*`, `localStorage[key]`, `sessionStorage.*`, and `sessionStorage[key]` accesses are 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 state dependencies — no `reads {}` / `writes {}` declaration in the fn header " + diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 1679c913..80646f8a 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1124,8 +1124,8 @@ export const EXPLANATIONS: Readonly> = { "sessions and shared across all tabs on the same origin; `sessionStorage` is per-tab and cleared " + "when the tab closes, but still invisible to the capability model.\n\n" + "**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). " + + "by `.`/`?.` (which would make it a member of another object), followed by `.`, `?.`, or `[` " + + "(confirming this is a member or computed access on the storage global, not a bare reference or a declaration). " + "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" + From eb4013859aa0b87201312279bf4b4bae1fefcbbd Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 19:39:34 -0300 Subject: [PATCH 14/17] =?UTF-8?q?fix(compiler):=20SYN015=20=E2=80=94=20reo?= =?UTF-8?q?rder=20member-access=20check=20before=20localBindings;=20scope?= =?UTF-8?q?=20to=20top-level=20bindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/compiler/src/passes/_callgraph.ts | 17 ++++++++++++++++- packages/compiler/src/passes/syn-check.ts | 19 +++++++++++-------- packages/compiler/tests/syn015-check.test.ts | 12 ++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/passes/_callgraph.ts b/packages/compiler/src/passes/_callgraph.ts index e2cc4c05..3f8ba25e 100644 --- a/packages/compiler/src/passes/_callgraph.ts +++ b/packages/compiler/src/passes/_callgraph.ts @@ -329,11 +329,17 @@ export function collectFnBodyLocalNames( tokens: Token[], fn: FnDecl, inner: FnDecl[], + opts?: { topLevelOnly?: boolean }, ): Set { const names = new Set(); const open: FnDecl[] = []; let nextInner = 0; const start = fn.bodyTokenStart ?? fn.tokenStart; + const topLevelOnly = opts?.topLevelOnly ?? false; + // blockDepth tracks brace nesting for non-function blocks. + // bodyTokenStart points to the function body's opening `{`, so the first `{` + // increments depth to 1; top-level bindings are at depth === 1. + let blockDepth = 0; for (let i = start; i < fn.tokenEnd; i++) { while (open.length > 0 && open[open.length - 1]!.tokenEnd <= i) open.pop(); @@ -344,8 +350,17 @@ export function collectFnBodyLocalNames( if (open.length > 0) continue; const tok = tokens[i]; - if (!tok || tok.kind !== "ident") continue; + if (!tok) continue; + + // Track brace depth for non-function blocks (inner function bodies are already skipped above). + if (tok.kind === "open" && tok.text === "{") { blockDepth++; continue; } + if (tok.kind === "close" && tok.text === "}") { blockDepth--; continue; } + + if (tok.kind !== "ident") continue; 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; const nameIdx = nextSignificant(tokens, i + 1); const nameTok = tokens[nameIdx]; diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index d4b605a8..45d7e71a 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1276,17 +1276,10 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (prev15 && prev15.kind === "keyword" && prev15.text === "fn") continue; if (prev15 && prev15.kind === "operator" && prev15.text === "*" && isFunctionStarDecl(tokens, prevIdx15)) continue; - // 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]. + // Check before localBindings to avoid an unnecessary body scan for bare references. const nextIdx15 = nextSignificant(tokens, i + 1); const next15 = tokens[nextIdx15]; if (!next15 || !( @@ -1295,6 +1288,16 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult (next15.kind === "open" && next15.text === "[") )) continue; + // Exclude: local bindings — parameters or top-level `const/let/var` named + // `localStorage`/`sessionStorage`. Only top-level bindings are collected to avoid + // suppressing the entire function when a block-scoped shadow exists in an inner block + // (e.g. `if (x) { const localStorage = mock }` should not suppress outer-scope reads). + 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; + if (isInsideRange(tok.start, unsafeRanges)) continue; const storageName15 = tok.text; diff --git a/packages/compiler/tests/syn015-check.test.ts b/packages/compiler/tests/syn015-check.test.ts index 2b36a066..5b424984 100644 --- a/packages/compiler/tests/syn015-check.test.ts +++ b/packages/compiler/tests/syn015-check.test.ts @@ -310,4 +310,16 @@ describe("SYN015: localStorage / sessionStorage access detection", () => { const w = result.warnings.find((w) => w.code === "SYN015"); expect(w?.message).toContain("sessionStorage["); }); + + 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); + }); }); From 4afcca97b2a47b463ab4f3b4066310de7d48c9e1 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 23:35:58 -0300 Subject: [PATCH 15/17] fix(syn015): track var bindings in nested blocks (var is fn-scoped) 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 --- packages/compiler/src/passes/_callgraph.ts | 7 ++++--- packages/compiler/tests/syn015-check.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/passes/_callgraph.ts b/packages/compiler/src/passes/_callgraph.ts index 3f8ba25e..8ffa1597 100644 --- a/packages/compiler/src/passes/_callgraph.ts +++ b/packages/compiler/src/passes/_callgraph.ts @@ -358,9 +358,10 @@ export function collectFnBodyLocalNames( if (tok.kind !== "ident") continue; 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; + // When topLevelOnly, skip const/let bindings inside nested blocks (if, for, etc.), + // but keep var — var is function-scoped in JS/TS and shadows the global name for the + // entire function body regardless of where the declaration appears. + if (topLevelOnly && blockDepth > 1 && tok.text !== "var") continue; const nameIdx = nextSignificant(tokens, i + 1); const nameTok = tokens[nameIdx]; diff --git a/packages/compiler/tests/syn015-check.test.ts b/packages/compiler/tests/syn015-check.test.ts index 5b424984..1fc51b63 100644 --- a/packages/compiler/tests/syn015-check.test.ts +++ b/packages/compiler/tests/syn015-check.test.ts @@ -322,4 +322,17 @@ describe("SYN015: localStorage / sessionStorage access detection", () => { const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); }); + + it("does NOT fire when var localStorage is declared inside a nested block — var is fn-scoped", () => { + // `var` is function-scoped: `var localStorage` inside an `if` block shadows the global + // for the entire function body. SYN015 must treat it as a local and suppress. + const src = + "?bs 0.7\n" + + "fn test(cond: boolean) -> string {\n" + + " if (cond) { var localStorage = 'mock' }\n" + + " return localStorage.getItem('key') ?? ''\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + }); }); From e7a8694e0f3e68a317f514d9623304dde0f55b9a Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 18 Jun 2026 11:34:26 -0300 Subject: [PATCH 16/17] fix(syn015): simplify local-binding suppression to param-names only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/compiler/src/passes/syn-check.ts | 12 ++++++------ packages/compiler/tests/syn015-check.test.ts | 17 ++++++++++------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 45d7e71a..603ebc3e 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -162,7 +162,7 @@ import { getErrorCode } from "../error-codes.js"; import { parseProgram } from "../parser/parse.js"; import type { Token } from "../parser/lex.js"; import { locationOf } from "./_location.js"; -import { computeNesting, prevSignificant, nextSignificant, collectTopLevelParamNames, collectFnBodyLocalNames } from "./_callgraph.js"; +import { computeNesting, prevSignificant, nextSignificant, collectTopLevelParamNames } from "./_callgraph.js"; import { atLeast, type VersionInfo } from "./version.js"; import { collectUnsafeBlockRanges, isInsideRange } from "./_unsafe-ranges.js"; @@ -1288,13 +1288,13 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult (next15.kind === "open" && next15.text === "[") )) continue; - // Exclude: local bindings — parameters or top-level `const/let/var` named - // `localStorage`/`sessionStorage`. Only top-level bindings are collected to avoid - // suppressing the entire function when a block-scoped shadow exists in an inner block - // (e.g. `if (x) { const localStorage = mock }` should not suppress outer-scope reads). + // Exclude: fn parameters named localStorage / sessionStorage. + // 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. 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; diff --git a/packages/compiler/tests/syn015-check.test.ts b/packages/compiler/tests/syn015-check.test.ts index 1fc51b63..d2ea8206 100644 --- a/packages/compiler/tests/syn015-check.test.ts +++ b/packages/compiler/tests/syn015-check.test.ts @@ -229,7 +229,10 @@ describe("SYN015: localStorage / sessionStorage access detection", () => { expect(w?.message).toContain("localStorage.setItem"); }); - it("does NOT fire when localStorage is a local const binding", () => { + it("DOES fire when localStorage is a local const binding (body-local shadow not tracked)", () => { + // SYN015 only suppresses parameter-name shadows, not body-local bindings. + // Tracking block-scope const/let/var rebindings would require a full scope walk; + // the parameter case is the only practically relevant shadow to suppress. const src = "?bs 0.7\n" + "fn getToken() -> any {\n" + @@ -237,7 +240,7 @@ describe("SYN015: localStorage / sessionStorage access detection", () => { " return localStorage.getItem(\"auth\")\n" + "}\n"; const result = compile(src); - expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); }); it("does NOT fire on function localStorage(...) JS declaration", () => { @@ -323,16 +326,16 @@ describe("SYN015: localStorage / sessionStorage access detection", () => { expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); }); - it("does NOT fire when var localStorage is declared inside a nested block — var is fn-scoped", () => { - // `var` is function-scoped: `var localStorage` inside an `if` block shadows the global - // for the entire function body. SYN015 must treat it as a local and suppress. + it("DOES fire when var localStorage is declared inside a nested block (body-local shadow not tracked)", () => { + // Even though `var` is function-scoped, SYN015 only suppresses parameter-name shadows. + // Body-local bindings (const/let/var) are not tracked to avoid a full scope walk. const src = "?bs 0.7\n" + - "fn test(cond: boolean) -> string {\n" + + "fn check(cond: boolean) -> string {\n" + " if (cond) { var localStorage = 'mock' }\n" + " return localStorage.getItem('key') ?? ''\n" + "}\n"; const result = compile(src); - expect(result.warnings.some((w) => w.code === "SYN015")).toBe(false); + expect(result.warnings.some((w) => w.code === "SYN015")).toBe(true); }); }); From a40a4f1b7f9ba7f1431dc1e54f6138220a3804a2 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 18 Jun 2026 19:45:06 -0300 Subject: [PATCH 17/17] =?UTF-8?q?docs(syn015):=20align=20doc=20comments=20?= =?UTF-8?q?with=20implementation=20=E2=80=94=20only=20parameter=20shadows?= =?UTF-8?q?=20are=20suppressed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 2 +- packages/compiler/src/passes/syn-check.ts | 7 ++++--- packages/mcp/src/explanations.ts | 5 +++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3a48c31b..1d69e2be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,7 +208,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | 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(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 `(` (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 " { 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 `(` (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 "" { 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(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 `(` 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 "" { 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 " { localStorage.getItem(key) }`. | +| 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. Fn parameters that shadow the global name are excluded (e.g. `fn f(localStorage: Storage)` does not warn); body-local `const`/`let`/`var` bindings are NOT 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 " { localStorage.getItem(key) }`. | | SYN016 | (0.7+, warning) A fn body accesses `indexedDB.*` — any member access (`.`, `?.`) on the `indexedDB` global. `indexedDB` is same-origin persistent database storage invisible to botscript's capability model (`reads {}` / `writes {}` labels cover declared resource identifiers, not the Web Storage API). Unlike `localStorage`, `indexedDB` is asynchronous and has no practical size limit, making invisible access higher-impact. A fn that accesses `indexedDB` has undeclared persistent state dependencies invisible to callers and audit tooling. Detection: `indexedDB` ident not preceded by `.`/`?.`, followed by `.` or `?.`. Bare references and `fn`/`function` declarations named `indexedDB` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass an `IDBDatabase` handle as an explicit fn parameter so callers control what database is accessed and tests can inject a mock. If direct global access is required, wrap in `unsafe "reads/writes indexedDB for " { indexedDB.open(name) }`. | | SYN018 | (0.7+, warning) A fn body calls `Math.random()`, `Math?.random()`, or `Math.random?.()`. `Math.random` generates a random float at runtime but is invisible to botscript's capability model: `uses { random }` covers `random.*` stdlib namespace calls, not the `Math` global. A fn that calls `Math.random()` has an undeclared randomness dependency — callers cannot see it, tests cannot mock or suppress it the way they can the `random` stdlib. Detection: `Math` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member is `random`, followed by `(` or `?.(`. Bare `Math.random` references without a trailing `(` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Replace `Math.random()` with `random.next()` and add `uses { random }` to the fn header. If `Math.random` is required, wrap in `unsafe "uses Math.random for " { Math.random() }`. | | 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 }`. | diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 603ebc3e..363fe396 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -67,8 +67,9 @@ * Detected forms: `localStorage.key`, `localStorage?.key`, `localStorage[key]`, * `localStorage?.[key]` (and equivalents for `sessionStorage`). * 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. + * `localStorage`/`sessionStorage`, fn parameters shadowing the global name, and object + * method shorthands. Body-local `const`/`let`/`var` bindings that shadow the name are + * NOT excluded — only parameter-name shadows are suppressed. * The check fires on any member access (`.` or `?.`): both reads and writes. * * SYN010 A `setTimeout(...)`, `setInterval(...)`, or `queueMicrotask(...)` @@ -1292,7 +1293,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult // 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. + // This mirrors SYN016 which also does not track body-local shadows. if (localBindings === null) { localBindings = collectTopLevelParamNames(decl.args); } diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 80646f8a..39b1aada 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1127,8 +1127,9 @@ export const EXPLANATIONS: Readonly> = { "by `.`/`?.` (which would make it a member of another object), followed by `.`, `?.`, or `[` " + "(confirming this is a member or computed access on the storage global, not a bare reference or a declaration). " + "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" + + "Fn parameters that shadow the global name are excluded — e.g. `fn f(localStorage: Storage)` " + + "will not warn. Body-local `const`/`let`/`var` bindings are NOT excluded; only parameter shadows " + + "are suppressed.\n\n" + "**Fix (preferred):** pass a `Storage`-compatible object as an explicit fn parameter. This makes " + "the dependency visible in the fn signature and tests can inject a mock:\n\n" + "```\n" +