Skip to content

Do not treat missing type attribution as proof of semantic equality - #8333

Open
timtebeek wants to merge 1 commit into
mainfrom
tim/semanticallyequal-missing-types
Open

Do not treat missing type attribution as proof of semantic equality#8333
timtebeek wants to merge 1 commit into
mainfrom
tim/semanticallyequal-missing-types

Conversation

@timtebeek

@timtebeek timtebeek commented Jul 27, 2026

Copy link
Copy Markdown
Member

Problem

SemanticallyEqual considered this.x and a bare x equal whenever both sides lacked a JavaType.Variable, because TypeUtils.isOfType(null, null) is true. On an LST with incomplete type attribution a constructor's this.x = x therefore looks like a self-assignment, and RemoveSelfAssignment deletes it — 852 files across 83 repositories in the Netflix + Spring + Apache run, and where the field is private final the output no longer compiles.

Two paths reach it:

  • visitFieldAccess (the order RemoveSelfAssignment uses) guarded on fieldType != null && !fieldType.hasFlags(Static), so a null field type skipped the guard entirely.
  • the implicit-this shortcut in visitIdentifier, which recurses into an identifier-vs-identifier comparison that distinguishes a field from a parameter only by field type.

Fix

this.x is equivalent to x only when x resolves to the field rather than to a shadowing parameter or local. That cannot be established without type information in any language, so both directions of the J.FieldAccess / J.Identifier comparison now require a field type on both sides.

The carve-out that matters: a null field type on a J.FieldAccess name also means "type reference" (java.util.regex.Pattern vs Pattern, java.lang.String.class vs String.class), where null is expected rather than missing. Those keep comparing by name.

Comparisons between two identifiers are untouched and still fall back to name equality — that is what languages which never attribute field types rely on, so this is not a coverage regression for them.

Why this is not Java-only

The Groovy parser passes null for fieldType at essentially every reference site (GroovyParserVisitor visitVariableExpression, parameters, visitPropertyExpression), so Groovy hit this bug unconditionally — a plain this.readOnly = readOnly in a Groovy constructor compared equal before this change. The new rewrite-groovy test covers both that case and the identifier-vs-identifier fallback that must keep working.

Tests

  • SemanticallyEqualTest.fieldNotEqualToShadowedNameVariableWithoutTypeAttribution (rewrite-java-test) — strips fieldType/type off identifiers to simulate an LST where attribution did not complete, since JavaParser in the test harness always attributes fully. This is why the existing RemoveSelfAssignment test suite could not reproduce the bug.
  • org.openrewrite.groovy.search.SemanticallyEqualTest (new) — the natural reproduction, plus the fallback that must stay true.

Both fail on main and pass here. :rewrite-java:test, :rewrite-java-test:test, :rewrite-java-tck:test, :rewrite-groovy:test and :rewrite-kotlin:test are green.

Follow-ups (not in this PR)

Tracked downstream in openrewrite/rewrite-static-analysis#953; none of this belongs in rewrite-java.

  • Verifying RemoveSelfAssignment needs a real LST. Green unit tests are not evidence here — RewriteTest/JavaParser always attribute fully, which is why the recipe's own doNotChangeFieldAssignedFromParameter passed the whole time the bug was live. The check that works is mod build over a repo with unresolved dependencies and reading fix.patch (apache/geronimo-specs: 193 files, expected 0 after this change), or a unit test that strips attribution the way the new test here does.
  • CombineSemanticallyEqualCatchBlocks$CommentVisitor does not need this change, despite the "apply bug fixes here too" note on SemanticallyEqual's javadoc. Its visitFieldAccess/visitIdentifier require both sides to be the same node type, so it has neither the cross-shape branch nor the implicit-this shortcut that were unsound here.
  • A residual hole remains that this PR cannot close. Two bare identifiers with the same name and no type information on either side are genuinely indistinguishable, so a field and a same-named local still compare equal. That is where a NoMissingTypes precondition on the deletion-gating recipes would help — 17 recipes in rewrite-static-analysis call SemanticallyEqual and none has one. Worth triaging by exposure rather than applying uniformly: FindMissingTypes has no language gate, so such a precondition flags Groovy identifiers en masse and silently disables the recipe for Groovy, and for C#/Python LSTs parsed without a semantic model or ty server.
  • The real fix for "missing vs systemically absent" is a signal on the LST. Today there is none: neither SourceFile nor JavaSourceFile exposes a language id, no parser emits a "types unavailable" marker, and TreeVisitor#getLanguage() lives on the visitor (and JavaScriptVisitor returns "org/openrewrite/javascript", which looks like a bug). Both the Python and C# parsers already know at parse time that they are degrading to all-null and could record it.

`SemanticallyEqual` considered `this.x` and a bare `x` equal whenever both
sides lacked a `JavaType.Variable`, because `TypeUtils.isOfType(null, null)`
is `true`. On LSTs with incomplete type attribution that made a constructor's
`this.x = x` look like a self-assignment, and `RemoveSelfAssignment` deleted
it; where the field is `private final` the result no longer compiles.

`this.x` is equivalent to `x` only when `x` resolves to the field rather than
to a shadowing parameter or local, which cannot be established without type
information in any language. Both directions of the `J.FieldAccess` /
`J.Identifier` comparison now require a field type on both sides, keeping the
carve-out for type references such as `java.util.regex.Pattern` vs `Pattern`,
where a null field type is expected rather than missing.

Comparisons between two identifiers still fall back to name equality, which
is what languages that never attribute field types rely on.

See openrewrite/rewrite-static-analysis#953
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant