feat: UNS005 — external call without declared result contract (?bs 0.9)#63
feat: UNS005 — external call without declared result contract (?bs 0.9)#63marcelofarias wants to merge 17 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Introduces UNS005, a ?bs 0.9-gated, compiler-inferred check that flags stdlib capability calls (http.*, fs.*, time.*, random.*, stdout.*, stderr.*) whose result has no declared output contract at the call site. Suppression is provided by direct match (including match await ...), unsafe "..." { } blocks, or unsafe "..." fn declarations. The PR also updates the explanations registry, threads UNS005 through MCP known-codes, and rewrites a few CAP003 fixtures to compose with the new rule.
Changes:
- New pass
passUnsCheck(token-driven, scans fn bodies, excludes inner fns and unsafe ranges) registered beforepassUnsafeatminVersion: "0.9". - New
UNS005entry inerror-codes.tsandexplanations.ts; MCP test list updated. - CAP003 test fixtures and the CAP003 example in
error-codes.ts/explanations.tsrewrapped so the bare stdlib calls no longer fire UNS005 incidentally; CHANGELOG updated.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/compiler/src/passes/uns-check.ts | New pass implementing UNS005 detection and suppression. |
| packages/compiler/src/transform.ts | Registers unsCheck ahead of passUnsafe at minVersion 0.9. |
| packages/compiler/src/error-codes.ts | Adds UNS005 entry; updates CAP003 example to avoid bare http.get. |
| packages/compiler/tests/uns-check.test.ts | 256-line test file covering firing, match/unsafe suppression, version gate, inner-fn exclusion, multi-call. |
| packages/compiler/tests/cap-assert.test.ts | Rewraps fixtures to keep CAP003 tests compatible with UNS005. |
| packages/mcp/src/explanations.ts | UNS005 long-form explanation; updates CAP003 passes example. |
| packages/mcp/tests/server.test.ts | Adds UNS005 to known-codes. |
| CHANGELOG.md | Documents UNS005 and updates the 0.9 compat note. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Advance past the call to avoid redundant hits (e.g. chained calls). | ||
| i = parenTok.matchedAt ?? parenIdx; |
| /** Collects char-offset ranges for `unsafe "reason" fn` declaration bodies. */ | ||
| function collectUnsafeFnBodyRanges(tokens: Token[], out: CharRange[]): void { | ||
| for (let i = 0; i < tokens.length; i++) { | ||
| const t = tokens[i]; | ||
| if (!t || t.kind !== "keyword" || t.keyword !== "unsafe") continue; | ||
|
|
||
| const j = nextSignificant(tokens, i + 1); | ||
| const reasonTok = tokens[j]; | ||
| if (!reasonTok || reasonTok.kind !== "string") continue; | ||
|
|
||
| const k = nextSignificant(tokens, j + 1); | ||
| const next = tokens[k]; | ||
| if (!next || next.kind !== "keyword") continue; | ||
|
|
||
| let fnIdx: number; | ||
| if (next.keyword === "fn") { | ||
| fnIdx = k; | ||
| } else if (next.keyword === "async") { | ||
| const l = nextSignificant(tokens, k + 1); | ||
| const fnTok = tokens[l]; | ||
| if (!fnTok || fnTok.kind !== "keyword" || fnTok.keyword !== "fn") continue; | ||
| fnIdx = l; | ||
| } else { | ||
| continue; | ||
| } | ||
|
|
||
| const decl = parseFn(tokens, fnIdx, { allowGenerics: true }); | ||
| if (!decl) continue; | ||
|
|
||
| out.push({ start: decl.body.start, end: decl.body.end }); | ||
| i = decl.tokenEnd - 1; | ||
| } | ||
| } |
| /** | ||
| * Returns true when the stdlib call at `callIdx` is the direct subject of a | ||
| * `match` expression. Skips trivia and `await` tokens looking backward. | ||
| */ | ||
| function isDirectMatchSubject(tokens: Token[], callIdx: number): boolean { | ||
| let i = callIdx - 1; | ||
| while (i >= 0) { | ||
| const t = tokens[i]; | ||
| if (!t) { i--; continue; } | ||
| if ( | ||
| t.kind === "whitespace" || | ||
| t.kind === "newline" || | ||
| t.kind === "lineComment" || | ||
| t.kind === "blockComment" | ||
| ) { | ||
| i--; | ||
| continue; | ||
| } | ||
| // await is transparent — the match still covers the result. | ||
| // Note: await is not in the KEYWORDS set; it lexes as an ident. | ||
| if (t.kind === "ident" && t.text === "await") { | ||
| i--; | ||
| continue; | ||
| } | ||
| return t.kind === "keyword" && t.keyword === "match"; | ||
| } | ||
| return false; | ||
| } |
| " Err(e) => err(e),\n" + | ||
| " }\n" + | ||
| "}\n"; | ||
| expect(() => compile(src)).not.toThrow("UNS005"); |
| for (const outer of decls) { | ||
| const inner = decls.filter( | ||
| (d) => d !== outer && d.tokenStart >= outer.tokenStart && d.tokenEnd <= outer.tokenEnd, | ||
| ); | ||
| inner.sort((a, b) => a.tokenStart - b.tokenStart); | ||
| result.set(outer, inner); | ||
| } |
| import { locationOf } from "./_location.js"; | ||
| import { atLeast, type VersionInfo } from "./version.js"; | ||
|
|
||
| /** stdlib namespaces that consume external capabilities. */ |
| start: tok.start, | ||
| end: memberTok.end, |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Comments suppressed due to low confidence (2)
packages/compiler/src/error-codes.ts:161
- UNS005’s idiom/rewrite strings use
match ... { Ok(v) => ..., Err(e) => ... }, but botscript match syntax isPattern -> bodyand tag binds use{ ... }(no(...)), so the suggested rewrite is not valid botscript. Please update these strings (and the example below) to the supported match grammar.
idiom:
"prefer `match ns.method(...) { Ok(v) => ..., Err(e) => ... }` — it 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(...) { Ok(value) => { /* use value */ }, Err(e) => { /* handle */ } }',
packages/mcp/src/explanations.ts:205
- UNS005’s description and examples show
match http.get(...) { Ok(v) => ..., Err(e) => ... }, which doesn’t match botscript’s actual match grammar (Pattern -> body, tag binds via{ ... }). As written, the docs encourage code that won’t parse. Please update the inline suppression description and the passes example to valid botscript match syntax.
"**Suppression mechanisms (in order of preference):**\n\n" +
"1. **match** — `match http.get(url) { Ok(v) => ..., Err(e) => ... }` wraps " +
"the call in a structural contract check. Both success and failure paths are explicit. " +
"`match await http.get(url)` is also accepted (await is transparent).\n\n" +
"2. **unsafe block** — `unsafe \"I know what X returns\" { ns.method(...) }` accepts the " +
| "fn fetchData(url: string) uses { net } -> Result<string, string> {\n" + | ||
| " match http.get(url) {\n" + | ||
| " Ok(data) => ok(data),\n" + | ||
| " Err(e) => err(e),\n" + | ||
| " }\n" + |
| " Ok(data) => ok(data),\n" + | ||
| " Err(e) => err(e),\n" + |
| ` Ok(value) => { /* use value */ },\n` + | ||
| ` Err(e) => { /* handle error */ },\n` + |
| " Ok(data) => ok(data),\n" + | ||
| " Err(e) => err(`fetch failed: ${e}`),\n" + |
| " Ok(data) => ok(data),\n" + | ||
| " Err(e) => err(e),\n" + |
| 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 wrapping in `match ns.method(...) { Ok(...) => ..., Err(...) => ... }` |
| * "Declared output contract" at the call site means one of: | ||
| * - The call is the direct subject of a `match` expression | ||
| * (`match http.get(url) { ... }`). | ||
| * - The call is inside an `unsafe "<reason>" { ... }` block. | ||
| * |
| // Suppression 1: inside an unsafe block or unsafe fn body. | ||
| if (insideAnyChar(tok.start, unsafeRanges)) continue; | ||
|
|
||
| // Suppression 2: direct subject of a `match` expression. | ||
| // Skips trivia and `await` — `match await http.get(url) { }` is fine. |
| "`match await http.get(url)` is also accepted (await is transparent).\n\n" + | ||
| "2. **unsafe block** — `unsafe \"I know what X returns\" { ns.method(...) }` accepts the " + | ||
| "uncertainty with a written explanation. The reason becomes the review record on the call.\n\n" + | ||
| "3. **(Future) ensures annotation** — when `ensures: \"...\"` lands in a future version, " + |
| "at the call site — either wrap in `match` to make the success and failure paths explicit, or use " + | ||
| "`unsafe \"<reason>\" { ... }` to accept the uncertainty with a written explanation", | ||
| idiom: | ||
| "prefer `match ns.method(...) { ok { value } -> ...\n err { error } -> ... }` — it 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}", |
| * Fires on any stdlib capability call (http.x, fs.x, time.x, etc.) that has | ||
| * no declared result contract at the call site. | ||
| * |
| - **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 wrapping in `match ns.method(...) { ok { value } -> ...\n err { error } -> ... }` | ||
| (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. |
| i--; | ||
| continue; | ||
| } | ||
| return t.kind === "keyword" && t.keyword === "match"; | ||
| } |
| import { atLeast, type VersionInfo } from "./version.js"; | ||
|
|
||
| /** | ||
| * stdlib namespaces that consume external capabilities. | ||
| * Must stay in sync with STDLIB_TO_CAP in cap-check.ts (the canonical source). | ||
| */ | ||
| const STDLIB_CAPS = new Set(["http", "fs", "time", "random", "stdout", "stderr"]); |
| it("does not fire when the call is parenthesized inside match", () => { | ||
| const src = | ||
| "?bs 0.9\n" + | ||
| "fn fetchData(url: string) uses { net } -> Result<string, string> {\n" + | ||
| " match (http.get(url)) {\n" + |
| | 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. | |
| @@ -34,10 +47,9 @@ goes behind a new pin. | |||
| unaffected; the new field is additive. | |||
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
packages/compiler/src/passes/cap-check.ts:38
- The comment says this mapping is the “canonical source; import from here to avoid drift”, but
passes/intent-check.tsstill defines its ownSTDLIB_TO_CAPcopy. That’s already a drift risk (and now contradicts this comment). Consider exporting/importing the shared mapping everywhere it’s used (e.g.intent-check) or soften the “canonical” claim here.
/** stdlib namespace -> capability it consumes. Canonical source; import from here to avoid drift. */
export const STDLIB_TO_CAP: Readonly<Record<string, string>> = {
http: "net",
time: "time",
random: "random",
fs: "fs",
stdout: "stdout",
stderr: "stderr",
};
| /** Collects char-offset ranges for `unsafe "reason" { body }` block bodies. */ | ||
| function collectUnsafeBlockRanges(tokens: Token[], out: CharRange[]): void { | ||
| for (let i = 0; i < tokens.length; i++) { | ||
| const t = tokens[i]; | ||
| if (!t || t.kind !== "keyword" || t.keyword !== "unsafe") continue; | ||
|
|
||
| const j = nextSignificant(tokens, i + 1); | ||
| const head = tokens[j]; | ||
| if (!head) continue; | ||
|
|
||
| let braceIdx = -1; | ||
| if (head.kind === "open" && head.text === "{") { | ||
| braceIdx = j; | ||
| } else if (head.kind === "string") { | ||
| const k = nextSignificant(tokens, j + 1); | ||
| const open = tokens[k]; | ||
| if (open && open.kind === "open" && open.text === "{") { | ||
| braceIdx = k; | ||
| } | ||
| } | ||
| if (braceIdx === -1) continue; |
| "can tell at a glance whether the author made a deliberate choice (unsafe block) or the " + | ||
| "compiler is flagging an omission.\n\n" + | ||
| "**Suppression mechanisms (in order of preference):**\n\n" + | ||
| "1. **match** — `match http.get(url) { ok { value } -> ...\n err { error } -> ... }` wraps " + |
| "`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 ns.method(...) { ok { value } -> ...\n err { error } -> ... }` — it makes both success " + |
| 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 wrapping in `match ns.method(...) { ok { value } -> ...\n err { error } -> ... }` |
| const src = | ||
| "?bs 0.9\n" + | ||
| "fn logErr(msg: string) uses { stderr } -> string {\n" + | ||
| " const r = stderr.write(msg);\n" + |
| | `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 a stable diagnostic code (`BS001`, `BS002`, `CAP001`, `CAP002`, `UNS001`–`UNS004`, `RES001`, `FMT001`, `INT001`, `SYN001`) 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). | |
| /** stdlib namespace -> capability it consumes. Canonical source; import from here to avoid drift. */ | ||
| export const STDLIB_TO_CAP: Readonly<Record<string, string>> = { | ||
| http: "net", | ||
| time: "time", |
| /** stdlib namespace -> capability it consumes. Canonical source; import from here to avoid drift. */ | ||
| export const STDLIB_TO_CAP: Readonly<Record<string, string>> = { |
| * is flagging an omission. | ||
| * | ||
| * Suppression mechanisms: | ||
| * 1. Wrap in `match` to handle both Ok and Err arms. |
| if (diagnostics.length > 0) { | ||
| throw new BotscriptError(diagnostics); | ||
| } |
| const j = nextSignificant(tokens, i + 1); | ||
| const head = tokens[j]; | ||
| if (!head) continue; | ||
|
|
||
| let braceIdx = -1; | ||
| if (head.kind === "open" && head.text === "{") { | ||
| braceIdx = j; | ||
| } else if (head.kind === "string") { | ||
| const k = nextSignificant(tokens, j + 1); | ||
| const open = tokens[k]; | ||
| if (open && open.kind === "open" && open.text === "{") { | ||
| braceIdx = k; | ||
| } | ||
| } | ||
| if (braceIdx === -1) continue; |
| function isMalformedUnsafeExpr(tokens: Token[], callIdx: number): boolean { | ||
| let i = callIdx - 1; | ||
| while (i >= 0 && (tokens[i]?.kind === "whitespace" || tokens[i]?.kind === "newline")) i--; | ||
| if (i < 0 || tokens[i]?.kind !== "string") return false; | ||
| i--; | ||
| while (i >= 0 && (tokens[i]?.kind === "whitespace" || tokens[i]?.kind === "newline")) i--; | ||
| const t = tokens[i]; | ||
| return !!(t && t.kind === "keyword" && t.keyword === "unsafe"); | ||
| } |
| const { code, forms, version, warnings } = transform(source, filename ? { filename } : {}); | ||
| return json({ ok: true, code, forms, version, warnings: [...warnings] }); | ||
| } catch (e) { |
| while (i >= 0) { | ||
| const t = tokens[i]!; | ||
| if (isTrivia(t.kind)) { i--; continue; } | ||
| if (t.kind === "ident" && t.text === "await") { i--; continue; } | ||
| if (t.kind === "open" && t.text === "(") { i--; continue; } |
| // Wrap in match to suppress UNS005 (which runs before cap-check); CAP001 still | ||
| // fires because uses { net } is absent. | ||
| const src = | ||
| "?bs 0.9\nfn fetchData(url: string) -> Result<string, string> {\n" + |
| // Must be `stdlib.method(` — confirm the shape before acting. | ||
| const dotIdx = nextSignificant(tokens, i + 1); | ||
| const dotTok = tokens[dotIdx]; | ||
| if (!dotTok || dotTok.kind !== "punct" || dotTok.text !== ".") continue; |
| function nextSignificant(tokens: Token[], start: number): number { | ||
| let i = start; | ||
| while (i < tokens.length) { | ||
| const t = tokens[i]; | ||
| if (!t) return i; | ||
| if ( | ||
| t.kind === "whitespace" || | ||
| t.kind === "newline" || | ||
| t.kind === "lineComment" || | ||
| t.kind === "blockComment" | ||
| ) { | ||
| i++; | ||
| continue; | ||
| } | ||
| return i; | ||
| } | ||
| return i; | ||
| } |
| // Unsafe fn bodies come from the pre-parsed decls — no re-parsing needed. | ||
| for (const decl of decls) { | ||
| if (decl.unsafeReason !== undefined) { | ||
| unsafeRanges.push({ start: decl.body.start, end: decl.body.end }); | ||
| } |
| function nextSignificant(tokens: Token[], start: number): number { | ||
| let i = start; | ||
| while (i < tokens.length) { | ||
| const t = tokens[i]; | ||
| if (!t) return i; |
Fires 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.
Suppressed by wrapping in `match` (including `match await ...`) or by
an `unsafe "reason" { }` block or `unsafe "reason" fn` declaration.
UNS005 is compiler-inferred — unlike UNS001-UNS004 which fire on
malformed unsafe blocks. This lets reviewers distinguish deliberate
escapes (unsafe block) from omissions the compiler caught.
Gated on ?bs 0.9. 27 new tests, 554/554 pass.
Closes #50
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n test API names, soften match-suppression wording - Remove unused `lex` import from uns-check.ts (only Token type is needed) - Replace `fs.read` with `fs.readText` and `random.float()` with `random.next()` in tests — aligns with actual runtime stdlib surface - Remove "exhaustively"/"compiler-checked"/"compiler-enforced" claims from error-codes.ts and explanations.ts — match suppression does not verify arm completeness; wording now says "makes both paths explicit" without overclaiming Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…iant at ?bs 0.9 CAP003 tests and MCP examples used bare stdlib calls in regular fn bodies, which now trigger UNS005 at ?bs 0.9. Updated to use match-wrapped calls or unsafe blocks so the examples compile cleanly alongside the UNS005 pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove advance-past-closing-paren so nested stdlib calls inside args are
also flagged (e.g. http.get(fs.readText(path)) now fires twice)
- Replace collectUnsafeFnBodyRanges token-scan with a simple filter over
pre-parsed decls — removes re-parsing and drift risk
- Handle parenthesized match subjects in isDirectMatchSubject (treat `(` as
transparent like `await`); add test for match (http.get(url))
- Fix diagnostic `end` to span full call expression including closing paren
- Replace O(N²) computeNesting with O(N log N) sort+stack (from dep-check.ts)
- Strengthen negative-case test assertions: not.toThrow("UNS005") → not.toThrow()
- Add comment pointing at cap-check.ts as canonical source for STDLIB_CAPS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… tests
Replace Ok(v) => ... / Err(e) => ... with ok { value } -> ... / err { error } -> ...
throughout error-codes.ts, explanations.ts, uns-check.ts rewrite strings,
uns-check.test.ts, cap-assert.test.ts, and CHANGELOG.md.
The runtime Result<T,E> has kind:"ok"/"err" fields, so the correct tag
patterns are lowercase with brace binds and -> arms.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Document unsafe fn body as suppression in uns-check.ts, error-codes.ts,
and explanations.ts — compiler already suppressed it, docs now match
- Add stdout/stderr firing and suppression tests (568/568 pass)
- Add UNS005, CAP003, DEP001, DEP002 rows to AGENTS.md diagnostic table
- Update SYN001 in AGENTS.md table to mention throws {}
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- isDirectMatchSubject: add forward check to prevent false suppression
when a stdlib call is part of a larger match scrutinee expression.
Passes the call's closing paren index and verifies that what follows
(skipping grouping parens) is `{`, not another operator.
- STDLIB_CAPS: derive from STDLIB_TO_CAP (exported from cap-check.ts)
so the two passes share one canonical namespace list.
- AGENTS.md: remove premature throws {} references from SYN001 row
(throws {} support lands in a separate PR).
- README.md: update MCP transform output to include warnings field;
expand explain code list to include CAP003, UNS005, DEP001, DEP002.
- uns-check.test.ts: add regression test — stdlib call inside a match
arm (not the scrutinee) still fires UNS005.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix stderr.write -> stderr.println (stderr.write doesn't exist in runtime) - Remove literal \n from inline code backtick spans in idiom/explanations/CHANGELOG - Add regression test: UNS005 fires when stdlib call is inside a binary expression in the match scrutinee (forward scan correctly rejects suppression) - Fix UNS005 explanation to use a fenced code block for the match example Co-Authored-By: Botkowski <noreply@anthropic.com>
MCP server was destructuring only {code, forms, version} from transform(),
dropping the warnings array entirely. README already documented
warnings in the response contract (e.g. CAP003 non-blocking diagnostics).
Co-Authored-By: Botkowski <noreply@anthropic.com>
A malformed unsafe expression like `unsafe "reason" http.get(url)` (missing
`{}`) was causing UNS005 to fire first, preventing passUnsafe from surfacing
the more specific UNS003 diagnostic. Add isMalformedUnsafeExpr guard and a
regression test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… description - intent-check.ts had its own STDLIB_TO_CAP copy despite cap-check.ts being the declared canonical source. Import directly from cap-check.js to prevent drift if namespaces are added. - MCP transform tool description now reflects the warnings field added to the response in a prior round. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pping in isMalformedUnsafeExpr - Remove local `computeNesting` duplicate; import shared version from `_callgraph.ts` (same helper used by dep-check and thr-check). - `isMalformedUnsafeExpr` now skips lineComment and blockComment tokens in addition to whitespace/newline, so `unsafe "reason" // comment\nhttp.get(...)` is correctly recognized as a malformed unsafe expression. - Fix cap-check test that expected CAP001 on a bare http.get — UNS005 fires first at ?bs 0.9; wrap in match to suppress UNS005 so CAP001 still fires as intended. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ng parens Handles `unsafe "r" await ns.call()` and `unsafe "r" (ns.call())` — both are malformed unsafe expressions that passUnsafe will flag as UNS003. Suppress UNS005 for these so the more specific diagnostic wins. Adds two tests covering the new forms. Co-Authored-By: Botkowski <noreply@anthropic.com>
…test return type isMalformedUnsafeExpr was only scanning through trivia, await, and parens, missing cases like `unsafe "r" foo(http.get(url))` where the call is inside a wrapper. Now also scans past idents, dots, and close-parens so the UNS003 diagnostic wins over UNS005 for any malformed unsafe expression form. Also fixes a cap-check test fixture that returned plain strings from Result-typed arms; arms now use ok()/err() to match the declared return type. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ib call Covers the case where a stdlib call is nested inside a wrapper function inside a malformed unsafe expression (unsafe "r" foo(ns.method())). The extended backwards scan in isMalformedUnsafeExpr must see through the wrapper ident and parens to detect the enclosing unsafe context. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… fix ok/err caps - Import nextSignificant from _callgraph.ts (shared helper, no drift) - Detect stdlib calls via optional chaining (`?.`) same as dot access - Fix Ok/Err capitalisation in file-level comment (botscript uses lowercase) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
86ba808 to
829a124
Compare
| // Scan backwards past trivia, await, idents, dots, and parens to find | ||
| // `unsafe "reason"` anywhere wrapping this call — handles both direct | ||
| // (`unsafe "r" ns.call()`) and wrapped (`unsafe "r" foo(ns.call())`). | ||
| while (i >= 0) { | ||
| const t = tokens[i]!; | ||
| if (isTrivia(t.kind)) { i--; continue; } | ||
| if (t.kind === "ident") { i--; continue; } | ||
| if (t.kind === "punct" && t.text === ".") { i--; continue; } | ||
| if (t.kind === "open" && t.text === "(") { i--; continue; } | ||
| if (t.kind === "close" && t.text === ")") { i--; continue; } | ||
| break; | ||
| } | ||
| if (i < 0 || tokens[i]?.kind !== "string") return false; | ||
| i--; | ||
| while (i >= 0 && isTrivia(tokens[i]!.kind)) i--; | ||
| const t = tokens[i]; | ||
| return !!(t && t.kind === "keyword" && t.keyword === "unsafe"); |
| | `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 a stable diagnostic code (`BS001`, `BS002`, `CAP001`, `CAP002`, `UNS001`–`UNS004`, `RES001`, `FMT001`, `INT001`, `SYN001`) 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 a stable diagnostic code (`BS001`, `BS002`, `CAP001`–`CAP003`, `UNS001`–`UNS005`, `RES001`, `FMT001`, `INT001`, `SYN001`, `DEP001`, `DEP002`) plus a fails/passes example pair. | |
| * UNS005 A stdlib capability call (http.x, fs.x, time.x, random.x, | ||
| * stdout.x, stderr.x) appears in a function body with no | ||
| * declared output contract the compiler can verify. | ||
| * | ||
| * "Declared output contract" at the call site means one of: | ||
| * - The call is the direct subject of a `match` expression | ||
| * (`match http.get(url) { ... }`). | ||
| * - The call is inside an `unsafe "<reason>" { ... }` block. | ||
| * - The call is inside an `unsafe "<reason>" fn` body. |
Summary
?bs 0.9, the compiler fires 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.match ns.method(...) { Ok(...) => ..., Err(...) => ... }or useunsafe "<reason>" { ns.method(...) }.uses {}onunsafe fnis programmer-asserted, not compiler-proven) and the warning severity extension toTransformResult.Closes #50.
Supersedes #61 (rebased cleanly onto main after DEP001/DEP002 merge in #60).
Test plan
pnpm -r testpasses (564/564)bs explain UNS005returns the rule/idiom/rewritebs explain CAP003returns the warning semantics?bs 0.9→ UNS005 errormatch→ cleanunsafe "..." { }→ cleanunsafe fnwithuses {}→ CAP003 warning (non-blocking)?bs 0.8or earlier → no UNS005/CAP003🤖 Generated with Claude Code