From 38a6cce9be51419ee0623e79ef53550dd00e4667 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sun, 14 Jun 2026 03:46:49 -0300 Subject: [PATCH 01/18] =?UTF-8?q?feat(compiler):=20SYN017=20=E2=80=94=20wa?= =?UTF-8?q?rn=20on=20Notification()=20construction=20that=20bypasses=20the?= =?UTF-8?q?=20capability=20model=20(=3Fbs=200.7+)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SYN017 fires when a fn body constructs or calls `Notification` via: - `new Notification(title[, options])` — constructor form - bare `Notification(title)` — call without new (valid in JS/TS) - `Notification?.(title)` — optional call form - `new Notification(title)` — TypeScript generic instantiation `Notification` dispatches a user-visible browser notification at runtime — a UI side effect entirely invisible to botscript's capability model. No `uses {}`, `reads {}`, or `writes {}` declaration covers notification dispatch. Callers cannot observe, audit, or suppress the effect from the fn's declared surface. This is especially relevant in bot/agent contexts: an autonomously-running fn that calls `new Notification()` creates user-visible interruptions with no auditable capability surface. Detection follows the same architecture as SYN008/SYN012/SYN013/SYN014: - Single dispatch loop case for the `Notification` ident - Member-call exclusion (`.Notification`/`?.Notification`) - fn/function declaration exclusion (including generator `function*`) - Object method shorthand / TS method signature exclusion - Optional-param `?:` fix built in from the start (no ternary-depth miscount) - Generic `(` scan gated on `new` to avoid comparison false-positives - Ternary guard preserving both `cond ? new Notification(a) : ...` forms - Message and rewrite suggestion preserve the exact form used (new/bare/optional) - unsafe block and unsafe fn suppression 18 tests covering all detection/exclusion branches. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 1 + README.md | 2 +- packages/compiler/src/error-codes.ts | 32 +++ packages/compiler/src/passes/syn-check.ts | 123 ++++++++++ packages/compiler/tests/error-codes.test.ts | 2 +- packages/compiler/tests/syn017-check.test.ts | 222 +++++++++++++++++++ packages/mcp/src/explanations.ts | 40 ++++ packages/mcp/tests/server.test.ts | 1 + 8 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/tests/syn017-check.test.ts diff --git a/AGENTS.md b/AGENTS.md index c23bffd4..01484c34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,6 +209,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope. | 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. | | 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) }`. | +| SYN017 | (0.7+, warning) A fn body constructs or calls `Notification` via `new Notification(title)`, bare `Notification(title)`, `Notification?.(title)`, or TypeScript instantiation `new Notification(title)`. `Notification` dispatches a user-visible browser notification at runtime — a UI side effect invisible to botscript's capability model: no `uses {}`, `reads {}`, or `writes {}` declaration covers notification dispatch. Callers cannot observe, audit, or suppress the effect from the fn's declared surface. Detection: `Notification` ident not preceded by `.`/`?.`, followed by `(` or `?.(` (or `(` when `new` precedes). Member calls, fn/function declarations named `Notification`, object method shorthands, and TypeScript method signatures (including optional-param forms) are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass a notification-dispatch callback as an explicit fn parameter so callers control whether a notification fires. If direct access is required, wrap in `unsafe "sends notification for " { new Notification(title, options) }`. | | 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() }`. | | SYN019 | (0.7+, warning) A fn body calls `crypto.getRandomValues(buf)` or `crypto.randomUUID()` (including optional-chain forms `crypto?.getRandomValues(buf)` and optional-call forms `crypto.getRandomValues?.(buf)`). These calls generate cryptographic randomness at runtime but are invisible to botscript's capability model: `uses { random }` covers `random.*` stdlib calls (`random.next()`, `random.int()`), not the `crypto` global. A fn that calls these methods has an undeclared randomness dependency — tests cannot control the output and callers cannot observe the dependency from the fn header. Detection: `crypto` ident not preceded by `.`/`?.`, followed by `.` or `?.`, followed by `getRandomValues` or `randomUUID`, followed by `(` or `?.(`. Member calls (`obj.crypto.getRandomValues(...)`), non-randomness members (e.g. `crypto.subtle.digest(...)`), bare references, and `fn`/`function` declarations named `crypto` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Use `random.next()` or `random.int(min, max)` from the `random` stdlib with `uses { random }` when general randomness is sufficient. If cryptographic randomness or UUIDs are genuinely required, wrap in `unsafe "uses crypto for " { crypto.getRandomValues(buf) }`. | | SYN022 | (0.7+, warning) A fn body accesses `process.argv`, `process.cwd()`, `process.platform`, `process.arch`, `process.pid`, `process.ppid`, `process.version`, `process.versions`, `process.hrtime()`, `process.uptime()`, `process.memoryUsage()`, `process.cpuUsage()`, or `process.resourceUsage()`. These read ambient Node.js process state at runtime — OS identity, process tree, memory, CPU — invisible to botscript's capability model. Unlike `process.env` (SYN005) and `process.exit` (SYN006), which cover configuration and termination respectively, SYN022 targets the remaining ambient introspection surface. Detection: `process` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member in the ambient-state set, optionally followed by `(` or `?.(`. Bare `process.*` references without a trailing `(` still fire. `obj.process.*` member calls, `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. `process.env` and `process.exit` are handled by SYN005/SYN006 and excluded here. | Pass the required ambient value as an explicit fn parameter so the dependency is visible in the call signature and tests can inject a mock. If direct `process.*` access is required at a bootstrap entry point, wrap in `unsafe "reads process state for " { process.argv }`. | diff --git a/README.md b/README.md index 00df835d..9a05c460 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ claude mcp add botscript -- npx -y @mbfarias/botscript-mcp | ----------- | -------------------------------------- | --------------------------------------------------------------------------------------------------- | | `primer` | (no args) | The canonical language primer (same text the `?primer` directive emits). | | `transform` | `{ source: string, filename?: string }` | `{ ok: true, code, forms, version, warnings: [...] }` on success, or `{ ok: false, diagnostics: [...] }` on failure. `warnings` is an array of non-blocking diagnostics (e.g. CAP003). | -| `explain` | `{ code: string }` | Long-form explanation for any stable diagnostic code (`ALI001`, `ALI002`, `ALI003`, `BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`–`DEP004`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN004`, `SYN005`, `SYN006`, `SYN007`, `SYN008`, `SYN010`, `SYN011`, `SYN012`, `SYN013`, `SYN014`, `SYN016`, `SYN018`, `SYN019`, `SYN022`, `SYN023`, `THR001`–`THR004`, `UNS001`–`UNS005`, `VER001`–`VER003`) plus a fails/passes example pair. | +| `explain` | `{ code: string }` | Long-form explanation for any stable diagnostic code (`ALI001`, `ALI002`, `ALI003`, `BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`–`DEP004`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN004`, `SYN005`, `SYN006`, `SYN007`, `SYN008`, `SYN010`, `SYN011`, `SYN012`, `SYN013`, `SYN014`, `SYN016`, `SYN017`, `SYN018`, `SYN019`, `SYN022`, `SYN023`, `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 1bd18e33..5d583a38 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -914,6 +914,38 @@ const E: Record = { " return new Promise((resolve) => { req.onsuccess = (e) => resolve(e.target.result) })\n" + "}", }, + SYN017: { + code: "SYN017", + title: "Notification() construction bypasses the capability model", + rule: + "`new Notification(title)` and bare `Notification(title)` calls create user-visible browser " + + "notifications at runtime — a side effect entirely invisible to botscript's capability model. " + + "No `uses {}`, `reads {}`, or `writes {}` declaration covers notification dispatch: callers " + + "cannot observe, audit, or suppress the UI effect from the fn's declared surface.", + idiom: + "accept a notification-dispatch callback as an explicit fn parameter so callers control " + + "whether a notification is shown and tests can capture or suppress it; " + + "if direct `Notification` access is required, wrap in " + + "`unsafe \"sends browser notification for \" { new Notification(title, options) }`", + rewrite: + "// before — notification dispatch invisible to the capability model\n" + + "fn alertUser(msg: string) -> void {\n" + + " new Notification(msg) // SYN017\n" + + "}\n\n" + + "// after — dispatch function passed as parameter; callers control UI side effect\n" + + "fn alertUser(notify: (msg: string) => void, msg: string) -> void {\n" + + " notify(msg)\n" + + "}", + example: + "// SYN017: Notification dispatch bypasses the capability model\n" + + "fn warnUser(title: string, body: string) -> void {\n" + + " new Notification(title, { body }) // SYN017\n" + + "}\n\n" + + "// fix: wrap in unsafe with a reason\n" + + "fn warnUser(title: string, body: string) -> void {\n" + + " unsafe \"shows alert notification for user-triggered warning\" { new Notification(title, { body }) }\n" + + "}", + }, SYN018: { code: "SYN018", title: "Math.random() call bypasses the random capability model", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index a9f192c6..060363d4 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). * + * SYN017 A `new Notification(title)`, `Notification(title)`, or TypeScript instantiation + * form `new Notification(title)` was detected in a fn body (?bs 0.7+). + * `Notification` fires a user-visible browser notification at runtime — a UI side + * effect invisible to botscript's capability model: no `uses {}`, `reads {}`, or + * `writes {}` declaration covers notification dispatch. Callers cannot observe, + * audit, or suppress the effect from the fn's declared surface. + * Excluded: member calls (`obj.Notification`), `function`/`fn` declarations named + * `Notification`, object/class method shorthands, and TypeScript method signatures. + * The `:` exclusion is guarded against ternary consequents. + * * 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 @@ -240,6 +250,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult const syn013 = getErrorCode("SYN013")!; const syn014 = getErrorCode("SYN014")!; const syn016 = getErrorCode("SYN016")!; + const syn017 = getErrorCode("SYN017")!; const syn018 = getErrorCode("SYN018")!; const syn019 = getErrorCode("SYN019")!; const syn022 = getErrorCode("SYN022")!; @@ -1328,6 +1339,118 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult break; } + // ── SYN017: new Notification() / Notification() call ───────────────── + case "Notification": { + // Exclude: `obj.Notification(...)` — preceded by `.` or `?.` + const prevIdx17 = prevSignificant(tokens, i - 1); + const prev17 = tokens[prevIdx17]; + if (prev17 && ((prev17.kind === "punct" && prev17.text === ".") || prev17.kind === "questionDot")) + continue; + + // Exclude: function/fn declarations named Notification + if (prev17 && prev17.kind === "ident" && prev17.text === "function") continue; + if (prev17 && prev17.kind === "keyword" && prev17.text === "fn") continue; + + const hasNew17 = prev17 && prev17.kind === "ident" && prev17.text === "new"; + // Ternary guard: `cond ? new Notification(title) : other` + const prevBeforeNew17 = hasNew17 + ? tokens[prevSignificant(tokens, prevIdx17 - 1)] + : undefined; + const isTernaryConsequent17 = + (prev17 !== undefined && prev17 !== null && prev17.kind === "question") || + (prevBeforeNew17 !== undefined && prevBeforeNew17 !== null && prevBeforeNew17.kind === "question"); + + const nextIdx17 = nextSignificant(tokens, i + 1); + const next17 = tokens[nextIdx17]; + + let isOpt17 = false; + let callIdx17 = nextIdx17; + + if (next17 && next17.kind === "questionDot") { + // Notification?.( — optional call + isOpt17 = true; + callIdx17 = nextSignificant(tokens, nextIdx17 + 1); + } else if (hasNew17 && next17 && next17.kind === "operator" && next17.text === "<") { + // new Notification( — generic scan only when `new` precedes + let depth = 1; + let j = nextIdx17 + 1; + while (j < tokens.length && depth > 0) { + const t = tokens[j]; + if (!t) break; + if (t.kind === "operator" && t.text === "<") depth++; + else if (t.kind === "operator" && (t.text === ">" || t.text === ">>" || t.text === ">>>")) + depth = Math.max(0, depth - t.text.length); + j++; + } + callIdx17 = nextSignificant(tokens, j); + } + + const callTok17 = tokens[callIdx17]; + if (!callTok17 || !(callTok17.kind === "open" && callTok17.text === "(")) continue; + + // Exclude method shorthands and TS method signatures. + if (callTok17.matchedAt !== undefined) { + const afterCloseIdx17 = nextSignificant(tokens, callTok17.matchedAt + 1); + const afterClose17 = tokens[afterCloseIdx17]; + if (afterClose17 && ( + (afterClose17.kind === "open" && afterClose17.text === "{") || + afterClose17.kind === "fatArrow" || + (!isTernaryConsequent17 && afterClose17.kind === "punct" && afterClose17.text === ":") + )) continue; + // Exclude TS method signatures with omitted return type or optional params: + // `{ Notification(title: string) }` / `{ Notification(title?: string) }`. + let hasTypeAnnotation17 = false; + let depth17 = 0; + let ternaryDepth17 = 0; + for (let k17 = callIdx17 + 1; k17 < callTok17.matchedAt; k17++) { + const at17 = tokens[k17]; + if (!at17) continue; + if (at17.kind === "open") { depth17++; continue; } + if (at17.kind === "close") { depth17--; continue; } + if (depth17 !== 0) continue; + if (at17.kind === "question") { + const nextAfterQ17 = nextSignificant(tokens, k17 + 1); + const nextTokQ17 = tokens[nextAfterQ17]; + if (nextTokQ17 && nextTokQ17.kind === "punct" && nextTokQ17.text === ":") { + hasTypeAnnotation17 = true; + break; + } + ternaryDepth17++; + continue; + } + if (at17.kind === "punct" && at17.text === ":") { + if (ternaryDepth17 > 0) { ternaryDepth17--; continue; } + hasTypeAnnotation17 = true; + break; + } + } + if (hasTypeAnnotation17) continue; + } + + if (isInsideRange(tok.start, unsafeRanges)) continue; + + const callSep17 = isOpt17 ? "?." : ""; + const warnStart17 = hasNew17 ? prev17!.start : tok.start; + const loc17 = locationOf(src, warnStart17); + warnings.push({ + code: "SYN017", + severity: "warning", + file: null, + line: loc17.line, + column: loc17.column, + start: warnStart17, + end: callTok17.start + 1, + message: + `fn '${decl.name}' ${hasNew17 ? "constructs new " : "calls "}Notification${callSep17}() — ` + + `Notification fires a user-visible browser notification invisible to the capability model; ` + + `wrap in unsafe "sends notification for " { ${hasNew17 ? "new " : ""}Notification${callSep17}(title, options) }`, + rule: syn017.rule, + idiom: syn017.idiom, + rewrite: syn017.rewrite, + }); + break; + } + // ── SYN018: Math.random() ──────────────────────────────────────────── case "Math": { // Exclude: `obj.Math.random(...)` — Math preceded by `.` or `?.` diff --git a/packages/compiler/tests/error-codes.test.ts b/packages/compiler/tests/error-codes.test.ts index 8c9bfdf2..5d061660 100644 --- a/packages/compiler/tests/error-codes.test.ts +++ b/packages/compiler/tests/error-codes.test.ts @@ -15,7 +15,7 @@ describe("error-code registry", () => { "INT001", "INT002", "INT003", "INT004", "INT005", "MAT001", "MAT002", "MAT003", "MAT004", "RES001", "RES002", - "SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN010", "SYN011", "SYN012", "SYN013", "SYN014", "SYN016", "SYN018", "SYN019", "SYN022", "SYN023", + "SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN010", "SYN011", "SYN012", "SYN013", "SYN014", "SYN016", "SYN017", "SYN018", "SYN019", "SYN022", "SYN023", "THR001", "THR002", "THR003", "THR004", "UNS001", "UNS002", "UNS003", "UNS004", "UNS005", "VER001", "VER002", "VER003", diff --git a/packages/compiler/tests/syn017-check.test.ts b/packages/compiler/tests/syn017-check.test.ts new file mode 100644 index 00000000..c3e48b11 --- /dev/null +++ b/packages/compiler/tests/syn017-check.test.ts @@ -0,0 +1,222 @@ +/** + * Tests for SYN017: Notification() construction detection (?bs 0.7+). + * + * SYN017 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("SYN017: Notification() construction detection", () => { + it("fires on new Notification(title) inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn alert(title: string) -> void {\n" + + " new Notification(title)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(true); + }); + + it("fires on bare Notification(title) without new", () => { + const src = + "?bs 0.7\n" + + "fn alert(title: string) -> void {\n" + + " Notification(title)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(true); + }); + + it("fires on TypeScript instantiation form new Notification(title)", () => { + const src = + "?bs 0.7\n" + + "fn alert(title: string) -> void {\n" + + " new Notification(title)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(true); + }); + + it("fires on Notification?.(title) optional call form", () => { + const src = + "?bs 0.7\n" + + "fn alert(title: string) -> void {\n" + + " Notification?.(title)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(true); + }); + + it("does NOT fire below ?bs 0.7", () => { + const src = + "?bs 0.6\n" + + "fn alert(title: string) -> void {\n" + + " new Notification(title)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + + it("does NOT fire inside an unsafe block", () => { + const src = + "?bs 0.7\n" + + "fn alert(title: string) -> void {\n" + + ' unsafe "sends alert notification for user action" { new Notification(title) }\n' + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + + it("does NOT fire inside an unsafe fn body", () => { + const src = + "?bs 0.7\n" + + 'unsafe "notification factory" fn alert(title: string) -> void {\n' + + " new Notification(title)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + + it("does NOT fire on obj.Notification(...) — member call on a local", () => { + const src = + "?bs 0.7\n" + + "fn alert(obj: any, title: string) -> void {\n" + + " obj.Notification(title)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + + it("does NOT fire on bare Notification reference (not called)", () => { + const src = + "?bs 0.7\n" + + "fn getConstructor() -> any {\n" + + " return Notification\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + + it("does NOT fire on object method shorthands named Notification", () => { + const src = + "?bs 0.7\n" + + "fn makeObj(title: string) -> any {\n" + + " return { Notification(t: string) { return t } }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + + it("does NOT fire on fn Notification(...) botscript declarations", () => { + const src = + "?bs 0.7\n" + + "fn Notification(title: string) -> void {\n" + + " return\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + + it("does NOT fire on JS function Notification() declaration inside a fn body", () => { + const src = + "?bs 0.7\n" + + "fn outer(title: string) -> void {\n" + + " function Notification(t: string) { return }\n" + + " return\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + + it("does NOT fire on TS type-literal method signature without return type", () => { + const src = + "?bs 0.7\n" + + "fn outer() -> any {\n" + + " type T = { Notification(title: string) }\n" + + " return null\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + + it("does NOT fire on TS type-literal method signature with optional parameter", () => { + const src = + "?bs 0.7\n" + + "fn outer() -> any {\n" + + " type T = { Notification(title?: string) }\n" + + " return null\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + + it("fires in ternary consequent — not suppressed by trailing ':'", () => { + const src = + "?bs 0.7\n" + + "fn pick(cond: boolean, a: string, b: string) -> void {\n" + + " cond ? new Notification(a) : new Notification(b)\n" + + "}\n"; + const result = compile(src); + const codes = result.warnings.filter((w) => w.code === "SYN017"); + expect(codes.length).toBe(2); + }); + + it("message says 'constructs new Notification' for new Notification form", () => { + const src = + "?bs 0.7\n" + + "fn alert(title: string) -> void {\n" + + " new Notification(title)\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN017"); + expect(w?.message).toContain("constructs new Notification()"); + }); + + it("message says 'calls Notification' for bare Notification(title) form", () => { + const src = + "?bs 0.7\n" + + "fn alert(title: string) -> void {\n" + + " Notification(title)\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN017"); + expect(w?.message).toContain("calls Notification()"); + }); + + it("message preserves ?. for Notification?.() optional call form", () => { + const src = + "?bs 0.7\n" + + "fn alert(title: string) -> void {\n" + + " Notification?.(title)\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN017"); + expect(w?.message).toContain("calls Notification?.()"); + }); + + it("severity is warning", () => { + const src = + "?bs 0.7\n" + + "fn alert(title: string) -> void {\n" + + " new Notification(title)\n" + + "}\n"; + const result = compile(src); + const w = result.warnings.find((w) => w.code === "SYN017"); + expect(w?.severity).toBe("warning"); + }); + + it("does NOT false-fire on Notification < x > (y) comparison expression", () => { + const src = + "?bs 0.7\n" + + "fn compare(Notification: number, x: number, y: number) -> boolean {\n" + + " return Notification < x > (y)\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index e6f5a6a0..c24e2882 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1155,6 +1155,46 @@ export const EXPLANATIONS: Readonly> = { "}\n", }, }, + SYN017: { + code: "SYN017", + title: "Notification() / new Notification() call bypasses the capability model", + body: + "SYN017 fires when a fn body constructs a `Notification` via `new Notification(title)`, " + + "bare `Notification(title)`, `Notification?.(title)`, or TypeScript instantiation " + + "`new Notification(title)`.\n\n" + + "**Why it matters:** `Notification` dispatches a user-visible browser notification at " + + "runtime — a UI side effect that is entirely invisible to botscript's capability model. " + + "No `uses {}`, `reads {}`, or `writes {}` declaration covers notification dispatch. " + + "Callers reading the fn header see no indication that the fn can create system-level " + + "notifications; tests cannot intercept or suppress the effect without global mocking. " + + "In agent / bot contexts this is especially hazardous: an autonomously-running bot that " + + "calls `new Notification()` creates user-visible interruptions with no observable " + + "capability surface for callers to audit or gate.\n\n" + + "**Detection:** the check looks for a `Notification` ident token not preceded by " + + "`.`/`?.` (member-call exclusion), followed by `(` or `?.(` — or `(` when preceded " + + "by `new` (generic scan gated on `new` to avoid `<`/`>` comparison false-positives). " + + "Object/class method shorthands, TypeScript method signatures (including optional-param " + + "forms like `{ Notification(title?: string) }`), and `fn`/`function` declarations " + + "named `Notification` are excluded. The `:` check is guarded against ternary " + + "consequents (`cond ? new Notification(a) : ...`).\n\n" + + "**Fix:** pass a notification-dispatch callback as an explicit fn parameter so callers " + + "control whether a notification fires and tests can capture or suppress it. If direct " + + "access is required, wrap in `unsafe \"sends notification for \" { new Notification(title, options) }`.\n\n" + + "SYN017 fires at `?bs 0.7+` as a non-blocking warning. " + + "Calls inside `unsafe { }` blocks or `unsafe \"reason\" fn` bodies are suppressed.", + example: { + fails: + "?bs 0.7\n" + + "fn warnUser(title: string, body: string) -> void {\n" + + " new Notification(title, { body })\n" + + "}\n", + passes: + "?bs 0.7\n" + + "fn warnUser(title: string, body: string) -> void {\n" + + " unsafe \"shows alert notification for user-triggered warning\" { new Notification(title, { body }) }\n" + + "}\n", + }, + }, SYN018: { code: "SYN018", title: "Math.random() call bypasses the random capability model", diff --git a/packages/mcp/tests/server.test.ts b/packages/mcp/tests/server.test.ts index 780ef193..eb13233a 100644 --- a/packages/mcp/tests/server.test.ts +++ b/packages/mcp/tests/server.test.ts @@ -86,6 +86,7 @@ describe("botscript-mcp explanations", () => { "SYN013", "SYN014", "SYN016", + "SYN017", "SYN018", "SYN019", "SYN022", From f8f7405dde17f3988ae34236b086acbacc4abafb Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sun, 14 Jun 2026 07:26:43 -0300 Subject: [PATCH 02/18] =?UTF-8?q?fix(compiler):=20address=20Copilot=20revi?= =?UTF-8?q?ew=20on=20SYN017=20=E2=80=94=20add=20optional-call=20and=20gene?= =?UTF-8?q?ric=20forms=20to=20header=20comment=20and=20rule=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Botkowski --- packages/compiler/src/error-codes.ts | 3 ++- packages/compiler/src/passes/syn-check.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 5d583a38..82cd5cdb 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -918,7 +918,8 @@ const E: Record = { code: "SYN017", title: "Notification() construction bypasses the capability model", rule: - "`new Notification(title)` and bare `Notification(title)` calls create user-visible browser " + + "`new Notification(title)`, bare `Notification(title)`, optional-call `Notification?.(title)`, " + + "and TypeScript generic form `new Notification(title)` calls create user-visible browser " + "notifications at runtime — a side effect entirely invisible to botscript's capability model. " + "No `uses {}`, `reads {}`, or `writes {}` declaration covers notification dispatch: callers " + "cannot observe, audit, or suppress the UI effect from the fn's declared surface.", diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 060363d4..09b7759f 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -57,8 +57,8 @@ * `cond ? new WebSocket(url) : other`). Generic `` detection only when * preceded by `new` (avoids false-positives on `WebSocket < x > (y)` comparisons). * - * SYN017 A `new Notification(title)`, `Notification(title)`, or TypeScript instantiation - * form `new Notification(title)` was detected in a fn body (?bs 0.7+). + * SYN017 A `new Notification(title)`, `Notification(title)`, `Notification?.(title)`, or + * TypeScript instantiation form `new Notification(title)` was detected in a fn body (?bs 0.7+). * `Notification` fires a user-visible browser notification at runtime — a UI side * effect invisible to botscript's capability model: no `uses {}`, `reads {}`, or * `writes {}` declaration covers notification dispatch. Callers cannot observe, From 02261b790ea5808dc632dfeb771980ebd95d2a1c Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sun, 14 Jun 2026 15:31:28 -0300 Subject: [PATCH 03/18] =?UTF-8?q?fix(syn017):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20generator=20declaration=20exclusion=20and=20awai?= =?UTF-8?q?t-ternary=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Exclude `function* Notification(...)` generator declarations (prev token is `*`; check that token before it is `function`) — same pattern as SYN016. - Extend ternary guard to cover `cond ? await Notification(title) : ...` and `cond ? await new Notification(title) : ...` so the trailing `:` is not mistaken for a method-signature return type (false negative). - Add 4 regression tests covering both fixes. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 21 ++++++++++++-- packages/compiler/tests/syn017-check.test.ts | 30 ++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 09b7759f..afef664c 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1347,18 +1347,33 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (prev17 && ((prev17.kind === "punct" && prev17.text === ".") || prev17.kind === "questionDot")) continue; - // Exclude: function/fn declarations named Notification + // Exclude: function/fn/function* declarations named Notification if (prev17 && prev17.kind === "ident" && prev17.text === "function") continue; if (prev17 && prev17.kind === "keyword" && prev17.text === "fn") continue; + // Generator: `function* Notification` — prev token is `*`, token before that is `function` + if (prev17 && prev17.kind === "punct" && prev17.text === "*") { + const prevPrevIdx17 = prevSignificant(tokens, prevIdx17 - 1); + const prevPrev17 = tokens[prevPrevIdx17]; + if (prevPrev17 && prevPrev17.kind === "ident" && prevPrev17.text === "function") continue; + } const hasNew17 = prev17 && prev17.kind === "ident" && prev17.text === "new"; - // Ternary guard: `cond ? new Notification(title) : other` + // Ternary guard: `cond ? Notification(title) : other`, `cond ? new Notification(title) : other`, + // `cond ? await Notification(title) : other`, `cond ? await new Notification(title) : other` const prevBeforeNew17 = hasNew17 ? tokens[prevSignificant(tokens, prevIdx17 - 1)] : undefined; + // Look through `await` between ternary `?` and the call/construction + const awaitIdx17 = (!hasNew17 && prev17 && prev17.kind === "ident" && prev17.text === "await") + ? prevIdx17 + : (prevBeforeNew17 && prevBeforeNew17.kind === "ident" && prevBeforeNew17.text === "await") + ? prevSignificant(tokens, prevIdx17 - 1) + : -1; + const prevBeforeAwait17 = awaitIdx17 >= 0 ? tokens[prevSignificant(tokens, awaitIdx17 - 1)] : undefined; const isTernaryConsequent17 = (prev17 !== undefined && prev17 !== null && prev17.kind === "question") || - (prevBeforeNew17 !== undefined && prevBeforeNew17 !== null && prevBeforeNew17.kind === "question"); + (prevBeforeNew17 !== undefined && prevBeforeNew17 !== null && prevBeforeNew17.kind === "question") || + (prevBeforeAwait17 !== undefined && prevBeforeAwait17 !== null && prevBeforeAwait17.kind === "question"); const nextIdx17 = nextSignificant(tokens, i + 1); const next17 = tokens[nextIdx17]; diff --git a/packages/compiler/tests/syn017-check.test.ts b/packages/compiler/tests/syn017-check.test.ts index c3e48b11..e365a1e7 100644 --- a/packages/compiler/tests/syn017-check.test.ts +++ b/packages/compiler/tests/syn017-check.test.ts @@ -219,4 +219,34 @@ describe("SYN017: Notification() construction detection", () => { const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); }); + + it("does NOT fire on function* Notification() generator declaration", () => { + const src = + "?bs 0.7\n" + + "fn outer() -> void {\n" + + " function* Notification(title: string) { yield title }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + + it("fires on Notification() in ternary consequent with await (not a method signature)", () => { + const src = + "?bs 0.7\n" + + "fn check(cond: boolean, title: string) -> void {\n" + + " cond ? await Notification(title) : null\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(true); + }); + + it("fires on new Notification() in ternary consequent with await (not a method signature)", () => { + const src = + "?bs 0.7\n" + + "fn check(cond: boolean, title: string) -> void {\n" + + " cond ? await new Notification(title) : null\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(true); + }); }); From c49f84250eb29889a636fc3bd6ae8354367bcc98 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Sun, 14 Jun 2026 19:33:53 -0300 Subject: [PATCH 04/18] fix(syn017): use operator token kind for generator * exclusion The lexer emits * as kind="operator", not "punct". The explicit generator exclusion check used the wrong kind and never matched. Generator declarations are caught in practice by the {-after-) shorthand exclusion, but the explicit check is now semantically correct. Co-Authored-By: Claude Sonnet 4.6 --- 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 afef664c..f3676cd7 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1350,8 +1350,8 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult // Exclude: function/fn/function* declarations named Notification if (prev17 && prev17.kind === "ident" && prev17.text === "function") continue; if (prev17 && prev17.kind === "keyword" && prev17.text === "fn") continue; - // Generator: `function* Notification` — prev token is `*`, token before that is `function` - if (prev17 && prev17.kind === "punct" && prev17.text === "*") { + // Generator: `function* Notification` — prev token is `*` (operator kind), token before that is `function` + if (prev17 && prev17.kind === "operator" && prev17.text === "*") { const prevPrevIdx17 = prevSignificant(tokens, prevIdx17 - 1); const prevPrev17 = tokens[prevPrevIdx17]; if (prevPrev17 && prevPrev17.kind === "ident" && prevPrev17.text === "function") continue; From 55bd487bc5c8b4946caa145472f55facecae2d3e Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Mon, 15 Jun 2026 19:46:35 -0300 Subject: [PATCH 05/18] fix(syn017): exclude type-literal method signatures with no type annotations Copilot flagged that `type T = { Notification() }` inside a fn body incorrectly fires SYN017. The existing check catches `{ Notification(): T }` (`:` after `)`) and `{ Notification(title: string) }` (`:` inside parens), but missed the no- annotation case where `)` is followed immediately by `}`. Added: when afterClose17 is a `}` (close brace), find the matched `{` and check what precedes it. If preceded by `=` (type alias: `type T = { ... }`) or `:` (type annotation: `x: { ... }`), treat it as a type context and skip. The `=` token has kind `"eq"` in the lexer, so matched accordingly. Adds a regression test for `type T = { Notification() }`. Co-Authored-By: Botkowski --- packages/compiler/src/passes/syn-check.ts | 15 +++++++++++++++ packages/compiler/tests/syn017-check.test.ts | 11 +++++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index f3676cd7..b5af6d76 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1440,6 +1440,21 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult } } if (hasTypeAnnotation17) continue; + + // Exclude TS type-literal method signatures with no annotations at all: + // `{ Notification() }` — empty parens, `}` immediately after `)`. + // Find the `{` matched by that `}` and check if it was opened in a type context + // (preceded by `=` or `:`). This is the only case not caught by the type annotation scan. + if (afterClose17 && afterClose17.kind === "close" && afterClose17.text === "}" && + afterClose17.matchedAt !== undefined) { + const openBraceIdx17 = afterClose17.matchedAt; + const prevOpenIdx17 = prevSignificant(tokens, openBraceIdx17 - 1); + const prevOpen17 = tokens[prevOpenIdx17]; + if (prevOpen17 && ( + prevOpen17.kind === "eq" || // type T = { ... } + (prevOpen17.kind === "punct" && prevOpen17.text === ":") // x: { ... } + )) continue; + } } if (isInsideRange(tok.start, unsafeRanges)) continue; diff --git a/packages/compiler/tests/syn017-check.test.ts b/packages/compiler/tests/syn017-check.test.ts index e365a1e7..ed7692c7 100644 --- a/packages/compiler/tests/syn017-check.test.ts +++ b/packages/compiler/tests/syn017-check.test.ts @@ -249,4 +249,15 @@ describe("SYN017: Notification() construction detection", () => { const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN017")).toBe(true); }); + + it("does NOT fire on TS type-literal method signature with no annotations: type T = { Notification() }", () => { + // Empty parens + `}` after `)`, and the enclosing `{` is preceded by `=` → type literal context + const src = + "?bs 0.7\n" + + "fn setup() -> void {\n" + + " type T = { Notification() }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); }); From 13449e5a61139344dad519592e748c06010d55a3 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Tue, 16 Jun 2026 07:38:50 -0300 Subject: [PATCH 06/18] fix(syn017): narrow type-literal exclusion to guard against object-literal false negative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `{ Notification() }` exclusion was skipping any Notification() call whose closing `)` was followed by `}` with the matched `{` preceded by `=` or `:`. This also suppressed `const o = { x: Notification() }` because the outer object-literal `{` is preceded by `=`. Fix: add condition (b) — only exclude when Notification is the first significant token inside the matched `{`. Type-literal method signatures (`type T = { Notification() }`) have Notification immediately after `{`; object literals with other properties do not. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 10 +++++++--- packages/compiler/tests/syn017-check.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index b5af6d76..abb2b655 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1443,14 +1443,18 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult // Exclude TS type-literal method signatures with no annotations at all: // `{ Notification() }` — empty parens, `}` immediately after `)`. - // Find the `{` matched by that `}` and check if it was opened in a type context - // (preceded by `=` or `:`). This is the only case not caught by the type annotation scan. + // Conditions: (a) enclosing `{` is in a type context (preceded by `=` or `:`), + // (b) `Notification` is the first significant token inside that `{`. + // Condition (b) guards against `const o = { x: Notification() }` where the + // outer `{` is also preceded by `=` but `Notification` is not the first token. if (afterClose17 && afterClose17.kind === "close" && afterClose17.text === "}" && afterClose17.matchedAt !== undefined) { const openBraceIdx17 = afterClose17.matchedAt; const prevOpenIdx17 = prevSignificant(tokens, openBraceIdx17 - 1); const prevOpen17 = tokens[prevOpenIdx17]; - if (prevOpen17 && ( + const firstInsideBraceIdx17 = nextSignificant(tokens, openBraceIdx17 + 1); + const isNotificationFirst17 = firstInsideBraceIdx17 === i; + if (isNotificationFirst17 && prevOpen17 && ( prevOpen17.kind === "eq" || // type T = { ... } (prevOpen17.kind === "punct" && prevOpen17.text === ":") // x: { ... } )) continue; diff --git a/packages/compiler/tests/syn017-check.test.ts b/packages/compiler/tests/syn017-check.test.ts index ed7692c7..2f889f63 100644 --- a/packages/compiler/tests/syn017-check.test.ts +++ b/packages/compiler/tests/syn017-check.test.ts @@ -260,4 +260,16 @@ describe("SYN017: Notification() construction detection", () => { const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); }); + + it("DOES fire on Notification() as last expression in an object literal", () => { + // `const o = { x: Notification() }` — the outer `{` is preceded by `=` but + // Notification is NOT the first token inside it, so this is not a type literal. + const src = + "?bs 0.7\n" + + "fn setup() -> void {\n" + + " const o = { x: Notification('title') }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(true); + }); }); From 863c774646c824e819d360b4d36816280aeda2b6 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Tue, 16 Jun 2026 11:32:15 -0300 Subject: [PATCH 07/18] docs(mcp): fix grammar nit in SYN017 explanation text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "a \`Notification\` ident token" → "an identifier token \`Notification\`" Avoids internal lexer jargon in user-facing documentation. Addresses Copilot review comment from the APPROVED review. Co-Authored-By: Claude Sonnet 4.6 --- packages/mcp/src/explanations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index c24e2882..39a9a118 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1170,7 +1170,7 @@ export const EXPLANATIONS: Readonly> = { "In agent / bot contexts this is especially hazardous: an autonomously-running bot that " + "calls `new Notification()` creates user-visible interruptions with no observable " + "capability surface for callers to audit or gate.\n\n" + - "**Detection:** the check looks for a `Notification` ident token not preceded by " + + "**Detection:** the check looks for an identifier token `Notification` not preceded by " + "`.`/`?.` (member-call exclusion), followed by `(` or `?.(` — or `(` when preceded " + "by `new` (generic scan gated on `new` to avoid `<`/`>` comparison false-positives). " + "Object/class method shorthands, TypeScript method signatures (including optional-param " + From 55797d7945c11040d72af77390032666b78558f4 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Tue, 16 Jun 2026 15:43:23 -0300 Subject: [PATCH 08/18] fix(syn017): extend type-literal exclusion to handle semicolon/comma separators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit { Notification(); } and { Notification(), } are valid TypeScript type-literal method signatures but were not excluded — the guard only checked for `}` directly after `)`. Now also skips past `;`/`,` separators before looking for the enclosing `}` in type context. Regression tests added for both separator forms. --- packages/compiler/src/passes/syn-check.ts | 34 +++++++++++++------- packages/compiler/tests/syn017-check.test.ts | 20 ++++++++++++ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index abb2b655..cf6047f1 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1442,22 +1442,32 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (hasTypeAnnotation17) continue; // Exclude TS type-literal method signatures with no annotations at all: - // `{ Notification() }` — empty parens, `}` immediately after `)`. + // `{ Notification() }`, `{ Notification(); }`, `{ Notification(), }` — empty parens. + // The token after `)` may be `}` directly, or a separator (`;` / `,`) then `}`. // Conditions: (a) enclosing `{` is in a type context (preceded by `=` or `:`), // (b) `Notification` is the first significant token inside that `{`. // Condition (b) guards against `const o = { x: Notification() }` where the // outer `{` is also preceded by `=` but `Notification` is not the first token. - if (afterClose17 && afterClose17.kind === "close" && afterClose17.text === "}" && - afterClose17.matchedAt !== undefined) { - const openBraceIdx17 = afterClose17.matchedAt; - const prevOpenIdx17 = prevSignificant(tokens, openBraceIdx17 - 1); - const prevOpen17 = tokens[prevOpenIdx17]; - const firstInsideBraceIdx17 = nextSignificant(tokens, openBraceIdx17 + 1); - const isNotificationFirst17 = firstInsideBraceIdx17 === i; - if (isNotificationFirst17 && prevOpen17 && ( - prevOpen17.kind === "eq" || // type T = { ... } - (prevOpen17.kind === "punct" && prevOpen17.text === ":") // x: { ... } - )) continue; + { + let closeBrace17 = afterClose17; + if (closeBrace17 && + closeBrace17.kind === "punct" && + (closeBrace17.text === ";" || closeBrace17.text === ",")) { + const nextAfterSepIdx17 = nextSignificant(tokens, afterCloseIdx17 + 1); + closeBrace17 = tokens[nextAfterSepIdx17]; + } + if (closeBrace17 && closeBrace17.kind === "close" && closeBrace17.text === "}" && + closeBrace17.matchedAt !== undefined) { + const openBraceIdx17 = closeBrace17.matchedAt; + const prevOpenIdx17 = prevSignificant(tokens, openBraceIdx17 - 1); + const prevOpen17 = tokens[prevOpenIdx17]; + const firstInsideBraceIdx17 = nextSignificant(tokens, openBraceIdx17 + 1); + const isNotificationFirst17 = firstInsideBraceIdx17 === i; + if (isNotificationFirst17 && prevOpen17 && ( + prevOpen17.kind === "eq" || // type T = { ... } + (prevOpen17.kind === "punct" && prevOpen17.text === ":") // x: { ... } + )) continue; + } } } diff --git a/packages/compiler/tests/syn017-check.test.ts b/packages/compiler/tests/syn017-check.test.ts index 2f889f63..a206591a 100644 --- a/packages/compiler/tests/syn017-check.test.ts +++ b/packages/compiler/tests/syn017-check.test.ts @@ -261,6 +261,26 @@ describe("SYN017: Notification() construction detection", () => { expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); }); + it("does NOT fire on TS type-literal method signature with semicolon separator: type T = { Notification(); }", () => { + const src = + "?bs 0.7\n" + + "fn setup() -> void {\n" + + " type T = { Notification(); }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + + it("does NOT fire on TS type-literal method signature with comma separator: type T = { Notification(), }", () => { + const src = + "?bs 0.7\n" + + "fn setup() -> void {\n" + + " type T = { Notification(), }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + it("DOES fire on Notification() as last expression in an object literal", () => { // `const o = { x: Notification() }` — the outer `{` is preceded by `=` but // Notification is NOT the first token inside it, so this is not a type literal. From 4e1b8c1da62008426b5f1afd265553b0518bb312 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Tue, 16 Jun 2026 19:26:18 -0300 Subject: [PATCH 09/18] fix(syn017): bound Notification generic scan to decl.tokenEnd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walk scanning past `<` for `new Notification()` was using `tokens.length`, which can walk past the current function's token range. Changed to `decl.tokenEnd` to match the BroadcastChannel generic scan pattern at line 833 — keeps the scan local to the current fn body. Co-Authored-By: Claude Sonnet 4.6 --- 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 cf6047f1..744a9ca6 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1389,7 +1389,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult // new Notification( — generic scan only when `new` precedes let depth = 1; let j = nextIdx17 + 1; - while (j < tokens.length && depth > 0) { + while (j < decl.tokenEnd && depth > 0) { const t = tokens[j]; if (!t) break; if (t.kind === "operator" && t.text === "<") depth++; From 053fa4a8c2a439f7aec544797d08e0ff27b1280e Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Tue, 16 Jun 2026 23:34:57 -0300 Subject: [PATCH 10/18] =?UTF-8?q?fix(syn017):=20address=20APPROVED=20revie?= =?UTF-8?q?w=20nits=20=E2=80=94=20title=20wording=20and=20header=20orderin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update SYN017 registry title from "Notification() construction" to "new Notification() / Notification() call" to match SYN008/SYN014 style and reflect that both new-form and bare-call form are detected. - Move SYN017 header comment to numeric order (after SYN016, before SYN018). Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/error-codes.ts | 2 +- packages/compiler/src/passes/syn-check.ts | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/compiler/src/error-codes.ts b/packages/compiler/src/error-codes.ts index 82cd5cdb..870026a4 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -916,7 +916,7 @@ const E: Record = { }, SYN017: { code: "SYN017", - title: "Notification() construction bypasses the capability model", + title: "new Notification() / Notification() call bypasses the capability model", rule: "`new Notification(title)`, bare `Notification(title)`, optional-call `Notification?.(title)`, " + "and TypeScript generic form `new Notification(title)` calls create user-visible browser " + diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 744a9ca6..eaeda337 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -57,16 +57,6 @@ * `cond ? new WebSocket(url) : other`). Generic `` detection only when * preceded by `new` (avoids false-positives on `WebSocket < x > (y)` comparisons). * - * SYN017 A `new Notification(title)`, `Notification(title)`, `Notification?.(title)`, or - * TypeScript instantiation form `new Notification(title)` was detected in a fn body (?bs 0.7+). - * `Notification` fires a user-visible browser notification at runtime — a UI side - * effect invisible to botscript's capability model: no `uses {}`, `reads {}`, or - * `writes {}` declaration covers notification dispatch. Callers cannot observe, - * audit, or suppress the effect from the fn's declared surface. - * Excluded: member calls (`obj.Notification`), `function`/`fn` declarations named - * `Notification`, object/class method shorthands, and TypeScript method signatures. - * The `:` exclusion is guarded against ternary consequents. - * * 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 @@ -125,6 +115,16 @@ * `fn`/`function` declarations named `indexedDB` and bare references are excluded. * `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. * + * SYN017 A `new Notification(title)`, `Notification(title)`, `Notification?.(title)`, or + * TypeScript instantiation form `new Notification(title)` was detected in a fn body (?bs 0.7+). + * `Notification` fires a user-visible browser notification at runtime — a UI side + * effect invisible to botscript's capability model: no `uses {}`, `reads {}`, or + * `writes {}` declaration covers notification dispatch. Callers cannot observe, + * audit, or suppress the effect from the fn's declared surface. + * Excluded: member calls (`obj.Notification`), `function`/`fn` declarations named + * `Notification`, object/class method shorthands, and TypeScript method signatures. + * The `:` exclusion is guarded against ternary consequents. + * * SYN018 A `Math.random()`, `Math?.random()`, or `Math.random?.()` call was detected in a fn body (?bs 0.7+). * `Math.random` generates a random float at runtime but is invisible to botscript's * capability model: `uses { random }` covers `random.*` stdlib calls, not the From c92cfd92a78f2fa5b1ddd3c2963e519b771dbba0 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 07:27:30 -0300 Subject: [PATCH 11/18] refactor(compiler): use isFunctionStarDecl helper in SYN017 generator exclusion Replaces inline generator-declaration guard with the existing shared helper, consistent with SYN007/SYN008/SYN014/SYN016 checks. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index eaeda337..d88a2d8a 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1350,12 +1350,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult // Exclude: function/fn/function* declarations named Notification if (prev17 && prev17.kind === "ident" && prev17.text === "function") continue; if (prev17 && prev17.kind === "keyword" && prev17.text === "fn") continue; - // Generator: `function* Notification` — prev token is `*` (operator kind), token before that is `function` - if (prev17 && prev17.kind === "operator" && prev17.text === "*") { - const prevPrevIdx17 = prevSignificant(tokens, prevIdx17 - 1); - const prevPrev17 = tokens[prevPrevIdx17]; - if (prevPrev17 && prevPrev17.kind === "ident" && prevPrev17.text === "function") continue; - } + if (isFunctionStarDecl(tokens, prevIdx17)) continue; const hasNew17 = prev17 && prev17.kind === "ident" && prev17.text === "new"; // Ternary guard: `cond ? Notification(title) : other`, `cond ? new Notification(title) : other`, From 760d1ee896fd175cda486ac937e6b50727a6544a Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 07:51:51 -0300 Subject: [PATCH 12/18] fix(SYN017): use (...) placeholder in unsafe-wrap message, not (title, options) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot flagged the message suggesting `Notification?.(title, options)` as misleading — the original call may not have an options argument, and the diagnostic fires on Notification(title) too. Use `(...)` as a neutral placeholder, consistent with other SYN warnings. Co-Authored-By: Claude Sonnet 4.6 --- 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 d88a2d8a..53767de3 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1482,7 +1482,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult message: `fn '${decl.name}' ${hasNew17 ? "constructs new " : "calls "}Notification${callSep17}() — ` + `Notification fires a user-visible browser notification invisible to the capability model; ` + - `wrap in unsafe "sends notification for " { ${hasNew17 ? "new " : ""}Notification${callSep17}(title, options) }`, + `wrap in unsafe "sends notification for " { ${hasNew17 ? "new " : ""}Notification${callSep17}(...) }`, rule: syn017.rule, idiom: syn017.idiom, rewrite: syn017.rewrite, From 9996ff5fc37f58c7a2fc6829e8ae468f57f4a070 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 11:34:36 -0300 Subject: [PATCH 13/18] fix(compiler): SYN017 add empty-parens type-literal test; fix MCP example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: 1. Add test for type T = { Notification() } — empty parens in a type literal context was already excluded by the code at 1397-1424 (the prevOpen === '=' guard), but had no explicit test coverage. Copilot flagged the gap. 2. Fix the MCP explanations fails example: { body } shorthand inside the Notification options object was hitting a compiler assertion. Simplified to new Notification(title) which compiles cleanly and still demonstrates the diagnostic. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/tests/syn017-check.test.ts | 11 +++++++++++ packages/mcp/src/explanations.ts | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/compiler/tests/syn017-check.test.ts b/packages/compiler/tests/syn017-check.test.ts index a206591a..c241fb87 100644 --- a/packages/compiler/tests/syn017-check.test.ts +++ b/packages/compiler/tests/syn017-check.test.ts @@ -155,6 +155,17 @@ describe("SYN017: Notification() construction detection", () => { expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); }); + it("does NOT fire on TS type-literal method signature with empty parens — { Notification() }", () => { + const src = + "?bs 0.7\n" + + "fn outer() -> any {\n" + + " type T = { Notification() }\n" + + " return null\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + it("fires in ternary consequent — not suppressed by trailing ':'", () => { const src = "?bs 0.7\n" + diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 39a9a118..22840444 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1185,13 +1185,13 @@ export const EXPLANATIONS: Readonly> = { example: { fails: "?bs 0.7\n" + - "fn warnUser(title: string, body: string) -> void {\n" + - " new Notification(title, { body })\n" + + "fn warnUser(title: string) -> void {\n" + + " new Notification(title)\n" + "}\n", passes: "?bs 0.7\n" + - "fn warnUser(title: string, body: string) -> void {\n" + - " unsafe \"shows alert notification for user-triggered warning\" { new Notification(title, { body }) }\n" + + "fn warnUser(title: string) -> void {\n" + + " unsafe \"shows alert notification for user-triggered warning\" { new Notification(title) }\n" + "}\n", }, }, From 0624e633f4eed5f180d12216d8c4c416cab9d770 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 15:39:15 -0300 Subject: [PATCH 14/18] fix(compiler): align SYN017 warning message unsafe reason with registry idiom Co-Authored-By: Claude Sonnet 4.6 --- 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 53767de3..8215bd53 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1482,7 +1482,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult message: `fn '${decl.name}' ${hasNew17 ? "constructs new " : "calls "}Notification${callSep17}() — ` + `Notification fires a user-visible browser notification invisible to the capability model; ` + - `wrap in unsafe "sends notification for " { ${hasNew17 ? "new " : ""}Notification${callSep17}(...) }`, + `wrap in unsafe "sends browser notification for " { ${hasNew17 ? "new " : ""}Notification${callSep17}(...) }`, rule: syn017.rule, idiom: syn017.idiom, rewrite: syn017.rewrite, From 16f715755eb9fec84b3294aa7076490f2b4554b2 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 19:30:00 -0300 Subject: [PATCH 15/18] fix(compiler): SYN017 empty-parens type-literal exclusion now covers non-first members type T = { x: string; Notification() } was incorrectly producing a SYN017 warning because the exclusion only fired when Notification was the first significant token inside the enclosing type-literal brace. Fix: expand the member-position check to also match when the token immediately preceding Notification is a ';' or ',' separator (subsequent type members). Gate the whole block on isEmptyParens17 so non-empty calls like Notification('title') in value objects are unaffected. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 57 +++++++++++--------- packages/compiler/tests/syn017-check.test.ts | 20 +++++++ 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 8215bd53..64399ef1 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1437,31 +1437,40 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult if (hasTypeAnnotation17) continue; // Exclude TS type-literal method signatures with no annotations at all: - // `{ Notification() }`, `{ Notification(); }`, `{ Notification(), }` — empty parens. - // The token after `)` may be `}` directly, or a separator (`;` / `,`) then `}`. - // Conditions: (a) enclosing `{` is in a type context (preceded by `=` or `:`), - // (b) `Notification` is the first significant token inside that `{`. - // Condition (b) guards against `const o = { x: Notification() }` where the - // outer `{` is also preceded by `=` but `Notification` is not the first token. + // `{ Notification() }`, `{ x: string; Notification() }`, `{ Notification(); }` etc. + // Only applies to empty-parens forms — annotated params are handled by hasTypeAnnotation17 + // above. The token after `)` may be `}` directly, or a separator then `}`. + // Conditions: (a) parens are empty (no significant tokens between `(` and `)`), + // (b) enclosing `{` is in a type context (preceded by `=` or `:`), + // (c) `Notification` is at a method-signature position: either the first + // significant token inside the `{`, or preceded by `;` / `,` + // (subsequent type member, e.g. `{ x: string; Notification() }`). { - let closeBrace17 = afterClose17; - if (closeBrace17 && - closeBrace17.kind === "punct" && - (closeBrace17.text === ";" || closeBrace17.text === ",")) { - const nextAfterSepIdx17 = nextSignificant(tokens, afterCloseIdx17 + 1); - closeBrace17 = tokens[nextAfterSepIdx17]; - } - if (closeBrace17 && closeBrace17.kind === "close" && closeBrace17.text === "}" && - closeBrace17.matchedAt !== undefined) { - const openBraceIdx17 = closeBrace17.matchedAt; - const prevOpenIdx17 = prevSignificant(tokens, openBraceIdx17 - 1); - const prevOpen17 = tokens[prevOpenIdx17]; - const firstInsideBraceIdx17 = nextSignificant(tokens, openBraceIdx17 + 1); - const isNotificationFirst17 = firstInsideBraceIdx17 === i; - if (isNotificationFirst17 && prevOpen17 && ( - prevOpen17.kind === "eq" || // type T = { ... } - (prevOpen17.kind === "punct" && prevOpen17.text === ":") // x: { ... } - )) continue; + const isEmptyParens17 = nextSignificant(tokens, callIdx17 + 1) >= (callTok17.matchedAt as number); + if (isEmptyParens17) { + let closeBrace17 = afterClose17; + if (closeBrace17 && + closeBrace17.kind === "punct" && + (closeBrace17.text === ";" || closeBrace17.text === ",")) { + const nextAfterSepIdx17 = nextSignificant(tokens, afterCloseIdx17 + 1); + closeBrace17 = tokens[nextAfterSepIdx17]; + } + if (closeBrace17 && closeBrace17.kind === "close" && closeBrace17.text === "}" && + closeBrace17.matchedAt !== undefined) { + const openBraceIdx17 = closeBrace17.matchedAt; + const prevOpenIdx17 = prevSignificant(tokens, openBraceIdx17 - 1); + const prevOpen17 = tokens[prevOpenIdx17]; + const firstInsideBraceIdx17 = nextSignificant(tokens, openBraceIdx17 + 1); + // Method-signature position: first token in the type literal, or preceded by + // a member separator (handles `{ x: string; Notification() }` etc.) + const isAtMemberPos17 = + firstInsideBraceIdx17 === i || + (prev17 && prev17.kind === "punct" && (prev17.text === ";" || prev17.text === ",")); + if (isAtMemberPos17 && prevOpen17 && ( + prevOpen17.kind === "eq" || // type T = { ... } + (prevOpen17.kind === "punct" && prevOpen17.text === ":") // x: { ... } + )) continue; + } } } } diff --git a/packages/compiler/tests/syn017-check.test.ts b/packages/compiler/tests/syn017-check.test.ts index c241fb87..8a82f80e 100644 --- a/packages/compiler/tests/syn017-check.test.ts +++ b/packages/compiler/tests/syn017-check.test.ts @@ -292,6 +292,26 @@ describe("SYN017: Notification() construction detection", () => { expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); }); + it("does NOT fire on TS type-literal method signature preceded by semicolon: type T = { x: string; Notification() }", () => { + const src = + "?bs 0.7\n" + + "fn setup() -> void {\n" + + " type T = { x: string; Notification() }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + + it("does NOT fire on TS type-literal method signature preceded by comma: type T = { x: string, Notification() }", () => { + const src = + "?bs 0.7\n" + + "fn setup() -> void {\n" + + " type T = { x: string, Notification() }\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); + it("DOES fire on Notification() as last expression in an object literal", () => { // `const o = { x: Notification() }` — the outer `{` is preceded by `=` but // Notification is NOT the first token inside it, so this is not a type literal. From 18ec8683e4e7858e9186741034807a784901600c Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Wed, 17 Jun 2026 23:29:09 -0300 Subject: [PATCH 16/18] fix(syn017): broaden TS type-literal exclusion; align title strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review feedback on PR #164: 1. syn-check.ts: the method-signature exclusion was too narrow — only caught `=` and `:` before `{`. Now also skips when `{` is preceded by `&`/`|`/`<`/`>` (intersection, union, generic) or `as`/`extends`/ `satisfies` keywords, covering all common TS type-literal positions. 2. explanations.ts: SYN017 title was "Notification() / new Notification()" (flipped order vs error-codes.ts "new Notification() / Notification()"). Aligned to match. Co-Authored-By: Botkowski --- packages/compiler/src/passes/syn-check.ts | 13 ++++++++++++- packages/mcp/src/explanations.ts | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index 64399ef1..e82f9aa0 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1468,7 +1468,18 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult (prev17 && prev17.kind === "punct" && (prev17.text === ";" || prev17.text === ",")); if (isAtMemberPos17 && prevOpen17 && ( prevOpen17.kind === "eq" || // type T = { ... } - (prevOpen17.kind === "punct" && prevOpen17.text === ":") // x: { ... } + (prevOpen17.kind === "punct" && prevOpen17.text === ":") || // x: { ... } + (prevOpen17.kind === "operator" && ( // intersection / union / generic + prevOpen17.text === "&" || // Foo & { ... } + prevOpen17.text === "|" || // Foo | { ... } + prevOpen17.text === "<" || // Foo<{ ... }> + prevOpen17.text === ">" // closing generic: Foo + )) || + (prevOpen17.kind === "ident" && ( // keyword-led type positions + prevOpen17.text === "as" || // x as { ... } + prevOpen17.text === "extends" || // T extends { ... } + prevOpen17.text === "satisfies" // x satisfies { ... } + )) )) continue; } } diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index 22840444..53475fb5 100644 --- a/packages/mcp/src/explanations.ts +++ b/packages/mcp/src/explanations.ts @@ -1157,7 +1157,7 @@ export const EXPLANATIONS: Readonly> = { }, SYN017: { code: "SYN017", - title: "Notification() / new Notification() call bypasses the capability model", + title: "new Notification() / Notification() call bypasses the capability model", body: "SYN017 fires when a fn body constructs a `Notification` via `new Notification(title)`, " + "bare `Notification(title)`, `Notification?.(title)`, or TypeScript instantiation " + From 01ade43972101034152525ec7612f04e2fb5af67 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 18 Jun 2026 07:46:14 -0300 Subject: [PATCH 17/18] =?UTF-8?q?fix(SYN017):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20mark=20await-in-ternary=20test=20fixtures=20as?= =?UTF-8?q?=20async=20fn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ternary+await test fixtures used `await` inside a non-async fn body, producing invalid JS/TS. Changed both to `async fn ... -> Promise`, consistent with how other SYN check tests handle ternary+await regressions (e.g. SYN007). Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/tests/syn017-check.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/tests/syn017-check.test.ts b/packages/compiler/tests/syn017-check.test.ts index 8a82f80e..4e98c89f 100644 --- a/packages/compiler/tests/syn017-check.test.ts +++ b/packages/compiler/tests/syn017-check.test.ts @@ -244,7 +244,7 @@ describe("SYN017: Notification() construction detection", () => { it("fires on Notification() in ternary consequent with await (not a method signature)", () => { const src = "?bs 0.7\n" + - "fn check(cond: boolean, title: string) -> void {\n" + + "async fn check(cond: boolean, title: string) -> Promise {\n" + " cond ? await Notification(title) : null\n" + "}\n"; const result = compile(src); @@ -254,7 +254,7 @@ describe("SYN017: Notification() construction detection", () => { it("fires on new Notification() in ternary consequent with await (not a method signature)", () => { const src = "?bs 0.7\n" + - "fn check(cond: boolean, title: string) -> void {\n" + + "async fn check(cond: boolean, title: string) -> Promise {\n" + " cond ? await new Notification(title) : null\n" + "}\n"; const result = compile(src); From ef96f10f9ce8f98535db366860e6c38b89893455 Mon Sep 17 00:00:00 2001 From: Marcelo Farias Date: Thu, 18 Jun 2026 11:39:15 -0300 Subject: [PATCH 18/18] fix(syn017): extend type-literal exclusion to non-first generic type args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foo — the `{` is preceded by `,` (comma between generic type arguments), which was not treated as a type-context token. Replaces the incorrect `>` case (which never matched a real type-literal position) with the correct `,` case; adds a regression test. Co-Authored-By: Claude Sonnet 4.6 --- packages/compiler/src/passes/syn-check.ts | 4 ++-- packages/compiler/tests/syn017-check.test.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/passes/syn-check.ts b/packages/compiler/src/passes/syn-check.ts index e82f9aa0..d3d1ad24 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -1472,9 +1472,9 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult (prevOpen17.kind === "operator" && ( // intersection / union / generic prevOpen17.text === "&" || // Foo & { ... } prevOpen17.text === "|" || // Foo | { ... } - prevOpen17.text === "<" || // Foo<{ ... }> - prevOpen17.text === ">" // closing generic: Foo + prevOpen17.text === "<" // Foo<{ ... }> )) || + (prevOpen17.kind === "punct" && prevOpen17.text === ",") || // Foo — non-first type arg (prevOpen17.kind === "ident" && ( // keyword-led type positions prevOpen17.text === "as" || // x as { ... } prevOpen17.text === "extends" || // T extends { ... } diff --git a/packages/compiler/tests/syn017-check.test.ts b/packages/compiler/tests/syn017-check.test.ts index 4e98c89f..5243e802 100644 --- a/packages/compiler/tests/syn017-check.test.ts +++ b/packages/compiler/tests/syn017-check.test.ts @@ -323,4 +323,15 @@ describe("SYN017: Notification() construction detection", () => { const result = compile(src); expect(result.warnings.some((w) => w.code === "SYN017")).toBe(true); }); + + it("does NOT fire on TS type-literal as non-first generic type arg: Foo", () => { + // `{` is preceded by `,` (comma between generic type args) — should be treated as type-literal. + const src = + "?bs 0.7\n" + + "fn setup() -> void {\n" + + " type T = Foo\n" + + "}\n"; + const result = compile(src); + expect(result.warnings.some((w) => w.code === "SYN017")).toBe(false); + }); });