Skip to content
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope.
| 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 <reason>" { indexedDB.open(name) }`. |
| SYN018 | (0.7+, warning) A fn body calls `Math.random()`, `Math?.random()`, or `Math.random?.()`. `Math.random` generates a random float at runtime but is invisible to botscript's capability model: `uses { random }` covers `random.*` stdlib namespace calls, not the `Math` global. A fn that calls `Math.random()` has an undeclared randomness dependency — callers cannot see it, tests cannot mock or suppress it the way they can the `random` stdlib. Detection: `Math` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member is `random`, followed by `(` or `?.(`. Bare `Math.random` references without a trailing `(` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Replace `Math.random()` with `random.next()` and add `uses { random }` to the fn header. If `Math.random` is required, wrap in `unsafe "uses Math.random for <reason>" { 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 <reason>" { crypto.getRandomValues(buf) }`. |
| SYN020 | (0.7+, warning) A fn body calls `Date.now()`, constructs `new Date()` / `new Date` (no-arg ambient time), or calls `Date(...)` / `Date?.(...)`. These inject the current wallclock time at runtime but are invisible to botscript's capability model: `uses { time }` covers `time.*` stdlib calls, not the `Date` global. A fn that reads these has an undeclared time dependency — callers cannot see it and tests cannot control the clock value observed. Detection has three forms: (1) member call — `Date` ident (not preceded by `.`/`?.`) followed by `.` or `?.` then `now`, then `(` or `?.(`; (2) new-expression — `Date` preceded by `new` with no-parens (`new Date`) or empty parens (`new Date()`), but NOT `new Date<T>(...)` or `new Date(arg)` with explicit arguments (which construct a specific date, not ambient time); (3) bare call — `Date` followed by `(` or `?.(` without a preceding `new`. Excluded: `new Date(arg)` with explicit arguments, `obj.Date.*`, member calls on a `Date` instance, and `fn`/`function` declarations named `Date`. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass `nowMs: number` as an explicit parameter so callers and tests can control the time value. If ambient time is required at an entry point, use `time.now()` from the `time` stdlib with `uses { time }`, or wrap in `unsafe "reads wallclock time for <reason>" { Date.now() }`. |
| SYN021 | (0.7+, warning) A fn body calls `performance.now()`, `performance?.now()`, `performance.now?.()`, or reads `performance.timeOrigin` / `performance?.timeOrigin`. These inject ambient timing information at runtime but are invisible to botscript's capability model: `uses { time }` covers `time.*` stdlib calls, not the `performance` global. Detection: `performance` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member is `now` (with trailing `(` or `?.(`) or `timeOrigin`. `obj.performance.*` member calls, `fn`/`function` declarations named `performance`, and `unsafe {}` / `unsafe "reason" fn` bodies are suppressed. | Pass the current time (or time origin) as an explicit parameter so callers control the clock value. If `performance.now` is genuinely needed (e.g. for high-resolution elapsed time), wrap in `unsafe "uses performance.now for <reason>" { performance.now() }`. |
| 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 <reason>" { process.argv }`. |
| SYN023 | (0.7+, warning) A fn body accesses a high-concern `navigator.*` member: `geolocation`, `clipboard`, `mediaDevices`, `serviceWorker`, `permissions`, `onLine`, `userAgent`, `language`, `languages`, `platform`, `hardwareConcurrency`, `deviceMemory`, `connection`, or `wakeLock`. These expose ambient browser capability state — location, clipboard, media devices, background service workers, network connectivity, browser identity, and hardware specs — invisible to botscript's capability model. Detection: `navigator` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member in the high-concern set. `obj.navigator.*` member calls, `fn`/`function`/`function*` declarations named `navigator`, members not in the listed set, `unsafe {}` blocks, and `unsafe "reason" fn` bodies are excluded. | Pass the required value as an explicit fn parameter so callers can see the dependency and tests can inject a mock. If direct `navigator.*` access is required, wrap in `unsafe "accesses navigator.<member> for <reason>" { navigator.<member> }`. |
| 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`, `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`, `SYN018`, `SYN019`, `SYN020`, `SYN021`, `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.
Expand Down
69 changes: 69 additions & 0 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,75 @@ const E: Record<string, ErrorCodeEntry> = {
" return random.int(1, 7)\n" +
"}",
},
SYN020: {
code: "SYN020",
title: "Date.now() / new Date() / new Date (no parens) / Date() / Date?.() construction bypasses the time capability model",
rule:
"`Date.now()`, `new Date()`, and `Date()` inject the current time at runtime but are " +
"invisible to botscript's capability model: `uses { time }` declarations cover `time.*` " +
"stdlib namespace calls, not the `Date` global. A fn that calls these forms has an " +
"undeclared time dependency — no `uses {}` declaration covers it, callers cannot see it, " +
"and tests cannot control the time value the fn observes.",
idiom:
"pass the current time as an explicit parameter so callers and tests can control it; " +
"or use `time.now()` from the `time` stdlib namespace with `uses { time }` so the " +
"time dependency is declared in the fn header (note: `time.now()` returns epoch ms, not a Date object); " +
"if the raw `Date` API is genuinely required, wrap in " +
"`unsafe \"uses current time for <reason>\" { Date.now() }`",
rewrite:
"// before — time dependency invisible to the capability model\n" +
"fn isExpired(expiresAtMs: number) -> boolean {\n" +
" return Date.now() > expiresAtMs // SYN020\n" +
"}\n\n" +
"// after — time passed as a parameter; tests can control it\n" +
"fn isExpired(expiresAtMs: number, nowMs: number) -> boolean {\n" +
" return nowMs > expiresAtMs\n" +
"}",
example:
"// SYN020: Date.now() bypasses the time capability model\n" +
"fn isExpired(expiresAt: number) -> boolean {\n" +
" return Date.now() > expiresAt // SYN020\n" +
"}\n\n" +
"// fix: pass nowMs as a parameter\n" +
"fn isExpired(expiresAt: number, nowMs: number) -> boolean {\n" +
" return nowMs > expiresAt\n" +
"}",
},
SYN021: {
code: "SYN021",
title: "performance.now() / performance.timeOrigin access bypasses the time capability model",
rule:
"`performance.now()` and `performance.timeOrigin` inject ambient timing information at " +
"runtime but are invisible to botscript's capability model: `uses { time }` declarations " +
"cover `time.*` stdlib namespace calls, not the `performance` global. A fn that reads " +
"these values has an undeclared time dependency — no `uses {}` declaration covers it, " +
"callers cannot see it, and tests cannot control the clock value the fn observes.",
idiom:
"pass the current time as an explicit parameter so callers and tests can control it (preferred); " +
"if only epoch time (not monotonic time) is needed, use `time.now()` from the `time` stdlib " +
"with `uses { time }` so the dependency is declared in the fn header — " +
"note: `time.now()` is wall-clock epoch time, not a monotonic clock; " +
"if direct `performance` access is required, wrap in " +
"`unsafe \"uses performance.now for <reason>\" { performance.now() }`",
rewrite:
"// before — time dependency invisible to the capability model\n" +
"fn elapsed(startMs: number) -> number {\n" +
" return performance.now() - startMs // SYN021\n" +
"}\n\n" +
"// after — time passed as a parameter; tests can control it\n" +
"fn elapsed(startMs: number, nowMs: number) -> number {\n" +
" return nowMs - startMs\n" +
"}",
example:
"// SYN021: performance.now() bypasses the time capability model\n" +
"fn elapsed(startMs: number) -> number {\n" +
" return performance.now() - startMs // SYN021\n" +
"}\n\n" +
"// fix: pass nowMs as a parameter\n" +
"fn elapsed(startMs: number, nowMs: number) -> number {\n" +
" return nowMs - startMs\n" +
"}",
},
SYN022: {
code: "SYN022",
title: "process.* ambient state access bypasses the capability model",
Expand Down
Loading