Skip to content

Read @throws from library declarations in exception analysis - #9198

Open
cknitt wants to merge 1 commit into
masterfrom
drift-9182-temporal-exn
Open

cknitt wants to merge 1 commit into
masterfrom
drift-9182-temporal-exn

Conversation

@cknitt

@cknitt cknitt commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

The exception analysis knew what library functions throw only from a hand-kept table in analysis/reanalyze/src/exn_lib.ml, and that table drifted. #9182 is the example: the eight Temporal fromStringOrThrow functions were never added, so rescript-tools reanalyze -exception treated Temporal.PlainDate.fromStringOrThrow("x") as throwing nothing. A few Intl and Belt functions were missing as well. Third-party libraries, and a project's own externals, had no way to declare their exceptions at all.

Fixes #9182

The new rule

At each call, the analysis reads @throws(...) from the declaration of the value being called. The .cmi keeps the attributes of externals and interface declarations, so their @throws reaches calls from other modules and packages. It also survives module aliases, open, include, functor results and first-class modules.

A call's exceptions are declared(f) ∪ values(f): the declaration's @throws, together with the values-table entry (the @throws on the implementation's own binding, as before). There is no table any more. I chose the union over letting one source win because either source can be the only one written down. An annotated .resi let whose .res binding isn't annotated is a common case, and a call can throw whatever either source names.

A @throws without a payload still means nothing, as it always has on bindings.

Compiler: @throws in inferred signatures

A let in a module without an interface used to lose its attributes in the inferred signature, so its @throws couldn't reach other modules. That is why Stdlib.panic needed the table. Typemod.type_structure already copied a binding's @deprecated into the signature item (#8924). It now copies the @throws attributes the same way, into the signature only. The other spellings (@throw and the deprecated @raises/@raise) are not copied; a separate PR removes them from reanalyze altogether.

Other attributes stay out on purpose. A doc comment is an attribute, and having it in the .cmi would make editing a comment recompile the module's dependents. A module with a .resi is unaffected, because its .cmi holds the interface's declarations. The test below checks that an annotated binding behind an unannotated .resi is still not reported through the signature. The .cmi format and magic numbers are unchanged.

What moved into the sources

Every stdlib and Belt declaration the table listed now carries its own annotation, in its .resi or, where a module has none, in the .res:

  • the getExn/getOrThrow family across Belt's collections (including the .res-only internal ones), Option and Result
  • Belt.Array.getExn/setExn (Assert_failure)
  • List, Null, Nullable, Option, Result, Bool and BigInt *OrThrow
  • JSON.parseOrThrow and stringifyAny
  • the JsError throwers
  • / and mod (Division_by_zero)
  • Stdlib.panic and Stdlib.assertEqual

The functions #9182 found missing are annotated too: the Temporal fromStringOrThrow, the Intl *Exn externals, Belt.MutableStack.popOrThrow, and Belt.Map.Dict/Belt.Set.Dict getOrThrow.

One correction: the old table said assertEqual throws JsExn, but it is implemented with assert(false), which throws Assert_failure. The annotation now says Assert_failure. The two payload-less @throws in Stdlib_JSON.res now name JsExn.

The only change to generated JavaScript is the line number inside assertEqual's Assert_failure in Stdlib.mjs/Stdlib.cjs, which moved because a line was added above it.

I deliberately left unannotated the functions that throw only for some arguments and whose names don't say so: Temporal constructors and arithmetic, Int.range with step 0, and Date.toTemporalInstant on an invalid date. Annotating them would flag every call to them. The % operator, the same primitive as mod, was never in the table, and I've left it as it was.

exn_lib.ml is deleted, along with the Yojson stub, its test and the empty BsJson.res. Calls to bs-json or Yojson functions are reported only if those libraries annotate their declarations.

Guard

tests/analysis_tests/tests-reanalyze/checkThrowsAnnotations.mjs runs at the start of the reanalyze test.sh. It fails when a stdlib or Belt value named *OrThrow or *Exn has no @throws(...) with a payload. It checks the module's .resi, or every let and external of a .res that has no interface. JsError.toJsExn is allowlisted because it converts a value and throws nothing. Removing a single annotation makes the guard fail.

Tests

  • src/exception/ThrowsDecl.res, ThrowsIface.res/.resi and ThrowsDeclCalls.res are new. Calls from another module to an annotated external, a nested external, an annotated .resi let (whose .res binding is unannotated), a .resi external, and a module type's let reached through a functor and a first-class module are reported. So are calls made through a pipe, an alias, open and include.
    • callViaLetIncluded calls an annotated let in a .res without an interface, through an include, so the values table can't see it. It is reported only because of the typemod change.
    • callImplOnlyIncluded shows that a .resi without @throws still wins over an annotated .res binding.
  • StdlibTest.res and BeltTest.res keep the cases from the first version of this PR. They also cover panic and assertEqual, which reach calls through the new signature attributes, plus JsError.RangeError.throwWithMessage, List.headOrThrow, Bool.fromStringOrThrow, Nullable.getOrThrow, JSON.stringifyAny, Belt.Array.getExn and Belt.MutableQueue.popOrThrow.
  • ExnPayloadGuard.res takes over TestYojson.res's case: catching an exception with a payload pattern and a guard, using its own exception.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kbf3gGhHjzBTXqSo8Dckpb

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Changelog

🚀 New Feature

  • Exception analysis reads @throws from library and external declarations, including third-party ones, instead of a built-in list, and a @throws on a let now reaches other modules even without an interface.

Semantic Diff

1 commit · Full page

Read @throws from library declarations in exception analysis

1d38461 · cknitt + Claude · Nov 03, 2026, 18:23 UTC

Why

The exception analysis knew what library functions throw only from a table kept by hand in exn_lib.ml. The table drifted: the Temporal fromStringOrThrow functions were never added (#9182), and neither were a few Intl and Belt ones. Third-party libraries and a project's own externals had no way to say what they throw.

A call now takes the exceptions named by the @throws on the declaration of the value it calls, and the table is gone. The .cmi keeps the attributes of externals and interface declarations, so their @throws reaches calls in other modules and packages, through aliases, open, include, functors and first-class modules. For a let in a module without an interface, the type checker now copies the binding's @throws into the inferred signature, as it already did for @deprecated. The stdlib and Belt declare what their throwing functions throw, and a guard in the reanalyze tests fails when a stdlib or Belt *OrThrow or *Exn declaration has no @throws(...).

Analysis modified (exceptions of a call to f, Exception.Event loop)

rule

BeforeAfter
$$\begin{array}{l}\color{red}{\mathrm{throws}(\texttt{f})=\mathrm{values}(\texttt{f})} \qquad \color{red}{\mathrm{when}\ \texttt{f}\ \mathrm{is}\ \mathrm{in}\ \mathrm{the}\ \mathrm{values}\ \mathrm{table}} \\ \color{red}{\mathrm{throws}(\texttt{f})=\mathrm{table}(\texttt{f})} \qquad \color{red}{\mathrm{otherwise}}\end{array}$$ $$\begin{array}{l}\color{green}{\mathrm{throws}(\texttt{f})=\mathrm{declared}(\texttt{f})∪\mathrm{values}(\texttt{f})}\end{array}$$

Signature modified (inferred signature of a module without an interface, Typemod.type_structure)

/** Finds the value. */
@throws(Not_found)
let find = x => x > 0 ? x : throw(Not_found)

rescript · Before: the signature in the .cmi

+@throws(Not_found)
 let find: int => int

Reanalyze modified (calls to annotated externals)

// Lib.res
@throws(JsExn)
external parse: string => int = "parseInt"

// App.res
let parse = s => Lib.parse(s)

@throws(JsExn)
let parseAnnotated = s => Lib.parse(s)

let date = () => Temporal.PlainDate.fromStringOrThrow("x")

reanalyze

-Exception Analysis
-App.res:4:5-18
-parseAnnotated throws nothing and is annotated with redundant @throws(JsExn)
 
-Analysis reported 1 issues (Exception Analysis:1)
 
+Exception Analysis
+App.res:1:5-9
+parse might throw JsExn (App.res:1:17) and is not annotated with @throws(JsExn)
 
+Exception Analysis
+App.res:6:5-8
+date might throw JsExn (App.res:6:17) and is not annotated with @throws(JsExn)
 
+Analysis reported 2 issues (Exception Analysis:2)

Fixes #9182

Review

  • Why the union of the declaration and the values table, rather than one winning? The values table holds the @throws on the implementation's binding, the declaration the one in its interface. Either can be the only one written, as with an annotated .resi let whose .res isn't annotated, so the call can throw what either names.
  • Why does the inferred signature take only the @throws family and @deprecated, and not every attribute? A doc comment is an attribute too: in the .cmi it would make editing a comment recompile the module's dependents. A module with a .resi is unchanged, since its .cmi holds the interface's declarations.
  • What does a @throws without a payload mean? Nothing, as on a binding. The guard requires a payload, and the stdlib's two payload-less @throws in Stdlib_JSON.res now name JsExn.

@codecov

codecov Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.00000% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.73%. Comparing base (ac73578) to head (1d38461).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
analysis/reanalyze/src/exception.ml 80.95% 4 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##           master    #9198   +/-   ##
=======================================
  Coverage   81.73%   81.73%           
=======================================
  Files         458      457    -1     
  Lines       65292    65286    -6     
=======================================
- Hits        53364    53361    -3     
+ Misses      11928    11925    -3     
Files with missing lines Coverage Δ
analysis/reanalyze/src/exn.ml 100.00% <ø> (ø)
compiler/ml/typemod.ml 77.08% <100.00%> (+0.17%) ⬆️
analysis/reanalyze/src/exception.ml 79.79% <80.95%> (-0.07%) ⬇️

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pkg-pr-new

pkg-pr-new Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@9198

@rescript/belt

npm i https://pkg.pr.new/@rescript/belt@9198

@rescript/darwin-arm64

npm i https://pkg.pr.new/@rescript/darwin-arm64@9198

@rescript/linux-arm64

npm i https://pkg.pr.new/@rescript/linux-arm64@9198

@rescript/linux-x64

npm i https://pkg.pr.new/@rescript/linux-x64@9198

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@9198

@rescript/win32-x64

npm i https://pkg.pr.new/@rescript/win32-x64@9198

commit: 1d38461

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

@cknitt
cknitt force-pushed the drift-9182-temporal-exn branch from 0a37396 to 9b3d665 Compare November 3, 2026 18:59
@cknitt cknitt changed the title Know the Temporal fromStringOrThrow and other missing throwing functions in exception analysis Read @throws from library declarations in exception analysis Nov 3, 2026
@cknitt

cknitt commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-03T19:45:12.093937Z 76f8a78 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 9b3d66507a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cknitt
cknitt force-pushed the drift-9182-temporal-exn branch from 9b3d665 to 76f8a78 Compare November 3, 2026 19:40
@cknitt

cknitt commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 76f8a7895a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cknitt
cknitt force-pushed the drift-9182-temporal-exn branch from 76f8a78 to 68c774c Compare November 3, 2026 19:57
Why:
    The exception analysis knew what library functions throw only from a
    table kept by hand in exn_lib.ml. The table drifted: the Temporal
    `fromStringOrThrow` functions were never added (#9182), and neither
    were a few Intl and Belt ones. Third-party libraries and a project's
    own externals had no way to say what they throw.

A call now takes the exceptions named by the `@throws` on the declaration
of the value it calls, and the table is gone. The `.cmi` keeps the
attributes of externals and interface declarations, so their `@throws`
reaches calls in other modules and packages, through aliases, `open`,
`include`, functors and first-class modules. For a `let` in a module
without an interface, the type checker now copies the binding's `@throws`
into the inferred signature, as it already did for `@deprecated`. The
stdlib and Belt declare what their throwing functions throw, and a guard
in the reanalyze tests fails when a stdlib or Belt `*OrThrow` or `*Exn`
declaration has no `@throws(...)`.

Analysis modified (exceptions of a call to `f`, `Exception.Event` loop):
Before (rule):
    throws(`f`) = values(`f`)   when `f` is in the values table
    throws(`f`) = table(`f`)   otherwise
After (rule):
    throws(`f`) = declared(`f`) ∪ values(`f`)

Signature modified (inferred signature of a module without an interface, `Typemod.type_structure`):
Example:
    /** Finds the value. */
    @throws(Not_found)
    let find = x => x > 0 ? x : throw(Not_found)
Before (rescript): the signature in the .cmi
    let find: int => int
After (rescript):
    @throws(Not_found)
    let find: int => int

Reanalyze modified (calls to annotated externals):
Example:
    // Lib.res
    @throws(JsExn)
    external parse: string => int = "parseInt"

    // App.res
    let parse = s => Lib.parse(s)

    @throws(JsExn)
    let parseAnnotated = s => Lib.parse(s)

    let date = () => Temporal.PlainDate.fromStringOrThrow("x")
Before (reanalyze):
    Exception Analysis
    App.res:4:5-18
    parseAnnotated throws nothing and is annotated with redundant @throws(JsExn)

    Analysis reported 1 issues (Exception Analysis:1)
After (reanalyze):
    Exception Analysis
    App.res:1:5-9
    parse might throw JsExn (App.res:1:17) and is not annotated with @throws(JsExn)

    Exception Analysis
    App.res:6:5-8
    date might throw JsExn (App.res:6:17) and is not annotated with @throws(JsExn)

    Analysis reported 2 issues (Exception Analysis:2)

Review: Why the union of the declaration and the values table, rather than
one winning? The values table holds the `@throws` on the implementation's
binding, the declaration the one in its interface. Either can be the only
one written, as with an annotated `.resi` `let` whose `.res` isn't
annotated, so the call can throw what either names.

Review: Why does the inferred signature take only the `@throws` family and
`@deprecated`, and not every attribute? A doc comment is an attribute too:
in the `.cmi` it would make editing a comment recompile the module's
dependents. A module with a `.resi` is unchanged, since its `.cmi` holds
the interface's declarations.

Review: What does a `@throws` without a payload mean? Nothing, as on a
binding. The guard requires a payload, and the stdlib's two payload-less
`@throws` in Stdlib_JSON.res now name `JsExn`.

Fixes #9182

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kbf3gGhHjzBTXqSo8Dckpb
Signed-off-by: Christoph Knittel <ck@cca.io>

This branch has not been deployed

No deployments
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.

Exception analysis does not know the Temporal fromStringOrThrow functions

1 participant