Skip to content

Resolve FFI externals during type checking - #8728

Draft
cknitt wants to merge 1 commit into
masterfrom
codex/resolve-external-in-typedecl
Draft

cknitt wants to merge 1 commit into
masterfrom
codex/resolve-external-in-typedecl

Conversation

@cknitt

@cknitt cknitt commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Why

Parsetree is the AST that PPXs see, that -bs-ast serializes, and that #8624 wants to freeze as "v1". A principle there is that the parsetree describes source code only.

Externals broke that. After the built-in PPX had read an external's FFI attributes (@send, @module, @as, @inline, …), it replaced the primitive string with the computed specification:

and primitive_repr =
  | Prim_name of string
  | Prim_ffi of {name: string; spec: External_ffi_types.t}
  | Prim_inline_const of External_ffi_types.inline_const

So parsetree.ml depended on the compiler's internal FFI representation, the type had two cases no PPX can observe, and the v0 bridge needed an error branch for them.

What changes

pval_prim is the string as written, with its location: string loc option. The attributes stay where the user put them, and the type checker interprets them, once.

For

@send external on: (t, @as("click") _, unit => unit) => unit = "addEventListener"
@inline let answer = 42

the tree after the built-in PPX (bsc -only-parse -dparsetree) changes from

value_description "on"
  core_type
    Ptyp_arrow        (* arity 2: the @as argument already erased *)
    …
  "addEventListener"
  <ffi spec>
value_description "answer"
  core_type
    Ptyp_constr "int"
  <inline const>

to

value_description "on"
  attribute "send"
  core_type
    Ptyp_arrow        (* arity 3, with the Parg_fixed "click" argument *)
    …
  "addEventListener"
value_description "answer"
  attribute "inline"
    [ Pstr_eval (Pexp_constant PConst_int (42,None)) ]
  core_type
    Ptyp_constr "int"
  "#rescript-inline"

How the pieces fit:

  • Type checker resolves. Typedecl.transl_value_decl calls Primitive.resolve_external, a hook that returns the resolved type, the attributes resolution did not consume, the primitive name and its kind (Kind_intrinsic, Kind_external spec, Kind_inline_const c). It types the resolved type, so .cmi/.cmj and the generated JavaScript are what they were.
  • The hook. The resolver (Ast_external.resolve) lives above compiler/ml, so it is a hook; Ppx_entry, which every compiler driver links, registers it at module initialization, like the Oprint hooks. It runs in the type checker because -bs-ast writes the parsetree after the built-in PPX and a later bsc process reads it back: the specification must be recomputable from the tree.
  • Diagnostics come from the type checker. The resolver reports an external's errors and warnings, and checks unused attributes on the resolved form (Bs_ast_invariant's pass skips FFI externals). It runs inside the declaration's @warning scope, so @warning("-101") on an external now silences its unused-attribute warning. Errors and warning 101 now come in source order with type errors rather than in the parse step; the reprint path and -bs-ast alone no longer report them.
  • Relative @module paths. Such an external must not be inlined in other modules, since its path is relative to the declaring file. The built-in PPX used to wrap it in include (struct external … end : {let …}). Now the type checker does the equivalent: in a signature, Typedecl gives it Val_reg; in a structure, Typemod builds that constrained include in the typed tree, whose coercion (Tcoerce_primitive) emits the exported function. Primitive.no_cross_module_inline reads relativeness off the resolved spec (External_ffi_types.is_relative, split out of check_decl), so Ast_external_process.resolution loses its no_inline_cross_module flag. The JavaScript is unchanged, and so are hover, references and definition in the editor.
  • Each unused attribute is reported once. A relative @module external used to keep its resolved form as a val in the wrapper's interface, which was checked as well, so its unused attributes were reported twice.
  • @inline constants become external answer: int = "#rescript-inline" with the @inline(<literal>) attribute kept; Ast_external_mk has the helpers that build and read such a declaration, and Bs_builtin_ppx shrinks to one case per item instead of one per literal kind. A structural node for them is a separate Stamp parsetree v1: what remains before the AST can be frozen for PPXs #8624 item.
  • @inline let bigint constants are inlined now, like the other literals and like bigint constants in signatures. Before, a let skipped them: @inline let big = 10n stayed an exported binding, with warning 53 saying @inline cannot appear there.
  • Ast_external_process.handle_attributes is resolve, returning a resolution record (pval_type, spec, pval_attributes) instead of a tuple and a second wrapper.
  • Printers. Outcometree.oval_prim is the value's resolved Primitive.description, so hover and interface output still render the FFI attributes, and Primitive.print is gone. Printast, Pprintast and the ReScript printer print the string as written.
  • Typed tree. Typedtree.val_prim is gone: its two readers, the typed tree printer and gentype, use the value's val_kind (Val_prim), which has the resolved primitive.
  • v0 bridge. Ast_mapper_to0 no longer needs an error branch; Ast_mapper_from0 gives the string a ghost location, since v0 has none.
  • Ast_attributes.rs_externals is renamed is_ffi_external and takes the primitive name. Bs_ast_invariant loses a Bs_ffi_warning branch that could not fire.

Under -bs-no-builtin-ppx, a plain-named external is now resolved as an FFI external by the type checker, where it used to be typed as an intrinsic of that name.

Cost

Each external is resolved once, in the compile step, which now reads it as written. Allocated words for the parse and compile steps together, measured locally against master:

File master this PR
Stdlib_Math.resi 335,281 337,421 (+0.6%)
Stdlib_Math.res 337,081 342,615 (+1.6%)
Stdlib_Console.res 391,697 397,048 (+1.4%)

The parse step allocates less than on master; the rest is the compile step reading each external's attributes as written and resolving them there.

Tests

  • tests/syntax_tests/data/ast-mapping/Externals.res: intrinsic, @val, @send, relative @module, @scope, @obj, @as and @variadic externals print back unchanged after the v0 round trip.
  • ounit_ast_mapper0_tests: an external's primitive string and @send attribute survive Ast_mapper_to0 and Ast_mapper_from0.
  • ounit_string_literal_tests builds its inline constants with Ast_external_mk.inline_const_of_expression.
  • tests/build_tests/super_errors/fixtures/warning_101_external_arg_attribute.res: an @as on a non-_ argument of a @send external still reports warning 101, once per external, in source order.
  • tests/build_tests/super_errors/fixtures/warning_101_relative_module_external.res: the same on a relative @module external is reported once, where it used to be reported twice, and @warning("-101") on an external silences it.
  • tests/tests/src/relative_path_use.res: another module calls relative externals (top level, nested module, functor, declared in a .resi as external or as let) through the exported values, and inlines only the non-relative one.
  • tests/analysis_tests/tests/src/RelativeExternal.res: hover, references and definition on relative externals.
  • tests/tests/src/inline_const.res: @inline let bigint constants, positive and negative, are inlined.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Kbf3gzUzT5G

@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.97468% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.00%. Comparing base (32116b5) to head (85671c0).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
compiler/ml/printtyped.ml 0.00% 4 Missing ⚠️
compiler/frontend/ast_external_mk.ml 87.50% 2 Missing ⚠️
compiler/frontend/bs_builtin_ppx.ml 91.66% 2 Missing ⚠️
compiler/gentype/translate_signature.ml 50.00% 2 Missing ⚠️
compiler/frontend/ast_external.ml 88.88% 1 Missing ⚠️
compiler/frontend/bs_ast_invariant.ml 87.50% 1 Missing ⚠️
compiler/ml/external_ffi_types.ml 92.30% 1 Missing ⚠️
compiler/ml/primitive.ml 75.00% 1 Missing ⚠️
compiler/ml/printast.ml 0.00% 1 Missing ⚠️
compiler/syntax/src/res_outcome_printer.ml 66.66% 1 Missing ⚠️
... and 3 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8728      +/-   ##
==========================================
+ Coverage   81.95%   82.00%   +0.04%     
==========================================
  Files         456      456              
  Lines       65612    65659      +47     
==========================================
+ Hits        53775    53845      +70     
+ Misses      11837    11814      -23     
Files with missing lines Coverage Δ
compiler/core/lam_compile_external_call.ml 92.98% <ø> (ø)
compiler/frontend/ast_attributes.ml 94.20% <100.00%> (+2.42%) ⬆️
compiler/frontend/ast_exp_handle_external.ml 80.48% <100.00%> (ø)
compiler/frontend/ast_external_process.ml 77.51% <100.00%> (-0.15%) ⬇️
compiler/frontend/ast_literal.ml 100.00% <100.00%> (ø)
compiler/frontend/ppx_entry.ml 91.66% <ø> (ø)
compiler/gentype/translation.ml 92.85% <100.00%> (+1.42%) ⬆️
compiler/ml/ast_iterator.ml 94.62% <100.00%> (+0.01%) ⬆️
compiler/ml/ast_mapper.ml 98.49% <100.00%> (+<0.01%) ⬆️
compiler/ml/ast_mapper_from0.ml 79.49% <100.00%> (+0.02%) ⬆️
... and 20 more

... and 9 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.

@cknitt
cknitt force-pushed the codex/resolve-external-in-typedecl branch from 36bd95a to b71e398 Compare October 7, 2026 04:59
@pkg-pr-new

pkg-pr-new Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

rescript

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

@rescript/belt

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

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: 85671c0

@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

@cknitt
cknitt force-pushed the codex/resolve-external-in-typedecl branch 3 times, most recently from e2110a3 to 17bf46b Compare October 13, 2026 20:15
cknitt added a commit that referenced this pull request Oct 16, 2026
`pval_prim` is the primitive string as written, with its location. The
frontend still checks each FFI external, but `Typedecl` gets its spec through
`Primitive.resolve_external`, so the parsetree no longer depends on
`External_ffi_types`.

Review: Why does the frontend resolve each external at all? It reports the
errors and warnings where they were reported before, and decides whether a
relative `@module` keeps other modules from inlining the external. The type
checker's second resolution runs without warnings.

Review: How do `@inline` constants work without `Prim_inline_const`? The
built-in PPX turns them into an external with the internal primitive
`#rescript-inline` that keeps the `@inline(<literal>)` attribute.

Review: Is the global `Primitive.resolve_external` safe with parallel
requests? `Ast_external` sets it once during module initialization, like the
`Oprint` hooks, and it is only read afterwards.

Area: frontend typechecker syntax gentype tests
Output: unchanged, generated JS of the test suites is byte-identical
Formatting: unchanged
Formats: ast cmt
PPX: unchanged, an external's primitive string reaches v0 as before
Editor: unchanged
Errors: none, the new fixture follows the warning_<NN> naming convention
Tests: +tests/syntax_tests/data/ast-mapping/Externals.res
 +tests/build_tests/super_errors/fixtures/warning_101_external_arg_attribute.res
 ~tests/ounit_tests/ounit_ast_mapper0_tests.ml
 ~tests/ounit_tests/ounit_string_literal_tests.ml
PR: #8728
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018oLiCQjGFSE2qwfYqCMF9e
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt
cknitt force-pushed the codex/resolve-external-in-typedecl branch from 17bf46b to 0527e03 Compare October 16, 2026 19:29
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Changelog

🐛 Bug fix

  • Report an unused attribute on a relative @module external once instead of twice.
  • Inline @inline let bigint constants like other literals, instead of warning that @inline cannot appear there.

🏠 Internal

Semantic Diff

5 commits · Full page

✨ Resolve an external's FFI attributes in the type checker

9d69d5d · cknitt + Claude · Oct 16, 2026, 19:28 UTC · new

Why

The parsetree is what PPXs see and what -bs-ast serializes, and #8624 wants it to describe source only. After the built-in PPX had read an external's @send, @module, @as… attributes, it replaced the primitive string with the computed FFI specification (Prim_ffi, Prim_inline_const), so Parsetree depended on External_ffi_types and carried cases no PPX can observe. Now pval_prim is the string as written, with its location, and Typedecl resolves the attributes through Primitive.resolve_external, a hook the frontend registers since the resolver lives above compiler/ml. The generated JavaScript is unchanged.

AST modified (externals after the built-in PPX)

type t
@send external on: (t, @as("click") _, unit => unit) => unit = "addEventListener"
@inline let answer = 42

expanded

 …
 structure_item
   Pstr_primitive
   value_description "on"
+    attribute  "send"
+      []
     core_type
       Ptyp_arrow
-      arity = 2
+      arity = 3
       Nolabel
       core_type
         Ptyp_constr "t"
         []
       Nolabel
+      Parg_fixed
+        value
+          Pfixed_string (source="click", semantic="click")
+      Nolabel
       core_type
         Ptyp_arrow
         …
       core_type
         Ptyp_constr "unit"
         []
     "addEventListener"
-    <ffi spec>
 structure_item
   Pstr_primitive
   value_description "answer"
+    attribute  "inline"
+        structure_item
+          Pstr_eval
+          expression
+            Pexp_constant PConst_int (42,None)
     core_type
       Ptyp_constr "int"
       []
-    <inline const>
+    "#rescript-inline"

Typedtree modified (externals)

type t
@send external on: (t, @as("click") _, unit => unit) => unit = "addEventListener"
@inline let answer = 42

typed

 …
 structure_item
   Tstr_primitive
   value_description on/1002
     core_type
       Ttyp_arrow
       Nolabel
       core_type
         Ttyp_constr "t/1001"
         []
       Nolabel
       core_type
         Ttyp_arrow
         …
       core_type
         Ttyp_constr "unit/5"
         []
     "addEventListener"
-    <ffi spec>
 structure_item
   Tstr_primitive
   value_description answer/1003
     core_type
       Ttyp_constr "int/1"
       []
-    <inline const>
+    "#rescript-inline"

Review

  • Why does the frontend still read the attributes? To report errors and warnings where they were reported, to check unused attributes on the resolved form, and to learn whether a relative @module path keeps other modules from inlining the external, which wraps it in a constrained include as before. The type checker resolves again with warnings off, so nothing is reported twice.
  • Why a hook rather than passing the result along? -bs-ast writes the parsetree after the built-in PPX and a later bsc process reads it back, so the specification has to be recomputable from the tree; Ast_external registers the resolver at module initialization, as the Oprint hooks are, and every entry point that type checks links it.
  • What is "#rescript-inline"? The primitive of an @inline constant's declaration, which keeps its @inline(<literal>) attribute; the resolver turns it into Kind_inline_const. A structural form is a separate Stamp parsetree v1: what remains before the AST can be frozen for PPXs #8624 item.
  • Do PPXs see a difference? No: they run before the built-in PPX, and the v0 bridge carries the string as before; its error branch for already-resolved externals is gone.
  • What changes under -bs-no-builtin-ppx? An external with a plain name is now resolved as an FFI external by the type checker; before, it was typed as an intrinsic of that name.
✨ Check an external's unused attributes where it is resolved

548eab4 · cknitt + Claude · Nov 02, 2026, 19:49 UTC · new

Why

The frontend resolved each FFI external twice, once to validate it and once more for the unused-attribute check after the built-in PPX, and the resolved val that a relative @module external keeps for its interface was checked as well, so its unused attributes were reported twice. Ast_external.validate now runs the check on the form it has just resolved, the later pass skips FFI externals, and an attribute is reported at most once. The structure mapper maps each item before the rest, so these diagnostics stay in source order.

Diagnostic modified (unused attribute on a relative @module external)

@module("./local") external relative: (@as("v") int) => unit = "relative"

diagnostic

 Warning number 101
 A.res:1:40-42
 Unused attribute: @as
-
-Warning number 101
-A.res:1:40-42
-Unused attribute: @as

Review

  • Does the order of warnings change? Between externals, no: the mapper now maps items left to right (the right operand of :: was evaluated first, which had reversed them). An external's unused attributes are reported while it is mapped, so they come before the later pass's warnings for other items, such as integer overflow.
✨ Read a value's primitive from its kind, not copies of it

3cd7c05 · cknitt + Claude · Nov 02, 2026, 19:51 UTC · new

Typedtree.val_prim kept the primitive string as written, but its two readers (the typed tree printer and gentype) only need the resolved primitive, which the value's val_kind already has; the field is gone. Outcometree.oval_prim is that Primitive.description too, instead of out_primitive, a copy of its kind with the name, so Primitive.print is gone as well.

✨ Inline @inline let bigint constants like other literals

59a5808 · cknitt + Claude · Nov 02, 2026, 19:53 UTC · new

Why

An @inline bigint constant in a signature was inlined, but in a let the built-in PPX skipped bigints, so the binding stayed an ordinary exported value and the attribute was reported as misplaced. Both forms now read the constant the same way.

JS modified (@inline let with a bigint)

@inline let big = 10n
let use = () => big

js

 function use() {
   return 10n;
 }
-let big = 10n;
-exports.big = big;
 exports.use = use;

Diagnostic removed (@inline on a bigint let)

@inline let big = 10n

diagnostic · Before

Warning number 53
the @inline attribute cannot appear in this context
✨ Pass an FFI external's primitive name to the code that resolves it

d7f09dd · cknitt + Claude · Nov 02, 2026, 19:54 UTC · new

Ast_attributes.is_ffi_external takes the primitive name rather than pval_prim, so each caller matches Some {txt = prim_name} once and hands the name on, and Ast_external.validate loses an "empty primitive string" error it could not reach. Primitive.resolve_external keeps its string argument for the same reason: Typedecl has already matched it.

@cknitt
cknitt force-pushed the codex/resolve-external-in-typedecl branch 3 times, most recently from 02f4919 to d7f09dd Compare November 2, 2026 19:56
@cknitt
cknitt force-pushed the codex/resolve-external-in-typedecl branch from d7f09dd to e642cee Compare November 10, 2026 16:57
@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Changelog

🐛 Bug fix

  • Report an unused attribute on a relative @module external once instead of twice.
  • A @warning attribute on an external now applies to the warnings about its FFI attributes, such as an unused @as.
  • Inline @inline let bigint constants like other literals, instead of warning that @inline cannot appear there.

🏠 Internal

  • Keep resolved FFI specifications out of the parsetree, which now holds an external's primitive string as written. The type checker resolves each external once.

Semantic Diff

1 commit · Full page

✏️ Resolve an external's FFI attributes in the type checker

85671c0 · cknitt + Claude · Nov 10, 2026, 19:24 UTC · edited

Why

The parsetree is what PPXs see and what -bs-ast serializes, and #8624 wants it to describe source only. After the built-in PPX had read an external's @send, @module, @as… attributes, it replaced the primitive string with the computed FFI specification (Prim_ffi, Prim_inline_const), so Parsetree depended on External_ffi_types and carried cases no PPX can observe. Now pval_prim is the string as written, with its location, and Typedecl resolves the attributes, once, through Primitive.resolve_external, a hook the frontend registers since the resolver lives above compiler/ml. The resolver also reports the external's errors and warnings, inside the declaration's @warning scope. The generated JavaScript is unchanged.

AST modified (externals after the built-in PPX)

type t
@send external on: (t, @as("click") _, unit => unit) => unit = "addEventListener"
@inline let answer = 42

expanded

 …
 structure_item
   Pstr_primitive
   value_description "on"
+    attribute  "send"
+      []
     core_type
       Ptyp_arrow
-      arity = 2
+      arity = 3
       Nolabel
       core_type
         Ptyp_constr "t"
         []
       Nolabel
+      Parg_fixed
+        value
+          Pfixed_string (source="click", semantic="click")
+      Nolabel
       core_type
         Ptyp_arrow
         …
       core_type
         Ptyp_constr "unit"
         []
     "addEventListener"
-    <ffi spec>
 structure_item
   Pstr_primitive
   value_description "answer"
+    attribute  "inline"
+        structure_item
+          Pstr_eval
+          expression
+            Pexp_constant PConst_int (42,None)
     core_type
       Ptyp_constr "int"
       []
-    <inline const>
+    "#rescript-inline"

AST modified (an external with a relative path after the built-in PPX)

@module("./local") external relative: int => int = "relative"

expanded

 structure_item
-  Pstr_include
-  module_expr
-    Pmod_constraint
-    module_expr
-      Pmod_structure
-        structure_item
-          Pstr_primitive
-          value_description "relative"
-            attribute  "module"
-              …
-            core_type
-              …
-            "relative"
-    module_type
-      Pmty_signature
-        signature_item
-          Psig_value
-          value_description "relative"
-            core_type
-              …
+  Pstr_primitive
+  value_description "relative"
+    attribute  "module"
+      …
+    core_type
+      …
+    "relative"

Typedtree modified (an external with a relative path)

@module("./local") external relative: int => int = "relative"

typed · Before: the constraint the built-in PPX wrote · After: the constraint Typemod adds, implicit

 structure_item
   Tstr_include
   module_expr
-    Tmod_constraint
     module_expr
       Tmod_structure
         structure_item
           Tstr_primitive
           value_description relative/1001
             …
             "relative"
-    module_type
-      Tmty_signature
-        signature_item
-          Tsig_value
-          value_description relative/1002
-            …

Typedtree modified (externals)

type t
@send external on: (t, @as("click") _, unit => unit) => unit = "addEventListener"
@inline let answer = 42

typed

 …
 structure_item
   Tstr_primitive
   value_description on/1002
     core_type
       Ttyp_arrow
       …
     "addEventListener"
-    <ffi spec>
 structure_item
   Tstr_primitive
   value_description answer/1003
     core_type
       Ttyp_constr "int/1"
       []
-    <inline const>
+    "#rescript-inline"

Diagnostic modified (unused attribute on a relative @module external)

@module("./local") external relative: @as("v") int => unit = "relative"

diagnostic

 Warning number 101
 A.res:1:39-41
 Unused attribute: @as
-
-Warning number 101
-A.res:1:39-41
-Unused attribute: @as

Diagnostic removed (unused attribute under a @warning that disables it)

@warning("-101") @module("./local")
external silenced: @as("w") int => unit = "silenced"

diagnostic · Before

Warning number 101
A.res:2:21-23
Unused attribute: @as

JS modified (@inline let with a bigint)

@inline let big = 10n
let use = () => big

js

 function use() {
   return 10n;
 }
-let big = 10n;
-exports.big = big;
 exports.use = use;

Diagnostic removed (@inline on a bigint let)

@inline let big = 10n

diagnostic · Before

Warning number 53
the @inline attribute cannot appear in this context

Review

  • How does an external with a relative path stay out of other modules? Its path is relative to the file that declares it, so other modules call the value its module exports. In a signature, Typedecl gives it Val_reg. In a structure, Typemod types it as include (struct external … end : {let …}), the wrapper the built-in PPX used to write, whose coercion (Tcoerce_primitive) emits the exported function. Primitive.no_cross_module_inline reads relativeness off the resolved spec (External_ffi_types.is_relative, split out of check_decl).
  • Why a hook rather than passing the result along? -bs-ast writes the parsetree after the built-in PPX and a later bsc process reads it back, so the specification has to be recomputable from the tree. Ppx_entry, which every compiler driver links, registers the resolver at module initialization, as the Oprint hooks are.
  • What do diagnostics look like now? An external's errors and warning 101 come during type checking, in source order with the type errors, instead of in the parse step; an earlier type error hides them until it is fixed. The reprint path and -bs-ast alone no longer report them.
  • What does this cost? Each external is resolved once, in the compile step, which reads it as written: on Stdlib_Math.resi the parse and compile steps allocate 0.6% more words than before this change (resolving in the frontend as well had made it 4.3%).
  • What is "#rescript-inline"? The primitive of an @inline constant's declaration, which keeps its @inline(<literal>) attribute; the resolver turns it into Kind_inline_const. A structural form is a separate Stamp parsetree v1: what remains before the AST can be frozen for PPXs #8624 item.
  • Do PPXs see a difference? No: they run before the built-in PPX, and the v0 bridge carries the string as before; its error branch for already-resolved externals is gone.
  • What changes under -bs-no-builtin-ppx? An external with a plain name is now resolved as an FFI external by the type checker; before, it was typed as an intrinsic of that name.
  • What else goes? Typedtree.val_prim, a copy of the primitive string whose readers only need the value's resolved kind, and Primitive.print: Outcometree.oval_prim is the Primitive.description itself.

@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

⚠️ Compiler benchmarks

Allocated words

In total: 640,767,090 → 640,963,611 (+0.03%).

Generated projects: 920,854,787 → 920,854,754 (±0.00%).

⚠️ Regression: 15 benchmarks allocate more than 1% more.

Benchmark Base Head Change
packages/@rescript/runtime/Stdlib_Symbol.res 144,086 147,406 +2.30%
packages/@rescript/runtime/Stdlib_Math.res 326,831 332,053 +1.60%
packages/@rescript/runtime/Stdlib_TypedArray.res 782,659 795,150 +1.60%
packages/@rescript/runtime/Stdlib_Date.res 551,382 558,996 +1.38%
packages/@rescript/runtime/Stdlib_Console.res 369,767 374,811 +1.36%
packages/@rescript/runtime/Stdlib_Object.res 244,951 248,099 +1.29%
packages/@rescript/runtime/Stdlib_Temporal_PlainDateTime.res 436,943 442,165 +1.20%
packages/@rescript/runtime/Stdlib_RegExp.res 162,817 164,733 +1.18%
packages/@rescript/runtime/Stdlib_String.res 504,410 510,268 +1.16%
packages/@rescript/runtime/Stdlib_Temporal_ZonedDateTime.res 572,204 578,845 +1.16%
141 more changes
Benchmark Base Head Change
packages/@rescript/runtime/Stdlib_Promise.res 351,719 355,728 +1.14%
packages/@rescript/runtime/Stdlib_Temporal_PlainDate.res 402,866 407,270 +1.09%
packages/@rescript/runtime/Pervasives.res 238,939 241,474 +1.06%
packages/@rescript/runtime/Stdlib_DataView.res 230,058 232,457 +1.04%
packages/@rescript/runtime/JsxEvent.res 1,088,117 1,099,058 +1.01%
packages/@rescript/runtime/Stdlib_Temporal_Duration.res 372,671 376,332 +0.98%
packages/@rescript/runtime/Stdlib_Set.res 210,614 212,428 +0.86%
packages/@rescript/runtime/Stdlib_Float.res 218,542 220,422 +0.86%
packages/@rescript/runtime/Stdlib_Intl_Locale.res 197,097 198,775 +0.85%
packages/@rescript/runtime/Stdlib_Temporal_PlainTime.res 319,801 322,483 +0.84%
packages/@rescript/runtime/Dom_storage2.res 119,493 120,493 +0.84%
packages/@rescript/runtime/Stdlib_Temporal_PlainYearMonth.res 381,509 384,680 +0.83%
packages/@rescript/runtime/Stdlib_Symbol.resi 159,849 161,151 +0.81%
packages/@rescript/runtime/Stdlib_Global.res 141,326 142,375 +0.74%
packages/@rescript/runtime/Stdlib_Temporal_Instant.res 307,209 309,434 +0.72%
packages/@rescript/runtime/Stdlib_Map.res 205,383 206,852 +0.72%
packages/@rescript/runtime/Stdlib_Dict.res 319,549 321,817 +0.71%
packages/@rescript/runtime/Stdlib_BigInt.res 206,822 208,189 +0.66%
packages/@rescript/runtime/Stdlib_BigInt.resi 186,312 187,493 +0.63%
packages/@rescript/runtime/Dom_storage.res 166,886 167,889 +0.60%
packages/@rescript/runtime/Stdlib_Math.resi 330,661 332,614 +0.59%
packages/@rescript/runtime/Stdlib_Array.res 1,293,029 1,300,460 +0.57%
packages/@rescript/runtime/Stdlib_Int.resi 205,677 206,841 +0.57%
packages/@rescript/runtime/Stdlib_Temporal_PlainMonthDay.res 268,033 269,502 +0.55%
packages/@rescript/runtime/Stdlib_Float.resi 194,403 195,451 +0.54%
packages/@rescript/runtime/Stdlib_Intl_NumberFormat.res 403,719 405,882 +0.54%
packages/@rescript/runtime/Stdlib_IteratorObject.res 250,973 252,312 +0.53%
packages/@rescript/runtime/Stdlib_Temporal_Now.res 142,907 143,667 +0.53%
packages/@rescript/runtime/Stdlib_JsError.res 230,016 231,223 +0.52%
packages/@rescript/runtime/Stdlib_WeakMap.res 134,936 135,623 +0.51%
packages/@rescript/runtime/Stdlib_Dict.resi 241,018 242,214 +0.50%
packages/@rescript/runtime/Stdlib_String.resi 500,049 502,499 +0.49%
packages/@rescript/runtime/Stdlib_RegExp.resi 188,001 188,909 +0.48%
packages/@rescript/runtime/Stdlib_JSON.resi 236,625 237,740 +0.47%
packages/@rescript/runtime/Stdlib_WeakSet.res 122,595 123,164 +0.46%
packages/@rescript/runtime/Stdlib_Intl_PluralRules.res 201,180 202,107 +0.46%
packages/@rescript/runtime/Stdlib_Date.resi 618,508 621,342 +0.46%
packages/@rescript/runtime/Stdlib_Promise.resi 357,176 358,790 +0.45%
packages/@rescript/runtime/Stdlib_Array.resi 731,792 735,033 +0.44%
packages/@rescript/runtime/Stdlib_Intl_ListFormat.res 169,175 169,869 +0.41%
packages/@rescript/runtime/Primitive_object_extern.res 114,242 114,710 +0.41%
packages/@rescript/belt/src/Belt_Int.res 165,421 166,090 +0.40%
packages/@rescript/runtime/Stdlib_JsError.resi 185,010 185,732 +0.39%
packages/@rescript/runtime/Stdlib_Console.resi 414,795 416,383 +0.38%
packages/@rescript/runtime/Stdlib_Intl_RelativeTimeFormat.res 179,816 180,492 +0.38%
packages/@rescript/runtime/Stdlib_Intl_Segmenter.res 153,921 154,488 +0.37%
packages/@rescript/belt/src/Belt_Float.res 164,752 165,354 +0.37%
packages/@rescript/runtime/Stdlib_Set.resi 229,595 230,415 +0.36%
packages/@rescript/runtime/Stdlib_Ordering.res 152,123 152,660 +0.35%
packages/@rescript/runtime/Jsx.res 121,256 121,671 +0.34%
packages/@rescript/runtime/Stdlib_Int.res 457,227 458,775 +0.34%
packages/@rescript/runtime/Stdlib_JSON.res 297,543 298,527 +0.33%
packages/@rescript/belt/src/Belt_Int.resi 149,367 149,854 +0.33%
packages/@rescript/runtime/Stdlib_ArrayBuffer.res 107,060 107,408 +0.33%
packages/@rescript/runtime/Stdlib_DataView.resi 269,445 270,319 +0.32%
packages/@rescript/runtime/Stdlib_Nullable.resi 199,638 200,285 +0.32%
packages/@rescript/belt/src/Belt_Float.resi 150,843 151,331 +0.32%
packages/@rescript/runtime/Stdlib_Global.resi 155,741 156,235 +0.32%
packages/@rescript/runtime/Stdlib_Map.resi 217,639 218,329 +0.32%
packages/@rescript/runtime/Stdlib_Intl_Collator.res 178,054 178,613 +0.31%
packages/@rescript/runtime/Primitive_array_extern.res 113,857 114,212 +0.31%
packages/@rescript/runtime/Stdlib_IteratorObject.resi 235,056 235,761 +0.30%
packages/@rescript/runtime/Stdlib_Type.res 201,437 202,008 +0.28%
packages/@rescript/runtime/Stdlib_Null.resi 204,322 204,899 +0.28%
packages/@rescript/runtime/Stdlib_Intl_Segments.res 116,121 116,448 +0.28%
packages/@rescript/belt/src/Belt_Array.resi 649,773 651,405 +0.25%
packages/@rescript/runtime/Stdlib_Intl_DateTimeFormat.res 388,726 389,663 +0.24%
packages/@rescript/runtime/Stdlib_Bool.resi 116,432 116,710 +0.24%
packages/@rescript/belt/src/Belt_MutableSetString.resi 264,513 265,127 +0.23%
packages/@rescript/belt/src/Belt_MutableSetInt.resi 264,630 265,244 +0.23%
packages/@rescript/belt/src/Belt_SetString.resi 259,453 260,023 +0.22%
packages/@rescript/belt/src/Belt_SetInt.resi 259,580 260,150 +0.22%
packages/@rescript/runtime/Primitive_dict.res 102,325 102,540 +0.21%
packages/@rescript/runtime/Stdlib_IterableIterator.resi 149,129 149,441 +0.21%
packages/@rescript/runtime/Stdlib_JsExn.resi 122,404 122,657 +0.21%
packages/@rescript/runtime/Stdlib_List.resi 409,309 410,155 +0.21%
packages/@rescript/belt/src/Belt_MutableMapString.resi 314,510 315,124 +0.20%
packages/@rescript/belt/src/Belt_MutableMapInt.resi 314,628 315,242 +0.20%
packages/@rescript/runtime/Stdlib_Float32Array.res 343,118 343,786 +0.19%
packages/@rescript/runtime/Stdlib_Float64Array.res 343,118 343,786 +0.19%
packages/@rescript/runtime/Stdlib_Int8Array.res 343,000 343,667 +0.19%
packages/@rescript/runtime/Stdlib_Uint8Array.res 343,015 343,681 +0.19%
packages/@rescript/runtime/Stdlib_Int16Array.res 343,147 343,813 +0.19%
packages/@rescript/runtime/Stdlib_BigUint64Array.res 343,254 343,920 +0.19%
packages/@rescript/runtime/Stdlib_Uint8ClampedArray.res 343,530 344,196 +0.19%
packages/@rescript/runtime/Stdlib_Uint16Array.res 343,154 343,819 +0.19%
packages/@rescript/runtime/Stdlib_Uint32Array.res 343,154 343,819 +0.19%
packages/@rescript/runtime/Stdlib_BigInt64Array.res 343,180 343,845 +0.19%
packages/@rescript/runtime/Stdlib_Int32Array.res 343,149 343,813 +0.19%
packages/@rescript/belt/src/Belt_MapString.resi 390,782 391,529 +0.19%
packages/@rescript/belt/src/Belt_MapInt.resi 390,825 391,572 +0.19%
packages/@rescript/runtime/RescriptTools.res 104,120 104,314 +0.19%
packages/@rescript/runtime/Stdlib_Generator.res 200,647 201,016 +0.18%
packages/@rescript/runtime/Stdlib_AsyncIterableIterator.resi 159,531 159,823 +0.18%
packages/@rescript/runtime/Stdlib_Option.resi 236,075 236,505 +0.18%
packages/@rescript/runtime/Primitive_exceptions.res 201,856 202,223 +0.18%
packages/@rescript/runtime/Stdlib_Generator.resi 160,178 160,469 +0.18%
packages/@rescript/belt/src/Belt_List.resi 758,030 759,396 +0.18%
packages/@rescript/runtime/Primitive_js_extern.res 239,398 239,819 +0.18%
packages/@rescript/runtime/Stdlib_AsyncGenerator.resi 165,530 165,821 +0.18%
packages/@rescript/runtime/Primitive_string_extern.res 97,857 98,028 +0.17%
packages/@rescript/runtime/Stdlib_AsyncGenerator.res 211,493 211,861 +0.17%
packages/@rescript/runtime/Stdlib_ArrayBuffer.resi 110,780 110,969 +0.17%
packages/@rescript/runtime/Stdlib_Iterator.res 235,116 235,514 +0.17%
packages/@rescript/belt/src/Belt_internalAVLset.resi 354,491 355,076 +0.17%
packages/@rescript/belt/src/Belt_HashMapString.resi 213,316 213,665 +0.16%
packages/@rescript/belt/src/Belt_HashMapInt.resi 213,431 213,779 +0.16%
packages/@rescript/runtime/Stdlib_IterableIterator.res 178,833 179,124 +0.16%
packages/@rescript/belt/src/Belt_MutableQueue.resi 206,944 207,278 +0.16%
packages/@rescript/belt/src/Belt_Option.resi 242,980 243,369 +0.16%
packages/@rescript/belt/src/Belt_MutableMap.resi 394,956 395,585 +0.16%
packages/@rescript/belt/src/Belt_HashSetString.resi 175,179 175,454 +0.16%
packages/@rescript/belt/src/Belt_HashSetInt.resi 175,292 175,567 +0.16%
packages/@rescript/belt/src/Belt_internalAVLtree.resi 452,018 452,722 +0.16%
packages/@rescript/belt/src/Belt_MutableSet.resi 397,063 397,677 +0.15%
packages/@rescript/runtime/Primitive_object.resi 116,778 116,948 +0.15%
packages/@rescript/belt/src/Belt_MapDict.resi 513,029 513,775 +0.15%
packages/@rescript/runtime/Stdlib_Iterator.resi 159,008 159,234 +0.14%
packages/@rescript/belt/src/Belt_SetDict.resi 402,431 403,001 +0.14%
packages/@rescript/belt/src/Belt_Id.resi 259,758 260,124 +0.14%
packages/@rescript/belt/src/Belt_Set.resi 436,953 437,567 +0.14%
packages/@rescript/runtime/Stdlib_AsyncIterator.resi 177,546 177,790 +0.14%
packages/@rescript/runtime/Stdlib_Result.resi 295,963 296,367 +0.14%
packages/@rescript/belt/src/Belt_Map.resi 578,948 579,736 +0.14%
packages/@rescript/runtime/Stdlib_AsyncIterator.res 295,706 296,104 +0.13%
packages/@rescript/runtime/Stdlib_Type.resi 135,551 135,727 +0.13%
packages/@rescript/belt/src/Belt_MutableStack.resi 168,936 169,153 +0.13%
packages/@rescript/runtime/Stdlib_Intl_NumberFormat_Grouping.res 159,837 160,039 +0.13%
packages/@rescript/runtime/Primitive_int_extern.res 97,539 97,660 +0.12%
packages/@rescript/runtime/Stdlib_Lazy.resi 112,105 112,244 +0.12%
packages/@rescript/belt/src/Belt_HashSet.resi 222,404 222,679 +0.12%
packages/@rescript/runtime/Stdlib_Lazy.res 222,145 222,417 +0.12%
packages/@rescript/runtime/Primitive_promise.res 96,689 96,807 +0.12%
packages/@rescript/runtime/Stdlib_Pair.res 165,629 165,830 +0.12%
packages/@rescript/runtime/Stdlib_Bool.res 146,041 146,214 +0.12%
packages/@rescript/belt/src/Belt_HashMap.resi 296,511 296,859 +0.12%
packages/@rescript/runtime/Stdlib_Intl.res 173,934 174,137 +0.12%
packages/@rescript/runtime/Stdlib_Nullable.res 331,825 332,197 +0.11%
packages/@rescript/belt/src/Belt_internalBucketsType.resi 164,493 164,675 +0.11%
packages/@rescript/belt/src/Belt_Result.resi 239,810 240,056 +0.10%
packages/@rescript/belt/src/Belt_SortArray.resi 253,595 253,855 +0.10%

Instructions

In total: 9,211,172,800 → 9,214,096,307 (+0.03%).

⚠️ Regression: 3 benchmarks execute more than 1% more instructions.

Benchmark Base Head Change
packages/@rescript/runtime/Stdlib_Math.resi 21,158,495 21,430,032 +1.28%
packages/@rescript/runtime/Stdlib_TypedArray.res 41,079,388 41,593,742 +1.25%
packages/@rescript/runtime/Stdlib_Date.resi 34,102,855 34,478,711 +1.10%
packages/@rescript/runtime/Stdlib_Temporal_ZonedDateTime.res 31,030,446 31,323,533 +0.94%
packages/@rescript/runtime/Stdlib_String.resi 29,334,052 29,594,365 +0.89%
packages/@rescript/runtime/Stdlib_Temporal_PlainDateTime.res 25,401,662 25,606,474 +0.81%
packages/@rescript/runtime/Stdlib_Temporal_PlainDate.res 23,966,342 24,154,560 +0.79%
packages/@rescript/runtime/Stdlib_Array.resi 39,941,655 40,253,758 +0.78%
packages/@rescript/belt/src/Belt_Array.resi 33,675,836 33,733,263 +0.17%
packages/@rescript/runtime/Stdlib_List.resi 24,502,787 24,537,005 +0.14%
2 more changes
Benchmark Base Head Change
parse/RedBlackTree.res 14,933,665 14,954,003 +0.14%
parse/RedBlackTreeNoComments.res 14,532,761 14,552,058 +0.13%

Output size

In total: 25,843,198 → 25,816,622 (-0.10%).

🎉 Improvement: 1 output is more than 1% smaller.

Benchmark Base Head Change
output/tests/*.cmti 176,053 173,922 -1.21%
output/runtime/*.cmti 1,118,869 1,115,134 -0.33%
output/runtime/*.cmt 3,044,137 3,035,903 -0.27%
output/belt/*.cmti 981,298 980,059 -0.13%

Pull request 85671c0 compared with base 00e3f40 in this run.

@cknitt
cknitt force-pushed the codex/resolve-external-in-typedecl branch from e642cee to 282e6dd Compare November 10, 2026 18:35
Why:
    The parsetree is what PPXs see and what `-bs-ast` serializes, and
    #8624 wants it to describe source only. After the built-in PPX had
    read an external's `@send`, `@module`, `@as`… attributes, it replaced
    the primitive string with the computed FFI specification
    (`Prim_ffi`, `Prim_inline_const`), so `Parsetree` depended on
    `External_ffi_types` and carried cases no PPX can observe. Now
    `pval_prim` is the string as written, with its location, and
    `Typedecl` resolves the attributes, once, through
    `Primitive.resolve_external`, a hook the frontend registers since the
    resolver lives above `compiler/ml`. The resolver also reports the
    external's errors and warnings, inside the declaration's `@warning`
    scope. The generated JavaScript is unchanged.

AST modified (externals after the built-in PPX):
Example:
    type t
    @send external on: (t, @as("click") _, unit => unit) => unit = "addEventListener"
    @inline let answer = 42
Before (expanded):
    …
    structure_item
      Pstr_primitive
      value_description "on"
        core_type
          Ptyp_arrow
          arity = 2
          Nolabel
          core_type
            Ptyp_constr "t"
            []
          Nolabel
          core_type
            Ptyp_arrow
            …
          core_type
            Ptyp_constr "unit"
            []
        "addEventListener"
        <ffi spec>
    structure_item
      Pstr_primitive
      value_description "answer"
        core_type
          Ptyp_constr "int"
          []
        <inline const>
After (expanded):
    …
    structure_item
      Pstr_primitive
      value_description "on"
        attribute  "send"
          []
        core_type
          Ptyp_arrow
          arity = 3
          Nolabel
          core_type
            Ptyp_constr "t"
            []
          Nolabel
          Parg_fixed
            value
              Pfixed_string (source="click", semantic="click")
          Nolabel
          core_type
            Ptyp_arrow
            …
          core_type
            Ptyp_constr "unit"
            []
        "addEventListener"
    structure_item
      Pstr_primitive
      value_description "answer"
        attribute  "inline"
          [
            structure_item
              Pstr_eval
              expression
                Pexp_constant PConst_int (42,None)
          ]
        core_type
          Ptyp_constr "int"
          []
        "#rescript-inline"

AST modified (an external with a relative path after the built-in PPX):
Example:
    @module("./local") external relative: int => int = "relative"
Before (expanded):
    structure_item
      Pstr_include
      module_expr
        Pmod_constraint
        module_expr
          Pmod_structure
          [
            structure_item
              Pstr_primitive
              value_description "relative"
                attribute  "module"
                  …
                core_type
                  …
                "relative"
          ]
        module_type
          Pmty_signature
          [
            signature_item
              Psig_value
              value_description "relative"
                core_type
                  …
          ]
After (expanded):
    structure_item
      Pstr_primitive
      value_description "relative"
        attribute  "module"
          …
        core_type
          …
        "relative"

Typedtree modified (an external with a relative path):
Example:
    @module("./local") external relative: int => int = "relative"
Before (typed): the constraint the built-in PPX wrote
    structure_item
      Tstr_include
      module_expr
        Tmod_constraint
        module_expr
          Tmod_structure
          [
            structure_item
              Tstr_primitive
              value_description relative/1001
                …
                "relative"
          ]
        module_type
          Tmty_signature
          [
            signature_item
              Tsig_value
              value_description relative/1002
                …
          ]
After (typed): the constraint Typemod adds, implicit
    structure_item
      Tstr_include
      module_expr
        module_expr
          Tmod_structure
          [
            structure_item
              Tstr_primitive
              value_description relative/1001
                …
                "relative"
          ]

Typedtree modified (externals):
Example:
    type t
    @send external on: (t, @as("click") _, unit => unit) => unit = "addEventListener"
    @inline let answer = 42
Before (typed):
    …
    structure_item
      Tstr_primitive
      value_description on/1002
        core_type
          Ttyp_arrow
          …
        "addEventListener"
        <ffi spec>
    structure_item
      Tstr_primitive
      value_description answer/1003
        core_type
          Ttyp_constr "int/1"
          []
        <inline const>
After (typed):
    …
    structure_item
      Tstr_primitive
      value_description on/1002
        core_type
          Ttyp_arrow
          …
        "addEventListener"
    structure_item
      Tstr_primitive
      value_description answer/1003
        core_type
          Ttyp_constr "int/1"
          []
        "#rescript-inline"

Diagnostic modified (unused attribute on a relative @module external):
Example:
    @module("./local") external relative: @as("v") int => unit = "relative"
Before (diagnostic):
    Warning number 101
    A.res:1:39-41
    Unused attribute: @as

    Warning number 101
    A.res:1:39-41
    Unused attribute: @as
After (diagnostic):
    Warning number 101
    A.res:1:39-41
    Unused attribute: @as

Diagnostic removed (unused attribute under a @warning that disables it):
Example:
    @warning("-101") @module("./local")
    external silenced: @as("w") int => unit = "silenced"
Before (diagnostic):
    Warning number 101
    A.res:2:21-23
    Unused attribute: @as

JS modified (@inline let with a bigint):
Example:
    @inline let big = 10n
    let use = () => big
Before (js):
    function use() {
      return 10n;
    }
    let big = 10n;
    exports.big = big;
    exports.use = use;
After (js):
    function use() {
      return 10n;
    }
    exports.use = use;

Diagnostic removed (@inline on a bigint let):
Example:
    @inline let big = 10n
Before (diagnostic):
    Warning number 53
    the @inline attribute cannot appear in this context

Review: How does an external with a relative path stay out of other
    modules? Its path is relative to the file that declares it, so other
    modules call the value its module exports. In a signature, `Typedecl`
    gives it `Val_reg`. In a structure, `Typemod` types it as
    `include (struct external … end : {let …})`, the wrapper the built-in
    PPX used to write, whose coercion (`Tcoerce_primitive`) emits the
    exported function. `Primitive.no_cross_module_inline` reads
    relativeness off the resolved spec (`External_ffi_types.is_relative`,
    split out of `check_decl`).
Review: Why a hook rather than passing the result along? `-bs-ast` writes
    the parsetree after the built-in PPX and a later `bsc` process reads
    it back, so the specification has to be recomputable from the tree.
    `Ppx_entry`, which every compiler driver links, registers the resolver
    at module initialization, as the `Oprint` hooks are.
Review: What do diagnostics look like now? An external's errors and
    warning 101 come during type checking, in source order with the type
    errors, instead of in the parse step; an earlier type error hides
    them until it is fixed. The reprint path and `-bs-ast` alone no longer
    report them.
Review: What does this cost? Each external is resolved once, in the
    compile step, which reads it as written: on Stdlib_Math.resi the
    parse and compile steps allocate 0.6% more words than before this
    change (resolving in the frontend as well had made it 4.3%).
Review: What is `"#rescript-inline"`? The primitive of an `@inline`
    constant's declaration, which keeps its `@inline(<literal>)`
    attribute; the resolver turns it into `Kind_inline_const`. A
    structural form is a separate #8624 item.
Review: Do PPXs see a difference? No: they run before the built-in PPX,
    and the v0 bridge carries the string as before; its error branch for
    already-resolved externals is gone.
Review: What changes under `-bs-no-builtin-ppx`? An external with a plain
    name is now resolved as an FFI external by the type checker; before,
    it was typed as an intrinsic of that name.
Review: What else goes? `Typedtree.val_prim`, a copy of the primitive
    string whose readers only need the value's resolved kind, and
    `Primitive.print`: `Outcometree.oval_prim` is the
    `Primitive.description` itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018oLiCQjGFSE2qwfYqCMF9e
Claude-Session: https://claude.ai/code/session_01Kbf3gLpf7RN1MiRcubPgRn
Claude-Session: https://claude.ai/code/session_01Kbf3gJFAva9685fjvkajub
Claude-Session: https://claude.ai/code/session_01Kbf3gzUzT5G
Signed-off-by: Christoph Knittel <ck@cca.io>
@cknitt
cknitt force-pushed the codex/resolve-external-in-typedecl branch from 282e6dd to 85671c0 Compare November 10, 2026 19:28

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.

1 participant