Fix deadcode removal eating a preceding heredoc - #980
Conversation
When removing a definition, `NodeRemover` walks up from the definition to
absorb attached sigs, comments and a preceding blank line. To decide
whether a blank line separates it from the node above, it used that
node's `location.end_line`. Prism reports a node ending in a heredoc as
ending on the heredoc's *opening* line (the terminator is exposed
separately via `closing_loc`), so a statement like
def_node_matcher :name, <<~PATTERN
...
PATTERN
is reported as ending on its first line. The remover then treated the
heredoc body and its terminator as blank filler and deleted them along
with the definition below, producing invalid Ruby (a dangling heredoc).
Compute the real last line a node occupies by descending into its
children and using any heredoc string's `closing_loc`, so the blank-line
accounting stops at the terminator.
a3b7032 to
92d2876
Compare
Bump vendored Spoom to 1.8.4 and preload a monkey-patch into the `spoom deadcode remove` subprocess that backports two removal fixes opened upstream but not yet in a released gem: - Shopify/spoom#980: removal no longer eats a preceding heredoc. - Shopify/spoom#981: a `private_constant`/`public_constant` referencing a removed constant is removed alongside it. The patch reopens `NodeRemover` via a prepended module and is loaded only in the removal subprocess (via `ruby -r`), never into brew itself. Delete the patch and its wiring once the vendored Spoom includes both fixes. Claude-Session: https://claude.ai/code/session_01XcY6NJn468hSt2n4WVkfoK
Bump vendored Spoom to 1.8.4 and preload a monkey-patch into the `spoom deadcode remove` subprocess that backports two removal fixes opened upstream but not yet in a released gem: - Shopify/spoom#980: removal no longer eats a preceding heredoc. - Shopify/spoom#981: a `private_constant`/`public_constant` referencing a removed constant is removed alongside it. The patch reopens `NodeRemover` via a prepended module and is loaded only in the removal subprocess (via `ruby -r`), never into brew itself. Delete the patch and its wiring once the vendored Spoom includes both fixes. Claude-Session: https://claude.ai/code/session_01XcY6NJn468hSt2n4WVkfoK
| when Prism::StringNode, Prism::InterpolatedStringNode, | ||
| Prism::XStringNode, Prism::InterpolatedXStringNode | ||
| opening = node.opening_loc | ||
| return unless opening&.slice&.start_with?("<<") |
There was a problem hiding this comment.
Can this ever be false?
There was a problem hiding this comment.
Yes, in two distinct ways — this is a necessary filter, not dead code:
-
opening_loccan benilentirely, e.g. for implicit string concatenation:x = "foo" "bar"
Prism represents this as an
InterpolatedStringNodewithopening_loc == nil(andclosing_loc == nil), wrapping the individualStringNodeparts. Hence the&.onopening. -
opening_loccan be present but not start with"<<"for any non-heredoc string/xstring, since thecasehere matches onStringNode/InterpolatedStringNode/XStringNode/InterpolatedXStringNodebroadly, not just heredocs:x = "foo" # opening_loc.slice == "\"" x = ?a # opening_loc.slice == "?", closing_loc == nil x = `foo` # opening_loc.slice == "`"
Only actual heredocs have opening text like
"<<~HEREDOC"or"<<-EOS".
Since node_end_line walks every descendant node (via compact_child_nodes), heredoc_terminator_line gets called on all string-like nodes it encounters, not just heredocs — so this check is what distinguishes the heredoc case from the rest.
Good catch that neither case was tested — pushed test_removes_method_defined_after_an_adjacent_string_concatenation and test_removes_method_defined_after_a_plain_string_argument to cover them.
Bump vendored Spoom to 1.8.4 and preload a monkey-patch into the `spoom deadcode remove` subprocess that backports two removal fixes opened upstream but not yet in a released gem: - Shopify/spoom#980: removal no longer eats a preceding heredoc. - Shopify/spoom#981: a `private_constant`/`public_constant` referencing a removed constant is removed alongside it. The patch reopens `NodeRemover` via a prepended module and is loaded only in the removal subprocess (via `ruby -r`), never into brew itself. Delete the patch and its wiring once the vendored Spoom includes both fixes. Claude-Session: https://claude.ai/code/session_01XcY6NJn468hSt2n4WVkfoK
Bump vendored Spoom to 1.8.4 and preload a monkey-patch into the `spoom deadcode remove` subprocess that backports two removal fixes opened upstream but not yet in a released gem: - Shopify/spoom#980: removal no longer eats a preceding heredoc. - Shopify/spoom#981: a `private_constant`/`public_constant` referencing a removed constant is removed alongside it. The patch reopens `NodeRemover` via a prepended module and is loaded only in the removal subprocess (via `ruby -r`), never into brew itself. Delete the patch and its wiring once the vendored Spoom includes both fixes. Claude-Session: https://claude.ai/code/session_01XcY6NJn468hSt2n4WVkfoK
Bump vendored Spoom to 1.8.4 and preload a monkey-patch into the `spoom deadcode remove` subprocess that backports two removal fixes opened upstream but not yet in a released gem: - Shopify/spoom#980: removal no longer eats a preceding heredoc. - Shopify/spoom#981: a `private_constant`/`public_constant` referencing a removed constant is removed alongside it. The patch reopens `NodeRemover` via a prepended module and is loaded only in the removal subprocess (via `ruby -r`), never into brew itself. Delete the patch and its wiring once the vendored Spoom includes both fixes. Claude-Session: https://claude.ai/code/session_01XcY6NJn468hSt2n4WVkfoK
…over-removal # Conflicts: # rbi/spoom.rbi
heredoc_terminator_line's opening_loc guard also has to handle two cases besides an actual heredoc: opening_loc being nil (implicit string concatenation) and opening_loc present but not starting with "<<" (an ordinary string). Neither was covered by a test.
Bump vendored Spoom to 1.8.4 and preload a monkey-patch into the `spoom deadcode remove` subprocess that backports two removal fixes opened upstream but not yet in a released gem: - Shopify/spoom#980: removal no longer eats a preceding heredoc. - Shopify/spoom#981: a `private_constant`/`public_constant` referencing a removed constant is removed alongside it. The patch reopens `NodeRemover` via a prepended module and is loaded only in the removal subprocess (via `ruby -r`), never into brew itself. Delete the patch and its wiring once the vendored Spoom includes both fixes. Claude-Session: https://claude.ai/code/session_01XcY6NJn468hSt2n4WVkfoK
| if before | ||
| to_node = first_comment || node | ||
| comment = @node_context.comments_between_lines(before.location.end_line, to_node.location.start_line).last | ||
| comment = @node_context.comments_between_lines(node_end_line(before), to_node.location.start_line).last |
There was a problem hiding this comment.
node_end_line(before) can be cached and re-used, currently there are 3 calls.
There was a problem hiding this comment.
Done — cached as before_end_line.
It ends up as two calls rather than one, though: a few lines above, before is swapped for an intervening comment (before = comment if comment), so the value passed to comments_between_lines is computed on a different object than the one the comparison below needs. Cached the post-swap value, which is where the duplicated pair was.
There was a problem hiding this comment.
Should node_end_line be used here too? Then could we remove the case statement right below?
There was a problem hiding this comment.
Yes to both — done. end_line = node_end_line(node), and the case statement (plus its TODO: remove once Prism location are fixed, which I moved onto node_end_line) is gone.
It turned out to be more than a cleanup: the special case only recognized a value that was directly a Prism::StringNode, so it missed two shapes that node_end_line handles:
BAR = build(<<~MSG) # value is a CallNode
Some text
MSG
BAR = <<~MSG # value is an InterpolatedStringNode
Some #{text}
MSGRemoving BAR in either case used to leave the body and terminator behind as orphaned lines:
class Foo
def bar; end
Some text
MSG
def baz; end
endAdded test_removes_multiline_const_with_heredoc_passed_to_a_call and test_removes_multiline_const_with_interpolation; both fail without the change.
| RB | ||
| end | ||
|
|
||
| def test_removes_method_defined_after_a_plain_string_argument |
There was a problem hiding this comment.
| def test_removes_method_defined_after_a_plain_string_argument | |
| def test_removes_method_defined_after_a_plain_string_assignment |
There was a problem hiding this comment.
Applied — thanks, "assignment" is right, there's no argument involved.
| end_line = node.location.end_line | ||
| return end_line unless node.is_a?(Prism::Node) | ||
|
|
||
| stack = node.compact_child_nodes |
There was a problem hiding this comment.
| stack = node.compact_child_nodes | |
| stack = [node] |
We should include node itself in the traversal, since a heredoc can be the node itself.
There was a problem hiding this comment.
Applied — and you're right that it's reachable, not just theoretical. A bare heredoc statement ahead of a dead definition:
class Foo
<<~MSG
hello
MSG
sig { void }
def on_send(node)
something
end
def baz; end
endRemoving on_send produced an unterminated heredoc, i.e. invalid Ruby:
class Foo
<<~MSG
def baz; end
end
Added test_removes_method_defined_after_a_bare_heredoc, which fails without this change.
Bump vendored Spoom to 1.8.4 and preload a monkey-patch into the `spoom deadcode remove` subprocess that backports two removal fixes opened upstream but not yet in a released gem: - Shopify/spoom#980: removal no longer eats a preceding heredoc. - Shopify/spoom#981: a `private_constant`/`public_constant` referencing a removed constant is removed alongside it. The patch reopens `NodeRemover` via a prepended module and is loaded only in the removal subprocess (via `ruby -r`), never into brew itself. Delete the patch and its wiring once the vendored Spoom includes both fixes. Claude-Session: https://claude.ai/code/session_01XcY6NJn468hSt2n4WVkfoK
`delete_node_and_comments_and_sigs` special-cased constant writes whose value is a `Prism::StringNode` to recover the heredoc terminator line. `node_end_line` already does this generally, so use it and drop the special case. This also covers two shapes the special case missed, because it only looked at a `Prism::StringNode` value directly: a heredoc reaching the constant through a call (`FOO = build(<<~MSG)`) and an interpolated heredoc (a `Prism::InterpolatedStringNode`). Both previously left the heredoc body and terminator behind as orphaned lines.
`node_end_line` walked only a node's descendants, so a node that *is* a heredoc string reported its opening line as its end line. A bare heredoc statement preceding a dead definition was therefore treated as blank filler, and removing the definition ate the heredoc body, leaving unterminated and invalid Ruby.
`before` is swapped for an intervening comment before its end line is compared and reused, so resolve the line once after that point rather than calling `node_end_line` on each use.
Motivation
When
spoom deadcode removeremoves a definition,NodeRemoverwalks upward from it to absorb any attached sigs, comments, and a single preceding blank line. To decide whether a blank line separates the definition from the statement above it, it compared against that statement'slocation.end_line.Prism reports a node that ends in a heredoc as ending on the heredoc's opening line, exposing the terminator separately through
closing_loc. So a statement like:is reported as ending on its first line. The remover then treated the heredoc body and its
PATTERNterminator as blank filler between the two statements and deleted them together withon_send, leaving a dangling, unterminated heredoc and thus invalid Ruby.Implementation
Add
node_end_line, which descends into a node's children and, for any heredoc string, uses itsclosing_locstart line as the real last line the node occupies. The blank-line accounting indelete_node_and_comments_and_sigsnow uses this instead oflocation.end_line, so it stops at the terminator rather than eating the heredoc.Tests
Added
test_removes_method_defined_after_a_heredoc_argumentandtest_removes_method_defined_after_an_interpolated_heredoc_argument(covering theInterpolatedStringNodepath, which is what realdef_node_matcherpatterns with#{...}produce). The full remover suite andsrb tcpass.