Skip to content

feat: declaration-level unsafe fn escape hatch (closes #49)#53

Open
marcelofarias wants to merge 5 commits into
mainfrom
botkowski/unsafe-fn
Open

feat: declaration-level unsafe fn escape hatch (closes #49)#53
marcelofarias wants to merge 5 commits into
mainfrom
botkowski/unsafe-fn

Conversation

@marcelofarias
Copy link
Copy Markdown
Owner

Summary

  • Implements issue feat: declaration-level unsafe fn escape to avoid per-site repetition #49: unsafe "reason" fn name(…) -> T { … } — marks a fn as the type-coercion trust boundary
  • Inside the body, bare as casts are allowed without repeating the justification at every call site
  • The reason is emitted as /* unsafe: "…" */ before the compiled function so reviewers see it in the diff
  • Works with async: unsafe "reason" async fn
  • Callers treat the fn as a normal fn — no unsafe context required at the call site

Motivation (from #49 and #48)

The most common false-positive pressure on UNS004: an adapter/normalization fn that is the one safe coercion boundary in a module. With per-cast unsafe { }, authors repeat the same justification at every call site, or wrap every call in unsafe, creating incentive to just turn off the check. Declaration-level unsafe fn eliminates the per-site repetition without weakening the audit trail — the reason is in the diff at the declaration.

Implementation

  • parser/parse-fn.ts: Detect [unsafe "reason"] [async] fn prefix; store unsafeReason on FnDecl; tokenStart covers the unsafe keyword so it's stripped from output
  • passes/unsafe.ts: Skip declaration-level unsafe fn (don't throw UNS003 — passFn handles this form)
  • passes/bare-as.ts: collectUnsafeFnBodies adds unsafe fn body source-offset ranges to skip set (separate from token-index ranges for unsafe block bodies)
  • passes/fn.ts: emitFn prepends /* unsafe: "…" */ when unsafeReason is set
  • tests/unsafe-fn.test.ts: 12 tests — basic, async, validation, UNS004 still fires outside unsafe fn, version compat

Test plan

  • pnpm -r build && pnpm test — 455 tests pass
  • Bare as in normal fn body still throws UNS004
  • unsafe "" fn throws UNS002 (empty reason)
  • unsafe fn without reason string: bare as throws UNS004 (not treated as declaration-level unsafe fn)
  • Forward-compat: files < 0.5 are unaffected (passBareAs doesn't run)
  • STDLIB.bs updated with the new form

🤖 Generated with Claude Code

Adds `unsafe "reason" fn name(…) -> T { … }` — a way to mark a function
itself as the type-coercion trust boundary instead of repeating the
justification at every call site.

Inside the body of an `unsafe fn`, bare `as` casts are allowed without
wrapping each cast in `unsafe "reason" { … }`. The justification lives
on the declaration; callers treat the fn as a normal fn with no unsafe
context required.

The reason is preserved as a `/* unsafe: "…" */` comment before the
compiled TypeScript function, so the diff reviewer sees the why.

Works with async: `unsafe "reason" async fn`.

Implementation:
- parser/parse-fn.ts: detect `[unsafe "reason"] [async] fn` prefix;
  store unsafeReason on FnDecl; tokenStart covers the unsafe keyword
- passes/unsafe.ts: skip declaration-level unsafe fn (don't throw UNS003)
- passes/bare-as.ts: collectUnsafeFnBodies adds unsafe fn body source
  ranges to the skip set (character-offset ranges, separate from the
  token-index ranges used for unsafe block bodies)
- passes/fn.ts: emitFn prepends /* unsafe: "…" */ when unsafeReason set
- tests/unsafe-fn.test.ts: 12 tests covering basic, async, validation,
  UNS004 still fires outside, and version compat
- STDLIB.bs, primer.ts, explanations.ts, AGENTS.md: document the form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@marcelofarias marcelofarias requested a review from Copilot May 15, 2026 08:00
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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

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 4 comments.

Comment thread packages/compiler/src/passes/unsafe.ts Outdated
Comment on lines +57 to +61
// Declaration-level `unsafe "reason" fn` — the fn keyword (or `async` before it)
// follows the reason string instead of `{`. This form is handled entirely by
// passFn; passUnsafe must not throw here.
if (open && open.kind === "keyword" && (open.keyword === "fn" || open.keyword === "async")) {
continue;
Comment on lines +301 to +325
const j = skipTrivia(tokens, i + 1);
const reasonTok = tokens[j];
if (!reasonTok || reasonTok.kind !== "string") continue;

const k = skipTrivia(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 = skipTrivia(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 54 to 60
const tparams = decl.typeParams ?? "";
const unsafePrefix = decl.unsafeReason
? `/* unsafe: ${JSON.stringify(decl.unsafeReason)} */\n`
: "";
return (
`${asyncPrefix}function ${decl.name}${tparams}${decl.args}: ${decl.returnType} {\n` +
`${unsafePrefix}${asyncPrefix}function ${decl.name}${tparams}${decl.args}: ${decl.returnType} {\n` +
` return $enter(${capsLiteral} as const, ${arrow}{\n` +
Comment on lines +114 to 144
// Detect leading `async` and/or `unsafe "reason"` modifiers.
// Valid prefix forms (in source order):
// fn name(...)
// async fn name(...)
// unsafe "reason" fn name(...)
// unsafe "reason" async fn name(...)
let isAsync = false;
let tokenStart = idx;
const prev = prevSignificant(tokens, idx);
if (prev !== -1 && tokens[prev]!.kind === "keyword" && tokens[prev]!.keyword === "async") {
let unsafeReason: string | undefined;

const prev1 = prevSignificant(tokens, idx);
if (prev1 !== -1 && tokens[prev1]!.kind === "keyword" && tokens[prev1]!.keyword === "async") {
isAsync = true;
tokenStart = prev;
tokenStart = prev1;
// Check for `unsafe "reason"` before `async`
const prev2 = prevSignificant(tokens, prev1);
if (prev2 !== -1 && tokens[prev2]!.kind === "string") {
const prev3 = prevSignificant(tokens, prev2);
if (prev3 !== -1 && tokens[prev3]!.kind === "keyword" && tokens[prev3]!.keyword === "unsafe") {
unsafeReason = tokens[prev2]!.text.slice(1, -1);
tokenStart = prev3;
}
}
} else if (prev1 !== -1 && tokens[prev1]!.kind === "string") {
// Check for `unsafe "reason"` before `fn` (no async)
const prev2 = prevSignificant(tokens, prev1);
if (prev2 !== -1 && tokens[prev2]!.kind === "keyword" && tokens[prev2]!.keyword === "unsafe") {
unsafeReason = tokens[prev1]!.text.slice(1, -1);
tokenStart = prev2;
}
}
- passUnsafe: verify `async` is followed by `fn` before continuing past
  declaration-level unsafe — prevents `unsafe "r" async const x = 1`
  from being silently ignored
- bare-as: throw UNS002 from collectUnsafeFnBodies when a declaration-level
  `unsafe "" fn` has an empty reason, independent of passUnsafe's
  expression-position heuristic
- fn: validate unsafeReason is non-empty (UNS002) in passFn for pre-0.5
  code paths where passBareAs doesn't run; use `!== undefined` instead
  of truthiness check in emitFn so empty-string reason is handled cleanly

All 455 tests pass.

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 9 out of 9 changed files in this pull request and generated 3 comments.

Comment on lines +13 to +17
import { transform } from "../src/transform.js";
import { formatSource } from "../src/format/format.js";

function compile(src: string): string {
return transform(formatSource(src)).code;
Comment on lines 67 to 73
const t = tokens[i];
if (!t || t.kind !== "ident" || t.text !== "as") continue;
if (insideAny(i, skip)) continue;
if (insideAnyChar(t.start, unsafeFnBodyRanges)) continue;
if (!isExpressionPosition(tokens, i)) continue;
if (!looksLikeTypeAfter(tokens, i)) continue;
throw mkError(t, src);
Comment on lines +114 to +119
// Detect leading `async` and/or `unsafe "reason"` modifiers.
// Valid prefix forms (in source order):
// fn name(...)
// async fn name(...)
// unsafe "reason" fn name(...)
// unsafe "reason" async fn name(...)
- tests: switch unsafe-fn.test.ts imports to public index.js surface; write
  all test inputs in canonical form so FMT001 doesn't fire and
  canonicalization bugs aren't hidden (matches convention of all other
  test files in the package)
- bare-as: update UNS004 message to name both escape hatches —
  `unsafe "reason" { … }` block and `unsafe "reason" fn` declaration
- parse-fn: update doc comments to reflect that tokenStart can point to
  the `unsafe` keyword and that parseFn walks backwards past `unsafe "reason"`
  as well as `async`

All 455 tests pass.

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 9 out of 9 changed files in this pull request and generated 3 comments.

Comment on lines +115 to 145
// Detect leading `async` and/or `unsafe "reason"` modifiers.
// Valid prefix forms (in source order):
// fn name(...)
// async fn name(...)
// unsafe "reason" fn name(...)
// unsafe "reason" async fn name(...)
let isAsync = false;
let tokenStart = idx;
const prev = prevSignificant(tokens, idx);
if (prev !== -1 && tokens[prev]!.kind === "keyword" && tokens[prev]!.keyword === "async") {
let unsafeReason: string | undefined;

const prev1 = prevSignificant(tokens, idx);
if (prev1 !== -1 && tokens[prev1]!.kind === "keyword" && tokens[prev1]!.keyword === "async") {
isAsync = true;
tokenStart = prev;
tokenStart = prev1;
// Check for `unsafe "reason"` before `async`
const prev2 = prevSignificant(tokens, prev1);
if (prev2 !== -1 && tokens[prev2]!.kind === "string") {
const prev3 = prevSignificant(tokens, prev2);
if (prev3 !== -1 && tokens[prev3]!.kind === "keyword" && tokens[prev3]!.keyword === "unsafe") {
unsafeReason = tokens[prev2]!.text.slice(1, -1);
tokenStart = prev3;
}
}
} else if (prev1 !== -1 && tokens[prev1]!.kind === "string") {
// Check for `unsafe "reason"` before `fn` (no async)
const prev2 = prevSignificant(tokens, prev1);
if (prev2 !== -1 && tokens[prev2]!.kind === "keyword" && tokens[prev2]!.keyword === "unsafe") {
unsafeReason = tokens[prev1]!.text.slice(1, -1);
tokenStart = prev2;
}
}
Comment on lines 74 to 80
const tparams = decl.typeParams ?? "";
const unsafePrefix = decl.unsafeReason !== undefined
? `/* unsafe: ${JSON.stringify(decl.unsafeReason)} */\n`
: "";
return (
`${asyncPrefix}function ${decl.name}${tparams}${decl.args}: ${decl.returnType} {\n` +
`${unsafePrefix}${asyncPrefix}function ${decl.name}${tparams}${decl.args}: ${decl.returnType} {\n` +
` return $enter(${capsLiteral} as const, ${arrow}{\n` +
const out = compile(src);
expect(out).toContain('/* unsafe: "test only" */');
expect(out).toContain("async function asyncCoerce");
});
…ction

- parse-fn.ts: support `async unsafe "reason" fn` modifier order; previously
  the `async` keyword was silently dropped when it preceded `unsafe`, leaving
  a dangling `async` token in the emitted TypeScript
- fn.ts: escape `*/` in the unsafe reason comment to prevent block-comment
  injection when a justification string contains the sequence
- tests: add coverage for both cases (async before unsafe, and `*/` in reason)

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 9 out of 9 changed files in this pull request and generated 4 comments.

Comment on lines +32 to +37
// Declaration-level `unsafe "reason" fn` must have a non-empty reason (UNS002).
if (decl.unsafeReason !== undefined && decl.unsafeReason.trim() === "") {
const tok = tokens[decl.tokenStart]!;
const entry = getErrorCode("UNS002")!;
const { line, column } = locationOf(src, tok.start);
const diag: Diagnostic = {
// fn name(...)
// async fn name(...)
// unsafe "reason" fn name(...)
// unsafe "reason" async fn name(...)
Comment on lines +57 to +67
// Declaration-level `unsafe "reason" fn` — the fn keyword (or `async fn` before it)
// follows the reason string instead of `{`. This form is handled entirely by
// passFn; passUnsafe must not throw here.
if (open && open.kind === "keyword" && open.keyword === "fn") {
continue;
}
if (open && open.kind === "keyword" && open.keyword === "async") {
const m = skipTrivia(tokens, k + 1);
const fnTok = tokens[m];
if (fnTok && fnTok.kind === "keyword" && fnTok.keyword === "fn") continue;
}
const commentMatch = out.match(/\/\* unsafe: (.*?) \*\//s);
expect(commentMatch).not.toBeNull();
expect(commentMatch![1]).not.toContain("*/");
});
…ion, docs

- Track `unsafeReasonStart` in FnDecl so UNS002 anchors at the reason
  string token rather than the `async`/`unsafe` keyword
- Add `async unsafe "reason" fn` to the valid prefix forms doc comment
- Escape `*/` in passUnsafe block-comment output to prevent comment injection
- Add test: unsafe block reason containing `*/` does not break the comment

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 9 out of 9 changed files in this pull request and generated 3 comments.

Comment on lines +329 to +341
const entry = getErrorCode("UNS002")!;
const { line, column } = locationOf(src, t.start);
const diag: Diagnostic = {
code: "UNS002",
severity: "error",
file: null,
line,
column,
message: "declaration-level unsafe fn has an empty justification string",
rule: entry.rule,
idiom: entry.idiom,
rewrite: entry.rewrite,
};
message: "declaration-level unsafe fn has an empty justification string",
rule: entry.rule,
idiom: entry.idiom,
rewrite: entry.rewrite,
`;
const out = compile(src);
// The emitted comment must not contain a raw `*/` before the closing delimiter.
// JSON.stringify escapes it so the comment is a single valid block comment.
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.

2 participants