Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9714cb6
feat: UNS005 — external call without declared result contract (?bs 0.9)
May 18, 2026
b063018
docs(changelog): document UNS005 in ?bs 0.9 section
May 18, 2026
3a6dbd4
fix(uns-check): address Copilot review — drop unused lex import, alig…
May 18, 2026
37e7278
fix(cap-assert,explanations): update test examples to be UNS005-compl…
May 18, 2026
4b8b9c6
fix: address Copilot review on UNS005 PR
May 19, 2026
1b0e626
fix(uns005): correct botscript match syntax in docs, diagnostics, and…
May 19, 2026
67b723b
fix: address third-round Copilot review on UNS005 PR
May 19, 2026
1f707fe
fix(uns-check): address fourth-round Copilot review
May 19, 2026
61ae009
fix: address Copilot review on UNS005 PR (fifth round)
May 19, 2026
67146d2
fix(mcp): include warnings in transform tool response
May 20, 2026
b273e69
fix(uns-check): suppress UNS005 when passUnsafe will fire UNS003
May 20, 2026
2bd7cad
fix: import STDLIB_TO_CAP from canonical source; update MCP transform…
May 20, 2026
6c4280f
fix(uns-check): import computeNesting from _callgraph; fix trivia ski…
May 21, 2026
40b0a03
fix(uns-check): extend isMalformedUnsafeExpr to skip await and groupi…
May 21, 2026
7ba3f0c
fix: extend isMalformedUnsafeExpr to catch wrapped stdlib calls; fix …
May 21, 2026
1fdbed3
test(uns-check): add coverage for UNS003 precedence with wrapped stdl…
May 21, 2026
55cf98e
fix(uns-check): remove duplicate nextSignificant, handle questionDot,…
May 22, 2026
4dbc872
fix(uns-check): use correct accessor in message/rewrite for optional-…
May 22, 2026
3351aae
fix(uns-check): start body scan from bodyTokenStart; dedup AGENTS.md …
May 23, 2026
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: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,11 @@ parse the resulting `{ ok: false, diagnostics: [...] }` envelope.
| 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> }`. |
| 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 { … }`. Pure functions may not declare capabilities or resource dependencies. | Either drop the conflicting header clause(s) or change the intent to reflect the actual behaviour. |
| SYN001 | Duplicate fn header clause (e.g. two `reads { }` on the same fn, or two `intent:`), or a label inside `reads {}` / `writes {}` 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. |
| 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. |
| 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. |
| 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. |
Expand Down
20 changes: 16 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@ goes behind a new pin.
- THR003 fires when any callback parameter's declared throws are not a subset of the
outer fn's declared throws.

- **UNS005 — external call without declared result contract.**
From `?bs 0.9`, the compiler fires `UNS005` when a stdlib capability call
(`http.x`, `fs.x`, `time.x`, `random.x`, `stdout.x`, `stderr.x`) has no
declared result contract at the call site. The return value may be
structurally typed correctly but semantically incorrect in ways the compiler
cannot detect — UNS005 forces explicit handling.
- Unlike UNS001–UNS004 (programmer-applied), UNS005 is **compiler-inferred**.
- Suppress by making the call the direct subject of a `match` expression:
`match ns.method(...) { ok { v } -> ... err { e } -> ... }` (including `match await ...`).
- Suppress with `unsafe "<reason>" { ns.method(...) }` to accept the
uncertainty with a written explanation.
- `unsafe "<reason>" fn` declaration bodies are also suppressed.
Comment on lines +70 to +81

- **CAP003 — capability asserted in unsafe fn (non-blocking warning).**
From `?bs 0.9`, the compiler emits a `warning` (not an error) when a
`uses {}` declaration appears on an `unsafe fn`. The capability inference
Expand All @@ -83,10 +96,9 @@ goes behind a new pin.
unaffected; the new field is additive.

### Compat
- Files on `?bs 0.8` (or earlier) are unaffected — DEP001/DEP002 and CAP003
are gated on `?bs 0.9`. Existing code that uses `reads {}` / `writes {}` or
`unsafe fn` without transitivity/assertion warnings continues to compile at
its current pin.
- Files on `?bs 0.8` (or earlier) are unaffected — DEP001/DEP002, UNS005, and
CAP003 are gated on `?bs 0.9`. Existing code continues to compile at its
current pin.

## ?bs 0.8 — unreleased

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ claude mcp add botscript -- npx -y @mbfarias/botscript-mcp
| Tool | Input | Output |
| ----------- | -------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `primer` | (no args) | The canonical language primer (same text the `?primer` directive emits). |
| `transform` | `{ source: string, filename?: string }` | `{ ok: true, code, forms, version }` on success, or `{ ok: false, diagnostics: [...] }` on failure. |
| `explain` | `{ code: string }` | Long-form explanation for any stable diagnostic code (`BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`, `DEP002`, `EFF002`–`EFF004`, `FMT001`, `INT001`, `INT002`, `MAT001`, `RES001`, `SYN001`, `THR001`–`THR003`, `UNS001`–`UNS004`, `VER001`–`VER002`) plus a fails/passes example pair. |
| `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 (`BS001`, `BS002`, `CAP001`–`CAP003`, `DEP001`, `DEP002`, `EFF002`–`EFF004`, `FMT001`, `INT001`, `INT002`, `MAT001`, `RES001`, `SYN001`, `THR001`–`THR003`, `UNS001`–`UNS005`, `VER001`–`VER002`) 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
37 changes: 35 additions & 2 deletions packages/compiler/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,11 @@ const E: Record<string, ErrorCodeEntry> = {
"}\n\n" +
"// No CAP003: regular fn with the same claim is compiler-verified\n" +
"?bs 0.9\n" +
"fn callApi(url: string) uses { net } -> string {\n" +
" http.get(url) // CAP001/CAP002 apply normally\n" +
"fn callApi(url: string) uses { net } -> Result<string, string> {\n" +
" match http.get(url) {\n" +
Comment on lines +80 to +81
" ok { value } -> ok(value)\n" +
" err { error } -> err(`fetch failed: ${error}`)\n" +
" }\n" +
"}",
},
UNS001: {
Expand Down Expand Up @@ -143,6 +146,36 @@ const E: Record<string, ErrorCodeEntry> = {
"?bs 0.5\n" +
'const u = unsafe "Response.json() returns any" { data as User };',
},
UNS005: {
code: "UNS005",
title: "external call without declared result contract",
rule:
"a stdlib capability call (http.x, fs.x, time.x, etc.) must have a declared result contract " +
"at the call site — wrap in `match` to make success and failure paths explicit, use " +
"`unsafe \"<reason>\" { ... }` to accept the uncertainty with a written explanation, or " +
"declare the containing fn as `unsafe \"<reason>\" fn` when the entire body is the escape hatch",
idiom:
"prefer match over bare stdlib calls — " +
"`match ns.method(...)` makes both success and failure paths explicit; " +
"use `unsafe` only when you are certain about the shape and want to document why",
rewrite:
"match ns.method(...) {\n ok { value } -> { /* use value */ }\n err { error } -> { /* handle error */ }\n}",
example:
"// before — UNS005: no contract on what http.get returns\n" +
"?bs 0.9\n" +
"fn fetchUser(id: string) uses { net } -> string {\n" +
" const data = http.get(`/users/${id}`);\n" +
" data\n" +
"}\n\n" +
"// after — result contract via match\n" +
"?bs 0.9\n" +
"fn fetchUser(id: string) uses { net } -> Result<string, string> {\n" +
" match http.get(`/users/${id}`) {\n" +
" ok { value } -> ok(value)\n" +
" err { error } -> err(`fetch failed: ${error}`)\n" +
" }\n" +
"}",
},
FMT001: {
code: "FMT001",
title: "source is not in canonical form",
Expand Down
4 changes: 2 additions & 2 deletions packages/compiler/src/passes/cap-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ import { locationOf } from "./_location.js";
import { nextSignificant } from "./_callgraph.js";
import { atLeast, type VersionInfo } from "./version.js";

/** stdlib namespace -> capability it consumes. */
const STDLIB_TO_CAP: Readonly<Record<string, string>> = {
/** stdlib namespace -> capability it consumes. Canonical source; import from here to avoid drift. */
export const STDLIB_TO_CAP: Readonly<Record<string, string>> = {
Comment on lines +31 to +32
http: "net",
time: "time",
Comment on lines +31 to 34
random: "random",
Expand Down
11 changes: 1 addition & 10 deletions packages/compiler/src/passes/intent-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,7 @@ import { parseProgram } from "../parser/parse.js";
import type { FnDecl } from "../parser/parse-fn.js";
import { locationOf } from "./_location.js";
import { atLeast, type VersionInfo } from "./version.js";

/** stdlib namespace -> capability it consumes (subset mirrored from cap-check). */
const STDLIB_TO_CAP: Readonly<Record<string, string>> = {
http: "net",
time: "time",
random: "random",
fs: "fs",
stdout: "stdout",
stderr: "stderr",
};
import { STDLIB_TO_CAP } from "./cap-check.js";

export function passIntentCheck(src: string, version: VersionInfo): string {
if (!atLeast(version.resolved, "0.7")) return src;
Expand Down
Loading
Loading