Skip to content
Open
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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,11 @@ 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: `Date` ident not preceded by `.`/`?.`, followed by `.` or `?.` then `now`, or preceded by `new` / bare call form, followed by `(` or `?.(` or `)` (no-arg). `obj.Date.*`, member calls on a `Date` instance, and `fn`/`function` declarations named `Date` are excluded. `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 start time 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> }`. |
| SYN024 | (0.7+, warning) A fn body reads or writes `document.cookie` or `document?.cookie`. `document.cookie` is read/write persistent storage: reads return all cookies for the origin as a single concatenated string; writes append a cookie. Unlike `localStorage` and `indexedDB` (SYN016), cookies are also transmitted with every matching HTTP request, so accessing `document.cookie` carries an implicit network-side effect. Both surfaces are invisible to botscript's capability model: `reads {}` / `writes {}` cover declared resource identifiers, not the `document` global. Detection: `document` ident not preceded by `.`/`?.`, followed by `.` or `?.`, member is `cookie`. Detects reads, assignments, and member calls (e.g. `.includes()`). `obj.document.cookie` (member on a local binding), bare `document`, and `fn`/`function`/`function*` declarations named `document` are excluded. `unsafe {}` blocks and `unsafe "reason" fn` bodies are suppressed. | Pass the cookie string as an explicit fn parameter and inject a mock in tests. If direct cookie access is required, wrap in `unsafe "reads/writes document.cookie for <reason>" { document.cookie }`. |
| 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`–`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`, `SYN024`, `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
103 changes: 103 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 Expand Up @@ -1052,6 +1121,40 @@ const E: Record<string, ErrorCodeEntry> = {
" return userAgent\n" +
"}",
},
SYN024: {
code: "SYN024",
title: "document.cookie access bypasses the storage capability model",
rule:
"`document.cookie` is a persistent read/write storage mechanism invisible to botscript's " +
"capability model: `reads {}` / `writes {}` labels cover declared resource identifiers, not the " +
"`document` global. Unlike `localStorage`, cookies are also transmitted with every " +
"matching HTTP request — so `document.cookie` access has implicit network-side effects as well. " +
"A fn that reads or writes `document.cookie` has undeclared storage and indirect network " +
"dependencies that callers cannot see and tests cannot intercept without global mocking.",
idiom:
"pass cookies as an explicit parameter so callers and tests can control the value; " +
"or accept a cookie-jar abstraction so the dependency is visible at the call site; " +
"if direct `document.cookie` access is genuinely required (e.g. a thin cookie adapter), " +
"wrap in `unsafe \"accesses document.cookie for <reason>\" { document.cookie }`",
rewrite:
"// before — cookie access invisible to the capability model\n" +
"fn getSession() -> string {\n" +
" return document.cookie // SYN024\n" +
"}\n\n" +
"// after — cookie value passed as a parameter; tests can control it\n" +
"fn getSession(cookieHeader: string) -> string {\n" +
" return cookieHeader\n" +
"}",
example:
"// SYN024: document.cookie bypasses the storage capability model\n" +
"fn isLoggedIn() -> boolean {\n" +
" return document.cookie.includes('session=') // SYN024\n" +
"}\n\n" +
"// fix: pass the cookie header as a parameter\n" +
"fn isLoggedIn(cookieHeader: string) -> boolean {\n" +
" return cookieHeader.includes('session=')\n" +
"}",
},
DEP001: {
code: "DEP001",
title: "fn transitively reads a resource category not declared in its header",
Expand Down
Loading