diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38c000d..b40f2f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,8 @@ jobs: - name: Run adapter tests run: make adapter-test - - name: Validate spike contracts - run: make spike-validate + - name: Validate captured evidence + run: make evidence-validate java-lint: name: Java lint gate diff --git a/Makefile b/Makefile index dfa68a7..2bdf209 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test package adapter-test adapter-package java-lint java-lint-report java-lint-test manual-analysis spike-validate framework-enrichment semantic-facts-spike fixture-test fixture-package spring-test download-jdtls jdtls-probe sootup-quarkus sootup-quarkus-full sootup-spring sootup-fsi codeql-quarkus codeql-spring codeql-fsi normalize-static-analysis +.PHONY: test package adapter-test adapter-package java-lint java-lint-report java-lint-test manual-analysis evidence-validate framework-enrichment semantic-facts-spike fixture-test fixture-package spring-test download-jdtls jdtls-probe sootup-quarkus sootup-quarkus-full sootup-spring sootup-fsi codeql-quarkus codeql-spring codeql-fsi normalize-static-analysis adapter-test test: MAVEN_BIN="$$(bash spike/scripts/bootstrap-maven.sh)" && "$${MAVEN_BIN}" -B -ntp test -f pom.xml @@ -33,7 +33,7 @@ java-lint-report: "$${MAVEN_BIN}" -B -ntp pmd:pmd pmd:cpd -f pom.xml && \ "$${MAVEN_BIN}" -B -ntp org.projectlightpath:java-lint-maven-plugin:0.1.0:validate -DjavaLint.enforce=false -f pom.xml -spike-validate: +evidence-validate: python3 spike/scripts/validate_spike.py framework-enrichment: diff --git a/README.md b/README.md index d81ee1c..d337f53 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,13 @@ make test make package ``` -The `Adapter tests` CI lane runs `make adapter-test` and `make spike-validate` +The `Adapter tests` CI lane runs `make adapter-test` and `make evidence-validate` on pull requests and pushes to `main`. The separate `Java lint` CI lane runs the enforced production-quality gate with `make java-lint` and uploads the PMD/CPD reports as workflow artifacts, including when the gate fails. See [`docs/flpath-5002-java-adapter.md`](docs/flpath-5002-java-adapter.md) for -the implementation boundary. +the implementation boundary and pre-release Java API versioning policy. Run the enforced Java quality gate with: @@ -53,10 +53,10 @@ matrices, contracts, analyzer probes, scripts, and portable normalized results. The readiness report is in [`docs/flpath-5000-java-readiness.md`](docs/flpath-5000-java-readiness.md). -Validate the checked-in spike evidence with: +Validate the checked-in evidence contracts with: ```sh -make spike-validate +make evidence-validate ``` The Spring Boot baseline remains external and is not vendored. The ignored diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json index 8497379..b7f31c4 100644 --- a/config/java-lint-baseline.json +++ b/config/java-lint-baseline.json @@ -1,5 +1,5 @@ { - "capturedOn": "2026-11-11", + "capturedOn": "2026-11-12", "cpd": { "findings": [], "minimumTokens": 100, @@ -46,8 +46,8 @@ "class": "BoundedFrameworkModel", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java", - "fingerprint": "76b77b164603c48a5b79a8bb6b310dd1885088aac4d3512da35f5424b0dacee9", - "message": "Possible God Class (WMC=367, ATFD=59, TCC=6.816%)", + "fingerprint": "3eb67caa04e30dc4b4d3f1a951d4a705f1c6bfc94f8d74d0ab03c7f7dee3db5d", + "message": "Possible God Class (WMC=368, ATFD=60, TCC=6.646%)", "method": "", "rationale": "Reviewed maintainability baseline: framework enrichment intentionally centralizes route, injection, proxy, generated-source, and lifecycle policy; decomposition is future work and the behavior is covered by adapter tests.", "rule": "GodClass", @@ -57,8 +57,8 @@ "class": "BoundedFrameworkModel", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java", - "fingerprint": "4a15360d0a2967fbbe5c1072b2c4c5a1f4906f339f80fc25fe27517793203e77", - "message": "The class 'BoundedFrameworkModel' has a total cyclomatic complexity of 367 (highest 23).", + "fingerprint": "4f27384fd3f5ae8c59d7611df3a84aed0afdb35c224c4d7202e961ddcb7d6bd8", + "message": "The class 'BoundedFrameworkModel' has a total cyclomatic complexity of 368 (highest 23).", "method": "", "rationale": "Reviewed maintainability baseline: framework enrichment intentionally centralizes route, injection, proxy, generated-source, and lifecycle policy; decomposition is future work and the behavior is covered by adapter tests.", "rule": "CyclomaticComplexity", @@ -141,17 +141,6 @@ "rule": "NPathComplexity", "variable": "" }, - { - "class": "BoundedFrameworkModel", - "classification": "maintainability", - "file": "src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java", - "fingerprint": "5b64fdc4e97135fa514decb19272c193d0589e5abb82c3e622ab18c473ec833d", - "message": "The method 'springLifecycleFacts(AnalysisContext, SourceMetadataScanner)' has a cognitive complexity of 20, current threshold is 20", - "method": "springLifecycleFacts", - "rationale": "Reviewed maintainability baseline: framework enrichment intentionally centralizes route, injection, proxy, generated-source, and lifecycle policy; decomposition is future work and the behavior is covered by adapter tests.", - "rule": "CognitiveComplexity", - "variable": "" - }, { "class": "ManualAnalysisMain", "classification": "intentional", @@ -189,8 +178,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "282d5663e603a62b9f9cd8e1f68a01b46cbab6d1ed5fce6c47c7c1716dd0d407", - "message": "Possible God Class (WMC=1,378, ATFD=75, TCC=0.054%)", + "fingerprint": "9ea40331a2cb034c62173741f52bfd5c4ccd5556f2f5a6da50fdf1ceb14ab915", + "message": "Possible God Class (WMC=1,506, ATFD=90, TCC=0.049%)", "method": "", "rationale": "Reviewed maintainability baseline: the scanner is an intentionally source-backed Java parser with stateful comment, literal, delimiter, type, route, injection, and dynamic-feature handling; broad extraction risks parser regressions and is tracked separately.", "rule": "GodClass", @@ -200,8 +189,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "1fb90fabdb5aa8026d897b714924fe3328ec3b0986904373aab219379d3b65ee", - "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2616 (Highest = 68).", + "fingerprint": "d795a7d0072edb1c2a0cbad0e71c0b7f402ea9dc1b39aa25a6e1a711b8489b91", + "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2861 (Highest = 68).", "method": "", "rationale": "Reviewed maintainability baseline: the scanner is an intentionally source-backed Java parser with stateful comment, literal, delimiter, type, route, injection, and dynamic-feature handling; broad extraction risks parser regressions and is tracked separately.", "rule": "NcssCount", @@ -211,8 +200,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "d72bdb252b636c1c4f9ad4ed1e6c987c81019219862bf16c31a4213f1a232536", - "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1378 (highest 37).", + "fingerprint": "8a6cd91bba3f2e931bc8bb8ae8e4deb0d9daa1b8983cd6b4e07b5654a3aedb4f", + "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1506 (highest 37).", "method": "", "rationale": "Reviewed maintainability baseline: the scanner is an intentionally source-backed Java parser with stateful comment, literal, delimiter, type, route, injection, and dynamic-feature handling; broad extraction risks parser regressions and is tracked separately.", "rule": "CyclomaticComplexity", @@ -222,8 +211,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "056fd2ebdb9bd1b8f2a590a9f2a51b5f226944c297972d581f8314d34504fdb7", - "message": "The method 'routes(Framework)' has a cognitive complexity of 95, current threshold is 20", + "fingerprint": "9c74f4e47948351ce815051c1e309c97cc2bf86b09968d7d0b0f6efa421ab9b1", + "message": "The method 'routes(Framework)' has a cognitive complexity of 96, current threshold is 20", "method": "routes", "rationale": "Reviewed maintainability baseline: the scanner is an intentionally source-backed Java parser with stateful comment, literal, delimiter, type, route, injection, and dynamic-feature handling; broad extraction risks parser regressions and is tracked separately.", "rule": "CognitiveComplexity", @@ -244,8 +233,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "eb46135d37209826fa01fa79274ab80d9a61c9728308ff50308a554446af3ee8", - "message": "The method 'routes(Framework)' has an NPath complexity of 6648642, current threshold is 300", + "fingerprint": "b57a8abe64701a056707019999a6e4af63d6a0849f72627fadc659c75e59c159", + "message": "The method 'routes(Framework)' has an NPath complexity of 13297283, current threshold is 300", "method": "routes", "rationale": "Reviewed maintainability baseline: the scanner is an intentionally source-backed Java parser with stateful comment, literal, delimiter, type, route, injection, and dynamic-feature handling; broad extraction risks parser regressions and is tracked separately.", "rule": "NPathComplexity", @@ -607,8 +596,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "56ab896a60381fd41d7acc9874dd9a917cb9b11b88ace228eb2dce05cde31b11", - "message": "The method 'httpMethods(String, String)' has a cyclomatic complexity of 18.", + "fingerprint": "392cdb5839ea8c3b93fb6563495a87900591daaab436e68f314485d16c5a3e07", + "message": "The method 'httpMethods(SourceFile, String, String)' has a cyclomatic complexity of 18.", "method": "httpMethods", "rationale": "Reviewed maintainability baseline: the scanner is an intentionally source-backed Java parser with stateful comment, literal, delimiter, type, route, injection, and dynamic-feature handling; broad extraction risks parser regressions and is tracked separately.", "rule": "CyclomaticComplexity", @@ -618,8 +607,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "93f77831d91235771cfbc8299f46429a76615354390ae0f6b6a2bc20cce6ae21", - "message": "The method 'typeHeader(SourceFile)' has a cyclomatic complexity of 15.", + "fingerprint": "77601f3b5f568ad6a7629e8ca4cb3201edd78b31606ac767e94fa0b7fb565f36", + "message": "The method 'typeHeader(SourceFile, String)' has a cyclomatic complexity of 17.", "method": "typeHeader", "rationale": "Reviewed maintainability baseline: the scanner is an intentionally source-backed Java parser with stateful comment, literal, delimiter, type, route, injection, and dynamic-feature handling; broad extraction risks parser regressions and is tracked separately.", "rule": "CyclomaticComplexity", @@ -640,8 +629,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "a5c6eeff1738a6792f5a087f216a3a49954d80e086fd44aea60d53b758192001", - "message": "The method 'qualifierNames(String)' has a cognitive complexity of 33, current threshold is 20", + "fingerprint": "afdbcd3a0fd3f8b4f9db768da99799f5dc5c8b5a5a4d936233a1c0837e9664e9", + "message": "The method 'qualifierNames(SourceFile, String)' has a cognitive complexity of 36, current threshold is 20", "method": "qualifierNames", "rationale": "Reviewed maintainability baseline: the scanner is an intentionally source-backed Java parser with stateful comment, literal, delimiter, type, route, injection, and dynamic-feature handling; broad extraction risks parser regressions and is tracked separately.", "rule": "CognitiveComplexity", @@ -651,13 +640,24 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "08be2bbc27f357b5512b3c47b7feffa8f5d3c3cb47d05fcaddee2d6add55d2d3", - "message": "The method 'qualifierNames(String)' has a cyclomatic complexity of 17.", + "fingerprint": "3a500e26a12b851259b171dcfddd11ed4ce71b67150dd1a0616ec347d0885b08", + "message": "The method 'qualifierNames(SourceFile, String)' has a cyclomatic complexity of 20.", "method": "qualifierNames", "rationale": "Reviewed maintainability baseline: the scanner is an intentionally source-backed Java parser with stateful comment, literal, delimiter, type, route, injection, and dynamic-feature handling; broad extraction risks parser regressions and is tracked separately.", "rule": "CyclomaticComplexity", "variable": "" }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "6e9c975e57f810d09e842a2b08be5bcc1442e5c925a64b23b5de0d25e0ab153c", + "message": "The method 'qualifierNames(SourceFile, String)' has an NPath complexity of 890, current threshold is 300", + "method": "qualifierNames", + "rationale": "Reviewed maintainability baseline: the scanner is an intentionally source-backed Java parser with stateful comment, literal, delimiter, type, route, injection, and dynamic-feature handling; broad extraction risks parser regressions and is tracked separately.", + "rule": "NPathComplexity", + "variable": "" + }, { "class": "SourceMetadataScanner", "classification": "maintainability", @@ -756,6 +756,39 @@ "rationale": "Reviewed maintainability baseline: the scanner is an intentionally source-backed Java parser with stateful comment, literal, delimiter, type, route, injection, and dynamic-feature handling; broad extraction risks parser regressions and is tracked separately.", "rule": "NPathComplexity", "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "23df838dee3ab6913395bce5b1dcbfeccbeb17d6a3f5b5e8c09ded1a68f1e2f1", + "message": "The method 'beanCandidates(Framework)' has a cognitive complexity of 53, current threshold is 20", + "method": "beanCandidates", + "rationale": "Reviewed maintainability baseline for FLPATH-5077: repository-interface classification now preserves annotated Spring Data candidates while the existing scanner intentionally centralizes bounded source parsing; focused repository and lifecycle regressions cover the behavior.", + "rule": "CognitiveComplexity", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "c2619061c39dc23e1c7ab277ae4ffbf1569ccfee8e3c2b8ac4bc709cf5b09474", + "message": "The method 'beanCandidates(Framework)' has a cyclomatic complexity of 20.", + "method": "beanCandidates", + "rationale": "Reviewed maintainability baseline for FLPATH-5077: repository-interface classification now preserves annotated Spring Data candidates while the existing scanner intentionally centralizes bounded source parsing; focused repository and lifecycle regressions cover the behavior.", + "rule": "CyclomaticComplexity", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "1441640aa465d43ad537ae3b003ca11fe39af1a0aeb906c3222bba978d51af44", + "message": "The method 'beanCandidates(Framework)' has an NPath complexity of 203743, current threshold is 300", + "method": "beanCandidates", + "rationale": "Reviewed maintainability baseline for FLPATH-5077: repository-interface classification now preserves annotated Spring Data candidates while the existing scanner intentionally centralizes bounded source parsing; focused repository and lifecycle regressions cover the behavior.", + "rule": "NPathComplexity", + "variable": "" } ], "minimumPriority": 3, diff --git a/docs/flpath-5002-java-adapter.md b/docs/flpath-5002-java-adapter.md index 07d7aa8..d760d09 100644 --- a/docs/flpath-5002-java-adapter.md +++ b/docs/flpath-5002-java-adapter.md @@ -35,6 +35,29 @@ a unique non-abstained provider target identity. Links use `enriches_without_replacing`, preserving separate provider and framework fact sets. +## Java API versioning + +The Maven module is currently `0.1.0-SNAPSHOT` and has no production release. +The public Java API, including `SemanticCallFacts`, `Fact.FrameworkInput`, and +`RunMetadata`, is therefore pre-release and has no source or binary backwards- +compatibility guarantee. Breaking API changes are allowed before the first +production release when the adapter tests and handoff evidence are updated in +the same change. + +This policy covers the intentional FLPATH-5076 API change: + +- positional `Fact.framework(...)` arguments were replaced by the named + `Fact.FrameworkInput` value; +- public positional `RunMetadata` constructors were replaced by + `RunMetadata.from(AnalysisContext, AnalysisGraph)`; and +- run metadata now derives immutable source-root information from the analysis + context and provider graph. + +No japicmp or Revapi comparison is configured because there is no released +artifact to use as a compatibility baseline. Before the first production +release, publish a stable version and add a dedicated compatibility policy and +check if downstream Java consumers require source or binary compatibility. + Route scanning expands literal class/method path arrays, HTTP method arrays, and Spring header/parameter/media conditions. Quarkus routes require a class-level `@Path`; method-level `@Path` narrows that root and an unregistered method-only diff --git a/docs/java-lint.md b/docs/java-lint.md index 018a2a2..a31b0d1 100644 --- a/docs/java-lint.md +++ b/docs/java-lint.md @@ -129,12 +129,17 @@ broad exclusion merely to make the count zero. ## CI and limitations -The CI workflow keeps adapter tests, spike validation, and Java lint in +The CI workflow keeps adapter tests, captured-evidence validation, and Java lint in separate jobs. The lint job uploads PMD/CPD reports even when the enforced gate fails, so a failure remains inspectable. The gate does not prove semantic API compatibility, runtime behavior, security -correctness, or absence of reflective/source-text usage. Public API evolution -should eventually be checked with a dedicated japicmp or Revapi comparison. +correctness, or absence of reflective/source-text usage. This module is still +`0.1.0-SNAPSHOT` with no production release, so its public Java API has no +source or binary backwards-compatibility guarantee; see +[`docs/flpath-5002-java-adapter.md`](flpath-5002-java-adapter.md) for the +intentional API policy and the pre-release `SemanticCallFacts` changes. A +japicmp or Revapi comparison becomes appropriate once a released artifact +exists as a compatibility baseline. CPD and PMD metrics are evidence for review, not proof that code is defective or machine-generated. diff --git a/spike/README.md b/spike/README.md index 35bd772..f8fa99c 100644 --- a/spike/README.md +++ b/spike/README.md @@ -69,8 +69,8 @@ The authoritative environment is Java 21. Maven 3.9.11 is bootstrapped into a cache when it is not already installed. ```sh -# Validate contracts without downloading dependencies. -make spike-validate +# Validate captured contracts without downloading dependencies. +make evidence-validate # Apply the spike-only FrameworkModel to both source fixtures and emit # synthetic framework facts. diff --git a/src/main/java/com/projectlightpath/analysis/java/AnalysisContext.java b/src/main/java/com/projectlightpath/analysis/java/AnalysisContext.java index 7e6a78b..7db1f9a 100644 --- a/src/main/java/com/projectlightpath/analysis/java/AnalysisContext.java +++ b/src/main/java/com/projectlightpath/analysis/java/AnalysisContext.java @@ -2,6 +2,7 @@ import java.nio.file.Path; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -555,16 +556,34 @@ public Optional metadata(String metadataId) { /** Return a stable path relative to the selected source root for provenance. */ public String displayPath(Path file) { Path normalized = file.toAbsolutePath().normalize(); - for (SourceRoot root : includedSourceRoots()) { - if (normalized.startsWith(root.path())) { - Path relative = root.path().relativize(normalized); - String suffix = relative.toString().replace(normalized.getFileSystem().getSeparator(), "/"); - return suffix.isEmpty() ? root.declaredPath() : root.declaredPath() + "/" + suffix; - } + Optional root = mostSpecificRoot(normalized, includedSourceRoots()); + if (root.isPresent()) { + Path relative = root.get().path().relativize(normalized); + String suffix = relative.toString().replace(normalized.getFileSystem().getSeparator(), "/"); + return suffix.isEmpty() ? root.get().declaredPath() : root.get().declaredPath() + "/" + suffix; } return normalized.toString(); } + /** Orders overlapping roots from most-specific to least-specific provenance. */ + static List rootsBySpecificity(List roots) { + return roots.stream() + .sorted(Comparator + .comparingInt((SourceRoot root) -> root.path().getNameCount()) + .reversed() + .thenComparing(SourceRoot::generated, Comparator.reverseOrder()) + .thenComparing(SourceRoot::declaredPath)) + .toList(); + } + + /** Finds the most-specific root containing one normalized source path. */ + static Optional mostSpecificRoot(Path file, List roots) { + Path normalized = file.toAbsolutePath().normalize(); + return rootsBySpecificity(roots).stream() + .filter(root -> normalized.startsWith(root.path())) + .findFirst(); + } + private static String requireValue(String value, String name) { if (value == null || value.isBlank()) { throw new IllegalArgumentException(name + " must not be blank"); diff --git a/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java b/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java index dad542f..6c83ecd 100644 --- a/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java +++ b/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java @@ -610,7 +610,7 @@ private List generatedFacts( generated.file().displayPath(), List.of(generated.file().simpleTypeName() + "#" + generated.method().name()), generated.method().line(), - Map.of("sourceRootRequired", "true"))), + generatedRootAttributes(context, generated))), certainty == SemanticCallFacts.Certainty.DEFINITE ? null : "Generated source was found, but the selected generated-source metadata is incomplete."))); @@ -668,23 +668,37 @@ private List springRepositoryFacts( SemanticCallFacts.Certainty certainty = metadataAvailable ? SemanticCallFacts.Certainty.POSSIBLE : SemanticCallFacts.Certainty.UNRESOLVED; - String id = springRepositoryFactId(scanner, candidates, candidate); - facts.add(SemanticCallFacts.Fact.framework( - id, - context, - new SemanticCallFacts.Fact.FrameworkInput( + String repositoryContract = scanner.springRepositoryContract(candidate.file()).orElse("Repository"); + for (SourceMetadataScanner.RepositoryMethod repositoryMethod + : scanner.springRepositoryMethods(candidate.file())) { + String methodName = repositoryMethod.name(); + String methodIdentity = repositoryMethod.identity(); + String id = springRepositoryFactId(scanner, candidates, candidate, methodIdentity); + List symbols = new ArrayList<>(List.of("extends " + repositoryContract)); + symbols.add(repositoryMethod.guaranteed() + ? "CrudRepository#" + methodName + : "declared " + methodIdentity); + symbols.add(methodName); + facts.add(SemanticCallFacts.Fact.framework( + id, + context, + new SemanticCallFacts.Fact.FrameworkInput( "spring-data-proxy:" + candidate.file().typeName(), - candidate.file().typeName() + "#findById", - "generated_implementation", - certainty, - "spring-data-generated-repository", - List.of(new SemanticCallFacts.Evidence( - "repository_interface", - candidate.file().displayPath(), - List.of("extends JpaRepository", "CrudRepository#findById"), - null, - Map.of("generatedImplementation", "outside application source"))), - "Spring Data implementation and proxy identity are generated outside application source."))); + candidate.file().typeName() + "#" + methodIdentity, + "generated_implementation", + certainty, + "spring-data-generated-repository", + List.of(new SemanticCallFacts.Evidence( + "repository_interface", + candidate.file().displayPath(), + List.copyOf(new LinkedHashSet<>(symbols)), + null, + Map.of( + "generatedImplementation", "outside application source", + "repositoryContract", repositoryContract, + "repositoryMethod", methodIdentity))), + "Spring Data implementation and proxy identity are generated outside application source."))); + } } return facts; } @@ -697,57 +711,62 @@ private List springLifecycleFacts( SemanticCallFacts.Certainty certainty = sourceMetadataAvailable ? SemanticCallFacts.Certainty.POSSIBLE : SemanticCallFacts.Certainty.UNRESOLVED; - for (SourceMetadataScanner.SourceFile file : scanner.files()) { - if (file.source().contains("@Configuration")) { - for (SourceMetadataScanner.MethodInfo method : SourceMetadataScanner.methods(file)) { - if (!method.hasAnnotation("Bean")) { - continue; - } - facts.add(SemanticCallFacts.Fact.framework( - lifecycleFactId(scanner, file, method, "bean"), - context, - new SemanticCallFacts.Fact.FrameworkInput( - "Spring ApplicationContext", - file.typeName() + "#" + method.name(), - "lifecycle_callback", - certainty, - "spring-lifecycle-callback", - List.of(new SemanticCallFacts.Evidence( - "bean_factory_annotation", - file.displayPath(), - List.of("@Configuration", "@Bean " + method.name()), - method.line(), - Map.of())), - "Conditional configuration and exact ApplicationContext construction are not captured."))); - } - } - if (file.source().matches("(?s).*\\bimplements\\s+CommandLineRunner\\b.*")) { - for (SourceMetadataScanner.MethodInfo method : SourceMetadataScanner.methods(file)) { - if (!method.name().equals("run")) { - continue; - } - facts.add(SemanticCallFacts.Fact.framework( - lifecycleFactId(scanner, file, method, "runner"), - context, - new SemanticCallFacts.Fact.FrameworkInput( - "Spring Boot application lifecycle", - file.typeName() + "#run", - "lifecycle_callback", - certainty, - "spring-lifecycle-callback", - List.of(new SemanticCallFacts.Evidence( - "lifecycle_interface", - file.displayPath(), - List.of("@Component", "implements CommandLineRunner", "run(String...)"), - method.line(), - Map.of())), - "Startup ordering and conditional bean activation are not captured."))); - } + for (SourceMetadataScanner.LifecycleMethod lifecycle : scanner.springLifecycleMethods()) { + SourceMetadataScanner.SourceFile file = lifecycle.file(); + SourceMetadataScanner.MethodInfo method = lifecycle.method(); + String declaringType = lifecycle.declaringType(); + boolean runner = lifecycle.kind().equals("runner"); + List symbols = new ArrayList<>(lifecycle.annotationSymbols()); + if (!runner) { + symbols.add("@Bean " + method.name()); + } else { + symbols.add("implements CommandLineRunner"); + symbols.add("run(String...)"); } + facts.add(SemanticCallFacts.Fact.framework( + lifecycleFactId(scanner, file, method, declaringType, lifecycle.kind()), + context, + new SemanticCallFacts.Fact.FrameworkInput( + runner ? "Spring Boot application lifecycle" : "Spring ApplicationContext", + declaringType + "#" + method.name(), + "lifecycle_callback", + certainty, + "spring-lifecycle-callback", + List.of(new SemanticCallFacts.Evidence( + runner ? "lifecycle_interface" : "bean_factory_annotation", + file.displayPath(), + List.copyOf(new LinkedHashSet<>(symbols)), + method.line(), + Map.of())), + runner + ? "Startup ordering and conditional bean activation are not captured." + : "Conditional configuration and exact ApplicationContext construction are not captured."))); } return facts; } + + /** Carries the selected generated root and its handoff fingerprint into source evidence. */ + private static Map generatedRootAttributes( + AnalysisContext context, + SourceMetadataScanner.GeneratedMethod generated) { + Map attributes = new HashMap<>(); + Optional root = AnalysisContext.mostSpecificRoot( + generated.file().path(), context.includedSourceRoots().stream() + .filter(AnalysisContext.SourceRoot::generated) + .toList()); + attributes.put("sourceRootRequired", root.map(item -> Boolean.toString(item.required())).orElse("true")); + root.ifPresent(item -> { + attributes.put("sourceRoot", item.declaredPath()); + attributes.put("sourceRootIncluded", Boolean.toString(item.included())); + attributes.put("sourceRootScope", item.scope().id()); + if (item.fingerprint() != null && !item.fingerprint().isBlank()) { + attributes.put("sourceRootFingerprint", item.fingerprint()); + } + }); + return Map.copyOf(attributes); + } + /** Filters candidates by requested framework qualifier semantics. */ private List qualifierMatches( AnalysisContext context, @@ -1724,8 +1743,10 @@ && dynamicTargetMethod(feature.target()).equals("onAuthenticated") private String springRepositoryFactId( SourceMetadataScanner scanner, List candidates, - SourceMetadataScanner.BeanCandidate candidate) { - boolean uniqueLegacyRepository = candidate.file().simpleTypeName().equals("AccountRepository") + SourceMetadataScanner.BeanCandidate candidate, + String repositoryMethod) { + boolean uniqueLegacyRepository = repositoryMethod.equals("findById") + && candidate.file().simpleTypeName().equals("AccountRepository") && uniqueSimpleType(scanner, candidate.file().simpleTypeName()) && candidates.stream() .filter(item -> item.kind().equals("repository_interface")) @@ -1744,9 +1765,9 @@ && uniqueSimpleType(scanner, candidate.file().simpleTypeName()) candidate.candidateId(), candidate.candidateType(), candidate.kind(), - "findById"); + repositoryMethod); return "spring-data-generated-" - + slug(candidate.file().typeName() + "-findById") + + slug(candidate.file().typeName() + "-" + repositoryMethod) + "-" + digest(identity).substring(0, 16); } @@ -1801,6 +1822,7 @@ private String lifecycleFactId( SourceMetadataScanner scanner, SourceMetadataScanner.SourceFile file, SourceMetadataScanner.MethodInfo method, + String declaringType, String kind) { if (file.simpleTypeName().equals("SecurityConfig") && method.name().equals("securityFilterChain") @@ -1815,14 +1837,14 @@ && uniqueLifecycleMethod(scanner, file, method)) { String identity = String.join("\u0000", framework.id(), kind, - file.typeName(), + declaringType, file.displayPath(), method.name(), method.parameters().replaceAll("\\s+", " ").trim(), method.returnType(), Integer.toString(method.start())); return "spring-lifecycle-" + kind + "-" - + slug(file.typeName() + "-" + method.name()) + + slug(declaringType + "-" + method.name()) + "-" + digest(identity).substring(0, 16); } diff --git a/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java b/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java index 8734a8d..7981114 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java +++ b/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java @@ -141,7 +141,8 @@ private static List applicationCalls( private static String sourcePath(AnalysisContext context, String owner) { String topLevel = owner.contains("$") ? owner.substring(0, owner.indexOf('$')) : owner; String relative = topLevel.replace('.', '/') + ".java"; - for (AnalysisContext.SourceRoot root : context.includedSourceRoots()) { + for (AnalysisContext.SourceRoot root : + AnalysisContext.rootsBySpecificity(context.includedSourceRoots())) { Path candidate = root.path().resolve(relative); if (Files.isRegularFile(candidate)) { return context.displayPath(candidate); diff --git a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java index 966e85f..0229899 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java +++ b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java @@ -34,6 +34,8 @@ final class SourceMetadataScanner { private static final Pattern NEW_EXPRESSION = Pattern.compile("\\bnew\\b"); private static final Pattern METHOD = Pattern.compile( "(?m)(?:^|[;{}\\n])\\s*(?:(?:@[A-Za-z_][\\w.]*(?:\\([^)]*\\))?\\s*)|(?:(?:public|protected|private|static|final|synchronized|abstract|native|default|strictfp)\\s+))*(?:<[^>{}]+>\\s+)?[\\w.?$<>\\[\\], ]+\\s+(\\w+)\\s*\\(([^)]*)\\)\\s*(?:throws [^{;]+)?(?:\\{|;)"); + private static final Pattern METHOD_BODY_STATEMENT = Pattern.compile( + "^(?:return|throw|if|for|while|switch|catch|try|synchronized|new|assert|case|else|do)\\b.*"); private static final Pattern QUOTED = Pattern.compile("\\\"([^\\\"]*)\\\""); private static final Pattern CLASS_FOR_NAME = Pattern.compile( "(? CDI_PRODUCER_ANNOTATIONS = Set.of( "jakarta.enterprise.inject.Produces", "javax.enterprise.inject.Produces"); - private static final Set SPRING_PROXY_ANNOTATIONS = Set.of( - "Transactional", "Async", "Cacheable", "CachePut", "CacheEvict", "PreAuthorize", - "PostAuthorize", "Secured", "RolesAllowed", "Retryable", "CircuitBreaker", "Validated", - "TransactionalEventListener"); + private static final Map> SPRING_ROUTE_ANNOTATION_TYPES = Map.of( + "RequestMapping", List.of("org.springframework.web.bind.annotation.RequestMapping"), + "GetMapping", List.of("org.springframework.web.bind.annotation.GetMapping"), + "PostMapping", List.of("org.springframework.web.bind.annotation.PostMapping"), + "PutMapping", List.of("org.springframework.web.bind.annotation.PutMapping"), + "PatchMapping", List.of("org.springframework.web.bind.annotation.PatchMapping"), + "DeleteMapping", List.of("org.springframework.web.bind.annotation.DeleteMapping"), + "RestController", List.of("org.springframework.web.bind.annotation.RestController")); + private static final Map> SPRING_PROXY_ANNOTATION_TYPES = Map.ofEntries( + Map.entry("Transactional", List.of( + "org.springframework.transaction.annotation.Transactional", + "jakarta.transaction.Transactional", + "javax.transaction.Transactional")), + Map.entry("Async", List.of("org.springframework.scheduling.annotation.Async")), + Map.entry("Cacheable", List.of("org.springframework.cache.annotation.Cacheable")), + Map.entry("CachePut", List.of("org.springframework.cache.annotation.CachePut")), + Map.entry("CacheEvict", List.of("org.springframework.cache.annotation.CacheEvict")), + Map.entry("PreAuthorize", List.of("org.springframework.security.access.prepost.PreAuthorize")), + Map.entry("PostAuthorize", List.of("org.springframework.security.access.prepost.PostAuthorize")), + Map.entry("Secured", List.of("org.springframework.security.access.annotation.Secured")), + Map.entry("RolesAllowed", List.of( + "jakarta.annotation.security.RolesAllowed", + "javax.annotation.security.RolesAllowed")), + Map.entry("Retryable", List.of("org.springframework.retry.annotation.Retryable")), + Map.entry("CircuitBreaker", List.of( + "io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker")), + Map.entry("Validated", List.of("org.springframework.validation.annotation.Validated")), + Map.entry("TransactionalEventListener", List.of( + "org.springframework.transaction.event.TransactionalEventListener"))); + private static final Map> SPRING_BEAN_ANNOTATION_TYPES = Map.ofEntries( + Map.entry("Service", List.of("org.springframework.stereotype.Service")), + Map.entry("Component", List.of("org.springframework.stereotype.Component")), + Map.entry("Repository", List.of("org.springframework.stereotype.Repository")), + Map.entry("Configuration", List.of("org.springframework.context.annotation.Configuration")), + Map.entry("Controller", List.of("org.springframework.stereotype.Controller")), + Map.entry("RestController", List.of("org.springframework.web.bind.annotation.RestController")), + Map.entry("Named", List.of("jakarta.inject.Named", "javax.inject.Named")), + Map.entry("Bean", List.of("org.springframework.context.annotation.Bean")), + Map.entry("Qualifier", List.of("org.springframework.beans.factory.annotation.Qualifier")), + Map.entry("Resource", List.of("jakarta.annotation.Resource", "javax.annotation.Resource")), + Map.entry("Primary", List.of("org.springframework.context.annotation.Primary")), + Map.entry("Profile", List.of("org.springframework.context.annotation.Profile")), + Map.entry("Conditional", List.of("org.springframework.context.annotation.Conditional")), + Map.entry("ConditionalOnBean", List.of( + "org.springframework.boot.autoconfigure.condition.ConditionalOnBean")), + Map.entry("ConditionalOnMissingBean", List.of( + "org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean")), + Map.entry("ConditionalOnClass", List.of( + "org.springframework.boot.autoconfigure.condition.ConditionalOnClass")), + Map.entry("ConditionalOnMissingClass", List.of( + "org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass")), + Map.entry("ConditionalOnProperty", List.of( + "org.springframework.boot.autoconfigure.condition.ConditionalOnProperty")), + Map.entry("ConditionalOnExpression", List.of( + "org.springframework.boot.autoconfigure.condition.ConditionalOnExpression")), + Map.entry("Lazy", List.of("org.springframework.context.annotation.Lazy"))); + private static final Set SPRING_PROXY_ANNOTATIONS = + Set.copyOf(SPRING_PROXY_ANNOTATION_TYPES.keySet()); + private static final String SPRING_COMMAND_LINE_RUNNER = + "org.springframework.boot.CommandLineRunner"; + private static final Map SPRING_DATA_REPOSITORY_CONTRACTS = Map.of( + "org.springframework.data.repository.Repository", "Repository", + "org.springframework.data.repository.CrudRepository", "CrudRepository", + "org.springframework.data.jpa.repository.JpaRepository", "JpaRepository", + "org.springframework.data.repository.PagingAndSortingRepository", "PagingAndSortingRepository", + "org.springframework.data.repository.ListCrudRepository", "ListCrudRepository", + "org.springframework.data.repository.ListPagingAndSortingRepository", "ListPagingAndSortingRepository"); + private static final Set SPRING_DATA_FIND_BY_ID_CONTRACTS = Set.of( + "CrudRepository", "JpaRepository", "PagingAndSortingRepository", "ListCrudRepository", + "ListPagingAndSortingRepository"); private static final List SUPPORTED_HTTP_METHODS = List.of("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"); @@ -173,8 +241,9 @@ List files() { if (files != null) { return files; } - List result = new ArrayList<>(); - for (AnalysisContext.SourceRoot root : context.includedSourceRoots()) { + Map selectedRoots = new LinkedHashMap<>(); + for (AnalysisContext.SourceRoot root : + AnalysisContext.rootsBySpecificity(context.includedSourceRoots())) { if (!Files.isDirectory(root.path())) { continue; } @@ -182,12 +251,15 @@ List files() { paths.filter(Files::isRegularFile) .filter(path -> path.toString().endsWith(".java")) .sorted() - .map(path -> read(path, root)) - .forEach(result::add); + .forEach(path -> selectedRoots.putIfAbsent(path.toAbsolutePath().normalize(), root)); } catch (IOException error) { throw new IllegalStateException("cannot scan source root " + root.path(), error); } } + List result = new ArrayList<>(selectedRoots.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> read(entry.getKey(), entry.getValue())) + .toList()); result.sort(Comparator.comparing(SourceFile::displayPath)); files = List.copyOf(result); return files; @@ -406,6 +478,21 @@ Optional resolveReferenceType(String type, SourceFile sourceFile) { : Optional.empty(); } + /** Resolves a source type with lexical nested-type precedence at one source offset. */ + private Optional resolveReferenceType(String type, SourceFile sourceFile, int siteOffset) { + String normalized = stripBalancedGenericArguments(type == null ? "" : type).trim(); + if (normalized.endsWith("...")) { + normalized = normalized.substring(0, normalized.length() - 3) + "[]"; + } + if (normalized.endsWith("[]")) { + return resolveReferenceType(normalized.substring(0, normalized.length() - 2), sourceFile, siteOffset) + .map(value -> value + "[]"); + } + String reference = normalized; + return resolveOwnerType(reference, sourceFile, siteOffset) + .or(() -> resolveReferenceType(reference, sourceFile)); + } + /** Resolves a contract while ignoring annotation imports that share its simple name. */ Optional resolveContractType(String type, SourceFile sourceFile) { String normalized = stripBalancedGenericArguments(type == null ? "" : type).trim(); @@ -511,18 +598,19 @@ List routes(AnalysisContext.Framework framework) { for (SourceFile file : files()) { String classBlock = file.classAnnotationBlock(); boolean quarkusClassPath = hasAnnotation(classBlock, "Path"); - boolean springController = hasAnnotation(classBlock, "RestController"); + boolean springController = framework == AnalysisContext.Framework.SPRING_BOOT + && canonicalSpringAnnotation(file, classBlock, "RestController"); if (framework == AnalysisContext.Framework.SPRING_BOOT && !springController) { continue; } List classPaths = framework == AnalysisContext.Framework.QUARKUS ? pathValues(classBlock, "Path") - : mappingPaths(classBlock, "RequestMapping"); + : mappingPaths(file, classBlock, "RequestMapping"); if (framework == AnalysisContext.Framework.QUARKUS && quarkusClassPath && classPaths.isEmpty()) { continue; } if (framework == AnalysisContext.Framework.SPRING_BOOT - && hasAnnotation(classBlock, "RequestMapping") + && canonicalSpringAnnotation(file, classBlock, "RequestMapping") && classPaths.isEmpty()) { continue; } @@ -530,12 +618,12 @@ && hasAnnotation(classBlock, "RequestMapping") classPaths = List.of(""); } List classHttpMethods = framework == AnalysisContext.Framework.SPRING_BOOT - ? httpMethods(classBlock, "RequestMapping") + ? httpMethods(file, classBlock, "RequestMapping") : SUPPORTED_HTTP_METHODS; for (MethodInfo method : methods(file)) { - for (String annotationName : routeAnnotations(method.annotationBlock(), framework)) { + for (String annotationName : routeAnnotations(file, method.annotationBlock(), framework)) { List httpMethods = intersectHttpMethods( - httpMethods(method.annotationBlock(), annotationName), classHttpMethods); + httpMethods(file, method.annotationBlock(), annotationName), classHttpMethods); if (httpMethods.isEmpty()) { continue; } @@ -543,13 +631,13 @@ && hasAnnotation(classBlock, "RequestMapping") && hasAnnotation(method.annotationBlock(), "Path"); List methodPaths = framework == AnalysisContext.Framework.QUARKUS ? pathValues(method.annotationBlock(), "Path") - : mappingPaths(method.annotationBlock(), annotationName); + : mappingPaths(file, method.annotationBlock(), annotationName); if (framework == AnalysisContext.Framework.QUARKUS && methodPathAnnotation && methodPaths.isEmpty()) { continue; } if (framework == AnalysisContext.Framework.SPRING_BOOT && methodPaths.isEmpty() - && hasExplicitMappingPath(method.annotationBlock(), annotationName)) { + && hasExplicitMappingPath(file, method.annotationBlock(), annotationName)) { continue; } if (methodPaths.isEmpty()) { @@ -561,26 +649,26 @@ && hasExplicitMappingPath(method.annotationBlock(), annotationName)) { List consumes = overrideValues( framework == AnalysisContext.Framework.QUARKUS ? mediaValues(classBlock, "Consumes", null) - : mediaValues(classBlock, "RequestMapping", "consumes"), + : mediaValues(file, classBlock, "RequestMapping", "consumes"), framework == AnalysisContext.Framework.QUARKUS ? mediaValues(method.annotationBlock(), "Consumes", null) - : mediaValues(method.annotationBlock(), annotationName, "consumes")); + : mediaValues(file, method.annotationBlock(), annotationName, "consumes")); List produces = overrideValues( framework == AnalysisContext.Framework.QUARKUS ? mediaValues(classBlock, "Produces", null) - : mediaValues(classBlock, "RequestMapping", "produces"), + : mediaValues(file, classBlock, "RequestMapping", "produces"), framework == AnalysisContext.Framework.QUARKUS ? mediaValues(method.annotationBlock(), "Produces", null) - : mediaValues(method.annotationBlock(), annotationName, "produces")); + : mediaValues(file, method.annotationBlock(), annotationName, "produces")); List headers = framework == AnalysisContext.Framework.SPRING_BOOT ? mergeValues( - values(classBlock, "RequestMapping", "headers"), - values(method.annotationBlock(), annotationName, "headers")) + values(file, classBlock, "RequestMapping", "headers"), + values(file, method.annotationBlock(), annotationName, "headers")) : List.of(); List parameters = framework == AnalysisContext.Framework.SPRING_BOOT ? mergeValues( - values(classBlock, "RequestMapping", "params"), - values(method.annotationBlock(), annotationName, "params")) + values(file, classBlock, "RequestMapping", "params"), + values(file, method.annotationBlock(), annotationName, "params")) : List.of(); for (String httpMethod : httpMethods) { for (String classPath : classPaths) { @@ -599,7 +687,7 @@ && hasExplicitMappingPath(method.annotationBlock(), annotationName)) { produces, headers, parameters, - routeSymbols(classBlock, method.annotationBlock(), framework, annotationName))); + routeSymbols(file, classBlock, method.annotationBlock(), framework, annotationName))); } } } @@ -615,7 +703,7 @@ List injectionPoints(AnalysisContext.Framework framework) { for (SourceFile file : files()) { boolean component = framework == AnalysisContext.Framework.QUARKUS ? cdiBean(file.classAnnotationBlock()) - : springComponent(file.classAnnotationBlock()); + : springComponent(file); Pattern constructor = Pattern.compile( "(?s)((?:(?:@[A-Za-z_][\\w.]*(?:\\([^)]*\\))?\\s*)*))" + "(?:public|protected|private)?\\s*" @@ -640,7 +728,8 @@ List injectionPoints(AnalysisContext.Framework framework) { if (!selected) { continue; } - List parsedParameters = parameters(constructorInfo.parameters()); + List parsedParameters = parameters( + framework, file, constructorInfo.parameters()); if (!parsedParameters.isEmpty()) { result.add(new InjectionPoint( file, @@ -681,7 +770,7 @@ && hasAnnotation(file.classAnnotationBlock(), "Path"))) { || !hasInjectionAnnotation(method.annotationBlock())) { continue; } - List parsedParameters = parameters(method.parameters()); + List parsedParameters = parameters(framework, file, method.parameters()); if (!parsedParameters.isEmpty()) { List methodQualifiers = injectionQualifiers( method.annotationBlock(), method.name(), framework == AnalysisContext.Framework.QUARKUS); @@ -706,23 +795,28 @@ List beanCandidates(AnalysisContext.Framework framework) { List result = new ArrayList<>(); for (SourceFile file : files()) { String block = file.classAnnotationBlock(); + boolean repositoryInterface = framework == AnalysisContext.Framework.SPRING_BOOT + && springRepositoryContract(file).isPresent(); boolean bean = framework == AnalysisContext.Framework.QUARKUS ? cdiBean(block) - : springComponent(block) || file.structuralSource().matches( - "(?s).*\\binterface\\s+\\w+\\s+extends\\s+[^;]*\\b(?:JpaRepository|Repository)\\b.*"); + : springComponent(file) || repositoryInterface; if (bean) { Set contracts = contracts(file); result.add(new BeanCandidate( file, contracts, - beanKind(block, framework), + repositoryInterface ? "repository_interface" : beanKind(file, block, framework), file.typeName(), file.typeName(), - qualifierNames(block), - profileValues(block), - conditionAnnotations(block), + framework == AnalysisContext.Framework.QUARKUS + ? qualifierNames(block) : qualifierNames(file, block), + framework == AnalysisContext.Framework.QUARKUS + ? profileValues(block) : profileValues(file, block), + framework == AnalysisContext.Framework.QUARKUS + ? conditionAnnotations(block) : conditionAnnotations(file, block), hasAnnotation(block, "Alternative"), - hasAnnotation(block, "Primary"), + framework == AnalysisContext.Framework.QUARKUS + ? hasAnnotation(block, "Primary") : canonicalSpringAnnotation(file, block, "Primary"), null, null, null)); @@ -731,7 +825,7 @@ List beanCandidates(AnalysisContext.Framework framework) { for (MethodInfo method : methods(file)) { boolean producer = framework == AnalysisContext.Framework.QUARKUS ? cdiProducerAnnotation(file, method.annotationBlock()) - : method.hasAnnotation("Bean"); + : springBeanAnnotation(file, method.annotationBlock()); if (!producer || method.returnType().isBlank()) { continue; } @@ -744,11 +838,27 @@ List beanCandidates(AnalysisContext.Framework framework) { "producer", candidateId, producerType, - mergeValues(qualifierNames(block), qualifierNames(method.annotationBlock())), - mergeValues(profileValues(block), profileValues(method.annotationBlock())), - mergeValues(conditionAnnotations(block), conditionAnnotations(method.annotationBlock())), + mergeValues( + framework == AnalysisContext.Framework.QUARKUS + ? qualifierNames(block) : qualifierNames(file, block), + framework == AnalysisContext.Framework.QUARKUS + ? qualifierNames(method.annotationBlock()) : qualifierNames(file, method.annotationBlock())), + mergeValues( + framework == AnalysisContext.Framework.QUARKUS + ? profileValues(block) : profileValues(file, block), + framework == AnalysisContext.Framework.QUARKUS + ? profileValues(method.annotationBlock()) : profileValues(file, method.annotationBlock())), + mergeValues( + framework == AnalysisContext.Framework.QUARKUS + ? conditionAnnotations(block) : conditionAnnotations(file, block), + framework == AnalysisContext.Framework.QUARKUS + ? conditionAnnotations(method.annotationBlock()) + : conditionAnnotations(file, method.annotationBlock())), hasAnnotation(block, "Alternative") || hasAnnotation(method.annotationBlock(), "Alternative"), - hasAnnotation(block, "Primary") || hasAnnotation(method.annotationBlock(), "Primary"), + framework == AnalysisContext.Framework.QUARKUS + ? hasAnnotation(block, "Primary") || hasAnnotation(method.annotationBlock(), "Primary") + : canonicalSpringAnnotation(file, block, "Primary") + || canonicalSpringAnnotation(file, method.annotationBlock(), "Primary"), method.name(), method.returnType(), method.parameters())); @@ -757,6 +867,262 @@ List beanCandidates(AnalysisContext.Framework framework) { return List.copyOf(result); } + /** Finds bounded Spring Data repository interfaces, including annotated repositories. */ + Optional springRepositoryContract(SourceFile file) { + return springRepositoryContracts(file).stream().findFirst(); + } + + /** Returns all directly inherited supported Spring Data repository contracts. */ + private List springRepositoryContracts(SourceFile file) { + String header = maskStringLiterals(typeHeader(file)); + if (!header.matches("(?s).*\\binterface\\s+\\w+\\b.*")) { + return List.of(); + } + int extendsIndex = topLevelKeywordIndex(header, "extends"); + if (extendsIndex < 0) { + return List.of(); + } + int permitsIndex = topLevelKeywordIndex(header, "permits"); + String extended = header.substring( + extendsIndex + "extends".length(), permitsIndex >= 0 ? permitsIndex : header.length()); + return typeNames(extended).stream() + .map(type -> resolveKnownFrameworkType(type, file, SPRING_DATA_REPOSITORY_CONTRACTS.keySet())) + .flatMap(Optional::stream) + .map(SPRING_DATA_REPOSITORY_CONTRACTS::get) + .distinct() + .toList(); + } + + /** Returns repository methods guaranteed by the contract or declared by the source interface. */ + List springRepositoryMethods(SourceFile file) { + List contracts = springRepositoryContracts(file); + if (contracts.isEmpty()) { + return List.of(); + } + Map result = new LinkedHashMap<>(); + if (contracts.stream().anyMatch(this::guaranteedFindByIdContract)) { + result.put("findById", new RepositoryMethod("findById", "findById", true)); + } + for (MethodInfo method : methods(file)) { + if (typeContaining(file, declarationStart(file, method.start())).equals(file.typeName()) + && abstractInstanceRepositoryMethod(method) + && (!redeclaresInheritedFindById(file, method) || !result.containsKey("findById"))) { + RepositoryMethod repositoryMethod = new RepositoryMethod( + method.name(), repositoryMethodSignature(method), false); + result.putIfAbsent(repositoryMethod.identity(), repositoryMethod); + } + } + return List.copyOf(result.values()); + } + + /** Returns whether the selected Spring Data contract guarantees findById. */ + private boolean guaranteedFindByIdContract(String contract) { + if (!SPRING_DATA_FIND_BY_ID_CONTRACTS.contains(contract)) { + return false; + } + boolean pagingContract = contract.equals("PagingAndSortingRepository") + || contract.equals("ListPagingAndSortingRepository"); + return !pagingContract || springBootMajorVersion().map(major -> major < 3).orElse(false); + } + + /** Reads the Spring Boot major version carried by AnalysisContext.frameworkVersion. */ + private Optional springBootMajorVersion() { + Matcher matcher = Pattern.compile("^\\D*(\\d+)").matcher(context.frameworkVersion()); + return matcher.find() ? Optional.of(Integer.parseInt(matcher.group(1))) : Optional.empty(); + } + + /** Detects a source declaration that exactly redeclares the repository ID contract. */ + private boolean redeclaresInheritedFindById(SourceFile file, MethodInfo method) { + if (!method.name().equals("findById")) { + return false; + } + Optional repositoryIdType = springRepositoryIdType(file); + List methodParameters = parameters(method.parameters()); + if (repositoryIdType.isEmpty() || methodParameters.size() != 1) { + return false; + } + return repositoryTypeToken(methodParameters.get(0).type(), file) + .filter(repositoryIdType.get()::equals) + .isPresent(); + } + + /** Resolves the ID type from the first supported Spring Data contract declaration. */ + private Optional springRepositoryIdType(SourceFile file) { + String header = maskStringLiterals(typeHeader(file)); + int extendsIndex = topLevelKeywordIndex(header, "extends"); + if (extendsIndex < 0) { + return Optional.empty(); + } + int permitsIndex = topLevelKeywordIndex(header, "permits"); + String extended = header.substring( + extendsIndex + "extends".length(), permitsIndex >= 0 ? permitsIndex : header.length()); + for (String type : splitArguments(extended)) { + if (resolveKnownFrameworkType(type, file, SPRING_DATA_REPOSITORY_CONTRACTS.keySet()).isEmpty()) { + continue; + } + String arguments = genericArguments(type); + List parameters = splitArguments(arguments); + if (parameters.size() >= 2) { + Optional resolved = repositoryTypeToken(parameters.get(1), file); + if (resolved.isPresent()) { + return resolved; + } + } + } + return Optional.empty(); + } + + /** Resolves a repository type argument, retaining declared type variables for identity checks. */ + private Optional repositoryTypeToken(String type, SourceFile file) { + String normalized = stripBalancedGenericArguments(type == null ? "" : type).trim(); + Optional resolved = resolveReferenceType(normalized, file); + if (resolved.isPresent()) { + return resolved; + } + if (normalized.matches("[A-Za-z_$][\\w$]*") + && typeVariableBounds(file, normalized, file.typeDeclarationStart()).isPresent()) { + return Optional.of(normalized); + } + return Optional.empty(); + } + + /** Keeps only abstract instance methods that Spring Data can implement from an interface. */ + private static boolean abstractInstanceRepositoryMethod(MethodInfo method) { + String declaration = maskAnnotationArguments(stripComments(method.annotationBlock())) + .trim() + .replaceFirst("^[;}\\s]+", ""); + return !Pattern.compile("(? parameterTypes = parameters(method.parameters()).stream() + .map(Parameter::type) + .map(type -> type.replaceAll("\\s+", " ").trim()) + .toList(); + return method.name() + "(" + String.join(",", parameterTypes) + ")"; + } + + /** Resolves only known framework types, abstaining on ambiguous or unknown imports. */ + private Optional resolveKnownFrameworkType( + String type, SourceFile sourceFile, Set canonicalTypes) { + String normalized = stripBalancedGenericArguments(type == null ? "" : type).trim(); + if (canonicalTypes.contains(normalized)) { + return Optional.of(normalized); + } + List imported = importedTypes(normalized, sourceFile); + if (imported.size() > 1) { + return Optional.empty(); + } + if (imported.size() == 1 && canonicalTypes.contains(imported.get(0))) { + return Optional.of(imported.get(0)); + } + return resolveContractType(normalized, sourceFile).filter(canonicalTypes::contains); + } + + /** Scans Spring @Bean and bounded CommandLineRunner lifecycle callbacks. */ + List springLifecycleMethods() { + List result = new ArrayList<>(); + Set registeredBeanTypes = registeredSpringBeanTypes(); + for (SourceFile file : files()) { + for (MethodInfo method : methods(file)) { + String declaringType = typeContaining(file, declarationStart(file, method.start())); + boolean springBean = springComponent(file, declaringType); + boolean commandLineRunner = (springBean || registeredBeanTypes.contains(declaringType)) + && implementsNamedInterface(file, declaringType, SPRING_COMMAND_LINE_RUNNER); + if (springBeanAnnotation(file, method.annotationBlock())) { + if (!springBean) { + continue; + } + result.add(new LifecycleMethod( + file, + method, + declaringType, + "bean", + lifecycleAnnotationSymbols(file, method, declaringType))); + } + if (commandLineRunner + && method.name().equals("run") + && commandLineRunnerParameters(method.parameters())) { + result.add(new LifecycleMethod( + file, + method, + declaringType, + "runner", + lifecycleAnnotationSymbols(file, method, declaringType))); + } + } + } + return List.copyOf(result); + } + + /** Returns source types registered by canonical Spring component bean methods. */ + private Set registeredSpringBeanTypes() { + Set result = new LinkedHashSet<>(); + for (SourceFile file : files()) { + for (MethodInfo method : methods(file)) { + if (!springBeanAnnotation(file, method.annotationBlock()) || method.returnType().isBlank()) { + continue; + } + String declaringType = typeContaining(file, declarationStart(file, method.start())); + if (!springComponent(file, declaringType)) { + continue; + } + resolveReferenceType( + method.returnType(), file, declarationStart(file, method.start())) + .filter(type -> !type.isBlank()) + .ifPresent(result::add); + } + } + return Set.copyOf(result); + } + + /** Returns whether a source type header implements the named interface. */ + private boolean implementsNamedInterface( + SourceFile file, String typeName, String interfaceName) { + String header = maskStringLiterals(typeHeader(file, typeName)); + int implementsIndex = keywordIndex(header, "implements"); + if (implementsIndex < 0) { + return false; + } + int end = keywordIndexAfter(header, "permits", implementsIndex + "implements".length()); + String implemented = header.substring( + implementsIndex + "implements".length(), end >= 0 ? end : header.length()); + return typeNames(implemented).stream() + .map(type -> resolveKnownFrameworkType(type, file, Set.of(interfaceName))) + .flatMap(Optional::stream) + .anyMatch(interfaceName::equals); + } + + /** Accepts both String... and the equivalent String[] CommandLineRunner signature. */ + private static boolean commandLineRunnerParameters(String source) { + List parameters = parameters(source); + if (parameters.size() != 1) { + return false; + } + String type = parameters.get(0).type().replaceAll("\\s+", ""); + return type.equals("String[]") || type.equals("java.lang.String[]"); + } + + /** Preserves the exact source annotations used to establish a lifecycle candidate. */ + private static List lifecycleAnnotationSymbols( + SourceFile file, MethodInfo method, String declaringType) { + List result = new ArrayList<>(); + result.addAll(canonicalAnnotationSources(file, typeAnnotationBlock(file, declaringType), List.of( + "Component", "Service", "Repository", "Configuration", "Controller", + "RestController", "Named"))); + result.addAll(canonicalAnnotationSources(file, method.annotationBlock(), List.of("Bean"))); + return List.copyOf(new LinkedHashSet<>(result)); + } + + /** Recognizes only the canonical Spring {@code @Bean} annotation. */ + private static boolean springBeanAnnotation(SourceFile file, String block) { + return canonicalAnnotation(file, block, "Bean", "org.springframework.context.annotation.Bean"); + } + /** Scans framework proxy targets and records CDI scope eligibility. */ List proxyTargets(AnalysisContext.Framework framework) { List result = new ArrayList<>(); @@ -764,7 +1130,7 @@ List proxyTargets(AnalysisContext.Framework framework) { String classBlock = file.classAnnotationBlock(); boolean bean = framework == AnalysisContext.Framework.QUARKUS ? cdiBean(classBlock) - : springComponent(classBlock); + : springComponent(file); if (!bean) { continue; } @@ -772,8 +1138,10 @@ List proxyTargets(AnalysisContext.Framework framework) { for (MethodInfo method : methods(file)) { boolean selected = framework == AnalysisContext.Framework.QUARKUS ? !method.name().equals(file.simpleTypeName()) - : SPRING_PROXY_ANNOTATIONS.stream().anyMatch(name -> hasAnnotation(method.annotationBlock(), name)) - || SPRING_PROXY_ANNOTATIONS.stream().anyMatch(name -> hasAnnotation(classBlock, name)); + : SPRING_PROXY_ANNOTATIONS.stream() + .anyMatch(name -> canonicalSpringAnnotation(file, method.annotationBlock(), name)) + || SPRING_PROXY_ANNOTATIONS.stream() + .anyMatch(name -> canonicalSpringAnnotation(file, classBlock, name)); if (selected && springProxyMethod(file, method)) { result.add(new ProxyTarget( file, @@ -3204,6 +3572,20 @@ static List methods(SourceFile file) { return List.copyOf(result); } + /** Skips closing braces captured by METHOD before resolving declaration ownership. */ + private static int declarationStart(SourceFile file, int start) { + String source = file.structuralSource(); + int position = Math.max(0, start); + while (position < source.length()) { + char current = source.charAt(position); + if (current != '}' && !Character.isWhitespace(current)) { + break; + } + position++; + } + return position; + } + private static boolean isControlFlowKeyword(String name) { return switch (name) { case "if", "for", "while", "switch", "catch", "try", "synchronized" -> true; @@ -3717,13 +4099,16 @@ private static List typeDeclarations(SourceFile file) { return List.copyOf(result); } - private static List routeAnnotations(String block, AnalysisContext.Framework framework) { + private static List routeAnnotations( + SourceFile file, String block, AnalysisContext.Framework framework) { List candidates = framework == AnalysisContext.Framework.QUARKUS ? List.of("POST", "GET", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS") : List.of("PostMapping", "GetMapping", "PutMapping", "PatchMapping", "DeleteMapping", "RequestMapping"); List result = new ArrayList<>(); for (String name : candidates) { - if (hasAnnotation(block, name)) { + if (framework == AnalysisContext.Framework.QUARKUS + ? hasAnnotation(block, name) + : canonicalSpringAnnotation(file, block, name)) { result.add(name); } } @@ -3731,7 +4116,7 @@ private static List routeAnnotations(String block, AnalysisContext.Frame } /** Expands supported HTTP method annotation values. */ - private static List httpMethods(String block, String annotationName) { + private static List httpMethods(SourceFile file, String block, String annotationName) { List result = new ArrayList<>(); switch (annotationName) { case "POST", "PostMapping" -> result.add("POST"); @@ -3742,8 +4127,9 @@ private static List httpMethods(String block, String annotationName) { case "HEAD" -> result.add("HEAD"); case "OPTIONS" -> result.add("OPTIONS"); case "RequestMapping" -> { - boolean wildcard = annotationArguments(block, annotationName).isEmpty(); - for (String args : annotationArguments(block, annotationName)) { + List arguments = annotationArguments(file, block, annotationName); + boolean wildcard = arguments.isEmpty(); + for (String args : arguments) { String selected = attributeValue(args, "method"); if (selected == null) { wildcard = true; @@ -3789,12 +4175,13 @@ private static List pathValues(String block, String annotation) { } /** Extracts literal Spring mapping paths from value and path attributes. */ - private static List mappingPaths(String block, String annotation) { - if (!hasAnnotation(block, annotation)) { + private static List mappingPaths(SourceFile file, String block, String annotation) { + List arguments = annotationArguments(file, block, annotation); + if (arguments.isEmpty()) { return List.of(); } LinkedHashSet result = new LinkedHashSet<>(); - for (String args : annotationArguments(block, annotation)) { + for (String args : arguments) { for (String attribute : List.of("value", "path")) { String selected = attributeValue(args, attribute); if (selected == null) { @@ -3810,11 +4197,11 @@ private static List mappingPaths(String block, String annotation) { if (!result.isEmpty()) { return List.copyOf(result); } - return hasExplicitMappingPath(block, annotation) ? List.of() : List.of(""); + return hasExplicitMappingPath(file, block, annotation) ? List.of() : List.of(""); } - private static boolean hasExplicitMappingPath(String block, String annotation) { - return annotationArguments(block, annotation).stream() + private static boolean hasExplicitMappingPath(SourceFile file, String block, String annotation) { + return annotationArguments(file, block, annotation).stream() .anyMatch(args -> attributeValue(args, "value") != null || attributeValue(args, "path") != null); } @@ -3885,15 +4272,23 @@ private static String routeKey( } private static List routeSymbols( - String classBlock, String methodBlock, AnalysisContext.Framework framework, String annotationName) { + SourceFile file, + String classBlock, + String methodBlock, + AnalysisContext.Framework framework, + String annotationName) { List symbols = new ArrayList<>(); if (framework == AnalysisContext.Framework.QUARKUS) { addSymbols(symbols, classBlock, "Path", "Consumes", "Produces"); addSymbols(symbols, methodBlock, "Path", "Consumes", "Produces"); } else { - addSymbols(symbols, classBlock, "RequestMapping", "RestController"); + addCanonicalSymbols(symbols, file, classBlock, "RequestMapping", "RestController"); + } + if (framework == AnalysisContext.Framework.QUARKUS) { + addSymbols(symbols, methodBlock, annotationName); + } else { + addCanonicalSymbols(symbols, file, methodBlock, annotationName); } - addSymbols(symbols, methodBlock, annotationName); return List.copyOf(symbols); } @@ -3906,15 +4301,71 @@ private static void addSymbols(List symbols, String block, String... nam } } + /** Adds only canonically resolved Spring annotation spellings to evidence. */ + private static void addCanonicalSymbols( + List symbols, SourceFile file, String block, String... names) { + for (String name : names) { + for (AnnotationOccurrence occurrence : canonicalSpringAnnotationOccurrences(file, block, name)) { + symbols.add(occurrence.source()); + } + } + } + /** Recognizes the bounded Spring component stereotype subset. */ - private static boolean springComponent(String block) { - return hasAnnotation(block, "Service") - || hasAnnotation(block, "Component") - || hasAnnotation(block, "Repository") - || hasAnnotation(block, "Configuration") - || hasAnnotation(block, "Controller") - || hasAnnotation(block, "RestController") - || hasAnnotation(block, "Named"); + private static boolean springComponent(SourceFile file) { + return springComponentAnnotations(file, file.classAnnotationBlock()); + } + + /** Recognizes a canonical Spring component stereotype on one source type. */ + private static boolean springComponent(SourceFile file, String typeName) { + if (file.typeName().equals(typeName)) { + return springComponent(file); + } + return typeDeclaration(file, typeName) + .filter(type -> !isLocalType(file, type)) + .map(type -> typeAnnotationBlock(file, type.start())) + .map(block -> springComponentAnnotations(file, block)) + .orElse(false); + } + + /** Finds the declared source type matching one source-level owner name. */ + private static Optional typeDeclaration(SourceFile file, String typeName) { + return typeDeclarations(file).stream() + .filter(type -> typeContaining(file, type.start()).equals(typeName)) + .findFirst(); + } + + /** Recognizes the bounded Spring component stereotype subset in an annotation block. */ + private static boolean springComponentAnnotations(SourceFile file, String block) { + return canonicalAnnotation(file, block, "Service", "org.springframework.stereotype.Service") + || canonicalAnnotation(file, block, "Component", "org.springframework.stereotype.Component") + || canonicalAnnotation(file, block, "Repository", "org.springframework.stereotype.Repository") + || canonicalAnnotation( + file, block, "Configuration", "org.springframework.context.annotation.Configuration") + || canonicalAnnotation(file, block, "Controller", "org.springframework.stereotype.Controller") + || canonicalAnnotation( + file, block, "RestController", "org.springframework.web.bind.annotation.RestController") + || canonicalAnnotation(file, block, "Named", "jakarta.inject.Named", "javax.inject.Named"); + } + + /** Returns the annotation-bearing declaration prefix for one nested source type. */ + private static String typeAnnotationBlock(SourceFile file, int typeStart) { + String maskedSource = maskAnnotationArguments(file.structuralSource()); + int boundary = typeStart - 1; + while (boundary >= 0 && "{};".indexOf(maskedSource.charAt(boundary)) < 0) { + boundary--; + } + return file.source().substring(boundary + 1, typeStart); + } + + /** Returns the annotation-bearing declaration prefix for one source-level owner name. */ + private static String typeAnnotationBlock(SourceFile file, String typeName) { + if (file.typeName().equals(typeName)) { + return file.classAnnotationBlock(); + } + return typeDeclaration(file, typeName) + .map(type -> typeAnnotationBlock(file, type.start())) + .orElse(""); } /** Recognizes CDI scopes, stereotypes, and named beans. */ @@ -3968,11 +4419,23 @@ private void addResolvedContracts(Set contracts, String source, SourceFi /** Returns the declaration header before its top-level body. */ private static String typeHeader(SourceFile file) { + return typeHeader(file, file.typeName()); + } + + /** Returns the declaration header before one source type's body. */ + private static String typeHeader(SourceFile file, String typeName) { String source = file.structuralSource(); + int declarationStart = file.typeDeclarationStart(); + if (!file.typeName().equals(typeName)) { + declarationStart = typeDeclaration(file, typeName).map(TypeInfo::start).orElse(-1); + } + if (declarationStart < 0) { + return ""; + } int angleDepth = 0; int parenthesisDepth = 0; int bracketDepth = 0; - for (int index = file.typeDeclarationStart(); index < source.length(); index++) { + for (int index = declarationStart; index < source.length(); index++) { char character = source.charAt(index); if (character == '<') { angleDepth++; @@ -3987,10 +4450,10 @@ private static String typeHeader(SourceFile file) { } else if (character == ']' && bracketDepth > 0) { bracketDepth--; } else if (character == '{' && angleDepth == 0 && parenthesisDepth == 0 && bracketDepth == 0) { - return source.substring(file.typeDeclarationStart(), index); + return source.substring(declarationStart, index); } } - return source.substring(file.typeDeclarationStart()); + return source.substring(declarationStart); } /** Finds a word boundary keyword in a declaration header. */ @@ -4041,6 +4504,11 @@ private static List profileValues(String block) { return values(block, "Profile", null); } + /** Extracts profile values only from the canonical Spring annotation. */ + private static List profileValues(SourceFile file, String block) { + return values(file, block, "Profile", null); + } + /** Extracts supported conditional registration annotations. */ private static List conditionAnnotations(String block) { List result = new ArrayList<>(); @@ -4054,11 +4522,23 @@ private static List conditionAnnotations(String block) { return List.copyOf(result); } + /** Extracts conditions only from canonical Spring registration annotations. */ + private static List conditionAnnotations(SourceFile file, String block) { + return canonicalAnnotationSources(file, block, List.of( + "Conditional", "ConditionalOnBean", "ConditionalOnMissingBean", "ConditionalOnClass", + "ConditionalOnMissingClass", "ConditionalOnProperty", "ConditionalOnExpression")); + } + /** Extracts normalized qualifiers after removing comments and ordinary annotations. */ private static List qualifierNames(String block) { + return qualifierNames(null, block); + } + + /** Extracts qualifiers while resolving recognized Spring annotations canonically. */ + private static List qualifierNames(SourceFile file, String block) { String source = stripComments(block == null ? "" : block); List result = new ArrayList<>(); - Matcher matcher = Pattern.compile("@([A-Za-z_][\\w.]*)").matcher(source); + Matcher matcher = Pattern.compile("@([A-Za-z_][\\w.]*)").matcher(maskStringLiterals(source)); while (matcher.find()) { String name = simpleName(matcher.group(1)); int end = matcher.end(); @@ -4066,12 +4546,19 @@ private static List qualifierNames(String block) { end++; } String arguments = ""; + int annotationEnd = matcher.end(); if (end < source.length() && source.charAt(end) == '(') { int closing = matchingDelimiter(source, end, '(', ')'); if (closing >= 0) { arguments = source.substring(end + 1, closing).trim(); + annotationEnd = closing + 1; } } + if (file != null && SPRING_BEAN_ANNOTATION_TYPES.containsKey(name) + && !canonicalSpringAnnotation( + file, source.substring(matcher.start(), annotationEnd), name)) { + continue; + } if (name.equals("Resource")) { String value = attributeValue(arguments, "name"); if (value == null) { @@ -4178,7 +4665,8 @@ private static String compactGenericWhitespace(String source) { } /** Classifies a source bean using the bounded framework stereotype subset. */ - private static String beanKind(String block, AnalysisContext.Framework framework) { + private static String beanKind( + SourceFile file, String block, AnalysisContext.Framework framework) { if (framework == AnalysisContext.Framework.QUARKUS) { for (String annotation : List.of( "ApplicationScoped", "RequestScoped", "SessionScoped", "ConversationScoped", @@ -4190,7 +4678,7 @@ private static String beanKind(String block, AnalysisContext.Framework framework } for (String annotation : List.of( "Service", "Component", "Repository", "Configuration", "Controller", "RestController", "Named")) { - if (hasAnnotation(block, annotation)) { + if (canonicalSpringAnnotation(file, block, annotation)) { return annotation; } } @@ -4205,16 +4693,18 @@ List sourceAnnotations(BeanCandidate candidate, AnalysisContext.Framewor .map(MethodInfo::annotationBlock) .findFirst() .orElse(""); - return annotationSources(block, framework == AnalysisContext.Framework.QUARKUS - ? List.of("Produces") - : List.of("Bean")); + return framework == AnalysisContext.Framework.QUARKUS + ? annotationSources(block, List.of("Produces")) + : canonicalAnnotationSources(candidate.file(), block, List.of("Bean")); } List names = framework == AnalysisContext.Framework.QUARKUS ? List.of("ApplicationScoped", "RequestScoped", "SessionScoped", "ConversationScoped", "Dependent", "Singleton", "Named", "Stereotype") : List.of("Service", "Component", "Repository", "Configuration", "Controller", "RestController", "Named"); - return annotationSources(candidate.file().classAnnotationBlock(), names); + return framework == AnalysisContext.Framework.QUARKUS + ? annotationSources(candidate.file().classAnnotationBlock(), names) + : canonicalAnnotationSources(candidate.file(), candidate.file().classAnnotationBlock(), names); } /** Returns source advisor or scope annotations that establish one proxy target. */ @@ -4225,9 +4715,16 @@ List sourceAnnotations(ProxyTarget target, AnalysisContext.Framework fra : List.of("Transactional", "Async", "Cacheable", "CachePut", "CacheEvict", "PreAuthorize", "PostAuthorize", "Secured", "RolesAllowed", "Retryable", "CircuitBreaker", "Validated", "TransactionalEventListener"); - List result = new ArrayList<>(annotationSources( - target.file().classAnnotationBlock(), names)); - result.addAll(annotationSources(target.method().annotationBlock(), names)); + List result = framework == AnalysisContext.Framework.QUARKUS + ? new ArrayList<>(annotationSources(target.file().classAnnotationBlock(), names)) + : new ArrayList<>(canonicalAnnotationSources( + target.file(), target.file().classAnnotationBlock(), names)); + if (framework == AnalysisContext.Framework.QUARKUS) { + result.addAll(annotationSources(target.method().annotationBlock(), names)); + } else { + result.addAll(canonicalAnnotationSources( + target.file(), target.method().annotationBlock(), names)); + } return List.copyOf(new LinkedHashSet<>(result)); } @@ -4242,12 +4739,37 @@ private static List annotationSources(String block, List names) return List.copyOf(new LinkedHashSet<>(result)); } + /** Extracts source spellings for canonically resolved Spring annotations. */ + private static List canonicalAnnotationSources( + SourceFile file, String block, List names) { + List result = new ArrayList<>(); + for (String name : names) { + result.addAll(canonicalSpringAnnotationOccurrences(file, block, name).stream() + .map(AnnotationOccurrence::source) + .toList()); + } + return List.copyOf(new LinkedHashSet<>(result)); + } + /** Parses method or constructor parameters and their qualifier annotations. */ static List parameters(String source) { + return parameters(null, source); + } + + /** Parses injection parameters with framework-specific qualifier resolution. */ + private static List parameters( + AnalysisContext.Framework framework, SourceFile file, String source) { + return framework == AnalysisContext.Framework.SPRING_BOOT + ? parameters(file, source) + : parameters(source); + } + + private static List parameters(SourceFile file, String source) { List result = new ArrayList<>(); for (String raw : splitArguments(source)) { - List qualifiers = qualifierNames(raw); - String cleaned = raw.replaceAll("@[A-Za-z_][\\w.]*(?:\\s*\\([^)]*\\))?", " ").trim(); + List qualifiers = qualifierNames(file, raw); + String cleaned = maskAnnotationArguments(stripComments(raw)) + .replaceAll("@[A-Za-z_][\\w.]*", " ").trim(); cleaned = cleaned.replaceAll("\\b(final|volatile|transient)\\b", "").trim(); if (cleaned.isBlank()) { continue; @@ -4311,6 +4833,46 @@ private static boolean hasAnnotation(String block, String name) { return !annotationOccurrences(block, name).isEmpty(); } + /** Recognizes an annotation only when its source spelling resolves canonically. */ + private static boolean canonicalAnnotation( + SourceFile file, String block, String simpleName, String... canonicalNames) { + return !canonicalAnnotationOccurrences(file, block, simpleName, Set.of(canonicalNames)).isEmpty(); + } + + /** Resolves a supported Spring annotation without accepting lookalike or wildcard imports. */ + private static boolean canonicalSpringAnnotation(SourceFile file, String block, String simpleName) { + return !canonicalSpringAnnotationOccurrences(file, block, simpleName).isEmpty(); + } + + /** Returns only annotation occurrences whose source spelling resolves canonically. */ + private static List canonicalSpringAnnotationOccurrences( + SourceFile file, String block, String simpleName) { + return canonicalAnnotationOccurrences( + file, + block, + simpleName, + canonicalSpringAnnotationTypes(simpleName)); + } + + /** Returns canonical names for every supported Spring annotation family. */ + private static Set canonicalSpringAnnotationTypes(String simpleName) { + Set result = new LinkedHashSet<>(); + result.addAll(SPRING_ROUTE_ANNOTATION_TYPES.getOrDefault(simpleName, List.of())); + result.addAll(SPRING_PROXY_ANNOTATION_TYPES.getOrDefault(simpleName, List.of())); + result.addAll(SPRING_BEAN_ANNOTATION_TYPES.getOrDefault(simpleName, List.of())); + return Set.copyOf(result); + } + + private static List canonicalAnnotationOccurrences( + SourceFile file, String block, String simpleName, Set canonical) { + List explicitImports = importedTypes(simpleName, file); + boolean canonicalImport = explicitImports.stream().anyMatch(canonical::contains); + return annotationOccurrences(block, simpleName).stream() + .filter(occurrence -> canonical.contains(annotationType(occurrence.source())) + || annotationType(occurrence.source()).equals(simpleName) && canonicalImport) + .toList(); + } + /** Checks a method declaration modifier without reading annotation arguments as modifiers. */ private static boolean hasModifier(String block, String modifier) { String structural = maskAnnotationArguments(stripComments(block == null ? "" : block)); @@ -4376,8 +4938,18 @@ private static String annotationType(String source) { } private static List values(String block, String annotation, String attribute) { + return values(annotationArguments(block, annotation), attribute); + } + + /** Extracts values only from canonically resolved Spring route annotations. */ + private static List values( + SourceFile file, String block, String annotation, String attribute) { + return values(annotationArguments(file, block, annotation), attribute); + } + + private static List values(List arguments, String attribute) { LinkedHashSet result = new LinkedHashSet<>(); - for (String args : annotationArguments(block, annotation)) { + for (String args : arguments) { String selected = selectedValue(args, attribute); if (selected.isBlank()) { continue; @@ -4402,8 +4974,18 @@ private static List values(String block, String annotation, String attri } private static List mediaValues(String block, String annotation, String attribute) { + return mediaValues(values(block, annotation, attribute)); + } + + /** Extracts media values only from canonically resolved Spring route annotations. */ + private static List mediaValues( + SourceFile file, String block, String annotation, String attribute) { + return mediaValues(values(file, block, annotation, attribute)); + } + + private static List mediaValues(List values) { LinkedHashSet result = new LinkedHashSet<>(); - for (String value : values(block, annotation, attribute)) { + for (String value : values) { String normalized = mediaType(value); if (!normalized.isBlank()) { result.add(normalized); @@ -4440,12 +5022,20 @@ private static List annotationArguments(String block, String name) { return annotationOccurrences(block, name).stream().map(AnnotationOccurrence::arguments).toList(); } + /** Extracts arguments only from canonically resolved Spring route annotations. */ + private static List annotationArguments(SourceFile file, String block, String name) { + return canonicalSpringAnnotationOccurrences(file, block, name).stream() + .map(AnnotationOccurrence::arguments) + .toList(); + } + /** Finds annotation occurrences with balanced argument extraction. */ private static List annotationOccurrences(String block, String name) { List result = new ArrayList<>(); String source = stripComments(block == null ? "" : block); + String searchSource = maskStringLiterals(source); Matcher matcher = Pattern.compile( - "@(?:[A-Za-z_][\\w.]*\\.)?" + Pattern.quote(name) + "\\b").matcher(source); + "@(?:[A-Za-z_][\\w.]*\\.)?" + Pattern.quote(name) + "\\b").matcher(searchSource); while (matcher.find()) { int end = matcher.end(); while (end < source.length() && Character.isWhitespace(source.charAt(end))) { @@ -4629,6 +5219,16 @@ private static String stripBalancedGenericArguments(String source) { return result.toString(); } + /** Returns the outermost generic argument text from one declared type. */ + private static String genericArguments(String source) { + int opening = source.indexOf('<'); + if (opening < 0) { + return ""; + } + int closing = matchingDelimiter(source, opening, '<', '>'); + return closing > opening ? source.substring(opening + 1, closing) : ""; + } + /** Finds the matching delimiter while ignoring quoted strings. */ private static int matchingDelimiter(String source, int opening, char open, char close) { int depth = 0; @@ -4919,5 +5519,18 @@ private record ConstructorInfo(String annotations, String parameters, int start) record GeneratedMethod(SourceFile file, MethodInfo method) {} + record LifecycleMethod( + SourceFile file, + MethodInfo method, + String declaringType, + String kind, + List annotationSymbols) {} + + record RepositoryMethod(String name, String signature, boolean guaranteed) { + String identity() { + return guaranteed ? name : signature; + } + } + private record AnnotationOccurrence(String arguments, String source) {} } diff --git a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java index 3a88a57..24c43ff 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -140,6 +140,8 @@ public class GeneratedTransferMapper { SemanticCallFacts.Fact generated = fact(facts.frameworkFacts(), "quarkus-generated-mapper-binding"); assertEquals(SemanticCallFacts.Certainty.DEFINITE, generated.certainty()); + assertEquals("generated-src/main/java", generated.evidence().get(0).attributes().get("sourceRoot")); + assertEquals("generated-root-fingerprint", generated.evidence().get(0).attributes().get("sourceRootFingerprint")); SemanticCallFacts.Fact unresolvedProxy = fact(facts.frameworkFacts(), "quarkus-unbounded-proxy-abstention"); assertEquals(SemanticCallFacts.Certainty.UNRESOLVED, unresolvedProxy.certainty()); @@ -168,6 +170,135 @@ public class GeneratedTransferMapper { assertEquals("(Ljava/lang/String;)Z", facts.providerFacts().get(0).evidence().get(0).attributes().get("callerSignature")); } + /** Deduplicates files in overlapping roots and keeps the most-specific generated provenance. */ + @Test + void overlappingGeneratedRootsAreScannedOnceWithSpecificProvenance() throws IOException { + Path sourceRoot = temp.resolve("overlapping/src/main/java"); + Path generatedRoot = sourceRoot.resolve("generated-src/main/java"); + write(generatedRoot, "demo/generated/GeneratedTransferMapper.java", """ + package demo.generated; + public class GeneratedTransferMapper { + public String toResponse(String value) { return value; } + } + """); + + AnalysisContext context = AnalysisContext.builder() + .id("quarkus-production") + .framework(AnalysisContext.Framework.QUARKUS) + .frameworkVersion("3.40.1") + .fixtureId("java21-quarkus") + .javaRelease(21) + .repositoryRoot(temp) + .applicationClasses(temp.resolve("classes")) + .selectedProfile("production") + .analysisScope(AnalysisContext.Scope.PRODUCTION) + .sourceRoot("src/main/java", sourceRoot, AnalysisContext.Scope.PRODUCTION, true) + .generatedSourceRoot( + "generated-src/main/java", + generatedRoot, + AnalysisContext.Scope.PRODUCTION, + true, + true, + "generated-root-fingerprint") + .dependencyClasspathRequired(false) + .frameworkMetadata(new AnalysisContext.FrameworkMetadata( + "quarkus-generated-source-root", + "generated_source_root", + AnalysisContext.MetadataStatus.OBSERVED, + "generated-src/main/java")) + .provenance(AnalysisContext.Provenance.forTesting()) + .build(); + + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(context, AnalysisGraph.empty()); + + List generatedFacts = facts.frameworkFacts().stream() + .filter(item -> item.relationshipType().equals("generated_implementation")) + .toList(); + assertEquals(1, generatedFacts.size()); + assertEquals("generated-src/main/java", + generatedFacts.get(0).evidence().get(0).attributes().get("sourceRoot")); + assertEquals("generated-root-fingerprint", + generatedFacts.get(0).evidence().get(0).attributes().get("sourceRootFingerprint")); + } + + /** Keeps excluded and wrong-scope nested roots out of generated provenance. */ + @Test + void generatedProvenanceUsesOnlyIncludedActiveScopeNestedRoots() throws IOException { + Path sourceRoot = temp.resolve("generated-scope/src/main/java"); + Path generatedRoot = temp.resolve("generated-scope/generated-src/main/java"); + Path excludedNestedRoot = generatedRoot.resolve("excluded"); + Path testNestedRoot = generatedRoot.resolve("test"); + write(excludedNestedRoot, "demo/generated/ExcludedMapper.java", """ + package demo.generated; + public class ExcludedMapper { + public String map(String value) { return value; } + } + """); + write(testNestedRoot, "demo/generated/TestMapper.java", """ + package demo.generated; + public class TestMapper { + public String map(String value) { return value; } + } + """); + + AnalysisContext context = AnalysisContext.builder() + .id("quarkus-production") + .framework(AnalysisContext.Framework.QUARKUS) + .frameworkVersion("3.40.1") + .fixtureId("java21-quarkus") + .javaRelease(21) + .repositoryRoot(temp) + .applicationClasses(temp.resolve("classes")) + .selectedProfile("production") + .analysisScope(AnalysisContext.Scope.PRODUCTION) + .sourceRoot("src/main/java", sourceRoot, AnalysisContext.Scope.PRODUCTION, true) + .generatedSourceRoot( + "generated-src/main/java", + generatedRoot, + AnalysisContext.Scope.PRODUCTION, + true, + true, + "included-fingerprint") + .generatedSourceRoot( + "generated-src/excluded", + excludedNestedRoot, + AnalysisContext.Scope.PRODUCTION, + false, + false, + "excluded-fingerprint") + .generatedSourceRoot( + "generated-src/test", + testNestedRoot, + AnalysisContext.Scope.TEST, + true, + false, + "test-fingerprint") + .dependencyClasspathRequired(false) + .frameworkMetadata(new AnalysisContext.FrameworkMetadata( + "quarkus-generated-source-root", + "generated_source_root", + AnalysisContext.MetadataStatus.OBSERVED, + "generated-src/main/java")) + .provenance(AnalysisContext.Provenance.forTesting()) + .build(); + + List generatedFacts = new JavaAnalysisAdapter() + .analyze(context, AnalysisGraph.empty()) + .frameworkFacts().stream() + .filter(item -> item.relationshipType().equals("generated_implementation")) + .toList(); + + assertEquals(2, generatedFacts.size()); + assertTrue(generatedFacts.stream().allMatch(item -> + item.evidence().get(0).attributes().get("sourceRoot").equals("generated-src/main/java"))); + assertTrue(generatedFacts.stream().allMatch(item -> + item.evidence().get(0).attributes().get("sourceRootIncluded").equals("true"))); + assertTrue(generatedFacts.stream().allMatch(item -> + item.evidence().get(0).attributes().get("sourceRootScope").equals("production"))); + assertTrue(generatedFacts.stream().allMatch(item -> + item.evidence().get(0).attributes().get("sourceRootFingerprint").equals("included-fingerprint"))); + } + /** Covers Spring MVC, injection, proxy, repository, and lifecycle facts. */ @Test void springFactsCoverMvcInjectionProxyRepositoryAndLifecycle() throws IOException { @@ -195,7 +326,8 @@ public class TransferController { write(sourceRoot, "demo/AccountRepository.java", """ package demo; import org.springframework.data.jpa.repository.JpaRepository; - public interface AccountRepository extends JpaRepository { } + import org.springframework.stereotype.Repository; + @Repository public interface AccountRepository extends JpaRepository { } """); write(sourceRoot, "demo/Account.java", "package demo; public class Account {}\n"); write(sourceRoot, "demo/SecurityConfig.java", """ @@ -240,10 +372,498 @@ public interface AccountRepository extends JpaRepository { } && item.certainty() == SemanticCallFacts.Certainty.POSSIBLE)); assertTrue(facts.frameworkFacts().stream().anyMatch(item -> item.id().equals("spring-data-generated-account-repository") || item.relationshipType().equals("dependency_injection"))); + SemanticCallFacts.Fact repository = fact(facts.frameworkFacts(), "spring-data-generated-account-repository"); + assertTrue(repository.evidence().get(0).symbols().contains("findById")); + assertEquals("JpaRepository", repository.evidence().get(0).attributes().get("repositoryContract")); assertTrue(facts.frameworkFacts().stream().anyMatch(item -> item.id().equals("spring-bean-factory-to-security-config"))); assertTrue(facts.frameworkFacts().stream().anyMatch(item -> item.id().equals("spring-boot-to-data-seeder"))); } + /** Ignores unregistered runner lookalikes and preserves exact lifecycle source evidence. */ + @Test + void springLifecycleFactsRequireBeanEvidenceAndRunnerSignature() throws IOException { + Path sourceRoot = temp.resolve("spring-lifecycle/src/main/java"); + write(sourceRoot, "demo/RegisteredRunner.java", """ + package demo; + import org.springframework.boot.CommandLineRunner; + import org.springframework.stereotype.Component; + @Component public class RegisteredRunner implements CommandLineRunner { + @Override public void run(String... args) { } + public void run(String value) { } + static class NestedRunner implements CommandLineRunner { + public void run(String... args) { } + } + } + """); + write(sourceRoot, "demo/UnregisteredRunner.java", """ + package demo; + import org.springframework.boot.CommandLineRunner; + public class UnregisteredRunner implements CommandLineRunner { + @Override public void run(String... args) { } + } + """); + write(sourceRoot, "demo/UnregisteredFactory.java", """ + package demo; + import org.springframework.context.annotation.Bean; + public class UnregisteredFactory { + @Bean public Object ignoredBean() { return new Object(); } + } + """); + write(sourceRoot, "demo/BeanFactory.java", """ + package demo; + import org.springframework.context.annotation.Bean; + import org.springframework.stereotype.Component; + @Component public class BeanFactory { + @Bean public Object suppliedBean() { return new Object(); } + static class NestedFactory { + @Bean public NestedUnregisteredRunner nestedRunner() { + return new NestedUnregisteredRunner(); + } + } + } + """); + write(sourceRoot, "demo/NestedUnregisteredRunner.java", """ + package demo; + import org.springframework.boot.CommandLineRunner; + public class NestedUnregisteredRunner implements CommandLineRunner { + @Override public void run(String... args) { } + } + """); + write(sourceRoot, "demo/NestedConfiguredRunner.java", """ + package demo; + import org.springframework.boot.CommandLineRunner; + public class NestedConfiguredRunner implements CommandLineRunner { + @Override public void run(String... args) { } + } + """); + write(sourceRoot, "demo/NestedConfigurationOwner.java", """ + package demo; + import org.springframework.boot.CommandLineRunner; + import org.springframework.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + public class NestedConfigurationOwner { + @Configuration + @SuppressWarnings({"unused"}) + static class RegisteredNestedConfiguration { + @Bean public NestedConfiguredRunner nestedConfiguredRunner() { + return new NestedConfiguredRunner(); + } + @Bean public NestedRunner nestedRunner() { + return new NestedRunner(); + } + static class NestedRunner implements CommandLineRunner { + @Override public void run(String... args) { } + } + } + } + """); + write(sourceRoot, "demo/ConfiguredRunner.java", """ + package demo; + import org.springframework.boot.CommandLineRunner; + public class ConfiguredRunner implements CommandLineRunner { + @Override public void run(String... args) { } + } + """); + write(sourceRoot, "demo/RunnerConfiguration.java", """ + package demo; + import org.springframework.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + @Configuration @demo.Configuration public class RunnerConfiguration { + static class NestedConfiguration { } + @Bean @demo.Bean public ConfiguredRunner configuredRunner() { + return new ConfiguredRunner(); + } + } + """); + write(sourceRoot, "demo/Bean.java", "package demo; public @interface Bean {}\n"); + write(sourceRoot, "demo/CustomBeanFactory.java", """ + package demo; + import org.springframework.context.annotation.Configuration; + @Configuration public class CustomBeanFactory { + @demo.Bean public Object customBean() { return new Object(); } + } + """); + write(sourceRoot, "demo/Lookalike.java", """ + package demo; + public class Lookalike { + String text = "@Configuration @Bean implements CommandLineRunner"; + // @Configuration class Fake { @Bean Object fake() { return null; } } + } + """); + write(sourceRoot, "demo/CommandLineRunner.java", """ + package demo; + public interface CommandLineRunner { } + """); + write(sourceRoot, "demo/LookalikeRunner.java", """ + package demo; + import org.springframework.stereotype.Component; + @Component public class LookalikeRunner implements CommandLineRunner { + public void run(String... args) { } + } + """); + write(sourceRoot, "wildcard/WildcardRunner.java", """ + package wildcard; + import org.springframework.boot.*; + import org.springframework.stereotype.Component; + @Component public class WildcardRunner implements CommandLineRunner { + public void run(String... args) { } + } + """); + write(sourceRoot, "demo/Component.java", "package demo; public @interface Component {}\n"); + write(sourceRoot, "demo/Configuration.java", "package demo; public @interface Configuration {}\n"); + write(sourceRoot, "demo/FakeComponent.java", """ + package demo; + import org.springframework.boot.CommandLineRunner; + @demo.Component public class FakeComponent implements CommandLineRunner { + public void run(String... args) { } + } + """); + write(sourceRoot, "demo/FakeConfiguration.java", """ + package demo; + @demo.Configuration public class FakeConfiguration { + @org.springframework.context.annotation.Bean public Object fakeBean() { return null; } + } + """); + + AnalysisContext context = context( + AnalysisContext.Framework.SPRING_BOOT, + sourceRoot, + null, + List.of(new AnalysisContext.FrameworkMetadata( + "spring-bean-annotations", + "source_annotations", + AnalysisContext.MetadataStatus.OBSERVED, + "src/main/java"))); + + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(context, AnalysisGraph.empty()); + + List lifecycleFacts = facts.frameworkFacts().stream() + .filter(item -> item.relationshipType().equals("lifecycle_callback")) + .toList(); + assertEquals(8, lifecycleFacts.size()); + assertTrue(lifecycleFacts.stream().anyMatch(item -> item.callee().equals("demo.RegisteredRunner#run") + && item.evidence().get(0).symbols().contains("@Component") + && item.evidence().get(0).symbols().contains("implements CommandLineRunner"))); + assertTrue(lifecycleFacts.stream().anyMatch(item -> item.callee().equals("demo.BeanFactory#suppliedBean") + && item.evidence().get(0).symbols().contains("@Bean suppliedBean"))); + assertTrue(lifecycleFacts.stream().anyMatch(item -> item.callee().equals( + "demo.RunnerConfiguration#configuredRunner") + && item.evidence().get(0).symbols().contains("@Configuration") + && item.evidence().get(0).symbols().contains("@Bean configuredRunner"))); + SemanticCallFacts.Fact configuredRunnerFactory = lifecycleFacts.stream() + .filter(item -> item.callee().equals("demo.RunnerConfiguration#configuredRunner")) + .findFirst() + .orElseThrow(); + assertFalse(configuredRunnerFactory.evidence().get(0).symbols().contains("@demo.Configuration")); + assertFalse(configuredRunnerFactory.evidence().get(0).symbols().contains("@demo.Bean")); + assertTrue(lifecycleFacts.stream().anyMatch(item -> item.callee().equals("demo.ConfiguredRunner#run") + && item.evidence().get(0).symbols().contains("implements CommandLineRunner"))); + assertTrue(lifecycleFacts.stream().anyMatch(item -> item.callee().equals( + "demo.NestedConfiguredRunner#run") + && item.evidence().get(0).symbols().contains("implements CommandLineRunner"))); + assertTrue(lifecycleFacts.stream().anyMatch(item -> item.callee().equals( + "demo.NestedConfigurationOwner$RegisteredNestedConfiguration#nestedConfiguredRunner") + && item.evidence().get(0).symbols().contains("@Configuration") + && item.evidence().get(0).symbols().contains("@Bean nestedConfiguredRunner"))); + assertTrue(lifecycleFacts.stream().anyMatch(item -> item.callee().equals( + "demo.NestedConfigurationOwner$RegisteredNestedConfiguration#nestedRunner") + && item.evidence().get(0).symbols().contains("@Bean nestedRunner"))); + assertTrue(lifecycleFacts.stream().anyMatch(item -> item.callee().equals( + "demo.NestedConfigurationOwner$RegisteredNestedConfiguration$NestedRunner#run") + && item.evidence().get(0).symbols().contains("implements CommandLineRunner"))); + assertFalse(lifecycleFacts.stream().anyMatch(item -> item.callee().contains("UnregisteredRunner"))); + assertFalse(lifecycleFacts.stream().anyMatch(item -> item.callee().contains("UnregisteredFactory"))); + assertFalse(lifecycleFacts.stream().anyMatch(item -> item.callee().contains("NestedUnregisteredRunner"))); + assertFalse(lifecycleFacts.stream().anyMatch(item -> item.callee().contains("Lookalike"))); + assertFalse(lifecycleFacts.stream().anyMatch(item -> item.callee().contains("LookalikeRunner"))); + assertFalse(lifecycleFacts.stream().anyMatch(item -> item.callee().contains("WildcardRunner"))); + assertFalse(lifecycleFacts.stream().anyMatch(item -> item.callee().contains("FakeComponent"))); + assertFalse(lifecycleFacts.stream().anyMatch(item -> item.callee().contains("FakeConfiguration"))); + assertFalse(facts.frameworkFacts().stream().anyMatch(item -> item.caller().contains("FakeComponent") + || item.caller().contains("FakeConfiguration") + || item.callee().contains("FakeComponent") + || item.callee().contains("FakeConfiguration"))); + assertTrue(lifecycleFacts.stream().allMatch(item -> item.factSet() == SemanticCallFacts.FactSet.FRAMEWORK)); + assertTrue(facts.providerFacts().isEmpty()); + } + + /** Rejects Spring route and advisor lookalikes while retaining canonical resolution. */ + @Test + void springRoutesAndProxiesRequireCanonicalAnnotationResolution() throws IOException { + Path sourceRoot = temp.resolve("spring-annotation-resolution/src/main/java"); + write(sourceRoot, "demo/FakeRestController.java", """ + package demo; + import org.springframework.web.bind.annotation.GetMapping; + @demo.RestController public class FakeRestController { + @GetMapping("/ignored-controller") public String ignored() { return "ignored"; } + } + """); + write(sourceRoot, "demo/FakeGetMapping.java", """ + package demo; + import org.springframework.web.bind.annotation.RestController; + @RestController public class FakeGetMapping { + @demo.GetMapping("/ignored-method") public String ignored() { return "ignored"; } + } + """); + write(sourceRoot, "demo/FakeTransactional.java", """ + package demo; + import org.springframework.stereotype.Service; + @Service public class FakeTransactional { + @demo.Transactional public String ignored() { return "ignored"; } + } + """); + write(sourceRoot, "demo/LiteralRouteController.java", String.join("\n", + "package demo;", + "import org.springframework.web.bind.annotation.GetMapping;", + "import org.springframework.web.bind.annotation.RestController;", + "@RestController public class LiteralRouteController {", + " @Marker(\"@GetMapping\") public String stringIgnored() { return \"ignored\"; }", + " @Marker(\"\"\"", + " @GetMapping", + " \"\"\") public String textBlockIgnored() { return \"ignored\"; }", + "}", + "")); + write(sourceRoot, "demo/LiteralProxy.java", """ + package demo; + import org.springframework.stereotype.Service; + import org.springframework.transaction.annotation.Transactional; + @Service public class LiteralProxy { + @Marker("@Transactional") public String ignored() { return "ignored"; } + } + """); + write(sourceRoot, "demo/Marker.java", "package demo; public @interface Marker { String value(); }\n"); + write(sourceRoot, "demo/RestController.java", "package demo; public @interface RestController {}\n"); + write(sourceRoot, "demo/GetMapping.java", "package demo; public @interface GetMapping {}\n"); + write(sourceRoot, "demo/Transactional.java", "package demo; public @interface Transactional {}\n"); + + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze( + context( + AnalysisContext.Framework.SPRING_BOOT, + sourceRoot, + null, + List.of( + new AnalysisContext.FrameworkMetadata( + "spring-web-annotations", + "source_annotations", + AnalysisContext.MetadataStatus.OBSERVED, + "src/main/java"), + new AnalysisContext.FrameworkMetadata( + "spring-bean-annotations", + "source_annotations", + AnalysisContext.MetadataStatus.OBSERVED, + "src/main/java"), + new AnalysisContext.FrameworkMetadata( + "spring-aop-advisor-metadata", + "runtime_or_build_metadata", + AnalysisContext.MetadataStatus.NOT_CAPTURED, + null))), + AnalysisGraph.empty()); + + assertFalse(facts.frameworkFacts().stream() + .filter(item -> item.relationshipType().equals("framework_entrypoint")) + .anyMatch(item -> item.callee().contains("FakeRestController") + || item.callee().contains("FakeGetMapping") + || item.callee().contains("LiteralRouteController"))); + assertFalse(facts.frameworkFacts().stream() + .filter(item -> item.relationshipType().equals("proxy_dispatch")) + .anyMatch(item -> item.callee().equals("demo.FakeTransactional#ignored") + || item.callee().equals("demo.LiteralProxy#ignored"))); + } + + /** Excludes same-named Spring annotation lookalikes from bean metadata and bindings. */ + @Test + void springBeanCandidatesRequireCanonicalAnnotationMetadata() throws IOException { + Path sourceRoot = temp.resolve("spring-bean-annotation-resolution/src/main/java"); + write(sourceRoot, "api/Service.java", "package api; public interface Service { }\n"); + write(sourceRoot, "demo/Service.java", """ + package demo; + public @interface Service { String value(); } + """); + write(sourceRoot, "demo/ShadowedService.java", """ + package demo; + import org.springframework.stereotype.Component; + @Component @Service("shadow") public class ShadowedService implements api.Service { } + """); + write(sourceRoot, "demo/Consumer.java", """ + package demo; + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.beans.factory.annotation.Qualifier; + import org.springframework.stereotype.Component; + @Component public class Consumer { + @Autowired public Consumer(@Qualifier("shadow") api.Service service) { } + } + """); + + AnalysisContext context = context( + AnalysisContext.Framework.SPRING_BOOT, + sourceRoot, + null, + List.of(new AnalysisContext.FrameworkMetadata( + "spring-bean-annotations", + "source_annotations", + AnalysisContext.MetadataStatus.OBSERVED, + "src/main/java"))); + SourceMetadataScanner scanner = new SourceMetadataScanner(context); + SourceMetadataScanner.BeanCandidate candidate = scanner.beanCandidates( + AnalysisContext.Framework.SPRING_BOOT).stream() + .filter(item -> item.candidateType().equals("demo.ShadowedService")) + .findFirst() + .orElseThrow(); + + assertEquals("Component", candidate.kind()); + assertFalse(candidate.qualifiers().contains("BeanName:shadow")); + assertEquals(List.of("@Component"), scanner.sourceAnnotations( + candidate, AnalysisContext.Framework.SPRING_BOOT)); + + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(context, AnalysisGraph.empty()); + List injections = facts.frameworkFacts().stream() + .filter(item -> item.relationshipType().equals("dependency_injection")) + .toList(); + assertTrue(injections.stream().anyMatch(item -> item.callee().equals("api.Service#"))); + assertFalse(injections.stream().anyMatch(item -> item.callee().equals("demo.ShadowedService#"))); + } + + /** Requires canonical Spring Data contracts and does not invent marker or paging methods. */ + @Test + void springRepositoryFactsRequireCanonicalContractsAndKnownMethods() throws IOException { + Path sourceRoot = temp.resolve("spring-repositories/src/main/java"); + write(sourceRoot, "demo/Account.java", "package demo; public class Account {}\n"); + write(sourceRoot, "demo/Repository.java", "package demo; public interface Repository {}\n"); + write(sourceRoot, "demo/LookalikeRepository.java", """ + package demo; + import org.springframework.stereotype.Repository; + @Repository public interface LookalikeRepository extends Repository { + String findByEmail(String email); + } + """); + write(sourceRoot, "demo/MarkerRepository.java", """ + package demo; + public interface MarkerRepository + extends org.springframework.data.repository.Repository { } + """); + write(sourceRoot, "demo/PagingRepository.java", """ + package demo; + public interface PagingRepository extends + org.springframework.data.repository.PagingAndSortingRepository { } + """); + write(sourceRoot, "demo/CustomRepository.java", """ + package demo; + public interface CustomRepository + extends org.springframework.data.repository.Repository { + static class Helper { } + /** Returns the default account lookup. */ + String findByEmail(String email); + default String delegated(String email) { return findByEmail(email); } + } + """); + write(sourceRoot, "demo/OverloadedRepository.java", """ + package demo; + public interface OverloadedRepository + extends org.springframework.data.repository.Repository { + String findByEmail(String email); + String findByEmail(int email); + Account findById(Long id); + Account findById(String id); + default String helper() { return "default"; } + static String staticHelper() { return "static"; } + private String privateHelper() { return "private"; } + } + """); + write(sourceRoot, "wildcard/WildcardRepository.java", """ + package wildcard; + import org.springframework.data.repository.*; + public interface WildcardRepository extends Repository { } + """); + write(sourceRoot, "demo/GenericRepository.java", """ + package demo; + public interface GenericRepository + extends org.springframework.data.repository.CrudRepository { + T findById(ID id); + T findById(String id); + } + """); + write(sourceRoot, "demo/CombinedRepository.java", """ + package demo; + public interface CombinedRepository extends + org.springframework.data.repository.PagingAndSortingRepository, + org.springframework.data.repository.CrudRepository { } + """); + + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze( + context( + AnalysisContext.Framework.SPRING_BOOT, + sourceRoot, + null, + List.of( + new AnalysisContext.FrameworkMetadata( + "spring-bean-annotations", + "source_annotations", + AnalysisContext.MetadataStatus.OBSERVED, + "src/main/java"), + new AnalysisContext.FrameworkMetadata( + "spring-data-repository-contract", + "source_interface_metadata", + AnalysisContext.MetadataStatus.OBSERVED, + "src/main/java"))), + AnalysisGraph.empty()); + + List repositoryFacts = facts.frameworkFacts().stream() + .filter(item -> item.relationshipType().equals("generated_implementation")) + .toList(); + assertEquals(9, repositoryFacts.size()); + SemanticCallFacts.Fact custom = repositoryFacts.stream() + .filter(item -> item.callee().startsWith("demo.CustomRepository#")) + .findFirst() + .orElseThrow(); + assertEquals("demo.CustomRepository#findByEmail(String)", custom.callee()); + assertTrue(custom.evidence().get(0).symbols().contains("declared findByEmail(String)")); + assertTrue(repositoryFacts.stream().anyMatch(item -> item.callee().equals("demo.PagingRepository#findById"))); + assertTrue(repositoryFacts.stream().anyMatch(item -> item.callee().equals( + "demo.OverloadedRepository#findByEmail(String)"))); + assertTrue(repositoryFacts.stream().anyMatch(item -> item.callee().equals( + "demo.OverloadedRepository#findByEmail(int)"))); + assertTrue(repositoryFacts.stream().anyMatch(item -> item.callee().equals( + "demo.OverloadedRepository#findById(String)"))); + assertTrue(repositoryFacts.stream().anyMatch(item -> item.callee().equals( + "demo.OverloadedRepository#findById(Long)"))); + assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().equals( + "demo.CustomRepository#findByEmail()"))); + assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().contains("#delegated"))); + assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().contains("#helper"))); + assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().contains("#staticHelper"))); + assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().contains("#privateHelper"))); + assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().contains("LookalikeRepository"))); + assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().contains("MarkerRepository"))); + assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().contains("WildcardRepository"))); + assertTrue(repositoryFacts.stream().anyMatch(item -> item.callee().equals( + "demo.GenericRepository#findById(String)"))); + assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().equals( + "demo.GenericRepository#findById(ID)"))); + + SemanticCallFacts springBoot3Facts = new JavaAnalysisAdapter().analyze( + context( + AnalysisContext.Framework.SPRING_BOOT, + sourceRoot, + null, + List.of( + new AnalysisContext.FrameworkMetadata( + "spring-bean-annotations", + "source_annotations", + AnalysisContext.MetadataStatus.OBSERVED, + "src/main/java"), + new AnalysisContext.FrameworkMetadata( + "spring-data-repository-contract", + "source_interface_metadata", + AnalysisContext.MetadataStatus.OBSERVED, + "src/main/java")), + "3.2.0"), + AnalysisGraph.empty()); + assertFalse(springBoot3Facts.frameworkFacts().stream() + .anyMatch(item -> item.callee().equals("demo.PagingRepository#findById"))); + assertTrue(springBoot3Facts.frameworkFacts().stream() + .anyMatch(item -> item.callee().equals("demo.CombinedRepository#findById"))); + } + /** Keeps fixture IDs stable only when their complete source identity is unique. */ @Test void fixtureSpecificFactIdsRequireUnambiguousFullIdentity() throws IOException { @@ -1490,6 +2110,89 @@ void springQualifierUsesBeanNamesAndResourceFallsBackToTypeOnlyWhenNameIsAbsent( assertTrue(namedResource.callee().contains("OtherService#")); } + /** Excludes qualifier lookalikes and annotation-shaped text from injection parameters. */ + @Test + void springInjectionParametersRequireCanonicalQualifiers() throws IOException { + Path sourceRoot = temp.resolve("spring-parameter-qualifiers/src/main/java"); + write(sourceRoot, "demo/Consumer.java", """ + package demo; + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.beans.factory.annotation.Qualifier; + import org.springframework.stereotype.Component; + @Component public class Consumer { + @Autowired public Consumer( + @Qualifier("fast") + @demo.Qualifier("custom") + @org.springframework.beans.factory.annotation.Value("@Qualifier(literal)") + Service service) { } + } + """); + write(sourceRoot, "demo/Qualifier.java", "package demo; public @interface Qualifier { String value(); }\n"); + write(sourceRoot, "demo/Service.java", "package demo; public interface Service { }\n"); + AnalysisContext context = context( + AnalysisContext.Framework.SPRING_BOOT, + sourceRoot, + null, + List.of(new AnalysisContext.FrameworkMetadata( + "spring-bean-annotations", "source_annotations", AnalysisContext.MetadataStatus.OBSERVED, + "src/main/java"))); + + SourceMetadataScanner.InjectionPoint injection = new SourceMetadataScanner(context) + .injectionPoints(AnalysisContext.Framework.SPRING_BOOT).stream() + .filter(item -> item.file().typeName().equals("demo.Consumer")) + .findFirst() + .orElseThrow(); + + SourceMetadataScanner.Parameter parameter = injection.parameters().get(0); + assertEquals("Service", parameter.type()); + assertEquals("service", parameter.name()); + assertEquals(List.of("Qualifier:fast"), parameter.qualifiers()); + } + + /** Preserves CDI parameter qualifiers without Spring annotation resolution. */ + @Test + void quarkusInjectionParametersPreserveNamedQualifier() throws IOException { + Path sourceRoot = temp.resolve("quarkus-parameter-qualifiers/src/main/java"); + write(sourceRoot, "demo/Consumer.java", """ + package demo; + import jakarta.inject.*; + @jakarta.enterprise.context.ApplicationScoped public class Consumer { + @Inject public Consumer(@Named("fast") Service service) { } + } + """); + write(sourceRoot, "demo/Service.java", "package demo; public interface Service { }\n"); + write(sourceRoot, "demo/FastService.java", """ + package demo; + import jakarta.inject.Named; + @jakarta.enterprise.context.ApplicationScoped @Named("fast") + public class FastService implements Service { } + """); + write(sourceRoot, "demo/OtherService.java", """ + package demo; + @jakarta.enterprise.context.ApplicationScoped public class OtherService implements Service { } + """); + AnalysisContext context = context( + AnalysisContext.Framework.QUARKUS, + sourceRoot, + null, + List.of(new AnalysisContext.FrameworkMetadata( + "quarkus-cdi-annotations", "source_annotations", AnalysisContext.MetadataStatus.OBSERVED, + "src/main/java"))); + + SourceMetadataScanner.InjectionPoint injection = new SourceMetadataScanner(context) + .injectionPoints(AnalysisContext.Framework.QUARKUS).stream() + .findFirst() + .orElseThrow(); + assertEquals(List.of("Named:fast"), injection.parameters().get(0).qualifiers()); + + SemanticCallFacts.Fact fact = new JavaAnalysisAdapter() + .analyze(context, AnalysisGraph.empty()).frameworkFacts().stream() + .filter(item -> item.relationshipType().equals("dependency_injection")) + .findFirst() + .orElseThrow(); + assertEquals("demo.FastService#", fact.callee()); + } + /** Requires active alternative metadata and normal-scope proxy eligibility. */ @Test void quarkusAlternativesNeedActiveMetadataAndDependentBeansNeedExplicitProxyTargets() @@ -5281,6 +5984,17 @@ private AnalysisContext context( AnalysisContext.FrameworkBindingMetadata.empty()); } + /** Builds a production context with an explicit framework version. */ + private AnalysisContext context( + AnalysisContext.Framework framework, + Path sourceRoot, + Path generatedRoot, + List metadata, + String frameworkVersion) { + return context(framework, sourceRoot, generatedRoot, metadata, + AnalysisContext.FrameworkBindingMetadata.empty(), frameworkVersion); + } + /** Builds a production context with optional binding metadata. */ private AnalysisContext context( AnalysisContext.Framework framework, @@ -5288,10 +6002,22 @@ private AnalysisContext context( Path generatedRoot, List metadata, AnalysisContext.FrameworkBindingMetadata bindingMetadata) { + return context(framework, sourceRoot, generatedRoot, metadata, bindingMetadata, + framework == AnalysisContext.Framework.QUARKUS ? "3.40.1" : "2.7.18"); + } + + /** Builds a production context with optional binding metadata and framework version. */ + private AnalysisContext context( + AnalysisContext.Framework framework, + Path sourceRoot, + Path generatedRoot, + List metadata, + AnalysisContext.FrameworkBindingMetadata bindingMetadata, + String frameworkVersion) { AnalysisContext.Builder builder = AnalysisContext.builder() .id(framework == AnalysisContext.Framework.QUARKUS ? "quarkus-production" : "spring-boot-production") .framework(framework) - .frameworkVersion(framework == AnalysisContext.Framework.QUARKUS ? "3.40.1" : "2.7.18") + .frameworkVersion(frameworkVersion) .fixtureId(framework == AnalysisContext.Framework.QUARKUS ? "java21-quarkus" : "fsi-spring-boot") .javaRelease(framework == AnalysisContext.Framework.QUARKUS ? 21 : 17) .repositoryRoot(temp) @@ -5304,7 +6030,12 @@ private AnalysisContext context( .provenance(AnalysisContext.Provenance.forTesting()); if (generatedRoot != null) { builder.generatedSourceRoot( - "generated-src/main/java", generatedRoot, AnalysisContext.Scope.PRODUCTION, true, true); + "generated-src/main/java", + generatedRoot, + AnalysisContext.Scope.PRODUCTION, + true, + true, + "generated-root-fingerprint"); } metadata.forEach(builder::frameworkMetadata); return builder.build(); diff --git a/tools/java-lint-maven-plugin/src/test/java/org/projectlightpath/javalint/JavaLintValidatorTest.java b/tools/java-lint-maven-plugin/src/test/java/org/projectlightpath/javalint/JavaLintValidatorTest.java index 2d96efc..fc32547 100644 --- a/tools/java-lint-maven-plugin/src/test/java/org/projectlightpath/javalint/JavaLintValidatorTest.java +++ b/tools/java-lint-maven-plugin/src/test/java/org/projectlightpath/javalint/JavaLintValidatorTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; @@ -148,6 +149,59 @@ void effectivePmdConfigurationReadsReviewedRuleAndSourceScope() throws Exception assertEquals(100, configuration.minimumTokens()); } + /** Maven defaults and build source directories remain visible when paths are not configured. */ + @Test + void effectivePmdConfigurationUsesDefaultBuildSourceScopes() throws Exception { + Path effectivePom = temp.resolve("effective-pom.xml"); + Files.writeString(effectivePom, """ + + + src/main/java + src/test/java + + maven-pmd-plugin + true + + + + """); + + JavaLintValidator.EffectivePmdConfiguration configuration = + JavaLintValidator.effectivePmdConfiguration(effectivePom, temp); + + assertEquals(List.of(), configuration.rulesets()); + assertEquals(5, configuration.minimumPriority()); + assertTrue(configuration.includeTests()); + assertEquals(List.of("src/main/java", "src/test/java"), configuration.sourceScope()); + assertEquals(100, configuration.minimumTokens()); + } + + /** Invalid effective Maven settings fail closed rather than silently changing the policy. */ + @Test + void effectivePmdConfigurationRejectsInvalidSettings() throws Exception { + Path effectivePom = temp.resolve("effective-pom.xml"); + Files.writeString(effectivePom, effectivePomWithConfiguration( + "0")); + IllegalArgumentException nonPositive = assertThrows( + IllegalArgumentException.class, + () -> JavaLintValidator.effectivePmdConfiguration(effectivePom, temp)); + assertTrue(nonPositive.getMessage().contains("must be positive")); + + Files.writeString(effectivePom, effectivePomWithConfiguration( + "not-a-number")); + IllegalArgumentException nonInteger = assertThrows( + IllegalArgumentException.class, + () -> JavaLintValidator.effectivePmdConfiguration(effectivePom, temp)); + assertTrue(nonInteger.getMessage().contains("is not an integer")); + + Files.writeString(effectivePom, effectivePomWithConfiguration( + "sometimes")); + IllegalArgumentException nonBoolean = assertThrows( + IllegalArgumentException.class, + () -> JavaLintValidator.effectivePmdConfiguration(effectivePom, temp)); + assertTrue(nonBoolean.getMessage().contains("is not boolean")); + } + /** A configuration error prevents validate from returning a passing report. */ @Test void validateFailsOnConfigurationErrors() throws Exception { @@ -227,4 +281,15 @@ private static String sha256(String value) throws Exception { return HexFormat.of().formatHex( MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + + private static String effectivePomWithConfiguration(String configuration) { + return """ + + + maven-pmd-plugin + %s + + + """.formatted(configuration); + } } diff --git a/tools/java-lint-maven-plugin/src/test/java/org/projectlightpath/javalint/ValidateJavaLintMojoTest.java b/tools/java-lint-maven-plugin/src/test/java/org/projectlightpath/javalint/ValidateJavaLintMojoTest.java new file mode 100644 index 0000000..447b533 --- /dev/null +++ b/tools/java-lint-maven-plugin/src/test/java/org/projectlightpath/javalint/ValidateJavaLintMojoTest.java @@ -0,0 +1,147 @@ +package org.projectlightpath.javalint; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Regression tests for the Maven goal's pass, failure, and report-only paths. */ +class ValidateJavaLintMojoTest { + private static final String RULESET = """ + + CognitiveComplexity CyclomaticComplexity NPathComplexity NcssCount + ExcessiveParameterList AvoidCatchingGenericException PreserveStackTrace + UnusedPrivateMethod CommentRequired + + """; + + @TempDir Path temp; + + @Test + void executePassesForAReviewedReport() throws Exception { + writeProject(temp, "", "// NOPMD: fixture inventory\nclass Fixture {}\n"); + + assertDoesNotThrow(() -> configure(temp, true).execute()); + } + + @Test + void executeFailsWhenEnforcedReportContainsANewFinding() throws Exception { + writeProject(temp, newFindingReport(), "class Fixture {}\n"); + + MojoFailureException failure = assertThrows( + MojoFailureException.class, + () -> configure(temp, true).execute()); + + assertEquals( + "Java lint gate failed: 1 new finding(s), 0 report/configuration error(s)", + failure.getMessage()); + } + + @Test + void executeReportsNewFindingsWithoutFailingInReportOnlyMode() throws Exception { + writeProject(temp, newFindingReport(), "class Fixture {}\n"); + + assertDoesNotThrow(() -> configure(temp, false).execute()); + } + + @Test + void executeWrapsUnexpectedValidationErrors() throws Exception { + MojoExecutionException failure = assertThrows( + MojoExecutionException.class, + () -> configure(temp, true).execute()); + + assertTrue(failure.getMessage().contains("Java lint report validation failed")); + assertInstanceOf(java.io.IOException.class, failure.getCause()); + } + + private static ValidateJavaLintMojo configure(Path root, boolean enforce) throws Exception { + ValidateJavaLintMojo mojo = new ValidateJavaLintMojo(); + setField(mojo, "baseDirectory", root.toFile()); + setField(mojo, "enforce", enforce); + return mojo; + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static void writeProject(Path root, String pmdReport, String source) throws Exception { + Files.createDirectories(root.resolve("config")); + Files.createDirectories(root.resolve("target")); + Files.createDirectories(root.resolve("src/main/java")); + Files.writeString(root.resolve("config/pmd-production.xml"), RULESET); + Files.writeString(root.resolve("config/java-lint-baseline.json"), baseline()); + Files.writeString(root.resolve("target/pmd.xml"), pmdReport); + Files.writeString(root.resolve("target/cpd.xml"), ""); + Files.writeString(root.resolve("target/java-lint-effective-pom.xml"), effectivePom()); + Files.writeString(root.resolve("src/main/java/Fixture.java"), source); + } + + private static String baseline() throws Exception { + return """ + { + "schemaVersion": 1, + "pmd": { + "findings": [], + "includeTests": false, + "minimumPriority": 3, + "pmdVersion": "7.17.0", + "rulesetSha256": "%s", + "rulesets": ["config/pmd-production.xml"], + "sourceScope": ["src/main/java"] + }, + "cpd": {"findings": [], "minimumTokens": 100, "pmdVersion": "7.17.0"} + } + """.formatted(sha256(RULESET)); + } + + private static String effectivePom() { + return """ + + + src/main/java + + org.apache.maven.plugins + maven-pmd-plugin + + config/pmd-production.xml + false + 3 + 100 + + + + + """; + } + + private static String newFindingReport() { + return """ + + + new finding + + + """; + } + + private static String sha256(String value) throws Exception { + return HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); + } +}