Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope.
| SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers and to static analysis; no capability or resource declaration covers it, so the fn has an undeclared dependency on deployment configuration. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `env`. `obj.process.env` (member access on a local), `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass config and secrets as explicit fn parameters so the dependency is visible in the call signature; if env access is required at the load site, wrap in `unsafe "reads deployment env" { }`. |
| SYN006 | (0.7+, warning) A fn body calls `process.exit()`, `process?.exit()`, or `process.exit?.()`. All forms terminate the entire host process — not just the fn, not just the bot. They produce no return value, bypass `Result` propagation, `throws {}`, `match`, and any caller recovery path. No capability declaration covers them. Detection: `process` not preceded by `.`/`?.`, followed by `.`/`?.` then `exit` then `(` or `?.(`. `obj.process.exit(...)`, `process.exit` without `(`, and `process.exitCode` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Return `err(...)` and let the caller decide whether to terminate. If `process.exit` is genuinely required at a bootstrap entry point, wrap in `unsafe "exits on invalid config" { process.exit(1) }`. |
| SYN007 | (0.7+, warning) A fn body calls `fetch(url)` or `fetch?.(url)`. `fetch` makes HTTP requests at runtime but is invisible to CAP001, which only checks `http.*` member calls. CAP001 cannot infer or require `uses { net }` from `fetch` calls, so callers cannot rely on CAP001 to detect a missing declaration. Detection: `fetch` not preceded by `.`/`?.`, followed by `(` or `?.(`. Member calls (`obj.fetch(...)`), object method shorthands (`{ fetch(url) {} }`), TypeScript method signatures, fn/function declarations named `fetch`, and bare `fetch` references are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Replace `fetch(url)` with `http.get(url)` / `http.post(url, { body })` and add `uses { net }` to the fn header. If the native fetch API is required, wrap in `unsafe "calls fetch directly" { fetch(url) }`. |
| SYN008 | (0.7+, warning) A fn body constructs or calls `WebSocket` via `new WebSocket(url)`, `WebSocket(url)`, `WebSocket?.(url)`, or TypeScript generic forms `new WebSocket<T>(url)`. `WebSocket` opens a persistent bidirectional connection at runtime but is invisible to CAP001, which only checks `http.*` member calls. A fn that constructs a WebSocket has an undeclared network dependency — no `uses {}` declaration covers it. Generic scan (`<T>`) is gated on `new` to avoid false-positives on comparison expressions like `WebSocket < x > (y)`. Member calls (`obj.WebSocket(...)`), object method shorthands, and fn declarations named `WebSocket` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap the construction in `unsafe "wraps WebSocket for <reason>" { new WebSocket(url) }` to make the network dependency visible in the diff and to callers reading the fn. |
| SYN010 | (0.7+, warning) A fn body calls `setTimeout(...)`, `setInterval(...)`, or `queueMicrotask(...)`. These globals schedule callbacks that run after the fn returns — any effects inside those callbacks are invisible to callers: no capability declaration, no `writes {}` label, and no `throws {}` entry can cover them. Detection: identifier not preceded by `.`/`?.`, followed by `(` or `?.(`. Member calls (`obj.setTimeout(...)`) and bare references (without `(`) are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Make the timing explicit: return a `Promise` the caller awaits, or return a teardown function so the caller controls the lifecycle. If a timer is genuinely required, wrap in `unsafe "schedules deferred effect" { setTimeout(...) }`. |
| SYN011 | (0.7+, warning) A fn body calls `import(specifier)` — the dynamic import form. Dynamic imports load a module at runtime whose capability surface is unbounded: CAP001 checks for stdlib namespace calls, not dynamic module loads. A fn that calls `import()` has an undeclared capability surface proportional to everything the dynamically loaded module might do at runtime. Detection: `import` token not preceded by `.`/`?.`, followed by `(` or `?.(`. `import.meta` (followed by `.`) is excluded. Object method shorthands and `fn import(...)` declarations are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | If the module is known at compile time, use a static top-level `import { ... } from` declaration instead. If dynamic loading is required, wrap in `unsafe "loads plugin dynamically" { import(specifier) }`. |
| INT002 | (0.7+) A fn declares `intent: "pure"` but its body directly references a stdlib capability (e.g. `http.get`, `fs.read`). Pure intent is enforced at the body level as well as the header. | Remove the stdlib call from the body, or change the intent. |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `SYN010`, `SYN011`, `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`, `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.
Expand Down
33 changes: 33 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,39 @@ const E: Record<string, ErrorCodeEntry> = {
" return http.get(`/api/users/${id}`)\n" +
"}",
},
SYN008: {
code: "SYN008",
title: "new WebSocket() / WebSocket() call bypasses the net capability model",
rule:
"`new WebSocket(url)`, `WebSocket(url)`, and TypeScript instantiation forms like " +
"`new WebSocket<T>(url)` open persistent bidirectional connections at runtime but are " +
"invisible to botscript's capability model: CAP001 checks for `http.*` member calls, " +
"not the `WebSocket` global. A fn that constructs a WebSocket has an undeclared network " +
"dependency — no `uses {}` declaration covers it, and no audit tool can observe it from the fn header.",
idiom:
"wrap the `WebSocket` constructor in `unsafe \"<reason>\" { new WebSocket(url) }` " +
"to make the escape hatch visible in the diff",
Comment thread
Copilot marked this conversation as resolved.
rewrite:
"// before — WebSocket is invisible to the capability model\n" +
"fn openFeed(url: string) -> WebSocket {\n" +
" return new WebSocket(url) // SYN008\n" +
"}\n\n" +
"// after — escape hatch justified in the diff\n" +
"fn openFeed(url: string) -> WebSocket {\n" +
' return unsafe "wraps WebSocket for streaming feed" { new WebSocket(url) }\n' +
"}",
example:
"// SYN008: WebSocket bypasses the net capability model\n" +
"fn subscribe(url: string) -> void {\n" +
" const ws = new WebSocket(url) // SYN008\n" +
" ws.onmessage = (e) => handle(e.data)\n" +
"}\n\n" +
"// fix: wrap in unsafe with a justification\n" +
"fn subscribe(url: string) -> void {\n" +
' const ws = unsafe "wraps WebSocket for live updates" { new WebSocket(url) }\n' +
" ws.onmessage = (e) => handle(e.data)\n" +
"}",
},
SYN010: {
code: "SYN010",
title: "setTimeout / setInterval / queueMicrotask defers side effects outside the fn's capability surface",
Expand Down
99 changes: 99 additions & 0 deletions packages/compiler/src/passes/syn-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@
* signatures (`{ fetch(url): T; }`). The `:` exclusion is guarded
* against ternary consequents (`cond ? fetch(url) : other`).
*
* SYN008 A `new WebSocket(url)` / `WebSocket(url)` call was detected in a fn body (?bs 0.7+).
* `WebSocket` opens a persistent bidirectional connection at runtime but is
* invisible to botscript's capability model: CAP001 checks for `http.*` member
* calls, not the `WebSocket` global. A fn that constructs a WebSocket has an
* undeclared network dependency that no capability declaration can see.
* Excluded: member calls (`obj.WebSocket`), `function`/`fn` declarations named
* `WebSocket`, object/class method shorthands, and TypeScript method
* signatures (`{ WebSocket(url): T; }`). The `:` exclusion is guarded
* against ternary consequents (`cond ? WebSocket(url) : other`, including
* `cond ? new WebSocket(url) : other`). Generic `<T>` detection only when
* preceded by `new` (avoids false-positives on `WebSocket < x > (y)` comparisons).
*
* 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
Expand Down Expand Up @@ -100,6 +112,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
const syn005 = getErrorCode("SYN005")!;
const syn006 = getErrorCode("SYN006")!;
const syn007 = getErrorCode("SYN007")!;
const syn008 = getErrorCode("SYN008")!;
const syn010 = getErrorCode("SYN010")!;
const syn011 = getErrorCode("SYN011")!;

Expand Down Expand Up @@ -588,6 +601,92 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
break;
}

// ── SYN008: new WebSocket() / WebSocket() call ───────────────────────
case "WebSocket": {
const prevIdx8 = prevSignificant(tokens, i - 1);
const prev8 = tokens[prevIdx8];

// Exclude: `obj.WebSocket(...)` — preceded by `.` or `?.`
if (prev8 && ((prev8.kind === "punct" && prev8.text === ".") || prev8.kind === "questionDot"))
continue;

// Exclude: function/fn declarations named WebSocket
if (prev8 && prev8.kind === "ident" && prev8.text === "function") continue;
if (prev8 && prev8.kind === "keyword" && prev8.text === "fn") continue;

const hasNew8 = prev8 && prev8.kind === "ident" && prev8.text === "new";
// For ternary guard: check if token before WebSocket (or before `new`) is `?`
const prevBeforeNew8 = hasNew8
? tokens[prevSignificant(tokens, prevIdx8 - 1)]
: undefined;
const isTernaryConsequent8 = (prev8 !== undefined && prev8 !== null && prev8.kind === "question") ||
(prevBeforeNew8 !== undefined && prevBeforeNew8 !== null && prevBeforeNew8.kind === "question");
Comment on lines +617 to +623

const nextIdx8 = nextSignificant(tokens, i + 1);
const next8 = tokens[nextIdx8];

let isOpt8 = false;
let callIdx8 = nextIdx8;

if (next8 && next8.kind === "questionDot") {
// WebSocket?.( — optional call (no generic scan to avoid false-positives)
isOpt8 = true;
callIdx8 = nextSignificant(tokens, nextIdx8 + 1);
} else if (hasNew8 && next8 && next8.kind === "operator" && next8.text === "<") {
// new WebSocket<T>( — generic scan only when `new` precedes, preventing
// `WebSocket < x > (y)` comparison expressions from false-firing.
let depth = 1;
let j = nextIdx8 + 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++;
}
callIdx8 = nextSignificant(tokens, j);
}

const callTok8 = tokens[callIdx8];
if (!callTok8 || !(callTok8.kind === "open" && callTok8.text === "(")) continue;

// Exclude method shorthands and TS method signatures: { WebSocket(url) { ... } } / { WebSocket(url): T; }
// Guard the `:` check against ternary consequents: `cond ? WebSocket(url) : other`
if (callTok8.matchedAt !== undefined) {
const afterCloseIdx8 = nextSignificant(tokens, callTok8.matchedAt + 1);
const afterClose8 = tokens[afterCloseIdx8];
if (afterClose8 && (
(afterClose8.kind === "open" && afterClose8.text === "{") ||
afterClose8.kind === "fatArrow" ||
(!isTernaryConsequent8 && afterClose8.kind === "punct" && afterClose8.text === ":")
)) continue;
}

if (isInsideRange(tok.start, unsafeRanges)) continue;

const callSep8 = isOpt8 ? "?." : "";
const warnStart8 = hasNew8 ? prev8!.start : tok.start;
const loc8 = locationOf(src, warnStart8);
warnings.push({
code: "SYN008",
severity: "warning",
file: null,
line: loc8.line,
column: loc8.column,
start: warnStart8,
end: callTok8.start + 1,
message:
`fn '${decl.name}' ${hasNew8 ? "constructs new " : "calls "}WebSocket${callSep8}() — ` +
`WebSocket opens a network connection invisible to the capability model; ` +
`wrap in unsafe "wraps WebSocket for <reason>" { ${hasNew8 ? "new " : ""}WebSocket${isOpt8 ? "?." : ""}(url) }`,
rule: syn008.rule,
idiom: syn008.idiom,
rewrite: syn008.rewrite,
});
break;
}

// ── SYN011: dynamic import() call ────────────────────────────────────
case "import": {
// Exclude: `obj.import(...)` — preceded by `.` or `?.`
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/tests/error-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", "SYN010", "SYN011",
"SYN001", "SYN002", "SYN003", "SYN004", "SYN005", "SYN006", "SYN007", "SYN008", "SYN010", "SYN011",
"THR001", "THR002", "THR003", "THR004",
"UNS001", "UNS002", "UNS003", "UNS004", "UNS005",
"VER001", "VER002", "VER003",
Expand Down
Loading
Loading