Skip to content

Instantly share code, notes, and snippets.

@eregon
Created July 25, 2026 00:03
Show Gist options
  • Select an option

  • Save eregon/469e40f447f6edac813a389e7f3f4832 to your computer and use it in GitHub Desktop.

Select an option

Save eregon/469e40f447f6edac813a389e7f3f4832 to your computer and use it in GitHub Desktop.
node_id instability across Prism versions

node_id Instability Across Prism Versions

The key point is that node_id is an allocation sequence number (++parser->node_id), not an identity derived from a node's type or source location.

Merely reordering node definitions in config.yml does not affect node_id. Changing the number or order of node allocations does.

Historical examples

An ordinary parser fix changed the number of nodes

The reproduction from note #13 of Feature #21795 changed from 25 to 26 nodes between Prism versions.

This can be bisected to Ensure context terminators terminate expressions:

case 1
in 2
  A.print message:
in 3
  A.print message:
end

Before the fix, the second in was represented by a MatchPredicateNode nested inside the first clause. After the fix, it was represented by a second InNode.

The commit was an ordinary six-line parser fix and did not change config.yml. Consequently, a node-definition ABI version would not have detected this change.

ItParametersNode added an allocation

Internal support for node_id was added on February 8, 2024. Less than two weeks later, ItParametersNode was added.

The unchanged source:

-> { it }

gained an extra node. This shifted the LambdaNode and every node allocated after it.

ShareableConstantNode added a wrapper

A month later, ShareableConstantNode was added:

# shareable_constant_value: literal
A = 1

def target
end

The constant write gained a ShareableConstantNode wrapper before target, shifting the DefNode ID.

It is also believable that this node could disappear in the future. For example, the shareability mode could be stored as flags directly on the constant-write nodes instead of using a wrapper. The same thing could happen if this behavior became the default.

ConstantPathNode lost a child node

Shortly afterward, ConstantPathNode#child was replaced with name and name_loc:

Foo::Bar

def target
end

Previously, Foo::Bar contained a child ConstantReadNode for Bar. After the change, Bar was stored directly as a name and location on the ConstantPathNode.

This removed one node allocation, shifting the ID of target down by one. The commit explicitly described itself as an API-breaking representation change.

Temporary nodes also consume IDs

The instability is broader than changes to the final AST. Nodes that are allocated and later discarded still consume IDs.

For example:

A = 1

def target
end

currently has visible nodes with IDs resembling:

StatementsNode       1
# ID 2: temporary ConstantReadNode, absent from the final tree
IntegerNode          3
ConstantWriteNode    4
DefNode              5
ProgramNode          6

Prism first parses A as a read/target and then converts it into a write. A reasonable optimization could construct the ConstantWriteNode directly, eliminating the invisible allocation. That would change every later ID without changing the final AST or the node definitions at all.

This means an ABI version that changes only when config.yml changes would not be sufficient.

Allocation order can change without changing the tree

IDs can also move when the same nodes are allocated in a different order.

For example, DefNode, LambdaNode, and BlockNode are currently allocated after their bodies have been parsed. A parser refactor could instead allocate the outer node when its opening token is encountered and fill in its body later.

Such a refactor would change the ID of exactly the node returned by Method#ast or Proc#ast, without adding or removing any nodes.

Similarly, a CallNode is commonly allocated before its arguments. Building the arguments first and the call afterward would change the CallNode ID relevant to Thread::Backtrace::Location#ast, even if the final AST were structurally equivalent.

Nodes that are especially likely to change

ImplicitNode and ImplicitRestNode are particularly plausible candidates. Prism describes these as synthetic nodes that do not directly correspond to source syntax:

{ foo: }        # ImplicitNode
foo { |bar,| }  # ImplicitRestNode
foo in [bar,]   # ImplicitRestNode
foo, = bar      # ImplicitRestNode

These could naturally be replaced with flags or nullable fields on nodes such as AssocNode, ParametersNode, ArrayPatternNode, or MultiTargetNode.

Other plausible areas include:

  • ShareableConstantNode, which could be folded into constant-write flags.

  • ItParametersNode and NumberedParametersNode, which could be represented as modes on another parameter node.

  • Grouping nodes such as StatementsNode, ArgumentsNode, BlockParametersNode, and KeywordHashNode.

  • The many specialized target and write nodes, which could be merged, split, or replaced with flags.

There are historical precedents for these kinds of changes even before node_id was introduced: Prism split CaseNode and CaseMatchNode, split CallNode in target position, and replaced RequiredDestructuredParameterNode with MultiTargetNode.

Conclusion

Stable node_id values require the exact same parser implementation, parser options, and source—not merely synchronized node definitions.

Normal bug fixes, temporary-node optimizations, representation changes, and allocation-order refactors can all change IDs. Some of these changes do not alter the node ABI or even the shape of the final AST.

Start and end source offsets are materially more robust across Prism versions because they describe actual positions in the source rather than incidental parser allocation order.

@eregon

eregon commented Aug 4, 2026

Copy link
Copy Markdown
Author

Imaginary-literal node_id changes between Ruby 4.0.6 and Prism master

Ruby 4.0.6 embeds Prism 1.8.1. Imaginary literals have different node IDs
when the same source is parsed with Prism master.

For example:

require "prism"

node = Prism.parse("1i").value.statements.body.first
p [
  Prism::VERSION,
  node.class,
  node.node_id,
  node.numeric.class,
  node.numeric.node_id
]

Run it with the Prism embedded in Ruby 4.0.6:

~/.rubies/ruby-4.0.6/bin/ruby --disable-gems example.rb

The result is:

["1.8.1", Prism::ImaginaryNode, 2, Prism::IntegerNode, 3]

Run the same program with Prism master from the current checkout:

~/.rubies/ruby-4.0.6/bin/ruby -Ilib example.rb

The result is:

["1.9.0", Prism::ImaginaryNode, 3, Prism::IntegerNode, 2]

The nodes have the same structure and the total number of allocated nodes is
unchanged, but the IDs of the wrapper and its numeric child are swapped:

Node Prism 1.8.1 Prism master
ImaginaryNode("1i") 2 3
IntegerNode("1") 3 2

The same change occurs for the other imaginary-literal forms:

Source Numeric child
1i IntegerNode
1.0i FloatNode
1ri RationalNode
1.0ri RationalNode

In each case, Prism 1.8.1 assigns ID 2 to the ImaginaryNode and ID 3 to
the numeric child. Prism master assigns ID 3 to the ImaginaryNode and ID 2
to the child.

The change was introduced by
b548656979,
“Template out node creation functions.” The refactor changed the allocation
order of the ImaginaryNode wrapper and its numeric child without adding or
removing a node or changing the final AST shape.

Limitation

This is evidence that node_id can change solely because allocation order
changed, but it is not by itself a Prism.find failure. ImaginaryNode and
its numeric child do not represent a method, proc, or an operation whose ID is
exposed through a backtrace location. Their IDs are therefore not supplied to
Prism.find by CRuby. The swap also leaves subsequent IDs unchanged.

@eregon

eregon commented Aug 4, 2026

Copy link
Copy Markdown
Author

Summary of all observed node_id changes since Prism 1.2.0

I found eight distinct changes since the exact v1.2.0 tag that alter
visible node IDs.

I compared every official tag over 998 fixtures, added targeted
reproductions, and checked both surviving nodes moving IDs and IDs being
reassigned. This is corpus-exhaustive, not a proof over every possible Ruby
program.

Commit Release transition Cause Example ID change Prism.find impact
871ed4b46 1.3.2 → 1.4.0 Added ParenthesesNode for not() Following DefNode(:a) 3 → 4 The relevant CRubies did not yet expose method node IDs
07202005c 1.3.2 → 1.4.0 Rescue-modifier precedence changed DefNode(:target) 8 → 6 Same pre-exposure limitation
915f6b3ae 1.4.0 → 1.5.0 Context terminators changed case/in structure Later methods shift by one Proven Prism.find failure
cf3bbf9d2 1.4.0 → 1.5.0 Removed ItParametersNode for it = it DefNode(:a) 10 → 9 Proven failures for methods, lambdas, procs, and backtraces
211677000 1.6.0 → 1.7.0 Added InterpolatedStringNode around heredoc-interrupted %w parts Methods 11/12 → 12/13 Proven Prism.find failure
Fix in handling 1.8.0 → 1.8.1; 1.9.0 → master Restored MatchPredicateNode inside an in body Outer InNode 9 → 12 Only InNode, pinned, and local-read IDs move; no exposed failure found
b54865697 post-1.9/master Templated node construction changed allocation order ImaginaryNode/numeric child IDs swap 2 ↔ 3 Internal only; not exposed through CRuby
ab9412cfc post-1.9/master Rescue associativity changed DefNode(:target) 6 → 8; ID 6 becomes CallNode(:c) Proven failure with Ruby 4.0.6

Fix in handling branches

“Fix in handling” was applied independently to three branches:

  • Main: 20374ced51d31d10fe803d7e62c8af22c781f6fa
  • Ruby 4.0: 34e5539c53e1c807f91109235949742037fd761f
  • Ruby 3.4: 7e3f3ca6e11e5048ba35717615552e8bdf87e0af

Release transitions without observed changes

No visible ID changes were found across these official transitions:

  • 1.2.0 → 1.3.0
  • 1.3.0 → 1.3.2
  • 1.5.0 → 1.5.1
  • 1.5.1 → 1.5.2
  • 1.5.2 → 1.6.0
  • 1.7.0 → 1.8.0
  • 1.8.0 → 1.9.0

CRuby version caveat

Ruby 3.4.1 → 3.4.2 contains the not() and rescue changes while both report
Prism::VERSION == "1.2.0". CRuby started recording useful method ISeq node
IDs in 3.4.2; 3.4.0 and 3.4.1 record -1. Exact parser provenance therefore
matters more than Prism::VERSION.

Excluded change

I also checked
a8a7c6b77,
“Preserve line-continuation only in dedent heredocs.” It exposes a newly
retained node using an ID previously consumed internally, but does not change
any pre-existing visible node ID or affect subsequent IDs, so it is excluded
from the table.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment