Semantic Diff

#8909 Semantic diff examples (illustration, not for merge)

7 commits, drawn from their messages.

0308018d62161f515927225c44b78a665ad70a83..pr-8909

26efa7c53 · Cristiano Calcagno · Oct 19, 2026

Revert six merged changes so the next commits re-apply them

Scaffolding for the examples, each later commit re-applying one change.

cb81882ee · Cristiano Calcagno · Oct 19, 2026

Shorten the arity mismatch error message

Why

The signature arity mismatch error explained in a second sentence what its first sentence already states.

modifiedDiagnostic signature arity mismatch

module M: {  let f: (int, int) => int} = {  let f = (x: int) => (y: int) => x + y}
diagnostic
Before
After
We've found a bug for you! We've found a bug for you! Arity.res:3:5-5:1 Arity.res:3:5-5:1 1 │ module M: { 1 │ module M: { 2 │ let f: (int, int) => int 2 │ let f: (int, int) => int 3 │ } = { 3 │ } = { 4 │ let f = (x: int) => (y: int) => x + y 4 │ let f = (x: int) => (y: int) => x + y 5 │ } 5 │ } 6 │ 6 │ Signature mismatch: Signature mismatch: Modules do not match: Modules do not match: { { let f: int => int => int let f: int => int => int}} is not included in is not included in { { let f: (int, int) => int let f: (int, int) => int}} Values do not match: Values do not match: let f: int => int => int let f: int => int => int is not included in is not included in let f: (int, int) => int let f: (int, int) => int The implementation contains a function taking 1 argument where the interface expects one taking 2 arguments. The implementation contains a function taking 1 argument where the interface expects one taking 2 arguments. A function's arity is part of its type: calls are compiled to plain JavaScript calls with exactly that many arguments. Arity.res:2:3-26: Expected declaration Arity.res:2:3-26: Expected declaration Arity.res:4:7: Actual declaration Arity.res:4:7: Actual declaration

Re-applies #8559 by @cristianoc for illustration.

Review

  • Which tests expect the error text? The four module_sig_value_arity_mismatch fixtures in super_errors and Iface_value_arity_mismatch in super_errors_multi; this commit updates all five.
with Claude Opus 5.5
0dd963b55 · Cristiano Calcagno · Oct 19, 2026 · #8850

Keep comments in dict and variant spread patterns

Why

The formatter deleted comments on dict pattern keys, inside empty dict patterns, in ...t and #...t patterns, and before the first or after the last row of a run of rows after a spread in a dict literal (#8820).

modifiedFormat comments in dict and variant spread patterns

let f = d =>  switch d {  | dict{"a" /* key */: b} => b  | dict{/* empty */} => 0  | _ => 1  } let g = x =>  switch x {  | .../* spread */ t => 0  | _ => 1  }
rescript
Before
After
let f = d =>let f = d => switch d { switch d { | dict{"a": b} => b | dict{} => 0 | dict{"a" /* key */: b} => b | dict{/* empty */} => 0 | _ => 1 | _ => 1 } } let g = x =>let g = x => switch x { switch x { | ...t => 0 | .../* spread */ t => 0 | _ => 1 | _ => 1 } }

Re-applies #8850 by @cknitt for illustration.

Review

  • Does any comment position in these shapes still lose its comment? #8850 reports a check that places a block and a line comment at every token boundary of 15 dict pattern, variant spread pattern and dict literal shapes: 53 cases lost the comment before and none after, and 12 of the 100 cases unstable before (output reparsing to a different tree or changing when formatted again) are stable after.
with Claude Opus 5.5
6ec106d96 · Cristiano Calcagno · Oct 19, 2026 · #8717

Fix integer range patterns at int32 boundaries

Why

A match on consecutive integers ending at 2147483647 compiled its upper bound as an exclusive comparison against 2147483648, which wraps to -2147483648 as a Lambda constant, so matching values fell through to the next case (#8716).

modifiedJS integer patterns ending at the int32 maximum

let top = x =>  switch x {  | 2147483645 | 2147483646 | 2147483647 => true  | _ => false  }
lambda
Before
After
(let(let (top/1001 = (top/1001 = (function x/1002 (function x/1002 (catch (catch (catch (if (>=[int] x/1002 2147483645) (if (>=[int] x/1002 -2147483648) (exit 2) (exit 1)) (exit 2)) with (2) false) (catch (if (>=[int] x/1002 2147483645) (exit 1) (exit 2)) with (2) false) with (1) true))) with (1) true))) (makeblock module/exports top/1001)) (makeblock module/exports top/1001))
js
Before
After
// Generated by ReScript, PLEASE EDIT WITH CARE// Generated by ReScript, PLEASE EDIT WITH CARE'use strict';'use strict'; function top(x) {function top(x) { if (x >= 2147483645) { return x < -2147483648; } else { return false; } return x >= 2147483645; }} exports.top = top;exports.top = top;/* No side effect *//* No side effect */

Re-applies #8717 by @fhammerschmidt for illustration.

Review

  • Which other patterns does the int32 normalization affect? Typed integer constants are reduced to their int32 value before grouping, so wrapped aliases such as 4294967295 and -1 are one value: a redundant one is reported as unused, and exhaustiveness diagnostics can change, as the updated intoverflow fixture shows.
with Claude Opus 5.5
1ec3df81e · Cristiano Calcagno · Oct 19, 2026 · #8842

Match optional fields as options

Why

A match testing an optional field both for a value and for presence crashed the compiler or dropped a case (#8810), because the match compiler rewrote the field pattern Some(p) to p row by row, so one column mixed constants with None.

modifiedMatching optional field column

type r = {x?: int, y?: int} let f = (d: r) =>  switch d {  | {x: 1} => 0  | {y: 2} => 0  | {x: ?None} => 1  | _ => 2  }
matrix
Before
After
1 _ _ 2 None _ _ _ Some(1) _ _ Some(2)None _ _ _
columnsthe fields x and y
columnsthe fields x and y
splitthe constant 1 heads the group, so the None row joins the variable rows below it and reaches an assertion
splitevery head of the x column is a constructor of option

removedDiagnostic assertion failure in the match compiler

diagnostic
Before compiling the program above
Fatal error: exception File "compiler/ml/matching.ml", line 1236, characters 9-15: Assertion failed

modifiedJS value and presence tests on one optional field

type r = {x?: int} let f = (d: r) =>  switch d {  | {x: ?None} => 0  | {x: 1} => 1  | {x: ?Some(_)} => 2  }
js
Before f({x: 1}) returns 2
After f({x: 1}) returns 1
// Generated by ReScript, PLEASE EDIT WITH CARE// Generated by ReScript, PLEASE EDIT WITH CARE'use strict';'use strict'; function f(d) {function f(d) { if (d.x !== undefined) { return 2; let match = d.x; if (match !== undefined) { if (match !== 1) { return 2; } else { return 1; } } else { } else { return 0; return 0; } }}} exports.f = f;exports.f = f;/* No side effect *//* No side effect */

Re-applies #8842 by @cknitt for illustration.

Review

  • Does matching through Some cost a nested-option check now? Only where a payload pattern needs it: a Some payload is read as is when every payload pattern of the division rejects the object that represents Some(None), and a presence test is dropped when the value test already sends undefined to the absent case.
with Claude Opus 5.5
d1c4b04a9 · Cristiano Calcagno · Oct 19, 2026 · #8868

Compile character range patterns in linear time

Why

A character range pattern is expanded into one or-pattern alternative per character, and typing, the exhaustiveness and unused-case checks, default matrices and switch compilation each took time quadratic in the number of alternatives: a range over the CJK block ran out of memory (#8866).

modifiedMatching character range patterns

let f = c =>  switch c {  | '\u{100}' .. '\u{1100}' => 1  | _ => 0  }
lambda
Before and After are equal
(let  (f/1001 =     (function c/1002       (catch         (catch           (if (|| (>[int] c/1002 4352) (<[int] c/1002 256)) (exit 2)             (exit 1))          with (2) 0)        with (1) 1)))  (makeblock module/exports f/1001))
text
Before /usr/bin/time -l bsc on the example, three runs
After /usr/bin/time -l bsc on the example, three runs
2.22 real 2.03 user 0.18 sys 1273511936 maximum resident set size2.20 real 2.02 user 0.16 sys 1271463936 maximum resident set size2.21 real 2.03 user 0.17 sys 1273659392 maximum resident set size0.02 real 0.02 user 0.00 sys 25395200 maximum resident set size0.02 real 0.02 user 0.00 sys 25624576 maximum resident set size0.03 real 0.02 user 0.00 sys 25591808 maximum resident set size

Re-applies #8868 by @cknitt for illustration.

Review

  • Where did the time go? Typing copied the right subtree of each nested or-pattern to rename variables even when there are none; the checks searched the groups linearly for each row and compared all pairs of rows for the default matrix minima; switch compilation found each key's cell with List.find. Groups, minima and int, char and string cells are now found by hashing, and the copy is skipped when nothing is renamed.
  • Does the generated code change? No: #8868 reports byte-identical JavaScript and warnings across tests/tests/src, the super_errors fixtures and the runtime.
with Claude Opus 5.5
a86f7a337 · Cristiano Calcagno · Oct 19, 2026 · #8879

Represent %raw, %ffi and %%raw with parsetree nodes

Why

%raw, %ffi and %%raw were extensions whose string payload the compiler recognized by name, with a constant Pconst_raw_source used only by them; #8624 moves extension-keyed language features to parsetree nodes. Through a PPX, a template payload lost its template marker, so its escapes were not decoded.

modifiedAST raw extensions

let f = %raw(`(a) => a`)%%raw("var x = 1")
parse
Before
After
structure_itemstructure_item└─ Pstr_value Nonrec└─ Pstr_value Nonrec   └─ <def>   └─ <def>      ├─ pattern      ├─ pattern      │  └─ Ppat_var "f"      │  └─ Ppat_var "f"      └─ expression      └─ expression         └─ Pexp_extension "raw"            └─ structure_item               ├─ Pstr_eval               └─ expression                  └─ Pexp_template                     └─ source_segment "(a) => a"         ├─ Pexp_raw "raw"         └─ Raw_template "(a) => a" structure_itemstructure_item└─ Pstr_extension "raw"   └─ structure_item      ├─ Pstr_eval      └─ expression         └─ Pexp_constant PConst_raw_source "var x = 1"├─ Pstr_raw└─ Raw_string "var x = 1"
expanded
Before
After
structure_itemstructure_item└─ Pstr_value Nonrec└─ Pstr_value Nonrec   └─ <def>   └─ <def>      ├─ pattern      ├─ pattern      │  └─ Ppat_var "f"      │  └─ Ppat_var "f"      └─ expression      └─ expression         ├─ attribute "internal.expansive"         ├─ attribute "internal.expansive"         │  └─ []         │  └─ []         ├─ Pexp_letmodule "J"         ├─ Pexp_letmodule "J"         ├─ module_expr         ├─ module_expr         │  └─ Pmod_structure         │  └─ Pmod_structure         │     └─ structure_item         │     └─ structure_item         │        ├─ Pstr_primitive         │        ├─ Pstr_primitive         │        └─ value_description "unsafe_expr"         │        └─ value_description "unsafe_expr"         │           ├─ core_type         │           ├─ core_type         │           │  ├─ Ptyp_arrow         │           │  ├─ Ptyp_arrow         │           │  ├─ arity = 1         │           │  ├─ arity = 1         │           │  ├─ Nolabel         │           │  ├─ Nolabel         │           │  ├─ core_type         │           │  ├─ core_type         │           │  │  └─ Ptyp_any         │           │  │  └─ Ptyp_any         │           │  └─ core_type         │           │  └─ core_type         │           │     └─ Ptyp_any         │           │     └─ Ptyp_any         │           └─ "#raw_expr"         │           └─ "#raw_expr"         └─ expression         └─ expression            ├─ Pexp_apply            ├─ Pexp_apply            ├─ expression            ├─ expression            │  ├─ Pexp_ident "J.unsafe_expr"            │  ├─ Pexp_ident "J.unsafe_expr"            │  ├─ <arg>            │  ├─ <arg>            │  └─ Nolabel            │  └─ Nolabel            │     └─ expression            │     └─ expression            │        └─ Pexp_constant PConst_raw_source "(a) => a"            │        └─ Pexp_constant PConst_string (source="(a) => a", semantic="(a) => a")            └─ transformed_jsx: false            └─ transformed_jsx: falsestructure_itemstructure_item├─ Pstr_eval├─ Pstr_eval└─ expression└─ expression   ├─ Pexp_letmodule "J"   ├─ Pexp_letmodule "J"   ├─ module_expr   ├─ module_expr   │  └─ Pmod_structure   │  └─ Pmod_structure   │     └─ structure_item   │     └─ structure_item   │        ├─ Pstr_primitive   │        ├─ Pstr_primitive   │        └─ value_description "unsafe_expr"   │        └─ value_description "unsafe_expr"   │           ├─ core_type   │           ├─ core_type   │           │  ├─ Ptyp_arrow   │           │  ├─ Ptyp_arrow   │           │  ├─ arity = 1   │           │  ├─ arity = 1   │           │  ├─ Nolabel   │           │  ├─ Nolabel   │           │  ├─ core_type   │           │  ├─ core_type   │           │  │  └─ Ptyp_any   │           │  │  └─ Ptyp_any   │           │  └─ core_type   │           │  └─ core_type   │           │     └─ Ptyp_any   │           │     └─ Ptyp_any   │           └─ "#raw_stmt"   │           └─ "#raw_stmt"   └─ expression   └─ expression      ├─ Pexp_apply      ├─ Pexp_apply      ├─ expression      ├─ expression      │  ├─ Pexp_ident "J.unsafe_expr"      │  ├─ Pexp_ident "J.unsafe_expr"      │  ├─ <arg>      │  ├─ <arg>      │  └─ Nolabel      │  └─ Nolabel      │     └─ expression      │     └─ expression      │        └─ Pexp_constant PConst_raw_source "var x = 1"      │        └─ Pexp_constant PConst_string (source="var x = 1", semantic="var x = 1")      └─ transformed_jsx: false      └─ transformed_jsx: false

Re-applies #8879 by @cknitt for illustration.

Review

  • What does a PPX see? The %raw, %ffi and %%raw extensions with the same v0 encoding as before; Ast_mapper_from0 reads them back into the nodes. The .cmi, .cmt and AST magic numbers are bumped.
  • Which payloads stay extensions? Braced or attributed payloads and non-strings, which the nodes cannot represent; they are processed as before.
with Claude Opus 5.5