From 170c3f4a2f4d8a5d8b9ad3f47d7a06abd4f4dda9 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Wed, 11 Nov 2026 22:04:00 -0500 Subject: [PATCH 01/15] Add Spring generated-root enrichment guards --- .github/workflows/ci.yml | 4 +- Makefile | 4 +- README.md | 8 +- config/java-lint-baseline.json | 42 ++--- docs/flpath-5002-java-adapter.md | 23 +++ docs/java-lint.md | 11 +- spike/README.md | 4 +- .../analysis/java/BoundedFrameworkModel.java | 154 ++++++++++-------- .../analysis/java/SourceMetadataScanner.java | 146 ++++++++++++++++- .../java/JavaAnalysisAdapterTest.java | 152 ++++++++++++++++- .../javalint/JavaLintValidatorTest.java | 65 ++++++++ .../javalint/ValidateJavaLintMojoTest.java | 147 +++++++++++++++++ 12 files changed, 653 insertions(+), 107 deletions(-) create mode 100644 tools/java-lint-maven-plugin/src/test/java/org/projectlightpath/javalint/ValidateJavaLintMojoTest.java 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..efa2074 100644 --- a/config/java-lint-baseline.json +++ b/config/java-lint-baseline.json @@ -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": "32d56df204162b5e8fa90ab279f3d464dfa5d6a739f385bb7c4731c256554330", + "message": "Possible God Class (WMC=1,407, ATFD=77, TCC=0.051%)", "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": "029ca5cffd39002eb8d9d78bb53d6ac51cded87b1ed90a289dcb4c2987d94769", + "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2681 (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": "c5bb937251596a46d18b9a41433b3f7ee5f8aff1c7ed2878b0862d92bef94722", + "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1407 (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", @@ -756,6 +745,17 @@ "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": "dd1297170823a449c478edd87f7d4fd399526eed7cb6f9147b14cb348ec86345", + "message": "The method 'beanCandidates(Framework)' has a cognitive complexity of 20, 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": "" } ], "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/BoundedFrameworkModel.java b/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java index dad542f..fe20038 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,36 @@ 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( - "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."))); + String repositoryContract = scanner.springRepositoryContract(candidate.file()).orElse("Repository"); + for (SourceMetadataScanner.RepositoryMethod repositoryMethod + : scanner.springRepositoryMethods(candidate.file())) { + String methodName = repositoryMethod.name(); + String id = springRepositoryFactId(scanner, candidates, candidate, methodName); + List symbols = new ArrayList<>(List.of("extends " + repositoryContract)); + symbols.add(repositoryMethod.guaranteed() + ? "CrudRepository#" + methodName + : "declared " + methodName); + symbols.add(methodName); + facts.add(SemanticCallFacts.Fact.framework( + id, + context, + new SemanticCallFacts.Fact.FrameworkInput( + "spring-data-proxy:" + candidate.file().typeName(), + candidate.file().typeName() + "#" + methodName, + "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", methodName))), + "Spring Data implementation and proxy identity are generated outside application source."))); + } } return facts; } @@ -697,57 +710,60 @@ 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(); + 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, lifecycle.kind()), + context, + new SemanticCallFacts.Fact.FrameworkInput( + runner ? "Spring Boot application lifecycle" : "Spring ApplicationContext", + file.typeName() + "#" + 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 = context.generatedSourceRoots().stream() + .filter(candidate -> generated.file().path().startsWith(candidate.path())) + .findFirst(); + 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 +1740,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 +1762,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); } diff --git a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java index 966e85f..6634d22 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java +++ b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java @@ -156,6 +156,17 @@ final class SourceMetadataScanner { "Transactional", "Async", "Cacheable", "CachePut", "CacheEvict", "PreAuthorize", "PostAuthorize", "Secured", "RolesAllowed", "Retryable", "CircuitBreaker", "Validated", "TransactionalEventListener"); + 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", "ListCrudRepository"); private static final List SUPPORTED_HTTP_METHODS = List.of("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"); @@ -706,16 +717,17 @@ 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(block) || repositoryInterface; if (bean) { Set contracts = contracts(file); result.add(new BeanCandidate( file, contracts, - beanKind(block, framework), + repositoryInterface ? "repository_interface" : beanKind(block, framework), file.typeName(), file.typeName(), qualifierNames(block), @@ -757,6 +769,126 @@ List beanCandidates(AnalysisContext.Framework framework) { return List.copyOf(result); } + /** Finds bounded Spring Data repository interfaces, including annotated repositories. */ + Optional springRepositoryContract(SourceFile file) { + String header = maskStringLiterals(typeHeader(file)); + if (!header.matches("(?s).*\\binterface\\s+\\w+\\b.*")) { + return Optional.empty(); + } + 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()); + return typeNames(extended).stream() + .map(type -> resolveKnownFrameworkType(type, file, SPRING_DATA_REPOSITORY_CONTRACTS.keySet())) + .flatMap(Optional::stream) + .map(SPRING_DATA_REPOSITORY_CONTRACTS::get) + .findFirst(); + } + + /** Returns repository methods guaranteed by the contract or declared by the source interface. */ + List springRepositoryMethods(SourceFile file) { + Optional contract = springRepositoryContract(file); + if (contract.isEmpty()) { + return List.of(); + } + Map result = new LinkedHashMap<>(); + if (SPRING_DATA_FIND_BY_ID_CONTRACTS.contains(contract.get())) { + result.put("findById", new RepositoryMethod("findById", true)); + } + for (MethodInfo method : methods(file)) { + if (typeContaining(file, method.start()).equals(file.typeName())) { + result.putIfAbsent(method.name(), new RepositoryMethod(method.name(), false)); + } + } + return List.copyOf(result.values()); + } + + /** 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<>(); + for (SourceFile file : files()) { + boolean springBean = springComponent(file.classAnnotationBlock()); + boolean commandLineRunner = springBean + && implementsNamedInterface(file, SPRING_COMMAND_LINE_RUNNER); + for (MethodInfo method : methods(file)) { + if (method.hasAnnotation("Bean")) { + result.add(new LifecycleMethod( + file, + method, + "bean", + lifecycleAnnotationSymbols(file, method))); + } + if (commandLineRunner + && method.name().equals("run") + && commandLineRunnerParameters(method.parameters())) { + result.add(new LifecycleMethod( + file, + method, + "runner", + lifecycleAnnotationSymbols(file, method))); + } + } + } + return List.copyOf(result); + } + + /** Returns whether a class header implements the named interface. */ + private boolean implementsNamedInterface(SourceFile file, String interfaceName) { + String header = maskStringLiterals(typeHeader(file)); + 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) { + List result = new ArrayList<>(); + result.addAll(annotationSources(file.classAnnotationBlock(), List.of( + "Component", "Service", "Repository", "Configuration", "Controller", + "RestController", "Named"))); + result.addAll(annotationSources(method.annotationBlock(), List.of("Bean"))); + return List.copyOf(new LinkedHashSet<>(result)); + } + /** Scans framework proxy targets and records CDI scope eligibility. */ List proxyTargets(AnalysisContext.Framework framework) { List result = new ArrayList<>(); @@ -4919,5 +5051,13 @@ private record ConstructorInfo(String annotations, String parameters, int start) record GeneratedMethod(SourceFile file, MethodInfo method) {} + record LifecycleMethod( + SourceFile file, + MethodInfo method, + String kind, + List annotationSymbols) {} + + record RepositoryMethod(String name, boolean guaranteed) {} + 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..efaeaf9 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()); @@ -195,7 +197,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 +243,150 @@ 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) { } + } + """); + 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/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(); } + } + """); + 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) { } + } + """); + + 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(2, 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"))); + assertFalse(lifecycleFacts.stream().anyMatch(item -> item.callee().contains("UnregisteredRunner"))); + assertFalse(lifecycleFacts.stream().anyMatch(item -> item.callee().contains("Lookalike"))); + assertFalse(lifecycleFacts.stream().anyMatch(item -> item.callee().contains("LookalikeRunner"))); + assertTrue(lifecycleFacts.stream().allMatch(item -> item.factSet() == SemanticCallFacts.FactSet.FRAMEWORK)); + assertTrue(facts.providerFacts().isEmpty()); + } + + /** 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 { + String findByEmail(String email); + } + """); + + 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(1, repositoryFacts.size()); + SemanticCallFacts.Fact custom = repositoryFacts.get(0); + assertEquals("demo.CustomRepository#findByEmail", custom.callee()); + assertTrue(custom.evidence().get(0).symbols().contains("declared findByEmail")); + assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().contains("findById"))); + 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("PagingRepository"))); + } + /** Keeps fixture IDs stable only when their complete source identity is unique. */ @Test void fixtureSpecificFactIdsRequireUnambiguousFullIdentity() throws IOException { @@ -5304,7 +5447,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))); + } +} From 332d8ffd20a386ff0a2b99b94620e175c15daf2c Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Wed, 11 Nov 2026 22:51:40 -0500 Subject: [PATCH 02/15] Harden Spring repository and lifecycle guards --- .../analysis/java/BoundedFrameworkModel.java | 11 ++-- .../analysis/java/SourceMetadataScanner.java | 39 ++++++++++++-- .../java/JavaAnalysisAdapterTest.java | 53 ++++++++++++++++--- 3 files changed, 87 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java b/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java index fe20038..2922c34 100644 --- a/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java +++ b/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java @@ -672,18 +672,19 @@ private List springRepositoryFacts( for (SourceMetadataScanner.RepositoryMethod repositoryMethod : scanner.springRepositoryMethods(candidate.file())) { String methodName = repositoryMethod.name(); - String id = springRepositoryFactId(scanner, candidates, candidate, methodName); + 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 " + 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() + "#" + methodName, + "spring-data-proxy:" + candidate.file().typeName(), + candidate.file().typeName() + "#" + methodIdentity, "generated_implementation", certainty, "spring-data-generated-repository", @@ -695,7 +696,7 @@ private List springRepositoryFacts( Map.of( "generatedImplementation", "outside application source", "repositoryContract", repositoryContract, - "repositoryMethod", methodName))), + "repositoryMethod", methodIdentity))), "Spring Data implementation and proxy identity are generated outside application source."))); } } diff --git a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java index 6634d22..a55bed3 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java +++ b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java @@ -166,7 +166,8 @@ final class SourceMetadataScanner { "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", "ListCrudRepository"); + "CrudRepository", "JpaRepository", "PagingAndSortingRepository", "ListCrudRepository", + "ListPagingAndSortingRepository"); private static final List SUPPORTED_HTTP_METHODS = List.of("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"); @@ -797,16 +798,37 @@ List springRepositoryMethods(SourceFile file) { } Map result = new LinkedHashMap<>(); if (SPRING_DATA_FIND_BY_ID_CONTRACTS.contains(contract.get())) { - result.put("findById", new RepositoryMethod("findById", true)); + result.put("findById", new RepositoryMethod("findById", "findById", true)); } for (MethodInfo method : methods(file)) { - if (typeContaining(file, method.start()).equals(file.typeName())) { - result.putIfAbsent(method.name(), new RepositoryMethod(method.name(), false)); + if (typeContaining(file, method.start()).equals(file.typeName()) + && abstractInstanceRepositoryMethod(method) + && !method.name().equals("findById")) { + RepositoryMethod repositoryMethod = new RepositoryMethod( + method.name(), repositoryMethodSignature(method), false); + result.putIfAbsent(repositoryMethod.identity(), repositoryMethod); } } return List.copyOf(result.values()); } + /** Keeps only abstract instance methods that Spring Data can implement from an interface. */ + private static boolean abstractInstanceRepositoryMethod(MethodInfo method) { + String declaration = maskAnnotationArguments(method.annotationBlock()); + 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) { @@ -833,6 +855,9 @@ List springLifecycleMethods() { && implementsNamedInterface(file, SPRING_COMMAND_LINE_RUNNER); for (MethodInfo method : methods(file)) { if (method.hasAnnotation("Bean")) { + if (!springBean) { + continue; + } result.add(new LifecycleMethod( file, method, @@ -5057,7 +5082,11 @@ record LifecycleMethod( String kind, List annotationSymbols) {} - record RepositoryMethod(String name, boolean guaranteed) {} + 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 efaeaf9..af695a3 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -270,6 +270,13 @@ 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; @@ -296,6 +303,14 @@ public interface 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) { } + } + """); AnalysisContext context = context( AnalysisContext.Framework.SPRING_BOOT, @@ -319,8 +334,10 @@ public void run(String... args) { } assertTrue(lifecycleFacts.stream().anyMatch(item -> item.callee().equals("demo.BeanFactory#suppliedBean") && item.evidence().get(0).symbols().contains("@Bean suppliedBean"))); 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("Lookalike"))); assertFalse(lifecycleFacts.stream().anyMatch(item -> item.callee().contains("LookalikeRunner"))); + assertFalse(lifecycleFacts.stream().anyMatch(item -> item.callee().contains("WildcardRunner"))); assertTrue(lifecycleFacts.stream().allMatch(item -> item.factSet() == SemanticCallFacts.FactSet.FRAMEWORK)); assertTrue(facts.providerFacts().isEmpty()); } @@ -355,6 +372,21 @@ public interface CustomRepository String findByEmail(String 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); + default String helper() { return "default"; } + static String staticHelper() { return "static"; } + } + """); + write(sourceRoot, "wildcard/WildcardRepository.java", """ + package wildcard; + import org.springframework.data.repository.*; + public interface WildcardRepository extends Repository { } + """); SemanticCallFacts facts = new JavaAnalysisAdapter().analyze( context( @@ -377,14 +409,23 @@ public interface CustomRepository List repositoryFacts = facts.frameworkFacts().stream() .filter(item -> item.relationshipType().equals("generated_implementation")) .toList(); - assertEquals(1, repositoryFacts.size()); - SemanticCallFacts.Fact custom = repositoryFacts.get(0); - assertEquals("demo.CustomRepository#findByEmail", custom.callee()); - assertTrue(custom.evidence().get(0).symbols().contains("declared findByEmail")); - assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().contains("findById"))); + assertEquals(4, 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)"))); + 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("LookalikeRepository"))); assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().contains("MarkerRepository"))); - assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().contains("PagingRepository"))); + assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().contains("WildcardRepository"))); } /** Keeps fixture IDs stable only when their complete source identity is unique. */ From 449a5db86dae124fdead43cc11afd87b3b62ae35 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Wed, 11 Nov 2026 23:09:22 -0500 Subject: [PATCH 03/15] Refresh scanner lint fingerprints --- config/java-lint-baseline.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json index efa2074..4f37b65 100644 --- a/config/java-lint-baseline.json +++ b/config/java-lint-baseline.json @@ -178,8 +178,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "32d56df204162b5e8fa90ab279f3d464dfa5d6a739f385bb7c4731c256554330", - "message": "Possible God Class (WMC=1,407, ATFD=77, TCC=0.051%)", + "fingerprint": "2f24f53f510118bbf540184cb70f8a5fbf4c4665be50ccf507a873337eb37963", + "message": "Possible God Class (WMC=1,412, ATFD=77, TCC=0.050%)", "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", @@ -189,8 +189,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "029ca5cffd39002eb8d9d78bb53d6ac51cded87b1ed90a289dcb4c2987d94769", - "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2681 (Highest = 68).", + "fingerprint": "3a39186400685d71c7e369de092f53afcdb01c48b361afb94ad791031d1142ac", + "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2692 (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", @@ -200,8 +200,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "c5bb937251596a46d18b9a41433b3f7ee5f8aff1c7ed2878b0862d92bef94722", - "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1407 (highest 37).", + "fingerprint": "3f53182dfbc92995e744bacbf0d8e73efca2b378c1cf6c015cb6652589e9ec98", + "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1412 (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", From 4bdb0ec49dac25baa069768a93dc4ee3d383ecd0 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Wed, 11 Nov 2026 23:30:19 -0500 Subject: [PATCH 04/15] Preserve declared repository overloads --- .../analysis/java/SourceMetadataScanner.java | 3 +-- .../analysis/java/JavaAnalysisAdapterTest.java | 7 +++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java index a55bed3..d0adf11 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java +++ b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java @@ -802,8 +802,7 @@ List springRepositoryMethods(SourceFile file) { } for (MethodInfo method : methods(file)) { if (typeContaining(file, method.start()).equals(file.typeName()) - && abstractInstanceRepositoryMethod(method) - && !method.name().equals("findById")) { + && abstractInstanceRepositoryMethod(method)) { RepositoryMethod repositoryMethod = new RepositoryMethod( method.name(), repositoryMethodSignature(method), false); result.putIfAbsent(repositoryMethod.identity(), repositoryMethod); diff --git a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java index af695a3..238ad88 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -375,9 +375,10 @@ public interface CustomRepository write(sourceRoot, "demo/OverloadedRepository.java", """ package demo; public interface OverloadedRepository - extends org.springframework.data.repository.Repository { + extends org.springframework.data.repository.Repository { String findByEmail(String email); String findByEmail(int email); + Account findById(String id); default String helper() { return "default"; } static String staticHelper() { return "static"; } } @@ -409,7 +410,7 @@ public interface WildcardRepository extends Repository { } List repositoryFacts = facts.frameworkFacts().stream() .filter(item -> item.relationshipType().equals("generated_implementation")) .toList(); - assertEquals(4, repositoryFacts.size()); + assertEquals(5, repositoryFacts.size()); SemanticCallFacts.Fact custom = repositoryFacts.stream() .filter(item -> item.callee().startsWith("demo.CustomRepository#")) .findFirst() @@ -421,6 +422,8 @@ public interface WildcardRepository extends Repository { } "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)"))); 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("LookalikeRepository"))); From cd0ebde86cb69f6f243f9c29f818188a03397515 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Wed, 11 Nov 2026 23:45:29 -0500 Subject: [PATCH 05/15] Refresh scanner complexity fingerprints --- config/java-lint-baseline.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json index 4f37b65..8e6fc02 100644 --- a/config/java-lint-baseline.json +++ b/config/java-lint-baseline.json @@ -178,8 +178,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "2f24f53f510118bbf540184cb70f8a5fbf4c4665be50ccf507a873337eb37963", - "message": "Possible God Class (WMC=1,412, ATFD=77, TCC=0.050%)", + "fingerprint": "ae23ced37b9aac869953c26052c4fac105490dad3dc5f806eaa82fd2a67d587e", + "message": "Possible God Class (WMC=1,411, ATFD=77, TCC=0.050%)", "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 +200,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "3f53182dfbc92995e744bacbf0d8e73efca2b378c1cf6c015cb6652589e9ec98", - "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1412 (highest 37).", + "fingerprint": "a63b6ff634295785098298217448032b847fc4381ba408fab1885d80c951499c", + "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1411 (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", From 58bfb8a975c577eb124004e2512167987320b5b9 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 08:12:40 -0500 Subject: [PATCH 06/15] Harden Spring lifecycle and repository identity --- .../analysis/java/SourceMetadataScanner.java | 75 ++++++++++++++++++- .../java/JavaAnalysisAdapterTest.java | 19 +++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java index d0adf11..31a2639 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java +++ b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java @@ -744,7 +744,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; } @@ -802,7 +802,8 @@ List springRepositoryMethods(SourceFile file) { } for (MethodInfo method : methods(file)) { if (typeContaining(file, method.start()).equals(file.typeName()) - && abstractInstanceRepositoryMethod(method)) { + && abstractInstanceRepositoryMethod(method) + && !redeclaresInheritedFindById(file, method)) { RepositoryMethod repositoryMethod = new RepositoryMethod( method.name(), repositoryMethodSignature(method), false); result.putIfAbsent(repositoryMethod.identity(), repositoryMethod); @@ -811,6 +812,47 @@ && abstractInstanceRepositoryMethod(method)) { return List.copyOf(result.values()); } + /** 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 resolveReferenceType(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 = resolveReferenceType(parameters.get(1), file); + if (resolved.isPresent()) { + return resolved; + } + } + } + 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(method.annotationBlock()); @@ -853,7 +895,10 @@ List springLifecycleMethods() { boolean commandLineRunner = springBean && implementsNamedInterface(file, SPRING_COMMAND_LINE_RUNNER); for (MethodInfo method : methods(file)) { - if (method.hasAnnotation("Bean")) { + if (!typeContaining(file, method.start()).equals(file.typeName())) { + continue; + } + if (springBeanAnnotation(file, method.annotationBlock())) { if (!springBean) { continue; } @@ -913,6 +958,20 @@ private static List lifecycleAnnotationSymbols(SourceFile file, MethodIn return List.copyOf(new LinkedHashSet<>(result)); } + /** Recognizes only the canonical Spring {@code @Bean} annotation. */ + private static boolean springBeanAnnotation(SourceFile file, String block) { + List explicitImports = importedTypes("Bean", file); + boolean canonicalImport = explicitImports.size() == 1 + && explicitImports.get(0).equals("org.springframework.context.annotation.Bean"); + return annotationOccurrences(block, "Bean").stream().anyMatch(annotation -> { + String source = annotation.source(); + int arguments = source.indexOf('('); + String name = arguments < 0 ? source : source.substring(0, arguments); + return name.equals("@org.springframework.context.annotation.Bean") + || (name.equals("@Bean") && canonicalImport); + }); + } + /** Scans framework proxy targets and records CDI scope eligibility. */ List proxyTargets(AnalysisContext.Framework framework) { List result = new ArrayList<>(); @@ -4785,6 +4844,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; diff --git a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java index 238ad88..fac04fa 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -261,6 +261,9 @@ void springLifecycleFactsRequireBeanEvidenceAndRunnerSignature() throws IOExcept @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", """ @@ -283,6 +286,17 @@ public class UnregisteredFactory { import org.springframework.stereotype.Component; @Component public class BeanFactory { @Bean public Object suppliedBean() { return new Object(); } + static class NestedFactory { + @Bean public Object nestedBean() { return new Object(); } + } + } + """); + 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", """ @@ -378,9 +392,11 @@ 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", """ @@ -424,8 +440,11 @@ public interface WildcardRepository extends Repository { } "demo.OverloadedRepository#findByEmail(int)"))); assertTrue(repositoryFacts.stream().anyMatch(item -> item.callee().equals( "demo.OverloadedRepository#findById(String)"))); + assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().equals( + "demo.OverloadedRepository#findById(Long)"))); 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"))); From 324d60e5a84d55df5d1dd118f5df86eb65f892db Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 08:30:11 -0500 Subject: [PATCH 07/15] Refresh scanner lint fingerprints --- config/java-lint-baseline.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json index 8e6fc02..6755dc1 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, @@ -178,8 +178,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "ae23ced37b9aac869953c26052c4fac105490dad3dc5f806eaa82fd2a67d587e", - "message": "Possible God Class (WMC=1,411, ATFD=77, TCC=0.050%)", + "fingerprint": "7fb05bd55e0ad9a49a5f6875d92215658d8fe8ceccbcffc99d54aef9e1c49984", + "message": "Possible God Class (WMC=1,428, ATFD=81, TCC=0.052%)", "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", @@ -189,8 +189,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "3a39186400685d71c7e369de092f53afcdb01c48b361afb94ad791031d1142ac", - "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2692 (Highest = 68).", + "fingerprint": "d56d8215b412d97bda1b344cd31388a2d6f0e927ec24abe6f9ce6c2ddd6d5a6c", + "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2729 (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", @@ -200,8 +200,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "a63b6ff634295785098298217448032b847fc4381ba408fab1885d80c951499c", - "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1411 (highest 37).", + "fingerprint": "3e2d2407b7904776a72ca7df38f2aef7152714031d47eac6cc8c0fa092cb1ec5", + "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1428 (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", From c9f12b9ac3ad1d9ff99a20069e72cfe1c765dfdd Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 09:32:12 -0500 Subject: [PATCH 08/15] Harden Spring roots and generic identity --- .../analysis/java/AnalysisContext.java | 31 +++++-- .../analysis/java/BoundedFrameworkModel.java | 5 +- .../java/SootUpBaseGraphProvider.java | 3 +- .../analysis/java/SourceMetadataScanner.java | 81 +++++++++++------ .../java/JavaAnalysisAdapterTest.java | 86 ++++++++++++++++++- 5 files changed, 167 insertions(+), 39 deletions(-) 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 2922c34..61ab28f 100644 --- a/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java +++ b/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java @@ -750,9 +750,8 @@ private static Map generatedRootAttributes( AnalysisContext context, SourceMetadataScanner.GeneratedMethod generated) { Map attributes = new HashMap<>(); - Optional root = context.generatedSourceRoots().stream() - .filter(candidate -> generated.file().path().startsWith(candidate.path())) - .findFirst(); + Optional root = AnalysisContext.mostSpecificRoot( + generated.file().path(), context.generatedSourceRoots()); attributes.put("sourceRootRequired", root.map(item -> Boolean.toString(item.required())).orElse("true")); root.ifPresent(item -> { attributes.put("sourceRoot", item.declaredPath()); 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 31a2639..e252594 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java +++ b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java @@ -185,8 +185,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; } @@ -194,12 +195,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; @@ -627,7 +631,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*" @@ -722,7 +726,7 @@ List beanCandidates(AnalysisContext.Framework framework) { && springRepositoryContract(file).isPresent(); boolean bean = framework == AnalysisContext.Framework.QUARKUS ? cdiBean(block) - : springComponent(block) || repositoryInterface; + : springComponent(file) || repositoryInterface; if (bean) { Set contracts = contracts(file); result.add(new BeanCandidate( @@ -822,7 +826,7 @@ private boolean redeclaresInheritedFindById(SourceFile file, MethodInfo method) if (repositoryIdType.isEmpty() || methodParameters.size() != 1) { return false; } - return resolveReferenceType(methodParameters.get(0).type(), file) + return repositoryTypeToken(methodParameters.get(0).type(), file) .filter(repositoryIdType.get()::equals) .isPresent(); } @@ -844,7 +848,7 @@ private Optional springRepositoryIdType(SourceFile file) { String arguments = genericArguments(type); List parameters = splitArguments(arguments); if (parameters.size() >= 2) { - Optional resolved = resolveReferenceType(parameters.get(1), file); + Optional resolved = repositoryTypeToken(parameters.get(1), file); if (resolved.isPresent()) { return resolved; } @@ -853,6 +857,20 @@ private Optional springRepositoryIdType(SourceFile file) { 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(method.annotationBlock()); @@ -891,7 +909,7 @@ private Optional resolveKnownFrameworkType( List springLifecycleMethods() { List result = new ArrayList<>(); for (SourceFile file : files()) { - boolean springBean = springComponent(file.classAnnotationBlock()); + boolean springBean = springComponent(file); boolean commandLineRunner = springBean && implementsNamedInterface(file, SPRING_COMMAND_LINE_RUNNER); for (MethodInfo method : methods(file)) { @@ -960,16 +978,7 @@ private static List lifecycleAnnotationSymbols(SourceFile file, MethodIn /** Recognizes only the canonical Spring {@code @Bean} annotation. */ private static boolean springBeanAnnotation(SourceFile file, String block) { - List explicitImports = importedTypes("Bean", file); - boolean canonicalImport = explicitImports.size() == 1 - && explicitImports.get(0).equals("org.springframework.context.annotation.Bean"); - return annotationOccurrences(block, "Bean").stream().anyMatch(annotation -> { - String source = annotation.source(); - int arguments = source.indexOf('('); - String name = arguments < 0 ? source : source.substring(0, arguments); - return name.equals("@org.springframework.context.annotation.Bean") - || (name.equals("@Bean") && canonicalImport); - }); + return canonicalAnnotation(file, block, "Bean", "org.springframework.context.annotation.Bean"); } /** Scans framework proxy targets and records CDI scope eligibility. */ @@ -979,7 +988,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; } @@ -4122,14 +4131,17 @@ private static void addSymbols(List symbols, String block, String... nam } /** 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) { + String block = file.classAnnotationBlock(); + 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"); } /** Recognizes CDI scopes, stereotypes, and named beans. */ @@ -4526,6 +4538,19 @@ 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) { + Set canonical = Set.of(canonicalNames); + List explicitImports = importedTypes(simpleName, file); + boolean canonicalImport = explicitImports.stream().anyMatch(canonical::contains); + return annotationOccurrences(block, simpleName).stream() + .map(AnnotationOccurrence::source) + .map(SourceMetadataScanner::annotationType) + .anyMatch(type -> canonical.contains(type) + || type.equals(simpleName) && canonicalImport); + } + /** 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)); diff --git a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java index fac04fa..084ce72 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -170,6 +170,57 @@ 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")); + } + /** Covers Spring MVC, injection, proxy, repository, and lifecycle facts. */ @Test void springFactsCoverMvcInjectionProxyRepositoryAndLifecycle() throws IOException { @@ -325,6 +376,21 @@ public void run(String... args) { } 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, @@ -352,6 +418,12 @@ public void run(String... args) { } 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()); } @@ -404,6 +476,14 @@ public interface OverloadedRepository 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); + } + """); SemanticCallFacts facts = new JavaAnalysisAdapter().analyze( context( @@ -426,7 +506,7 @@ public interface WildcardRepository extends Repository { } List repositoryFacts = facts.frameworkFacts().stream() .filter(item -> item.relationshipType().equals("generated_implementation")) .toList(); - assertEquals(5, repositoryFacts.size()); + assertEquals(7, repositoryFacts.size()); SemanticCallFacts.Fact custom = repositoryFacts.stream() .filter(item -> item.callee().startsWith("demo.CustomRepository#")) .findFirst() @@ -448,6 +528,10 @@ public interface WildcardRepository extends Repository { } 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)"))); } /** Keeps fixture IDs stable only when their complete source identity is unique. */ From 238c9a5888e837fdeeb42a2ae9b095eefb3d545b Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 09:45:27 -0500 Subject: [PATCH 09/15] Refresh scanner lint fingerprints --- config/java-lint-baseline.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json index 6755dc1..bbae0d7 100644 --- a/config/java-lint-baseline.json +++ b/config/java-lint-baseline.json @@ -178,8 +178,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "7fb05bd55e0ad9a49a5f6875d92215658d8fe8ceccbcffc99d54aef9e1c49984", - "message": "Possible God Class (WMC=1,428, ATFD=81, TCC=0.052%)", + "fingerprint": "4e22acd08e0b119dce908a9a10fc9b10af9314e97e1ba8789ec51aa252d2fad5", + "message": "Possible God Class (WMC=1,434, ATFD=85, TCC=0.051%)", "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", @@ -189,8 +189,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "d56d8215b412d97bda1b344cd31388a2d6f0e927ec24abe6f9ce6c2ddd6d5a6c", - "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2729 (Highest = 68).", + "fingerprint": "f50db66e8c45bb764b2543c03bd596f9a64c6b5aeb8af407a53768a186d8a65b", + "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2742 (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", @@ -200,8 +200,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "3e2d2407b7904776a72ca7df38f2aef7152714031d47eac6cc8c0fa092cb1ec5", - "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1428 (highest 37).", + "fingerprint": "6a4e883b46d0a686792d7e43e5b0a1ae4951b817791f5426ba257e16cd4e6bb4", + "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1434 (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", From 520313353d1521f3f03c1fc1388da209fd73aac5 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 11:55:15 -0500 Subject: [PATCH 10/15] Harden generated roots and Spring annotations --- config/java-lint-baseline.json | 24 +- .../analysis/java/BoundedFrameworkModel.java | 4 +- .../analysis/java/SourceMetadataScanner.java | 216 ++++++++++++++---- .../java/JavaAnalysisAdapterTest.java | 161 +++++++++++++ 4 files changed, 344 insertions(+), 61 deletions(-) diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json index bbae0d7..cd6ffd0 100644 --- a/config/java-lint-baseline.json +++ b/config/java-lint-baseline.json @@ -178,8 +178,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "4e22acd08e0b119dce908a9a10fc9b10af9314e97e1ba8789ec51aa252d2fad5", - "message": "Possible God Class (WMC=1,434, ATFD=85, TCC=0.051%)", + "fingerprint": "5e6b5fc8813de68c44b0318bc7a0aea8e308a1b5cdc33025b6f0d8b94d8f2401", + "message": "Possible God Class (WMC=1,453, ATFD=88, TCC=0.047%)", "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", @@ -189,8 +189,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "f50db66e8c45bb764b2543c03bd596f9a64c6b5aeb8af407a53768a186d8a65b", - "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2742 (Highest = 68).", + "fingerprint": "97550cfce0eaaf13ed88b43e4e31ee63f6a79eceab6eb1e010218311a44c8fe5", + "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2777 (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", @@ -200,8 +200,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "6a4e883b46d0a686792d7e43e5b0a1ae4951b817791f5426ba257e16cd4e6bb4", - "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1434 (highest 37).", + "fingerprint": "2bdac4386cfab5fa16e3eef653881b3f83acb85b72200345082b0d113f8974c9", + "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1453 (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", @@ -211,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", @@ -233,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", @@ -596,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", diff --git a/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java b/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java index 61ab28f..8efb9f2 100644 --- a/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java +++ b/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java @@ -751,7 +751,9 @@ private static Map generatedRootAttributes( SourceMetadataScanner.GeneratedMethod generated) { Map attributes = new HashMap<>(); Optional root = AnalysisContext.mostSpecificRoot( - generated.file().path(), context.generatedSourceRoots()); + 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()); diff --git a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java index e252594..eed737e 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java +++ b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java @@ -152,10 +152,37 @@ final class SourceMetadataScanner { "boolean", "byte", "char", "short", "int", "long", "float", "double", "void"); private static final Set 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 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( @@ -527,18 +554,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; } @@ -546,12 +574,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; } @@ -559,13 +587,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()) { @@ -577,26 +605,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) { @@ -615,7 +643,7 @@ && hasExplicitMappingPath(method.annotationBlock(), annotationName)) { produces, headers, parameters, - routeSymbols(classBlock, method.annotationBlock(), framework, annotationName))); + routeSymbols(file, classBlock, method.annotationBlock(), framework, annotationName))); } } } @@ -996,8 +1024,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, @@ -3941,13 +3971,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); } } @@ -3955,7 +3988,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"); @@ -3966,8 +3999,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; @@ -4013,12 +4047,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) { @@ -4034,11 +4069,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); } @@ -4109,15 +4144,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); } @@ -4130,6 +4173,16 @@ 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(SourceFile file) { String block = file.classAnnotationBlock(); @@ -4452,9 +4505,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)); } @@ -4469,6 +4529,18 @@ 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) { List result = new ArrayList<>(); @@ -4541,14 +4613,34 @@ private static boolean hasAnnotation(String block, String name) { /** Recognizes an annotation only when its source spelling resolves canonically. */ private static boolean canonicalAnnotation( SourceFile file, String block, String simpleName, String... canonicalNames) { - Set canonical = Set.of(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, + Set.copyOf(SPRING_ROUTE_ANNOTATION_TYPES.getOrDefault( + simpleName, + SPRING_PROXY_ANNOTATION_TYPES.getOrDefault(simpleName, List.of())))); + } + + 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() - .map(AnnotationOccurrence::source) - .map(SourceMetadataScanner::annotationType) - .anyMatch(type -> canonical.contains(type) - || type.equals(simpleName) && canonicalImport); + .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. */ @@ -4616,8 +4708,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; @@ -4642,8 +4744,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); @@ -4680,12 +4792,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))) { diff --git a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java index 084ce72..b7d0d1a 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -221,6 +221,84 @@ public class GeneratedTransferMapper { 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 { @@ -428,6 +506,89 @@ public void run(String... args) { } 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"))); + } + /** Requires canonical Spring Data contracts and does not invent marker or paging methods. */ @Test void springRepositoryFactsRequireCanonicalContractsAndKnownMethods() throws IOException { From 88affe2514d54f1f8fbf2c7067cfbc35c5eec90e Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 13:47:08 -0500 Subject: [PATCH 11/15] Address CodeRabbit scanner findings --- config/java-lint-baseline.json | 12 +-- .../analysis/java/SourceMetadataScanner.java | 65 +++++++++++++-- .../java/JavaAnalysisAdapterTest.java | 80 ++++++++++++++++++- 3 files changed, 139 insertions(+), 18 deletions(-) diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json index cd6ffd0..b12edc8 100644 --- a/config/java-lint-baseline.json +++ b/config/java-lint-baseline.json @@ -178,8 +178,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "5e6b5fc8813de68c44b0318bc7a0aea8e308a1b5cdc33025b6f0d8b94d8f2401", - "message": "Possible God Class (WMC=1,453, ATFD=88, TCC=0.047%)", + "fingerprint": "778b686d9b61317e1c51a88ced4e448251c6c112e6dc04569fab1a2d64ddde77", + "message": "Possible God Class (WMC=1,467, ATFD=89, TCC=0.052%)", "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", @@ -189,8 +189,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "97550cfce0eaaf13ed88b43e4e31ee63f6a79eceab6eb1e010218311a44c8fe5", - "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2777 (Highest = 68).", + "fingerprint": "b7f5b464fc56b89978dfa793e23c28d14ee384611742652d4a09d3b1706cd973", + "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2802 (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", @@ -200,8 +200,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "2bdac4386cfab5fa16e3eef653881b3f83acb85b72200345082b0d113f8974c9", - "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1453 (highest 37).", + "fingerprint": "a2983c90dc732122ae517bc431d5b0255569592712467f1b3c0cd96cf48f85fe", + "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1467 (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", diff --git a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java index eed737e..8a92d3f 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( "(? springRepositoryMethods(SourceFile file) { return List.of(); } Map result = new LinkedHashMap<>(); - if (SPRING_DATA_FIND_BY_ID_CONTRACTS.contains(contract.get())) { + if (guaranteedFindByIdContract(contract.get())) { result.put("findById", new RepositoryMethod("findById", "findById", true)); } for (MethodInfo method : methods(file)) { - if (typeContaining(file, method.start()).equals(file.typeName()) + if (typeContaining(file, declarationStart(file, method.start())).equals(file.typeName()) && abstractInstanceRepositoryMethod(method) - && !redeclaresInheritedFindById(file, method)) { + && (!redeclaresInheritedFindById(file, method) || !result.containsKey("findById"))) { RepositoryMethod repositoryMethod = new RepositoryMethod( method.name(), repositoryMethodSignature(method), false); result.putIfAbsent(repositoryMethod.identity(), repositoryMethod); @@ -844,6 +846,22 @@ && abstractInstanceRepositoryMethod(method) 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")) { @@ -901,10 +919,13 @@ && typeVariableBounds(file, normalized, file.typeDeclarationStart()).isPresent() /** Keeps only abstract instance methods that Spring Data can implement from an interface. */ private static boolean abstractInstanceRepositoryMethod(MethodInfo method) { - String declaration = maskAnnotationArguments(method.annotationBlock()); + String declaration = maskAnnotationArguments(stripComments(method.annotationBlock())) + .trim() + .replaceFirst("^[;}\\s]+", ""); return !Pattern.compile("(? resolveKnownFrameworkType( /** Scans Spring @Bean and bounded CommandLineRunner lifecycle callbacks. */ List springLifecycleMethods() { List result = new ArrayList<>(); + Set registeredBeanTypes = registeredSpringBeanTypes(); for (SourceFile file : files()) { boolean springBean = springComponent(file); - boolean commandLineRunner = springBean + boolean commandLineRunner = (springBean || registeredBeanTypes.contains(file.typeName())) && implementsNamedInterface(file, SPRING_COMMAND_LINE_RUNNER); for (MethodInfo method : methods(file)) { - if (!typeContaining(file, method.start()).equals(file.typeName())) { + if (!typeContaining(file, declarationStart(file, method.start())).equals(file.typeName())) { continue; } if (springBeanAnnotation(file, method.annotationBlock())) { @@ -968,6 +990,19 @@ && commandLineRunnerParameters(method.parameters())) { return List.copyOf(result); } + /** Returns source types registered by canonical Spring component bean methods. */ + private Set registeredSpringBeanTypes() { + Set result = new LinkedHashSet<>(); + for (BeanCandidate candidate : beanCandidates(AnalysisContext.Framework.SPRING_BOOT)) { + if (candidate.producer() + && springComponent(candidate.file()) + && !candidate.candidateType().isBlank()) { + result.add(candidate.candidateType()); + } + } + return Set.copyOf(result); + } + /** Returns whether a class header implements the named interface. */ private boolean implementsNamedInterface(SourceFile file, String interfaceName) { String header = maskStringLiterals(typeHeader(file)); @@ -3458,6 +3493,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; diff --git a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java index b7d0d1a..21a55ce 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -420,6 +420,22 @@ static class NestedFactory { } } """); + 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 public class RunnerConfiguration { + static class NestedConfiguration { } + @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; @@ -485,12 +501,18 @@ public void run(String... args) { } List lifecycleFacts = facts.frameworkFacts().stream() .filter(item -> item.relationshipType().equals("lifecycle_callback")) .toList(); - assertEquals(2, lifecycleFacts.size()); + assertEquals(4, 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"))); + assertTrue(lifecycleFacts.stream().anyMatch(item -> item.callee().equals("demo.ConfiguredRunner#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("Lookalike"))); @@ -616,7 +638,10 @@ public interface PagingRepository extends 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", """ @@ -667,7 +692,7 @@ public interface GenericRepository List repositoryFacts = facts.frameworkFacts().stream() .filter(item -> item.relationshipType().equals("generated_implementation")) .toList(); - assertEquals(7, repositoryFacts.size()); + assertEquals(8, repositoryFacts.size()); SemanticCallFacts.Fact custom = repositoryFacts.stream() .filter(item -> item.callee().startsWith("demo.CustomRepository#")) .findFirst() @@ -681,8 +706,11 @@ public interface GenericRepository "demo.OverloadedRepository#findByEmail(int)"))); assertTrue(repositoryFacts.stream().anyMatch(item -> item.callee().equals( "demo.OverloadedRepository#findById(String)"))); - assertFalse(repositoryFacts.stream().anyMatch(item -> item.callee().equals( + 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"))); @@ -693,6 +721,27 @@ public interface GenericRepository "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"))); } /** Keeps fixture IDs stable only when their complete source identity is unique. */ @@ -5732,6 +5781,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, @@ -5739,10 +5799,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) From 2240660eb3eb6a0d49249fcd2ed13a838ae937d9 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 15:07:27 -0500 Subject: [PATCH 12/15] Fix nested Spring bean ownership --- config/java-lint-baseline.json | 12 ++--- .../analysis/java/SourceMetadataScanner.java | 45 ++++++++++++++++--- .../java/JavaAnalysisAdapterTest.java | 36 ++++++++++++++- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json index b12edc8..8a22d52 100644 --- a/config/java-lint-baseline.json +++ b/config/java-lint-baseline.json @@ -178,8 +178,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "778b686d9b61317e1c51a88ced4e448251c6c112e6dc04569fab1a2d64ddde77", - "message": "Possible God Class (WMC=1,467, ATFD=89, TCC=0.052%)", + "fingerprint": "88caa9eb6c6d306bd653d35805cd8db33dc81b4f896f45ba46cb08638ba02e9e", + "message": "Possible God Class (WMC=1,474, ATFD=90, TCC=0.050%)", "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", @@ -189,8 +189,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "b7f5b464fc56b89978dfa793e23c28d14ee384611742652d4a09d3b1706cd973", - "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2802 (Highest = 68).", + "fingerprint": "2040ca32e2cd3d7e0f8d15a5c6eeb4333e6b8807010a4cbab0e69efc321db655", + "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2818 (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", @@ -200,8 +200,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "a2983c90dc732122ae517bc431d5b0255569592712467f1b3c0cd96cf48f85fe", - "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1467 (highest 37).", + "fingerprint": "fc519a0ace924a43a2252cde8fc7dca105863b6324d54f81590ba799e61b4ab0", + "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1474 (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", diff --git a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java index 8a92d3f..c50efb5 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java +++ b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java @@ -993,11 +993,18 @@ && commandLineRunnerParameters(method.parameters())) { /** Returns source types registered by canonical Spring component bean methods. */ private Set registeredSpringBeanTypes() { Set result = new LinkedHashSet<>(); - for (BeanCandidate candidate : beanCandidates(AnalysisContext.Framework.SPRING_BOOT)) { - if (candidate.producer() - && springComponent(candidate.file()) - && !candidate.candidateType().isBlank()) { - result.add(candidate.candidateType()); + 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) + .filter(type -> !type.isBlank()) + .ifPresent(result::add); } } return Set.copyOf(result); @@ -4234,7 +4241,23 @@ private static void addCanonicalSymbols( /** Recognizes the bounded Spring component stereotype subset. */ private static boolean springComponent(SourceFile file) { - String block = file.classAnnotationBlock(); + 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 typeDeclarations(file).stream() + .filter(type -> typeContaining(file, type.start()).equals(typeName)) + .filter(type -> !isLocalType(file, type)) + .map(type -> typeAnnotationBlock(file, type)) + .anyMatch(block -> springComponentAnnotations(file, block)); + } + + /** 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") @@ -4246,6 +4269,16 @@ private static boolean springComponent(SourceFile file) { || 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, TypeInfo type) { + String source = file.structuralSource(); + int boundary = type.start() - 1; + while (boundary >= 0 && "{};".indexOf(source.charAt(boundary)) < 0) { + boundary--; + } + return source.substring(boundary + 1, type.start()); + } + /** Recognizes CDI scopes, stereotypes, and named beans. */ private static boolean cdiBean(String block) { return CDI_SCOPES.stream().anyMatch(name -> hasAnnotation(block, name)) diff --git a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java index 21a55ce..57293f6 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -416,7 +416,35 @@ public class UnregisteredFactory { @Component public class BeanFactory { @Bean public Object suppliedBean() { return new Object(); } static class NestedFactory { - @Bean public Object nestedBean() { return new Object(); } + @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.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + public class NestedConfigurationOwner { + @Configuration static class RegisteredNestedConfiguration { + @Bean public NestedConfiguredRunner nestedConfiguredRunner() { + return new NestedConfiguredRunner(); + } } } """); @@ -501,7 +529,7 @@ public void run(String... args) { } List lifecycleFacts = facts.frameworkFacts().stream() .filter(item -> item.relationshipType().equals("lifecycle_callback")) .toList(); - assertEquals(4, lifecycleFacts.size()); + assertEquals(5, 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"))); @@ -513,8 +541,12 @@ public void run(String... args) { } && item.evidence().get(0).symbols().contains("@Bean configuredRunner"))); 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"))); 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"))); From 794b0c62a7cc1022958f60037f606bad1fabae6b Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 15:42:40 -0500 Subject: [PATCH 13/15] Complete Spring metadata enrichment --- config/java-lint-baseline.json | 61 +++-- .../analysis/java/BoundedFrameworkModel.java | 10 +- .../analysis/java/SourceMetadataScanner.java | 215 ++++++++++++++---- .../java/JavaAnalysisAdapterTest.java | 73 +++++- 4 files changed, 290 insertions(+), 69 deletions(-) diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json index 8a22d52..45cf71e 100644 --- a/config/java-lint-baseline.json +++ b/config/java-lint-baseline.json @@ -178,8 +178,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "88caa9eb6c6d306bd653d35805cd8db33dc81b4f896f45ba46cb08638ba02e9e", - "message": "Possible God Class (WMC=1,474, ATFD=90, TCC=0.050%)", + "fingerprint": "65feab73f0feede9c54ce61bbcc02e2435062c26ce9507f0f8391a6a5c63e89f", + "message": "Possible God Class (WMC=1,499, ATFD=90, TCC=0.050%)", "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", @@ -189,8 +189,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "2040ca32e2cd3d7e0f8d15a5c6eeb4333e6b8807010a4cbab0e69efc321db655", - "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2818 (Highest = 68).", + "fingerprint": "19437601bd2edd1d2a73c9ad8802bf9c147911bfffb630b2e0ecd33b4d8d65e9", + "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2849 (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", @@ -200,8 +200,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "fc519a0ace924a43a2252cde8fc7dca105863b6324d54f81590ba799e61b4ab0", - "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1474 (highest 37).", + "fingerprint": "27fc547f83572a71f36d3fecfdf0b5825876f43a03a4f3418587c0c844bfff54", + "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1499 (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", @@ -607,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", @@ -629,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", @@ -640,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", @@ -750,12 +761,34 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "dd1297170823a449c478edd87f7d4fd399526eed7cb6f9147b14cb348ec86345", - "message": "The method 'beanCandidates(Framework)' has a cognitive complexity of 20, current threshold is 20", + "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/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java b/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java index 8efb9f2..6c83ecd 100644 --- a/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java +++ b/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java @@ -714,6 +714,7 @@ private List springLifecycleFacts( 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) { @@ -723,11 +724,11 @@ private List springLifecycleFacts( symbols.add("run(String...)"); } facts.add(SemanticCallFacts.Fact.framework( - lifecycleFactId(scanner, file, method, lifecycle.kind()), + lifecycleFactId(scanner, file, method, declaringType, lifecycle.kind()), context, new SemanticCallFacts.Fact.FrameworkInput( runner ? "Spring Boot application lifecycle" : "Spring ApplicationContext", - file.typeName() + "#" + method.name(), + declaringType + "#" + method.name(), "lifecycle_callback", certainty, "spring-lifecycle-callback", @@ -1821,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") @@ -1835,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/SourceMetadataScanner.java b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java index c50efb5..14bb217 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java +++ b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java @@ -183,6 +183,33 @@ final class SourceMetadataScanner { 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 = @@ -762,14 +789,18 @@ List beanCandidates(AnalysisContext.Framework framework) { result.add(new BeanCandidate( file, contracts, - repositoryInterface ? "repository_interface" : 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)); @@ -791,11 +822,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())); @@ -806,13 +853,18 @@ List beanCandidates(AnalysisContext.Framework framework) { /** 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 Optional.empty(); + return List.of(); } int extendsIndex = topLevelKeywordIndex(header, "extends"); if (extendsIndex < 0) { - return Optional.empty(); + return List.of(); } int permitsIndex = topLevelKeywordIndex(header, "permits"); String extended = header.substring( @@ -821,17 +873,18 @@ Optional springRepositoryContract(SourceFile file) { .map(type -> resolveKnownFrameworkType(type, file, SPRING_DATA_REPOSITORY_CONTRACTS.keySet())) .flatMap(Optional::stream) .map(SPRING_DATA_REPOSITORY_CONTRACTS::get) - .findFirst(); + .distinct() + .toList(); } /** Returns repository methods guaranteed by the contract or declared by the source interface. */ List springRepositoryMethods(SourceFile file) { - Optional contract = springRepositoryContract(file); - if (contract.isEmpty()) { + List contracts = springRepositoryContracts(file); + if (contracts.isEmpty()) { return List.of(); } Map result = new LinkedHashMap<>(); - if (guaranteedFindByIdContract(contract.get())) { + if (contracts.stream().anyMatch(this::guaranteedFindByIdContract)) { result.put("findById", new RepositoryMethod("findById", "findById", true)); } for (MethodInfo method : methods(file)) { @@ -959,13 +1012,11 @@ List springLifecycleMethods() { List result = new ArrayList<>(); Set registeredBeanTypes = registeredSpringBeanTypes(); for (SourceFile file : files()) { - boolean springBean = springComponent(file); - boolean commandLineRunner = (springBean || registeredBeanTypes.contains(file.typeName())) - && implementsNamedInterface(file, SPRING_COMMAND_LINE_RUNNER); for (MethodInfo method : methods(file)) { - if (!typeContaining(file, declarationStart(file, method.start())).equals(file.typeName())) { - continue; - } + 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; @@ -973,8 +1024,9 @@ List springLifecycleMethods() { result.add(new LifecycleMethod( file, method, + declaringType, "bean", - lifecycleAnnotationSymbols(file, method))); + lifecycleAnnotationSymbols(file, method, declaringType))); } if (commandLineRunner && method.name().equals("run") @@ -982,8 +1034,9 @@ && commandLineRunnerParameters(method.parameters())) { result.add(new LifecycleMethod( file, method, + declaringType, "runner", - lifecycleAnnotationSymbols(file, method))); + lifecycleAnnotationSymbols(file, method, declaringType))); } } } @@ -1010,9 +1063,10 @@ private Set registeredSpringBeanTypes() { return Set.copyOf(result); } - /** Returns whether a class header implements the named interface. */ - private boolean implementsNamedInterface(SourceFile file, String interfaceName) { - String header = maskStringLiterals(typeHeader(file)); + /** 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; @@ -1037,9 +1091,10 @@ private static boolean commandLineRunnerParameters(String source) { } /** Preserves the exact source annotations used to establish a lifecycle candidate. */ - private static List lifecycleAnnotationSymbols(SourceFile file, MethodInfo method) { + private static List lifecycleAnnotationSymbols( + SourceFile file, MethodInfo method, String declaringType) { List result = new ArrayList<>(); - result.addAll(annotationSources(file.classAnnotationBlock(), List.of( + result.addAll(annotationSources(typeAnnotationBlock(file, declaringType), List.of( "Component", "Service", "Repository", "Configuration", "Controller", "RestController", "Named"))); result.addAll(annotationSources(method.annotationBlock(), List.of("Bean"))); @@ -4249,11 +4304,18 @@ 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)) - .filter(type -> !isLocalType(file, type)) - .map(type -> typeAnnotationBlock(file, type)) - .anyMatch(block -> springComponentAnnotations(file, block)); + .findFirst(); } /** Recognizes the bounded Spring component stereotype subset in an annotation block. */ @@ -4270,13 +4332,23 @@ private static boolean springComponentAnnotations(SourceFile file, String block) } /** Returns the annotation-bearing declaration prefix for one nested source type. */ - private static String typeAnnotationBlock(SourceFile file, TypeInfo type) { - String source = file.structuralSource(); - int boundary = type.start() - 1; - while (boundary >= 0 && "{};".indexOf(source.charAt(boundary)) < 0) { + 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 source.substring(boundary + 1, type.start()); + 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. */ @@ -4330,11 +4402,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++; @@ -4349,10 +4433,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. */ @@ -4403,6 +4487,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<>(); @@ -4416,8 +4505,20 @@ 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); @@ -4428,12 +4529,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) { @@ -4540,7 +4648,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", @@ -4552,7 +4661,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; } } @@ -4567,16 +4676,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. */ @@ -4710,9 +4821,16 @@ private static List canonicalSpringAnnotationOccurrences( file, block, simpleName, - Set.copyOf(SPRING_ROUTE_ANNOTATION_TYPES.getOrDefault( - simpleName, - SPRING_PROXY_ANNOTATION_TYPES.getOrDefault(simpleName, List.of())))); + 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( @@ -5374,6 +5492,7 @@ record GeneratedMethod(SourceFile file, MethodInfo method) {} record LifecycleMethod( SourceFile file, MethodInfo method, + String declaringType, String kind, List annotationSymbols) {} diff --git a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java index 57293f6..0693149 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -441,7 +441,9 @@ public class NestedConfiguredRunner implements CommandLineRunner { import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; public class NestedConfigurationOwner { - @Configuration static class RegisteredNestedConfiguration { + @Configuration + @SuppressWarnings({"unused"}) + static class RegisteredNestedConfiguration { @Bean public NestedConfiguredRunner nestedConfiguredRunner() { return new NestedConfiguredRunner(); } @@ -529,7 +531,7 @@ public void run(String... args) { } List lifecycleFacts = facts.frameworkFacts().stream() .filter(item -> item.relationshipType().equals("lifecycle_callback")) .toList(); - assertEquals(5, lifecycleFacts.size()); + assertEquals(6, 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"))); @@ -544,6 +546,10 @@ public void run(String... args) { } 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"))); 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"))); @@ -643,6 +649,59 @@ void springRoutesAndProxiesRequireCanonicalAnnotationResolution() throws IOExcep || 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 { @@ -702,6 +761,12 @@ public interface GenericRepository 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( @@ -724,7 +789,7 @@ public interface GenericRepository List repositoryFacts = facts.frameworkFacts().stream() .filter(item -> item.relationshipType().equals("generated_implementation")) .toList(); - assertEquals(8, repositoryFacts.size()); + assertEquals(9, repositoryFacts.size()); SemanticCallFacts.Fact custom = repositoryFacts.stream() .filter(item -> item.callee().startsWith("demo.CustomRepository#")) .findFirst() @@ -774,6 +839,8 @@ public interface GenericRepository 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. */ From c76b0a6c97e7da004656f7f898348c384787f535 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 16:29:18 -0500 Subject: [PATCH 14/15] Harden Spring metadata parsing guards --- config/java-lint-baseline.json | 12 ++-- .../analysis/java/SourceMetadataScanner.java | 34 +++++++--- .../java/JavaAnalysisAdapterTest.java | 63 ++++++++++++++++++- 3 files changed, 93 insertions(+), 16 deletions(-) diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json index 45cf71e..339f981 100644 --- a/config/java-lint-baseline.json +++ b/config/java-lint-baseline.json @@ -178,8 +178,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "65feab73f0feede9c54ce61bbcc02e2435062c26ce9507f0f8391a6a5c63e89f", - "message": "Possible God Class (WMC=1,499, ATFD=90, TCC=0.050%)", + "fingerprint": "5becfa706bfbd2a82ef9567f388ac58120e6fe375d1807c3000e38af35b3c301", + "message": "Possible God Class (WMC=1,504, ATFD=90, TCC=0.050%)", "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", @@ -189,8 +189,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "19437601bd2edd1d2a73c9ad8802bf9c147911bfffb630b2e0ecd33b4d8d65e9", - "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2849 (Highest = 68).", + "fingerprint": "90c7cfbc8966ce881a5baac4c596d8731e7175974d2795d00e624fb4f394b4a9", + "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2859 (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", @@ -200,8 +200,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "27fc547f83572a71f36d3fecfdf0b5825876f43a03a4f3418587c0c844bfff54", - "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1499 (highest 37).", + "fingerprint": "e9d81dd22fac22539be6280b0399204cf919c10c692b8b1fa3c5379e15eabd30", + "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1504 (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", diff --git a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java index 14bb217..1b3dd49 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java +++ b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java @@ -478,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(); @@ -713,7 +728,7 @@ List injectionPoints(AnalysisContext.Framework framework) { if (!selected) { continue; } - List parsedParameters = parameters(constructorInfo.parameters()); + List parsedParameters = parameters(file, constructorInfo.parameters()); if (!parsedParameters.isEmpty()) { result.add(new InjectionPoint( file, @@ -754,7 +769,7 @@ && hasAnnotation(file.classAnnotationBlock(), "Path"))) { || !hasInjectionAnnotation(method.annotationBlock())) { continue; } - List parsedParameters = parameters(method.parameters()); + List parsedParameters = parameters(file, method.parameters()); if (!parsedParameters.isEmpty()) { List methodQualifiers = injectionQualifiers( method.annotationBlock(), method.name(), framework == AnalysisContext.Framework.QUARKUS); @@ -1055,7 +1070,8 @@ private Set registeredSpringBeanTypes() { if (!springComponent(file, declaringType)) { continue; } - resolveReferenceType(method.returnType(), file) + resolveReferenceType( + method.returnType(), file, declarationStart(file, method.start())) .filter(type -> !type.isBlank()) .ifPresent(result::add); } @@ -1094,10 +1110,10 @@ private static boolean commandLineRunnerParameters(String source) { private static List lifecycleAnnotationSymbols( SourceFile file, MethodInfo method, String declaringType) { List result = new ArrayList<>(); - result.addAll(annotationSources(typeAnnotationBlock(file, declaringType), List.of( + result.addAll(canonicalAnnotationSources(file, typeAnnotationBlock(file, declaringType), List.of( "Component", "Service", "Repository", "Configuration", "Controller", "RestController", "Named"))); - result.addAll(annotationSources(method.annotationBlock(), List.of("Bean"))); + result.addAll(canonicalAnnotationSources(file, method.annotationBlock(), List.of("Bean"))); return List.copyOf(new LinkedHashSet<>(result)); } @@ -4521,7 +4537,7 @@ private static List qualifierNames(String block) { 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(); @@ -4736,9 +4752,13 @@ private static List canonicalAnnotationSources( /** Parses method or constructor parameters and their qualifier annotations. */ static List parameters(String source) { + return parameters(null, source); + } + + private static List parameters(SourceFile file, String source) { List result = new ArrayList<>(); for (String raw : splitArguments(source)) { - List qualifiers = qualifierNames(raw); + List qualifiers = qualifierNames(file, raw); String cleaned = raw.replaceAll("@[A-Za-z_][\\w.]*(?:\\s*\\([^)]*\\))?", " ").trim(); cleaned = cleaned.replaceAll("\\b(final|volatile|transient)\\b", "").trim(); if (cleaned.isBlank()) { diff --git a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java index 0693149..ac45c4f 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -438,6 +438,7 @@ public class NestedConfiguredRunner implements CommandLineRunner { """); 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 { @@ -447,6 +448,12 @@ 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) { } + } } } """); @@ -461,9 +468,11 @@ public class ConfiguredRunner implements CommandLineRunner { package demo; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; - @Configuration public class RunnerConfiguration { + @Configuration @demo.Configuration public class RunnerConfiguration { static class NestedConfiguration { } - @Bean public ConfiguredRunner configuredRunner() { return new ConfiguredRunner(); } + @Bean @demo.Bean public ConfiguredRunner configuredRunner() { + return new ConfiguredRunner(); + } } """); write(sourceRoot, "demo/Bean.java", "package demo; public @interface Bean {}\n"); @@ -531,7 +540,7 @@ public void run(String... args) { } List lifecycleFacts = facts.frameworkFacts().stream() .filter(item -> item.relationshipType().equals("lifecycle_callback")) .toList(); - assertEquals(6, lifecycleFacts.size()); + 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"))); @@ -541,6 +550,12 @@ public void run(String... args) { } "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( @@ -550,6 +565,12 @@ public void run(String... args) { } "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"))); @@ -2089,6 +2110,42 @@ 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(); + + assertEquals(List.of("Qualifier:fast"), injection.parameters().get(0).qualifiers()); + } + /** Requires active alternative metadata and normal-scope proxy eligibility. */ @Test void quarkusAlternativesNeedActiveMetadataAndDependentBeansNeedExplicitProxyTargets() From 1fd47f579d3875e5a8276270681c7216dbd00257 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 16:49:46 -0500 Subject: [PATCH 15/15] Preserve framework-specific injection qualifiers --- config/java-lint-baseline.json | 12 ++--- .../analysis/java/SourceMetadataScanner.java | 16 ++++-- .../java/JavaAnalysisAdapterTest.java | 49 ++++++++++++++++++- 3 files changed, 67 insertions(+), 10 deletions(-) diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json index 339f981..b7f31c4 100644 --- a/config/java-lint-baseline.json +++ b/config/java-lint-baseline.json @@ -178,8 +178,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "5becfa706bfbd2a82ef9567f388ac58120e6fe375d1807c3000e38af35b3c301", - "message": "Possible God Class (WMC=1,504, ATFD=90, TCC=0.050%)", + "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", @@ -189,8 +189,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "90c7cfbc8966ce881a5baac4c596d8731e7175974d2795d00e624fb4f394b4a9", - "message": "The class 'SourceMetadataScanner' has a NCSS line count of 2859 (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", @@ -200,8 +200,8 @@ "class": "SourceMetadataScanner", "classification": "maintainability", "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", - "fingerprint": "e9d81dd22fac22539be6280b0399204cf919c10c692b8b1fa3c5379e15eabd30", - "message": "The class 'SourceMetadataScanner' has a total cyclomatic complexity of 1504 (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", diff --git a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java index 1b3dd49..0229899 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java +++ b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java @@ -728,7 +728,8 @@ List injectionPoints(AnalysisContext.Framework framework) { if (!selected) { continue; } - List parsedParameters = parameters(file, constructorInfo.parameters()); + List parsedParameters = parameters( + framework, file, constructorInfo.parameters()); if (!parsedParameters.isEmpty()) { result.add(new InjectionPoint( file, @@ -769,7 +770,7 @@ && hasAnnotation(file.classAnnotationBlock(), "Path"))) { || !hasInjectionAnnotation(method.annotationBlock())) { continue; } - List parsedParameters = parameters(file, method.parameters()); + List parsedParameters = parameters(framework, file, method.parameters()); if (!parsedParameters.isEmpty()) { List methodQualifiers = injectionQualifiers( method.annotationBlock(), method.name(), framework == AnalysisContext.Framework.QUARKUS); @@ -4755,11 +4756,20 @@ 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(file, raw); - String cleaned = raw.replaceAll("@[A-Za-z_][\\w.]*(?:\\s*\\([^)]*\\))?", " ").trim(); + String cleaned = maskAnnotationArguments(stripComments(raw)) + .replaceAll("@[A-Za-z_][\\w.]*", " ").trim(); cleaned = cleaned.replaceAll("\\b(final|volatile|transient)\\b", "").trim(); if (cleaned.isBlank()) { continue; diff --git a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java index ac45c4f..24c43ff 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -2143,7 +2143,54 @@ void springInjectionParametersRequireCanonicalQualifiers() throws IOException { .findFirst() .orElseThrow(); - assertEquals(List.of("Qualifier:fast"), injection.parameters().get(0).qualifiers()); + 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. */