Skip to content

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

Draft
cristianoc wants to merge 7 commits into
cristianoc/semantic-diff-examples-basefrom
cristianoc/semantic-diff-examples
Draft

cristianoc wants to merge 7 commits into
cristianoc/semantic-diff-examples-basefrom
cristianoc/semantic-diff-examples

Conversation

@cristianoc

@cristianoc cristianoc commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

A standing illustration of semantic diffs. It stays open as a reference: please don't merge or close it.

Semantic diff (#8908) is a generic mechanism with three parameters: the notion of semantic object, the format each object is written in, and the way each is rendered. This PR shows the ReScript compiler's instance on six real, already-merged changes. Each commit message states its change as the semantic objects it changes, and the Semantic Diff link on this PR draws them.

Object Format in the commit message Drawn as
Parse tree the compiler's tree printout, Before and After trees with connectors, on aligned rows
Clause matrix rows of patterns, with notes a matrix
Lambda and generated JS code, Before and After highlighted code, changed lines shaded
Diagnostic the error text text
Formatter output ReScript, Before and After highlighted code

Use it in your project

The mechanism to adapt is #8908; this PR is an example of it on real changes. #8908 provides:

  • .claude/skills/records/SKILL.md: the skill that tells an agent how to write a commit's change as semantic objects, Before and After;
  • scripts/recordRender.js: parsing a commit message and drawing each object;
  • scripts/commitRecords.js: the page for a range of commits, run by make semantic-diff and by .github/workflows/semantic_diff.yml, which links the page on every pull request.

To adapt it, choose your project's semantic objects, how each is written in a commit message, and how each is drawn. Point your coding agent at #8908 as the implementation and at this PR as the example. For instance:

Adapt the mechanism implemented in #8908 to this repository, using #8909 as an example of it in use. Identify the semantic objects our changes affect, define how a commit message states each one Before and After, and write the skill, the renderer and the per-PR page for them.

The six examples

This PR re-applies six merged changes. Each commit message is written in the commit-record format from #8908, so the Semantic Diff link on this PR shows what the format looks like for each kind of change. The first commit reverts the six changes, and each following commit re-applies one of them unchanged, so the final tree equals the base's. The base, cristianoc/semantic-diff-examples-base, holds #8908's commit as it was when the examples were built, so this PR's diff is exactly the seven example commits.

Commit Original Forms in the message
Shorten the arity mismatch error message #8559 diagnostic text, Before and After
Keep comments in dict and variant spread patterns #8850 formatter output, Before and After
Fix integer range patterns at int32 boundaries #8717 Lambda and generated JavaScript
Match optional fields as options #8842 clause matrix, the removed assertion failure, and generated JavaScript
Compile character range patterns in linear time #8868 Lambda (equal Before and After), compile time and memory
Represent %raw, %ffi and %%raw with parsetree nodes #8879 parse and expanded trees

Each Before and After is the compiler's output at the commit's parent and at the commit, copied into the message. Each message credits the original PR and author.

Screenshots

Each card of this PR's Semantic Diff page, drawn from the commit's message.

Diagnostic: shorten the arity mismatch error message (#8559)

Diagnostic: shorten the arity mismatch error message (#8559)

Format: keep comments in dict and variant spread patterns (#8850)

Format: keep comments in dict and variant spread patterns (#8850)

JS: integer range patterns at int32 boundaries (#8717)

JS: integer range patterns at int32 boundaries (#8717)

Matching: optional fields as options (#8842)

Matching: optional fields as options (#8842)

Matching: character range patterns in linear time (#8868)

Matching: character range patterns in linear time (#8868)

AST: %raw, %ffi and %%raw as parsetree nodes (#8879)

AST: %raw, %ffi and %%raw as parsetree nodes (#8879)

To reproduce the page locally:

git fetch origin cristianoc/semantic-diff-examples-base cristianoc/semantic-diff-examples
node scripts/commitRecords.js --out page.html origin/cristianoc/semantic-diff-examples-base..origin/cristianoc/semantic-diff-examples

🤖 Generated with Claude Code

@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

rescript

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

@rescript/belt

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

@rescript/darwin-arm64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

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

@rescript/win32-x64

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

commit: a86f7a3

cristianoc and others added 7 commits October 19, 2026 16:36
Scaffolding for the examples, each later commit re-applying one change.

Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
Why:
    The signature arity mismatch error explained in a second sentence what
    its first sentence already states.

Diagnostic modified (signature arity mismatch):
Example:
    module M: {
      let f: (int, int) => int
    } = {
      let f = (x: int) => (y: int) => x + y
    }
Before (diagnostic):
      We've found a bug for you!
      Arity.res:3:5-5:1

      1 │ module M: {
      2 │   let f: (int, int) => int
      3 │ } = {
      4 │   let f = (x: int) => (y: int) => x + y
      5 │ }
      6 │

      Signature mismatch:
      Modules do not match:
        {
      let f: int => int => int
    }
      is not included in
        {
      let f: (int, int) => int
    }
      Values do not match:
        let f: int => int => int
      is not included in
        let f: (int, int) => int
      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:4:7: Actual declaration
After (diagnostic):
      We've found a bug for you!
      Arity.res:3:5-5:1

      1 │ module M: {
      2 │   let f: (int, int) => int
      3 │ } = {
      4 │   let f = (x: int) => (y: int) => x + y
      5 │ }
      6 │

      Signature mismatch:
      Modules do not match:
        {
      let f: int => int => int
    }
      is not included in
        {
      let f: (int, int) => int
    }
      Values do not match:
        let f: int => int => int
      is not included in
        let f: (int, int) => int
      The implementation contains a function taking 1 argument where the interface expects one taking 2 arguments.
      Arity.res:2:3-26: Expected declaration
      Arity.res:4:7: Actual declaration

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.

Re-applies #8559 by @cristianoc for illustration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
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).

Format modified (comments in dict and variant spread patterns):
Example:
    let f = d =>
      switch d {
      | dict{"a" /* key */: b} => b
      | dict{/* empty */} => 0
      | _ => 1
      }

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

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

    let g = x =>
      switch x {
      | .../* spread */ t => 0
      | _ => 1
      }

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.

Re-applies #8850 by @cknitt for illustration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
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).

JS modified (integer patterns ending at the int32 maximum):
Example:
    let top = x =>
      switch x {
      | 2147483645 | 2147483646 | 2147483647 => true
      | _ => false
      }
Before (lambda):
    (let
      (top/1001 =
         (function x/1002
           (catch
             (catch
               (if (>=[int] x/1002 2147483645)
                 (if (>=[int] x/1002 -2147483648) (exit 2) (exit 1)) (exit 2))
              with (2) false)
            with (1) true)))
      (makeblock module/exports top/1001))
After (lambda):
    (let
      (top/1001 =
         (function x/1002
           (catch
             (catch (if (>=[int] x/1002 2147483645) (exit 1) (exit 2)) with (2)
               false)
            with (1) true)))
      (makeblock module/exports top/1001))
Before (js):
    // Generated by ReScript, PLEASE EDIT WITH CARE
    'use strict';


    function top(x) {
      if (x >= 2147483645) {
        return x < -2147483648;
      } else {
        return false;
      }
    }

    exports.top = top;
    /* No side effect */
After (js):
    // Generated by ReScript, PLEASE EDIT WITH CARE
    'use strict';


    function top(x) {
      return x >= 2147483645;
    }

    exports.top = top;
    /* No side effect */

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.

Re-applies #8717 by @fhammerschmidt for illustration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
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`.

Matching modified (optional field column):
Example:
    type r = {x?: int, y?: int}

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

Diagnostic removed (assertion failure in the match compiler):
Before (diagnostic): compiling the program above
    Fatal error: exception File "compiler/ml/matching.ml", line 1236, characters 9-15: Assertion failed

JS modified (value and presence tests on one optional field):
Example:
    type r = {x?: int}

    let f = (d: r) =>
      switch d {
      | {x: ?None} => 0
      | {x: 1} => 1
      | {x: ?Some(_)} => 2
      }
Before (js): `f({x: 1})` returns 2
    // Generated by ReScript, PLEASE EDIT WITH CARE
    'use strict';


    function f(d) {
      if (d.x !== undefined) {
        return 2;
      } else {
        return 0;
      }
    }

    exports.f = f;
    /* No side effect */
After (js): `f({x: 1})` returns 1
    // Generated by ReScript, PLEASE EDIT WITH CARE
    'use strict';


    function f(d) {
      let match = d.x;
      if (match !== undefined) {
        if (match !== 1) {
          return 2;
        } else {
          return 1;
        }
      } else {
        return 0;
      }
    }

    exports.f = f;
    /* No side effect */

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.

Re-applies #8842 by @cknitt for illustration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
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).

Matching modified (character range patterns):
Example:
    let f = c =>
      switch c {
      | '\u{100}' .. '\u{1100}' => 1
      | _ => 0
      }
Before (lambda):
    (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))
After (lambda):
    (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))
Before (text): `/usr/bin/time -l bsc` on the example, three runs
            2.22 real         2.03 user         0.18 sys
              1273511936  maximum resident set size
            2.20 real         2.02 user         0.16 sys
              1271463936  maximum resident set size
            2.21 real         2.03 user         0.17 sys
              1273659392  maximum resident set size
After (text): `/usr/bin/time -l bsc` on the example, three runs
            0.02 real         0.02 user         0.00 sys
                25395200  maximum resident set size
            0.02 real         0.02 user         0.00 sys
                25624576  maximum resident set size
            0.03 real         0.02 user         0.00 sys
                25591808  maximum resident set size

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.

Review: Does the generated code change? No: #8868 reports byte-identical
    JavaScript and warnings across `tests/tests/src`, the `super_errors`
    fixtures and the runtime.

Re-applies #8868 by @cknitt for illustration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
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.

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

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.

Review: Which payloads stay extensions? Braced or attributed payloads and
    non-strings, which the nodes cannot represent; they are processed as
    before.

Re-applies #8879 by @cknitt for illustration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
@cristianoc
cristianoc force-pushed the cristianoc/semantic-diff-examples branch from 322b3b6 to a86f7a3 Compare October 19, 2026 14:40
@cristianoc
cristianoc force-pushed the cristianoc/commit-records-v2 branch from 56c69ba to 0308018 Compare October 19, 2026 14:40
@github-actions

Copy link
Copy Markdown

@cristianoc
cristianoc removed this pull request from stack #8911 October 20, 2026 04:44
@cristianoc
cristianoc changed the base branch from cristianoc/commit-records-v2 to cristianoc/semantic-diff-examples-base October 20, 2026 04:44

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