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..870026a4 100644 --- a/packages/compiler/src/error-codes.ts +++ b/packages/compiler/src/error-codes.ts @@ -914,6 +914,39 @@ const E: Record = { " return new Promise((resolve) => { req.onsuccess = (e) => resolve(e.target.result) })\n" + "}", }, + SYN017: { + code: "SYN017", + 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 " + + "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..d3d1ad24 100644 --- a/packages/compiler/src/passes/syn-check.ts +++ b/packages/compiler/src/passes/syn-check.ts @@ -115,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 @@ -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,177 @@ 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/function* declarations named Notification + if (prev17 && prev17.kind === "ident" && prev17.text === "function") continue; + if (prev17 && prev17.kind === "keyword" && prev17.text === "fn") 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`, + // `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") || + (prevBeforeAwait17 !== undefined && prevBeforeAwait17 !== null && prevBeforeAwait17.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 < decl.tokenEnd && 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; + + // Exclude TS type-literal method signatures with no annotations at all: + // `{ 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() }`). + { + 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: { ... } + (prevOpen17.kind === "operator" && ( // intersection / union / generic + prevOpen17.text === "&" || // Foo & { ... } + prevOpen17.text === "|" || // 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 { ... } + prevOpen17.text === "satisfies" // x satisfies { ... } + )) + )) 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 browser notification for " { ${hasNew17 ? "new " : ""}Notification${callSep17}(...) }`, + 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..5243e802 --- /dev/null +++ b/packages/compiler/tests/syn017-check.test.ts @@ -0,0 +1,337 @@ +/** + * 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("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" + + "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); + }); + + 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" + + "async fn check(cond: boolean, title: string) -> Promise {\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" + + "async fn check(cond: boolean, title: string) -> Promise {\n" + + " cond ? await new Notification(title) : null\n" + + "}\n"; + 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); + }); + + 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 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. + 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); + }); + + 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); + }); +}); diff --git a/packages/mcp/src/explanations.ts b/packages/mcp/src/explanations.ts index e6f5a6a0..53475fb5 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: "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 " + + "`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 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 " + + "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) -> void {\n" + + " new Notification(title)\n" + + "}\n", + passes: + "?bs 0.7\n" + + "fn warnUser(title: string) -> void {\n" + + " unsafe \"shows alert notification for user-triggered warning\" { new Notification(title) }\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",