From 6e20c6d1a4ea3bebc696bd1aa3cf4ce740611625 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Wed, 11 Nov 2026 11:22:55 -0500 Subject: [PATCH 1/3] Enforce dynamic guards and Java lint gate --- .github/workflows/adapter-tests.yml | 33 - .github/workflows/ci.yml | 64 ++ .gitignore | 1 + Makefile | 20 +- README.md | 18 +- config/java-lint-baseline.json | 764 ++++++++++++++++++ config/pmd-production.xml | 102 +++ docs/java-lint.md | 134 +++ pom.xml | 24 + .../analysis/java/AnalysisContext.java | 1 + .../analysis/java/BoundedFrameworkModel.java | 348 ++++---- .../analysis/java/DynamicCertaintyPolicy.java | 41 + .../analysis/java/FrameworkModels.java | 1 + .../analysis/java/JavaAnalysisAdapter.java | 9 +- .../analysis/java/ManualAnalysisMain.java | 3 +- .../analysis/java/ProviderFactNormalizer.java | 11 +- .../analysis/java/SemanticCallFacts.java | 294 ++++--- .../java/SemanticCallFactsJsonWriter.java | 3 + .../analysis/java/SourceMetadataScanner.java | 253 ++---- .../java/DynamicCertaintyGuardTest.java | 281 +++++++ .../java/JavaAnalysisAdapterTest.java | 24 +- tools/java-lint-maven-plugin/pom.xml | 92 +++ .../javalint/JavaLintValidator.java | 623 ++++++++++++++ .../javalint/ValidateJavaLintMojo.java | 36 + .../javalint/JavaLintValidatorTest.java | 103 +++ 25 files changed, 2809 insertions(+), 474 deletions(-) delete mode 100644 .github/workflows/adapter-tests.yml create mode 100644 .github/workflows/ci.yml create mode 100644 config/java-lint-baseline.json create mode 100644 config/pmd-production.xml create mode 100644 docs/java-lint.md create mode 100644 src/main/java/com/projectlightpath/analysis/java/DynamicCertaintyPolicy.java create mode 100644 src/test/java/com/projectlightpath/analysis/java/DynamicCertaintyGuardTest.java create mode 100644 tools/java-lint-maven-plugin/pom.xml create mode 100644 tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/JavaLintValidator.java create mode 100644 tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/ValidateJavaLintMojo.java create mode 100644 tools/java-lint-maven-plugin/src/test/java/org/projectlightpath/javalint/JavaLintValidatorTest.java diff --git a/.github/workflows/adapter-tests.yml b/.github/workflows/adapter-tests.yml deleted file mode 100644 index 3f330c4..0000000 --- a/.github/workflows/adapter-tests.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Java analysis adapter - -on: - pull_request: - push: - branches: - - main - workflow_dispatch: - -permissions: - contents: read - -jobs: - adapter-tests: - name: Adapter tests - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up Java 17 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - cache: maven - - - name: Run adapter tests - run: make adapter-test - - - name: Validate spike contracts - run: make spike-validate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..38c000d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: Java CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + adapter-tests: + name: Adapter tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - name: Run adapter tests + run: make adapter-test + + - name: Validate spike contracts + run: make spike-validate + + java-lint: + name: Java lint gate + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - name: Run Java lint gate + run: make java-lint + + - name: Upload Java lint reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: java-lint-reports + path: | + target/pmd.xml + target/cpd.xml + target/java-lint/ + target/java-lint-effective-pom.xml + if-no-files-found: warn + retention-days: 7 diff --git a/.gitignore b/.gitignore index 1bb925a..1f7a573 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Java/Maven build output /target/ +tools/java-lint-maven-plugin/target/ # FLPATH-5000 disposable analysis output spike/runs/ diff --git a/Makefile b/Makefile index 3f496ee..dfa68a7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test package adapter-test adapter-package 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 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 adapter-test test: MAVEN_BIN="$$(bash spike/scripts/bootstrap-maven.sh)" && "$${MAVEN_BIN}" -B -ntp test -f pom.xml @@ -15,6 +15,24 @@ manual-analysis: adapter-package package: MAVEN_BIN="$$(bash spike/scripts/bootstrap-maven.sh)" && "$${MAVEN_BIN}" -B -ntp package -DskipTests -f pom.xml +java-lint-test: + MAVEN_BIN="$$(bash spike/scripts/bootstrap-maven.sh)" && \ + "$${MAVEN_BIN}" -B -ntp test -f tools/java-lint-maven-plugin/pom.xml + +java-lint: java-lint-test + MAVEN_BIN="$$(bash spike/scripts/bootstrap-maven.sh)" && \ + "$${MAVEN_BIN}" -B -ntp install -DskipTests -f tools/java-lint-maven-plugin/pom.xml && \ + "$${MAVEN_BIN}" -B -ntp help:effective-pom -Doutput=target/java-lint-effective-pom.xml -f pom.xml && \ + "$${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=true -f pom.xml + +java-lint-report: + MAVEN_BIN="$$(bash spike/scripts/bootstrap-maven.sh)" && \ + "$${MAVEN_BIN}" -B -ntp install -DskipTests -f tools/java-lint-maven-plugin/pom.xml && \ + "$${MAVEN_BIN}" -B -ntp help:effective-pom -Doutput=target/java-lint-effective-pom.xml -f pom.xml && \ + "$${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: python3 spike/scripts/validate_spike.py diff --git a/README.md b/README.md index 38354c0..d81ee1c 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,27 @@ make package ``` The `Adapter tests` CI lane runs `make adapter-test` and `make spike-validate` -on pull requests and pushes to `main`. +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. +Run the enforced Java quality gate with: + +```sh +make java-lint +``` + +It runs the pinned PMD/CPD configuration against production Java sources, +compares normalized findings and the effective Maven CPD configuration with +[`config/java-lint-baseline.json`](config/java-lint-baseline.json), and fails +on new findings, changed lint configuration, or partial PMD/CPD reports. For +exploratory reports without enforcement, use `make java-lint-report`. See +[`docs/java-lint.md`](docs/java-lint.md) for the rule rationale, baseline, +limitations, and review process. + ## FLPATH-5000 spike and findings The [`spike/`](spike/) directory contains the Java 21/Quarkus fixture, pinned diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json new file mode 100644 index 0000000..b0ba1cc --- /dev/null +++ b/config/java-lint-baseline.json @@ -0,0 +1,764 @@ +{ + "capturedOn": "2026-11-11", + "cpd": { + "findings": [], + "minimumTokens": 100, + "pmdVersion": "7.17.0" + }, + "description": "Reviewed PMD/CPD findings for the production Java quality gate. New fingerprints fail CI; resolved baseline entries are reported for cleanup.", + "pmd": { + "findings": [ + { + "class": "AnalysisContextLoader", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/AnalysisContextLoader.java", + "fingerprint": "4b4e07586437085547792b443a34cb0dd3597be55ceea4ab38932dfdb94a4344", + "message": "Possible God Class (WMC=87, ATFD=7, TCC=0.000%)", + "method": "", + "rationale": "Reviewed maintainability baseline: context-document parsing intentionally aggregates optional metadata and compatibility defaults; the behavior is covered by loader tests and should be split only with targeted schema regressions.", + "rule": "GodClass", + "variable": "" + }, + { + "class": "AnalysisContextLoader", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/AnalysisContextLoader.java", + "fingerprint": "10880b50fc0b0148f93bca81ae5508cc99490bf35af12233df2a31255819f456", + "message": "The method 'fromDocument(ContextDocument)' has a cyclomatic complexity of 17.", + "method": "fromDocument", + "rationale": "Reviewed maintainability baseline: context-document parsing intentionally aggregates optional metadata and compatibility defaults; the behavior is covered by loader tests and should be split only with targeted schema regressions.", + "rule": "CyclomaticComplexity", + "variable": "" + }, + { + "class": "AnalysisContextLoader", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/AnalysisContextLoader.java", + "fingerprint": "02fadb4278cd7d6257b8ff661d86e7140b9531e6ec17e91c736eeb67dcd7aa24", + "message": "The method 'fromDocument(ContextDocument)' has an NPath complexity of 494, current threshold is 300", + "method": "fromDocument", + "rationale": "Reviewed maintainability baseline: context-document parsing intentionally aggregates optional metadata and compatibility defaults; the behavior is covered by loader tests and should be split only with targeted schema regressions.", + "rule": "NPathComplexity", + "variable": "" + }, + { + "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%)", + "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", + "variable": "" + }, + { + "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).", + "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", + "variable": "" + }, + { + "class": "BoundedFrameworkModel", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java", + "fingerprint": "24c20183a53ff9d2653262adc4a10adc162594c4d9761c6d0421c9faee32acbc", + "message": "The method 'injectionFacts(AnalysisContext, SourceMetadataScanner)' has a cognitive complexity of 62, current threshold is 20", + "method": "injectionFacts", + "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": "BoundedFrameworkModel", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java", + "fingerprint": "4b45537445612f911432433b64fe28c253a09f3a74b930f56de827bccb2872e6", + "message": "The method 'injectionFacts(AnalysisContext, SourceMetadataScanner)' has a cyclomatic complexity of 23.", + "method": "injectionFacts", + "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", + "variable": "" + }, + { + "class": "BoundedFrameworkModel", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java", + "fingerprint": "6231a5e570c8e2684c1c80ccd5827b0371ddd2431da6232365a8bcba41510b82", + "message": "The method 'injectionFacts(AnalysisContext, SourceMetadataScanner)' has an NPath complexity of 903312, current threshold is 300", + "method": "injectionFacts", + "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": "NPathComplexity", + "variable": "" + }, + { + "class": "BoundedFrameworkModel", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java", + "fingerprint": "82547668afc9291962dbe1105f1ed482c1376c6bb07e0db95d8956118b0b2a9d", + "message": "The method 'injectionFact(InjectionFactRequest)' has an NPath complexity of 1664, current threshold is 300", + "method": "injectionFact", + "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": "NPathComplexity", + "variable": "" + }, + { + "class": "BoundedFrameworkModel", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java", + "fingerprint": "73890851bff3014b87c8c04017fff794c89e95a5b7df57667438437a6257d2af", + "message": "The method 'proxyFacts(AnalysisContext, SourceMetadataScanner)' has a cognitive complexity of 43, current threshold is 20", + "method": "proxyFacts", + "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": "BoundedFrameworkModel", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java", + "fingerprint": "0aa988a6ba34fb59d88a08ba22f767407f01aff16eee6c4bacf7cbbe1e603577", + "message": "The method 'proxyFacts(AnalysisContext, SourceMetadataScanner)' has a cyclomatic complexity of 23.", + "method": "proxyFacts", + "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", + "variable": "" + }, + { + "class": "BoundedFrameworkModel", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java", + "fingerprint": "5584575fafc7743a5f0538f30d028512308bf9704f3896f6edbd92f4eeaa7341", + "message": "The method 'proxyFacts(AnalysisContext, SourceMetadataScanner)' has an NPath complexity of 147498, current threshold is 300", + "method": "proxyFacts", + "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": "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", + "file": "src/main/java/com/projectlightpath/analysis/java/ManualAnalysisMain.java", + "fingerprint": "7abf2fefe85766b8b8254a27a357312e1216583dd6159de1d80e68ef0dfb00f1", + "message": "Avoid catching generic exceptions such as NullPointerException, RuntimeException, Exception in try-catch block", + "method": "main", + "rationale": "Intentional process-boundary handling: the CLI must convert any analysis failure into a stable user-facing error and non-zero exit; narrowing this catch requires redesigning the command boundary.", + "rule": "AvoidCatchingGenericException", + "variable": "" + }, + { + "class": "ProviderFactNormalizer", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java", + "fingerprint": "3fbf0a1821c2674cb406f19a1758574a5e156721d834558a4e4dff56d2b8048f", + "message": "The method 'normalize(AnalysisContext, AnalysisGraph)' has a cognitive complexity of 20, current threshold is 20", + "method": "normalize", + "rationale": "Reviewed maintainability baseline: provider normalization combines fact identity, dynamic-certainty guards, and contract-preserving defaults; the branches are behavior-bearing and covered by normalization tests.", + "rule": "CognitiveComplexity", + "variable": "" + }, + { + "class": "ProviderFactNormalizer", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java", + "fingerprint": "03578503563f4b3ea007862733f12db6512c5aedf8f09bc6f4c7cdf659d1f808", + "message": "The method 'normalize(AnalysisContext, AnalysisGraph)' has an NPath complexity of 442, current threshold is 300", + "method": "normalize", + "rationale": "Reviewed maintainability baseline: provider normalization combines fact identity, dynamic-certainty guards, and contract-preserving defaults; the branches are behavior-bearing and covered by normalization tests.", + "rule": "NPathComplexity", + "variable": "" + }, + { + "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%)", + "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", + "variable": "" + }, + { + "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).", + "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", + "variable": "" + }, + { + "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).", + "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", + "variable": "" + }, + { + "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", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "ab5c0ad1fd3090d266bd83afa2d69874df0539cdd74b712bf5a079f4701b6877", + "message": "The method 'routes(Framework)' has a cyclomatic complexity of 36.", + "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": "CyclomaticComplexity", + "variable": "" + }, + { + "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", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "8f93d7bc1b97a056a97ba87fb0307be31fe7e546cbe52fad60e39e138a4b5566", + "message": "The method 'injectionPoints(Framework)' has a cognitive complexity of 42, current threshold is 20", + "method": "injectionPoints", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "ada65e7e0c65de9fcbb041b05b4bc69a4e05512b90a771d86f79bded9d2db5fc", + "message": "The method 'injectionPoints(Framework)' has a cyclomatic complexity of 17.", + "method": "injectionPoints", + "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": "4aa9e6026df115075438f32258f296df4ce8e87531436b191a84cb44b9160b7b", + "message": "The method 'injectionPoints(Framework)' has an NPath complexity of 2049, current threshold is 300", + "method": "injectionPoints", + "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": "9134fd507ed39e87c263dc6be9bdd4a0c1f797fea290e86d1b56548fc6e7933e", + "message": "The method 'dynamicFeatures()' has a cognitive complexity of 25, current threshold is 20", + "method": "dynamicFeatures", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "a8e00a96f4ac20ed10f35468f675b80fcc1b61c25435c21faf82182e288534c0", + "message": "The method 'proxyCallOffsets(SourceFile, String)' has a cognitive complexity of 25, current threshold is 20", + "method": "proxyCallOffsets", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "c361a3e2e5a57b1f8903f68a1111e015dcb69ca900a231ed5c8efe4617dee04e", + "message": "The method 'proxyCallOffsets(SourceFile, String)' has a cyclomatic complexity of 18.", + "method": "proxyCallOffsets", + "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": "a1e814bbd1b3d1c3a0298612abee60be262037f30b1c153f56c2de7893b69dd2", + "message": "The method 'proxyCallOffsets(SourceFile, String)' has an NPath complexity of 1360, current threshold is 300", + "method": "proxyCallOffsets", + "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": "a6f416e1e38efbd68841eccdfc795f757a80f48fcb32e1073a464532d1140b46", + "message": "The method 'splitBalanced(String, char, boolean)' has a cognitive complexity of 28, current threshold is 20", + "method": "splitBalanced", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "44463b4b5124754f4334a6aaba55ffe9f9701dc4637739fc3f1666a7fbeb8025", + "message": "The method 'splitBalanced(String, char, boolean)' has a cyclomatic complexity of 27.", + "method": "splitBalanced", + "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": "687314213b2087dfa84a7ab136d37282d07c96eed2704771379eeece0468512b", + "message": "The method 'resolveOwnerType(String, SourceFile, int)' has a cognitive complexity of 25, current threshold is 20", + "method": "resolveOwnerType", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "5e2d19de54ed88cdb289c3bf23575991e274f89e29a1be1fb0f2dec382c392a3", + "message": "The method 'resolveOwnerType(String, SourceFile, int)' has a cyclomatic complexity of 25.", + "method": "resolveOwnerType", + "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": "bc91595db99e7723b543d9c7539330108392b17aa5a60812c78d80fab40a950c", + "message": "The method 'isMethodTypeParameterPrefix(String)' has a cognitive complexity of 30, current threshold is 20", + "method": "isMethodTypeParameterPrefix", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "98a695e2951825ec7122d99dcd41abbcf9ddce8407274a60972ecef5a2698ca8", + "message": "The method 'isMethodTypeParameterPrefix(String)' has a cyclomatic complexity of 17.", + "method": "isMethodTypeParameterPrefix", + "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": "108f097a8db1e17440aac3055808af6215bd98031266d73ee7dc74146d14d0e4", + "message": "The method 'stripLeadingTypeParameterAnnotations(String)' has a cognitive complexity of 20, current threshold is 20", + "method": "stripLeadingTypeParameterAnnotations", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "c56779183055732daba950c9096df445e2fe6c27a72706badf8414d3b8e63e3c", + "message": "The method 'addInlineProxyInvocationSites(List, String, String, SourceFile)' has a cognitive complexity of 24, current threshold is 20", + "method": "addInlineProxyInvocationSites", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "a812fa2631eb141bd8008ed56964449cec31e11d40a7753f40a640da15d5bd2b", + "message": "The method 'activeLocalDeclaration(SourceFile, MethodInfo, String, int)' has a cognitive complexity of 28, current threshold is 20", + "method": "activeLocalDeclaration", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "84a932c70f8438c1f933110186df2fcb79e21678038c60f2e5c80911a1346f29", + "message": "The method 'visibleField(SourceFile, String, int, String)' has a cognitive complexity of 21, current threshold is 20", + "method": "visibleField", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "ecb92c26d86a40c0a73e0b72e51d98dcf0794d87278a2c528878b40bcf964ad4", + "message": "The method 'resolveParentTypes(TypeReference)' has a cognitive complexity of 25, current threshold is 20", + "method": "resolveParentTypes", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "4696d1e1d58044fa89ae9bbeaa241872159346262037dca9944f771c0ab15396", + "message": "The method 'declaredTypeCandidates(String, SourceFile, int)' has a cognitive complexity of 21, current threshold is 20", + "method": "declaredTypeCandidates", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "56a9a55e4c05fe377748eb1a42b53c5a58a508b046c6b937a6f8f7511ec22071", + "message": "The method 'catchOrResourceScope(String, int)' has a cognitive complexity of 22, current threshold is 20", + "method": "catchOrResourceScope", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "e4cdd78327b11ce8dd8ff91fc156127c52c4a25d4fc71510523ce13b1622351e", + "message": "The method 'catchOrResourceScope(String, int)' has a cyclomatic complexity of 16.", + "method": "catchOrResourceScope", + "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": "27513bdd845b75ffb775eaa0ea22898e44abc3b037b988be52281e53af93dfeb", + "message": "The method 'forInitializerScope(String, int)' has a cognitive complexity of 21, current threshold is 20", + "method": "forInitializerScope", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "a1d7f04d8e565b17958ae96bc1edae9ee5b1e2daebddd707cf0fbb637c7ea87d", + "message": "The method 'forInitializerScope(String, int)' has a cyclomatic complexity of 17.", + "method": "forInitializerScope", + "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": "c8a7f88875a7d99c041a2d9e365e86cd18cb255115896c43ecb5f50d7a885bd1", + "message": "The method 'forInitializerScope(String, int)' has an NPath complexity of 452, current threshold is 300", + "method": "forInitializerScope", + "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": "a5c03b6abda432d9802cb949672dddc5a899bfc57251e76be1519d42ce786b7b", + "message": "The method 'statementEnd(String, int)' has a cognitive complexity of 24, current threshold is 20", + "method": "statementEnd", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "42ecc399b44c9e7c67d89023e9eb1a78b31ee27e6e7a55e1c4794f15def2b59a", + "message": "The method 'statementEnd(String, int)' has a cyclomatic complexity of 16.", + "method": "statementEnd", + "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": "2620e68e9350e01197119964bb3fb4fe139e5d4e2b8bc9cabda0dda09c5d0184", + "message": "The method 'firstQuotedArgument(String, int)' has a cyclomatic complexity of 16.", + "method": "firstQuotedArgument", + "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": "6b1775c6e5c34dcc2226e62d811be0313a4ac3414e75713f923f4cbfbef7cb62", + "message": "The method 'firstQuotedArgument(String, int)' has an NPath complexity of 308, current threshold is 300", + "method": "firstQuotedArgument", + "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": "030bd14cc4b02bec2a6c2ca5aeee9d20818a35e793ccdbc9a4bd823d03d5fb4b", + "message": "The method 'anonymousConstructorOpening(String, int)' has a cognitive complexity of 21, current threshold is 20", + "method": "anonymousConstructorOpening", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "7a2a97f5786bde8c7a9c117b3cacc275a923569dafb5d9783eec1e121405f271", + "message": "The method 'anonymousConstructorOpening(String, int)' has a cyclomatic complexity of 16.", + "method": "anonymousConstructorOpening", + "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": "219890336233dccc1b05d0d225dbeca0eaf4eb2456a7c6d6a8bb166373f3d5eb", + "message": "The method 'matchingGenericDelimiter(String, int)' has a cyclomatic complexity of 18.", + "method": "matchingGenericDelimiter", + "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": "8da265d944d122db708df02ffaac42491dcdcf4062d863924ddfb289a105d25a", + "message": "The method 'typeBodyOpening(String, int)' has a cyclomatic complexity of 20.", + "method": "typeBodyOpening", + "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": "56ab896a60381fd41d7acc9874dd9a917cb9b11b88ace228eb2dce05cde31b11", + "message": "The method 'httpMethods(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", + "variable": "" + }, + { + "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.", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "0932cbbc69ca7c2d9a1b682139b53e8e3c1b564a28b6d88053e6e0b07ac689a2", + "message": "The method 'topLevelKeywordIndex(String, String)' has a cyclomatic complexity of 16.", + "method": "topLevelKeywordIndex", + "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": "a5c6eeff1738a6792f5a087f216a3a49954d80e086fd44aea60d53b758192001", + "message": "The method 'qualifierNames(String)' has a cognitive complexity of 33, 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", + "variable": "" + }, + { + "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.", + "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": "95df2cbfe41ec13108e87a70683db78f74296f0a1bf7d6e49f71685675798e80", + "message": "The method 'stripComments(String)' has a cognitive complexity of 50, current threshold is 20", + "method": "stripComments", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "15f75cb9fa9aacb0c87a356b3cf3ecc110d7dcc86898f85973efe84256c5a7b9", + "message": "The method 'stripComments(String)' has a cyclomatic complexity of 37.", + "method": "stripComments", + "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": "3e2208732e744d042cb2576a7fe4c2c935f5dd33aac154bd06f5902a73c5e240", + "message": "The method 'stripComments(String)' has an NPath complexity of 5261, current threshold is 300", + "method": "stripComments", + "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": "d9271d72241b10979f3930419c9262744d0f57487cc41d46abc012b59366c17c", + "message": "The method 'maskStringLiterals(String)' has a cognitive complexity of 39, current threshold is 20", + "method": "maskStringLiterals", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "1ff5665672da1baa3ee226965888260f4e0800549632dee33a0cf3220f04a452", + "message": "The method 'maskStringLiterals(String)' has a cyclomatic complexity of 25.", + "method": "maskStringLiterals", + "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": "0bc4a6ee99db318c23c1a2904a9d20c3cdf0f315143cd9445890731deb02df69", + "message": "The method 'maskStringLiterals(String)' has an NPath complexity of 342, current threshold is 300", + "method": "maskStringLiterals", + "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": "74079206e3ba98791a1ef0d0888619cc65f52b25f0a62d94bc1b478bfbc14cb0", + "message": "The method 'matchingDelimiter(String, int, char, char)' has a cognitive complexity of 32, current threshold is 20", + "method": "matchingDelimiter", + "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", + "variable": "" + }, + { + "class": "SourceMetadataScanner", + "classification": "maintainability", + "file": "src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java", + "fingerprint": "6e5e73210e36858674a6358504754d3e43d543059d3761c69af350557a6943df", + "message": "The method 'matchingDelimiter(String, int, char, char)' has a cyclomatic complexity of 22.", + "method": "matchingDelimiter", + "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": "7b0d79e9c4a4c5779f18dfeafcb01ecddf42301a63e60529e03dc52cb51eb2ad", + "message": "The method 'matchingDelimiter(String, int, char, char)' has an NPath complexity of 585, current threshold is 300", + "method": "matchingDelimiter", + "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": "" + } + ], + "pmdVersion": "7.17.0", + "rulesetSha256": "08c9bf5e0f1a4e1a95d46d8ef69bf397426bbaf048513e00c353f490bc566146" + }, + "schemaVersion": 1 +} diff --git a/config/pmd-production.xml b/config/pmd-production.xml new file mode 100644 index 0000000..9734892 --- /dev/null +++ b/config/pmd-production.xml @@ -0,0 +1,102 @@ + + + + Focused PMD rules for production-quality signals: complexity and size, + exception safety, dead-looking code, accidental API surface, and narrowly + scoped public type documentation. Copy/paste detection is run separately by + the Maven PMD plugin's CPD goal. This ruleset is not an AI detector. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/java-lint.md b/docs/java-lint.md new file mode 100644 index 0000000..6eea78a --- /dev/null +++ b/docs/java-lint.md @@ -0,0 +1,134 @@ +# Java lint / quality gate + +## Commands + +Run the enforced production-quality gate: + +```sh +make java-lint +``` + +For an exploratory report without failing on new findings: + +```sh +make java-lint-report +``` + +The unit tests for the baseline normalizer can be run independently with +`make java-lint-test`. + +## What runs + +The gate first writes Maven's effective project configuration to +`target/java-lint-effective-pom.xml`, then runs Maven PMD Plugin `3.28.0`, +pinned to PMD `7.17.0`, against `src/main/java` only. Tests, generated fixture +sources, and dependencies are outside this production baseline. CPD runs +separately with a 100-token minimum; the validator reads that threshold from +the effective PMD plugin configuration even when the CPD baseline is empty. +The baseline validator is a Java Maven plugin in +[`tools/java-lint-maven-plugin`](../tools/java-lint-maven-plugin), and its +JUnit tests run before the enforced gate. + +The configuration is deliberately narrow and is stored in +[`config/pmd-production.xml`](../config/pmd-production.xml). It covers: + +| Signal | Rules | Review intent | +| --- | --- | --- | +| Complexity and size | `CognitiveComplexity`, `CyclomaticComplexity`, `NPathComplexity`, `NcssCount`, `ExcessiveParameterList`, `ExcessivePublicCount`, `GodClass`, `AvoidDeeplyNestedIfStmts` | Find structures that deserve decomposition or API review. Thresholds are high enough to avoid ordinary style noise. | +| Exception safety | `AvoidCatchingGenericException`, `EmptyCatchBlock`, `AvoidThrowingRawExceptionTypes`, `AvoidRethrowingException`, `ExceptionAsFlowControl`, `PreserveStackTrace` | Find swallowed failures, lost causes, and broad exception paths. | +| Dead-looking code | `UnusedAssignment`, `UnusedLocalVariable`, `UnusedPrivateField`, `UnusedPrivateMethod`, `EmptyControlStatement`, `IdenticalCatchBranches` | Find likely cleanup opportunities; source scanning and reflection require human review. | +| Public type documentation | Narrow `CommentRequired` plus empty-constructor/method checks | Review public type intent without requiring mass Javadocs for accessors, overrides, or records. | +| Mechanical cleanup | `UnnecessaryModifier` | Find small, high-confidence declaration mistakes. | +| Copy/paste | PMD CPD, 100 tokens | Identify exact repeated code for inspection, not to label code or infer authorship. | + +This is a production maintainability gate, **not an AI detector**. Findings are +review candidates and the baseline records why an existing finding is allowed. + +PMD type resolution is disabled because the project targets Java 17 while a +newer host JDK can expose class files that the pinned PMD ASM reader cannot +parse. CI provisions Java 17. On a newer local JDK, PMD may print non-fatal +`Unsupported class file major version` warnings; the XML report and normalized +gate still complete. Type-dependent rules therefore have reduced precision. + +Reports are disposable output: + +```text +target/pmd.xml +target/cpd.xml +target/java-lint-effective-pom.xml +target/java-lint/ +``` + +## Baseline and enforcement + +[`config/java-lint-baseline.json`](../config/java-lint-baseline.json) is the +reviewed baseline. It records the PMD/CPD version, the PMD ruleset digest, the +CPD token threshold, normalized finding details, classification, and rationale. + +The normalizer intentionally excludes source line numbers: + +- PMD fingerprints use rule, repository-relative path, class, method, variable, + and a normalized message. +- CPD fingerprints use token count, sorted paths, and a normalized fragment + hash. + +The gate compares fingerprints as multisets. It fails when a current finding is +outside the baseline, when the PMD version/ruleset/CPD configuration changes, +or when either XML report contains a processing error. Resolved baseline +findings are reported but do not fail the build. This makes line shifts and +duplicate findings deterministic without silently accepting new quality +regressions or partial reports. + +### Baseline captured for this change + +The current reviewed inventory contains: + +| Category | Count | Meaning | +| --- | ---: | --- | +| Maintainability | 67 | Parser/model complexity retained as an explicit reviewed backlog. | +| Intentional | 1 | The CLI's process-boundary `RuntimeException` catch. | +| High-confidence defect | 0 | Fixable dead code and duplicated parser mechanics were removed before capture. | +| Configuration noise | 0 | Documentation/parser false positives were removed from the production ruleset. | +| PMD findings | 68 | All listed with stable fingerprints in the JSON baseline. | +| CPD duplications | 0 | No duplication remains at the 100-token threshold. | + +The PMD distribution is 25 `CognitiveComplexity`, 25 +`CyclomaticComplexity`, 13 `NPathComplexity`, 3 `GodClass`, 1 `NcssCount`, +and 1 intentional `AvoidCatchingGenericException`. The largest reviewed +clusters are the source parser, bounded framework model, context loader, and +provider normalizer. + +Before capture, the change fixed unused locals/private code, isolated scanner +I/O error handling, consolidated repeated parser splitting and import +resolution, introduced named framework-fact inputs, and removed documentation +rules that reported valid record/annotation forms. No blanket suppression or +source exclusion was added. + +## Review policy + +For each finding: + +1. Fix it when it is a high-confidence defect or low-risk duplication. +2. Mark it `intentional` only when the behavior is required at a clear boundary + and the rationale explains the constraint. +3. Mark parser/model complexity `maintainability` when decomposition would risk + behavior without focused tests; keep it visible in the baseline. +4. Mark `config-noise` only for a documented tool/configuration false positive; + adjust the narrow ruleset when the signal is not useful. + +To refresh the baseline intentionally, regenerate the reports, inspect every +new or changed fingerprint, update the classification/rationale entries, and +run `make java-lint`. Do not replace the baseline with an empty file or add a +broad exclusion merely to make the count zero. + +## CI and limitations + +The CI workflow keeps adapter tests, spike 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. +CPD and PMD metrics are evidence for review, not proof that code is defective +or machine-generated. diff --git a/pom.xml b/pom.xml index 6ad184e..4d9a84a 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,7 @@ 3.0.1 2.17.2 5.11.0 + 7.17.0 @@ -61,6 +62,29 @@ false + + org.apache.maven.plugins + maven-pmd-plugin + 3.28.0 + + + ${project.basedir}/config/pmd-production.xml + + false + false + false + xml + ${project.build.directory}/java-lint + ${pmd.version} + ${maven.compiler.release} + false + 3 + false + false + 100 + true + + diff --git a/src/main/java/com/projectlightpath/analysis/java/AnalysisContext.java b/src/main/java/com/projectlightpath/analysis/java/AnalysisContext.java index ccb94c0..7e6a78b 100644 --- a/src/main/java/com/projectlightpath/analysis/java/AnalysisContext.java +++ b/src/main/java/com/projectlightpath/analysis/java/AnalysisContext.java @@ -577,6 +577,7 @@ private static Path normalize(Path path, String name) { return Objects.requireNonNull(path, name).toAbsolutePath().normalize(); } + /** Mutable construction surface for one immutable analysis context. */ public static final class Builder { private String id; private Framework framework; diff --git a/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java b/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java index 25e1629..dad542f 100644 --- a/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java +++ b/src/main/java/com/projectlightpath/analysis/java/BoundedFrameworkModel.java @@ -24,6 +24,23 @@ abstract class BoundedFrameworkModel implements FrameworkModel { private final AnalysisContext.Framework framework; + /** Named inputs for one emitted injection fact, avoiding positional arguments. */ + private record InjectionFactRequest( + AnalysisContext context, + SourceMetadataScanner.InjectionPoint injection, + SourceMetadataScanner.Parameter parameter, + SourceMetadataScanner.BeanCandidate candidate, + SemanticCallFacts.Certainty certainty, + String registryMetadata, + int candidateCount, + AnalysisContext.BindingCandidateMetadata candidateMetadata, + AnalysisContext.ResolvedBindingMetadata binding, + List bindingRows, + Integer parameterIndex, + SourceMetadataScanner scanner, + boolean legacyIdAllowed, + String limitation) {} + /** Creates a bounded model for one supported framework. */ BoundedFrameworkModel(AnalysisContext.Framework framework) { this.framework = Objects.requireNonNull(framework, "framework"); @@ -34,8 +51,8 @@ public final AnalysisContext.Framework framework() { return framework; } - @Override /** Enriches the provider graph with framework-owned facts without replacing it. */ + @Override public final List enrich(AnalysisContext context, AnalysisGraph graph) { if (context.framework() != framework) { throw new IllegalArgumentException( @@ -100,23 +117,24 @@ private List routeFacts( SemanticCallFacts.Fact fact = SemanticCallFacts.Fact.framework( id, context, - "external HTTP " + route.httpMethod() + " " + route.path(), - callee, - "framework_entrypoint", - certainty, - rule, - List.of(new SemanticCallFacts.Evidence( - "route_annotations", - route.file().displayPath(), - route.symbols(), - route.method().line(), - Map.of( - "annotationMetadata", metadataId, - "registrationMetadata", registrationMetadataId, - "registrationMetadataObserved", Boolean.toString(registrationMetadataAvailable), - "registeredRouteMapping", Boolean.toString(certainty == SemanticCallFacts.Certainty.DEFINITE), - "routeCandidate", "true"))), - limitation); + new SemanticCallFacts.Fact.FrameworkInput( + "external HTTP " + route.httpMethod() + " " + route.path(), + callee, + "framework_entrypoint", + certainty, + rule, + List.of(new SemanticCallFacts.Evidence( + "route_annotations", + route.file().displayPath(), + route.symbols(), + route.method().line(), + Map.of( + "annotationMetadata", metadataId, + "registrationMetadata", registrationMetadataId, + "registrationMetadataObserved", Boolean.toString(registrationMetadataAvailable), + "registeredRouteMapping", Boolean.toString(certainty == SemanticCallFacts.Certainty.DEFINITE), + "routeCandidate", "true"))), + limitation)); facts.add(fact.withFrameworkDetails( "registered_route_mapping", "potential_request_match", @@ -209,7 +227,7 @@ private List injectionFacts( boolean legacyIdAllowed = uniqueInjectionSite && matches.size() == 1; if (matches.isEmpty()) { - facts.add(injectionFact( + facts.add(injectionFact(new InjectionFactRequest( context, injection, parameter, @@ -223,7 +241,7 @@ private List injectionFacts( null, scanner, legacyIdAllowed, - "No active bounded bean candidate matches the injection type, qualifiers, profile, or binding metadata.")); + "No active bounded bean candidate matches the injection type, qualifiers, profile, or binding metadata."))); continue; } @@ -281,7 +299,7 @@ && candidateMetadataActive(context, scanner, matches.get(0)) == Boolean.TRUE : matches.size() > 1 ? "Multiple active bounded bean candidates remain after source-level filtering." : "The source-visible candidate is bounded, but exact framework selection metadata is incomplete."; - facts.add(injectionFact( + facts.add(injectionFact(new InjectionFactRequest( context, injection, parameter, @@ -295,7 +313,7 @@ && candidateMetadataActive(context, scanner, matches.get(0)) == Boolean.TRUE parameterIndex, scanner, legacyIdAllowed, - limitation)); + limitation))); } } } @@ -303,21 +321,21 @@ && candidateMetadataActive(context, scanner, matches.get(0)) == Boolean.TRUE } /** Builds one framework injection fact and its candidate evidence. */ - private SemanticCallFacts.Fact injectionFact( - AnalysisContext context, - SourceMetadataScanner.InjectionPoint injection, - SourceMetadataScanner.Parameter parameter, - SourceMetadataScanner.BeanCandidate candidate, - SemanticCallFacts.Certainty certainty, - String registryMetadata, - int candidateCount, - AnalysisContext.BindingCandidateMetadata candidateMetadata, - AnalysisContext.ResolvedBindingMetadata binding, - List bindingRows, - Integer parameterIndex, - SourceMetadataScanner scanner, - boolean legacyIdAllowed, - String limitation) { + private SemanticCallFacts.Fact injectionFact(InjectionFactRequest request) { + AnalysisContext context = request.context(); + SourceMetadataScanner.InjectionPoint injection = request.injection(); + SourceMetadataScanner.Parameter parameter = request.parameter(); + SourceMetadataScanner.BeanCandidate candidate = request.candidate(); + SemanticCallFacts.Certainty certainty = request.certainty(); + String registryMetadata = request.registryMetadata(); + int candidateCount = request.candidateCount(); + AnalysisContext.BindingCandidateMetadata candidateMetadata = request.candidateMetadata(); + AnalysisContext.ResolvedBindingMetadata binding = request.binding(); + List bindingRows = request.bindingRows(); + Integer parameterIndex = request.parameterIndex(); + SourceMetadataScanner scanner = request.scanner(); + boolean legacyIdAllowed = request.legacyIdAllowed(); + String limitation = request.limitation(); String owner = injection.ownerType(); String target = candidate == null ? parameter.type() + "#" @@ -380,16 +398,17 @@ private SemanticCallFacts.Fact injectionFact( SemanticCallFacts.Fact fact = SemanticCallFacts.Fact.framework( id, context, - callerPrefix + ":injection:" + owner + "#" + injection.member() - + "(" + parameter.type() + ")", - target, - "dependency_injection", - certainty, - framework == AnalysisContext.Framework.QUARKUS - ? "quarkus-unique-cdi-bean" - : "spring-unique-component-bean", - evidence, - limitation); + new SemanticCallFacts.Fact.FrameworkInput( + callerPrefix + ":injection:" + owner + "#" + injection.member() + + "(" + parameter.type() + ")", + target, + "dependency_injection", + certainty, + framework == AnalysisContext.Framework.QUARKUS + ? "quarkus-unique-cdi-bean" + : "spring-unique-component-bean", + evidence, + limitation)); return fact.withFrameworkDetails( "resolved_bean_binding", certainty == SemanticCallFacts.Certainty.DEFINITE @@ -501,30 +520,32 @@ private List proxyFacts( String id = proxyFactId(scanner, target); List proxySymbols = new ArrayList<>(scanner.sourceAnnotations(target, framework)); proxySymbols.add(target.method().name()); + String limitation = exact + ? null + : framework == AnalysisContext.Framework.QUARKUS + ? "Arc-generated proxy and interceptor order are not captured by the adapter." + : "The selected proxy strategy and interceptor ordering are not captured by the adapter."; + SemanticCallFacts.Evidence proxyEvidence = new SemanticCallFacts.Evidence( + framework == AnalysisContext.Framework.QUARKUS ? "bean_scope" : "advisor_annotation", + target.file().displayPath(), + proxySymbols, + target.method().line(), + proxyAttributes( + metadataId, + target, + metadata, + methodDescriptor(scanner, target).orElse(null))); facts.add(SemanticCallFacts.Fact.framework( id, context, - prefix + owner, - owner + "#" + target.method().name(), - "proxy_dispatch", - certainty, - rule, - List.of(new SemanticCallFacts.Evidence( - framework == AnalysisContext.Framework.QUARKUS ? "bean_scope" : "advisor_annotation", - target.file().displayPath(), - proxySymbols, - target.method().line(), - proxyAttributes( - metadataId, - target, - metadata, - methodDescriptor(scanner, target).orElse(null)))), - exact - ? null - : framework == AnalysisContext.Framework.QUARKUS - ? "Arc-generated proxy and interceptor order are not captured by the adapter." - : "The selected proxy strategy and interceptor ordering are not captured by the adapter." - )); + new SemanticCallFacts.Fact.FrameworkInput( + prefix + owner, + owner + "#" + target.method().name(), + "proxy_dispatch", + certainty, + rule, + List.of(proxyEvidence), + limitation))); SemanticCallFacts.Fact fact = facts.remove(facts.size() - 1); facts.add(fact.withFrameworkDetails( "proxy_target_dispatch", @@ -578,20 +599,21 @@ private List generatedFacts( facts.add(SemanticCallFacts.Fact.framework( generatedFactId(scanner, generated), context, - "quarkus:generated-source:" + generated.file().typeName(), - callee, - "generated_implementation", - certainty, - "quarkus-generated-source-binding", - List.of(new SemanticCallFacts.Evidence( - "generated_source", - generated.file().displayPath(), - List.of(generated.file().simpleTypeName() + "#" + generated.method().name()), - generated.method().line(), - Map.of("sourceRootRequired", "true"))), - certainty == SemanticCallFacts.Certainty.DEFINITE - ? null - : "Generated source was found, but the selected generated-source metadata is incomplete.")); + new SemanticCallFacts.Fact.FrameworkInput( + "quarkus:generated-source:" + generated.file().typeName(), + callee, + "generated_implementation", + certainty, + "quarkus-generated-source-binding", + List.of(new SemanticCallFacts.Evidence( + "generated_source", + generated.file().displayPath(), + List.of(generated.file().simpleTypeName() + "#" + generated.method().name()), + generated.method().line(), + Map.of("sourceRootRequired", "true"))), + certainty == SemanticCallFacts.Certainty.DEFINITE + ? null + : "Generated source was found, but the selected generated-source metadata is incomplete."))); } return facts; } @@ -616,18 +638,19 @@ private List dynamicProxyFacts( facts.add(SemanticCallFacts.Fact.framework( factId, context, - caller, - feature.target(), - "proxy_dispatch", - SemanticCallFacts.Certainty.UNRESOLVED, - framework == AnalysisContext.Framework.QUARKUS ? "quarkus-unbounded-proxy" : "spring-open-reflection", - List.of(new SemanticCallFacts.Evidence( - "dynamic_proxy_call", - feature.sourcePath(), - feature.symbols(), - feature.sourceLine() > 0 ? feature.sourceLine() : null, - dynamicProxyEvidenceAttributes(feature))), - feature.limitation())); + new SemanticCallFacts.Fact.FrameworkInput( + caller, + feature.target(), + "proxy_dispatch", + SemanticCallFacts.Certainty.UNRESOLVED, + framework == AnalysisContext.Framework.QUARKUS ? "quarkus-unbounded-proxy" : "spring-open-reflection", + List.of(new SemanticCallFacts.Evidence( + "dynamic_proxy_call", + feature.sourcePath(), + feature.symbols(), + feature.sourceLine() > 0 ? feature.sourceLine() : null, + dynamicProxyEvidenceAttributes(feature))), + feature.limitation()))); } return facts; } @@ -649,18 +672,19 @@ private List springRepositoryFacts( facts.add(SemanticCallFacts.Fact.framework( id, context, - "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.")); + 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."))); } return facts; } @@ -682,18 +706,19 @@ private List springLifecycleFacts( facts.add(SemanticCallFacts.Fact.framework( lifecycleFactId(scanner, file, method, "bean"), context, - "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.")); + 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.*")) { @@ -704,19 +729,19 @@ private List springLifecycleFacts( facts.add(SemanticCallFacts.Fact.framework( lifecycleFactId(scanner, file, method, "runner"), context, - "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.")); + 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."))); } } } @@ -743,12 +768,12 @@ private List qualifierMatches( } return candidates.stream() .filter(candidate -> requested.stream().allMatch(requestedQualifier -> - qualifierMatches(context, scanner, requestedQualifier, candidate, candidates))) + matchesRequestedQualifier(context, scanner, requestedQualifier, candidate, candidates))) .toList(); } /** Applies framework qualifier rules, including Spring bean-name fallbacks. */ - private boolean qualifierMatches( + private boolean matchesRequestedQualifier( AnalysisContext context, SourceMetadataScanner scanner, String requestedQualifier, @@ -1549,30 +1574,11 @@ private String injectionFactId( SourceMetadataScanner.Parameter parameter, SourceMetadataScanner.BeanCandidate candidate, boolean legacyIdAllowed) { - String owner = injection.file().simpleTypeName(); - String candidateName = candidate == null ? "unresolved" : candidate.file().simpleTypeName(); - if (framework == AnalysisContext.Framework.QUARKUS - && owner.equals("TransferResource") - && candidate != null - && !candidate.producer() - && candidateName.equals("DefaultTransferService") - && injection.ownerType().equals(injection.file().typeName()) - && uniqueSimpleType(scanner, owner) - && uniqueSimpleType(scanner, candidateName) - && legacyIdAllowed) { - return "quarkus-resource-to-cdi-bean"; - } - if (framework == AnalysisContext.Framework.SPRING_BOOT - && owner.equals("TransferController") - && candidate != null - && !candidate.producer() - && candidateName.equals("TransactionService") - && injection.ownerType().equals(injection.file().typeName()) - && uniqueSimpleType(scanner, owner) - && uniqueSimpleType(scanner, candidateName) - && legacyIdAllowed) { - return "spring-controller-to-service-injection"; + String legacyId = legacyInjectionFactId(scanner, injection, candidate, legacyIdAllowed); + if (legacyId != null) { + return legacyId; } + String candidateName = candidate == null ? "unresolved" : candidate.file().simpleTypeName(); String injectionSignature = injection.member() + "(" + String.join(",", injection.parameters().stream() .map(SourceMetadataScanner.Parameter::type) @@ -1599,6 +1605,34 @@ && uniqueSimpleType(scanner, candidateName) + "-" + digest(identity).substring(0, 16); } + /** Preserves fixture IDs only for the original unambiguous transfer bindings. */ + private String legacyInjectionFactId( + SourceMetadataScanner scanner, + SourceMetadataScanner.InjectionPoint injection, + SourceMetadataScanner.BeanCandidate candidate, + boolean legacyIdAllowed) { + if (candidate == null || candidate.producer() || !legacyIdAllowed + || !injection.ownerType().equals(injection.file().typeName())) { + return null; + } + String owner = injection.file().simpleTypeName(); + String candidateName = candidate.file().simpleTypeName(); + if (!uniqueSimpleType(scanner, owner) || !uniqueSimpleType(scanner, candidateName)) { + return null; + } + if (framework == AnalysisContext.Framework.QUARKUS + && owner.equals("TransferResource") + && candidateName.equals("DefaultTransferService")) { + return "quarkus-resource-to-cdi-bean"; + } + if (framework == AnalysisContext.Framework.SPRING_BOOT + && owner.equals("TransferController") + && candidateName.equals("TransactionService")) { + return "spring-controller-to-service-injection"; + } + return null; + } + /** Builds a stable proxy identity that includes the full target overload signature. */ private String proxyFactId(SourceMetadataScanner scanner, SourceMetadataScanner.ProxyTarget target) { if (framework == AnalysisContext.Framework.QUARKUS @@ -1630,7 +1664,6 @@ && uniqueMethodName(target.file(), target.method().name())) { + "-" + digest(identity).substring(0, 16); } - /** Ensures legacy fixture IDs cannot collide with another package's simple type. */ /** Checks whether a simple type name identifies one source declaration in this scan. */ private static boolean uniqueSimpleType(SourceMetadataScanner scanner, String simpleType) { return scanner.files().stream() @@ -1638,7 +1671,6 @@ private static boolean uniqueSimpleType(SourceMetadataScanner scanner, String si .count() == 1; } - /** Ensures a legacy proxy ID identifies one overload rather than a method family. */ /** Checks whether a method name has one overload in its declaring source type. */ private static boolean uniqueMethodName( SourceMetadataScanner.SourceFile file, diff --git a/src/main/java/com/projectlightpath/analysis/java/DynamicCertaintyPolicy.java b/src/main/java/com/projectlightpath/analysis/java/DynamicCertaintyPolicy.java new file mode 100644 index 0000000..c76a542 --- /dev/null +++ b/src/main/java/com/projectlightpath/analysis/java/DynamicCertaintyPolicy.java @@ -0,0 +1,41 @@ +package com.projectlightpath.analysis.java; + +/** Central certainty policy for open-world dynamic behavior. */ +final class DynamicCertaintyPolicy { + static final String LIMITATION = + "Open-world dynamic behavior does not identify a unique runtime target."; + + private DynamicCertaintyPolicy() {} + + /** Returns whether a provider relationship type must remain unresolved. */ + static boolean isOpenWorldProviderRelationship(String relationshipType) { + return "reflection".equals(relationshipType) + || "reflection_api".equals(relationshipType) + || "dynamic".equals(relationshipType) + || "dynamic_proxy".equals(relationshipType) + || "proxy_dispatch".equals(relationshipType); + } + + /** + * Returns whether framework evidence identifies an open-world dynamic site. + * Dynamic evidence overrides bounded model rules. + */ + static boolean isOpenWorldFrameworkFact(SemanticCallFacts.Fact fact) { + boolean proxyDispatch = "proxy_dispatch".equals(fact.relationshipType()); + if (!proxyDispatch && isOpenWorldProviderRelationship(fact.relationshipType())) { + return true; + } + if (!proxyDispatch) { + return fact.evidence().stream().anyMatch(DynamicCertaintyPolicy::isDynamicEvidence); + } + return "quarkus-unbounded-proxy".equals(fact.modelRule()) + || "spring-open-reflection".equals(fact.modelRule()) + || fact.evidence().stream().anyMatch(DynamicCertaintyPolicy::isDynamicEvidence); + } + + /** Returns whether evidence identifies reflection or an unbounded proxy call. */ + private static boolean isDynamicEvidence(SemanticCallFacts.Evidence evidence) { + return "reflection_api".equals(evidence.kind()) + || "dynamic_proxy_call".equals(evidence.kind()); + } +} diff --git a/src/main/java/com/projectlightpath/analysis/java/FrameworkModels.java b/src/main/java/com/projectlightpath/analysis/java/FrameworkModels.java index 273cf7d..3b32bc6 100644 --- a/src/main/java/com/projectlightpath/analysis/java/FrameworkModels.java +++ b/src/main/java/com/projectlightpath/analysis/java/FrameworkModels.java @@ -1,5 +1,6 @@ package com.projectlightpath.analysis.java; +/** Selects the bounded framework model for a supported framework. */ final class FrameworkModels { private FrameworkModels() {} diff --git a/src/main/java/com/projectlightpath/analysis/java/JavaAnalysisAdapter.java b/src/main/java/com/projectlightpath/analysis/java/JavaAnalysisAdapter.java index 807ebde..4d38405 100644 --- a/src/main/java/com/projectlightpath/analysis/java/JavaAnalysisAdapter.java +++ b/src/main/java/com/projectlightpath/analysis/java/JavaAnalysisAdapter.java @@ -53,12 +53,16 @@ public SemanticCallFacts analyze(AnalysisContext context, AnalysisGraph graph) { links(providerFacts, frameworkFacts)); } - /** Attaches provider identities only for unique eligible framework targets. */ + /** Abstains open-world dynamic evidence and attaches identities for eligible targets. */ private static List attachExactProviderIdentities( List frameworkFacts, List providerFacts) { List result = new ArrayList<>(); for (SemanticCallFacts.Fact frameworkFact : frameworkFacts) { + if (DynamicCertaintyPolicy.isOpenWorldFrameworkFact(frameworkFact)) { + result.add(frameworkFact.abstainDynamic()); + continue; + } if (frameworkFact.derivedFrom() != null) { boolean providerExists = providerFacts.stream() .anyMatch(provider -> provider.id().equals(frameworkFact.derivedFrom()) @@ -116,6 +120,9 @@ private static List links( providerFacts.forEach(fact -> providers.put(fact.id(), fact)); List result = new ArrayList<>(); for (SemanticCallFacts.Fact frameworkFact : frameworkFacts) { + if (DynamicCertaintyPolicy.isOpenWorldFrameworkFact(frameworkFact)) { + continue; + } if (frameworkFact.derivedFrom() == null && !frameworkFact.relationshipType().equals("framework_entrypoint")) { continue; diff --git a/src/main/java/com/projectlightpath/analysis/java/ManualAnalysisMain.java b/src/main/java/com/projectlightpath/analysis/java/ManualAnalysisMain.java index 5e2f01f..a2f81af 100644 --- a/src/main/java/com/projectlightpath/analysis/java/ManualAnalysisMain.java +++ b/src/main/java/com/projectlightpath/analysis/java/ManualAnalysisMain.java @@ -1,5 +1,6 @@ package com.projectlightpath.analysis.java; +import java.io.IOException; import java.nio.file.Path; import java.util.HashMap; import java.util.Map; @@ -22,7 +23,7 @@ public static void main(String[] args) { facts.providerFacts().size(), facts.frameworkFacts().size(), outputPath); - } catch (Exception error) { + } catch (IOException | RuntimeException error) { System.err.println("manual Java analysis failed: " + error.getMessage()); System.exit(1); } diff --git a/src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java b/src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java index d095f9f..28c9a71 100644 --- a/src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java +++ b/src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java @@ -30,12 +30,13 @@ static List normalize(AnalysisContext context, AnalysisG String id = baseIdCounts.get(baseId) == 1 ? uniqueId(ids, baseId, callIdentity) : uniqueId(ids, baseId + "-" + sha256(callIdentity), callIdentity); - boolean openWorldDynamic = isOpenWorldDynamic(edge.relationshipType()); + boolean openWorldDynamic = DynamicCertaintyPolicy.isOpenWorldProviderRelationship( + edge.relationshipType()); SemanticCallFacts.Certainty certainty = openWorldDynamic ? SemanticCallFacts.Certainty.UNRESOLVED : edge.certaintyHint(); String limitation = openWorldDynamic - ? "Open-world dynamic behavior does not identify a unique runtime target." + ? DynamicCertaintyPolicy.LIMITATION : certainty == SemanticCallFacts.Certainty.POSSIBLE ? "CHA exposes a bounded implementation/callback candidate; runtime selection is not unique." : null; @@ -136,10 +137,4 @@ private static String sha256(String value) { } } - /** Keeps open-world reflection and proxy edges abstained even if a provider overstates certainty. */ - private static boolean isOpenWorldDynamic(String relationshipType) { - return "reflection".equals(relationshipType) - || "dynamic_proxy".equals(relationshipType) - || "proxy_dispatch".equals(relationshipType); - } } diff --git a/src/main/java/com/projectlightpath/analysis/java/SemanticCallFacts.java b/src/main/java/com/projectlightpath/analysis/java/SemanticCallFacts.java index 67bd508..ccbe5c5 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SemanticCallFacts.java +++ b/src/main/java/com/projectlightpath/analysis/java/SemanticCallFacts.java @@ -210,35 +210,37 @@ public static Fact provider( limitation); } - public static Fact framework( - String id, - AnalysisContext context, + /** Named framework-fact inputs so callers cannot swap positional evidence fields. */ + public record FrameworkInput( String caller, String callee, String relationshipType, Certainty certainty, String modelRule, List evidence, - String limitation) { + String limitation) {} + + public static Fact framework(String id, AnalysisContext context, FrameworkInput input) { + Objects.requireNonNull(input, "framework fact input"); return new Fact( id, context.framework().id(), context.id(), - caller, - callee, - relationshipType, - certainty, - certainty == Certainty.UNRESOLVED ? Status.ABSTAINED : Status.EMITTED, + input.caller(), + input.callee(), + input.relationshipType(), + input.certainty(), + input.certainty() == Certainty.UNRESOLVED ? Status.ABSTAINED : Status.EMITTED, FactSet.FRAMEWORK, EvidenceOrigin.FRAMEWORK_MODEL, - modelRule, + input.modelRule(), null, null, null, null, null, - evidence, - limitation); + input.evidence(), + input.limitation()); } public Fact withFrameworkDetails( @@ -291,6 +293,37 @@ public Fact withDerivedFrom(String providerFactId) { limitation); } + /** Downgrades open-world dynamic evidence without preserving a misleading provider link. */ + public Fact abstainDynamic() { + if (certainty == Certainty.UNRESOLVED + && derivedFrom == null + && limitation != null + && !limitation.isBlank()) { + return this; + } + return new Fact( + id, + framework, + contextId, + caller, + callee, + relationshipType, + Certainty.UNRESOLVED, + Status.ABSTAINED, + factSet, + evidenceOrigin, + modelRule, + claim, + runtimeSelection, + providerCallerAbsence, + selectionCriteria, + null, + evidence, + limitation == null || limitation.isBlank() + ? DynamicCertaintyPolicy.LIMITATION + : limitation); + } + private static void require(String value, String name) { if (value == null || value.isBlank()) { throw new IllegalArgumentException(name + " must not be blank"); @@ -326,42 +359,137 @@ public static MergePolicy contract() { } } - public record RunMetadata( - String contextId, - String framework, - String frameworkVersion, - String fixtureId, - String selectedProfile, - String analysisScope, - int javaRelease, - String providerName, - String providerVersion, - int graphMethodCount, - int graphCallCount, - AnalysisContext.Provenance provenance, - AnalysisContext.Handoff handoff, - List generatedSourceRoots) { - public RunMetadata { - generatedSourceRoots = List.copyOf( - generatedSourceRoots == null ? List.of() : generatedSourceRoots); - } - - /** Compatibility constructor for callers that already provide build handoff metadata. */ - public RunMetadata( - String contextId, - String framework, - String frameworkVersion, - String fixtureId, - String selectedProfile, - String analysisScope, - int javaRelease, - String providerName, - String providerVersion, - int graphMethodCount, - int graphCallCount, - AnalysisContext.Provenance provenance, - AnalysisContext.Handoff handoff) { - this( + /** Immutable metadata emitted for one analysis context and provider graph. */ + public static final class RunMetadata { + private final String contextId; + private final String framework; + private final String frameworkVersion; + private final String fixtureId; + private final String selectedProfile; + private final String analysisScope; + private final int javaRelease; + private final String providerName; + private final String providerVersion; + private final int graphMethodCount; + private final int graphCallCount; + private final AnalysisContext.Provenance provenance; + private final AnalysisContext.Handoff handoff; + private final List generatedSourceRoots; + private final List sourceRoots; + + private RunMetadata(AnalysisContext context, AnalysisGraph graph) { + contextId = context.id(); + framework = context.framework().id(); + frameworkVersion = context.frameworkVersion(); + fixtureId = context.fixtureId(); + selectedProfile = context.selectedProfile(); + analysisScope = context.analysisScope().id(); + javaRelease = context.javaRelease(); + providerName = graph.providerName(); + providerVersion = graph.providerVersion(); + graphMethodCount = graph.methodCount(); + graphCallCount = graph.callCount(); + provenance = context.provenance(); + handoff = context.handoff(); + generatedSourceRoots = List.copyOf(context.generatedSourceRoots()); + sourceRoots = List.copyOf(context.sourceRoots()); + } + + /** Creates run metadata from the immutable context and provider graph inputs. */ + public static RunMetadata from(AnalysisContext context, AnalysisGraph graph) { + return new RunMetadata( + Objects.requireNonNull(context, "analysis context"), + Objects.requireNonNull(graph, "analysis graph")); + } + + public String contextId() { + return contextId; + } + + public String framework() { + return framework; + } + + public String frameworkVersion() { + return frameworkVersion; + } + + public String fixtureId() { + return fixtureId; + } + + public String selectedProfile() { + return selectedProfile; + } + + public String analysisScope() { + return analysisScope; + } + + public int javaRelease() { + return javaRelease; + } + + public String providerName() { + return providerName; + } + + public String providerVersion() { + return providerVersion; + } + + public int graphMethodCount() { + return graphMethodCount; + } + + public int graphCallCount() { + return graphCallCount; + } + + public AnalysisContext.Provenance provenance() { + return provenance; + } + + public AnalysisContext.Handoff handoff() { + return handoff; + } + + public List generatedSourceRoots() { + return generatedSourceRoots; + } + + public List sourceRoots() { + return sourceRoots; + } + + @Override + public boolean equals(Object value) { + if (this == value) { + return true; + } + if (!(value instanceof RunMetadata other)) { + return false; + } + return javaRelease == other.javaRelease + && graphMethodCount == other.graphMethodCount + && graphCallCount == other.graphCallCount + && Objects.equals(contextId, other.contextId) + && Objects.equals(framework, other.framework) + && Objects.equals(frameworkVersion, other.frameworkVersion) + && Objects.equals(fixtureId, other.fixtureId) + && Objects.equals(selectedProfile, other.selectedProfile) + && Objects.equals(analysisScope, other.analysisScope) + && Objects.equals(providerName, other.providerName) + && Objects.equals(providerVersion, other.providerVersion) + && Objects.equals(provenance, other.provenance) + && Objects.equals(handoff, other.handoff) + && Objects.equals(generatedSourceRoots, other.generatedSourceRoots) + && Objects.equals(sourceRoots, other.sourceRoots); + } + + @Override + public int hashCode() { + return Objects.hash( contextId, framework, frameworkVersion, @@ -375,38 +503,28 @@ public RunMetadata( graphCallCount, provenance, handoff, - List.of()); - } - - /** Compatibility constructor for callers that predate the JSON handoff metadata. */ - public RunMetadata( - String contextId, - String framework, - String frameworkVersion, - String fixtureId, - String selectedProfile, - String analysisScope, - int javaRelease, - String providerName, - String providerVersion, - int graphMethodCount, - int graphCallCount, - AnalysisContext.Provenance provenance) { - this( - contextId, - framework, - frameworkVersion, - fixtureId, - selectedProfile, - analysisScope, - javaRelease, - providerName, - providerVersion, - graphMethodCount, - graphCallCount, - provenance, - AnalysisContext.Handoff.empty(), - List.of()); + generatedSourceRoots, + sourceRoots); + } + + @Override + public String toString() { + return "RunMetadata[contextId=" + contextId + + ", framework=" + framework + + ", frameworkVersion=" + frameworkVersion + + ", fixtureId=" + fixtureId + + ", selectedProfile=" + selectedProfile + + ", analysisScope=" + analysisScope + + ", javaRelease=" + javaRelease + + ", providerName=" + providerName + + ", providerVersion=" + providerVersion + + ", graphMethodCount=" + graphMethodCount + + ", graphCallCount=" + graphCallCount + + ", provenance=" + provenance + + ", handoff=" + handoff + + ", generatedSourceRoots=" + generatedSourceRoots + + ", sourceRoots=" + sourceRoots + + "]"; } } @@ -438,21 +556,7 @@ public SemanticCallFacts( this.frameworkFacts = immutableFacts(frameworkFacts, FactSet.FRAMEWORK); this.links = List.copyOf(links == null ? List.of() : links); mergePolicy = MergePolicy.contract(); - runMetadata = new RunMetadata( - context.id(), - context.framework().id(), - context.frameworkVersion(), - context.fixtureId(), - context.selectedProfile(), - context.analysisScope().id(), - context.javaRelease(), - graph.providerName(), - graph.providerVersion(), - graph.methodCount(), - graph.callCount(), - context.provenance(), - context.handoff(), - context.generatedSourceRoots()); + runMetadata = RunMetadata.from(context, graph); validateLinks(); } diff --git a/src/main/java/com/projectlightpath/analysis/java/SemanticCallFactsJsonWriter.java b/src/main/java/com/projectlightpath/analysis/java/SemanticCallFactsJsonWriter.java index 16d1a45..a7ece68 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SemanticCallFactsJsonWriter.java +++ b/src/main/java/com/projectlightpath/analysis/java/SemanticCallFactsJsonWriter.java @@ -64,6 +64,9 @@ private static Map runMetadata(SemanticCallFacts.RunMetadata met result.put("graphCallCount", metadata.graphCallCount()); result.put("provenance", provenance(metadata.provenance())); result.put("handoff", handoff(metadata.handoff())); + result.put("sourceRoots", metadata.sourceRoots().stream() + .map(SemanticCallFactsJsonWriter::sourceRoot) + .toList()); result.put("generatedSourceRoots", metadata.generatedSourceRoots().stream() .map(SemanticCallFactsJsonWriter::sourceRoot) .toList()); diff --git a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java index bf81b41..966e85f 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java +++ b/src/main/java/com/projectlightpath/analysis/java/SourceMetadataScanner.java @@ -455,46 +455,53 @@ private static List importedTypes(String type, SourceFile sourceFile) { /** Resolves on-demand imports only against unambiguous scanned source types. */ private List onDemandImportedTypes(String type, SourceFile sourceFile) { + return onDemandImportedTypeNames( + type, + sourceFile, + files().stream().map(SourceFile::typeName)); + } + + /** Resolves on-demand imports against the bounded set of known JDK types. */ + private static List onDemandImportedJdkTypes(String type, SourceFile sourceFile) { + return onDemandImportedTypeNames(type, sourceFile, KNOWN_JDK_TYPE_NAMES.stream()); + } + + /** Applies one on-demand import lookup to either source or JDK type candidates. */ + private static List onDemandImportedTypeNames( + String type, SourceFile sourceFile, Stream candidates) { String simpleType = type; int nestedSeparator = type.indexOf('.'); if (nestedSeparator >= 0) { simpleType = type.substring(0, nestedSeparator); } - Matcher matcher = Pattern.compile("(?m)^\\s*import\\s+([A-Za-z_][\\w.]*)\\.\\*\\s*;") - .matcher(maskStringLiterals(sourceFile.structuralSource())); - Set packages = new LinkedHashSet<>(); - while (matcher.find()) { - packages.add(matcher.group(1)); - } + Set packages = onDemandImportPackages(sourceFile); String suffix = type.substring(simpleType.length()); String requestedSimpleType = simpleType; - return files().stream() - .filter(file -> packages.contains(file.packageName())) - .filter(file -> file.simpleTypeName().equals(requestedSimpleType)) - .map(file -> file.typeName() + suffix) + return candidates + .filter(candidate -> packages.contains(packageName(candidate))) + .filter(candidate -> qualifiedSimpleName(candidate).equals(requestedSimpleType)) + .map(candidate -> candidate + suffix) .distinct() .toList(); } - private static List onDemandImportedJdkTypes(String type, SourceFile sourceFile) { - String simpleType = type; - int nestedSeparator = type.indexOf('.'); - if (nestedSeparator >= 0) { - simpleType = type.substring(0, nestedSeparator); - } + private static Set onDemandImportPackages(SourceFile sourceFile) { Matcher matcher = Pattern.compile("(?m)^\\s*import\\s+([A-Za-z_][\\w.]*)\\.\\*\\s*;") .matcher(maskStringLiterals(sourceFile.structuralSource())); Set packages = new LinkedHashSet<>(); while (matcher.find()) { packages.add(matcher.group(1)); } - String suffix = type.substring(simpleType.length()); - String requestedSimpleType = simpleType; - return KNOWN_JDK_TYPE_NAMES.stream() - .filter(candidate -> packages.contains(candidate.substring(0, candidate.lastIndexOf('.')))) - .filter(candidate -> candidate.substring(candidate.lastIndexOf('.') + 1).equals(requestedSimpleType)) - .map(candidate -> candidate + suffix) - .toList(); + return packages; + } + + private static String packageName(String qualifiedName) { + int separator = qualifiedName.lastIndexOf('.'); + return separator < 0 ? "" : qualifiedName.substring(0, separator); + } + + private static String qualifiedSimpleName(String qualifiedName) { + return qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1); } /** Scans bounded Quarkus and Spring route annotations. */ @@ -913,6 +920,12 @@ private static List proxyInterfaceNames(String source, int proxyOffset) /** Splits one call's arguments without crossing nested expressions. */ private static List splitTopLevelArguments(String arguments) { + return splitBalanced(arguments, ',', false); + } + + /** Splits text at one delimiter while preserving nested Java expression boundaries. */ + private static List splitBalanced( + String source, char separator, boolean inferGenericStarts) { List result = new ArrayList<>(); int start = 0; int parentheses = 0; @@ -921,8 +934,8 @@ private static List splitTopLevelArguments(String arguments) { int angleBrackets = 0; boolean quoted = false; boolean escaped = false; - for (int index = 0; index < arguments.length(); index++) { - char character = arguments.charAt(index); + for (int index = 0; index < source.length(); index++) { + char character = source.charAt(index); if (character == '"' && !escaped) { quoted = !quoted; } @@ -942,25 +955,32 @@ private static List splitTopLevelArguments(String arguments) { case '{' -> braces++; case '}' -> braces--; case '<' -> { - if (parentheses == 0 && brackets == 0 && braces == 0) { + boolean atTopLevel = parentheses == 0 && brackets == 0 && braces == 0; + if (inferGenericStarts + ? looksLikeGenericStart(source, index, angleBrackets) + : atTopLevel) { angleBrackets++; } } case '>' -> { - if (parentheses == 0 && brackets == 0 && braces == 0) { + if (inferGenericStarts + || (parentheses == 0 && brackets == 0 && braces == 0)) { angleBrackets = Math.max(0, angleBrackets - 1); } } - case ',' -> { - if (parentheses == 0 && brackets == 0 && braces == 0 && angleBrackets == 0) { - result.add(arguments.substring(start, index).trim()); + default -> { + if (character == separator + && parentheses == 0 + && brackets == 0 + && braces == 0 + && angleBrackets == 0) { + result.add(source.substring(start, index).trim()); start = index + 1; } } - default -> { } } } - result.add(arguments.substring(start).trim()); + result.add(source.substring(start).trim()); return result; } @@ -1059,8 +1079,6 @@ private void addStaticForNameSites( List result, String dynamicSource, String source, SourceFile file) { Matcher matcher = STATIC_FOR_NAME.matcher(dynamicSource); while (matcher.find()) { - int prefixStart = Math.max(0, matcher.start() - 24); - String prefix = dynamicSource.substring(prefixStart, matcher.start()); int previous = matcher.start() - 1; while (previous >= 0 && Character.isWhitespace(dynamicSource.charAt(previous))) { previous--; @@ -1406,55 +1424,7 @@ private static String stripLeadingTypeParameterAnnotations(String parameter) { } private static List splitTypeIntersectionBounds(String expression) { - List result = new ArrayList<>(); - int start = 0; - int parentheses = 0; - int brackets = 0; - int braces = 0; - int angleBrackets = 0; - boolean quoted = false; - boolean escaped = false; - for (int index = 0; index < expression.length(); index++) { - char character = expression.charAt(index); - if (character == '"' && !escaped) { - quoted = !quoted; - } - if (quoted) { - escaped = character == '\\' && !escaped; - if (character != '\\') { - escaped = false; - } - continue; - } - escaped = false; - switch (character) { - case '(' -> parentheses++; - case ')' -> parentheses = Math.max(0, parentheses - 1); - case '[' -> brackets++; - case ']' -> brackets = Math.max(0, brackets - 1); - case '{' -> braces++; - case '}' -> braces = Math.max(0, braces - 1); - case '<' -> { - if (parentheses == 0 && brackets == 0 && braces == 0) { - angleBrackets++; - } - } - case '>' -> { - if (parentheses == 0 && brackets == 0 && braces == 0) { - angleBrackets = Math.max(0, angleBrackets - 1); - } - } - case '&' -> { - if (parentheses == 0 && brackets == 0 && braces == 0 && angleBrackets == 0) { - result.add(expression.substring(start, index).trim()); - start = index + 1; - } - } - default -> { } - } - } - result.add(expression.substring(start).trim()); - return result; + return splitBalanced(expression, '&', false); } private boolean isJdkTypeOrSubclass( @@ -2290,51 +2260,7 @@ private static int declaratorVariableOffset(String declarators, String variable) } private static List splitFieldDeclarators(String source) { - List result = new ArrayList<>(); - int start = 0; - int parentheses = 0; - int brackets = 0; - int braces = 0; - int angleBrackets = 0; - boolean quoted = false; - boolean escaped = false; - for (int index = 0; index < source.length(); index++) { - char character = source.charAt(index); - if (character == '"' && !escaped) { - quoted = !quoted; - } - if (quoted) { - escaped = character == '\\' && !escaped; - if (character != '\\') { - escaped = false; - } - continue; - } - escaped = false; - switch (character) { - case '(' -> parentheses++; - case ')' -> parentheses = Math.max(0, parentheses - 1); - case '[' -> brackets++; - case ']' -> brackets = Math.max(0, brackets - 1); - case '{' -> braces++; - case '}' -> braces = Math.max(0, braces - 1); - case '<' -> { - if (looksLikeGenericStart(source, index, angleBrackets)) { - angleBrackets++; - } - } - case '>' -> angleBrackets = Math.max(0, angleBrackets - 1); - case ',' -> { - if (parentheses == 0 && brackets == 0 && braces == 0 && angleBrackets == 0) { - result.add(source.substring(start, index).trim()); - start = index + 1; - } - } - default -> { } - } - } - result.add(source.substring(start).trim()); - return result; + return splitBalanced(source, ',', true); } private static boolean looksLikeGenericStart(String source, int opening, int currentDepth) { @@ -2552,30 +2478,6 @@ private TypeReference findTypeReference(String owner) { return null; } - private String resolveSuperType(TypeReference reference) { - String source = maskStringLiterals(reference.file().structuralSource()); - int opening = typeBodyOpening(source, reference.type().start()); - if (opening < 0) { - return null; - } - Matcher extendsClause = Pattern.compile("\\bextends\\s+([A-Za-z_$][\\w$.]*)") - .matcher(source.substring(reference.type().start(), opening)); - if (!extendsClause.find()) { - return null; - } - String name = extendsClause.group(1); - List imported = importedTypes(name, reference.file()); - if (imported.size() == 1) { - List importedDeclared = declaredTypeCandidates( - imported.get(0), reference.file(), reference.type().start()); - return importedDeclared.size() == 1 - ? importedDeclared.get(0) - : normalizeDeclaredTypeName(imported.get(0), reference.file()); - } - List declared = declaredTypeCandidates(name, reference.file(), reference.type().start()); - return declared.size() == 1 ? declared.get(0) : null; - } - /** Resolves top-level and nested source types to the binary names used by field lookup. */ private List declaredTypeCandidates(String spelling, SourceFile sourceFile, int siteOffset) { String normalized = spelling == null ? "" : spelling.trim(); @@ -3245,28 +3147,33 @@ private DynamicFeature dynamicProxyFeature( /** Reads source text and derives its package and top-level type identity. */ private SourceFile read(Path path, AnalysisContext.SourceRoot root) { + String source = readSource(path); + String structuralSource = stripComments(source); + String maskedStructuralSource = maskStringLiterals(structuralSource); + Matcher packageMatcher = PACKAGE.matcher(maskedStructuralSource); + String packageName = packageMatcher.find() ? packageMatcher.group(1) : ""; + Matcher typeMatcher = TYPE.matcher(maskedStructuralSource); + if (!typeMatcher.find()) { + throw new IllegalStateException("Java source has no top-level type: " + path); + } + String simpleName = typeMatcher.group(1); + String typeName = packageName.isBlank() ? simpleName : packageName + "." + simpleName; + return new SourceFile( + path, + context.displayPath(path), + source, + structuralSource, + packageName, + typeName, + simpleName, + typeMatcher.start(), + root.generated()); + } + + /** Reads one source file while preserving the scanner's unchecked failure contract. */ + private static String readSource(Path path) { try { - String source = Files.readString(path); - String structuralSource = stripComments(source); - String maskedStructuralSource = maskStringLiterals(structuralSource); - Matcher packageMatcher = PACKAGE.matcher(maskedStructuralSource); - String packageName = packageMatcher.find() ? packageMatcher.group(1) : ""; - Matcher typeMatcher = TYPE.matcher(maskedStructuralSource); - if (!typeMatcher.find()) { - throw new IllegalStateException("Java source has no top-level type: " + path); - } - String simpleName = typeMatcher.group(1); - String typeName = packageName.isBlank() ? simpleName : packageName + "." + simpleName; - return new SourceFile( - path, - context.displayPath(path), - source, - structuralSource, - packageName, - typeName, - simpleName, - typeMatcher.start(), - root.generated()); + return Files.readString(path); } catch (IOException error) { throw new IllegalStateException("cannot read Java source " + path, error); } diff --git a/src/test/java/com/projectlightpath/analysis/java/DynamicCertaintyGuardTest.java b/src/test/java/com/projectlightpath/analysis/java/DynamicCertaintyGuardTest.java new file mode 100644 index 0000000..741d10b --- /dev/null +++ b/src/test/java/com/projectlightpath/analysis/java/DynamicCertaintyGuardTest.java @@ -0,0 +1,281 @@ +package com.projectlightpath.analysis.java; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DynamicCertaintyGuardTest { + @TempDir Path temp; + + /** Verifies framework dynamic claims are abstained before provider linking. */ + @Test + void frameworkDynamicEvidenceCannotBePromotedOrLinked() throws IOException { + Path sourceRoot = temp.resolve("src/main/java"); + Files.createDirectories(sourceRoot); + AnalysisContext context = context(sourceRoot); + MethodSymbol caller = new MethodSymbol("demo.App", "run", "()V"); + MethodSymbol callee = new MethodSymbol("demo.Target", "run", "()V"); + AnalysisGraph graph = new AnalysisGraph( + List.of(new CallEdge( + caller, + callee, + 4, + "src/main/java/demo/App.java", + SemanticCallFacts.Certainty.DEFINITE, + "invokes")), + List.of(), + Set.of("demo.App", "demo.Target"), + "SootUp", + "3.0.1", + 2, + 1); + FrameworkModel overclaimingModel = new FrameworkModel() { + @Override + public AnalysisContext.Framework framework() { + return AnalysisContext.Framework.QUARKUS; + } + + @Override + public List enrich(AnalysisContext selectedContext, AnalysisGraph ignored) { + return List.of( + dynamicFact( + selectedContext, + "overstated-reflection", + "reflection", + "reflection_api"), + dynamicFact( + selectedContext, + "overstated-proxy", + "proxy_dispatch", + "dynamic_proxy_call"), + SemanticCallFacts.Fact.framework( + "bounded-proxy", + selectedContext, + new SemanticCallFacts.Fact.FrameworkInput( + "quarkus:arc-client-proxy:demo.Target", + "demo.Target#run", + "proxy_dispatch", + SemanticCallFacts.Certainty.DEFINITE, + "quarkus-metadata-proxy-target", + List.of(new SemanticCallFacts.Evidence( + "bean_scope", + "src/main/java/demo/Target.java", + List.of("ApplicationScoped"), + 2, + Map.of())), + null))); + } + }; + + SemanticCallFacts facts = new JavaAnalysisAdapter( + new SootUpBaseGraphProvider(), overclaimingModel).analyze(context, graph); + + List dynamicFacts = facts.frameworkFacts().stream() + .filter(DynamicCertaintyGuardTest::isDynamicFact) + .toList(); + assertEquals(2, dynamicFacts.size()); + assertTrue(dynamicFacts.stream().allMatch(fact -> + fact.certainty() == SemanticCallFacts.Certainty.UNRESOLVED + && fact.status() == SemanticCallFacts.Status.ABSTAINED + && fact.derivedFrom() == null)); + assertTrue(facts.links().isEmpty()); + + SemanticCallFacts.Fact boundedProxy = facts.frameworkFacts().stream() + .filter(fact -> fact.id().equals("bounded-proxy")) + .findFirst() + .orElseThrow(); + assertEquals(SemanticCallFacts.Certainty.DEFINITE, boundedProxy.certainty()); + assertEquals(SemanticCallFacts.Status.EMITTED, boundedProxy.status()); + } + + /** Ensures dynamic proxy evidence overrides a bounded proxy model rule. */ + @Test + void dynamicProxyEvidenceOverridesBoundedProxyModelRule() throws IOException { + Path sourceRoot = temp.resolve("src/main/java"); + Files.createDirectories(sourceRoot); + AnalysisContext context = context(sourceRoot); + SemanticCallFacts.Fact boundedProxy = SemanticCallFacts.Fact.framework( + "bounded-dynamic-proxy", + context, + new SemanticCallFacts.Fact.FrameworkInput( + "quarkus:arc-client-proxy:demo.Target", + "demo.Target#run", + "proxy_dispatch", + SemanticCallFacts.Certainty.DEFINITE, + "quarkus-metadata-proxy-target", + List.of(new SemanticCallFacts.Evidence( + "dynamic_proxy_call", + "src/main/java/demo/App.java", + List.of("Proxy.newProxyInstance"), + 4, + Map.of("targetDescriptor", "()V"))), + null)); + + assertTrue(DynamicCertaintyPolicy.isOpenWorldFrameworkFact(boundedProxy)); + SemanticCallFacts.Fact abstained = boundedProxy.abstainDynamic(); + + assertEquals(SemanticCallFacts.Certainty.UNRESOLVED, abstained.certainty()); + assertEquals(SemanticCallFacts.Status.ABSTAINED, abstained.status()); + assertNull(abstained.derivedFrom()); + } + + /** Verifies all supported dynamic relationship aliases override definite hints. */ + @Test + void providerDynamicAliasesRemainAbstained() throws IOException { + Path sourceRoot = temp.resolve("src/main/java"); + Files.createDirectories(sourceRoot); + AnalysisContext context = context(sourceRoot); + MethodSymbol caller = new MethodSymbol("demo.App", "run", "()V"); + MethodSymbol callee = new MethodSymbol("demo.Target", "run", "()V"); + List dynamicRelationships = List.of( + "reflection", "reflection_api", "dynamic", "dynamic_proxy", "proxy_dispatch"); + List edges = new java.util.ArrayList<>(); + for (int index = 0; index < dynamicRelationships.size(); index++) { + edges.add(new CallEdge( + caller, + callee, + index + 1, + "src/main/java/demo/App.java", + SemanticCallFacts.Certainty.DEFINITE, + dynamicRelationships.get(index))); + } + edges.add(new CallEdge( + caller, + callee, + 10, + "src/main/java/demo/App.java", + SemanticCallFacts.Certainty.DEFINITE, + "invokes")); + + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(context, new AnalysisGraph( + edges, + List.of(), + Set.of(), + "SootUp", + "3.0.1", + 0, + edges.size())); + + assertTrue(facts.providerFacts().stream() + .filter(fact -> dynamicRelationships.contains(fact.relationshipType())) + .allMatch(fact -> fact.certainty() == SemanticCallFacts.Certainty.UNRESOLVED + && fact.status() == SemanticCallFacts.Status.ABSTAINED)); + SemanticCallFacts.Fact ordinary = facts.providerFacts().stream() + .filter(fact -> fact.relationshipType().equals("invokes")) + .findFirst() + .orElseThrow(); + assertEquals(SemanticCallFacts.Certainty.DEFINITE, ordinary.certainty()); + assertFalse(ordinary.status() == SemanticCallFacts.Status.ABSTAINED); + } + + /** Ensures repeated dynamic abstention supplies the contract limitation when it is missing. */ + @Test + void alreadyUnresolvedDynamicFactReceivesDefaultLimitation() throws IOException { + Path sourceRoot = temp.resolve("src/main/java"); + Files.createDirectories(sourceRoot); + AnalysisContext context = context(sourceRoot); + SemanticCallFacts.Fact unresolved = SemanticCallFacts.Fact.framework( + "unresolved-without-limitation", + context, + new SemanticCallFacts.Fact.FrameworkInput( + "dynamic", + "demo.Target#run", + "reflection", + SemanticCallFacts.Certainty.UNRESOLVED, + "source-dynamic-reflection", + List.of(), + null)); + + SemanticCallFacts.Fact abstained = unresolved.abstainDynamic(); + + assertEquals(SemanticCallFacts.Status.ABSTAINED, abstained.status()); + assertEquals(DynamicCertaintyPolicy.LIMITATION, abstained.limitation()); + } + + /** Ensures abstention removes a provider identity assigned before dynamic certainty was known. */ + @Test + void abstainingDynamicFactClearsPreassignedProviderIdentity() throws IOException { + Path sourceRoot = temp.resolve("src/main/java"); + Files.createDirectories(sourceRoot); + AnalysisContext context = context(sourceRoot); + SemanticCallFacts.Fact linked = SemanticCallFacts.Fact.framework( + "linked-dynamic", + context, + new SemanticCallFacts.Fact.FrameworkInput( + "dynamic", + "demo.Target#run", + "proxy_dispatch", + SemanticCallFacts.Certainty.POSSIBLE, + "provider-hint", + List.of(), + null)) + .withDerivedFrom("provider-fact-id"); + + SemanticCallFacts.Fact abstained = linked.abstainDynamic(); + + assertEquals("provider-fact-id", linked.derivedFrom()); + assertEquals(SemanticCallFacts.Certainty.UNRESOLVED, abstained.certainty()); + assertEquals(SemanticCallFacts.Status.ABSTAINED, abstained.status()); + assertNull(abstained.derivedFrom()); + } + + /** Identifies the dynamic framework facts used by the regression assertions. */ + private static boolean isDynamicFact(SemanticCallFacts.Fact fact) { + return fact.relationshipType().equals("reflection") + || fact.evidence().stream().anyMatch(evidence -> + evidence.kind().equals("dynamic_proxy_call")); + } + + /** Builds a deliberately overstated dynamic framework fact. */ + private static SemanticCallFacts.Fact dynamicFact( + AnalysisContext context, + String id, + String relationshipType, + String evidenceKind) { + return SemanticCallFacts.Fact.framework( + id, + context, + new SemanticCallFacts.Fact.FrameworkInput( + "dynamic", + "demo.Target#run", + relationshipType, + SemanticCallFacts.Certainty.DEFINITE, + "overstated-default", + List.of(new SemanticCallFacts.Evidence( + evidenceKind, + "src/main/java/demo/App.java", + List.of(evidenceKind), + 4, + Map.of("targetDescriptor", "()V"))), + null)); + } + + /** Builds the minimal production context for certainty-guard tests. */ + private AnalysisContext context(Path sourceRoot) { + return AnalysisContext.builder() + .id("quarkus-production") + .framework(AnalysisContext.Framework.QUARKUS) + .frameworkVersion("3.40.1") + .fixtureId("dynamic-guard-test") + .javaRelease(17) + .repositoryRoot(temp) + .applicationClasses(temp.resolve("target/classes")) + .selectedProfile("production") + .analysisScope(AnalysisContext.Scope.PRODUCTION) + .sourceRoot("src/main/java", sourceRoot, AnalysisContext.Scope.PRODUCTION, true, + "source-root-fingerprint") + .dependencyClasspathRequired(false) + .provenance(AnalysisContext.Provenance.forTesting()) + .build(); + } +} diff --git a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java index 7dd7fa2..3a88a57 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -5181,7 +5182,7 @@ void collidingProviderBaseIdsAreStableAndNeverUseLegacyId() throws IOException { /** Preserves selected build context and generated roots in the serialized run metadata. */ @Test - void runMetadataPreservesProfileScopeRootsFingerprintsAndLimitations() throws IOException { + void runMetadataFactoryPreservesProfileScopeRootsFingerprintsAndLimitations() throws IOException { Path sourceRoot = temp.resolve("metadata/src/test/java"); Path generatedRoot = temp.resolve("metadata/target/generated-test-sources"); Files.createDirectories(sourceRoot); @@ -5196,7 +5197,12 @@ void runMetadataPreservesProfileScopeRootsFingerprintsAndLimitations() throws IO .applicationClasses(temp.resolve("metadata/classes")) .selectedProfile("test") .analysisScope(AnalysisContext.Scope.TEST) - .sourceRoot("src/test/java", sourceRoot, AnalysisContext.Scope.TEST, true) + .sourceRoot( + "src/test/java", + sourceRoot, + AnalysisContext.Scope.TEST, + true, + "source-root-fingerprint") .generatedSourceRoot( "target/generated-test-sources", generatedRoot, @@ -5231,16 +5237,26 @@ void runMetadataPreservesProfileScopeRootsFingerprintsAndLimitations() throws IO List.of("dynamic behavior remains unresolved"))) .build(); - SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(context, AnalysisGraph.empty()); + AnalysisGraph graph = new AnalysisGraph( + List.of(), List.of(), Set.of(), "SootUp", "3.0.1", 42, 17); + SemanticCallFacts.RunMetadata factoryMetadata = SemanticCallFacts.RunMetadata.from(context, graph); + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(context, graph); SemanticCallFacts.RunMetadata metadata = facts.runMetadata(); + assertEquals(factoryMetadata, metadata); assertEquals("test", metadata.selectedProfile()); assertEquals("test", metadata.analysisScope()); + assertEquals("SootUp", metadata.providerName()); + assertEquals(42, metadata.graphMethodCount()); + assertEquals(17, metadata.graphCallCount()); assertEquals(List.of(generatedRoot.toAbsolutePath().normalize()), metadata.generatedSourceRoots().stream() .map(AnalysisContext.SourceRoot::path) .toList()); + assertEquals("source-root-fingerprint", metadata.sourceRoots().get(0).fingerprint()); assertEquals("generated-fingerprint", metadata.provenance().generatedSourceFingerprint()); assertEquals("profile-digest", metadata.provenance().profileDigest()); assertEquals(List.of("dynamic behavior remains unresolved"), metadata.handoff().limitations()); + assertThrows(UnsupportedOperationException.class, () -> factoryMetadata.sourceRoots().clear()); + assertThrows(UnsupportedOperationException.class, () -> factoryMetadata.generatedSourceRoots().clear()); Path output = temp.resolve("metadata/semantic-call-facts.json"); SemanticCallFactsJsonWriter.write(facts, output); @@ -5249,6 +5265,8 @@ void runMetadataPreservesProfileScopeRootsFingerprintsAndLimitations() throws IO assertTrue(json.contains("\"analysisScope\" : \"test\"")); assertTrue(json.contains("\"generatedSourceRoots\"")); assertTrue(json.contains(generatedRoot.toAbsolutePath().normalize().toString())); + assertTrue(json.contains("\"sourceRoots\"")); + assertTrue(json.contains("\"source-root-fingerprint\"")); assertTrue(json.contains("\"generatedSourceFingerprint\" : \"generated-fingerprint\"")); assertTrue(json.contains("dynamic behavior remains unresolved")); } diff --git a/tools/java-lint-maven-plugin/pom.xml b/tools/java-lint-maven-plugin/pom.xml new file mode 100644 index 0000000..70b99e4 --- /dev/null +++ b/tools/java-lint-maven-plugin/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + org.projectlightpath + java-lint-maven-plugin + 0.1.0 + maven-plugin + Project Lightpath Java lint Maven plugin + + + 17 + UTF-8 + 3.9.11 + 3.15.1 + 3.15.1 + 2.17.2 + 5.11.0 + + + + + org.apache.maven + maven-plugin-api + ${maven.version} + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + ${maven.plugin.annotations.version} + provided + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${maven.compiler.release} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.0 + + false + + + + org.apache.maven.plugins + maven-plugin-plugin + ${maven.plugin.tools.version} + + java-lint + + + + default-descriptor + process-classes + + descriptor + + + + help-goal + + helpmojo + + + + + + + diff --git a/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/JavaLintValidator.java b/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/JavaLintValidator.java new file mode 100644 index 0000000..007e4e4 --- /dev/null +++ b/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/JavaLintValidator.java @@ -0,0 +1,623 @@ +package org.projectlightpath.javalint; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +/** Baseline-aware PMD/CPD report validation used by the Maven lint goal. */ +public final class JavaLintValidator { + public static final int CPD_MINIMUM_TOKENS = 100; + + private static final String MAVEN_NAMESPACE = "http://maven.apache.org/POM/4.0.0"; + private static final Pattern LOCATION_MESSAGE = + Pattern.compile("\\bat line [0-9][0-9,]*\\b"); + private static final Set REQUIRED_RULES = Set.of( + "CognitiveComplexity", + "CyclomaticComplexity", + "NPathComplexity", + "NcssCount", + "ExcessiveParameterList", + "AvoidCatchingGenericException", + "PreserveStackTrace", + "UnusedPrivateMethod", + "CommentRequired"); + private static final Set CLASSIFICATIONS = Set.of( + "maintainability", + "intentional", + "high-confidence-defect", + "config-noise"); + private static final ObjectMapper JSON = new ObjectMapper() + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + + private final Path root; + + public JavaLintValidator(Path root) { + this.root = root.toAbsolutePath().normalize(); + } + + /** Validates generated reports against the checked-in baseline. */ + public ValidationReport validate(boolean enforce) throws IOException { + Path ruleset = root.resolve("config/pmd-production.xml"); + Path baselineFile = root.resolve("config/java-lint-baseline.json"); + Path pmdReport = root.resolve("target/pmd.xml"); + Path cpdReport = root.resolve("target/cpd.xml"); + Path effectivePom = root.resolve("target/java-lint-effective-pom.xml"); + + requireFile(ruleset); + String rulesetText = Files.readString(ruleset, StandardCharsets.UTF_8); + List missingRules = REQUIRED_RULES.stream() + .filter(rule -> !rulesetText.contains(rule)) + .sorted() + .toList(); + if (!missingRules.isEmpty()) { + throw new IllegalArgumentException( + "java lint gate: ruleset lost required rules: " + String.join(", ", missingRules)); + } + requireFile(pmdReport); + requireFile(cpdReport); + + JsonNode baseline = loadBaseline(baselineFile); + Document pmd = parseXml(pmdReport); + Document cpd = parseXml(cpdReport); + List pmdFindings = collectPmd(pmd); + List cpdFindings = collectCpd(cpd); + Comparison pmdComparison = compareFingerprints( + fingerprints(pmdFindings), baselineFingerprints(baseline, "pmd")); + Comparison cpdComparison = compareFingerprints( + fingerprints(cpdFindings), baselineFingerprints(baseline, "cpd")); + List metadataErrors = validateMetadata( + pmd, cpd, baseline, rulesetText, effectivePom); + List reportErrors = new ArrayList<>(); + reportErrors.addAll(reportProcessingErrors(pmd, "PMD")); + reportErrors.addAll(reportProcessingErrors(cpd, "CPD")); + + List messages = new ArrayList<>(); + String mode = enforce ? "enforced" : "report-only"; + summarizePmd(messages, pmdFindings, mode); + summarizeCpd(messages, cpdFindings, mode); + messages.add("PMD baseline findings allowed: " + + (pmdFindings.size() - pmdComparison.newFingerprints().size())); + messages.add("PMD new findings: " + pmdComparison.newFingerprints().size() + + "; resolved baseline findings: " + pmdComparison.resolvedFingerprints().size()); + messages.add("CPD baseline duplications allowed: " + + (cpdFindings.size() - cpdComparison.newFingerprints().size())); + messages.add("CPD new duplications: " + cpdComparison.newFingerprints().size() + + "; resolved baseline duplications: " + cpdComparison.resolvedFingerprints().size()); + printNewFindings(messages, "PMD", pmdFindings, pmdComparison.newFingerprints()); + printNewFindings(messages, "CPD", cpdFindings, cpdComparison.newFingerprints()); + metadataErrors.forEach(error -> messages.add("Lint baseline metadata error: " + error)); + reportErrors.forEach(error -> messages.add("Lint report processing error: " + error)); + reviewInventory(messages); + + int findingFailures = pmdComparison.newFingerprints().size() + + cpdComparison.newFingerprints().size(); + int configurationFailures = metadataErrors.size() + reportErrors.size(); + boolean passed = configurationFailures == 0 && (!enforce || findingFailures == 0); + if (configurationFailures > 0) { + messages.add("java lint gate: FAIL (" + configurationFailures + + " report/configuration error(s))"); + } else if (enforce && findingFailures > 0) { + messages.add("java lint gate: FAIL (" + findingFailures + " new finding(s))"); + } else if (enforce) { + messages.add("java lint gate: PASS (no findings outside the reviewed baseline)"); + } else { + messages.add("java lint report-only: PASS (baseline comparison was not enforced)"); + } + return new ValidationReport( + passed, + findingFailures, + configurationFailures, + List.copyOf(messages)); + } + + static Document parseXml(String xml) { + try { + DocumentBuilderFactory factory = secureDocumentBuilderFactory(); + Document document = factory.newDocumentBuilder().parse( + new org.xml.sax.InputSource(new java.io.StringReader(xml))); + document.getDocumentElement().normalize(); + return document; + } catch (IOException | ParserConfigurationException | SAXException error) { + throw new IllegalArgumentException("invalid XML fixture", error); + } + } + + static String pmdFingerprint(Element file, Element violation) { + Map details = new LinkedHashMap<>(); + details.put("file", relativeSourcePath(Path.of("/"), file.getAttribute("name"))); + details.put("rule", attributeOr(violation, "rule", "unknown")); + details.put("class", violation.getAttribute("class")); + details.put("method", violation.getAttribute("method")); + details.put("variable", violation.getAttribute("variable")); + details.put("message", normalizeMessage(violation.getTextContent())); + return sha256(canonicalJson(details)); + } + + static String cpdFingerprint(Element duplication) { + List files = directChildren(duplication, "file").stream() + .map(file -> file.getAttribute("path")) + .map(path -> relativeSourcePath(Path.of("/"), path)) + .sorted() + .toList(); + String fragment = directChildren(duplication, "codefragment").stream() + .findFirst() + .map(Node::getTextContent) + .orElse(""); + Map details = new LinkedHashMap<>(); + details.put("tokens", duplication.getAttribute("tokens")); + details.put("files", files); + details.put("fragmentSha256", sha256(normalizeWhitespace(fragment))); + return sha256(canonicalJson(details)); + } + + static List reportProcessingErrors(Document document, String reportName) { + List errors = new ArrayList<>(); + for (Element error : descendants(document.getDocumentElement(), "error")) { + String detail = normalizeWhitespace(error.getTextContent()); + Map attributes = new TreeMap<>(); + NamedNodeMap nodeAttributes = error.getAttributes(); + for (int index = 0; index < nodeAttributes.getLength(); index++) { + Node attribute = nodeAttributes.item(index); + attributes.put(attribute.getNodeName(), attribute.getNodeValue()); + } + if (!attributes.isEmpty()) { + String formatted = attributes.entrySet().stream() + .map(entry -> entry.getKey() + "='" + entry.getValue() + "'") + .collect(java.util.stream.Collectors.joining(" ")); + detail = detail.isEmpty() ? formatted : detail + " (" + formatted + ")"; + } + errors.add(reportName + " report processing error: " + + (detail.isEmpty() ? "unspecified" : detail)); + } + return errors; + } + + static int effectiveCpdMinimumTokens(Path effectivePom) throws IOException { + requireFile(effectivePom); + Document document = parseXml(Files.readString(effectivePom, StandardCharsets.UTF_8)); + List plugins = new ArrayList<>(); + for (Element build : directChildren(document.getDocumentElement(), "build")) { + for (Element pluginContainer : directChildren(build, "plugins")) { + plugins.addAll(directChildren(pluginContainer, "plugin")); + } + } + List matching = plugins.stream() + .filter(plugin -> "maven-pmd-plugin".equals(childText(plugin, "artifactId"))) + .filter(plugin -> { + String groupId = childText(plugin, "groupId"); + return groupId.isEmpty() || "org.apache.maven.plugins".equals(groupId); + }) + .toList(); + if (matching.size() != 1) { + throw new IllegalArgumentException( + "java lint gate: effective POM must contain exactly one active maven-pmd-plugin"); + } + Element configuration = directChildren(matching.get(0), "configuration").stream() + .findFirst() + .orElse(null); + if (configuration == null) { + return CPD_MINIMUM_TOKENS; + } + String configured = childText(configuration, "minimumTokens"); + if (configured.isEmpty()) { + return CPD_MINIMUM_TOKENS; + } + try { + int value = Integer.parseInt(configured); + if (value <= 0) { + throw new IllegalArgumentException( + "java lint gate: effective CPD minimumTokens must be positive"); + } + return value; + } catch (NumberFormatException error) { + throw new IllegalArgumentException( + "java lint gate: effective CPD minimumTokens is not an integer: " + configured, + error); + } + } + + static Comparison compareFingerprints(List current, List baseline) { + Map currentCounts = counts(current); + Map baselineCounts = counts(baseline); + List newFingerprints = difference(currentCounts, baselineCounts); + List resolvedFingerprints = difference(baselineCounts, currentCounts); + return new Comparison(newFingerprints, resolvedFingerprints); + } + + private List collectPmd(Document document) { + List findings = new ArrayList<>(); + for (Element file : descendants(document.getDocumentElement(), "file")) { + for (Element violation : descendants(file, "violation")) { + Map details = new LinkedHashMap<>(); + details.put("file", relativeSourcePath(root, attributeOr(file, "name", "unknown"))); + details.put("rule", attributeOr(violation, "rule", "unknown")); + details.put("class", violation.getAttribute("class")); + details.put("method", violation.getAttribute("method")); + details.put("variable", violation.getAttribute("variable")); + details.put("message", normalizeMessage(violation.getTextContent())); + findings.add(new Finding(details)); + } + } + return findings; + } + + private List collectCpd(Document document) { + List findings = new ArrayList<>(); + for (Element duplication : descendants(document.getDocumentElement(), "duplication")) { + List files = directChildren(duplication, "file").stream() + .map(file -> relativeSourcePath(root, file.getAttribute("path"))) + .sorted() + .toList(); + String fragment = directChildren(duplication, "codefragment").stream() + .findFirst() + .map(Node::getTextContent) + .orElse(""); + Map details = new LinkedHashMap<>(); + details.put("tokens", duplication.getAttribute("tokens")); + details.put("files", files); + details.put("fragmentSha256", sha256(normalizeWhitespace(fragment))); + findings.add(new Finding(details)); + } + return findings; + } + + private List validateMetadata( + Document pmd, + Document cpd, + JsonNode baseline, + String rulesetText, + Path effectivePom) throws IOException { + List errors = new ArrayList<>(); + JsonNode pmdBaseline = baseline.path("pmd"); + JsonNode cpdBaseline = baseline.path("cpd"); + String reportPmdVersion = pmd.getDocumentElement().getAttribute("version"); + if (!reportPmdVersion.equals(pmdBaseline.path("pmdVersion").asText())) { + errors.add("PMD version changed: report=" + quote(reportPmdVersion) + + " baseline=" + quote(pmdBaseline.path("pmdVersion").asText())); + } + String rulesetDigest = sha256(rulesetText); + if (!rulesetDigest.equals(pmdBaseline.path("rulesetSha256").asText())) { + errors.add("PMD ruleset changed; review and intentionally refresh the baseline"); + } + String reportCpdVersion = cpd.getDocumentElement().getAttribute("pmdVersion"); + if (!reportCpdVersion.equals(cpdBaseline.path("pmdVersion").asText())) { + errors.add("CPD version changed: report=" + quote(reportCpdVersion) + + " baseline=" + quote(cpdBaseline.path("pmdVersion").asText())); + } + int effectiveMinimumTokens = effectiveCpdMinimumTokens(effectivePom); + if (effectiveMinimumTokens != CPD_MINIMUM_TOKENS) { + errors.add("CPD minimum token configuration changed: effective Maven=" + + effectiveMinimumTokens + "; policy=" + CPD_MINIMUM_TOKENS); + } + if (cpdBaseline.path("minimumTokens").asInt(Integer.MIN_VALUE) != effectiveMinimumTokens) { + errors.add("CPD minimum token configuration is inconsistent with the reviewed baseline: " + + "effective Maven=" + effectiveMinimumTokens + "; baseline=" + + cpdBaseline.path("minimumTokens").asText()); + } + return errors; + } + + private JsonNode loadBaseline(Path baselineFile) throws IOException { + requireFile(baselineFile); + JsonNode baseline; + try { + baseline = JSON.readTree(Files.readString(baselineFile, StandardCharsets.UTF_8)); + } catch (JsonProcessingException error) { + throw new IOException("java lint gate: invalid baseline " + baselineFile, error); + } + if (baseline == null || baseline.path("schemaVersion").asInt(Integer.MIN_VALUE) != 1) { + throw new IllegalArgumentException("java lint gate: unsupported baseline schemaVersion"); + } + for (String section : List.of("pmd", "cpd")) { + JsonNode sectionNode = baseline.get(section); + if (sectionNode == null || !sectionNode.isObject()) { + throw new IllegalArgumentException( + "java lint gate: baseline section " + section + " is missing"); + } + JsonNode entries = sectionNode.get("findings"); + if (entries == null || !entries.isArray()) { + throw new IllegalArgumentException( + "java lint gate: baseline section " + section + ".findings is missing"); + } + for (JsonNode entry : entries) { + if (!entry.isObject() || entry.path("fingerprint").asText().isBlank()) { + throw new IllegalArgumentException( + "java lint gate: malformed " + section + " baseline entry"); + } + if (!CLASSIFICATIONS.contains(entry.path("classification").asText())) { + throw new IllegalArgumentException( + "java lint gate: invalid " + section + " baseline classification"); + } + if (entry.path("rationale").asText().isBlank()) { + throw new IllegalArgumentException("java lint gate: baseline rationale is required"); + } + } + } + return baseline; + } + + private List baselineFingerprints(JsonNode baseline, String section) { + List fingerprints = new ArrayList<>(); + baseline.path(section).path("findings") + .forEach(entry -> fingerprints.add(entry.path("fingerprint").asText())); + return fingerprints; + } + + private void summarizePmd(List messages, List findings, String mode) { + Map byRule = findings.stream() + .map(Finding::details) + .map(details -> String.valueOf(details.get("rule"))) + .collect(java.util.stream.Collectors.groupingBy( + value -> value, TreeMap::new, java.util.stream.Collectors.counting())); + Map byFile = findings.stream() + .map(Finding::details) + .map(details -> String.valueOf(details.get("file"))) + .collect(java.util.stream.Collectors.groupingBy( + value -> value, TreeMap::new, java.util.stream.Collectors.counting())); + messages.add("PMD " + mode + " findings: " + findings.size()); + byRule.forEach((rule, count) -> messages.add(" " + rule + ": " + count)); + if (!byFile.isEmpty()) { + messages.add("PMD " + mode + " files with findings:"); + byFile.forEach((file, count) -> messages.add(" " + file + ": " + count)); + } + } + + private void summarizeCpd(List messages, List findings, String mode) { + messages.add("CPD " + mode + " duplications: " + findings.size()); + if (!findings.isEmpty()) { + messages.add(" token counts: " + findings.stream() + .map(finding -> String.valueOf(finding.details().get("tokens"))) + .collect(java.util.stream.Collectors.joining(", "))); + } + } + + private void printNewFindings( + List messages, + String label, + List findings, + List newFingerprints) { + if (newFingerprints.isEmpty()) { + return; + } + Map byFingerprint = new HashMap<>(); + findings.forEach(finding -> byFingerprint.put(finding.fingerprint(), finding)); + messages.add(label + " findings outside baseline:"); + for (String fingerprint : newFingerprints) { + messages.add(" " + canonicalJson(byFingerprint.get(fingerprint).details())); + } + } + + private void reviewInventory(List messages) throws IOException { + Path sourceRoot = root.resolve("src/main/java"); + List suppressions = new ArrayList<>(); + List placeholders = new ArrayList<>(); + if (Files.isDirectory(sourceRoot)) { + try (Stream sources = Files.walk(sourceRoot)) { + for (Path source : sources.filter(path -> path.toString().endsWith(".java")).toList()) { + List lines = Files.readAllLines(source, StandardCharsets.UTF_8); + for (int index = 0; index < lines.size(); index++) { + String line = lines.get(index); + String relative = root.relativize(source).toString().replace('\\', '/'); + if (line.contains("@SuppressWarnings")) { + suppressions.add(relative + ":" + (index + 1)); + } + if (line.matches(".*\\b(TODO|FIXME|XXX|HACK)\\b.*")) { + placeholders.add(relative + ":" + (index + 1)); + } + } + } + } + } + messages.add("production suppression inventory: " + suppressions.size()); + messages.add("placeholder-comment inventory: " + placeholders.size()); + if (!suppressions.isEmpty()) { + messages.add(" suppressions for manual review: " + String.join(", ", suppressions)); + } + if (!placeholders.isEmpty()) { + messages.add(" comment markers for manual review: " + String.join(", ", placeholders)); + } + } + + private static Document parseXml(Path path) throws IOException { + try { + Document document = secureDocumentBuilderFactory().newDocumentBuilder().parse(path.toFile()); + document.getDocumentElement().normalize(); + return document; + } catch (ParserConfigurationException | SAXException error) { + throw new IOException("java lint gate: invalid XML report " + path, error); + } + } + + private static DocumentBuilderFactory secureDocumentBuilderFactory() + throws ParserConfigurationException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory; + } + + private static List descendants(Element root, String name) { + List result = new ArrayList<>(); + NodeList children = root.getElementsByTagNameNS("*", name); + for (int index = 0; index < children.getLength(); index++) { + result.add((Element) children.item(index)); + } + if (children.getLength() == 0) { + NodeList fallback = root.getElementsByTagName(name); + for (int index = 0; index < fallback.getLength(); index++) { + result.add((Element) fallback.item(index)); + } + } + return result; + } + + private static List directChildren(Element root, String name) { + List result = new ArrayList<>(); + NodeList children = root.getChildNodes(); + for (int index = 0; index < children.getLength(); index++) { + Node child = children.item(index); + if (child.getNodeType() == Node.ELEMENT_NODE && name.equals(localName(child))) { + result.add((Element) child); + } + } + return result; + } + + private static String childText(Element root, String name) { + return directChildren(root, name).stream() + .findFirst() + .map(Node::getTextContent) + .map(String::trim) + .orElse(""); + } + + private static String localName(Node node) { + String localName = node.getLocalName(); + if (localName != null) { + return localName; + } + String name = node.getNodeName(); + int separator = name.indexOf(':'); + return separator >= 0 ? name.substring(separator + 1) : name; + } + + private static String attributeOr(Element element, String name, String fallback) { + String value = element.getAttribute(name); + return value.isEmpty() ? fallback : value; + } + + private static String relativeSourcePath(Path root, String pathText) { + try { + Path path = Path.of(pathText); + if (path.isAbsolute()) { + Path normalizedRoot = root.toAbsolutePath().normalize(); + Path normalizedPath = path.toAbsolutePath().normalize(); + if (normalizedPath.startsWith(normalizedRoot)) { + path = normalizedRoot.relativize(normalizedPath); + } + } + return path.toString().replace('\\', '/'); + } catch (InvalidPathException error) { + return pathText.replace('\\', '/'); + } + } + + private static String normalizeMessage(String message) { + return LOCATION_MESSAGE.matcher(normalizeWhitespace(message)).replaceAll("at line "); + } + + private static String normalizeWhitespace(String value) { + return value == null ? "" : value.trim().replaceAll("\\s+", " "); + } + + private static String canonicalJson(Object value) { + try { + return JSON.writeValueAsString(value); + } catch (JsonProcessingException error) { + throw new IllegalStateException("cannot serialize lint fingerprint", error); + } + } + + private static String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(digest.length * 2); + for (byte item : digest) { + result.append(String.format("%02x", item & 0xff)); + } + return result.toString(); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 is unavailable", error); + } + } + + private static Map counts(List values) { + Map counts = new HashMap<>(); + values.forEach(value -> counts.merge(value, 1, Integer::sum)); + return counts; + } + + private static List difference(Map left, Map right) { + List result = new ArrayList<>(); + left.forEach((value, count) -> { + int difference = count - right.getOrDefault(value, 0); + for (int index = 0; index < difference; index++) { + result.add(value); + } + }); + Collections.sort(result); + return result; + } + + private static List fingerprints(List findings) { + return findings.stream().map(Finding::fingerprint).toList(); + } + + private static void requireFile(Path path) throws IOException { + if (!Files.isRegularFile(path)) { + throw new IOException("java lint gate: missing file " + path); + } + } + + private static String quote(String value) { + return "'" + value + "'"; + } + + private record Finding(Map details) { + private String fingerprint() { + return sha256(canonicalJson(details)); + } + } + + static record Comparison(List newFingerprints, List resolvedFingerprints) {} + + /** Result returned to the Maven goal and unit tests. */ + public record ValidationReport( + boolean passed, + int newFindingCount, + int configurationErrorCount, + List messages) { + String failureMessage() { + return "Java lint gate failed: " + newFindingCount + " new finding(s), " + + configurationErrorCount + " report/configuration error(s)"; + } + } +} diff --git a/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/ValidateJavaLintMojo.java b/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/ValidateJavaLintMojo.java new file mode 100644 index 0000000..d2e7ca3 --- /dev/null +++ b/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/ValidateJavaLintMojo.java @@ -0,0 +1,36 @@ +package org.projectlightpath.javalint; + +import java.io.File; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +/** Maven goal that enforces the reviewed PMD/CPD baseline for a project. */ +@Mojo(name = "validate", requiresProject = true, threadSafe = true) +public final class ValidateJavaLintMojo extends AbstractMojo { + /** Project root containing config/, target/, and src/. */ + @Parameter(defaultValue = "${project.basedir}", readonly = true, required = true) + private File baseDirectory; + + /** Whether new PMD/CPD findings should fail the goal. */ + @Parameter(property = "javaLint.enforce", defaultValue = "true") + private boolean enforce; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + try { + JavaLintValidator.ValidationReport report = + new JavaLintValidator(baseDirectory.toPath()).validate(enforce); + report.messages().forEach(message -> getLog().info(message)); + if (!report.passed()) { + throw new MojoFailureException(report.failureMessage()); + } + } catch (MojoFailureException error) { + throw error; + } catch (Exception error) { + throw new MojoExecutionException("Java lint report validation failed", error); + } + } +} 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 new file mode 100644 index 0000000..a3a9d18 --- /dev/null +++ b/tools/java-lint-maven-plugin/src/test/java/org/projectlightpath/javalint/JavaLintValidatorTest.java @@ -0,0 +1,103 @@ +package org.projectlightpath.javalint; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.w3c.dom.Document; + +class JavaLintValidatorTest { + @TempDir Path temp; + + @Test + void pmdFingerprintIgnoresLineShifts() { + Document first = JavaLintValidator.parseXml( + "" + + "The method is at line 10"); + Document shifted = JavaLintValidator.parseXml( + "" + + "The method is at line 110"); + + assertEquals( + JavaLintValidator.pmdFingerprint( + (org.w3c.dom.Element) first.getElementsByTagName("file").item(0), + (org.w3c.dom.Element) first.getElementsByTagName("violation").item(0)), + JavaLintValidator.pmdFingerprint( + (org.w3c.dom.Element) shifted.getElementsByTagName("file").item(0), + (org.w3c.dom.Element) shifted.getElementsByTagName("violation").item(0))); + } + + @Test + void cpdFingerprintIgnoresLocationShifts() { + Document first = JavaLintValidator.parseXml( + "" + + "" + + "if (value) { return value; }"); + Document shifted = JavaLintValidator.parseXml( + "" + + "" + + "if (value) { return value; }"); + + assertEquals( + JavaLintValidator.cpdFingerprint( + (org.w3c.dom.Element) first.getElementsByTagName("duplication").item(0)), + JavaLintValidator.cpdFingerprint( + (org.w3c.dom.Element) shifted.getElementsByTagName("duplication").item(0))); + } + + @Test + void duplicateFingerprintsUseMultisetComparison() { + JavaLintValidator.Comparison comparison = JavaLintValidator.compareFingerprints( + List.of("same", "same"), List.of("same")); + + assertEquals(List.of("same"), comparison.newFingerprints()); + assertEquals(List.of(), comparison.resolvedFingerprints()); + } + + @Test + void effectiveCpdThresholdIsReadFromMavenConfiguration() throws Exception { + Path effectivePom = temp.resolve("effective-pom.xml"); + Files.writeString(effectivePom, """ + + + org.apache.maven.plugins + maven-pmd-plugin + 200 + + + """); + + assertEquals(200, JavaLintValidator.effectiveCpdMinimumTokens(effectivePom)); + } + + @Test + void processingErrorsAreReported() { + Document report = JavaLintValidator.parseXml( + "parse failed"); + + assertEquals( + List.of("PMD report processing error: parse failed (filename='Broken.java')"), + JavaLintValidator.reportProcessingErrors(report, "PMD")); + } + + @Test + void effectiveThresholdChangeIsDetectedAgainstBaseline() throws Exception { + Path effectivePom = temp.resolve("effective-pom.xml"); + Files.writeString(effectivePom, """ + + + maven-pmd-plugin + 200 + + + """); + + assertTrue(JavaLintValidator.effectiveCpdMinimumTokens(effectivePom) != 100); + } +} From 07dc9b2765f5232119bf9d10eb55727c6f2c3068 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Wed, 11 Nov 2026 11:52:14 -0500 Subject: [PATCH 2/3] Harden Java lint review safeguards --- config/java-lint-baseline.json | 10 +- docs/java-lint.md | 15 +- .../javalint/JavaLintValidator.java | 287 +++++++++++++++--- .../javalint/ValidateJavaLintMojo.java | 1 + .../javalint/JavaLintValidatorTest.java | 127 ++++++++ 5 files changed, 398 insertions(+), 42 deletions(-) diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json index b0ba1cc..2647abf 100644 --- a/config/java-lint-baseline.json +++ b/config/java-lint-baseline.json @@ -7,6 +7,7 @@ }, "description": "Reviewed PMD/CPD findings for the production Java quality gate. New fingerprints fail CI; resolved baseline entries are reported for cleanup.", "pmd": { + "includeTests": false, "findings": [ { "class": "AnalysisContextLoader", @@ -757,8 +758,15 @@ "variable": "" } ], + "minimumPriority": 3, "pmdVersion": "7.17.0", - "rulesetSha256": "08c9bf5e0f1a4e1a95d46d8ef69bf397426bbaf048513e00c353f490bc566146" + "rulesetSha256": "08c9bf5e0f1a4e1a95d46d8ef69bf397426bbaf048513e00c353f490bc566146", + "rulesets": [ + "config/pmd-production.xml" + ], + "sourceScope": [ + "src/main/java" + ] }, "schemaVersion": 1 } diff --git a/docs/java-lint.md b/docs/java-lint.md index 6eea78a..dafb20a 100644 --- a/docs/java-lint.md +++ b/docs/java-lint.md @@ -62,8 +62,11 @@ target/java-lint/ ## Baseline and enforcement [`config/java-lint-baseline.json`](../config/java-lint-baseline.json) is the -reviewed baseline. It records the PMD/CPD version, the PMD ruleset digest, the -CPD token threshold, normalized finding details, classification, and rationale. +reviewed baseline. It records the PMD/CPD versions, selected ruleset, minimum +priority, production source scope, CPD token threshold, normalized finding +details, classification, and rationale. The validator compares those PMD +settings with Maven's effective POM so a changed ruleset, priority, or test +scope cannot make findings disappear silently. The normalizer intentionally excludes source line numbers: @@ -74,10 +77,10 @@ The normalizer intentionally excludes source line numbers: The gate compares fingerprints as multisets. It fails when a current finding is outside the baseline, when the PMD version/ruleset/CPD configuration changes, -or when either XML report contains a processing error. Resolved baseline -findings are reported but do not fail the build. This makes line shifts and -duplicate findings deterministic without silently accepting new quality -regressions or partial reports. +or when either XML report contains a processing/configuration error. Resolved +baseline findings are reported but do not fail the build. This makes line +shifts and duplicate findings deterministic without silently accepting new +quality regressions or partial reports. ### Baseline captured for this change diff --git a/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/JavaLintValidator.java b/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/JavaLintValidator.java index 007e4e4..b5b3140 100644 --- a/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/JavaLintValidator.java +++ b/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/JavaLintValidator.java @@ -12,17 +12,13 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; -import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.stream.Stream; import javax.xml.XMLConstants; @@ -37,11 +33,16 @@ /** Baseline-aware PMD/CPD report validation used by the Maven lint goal. */ public final class JavaLintValidator { + /** Reviewed CPD policy threshold in tokens. */ public static final int CPD_MINIMUM_TOKENS = 100; - private static final String MAVEN_NAMESPACE = "http://maven.apache.org/POM/4.0.0"; + private static final String PMD_PLUGIN_ARTIFACT_ID = "maven-pmd-plugin"; + private static final String PMD_PLUGIN_GROUP_ID = "org.apache.maven.plugins"; + private static final int DEFAULT_PMD_MINIMUM_PRIORITY = 5; + private static final boolean DEFAULT_PMD_INCLUDE_TESTS = false; private static final Pattern LOCATION_MESSAGE = Pattern.compile("\\bat line [0-9][0-9,]*\\b"); + private static final Pattern PMD_SUPPRESSION_MARKER = Pattern.compile("\\bNOPMD\\b"); private static final Set REQUIRED_RULES = Set.of( "CognitiveComplexity", "CyclomaticComplexity", @@ -62,6 +63,7 @@ public final class JavaLintValidator { private final Path root; + /** Creates a validator rooted at the Maven project directory. */ public JavaLintValidator(Path root) { this.root = root.toAbsolutePath().normalize(); } @@ -90,6 +92,7 @@ public ValidationReport validate(boolean enforce) throws IOException { JsonNode baseline = loadBaseline(baselineFile); Document pmd = parseXml(pmdReport); Document cpd = parseXml(cpdReport); + EffectivePmdConfiguration effectivePmd = effectivePmdConfiguration(effectivePom, root); List pmdFindings = collectPmd(pmd); List cpdFindings = collectCpd(cpd); Comparison pmdComparison = compareFingerprints( @@ -97,7 +100,7 @@ public ValidationReport validate(boolean enforce) throws IOException { Comparison cpdComparison = compareFingerprints( fingerprints(cpdFindings), baselineFingerprints(baseline, "cpd")); List metadataErrors = validateMetadata( - pmd, cpd, baseline, rulesetText, effectivePom); + pmd, cpd, baseline, rulesetText, effectivePmd); List reportErrors = new ArrayList<>(); reportErrors.addAll(reportProcessingErrors(pmd, "PMD")); reportErrors.addAll(reportProcessingErrors(cpd, "CPD")); @@ -141,6 +144,7 @@ public ValidationReport validate(boolean enforce) throws IOException { List.copyOf(messages)); } + /** Parses a small XML fixture with external entity resolution disabled. */ static Document parseXml(String xml) { try { DocumentBuilderFactory factory = secureDocumentBuilderFactory(); @@ -153,6 +157,7 @@ static Document parseXml(String xml) { } } + /** Creates a line-stable fingerprint for one PMD violation. */ static String pmdFingerprint(Element file, Element violation) { Map details = new LinkedHashMap<>(); details.put("file", relativeSourcePath(Path.of("/"), file.getAttribute("name"))); @@ -164,6 +169,7 @@ static String pmdFingerprint(Element file, Element violation) { return sha256(canonicalJson(details)); } + /** Creates a line-stable fingerprint for one CPD duplication. */ static String cpdFingerprint(Element duplication) { List files = directChildren(duplication, "file").stream() .map(file -> file.getAttribute("path")) @@ -181,31 +187,74 @@ static String cpdFingerprint(Element duplication) { return sha256(canonicalJson(details)); } + /** Returns processing and configuration errors embedded in a PMD/CPD report. */ static List reportProcessingErrors(Document document, String reportName) { List errors = new ArrayList<>(); - for (Element error : descendants(document.getDocumentElement(), "error")) { - String detail = normalizeWhitespace(error.getTextContent()); - Map attributes = new TreeMap<>(); - NamedNodeMap nodeAttributes = error.getAttributes(); - for (int index = 0; index < nodeAttributes.getLength(); index++) { - Node attribute = nodeAttributes.item(index); - attributes.put(attribute.getNodeName(), attribute.getNodeValue()); - } - if (!attributes.isEmpty()) { - String formatted = attributes.entrySet().stream() - .map(entry -> entry.getKey() + "='" + entry.getValue() + "'") - .collect(java.util.stream.Collectors.joining(" ")); - detail = detail.isEmpty() ? formatted : detail + " (" + formatted + ")"; + for (String errorElement : List.of("error", "configerror")) { + for (Element error : descendants(document.getDocumentElement(), errorElement)) { + String detail = normalizeWhitespace(error.getTextContent()); + Map attributes = new TreeMap<>(); + NamedNodeMap nodeAttributes = error.getAttributes(); + for (int index = 0; index < nodeAttributes.getLength(); index++) { + Node attribute = nodeAttributes.item(index); + attributes.put(attribute.getNodeName(), attribute.getNodeValue()); + } + if (!attributes.isEmpty()) { + String formatted = attributes.entrySet().stream() + .map(entry -> entry.getKey() + "='" + entry.getValue() + "'") + .collect(java.util.stream.Collectors.joining(" ")); + detail = detail.isEmpty() ? formatted : detail + " (" + formatted + ")"; + } + String kind = "configerror".equals(errorElement) + ? "configuration error" + : "processing error"; + errors.add(reportName + " report " + kind + ": " + + (detail.isEmpty() ? "unspecified" : detail)); } - errors.add(reportName + " report processing error: " - + (detail.isEmpty() ? "unspecified" : detail)); } return errors; } - static int effectiveCpdMinimumTokens(Path effectivePom) throws IOException { + /** Reads the effective PMD plugin configuration emitted by Maven. */ + static EffectivePmdConfiguration effectivePmdConfiguration( + Path effectivePom, Path projectRoot) throws IOException { requireFile(effectivePom); Document document = parseXml(Files.readString(effectivePom, StandardCharsets.UTF_8)); + Element plugin = effectivePmdPlugin(document); + Element configuration = directChildren(plugin, "configuration").stream() + .findFirst() + .orElse(null); + + List rulesets = configuredPaths(configuration, "rulesets", "ruleset", projectRoot); + int minimumPriority = configuredInteger( + configuration, "minimumPriority", DEFAULT_PMD_MINIMUM_PRIORITY); + boolean includeTests = configuredBoolean( + configuration, "includeTests", DEFAULT_PMD_INCLUDE_TESTS); + int minimumTokens = configuredInteger(configuration, "minimumTokens", CPD_MINIMUM_TOKENS); + List sourceScope = new ArrayList<>(configuredPaths( + configuration, "sourceDirectories", "sourceDirectory", projectRoot)); + if (sourceScope.isEmpty()) { + Element build = directChildren(document.getDocumentElement(), "build").stream() + .findFirst() + .orElse(null); + if (build != null) { + addConfiguredPath(sourceScope, childText(build, "sourceDirectory"), projectRoot); + if (includeTests) { + addConfiguredPath(sourceScope, childText(build, "testSourceDirectory"), projectRoot); + } + } + } + return new EffectivePmdConfiguration( + rulesets, minimumPriority, includeTests, sourceScope, minimumTokens); + } + + /** Returns the effective CPD token threshold from Maven's PMD configuration. */ + static int effectiveCpdMinimumTokens(Path effectivePom) throws IOException { + return effectivePmdConfiguration(effectivePom, Path.of("/")).minimumTokens(); + } + + /** Finds the single active PMD plugin in Maven's effective build. */ + private static Element effectivePmdPlugin(Document document) { List plugins = new ArrayList<>(); for (Element build : directChildren(document.getDocumentElement(), "build")) { for (Element pluginContainer : directChildren(build, "plugins")) { @@ -213,40 +262,90 @@ static int effectiveCpdMinimumTokens(Path effectivePom) throws IOException { } } List matching = plugins.stream() - .filter(plugin -> "maven-pmd-plugin".equals(childText(plugin, "artifactId"))) + .filter(plugin -> PMD_PLUGIN_ARTIFACT_ID.equals(childText(plugin, "artifactId"))) .filter(plugin -> { String groupId = childText(plugin, "groupId"); - return groupId.isEmpty() || "org.apache.maven.plugins".equals(groupId); + return groupId.isEmpty() || PMD_PLUGIN_GROUP_ID.equals(groupId); }) .toList(); if (matching.size() != 1) { throw new IllegalArgumentException( "java lint gate: effective POM must contain exactly one active maven-pmd-plugin"); } - Element configuration = directChildren(matching.get(0), "configuration").stream() - .findFirst() - .orElse(null); + return matching.get(0); + } + + /** Reads and normalizes a path-valued list from plugin configuration. */ + private static List configuredPaths( + Element configuration, + String containerName, + String valueName, + Path projectRoot) { if (configuration == null) { - return CPD_MINIMUM_TOKENS; + return List.of(); } - String configured = childText(configuration, "minimumTokens"); + List values = new ArrayList<>(); + for (Element container : directChildren(configuration, containerName)) { + for (Element value : directChildren(container, valueName)) { + addConfiguredPath(values, value.getTextContent(), projectRoot); + } + } + return List.copyOf(values); + } + + /** Adds one effective-POM path in repository-relative form when possible. */ + private static void addConfiguredPath(List values, String value, Path projectRoot) { + if (value == null || value.isBlank()) { + return; + } + try { + Path root = projectRoot.toAbsolutePath().normalize(); + Path path = Path.of(value.trim()); + Path normalized = path.isAbsolute() ? path.normalize() : root.resolve(path).normalize(); + if (normalized.startsWith(root)) { + values.add(root.relativize(normalized).toString().replace('\\', '/')); + } else { + values.add(normalized.toString().replace('\\', '/')); + } + } catch (InvalidPathException error) { + values.add(value.trim().replace('\\', '/')); + } + } + + /** Reads a positive integer plugin setting or its Maven default. */ + private static int configuredInteger(Element configuration, String name, int defaultValue) { + String configured = configuration == null ? "" : childText(configuration, name); if (configured.isEmpty()) { - return CPD_MINIMUM_TOKENS; + return defaultValue; } try { int value = Integer.parseInt(configured); if (value <= 0) { throw new IllegalArgumentException( - "java lint gate: effective CPD minimumTokens must be positive"); + "java lint gate: effective " + name + " must be positive"); } return value; } catch (NumberFormatException error) { throw new IllegalArgumentException( - "java lint gate: effective CPD minimumTokens is not an integer: " + configured, + "java lint gate: effective " + name + " is not an integer: " + configured, error); } } + /** Reads a boolean plugin setting or its Maven default. */ + private static boolean configuredBoolean(Element configuration, String name, boolean defaultValue) { + String configured = configuration == null ? "" : childText(configuration, name); + if (configured.isEmpty()) { + return defaultValue; + } + if (!"true".equalsIgnoreCase(configured) && !"false".equalsIgnoreCase(configured)) { + throw new IllegalArgumentException( + "java lint gate: effective " + name + " is not boolean: " + configured); + } + return Boolean.parseBoolean(configured); + } + + /** Compares current and reviewed fingerprints as multisets. */ static Comparison compareFingerprints(List current, List baseline) { Map currentCounts = counts(current); Map baselineCounts = counts(baseline); @@ -255,6 +354,7 @@ static Comparison compareFingerprints(List current, List baselin return new Comparison(newFingerprints, resolvedFingerprints); } + /** Collects normalized PMD findings from a report document. */ private List collectPmd(Document document) { List findings = new ArrayList<>(); for (Element file : descendants(document.getDocumentElement(), "file")) { @@ -272,6 +372,7 @@ private List collectPmd(Document document) { return findings; } + /** Collects normalized CPD duplications from a report document. */ private List collectCpd(Document document) { List findings = new ArrayList<>(); for (Element duplication : descendants(document.getDocumentElement(), "duplication")) { @@ -292,12 +393,13 @@ private List collectCpd(Document document) { return findings; } + /** Compares report and effective-POM metadata with reviewed baseline metadata. */ private List validateMetadata( Document pmd, Document cpd, JsonNode baseline, String rulesetText, - Path effectivePom) throws IOException { + EffectivePmdConfiguration effectivePmd) { List errors = new ArrayList<>(); JsonNode pmdBaseline = baseline.path("pmd"); JsonNode cpdBaseline = baseline.path("cpd"); @@ -310,12 +412,32 @@ private List validateMetadata( if (!rulesetDigest.equals(pmdBaseline.path("rulesetSha256").asText())) { errors.add("PMD ruleset changed; review and intentionally refresh the baseline"); } + List reviewedRulesets = jsonStringList(pmdBaseline, "rulesets"); + if (!reviewedRulesets.equals(effectivePmd.rulesets())) { + errors.add("PMD rule selection changed: effective Maven=" + effectivePmd.rulesets() + + "; baseline=" + reviewedRulesets); + } + int reviewedPriority = pmdBaseline.path("minimumPriority").asInt(Integer.MIN_VALUE); + if (reviewedPriority != effectivePmd.minimumPriority()) { + errors.add("PMD minimum priority changed: effective Maven=" + + effectivePmd.minimumPriority() + "; baseline=" + reviewedPriority); + } + boolean reviewedIncludeTests = pmdBaseline.path("includeTests").asBoolean(true); + if (reviewedIncludeTests != effectivePmd.includeTests()) { + errors.add("PMD test-source scope changed: effective Maven includeTests=" + + effectivePmd.includeTests() + "; baseline=" + reviewedIncludeTests); + } + List reviewedSourceScope = jsonStringList(pmdBaseline, "sourceScope"); + if (!reviewedSourceScope.equals(effectivePmd.sourceScope())) { + errors.add("PMD source scope changed: effective Maven=" + effectivePmd.sourceScope() + + "; baseline=" + reviewedSourceScope); + } String reportCpdVersion = cpd.getDocumentElement().getAttribute("pmdVersion"); if (!reportCpdVersion.equals(cpdBaseline.path("pmdVersion").asText())) { errors.add("CPD version changed: report=" + quote(reportCpdVersion) + " baseline=" + quote(cpdBaseline.path("pmdVersion").asText())); } - int effectiveMinimumTokens = effectiveCpdMinimumTokens(effectivePom); + int effectiveMinimumTokens = effectivePmd.minimumTokens(); if (effectiveMinimumTokens != CPD_MINIMUM_TOKENS) { errors.add("CPD minimum token configuration changed: effective Maven=" + effectiveMinimumTokens + "; policy=" + CPD_MINIMUM_TOKENS); @@ -328,6 +450,14 @@ private List validateMetadata( return errors; } + /** Reads a JSON string array while preserving its reviewed order. */ + private static List jsonStringList(JsonNode object, String field) { + List values = new ArrayList<>(); + object.path(field).forEach(value -> values.add(value.asText())); + return List.copyOf(values); + } + + /** Loads and validates the structural shape of the reviewed baseline. */ private JsonNode loadBaseline(Path baselineFile) throws IOException { requireFile(baselineFile); JsonNode baseline; @@ -364,9 +494,59 @@ private JsonNode loadBaseline(Path baselineFile) throws IOException { } } } + JsonNode pmd = baseline.path("pmd"); + requireBaselineText(pmd, "pmdVersion"); + requireBaselineText(pmd, "rulesetSha256"); + requireBaselineInteger(pmd, "minimumPriority"); + requireBaselineBoolean(pmd, "includeTests"); + requireBaselineStringList(pmd, "rulesets"); + requireBaselineStringList(pmd, "sourceScope"); + JsonNode cpd = baseline.path("cpd"); + requireBaselineText(cpd, "pmdVersion"); + requireBaselineInteger(cpd, "minimumTokens"); return baseline; } + /** Requires one non-empty textual baseline metadata field. */ + private static void requireBaselineText(JsonNode object, String field) { + if (object.path(field).asText().isBlank()) { + throw new IllegalArgumentException("java lint gate: baseline metadata " + field + + " is required"); + } + } + + /** Requires one integral baseline metadata field. */ + private static void requireBaselineInteger(JsonNode object, String field) { + if (!object.path(field).isIntegralNumber()) { + throw new IllegalArgumentException("java lint gate: baseline metadata " + field + + " must be an integer"); + } + } + + /** Requires one boolean baseline metadata field. */ + private static void requireBaselineBoolean(JsonNode object, String field) { + if (!object.path(field).isBoolean()) { + throw new IllegalArgumentException("java lint gate: baseline metadata " + field + + " must be boolean"); + } + } + + /** Requires one non-empty-string-array baseline metadata field. */ + private static void requireBaselineStringList(JsonNode object, String field) { + JsonNode values = object.get(field); + if (values == null || !values.isArray()) { + throw new IllegalArgumentException("java lint gate: baseline metadata " + field + + " must be a list of non-empty strings"); + } + for (JsonNode value : values) { + if (!value.isTextual() || value.asText().isBlank()) { + throw new IllegalArgumentException("java lint gate: baseline metadata " + field + + " must be a list of non-empty strings"); + } + } + } + + /** Extracts the reviewed fingerprints for one report type. */ private List baselineFingerprints(JsonNode baseline, String section) { List fingerprints = new ArrayList<>(); baseline.path(section).path("findings") @@ -374,6 +554,7 @@ private List baselineFingerprints(JsonNode baseline, String section) { return fingerprints; } + /** Adds a compact PMD inventory to the Maven log. */ private void summarizePmd(List messages, List findings, String mode) { Map byRule = findings.stream() .map(Finding::details) @@ -393,6 +574,7 @@ private void summarizePmd(List messages, List findings, String } } + /** Adds a compact CPD inventory to the Maven log. */ private void summarizeCpd(List messages, List findings, String mode) { messages.add("CPD " + mode + " duplications: " + findings.size()); if (!findings.isEmpty()) { @@ -402,6 +584,7 @@ private void summarizeCpd(List messages, List findings, String } } + /** Adds details for findings not covered by the reviewed baseline. */ private void printNewFindings( List messages, String label, @@ -418,6 +601,7 @@ private void printNewFindings( } } + /** Inventories production-source suppressions and placeholder comments. */ private void reviewInventory(List messages) throws IOException { Path sourceRoot = root.resolve("src/main/java"); List suppressions = new ArrayList<>(); @@ -429,7 +613,7 @@ private void reviewInventory(List messages) throws IOException { for (int index = 0; index < lines.size(); index++) { String line = lines.get(index); String relative = root.relativize(source).toString().replace('\\', '/'); - if (line.contains("@SuppressWarnings")) { + if (containsSuppressionMarker(line)) { suppressions.add(relative + ":" + (index + 1)); } if (line.matches(".*\\b(TODO|FIXME|XXX|HACK)\\b.*")) { @@ -449,6 +633,12 @@ private void reviewInventory(List messages) throws IOException { } } + /** Returns whether a production source line contains a PMD suppression marker. */ + static boolean containsSuppressionMarker(String line) { + return line.contains("@SuppressWarnings") || PMD_SUPPRESSION_MARKER.matcher(line).find(); + } + + /** Parses a report file with external entity resolution disabled. */ private static Document parseXml(Path path) throws IOException { try { Document document = secureDocumentBuilderFactory().newDocumentBuilder().parse(path.toFile()); @@ -459,6 +649,7 @@ private static Document parseXml(Path path) throws IOException { } } + /** Creates the hardened XML parser factory used for reports and fixtures. */ private static DocumentBuilderFactory secureDocumentBuilderFactory() throws ParserConfigurationException { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); @@ -473,6 +664,7 @@ private static DocumentBuilderFactory secureDocumentBuilderFactory() return factory; } + /** Returns all descendant elements matching a local XML name. */ private static List descendants(Element root, String name) { List result = new ArrayList<>(); NodeList children = root.getElementsByTagNameNS("*", name); @@ -488,6 +680,7 @@ private static List descendants(Element root, String name) { return result; } + /** Returns direct child elements matching a local XML name. */ private static List directChildren(Element root, String name) { List result = new ArrayList<>(); NodeList children = root.getChildNodes(); @@ -500,6 +693,7 @@ private static List directChildren(Element root, String name) { return result; } + /** Reads trimmed text from the first matching direct child. */ private static String childText(Element root, String name) { return directChildren(root, name).stream() .findFirst() @@ -508,6 +702,7 @@ private static String childText(Element root, String name) { .orElse(""); } + /** Resolves an XML local name for namespace-aware and plain fixtures. */ private static String localName(Node node) { String localName = node.getLocalName(); if (localName != null) { @@ -518,11 +713,13 @@ private static String localName(Node node) { return separator >= 0 ? name.substring(separator + 1) : name; } + /** Reads an XML attribute, returning a fallback for absent values. */ private static String attributeOr(Element element, String name, String fallback) { String value = element.getAttribute(name); return value.isEmpty() ? fallback : value; } + /** Normalizes a report path to a stable slash-separated representation. */ private static String relativeSourcePath(Path root, String pathText) { try { Path path = Path.of(pathText); @@ -539,14 +736,17 @@ private static String relativeSourcePath(Path root, String pathText) { } } + /** Removes source-line drift from PMD messages before fingerprinting. */ private static String normalizeMessage(String message) { return LOCATION_MESSAGE.matcher(normalizeWhitespace(message)).replaceAll("at line "); } + /** Collapses report whitespace to a stable single-line value. */ private static String normalizeWhitespace(String value) { return value == null ? "" : value.trim().replaceAll("\\s+", " "); } + /** Serializes fingerprint details with deterministic key ordering. */ private static String canonicalJson(Object value) { try { return JSON.writeValueAsString(value); @@ -555,6 +755,7 @@ private static String canonicalJson(Object value) { } } + /** Returns the lowercase SHA-256 digest used for stable fingerprints. */ private static String sha256(String value) { try { byte[] digest = MessageDigest.getInstance("SHA-256") @@ -569,12 +770,14 @@ private static String sha256(String value) { } } + /** Counts duplicate fingerprints for multiset comparison. */ private static Map counts(List values) { Map counts = new HashMap<>(); values.forEach(value -> counts.merge(value, 1, Integer::sum)); return counts; } + /** Computes the sorted multiset difference between two fingerprint maps. */ private static List difference(Map left, Map right) { List result = new ArrayList<>(); left.forEach((value, count) -> { @@ -587,26 +790,39 @@ private static List difference(Map left, Map fingerprints(List findings) { return findings.stream().map(Finding::fingerprint).toList(); } + /** Fails early when a required gate input is absent. */ private static void requireFile(Path path) throws IOException { if (!Files.isRegularFile(path)) { throw new IOException("java lint gate: missing file " + path); } } + /** Formats a metadata value for a concise diagnostic. */ private static String quote(String value) { return "'" + value + "'"; } private record Finding(Map details) { + /** Computes the fingerprint for this normalized finding. */ private String fingerprint() { return sha256(canonicalJson(details)); } } + /** Effective PMD settings that must remain bound to the reviewed baseline. */ + static record EffectivePmdConfiguration( + List rulesets, + int minimumPriority, + boolean includeTests, + List sourceScope, + int minimumTokens) {} + + /** Multiset delta between the current report and the reviewed baseline. */ static record Comparison(List newFingerprints, List resolvedFingerprints) {} /** Result returned to the Maven goal and unit tests. */ @@ -615,6 +831,7 @@ public record ValidationReport( int newFindingCount, int configurationErrorCount, List messages) { + /** Returns the concise failure summary used by Maven. */ String failureMessage() { return "Java lint gate failed: " + newFindingCount + " new finding(s), " + configurationErrorCount + " report/configuration error(s)"; diff --git a/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/ValidateJavaLintMojo.java b/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/ValidateJavaLintMojo.java index d2e7ca3..17cbe04 100644 --- a/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/ValidateJavaLintMojo.java +++ b/tools/java-lint-maven-plugin/src/main/java/org/projectlightpath/javalint/ValidateJavaLintMojo.java @@ -18,6 +18,7 @@ public final class ValidateJavaLintMojo extends AbstractMojo { @Parameter(property = "javaLint.enforce", defaultValue = "true") private boolean enforce; + /** Loads the generated reports and fails when the reviewed lint contract is violated. */ @Override public void execute() throws MojoExecutionException, MojoFailureException { try { 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 a3a9d18..2d96efc 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 @@ -1,18 +1,24 @@ package org.projectlightpath.javalint; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.w3c.dom.Document; +/** Regression tests for baseline normalization and effective-POM safeguards. */ class JavaLintValidatorTest { @TempDir Path temp; + /** Line movement must not create a new PMD fingerprint. */ @Test void pmdFingerprintIgnoresLineShifts() { Document first = JavaLintValidator.parseXml( @@ -33,6 +39,7 @@ void pmdFingerprintIgnoresLineShifts() { (org.w3c.dom.Element) shifted.getElementsByTagName("violation").item(0))); } + /** Line movement must not create a new CPD fingerprint. */ @Test void cpdFingerprintIgnoresLocationShifts() { Document first = JavaLintValidator.parseXml( @@ -51,6 +58,7 @@ void cpdFingerprintIgnoresLocationShifts() { (org.w3c.dom.Element) shifted.getElementsByTagName("duplication").item(0))); } + /** Duplicate findings are compared with multiplicity rather than sets. */ @Test void duplicateFingerprintsUseMultisetComparison() { JavaLintValidator.Comparison comparison = JavaLintValidator.compareFingerprints( @@ -60,6 +68,7 @@ void duplicateFingerprintsUseMultisetComparison() { assertEquals(List.of(), comparison.resolvedFingerprints()); } + /** The validator reads the CPD threshold from Maven's effective configuration. */ @Test void effectiveCpdThresholdIsReadFromMavenConfiguration() throws Exception { Path effectivePom = temp.resolve("effective-pom.xml"); @@ -76,6 +85,7 @@ void effectiveCpdThresholdIsReadFromMavenConfiguration() throws Exception { assertEquals(200, JavaLintValidator.effectiveCpdMinimumTokens(effectivePom)); } + /** Processing errors embedded in a report are surfaced as gate failures. */ @Test void processingErrorsAreReported() { Document report = JavaLintValidator.parseXml( @@ -86,6 +96,117 @@ void processingErrorsAreReported() { JavaLintValidator.reportProcessingErrors(report, "PMD")); } + /** PMD configuration errors embedded in a report are surfaced as gate failures. */ + @Test + void configurationErrorsAreReported() { + Document report = JavaLintValidator.parseXml( + "rule could not load"); + + assertEquals( + List.of("PMD report configuration error: rule could not load (rule='MissingRule')"), + JavaLintValidator.reportProcessingErrors(report, "PMD")); + } + + /** Both annotation and source-marker suppressions enter the review inventory. */ + @Test + void suppressionInventoryRecognizesAnnotationsAndNopmd() { + assertTrue(JavaLintValidator.containsSuppressionMarker("// NOPMD: parser boundary")); + assertTrue(JavaLintValidator.containsSuppressionMarker("@SuppressWarnings(\"unused\")")); + assertFalse(JavaLintValidator.containsSuppressionMarker("// no suppression")); + } + + /** Effective ruleset, priority, test, source, and CPD settings are normalized. */ + @Test + void effectivePmdConfigurationReadsReviewedRuleAndSourceScope() throws Exception { + Path effectivePom = temp.resolve("effective-pom.xml"); + String ruleset = temp.resolve("config/pmd-production.xml").toString(); + String source = temp.resolve("src/main/java").toString(); + Files.writeString(effectivePom, """ + + + %s + + maven-pmd-plugin + + %s + false + 3 + 100 + + + + + """.formatted(source, ruleset)); + + JavaLintValidator.EffectivePmdConfiguration configuration = + JavaLintValidator.effectivePmdConfiguration(effectivePom, temp); + + assertEquals(List.of("config/pmd-production.xml"), configuration.rulesets()); + assertEquals(3, configuration.minimumPriority()); + assertFalse(configuration.includeTests()); + assertEquals(List.of("src/main/java"), configuration.sourceScope()); + assertEquals(100, configuration.minimumTokens()); + } + + /** A configuration error prevents validate from returning a passing report. */ + @Test + void validateFailsOnConfigurationErrors() throws Exception { + Path config = temp.resolve("config"); + Path target = temp.resolve("target"); + Files.createDirectories(config); + Files.createDirectories(target); + String ruleset = """ + + CognitiveComplexity CyclomaticComplexity NPathComplexity NcssCount + ExcessiveParameterList AvoidCatchingGenericException PreserveStackTrace + UnusedPrivateMethod CommentRequired + + """; + Path rulesetFile = config.resolve("pmd-production.xml"); + Files.writeString(rulesetFile, ruleset); + Files.writeString(config.resolve("java-lint-baseline.json"), """ + { + "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))); + Files.writeString(target.resolve("pmd.xml"), + "broken"); + Files.writeString(target.resolve("cpd.xml"), ""); + Files.writeString(target.resolve("java-lint-effective-pom.xml"), """ + + + %s + + maven-pmd-plugin + + %s + false + 3 + 100 + + + + + """.formatted(temp.resolve("src/main/java"), rulesetFile)); + + JavaLintValidator.ValidationReport report = new JavaLintValidator(temp).validate(true); + + assertFalse(report.passed()); + assertEquals(1, report.configurationErrorCount()); + assertTrue(report.messages().stream().anyMatch(message -> message.contains("configuration error"))); + } + + /** A threshold change is observable even when the baseline has no CPD findings. */ @Test void effectiveThresholdChangeIsDetectedAgainstBaseline() throws Exception { Path effectivePom = temp.resolve("effective-pom.xml"); @@ -100,4 +221,10 @@ void effectiveThresholdChangeIsDetectedAgainstBaseline() throws Exception { assertTrue(JavaLintValidator.effectiveCpdMinimumTokens(effectivePom) != 100); } + + /** Computes the digest format used by the baseline fixture. */ + private static String sha256(String value) throws Exception { + return HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); + } } From ea5c62deda6023f2b440491a8b8c9861888192de Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Wed, 11 Nov 2026 13:17:14 -0500 Subject: [PATCH 3/3] Clarify Java lint quality policy --- config/java-lint-baseline.json | 2 +- config/pmd-production.xml | 3 ++- docs/java-lint.md | 9 ++++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/config/java-lint-baseline.json b/config/java-lint-baseline.json index 2647abf..8497379 100644 --- a/config/java-lint-baseline.json +++ b/config/java-lint-baseline.json @@ -760,7 +760,7 @@ ], "minimumPriority": 3, "pmdVersion": "7.17.0", - "rulesetSha256": "08c9bf5e0f1a4e1a95d46d8ef69bf397426bbaf048513e00c353f490bc566146", + "rulesetSha256": "780ee85ac689a3a9b651d08bda79ac36ac2f6983156510322e099a683e71f83f", "rulesets": [ "config/pmd-production.xml" ], diff --git a/config/pmd-production.xml b/config/pmd-production.xml index 9734892..37230f9 100644 --- a/config/pmd-production.xml +++ b/config/pmd-production.xml @@ -7,7 +7,8 @@ Focused PMD rules for production-quality signals: complexity and size, exception safety, dead-looking code, accidental API surface, and narrowly scoped public type documentation. Copy/paste detection is run separately by - the Maven PMD plugin's CPD goal. This ruleset is not an AI detector. + the Maven PMD plugin's CPD goal. This ruleset is a maintainability defense + against low-quality or AI-slop-like output patterns, not an authorship detector.