feat: declaration-level unsafe fn escape hatch (closes #49)#53
Open
marcelofarias wants to merge 5 commits into
Open
feat: declaration-level unsafe fn escape hatch (closes #49)#53marcelofarias wants to merge 5 commits into
marcelofarias wants to merge 5 commits into
Conversation
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>
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>
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>
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>
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>
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
unsafe "reason" fn name(…) -> T { … }— marks a fn as the type-coercion trust boundaryascasts are allowed without repeating the justification at every call site/* unsafe: "…" */before the compiled function so reviewers see it in the diffasync:unsafe "reason" async fnMotivation (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 inunsafe, creating incentive to just turn off the check. Declaration-levelunsafe fneliminates 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] fnprefix; storeunsafeReasononFnDecl;tokenStartcovers theunsafekeyword so it's stripped from outputpasses/unsafe.ts: Skip declaration-levelunsafe fn(don't throw UNS003 —passFnhandles this form)passes/bare-as.ts:collectUnsafeFnBodiesadds unsafe fn body source-offset ranges to skip set (separate from token-index ranges for unsafe block bodies)passes/fn.ts:emitFnprepends/* unsafe: "…" */whenunsafeReasonis settests/unsafe-fn.test.ts: 12 tests — basic, async, validation, UNS004 still fires outside unsafe fn, version compatTest plan
pnpm -r build && pnpm test— 455 tests passasin normal fn body still throws UNS004unsafe "" fnthrows UNS002 (empty reason)unsafe fnwithout reason string: bareasthrows UNS004 (not treated as declaration-level unsafe fn)🤖 Generated with Claude Code