botscript's contribution guide, written for bots.
This project is intentionally built so that the primary contributors are LLMs (Claude, Codex, Gemini, DeepSeek, future ones we haven't met yet). Humans review the philosophy and the diff; bots write the code, the tests, and the docs. This document is the contract that makes that work.
If you are a model reading this in the middle of a task: stop, read, then proceed. Most of what would otherwise be guesswork is fixed below.
botscript/
├── packages/
│ ├── runtime/ Pure JS/TS. Result, Option, $match, $enter, $assert.
│ ├── compiler/ String-in / TypeScript-out transformer. Pass pipeline.
│ ├── cli/ `botscript build`, `botscript fmt`, `botscript primer`. Thin wrapper.
│ ├── vite-plugin/ Vite integration. Calls compiler, then esbuild for JSX.
│ └── babel-plugin/ Babel integration. parserOverride.
├── examples/
│ ├── node-app/ CLI — shapes/area/match end-to-end.
│ └── react-app/ Vite + React — todo list, tests in .bs.
├── MANIFESTO.md Why this project exists. Read first.
└── STDLIB.bs Canonical example of every language feature, exactly once.
The compiler is a sequence of string-to-string passes (packages/compiler/src/passes/),
each of which targets a single syntactic form. There is no AST — every pass
uses bracket-aware string scanning helpers in lex.ts. This is deliberate:
the entire compiler fits in one head, and a bot adding a new feature touches
exactly one new file plus the pipeline list in transform.ts.
These are non-negotiable. CI enforces them; humans don't.
If you add a transform, add a test for the form it rewrites and a test for
a form that should NOT be rewritten (the no-op case). If you add a runtime
helper, add a test for the happy path and the failure mode. Tests live in
packages/<pkg>/tests/.
packages/compiler/src/primer.ts is the single source of truth for what the
language is. If your change adds new syntax, the primer must also change in
the same PR. If your change does not add syntax, the primer must not change.
Drift here is the only thing in this codebase that compounds.
Adding a feature isn't done when tests pass — it's done when at least one example demonstrates it. If your feature isn't useful enough for the canonical node or react example, it's probably not useful enough to ship.
A .bs file pinned to ?bs 0.1 must compile identically a year from now.
New syntax goes behind a new version pin (?bs 0.2, ?bs 0.3). The
SUPPORTED_VERSIONS array in packages/compiler/src/passes/version.ts is the
list every other component branches on. Never modify a shipped version's
behavior in place.
The compiler has six passes because that's what botscript currently does. It does not have a pluggable pass registry, a configuration object, or a trace mode. Add those when a real second use case appears, not before. (See "Be small. Stay small." in the manifesto.)
In that order. If either fails, the change is not done. You may not skip
tests with .skip to land a PR; if a test is wrong, fix the test in the
same PR and explain why.
The user has zero patience for it. Default to plain text everywhere.
One commit, one feature/fix. The commit message starts with the package name
in scope: compiler: add support for ?ext directive. Body explains why,
not what. Don't reference issue numbers in the title.
The bot-optimized version of the contributor recipe. Steps 1-9 are mandatory; skipping any of them is what causes "I added a feature, why doesn't anyone know about it?" drift between the compiler, the docs, and the bots.
- Pick a target form. What syntax are you adding? Write down the exact
.bssnippet you want to support, and the TypeScript it should desugar to. Both must be in the PR description. - Pick a version pin. New syntax goes behind a new pin if (and only if)
the change can break already-shipped files at the previous pin. A purely
additive feature (new keyword, new block form) can land at the current
LATESTpin. A behaviour change to existing forms requires bumpingSUPPORTED_VERSIONSinpackages/compiler/src/passes/version.tsand gating internally. - Update
STDLIB.bs. Add one example of the new form. If you can't write one, the feature is probably not coherent yet. - Update
primer.ts. Add the new form to the right section. Keep the primer under one screen — if the feature can't be described in three lines, reconsider its scope. - Add a new pass. Create
packages/compiler/src/passes/<name>.ts. Export a single function(src: string, version: VersionInfo) => string(the second arg is optional — accept it if the pass branches on version). Reuselex.tshelpers (skipBalanced,findOutside,stepOne,readIdent); do not write your own bracket matcher. Handle the success case and pass through unchanged on any malformed input. - Wire the pass in. Add it to
PASS_PIPELINEintransform.ts. Order matters — passes that introduce new statements (likefn) must run before passes that transform statements (likeunwrap). - Add tests. A "rewrites X" test, a "leaves Y alone" test, a
forward-compat test (
?bs <prev>keeps its old behaviour), and an integration test that uses the form alongside other features. - Update the peripheral artifacts in the SAME PR. This is non-negotiable —
the items below are part of "done":
- Diagnostics: add any new error codes to
packages/compiler/src/error-codes.ts(rule/idiom/rewrite/example). - MCP server: add a long-form entry per code to
packages/mcp/src/explanations.tsand update the "known codes match the diagnostic codes" assertion inpackages/mcp/tests/server.test.ts. - AGENTS.md: add a row to the diagnostic codes table.
- README.md: add the feature to the "What's new in
?bs <pin>" section, and update the MCP-tools table'sexplainrow to list the new codes. - Examples: use the form at least once in
examples/node-app/orexamples/react-app/. AGENTS.md rule 3.
- Diagnostics: add any new error codes to
- Run the suite.
All five must succeed.
pnpm install pnpm -r build pnpm test pnpm --filter node-app test pnpm --filter react-app build
- Reproduce in a test first. Add a failing test in the appropriate package.
- Fix the bug. Make the test pass.
- Do not refactor in the same PR. A bug fix is a bug fix. Cleanup goes in a follow-up commit.
- If the bug was in a pass, also add an "integration" test that exercises the bug alongside other passes. Most real bugs in this codebase are pass-ordering bugs, not local logic bugs.
Stuck means: you've tried two distinct approaches and both produced regressions that you can't diagnose, OR you've spent more than ~30 minutes of tool calls without converging.
When stuck, stop, write your findings to STUCK.md at the repo root with:
- the file/line you're touching
- what you've tried (in chronological order)
- what each attempt did wrong
- what you currently believe the root cause is
- what you'd try next if you had more time
Then exit. Another agent (or a human) will pick it up. Trying a third time without writing this down is how a session burns hours and produces nothing useful.
Compiler errors carry a stable code so bot loops can branch on the cause
without regexing English text. Add --format=json to botscript build and
parse the resulting { ok: false, diagnostics: [...] } envelope.
| Code | Cause | The fix the rewrite field will suggest |
|---|---|---|
| BS001 | Malformed ?bs directive (e.g. ?bs nope). |
?bs 0.1 (or whatever LATEST_VERSION is). |
| BS002 | Unsupported version (e.g. ?bs 99.0). |
Pin to a supported version; see SUPPORTED_VERSIONS. |
| CAP001 | A fn calls or transitively reaches http/time/random/fs/stdout/stderr.X whose capability isn't in its uses { … }. (0.2 is direct-only; 0.3 adds same-file transitive propagation; cross-file propagation via moduleEffects applies from 0.3.) |
Either add the capability or remove the call. The diagnostic includes the literal fn name(...) uses { … } -> ... rewrite. |
| CAP002 | (0.3+) A fn declares a capability nothing in its body or callees reaches. | Remove the unused capability from the uses { … } clause, or actually use it. |
| UNS001 | (0.3+) unsafe { … } block missing a justification string. |
unsafe "<reason>" { … }. |
| UNS002 | (0.3+) unsafe "" { … } — empty justification. (0.5+) Also fires on a declaration-level unsafe "" fn name(…) with an empty reason. |
Replace "" with a one-sentence reason. |
| UNS003 | (0.3+) unsafe "reason" with no following body. |
unsafe "reason" { <body> }. |
| UNS004 | (0.5+) Bare as cast outside an unsafe "<reason>" { ... } block or an unsafe "reason" fn body. Every cast must be justified. import * as ns, import { foo as bar }, and export * as ns are not flagged. |
unsafe "<short reason>" { <expr> as <type> }, or declare the fn as unsafe "reason" fn name(…) when the fn is the module's one safe coercion point. |
| UNS005 | (0.9+) A stdlib capability call (http.x, fs.x, time.x, random.x, stdout.x, stderr.x) appears in a fn body with no declared result contract at the call site. |
Wrap in match ns.method(...) { ok { v } -> ... err { e } -> ... }, use unsafe "<reason>" { ... }, or declare the fn as unsafe "<reason>" fn. |
| FMT001 | (0.4+) Source is not in canonical form (RFC #13). Every program has exactly one canonical surface form; from ?bs 0.4 on, the compiler rejects whitespace / ordering variants rather than silently accepting them. The diagnostic points at the first UTF-16 code unit that differs from canonical. |
botscript fmt <file> --write. |
| RES001 | (0.3+) Result.try / Result.tryAsync with no body. |
Result.try { <body that may throw> }. |
| RES002 | (0.9+, warning) A same-file fn whose return type contains Result<> or Option<> is called as a bare statement — return value discarded, error/absence path permanently sealed. Excluded inside test { } and unsafe { } blocks. |
Use ? to propagate, match to handle, or let x = f() to assign. Wrap in unsafe "intentional discard" { f() } if the discard is deliberate. |
| INT001 | (0.7+) A fn declares intent: "pure" but also has uses { … }. (0.8+) Also fires when intent: "pure" is combined with reads { … } or writes { … }. (0.9+) Also fires when intent: "pure" is combined with throws { … } — throwing is a side effect; use Result<T, E> instead. Pure functions may not declare capabilities, resource dependencies, or throws. |
Either drop the conflicting header clause(s) or change the intent to reflect the actual behaviour. For throws {} conflicts, replace with Result<T, E>. |
| SYN001 | Duplicate fn header clause (e.g. two reads { } on the same fn, or two intent:, or two throws {}), or a label inside reads {} / writes {} / throws {} that is not a plain identifier. parseFn is version-agnostic, so SYN001 fires whenever a duplicate clause is written regardless of the ?bs pin. |
Declare each header clause once; merge label lists rather than repeating the clause; use bare identifiers (not quoted strings) as labels. |
| SYN002 | (0.7+, warning) A fn body contains a native throw statement. Native throws bypass botscript's Result-based error contract: callers using ? unwrap or match on Result will not observe exceptions raised via throw. |
Replace throw new ErrorType(...) with return err(new ErrorType(...)) and update the return type to Result<T, ErrorType>. |
| 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 }. |
| SYN004 | (0.7+, warning) A fn body calls eval(...) / eval?.(...) (global eval not preceded by ./?.) or calls Function(...) / Function?.(...) / new Function(...) (Function constructor not preceded by ./?.). All forms execute strings as code at runtime — every static capability check (CAP001/CAP002), resource declaration (reads/writes), and safety check (SYN002/SYN003) can be bypassed by routing any unsafe pattern through eval or the Function constructor. Suppressed inside unsafe {} blocks and unsafe fn bodies. .eval(...) (method call on a local) and Function.* member accesses are excluded. |
Refactor the eval-based pattern to use explicit code paths. If eval is genuinely required (e.g. sandboxed interpreter), wrap in unsafe "<reason>" { eval(...) }. |
| 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) }. |
| SYN012 | (0.7+, warning) A fn body constructs an EventSource via new EventSource(url), bare EventSource(url), EventSource?.(url), or TypeScript instantiation form new EventSource<T>(url). EventSource opens a persistent server-sent-events (SSE) connection at runtime but is invisible to CAP001 — the capability model only checks http.* member calls. Detection: EventSource not preceded by ./?., followed by (, ?.(, or <T>( (generic scan gated on new). Member calls, function/fn declarations, object method shorthands, and TS method signatures are excluded. unsafe {} blocks and unsafe "reason" fn bodies are suppressed. |
Wrap in unsafe "wraps EventSource for <reason>" { new EventSource(url) } to make the escape hatch visible in the diff. |
| 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 <T>( (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 "<reason>" { 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<T>(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 <T>( 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 "<reason>" { new BroadcastChannel(name) } (or matching call form) to make the cross-context messaging dependency visible in the diff. |
| SYN015 | (0.7+, warning) A fn body accesses localStorage.* or sessionStorage.* — any member access (., ?.) on either Web Storage API global. localStorage persists key-value data across browser sessions; sessionStorage scopes to the current tab. Both are synchronous and invisible to botscript's capability model: reads {} / writes {} labels cover declared resource identifiers, not the Web Storage API globals. A fn that accesses either global has undeclared persistent state dependencies invisible to callers and audit tooling. Detection: localStorage or sessionStorage ident not preceded by ./?., followed by . or ?.. Bare references and fn/function declarations named localStorage/sessionStorage are excluded. unsafe {} blocks and unsafe "reason" fn bodies are suppressed. |
Pass a storage abstraction ({ getItem, setItem, removeItem }) as an explicit fn parameter so callers control what storage is accessed and tests can inject a mock. If direct access is required, wrap in unsafe "accesses localStorage for <reason>" { localStorage.getItem(key) }. |
| 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) }. |
| 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<T>(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 <T>( 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 <reason>" { 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 <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> }. |
| SYN025 | (0.7+, warning) A fn body calls requestAnimationFrame(cb) or requestAnimationFrame?.(cb). requestAnimationFrame schedules the callback to run before the next browser repaint — after the current fn returns. Any effects inside the callback are invisible to callers: no uses {}, reads {}, writes {}, or throws {} declaration covers them. Detection: requestAnimationFrame ident not preceded by ./?., followed by ( or ?.(. obj.requestAnimationFrame(cb), fn/function/function* declarations named requestAnimationFrame, and method shorthands are excluded. unsafe {} blocks and unsafe "reason" fn bodies are suppressed. |
Extract the deferred work into a separate fn the caller can schedule. If animation frame scheduling is required at this layer, wrap in unsafe "schedules animation frame callback" { requestAnimationFrame(cb) }. |
| SYN026 | (0.7+, warning) A fn body calls requestIdleCallback(cb) or requestIdleCallback?.(cb). requestIdleCallback schedules the callback to run during a browser idle period — after the current fn returns. Any effects inside the callback are invisible to callers. Callback timing is non-deterministic (fires when the browser decides it is idle). Detection: same pattern as SYN025. obj.requestIdleCallback(cb), fn/function/function* declarations, and method shorthands are excluded. unsafe {} blocks and unsafe "reason" fn bodies are suppressed. |
Extract the deferred work into a separate fn the caller can schedule. If idle-period scheduling is required, wrap in unsafe "schedules idle callback" { requestIdleCallback(cb) }. |
| 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. |
| ALI001 | (0.8+, warning) A module-level const <name> = <expr> contains a stdlib namespace ident anywhere in the RHS but in a form too non-trivial to alias-track (member access, operator, call, conditional such as flag ? time : null, etc.). Static checks won't see name as a stdlib alias. Non-blocking. |
Use a direct binding const t = time for alias tracking, or call the namespace directly. |
| ALI002 | (0.8+, warning) A module-level const x = <alias> where <alias> is itself a tracked stdlib alias creates an alias-of-alias chain. Chain aliases are not tracked; x.member will not be detected by cap/intent/uns checks. Non-blocking. |
Use a direct binding (const x = time) or the canonical namespace name directly. |
| ALI003 | (0.8, warning; 0.9+, error) A module-level const { … } = <stdlib> object-destructuring extracts member references that no static check follows. The extracted idents are not recognized as stdlib aliases; cap/intent/uns checks will miss any calls through them. Warning at 0.8; blocking error at 0.9+ — no defensible use case. |
Use a direct namespace binding (const t = time) and call t.method() instead of destructuring. |
| INT005 | (0.8+) A fn declares intent: "idempotent" but also has writes { ... }. A fn that mutates a resource produces different side effects on each call, contradicting the idempotency contract. INT005 takes priority over INT003/INT004 when both writes and non-idempotent capabilities are declared. |
Remove writes {} if the fn does not actually mutate, or change the intent to reflect the actual behaviour. |
| CAP003 | (0.9+, warning) A fn is declared unsafe "reason" fn name(…) and also has a uses { … } clause. The compiler cannot prove the capability is actually reached — the assertion is programmer-owned. Non-blocking; the fn compiles. |
Remove the uses {} clause if it is not needed, or document why the assertion is trusted. |
| EFF002 | (0.7+) A callback parameter declares uses { … } capabilities beyond what the outer fn declares. A fn that claims uses { net } cannot safely accept a callback that also writes to fs — the outer declaration would be a lie. |
Extend the outer fn's uses {} to cover the callback's full capability set, or narrow the callback's annotation. |
| EFF003 | (0.9+) A callback parameter declares reads { … } labels not covered by the outer fn's reads {}. Same structural rule as EFF002 applied to resource read dependencies. |
Add the missing label(s) to the outer fn's reads {}, or narrow the callback annotation. |
| EFF004 | (0.9+) A callback parameter declares writes { … } labels not covered by the outer fn's writes {}. |
Add the missing label(s) to the outer fn's writes {}, or narrow the callback annotation. |
| DEP001 | (0.9+) A fn's body (or a callee in the same file) reads a resource label not declared in the fn's own reads {}. Transitivity is enforced: if loadUser calls fetchRow which reads userDb, loadUser must also declare reads { userDb }. |
Add the missing label(s) to reads {}, or remove the undeclared read. |
| DEP002 | (0.9+) Same as DEP001 but for writes {} labels. A fn whose callee writes a resource must declare that write in its own header. |
Add the missing label(s) to writes {}, or remove the undeclared write. |
| DEP003 | (0.9+, warning) A fn declares reads { x } but no tracked callee (same-file or moduleEffects entry, direct or transitive) also declares reads { x }. Suppressed when the fn body contains any opaque/untracked external call (the label may still be live cross-module). Leaf fns are excluded. Non-blocking. |
Remove the stale label from reads {}, or verify the fn is the intended access point. |
| DEP004 | (0.9+, warning) Same as DEP003 but for writes {}. A fn declares a write label that no tracked callee (same-file or moduleEffects entry) justifies; suppressed on fns with opaque external calls. Non-blocking. |
Remove the stale label from writes {}, or verify the fn is the intended access point. |
| THR001 | (0.9+) A fn's body (or a same-file callee) throws an exception type not declared in the fn's throws {}. Transitivity is enforced: if loadUser calls fetchRow throws { NetworkError }, loadUser must also declare throws { NetworkError }. |
Add the missing type(s) to throws {}, or add a match / unsafe to suppress the propagation. |
| THR002 | (0.9+) A fn body directly constructs err(TypeName(...)), err(new TypeName(...)), or err(TypeName) where TypeName (CapCase) is not declared in the fn's own throws {} clause. Producer-side complement to THR001. |
Add TypeName to the fn's throws {}, or change the error construction to use a declared type. |
| THR003 | (0.9+) A callback parameter declares throws { … } types not covered by the outer fn's throws {}. Same structural rule as THR001 applied to callback parameters. |
Add the missing type(s) to the outer fn's throws {}, or narrow the callback annotation. |
| THR004 | (0.9+, warning) A fn declares throws { X } but no same-file callee (direct or transitive) throws X and the fn's body does not construct err(X...) directly. The annotation is likely stale. Leaf fns and fns with opaque calls are excluded. Non-blocking. |
Remove the stale label from throws {}, or verify the fn is the actual throw point. |
| MAT001 | (0.9+) A match expression handles ok or err tag patterns but omits the opposing tag without a wildcard _ arm. An incomplete Result match is a silent no-op for the missing path. |
Add the missing ok { ... } -> ... or err { ... } -> ... arm, or add a wildcard _ -> ... arm. |
| MAT002 | (0.9+) A match expression handles some or none tag patterns but omits the opposing tag without a wildcard _ arm. An incomplete Option match silently discards the missing case. |
Add the missing some { v } -> ... or none -> ... arm, or add a wildcard _ -> ... arm. |
| MAT003 | (0.9+) A match expression whose arm tags all belong to a known user-defined tagged union is missing at least one variant arm and has no wildcard _ arm. Only fires when the arm tags uniquely identify a single union (no tag name collisions across unions). |
Add the missing variant arm(s), or add a wildcard _ -> ... arm. |
| MAT004 | (warning, 0.9+) A match expression on a user-defined tagged union already covers all variants explicitly AND also has a wildcard _ -> ... arm. The wildcard is unreachable dead code and silently absorbs future new variants, defeating the exhaustiveness check. |
Remove the wildcard arm. |
| VER001 | (warning, < 0.9) A non-empty reads {} or writes {} clause is declared on a fn in a file pinned below ?bs 0.9. DEP001/DEP002 enforcement is not active; the annotation is documentation only. Non-blocking. |
Upgrade the pin to ?bs 0.9 to activate enforcement, or leave it knowing it is unenforced. |
| VER002 | (warning, < 0.9) A non-empty throws {} clause is declared on a fn in a file pinned below ?bs 0.9. THR001 enforcement is not active; the annotation is documentation only. Non-blocking. |
Upgrade the pin to ?bs 0.9 to activate enforcement, or leave it knowing it is unenforced. |
| VER003 | (warning, < 0.7) A non-empty intent: "..." clause is declared on a fn in a file pinned below ?bs 0.7. INT001–INT005 enforcement is not active; the annotation is documentation only. Non-blocking. |
Upgrade the pin to ?bs 0.7 to activate enforcement, or leave it knowing it is unenforced. |
When you add a new compiler error, allocate the next free code in the same
range (BSnnn for general parse errors, CAPnnn for capability checks,
UNSnnn for unsafe-block checks, RESnnn for Result-block checks,
FMTnnn for canonical-form / formatter checks, SYNnnn for structural /
duplicate-clause checks). The
single source of truth is packages/compiler/src/error-codes.ts — passes
read rule/idiom/rewrite from that registry. When you add a code:
- Add the entry to
error-codes.tswith rule, idiom, rewrite, and example. - Add a long-form entry to
packages/mcp/src/explanations.tsso the MCPexplaintool answers for it. - Add a row to the table above.
- Add a row to the table in
README.md's "MCP server" tools section if the new code is part of the user-facing surface.
A PR is ready when ALL of the following are true. CI checks the easy ones; you check the others.
-
pnpm -r buildclean. -
pnpm testclean. -
pnpm --filter node-app testclean. -
pnpm --filter react-app buildclean. - Test added (rewrites X), (leaves Y alone), and forward-compat (previous
?bspin behaves identically). -
STDLIB.bsupdated if syntax changed. -
primer.tsupdated if syntax changed. -
error-codes.tsupdated if a new diagnostic was emitted. -
packages/mcp/src/explanations.tsupdated if a new diagnostic was emitted, and the MCP test'sKNOWN_CODESassertion updated. - AGENTS.md diagnostic-codes table updated if a new diagnostic was emitted.
- README.md "What's new in
?bs <pin>" section updated if a feature was added. - At least one
examples/program uses the new form. - No new dependencies (or the PR explains why a new dep was unavoidable).
- No
console.log,// TODO,// FIXME, or.only/.skipin tests. - No emojis anywhere.
- No backward-incompatible change to a shipped
?bs <version>. Behaviour changes go behind a new pin.
MANIFESTO.md— what we're building and why.packages/compiler/src/primer.ts(thePRIMERconst) — what the language is.STDLIB.bs— every feature, exactly once.packages/compiler/src/transform.ts— the pass pipeline.packages/compiler/src/passes/<any>.ts— pick the simplest one as a template.examples/node-app/src/main.bs— the shape of an actual program.
If your harness has MCP, you can also wire @mbfarias/botscript-mcp and call
primer / transform / explain instead of file-reading the above. Same
content, fewer reads.
If anything in this document conflicts with the MANIFESTO.md, the manifesto
wins. If the manifesto conflicts with reality, file an issue. We update
docs in the same PR as the code; ambiguity gets resolved at the diff, not in
the queue.