Skip to content
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope.
| SYN003 | (0.7+, warning) A fn body contains a `console.*` call (console.log, console.error, etc.). Direct console output bypasses the `stdout`/`stderr` capability model — the compiler cannot enforce or surface the output declaration for callers. | Replace `console.log(...)` with `stdout.write(...)` and add `uses { stdout }` to the fn header; replace `console.error(...)` with `stderr.write(...)` and add `uses { stderr }`. |
| SYN005 | (0.7+, warning) A fn body accesses `process.env`. `process.env` is a global deployment-environment namespace — access is invisible to callers, there is no capability or resource declaration that covers it, and 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) }`. |
| SYN008 | (0.7+, warning) A fn body calls or constructs a `WebSocket` via `new WebSocket(url)`, `WebSocket(url)`, TypeScript instantiation forms like `new WebSocket<T>(url)`, or optional-call-with-type-args `WebSocket?.<T>(url)`. All forms open persistent bidirectional connections at runtime but are invisible to CAP001 (which checks `http.*` member calls). A fn that calls or constructs a WebSocket has an undeclared `net` dependency. Detection: `WebSocket` not preceded by `.`/`?.`, followed by `(`, `?.(`, `<T>(`, or `?.<T>(`. `obj.WebSocket(...)` and bare `WebSocket` references are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Wrap the constructor in `unsafe "wraps WebSocket directly" { new WebSocket(url) }` to make the escape hatch visible in the diff; note that `uses { net }` alone will trigger CAP002 without a stdlib `http.*` call; write a thin `$require("net")`-checked wrapper for full capability tracking. |
| INT002 | (0.7+) A fn declares `intent: "pure"` but its body directly references a stdlib capability (e.g. `http.get`, `fs.read`). Pure intent is enforced at the body level as well as the header. | Remove the stdlib call from the body, or change the intent. |
| INT003 | (0.7+) A fn declares `intent: "idempotent"` but also has `uses { random }` or `uses { time }`. Both capabilities produce different values on each call, making the function non-idempotent. Only `random` and `time` are flagged; other capabilities are not structurally flagged by this check (INT003 is a narrow heuristic, not a proof of idempotence). | Remove `random`/`time` from `uses {}`, or change the intent. |
| INT004 | (0.7+) A fn declares `intent: "idempotent"` but its body directly references `random` or `time` without declaring them. Under-declaration variant of INT003 — fires when INT003 does not. | Remove the non-idempotent call from the body, or declare the capability and remove the idempotent 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`, `DEP002`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN005`, `SYN006`, `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`, `DEP002`, `EFF002`–`EFF004`, `FMT001`, `INT001`–`INT005`, `MAT001`–`MAT004`, `RES001`, `RES002`, `SYN001`, `SYN002`, `SYN003`, `SYN005`, `SYN006`, `SYN008`, `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
35 changes: 35 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,41 @@ const E: Record<string, ErrorCodeEntry> = {
" return ok(undefined)\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 — the capability model cannot see it (there is no `http.*` call for CAP001 to track), " +
"no audit tool can observe the dependency from the fn header, and the connection outlives the fn's return value.",
idiom:
"wrap the `WebSocket` constructor in `unsafe \"wraps WebSocket directly\" { new WebSocket(url) }` " +
"to make the escape hatch visible in the diff; for full capability tracking, write a thin " +
"wrapper fn that calls `$require(\"net\")` before constructing the socket",
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" +
"}",
},
DEP001: {
code: "DEP001",
title: "fn transitively reads a resource category not declared in its header",
Expand Down
128 changes: 126 additions & 2 deletions packages/compiler/src/passes/syn-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@
* depends on runtime deployment values that callers cannot see,
* audit, or mock in tests. The idiomatic fix is to pass config
* and secrets as explicit fn parameters.
*
* SYN006 A `process.exit()` call was detected in a fn body (?bs 0.7+).
* `process.exit()` terminates the entire host process — not just the
* fn, not just the bot. It produces no return value and bypasses
* Result propagation, throws {}, match, and any caller recovery
* path. The idiomatic fix is `return err(...)` so the caller can
* decide whether to terminate.
*
* SYN008 A `new WebSocket(...)` or `WebSocket(...)` call was detected in a
* fn body (?bs 0.7+). WebSocket opens a persistent bidirectional
* connection that is invisible to CAP001 (which checks `http.*`
* member calls). A fn that constructs a WebSocket has an undeclared
* `net` dependency. Wrap in `unsafe "reason" { }`; for full
* capability tracking, write a thin `$require("net")`-checked wrapper.
*/

import type { Diagnostic } from "../diagnostics.js";
Expand Down Expand Up @@ -55,8 +69,9 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
const syn003 = getErrorCode("SYN003")!;
const syn005 = getErrorCode("SYN005")!;
const syn006 = getErrorCode("SYN006")!;
const syn008 = getErrorCode("SYN008")!;

// Collect char-offset ranges where SYN002/SYN003/SYN005/SYN006 are suppressed:
// Collect char-offset ranges where SYN002/SYN003/SYN005/SYN006/SYN008 are suppressed:
Comment thread
marcelofarias marked this conversation as resolved.
// 1. `unsafe "reason" { ... }` expression blocks — explicit acknowledgment.
// 2. `unsafe "reason" fn` bodies — the entire body is exempt, including any
// non-unsafe nested fns declared inside it (matching uns-check's pattern).
Expand All @@ -70,7 +85,7 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
const nesting = computeNesting(program.fns.map((f) => f.decl));

for (const { decl } of program.fns) {
// An `unsafe "reason" fn` body is an explicit acknowledgment — skip SYN002/SYN003/SYN005.
// An `unsafe "reason" fn` body is an explicit acknowledgment — all SYN checks are skipped.
// The range-based suppression above also covers nested non-unsafe fns within it,
// so this early-continue is kept purely as an optimisation.
if (decl.unsafeReason !== undefined) continue;
Expand Down Expand Up @@ -396,6 +411,115 @@ export function passSynCheck(src: string, version: VersionInfo): SynCheckResult
rewrite: syn006.rewrite,
});
}

// SYN008: WebSocket constructor/call detection.
// Fires when a fn body contains `new WebSocket(...)`, `WebSocket(...)`,
// TypeScript instantiation form `new WebSocket<T>(...)` / `WebSocket<T>(...)`,
// optional-call `WebSocket?.(...)`, or optional-call-with-type-args `WebSocket?.<T>(...)`.
// All forms open a persistent bidirectional network connection at runtime
// but are invisible to CAP001 (which checks `http.*` member calls only).
// Suppressed inside `unsafe { }` blocks and `unsafe fn` bodies.
nextInner = 0;
const open8: typeof inner = [];
for (let i = bodyStart; i < decl.tokenEnd; i++) {
while (open8.length > 0 && open8[open8.length - 1]!.tokenEnd <= i) open8.pop();
while (nextInner < inner.length && inner[nextInner]!.tokenStart <= i) {
open8.push(inner[nextInner]!);
nextInner++;
}
if (open8.length > 0) continue;

const tok8 = tokens[i];
if (!tok8 || tok8.kind !== "ident" || tok8.text !== "WebSocket") continue;

// Exclude: `obj.WebSocket(...)` or `obj?.WebSocket(...)` — member call on a local.
const prevIdx8 = prevSignificant(tokens, i - 1);
const prev8 = tokens[prevIdx8];
if (prev8 && ((prev8.kind === "punct" && prev8.text === ".") || prev8.kind === "questionDot"))
continue;

// Must be followed by `(`, `?.(`, `<T>(`, or `?.<T>(` — confirming this is a
// constructor or direct call (including optional-call-with-type-args), not a bare `WebSocket` reference.
// parenIdx8 is normalized to the actual `(` token index for all forms.
const afterWsFirstIdx = nextSignificant(tokens, i + 1);
const afterWs = tokens[afterWsFirstIdx];
if (!afterWs) continue;

let parenIdx8: number;

if (afterWs.kind === "operator" && afterWs.text === "<") {
// TypeScript instantiation form: `WebSocket<T>(...)` or `new WebSocket<T>(...)`
let anglDepth = 1;
let j = afterWsFirstIdx + 1;
while (j < decl.tokenEnd && anglDepth > 0) {
const at = tokens[j];
if (!at) { j++; continue; }
if (at.kind === "operator" && at.text === "<") anglDepth++;
else if (at.kind === "operator" && (at.text === ">" || at.text === ">>" || at.text === ">>>"))
anglDepth = Math.max(0, anglDepth - at.text.length);
j++;
}
parenIdx8 = nextSignificant(tokens, j);
} else if (afterWs.kind === "questionDot") {
// `WebSocket?.(...)` or `WebSocket?.<T>(...)` — optional call
let afterQD8 = nextSignificant(tokens, afterWsFirstIdx + 1);
const afterQDTok8 = tokens[afterQD8];
if (afterQDTok8 && afterQDTok8.kind === "operator" && afterQDTok8.text === "<") {
let qdAnglDepth = 1;
let k = afterQD8 + 1;
while (k < decl.tokenEnd && qdAnglDepth > 0) {
const at8 = tokens[k];
if (!at8) { k++; continue; }
if (at8.kind === "operator" && at8.text === "<") qdAnglDepth++;
else if (at8.kind === "operator" && (at8.text === ">" || at8.text === ">>" || at8.text === ">>>"))
qdAnglDepth = Math.max(0, qdAnglDepth - at8.text.length);
k++;
}
afterQD8 = nextSignificant(tokens, k);
}
parenIdx8 = afterQD8;
} else if (afterWs.kind === "open" && afterWs.text === "(") {
parenIdx8 = afterWsFirstIdx;
} else {
continue;
}

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

// Exclude object/class method shorthands: `{ WebSocket(url) { ... } }`
const closeParenIdx8 = parenTok8.matchedAt;
if (closeParenIdx8 !== undefined) {
const afterParenIdx8 = nextSignificant(tokens, closeParenIdx8 + 1);
const afterParen8 = tokens[afterParenIdx8];
if (
afterParen8 &&
((afterParen8.kind === "open" && afterParen8.text === "{") ||
afterParen8.kind === "fatArrow" ||
(afterParen8.kind === "punct" && afterParen8.text === ":"))
) continue;
}

// Suppression check: unsafe block or unsafe fn body
if (isInsideRange(tok8.start, unsafeRanges)) continue;

const loc8 = locationOf(src, tok8.start);
warnings.push({
code: "SYN008",
severity: "warning",
file: null,
line: loc8.line,
column: loc8.column,
start: tok8.start,
end: tok8.end,
message:
`fn '${decl.name}' calls or constructs a WebSocket — bypasses the net capability model; ` +
`wrap in unsafe "wraps WebSocket directly" { new WebSocket(url) } or write a $require("net")-checked wrapper`,
Comment on lines +515 to +517
rule: syn008.rule,
idiom: syn008.idiom,
rewrite: syn008.rewrite,
});
}
}

return { code: src, warnings };
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", "SYN005", "SYN006",
"SYN001", "SYN002", "SYN003", "SYN005", "SYN006", "SYN008",
"THR001", "THR002", "THR003", "THR004",
"UNS001", "UNS002", "UNS003", "UNS004", "UNS005",
"VER001", "VER002", "VER003",
Expand Down
Loading