Skip to content

feat: UNS005 — external call without declared result contract (?bs 0.9)#63

Open
marcelofarias wants to merge 17 commits into
mainfrom
botkowski/uns005-v2
Open

feat: UNS005 — external call without declared result contract (?bs 0.9)#63
marcelofarias wants to merge 17 commits into
mainfrom
botkowski/uns005-v2

Conversation

@marcelofarias
Copy link
Copy Markdown
Owner

Summary

  • Adds UNS005: from ?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.
  • Suppression: wrap in match ns.method(...) { Ok(...) => ..., Err(...) => ... } or use unsafe "<reason>" { ns.method(...) }.
  • Unlike UNS001–UNS004 (programmer-applied), UNS005 is compiler-inferred.
  • Also includes the CAP003 warning infrastructure (non-blocking: uses {} on unsafe fn is programmer-asserted, not compiler-proven) and the warning severity extension to TransformResult.

Closes #50.

Supersedes #61 (rebased cleanly onto main after DEP001/DEP002 merge in #60).

Test plan

  • pnpm -r test passes (564/564)
  • bs explain UNS005 returns the rule/idiom/rewrite
  • bs explain CAP003 returns the warning semantics
  • Bare stdlib call at ?bs 0.9 → UNS005 error
  • Same call wrapped in match → clean
  • Same call inside unsafe "..." { } → clean
  • unsafe fn with uses {} → CAP003 warning (non-blocking)
  • Files pinned ?bs 0.8 or earlier → no UNS005/CAP003

🤖 Generated with Claude Code

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 before passUnsafe at minVersion: "0.9".
  • New UNS005 entry in error-codes.ts and explanations.ts; MCP test list updated.
  • CAP003 test fixtures and the CAP003 example in error-codes.ts/explanations.ts rewrapped 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.

Comment on lines +133 to +134
// Advance past the call to avoid redundant hits (e.g. chained calls).
i = parenTok.matchedAt ?? parenIdx;
Comment on lines +181 to +213
/** 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;
}
}
Comment on lines +219 to +246
/**
* 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");
Comment on lines +276 to +282
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. */
Comment on lines +116 to +117
start: tok.start,
end: memberTok.end,
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 is Pattern -> body and 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 " +

Comment on lines +83 to +87
"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" +
Comment on lines +64 to +65
" Ok(data) => ok(data),\n" +
" Err(e) => err(e),\n" +
Comment on lines +134 to +135
` Ok(value) => { /* use value */ },\n` +
` Err(e) => { /* handle error */ },\n` +
Comment thread packages/compiler/src/error-codes.ts Outdated
Comment on lines +82 to +83
" Ok(data) => ok(data),\n" +
" Err(e) => err(`fetch failed: ${e}`),\n" +
Comment thread packages/mcp/src/explanations.ts Outdated
Comment on lines +109 to +110
" Ok(data) => ok(data),\n" +
" Err(e) => err(e),\n" +
Comment thread CHANGELOG.md Outdated
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(...) => ... }`
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Comment on lines +8 to +12
* "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.
*
Comment on lines +105 to +109
// 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.
Comment thread packages/mcp/src/explanations.ts Outdated
"`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, " +
Comment thread packages/compiler/src/error-codes.ts Outdated
Comment on lines +154 to +161
"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}",
Comment on lines +4 to +6
* Fires on any stdlib capability call (http.x, fs.x, time.x, etc.) that has
* no declared result contract at the call site.
*
Comment thread CHANGELOG.md
Comment on lines +21 to +32
- **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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Comment on lines +229 to +233
i--;
continue;
}
return t.kind === "keyword" && t.keyword === "match";
}
Comment on lines +38 to +44
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"]);
Comment on lines +116 to +120
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" +
Comment thread AGENTS.md Outdated
| 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. |
Comment thread CHANGELOG.md
@@ -34,10 +47,9 @@ goes behind a new pin.
unaffected; the new field is additive.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts still defines its own STDLIB_TO_CAP copy. 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",
};

Comment on lines +159 to +179
/** 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;
Comment thread packages/mcp/src/explanations.ts Outdated
"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 " +
Comment thread packages/compiler/src/error-codes.ts Outdated
"`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 " +
Comment thread CHANGELOG.md Outdated
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" +
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Comment thread README.md
| `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). |
Comment on lines +30 to 33
/** 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",
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comment on lines +30 to +31
/** 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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comment on lines +148 to +150
if (diagnostics.length > 0) {
throw new BotscriptError(diagnostics);
}
Comment on lines +165 to +179
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;
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Comment on lines +215 to +223
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");
}
Comment on lines +103 to 105
const { code, forms, version, warnings } = transform(source, filename ? { filename } : {});
return json({ ok: true, code, forms, version, warnings: [...warnings] });
} catch (e) {
@marcelofarias marcelofarias requested a review from Copilot May 20, 2026 12:13
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Comment on lines +224 to +228
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" +
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Comment on lines +93 to +96
// 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;
Comment on lines +316 to +333
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;
}
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Comment on lines +63 to +67
// 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 });
}
Comment on lines +316 to +320
function nextSignificant(tokens: Token[], start: number): number {
let i = start;
while (i < tokens.length) {
const t = tokens[i];
if (!t) return i;
Marcelo Farias and others added 17 commits May 22, 2026 02:50
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.

Comment on lines +225 to +241
// 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");
Comment thread README.md
| `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. |
Comment on lines +4 to +12
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: UNS005 — external call without declared result contract

2 participants