Skip to content

SONARJAVA-7288 Replace Eclipse JDT core with PSI in the Java frontend - #6456

Closed
aurelien-coet-sonarsource wants to merge 2 commits into
ac/java-psi-frontend-6from
ac/java-psi-frontend-7
Closed

aurelien-coet-sonarsource wants to merge 2 commits into
ac/java-psi-frontend-6from
ac/java-psi-frontend-7

Conversation

@aurelien-coet-sonarsource

@aurelien-coet-sonarsource aurelien-coet-sonarsource commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary by Gitar

  • Replaced ECJ with PSI:
    • JParser, JSema, JType, and JSymbol now resolve types and symbols through PSI instead of ECJ bindings
    • Added PsiFrontendBackend and PsiSemanticAttachment as the new semantic backend
  • Removed ECJ classes:
    • Deleted the Ecj* model classes and org.eclipse.jdt.core.dom.ASTUtils, and dropped the java-frontend pom dependency
  • Build validation:
    • Added FrontendDependencyValidator to fail packaging if ECJ or JDT classes appear in the plugin JAR
  • Tests:
    • Migrated semantic tests to SemanticTestSupport and added PSI-specific suites such as PsiFrontendBackendTest and JSemaRegressionTest

This will update automatically on new commits.

@hashicorp-vault-sonar-prod hashicorp-vault-sonar-prod Bot changed the title Replace Eclipse JDT core with PSI in the Java frontend SONARJAVA-7288 Replace Eclipse JDT core with PSI in the Java frontend Nov 2, 2026
@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

SONARJAVA-7288

@aurelien-coet-sonarsource

Copy link
Copy Markdown
Contributor Author

/pvf

@gitar-bot

gitar-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
CI failed: No actual CI failure is identifiable in the supplied logs; the excerpts show only successful setup, cache misses (non-fatal), diff report generation and upload, and cleanup, so the cause is unclear and not tied to the PSI change.

Overview

Two unique log excerpts were analyzed across 7 logs and 7 error templates, but neither shows an explicit error, failed step, nonzero exit, or job conclusion. The visible steps succeeded and the cache and StepSecurity messages are warnings. The failing check cannot be identified from the supplied content.

Failures

No identifiable failure in Ruling QA logs (confidence: low)

  • Type: other
  • Affected jobs: 125306591777, 125305434607
  • Related to change: unclear
  • Root cause: The excerpts omit the section that would contain the Ruling QA command output, failing assertion, or job conclusion. Cache misses and StepSecurity post-job warnings are non-fatal. A ruling mismatch or test failure cannot be confirmed from these logs.
  • Suggested fix: Provide the full log around the first error or failed step for both jobs, including the Ruling QA command output, exit status, and any generated test.diff, so the actual failure can be identified.

Summary

  • Change-related failures: None confirmed. Both findings are marked related_to_pr unclear.
  • Infrastructure/flaky failures: None identified.
  • Recommended action: Retrieve the omitted Ruling QA job output from both runs (Linux and Windows) before attributing any failure to the PSI replacement.
Code Review ⚠️ Changes requested 0 closed / 3 findings

🟡 Medium risk · Replacing ECJ with PSI across Java parsing and symbol/type resolution could alter analysis results for Java projects.

Replaces ECJ with PSI as the Java frontend backend, removing the Ecj* model classes and the java-frontend pom dependency, and adds a build validator that fails packaging if ECJ or JDT classes appear in the plugin JAR. Changes are requested because removing the public JParser.parse(ASTParser,…) and JParserConfig.astParser() APIs with no shim breaks the symbolic-execution repo's JParserTestUtils.parse helper and its dependent tests. Also open: JSema.binaryName assigns wrong numbers to nested anonymous and local classes, and an unresolved type's fullyQualifiedName() depends on which usage is converted first.

⚠️ Cross-Repo: Removing astParser()/JParser.parse(ASTParser,…) breaks SE test helper

📄 java-frontend/src/main/java/org/sonar/java/model/JParserConfig.java:95-105 📄 java-frontend/src/main/java/org/sonar/java/model/JParser.java:209

Fix here and in SonarSource/sonar-java-symbolic-execution

Breaks in: SonarSource/sonar-java-symbolic-execution - java-symbolic-execution/java-symbolic-execution-plugin/src/test/java/org/sonar/java/se/utils/JParserTestUtils.java - JParserTestUtils.parse

In the base, JParserConfig#astParser() and JParser.parse(ASTParser, String version, String unitName, String source) are both public. This PR deletes both, and the only public parse entry point left is FrontendParser.parse(JavaVersion, List<File>, String, String). It returns an AutoCloseable ParsedFile.

In SonarSource/sonar-java-symbolic-execution, JParserTestUtils.parse (java-symbolic-execution/java-symbolic-execution-plugin/src/test/java/org/sonar/java/se/utils/JParserTestUtils.java) builds an ASTParser with JParserConfig.Mode.FILE_BY_FILE.create(...).astParser() and passes it to the four-argument JParser.parse. Earlier reviews flagged this same pair of APIs: the JParser.parse SE dependency and the astParser SE dependency. When that repo builds against this java-frontend, the helper stops compiling, and so does every SE test that depends on it. Removing ECJ is the point of this PR, so the consumer has to move. Code in this repo can still ease the transition.

Here: Record the removal of JParser.parse(ASTParser, …) and JParserConfig.astParser() in the release notes or migration notes, and name FrontendParser.parse as the replacement. Optionally add a deprecated, ASTParser-free JParser.parse(String version, String unitName, String source) shim that delegates to FrontendParser, so consumers can migrate in two steps.

There: In JParserTestUtils.parse, drop the astParser() call and the four-argument JParser.parse call, and use FrontendParser.parse(...) instead. Tie the PSI environment's lifetime to the test, for example by keeping the ParsedFile open until the tree is no longer needed and then closing it.

In SonarSource/sonar-java-symbolic-execution - java-symbolic-execution/java-symbolic-execution-plugin/src/test/java/org/sonar/java/se/utils/JParserTestUtils.java:

public static CompilationUnitTree parse(String unitName, String source, List<File> classpath) {
  // ParsedFile owns the PSI environment; close it once the test is done with the tree
  FrontendParser.ParsedFile parsed = FrontendParser.parse(JParserConfig.MAXIMUM_SUPPORTED_JAVA_VERSION, classpath, unitName, source);
  return parsed.tree();
}
💡 Bug: binaryName gives wrong numbers to anonymous and local classes

📄 java-frontend/src/main/java/org/sonar/java/model/JSema.java:386-400

JSema.binaryName numbers a class with no qualified name by counting every descendant PsiClass of the outer class that has no qualified name (PsiTreeUtil.findChildrenOfType(outer, PsiClass.class)). This walk is depth-first and recursive, and it puts anonymous and local classes on one counter. Two cases come out wrong:

  • In class A { Object a = new Object(){ Object b = new Object(){}; }; Object c = new Object(){}; }, the second top-level anonymous class becomes A$3, because the class nested inside the first one is counted too. javac/ECJ name it A$2.
  • In void m(){ new Object(){}; class L{} }, the local class becomes A$2L instead of A$1L.

The result reaches Type.fullyQualifiedName() and MethodSymbol.signature(), so checks that match on binary names see values that differ from the old ECJ getBinaryName().

Fix: count only direct nested classes, skipping classes inside other anonymous or local classes. Keep a separate counter for each local class name.

💡 Edge Case: Unresolved type's FQN depends on which usage is converted first

📄 java-frontend/src/main/java/org/sonar/java/model/JType.java:214-225 📄 java-frontend/src/main/java/org/sonar/java/model/JSema.java:149-163 📄 java-frontend/src/main/java/org/sonar/java/model/JSema.java:183-188

JType.qualifiedName gives an unresolved class type one of two forms. If the reference sits in a PsiReferenceList (extends/implements), it returns Recovered#typeBindingL…;0; otherwise it returns the plain canonical name. But JSema.typeKey keys every unresolved class type only by "recovered:" + canonicalText, and JSema.type caches one JType per key. So in class A extends Missing { Missing f; }, the field type and the supertype share a single JType. Whichever PsiType is converted first decides the fullyQualifiedName() that both report. The behavior the branch is meant to distinguish therefore depends on traversal order.

Fix: include the reference-list context in the key, or compute the recovered name from the type alone so it does not depend on context.

🤖 Prompt for agents
Code Review: Replaces ECJ with PSI as the Java frontend backend, removing the `Ecj*` model classes and the `java-frontend` pom dependency, and adds a build validator that fails packaging if ECJ or JDT classes appear in the plugin JAR. Changes are requested because removing the public `JParser.parse(ASTParser,…)` and `JParserConfig.astParser()` APIs with no shim breaks the symbolic-execution repo's `JParserTestUtils.parse` helper and its dependent tests. Also open: `JSema.binaryName` assigns wrong numbers to nested anonymous and local classes, and an unresolved type's `fullyQualifiedName()` depends on which usage is converted first.

1. 💡 Bug: binaryName gives wrong numbers to anonymous and local classes
   Files: java-frontend/src/main/java/org/sonar/java/model/JSema.java:386-400

   `JSema.binaryName` numbers a class with no qualified name by counting every descendant `PsiClass` of the outer class that has no qualified name (`PsiTreeUtil.findChildrenOfType(outer, PsiClass.class)`). This walk is depth-first and recursive, and it puts anonymous and local classes on one counter. Two cases come out wrong:
   - In `class A { Object a = new Object(){ Object b = new Object(){}; }; Object c = new Object(){}; }`, the second top-level anonymous class becomes `A$3`, because the class nested inside the first one is counted too. javac/ECJ name it `A$2`.
   - In `void m(){ new Object(){}; class L{} }`, the local class becomes `A$2L` instead of `A$1L`.
   
   The result reaches `Type.fullyQualifiedName()` and `MethodSymbol.signature()`, so checks that match on binary names see values that differ from the old ECJ `getBinaryName()`.
   
   Fix: count only direct nested classes, skipping classes inside other anonymous or local classes. Keep a separate counter for each local class name.

2. 💡 Edge Case: Unresolved type's FQN depends on which usage is converted first
   Files: java-frontend/src/main/java/org/sonar/java/model/JType.java:214-225, java-frontend/src/main/java/org/sonar/java/model/JSema.java:149-163, java-frontend/src/main/java/org/sonar/java/model/JSema.java:183-188

   `JType.qualifiedName` gives an unresolved class type one of two forms. If the reference sits in a `PsiReferenceList` (extends/implements), it returns `Recovered#typeBindingL…;0`; otherwise it returns the plain canonical name. But `JSema.typeKey` keys every unresolved class type only by `"recovered:" + canonicalText`, and `JSema.type` caches one `JType` per key. So in `class A extends Missing { Missing f; }`, the field type and the supertype share a single `JType`. Whichever `PsiType` is converted first decides the `fullyQualifiedName()` that both report. The behavior the branch is meant to distinguish therefore depends on traversal order.
   
   Fix: include the reference-list context in the key, or compute the recovered name from the type alone so it does not depend on context.

3. ⚠️ Cross-Repo: Removing astParser()/JParser.parse(ASTParser,…) breaks SE test helper
   Files: java-frontend/src/main/java/org/sonar/java/model/JParserConfig.java:95-105, java-frontend/src/main/java/org/sonar/java/model/JParser.java:209

   **Fix here and in [`SonarSource/sonar-java-symbolic-execution`](https://github.com/SonarSource/sonar-java-symbolic-execution)**
   
   **Breaks in:** [`SonarSource/sonar-java-symbolic-execution`](https://github.com/SonarSource/sonar-java-symbolic-execution) - [`java-symbolic-execution/java-symbolic-execution-plugin/src/test/java/org/sonar/java/se/utils/JParserTestUtils.java`](https://github.com/SonarSource/sonar-java-symbolic-execution/blob/HEAD/java-symbolic-execution/java-symbolic-execution-plugin/src/test/java/org/sonar/java/se/utils/JParserTestUtils.java) - `JParserTestUtils.parse`
   
   In the base, `JParserConfig#astParser()` and `JParser.parse(ASTParser, String version, String unitName, String source)` are both public. This PR deletes both, and the only public parse entry point left is `FrontendParser.parse(JavaVersion, List<File>, String, String)`. It returns an `AutoCloseable` `ParsedFile`.
   
   In SonarSource/sonar-java-symbolic-execution, `JParserTestUtils.parse` (`java-symbolic-execution/java-symbolic-execution-plugin/src/test/java/org/sonar/java/se/utils/JParserTestUtils.java`) builds an `ASTParser` with `JParserConfig.Mode.FILE_BY_FILE.create(...).astParser()` and passes it to the four-argument `JParser.parse`. Earlier reviews flagged this same pair of APIs: the [JParser.parse SE dependency](https://app.gitar.ai/dashboard/knowledge?fact=01a178c4-4b89-7121-96fa-437d21d7417b&organization=org_39IepXTlMfsacAJscTWZRzxocgg&organization=org_6yy8x4tTaz1NDNZ2) and the [astParser SE dependency](https://app.gitar.ai/dashboard/knowledge?fact=01a1817e-07ef-70cf-a9d0-823a125e2eac&organization=org_39IepXTlMfsacAJscTWZRzxocgg&organization=org_6yy8x4tTaz1NDNZ2). When that repo builds against this java-frontend, the helper stops compiling, and so does every SE test that depends on it. Removing ECJ is the point of this PR, so the consumer has to move. Code in this repo can still ease the transition.
   
   **Here:** Record the removal of `JParser.parse(ASTParser, …)` and `JParserConfig.astParser()` in the release notes or migration notes, and name `FrontendParser.parse` as the replacement. Optionally add a deprecated, ASTParser-free `JParser.parse(String version, String unitName, String source)` shim that delegates to `FrontendParser`, so consumers can migrate in two steps.
   
   **There:** In `JParserTestUtils.parse`, drop the `astParser()` call and the four-argument `JParser.parse` call, and use `FrontendParser.parse(...)` instead. Tie the PSI environment's lifetime to the test, for example by keeping the `ParsedFile` open until the tree is no longer needed and then closing it.
   
   In SonarSource/sonar-java-symbolic-execution - `java-symbolic-execution/java-symbolic-execution-plugin/src/test/java/org/sonar/java/se/utils/JParserTestUtils.java`:
   
   ````
   public static CompilationUnitTree parse(String unitName, String source, List<File> classpath) {
     // ParsedFile owns the PSI environment; close it once the test is done with the tree
     FrontendParser.ParsedFile parsed = FrontendParser.parse(JParserConfig.MAXIMUM_SUPPORTED_JAVA_VERSION, classpath, unitName, source);
     return parsed.tree();
   }
   ````

Verification Rules 2/2 met Knowledge 🔎 2 Repos 4 inspected · PRs 4 inspected

Functional validation No results

Rules 2 passed 1 not applicable

✅ Internal artifact registries
✅ Repository license compliance

Knowledge 🔎 2 informed a finding

🔎 JParser.parse(ASTParser, String version, String unitName, String source) / JParserConfig#astParser(): dependency with SonarSource/sonar-java-symbolic-execution informed a finding, open -> Removing astParser()/JParser.parse(ASTParser,…) breaks SE test helper (finding FbMwmv5L)
🔎 org.sonar.java.model.JParser.parse(ASTParser, String, String, String): dependency with SonarSource/sonar-java-symbolic-execution informed a finding, open -> Removing astParser()/JParser.parse(ASTParser,…) breaks SE test helper (finding FbMwmv5L)

Broader impact Repos 4 inspected PRs 4 inspected
Cross-repo 4 repositories inspected 1 open impact

SonarSource/sonar-java-symbolic-execution Inspected
SonarSource/sonarqube Inspected
SonarSource/sonarlint-intellij Inspected
SonarSource/sonarlint-vscode Inspected

Cross-PR 4 PRs inspected 0 open impacts

PR 6455: SONARJAVA-7287 Prepare the integration of PSI into the Java frontend Inspected
PR 6454: SONARJAVA-7286 Add PSI dependencies packaging and third-party license notices Inspected
PR 6451: SONARJAVA-7284 Introduce frontend sessions and deterministic resource ownership Inspected
PR 6453: SONARJAVA-7285 Compile Jasper helper classes with the JDK compiler Inspected

Auto-approval Not enabled

Set up

For more verification details, comment gitar display:verbose.

Tip

Comment Gitar fix CI or enable auto-apply: gitar auto-apply:on

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing compact verification results.
Unblock → Override a blocking verdict and allow merging.

Comment with these commands to change the behavior for this request:

Auto-apply Compact Unblock
gitar auto-apply:on         
gitar display:verbose         
gitar unblock         

Was this helpful? React with 👍 / 👎 | Gitar

Comment on lines 386 to +400

@Override
public Runnable getEnvironmentCleaner() {
if (environmentCleaner == null) {
environmentCleaner = FrontendResources.idempotent(ASTUtils.getEnvironmentCleaner(ast));
String binaryName(PsiClass cls) {
if (cls.getContainingClass() != null) {
return binaryName(cls.getContainingClass()) + "$" + cls.getName();
}
return environmentCleaner;
if (cls.getQualifiedName() != null) {
return cls.getQualifiedName();
}
var outer = PsiTreeUtil.getParentOfType(cls, PsiClass.class, true);
if (outer == null) {
return cls.getName() == null ? "" : cls.getName();
}
int ordinal = 0;
for (var candidate : PsiTreeUtil.findChildrenOfType(outer, PsiClass.class)) {
if (candidate instanceof PsiAnonymousClass || candidate.getQualifiedName() == null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Bug: binaryName gives wrong numbers to anonymous and local classes

JSema.binaryName numbers a class with no qualified name by counting every descendant PsiClass of the outer class that has no qualified name (PsiTreeUtil.findChildrenOfType(outer, PsiClass.class)). This walk is depth-first and recursive, and it puts anonymous and local classes on one counter. Two cases come out wrong:

  • In class A { Object a = new Object(){ Object b = new Object(){}; }; Object c = new Object(){}; }, the second top-level anonymous class becomes A$3, because the class nested inside the first one is counted too. javac/ECJ name it A$2.
  • In void m(){ new Object(){}; class L{} }, the local class becomes A$2L instead of A$1L.

The result reaches Type.fullyQualifiedName() and MethodSymbol.signature(), so checks that match on binary names see values that differ from the old ECJ getBinaryName().

Fix: count only direct nested classes, skipping classes inside other anonymous or local classes. Keep a separate counter for each local class name.

Was this helpful? React with 👍 / 👎

Comment on lines +214 to 225
if (type instanceof PsiClassType cls) {
var resolved = cls.resolve();
if (resolved instanceof PsiTypeParameter parameter) {
return parameter.getName();
}
if (resolved != null) {
return sema.binaryName(resolved);
}
String name = cls.rawType().getCanonicalText(false);
return (cls instanceof PsiClassReferenceType reference && reference.getReference().getParent() instanceof PsiReferenceList)
|| sema.explicitRuntime || sema.getClassType("java.lang.Object").isUnknown() ? "Recovered#typeBindingL" + name.replace('.', '/') + ";0" : name;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: Unresolved type's FQN depends on which usage is converted first

JType.qualifiedName gives an unresolved class type one of two forms. If the reference sits in a PsiReferenceList (extends/implements), it returns Recovered#typeBindingL…;0; otherwise it returns the plain canonical name. But JSema.typeKey keys every unresolved class type only by "recovered:" + canonicalText, and JSema.type caches one JType per key. So in class A extends Missing { Missing f; }, the field type and the supertype share a single JType. Whichever PsiType is converted first decides the fullyQualifiedName() that both report. The behavior the branch is meant to distinguish therefore depends on traversal order.

Fix: include the reference-list context in the key, or compute the recovered name from the type alone so it does not depend on context.

Was this helpful? React with 👍 / 👎

@datadog-sonarsource

datadog-sonarsource Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Pipelines

❌ Errors

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 8 Pipeline jobs failed

Build | Build and Unit Test on Windows — 🔧 Needs a code fix, caused by this PR

View more details · View in GitHub Actions

Build | Ruling QA (warp-custom-ubuntu-24-04, only-sonarqube-project, LATEST_RELEASE) — 🔧 Needs a code fix, caused by this PR

View more details · View in GitHub Actions

Build | Ruling QA (warp-custom-ubuntu-24-04, without-sonarqube-project, LATEST_RELEASE) — 🔧 Needs a code fix, caused by this PR

View more details · View in GitHub Actions

View all 8 failed jobs.

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 8c6424f | Docs | View more details | Give us feedback!

@hashicorp-vault-sonar-prod

Copy link
Copy Markdown
Contributor

📊 Performance Validation complete
🆕 387 new · 🗑️ 3309219 lost issues
View dashboard

Projects with unexpected PVF results:

  • apache-rocketmq
  • apache-tika
  • com.google.code.simple-spring-memcached-simple-spring-memcached-parent
  • com.redhat.lightblue-lightblue-core-pom
  • com.sparkjava-spark-core
  • com.zaxxer-HikariCP
  • crate
  • es.usc.citius.hipster-hipster-pom
  • guava
  • guava_java9
  • heritrix3
  • initializr
  • jadx
  • jar-analyzer
  • java-new-features
  • jhipster
  • jooq
  • junit-junit
  • kafka
  • micrometer
  • mybatis-dynamic-sql
  • net.code-story-http
  • net.sf.aislib-aislib
  • net.sf.flatpack-flatpack-parent
  • netflix-conductor
  • netflix-metacat
  • netflix-zuul
  • org.activiti-activiti-root
  • org.apache.abdera-abdera
  • org.apache.bcel-bcel
  • org.apache.cayenne-cayenne-parent
  • org.apache.commons-commons-collections4
  • org.apache.commons-commons-exec
  • org.apache.commons-commons-pool2
  • org.apache.commons-commons-vfs2-project
  • org.apache.empire-db-empire-db-parent
  • org.apache.jackrabbit-jackrabbit
  • org.apache.maven.release-maven-release
  • org.apache.myfaces.extensions.cdi-myfaces-extcdi-parent
  • org.apache.myfaces.tobago-tobago
  • org.apache.opennlp-opennlp
  • org.apache.portals.pluto-pluto
  • org.apache.wicket-wicket-parent
  • org.apache.xbean-xbean
  • org.assertj-assertj-assertions-generator
  • org.broadleafcommerce-broadleaf
  • org.codehaus.sonar-plugins-sonar-pitest-plugin
  • org.grouplens.grapht-grapht
  • org.javamoney-moneta
  • org.ops4j.pax-logging
  • org.sonarsource.xml-xml
  • overrungl-java-22
  • redisson
  • ro.pippo-pippo-parent
  • rxjava
  • sonar-persistit
  • sonarjava-symbolic-execution-test-sources
  • sonarqube
  • spring-framework
  • tomcat7
  • tomcat8
  • tomcat9
  • wavsep
  • xwiki-commons_fixed-9.3.1

@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

⚖️ Ruling update ready for review: #6458

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