diff --git a/.github/ISSUE_TEMPLATE/bug_report_template.md b/.github/ISSUE_TEMPLATE/bug_report_template.md new file mode 100644 index 00000000..466af55d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report_template.md @@ -0,0 +1,55 @@ +--- +name: Bug Report +about: Use this template for reporting a bug +labels: needs triage, bug report +--- + + + + + +### Description + + + +### Affected DSF Linter Version + + + +### To Reproduce + + + +### Expected Behavior + + + +### Logs + + +*CLI / Maven Output:* +```text +Log output here ... +``` + +### Screenshots + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..c3b724a9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: DSF Documentation + url: https://dsf.dev/process-development/linter-tool/linter-tool.html + about: Read the DSF Linter documentation. + - name: Getting Help + url: https://github.com/datasharingframework/dsf-linter/discussions + about: For general questions about the DSF Linter, please use GitHub Discussions. + - name: MII / NUM Related Questions + url: https://mii.zulipchat.com/#narrow/channel/392426-Data-Sharing-Framework-.28DSF.29/topic/DSF.20Linter/with/574964964 + about: For questions about the use of the DSF Linter in the Medical Informatics Initiative (MII) or the Network University Medicine (NUM), please use the channels in the MII Zulipchat. diff --git a/.github/ISSUE_TEMPLATE/feature_request_template.md b/.github/ISSUE_TEMPLATE/feature_request_template.md new file mode 100644 index 00000000..462ca569 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request_template.md @@ -0,0 +1,21 @@ +--- +name: Feature Request +about: Use this template if you want to request a new feature +labels: needs triage, enhancement +--- + + + + + +### Related Problem + + +### Describe the Solution You’d Like + + +### Describe Alternatives You’ve Considered + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..8d1769d5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,47 @@ +version: 2 +updates: + # Java / Maven dependencies + - package-ecosystem: "maven" + directory: "/" + target-branch: "develop" + schedule: + interval: "weekly" + day: "sunday" + open-pull-requests-limit: 15 + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + groups: + jackson: + patterns: + - "com.fasterxml.jackson*" + fhir: + patterns: + - "hapi-fhir*" + - "org.hl7.fhir*" + slf4j: + patterns: + - "org.slf4j*" + testing-tools: + patterns: + - "org.junit*" + - "org.mockito*" + safe-patch-updates: + update-types: + - "patch" + remaining-minor-updates: + update-types: + - "minor" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "develop" + schedule: + interval: "weekly" + day: "sunday" + open-pull-requests-limit: 10 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..0280a208 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,30 @@ + + + + + +Closes #issuenumber(s). + +### Changes + + + +### How Was This Patch Tested? + +- [ ] Unit tests +- [ ] Integration tests +- [ ] Manual executed tests diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..6babd31c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,206 @@ +name: DSF Linter Build + +on: + push: + branches: [ "release/*", "hotfix/*" ] + tags: + - v[0-9]+.[0-9]+.[0-9]+ + - v[0-9]+.[0-9]+.[0-9]+-M[0-9]+ + - v[0-9]+.[0-9]+.[0-9]+-RC[0-9]+ + pull_request: + branches: [ "main", "develop" ] + types: [opened, synchronize, reopened, closed] + schedule: + - cron: '11 15 * * 0' # Sundays, 15:11 + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + MVN_BATCH_MODE_FAIL_AT_END: --batch-mode --fail-at-end + MVN_SKIP_MOST: -P!generate-source-and-javadoc-jars -Dimpsort.skip=true -Dformatter.skip=true -Dlicense.skip=true -Denforcer.skip -Dmaven.buildNumber.skip=true -DskipShadePlugin=true + +jobs: + codeql: + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: java-kotlin + build-mode: manual + - language: javascript-typescript + build-mode: none + name: 'codeql: ${{ matrix.language }}' + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up JDK 25 + if: ${{ matrix.language == 'java-kotlin' }} + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'zulu' + java-version: 25 + cache: 'maven' + - name: Initialize CodeQL + uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended, security-and-quality + - name: Minimal Maven Build + if: ${{ matrix.language == 'java-kotlin' }} + run: mvn package $MVN_BATCH_MODE_FAIL_AT_END $MVN_SKIP_MOST -DskipTests + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + with: + category: "/language:${{matrix.language}}" + + maven-quick: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + main: ${{ steps.main.outputs.main }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'zulu' + java-version: 25 + cache: 'maven' + - name: Minimal Maven Build + run: mvn install $MVN_BATCH_MODE_FAIL_AT_END $MVN_SKIP_MOST -DskipTests -DbuildNumber=${GITHUB_SHA} -DscmBranch=${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}} + - name: Upload quick-build results + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: quick_build + path: | + ./**/target + - name: Get Maven project version + id: version + run: echo "version=$(mvn -q -Dexec.executable=echo -Dexec.args='${project.version}' --non-recursive org.codehaus.mojo:exec-maven-plugin:3.6.3:exec)" >> $GITHUB_OUTPUT + - name: Checkout main branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + - name: Check if ref is main HEAD + id: main + run: echo "main=$( [ "$GITHUB_SHA" = "$(git rev-parse HEAD)" ] && echo true || echo false )" >> $GITHUB_OUTPUT + + maven-full: + runs-on: ubuntu-latest + needs: maven-quick + permissions: + contents: read + strategy: + fail-fast: false + matrix: + mvn: + - name: JavaDoc + cmd: mvn javadoc:javadoc $MVN_BATCH_MODE_FAIL_AT_END -Dformatter.skip=true -Denforcer.skip -Dmaven.buildNumber.skip=true -DskipShadePlugin=true + - name: Formatter, Impsort, Enforcer + cmd: mvn compile test-compile $MVN_BATCH_MODE_FAIL_AT_END -Dmaven.buildNumber.skip=true + - name: Unit Tests + cmd: mvn dependency:properties surefire:test $MVN_BATCH_MODE_FAIL_AT_END $MVN_SKIP_MOST + name: ${{ matrix.mvn.name }} + timeout-minutes: 8 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Download quick-build results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: quick_build + path: ./ + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'zulu' + java-version: 25 + cache: 'maven' + - name: Install quick-build artifacts to local repository + run: | + VERSION=$(mvn -q -Dexec.executable=echo -Dexec.args='${project.version}' --non-recursive org.codehaus.mojo:exec-maven-plugin:3.6.3:exec) + mvn install:install-file \ + -Dfile="linter-core/target/linter-core-${VERSION}.jar" \ + -DpomFile=linter-core/pom.xml + - name: ${{ matrix.mvn.name }} + run: ${{ matrix.mvn.cmd }} + + maven-deploy: + if: ${{ !endsWith(needs.maven-quick.outputs.version, '-SNAPSHOT') && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} + runs-on: ubuntu-latest + needs: [codeql, maven-quick, maven-full] + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Download quick-build results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: quick_build + path: ./ + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'zulu' + java-version: 25 + cache: 'maven' + server-id: central + server-username: MAVEN_CENTRAL_USERNAME + server-password: MAVEN_CENTRAL_TOKEN + gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + - name: Deploy to Maven Central + run: mvn deploy -Dimpsort.skip=true -Dformatter.skip=true -Dlicense.skip=true -DskipTests -Ppublish-to-maven-central -Dmaven.buildNumber.skip=true -DbuildNumber=${GITHUB_SHA} -DscmBranch=${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}} + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.MAVEN_CENTRAL_TOKEN }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + + release-assets: + if: ${{ !endsWith(needs.maven-quick.outputs.version, '-SNAPSHOT') && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} + runs-on: ubuntu-latest + needs: [maven-quick, maven-full] + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'zulu' + java-version: 25 + cache: 'maven' + - name: Build executable JAR + run: mvn --batch-mode clean package -pl linter-cli -am -DskipTests + - name: Compute SHA256 checksum + run: | + VERSION="${GITHUB_REF_NAME#v}" + JAR="linter-cli/target/linter-cli-${VERSION}.jar" + sha256sum "$JAR" > "linter-cli-${VERSION}.jar.sha256" + - name: Upload release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + JAR="linter-cli/target/linter-cli-${VERSION}.jar" + CHECKSUM="linter-cli-${VERSION}.jar.sha256" + if ! gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + gh release create "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --generate-notes --draft + fi + gh release upload "${GITHUB_REF_NAME}" "$JAR" "$CHECKSUM" --clobber \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..78c30168 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,54 @@ +name: CodeQL Analysis + +on: + push: + branches: [ "main", "develop" ] + pull_request: + branches: [ "main", "develop" ] + schedule: + - cron: '11 15 * * 0' # Sundays, 15:11 + +permissions: {} + +jobs: + codeql: + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: java-kotlin + build-mode: manual + name: 'codeql: ${{ matrix.language }}' + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up JDK 25 + if: ${{ matrix.language == 'java-kotlin' }} + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 25 + cache: 'maven' + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended, security-and-quality + + - name: Build with Maven + if: ${{ matrix.language == 'java-kotlin' }} + run: mvn package --batch-mode --fail-at-end -DskipTests + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/maven-publish.yml b/.github/workflows/maven-publish.yml new file mode 100644 index 00000000..cd0dbdd9 --- /dev/null +++ b/.github/workflows/maven-publish.yml @@ -0,0 +1,54 @@ +name: DSF Linter Publish with Maven + +on: + pull_request: + types: closed + branches: [ "main" ] + +jobs: + publish: + # Only run if pull requests are merged, omit running if pull requests are closed without merging + if: github.event.pull_request.merged + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + cache: 'maven' + + - name: Import GPG key + run: | + echo "${{ secrets.MAVEN_GPG_PRIVATE_KEY }}" | gpg --batch --import + gpg --list-secret-keys --keyid-format LONG + + - name: Create Maven settings.xml with Sonatype credentials + run: | + mkdir -p ~/.m2 + cat > ~/.m2/settings.xml < + + + central + ${{ secrets.OSSRH_USERNAME }} + ${{ secrets.OSSRH_TOKEN }} + + + + EOF + + - name: Publish to Maven Central + env: + MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + run: mvn --batch-mode --fail-at-end -P release -Dgpg.passphrase="${MAVEN_GPG_PASSPHRASE}" clean deploy diff --git a/README.md b/README.md index 9c0c6bbb..fca3ec00 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@ Download the latest `linter-cli-.jar` from the [Releases page](https:// ```bash # Run on local JAR file -java -jar linter-cli-0.1.2.jar --path your-plugin.jar --html +java -jar linter-cli-0.1.3.jar --path your-plugin.jar --html # Run on remote JAR file -java -jar linter-cli-0.1.2.jar \ +java -jar linter-cli-0.1.3.jar \ --path https://github.com/datasharingframework/dsf-process-ping-pong/releases/download/v2.0.0.1/dsf-process-ping-pong-2.0.0.1.jar --html # View report at: /tmp/dsf-linter-report-/dsf-linter-report/index.html @@ -32,7 +32,7 @@ cd dsf-linter mvn clean package # Then run the freshly built JAR -java -jar linter-cli/target/linter-cli-0.1.2.jar --path your-plugin.jar --html +java -jar linter-cli/target/linter-cli-0.1.3.jar --path your-plugin.jar --html ``` ## Build @@ -69,11 +69,11 @@ Expected structure in the JAR file: ```bash # Local JAR file -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path C:\path\to\plugin.jar --html # Remote JAR file -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path https://github.com/datasharingframework/dsf-process-ping-pong/releases/download/v2.0.0.1/dsf-process-ping-pong-2.0.0.1.jar --html ``` @@ -81,11 +81,11 @@ java -jar linter-cli/target/linter-cli-0.1.2.jar \ ```bash # Multiple reports with custom path -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path plugin.jar --html --json --report-path ./reports # Verbose output (colors enabled by default, use --no-color to disable) -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path plugin.jar --html --verbose # Lint Maven project @@ -93,7 +93,7 @@ java -jar linter-cli/target/linter-cli-0.1.2.jar \ cd /path/to/project && mvn clean package # Step 2: Lint resulting JAR -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path /path/to/project/target/my-plugin-1.0.0.jar --html ``` @@ -101,16 +101,16 @@ java -jar linter-cli/target/linter-cli-0.1.2.jar \ ```bash # GitHub Actions / GitLab CI -FORCE_COLOR=1 java -jar linter-cli/target/linter-cli-0.1.2.jar \ +FORCE_COLOR=1 java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path plugin.jar --html --json --verbose # Jenkins (fail on errors) -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path plugin.jar --html # Exit code: 0 = success, 1 = errors # Don't fail build (gradual adoption) -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path plugin.jar --html --no-fail ``` @@ -122,6 +122,7 @@ java -jar linter-cli/target/linter-cli-0.1.2.jar \ | `--html` | Generate HTML report | | `--json` | Generate JSON report | | `--report-path ` | Custom report directory | +| `--exclusions ` | Path to a JSON exclusion config (see [Excluding Issues](#excluding-issues)) | | `--verbose` | Verbose logging | | `--no-color` | Disable colored output (default: enabled) | | `--no-fail` | Exit 0 even on errors | @@ -135,45 +136,139 @@ java -jar linter-cli/target/linter-cli-0.1.2.jar \ | `TERM=dumb` | Disables colored output | | `WT_SESSION`, `ANSICON` | Windows color detection | +## Excluding Issues + +Users often encounter known, intentional, or external findings that clutter reports and make triage harder. The exclusion system lets you suppress specific lint items from HTML and JSON reports without modifying the plugin source. + +### Configuration file + +Create a file named **`dsf-linter-exclusions.json`** in the project root (auto-discovered) or point to it explicitly with `--exclusions`: + +```json +{ + "affectsExitStatus": false, + "rules": [ + { "type": "BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING" }, + { "severity": "WARN", "file": "update-allow-list.bpmn" }, + { "messageContains": "optional field" } + ] +} +``` + +### Rule fields + +Each rule is an **AND** combination of its non-null fields. Multiple rules are **OR**-combined — an item is excluded when *any* rule matches. + +| Field | Match type | Example | +|---|---|---| +| `type` | Exact (case-insensitive) match against the `LintingType` enum name | `"BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING"` | +| `severity` | Exact (case-insensitive) match against the severity level | `"WARN"`, `"ERROR"`, `"INFO"` | +| `file` | Case-insensitive substring match against the file name | `"update-allow-list"` | +| `messageContains` | Case-insensitive substring match against the issue description | `"optional field"` | + +Every rule must specify at least one field — a rule with no criteria is rejected on load. + +### Exit-status control + +| `affectsExitStatus` | Behaviour | +|---|---| +| `false` *(default)* | Excluded items are fully suppressed — they do **not** appear in reports and do **not** count towards the exit code | +| `true` | Excluded items are hidden from reports, but their error count **still** contributes to the exit code (non-zero exit on errors) | + +### Usage examples + +```bash + +# Explicit exclusion file +java -jar linter-cli-0.1.3.jar \ + --path plugin.jar --html \ + --exclusions /path/to/my-exclusions.json + +# Exclude only from reports, but still fail on excluded errors +``` + +Exclusion file with `affectsExitStatus: true`: +```json +{ + "affectsExitStatus": true, + "rules": [ + { "type": "BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING" } + ] +} +``` + +### Available `LintingType` values + +All available type names are defined in `LintingType.java`. A few common examples: + +| Type | Description | +|---|---| +| `BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING` | `historyTimeToLive` not set on process | +| `BPMN_PROCESS_NOT_EXECUTABLE` | Process `isExecutable` not set to `true` | +| `STRUCTURE_DEFINITION_SNAPSHOT_PRESENT` | StructureDefinition should not contain a snapshot | +| `FHIR_TASK_STATUS_NOT_DRAFT` | Task status is not `draft` | +| `PLUGIN_DEFINITION_MISSING_SERVICE_LOADER_REGISTRATION` | Plugin missing ServiceLoader registration | + +See `linter-core/src/main/java/dev/dsf/linter/output/LintingType.java` for the full list. + +--- + ## Project Structure ``` dsf-linter/ +├── pom.xml # Parent Maven POM +├── dsf-linter-exclusions.json # Example exclusion config (optional) +├── .github/ # CI workflows & issue templates ├── linter-core/ # Core linting logic │ ├── src/main/java/dev/dsf/linter/ -│ │ ├── analysis/ # Resource analysis -│ │ ├── bpmn/ # BPMN parsing & validation -│ │ ├── fhir/ # FHIR parsing & validation -│ │ ├── service/ # Linting services (BPMN, FHIR, Plugin) -│ │ ├── output/ # Lint item definitions -│ │ │ └── item/ # Specific lint items -│ │ ├── report/ # Report generation -│ │ ├── input/ # Input handling & JAR processing -│ │ ├── setup/ # Project setup & building -│ │ ├── plugin/ # Plugin definition discovery +│ │ ├── DsfLinter.java # Main orchestrator +│ │ ├── analysis/ # Leftover resource detection +│ │ ├── bpmn/ # BPMN model & element linters │ │ ├── classloading/ # Dynamic class loading -│ │ ├── logger/ # Logging infrastructure -│ │ ├── repo/ # Repository management │ │ ├── constants/ # Constants & configuration │ │ ├── exception/ # Custom exceptions -│ │ └── util/ # Utilities +│ │ ├── exclusion/ # Exclusion rules & filtering +│ │ ├── fhir/ # FHIR resource linters +│ │ ├── input/ # Input handling & JAR processing +│ │ ├── logger/ # Logging infrastructure +│ │ ├── output/ # Lint item definitions +│ │ │ └── item/ # Specific lint items +│ │ ├── plugin/ # Plugin definition discovery +│ │ ├── report/ # HTML, JSON & console reporting +│ │ ├── service/ # Linting services & orchestration +│ │ ├── setup/ # Project setup & building +│ │ └── util/ # Shared utilities │ │ ├── api/ # API version detection +│ │ ├── bpmn/ # BPMN helpers +│ │ │ └── linters/ # Event, message & timer linters │ │ ├── cache/ # Caching utilities -│ │ ├── converter/ # Format converters -│ │ ├── linting/ # Linting utilities +│ │ ├── converter/ # Format converters (JSON/XML) +│ │ ├── linting/ # Linting helpers │ │ ├── loader/ # Class/service loading -│ │ ├── maven/ # Maven utilities -│ │ └── resource/ # Resource management +│ │ └── resource/ # Resource discovery & resolution │ ├── src/main/resources/ -│ │ └── templates/ # HTML report templates +│ │ ├── templates/ # HTML report templates +│ │ └── logback*.xml # Logging configuration │ └── src/test/ -│ ├── java/ # Unit tests +│ ├── java/ # Unit & integration tests +│ │ ├── bpmn/ +│ │ ├── exclusion/ +│ │ ├── fhir/ +│ │ ├── service/ +│ │ └── util/ │ └── resources/ # Test fixtures │ ├── bpmn/ -│ ├── fhir/ -│ └── dsf-multi-plugin-test/ +│ ├── dsf-multi-plugin-test/ +│ └── fhir/examples/ # Sample FHIR resources +│ ├── pingPongProcess/ +│ ├── dashBoardReport/ +│ └── feasibility/ └── linter-cli/ # CLI interface └── src/main/java/dev/dsf/linter/ + ├── Main.java # Entry point & argument parsing + ├── LinterExecutor.java # Runs the linting pipeline + └── ResultPrinter.java # Console output formatting ``` ## Development @@ -189,10 +284,11 @@ dsf-linter/ ### Testing ```bash -mvn test # All tests -mvn test -Dtest=BpmnLoadingTest # Specific test -mvn test -X # Verbose -mvn clean package -DskipTests # Build without tests +mvn test # All tests +mvn test -Dtest=BpmnLoadingTest # Specific test +mvn test -Dtest=ExclusionFilterTest # Exclusion filter tests +mvn test -X # Verbose +mvn clean package -DskipTests # Build without tests ``` ### Development Workflow @@ -205,7 +301,7 @@ vim linter-core/src/main/java/dev/dsf/linter/service/BpmnLintingService.java mvn clean package -DskipTests # 3. Test -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path test-plugin.jar --html --verbose # 4. Check report @@ -220,7 +316,7 @@ mvn test ```bash # Start with debugger java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005 \ - -jar linter-cli/target/linter-cli-0.1.2.jar \ + -jar linter-cli/target/linter-cli-0.1.3.jar \ --path plugin.jar --html --verbose # Attach debugger to localhost:5005 @@ -237,6 +333,8 @@ java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005 \ | `FhirLintingService` | FHIR validation | | `PluginLintingService` | Plugin validation | | `LintingReportGenerator` | Report generation | +| `ExclusionFilter` | Suppresses lint items matched by exclusion rules | +| `ExclusionConfigLoader` | Loads `dsf-linter-exclusions.json` | ## Report Output @@ -292,12 +390,12 @@ The linter accepts only JAR files as input: ```bash # Wrong - Maven project directly -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path /path/to/project --html # Correct - Build first, then lint JAR cd /path/to/project && mvn clean package -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path /path/to/project/target/my-plugin-1.0.0.jar --html ``` @@ -306,11 +404,11 @@ java -jar linter-cli/target/linter-cli-0.1.2.jar \ Verify the path: ```bash # Windows -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path "C:\Users\Username\project\target\plugin.jar" --html # Linux/Mac -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path /home/username/project/target/plugin.jar --html ``` @@ -321,7 +419,7 @@ java -jar linter-cli/target/linter-cli-0.1.2.jar \ ls ~/.m2/settings.xml # Use verbose mode -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path plugin.jar --html --verbose ``` @@ -329,11 +427,11 @@ java -jar linter-cli/target/linter-cli-0.1.2.jar \ ```bash # --html flag must be set -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path plugin.jar --html # ← Required # Use absolute path -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path plugin.jar --html --report-path $(pwd)/reports ``` @@ -345,7 +443,7 @@ Check the URL and network connection: curl -L -o test.jar https://example.com/plugin.jar # Then use the local file -java -jar linter-cli/target/linter-cli-0.1.2.jar \ +java -jar linter-cli/target/linter-cli-0.1.3.jar \ --path test.jar --html ``` diff --git a/linter-cli/pom.xml b/linter-cli/pom.xml index 9c194821..9fad31b3 100644 --- a/linter-cli/pom.xml +++ b/linter-cli/pom.xml @@ -5,34 +5,33 @@ 4.0.0 - dev.dsf.utils.linter + dev.dsf.linter dsf-linter - 0.1.2 + 0.1.3 linter-cli - 25 - 25 - UTF-8 + + true - dev.dsf.utils.linter + dev.dsf.linter linter-core ${project.version} info.picocli picocli - 4.7.4 + 4.7.7 com.fasterxml.jackson.core jackson-annotations - 2.18.0 + 2.22 org.jetbrains @@ -48,7 +47,6 @@ org.apache.maven.plugins maven-shade-plugin - 3.4.1 package diff --git a/linter-cli/src/main/java/dev/dsf/linter/LinterExecutor.java b/linter-cli/src/main/java/dev/dsf/linter/LinterExecutor.java index 2ea1c5e7..33451d5f 100644 --- a/linter-cli/src/main/java/dev/dsf/linter/LinterExecutor.java +++ b/linter-cli/src/main/java/dev/dsf/linter/LinterExecutor.java @@ -1,5 +1,6 @@ package dev.dsf.linter; +import dev.dsf.linter.exclusion.ExclusionConfig; import dev.dsf.linter.logger.Logger; import java.nio.file.Path; @@ -22,26 +23,29 @@ public class LinterExecutor { private final boolean generateHtmlReport; private final boolean generateJsonReport; private final boolean failOnErrors; + private final ExclusionConfig exclusionConfig; private final Logger logger; /** * Constructs a new LinterExecutor with the specified parameters. * - * @param projectPath the path to the project to lint - * @param reportPath the path where reports should be generated + * @param projectPath the path to the project to lint + * @param reportPath the path where reports should be generated * @param generateHtmlReport whether to generate an HTML report * @param generateJsonReport whether to generate a JSON report - * @param failOnErrors whether to fail (exit code 1) if errors are found - * @param logger the logger for output + * @param failOnErrors whether to fail (exit code 1) if errors are found + * @param exclusionConfig optional exclusion config; {@code null} disables exclusions + * @param logger the logger for output */ public LinterExecutor(Path projectPath, Path reportPath, boolean generateHtmlReport, boolean generateJsonReport, - boolean failOnErrors, Logger logger) { + boolean failOnErrors, ExclusionConfig exclusionConfig, Logger logger) { this.projectPath = projectPath; this.reportPath = reportPath; this.generateHtmlReport = generateHtmlReport; this.generateJsonReport = generateJsonReport; this.failOnErrors = failOnErrors; + this.exclusionConfig = exclusionConfig; this.logger = logger; } @@ -63,6 +67,7 @@ public DsfLinter.OverallLinterResult execute() throws Exception { generateHtmlReport, generateJsonReport, failOnErrors, + exclusionConfig, logger ); diff --git a/linter-cli/src/main/java/dev/dsf/linter/Main.java b/linter-cli/src/main/java/dev/dsf/linter/Main.java index d5cc06f2..80dcd505 100644 --- a/linter-cli/src/main/java/dev/dsf/linter/Main.java +++ b/linter-cli/src/main/java/dev/dsf/linter/Main.java @@ -1,5 +1,7 @@ package dev.dsf.linter; +import dev.dsf.linter.exclusion.ExclusionConfig; +import dev.dsf.linter.exclusion.ExclusionConfigLoader; import dev.dsf.linter.input.InputResolver; import dev.dsf.linter.logger.ConsoleLogger; import dev.dsf.linter.logger.Logger; @@ -79,6 +81,13 @@ public class Main implements Callable { description = "Disable colored console output. (Default: enabled)") private boolean disableColor = false; + @Option(names = "--exclusions", + description = "Path to a JSON file containing exclusion rules. " + + "Excluded items are hidden from reports. " + + "If omitted, the linter also looks for 'dsf-linter-exclusions.json' " + + "in the project root automatically.") + private Path exclusionsFile = null; + /** * Main entry point for the DSF Linter CLI application. @@ -177,6 +186,30 @@ public Integer call() { return 1; } + // Load exclusion config (explicit file wins over auto-discovery) + ExclusionConfig exclusionConfig = null; + try { + ExclusionConfigLoader exclusionLoader = new ExclusionConfigLoader(); + if (exclusionsFile != null) { + exclusionConfig = exclusionLoader.load(exclusionsFile); + logger.info("Loaded exclusion rules from: " + exclusionsFile); + } else { + Optional auto = exclusionLoader.loadFromProjectRoot(projectPath); + if (auto.isPresent()) { + exclusionConfig = auto.get(); + logger.info("Auto-discovered exclusion rules from: " + + projectPath.resolve(ExclusionConfigLoader.DEFAULT_FILENAME)); + } + } + if (exclusionConfig != null) { + logger.info("Exclusion rules loaded: " + exclusionConfig.getRules().size() + + " rule(s), affectsExitStatus=" + exclusionConfig.isAffectsExitStatus()); + } + } catch (IOException e) { + logger.error("ERROR: Failed to load exclusion config: " + e.getMessage(), e); + return 1; + } + try { // Execute linting LinterExecutor executor = new LinterExecutor( @@ -185,6 +218,7 @@ public Integer call() { generateHtmlReport, generateJsonReport, !noFailOnErrors, + exclusionConfig, logger ); diff --git a/linter-core/pom.xml b/linter-core/pom.xml index 1a2356fc..c752bdbd 100644 --- a/linter-core/pom.xml +++ b/linter-core/pom.xml @@ -9,21 +9,15 @@ - dev.dsf.utils.linter + dev.dsf.linter dsf-linter - 0.1.2 + 0.1.3 linter-core - - 25 - 25 - UTF-8 - - - 1.15.8 + 1.18.9 @@ -34,14 +28,14 @@ org.camunda.bpm.model camunda-bpmn-model - 7.22.0 + 7.24.0 org.junit.jupiter junit-jupiter - 5.11.3 + 5.14.4 test @@ -73,25 +67,25 @@ com.fasterxml.jackson.core jackson-databind - 2.18.0 + 2.22.0 com.fasterxml.jackson.core jackson-core - 2.18.6 + 2.21.4 com.fasterxml.jackson.core jackson-annotations - 2.18.0 + 2.22 org.xmlunit xmlunit-core - 2.10.3 + 2.12.0 test @@ -104,7 +98,16 @@ com.fasterxml.jackson.datatype jackson-datatype-jsr310 - 2.18.2 + 2.22.0 + + + + + jakarta.xml.bind + jakarta.xml.bind-api + 4.0.2 @@ -122,7 +125,7 @@ dev.dsf dsf-bpe-process-api-v1 - 1.7.0 + 1.9.0 runtime @@ -130,7 +133,7 @@ dev.dsf dsf-bpe-process-api-v2 - 2.0.2 + 2.1.0 runtime @@ -138,7 +141,7 @@ org.camunda.bpm camunda-engine - 7.20.0 + 7.24.0 runtime @@ -146,52 +149,33 @@ ca.uhn.hapi.fhir hapi-fhir-base - 5.1.0 + 5.7.9 runtime - + + + jakarta.xml.bind + jakarta.xml.bind-api + 4.0.5 + runtime + + + org.glassfish.jaxb + jaxb-runtime + 4.0.9 + runtime + - - - org.apache.maven.plugins - maven-shade-plugin - 3.4.1 - - - package - - shade - - - - - dev.dsf:dsf-bpe-process-api-v1 - dev.dsf:dsf-bpe-process-api-v2 - org.camunda.bpm:camunda-engine - ca.uhn.hapi.fhir:hapi-fhir-base - org.springframework:spring-beans - org.springframework:spring-core - org.springframework:spring-jcl - - - - - - - - org.apache.maven.plugins maven-surefire-plugin - 3.0.0-M9 - -Dnet.bytebuddy.experimental=true diff --git a/linter-core/src/main/java/dev/dsf/linter/DsfLinter.java b/linter-core/src/main/java/dev/dsf/linter/DsfLinter.java index 7172a40e..ad05c30c 100644 --- a/linter-core/src/main/java/dev/dsf/linter/DsfLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/DsfLinter.java @@ -3,6 +3,8 @@ import dev.dsf.linter.analysis.LeftoverResourceDetector; import dev.dsf.linter.exception.MissingServiceRegistrationException; import dev.dsf.linter.exception.ResourceLinterException; +import dev.dsf.linter.exclusion.ExclusionConfig; +import dev.dsf.linter.exclusion.ExclusionFilter; import dev.dsf.linter.logger.Console; import dev.dsf.linter.logger.Logger; import dev.dsf.linter.report.LintingReportGenerator; @@ -51,12 +53,13 @@ public class DsfLinter { /** * Configuration for the DSF Linter. * - * @param projectPath the path to the project root directory - * @param reportPath the path where linting reports should be generated + * @param projectPath the path to the project root directory + * @param reportPath the path where linting reports should be generated * @param generateHtmlReport whether to generate an HTML report * @param generateJsonReport whether to generate a JSON report - * @param failOnErrors whether the linter should fail (exit code 1) when errors are found - * @param logger the logger instance for output + * @param failOnErrors whether the linter should fail (exit code 1) when errors are found + * @param exclusionConfig optional exclusion configuration; {@code null} means no exclusions + * @param logger the logger instance for output */ public record Config( Path projectPath, @@ -64,24 +67,35 @@ public record Config( boolean generateHtmlReport, boolean generateJsonReport, boolean failOnErrors, + ExclusionConfig exclusionConfig, Logger logger ) { + /** + * Backward-compatible constructor without exclusion config. + */ + public Config(Path projectPath, Path reportPath, boolean generateHtmlReport, + boolean generateJsonReport, boolean failOnErrors, Logger logger) { + this(projectPath, reportPath, generateHtmlReport, generateJsonReport, + failOnErrors, null, logger); + } } /** * Linting result for a single plugin. * - * @param pluginName the name of the plugin - * @param pluginClass the fully qualified class name of the plugin - * @param apiVersion the DSF API version used by the plugin - * @param output the detailed linting output (errors, warnings, etc.) - * @param reportPath the path to the generated report for this plugin + * @param pluginName the name of the plugin + * @param pluginClass the fully qualified class name of the plugin + * @param apiVersion the DSF API version used by the plugin + * @param output the linting output after exclusions have been applied (used for reports) + * @param excludedErrorCount the number of ERROR-severity items that were excluded by exclusion rules + * @param reportPath the path to the generated report for this plugin */ public record PluginLinter( String pluginName, String pluginClass, ApiVersion apiVersion, LintingOutput output, + int excludedErrorCount, Path reportPath ) { } @@ -128,12 +142,6 @@ public int getLeftoverCount() { return leftoverAnalysis != null ? leftoverAnalysis.getTotalLeftoverCount() : 0; } - /** - * Get total error count across all plugins and project-level leftovers. - */ - public int getTotalErrors() { - return getPluginErrors() + getLeftoverCount(); - } } private final Config config; @@ -165,6 +173,9 @@ public DsfLinter(Config config) { this.config = config; this.logger = config.logger(); Console.init(logger); + ExclusionFilter exclusionFilter = config.exclusionConfig() != null + ? new ExclusionFilter(config.exclusionConfig()) + : null; this.setupHandler = new ProjectSetupHandler(logger); this.discoveryService = new ResourceDiscoveryService(logger); BpmnLintingService bpmnLinter = new BpmnLintingService(logger); @@ -179,6 +190,7 @@ public DsfLinter(Config config) { leftoverDetector, reportGenerator, config.reportPath(), + exclusionFilter, logger ); } @@ -211,11 +223,7 @@ public OverallLinterResult lint() throws IOException { // Phase 1: Project Setup reportGenerator.printPhaseHeader("Phase 1: Project Setup"); ProjectSetupHandler.ProjectContext context; - try { - context = setupHandler.setupLintingEnvironment(config.projectPath()); - } catch (IOException e) { - throw e; - } + context = setupHandler.setupLintingEnvironment(config.projectPath()); // Execute all linting phases with temporary context classloader return ClassLoaderUtils.withTemporaryContextClassLoader(context.projectClassLoader(), () -> { @@ -253,11 +261,20 @@ public OverallLinterResult lint() throws IOException { long executionTime = System.currentTimeMillis() - startTime; reportGenerator.printSummary(pluginLinting, discovery, leftoverResults, executionTime, config); - // Determine final success status + // Determine final success status. + // v.output().getErrorCount() reflects only included (non-excluded) items. + // When affectsExitStatus=true, add back the count of excluded errors. + boolean addExcludedToCount = config.exclusionConfig() != null + && config.exclusionConfig().isAffectsExitStatus(); + int totalPluginErrors = pluginLinting.values().stream() - .mapToInt(v -> v.output().getErrorCount()) + .mapToInt(v -> { + int reported = v.output().getErrorCount(); + int excluded = addExcludedToCount ? v.excludedErrorCount() : 0; + return reported + excluded; + }) .sum(); - + // Consider failed plugins as errors (partial success means non-zero exit code) boolean hasFailedPlugins = discovery.hasFailedPlugins(); boolean success = !config.failOnErrors() || (totalPluginErrors == 0 && !hasFailedPlugins); diff --git a/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnEventLinter.java b/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnEventLinter.java index af139477..5aa7cb88 100644 --- a/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnEventLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnEventLinter.java @@ -1,6 +1,7 @@ package dev.dsf.linter.bpmn; import dev.dsf.linter.output.item.BpmnElementLintItem; +import dev.dsf.linter.util.bpmn.BpmnModelUtils; import dev.dsf.linter.util.bpmn.linters.*; import org.camunda.bpm.model.bpmn.instance.*; @@ -193,8 +194,7 @@ public void lintStartEvent( File bpmnFile, String processId) { - if (!startEvent.getEventDefinitions().isEmpty() - && startEvent.getEventDefinitions().iterator().next() instanceof MessageEventDefinition) { + if (BpmnModelUtils.isMessageStartEvent(startEvent)) { BpmnStartEventLinter.lintMessageStartEvent(startEvent, issues, bpmnFile, processId, projectRoot); } else { BpmnStartEventLinter.lintGenericStartEvent(startEvent, issues, bpmnFile, processId, projectRoot); diff --git a/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnFieldInjectionLinter.java b/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnFieldInjectionLinter.java index 1d5d90e2..8db2972f 100644 --- a/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnFieldInjectionLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnFieldInjectionLinter.java @@ -3,6 +3,8 @@ import dev.dsf.linter.output.LinterSeverity; import dev.dsf.linter.output.LintingType; import dev.dsf.linter.output.item.*; +import dev.dsf.linter.util.api.ApiVersion; +import dev.dsf.linter.util.api.ApiVersionHolder; import dev.dsf.linter.util.resource.FhirResourceExtractor; import dev.dsf.linter.util.resource.FhirResourceLocator; import dev.dsf.linter.util.resource.FhirResourceParser; @@ -47,11 +49,11 @@ * *

Field Value Type Validation

*
    - *
  • String Literal Requirement: Validates that field values are provided as + *
  • String Literal Requirement (API v1): Validates that field values are provided as * string literals rather than expressions, ensuring static configuration that can be validated * at linting time
  • - *
  • Expression Detection: Issues errors when field values are provided as - * expressions, which cannot be statically validated
  • + *
  • Expression Detection: For API v1, issues errors when field values are provided as + * expressions. For API v2, expressions are allowed and further static validation of that field is skipped
  • *
* *

Profile Field Validation

@@ -312,12 +314,20 @@ private static void lintCamundaFields(ExtensionElements extensionElements, "Field injection '" + fieldName + "' provided as string literal")); } - // expression? -> immediate error + skip further processing of this field + // expression: error for API v1; allowed for API v2 (skip static field validation) if (fv != null && fv.type == FieldValueType.EXPRESSION) { - issues.add(new BpmnElementLintItem( - LinterSeverity.ERROR, LintingType.BPMN_FIELD_INJECTION_NOT_STRING_LITERAL, - elementId, bpmnFile, processId, - "Field injection '" + fieldName + "' is provided as expression, expected string literal")); //todo in api v2 is allowed + if (ApiVersionHolder.getVersion() == ApiVersion.V2) { + issues.add(BpmnElementLintItem.success( + elementId, + bpmnFile, + processId, + "Field injection '" + fieldName + "' provided as expression (allowed for API v2)")); + } else { + issues.add(new BpmnElementLintItem( + LinterSeverity.ERROR, LintingType.BPMN_FIELD_INJECTION_NOT_STRING_LITERAL, + elementId, bpmnFile, processId, + "Field injection '" + fieldName + "' is provided as expression, expected string literal")); + } continue; } @@ -328,7 +338,7 @@ private static void lintCamundaFields(ExtensionElements extensionElements, case "profile" -> { checkProfileField(elementId, bpmnFile, processId, issues, literal, projectRoot); profileVal = literal; - if (!isEmpty(literal) && locator.structureDefinitionExists(literal, projectRoot)) { + if (!isEmpty(literal) && locator.structureDefinitionExists(literal)) { structureFoundForProfile = true; issues.add(BpmnElementLintItem.success( elementId, @@ -350,7 +360,7 @@ private static void lintCamundaFields(ExtensionElements extensionElements, } } case "instantiatesCanonical" -> { - checkInstantiatesCanonicalField(elementId, literal, bpmnFile, processId, issues, projectRoot); + checkInstantiatesCanonicalField(elementId, literal, bpmnFile, processId, issues); instantiatesVal = literal; } default -> issues.add(new BpmnElementLintItem( @@ -390,7 +400,7 @@ private static void performCrossChecks(String elementId, String instantiatesVal, String messageNameVal) { var locator = FhirResourceLocator.create(projectRoot); - File structureFile = locator.findStructureDefinitionFile(profileVal, projectRoot); + File structureFile = locator.findStructureDefinitionFile(profileVal); if (structureFile == null) return; // Warn already added earlier. try { @@ -495,7 +505,7 @@ public static void checkProfileField( "Profile field contains a version placeholder: '" + literalValue + "'")); } - if (!locator.structureDefinitionExists(literalValue, projectRoot)) { + if (!locator.structureDefinitionExists(literalValue)) { issues.add(new BpmnElementLintItem(LinterSeverity.WARN, LintingType.BPMN_NO_STRUCTURE_DEFINITION_FOUND_FOR_MESSAGE, elementId, bpmnFile, processId, @@ -515,15 +525,13 @@ public static void checkProfileField( * @param bpmnFile the BPMN file under lint * @param processId the identifier of the BPMN process containing the element * @param issues the list of {@link BpmnElementLintItem} to which lint issues or success items will be added - * @param projectRoot the project root directory containing FHIR resources */ public static void checkInstantiatesCanonicalField( String elementId, String literalValue, File bpmnFile, String processId, - List issues, - File projectRoot) { + List issues) { if (isEmpty(literalValue)) { issues.add(new BpmnElementLintItem(LinterSeverity.ERROR, diff --git a/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnGatewayAndFlowLinter.java b/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnGatewayAndFlowLinter.java index ef759875..597f214f 100644 --- a/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnGatewayAndFlowLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnGatewayAndFlowLinter.java @@ -177,7 +177,7 @@ public void lintInclusiveGateway( if (gateway.getOutgoing() != null && gateway.getOutgoing().size() > 1) { if (isEmpty(gateway.getName())) { issues.add(BpmnElementLintItem.of( - LinterSeverity.ERROR, + LinterSeverity.WARN, LintingType.BPMN_INCLUSIVE_GATEWAY_HAS_MULTIPLE_OUTGOING_FLOWS_BUT_NAME_IS_EMPTY, elementId, bpmnFile, processId)); } else { diff --git a/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnModelLinter.java b/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnModelLinter.java index 55e02997..dfd5c00a 100644 --- a/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnModelLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnModelLinter.java @@ -46,6 +46,8 @@ *
  • Process Count: Validates that each BPMN file contains exactly one process definition
  • *
  • History Time To Live: Warns if {@code camunda:historyTimeToLive} is not set
  • *
  • Process Executable: Validates that the process has {@code isExecutable="true"}
  • + *
  • Message Start Event: Validates that the process has at least one message start event + * as a direct child (start events in subprocesses are not counted)
  • * * *

    Task Validation

    diff --git a/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnProcessLinter.java b/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnProcessLinter.java index e89e65c5..c700ebc0 100644 --- a/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnProcessLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnProcessLinter.java @@ -3,8 +3,10 @@ import dev.dsf.linter.output.LinterSeverity; import dev.dsf.linter.output.LintingType; import dev.dsf.linter.output.item.BpmnElementLintItem; +import dev.dsf.linter.util.bpmn.BpmnModelUtils; import org.camunda.bpm.model.bpmn.BpmnModelInstance; import org.camunda.bpm.model.bpmn.instance.Process; +import org.camunda.bpm.model.bpmn.instance.StartEvent; import java.io.File; import java.util.Collection; @@ -58,6 +60,12 @@ * which is required for the process to be deployable and executable by the process engine. * * + *

    Message Start Event Validation

    + *
      + *
    • Message Start Requirement: Validates that the process has at least one message + * start event as a direct child of the process element. Start events inside subprocesses are not counted.
    • + *
    + * *

    Usage Example

    *
    {@code
      * File projectRoot = new File("/path/to/project");
    @@ -110,6 +118,7 @@ public record BpmnProcessLinter(File projectRoot) {
          */
         private static final String PROCESS_ID_PATTERN_STRING = "^(?[a-zA-Z0-9-]+)_(?[a-zA-Z0-9-]+)$";
         private static final Pattern PROCESS_ID_PATTERN = Pattern.compile(PROCESS_ID_PATTERN_STRING);
    +    private static final String CAMUNDA_BPMN_NAMESPACE = "http://camunda.org/schema/1.0/bpmn";
     
         /**
          * Constructs a new {@code BpmnProcessLinter} instance with the specified project root directory.
    @@ -133,6 +142,8 @@ public record BpmnProcessLinter(File projectRoot) {
          *   
  • Process ID pattern
  • *
  • History time to live attribute
  • *
  • Executable flag
  • + *
  • Message start event on process level (not in subprocesses)
  • + *
  • Version tag placeholder ({@code camunda:versionTag="#{version}"})
  • * *

    * @@ -151,6 +162,8 @@ public String lintProcesses(BpmnModelInstance model, File bpmnFile, List + * DSF processes are started via incoming messages. Start events inside subprocesses are not counted. + *

    + * + * @param process the BPMN process to validate + * @param bpmnFile the BPMN file for error reporting + * @param issues the list to add validation issues to + */ + void validateMessageStartEvent(Process process, File bpmnFile, List issues) { + String processId = process.getId() != null ? process.getId() : ""; + + boolean hasMessageStart = process.getChildElementsByType(StartEvent.class).stream() + .anyMatch(BpmnModelUtils::isMessageStartEvent); + + if (!hasMessageStart) { + issues.add(BpmnElementLintItem.of( + LinterSeverity.ERROR, + LintingType.BPMN_MESSAGE_START_EVENT_NOT_FOUND, + processId, + bpmnFile, + processId)); + } else { + issues.add(new BpmnElementLintItem( + LinterSeverity.SUCCESS, + LintingType.SUCCESS, + processId, + bpmnFile, + processId, + String.format("Process '%s': has at least one message start event on process level.", processId) + )); + } + } + + /** + * Validates that camunda:versionTag exists and uses the DSF placeholder "#{version}". + * + *

    + * Validation behavior: + *

      + *
    • ERROR if missing, empty/blank, or set to literal "null"
    • + *
    • WARN if present but does not contain "#{version}"
    • + *
    + *

    + * + * @param process the BPMN process to validate + * @param bpmnFile the BPMN file for error reporting + * @param issues the list to add validation issues to + */ + void validateVersionTag(Process process, File bpmnFile, List issues) { + String processId = process.getId() != null ? process.getId() : ""; + String versionTag = process.getAttributeValueNs(CAMUNDA_BPMN_NAMESPACE, "versionTag"); + + if (versionTag == null || versionTag.isBlank() || "null".equalsIgnoreCase(versionTag.trim())) { + issues.add(new BpmnElementLintItem( + LinterSeverity.ERROR, + LintingType.BPMN_PROCESS_VERSION_TAG_MISSING_OR_EMPTY, + processId, + bpmnFile, + processId, + String.format("Process '%s': camunda:versionTag is missing, empty, or set to 'null'. " + + "Expected camunda:versionTag='#{version}'.", processId) + )); + return; + } + + if (!versionTag.contains("#{version}")) { + issues.add(new BpmnElementLintItem( + LinterSeverity.WARN, + LintingType.BPMN_PROCESS_VERSION_TAG_NO_PLACEHOLDER, + processId, + bpmnFile, + processId, + String.format("Process '%s': camunda:versionTag='%s' does not use placeholder '#{version}'.", + processId, versionTag) + )); + } else { + issues.add(new BpmnElementLintItem( + LinterSeverity.SUCCESS, + LintingType.SUCCESS, + processId, + bpmnFile, + processId, + String.format("Process '%s': camunda:versionTag uses '#{version}'.", processId) + )); + } + } } diff --git a/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnTaskLinter.java b/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnTaskLinter.java index ccefce0e..530be6e8 100644 --- a/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnTaskLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/bpmn/BpmnTaskLinter.java @@ -197,7 +197,7 @@ public void lintServiceTask( // 1. Validate task name if (isEmpty(task.getName())) { - issues.add(BpmnElementLintItem.of(LinterSeverity.ERROR, + issues.add(BpmnElementLintItem.of(LinterSeverity.WARN, LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, elementId, bpmnFile, processId)); } else { issues.add(BpmnElementLintItem.success(elementId, bpmnFile, processId, @@ -282,9 +282,8 @@ public void lintSendTask( // 1. Validate task name if (isEmpty(sendTask.getName())) { - issues.add(new BpmnElementLintItem(LinterSeverity.WARN, - LintingType.BPMN_EVENT_NAME_EMPTY, elementId, bpmnFile, processId, - "'" + elementId + "' has no name")); + issues.add(BpmnElementLintItem.of(LinterSeverity.WARN, + LintingType.BPMN_SEND_TASK_NAME_EMPTY, elementId, bpmnFile, processId)); } else { issues.add(BpmnElementLintItem.success(elementId, bpmnFile, processId, "SendTask has a non-empty name: '" + sendTask.getName() + "'")); @@ -357,7 +356,7 @@ public void lintUserTask( // 1. Validate task name if (isEmpty(userTask.getName())) { - issues.add(BpmnElementLintItem.of(LinterSeverity.ERROR, + issues.add(BpmnElementLintItem.of(LinterSeverity.WARN, LintingType.BPMN_USER_TASK_NAME_EMPTY, elementId, bpmnFile, processId)); } else { issues.add(BpmnElementLintItem.success(elementId, bpmnFile, processId, @@ -382,7 +381,7 @@ public void lintUserTask( issues.add(BpmnElementLintItem.success(elementId, bpmnFile, processId, "User Task formKey is valid: '" + formKey + "'")); - if (!locator.questionnaireExists(formKey, projectRoot)) { + if (!locator.questionnaireExists(formKey)) { issues.add(new BpmnElementLintItem( LinterSeverity.ERROR, LintingType.BPMN_USER_TASK_QUESTIONNAIRE_NOT_FOUND, elementId, bpmnFile, processId, @@ -409,9 +408,8 @@ public void lintReceiveTask( String elementId = receiveTask.getId(); if (isEmpty(receiveTask.getName())) { - issues.add(new BpmnElementLintItem(LinterSeverity.WARN, - LintingType.BPMN_EVENT_NAME_EMPTY, elementId, bpmnFile, processId, - "'" + elementId + "' has no name.")); + issues.add(BpmnElementLintItem.of(LinterSeverity.WARN, + LintingType.BPMN_RECEIVE_TASK_NAME_EMPTY, elementId, bpmnFile, processId)); } else { issues.add(BpmnElementLintItem.success(elementId, bpmnFile, processId, "ReceiveTask has a non-empty name: '" + receiveTask.getName() + "'")); @@ -419,7 +417,7 @@ public void lintReceiveTask( if (receiveTask.getMessage() == null || isEmpty(receiveTask.getMessage().getName())) { issues.add(BpmnElementLintItem.of(LinterSeverity.ERROR, - LintingType.BPMN_MESSAGE_START_EVENT_MESSAGE_NAME_EMPTY, elementId, bpmnFile, processId)); + LintingType.BPMN_RECEIVE_TASK_MESSAGE_NAME_EMPTY, elementId, bpmnFile, processId)); } else { String msgName = receiveTask.getMessage().getName(); issues.add(BpmnElementLintItem.success(elementId, bpmnFile, processId, diff --git a/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionConfig.java b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionConfig.java new file mode 100644 index 00000000..c0c232b0 --- /dev/null +++ b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionConfig.java @@ -0,0 +1,77 @@ +package dev.dsf.linter.exclusion; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Top-level exclusion configuration loaded from {@code dsf-linter-exclusions.json}. + *

    + * Contains an ordered list of {@link ExclusionRule}s and an optional flag that controls + * whether excluded items still contribute to the exit status. + *

    + * + *

    Sample {@code dsf-linter-exclusions.json}

    + *
    {@code
    + * {
    + *   "affectsExitStatus": false,
    + *   "rules": [
    + *     { "type": "BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING" },
    + *     { "severity": "WARN", "file": "update-allow-list.bpmn" },
    + *     { "messageContains": "optional field" }
    + *   ]
    + * }
    + * }
    + * + * @see ExclusionRule + * @see ExclusionFilter + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class ExclusionConfig { + + /** + * When {@code false} (default), excluded items are also removed from exit-status + * error/warning counts — they are fully suppressed. + * When {@code true}, excluded items still count toward the exit status (they are only + * hidden from the generated reports). + */ + private boolean affectsExitStatus = false; + + private List rules = new ArrayList<>(); + + public ExclusionConfig() { + } + + public ExclusionConfig(List rules, boolean affectsExitStatus) { + this.rules = new ArrayList<>(rules); + this.affectsExitStatus = affectsExitStatus; + } + + public boolean isAffectsExitStatus() { + return affectsExitStatus; + } + + public void setAffectsExitStatus(boolean affectsExitStatus) { + this.affectsExitStatus = affectsExitStatus; + } + + public List getRules() { + return Collections.unmodifiableList(rules); + } + + public void setRules(List rules) { + this.rules = rules != null ? new ArrayList<>(rules) : new ArrayList<>(); + } + + /** Returns {@code true} when there is at least one valid rule to evaluate. */ + public boolean hasRules() { + return rules != null && rules.stream().anyMatch(ExclusionRule::isValid); + } + + @Override + public String toString() { + return "ExclusionConfig{rules=" + rules.size() + ", affectsExitStatus=" + affectsExitStatus + "}"; + } +} diff --git a/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionConfigLoader.java b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionConfigLoader.java new file mode 100644 index 00000000..e481d428 --- /dev/null +++ b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionConfigLoader.java @@ -0,0 +1,92 @@ +package dev.dsf.linter.exclusion; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +/** + * Loads an {@link ExclusionConfig} from a JSON file. + * + *

    Auto-discovery

    + * {@link #loadFromProjectRoot(Path)} looks for a file named + * {@value #DEFAULT_FILENAME} in the project root directory. Use + * {@link #load(Path)} to specify an explicit path. + * + *

    File format

    + *
    {@code
    + * {
    + *   "affectsExitStatus": false,
    + *   "rules": [
    + *     { "type": "BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING" },
    + *     { "severity": "WARN", "file": "update-allow-list.bpmn" },
    + *     { "messageContains": "optional field" }
    + *   ]
    + * }
    + * }
    + * + *

    Unknown JSON fields are silently ignored. Rules with no criteria set are skipped.

    + */ +public class ExclusionConfigLoader { + + /** Default file name searched in the project root. */ + public static final String DEFAULT_FILENAME = "dsf-linter-exclusions.json"; + + private final ObjectMapper objectMapper; + + public ExclusionConfigLoader() { + this.objectMapper = new ObjectMapper(); + } + + /** + * Looks for {@value #DEFAULT_FILENAME} inside {@code projectRoot}. + * Returns an empty Optional when no file is found. + * + * @param projectRoot the project root directory + * @return loaded config, or empty if the file does not exist + * @throws IOException if the file exists but cannot be parsed + */ + public Optional loadFromProjectRoot(Path projectRoot) throws IOException { + if (projectRoot == null) return Optional.empty(); + + Path candidate = projectRoot.resolve(DEFAULT_FILENAME); + if (!Files.isRegularFile(candidate)) return Optional.empty(); + + return Optional.of(load(candidate)); + } + + /** + * Loads an {@link ExclusionConfig} from the given JSON file. + * + * @param configFile path to the JSON file + * @return the parsed configuration + * @throws IOException if the file cannot be read or parsed + */ + public ExclusionConfig load(Path configFile) throws IOException { + if (configFile == null || !Files.isRegularFile(configFile)) { + throw new IOException("Exclusion config file not found: " + configFile); + } + + ExclusionConfig config = objectMapper.readValue(configFile.toFile(), ExclusionConfig.class); + validate(config, configFile); + return config; + } + + // ------------------------------------------------------------------------- + + private void validate(ExclusionConfig config, Path source) throws IOException { + if (config.getRules() == null) return; + + for (int i = 0; i < config.getRules().size(); i++) { + ExclusionRule rule = config.getRules().get(i); + if (!rule.isValid()) { + throw new IOException( + "Exclusion rule #" + (i + 1) + " in '" + source.getFileName() + + "' has no criteria. Each rule must specify at least one of: " + + "type, severity, file, messageContains."); + } + } + } +} diff --git a/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionFilter.java b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionFilter.java new file mode 100644 index 00000000..eefe4ca0 --- /dev/null +++ b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionFilter.java @@ -0,0 +1,129 @@ +package dev.dsf.linter.exclusion; + +import dev.dsf.linter.output.item.AbstractLintItem; +import dev.dsf.linter.output.item.BpmnLintItem; +import dev.dsf.linter.output.item.FhirLintItem; +import dev.dsf.linter.output.item.PluginLintItem; + +import java.util.List; +import java.util.Locale; + +/** + * Evaluates {@link ExclusionRule}s against lint items and partitions them into + * included (visible in reports) and excluded (suppressed) sets. + * + *

    Matching logic

    + *
      + *
    • Rules are OR-combined: an item is excluded if any rule matches it.
    • + *
    • Within a single rule, all non-null fields are AND-combined.
    • + *
    + */ +public class ExclusionFilter { + + private final ExclusionConfig config; + + /** + * Creates an ExclusionFilter backed by the given configuration. + * + * @param config the exclusion configuration (must not be {@code null}) + */ + public ExclusionFilter(ExclusionConfig config) { + if (config == null) throw new IllegalArgumentException("ExclusionConfig must not be null"); + this.config = config; + } + + /** + * Returns the underlying configuration. + */ + public ExclusionConfig getConfig() { + return config; + } + + /** + * Returns {@code true} when at least one rule in the configuration matches {@code item}. + * + * @param item the lint item to test + * @return {@code true} if the item should be suppressed + */ + public boolean isExcluded(AbstractLintItem item) { + if (item == null) return false; + return config.getRules().stream() + .filter(ExclusionRule::isValid) + .anyMatch(rule -> matches(rule, item)); + } + + /** + * Returns only the items that are NOT excluded. + * + * @param items the full list of lint items + * @return items that pass through (i.e. are visible in reports) + */ + public List filter(List items) { + if (items == null || items.isEmpty()) return List.of(); + return items.stream() + .filter(i -> !isExcluded(i)) + .toList(); + } + + /** + * Returns only the items that ARE excluded by at least one rule. + * + * @param items the full list of lint items + * @return items that were suppressed + */ + public List getExcluded(List items) { + if (items == null || items.isEmpty()) return List.of(); + return items.stream() + .filter(this::isExcluded) + .toList(); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private boolean matches(ExclusionRule rule, AbstractLintItem item) { + if (hasValue(rule.getType()) + && !rule.getType().equalsIgnoreCase(item.getType().name())) { + return false; + } + + if (hasValue(rule.getSeverity()) + && !rule.getSeverity().equalsIgnoreCase(item.getSeverity().name())) { + return false; + } + + if (hasValue(rule.getFile())) { + String fileHint = resolveFileHint(item); + if (fileHint == null + || !fileHint.toLowerCase(Locale.ROOT) + .contains(rule.getFile().toLowerCase(Locale.ROOT))) { + return false; + } + } + + if (hasValue(rule.getMessageContains())) { + String desc = item.getDescription(); + return desc != null + && desc.toLowerCase(Locale.ROOT) + .contains(rule.getMessageContains().toLowerCase(Locale.ROOT)); + } + + return true; + } + + /** + * Resolves the best available file-name hint for an item. + * Uses specific subclass accessors before falling back to {@code toString()}. + */ + private String resolveFileHint(AbstractLintItem item) { + if (item instanceof BpmnLintItem b) return b.getBpmnFile(); + if (item instanceof FhirLintItem f) return f.getFhirFile(); + if (item instanceof PluginLintItem p) return p.getFileName(); + return null; + } + + private static boolean hasValue(String s) { + return s != null && !s.isBlank(); + } +} diff --git a/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionRule.java b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionRule.java new file mode 100644 index 00000000..72e6d635 --- /dev/null +++ b/linter-core/src/main/java/dev/dsf/linter/exclusion/ExclusionRule.java @@ -0,0 +1,116 @@ +package dev.dsf.linter.exclusion; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * A single exclusion rule that describes which lint items to suppress from reports. + *

    + * All non-null fields are AND-combined: an item is excluded only when every specified + * field matches. Multiple rules in an {@link ExclusionConfig} are OR-combined. + *

    + * + *

    Matching semantics

    + *
      + *
    • {@code type} — exact, case-insensitive match against {@link dev.dsf.linter.output.LintingType#name()}
    • + *
    • {@code severity} — exact, case-insensitive match against {@link dev.dsf.linter.output.LinterSeverity#name()}
    • + *
    • {@code file} — case-insensitive substring match against the item's file name
    • + *
    • {@code messageContains} — case-insensitive substring match against the item's description
    • + *
    + * + *

    Example (JSON)

    + *
    + * { "type": "BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING" }
    + * { "severity": "WARN", "file": "update-allow-list.bpmn" }
    + * { "messageContains": "optional field" }
    + * 
    + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class ExclusionRule { + + private String type; + private String severity; + private String file; + private String messageContains; + + public ExclusionRule() { + } + + public ExclusionRule(String type, String severity, String file, String messageContains) { + this.type = type; + this.severity = severity; + this.file = file; + this.messageContains = messageContains; + } + + /** + * Returns the exact {@link dev.dsf.linter.output.LintingType} name to match, + * or {@code null} to match any type. + */ + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + /** + * Returns the exact {@link dev.dsf.linter.output.LinterSeverity} name to match, + * or {@code null} to match any severity. + */ + public String getSeverity() { + return severity; + } + + public void setSeverity(String severity) { + this.severity = severity; + } + + /** + * Returns the file-name substring to match (case-insensitive), + * or {@code null} to match any file. + */ + public String getFile() { + return file; + } + + public void setFile(String file) { + this.file = file; + } + + /** + * Returns the description substring to match (case-insensitive), + * or {@code null} to match any description. + */ + public String getMessageContains() { + return messageContains; + } + + public void setMessageContains(String messageContains) { + this.messageContains = messageContains; + } + + /** + * Returns {@code true} when this rule has at least one non-null, non-blank criterion. + * An empty rule would match everything and is rejected by the loader. + */ + public boolean isValid() { + return hasValue(type) || hasValue(severity) || hasValue(file) || hasValue(messageContains); + } + + private static boolean hasValue(String s) { + return s != null && !s.isBlank(); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("ExclusionRule{"); + if (hasValue(type)) sb.append("type='").append(type).append("', "); + if (hasValue(severity)) sb.append("severity='").append(severity).append("', "); + if (hasValue(file)) sb.append("file='").append(file).append("', "); + if (hasValue(messageContains)) sb.append("messageContains='").append(messageContains).append("', "); + if (sb.charAt(sb.length() - 2) == ',') sb.setLength(sb.length() - 2); + sb.append("}"); + return sb.toString(); + } +} diff --git a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirCodeSystemLinter.java b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirCodeSystemLinter.java index a0a218de..427e29b4 100644 --- a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirCodeSystemLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirCodeSystemLinter.java @@ -98,12 +98,24 @@ public boolean canLint(Document d) @Override public List lint(Document doc, File resFile) { - final String ref = computeReference(doc, resFile); + final String ref = resolveReference(doc, resFile, CS_XP + "/*[local-name()='url']/@value"); final List issues = new ArrayList<>(); checkMeta(doc, resFile, ref, issues); checkMandatoryElements(doc, resFile, ref, issues); - checkPlaceholders(doc, resFile, ref, issues); + checkVersionDatePlaceholders( + doc, + CS_XP + "/*[local-name()='version']/@value", + CS_XP + "/*[local-name()='date']/@value", + resFile, + ref, + LintingType.CODE_SYSTEM_VERSION_NO_PLACEHOLDER, + LintingType.CODE_SYSTEM_DATE_NO_PLACEHOLDER, + " must contain '#{version}'", + " must contain '#{date}'", + " placeholder OK", + " placeholder OK", + issues); checkConcepts(doc, resFile, ref, issues); return issues; @@ -179,29 +191,6 @@ private void checkPresent(Document doc, File f, String ref, out.add(ok(f, ref, name + " present")); } - /* - 3) Placeholder checks (#{version}, #{date}) - */ - /** - * lints that version and date fields contain their respective DSF placeholders. - */ - private void checkPlaceholders(Document doc, File f, String ref, List out) - { - String version = val(doc, CS_XP + "/*[local-name()='version']/@value"); - if (version != null && !version.equals("#{version}")) - out.add(new FhirElementLintItem(LinterSeverity.ERROR, LintingType.CODE_SYSTEM_VERSION_NO_PLACEHOLDER, - f, ref, " must contain '#{version}'")); - else - out.add(ok(f, ref, " placeholder OK")); - - String date = val(doc, CS_XP + "/*[local-name()='date']/@value"); - if (date != null && !date.equals("#{date}")) - out.add(new FhirElementLintItem(LinterSeverity.WARN, LintingType.CODE_SYSTEM_DATE_NO_PLACEHOLDER, - f, ref, " must contain '#{date}'")); - else - out.add(ok(f, ref, " placeholder OK")); - } - /* 4) Concept checks (code + display + uniqueness) */ @@ -245,19 +234,4 @@ else if (seenCodes.contains(code)) out.add(ok(f, ref, "all concept codes unique ("+seenCodes.size()+")")); } - /* - Helper: use url (or filename) as reference for result messages - */ - /** - * Computes a reference string used in linter messages, preferring {@code url} over file name. - * - * @param doc the parsed XML document - * @param file the file the resource was loaded from - * @return a user-friendly reference (canonical URL or filename) - */ - private String computeReference(Document doc, File file) - { - String url = val(doc, CS_XP + "/*[local-name()='url']/@value"); - return !blank(url) ? url : file.getName(); - } } diff --git a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirQuestionnaireLinter.java b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirQuestionnaireLinter.java index e613f0b3..fd8ad443 100644 --- a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirQuestionnaireLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirQuestionnaireLinter.java @@ -106,11 +106,23 @@ public boolean canLint(Document document) @Override public List lint(Document doc, File resourceFile) { - final String ref = computeReference(doc, resourceFile); + final String ref = resolveReference(doc, resourceFile, Q_XP + "/*[local-name()='url']/@value"); final List issues = new ArrayList<>(); checkMetaAndBasics(doc, resourceFile, ref, issues); - checkPlaceholders(doc, resourceFile, ref, issues); + checkVersionDatePlaceholders( + doc, + Q_XP + "/*[local-name()='version']/@value", + Q_XP + "/*[local-name()='date']/@value", + resourceFile, + ref, + LintingType.QUESTIONNAIRE_VERSION_NO_PLACEHOLDER, + LintingType.QUESTIONNAIRE_DATE_NO_PLACEHOLDER, + "Questionnaire version must be '#{version}'.", + "Questionnaire date must be '#{date}'.", + "version placeholder present", + "date placeholder present", + issues); lintItems(doc, resourceFile, ref, issues); return issues; @@ -126,27 +138,18 @@ private void checkMetaAndBasics(Document doc, File file, String ref, /* meta.profile */ String profile = val(doc, META_PRO_XP); if (blank(profile)) - out.add(new FhirElementLintItem(LinterSeverity.ERROR, LintingType.QUESTIONNAIRE_MISSING_META_PROFILE, + out.add(new FhirElementLintItem(LinterSeverity.WARN, LintingType.QUESTIONNAIRE_MISSING_META_PROFILE, file, ref, "Questionnaire is missing meta.profile.")); // Modified to use regex matching else if (!PROFILE_URI_PATTERN.matcher(profile).matches()) - out.add(new FhirElementLintItem(LinterSeverity.ERROR, LintingType.QUESTIONNAIRE_INVALID_META_PROFILE, + out.add(new FhirElementLintItem(LinterSeverity.WARN, LintingType.QUESTIONNAIRE_INVALID_META_PROFILE, file, ref, "Questionnaire has invalid meta.profile: " + profile)); else out.add(ok(file, ref, "meta.profile present and valid")); /* meta.tag (read‑access ALL) */ - boolean tagOk = false; NodeList tags = xp(doc, META_TAG_XP); - if (tags != null) { - for (int i = 0; i < tags.getLength(); i++) { - String sys = val(tags.item(i), "./*[local-name()='system']/@value"); - String code = val(tags.item(i), "./*[local-name()='code']/@value"); - if (READ_TAG_SYS.equals(sys) && VALID_READ_ACCESS_CODES.contains(code)){ - tagOk = true; break; - } - } - } + boolean tagOk = hasAllowedTag(tags, READ_TAG_SYS, VALID_READ_ACCESS_CODES); if (!tagOk) out.add(new FhirElementLintItem(LinterSeverity.ERROR, LintingType.QUESTIONNAIRE_MISSING_READ_ACCESS_TAG, file, ref, "Questionnaire is missing valid read-access tag.")); @@ -162,28 +165,6 @@ else if (!PROFILE_URI_PATTERN.matcher(profile).matches()) out.add(ok(file, ref, "status = unknown")); } - /* - 2) placeholder linting - */ - - private void checkPlaceholders(Document doc, File file, String ref, - List out) - { - String version = val(doc, Q_XP + "/*[local-name()='version']/@value"); - if (version == null || !version.equals("#{version}")) - out.add(new FhirElementLintItem(LinterSeverity.ERROR, LintingType.QUESTIONNAIRE_VERSION_NO_PLACEHOLDER, - file, ref, "Questionnaire version must be '#{version}'.")); - else - out.add(ok(file, ref, "version placeholder present")); - - String date = val(doc, Q_XP + "/*[local-name()='date']/@value"); - if (date == null || !date.equals("#{date}")) - out.add(new FhirElementLintItem(LinterSeverity.ERROR, LintingType.QUESTIONNAIRE_DATE_NO_PLACEHOLDER, - file, ref, "Questionnaire date must be '#{date}'.")); - else - out.add(ok(file, ref, "date placeholder present")); - } - /* 3) item lint */ @@ -273,19 +254,6 @@ else if (!"true".equals(required)) 4) helper methods */ - /** - * Computes a reference string for the FHIR resource. - * - * @param doc the XML {@link Document} representing the FHIR resource - * @param file the file from which the resource was loaded - * @return the canonical {@code url} value if present, otherwise the file name - */ - private String computeReference(Document doc, File file) - { - String url = val(doc, Q_XP + "/*[local-name()='url']/@value"); - return !blank(url) ? url : file.getName(); - } - /** * Extracts the value of a FHIR primitive element. * diff --git a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirStructureDefinitionLinter.java b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirStructureDefinitionLinter.java index 609a7d51..c2806c8c 100644 --- a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirStructureDefinitionLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirStructureDefinitionLinter.java @@ -4,10 +4,15 @@ import dev.dsf.linter.output.LintingType; import dev.dsf.linter.output.item.*; import dev.dsf.linter.util.resource.FhirAuthorizationCache; +import dev.dsf.linter.util.resource.FhirResourceLocator; import dev.dsf.linter.util.linting.AbstractFhirInstanceLinter; +import dev.dsf.linter.util.linting.LintingUtils; import org.w3c.dom.Document; +import org.w3c.dom.Node; import org.w3c.dom.NodeList; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathFactory; import java.io.File; import java.util.*; @@ -126,20 +131,39 @@ public boolean canLint(Document doc) @Override public List lint(Document doc, File resFile) { - final String ref = determineRef(doc, resFile); + final String ref = resolveReference(doc, resFile, SD_XP + "/*[local-name()='url']/@value"); final List issues = new ArrayList<>(); /* 1 – meta / basic */ checkMetaAndBasics(doc, resFile, ref, issues); /* 2 – placeholders */ - checkPlaceholders(doc, resFile, ref, issues); + checkVersionDatePlaceholders( + doc, + SD_XP + "/*[local-name()='version']/@value", + SD_XP + "/*[local-name()='date']/@value", + resFile, + ref, + LintingType.STRUCTURE_DEFINITION_VERSION_NO_PLACEHOLDER, + LintingType.STRUCTURE_DEFINITION_DATE_NO_PLACEHOLDER, + "Version does not use placeholder: " + val(doc, SD_XP + "/*[local-name()='version']/@value"), + "Date does not use placeholder: " + val(doc, SD_XP + "/*[local-name()='date']/@value"), + "version placeholder present", + "date placeholder present", + issues); /* 3 – differential / snapshot / element IDs */ checkDifferentialSection(doc, resFile, ref, issues); /* 4 - slice-count vs. min/max */ checkSliceCardinality(doc, resFile, ref, issues); + + /* 5 - binding.valueSet references */ + checkBindingValueSets(doc, resFile, ref, issues); + + /* 6 - fixedUri/fixedCode against project CodeSystems */ + checkFixedCodings(doc, resFile, ref, issues); + return issues; } @@ -187,11 +211,8 @@ private void checkMetaAndBasics(Document doc, out.add(ok(file, ref, "meta.tag read‑access‑tag OK (" + tags.getLength() + " tag(s))")); /* url */ - String url = val(doc, SD_XP + "/*[local-name()='url']/@value"); - if (blank(url)) - out.add(FhirElementLintItem.of(LinterSeverity.ERROR, LintingType.STRUCTURE_DEFINITION_URL_MISSING, file, ref)); - else - out.add(ok(file, ref, "url looks good")); + checkRequiredValue(doc, SD_XP + "/*[local-name()='url']/@value", + file, ref, LintingType.STRUCTURE_DEFINITION_URL_MISSING, "url looks good", out); /* status */ String status = val(doc, SD_XP + "/*[local-name()='status']/@value"); @@ -201,36 +222,6 @@ private void checkMetaAndBasics(Document doc, out.add(ok(file, ref, "status = unknown")); } - /* CHECK 2: PLACEHOLDERS */ - /** - * lints that the StructureDefinition's {@code version} and {@code date} elements - * contain DSF template placeholders {@code #{version}} and {@code #{date}} respectively. - * - * @param doc the StructureDefinition XML document - * @param file the original file (used for error messages) - * @param ref a human-readable reference derived from the file or resource URL - * @param out the list where linting results are added - */ - private void checkPlaceholders(Document doc, - File file, - String ref, - List out) - { - /* version */ - String version = val(doc, SD_XP + "/*[local-name()='version']/@value"); - if (version == null || !version.equals("#{version}")) - out.add(new FhirElementLintItem(LinterSeverity.ERROR, LintingType.STRUCTURE_DEFINITION_VERSION_NO_PLACEHOLDER, file, ref, "Version does not use placeholder: " + version)); - else - out.add(ok(file, ref, "version placeholder present")); - - /* date */ - String date = val(doc, SD_XP + "/*[local-name()='date']/@value"); - if (date == null || !date.equals("#{date}")) - out.add(new FhirElementLintItem(LinterSeverity.WARN, LintingType.STRUCTURE_DEFINITION_DATE_NO_PLACEHOLDER, file, ref, "Date does not use placeholder: " + date)); - else - out.add(ok(file, ref, "date placeholder present")); - } - /* CHECK 3: DIFF / SNAPSHOT / IDs */ /** * lints the existence of the {@code differential} element, warns if a {@code snapshot} @@ -438,22 +429,203 @@ private void checkSliceCardinality(Document doc, } } } - /* HELPERS */ + + /* CHECK 5: BINDING VALUE SET REFERENCES */ /** - * Resolves the canonical reference for issue reporting from the StructureDefinition. - * If the {@code url} element is present, it is used as reference; otherwise, - * the file name is returned. + * Validates that every {@code binding.valueSet} canonical URL referenced in the differential + * can be resolved to a {@code ValueSet} resource within the project. * - * @param doc the StructureDefinition document - * @param file the original resource file - * @return a human-readable reference string + *

    When a differential element declares a {@code binding} with a {@code strength} and a + * {@code valueSet} URL, the referenced ValueSet must exist in the project (but it could be a foreign URL). If it does not: + *

      + *
    • An WARN is reported when {@code strength = "required"}
    • + *
    • An INFO is reported for all other strengths ({@code extensible}, {@code preferred}, {@code example})
    • + *
    + * + * @param doc the StructureDefinition DOM document + * @param file the original file (used for error messages) + * @param ref a human-readable reference derived from the file or resource URL + * @param out the list where linting results are appended */ - private String determineRef(Document doc, File file) - { - String url = val(doc, SD_XP + "/*[local-name()='url']/@value"); - return blank(url) ? file.getName() : url; + private void checkBindingValueSets(Document doc, + File file, + String ref, + List out) { + NodeList elements = xp(doc, ELEMENTS_XP); + if (elements == null) return; + + File projectRoot = LintingUtils.getProjectRoot(file.toPath()); + FhirResourceLocator locator = FhirResourceLocator.create(projectRoot); + + for (int i = 0; i < elements.getLength(); i++) { + Node elem = elements.item(i); + String strength = val(elem, ".//*[local-name()='binding']/*[local-name()='strength']/@value"); + String valueSetUrl = val(elem, ".//*[local-name()='binding']/*[local-name()='valueSet']/@value"); + + if (blank(strength) || blank(valueSetUrl)) continue; + + String elemId = val(elem, "./@id"); + boolean found = locator.valueSetExists(valueSetUrl); + + if (!found) { + if ("required".equals(strength)) { + out.add(new FhirElementLintItem(LinterSeverity.WARN, + LintingType.STRUCTURE_DEFINITION_BINDING_VALUESET_UNRESOLVED, + file, ref, + "Element '" + elemId + "': binding.valueSet '" + valueSetUrl + + "' (strength=required) could not be resolved to a known ValueSet. Are you sure?")); + } else { + out.add(new FhirElementLintItem(LinterSeverity.INFO, + LintingType.STRUCTURE_DEFINITION_BINDING_VALUESET_UNRESOLVED_NON_REQUIRED, + file, ref, + "Element '" + elemId + "': binding.valueSet '" + valueSetUrl + + "' (strength=" + strength + ") could not be resolved to a known ValueSet.")); + } + } else { + out.add(ok(file, ref, + "element '" + elemId + "': binding.valueSet '" + valueSetUrl + "' resolved (OK)")); + } + } } + /* CHECK 6: fixedUri / fixedCode AGAINST PROJECT CODE SYSTEMS */ + /** + * Validates that every {@code fixedUri} declared on a {@code *.system} element within the + * differential resolves to a known CodeSystem in the project, and that the corresponding + * {@code fixedCode} (if present on the same slice's {@code *.code} element) is a code that + * actually exists in that CodeSystem. + * + *

    Both checks are reported as {@link LinterSeverity#WARN} so that downstream consumers can + * distinguish them from hard structural errors.

    + * + * @param doc the StructureDefinition DOM document + * @param file the original file (used for error messages) + * @param ref a human-readable reference derived from the file or resource URL + * @param out the list where linting results are appended + */ + private void checkFixedCodings(Document doc, + File file, + String ref, + List out) { + // Key: direct parent path of the .system / .code element + // (e.g. "Task.output:data-set-status.type.coding" for both + // "...type.coding.system" and "...type.coding.code") + // This avoids collisions when a slice has multiple .system paths + // (e.g. "type.coding.system" AND "value[x].system"). + Map systemByParentPath = new HashMap<>(); + Map codeByParentPath = new HashMap<>(); + + try { + // Elements in the differential that carry fixedUri and whose @id contains ".system" + NodeList sysElems = (NodeList) XPathFactory.newInstance().newXPath() + .compile(DIFF_ELEM_XP + "[./*[local-name()='fixedUri'] and contains(@id,'.system')]") + .evaluate(doc, XPathConstants.NODESET); + for (int i = 0; i < sysElems.getLength(); i++) { + String id = val(sysElems.item(i), "./@id"); + String fixedUri = val(sysElems.item(i), "./*[local-name()='fixedUri']/@value"); + if (id != null && fixedUri != null) { + String parentPath = extractParentPath(id); + if (parentPath != null) systemByParentPath.put(parentPath, fixedUri); + } + } + + // Elements in the differential that carry fixedCode and whose @id contains ".code" + NodeList codeElems = (NodeList) XPathFactory.newInstance().newXPath() + .compile(DIFF_ELEM_XP + "[./*[local-name()='fixedCode'] and contains(@id,'.code')]") + .evaluate(doc, XPathConstants.NODESET); + for (int i = 0; i < codeElems.getLength(); i++) { + String id = val(codeElems.item(i), "./@id"); + String fixedCode = val(codeElems.item(i), "./*[local-name()='fixedCode']/@value"); + if (id != null && fixedCode != null) { + String parentPath = extractParentPath(id); + if (parentPath != null) codeByParentPath.put(parentPath, fixedCode); + } + } + } catch (Exception e) { + return; + } + + if (systemByParentPath.isEmpty()) return; + + for (Map.Entry entry : systemByParentPath.entrySet()) { + String parentPath = entry.getKey(); + String fixedUri = entry.getValue(); + String sliceRoot = extractSliceRoot(parentPath); + + if (!FhirAuthorizationCache.containsSystem(fixedUri)) { + out.add(new FhirElementLintItem(LinterSeverity.WARN, + LintingType.STRUCTURE_DEFINITION_FIXED_URI_CODESYSTEM_NOT_FOUND, + file, ref, + "Slice '" + sliceRoot + "' (" + parentPath + "): fixedUri='" + fixedUri + + "' not found in project resources. fixedCode validation is skipped.")); + continue; + } + + String fixedCode = codeByParentPath.get(parentPath); + if (fixedCode != null) { + if (FhirAuthorizationCache.isUnknown(fixedUri, fixedCode)) { + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.STRUCTURE_DEFINITION_FIXED_CODE_NOT_IN_CODESYSTEM, + file, ref, + "Slice '" + sliceRoot + "' (" + parentPath + "): fixedCode='" + fixedCode + + "' is not a known code in CodeSystem '" + fixedUri + "'.")); + } else { + out.add(ok(file, ref, "Slice '" + sliceRoot + "' (" + parentPath + "): fixedUri/fixedCode pair valid (OK)")); + } + } else { + out.add(ok(file, ref, "Slice '" + sliceRoot + "' (" + parentPath + "): fixedUri='" + fixedUri + "' is a known CodeSystem (OK)")); + } + } + } + + /** + * Returns the direct parent path of an element ID by removing the last path segment. + *

    + * Used to pair a {@code fixedUri} on a {@code *.system} element with the matching + * {@code fixedCode} on the sibling {@code *.code} element that shares the same parent. + *

    + * Examples: + *

      + *
    • {@code "Task.input:target-endpoints.type.coding.system"} → {@code "Task.input:target-endpoints.type.coding"}
    • + *
    • {@code "Task.output:data-set-status.value[x].system"} → {@code "Task.output:data-set-status.value[x]"}
    • + *
    + * + * @param elementId the full element ID string + * @return the parent path (everything before the last {@code '.'}), or {@code null} if not applicable + */ + private static String extractParentPath(String elementId) { + if (elementId == null) return null; + int lastDot = elementId.lastIndexOf('.'); + if (lastDot < 0) return null; + return elementId.substring(0, lastDot); + } + + /** + * Extracts the slice root from a StructureDefinition element ID. + *

    + * The slice root is the portion of the element ID up to (but not including) the first + * {@code '.'} character that appears after the slice discriminator {@code ':'}. + *

    + * Examples: + *

      + *
    • {@code "Task.input:target-endpoints.type.coding.system"} → {@code "Task.input:target-endpoints"}
    • + *
    • {@code "Task.output:ping-status.value[x].system"} → {@code "Task.output:ping-status"}
    • + *
    + * + * @param elementId the full element ID string + * @return the slice root, or {@code null} if the ID has no slice discriminator + */ + private static String extractSliceRoot(String elementId) { + if (elementId == null) return null; + int colon = elementId.indexOf(':'); + if (colon < 0) return null; + int dot = elementId.indexOf('.', colon + 1); + if (dot < 0) return null; + return elementId.substring(0, dot); + } + + /* HELPERS */ + /** * Parses the given string as an unsigned integer. *

    diff --git a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirTaskLinter.java b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirTaskLinter.java index 304d4009..2262ce73 100644 --- a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirTaskLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirTaskLinter.java @@ -144,7 +144,13 @@ *

  • {@link LintingType#FHIR_TASK_INPUT_INSTANCE_COUNT_EXCEEDS_MAX} – too many {@code Task.input} elements
  • *
  • {@link LintingType#FHIR_TASK_INPUT_SLICE_COUNT_BELOW_SLICE_MIN} – slice occurrence below minimum
  • *
  • {@link LintingType#FHIR_TASK_INPUT_SLICE_COUNT_EXCEEDS_SLICE_MAX} – slice occurrence exceeds maximum
  • - *
  • {@link LintingType#FHIR_TASK_UNKNOWN_CODE} – unknown terminology code
  • + *
  • {@link LintingType#FHIR_TASK_UNKNOWN_CODE} – unknown terminology code (non-input codings)
  • + *
  • {@link LintingType#FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN} – {@code Task.input.type.coding.system} not a known CodeSystem URI
  • + *
  • {@link LintingType#FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET} – {@code Task.input.type.coding.system} not allowed by the expected ValueSet binding context
  • + *
  • {@link LintingType#FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM} – {@code Task.input.type.coding.code} unknown in the specified CodeSystem
  • + *
  • {@link LintingType#FHIR_TASK_INPUT_FIXED_URI_MISMATCH} – {@code Task.input.type.coding.system} does not match the {@code fixedUri} declared in the StructureDefinition slice
  • + *
  • {@link LintingType#FHIR_TASK_INPUT_FIXED_CODE_MISMATCH} – {@code Task.input.type.coding.code} does not match the {@code fixedCode} declared in the StructureDefinition slice
  • + *
  • {@link LintingType#FHIR_TASK_INPUT_PAIR_NOT_ALLOWED_BY_SD} – {@code (system, code)} pair not defined by any {@code fixedUri}/{@code fixedCode} constraint in the StructureDefinition
  • *
  • {@link LintingType#FHIR_TASK_COULD_NOT_LOAD_PROFILE} – StructureDefinition could not be loaded (warning)
  • * *

    Successful validations are reported with {@link LinterSeverity#INFO} for completeness and traceability.

    @@ -177,7 +183,7 @@ public final class FhirTaskLinter extends AbstractFhirInstanceLinter { private static final String INPUT_XP = TASK_XP + "/*[local-name()='input']"; private static final String CODING_SYS_XP = "./*[local-name()='type']/*[local-name()='coding']/*[local-name()='system']/@value"; private static final String CODING_CODE_XP = "./*[local-name()='type']/*[local-name()='coding']/*[local-name()='code']/@value"; - private static final String SYSTEM_BPMN_MSG = "http://dsf.dev/fhir/CodeSystem/bpmn-message"; + private static final String SYSTEM_BPMN_MSG = FhirAuthorizationCache.CS_BPMN_MESSAGE; private static final String SYSTEM_ORG_ID = "http://dsf.dev/sid/organization-identifier"; private static final String TASK_IDENTIFIER_SID = "http://dsf.dev/sid/task-identifier"; private static final Set STATUSES_NEED_BIZKEY = Set.of("in-progress", "completed", "failed"); @@ -211,10 +217,25 @@ public List lint(Document doc, File resFile) { final List issues = new ArrayList<>(); checkMetaAndBasic(doc, resFile, ref, issues); - checkPlaceholders(doc, resFile, ref, issues); + checkPlaceholder(doc, TASK_XP + "/*[local-name()='authoredOn']/@value", "#{date}", + false, true, LinterSeverity.WARN, LintingType.FHIR_TASK_DATE_NO_PLACEHOLDER, + resFile, ref, " must contain '#{date}'.", " placeholder OK.", issues); + checkPlaceholder(doc, TASK_XP + "/*[local-name()='requester']/*[local-name()='identifier']/*[local-name()='value']/@value", "#{organization}", + true, false, LinterSeverity.WARN, LintingType.FHIR_TASK_REQUESTER_ORGANIZATION_NO_PLACEHOLDER, + resFile, ref, "requester.identifier.value must contain '#{organization}'.", "requester.identifier.value placeholder OK.", issues); + checkPlaceholder(doc, TASK_XP + "/*[local-name()='restriction']/*[local-name()='recipient']/*[local-name()='identifier']/*[local-name()='value']/@value", "#{organization}", + true, false, LinterSeverity.WARN, LintingType.FHIR_TASK_RECIPIENT_ORGANIZATION_NO_PLACEHOLDER, + resFile, ref, "restriction.recipient.identifier.value must contain '#{organization}'.", "restriction.recipient.identifier.value placeholder OK.", issues); lintTaskIdentifier(doc, resFile, ref, issues); - lintInputs(doc, resFile, ref, issues); + + // Load slice metadata once and reuse for structural + terminology checks + String profileUrl = val(doc, TASK_XP + "/*[local-name()='meta']/*[local-name()='profile']/@value"); + Map cards = loadInputCardinality(determineProjectRoot(resFile), profileUrl); + + lintInputs(doc, resFile, ref, issues, cards); + lintFixedConstraints(doc, resFile, ref, issues, cards); lintTerminology(doc, resFile, ref, issues); + lintInputTypeCodingTerminology(doc, resFile, ref, issues, cards); lintRequesterAuthorization(doc, resFile, ref, issues); lintRecipientAuthorization(doc, resFile, ref, issues); @@ -222,11 +243,8 @@ public List lint(Document doc, File resFile) { } private void checkMetaAndBasic(Document doc, File f, String ref, List out) { - NodeList prof = xp(doc, TASK_XP + "/*[local-name()='meta']/*[local-name()='profile']/@value"); - if (prof == null || prof.getLength() == 0) - out.add(FhirElementLintItem.of(LinterSeverity.ERROR, LintingType.FHIR_TASK_MISSING_PROFILE, f, ref)); - else - out.add(ok(f, ref, "meta.profile present.")); + checkRequiredValue(doc, TASK_XP + "/*[local-name()='meta']/*[local-name()='profile']/@value", + f, ref, LintingType.FHIR_TASK_MISSING_PROFILE, "meta.profile present.", out); String instCanon = val(doc, TASK_XP + "/*[local-name()='instantiatesCanonical']/@value"); if (blank(instCanon)) @@ -289,29 +307,6 @@ else if (!SYSTEM_ORG_ID.equals(recSys)) out.add(ok(f, ref, "restriction.recipient.identifier.system OK")); } - private void checkPlaceholders(Document doc, File f, String ref, List out) { - String authoredOn = val(doc, TASK_XP + "/*[local-name()='authoredOn']/@value"); - if (authoredOn != null && !authoredOn.contains("#{date}")) - out.add(new FhirElementLintItem(LinterSeverity.WARN, LintingType.FHIR_TASK_DATE_NO_PLACEHOLDER, f, ref, - " must contain '#{date}'.")); - else - out.add(ok(f, ref, " placeholder OK.")); - - String reqIdVal = val(doc, TASK_XP + "/*[local-name()='requester']/*[local-name()='identifier']/*[local-name()='value']/@value"); - if (reqIdVal == null || !reqIdVal.equals("#{organization}")) - out.add(new FhirElementLintItem(LinterSeverity.WARN, LintingType.FHIR_TASK_REQUESTER_ORGANIZATION_NO_PLACEHOLDER, f, ref, - "requester.identifier.value must contain '#{organization}'.")); - else - out.add(ok(f, ref, "requester.identifier.value placeholder OK.")); - - String recIdVal = val(doc, TASK_XP + "/*[local-name()='restriction']/*[local-name()='recipient']/*[local-name()='identifier']/*[local-name()='value']/@value"); - if (recIdVal == null || !recIdVal.equals("#{organization}")) - out.add(new FhirElementLintItem(LinterSeverity.WARN, LintingType.FHIR_TASK_RECIPIENT_ORGANIZATION_NO_PLACEHOLDER, f, ref, - "restriction.recipient.identifier.value must contain '#{organization}'.")); - else - out.add(ok(f, ref, "restriction.recipient.identifier.value placeholder OK.")); - } - /** * Validates Task identifier with system 'http://dsf.dev/sid/task-identifier'. * @@ -370,11 +365,9 @@ private void lintTaskIdentifier(Document doc, File f, String ref, List out) { - String profileUrl = val(doc, TASK_XP + "/*[local-name()='meta']/*[local-name()='profile']/@value"); - Map cards = loadInputCardinality(determineProjectRoot(f), profileUrl); - + private void lintInputs(Document doc, File f, String ref, List out, Map cards) { if (cards == null) { + String profileUrl = val(doc, TASK_XP + "/*[local-name()='meta']/*[local-name()='profile']/@value"); out.add(new FhirElementLintItem(LinterSeverity.WARN, LintingType.FHIR_TASK_COULD_NOT_LOAD_PROFILE, f, ref, "StructureDefinition for profile '" + profileUrl + "' not found → cardinality check skipped.")); } @@ -504,11 +497,23 @@ else if (cnt > card.max()) } } + /** + * Generic terminology check for all {@code coding} nodes in the Task document + * except {@code Task.input.type.coding} entries, which are validated + * separately with granular error types in {@link #lintInputTypeCodingTerminology}. + */ private void lintTerminology(Document doc, File f, String ref, List out) { NodeList codings = xp(doc, "//coding"); if (codings == null) return; for (int i = 0; i < codings.getLength(); i++) { Node c = codings.item(i); + // Skip Task.input.type.coding — handled by lintInputTypeCodingTerminology + Node parent = c.getParentNode(); + if (parent != null && "type".equals(parent.getLocalName())) { + Node grandParent = parent.getParentNode(); + if (grandParent != null && "input".equals(grandParent.getLocalName())) + continue; + } String sys = val(c, "./*[local-name()='system']/@value"); String code = val(c, "./*[local-name()='code']/@value"); if (FhirAuthorizationCache.isUnknown(sys, code)) @@ -517,6 +522,271 @@ private void lintTerminology(Document doc, File f, String ref, ListThree distinct checks are performed per input. Later checks depend on earlier ones:

    + *
      + *
    1. System known – {@code coding.system} must be registered in + * {@link FhirAuthorizationCache} (i.e., correspond to a loaded CodeSystem resource). + * If unknown, {@link LintingType#FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN} is emitted + * and further checks for that input are skipped.
    2. + *
    3. System in expected ValueSet context – driven by the profile's + * StructureDefinition: + *
        + *
      • If the matching slice declares a {@code fixedUri} at + * {@code Task.input:sliceName.type.coding.system}, the input's system must + * equal it literally.
      • + *
      • Else, if the slice declares a {@code binding.valueSet} and that ValueSet is + * loaded, the input's system must appear in that ValueSet's + * {@code compose.include.system}.
      • + *
      • Else, if the binding context cannot be resolved, validation fails explicitly + * (no permissive fallback to unrelated ValueSets).
      • + *
      + * On mismatch, {@link LintingType#FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET} is emitted.
    4. + *
    5. Code valid for system – {@code coding.code} must be a known code + * under the given {@code coding.system}. Only executed if Check 2 passed. + * If not, {@link LintingType#FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM} is emitted.
    6. + *
    + * + *

    Inputs that already failed structural validation (missing system or code) in + * {@link #lintInputs} are skipped here to avoid duplicate reporting.

    + * + * @param cards per-slice cardinality and binding metadata from the StructureDefinition + * (may be {@code null} if the profile could not be loaded) + */ + private void lintInputTypeCodingTerminology(Document doc, File f, String ref, + List out, + Map cards) { + NodeList inputs = xp(doc, INPUT_XP); + if (inputs == null || inputs.getLength() == 0) return; + + for (int i = 0; i < inputs.getLength(); i++) { + Node in = inputs.item(i); + String sys = val(in, CODING_SYS_XP); + String code = val(in, CODING_CODE_XP); + + // Structural errors (missing system/code) are already reported by lintInputs + if (blank(sys) || blank(code)) continue; + + // Check 1: coding.system must be a known CodeSystem + if (!FhirAuthorizationCache.containsSystem(sys)) { + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN, f, ref, + "Task.input.type.coding.system '" + sys + "' was not found on the classpath or the project directory.")); + continue; // checks 2 and 3 require the system to be known + } + + // Check 2: coding.system must match the expected ValueSet context for this slice + SliceCard slice = findSliceByCode(cards, code); + if (!isSystemAllowedByBinding(slice, sys, out, f, ref)) { + // Check 3 is only executed when Check 2 passed. + continue; + } + + // Check 3: coding.code must be a valid code in the given system + if (FhirAuthorizationCache.isUnknown(sys, code)) { + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM, f, ref, + "Task.input.type.coding.code '" + code + "' is unknown in CodeSystem '" + sys + "'.")); + continue; + } + + out.add(ok(f, ref, "Task.input.type.coding: system='" + sys + "' code='" + code + "' OK.")); + } + } + + /** + * Locates the slice metadata that matches the given input code. + * + *

    Match strategy (first hit wins):

    + *
      + *
    1. Slice whose {@code fixedCode} at + * {@code Task.input:sliceName.type.coding.code} equals {@code inputCode}.
    2. + *
    3. Slice whose map key (slice name) equals {@code inputCode} - DSF convention + * where slice names mirror the code value (e.g., {@code message-name}).
    4. + *
    + * + * @param cards map of slice metadata loaded from the StructureDefinition, may be {@code null} + * @param inputCode value of {@code Task.input.type.coding.code} for the current input + * @return the matching {@link SliceCard}, or {@code null} if no slice matches + */ + private SliceCard findSliceByCode(Map cards, String inputCode) { + if (cards == null || inputCode == null) return null; + for (Map.Entry e : cards.entrySet()) { + if ("__BASE__".equals(e.getKey())) continue; + SliceCard c = e.getValue(); + if (inputCode.equals(c.fixedCode())) return c; + } + SliceCard byName = cards.get(inputCode); + return (byName != null && !"__BASE__".equals(inputCode)) ? byName : null; + } + + /** + * Binding-driven evaluation of Check 2. + * + *

    The decision order reflects how tightly the profile constrains the system:

    + *
      + *
    1. fixedUri on {@code .type.coding.system} – strict literal comparison. + * A mismatch is silently deferred to {@link #lintFixedConstraints}, which reports it + * with the more specific {@link LintingType#FHIR_TASK_INPUT_FIXED_URI_MISMATCH} error + * type. No duplicate error is emitted here.
    2. + *
    3. binding.valueSet resolvable in the cache - input system must be + * listed in the ValueSet's {@code compose.include.system}.
    4. + *
    5. binding.valueSet declared but not loaded - explicit validation + * failure because the expected context cannot be resolved.
    6. + *
    7. No binding info available - explicit validation failure because no expected + * ValueSet context is available.
    8. + *
    + * + *

    On mismatch in cases 2–4, a {@link LintingType#FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET} + * error is appended to {@code out} and {@code false} is returned.

    + * + * @return {@code true} if the system is accepted by the resolved binding context, + * {@code false} otherwise + */ + private boolean isSystemAllowedByBinding(SliceCard slice, String sys, + List out, File f, String ref) { + // BPMN message inputs are validated in lintInputs(); base-profile constraints are not in derived SD slices. + if (SYSTEM_BPMN_MSG.equals(sys) && slice != null) { + return true; + } + + // 1. Strict fixedUri match on Task.input:slice.type.coding.system. + // A mismatch is already reported by lintFixedConstraints() as FHIR_TASK_INPUT_FIXED_URI_MISMATCH. + // Emitting a second error here would duplicate the report, so we return false silently. + if (slice != null && slice.fixedSystem() != null && !slice.fixedSystem().isBlank()) { + return sys.equals(slice.fixedSystem()); + } + + // 2. binding.valueSet declared on the slice + if (slice != null && slice.bindingValueSet() != null && !slice.bindingValueSet().isBlank()) { + String vsUrl = slice.bindingValueSet(); + if (FhirAuthorizationCache.isValueSetLoaded(vsUrl)) { + if (FhirAuthorizationCache.getSystemsInValueSet(vsUrl).contains(sys)) return true; + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET, f, ref, + "Task.input.type.coding.system '" + sys + + "' is not referenced by the bound ValueSet '" + vsUrl + "'.")); + return false; + } + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET, f, ref, + "Task.input.type.coding.system '" + sys + + "' cannot be validated against binding ValueSet '" + vsUrl + + "' because that ValueSet is not loaded.")); + return false; + } + + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET, f, ref, + "Task.input.type.coding.system '" + sys + + "' has no resolvable expected ValueSet context (missing fixedUri and binding.valueSet).")); + return false; + } + + /** + * Validates {@code Task.input} coding entries against {@code fixedUri}/{@code fixedCode} + * constraints declared in the Task profile's StructureDefinition. + * + *

    Validation direction

    + *

    The check is intentionally Task → StructureDefinition:

    + *
      + *
    1. Read all actual {@code (system, code)} pairs from {@code Task.input}.
    2. + *
    3. Verify each pair against allowed pairs from the referenced StructureDefinition.
    4. + *
    + * + *

    What is checked

    + *
      + *
    1. If an actual Task input code exists in SD constraints but with a different system + * → {@link LintingType#FHIR_TASK_INPUT_FIXED_URI_MISMATCH}
    2. + *
    3. If an actual Task input system exists in SD constraints but with a different code + * → {@link LintingType#FHIR_TASK_INPUT_FIXED_CODE_MISMATCH}
    4. + *
    5. If an actual Task input {@code (system, code)} pair is not defined by any SD + * fixedUri/fixedCode constraint (excluding BPMN message inputs) + * → {@link LintingType#FHIR_TASK_INPUT_PAIR_NOT_ALLOWED_BY_SD}
    6. + *
    + * + * @param doc the Task DOM document + * @param f the Task resource file + * @param ref a human-readable reference (e.g. instantiatesCanonical) + * @param out the list where linting results are appended + * @param cards per-slice cardinality and constraint metadata; if {@code null} or empty, + * no fixedUri/fixedCode constraints are available and the check is skipped + */ + private void lintFixedConstraints(Document doc, File f, String ref, + List out, + Map cards) { + if (cards == null || cards.isEmpty()) return; + NodeList ins = xp(doc, INPUT_XP); + if (ins == null || ins.getLength() == 0) return; + Map> expectedSystemsByCode = new HashMap<>(); + Map> expectedCodesBySystem = new HashMap<>(); + Set allowedPairs = new HashSet<>(); + for (Map.Entry entry : cards.entrySet()) { + if ("__BASE__".equals(entry.getKey())) continue; + SliceCard card = entry.getValue(); + if (card.fixedSystem() == null || card.fixedCode() == null) continue; + expectedSystemsByCode.computeIfAbsent(card.fixedCode(), k -> new HashSet<>()).add(card.fixedSystem()); + expectedCodesBySystem.computeIfAbsent(card.fixedSystem(), k -> new HashSet<>()).add(card.fixedCode()); + allowedPairs.add(card.fixedSystem() + "#" + card.fixedCode()); + } + if (allowedPairs.isEmpty()) return; + + List actualPairs = new ArrayList<>(); + for (int i = 0; i < ins.getLength(); i++) { + Node in = ins.item(i); + String sys = val(in, CODING_SYS_XP); + String code = val(in, CODING_CODE_XP); + if (!blank(sys) || !blank(code)) + actualPairs.add(new String[]{sys, code}); + } + + Set reported = new HashSet<>(); + for (String[] actual : actualPairs) { + String actualSys = actual[0]; + String actualCode = actual[1]; + if (blank(actualSys) || blank(actualCode)) continue; + if (allowedPairs.contains(actualSys + "#" + actualCode)) continue; + + Set expectedSystems = expectedSystemsByCode.get(actualCode); + if (expectedSystems != null && !expectedSystems.contains(actualSys)) { + String key = "uri|" + actualSys + "|" + actualCode; + if (reported.add(key)) { + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_FIXED_URI_MISMATCH, f, ref, + "Task.input with code='" + actualCode + "': system='" + actualSys + + "' does not match expected fixedUri(s)=" + expectedSystems + ".")); + } + continue; + } + + Set expectedCodes = expectedCodesBySystem.get(actualSys); + if (expectedCodes != null && !expectedCodes.contains(actualCode)) { + String key = "code|" + actualSys + "|" + actualCode; + if (reported.add(key)) { + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_FIXED_CODE_MISMATCH, f, ref, + "Task.input with system='" + actualSys + "': code='" + actualCode + + "' does not match expected fixedCode(s)=" + expectedCodes + ".")); + } + continue; + } + + // pair is completely unrecognized by the SD's fixedUri/fixedCode constraints. + // Exclude bpmn-message inputs as those are validated separately in lintInputs(). + if (!SYSTEM_BPMN_MSG.equals(actualSys)) { + String key = "unallowed|" + actualSys + "|" + actualCode; + if (reported.add(key)) { + out.add(new FhirElementLintItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_INPUT_PAIR_NOT_ALLOWED_BY_SD, f, ref, + "Task.input pair (system='" + actualSys + "', code='" + actualCode + + "') is not defined by any fixedUri/fixedCode constraint in the StructureDefinition.")); + } + } + } + } + private String computeReference(Document doc, File file) { String canon = val(doc, TASK_XP + "/*[local-name()='instantiatesCanonical']/@value"); if (!blank(canon)) return canon.split("\\|")[0]; @@ -555,15 +825,34 @@ private boolean instCanonDetermine(Document taskDoc, File taskFile) { if (blank(instCanon)) return true; File projectRoot = determineProjectRoot(taskFile); FhirResourceLocator locator = FhirResourceLocator.create(projectRoot); - File actFile = locator.findActivityDefinitionForInstantiatesCanonical(instCanon, projectRoot); + File actFile = locator.findActivityDefinitionForInstantiatesCanonical(instCanon); return actFile == null; } - private record SliceCard(int min, int max) {} + /** + * Cardinality and binding metadata for a {@code Task.input} slice extracted from the + * profile's StructureDefinition. + * + * @param min minimum occurrences of the slice ({@code element.min}) + * @param max maximum occurrences of the slice ({@code element.max}; {@code *} maps to {@link Integer#MAX_VALUE}) + * @param fixedSystem value of {@code fixedUri} at + * {@code Task.input:sliceName.type.coding.system}, if present + * @param fixedCode value of {@code fixedCode} at + * {@code Task.input:sliceName.type.coding.code}, if present + * @param bindingValueSet canonical URL of the ValueSet bound to the slice's + * {@code Task.input:sliceName.type[.coding]}, if declared + */ + private record SliceCard(int min, int max, + String fixedSystem, String fixedCode, + String bindingValueSet) { + static SliceCard cardinalityOnly(int min, int max) { + return new SliceCard(min, max, null, null, null); + } + } private Map loadInputCardinality(File projectRoot, String profileUrl) { FhirResourceLocator locator = FhirResourceLocator.create(projectRoot); - File sdFile = locator.findStructureDefinitionFile(profileUrl, projectRoot); + File sdFile = locator.findStructureDefinitionFile(profileUrl); if (sdFile == null) return null; try { Document sd; @@ -575,10 +864,12 @@ private Map loadInputCardinality(File projectRoot, String pro String maxBase = AbstractFhirInstanceLinter.extractSingleNodeValue(sd, "//*[local-name()='element' and @id='Task.input']/*[local-name()='max']/@value"); int baseMin = (minBase != null) ? Integer.parseInt(minBase) : 0; int baseMax = (maxBase == null || "*".equals(maxBase)) ? Integer.MAX_VALUE : Integer.parseInt(maxBase); - map.put("__BASE__", new SliceCard(baseMin, baseMax)); + map.put("__BASE__", SliceCard.cardinalityOnly(baseMin, baseMax)); + // Slice root elements use ids like Task.input:message-name; nested elements add dots after the slice name + // (e.g. Task.input:message-name.value[x]). Do not use not(contains(@id,'.')) — that excludes all Task.input:* ids. NodeList slices = (NodeList) XPathFactory.newInstance().newXPath() - .compile("//*[local-name()='element' and starts-with(@id,'Task.input:') and not(contains(@id,'.'))]") + .compile("//*[local-name()='element' and starts-with(@id,'Task.input:') and not(contains(substring-after(@id,'Task.input:'), '.'))]") .evaluate(sd, XPathConstants.NODESET); for (int i = 0; i < slices.getLength(); i++) { Node n = slices.item(i); @@ -587,7 +878,31 @@ private Map loadInputCardinality(File projectRoot, String pro String ma = AbstractFhirInstanceLinter.extractSingleNodeValue(n, "./*[local-name()='max']/@value"); int sMin = (mi != null) ? Integer.parseInt(mi) : 0; int sMax = (ma == null || "*".equals(ma)) ? baseMax : Integer.parseInt(ma); - map.put(sliceName, new SliceCard(sMin, sMax)); + + String codingId = "Task.input:" + sliceName + ".type.coding"; + String typeId = "Task.input:" + sliceName + ".type"; + String codingSystemId = codingId + ".system"; + String codingCodeId = codingId + ".code"; + + // fixed constraints on .type.coding.system / .type.coding.code + String fixedSystem = AbstractFhirInstanceLinter.extractSingleNodeValue(sd, + "//*[local-name()='element' and @id='" + codingSystemId + "']" + + "/*[local-name()='fixedUri']/@value"); + String fixedCode = AbstractFhirInstanceLinter.extractSingleNodeValue(sd, + "//*[local-name()='element' and @id='" + codingCodeId + "']" + + "/*[local-name()='fixedCode']/@value"); + + // binding.valueSet: prefer .type, fall back to .type.coding + String binding = AbstractFhirInstanceLinter.extractSingleNodeValue(sd, + "//*[local-name()='element' and @id='" + typeId + "']" + + "/*[local-name()='binding']/*[local-name()='valueSet']/@value"); + if (binding == null || binding.isBlank()) { + binding = AbstractFhirInstanceLinter.extractSingleNodeValue(sd, + "//*[local-name()='element' and @id='" + codingId + "']" + + "/*[local-name()='binding']/*[local-name()='valueSet']/@value"); + } + + map.put(sliceName, new SliceCard(sMin, sMax, fixedSystem, fixedCode, binding)); } return map; } catch (Exception e) { return null; } diff --git a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirValueSetLinter.java b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirValueSetLinter.java index a5e552bf..20bf2d35 100644 --- a/linter-core/src/main/java/dev/dsf/linter/fhir/FhirValueSetLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/fhir/FhirValueSetLinter.java @@ -150,6 +150,7 @@ public final class FhirValueSetLinter extends AbstractFhirInstanceLinter /** Shared XPath factory instance for creating XPath expressions. */ private static final XPathFactory XPATH_FACTORY = XPathFactory.newInstance(); + private static final Set VALID_READ_ACCESS_CODES = Set.of("ALL", "LOCAL"); /* --- API */ @@ -182,11 +183,23 @@ public boolean canLint(Document d) @Override public List lint(Document doc, File resFile) { - final String ref = computeReference(doc, resFile); + final String ref = resolveReference(doc, resFile, VS_XP + "/*[local-name()='url']/@value"); final List issues = new ArrayList<>(); checkMetaAndBasic(doc, resFile, ref, issues); - checkPlaceholders(doc, resFile, ref, issues); + checkVersionDatePlaceholders( + doc, + VS_XP + "/*[local-name()='version']/@value", + VS_XP + "/*[local-name()='date']/@value", + resFile, + ref, + LintingType.FHIR_VALUE_SET_VERSION_NO_PLACEHOLDER, + LintingType.FHIR_VALUE_SET_DATE_NO_PLACEHOLDER, + " must contain '#{version}'.", + " must contain '#{date}'.", + "version placeholder OK.", + "date placeholder OK.", + issues); lintComposeIncludes(doc, resFile, ref, issues); return issues; @@ -220,19 +233,7 @@ private void checkMetaAndBasic(Document doc, try { NodeList tagElements = (NodeList) XPATH_FACTORY.newXPath() .evaluate(META_TAGS_XP, doc, XPathConstants.NODESET); - boolean hasAllOrLocal = false; - for (int i = 0; i < tagElements.getLength(); i++) - { - Node tag = tagElements.item(i); - String sys = val(tag, "./*[local-name()='system']/@value"); - String code = val(tag, "./*[local-name()='code']/@value"); - if (TAG_SYSTEM_READ_ACCESS.equals(sys) - && ("ALL".equals(code) || "LOCAL".equals(code))) - { - hasAllOrLocal = true; - break; - } - } + boolean hasAllOrLocal = hasAllowedTag(tagElements, TAG_SYSTEM_READ_ACCESS, VALID_READ_ACCESS_CODES); if (!hasAllOrLocal) out.add(new FhirElementLintItem(LinterSeverity.ERROR, LintingType.FHIR_VALUE_SET_MISSING_READ_ACCESS_TAG_ALL_OR_LOCAL, res, ref, "meta.tag must contain at least one read-access-tag with code 'ALL' or 'LOCAL'")); @@ -247,40 +248,20 @@ private void checkMetaAndBasic(Document doc, // lint organization role codes lintOrganizationRoleCodes(doc, res, ref, out); - // url String url = val(doc, VS_XP + "/*[local-name()='url']/@value"); if (blank(url)) out.add(FhirElementLintItem.of(LinterSeverity.ERROR, LintingType.FHIR_VALUE_SET_MISSING_URL, res, ref)); else out.add(ok(res, ref, "url = '" + url + "'")); - // name - String name = val(doc, VS_XP + "/*[local-name()='name']/@value"); - if (blank(name)) - out.add(FhirElementLintItem.of(LinterSeverity.ERROR, LintingType.FHIR_VALUE_SET_MISSING_NAME, res, ref)); - else - out.add(ok(res, ref, "name OK")); - - // title - String title = val(doc, VS_XP + "/*[local-name()='title']/@value"); - if (blank(title)) - out.add(FhirElementLintItem.of(LinterSeverity.ERROR, LintingType.FHIR_VALUE_SET_MISSING_TITLE, res, ref)); - else - out.add(ok(res, ref, "title OK")); - - // publisher - String publisher = val(doc, VS_XP + "/*[local-name()='publisher']/@value"); - if (blank(publisher)) - out.add(FhirElementLintItem.of(LinterSeverity.ERROR, LintingType.FHIR_VALUE_SET_MISSING_PUBLISHER, res, ref)); - else - out.add(ok(res, ref, "publisher OK")); - - // description - String desc = val(doc, VS_XP + "/*[local-name()='description']/@value"); - if (blank(desc)) - out.add(FhirElementLintItem.of(LinterSeverity.ERROR, LintingType.FHIR_VALUE_SET_MISSING_DESCRIPTION, res, ref)); - else - out.add(ok(res, ref, "description OK")); + checkRequiredValue(doc, VS_XP + "/*[local-name()='name']/@value", res, ref, + LintingType.FHIR_VALUE_SET_MISSING_NAME, "name OK", out); + checkRequiredValue(doc, VS_XP + "/*[local-name()='title']/@value", res, ref, + LintingType.FHIR_VALUE_SET_MISSING_TITLE, "title OK", out); + checkRequiredValue(doc, VS_XP + "/*[local-name()='publisher']/@value", res, ref, + LintingType.FHIR_VALUE_SET_MISSING_PUBLISHER, "publisher OK", out); + checkRequiredValue(doc, VS_XP + "/*[local-name()='description']/@value", res, ref, + LintingType.FHIR_VALUE_SET_MISSING_DESCRIPTION, "description OK", out); } /** @@ -330,47 +311,6 @@ private void lintOrganizationRoleCodes(Document doc, } } - /* 2) Placeholder checks */ - - /** - * Checks for required placeholders in version and date fields. - * - *

    lints that:

    - *
      - *
    • The version element contains exactly '#{version}'
    • - *
    • The date element contains exactly '#{date}'
    • - *
    - * - *

    These placeholders are required by the DSF template system and will be - * replaced with actual values during deployment.

    - * - * @param doc the ValueSet XML document to lint, must not be null - * @param res the file reference for error reporting, must not be null - * @param ref a human-readable reference for reporting, must not be null - * @param out the list to which linting results are added, must not be null - */ - private void checkPlaceholders(Document doc, - File res, - String ref, - List out) - { - // version → #{version} - String version = val(doc, VS_XP + "/*[local-name()='version']/@value"); - if (version == null || !version.equals("#{version}")) - out.add(new FhirElementLintItem(LinterSeverity.ERROR, LintingType.FHIR_VALUE_SET_VERSION_NO_PLACEHOLDER, - res, ref, " must contain '#{version}'.")); - else - out.add(ok(res, ref, "version placeholder OK." )); - - // date → #{date} - String date = val(doc, VS_XP + "/*[local-name()='date']/@value"); - if (date == null || !date.equals("#{date}")) - out.add(new FhirElementLintItem(LinterSeverity.WARN, LintingType.FHIR_VALUE_SET_DATE_NO_PLACEHOLDER, - res, ref, " must contain '#{date}'.")); - else - out.add(ok(res, ref, "date placeholder OK.")); - } - /* 3) Compose/include */ /** @@ -511,22 +451,4 @@ private void lintCodeInAlternateSystems(File res, String ref, Listurl element of the ValueSet; falls back to the file name. - * - *

    This method extracts the ValueSet's canonical URL for use in linter - * messages. If no URL is present, it uses the filename as a fallback identifier.

    - * - * @param doc the ValueSet XML document, must not be null - * @param file the file reference, must not be null - * @return the ValueSet url or the file name if url is not present, never null - */ - private String computeReference(Document doc, File file) - { - String url = val(doc, VS_XP + "/*[local-name()='url']/@value"); - return !blank(url) ? url : file.getName(); - } } diff --git a/linter-core/src/main/java/dev/dsf/linter/input/JarHandler.java b/linter-core/src/main/java/dev/dsf/linter/input/JarHandler.java index 91b3562b..216d6790 100644 --- a/linter-core/src/main/java/dev/dsf/linter/input/JarHandler.java +++ b/linter-core/src/main/java/dev/dsf/linter/input/JarHandler.java @@ -3,6 +3,7 @@ import dev.dsf.linter.logger.Logger; import java.io.*; +import java.net.URI; import java.net.URL; import java.net.URLConnection; import java.nio.file.*; @@ -146,9 +147,21 @@ public JarProcessingResult processJar(String jarPath, boolean isRemote) // Cleanup on failure deleteDirectoryRecursively(extractDir); throw e; + } finally { + // For remote JARs the downloaded temp file is no longer needed after extraction + if (isRemote) { + try { + Files.deleteIfExists(jarFile); + } catch (IOException _deleteEx) { + logger.warn("Could not delete temporary download file: " + jarFile + ": " + _deleteEx.getMessage()); + } + } } - return new JarProcessingResult(extractDir, jarName, true); + // Remote JARs are kept on disk so subsequent runs can be inspected and the + // directory is reused (overwritten) on the next invocation with the same URL. + // Local JAR extraction directories are temporary and cleaned up after linting. + return new JarProcessingResult(extractDir, jarName, !isRemote); } /** @@ -200,7 +213,7 @@ private void extractJarContents(Path jarFile, Path extractDir) throws IOExceptio * @throws IOException if download fails or connection times out */ private Path downloadJar(String urlString) throws IOException { - URL url = new URL(urlString); + URL url = java.net.URI.create(urlString).toURL(); String jarName = extractJarNameFromUrl(urlString); Path tempJar = Files.createTempFile("dsf-linter-jar-", "-" + jarName); diff --git a/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java b/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java index 374b8d21..8fb404ff 100644 --- a/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java +++ b/linter-core/src/main/java/dev/dsf/linter/output/LintingType.java @@ -34,24 +34,31 @@ public enum LintingType { BPMN_MESSAGE_SEND_TASK_IMPLEMENTATION_CLASS_NOT_FOUND("Message send task implementation class not found."), // ==================== BPMN SEND TASK ==================== + BPMN_SEND_TASK_NAME_EMPTY("Send task name is empty."), BPMN_SEND_TASK_NO_INTERFACE_CLASS_IMPLEMENTING("Send task implementation class does not implement required interface."), BPMN_SEND_TASK_IMPLEMENTATION_CLASS_NOT_EXTENDING_ABSTRACT_TASK_MESSAGE_SEND("Send task implementation class does not extend AbstractTaskMessageSend."), + // ==================== BPMN RECEIVE TASK ==================== + BPMN_RECEIVE_TASK_NAME_EMPTY("Receive task name is empty."), + BPMN_RECEIVE_TASK_MESSAGE_NAME_EMPTY("Receive task message name is empty."), + // ==================== BPMN FLOW ==================== BPMN_MESSAGE_START_EVENT_NOT_FOUND("Message start event not found."), + BPMN_MESSAGE_START_EVENT_NAME_EMPTY("Message start event name is empty."), BPMN_MESSAGE_START_EVENT_MESSAGE_NAME_EMPTY("Message start event message name is empty."), BPMN_FLOATING_ELEMENT("BPMN element is outside of message start event triggered flow."), BPMN_FLOW_ELEMENT("BPMN flow element issue."), // ==================== BPMN EVENTS ==================== - BPMN_EVENT_NAME_EMPTY("Event name is empty."), + BPMN_MESSAGE_END_EVENT_NAME_EMPTY("Message end event name is empty."), + BPMN_MESSAGE_INTERMEDIATE_THROW_EVENT_NAME_EMPTY("Message intermediate throw event name is empty."), BPMN_ERROR_BOUNDARY_EVENT_ERROR_CODE_EMPTY("Error boundary event error code is empty."), BPMN_ERROR_BOUNDARY_EVENT_ERROR_CODE_VARIABLE_EMPTY("Error boundary event error code variable is empty."), BPMN_ERROR_BOUNDARY_EVENT_ERROR_NAME_EMPTY("Error boundary event error name is empty."), BPMN_ERROR_BOUNDARY_EVENT_NAME_EMPTY("Error boundary event name is empty."), - BPMN_MESSAGE_INTERMEDIATE_THROW_EVENT_HAS_MESSAGE("Message intermediate throw event has message."), - BPMN_START_EVENT_NOT_PART_OF_SUB_PROCESS("Start event is not part of subprocess."), - BPMN_END_EVENT_NOT_PART_OF_SUB_PROCESS("End event is not part of subprocess."), + BPMN_MESSAGE_INTERMEDIATE_THROW_EVENT_HAS_MESSAGE_REFERENCE("Message intermediate throw event has message reference."), + BPMN_START_EVENT_NOT_PART_OF_SUB_PROCESS_AND_HAS_NO_NAME("Start event is not part of subprocess and has no name."), + BPMN_END_EVENT_NOT_PART_OF_SUB_PROCESS_AND_HAS_NO_NAME("End event has no name and is not part of subprocess."), BPMN_MESSAGE_INTERMEDIATE_CATCH_EVENT_NAME_EMPTY("Message intermediate catch event name is empty."), BPMN_MESSAGE_INTERMEDIATE_CATCH_EVENT_MESSAGE_NAME_EMPTY("Message intermediate catch event message name is empty."), BPMN_MESSAGE_BOUNDARY_EVENT_NAME_EMPTY("Message boundary event name is empty."), @@ -62,6 +69,9 @@ public enum LintingType { BPMN_SIGNAL_END_EVENT_SIGNAL_EMPTY("Signal end event signal is empty."), BPMN_END_EVENT_INSIDE_SUB_PROCESS_SHOULD_HAVE_ASYNC_AFTER_TRUE("End event inside subprocess should have asyncAfter=true."), BPMN_END_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING("End event implementation class does not implement required interface."), + BPMN_INTERMEDIATE_THROW_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING("Intermediate throw event implementation class does not implement required interface."), + BPMN_MESSAGE_END_EVENT_IMPLEMENTATION_CLASS_NOT_EXTENDING_ABSTRACT_TASK_MESSAGE_SEND("Message end event implementation class does not extend AbstractTaskMessageSend."), + BPMN_MESSAGE_INTERMEDIATE_THROW_EVENT_IMPLEMENTATION_CLASS_NOT_EXTENDING_ABSTRACT_TASK_MESSAGE_SEND("Message intermediate throw event implementation class does not extend AbstractTaskMessageSend."), // ==================== BPMN GATEWAYS ==================== BPMN_EXCLUSIVE_GATEWAY_HAS_MULTIPLE_OUTGOING_FLOWS_BUT_NAME_IS_EMPTY("Exclusive gateway has multiple outgoing flows but name is empty."), @@ -78,6 +88,10 @@ public enum LintingType { BPMN_USER_TASK_LISTENER_TASK_OUTPUT_CODE_INVALID_FHIR_RESOURCE("User task listener task output code references invalid FHIR resource."), BPMN_USER_TASK_LISTENER_TASK_OUTPUT_SYSTEM_INVALID_FHIR_RESOURCE("User task listener task output system references invalid FHIR resource."), BPMN_USER_TASK_LISTENER_TASK_OUTPUT_VERSION_NO_PLACEHOLDER("User task listener task output version does not use placeholder."), + BPMN_USER_TASK_LISTENER_PRACTITIONER_ROLE_INPUT_EMPTY( + "User task listener input parameter 'practitionerRole' is defined but has no value."), + BPMN_USER_TASK_LISTENER_PRACTITIONERS_INPUT_EMPTY( + "User task listener input parameter 'practitioners' is defined but has no value."), // ==================== BPMN FIELD INJECTION ==================== BPMN_FIELD_INJECTION_NOT_STRING_LITERAL("Field injection value is not a string literal."), @@ -93,7 +107,7 @@ public enum LintingType { // ==================== BPMN EXECUTION LISTENER ==================== BPMN_EXECUTION_LISTENER_CLASS_NOT_FOUND("Execution listener class not found."), - BPMN_EXECUTION_LISTENER_NOT_IMPLEMENTING_REQUIRED_INTERFACE("Execution listener does not implement required interface."), + BPMN_EXECUTION_LISTENER_CLASS_NOT_IMPLEMENTING_REQUIRED_INTERFACE("Execution listener class does not implement required interface."), // ==================== BPMN PROCESS ==================== BPMN_PROCESS_ID_PATTERN_MISMATCH("Process ID does not match required pattern: domain_processname (e.g. testorg_myprocess)."), @@ -102,10 +116,10 @@ public enum LintingType { BPMN_FILE_MULTIPLE_PROCESSES("BPMN file contains multiple process definitions, expected exactly one."), BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING("Process historyTimeToLive is not set. DSF uses default 'P30D'. Best practice: set explicitly."), BPMN_PROCESS_NOT_EXECUTABLE("Process is not executable. Set isExecutable='true' for the process to be deployable."), + BPMN_PROCESS_VERSION_TAG_MISSING_OR_EMPTY("Process camunda:versionTag is missing, empty, or set to null."), + BPMN_PROCESS_VERSION_TAG_NO_PLACEHOLDER("Process camunda:versionTag does not use '#{version}' placeholder."), // ==================== BPMN GENERAL ==================== - BPMN_PRACTITIONERS_HAS_NO_VALUE_OR_NULL("Practitioners has no value or is null."), - BPMN_PRACTITIONER_ROLE_HAS_NO_VALUE_OR_NULL("PractitionerRole has no value or is null."), BPMN_NO_ACTIVITY_DEFINITION_FOUND_FOR_MESSAGE("No ActivityDefinition found for message."), BPMN_NO_STRUCTURE_DEFINITION_FOUND_FOR_MESSAGE("No StructureDefinition found for message."), @@ -142,6 +156,11 @@ public enum LintingType { STRUCTURE_DEFINITION_SLICE_MAX_TOO_HIGH("StructureDefinition slice max exceeds base max."), STRUCTURE_DEFINITION_SLICE_MIN_SUM_EXCEEDS_MAX("StructureDefinition slice min sum exceeds max."), STRUCTURE_DEFINITION_SLICE_MIN_SUM_ABOVE_BASE_MIN("StructureDefinition slice min sum above base min."), + STRUCTURE_DEFINITION_BINDING_VALUESET_UNRESOLVED("StructureDefinition binding.valueSet (strength=required) references unknown ValueSet."), + STRUCTURE_DEFINITION_BINDING_VALUESET_UNRESOLVED_NON_REQUIRED("StructureDefinition binding.valueSet references unknown ValueSet."), + STRUCTURE_DEFINITION_FIXED_URI_CODESYSTEM_NOT_FOUND("StructureDefinition fixedUri not found in project; fixedCode validation is skipped."), + STRUCTURE_DEFINITION_FIXED_CODE_NOT_IN_CODESYSTEM("StructureDefinition fixedCode is not a known code in the referenced CodeSystem."), + // ==================== FHIR TASK ==================== FHIR_TASK_IDENTIFIER_INVALID_FORMAT("Task identifier with system 'http://dsf.dev/sid/task-identifier' has invalid format. Expected: {process-url}/{process-version}/{task-example-name}"), @@ -177,11 +196,17 @@ public enum LintingType { FHIR_TASK_INPUT_INSTANCE_COUNT_EXCEEDS_MAX("Task input instance count exceeds maximum."), FHIR_TASK_INPUT_SLICE_COUNT_BELOW_SLICE_MIN("Task input slice count below slice minimum."), FHIR_TASK_INPUT_SLICE_COUNT_EXCEEDS_SLICE_MAX("Task input slice count exceeds slice maximum."), - FHIR_TASK_UNKNOWN_CODE("Task has unknown code."), + FHIR_TASK_UNKNOWN_CODE("Task has unknown code (outside Task.input.type.coding)."), + FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN("Task.input.type.coding.system was not found on the classpath or the project directory."), + FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET("Task.input.type.coding.system is not allowed by the expected ValueSet binding context."), + FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM("Task.input.type.coding.code is unknown in the specified CodeSystem."), FHIR_TASK_REQUESTER_ID_NOT_EXIST("Task requester ID does not exist."), FHIR_TASK_REQUESTER_ID_NO_PLACEHOLDER("Task requester ID missing placeholder."), FHIR_TASK_RECIPIENT_ID_NOT_EXIST("Task recipient ID does not exist."), FHIR_TASK_RECIPIENT_ID_NO_PLACEHOLDER("Task recipient ID missing placeholder."), + FHIR_TASK_INPUT_FIXED_URI_MISMATCH("Task input coding.system does not match fixedUri from StructureDefinition."), + FHIR_TASK_INPUT_FIXED_CODE_MISMATCH("Task input coding.code does not match fixedCode from StructureDefinition."), + FHIR_TASK_INPUT_PAIR_NOT_ALLOWED_BY_SD("Task input (system, code) pair is not defined by any fixedUri/fixedCode constraint in the StructureDefinition."), // ==================== FHIR VALUE SET ==================== FHIR_VALUE_SET_MISSING_URL("ValueSet is missing URL."), @@ -239,7 +264,31 @@ public enum LintingType { PLUGIN_DEFINITION_PROCESS_PLUGIN_RESOURCE_NOT_LOADED("Plugin definition process plugin resource not loaded."), PLUGIN_DEFINITION_UNPARSABLE_BPMN_RESOURCE("Plugin definition BPMN resource could not be parsed."), PLUGIN_DEFINITION_UNPARSABLE_FHIR_RESOURCE("Plugin definition FHIR resource could not be parsed."), - PLUGIN_DEFINITION_RESOURCE_VERSION_NULL("Plugin definition getResourceVersion() returned null - version pattern invalid."); + PLUGIN_DEFINITION_RESOURCE_VERSION_NULL("Plugin definition getResourceVersion() returned null - version pattern invalid."), + + // ==================== PLUGIN DEFINITION - SPRING CONFIGURATIONS ==================== + PLUGIN_DEFINITION_SPRING_CONFIGURATION_MISSING( + "A BPMN-referenced class is not provided as a @Bean " + + "in any @Configuration class returned by getSpringConfigurations()."), + PLUGIN_DEFINITION_ACTIVITY_PROTOTYPE_BEAN_MISSING( + "A BPMN-referenced activity is not registered via ActivityPrototypeBeanCreator " + + "and is not declared as a prototype @Bean."), + + // ==================== SPRING BEAN SCOPE ==================== + SPRING_BEAN_SCOPE_MISSING( + "BPMN-referenced bean has no @Scope annotation (defaults to singleton)."), + SPRING_BEAN_SCOPE_SINGLETON_EXPLICIT( + "BPMN-referenced bean is explicitly configured as singleton."), + SPRING_BEAN_SCOPE_PROTOTYPE( + "BPMN-referenced bean is correctly configured as a prototype-scoped @Bean."), + SPRING_BEAN_SCOPE_MUTABLE_SINGLETON( + "BPMN-referenced singleton bean has mutable (non-static, non-final) instance fields."), + SPRING_ACTIVITY_PROTOTYPE_BEAN_CREATOR( + "BPMN-referenced activity is registered via ActivityPrototypeBeanCreator (prototype scope)."), + SPRING_ACTIVITY_REGISTERED_TWICE( + "BPMN-referenced activity is registered both via ActivityPrototypeBeanCreator and as a @Bean."), + SPRING_ACTIVITY_REGISTERED_AS_BEAN( + "BPMN-referenced activity is declared as a @Bean but is not registered via ActivityPrototypeBeanCreator."); private final String defaultMessage; diff --git a/linter-core/src/main/java/dev/dsf/linter/plugin/PluginDefinitionDiscovery.java b/linter-core/src/main/java/dev/dsf/linter/plugin/PluginDefinitionDiscovery.java index ab14d88e..c34ff474 100644 --- a/linter-core/src/main/java/dev/dsf/linter/plugin/PluginDefinitionDiscovery.java +++ b/linter-core/src/main/java/dev/dsf/linter/plugin/PluginDefinitionDiscovery.java @@ -57,6 +57,22 @@ public interface PluginAdapter { * @return the resource version (e.g., "1.5"), or null if the version pattern is invalid */ String getResourceVersion(); + + /** + * Returns the list of Spring {@code @Configuration} classes that the plugin + * registers with the DSF Spring application context. + *

    + * The Camunda engine in DSF does not instantiate BPMN delegate or listener + * classes directly; Spring manages them via {@code @Bean} methods defined + * in {@code @Configuration} classes. The classes returned from this method + * are the ones declared via the plugin's + * {@code ProcessPluginDefinition#getSpringConfigurations()} method. + *

    + * + * @return the list of registered Spring configuration classes, or an empty + * list if none were registered or the call failed. + */ + List> getSpringConfigurations(); } /** @@ -117,6 +133,20 @@ public String getResourceVersion() { throw new RuntimeException("getResourceVersion", e); } } + + @Override + @SuppressWarnings("unchecked") + public List> getSpringConfigurations() { + try { + List> r = (List>) + delegateClass.getMethod("getSpringConfigurations").invoke(delegate); + return r != null ? r : Collections.emptyList(); + } catch (NoSuchMethodException e) { + return Collections.emptyList(); + } catch (Exception e) { + throw new RuntimeException("getSpringConfigurations", e); + } + } } /** @@ -177,6 +207,20 @@ public String getResourceVersion() { throw new RuntimeException("getResourceVersion", e); } } + + @Override + @SuppressWarnings("unchecked") + public List> getSpringConfigurations() { + try { + List> r = (List>) + delegateClass.getMethod("getSpringConfigurations").invoke(delegate); + return r != null ? r : Collections.emptyList(); + } catch (NoSuchMethodException e) { + return Collections.emptyList(); + } catch (Exception e) { + throw new RuntimeException("getSpringConfigurations", e); + } + } } /** diff --git a/linter-core/src/main/java/dev/dsf/linter/report/HtmlReportGenerator.java b/linter-core/src/main/java/dev/dsf/linter/report/HtmlReportGenerator.java index 54a9c19d..52968b33 100644 --- a/linter-core/src/main/java/dev/dsf/linter/report/HtmlReportGenerator.java +++ b/linter-core/src/main/java/dev/dsf/linter/report/HtmlReportGenerator.java @@ -4,7 +4,6 @@ import dev.dsf.linter.analysis.LeftoverResourceDetector; import dev.dsf.linter.output.item.AbstractLintItem; import dev.dsf.linter.logger.Logger; -import dev.dsf.linter.service.ResourceDiscoveryService; import dev.dsf.linter.util.api.ApiVersion; import dev.dsf.linter.util.api.ApiVersionHolder; import dev.dsf.linter.util.linting.LintingOutput; @@ -20,6 +19,7 @@ import java.text.SimpleDateFormat; import java.util.*; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Generates HTML linter reports using Thymeleaf templates. @@ -27,6 +27,9 @@ */ public class HtmlReportGenerator { + /** Relative path from {@code //lints.html} to the master report. */ + private static final String MASTER_REPORT_HREF = "../report.html"; + private final Logger logger; private final TemplateEngine templateEngine; @@ -52,18 +55,20 @@ private TemplateEngine createTemplateEngine() { /** * Generates an HTML report for a single plugin. * - * @param pluginName The name of the plugin - * @param lints The plugin linting result - * @param outputPath The path where the HTML report should be saved + * @param pluginName The name of the plugin + * @param lints The plugin linting result + * @param outputPath The path where the HTML report should be saved + * @param projectPath The extracted JAR project directory used to locate source files */ public void generatePluginReport( String pluginName, DsfLinter.PluginLinter lints, - Path outputPath) throws IOException { + Path outputPath, + Path projectPath) throws IOException { logger.debug("Generating HTML report for plugin: " + pluginName); - String html = formatPluginHtml(pluginName, lints); + String html = formatPluginHtml(pluginName, lints, projectPath); Files.writeString(outputPath, html); logger.debug("HTML report written to: " + outputPath); @@ -73,21 +78,19 @@ public void generatePluginReport( * Generates the master HTML report that aggregates all plugins. * * @param lints Map of all plugin lints - * @param discovery Resource discovery results * @param leftoverResults Leftover analysis results * @param outputPath The path where the master HTML report should be saved * @param config Linter configuration */ public void generateMasterReport( Map lints, - ResourceDiscoveryService.DiscoveryResult discovery, LeftoverResourceDetector.AnalysisResult leftoverResults, Path outputPath, DsfLinter.Config config) throws IOException { logger.debug("Generating master HTML report..."); - String html = formatMasterHtml(lints, discovery, leftoverResults, config); + String html = formatMasterHtml(lints, leftoverResults, config); Files.writeString(outputPath, html); logger.debug("Master HTML report written to: " + outputPath); @@ -96,7 +99,7 @@ public void generateMasterReport( /** * Formats the HTML content for a single plugin report. */ - private String formatPluginHtml(String pluginName, DsfLinter.PluginLinter lints) { + private String formatPluginHtml(String pluginName, DsfLinter.PluginLinter lints, Path projectPath) { Context context = new Context(); ApiVersion apiVersion = lints.apiVersion(); @@ -105,7 +108,7 @@ private String formatPluginHtml(String pluginName, DsfLinter.PluginLinter lints) addLogosToContext(context); addPluginMetadata(context, pluginName, lints, apiVersion); addLintsCounts(context, lints); - addLintingItems(context, lints); + addLintingItems(context, lints, projectPath); ApiVersionHolder.clear(); @@ -121,6 +124,7 @@ private void addPluginMetadata(Context context, String pluginName, context.setVariable("pluginName", pluginName); context.setVariable("pluginClass", lints.pluginClass()); context.setVariable("apiVersion", apiVersion.toString()); + context.setVariable("masterReportHref", MASTER_REPORT_HREF); } /** @@ -141,8 +145,12 @@ private void addLintsCounts(Context context, DsfLinter.PluginLinter lints) { /** * Adds lints items to the Thymeleaf context, grouped by severity. + * Also resolves referenced file contents for the in-report file viewer. + * After building the content map, removes {@code sourceFile} from any item + * whose file could not be loaded — keeping the clickable hint only when + * content is actually available. */ - private void addLintingItems(Context context, DsfLinter.PluginLinter lints) { + private void addLintingItems(Context context, DsfLinter.PluginLinter lints, Path projectPath) { List sortedItems = new ArrayList<>(lints.output().LintItems()); sortedItems.sort( Comparator.comparingInt((AbstractLintItem i) -> @@ -151,9 +159,22 @@ private void addLintingItems(Context context, DsfLinter.PluginLinter lints) { ); Map>> itemsBySeverity = groupItemsBySeverity(sortedItems); + Map fileContents = buildFileContentMap(sortedItems, projectPath); + + // Strip sourceFile from items whose file content could not be loaded, + // so no clickable indicator appears for items without a viewable source. + itemsBySeverity.values().forEach(items -> + items.forEach(itemMap -> { + Object sf = itemMap.get("sourceFile"); + if (sf != null && !fileContents.containsKey(sf.toString())) { + itemMap.remove("sourceFile"); + } + }) + ); context.setVariable("itemsBySeverity", itemsBySeverity); context.setVariable("hasItems", !sortedItems.isEmpty()); + context.setVariable("fileContents", fileContents); } /** @@ -179,6 +200,8 @@ private Map>> groupItemsBySeverity(List convertItemToMap(AbstractLintItem item) { Map itemMap = new LinkedHashMap<>(); @@ -196,15 +219,129 @@ private Map convertItemToMap(AbstractLintItem item) { itemMap.put("fullMessage", item.toString()); + String highlightTarget = determineHighlightTarget(itemMap); + if (highlightTarget != null) { + itemMap.put("highlightTarget", highlightTarget); + } + + // Pre-compute sourceFile so the template avoids unsupported OGNL Elvis (?:) syntax + Object resourceFile = itemMap.get("resourceFile"); + Object bpmnFile = itemMap.get("bpmnFile"); + Object fileName = itemMap.get("fileName"); + String sourceFile = resourceFile != null ? resourceFile.toString() + : bpmnFile != null ? bpmnFile.toString() + : fileName != null ? fileName.toString() + : null; + if (sourceFile != null) { + itemMap.put("sourceFile", sourceFile); + } + return itemMap; } + /** + * Determines the best string to highlight in the file viewer for a given lint item. + *
      + *
    • BPMN: searches for {@code id=""} — matches the element definition + * precisely, avoiding false hits on {@code sourceRef} / {@code targetRef} etc.
    • + *
    • FHIR: uses the full canonical {@code fhirReference} URL (version suffix stripped) + * which appears verbatim as an attribute value at the affected location.
    • + *
    • Fallback: {@code resourceId} for cases where neither field is available.
    • + *
    + */ + private String determineHighlightTarget(Map itemMap) { + // BPMN: search for the XML attribute declaration, not the bare ID string + Object elementId = itemMap.get("elementId"); + if (elementId != null && !elementId.toString().isBlank()) { + return "id=\"" + elementId + "\""; + } + + // FHIR: use the full canonical URL (strip |version suffix if present) + Object fhirReference = itemMap.get("fhirReference"); + if (fhirReference != null && !fhirReference.toString().isBlank()) { + String ref = fhirReference.toString(); + int pipeIdx = ref.indexOf('|'); + if (pipeIdx > 0) ref = ref.substring(0, pipeIdx); + if (!ref.isBlank()) return ref; + } + + // Fallback: resourceId + Object resourceId = itemMap.get("resourceId"); + if (resourceId != null && !resourceId.toString().isBlank()) { + return resourceId.toString(); + } + return null; + } + + /** + * Reads the content of all files referenced by the given lint items. + * Searches for files by name recursively inside {@code projectPath}. + * + * @param items the lint items whose file references should be resolved + * @param projectPath root directory of the extracted JAR + * @return map from file name to file content (raw text) + */ + private Map buildFileContentMap(List items, Path projectPath) { + Map contentMap = new LinkedHashMap<>(); + if (projectPath == null || !Files.exists(projectPath)) { + return contentMap; + } + + Set fileNames = new LinkedHashSet<>(); + for (AbstractLintItem item : items) { + collectFileName(item, "getResourceFile", fileNames); + collectFileName(item, "getBpmnFile", fileNames); + collectFileName(item, "getFileName", fileNames); + } + + for (String fileName : fileNames) { + if (fileName == null || fileName.isBlank()) continue; + try { + Optional found = findFileByName(projectPath, fileName); + if (found.isPresent()) { + contentMap.put(fileName, Files.readString(found.get())); + } else { + logger.debug("Referenced file not found in project directory: " + fileName); + } + } catch (IOException e) { + logger.debug("Could not read referenced file '" + fileName + "': " + e.getMessage()); + } + } + + return contentMap; + } + + /** + * Invokes a getter on the item and, if it returns a non-blank String, adds it to the set. + */ + private void collectFileName(AbstractLintItem item, String getterName, Set fileNames) { + try { + Method method = item.getClass().getMethod(getterName); + Object value = method.invoke(item); + if (value instanceof String s && !s.isBlank()) { + fileNames.add(s); + } + } catch (Exception ignored) { + } + } + + /** + * Recursively searches {@code baseDir} for a file whose name matches {@code fileName}. + */ + private Optional findFileByName(Path baseDir, String fileName) throws IOException { + try (Stream walk = Files.walk(baseDir)) { + return walk + .filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().equals(fileName)) + .findFirst(); + } + } + /** * Formats the HTML content for the master report. */ private String formatMasterHtml( Map lints, - ResourceDiscoveryService.DiscoveryResult discovery, LeftoverResourceDetector.AnalysisResult leftoverResults, DsfLinter.Config config) { diff --git a/linter-core/src/main/java/dev/dsf/linter/report/LintingReportGenerator.java b/linter-core/src/main/java/dev/dsf/linter/report/LintingReportGenerator.java index 2323dd9f..9015e8ec 100644 --- a/linter-core/src/main/java/dev/dsf/linter/report/LintingReportGenerator.java +++ b/linter-core/src/main/java/dev/dsf/linter/report/LintingReportGenerator.java @@ -82,7 +82,7 @@ private void generateIndividualPluginReports( // Generate HTML report if enabled if (config.generateHtmlReport()) { Path htmlReportPath = pluginReportDir.resolve("lints.html"); - htmlGenerator.generatePluginReport(pluginName, lint, htmlReportPath); + htmlGenerator.generatePluginReport(pluginName, lint, htmlReportPath, config.projectPath()); } // Generate JSON report if enabled @@ -109,7 +109,6 @@ private void generateMasterReports( Path masterHtmlPath = config.reportPath().resolve("report.html"); htmlGenerator.generateMasterReport( lints, - discovery, leftoverResults, masterHtmlPath, config diff --git a/linter-core/src/main/java/dev/dsf/linter/service/PluginLintingOrchestrator.java b/linter-core/src/main/java/dev/dsf/linter/service/PluginLintingOrchestrator.java index c3578a1c..45a3c441 100644 --- a/linter-core/src/main/java/dev/dsf/linter/service/PluginLintingOrchestrator.java +++ b/linter-core/src/main/java/dev/dsf/linter/service/PluginLintingOrchestrator.java @@ -2,6 +2,7 @@ import dev.dsf.linter.DsfLinter; import dev.dsf.linter.exception.ResourceLinterException; +import dev.dsf.linter.exclusion.ExclusionFilter; import dev.dsf.linter.output.LinterSeverity; import dev.dsf.linter.analysis.LeftoverResourceDetector; import dev.dsf.linter.exception.MissingServiceRegistrationException; @@ -32,6 +33,8 @@ public class PluginLintingOrchestrator { private final LeftoverResourceDetector leftoverDetector; private final LintingReportGenerator reportGenerator; private final Path reportBasePath; + private final ExclusionFilter exclusionFilter; + private final Logger logger; /** * Context information for validating a plugin in a multi-plugin environment. @@ -50,6 +53,7 @@ public PluginLintingOrchestrator( LeftoverResourceDetector leftoverDetector, LintingReportGenerator reportGenerator, Path reportBasePath, + ExclusionFilter exclusionFilter, Logger logger) { this.bpmnLinter = bpmnLinter; this.fhirLinter = fhirLinter; @@ -57,6 +61,8 @@ public PluginLintingOrchestrator( this.leftoverDetector = leftoverDetector; this.reportGenerator = reportGenerator; this.reportBasePath = reportBasePath; + this.exclusionFilter = exclusionFilter; + this.logger = logger; } /** @@ -105,6 +111,19 @@ public DsfLinter.PluginLinter lintSinglePlugin( context.projectPath() ); + // Step 4.6: Validate Spring configuration registration + // (cross-references getSpringConfigurations() with BPMN delegate/listener classes) + List springConfigItems = SpringConfigurationLinter.lint( + plugin.adapter(), + plugin.bpmnFiles(), + context.projectDir(), + logger + ); + if (!springConfigItems.isEmpty()) { + metadataItems = new ArrayList<>(metadataItems); + metadataItems.addAll(springConfigItems); + } + // Step 5: Get leftover items for this plugin List leftoverItems = leftoverDetector.getItemsForPlugin( leftoverAnalysis, @@ -233,6 +252,9 @@ private GroupedLintingItems groupAndFilterItems( /** * Builds the final PluginLinter result for a single plugin. + * If an {@link ExclusionFilter} is configured, excluded items are removed from the + * output that flows into reports, and their error count is stored separately so the + * caller can decide whether they should still affect the exit status. */ private DsfLinter.PluginLinter buildPluginLintResult( String pluginName, @@ -242,20 +264,32 @@ private DsfLinter.PluginLinter buildPluginLintResult( List metadataItems, List leftoverItems) throws IOException { - List finalLintingItems = new ArrayList<>(); - finalLintingItems.addAll(itemsCollection.nonPluginItems); - finalLintingItems.addAll(pluginResult.getItems()); + List allItems = new ArrayList<>(); + allItems.addAll(itemsCollection.nonPluginItems); + allItems.addAll(pluginResult.getItems()); // Add metadata Lint Items to final result if (metadataItems != null && !metadataItems.isEmpty()) { - finalLintingItems.addAll(metadataItems); + allItems.addAll(metadataItems); } - if (leftoverItems != null && !leftoverItems.isEmpty()) { - finalLintingItems.addAll(leftoverItems); + allItems.addAll(leftoverItems); + } + + List reportItems; + int excludedErrorCount = 0; + + if (exclusionFilter != null) { + List excluded = exclusionFilter.getExcluded(allItems); + reportItems = exclusionFilter.filter(allItems); + excludedErrorCount = (int) excluded.stream() + .filter(i -> i.getSeverity() == LinterSeverity.ERROR) + .count(); + } else { + reportItems = allItems; } - LintingOutput finalOutput = new LintingOutput(finalLintingItems); + LintingOutput finalOutput = new LintingOutput(reportItems); Path pluginReportPath = reportBasePath.resolve(pluginName); Files.createDirectories(pluginReportPath); @@ -265,6 +299,7 @@ private DsfLinter.PluginLinter buildPluginLintResult( plugin.adapter().sourceClass().getName(), plugin.apiVersion(), finalOutput, + excludedErrorCount, pluginReportPath ); } diff --git a/linter-core/src/main/java/dev/dsf/linter/service/SpringConfigurationLinter.java b/linter-core/src/main/java/dev/dsf/linter/service/SpringConfigurationLinter.java new file mode 100644 index 00000000..1e23e607 --- /dev/null +++ b/linter-core/src/main/java/dev/dsf/linter/service/SpringConfigurationLinter.java @@ -0,0 +1,796 @@ +package dev.dsf.linter.service; + +import dev.dsf.linter.logger.Logger; +import dev.dsf.linter.output.LintingType; +import dev.dsf.linter.output.LinterSeverity; +import dev.dsf.linter.output.item.AbstractLintItem; +import dev.dsf.linter.output.item.PluginLintItem; +import dev.dsf.linter.plugin.PluginDefinitionDiscovery.PluginAdapter; +import dev.dsf.linter.util.api.ApiVersion; +import dev.dsf.linter.util.api.ApiVersionHolder; + +import org.camunda.bpm.model.bpmn.Bpmn; +import org.camunda.bpm.model.bpmn.BpmnModelInstance; +import org.camunda.bpm.model.bpmn.instance.BaseElement; +import org.camunda.bpm.model.bpmn.instance.EventDefinition; +import org.camunda.bpm.model.bpmn.instance.MessageEventDefinition; +import org.camunda.bpm.model.bpmn.instance.SendTask; +import org.camunda.bpm.model.bpmn.instance.ServiceTask; +import org.camunda.bpm.model.bpmn.instance.ThrowEvent; +import org.camunda.bpm.model.bpmn.instance.camunda.CamundaExecutionListener; +import org.camunda.bpm.model.bpmn.instance.camunda.CamundaTaskListener; + +import java.io.File; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Validates DSF process plugins against how Spring provides Camunda delegates + * and listeners: bean registration and (for covered beans) {@code @Scope} plus + * mutable instance fields. + * + *

    Background

    + *

    + * In the DSF environment the engine does not instantiate Java delegate + * or listener classes directly. Spring creates those instances. + * {@code ProcessPluginDefinition#getSpringConfigurations()} must return the + * {@code @Configuration} classes that provide them. + *

    + * API V1 registers each BPMN class with its own {@code @Bean} factory method + * (typically {@code @Scope("prototype")}). API V2 may instead register activities + * via {@code dev.dsf.bpe.v2.spring.ActivityPrototypeBeanCreator}, which creates + * prototype beans from the {@code Class} literals passed to its constructor. + * A missing registration often appears only at deployment time as a + * {@code BeanCreationException} or {@code ClassNotFoundException}. + *

    + *

    + * DSF best practice is prototype scope for such beans. Omitting + * {@code @Scope} defaults Spring to singleton, which is unsafe if + * the implementation ever holds mutable instance state (non-{@code static}, + * non-{@code final} fields) across concurrent process executions. + *

    + * + *

    Validation pipeline (see {@link #lint})

    + *
      + *
    1. Load registered {@code @Configuration} classes from + * {@code getSpringConfigurations()}.
    2. + *
    3. Scan plugin BPMN files for {@code camunda:class} references (service + * and send tasks, throw events with message definitions, execution and + * task listeners).
    4. + *
    5. Build {@code @Bean} return types per configuration class and, for API + * V2, activity classes registered through + * {@code ActivityPrototypeBeanCreator}; check each referenced class is + * covered (exact FQN or supertype assignability).
    6. + *
    7. For each covered class, treat {@code ActivityPrototypeBeanCreator} + * registrations as prototype; otherwise find the covering {@code @Bean} + * method and inspect its {@code @Scope} and the implementation class for + * mutable instance fields.
    8. + *
    + * + *

    Emitted {@link LintingType} values

    + *

    Bean registration

    + *
      + *
    • ERROR – {@link LintingType#PLUGIN_DEFINITION_SPRING_CONFIGURATION_MISSING} + * (API V1): BPMN references a class that is not a {@code @Bean} in any + * registered configuration.
    • + *
    • ERROR – {@link LintingType#PLUGIN_DEFINITION_ACTIVITY_PROTOTYPE_BEAN_MISSING} + * (API V2): BPMN references a class that is neither passed to + * {@code ActivityPrototypeBeanCreator} nor declared as a prototype + * {@code @Bean}.
    • + *
    • WARN – {@link LintingType#SPRING_ACTIVITY_REGISTERED_TWICE} + * (API V2): the class is registered both via + * {@code ActivityPrototypeBeanCreator} and as a {@code @Bean}.
    • + *
    • WARN – {@link LintingType#SPRING_ACTIVITY_REGISTERED_AS_BEAN} + * (API V2): the class is declared as a {@code @Bean} but is not passed to + * {@code ActivityPrototypeBeanCreator}.
    • + *
    • SUCCESS – {@link LintingType#SUCCESS}: all references are + * registered for the detected API version and there are no registration + * errors (a summary item is also emitted when the reference set is + * non-empty and fully covered; see {@link #lint}).
    • + *
    • When there are no BPMN delegate/listener references, a single success + * item is emitted and the run returns early (scope checks do not run).
    • + *
    + *

    Scope and mutable state (covered classes only)

    + *
      + *
    • SUCCESS – {@link LintingType#SPRING_ACTIVITY_PROTOTYPE_BEAN_CREATOR} + * (API V2): the class is registered only via {@code ActivityPrototypeBeanCreator} + * (always prototype).
    • + *
    • SUCCESS – {@link LintingType#SPRING_BEAN_SCOPE_PROTOTYPE}: + * the covering {@code @Bean} has {@code @Scope} with value + * {@code "prototype"}.
    • + *
    • ERROR – {@link LintingType#SPRING_BEAN_SCOPE_MUTABLE_SINGLETON}: + * the bean is effectively singleton (no {@code @Scope} or explicit + * non-prototype scope) and the implementation class has mutable + * instance fields. Emitted before the corresponding scope warning for that + * reference.
    • + *
    • WARN – {@link LintingType#SPRING_BEAN_SCOPE_MISSING}: + * the covering {@code @Bean} has no {@code @Scope} (Spring defaults to + * singleton).
    • + *
    • WARN – {@link LintingType#SPRING_BEAN_SCOPE_SINGLETON_EXPLICIT}: + * the covering {@code @Bean} has an explicit non-prototype + * {@code @Scope} (e.g. {@code "singleton"}); the implementation must be + * provably stateless for a shared instance.
    • + *
    + */ +public final class SpringConfigurationLinter { + + private static final String CAMUNDA_NS = "http://camunda.org/schema/1.0/bpmn"; + + /** Fully qualified name of the Spring {@code @Bean} annotation. */ + private static final String BEAN_ANNOTATION = "org.springframework.context.annotation.Bean"; + + /** Fully qualified name of the Spring {@code @Scope} annotation. */ + private static final String SCOPE_ANNOTATION = "org.springframework.context.annotation.Scope"; + + /** + * Fully qualified name of the DSF API V2 helper that registers activity + * classes as prototype beans without one {@code @Bean} method per class. + */ + private static final String ACTIVITY_PROTOTYPE_BEAN_CREATOR = + "dev.dsf.bpe.v2.spring.ActivityPrototypeBeanCreator"; + + private SpringConfigurationLinter() { + } + + /** + * Runs Spring configuration validation: {@code @Bean} coverage for all BPMN + * delegate/listener references, then for each covered class an + * optional scope/mutability pass ({@code @Scope} on the covering + * {@code @Bean} method and mutable instance fields on the implementation + * type). + * + *

    When {@code bpmnFiles} yields no references, returns a single success item + * and does not run registration or scope checks.

    + * + *

    When there are references, emits one error per uncovered class, then for + * each covered class: API V2 classes registered both via + * {@code ActivityPrototypeBeanCreator} and as a {@code @Bean}, or only as a + * {@code @Bean}, emit a warning; API V2 classes registered only via + * {@code ActivityPrototypeBeanCreator} are treated as prototype. Otherwise if a + * covering {@code @Bean} method was resolved, prototype scope → one success + * item; otherwise if mutable fields → error, then either missing {@code @Scope} + * → warning or explicit non-prototype scope → warning. If every reference is + * covered by a registered {@code @Bean} or {@code ActivityPrototypeBeanCreator}, + * a summary success item is appended.

    + * + * @param adapter the plugin adapter used to invoke + * {@code getSpringConfigurations()} reflectively + * @param bpmnFiles BPMN files belonging to this plugin (used to collect + * delegate/listener class references) + * @param projectDir the extracted project root (used only for the file + * label on lint items; may be {@code null}) + * @param logger logger for diagnostic output (may be {@code null}) + * @return ordered list of lint items; never {@code null} + */ + public static List lint(PluginAdapter adapter, + List bpmnFiles, + File projectDir, + Logger logger) { + List items = new ArrayList<>(); + if (adapter == null) { + return items; + } + + String pluginLocation = adapter.sourceClass().getName(); + File locationFile = projectDir != null ? projectDir : new File("."); + + // Step 1: Get the registered @Configuration classes + List> registered; + try { + registered = adapter.getSpringConfigurations(); + } catch (RuntimeException e) { + if (logger != null) { + logger.debug("Could not invoke getSpringConfigurations() on plugin '" + + pluginLocation + "': " + e.getMessage()); + } + registered = Collections.emptyList(); + } + if (registered == null) { + registered = Collections.emptyList(); + } + + // Step 2: Collect all BPMN-referenced delegate/listener class names + Set referencedBpmnClasses = collectBpmnDelegateReferences(bpmnFiles, logger); + if (logger != null) { + logger.debug("Spring configuration check: " + referencedBpmnClasses.size() + + " BPMN delegate/listener class reference(s) found."); + } + + if (referencedBpmnClasses.isEmpty()) { + items.add(PluginLintItem.success( + locationFile, + pluginLocation, + "No BPMN delegate/listener references found; Spring configuration check skipped." + )); + return items; + } + + // Step 3: Build a map of configClassName -> set of @Bean return-type names + // from the REGISTERED configurations only. + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + Map> registeredConfigBeans = new LinkedHashMap<>(); + Set apbcRegistered = new LinkedHashSet<>(); + boolean apbcBeanPresent = false; + for (Class configClass : registered) { + if (configClass == null) { + continue; + } + Set beanTypes = extractBeanReturnTypes(configClass, logger); + registeredConfigBeans.put(configClass.getName(), beanTypes); + if (logger != null) { + logger.debug("Registered @Configuration '" + configClass.getName() + + "' exposes " + beanTypes.size() + " @Bean type(s)."); + } + if (hasActivityPrototypeBeanCreatorBean(configClass)) { + apbcBeanPresent = true; + } + apbcRegistered.addAll(extractActivityPrototypeBeanCreatorClasses(configClass, logger)); + } + + // Step 4: For each BPMN-referenced class check whether any registered + // configuration provides a @Bean for it (exact type or supertype). + List uncoveredClasses = new ArrayList<>(); + for (String refClass : referencedBpmnClasses) { + boolean covered = apbcRegistered.contains(refClass); + if (!covered) { + for (Set beanTypes : registeredConfigBeans.values()) { + if (configProvidesClass(beanTypes, refClass, cl)) { + covered = true; + break; + } + } + } + if (!covered) { + uncoveredClasses.add(refClass); + } + } + + // Step 5: Emit one ERROR per uncovered BPMN-referenced class + ApiVersion apiVersion = ApiVersionHolder.getVersion(); + for (String uncoveredClass : uncoveredClasses) { + items.add(new PluginLintItem( + LinterSeverity.ERROR, + missingRegistrationType(apiVersion), + locationFile, + uncoveredClass, + missingRegistrationMessage(apiVersion, uncoveredClass, registeredConfigBeans.size(), + adapter.getName(), apbcBeanPresent) + )); + } + + // Step 5.5: For each covered class, API V2 warns on dual/@Bean-only + // registration; otherwise check @Scope on the covering @Bean. + Map> configBeanMethods = new LinkedHashMap<>(); + for (Class configClass : registered) { + if (configClass != null) { + configBeanMethods.put(configClass.getName(), + extractBeanMethodMap(configClass, logger)); + } + } + for (String refClass : referencedBpmnClasses) { + if (uncoveredClasses.contains(refClass)) { + continue; // already reported as ERROR in step 5 + } + boolean inApbc = apbcRegistered.contains(refClass); + boolean inBean = providedByBean(registeredConfigBeans, refClass, cl); + if (apiVersion == ApiVersion.V2) { + if (inApbc && inBean) { + items.add(new PluginLintItem( + LinterSeverity.WARN, + LintingType.SPRING_ACTIVITY_REGISTERED_TWICE, + locationFile, + refClass, + "BPMN-referenced class '" + simpleName(refClass) + "' (" + + refClass + ") is registered both via " + + "ActivityPrototypeBeanCreator and as a @Bean. " + + "Remove one of the two registrations." + )); + continue; + } + if (!inApbc && inBean) { + items.add(new PluginLintItem( + LinterSeverity.WARN, + LintingType.SPRING_ACTIVITY_REGISTERED_AS_BEAN, + locationFile, + refClass, + "BPMN-referenced class '" + simpleName(refClass) + "' (" + + refClass + ") is declared as a @Bean but is not " + + "registered via ActivityPrototypeBeanCreator. " + + "Pass " + simpleName(refClass) + + ".class to ActivityPrototypeBeanCreator." + )); + continue; + } + } + if (inApbc) { + items.add(new PluginLintItem( + LinterSeverity.SUCCESS, + LintingType.SPRING_ACTIVITY_PROTOTYPE_BEAN_CREATOR, + locationFile, + refClass, + "BPMN-referenced class '" + simpleName(refClass) + "' (" + + refClass + ") is registered via ActivityPrototypeBeanCreator " + + "and is therefore prototype-scoped." + )); + continue; + } + Optional coveringMethod = findCoveringMethod(configBeanMethods, refClass, cl); + if (coveringMethod.isEmpty()) { + continue; + } + String scopeValue = getScopeValue(coveringMethod.get()); + boolean mutable = hasMutableInstanceFields(refClass, cl); + + if ("prototype".equals(scopeValue)) { + items.add(new PluginLintItem( + LinterSeverity.SUCCESS, + LintingType.SPRING_BEAN_SCOPE_PROTOTYPE, + locationFile, + refClass, + "BPMN-referenced class '" + simpleName(refClass) + "' (" + + refClass + ") is correctly configured as a prototype-scoped @Bean." + )); + continue; // prototype is safe – no further scope checks needed + } + + // Effective singleton (missing @Scope or explicit non-prototype): check mutable fields first. + if (mutable) { + items.add(new PluginLintItem( + LinterSeverity.ERROR, + LintingType.SPRING_BEAN_SCOPE_MUTABLE_SINGLETON, + locationFile, + refClass, + "BPMN-referenced class '" + simpleName(refClass) + "' (" + + refClass + ") is effectively singleton-scoped and " + + "contains mutable (non-static, non-final) instance fields. " + + "This will cause race conditions under concurrent process execution." + )); + } + + if (scopeValue == null) { + items.add(new PluginLintItem( + LinterSeverity.WARN, + LintingType.SPRING_BEAN_SCOPE_MISSING, + locationFile, + refClass, + "BPMN-referenced class '" + simpleName(refClass) + "' (" + + refClass + ") has a @Bean method without an explicit @Scope " + + "annotation. Spring defaults to singleton scope, which is risky " + + "for Camunda delegates and listeners. Consider adding " + + "@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)." + )); + } else { + // Explicit non-prototype scope (typically "singleton") + items.add(new PluginLintItem( + LinterSeverity.WARN, + LintingType.SPRING_BEAN_SCOPE_SINGLETON_EXPLICIT, + locationFile, + refClass, + "BPMN-referenced class '" + simpleName(refClass) + "' (" + + refClass + ") is explicitly configured with @Scope(\"" + + scopeValue + "\"). Singleton-scoped Camunda delegates and " + + "listeners must be actually completely stateless." + )); + } + } + + if (uncoveredClasses.isEmpty()) { + items.add(PluginLintItem.success( + locationFile, + pluginLocation, + coverageSummaryMessage(apiVersion, registered.size(), referencedBpmnClasses.size()) + )); + } + + return items; + } + + // ==================== BPMN REFERENCE COLLECTION ==================== + + private static Set collectBpmnDelegateReferences(List bpmnFiles, Logger logger) { + Set refs = new LinkedHashSet<>(); + if (bpmnFiles == null) { + return refs; + } + for (File bpmnFile : bpmnFiles) { + if (bpmnFile == null || !bpmnFile.isFile()) { + continue; + } + try { + BpmnModelInstance model = Bpmn.readModelFromFile(bpmnFile); + collectFromModel(model, refs); + } catch (Exception e) { + if (logger != null) { + logger.debug("Could not parse BPMN file for Spring config check: " + + bpmnFile.getAbsolutePath() + " - " + e.getMessage()); + } + } + } + return refs; + } + + private static void collectFromModel(BpmnModelInstance model, Set refs) { + for (ServiceTask t : model.getModelElementsByType(ServiceTask.class)) { + addIfNotBlank(t.getCamundaClass(), refs); + addListenerClasses(t, refs); + } + for (SendTask t : model.getModelElementsByType(SendTask.class)) { + addIfNotBlank(t.getCamundaClass(), refs); + addListenerClasses(t, refs); + } + for (ThrowEvent event : model.getModelElementsByType(ThrowEvent.class)) { + addDirectCamundaClass(event, refs); + addMessageEventClasses(event.getEventDefinitions(), refs); + addListenerClasses(event, refs); + } + for (BaseElement e : model.getModelElementsByType(BaseElement.class)) { + addListenerClasses(e, refs); + } + } + + private static void addDirectCamundaClass(BaseElement element, Set refs) { + String direct = element.getAttributeValueNs(CAMUNDA_NS, "class"); + addIfNotBlank(direct, refs); + } + + private static void addMessageEventClasses(Collection defs, Set refs) { + if (defs == null) return; + for (EventDefinition d : defs) { + if (d instanceof MessageEventDefinition med) { + addIfNotBlank(med.getCamundaClass(), refs); + } + } + } + + private static void addListenerClasses(BaseElement element, Set refs) { + try { + for (CamundaExecutionListener l : element.getChildElementsByType(CamundaExecutionListener.class)) { + addIfNotBlank(l.getCamundaClass(), refs); + } + } catch (RuntimeException ignored) { + // Element does not support execution listeners – skip silently. + } + try { + for (CamundaTaskListener l : element.getChildElementsByType(CamundaTaskListener.class)) { + addIfNotBlank(l.getCamundaClass(), refs); + } + } catch (RuntimeException ignored) { + // Element does not support task listeners – skip silently. + } + } + + private static void addIfNotBlank(String v, Set target) { + if (v != null && !v.isBlank()) { + target.add(v.trim()); + } + } + + // ==================== @Bean DISCOVERY ON REGISTERED CONFIGS ==================== + + /** + * Returns the set of return-type names of all {@code @Bean}-annotated methods + * declared directly on {@code configClass}. + */ + private static Set extractBeanReturnTypes(Class configClass, Logger logger) { + Set beanReturnTypes = new LinkedHashSet<>(); + try { + for (Method m : configClass.getDeclaredMethods()) { + if (hasAnnotationByName(m.getAnnotations())) { + Class rt = m.getReturnType(); + if (rt != void.class && rt != Void.class) { + beanReturnTypes.add(rt.getName()); + } + } + } + } catch (Throwable t) { + if (logger != null) { + logger.debug("Could not read @Bean methods of '" + + configClass.getName() + "': " + t.getMessage()); + } + } + return beanReturnTypes; + } + + /** + * Collects activity class names registered via a static no-arg {@code @Bean} + * that returns DSF API V2 {@code ActivityPrototypeBeanCreator}. That helper + * registers the constructor {@code Class} arguments as prototype beans, so + * they are not visible as {@code @Bean} return types. + */ + private static Set extractActivityPrototypeBeanCreatorClasses(Class configClass, Logger logger) { + Set activities = new LinkedHashSet<>(); + try { + for (Method m : configClass.getDeclaredMethods()) { + if (!hasAnnotationByName(m.getAnnotations()) + || !ACTIVITY_PROTOTYPE_BEAN_CREATOR.equals(m.getReturnType().getName()) + || !Modifier.isStatic(m.getModifiers()) + || m.getParameterCount() != 0) { + continue; + } + m.setAccessible(true); + Object creator = m.invoke(null); + if (creator == null) { + continue; + } + Field field = creator.getClass().getDeclaredField("activities"); + field.setAccessible(true); + Object value = field.get(creator); + if (value instanceof Iterable it) { + for (Object item : it) { + if (item instanceof Class c) { + activities.add(c.getName()); + } + } + } + } + } catch (Throwable t) { + if (logger != null) { + logger.debug("Could not extract ActivityPrototypeBeanCreator classes from '" + + configClass.getName() + "': " + t.getMessage()); + } + } + return activities; + } + + private static boolean hasAnnotationByName(Annotation[] annotations) { + if (annotations == null) return false; + for (Annotation a : annotations) { + if (a == null) continue; + Class type = a.annotationType(); + if (type != null && SpringConfigurationLinter.BEAN_ANNOTATION.equals(type.getName())) { + return true; + } + } + return false; + } + + /** + * Returns {@code true} if the set of {@code @Bean} return-type names includes + * {@code referencedClass} exactly, or if any listed type is a supertype of + * {@code referencedClass} (to handle abstract/interface return types). + */ + private static boolean configProvidesClass(Set beanReturnTypes, + String referencedClass, + ClassLoader cl) { + if (beanReturnTypes.contains(referencedClass)) { + return true; + } + if (cl == null) { + return false; + } + Class ref; + try { + ref = Class.forName(referencedClass, false, cl); + } catch (ClassNotFoundException | LinkageError e) { + return false; + } + for (String rt : beanReturnTypes) { + try { + Class returnType = Class.forName(rt, false, cl); + if (returnType.isAssignableFrom(ref)) { + return true; + } + } catch (ClassNotFoundException | LinkageError ignored) { + // ignore and continue + } + } + return false; + } + + private static boolean providedByBean(Map> registeredConfigBeans, + String referencedClass, + ClassLoader cl) { + for (Set beanTypes : registeredConfigBeans.values()) { + if (configProvidesClass(beanTypes, referencedClass, cl)) { + return true; + } + } + return false; + } + + private static String simpleName(String fqn) { + int i = fqn.lastIndexOf('.'); + return (i >= 0) ? fqn.substring(i + 1) : fqn; + } + + private static LintingType missingRegistrationType(ApiVersion apiVersion) { + return apiVersion == ApiVersion.V2 + ? LintingType.PLUGIN_DEFINITION_ACTIVITY_PROTOTYPE_BEAN_MISSING + : LintingType.PLUGIN_DEFINITION_SPRING_CONFIGURATION_MISSING; + } + + private static String missingRegistrationMessage(ApiVersion apiVersion, + String uncoveredClass, + int configCount, + String pluginName, + boolean apbcBeanPresent) { + String name = simpleName(uncoveredClass); + if (apiVersion == ApiVersion.V2) { + if (apbcBeanPresent) { + return "BPMN-referenced class '" + name + "' (" + uncoveredClass + + ") is not registered via ActivityPrototypeBeanCreator " + + "and is not declared as a prototype @Bean in plugin '" + + pluginName + "'. Pass " + name + + ".class to ActivityPrototypeBeanCreator."; + } + return "BPMN-referenced class '" + name + "' (" + uncoveredClass + + ") is not registered as a prototype activity in plugin '" + + pluginName + "'. API V2 registers activities with " + + "ActivityPrototypeBeanCreator (static @Bean) or as a " + + "prototype-scoped @Bean. Neither was found for this class."; + } + return "BPMN-referenced class '" + name + "' (" + uncoveredClass + + ") is not provided as a @Bean in any of the " + configCount + + " @Configuration class(es) registered via getSpringConfigurations() " + + "of plugin '" + pluginName + "'. Add a @Bean method returning " + + name + " to one of the registered @Configuration classes."; + } + + private static String coverageSummaryMessage(ApiVersion apiVersion, + int configCount, + int referencedCount) { + if (apiVersion == ApiVersion.V2) { + return "getSpringConfigurations() registers " + configCount + + " @Configuration class(es); all " + referencedCount + + " BPMN delegate/listener reference(s) are registered as " + + "prototype activities."; + } + return "getSpringConfigurations() registers " + configCount + + " @Configuration class(es); all " + referencedCount + + " BPMN delegate/listener reference(s) are covered by a registered @Bean."; + } + + private static boolean hasActivityPrototypeBeanCreatorBean(Class configClass) { + try { + for (Method m : configClass.getDeclaredMethods()) { + if (hasAnnotationByName(m.getAnnotations()) + && ACTIVITY_PROTOTYPE_BEAN_CREATOR.equals(m.getReturnType().getName()) + && Modifier.isStatic(m.getModifiers()) + && m.getParameterCount() == 0) { + return true; + } + } + } catch (Throwable ignored) { + } + return false; + } + + // ==================== @Bean METHOD MAP (for scope checks) ==================== + + /** + * Returns a map from return-type name to the {@code @Bean}-annotated {@link Method} + * for all bean methods declared directly on {@code configClass}. + * Analogous to {@link #extractBeanReturnTypes} but retains the {@code Method} + * object so callers can inspect further annotations such as {@code @Scope}. + */ + private static Map extractBeanMethodMap(Class configClass, Logger logger) { + Map result = new LinkedHashMap<>(); + try { + for (Method m : configClass.getDeclaredMethods()) { + if (hasAnnotationByName(m.getAnnotations())) { + Class rt = m.getReturnType(); + if (rt != void.class && rt != Void.class) { + result.putIfAbsent(rt.getName(), m); + } + } + } + } catch (Throwable t) { + if (logger != null) { + logger.debug("Could not read @Bean methods of '" + + configClass.getName() + "' for scope check: " + t.getMessage()); + } + } + return result; + } + + /** + * Searches all registered config bean-method maps for the first {@code @Bean} + * method whose return type covers {@code referencedClass} – either by exact name + * or by assignability ({@code returnType.isAssignableFrom(referencedClass)}). + */ + private static Optional findCoveringMethod( + Map> configBeanMethods, + String referencedClass, + ClassLoader cl) { + + // 1. Exact match across all configs + for (Map methodMap : configBeanMethods.values()) { + Method m = methodMap.get(referencedClass); + if (m != null) { + return Optional.of(m); + } + } + + // 2. Assignability fallback + if (cl == null) { + return Optional.empty(); + } + Class ref; + try { + ref = Class.forName(referencedClass, false, cl); + } catch (ClassNotFoundException | LinkageError e) { + return Optional.empty(); + } + for (Map methodMap : configBeanMethods.values()) { + for (Map.Entry entry : methodMap.entrySet()) { + try { + Class returnType = Class.forName(entry.getKey(), false, cl); + if (returnType.isAssignableFrom(ref)) { + return Optional.of(entry.getValue()); + } + } catch (ClassNotFoundException | LinkageError ignored) { + // skip unresolvable types + } + } + } + return Optional.empty(); + } + + // ==================== @Scope READING ==================== + + /** + * Returns the {@code value()} of the {@code @Scope} annotation present on + * {@code beanMethod}, or {@code null} if no {@code @Scope} annotation is found. + * + *

    The annotation is identified by its fully-qualified class name to tolerate + * cases where the annotation was loaded by a different {@link ClassLoader} than + * the linter's own class loader.

    + */ + private static String getScopeValue(Method beanMethod) { + for (Annotation a : beanMethod.getAnnotations()) { + if (SCOPE_ANNOTATION.equals(a.annotationType().getName())) { + try { + Object value = a.annotationType().getMethod("value").invoke(a); + if (value instanceof String s && !s.isBlank()) { + return s; + } + // scopeName() is an alias for value() in some Spring versions + Object scopeName = a.annotationType().getMethod("scopeName").invoke(a); + if (scopeName instanceof String s && !s.isBlank()) { + return s; + } + } catch (Exception ignored) { + // Annotation structure unexpected – treat as no scope + } + return null; + } + } + return null; + } + + // ==================== MUTABLE FIELD DETECTION ==================== + + /** + * Returns {@code true} when the class identified by {@code className} declares at + * least one instance field that is neither {@code static} nor {@code final}. + * Such fields are a concurrency hazard when the bean is singleton-scoped. + */ + private static boolean hasMutableInstanceFields(String className, ClassLoader cl) { + if (cl == null) { + return false; + } + try { + Class clazz = Class.forName(className, false, cl); + for (Field f : clazz.getDeclaredFields()) { + int mod = f.getModifiers(); + if (!Modifier.isStatic(mod) && !Modifier.isFinal(mod)) { + return true; + } + } + } catch (ClassNotFoundException | LinkageError ignored) { + // Cannot load class – skip mutable-field check + } + return false; + } +} diff --git a/linter-core/src/main/java/dev/dsf/linter/util/api/ApiVersionDetector.java b/linter-core/src/main/java/dev/dsf/linter/util/api/ApiVersionDetector.java deleted file mode 100644 index 947dbeba..00000000 --- a/linter-core/src/main/java/dev/dsf/linter/util/api/ApiVersionDetector.java +++ /dev/null @@ -1,155 +0,0 @@ -package dev.dsf.linter.util.api; - -import dev.dsf.linter.exception.MissingServiceRegistrationException; - -import java.io.IOException; -import java.nio.file.*; -import java.util.List; -import java.util.Optional; - -import static dev.dsf.linter.constants.DsfApiConstants.V1_SERVICE_FILE; -import static dev.dsf.linter.constants.DsfApiConstants.V2_SERVICE_FILE; - -/** - *

    DSF API Version Detection Utility

    - * - *

    - * This utility class detects whether a given plugin project uses the - * Data Sharing Framework (DSF) BPE API version {@code v1} or {@code v2}. - * It works by inspecting known ServiceLoader registration directories or, - * if necessary, by scanning class paths as a fallback. - *

    - * - *

    Detection Strategy

    - *
      - *
    1. Primary: Search for ServiceLoader configuration files - * ({@code META-INF/services/dev.dsf.bpe.v1.ProcessPluginDefinition} or - * {@code ...v2...}) in common Maven/Gradle layouts.
    2. - *
    3. Fallback: If ServiceLoader config files are missing, - * perform a directory walk to identify class files or resources with known DSF API package names.
    4. - *
    - * - *

    Usage Recommendations

    - *
      - *
    • Use {@link #detect(Path)} for a non-throwing best-effort version detection.
    • - *
    • Use {@link #detectOrThrow(Path)} to enforce registration presence (e.g., in linting workflows).
    • - *
    - * - *

    - * This class is stateless and safe for concurrent use. - *

    - * - * @see DetectedVersion - * @see ApiVersion - */ -public final class ApiVersionDetector -{ - /** - * List of known locations (relative to the project root) where ServiceLoader configuration files might reside. - * These cover common layouts for Maven and Gradle builds. - */ - private static final List SERVICE_DIRS = List.of( - "META-INF/services", // generic - "src/main/resources/META-INF/services", // source tree - "target/classes/META-INF/services", // Maven - "build/resources/main/META-INF/services", // Gradle resources - "build/classes/java/main/META-INF/services" // Gradle classes - ); - - private static final String V2_FILE = V2_SERVICE_FILE; - private static final String V1_FILE = V1_SERVICE_FILE; - - /** - * Attempts to detect the DSF BPE API version by scanning known ServiceLoader paths - * for provider configuration files. If none are found, a fallback scan is performed. - * This method does not throw and returns an empty {@link Optional} if the version - * cannot be determined. - * - * @param root the project root directory to scan - * @return an {@link Optional} containing the detected version and file path, or empty if none found - */ - public Optional detect(Path root) throws MissingServiceRegistrationException - { - // Step 1: ServiceLoader-based detection - for (String rel : SERVICE_DIRS) - { - Path dir = root.resolve(rel); - if (Files.isRegularFile(dir.resolve(V2_FILE))) - return Optional.of(new DetectedVersion(ApiVersion.V2, dir.resolve(V2_FILE), DetectionSource.SERVICE_FILE)); - if (Files.isRegularFile(dir.resolve(V1_FILE))) - return Optional.of(new DetectedVersion(ApiVersion.V1, dir.resolve(V1_FILE), DetectionSource.SERVICE_FILE)); - } - - // Step 2: Fallback scan for class/package names - return detectByFallbackOptional(root); - } - - /** - * Detects the DSF API version and throws a {@link MissingServiceRegistrationException} - * if no ServiceLoader registration file is found in any known location. - * - *

    This method should be used when ServiceLoader registration is considered mandatory.

    - * - * @param root the project root directory to scan - * @return the detected version with its origin - * @throws MissingServiceRegistrationException if no valid ServiceLoader file is found - */ - public DetectedVersion detectOrThrow(Path root) throws MissingServiceRegistrationException - { - return detect(root).orElseThrow(() -> - new MissingServiceRegistrationException("No ServiceLoader registration found under META-INF/services")); - } - - /** - * Performs a fallback scan by walking the file tree below common compiled output directories - * (e.g., {@code target/classes} or {@code build/classes/java/main}) to detect files - * that reference DSF BPE API package names. - * - *

    This method prefers API version {@code v2} over {@code v1} if both are detected.

    - * - * @param root the project root directory to scan - * @return an {@link Optional} containing the detected version and path if found; otherwise empty - */ - private Optional detectByFallbackOptional(Path root) throws MissingServiceRegistrationException - { - // Prefer compiled outputs; otherwise walk from project root - Path start = Files.isDirectory(root.resolve("target/classes")) - ? root.resolve("target/classes") - : (Files.isDirectory(root.resolve("build/classes/java/main")) - ? root.resolve("build/classes/java/main") - : root); - - try (var stream = Files.walk(start)) - { - final boolean[] seen = new boolean[2]; // [0]=v1, [1]=v2 - final Path[] firstSeen = new Path[2]; - - stream.forEach(p -> - { - String name = p.getFileName() != null ? p.getFileName().toString() : ""; - if (!seen[1] && name.startsWith("dev.dsf.bpe.v2")) - { - seen[1] = true; - firstSeen[1] = p; - } - else if (!seen[0] && name.startsWith("dev.dsf.bpe.v1")) - { - seen[0] = true; - firstSeen[0] = p; - } - }); - - if (seen[1]) - return Optional.of(new DetectedVersion(ApiVersion.V2, firstSeen[1], DetectionSource.FALLBACK_SCAN)); - if (seen[0]) - return Optional.of(new DetectedVersion(ApiVersion.V1, firstSeen[0], DetectionSource.FALLBACK_SCAN)); - - return Optional.empty(); - } - catch (IOException e) - { - detectOrThrow(root); - return Optional.empty(); - } - } -} diff --git a/linter-core/src/main/java/dev/dsf/linter/util/api/DetectedVersion.java b/linter-core/src/main/java/dev/dsf/linter/util/api/DetectedVersion.java deleted file mode 100644 index f3f20291..00000000 --- a/linter-core/src/main/java/dev/dsf/linter/util/api/DetectedVersion.java +++ /dev/null @@ -1,13 +0,0 @@ -package dev.dsf.linter.util.api; - -import java.nio.file.Path; - -/** - * Value object containing API version detection results with provenance information. - * - * @param version the detected API version - * @param foundAt the path where evidence was found - * @param source how the version was detected - */ -public record DetectedVersion(ApiVersion version, Path foundAt, DetectionSource source) {} - diff --git a/linter-core/src/main/java/dev/dsf/linter/util/api/DetectionSource.java b/linter-core/src/main/java/dev/dsf/linter/util/api/DetectionSource.java deleted file mode 100644 index d1b40b95..00000000 --- a/linter-core/src/main/java/dev/dsf/linter/util/api/DetectionSource.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.dsf.linter.util.api; - -/** - * Indicates how the API version was detected. - */ -public enum DetectionSource { - SERVICE_FILE, FALLBACK_SCAN -} diff --git a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/BpmnModelUtils.java b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/BpmnModelUtils.java index 7dfd31b3..a3e982ff 100644 --- a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/BpmnModelUtils.java +++ b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/BpmnModelUtils.java @@ -51,6 +51,18 @@ public static Optional extractImplementationClass(BaseElement element) { return getCamundaClassFromMessageEvents(definitions); } + + /** + * Returns whether the start event is triggered by a message (has a {@link MessageEventDefinition}). + * + * @param startEvent the BPMN start event to inspect + * @return {@code true} if the start event has a message event definition + */ + public static boolean isMessageStartEvent(StartEvent startEvent) { + return !startEvent.getEventDefinitions().isEmpty() + && startEvent.getEventDefinitions().iterator().next() instanceof MessageEventDefinition; + } + /** * Scans a collection of event definitions to find the Camunda class from a MessageEventDefinition. * diff --git a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnEndEventLinter.java b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnEndEventLinter.java index cf205d84..41355527 100644 --- a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnEndEventLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnEndEventLinter.java @@ -53,7 +53,7 @@ public static void lintMessageEndEvent( // 1. Check event name if (isEmpty(endEvent.getName())) { - issues.add(new BpmnElementLintItem(LinterSeverity.WARN, LintingType.BPMN_EVENT_NAME_EMPTY, + issues.add(new BpmnElementLintItem(LinterSeverity.WARN, LintingType.BPMN_MESSAGE_END_EVENT_NAME_EMPTY, elementId, bpmnFile, processId, "'" + elementId + "' has no name")); } else { issues.add(BpmnElementLintItem.success( @@ -103,7 +103,7 @@ public static void lintGenericEndEvent( // 1. Name check for non-SubProcess end events if (!(endEvent.getParentElement() instanceof SubProcess)) { if (isEmpty(endEvent.getName())) { - issues.add(BpmnElementLintItem.of(LinterSeverity.WARN, LintingType.BPMN_END_EVENT_NOT_PART_OF_SUB_PROCESS, + issues.add(BpmnElementLintItem.of(LinterSeverity.WARN, LintingType.BPMN_END_EVENT_NOT_PART_OF_SUB_PROCESS_AND_HAS_NO_NAME, elementId, bpmnFile, processId)); } else { issues.add(BpmnElementLintItem.success( diff --git a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnIntermediateThrowEventLinter.java b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnIntermediateThrowEventLinter.java index b1db020f..257f9e30 100644 --- a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnIntermediateThrowEventLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnIntermediateThrowEventLinter.java @@ -53,7 +53,7 @@ public static void lintMessageIntermediateThrowEvent( // 1. Check event name if (isEmpty(throwEvent.getName())) { - issues.add(new BpmnElementLintItem(LinterSeverity.WARN, LintingType.BPMN_EVENT_NAME_EMPTY, + issues.add(new BpmnElementLintItem(LinterSeverity.WARN, LintingType.BPMN_MESSAGE_INTERMEDIATE_THROW_EVENT_NAME_EMPTY, elementId, bpmnFile, processId, "'" + elementId + "' has no name")); } else { issues.add(BpmnElementLintItem.success( @@ -84,12 +84,9 @@ public static void lintMessageIntermediateThrowEvent( if (msgDef.getMessage() != null) { String messageName = msgDef.getMessage().getName(); - issues.add(new BpmnElementLintItem(LinterSeverity.WARN, LintingType.BPMN_MESSAGE_INTERMEDIATE_THROW_EVENT_HAS_MESSAGE, + issues.add(new BpmnElementLintItem(LinterSeverity.INFO, LintingType.BPMN_MESSAGE_INTERMEDIATE_THROW_EVENT_HAS_MESSAGE_REFERENCE, elementId, bpmnFile, processId, - "Message Intermediate Throw Event has a message with name: " + messageName)); - } else { - issues.add(BpmnElementLintItem.of(LinterSeverity.WARN, LintingType.BPMN_MESSAGE_INTERMEDIATE_THROW_EVENT_HAS_MESSAGE, - elementId, bpmnFile, processId)); + "Message Intermediate Throw Event has a message reference with name: " + messageName)); } // Check execution listener classes diff --git a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnListenerLinter.java b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnListenerLinter.java index 51191619..f5cf7e19 100644 --- a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnListenerLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnListenerLinter.java @@ -40,7 +40,7 @@ * if class is not found *
  • Interface Implementation: Validates that execution listener classes implement * the correct interface based on the API version ({@code ExecutionListener} interface for both V1 and V2 API). - * Reports {@link LintingType#BPMN_EXECUTION_LISTENER_NOT_IMPLEMENTING_REQUIRED_INTERFACE} with + * Reports {@link LintingType#BPMN_EXECUTION_LISTENER_CLASS_NOT_IMPLEMENTING_REQUIRED_INTERFACE} with * {@link LinterSeverity#ERROR} if interface is not implemented
  • * * @@ -69,10 +69,10 @@ *

    *
      *
    • practitionerRole: Validates that the practitionerRole input parameter has a non-empty value. - * Reports {@link LintingType#BPMN_PRACTITIONER_ROLE_HAS_NO_VALUE_OR_NULL} with {@link LinterSeverity#ERROR} + * Reports {@link LintingType#BPMN_USER_TASK_LISTENER_PRACTITIONER_ROLE_INPUT_EMPTY} with {@link LinterSeverity#ERROR} * if extends {@code DefaultUserTaskListener}, {@link LinterSeverity#WARN} otherwise
    • *
    • practitioners: Validates that the practitioners input parameter has a non-empty value. - * Reports {@link LintingType#BPMN_PRACTITIONERS_HAS_NO_VALUE_OR_NULL} with {@link LinterSeverity#ERROR} + * Reports {@link LintingType#BPMN_USER_TASK_LISTENER_PRACTITIONERS_INPUT_EMPTY} with {@link LinterSeverity#ERROR} * if extends {@code DefaultUserTaskListener}, {@link LinterSeverity#WARN} otherwise
    • *
    * @@ -164,7 +164,7 @@ public static void checkExecutionListenerClasses( if (doesNotImplementCorrectInterface(implClass, projectRoot, apiVersion, BpmnElementType.EXECUTION_LISTENER)) { String expectedInterface = getExpectedInterfaceDescription(apiVersion, BpmnElementType.EXECUTION_LISTENER); issues.add(new BpmnElementLintItem(LinterSeverity.ERROR, - LintingType.BPMN_EXECUTION_LISTENER_NOT_IMPLEMENTING_REQUIRED_INTERFACE, + LintingType.BPMN_EXECUTION_LISTENER_CLASS_NOT_IMPLEMENTING_REQUIRED_INTERFACE, elementId, bpmnFile, processId, "Execution listener '" + implClass + "' does not implement " + expectedInterface + ".")); } else { @@ -336,8 +336,8 @@ private static void validateInputParameter( if (value == null || value.trim().isEmpty()) { LintingType type = "practitionerRole".equals(paramName) - ? LintingType.BPMN_PRACTITIONER_ROLE_HAS_NO_VALUE_OR_NULL - : LintingType.BPMN_PRACTITIONERS_HAS_NO_VALUE_OR_NULL; + ? LintingType.BPMN_USER_TASK_LISTENER_PRACTITIONER_ROLE_INPUT_EMPTY + : LintingType.BPMN_USER_TASK_LISTENER_PRACTITIONERS_INPUT_EMPTY; issues.add(BpmnElementLintItem.of(severity, type, elementId, bpmnFile, processId)); } else { issues.add(BpmnElementLintItem.success(elementId, bpmnFile, processId, diff --git a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnMessageEventImplementationLinter.java b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnMessageEventImplementationLinter.java index f37819da..56c68799 100644 --- a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnMessageEventImplementationLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnMessageEventImplementationLinter.java @@ -10,14 +10,21 @@ import java.util.List; import static dev.dsf.linter.classloading.ClassInspector.*; +import static dev.dsf.linter.constants.DsfApiConstants.*; /** * Utility class for validating implementation classes for message events. *

    - * This class provides validation methods to ensure that implementation classes for message events - * (Intermediate Throw Events and End Events) exist and implement the correct interfaces - * based on the element type and API version. + * Validates implementation classes for BPMN Message Intermediate Throw Events and Message End Events + * against the correct DSF API interface requirements per API version: *

    + *
      + *
    • V1: Implementation class should extend {@code AbstractTaskMessageSend} (WARN if not) + * and must implement {@code JavaDelegate} (ERROR if not).
    • + *
    • V2 MessageEndEvent: Implementation class must implement {@code MessageEndEvent}.
    • + *
    • V2 MessageIntermediateThrowEvent: Implementation class must implement + * {@code MessageIntermediateThrowEvent}.
    • + *
    */ public final class BpmnMessageEventImplementationLinter { @@ -26,16 +33,20 @@ private BpmnMessageEventImplementationLinter() { } /** - * Validates implementation class for Message Events (IntermediateThrow, End) - * with element-specific interface requirements. + * Validates the implementation class for a BPMN Message Event (Intermediate Throw or End Event). * - * @param implClass the implementation class name to validate - * @param elementId the identifier of the BPMN element being validated - * @param elementType the type of BPMN element - * @param issues the list of {@link BpmnElementLintItem} to which lint issues or success items will be added + *

    For V1, checks that the class extends {@code AbstractTaskMessageSend} (warning) and + * implements {@code JavaDelegate} (error). For V2, checks that the class implements the + * element-type-specific DSF activity interface.

    + * + * @param implClass the fully-qualified implementation class name to validate + * @param elementId the BPMN element identifier + * @param elementType the BPMN element type ({@code MESSAGE_END_EVENT} or + * {@code MESSAGE_INTERMEDIATE_THROW_EVENT}) + * @param issues list to which lint items will be added * @param bpmnFile the BPMN file under lint - * @param processId the identifier of the BPMN process containing the element - * @param apiVersion the API version to use for interface validation + * @param processId the BPMN process identifier + * @param apiVersion the DSF API version * @param projectRoot the project root directory */ public static void lintMessageEventImplementationClass( @@ -50,36 +61,104 @@ public static void lintMessageEventImplementationClass( // Step 1: Check class existence if (!classExists(implClass, projectRoot)) { - issues.add(new BpmnElementLintItem(LinterSeverity.ERROR, LintingType.BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_FOUND, - elementId, bpmnFile, processId, "Implementation class not found: " + implClass)); + issues.add(new BpmnElementLintItem( + LinterSeverity.ERROR, + LintingType.BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_FOUND, + elementId, bpmnFile, processId, + "Implementation class not found: " + implClass)); return; } - // Step 2: ELEMENT-SPECIFIC interface check - if (doesNotImplementCorrectInterface(implClass, projectRoot, apiVersion, elementType)) { - String expectedInterface = getExpectedInterfaceDescription(apiVersion, elementType); - - switch (apiVersion) { - case V1 -> issues.add( - new BpmnElementLintItem(LinterSeverity.ERROR, LintingType.BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_IMPLEMENTING_JAVA_DELEGATE, - elementId, bpmnFile, processId, "Implementation class does not implement JavaDelegate: " + implClass)); - case V2 -> issues.add( - new BpmnElementLintItem(LinterSeverity.ERROR, LintingType.BPMN_END_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING, - elementId, bpmnFile, processId, "Implementation class '" + implClass - + "' does not implement " + expectedInterface + ".")); - } - return; + // Step 2: Version-specific interface validation + if (apiVersion == ApiVersion.V1) { + lintV1MessageEventImplementation(implClass, elementId, elementType, issues, bpmnFile, processId, projectRoot); + } else if (apiVersion == ApiVersion.V2) { + lintV2MessageEventImplementation(implClass, elementId, elementType, issues, bpmnFile, processId, projectRoot); + } + //Handling of default or unknown API versions is intentionally omitted. + // This point is unreachable because the API version is validated at an earlier entry point; + // if unknown, an exception is thrown, and the linter skips validation to move on to the next plugin. + } + + /** + * V1 validation: checks AbstractTaskMessageSend extension (WARN) and JavaDelegate + * implementation (ERROR). This mirrors the pattern used by + * {@code BpmnTaskLinter.lintSendTask} for V1 Send Tasks. + */ + private static void lintV1MessageEventImplementation( + String implClass, + String elementId, + BpmnElementType elementType, + List issues, + File bpmnFile, + String processId, + File projectRoot) { + + boolean extendsAbstract = isSubclassOf(implClass, V1_ABSTRACT_TASK_MESSAGE_SEND, projectRoot); + LintingType notExtendingType = elementType == BpmnElementType.MESSAGE_END_EVENT + ? LintingType.BPMN_MESSAGE_END_EVENT_IMPLEMENTATION_CLASS_NOT_EXTENDING_ABSTRACT_TASK_MESSAGE_SEND + : LintingType.BPMN_MESSAGE_INTERMEDIATE_THROW_EVENT_IMPLEMENTATION_CLASS_NOT_EXTENDING_ABSTRACT_TASK_MESSAGE_SEND; + + if (extendsAbstract) { + issues.add(BpmnElementLintItem.success(elementId, bpmnFile, processId, + "Implementation class '" + implClass + "' extends " + getSimpleName(V1_ABSTRACT_TASK_MESSAGE_SEND) + ".")); + } else { + issues.add(new BpmnElementLintItem(LinterSeverity.WARN, notExtendingType, + elementId, bpmnFile, processId, + "Implementation class '" + implClass + "' does not extend '" + + getSimpleName(V1_ABSTRACT_TASK_MESSAGE_SEND) + "'.")); + } + + boolean implementsDelegate = implementsInterface(implClass, V1_JAVA_DELEGATE, projectRoot); + + if (implementsDelegate) { + issues.add(BpmnElementLintItem.success(elementId, bpmnFile, processId, + "Implementation class '" + implClass + "' implements " + getSimpleName(V1_JAVA_DELEGATE) + ".")); + } else { + issues.add(new BpmnElementLintItem( + LinterSeverity.ERROR, + LintingType.BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_IMPLEMENTING_JAVA_DELEGATE, + elementId, bpmnFile, processId, + "Implementation class '" + implClass + "' does not implement '" + + getSimpleName(V1_JAVA_DELEGATE) + "'.")); } + } - // Step 3: Success - String implementedInterface = findImplementedInterface(implClass, projectRoot, apiVersion, elementType); - String interfaceName = implementedInterface != null - ? getSimpleName(implementedInterface) - : getExpectedInterfaceDescription(apiVersion, elementType); + /** + * V2 validation: checks the element-type-specific DSF activity interface. + *
      + *
    • MESSAGE_END_EVENT → must implement {@code dev.dsf.bpe.v2.activity.MessageEndEvent}
    • + *
    • MESSAGE_INTERMEDIATE_THROW_EVENT → must implement + * {@code dev.dsf.bpe.v2.activity.MessageIntermediateThrowEvent}
    • + *
    + */ + private static void lintV2MessageEventImplementation( + String implClass, + String elementId, + BpmnElementType elementType, + List issues, + File bpmnFile, + String processId, + File projectRoot) { + + String expectedInterface = elementType == BpmnElementType.MESSAGE_END_EVENT + ? V2_MESSAGE_END_EVENT + : V2_MESSAGE_INTERMEDIATE_THROW; - issues.add(BpmnElementLintItem.success( - elementId, bpmnFile, processId, - "Implementation class '" + implClass + "' implements " + interfaceName + ".")); + LintingType notImplementingType = elementType == BpmnElementType.MESSAGE_END_EVENT + ? LintingType.BPMN_END_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING + : LintingType.BPMN_INTERMEDIATE_THROW_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING; + + boolean implementsInterface = implementsInterface(implClass, expectedInterface, projectRoot); + + if (!implementsInterface) { + issues.add(new BpmnElementLintItem(LinterSeverity.ERROR, notImplementingType, + elementId, bpmnFile, processId, + "Implementation class '" + implClass + "' does not implement " + + getSimpleName(expectedInterface) + ".")); + } else { + issues.add(BpmnElementLintItem.success(elementId, bpmnFile, processId, + "Implementation class '" + implClass + "' implements " + getSimpleName(expectedInterface) + ".")); + } } } - diff --git a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnMessageLinter.java b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnMessageLinter.java index db2722fa..379f8977 100644 --- a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnMessageLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnMessageLinter.java @@ -46,7 +46,7 @@ public static void checkMessageName( var locator = FhirResourceLocator.create(projectRoot); // Check for a matching ActivityDefinition. - if (locator.activityDefinitionExists(messageName, projectRoot)) { + if (locator.activityDefinitionExists(messageName)) { issues.add(BpmnElementLintItem.success( elementId, bpmnFile, @@ -62,7 +62,7 @@ public static void checkMessageName( } // Check for a matching StructureDefinition. - if (locator.structureDefinitionExists(messageName, projectRoot)) { + if (locator.structureDefinitionExists(messageName)) { issues.add(BpmnElementLintItem.success( elementId, bpmnFile, @@ -102,7 +102,7 @@ public static void lintFhirReferences( FhirResourceLocator locator, File projectRoot) { - boolean activityDefFound = locator.activityDefinitionExists(msgName, projectRoot); + boolean activityDefFound = locator.activityDefinitionExists(msgName); if (!activityDefFound) { issues.add(new BpmnElementLintItem( @@ -115,7 +115,7 @@ public static void lintFhirReferences( "ActivityDefinition found for messageName: '" + msgName + "'")); } - if (!locator.structureDefinitionExists(msgName, projectRoot)) { + if (!locator.structureDefinitionExists(msgName)) { issues.add(new BpmnElementLintItem( LinterSeverity.ERROR, LintingType.BPMN_NO_STRUCTURE_DEFINITION_FOUND_FOR_MESSAGE, elementId, bpmnFile, processId, diff --git a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnStartEventLinter.java b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnStartEventLinter.java index f10241b0..b289b6f9 100644 --- a/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnStartEventLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/util/bpmn/linters/BpmnStartEventLinter.java @@ -48,7 +48,7 @@ public static void lintMessageStartEvent( // 1. Check event name if (isEmpty(startEvent.getName())) { issues.add(new BpmnElementLintItem( - LinterSeverity.WARN, LintingType.BPMN_EVENT_NAME_EMPTY, + LinterSeverity.WARN, LintingType.BPMN_MESSAGE_START_EVENT_NAME_EMPTY, elementId, bpmnFile, processId, "'" + elementId + "' has no name.")); } else { @@ -97,12 +97,12 @@ public static void lintGenericStartEvent( if (!(startEvent.getParentElement() instanceof SubProcess)) { if (isEmpty(startEvent.getName())) { issues.add(BpmnElementLintItem.of( - LinterSeverity.WARN, LintingType.BPMN_START_EVENT_NOT_PART_OF_SUB_PROCESS, + LinterSeverity.WARN, LintingType.BPMN_START_EVENT_NOT_PART_OF_SUB_PROCESS_AND_HAS_NO_NAME, elementId, bpmnFile, processId)); } else { issues.add(BpmnElementLintItem.success( elementId, bpmnFile, processId, - "Generic start event has a non-empty name: '" + startEvent.getName() + "'")); + "Generic start event is not a part of subprocess and has a non-empty name: '" + startEvent.getName() + "'")); } } diff --git a/linter-core/src/main/java/dev/dsf/linter/util/linting/AbstractFhirInstanceLinter.java b/linter-core/src/main/java/dev/dsf/linter/util/linting/AbstractFhirInstanceLinter.java index 0c651c52..f2b7e963 100644 --- a/linter-core/src/main/java/dev/dsf/linter/util/linting/AbstractFhirInstanceLinter.java +++ b/linter-core/src/main/java/dev/dsf/linter/util/linting/AbstractFhirInstanceLinter.java @@ -1,5 +1,7 @@ package dev.dsf.linter.util.linting; +import dev.dsf.linter.output.LinterSeverity; +import dev.dsf.linter.output.LintingType; import dev.dsf.linter.output.item.FhirElementLintItem; import org.w3c.dom.Document; import org.w3c.dom.Node; @@ -8,6 +10,7 @@ import javax.xml.xpath.*; import java.io.File; import java.util.List; +import java.util.Set; /** * Abstract base class for all FHIR resource linters used in the DSF linting framework. @@ -163,6 +166,126 @@ protected String val(Node ctx, String xp) return extractSingleNodeValue(ctx, xp); } + /** + * Builds a stable resource reference used in lint messages. + * Prefers the value at the provided XPath (typically a canonical URL), + * and falls back to the source file name when missing. + * + * @param document the FHIR XML document + * @param file the file from which the resource was loaded + * @param referenceXPath absolute XPath to the preferred reference value + * @return canonical reference or file name as fallback + */ + protected String resolveReference(Document document, File file, String referenceXPath) + { + String ref = val(document, referenceXPath); + return !blank(ref) ? ref : file.getName(); + } + + /** + * Applies a reusable placeholder validation rule. + * + * @param document XML document to inspect + * @param valueXPath XPath expression resolving the value to validate + * @param expectedPlaceholder expected placeholder token + * @param requirePresence if true, missing values are reported as issue + * @param containsMatch if true, value must contain placeholder; otherwise exact match is required + * @param severity lint severity used on validation failure + * @param lintingType linting type used on validation failure + * @param file source file used for reporting + * @param ref resource reference used for reporting + * @param errorMessage message used on validation failure + * @param successMessage message used on validation success + * @param out target list for lint items + */ + protected void checkPlaceholder(Document document, + String valueXPath, + String expectedPlaceholder, + boolean requirePresence, + boolean containsMatch, + LinterSeverity severity, + LintingType lintingType, + File file, + String ref, + String errorMessage, + String successMessage, + List out) + { + String value = val(document, valueXPath); + boolean missing = (value == null); + boolean matches = !missing && (containsMatch + ? value.contains(expectedPlaceholder) + : expectedPlaceholder.equals(value)); + + if ((requirePresence && missing) || (!missing && !matches)) + out.add(new FhirElementLintItem(severity, lintingType, file, ref, errorMessage)); + else + out.add(ok(file, ref, successMessage)); + } + + + /** + * Checks that an XPath-resolved value is non-blank and reports a typed default issue. + */ + protected void checkRequiredValue(Document document, + String valueXPath, + File file, + String ref, + LintingType missingType, + String successMessage, + List out) + { + String value = val(document, valueXPath); + if (blank(value)) + out.add(FhirElementLintItem.of(LinterSeverity.ERROR, missingType, file, ref)); + else + out.add(ok(file, ref, successMessage)); + } + + /** + * Checks that a code in a tag list matches required system and allowed code set. + */ + protected boolean hasAllowedTag(NodeList tags, String requiredSystem, Set allowedCodes) + { + if (tags == null) return false; + + for (int i = 0; i < tags.getLength(); i++) + { + String sys = val(tags.item(i), "./*[local-name()='system']/@value"); + String code = val(tags.item(i), "./*[local-name()='code']/@value"); + if (requiredSystem.equals(sys) && allowedCodes.contains(code)) + return true; + } + + return false; + } + + /** + * Applies standard DSF placeholder checks for version/date fields. + * Rule set: version mismatch => ERROR, date mismatch => WARN. + */ + protected void checkVersionDatePlaceholders(Document document, + String versionXPath, + String dateXPath, + File file, + String ref, + LintingType versionLintType, + LintingType dateLintType, + String versionErrorMessage, + String dateErrorMessage, + String versionSuccessMessage, + String dateSuccessMessage, + List out) + { + checkPlaceholder(document, versionXPath, "#{version}", + true, false, LinterSeverity.ERROR, versionLintType, + file, ref, versionErrorMessage, versionSuccessMessage, out); + + checkPlaceholder(document, dateXPath, "#{date}", + true, false, LinterSeverity.WARN, dateLintType, + file, ref, dateErrorMessage, dateSuccessMessage, out); + } + /** * Convenience wrapper for {@link #evaluateXPath(Node, String)}. * diff --git a/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirAuthorizationCache.java b/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirAuthorizationCache.java index 5d286354..94a68e12 100644 --- a/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirAuthorizationCache.java +++ b/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirAuthorizationCache.java @@ -87,6 +87,12 @@ public final class FhirAuthorizationCache public static final String CS_ORG_ROLE = "http://dsf.dev/fhir/CodeSystem/organization-role"; + /** + * DSF core CodeSystem URI for BPMN message slices ({@code message-name}, {@code business-key}, + * {@code correlation-key}). + */ + public static final String CS_BPMN_MESSAGE = "http://dsf.dev/fhir/CodeSystem/bpmn-message"; + /** * FHIR Task URI for Task status values. */ @@ -96,6 +102,25 @@ public final class FhirAuthorizationCache private static final Map> CODES_BY_SYSTEM = new ConcurrentHashMap<>(); + /** + * Set of CodeSystem URIs that are referenced by at least one known ValueSet's + * {@code compose.include.system} entry. Populated during + * {@link #seedFromProjectAndClasspath(File)}. + */ + private static final Set SYSTEMS_IN_VALUE_SETS = ConcurrentHashMap.newKeySet(); + + /** + * Index of known ValueSets keyed by their canonical {@code url}. + * Each entry maps to the set of {@code compose.include.system} URIs + * declared by that ValueSet. Populated during + * {@link #seedFromProjectAndClasspath(File)}. + * + *

    Used for binding-driven terminology checks, e.g., + * verifying that a {@code Task.input.type.coding.system} is allowed by the + * specific ValueSet referenced in a profile's {@code binding.valueSet}.

    + */ + private static final Map> SYSTEMS_PER_VALUE_SET = new ConcurrentHashMap<>(); + static { // Register official DSF codes (release v1.7) @@ -121,6 +146,10 @@ public final class FhirAuthorizationCache "draft", "requested", "received", "accepted", "rejected", "ready", "cancelled", "in-progress", "on-hold", "failed", "completed", "entered-in-error")); + register(CS_BPMN_MESSAGE, Set.of("message-name", "business-key", "correlation-key")); + + // The bpmn-message CodeSystem is always included in DSF's well-known ValueSets + SYSTEMS_IN_VALUE_SETS.add(CS_BPMN_MESSAGE); } private FhirAuthorizationCache() { /* Utility class – no instantiation */ } @@ -277,7 +306,7 @@ private static void dumpStatistics() /** * Seeds the CodeSystem cache from both the project directory (disk) and the project classpath - * (dependencies & plugin JAR). Keeps parsing logic centralized by materializing classpath resources + * (dependencies and plugin JAR). Keeps parsing logic centralized by materializing classpath resources * to temporary files. * * @param projectRoot the root of the project used to determine base traversal path and classpath setup @@ -285,12 +314,14 @@ private static void dumpStatistics() public static void seedFromProjectAndClasspath(File projectRoot) { Objects.requireNonNull(projectRoot, "projectRoot"); + + // ---- CodeSystem seeding ---- Set allCodeSystemFiles = new LinkedHashSet<>(findCodeSystemsOnDisk(projectRoot)); // 2) Classpath scan: fhir/CodeSystem/*.xml and *.json from dependency JARs or directories try { ClassLoader cl = getOrCreateProjectClassLoader(projectRoot); - allCodeSystemFiles.addAll(findCodeSystemsOnClasspath(cl)); + allCodeSystemFiles.addAll(findResourcesOnClasspath(cl, "fhir/CodeSystem")); } catch (Exception e) { logger.debug("[CodeSystem-Cache] Failed to scan classpath: " + e.getMessage()); // keep going; disk results might still be sufficient @@ -301,36 +332,65 @@ public static void seedFromProjectAndClasspath(File projectRoot) loadCodeSystemFile(cs); } + // ---- ValueSet seeding (compose.include.system → SYSTEMS_IN_VALUE_SETS) ---- + Set allValueSetFiles = new LinkedHashSet<>(findValueSetsOnDisk(projectRoot)); + try { + ClassLoader cl = getOrCreateProjectClassLoader(projectRoot); + allValueSetFiles.addAll(findResourcesOnClasspath(cl, "fhir/ValueSet")); + } catch (Exception e) { + logger.debug("[ValueSet-Cache] Failed to scan classpath: " + e.getMessage()); + } + for (File vs : allValueSetFiles) { + loadValueSetFile(vs); + } + dumpStatistics(); } // ---- Helper methods ---- /** - * Returns CodeSystem files under typical project locations (no changes to your current logic). + * Returns CodeSystem files under typical project locations. */ - private static Collection findCodeSystemsOnDisk(File projectRoot) - { - List candidates = List.of( + private static Collection findCodeSystemsOnDisk(File projectRoot) { + return findFhirResourcesOnDisk(projectRoot, List.of( "src/main/resources/fhir/CodeSystem", "target/classes/fhir/CodeSystem", - "fhir/CodeSystem" // exploded plugin root case - ); + "fhir/CodeSystem")); + } + + /** + * Returns ValueSet files under typical project locations. + */ + private static Collection findValueSetsOnDisk(File projectRoot) { + return findFhirResourcesOnDisk(projectRoot, List.of( + "src/main/resources/fhir/ValueSet", + "target/classes/fhir/ValueSet", + "fhir/ValueSet")); + } + + /** + * Generic disk scanner: lists {@code .xml} and {@code .json} files under the given + * relative subdirectories of {@code projectRoot}. + */ + private static Collection findFhirResourcesOnDisk(File projectRoot, List candidates) { List out = new ArrayList<>(); for (String dir : candidates) { File d = new File(projectRoot, dir); - File[] xmls = d.isDirectory() ? d.listFiles(f -> f.isFile() && (f.getName().endsWith(".xml") || f.getName().endsWith(".json"))) : null; - if (xmls != null) out.addAll(Arrays.asList(xmls)); + File[] files = d.isDirectory() + ? d.listFiles(f -> f.isFile() && (f.getName().endsWith(".xml") || f.getName().endsWith(".json"))) + : null; + if (files != null) out.addAll(Arrays.asList(files)); } return out; } /** - * Finds CodeSystem XMLs and JSONs on the classpath under "fhir/CodeSystem" (both directories and JARs). + * Finds FHIR resource files on the classpath under {@code basePath} + * (both directories and JARs), materializing JAR entries to temp files. */ - private static Collection findCodeSystemsOnClasspath(ClassLoader cl) throws IOException + private static Collection findResourcesOnClasspath(ClassLoader cl, String basePath) throws IOException { - final String basePath = "fhir/CodeSystem"; List out = new ArrayList<>(); // A) enumerate basePath URLs (dirs or inside JARs) @@ -342,8 +402,9 @@ private static Collection findCodeSystemsOnClasspath(ClassLoader cl) throw if ("file".equals(protocol)) { // Directory on classpath -> list *.xml and *.json File dir = new File(url.getPath()); - File[] files = dir.isDirectory() ? dir.listFiles(f -> f.isFile() && - (f.getName().endsWith(".xml") || f.getName().endsWith(".json"))) : null; + File[] files = dir.isDirectory() + ? dir.listFiles(f -> f.isFile() && (f.getName().endsWith(".xml") || f.getName().endsWith(".json"))) + : null; if (files != null) out.addAll(Arrays.asList(files)); } else if ("jar".equals(protocol)) { // JAR -> iterate entries @@ -356,7 +417,7 @@ private static Collection findCodeSystemsOnClasspath(ClassLoader cl) throw && (e.getName().endsWith(".xml") || e.getName().endsWith(".json"))) { // materialize to temp file String fileName = Paths.get(e.getName()).getFileName().toString(); - Path tmp = Files.createTempFile("cs-", "-" + fileName); + Path tmp = Files.createTempFile("fhir-", "-" + fileName); try (InputStream in = jar.getInputStream(e)) { Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING); } @@ -366,8 +427,7 @@ private static Collection findCodeSystemsOnClasspath(ClassLoader cl) throw } } } catch (IOException ioe) { - logger.debug("[CodeSystem-Cache] Failed to read JAR: " + ioe.getMessage()); - // ignore this JAR and keep going + logger.debug("[FHIR-Cache] Failed to read JAR (" + basePath + "): " + ioe.getMessage()); } } } @@ -376,6 +436,74 @@ private static Collection findCodeSystemsOnClasspath(ClassLoader cl) throw return out.stream().distinct().collect(Collectors.toList()); } + /** + * Parses a ValueSet file (XML or JSON) and registers each + * {@code compose.include.system} URI into {@link #SYSTEMS_IN_VALUE_SETS}. + */ + private static void loadValueSetFile(File f) { + String name = f.getName().toLowerCase(); + if (name.endsWith(".xml")) loadValueSetXml(f.toPath()); + else if (name.endsWith(".json")) loadValueSetJson(f.toPath()); + } + + private static void loadValueSetXml(Path xml) { + try (FileInputStream fis = new FileInputStream(xml.toFile())) { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + Document doc = dbf.newDocumentBuilder().parse(fis); + if (!"ValueSet".equals(doc.getDocumentElement().getLocalName())) return; + + String vsUrl = (String) XPathFactory.newInstance().newXPath() + .compile("/*[local-name()='ValueSet']/*[local-name()='url']/@value") + .evaluate(doc, XPathConstants.STRING); + + NodeList systems = (NodeList) XPathFactory.newInstance().newXPath() + .compile("/*[local-name()='ValueSet']/*[local-name()='compose']" + + "/*[local-name()='include']/*[local-name()='system']/@value") + .evaluate(doc, XPathConstants.NODESET); + if (systems == null) return; + for (int i = 0; i < systems.getLength(); i++) { + String sys = systems.item(i).getTextContent(); + registerValueSetSystem(sys); + indexValueSetSystem(vsUrl, sys); + logger.debug("[Cache-DEBUG] ValueSet " + xml.getFileName() + " (" + vsUrl + ") includes system: " + sys); + } + } catch (Exception ignore) { /* invalid or non-parsable file */ } + } + + private static void loadValueSetJson(Path json) { + try (InputStream in = Files.newInputStream(json)) { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(in); + if (root == null || !"ValueSet".equals(root.path("resourceType").asText())) return; + String vsUrl = root.path("url").asText(null); + com.fasterxml.jackson.databind.JsonNode includes = root.path("compose").path("include"); + if (includes.isArray()) { + for (com.fasterxml.jackson.databind.JsonNode inc : includes) { + String sys = inc.path("system").asText(null); + if (sys != null && !sys.isBlank()) { + registerValueSetSystem(sys); + indexValueSetSystem(vsUrl, sys); + logger.debug("[Cache-DEBUG] ValueSet " + json.getFileName() + " (" + vsUrl + ") includes system: " + sys); + } + } + } + } catch (Exception ignore) { /* invalid or non-parsable file */ } + } + + /** + * Indexes a single {@code compose.include.system} URI under the given ValueSet canonical URL. + * + * @param vsUrl canonical URL of the ValueSet (may be {@code null} or blank; in that case the entry is skipped) + * @param system CodeSystem URI referenced in {@code compose.include.system} + */ + private static void indexValueSetSystem(String vsUrl, String system) { + if (vsUrl == null || vsUrl.isBlank() || system == null || system.isBlank()) return; + SYSTEMS_PER_VALUE_SET + .computeIfAbsent(vsUrl, k -> ConcurrentHashMap.newKeySet()) + .add(system); + } + /** * Single-file load hook that delegates to existing XML/JSON parsing logic. */ @@ -389,6 +517,50 @@ private static void loadCodeSystemFile(File f) } } + // ---- ValueSet system tracking ---- + + /** + * Registers a CodeSystem URI as being referenced by a known ValueSet. + * Called during ValueSet scanning in {@link #seedFromProjectAndClasspath(File)}. + * + * @param system the CodeSystem URI referenced in a ValueSet's {@code compose.include.system} + */ + public static void registerValueSetSystem(String system) { + if (system != null && !system.isBlank()) + SYSTEMS_IN_VALUE_SETS.add(system); + } + + /** + * Returns {@code true} if a ValueSet with the given canonical URL has been loaded. + * Version suffixes (everything after {@code |}) are stripped before comparison. + * + * @param valueSetUrl canonical URL of the ValueSet, optionally versioned ({@code url|version}) + * @return {@code true} if the ValueSet is known to the cache + */ + public static boolean isValueSetLoaded(String valueSetUrl) { + if (valueSetUrl == null || valueSetUrl.isBlank()) return false; + return SYSTEMS_PER_VALUE_SET.containsKey(stripVersion(valueSetUrl)); + } + + /** + * Returns the set of CodeSystem URIs referenced by the given ValueSet's + * {@code compose.include.system} entries. Version suffixes on {@code valueSetUrl} + * are stripped before lookup. + * + * @param valueSetUrl canonical URL of the ValueSet, optionally versioned ({@code url|version}) + * @return an immutable view of the referenced system URIs; empty if the ValueSet is not loaded + */ + public static Set getSystemsInValueSet(String valueSetUrl) { + if (valueSetUrl == null || valueSetUrl.isBlank()) return Collections.emptySet(); + Set s = SYSTEMS_PER_VALUE_SET.get(stripVersion(valueSetUrl)); + return s == null ? Collections.emptySet() : Collections.unmodifiableSet(s); + } + + private static String stripVersion(String canonical) { + int pipe = canonical.indexOf('|'); + return pipe >= 0 ? canonical.substring(0, pipe) : canonical; + } + /** True if we have any codes cached for this CodeSystem URL. */ public static boolean containsSystem(String system) { return system != null && CODES_BY_SYSTEM.containsKey(system); diff --git a/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceExtractor.java b/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceExtractor.java index b1c852b2..f8c0241a 100644 --- a/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceExtractor.java +++ b/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceExtractor.java @@ -132,6 +132,20 @@ public boolean activityDefinitionContainsInstantiatesCanonical(Document doc, return evaluateXPathExists(doc, xpathExpr); } + /** + * Checks whether the given ValueSet {@link Document} contains a {@code } element + * whose {@code value} attribute exactly matches the provided canonical URL. + * + * @param doc the parsed XML {@link Document} representing a FHIR ValueSet + * @param url the canonical URL to search for (without version suffix) + * @return {@code true} if a matching {@code } element exists; {@code false} otherwise + * @throws XPathExpressionException if an XPath evaluation error occurs + */ + public boolean valueSetContainsUrl(Document doc, String url) throws XPathExpressionException { + return evaluateXPathExists(doc, + "/*[local-name()='ValueSet']/*[local-name()='url' and @value='" + url + "']"); + } + /** * Checks whether the given Questionnaire {@link Document} contains a {@code } element * whose {@code value} attribute exactly matches the provided canonical URL. diff --git a/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceLocator.java b/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceLocator.java index d2421d69..ba919469 100644 --- a/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceLocator.java +++ b/linter-core/src/main/java/dev/dsf/linter/util/resource/FhirResourceLocator.java @@ -39,6 +39,7 @@ public final class FhirResourceLocator { private static final String ACTIVITY_DEFINITION_DIR = "fhir/ActivityDefinition"; private static final String STRUCTURE_DEFINITION_DIR = "fhir/StructureDefinition"; private static final String QUESTIONNAIRE_DIR = "fhir/Questionnaire"; + private static final String VALUE_SET_DIR = "fhir/ValueSet"; private final ResourceProvider provider; private final FhirResourceExtractor extractor; @@ -93,10 +94,9 @@ public static FhirResourceLocator from(ResourceProvider provi * Checks if an ActivityDefinition exists for the given message name. * * @param messageName the message name to search for - * @param projectRoot the project root directory (currently unused, kept for API compatibility) * @return true if an ActivityDefinition with the specified message name exists */ - public boolean activityDefinitionExists(String messageName, File projectRoot) { + public boolean activityDefinitionExists(String messageName) { return searchInDirectories( entry -> checkActivityDefinitionForMessage(entry, messageName), ACTIVITY_DEFINITION_DIR @@ -110,10 +110,9 @@ public boolean activityDefinitionExists(String messageName, File projectRoot) { *

    * * @param profileValue the profile value/URL to search for - * @param projectRoot the project root directory (currently unused, kept for API compatibility) * @return true if a StructureDefinition with the specified profile value exists */ - public boolean structureDefinitionExists(String profileValue, File projectRoot) { + public boolean structureDefinitionExists(String profileValue) { String base = ResourcePathNormalizer.removeVersionSuffix(profileValue); return searchInDirectories( entry -> checkStructureDefinitionForValue(entry, base), @@ -144,10 +143,9 @@ public boolean activityDefinitionExistsForInstantiatesCanonical(String canonical *

    * * @param profileValue the profile value/URL to search for - * @param projectRoot the project root directory (currently unused, kept for API compatibility) * @return the File containing the StructureDefinition, or null if not found */ - public File findStructureDefinitionFile(String profileValue, File projectRoot) { + public File findStructureDefinitionFile(String profileValue) { String base = ResourcePathNormalizer.removeVersionSuffix(profileValue); return findFileInDirectories( entry -> checkStructureDefinitionForValue(entry, base), @@ -163,10 +161,9 @@ public File findStructureDefinitionFile(String profileValue, File projectRoot) { *

    * * @param canonical the canonical URL to search for - * @param projectRoot the project root directory (currently unused, kept for API compatibility) * @return the File containing the ActivityDefinition, or null if not found */ - public File findActivityDefinitionForInstantiatesCanonical(String canonical, File projectRoot) { + public File findActivityDefinitionForInstantiatesCanonical(String canonical) { String baseCanon = ResourcePathNormalizer.removeVersionSuffix(canonical); return findFileInDirectories( entry -> checkActivityDefinitionForInstantiatesCanonical(entry, baseCanon), @@ -182,10 +179,9 @@ public File findActivityDefinitionForInstantiatesCanonical(String canonical, Fil *

    * * @param formKey the form key/URL to search for - * @param projectRoot the project root directory (currently unused, kept for API compatibility) * @return true if a Questionnaire with the specified form key exists, false if formKey is null or blank */ - public boolean questionnaireExists(String formKey, File projectRoot) { + public boolean questionnaireExists(String formKey) { if (formKey == null || formKey.isBlank()) { return false; } @@ -197,6 +193,23 @@ public boolean questionnaireExists(String formKey, File projectRoot) { ); } + /** + * Checks if a ValueSet exists for the given canonical URL. + *

    + * Automatically removes version suffixes from the URL before searching. + *

    + * + * @param url the canonical URL of the ValueSet to search for + * @return true if a ValueSet with the specified URL exists + */ + public boolean valueSetExists(String url) { + String base = ResourcePathNormalizer.removeVersionSuffix(url); + return searchInDirectories( + entry -> checkValueSetForUrl(entry, base), + VALUE_SET_DIR + ); + } + /** * Checks if any ActivityDefinition has the specified message name. * @@ -324,6 +337,17 @@ private boolean checkQuestionnaireForUrl(FhirResourceEntry entry, String url) { }); } + private boolean checkValueSetForUrl(FhirResourceEntry entry, String url) { + return checkFhirResource(entry, "ValueSet", + doc -> { + try { + return extractor.valueSetContainsUrl(doc, url); + } catch (XPathExpressionException e) { + throw new RuntimeException(e); + } + }); + } + private boolean checkFhirResource(FhirResourceEntry entry, String expectedRootName, Predicate checkLogic) { diff --git a/linter-core/src/main/java/dev/dsf/linter/util/resource/ResourceResolutionService.java b/linter-core/src/main/java/dev/dsf/linter/util/resource/ResourceResolutionService.java index 819c6967..56259ca4 100644 --- a/linter-core/src/main/java/dev/dsf/linter/util/resource/ResourceResolutionService.java +++ b/linter-core/src/main/java/dev/dsf/linter/util/resource/ResourceResolutionService.java @@ -34,7 +34,6 @@ public class ResourceResolutionService { private final ConcurrentCache materializedCache; - private final ConcurrentCache resourceRootCache; /** * Creates a new resolution service with unified caching. @@ -56,7 +55,6 @@ public ResourceResolutionService() { } }); - this.resourceRootCache = new ConcurrentCache<>(); } /** diff --git a/linter-core/src/main/resources/templates/single_plugin_report.html b/linter-core/src/main/resources/templates/single_plugin_report.html index 9576988c..abdb3e83 100644 --- a/linter-core/src/main/resources/templates/single_plugin_report.html +++ b/linter-core/src/main/resources/templates/single_plugin_report.html @@ -481,6 +481,126 @@ font-size: 1.3rem; } } + + /* ── File Viewer Modal ── */ + .modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.72); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + } + + .modal-content { + background: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 12px; + width: 92%; + max-width: 1050px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5); + overflow: hidden; + } + + .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.9rem 1.4rem; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; + } + + .modal-title { + color: var(--primary-glow); + font-weight: 600; + font-size: 0.95rem; + font-family: 'Courier New', monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .modal-close { + background: none; + border: 1px solid var(--border-color); + color: var(--text-muted); + font-size: 1rem; + cursor: pointer; + padding: 0.25rem 0.6rem; + border-radius: 6px; + transition: color 0.2s, border-color 0.2s; + flex-shrink: 0; + margin-left: 1rem; + } + + .modal-close:hover { + color: var(--error-color); + border-color: var(--error-color); + } + + .modal-body { + overflow: auto; + flex: 1; + padding: 1.2rem 1.5rem; + } + + .file-content { + font-family: 'Courier New', monospace; + font-size: 0.82rem; + line-height: 1.65; + color: var(--text-color); + margin: 0; + white-space: pre-wrap; + word-break: break-all; + } + + .file-content mark { + background: rgba(255, 215, 0, 0.35); + color: var(--text-color); + border-radius: 2px; + padding: 0 2px; + outline: 1px solid rgba(255, 215, 0, 0.6); + } + + .lint-item[data-file] { + cursor: pointer; + } + + .lint-item[data-file]:hover .detail-label::first-line, + .lint-item[data-file] .view-source-hint { + display: inline; + } + + .view-source-hint { + float: right; + font-size: 0.75rem; + color: var(--primary-glow); + opacity: 0.7; + font-family: 'Exo 2', sans-serif; + font-style: italic; + margin-top: 0.1rem; + } + + .lint-item[data-file]:hover .view-source-hint { + opacity: 1; + } + + .dashboard-card--clickable { + cursor: pointer; + } + + .dashboard-card--clickable:hover { + transform: translateY(-8px); + border-color: var(--primary-glow); + box-shadow: + 0 20px 40px rgba(0, 0, 0, 0.3), + 0 0 40px rgba(99, 199, 166, 0.2); + } @@ -505,19 +625,19 @@

    DSF Linter Result

    -
    +
    42
    Success
    -
    +
    3
    Errors
    -
    +
    7
    Warnings
    -
    +
    12
    Info
    @@ -534,8 +654,12 @@

    DSF Linter Result

    Errors (0)

    -
    +
    ERROR + View in source ›
    Element ID
    @@ -589,8 +713,12 @@

    Errors (0)

    Warnings (0)

    -
    +
    Warning + View in source ›
    Element ID
    @@ -644,8 +772,12 @@

    Warnings (0)

    Information (0)

    -
    +
    Info + View in source ›
    Element ID
    @@ -699,8 +831,12 @@

    Information (0)

    Successful Checks (0)

    -
    +
    Success + View in source ›
    Element ID
    @@ -755,15 +891,32 @@

    Successful Checks (0No lint items to display

    + + + - diff --git a/linter-core/src/test/java/dev/dsf/bpe/v2/spring/ActivityPrototypeBeanCreator.java b/linter-core/src/test/java/dev/dsf/bpe/v2/spring/ActivityPrototypeBeanCreator.java new file mode 100644 index 00000000..03d18844 --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/bpe/v2/spring/ActivityPrototypeBeanCreator.java @@ -0,0 +1,22 @@ +package dev.dsf.bpe.v2.spring; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Test double for DSF API V2 {@code ActivityPrototypeBeanCreator}. + * Same type name, constructor and {@code activities} field as the production class + * so {@code SpringConfigurationLinter} can extract registered activity types. + */ +public class ActivityPrototypeBeanCreator { + + @SuppressWarnings("unused") // read reflectively by SpringConfigurationLinter + private final List> activities = new ArrayList<>(); + + public ActivityPrototypeBeanCreator(Class... activities) { + if (activities != null) { + Collections.addAll(this.activities, activities); + } + } +} diff --git a/linter-core/src/test/java/dev/dsf/linter/BpmnLoadingTest.java b/linter-core/src/test/java/dev/dsf/linter/BpmnLoadingTest.java index 91e5b065..24d7929d 100644 --- a/linter-core/src/test/java/dev/dsf/linter/BpmnLoadingTest.java +++ b/linter-core/src/test/java/dev/dsf/linter/BpmnLoadingTest.java @@ -5,13 +5,10 @@ import org.camunda.bpm.model.bpmn.impl.BpmnParser; import org.junit.jupiter.api.Test; -import java.io.FileInputStream; -import java.io.FileNotFoundException; - public class BpmnLoadingTest { @Test - public void testBpmnLoading() throws FileNotFoundException { + public void testBpmnLoading() { BpmnParser parser = new BpmnParser(); BpmnModelInstanceImpl model = parser.parseModelFromStream( this.getClass().getClassLoader().getResourceAsStream("bpmn/ping.bpmn")); diff --git a/linter-core/src/test/java/dev/dsf/linter/TestDsfLinter.java b/linter-core/src/test/java/dev/dsf/linter/TestDsfLinter.java index 7a687fce..8c4c607a 100644 --- a/linter-core/src/test/java/dev/dsf/linter/TestDsfLinter.java +++ b/linter-core/src/test/java/dev/dsf/linter/TestDsfLinter.java @@ -244,10 +244,13 @@ private void createValidBpmnFile(Path directory, String filename) throws IOExcep - + + + + """; Path bpmnFile = directory.resolve(filename); diff --git a/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnFieldInjectionLinterTest.java b/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnFieldInjectionLinterTest.java index df3a1266..801f7b2b 100644 --- a/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnFieldInjectionLinterTest.java +++ b/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnFieldInjectionLinterTest.java @@ -3,6 +3,8 @@ import dev.dsf.linter.output.LinterSeverity; import dev.dsf.linter.output.LintingType; import dev.dsf.linter.output.item.BpmnElementLintItem; +import dev.dsf.linter.util.api.ApiVersion; +import dev.dsf.linter.util.api.ApiVersionHolder; import org.camunda.bpm.model.bpmn.Bpmn; import org.camunda.bpm.model.bpmn.BpmnModelInstance; import org.camunda.bpm.model.bpmn.instance.EndEvent; @@ -12,6 +14,8 @@ import org.camunda.bpm.model.bpmn.instance.camunda.CamundaField; import org.camunda.bpm.model.bpmn.instance.camunda.CamundaString; import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import java.io.File; import java.io.IOException; @@ -73,7 +77,7 @@ public static void init() throws IOException { * Cleanup method to delete the temporary directory after all tests. */ @AfterAll - public static void cleanup() throws IOException { + public static void cleanup() { if (tempProjectRoot != null && Files.exists(tempProjectRoot)) { // Recursively delete temporary directory deleteDirectory(tempProjectRoot.toFile()); @@ -120,8 +124,55 @@ public void testNoFieldsNoIssues() { } @Test - @DisplayName("Test camunda:field with expression => triggers NotStringLiteral issue") - public void testFieldWithExpressionTriggersError() { + @DisplayName("Test camunda:field with expression (API v1) => triggers NotStringLiteral issue") + public void testFieldWithExpressionTriggersErrorForApiV1() { + try (MockedStatic mockedApiHolder = Mockito.mockStatic(ApiVersionHolder.class)) { + mockedApiHolder.when(ApiVersionHolder::getVersion).thenReturn(ApiVersion.V1); + assertFieldWithExpressionTriggersNotStringLiteralError(); + } + } + + @Test + @DisplayName("Test camunda:field with expression (API v2) => no NotStringLiteral issue") + public void testFieldWithExpressionAllowedForApiV2() { + try (MockedStatic mockedApiHolder = Mockito.mockStatic(ApiVersionHolder.class)) { + mockedApiHolder.when(ApiVersionHolder::getVersion).thenReturn(ApiVersion.V2); + + BpmnModelInstance model = Bpmn + .createProcess("testProcessExpressionsV2") + .startEvent() + .serviceTask("serviceTaskWithExprV2") + .endEvent() + .done(); + + ServiceTask serviceTask = model.getModelElementById("serviceTaskWithExprV2"); + ExtensionElements extensionElements = model.newInstance(ExtensionElements.class); + serviceTask.addChildElement(extensionElements); + + CamundaField field = model.newInstance(CamundaField.class); + field.setCamundaName("profile"); + field.setCamundaExpression("${someExpression}"); + extensionElements.addChildElement(field); + + List issues = new ArrayList<>(); + BpmnFieldInjectionLinter.lintMessageSendFieldInjections( + serviceTask, issues, bpmnFile, "testProcessExpressionsV2", projectRoot + ); + + assertTrue( + nonSuccess(issues).stream() + .noneMatch(i -> i.getType() == LintingType.BPMN_FIELD_INJECTION_NOT_STRING_LITERAL), + "Expression-based field injection must not trigger NOT_STRING_LITERAL for API v2"); + assertTrue( + issues.stream().anyMatch(i -> + i.getSeverity() == LinterSeverity.SUCCESS + && i.getDescription() != null + && i.getDescription().contains("expression")), + "Expected a success item acknowledging expression for API v2"); + } + } + + private void assertFieldWithExpressionTriggersNotStringLiteralError() { BpmnModelInstance model = Bpmn .createProcess("testProcessExpressions") .startEvent() @@ -151,7 +202,7 @@ public void testFieldWithExpressionTriggersError() { // We expect exactly 1 issue => type BPMN_FIELD_INJECTION_NOT_STRING_LITERAL assertEquals(1, issues.size(), "Expected exactly one lint issue for an expression-based field"); - assertEquals(LintingType.BPMN_FIELD_INJECTION_NOT_STRING_LITERAL, issues.get(0).getType(), + assertEquals(LintingType.BPMN_FIELD_INJECTION_NOT_STRING_LITERAL, issues.getFirst().getType(), "Expected the issue to be of type BPMN_FIELD_INJECTION_NOT_STRING_LITERAL"); } @@ -266,7 +317,7 @@ public void testEndEventMessageFields() { assertEquals(1, issues.size(), "Expected one issue due to empty messageName in nested MessageEventDefinition"); - assertEquals(LintingType.BPMN_FIELD_INJECTION_MESSAGE_VALUE_EMPTY, issues.get(0).getType(), + assertEquals(LintingType.BPMN_FIELD_INJECTION_MESSAGE_VALUE_EMPTY, issues.getFirst().getType(), "Expected the issue to be of type BPMN_FIELD_INJECTION_MESSAGE_VALUE_EMPTY"); } } \ No newline at end of file diff --git a/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnProcessLinterMessageStartEventTest.java b/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnProcessLinterMessageStartEventTest.java new file mode 100644 index 00000000..8700d388 --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnProcessLinterMessageStartEventTest.java @@ -0,0 +1,127 @@ +package dev.dsf.linter.bpmn; + +import dev.dsf.linter.output.LinterSeverity; +import dev.dsf.linter.output.LintingType; +import dev.dsf.linter.output.item.BpmnElementLintItem; +import org.camunda.bpm.model.bpmn.Bpmn; +import org.camunda.bpm.model.bpmn.BpmnModelInstance; +import org.camunda.bpm.model.bpmn.instance.Definitions; +import org.camunda.bpm.model.bpmn.instance.Message; +import org.camunda.bpm.model.bpmn.instance.MessageEventDefinition; +import org.camunda.bpm.model.bpmn.instance.Process; +import org.camunda.bpm.model.bpmn.instance.StartEvent; +import org.camunda.bpm.model.bpmn.instance.SubProcess; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class BpmnProcessLinterMessageStartEventTest { + + private BpmnProcessLinter processLinter; + private File bpmnFile; + + @BeforeEach + void setup() { + processLinter = new BpmnProcessLinter(new File(".")); + bpmnFile = new File("test-process.bpmn"); + } + + @Test + @DisplayName("Process without message start event => BPMN_MESSAGE_START_EVENT_NOT_FOUND") + void processWithoutMessageStartReportsNotFound() { + BpmnModelInstance model = Bpmn.createExecutableProcess("testorg_myprocess") + .startEvent("start") + .endEvent("end") + .done(); + + List issues = lintProcesses(model); + + assertTrue(issues.stream().anyMatch(i -> + i.getType() == LintingType.BPMN_MESSAGE_START_EVENT_NOT_FOUND + && i.getSeverity() == LinterSeverity.ERROR), + "Expected BPMN_MESSAGE_START_EVENT_NOT_FOUND when process has only a generic start event"); + } + + @Test + @DisplayName("Process with message start event => no NOT_FOUND") + void processWithMessageStartDoesNotReportNotFound() { + BpmnModelInstance model = Bpmn.createExecutableProcess("testorg_myprocess") + .startEvent("start") + .message("startMessage") + .endEvent("end") + .done(); + + List issues = lintProcesses(model); + + assertFalse(issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_MESSAGE_START_EVENT_NOT_FOUND), + "Expected no BPMN_MESSAGE_START_EVENT_NOT_FOUND when process has a message start event"); + } + + @Test + @DisplayName("ping.bpmn has message start on process level => no NOT_FOUND") + void pingBpmnHasProcessLevelMessageStart() { + File pingFile = new File("src/test/resources/bpmn/ping.bpmn"); + BpmnModelInstance model = Bpmn.readModelFromFile(pingFile); + + List issues = lintProcesses(model, pingFile); + + assertFalse(issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_MESSAGE_START_EVENT_NOT_FOUND), + "ping.bpmn should have a message start event on process level"); + } + + @Test + @DisplayName("Message start only in subprocess => NOT_FOUND on process") + void messageStartOnlyInSubProcessReportsNotFound() { + BpmnModelInstance model = Bpmn.createEmptyModel(); + Definitions definitions = model.newInstance(Definitions.class); + definitions.setTargetNamespace("http://camunda.org/examples"); + model.setDefinitions(definitions); + + Process process = model.newInstance(Process.class); + process.setId("testorg_myprocess"); + process.setExecutable(true); + definitions.addChildElement(process); + + StartEvent processStart = model.newInstance(StartEvent.class); + processStart.setId("processStart"); + process.addChildElement(processStart); + + SubProcess subProcess = model.newInstance(SubProcess.class); + subProcess.setId("sub"); + process.addChildElement(subProcess); + + StartEvent subStart = model.newInstance(StartEvent.class); + subStart.setId("subMessageStart"); + subProcess.addChildElement(subStart); + + Message message = model.newInstance(Message.class); + message.setId("Message_subStart"); + message.setName("subStartMessage"); + definitions.addChildElement(message); + + MessageEventDefinition messageDef = model.newInstance(MessageEventDefinition.class); + messageDef.setMessage(message); + subStart.addChildElement(messageDef); + + List issues = lintProcesses(model); + + assertTrue(issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_MESSAGE_START_EVENT_NOT_FOUND), + "Message start inside subprocess must not satisfy process-level requirement"); + } + + private List lintProcesses(BpmnModelInstance model) { + return lintProcesses(model, bpmnFile); + } + + private List lintProcesses(BpmnModelInstance model, File file) { + List issues = new ArrayList<>(); + processLinter.lintProcesses(model, file, issues); + return issues; + } +} diff --git a/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnProcessLinterVersionTagTest.java b/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnProcessLinterVersionTagTest.java new file mode 100644 index 00000000..afbc32a9 --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnProcessLinterVersionTagTest.java @@ -0,0 +1,114 @@ +package dev.dsf.linter.bpmn; + +import dev.dsf.linter.output.LinterSeverity; +import dev.dsf.linter.output.LintingType; +import dev.dsf.linter.output.item.BpmnElementLintItem; +import org.camunda.bpm.model.bpmn.Bpmn; +import org.camunda.bpm.model.bpmn.BpmnModelInstance; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BpmnProcessLinterVersionTagTest { + + @TempDir + Path tempDir; + + private BpmnModelInstance createModelFromXml(String bpmnXml) { + return Bpmn.readModelFromStream( + new java.io.ByteArrayInputStream(bpmnXml.getBytes(StandardCharsets.UTF_8))); + } + + private File createBpmnFile() throws IOException { + Path bpmnPath = tempDir.resolve("process.bpmn"); + Files.writeString(bpmnPath, ""); + return bpmnPath.toFile(); + } + + private List lint(String processAttributes) throws IOException { + String bpmnXml = """ + + + + + + + + + """.formatted(processAttributes); + + BpmnModelInstance model = createModelFromXml(bpmnXml); + BpmnProcessLinter linter = new BpmnProcessLinter(tempDir.toFile()); + List issues = new ArrayList<>(); + linter.lintProcesses(model, createBpmnFile(), issues); + return issues; + } + + @Test + @DisplayName("Should report ERROR when camunda:versionTag is missing") + void shouldReportErrorWhenVersionTagMissing() throws IOException { + List issues = lint(""); + + assertTrue(issues.stream().anyMatch(i -> + i.getSeverity() == LinterSeverity.ERROR + && i.getType() == LintingType.BPMN_PROCESS_VERSION_TAG_MISSING_OR_EMPTY), + "Expected ERROR for missing camunda:versionTag"); + } + + @Test + @DisplayName("Should report ERROR when camunda:versionTag is empty") + void shouldReportErrorWhenVersionTagEmpty() throws IOException { + List issues = lint("camunda:versionTag=\"\""); + + assertTrue(issues.stream().anyMatch(i -> + i.getSeverity() == LinterSeverity.ERROR + && i.getType() == LintingType.BPMN_PROCESS_VERSION_TAG_MISSING_OR_EMPTY), + "Expected ERROR for empty camunda:versionTag"); + } + + @Test + @DisplayName("Should report ERROR when camunda:versionTag is null literal") + void shouldReportErrorWhenVersionTagNullLiteral() throws IOException { + List issues = lint("camunda:versionTag=\"null\""); + + assertTrue(issues.stream().anyMatch(i -> + i.getSeverity() == LinterSeverity.ERROR + && i.getType() == LintingType.BPMN_PROCESS_VERSION_TAG_MISSING_OR_EMPTY), + "Expected ERROR for camunda:versionTag='null'"); + } + + @Test + @DisplayName("Should report WARN when camunda:versionTag has no placeholder") + void shouldReportWarnWhenVersionTagHasNoPlaceholder() throws IOException { + List issues = lint("camunda:versionTag=\"1.0.0\""); + + assertTrue(issues.stream().anyMatch(i -> + i.getSeverity() == LinterSeverity.WARN + && i.getType() == LintingType.BPMN_PROCESS_VERSION_TAG_NO_PLACEHOLDER), + "Expected WARN for camunda:versionTag without '#{version}'"); + } + + @Test + @DisplayName("Should report SUCCESS when camunda:versionTag uses placeholder") + void shouldReportSuccessWhenVersionTagHasPlaceholder() throws IOException { + List issues = lint("camunda:versionTag=\"#{version}\""); + + assertTrue(issues.stream().anyMatch(i -> + i.getSeverity() == LinterSeverity.SUCCESS + && i.getDescription().contains("camunda:versionTag uses '#{version}'")), + "Expected SUCCESS for camunda:versionTag='#{version}'"); + } +} diff --git a/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnTaskLinterTest.java b/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnTaskLinterTest.java index af004755..e738e491 100644 --- a/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnTaskLinterTest.java +++ b/linter-core/src/test/java/dev/dsf/linter/bpmn/BpmnTaskLinterTest.java @@ -54,7 +54,7 @@ public void setUp() throws IOException { * Cleanup method to delete the temporary directory after all tests. */ @AfterAll - public static void cleanup() throws IOException { + public static void cleanup() { if (tempProjectRoot != null && Files.exists(tempProjectRoot)) { // Recursively delete temporary directory deleteDirectory(tempProjectRoot.toFile()); @@ -167,6 +167,21 @@ public void testLintReceiveTask_EmptyMessageName() { linter.lintReceiveTask(receiveTask, issues, mockBpmnFile, "ProcessId"); // Assert - assertFalse(issues.isEmpty(), "Empty message name should yield a lint issue."); + assertTrue(issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_RECEIVE_TASK_MESSAGE_NAME_EMPTY), + "Empty message name should yield BPMN_RECEIVE_TASK_MESSAGE_NAME_EMPTY."); + } + + @Test + public void testLintReceiveTask_NullMessage() { + ReceiveTask receiveTask = mock(ReceiveTask.class); + when(receiveTask.getId()).thenReturn("receiveTaskId"); + when(receiveTask.getName()).thenReturn("My Receive Task"); + when(receiveTask.getMessage()).thenReturn(null); + + List issues = new ArrayList<>(); + linter.lintReceiveTask(receiveTask, issues, mockBpmnFile, "ProcessId"); + + assertTrue(issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_RECEIVE_TASK_MESSAGE_NAME_EMPTY), + "Null message should yield BPMN_RECEIVE_TASK_MESSAGE_NAME_EMPTY."); } } \ No newline at end of file diff --git a/linter-core/src/test/java/dev/dsf/linter/bpmn/TestBpmnModelLinter.java b/linter-core/src/test/java/dev/dsf/linter/bpmn/TestBpmnModelLinter.java index 785389cf..c06a8cca 100644 --- a/linter-core/src/test/java/dev/dsf/linter/bpmn/TestBpmnModelLinter.java +++ b/linter-core/src/test/java/dev/dsf/linter/bpmn/TestBpmnModelLinter.java @@ -62,6 +62,7 @@ void setup() { void testServiceTaskNoNameNoImplementation() { BpmnModelInstance model = Bpmn.createExecutableProcess(dummyProcessId) .startEvent("start") + .message("startMessage") .serviceTask("serviceTaskNoNameNoClass") // no .name() => name is empty .endEvent("end") .done(); @@ -112,6 +113,7 @@ void testMessageStartEventNoMessageName() { void testErrorBoundaryEventNoErrorCode() { BpmnModelInstance model = Bpmn.createExecutableProcess(dummyProcessId) .startEvent("start") + .message("startMessage") .userTask("someTask") .name("User Task For Boundary") .boundaryEvent("errorBoundary") @@ -145,6 +147,7 @@ void testErrorBoundaryEventNoErrorCode() { void testExclusiveGatewayMultipleFlowsNoNames() { BpmnModelInstance model = Bpmn.createExecutableProcess(dummyProcessId) .startEvent("start") + .message("startMessage") .exclusiveGateway("gateway") .sequenceFlowId("flow1").condition("cond1", "#{someCondition == true}") // no .name() .endEvent("end1") @@ -183,11 +186,20 @@ void testMultiInstanceSubProcessNotAsyncBefore() { process.setExecutable(true); definitions.addChildElement(process); - // Add a StartEvent to the process + // Add a message StartEvent to the process StartEvent startEvent = model.newInstance(StartEvent.class); startEvent.setId("start"); process.addChildElement(startEvent); + Message message = model.newInstance(Message.class); + message.setId("Message_start"); + message.setName("startMessage"); + definitions.addChildElement(message); + + MessageEventDefinition messageDef = model.newInstance(MessageEventDefinition.class); + messageDef.setMessage(message); + startEvent.addChildElement(messageDef); + // Create a multi-instance SubProcess (without asyncBefore) SubProcess subProcess = model.newInstance(SubProcess.class); subProcess.setId("mySubProcess"); @@ -248,6 +260,7 @@ void testMultiInstanceSubProcessNotAsyncBefore() { void testUserTaskNoName() { BpmnModelInstance model = Bpmn.createExecutableProcess(dummyProcessId) .startEvent("start") + .message("startMessage") .userTask("userTaskNoName") // no .name() .endEvent("end") .done(); @@ -270,6 +283,7 @@ void testUserTaskNoName() { void testSendTaskNoName() { BpmnModelInstance model = Bpmn.createExecutableProcess(dummyProcessId) .startEvent("start") + .message("startMessage") .sendTask("sendTaskNoName") // no .name() .endEvent("end") .done(); diff --git a/linter-core/src/test/java/dev/dsf/linter/exclusion/ExclusionFilterTest.java b/linter-core/src/test/java/dev/dsf/linter/exclusion/ExclusionFilterTest.java new file mode 100644 index 00000000..7bc6f00f --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/exclusion/ExclusionFilterTest.java @@ -0,0 +1,311 @@ +package dev.dsf.linter.exclusion; + +import dev.dsf.linter.output.LinterSeverity; +import dev.dsf.linter.output.LintingType; +import dev.dsf.linter.output.item.*; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link ExclusionFilter}. + * + *

    Tests cover: single-field matching, multi-field AND, multi-rule OR, + * case-insensitivity, no-match cases, and null/empty safety.

    + */ +class ExclusionFilterTest { + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private static ExclusionFilter filterWith(ExclusionRule... rules) { + ExclusionConfig config = new ExclusionConfig(List.of(rules), false); + return new ExclusionFilter(config); + } + + private static ExclusionRule ruleFor(String type, String severity, String file, String messageContains) { + return new ExclusionRule(type, severity, file, messageContains); + } + + private static BpmnElementLintItem bpmnItem(LinterSeverity sev, LintingType type, + String bpmnFile, String description) { + return new BpmnElementLintItem(sev, type, "element-1", bpmnFile, "process-1", description); + } + + private static FhirElementLintItem fhirItem(LinterSeverity sev, LintingType type, + String resourceFile, String description) { + return new FhirElementLintItem(sev, type, resourceFile, "http://example.com/ref", description); + } + + private static PluginLintItem pluginItem(String fileName) { + return new PluginLintItem(LinterSeverity.ERROR, LintingType.PLUGIN_DEFINITION_MISSING_SERVICE_LOADER_REGISTRATION, new File(fileName), "location", "Missing registration"); + } + + // ----------------------------------------------------------------------- + // 1. Type-only rule + // ----------------------------------------------------------------------- + + @Test + void excludeByType_matchesExact() { + ExclusionFilter filter = filterWith( + ruleFor("BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING", null, null, null)); + + BpmnElementLintItem item = bpmnItem(LinterSeverity.WARN, + LintingType.BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING, + "some.bpmn", "Missing historyTimeToLive"); + + assertTrue(filter.isExcluded(item), "Should be excluded by type rule"); + } + + @Test + void excludeByType_doesNotMatchOtherType() { + ExclusionFilter filter = filterWith( + ruleFor("BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING", null, null, null)); + + BpmnElementLintItem item = bpmnItem(LinterSeverity.ERROR, + LintingType.BPMN_PROCESS_ID_EMPTY, + "some.bpmn", "Process ID is empty"); + + assertFalse(filter.isExcluded(item), "Different type must not be excluded"); + } + + @Test + void excludeByType_caseInsensitive() { + ExclusionFilter filter = filterWith( + ruleFor("bpmn_process_history_time_to_live_missing", null, null, null)); + + BpmnElementLintItem item = bpmnItem(LinterSeverity.WARN, + LintingType.BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING, + "some.bpmn", "Missing historyTimeToLive"); + + assertTrue(filter.isExcluded(item), "Type match should be case-insensitive"); + } + + // ----------------------------------------------------------------------- + // 2. Severity-only rule + // ----------------------------------------------------------------------- + + @Test + void excludeBySeverity_matchesWarn() { + ExclusionFilter filter = filterWith(ruleFor(null, "WARN", null, null)); + + BpmnElementLintItem warnItem = bpmnItem(LinterSeverity.WARN, + LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "x.bpmn", "msg"); + BpmnElementLintItem errorItem = bpmnItem(LinterSeverity.ERROR, + LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "x.bpmn", "msg"); + + assertTrue(filter.isExcluded(warnItem)); + assertFalse(filter.isExcluded(errorItem)); + } + + // ----------------------------------------------------------------------- + // 3. File-only rule (substring, case-insensitive) + // ----------------------------------------------------------------------- + + @Test + void excludeByFile_substringMatchBpmn() { + ExclusionFilter filter = filterWith(ruleFor(null, null, "update-allow-list", null)); + + BpmnElementLintItem match = bpmnItem(LinterSeverity.WARN, + LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, + "update-allow-list.bpmn", "msg"); + BpmnElementLintItem noMatch = bpmnItem(LinterSeverity.WARN, + LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, + "other-process.bpmn", "msg"); + + assertTrue(filter.isExcluded(match)); + assertFalse(filter.isExcluded(noMatch)); + } + + @Test + void excludeByFile_substringMatchFhir() { + ExclusionFilter filter = filterWith(ruleFor(null, null, "task-example", null)); + + FhirElementLintItem match = fhirItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_MISSING_INPUT, "task-example.xml", "msg"); + FhirElementLintItem noMatch = fhirItem(LinterSeverity.ERROR, + LintingType.FHIR_TASK_MISSING_INPUT, "activity-definition.xml", "msg"); + + assertTrue(filter.isExcluded(match)); + assertFalse(filter.isExcluded(noMatch)); + } + + @Test + void excludeByFile_caseInsensitive() { + ExclusionFilter filter = filterWith(ruleFor(null, null, "UPDATE-ALLOW-LIST", null)); + + BpmnElementLintItem item = bpmnItem(LinterSeverity.WARN, + LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, + "update-allow-list.bpmn", "msg"); + + assertTrue(filter.isExcluded(item)); + } + + // ----------------------------------------------------------------------- + // 4. MessageContains-only rule + // ----------------------------------------------------------------------- + + @Test + void excludeByMessage_substringMatch() { + ExclusionFilter filter = filterWith(ruleFor(null, null, null, "optional field")); + + FhirElementLintItem match = fhirItem(LinterSeverity.WARN, + LintingType.FHIR_VALUE_SET_MISSING_DESCRIPTION, + "vs.xml", "This is an optional field that may be omitted"); + FhirElementLintItem noMatch = fhirItem(LinterSeverity.WARN, + LintingType.FHIR_VALUE_SET_MISSING_DESCRIPTION, + "vs.xml", "This field is required"); + + assertTrue(filter.isExcluded(match)); + assertFalse(filter.isExcluded(noMatch)); + } + + @Test + void excludeByMessage_caseInsensitive() { + ExclusionFilter filter = filterWith(ruleFor(null, null, null, "OPTIONAL FIELD")); + + FhirElementLintItem item = fhirItem(LinterSeverity.WARN, + LintingType.FHIR_VALUE_SET_MISSING_DESCRIPTION, + "vs.xml", "This is an optional field"); + + assertTrue(filter.isExcluded(item)); + } + + // ----------------------------------------------------------------------- + // 5. Multi-field AND within a single rule + // ----------------------------------------------------------------------- + + @Test + void andCombination_allFieldsMustMatch() { + ExclusionRule rule = ruleFor("BPMN_SERVICE_TASK_NAME_EMPTY", "WARN", "update", null); + ExclusionFilter filter = filterWith(rule); + + // Matches type + severity + file + BpmnElementLintItem fullMatch = bpmnItem(LinterSeverity.WARN, + LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "update-allow-list.bpmn", "msg"); + + // Matches type + severity but wrong file + BpmnElementLintItem wrongFile = bpmnItem(LinterSeverity.WARN, + LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "other.bpmn", "msg"); + + // Matches type + file but wrong severity + BpmnElementLintItem wrongSev = bpmnItem(LinterSeverity.ERROR, + LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "update-allow-list.bpmn", "msg"); + + assertTrue(filter.isExcluded(fullMatch)); + assertFalse(filter.isExcluded(wrongFile)); + assertFalse(filter.isExcluded(wrongSev)); + } + + // ----------------------------------------------------------------------- + // 6. Multi-rule OR combination + // ----------------------------------------------------------------------- + + @Test + void orCombination_matchesEitherRule() { + ExclusionFilter filter = filterWith( + ruleFor("BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING", null, null, null), + ruleFor(null, "WARN", "other.bpmn", null) + ); + + BpmnElementLintItem matchFirstRule = bpmnItem(LinterSeverity.ERROR, + LintingType.BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING, "any.bpmn", "msg"); + + BpmnElementLintItem matchSecondRule = bpmnItem(LinterSeverity.WARN, + LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "other.bpmn", "msg"); + + BpmnElementLintItem noMatch = bpmnItem(LinterSeverity.ERROR, + LintingType.BPMN_SERVICE_TASK_NAME_EMPTY, "other.bpmn", "msg"); + + assertTrue(filter.isExcluded(matchFirstRule)); + assertTrue(filter.isExcluded(matchSecondRule)); + assertFalse(filter.isExcluded(noMatch)); + } + + // ----------------------------------------------------------------------- + // 7. filter() and getExcluded() list operations + // ----------------------------------------------------------------------- + + @Test + void filterList_removesMatchingItems() { + ExclusionFilter filter = filterWith(ruleFor("BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING", null, null, null)); + + BpmnElementLintItem excluded = bpmnItem(LinterSeverity.WARN, + LintingType.BPMN_PROCESS_HISTORY_TIME_TO_LIVE_MISSING, "a.bpmn", "msg"); + BpmnElementLintItem included = bpmnItem(LinterSeverity.ERROR, + LintingType.BPMN_PROCESS_ID_EMPTY, "a.bpmn", "msg"); + + List items = List.of(excluded, included); + List kept = filter.filter(items); + List dropped = filter.getExcluded(items); + + assertEquals(1, kept.size()); + assertSame(included, kept.getFirst()); + assertEquals(1, dropped.size()); + assertSame(excluded, dropped.getFirst()); + } + + @Test + void filterList_emptyInput_returnsEmpty() { + ExclusionFilter filter = filterWith(ruleFor("BPMN_PROCESS_ID_EMPTY", null, null, null)); + assertTrue(filter.filter(List.of()).isEmpty()); + assertTrue(filter.getExcluded(List.of()).isEmpty()); + } + + // ----------------------------------------------------------------------- + // 8. No rules / null safety + // ----------------------------------------------------------------------- + + @Test + void noRules_nothingExcluded() { + ExclusionFilter filter = new ExclusionFilter(new ExclusionConfig(List.of(), false)); + + BpmnElementLintItem item = bpmnItem(LinterSeverity.ERROR, + LintingType.BPMN_PROCESS_ID_EMPTY, "a.bpmn", "msg"); + + assertFalse(filter.isExcluded(item)); + } + + @Test + void nullItemIsNotExcluded() { + ExclusionFilter filter = filterWith(ruleFor("BPMN_PROCESS_ID_EMPTY", null, null, null)); + assertFalse(filter.isExcluded(null)); + } + + // ----------------------------------------------------------------------- + // 9. PluginLintItem file matching + // ----------------------------------------------------------------------- + + @Test + void excludePluginItem_byFileSubstring() { + ExclusionFilter filter = filterWith(ruleFor(null, null, "services", null)); + + PluginLintItem match = pluginItem( + "target/classes/META-INF/services/some.Service"); + + PluginLintItem noMatch = pluginItem( + "SomePlugin.class"); + + assertTrue(filter.isExcluded(match)); + assertFalse(filter.isExcluded(noMatch)); + } + + // ----------------------------------------------------------------------- + // 10. affectsExitStatus is stored but does not change filter behavior + // ----------------------------------------------------------------------- + + @Test + void affectsExitStatus_storedCorrectly() { + ExclusionConfig withFlag = new ExclusionConfig( + List.of(ruleFor("BPMN_PROCESS_ID_EMPTY", null, null, null)), true); + assertTrue(withFlag.isAffectsExitStatus()); + + ExclusionConfig withoutFlag = new ExclusionConfig( + List.of(ruleFor("BPMN_PROCESS_ID_EMPTY", null, null, null)), false); + assertFalse(withoutFlag.isAffectsExitStatus()); + } +} diff --git a/linter-core/src/test/java/dev/dsf/linter/fhir/FhirTaskLinterInputCodingTerminologyTest.java b/linter-core/src/test/java/dev/dsf/linter/fhir/FhirTaskLinterInputCodingTerminologyTest.java new file mode 100644 index 00000000..b81b0335 --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/fhir/FhirTaskLinterInputCodingTerminologyTest.java @@ -0,0 +1,257 @@ +package dev.dsf.linter.fhir; + +import dev.dsf.linter.logger.Logger; +import dev.dsf.linter.output.LintingType; +import dev.dsf.linter.output.item.FhirElementLintItem; +import dev.dsf.linter.util.resource.FhirAuthorizationCache; +import dev.dsf.linter.util.resource.FhirResourceParser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Node; + +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathFactory; +import java.io.StringReader; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import org.junit.jupiter.api.io.TempDir; +import java.util.List; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; +import javax.xml.parsers.DocumentBuilderFactory; +import org.xml.sax.InputSource; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FhirTaskLinterInputCodingTerminologyTest +{ + private static final Path PING_PONG_ROOT = Path.of("src/test/resources/fhir/examples/pingPongProcess"); + private static final Path BASE_TASK = PING_PONG_ROOT.resolve("Task/dsf-task-start-ping.json"); + private static final File BASE_TASK_FILE = BASE_TASK.toFile(); + private static final String SECOND_INPUT_CODING_SYSTEM_XPATH = + "/*[local-name()='Task']/*[local-name()='input'][2]/*[local-name()='type']/*[local-name()='coding']/*[local-name()='system']/@value"; + private static final String CODE_UNKNOWN_TASK_XML = """ + + + + + + + + + + + + """; + + private FhirTaskLinter linter; + + @BeforeEach + void setUp() + { + linter = new FhirTaskLinter(); + FhirAuthorizationCache.setLogger(new SilentLogger()); + seedPingPongTerminology(); + } + + @Test + void shouldPassAllThreeInputCodingChecksForValidPingPongTask(@TempDir Path tempDir) throws Exception + { + copyPingPongFhirResourcesIntoMavenLayout(tempDir); + String prevRoot = System.getProperty("dsf.projectRoot"); + try { + File projectRoot = tempDir.toFile(); + System.setProperty("dsf.projectRoot", projectRoot.getAbsolutePath()); + FhirAuthorizationCache.seedFromProjectAndClasspath(projectRoot); + + Document doc = FhirResourceParser.parseFhirFile(BASE_TASK); + List items = linter.lint(doc, BASE_TASK_FILE); + + assertFalse(containsType(items, LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN)); + assertFalse(containsType(items, LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET)); + assertFalse(containsType(items, LintingType.FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM)); + + long okCount = items.stream() + .filter(i -> i.getType() == LintingType.SUCCESS) + .filter(i -> i.getDescription() != null && i.getDescription().contains("Task.input.type.coding")) + .count(); + assertEquals(2, okCount, "Expected SUCCESS for both valid Task.input.type.coding entries"); + } finally { + if (prevRoot == null) { + System.clearProperty("dsf.projectRoot"); + } else { + System.setProperty("dsf.projectRoot", prevRoot); + } + seedPingPongTerminology(); + } + } + + @Test + void shouldReportSystemUnknownForTaskInputCodingSystem() throws Exception + { + Document doc = FhirResourceParser.parseFhirFile(BASE_TASK); + setSecondInputCodingSystemValue(doc, "http://example.org/fhir/CodeSystem/not-known"); + + List items = linter.lint(doc, BASE_TASK_FILE); + + assertTrue(containsType(items, LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN), + "Expected FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN"); + } + + @Test + void shouldReportSystemNotInValueSetContextForTaskInputCodingSystem() throws Exception + { + Document doc = FhirResourceParser.parseFhirFile(BASE_TASK); + setSecondInputCodingSystemValue(doc, FhirAuthorizationCache.CS_READ_ACCESS); + + List items = linter.lint(doc, BASE_TASK_FILE); + + assertTrue(containsType(items, LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET), + "Expected FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET"); + } + + @Test + void shouldLoadTaskInputSlicesFromStructureDefinition(@TempDir Path tempDir) throws Exception + { + Path sdDir = tempDir.resolve("fhir/StructureDefinition"); + Files.createDirectories(sdDir); + Files.copy( + PING_PONG_ROOT.resolve("StructureDefinition/dsf-task-start-ping.json"), + sdDir.resolve("dsf-task-start-ping.json")); + + Method loadCards = FhirTaskLinter.class.getDeclaredMethod( + "loadInputCardinality", File.class, String.class); + loadCards.setAccessible(true); + @SuppressWarnings("unchecked") + Map cards = (Map) loadCards.invoke(linter, tempDir.toFile(), + "http://dsf.dev/fhir/StructureDefinition/task-start-ping|#{version}"); + + assertTrue(cards != null && cards.size() > 1, "Expected slice cards beyond __BASE__"); + assertTrue(cards.containsKey("message-name")); + assertTrue(cards.containsKey("target-endpoints")); + } + + @Test + void shouldReportCodeUnknownForSystemWhenBindingContextPasses() throws Exception + { + Document doc = parseXml(); + + Class sliceCardClass = Class.forName("dev.dsf.linter.fhir.FhirTaskLinter$SliceCard"); + Constructor ctor = sliceCardClass.getDeclaredConstructors()[0]; + ctor.setAccessible(true); + Object sliceCard = ctor.newInstance(0, 1, + FhirAuthorizationCache.CS_BPMN_MESSAGE, null, null); + + Map cards = new HashMap<>(); + cards.put("does-not-exist", sliceCard); + + List out = new ArrayList<>(); + Method method = FhirTaskLinter.class.getDeclaredMethod( + "lintInputTypeCodingTerminology", + Document.class, File.class, String.class, List.class, Map.class); + method.setAccessible(true); + method.invoke(linter, doc, new File("custom-task.xml"), "custom-ref", out, cards); + + String types = out.stream().map(i -> i.getType().name()).distinct().collect(Collectors.joining(", ")); + assertTrue(containsType(out, LintingType.FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM), + "Expected FHIR_TASK_INPUT_CODING_CODE_UNKNOWN_FOR_SYSTEM, got: " + types); + assertFalse(containsType(out, LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_UNKNOWN), + "System is known in the cache"); + assertFalse(containsType(out, LintingType.FHIR_TASK_INPUT_CODING_SYSTEM_NOT_IN_VALUE_SET), + "Binding context passes via fixed system in synthetic slice metadata"); + } + + private static void seedPingPongTerminology() + { + FhirAuthorizationCache.seedFromProjectAndClasspath(Path.of(".").toAbsolutePath().normalize().toFile()); + } + + private static void copyPingPongFhirResourcesIntoMavenLayout(Path tempDir) throws Exception + { + copyResourceDir(tempDir, "fhir/CodeSystem", PING_PONG_ROOT.resolve("CodeSystem")); + copyResourceDir(tempDir, "fhir/ValueSet", PING_PONG_ROOT.resolve("ValueSet")); + copyResourceDir(tempDir, "fhir/StructureDefinition", PING_PONG_ROOT.resolve("StructureDefinition")); + } + + private static void copyResourceDir(Path projectRoot, String relativeDir, Path sourceDir) throws Exception + { + Path targetDir = projectRoot.resolve(relativeDir); + Files.createDirectories(targetDir); + try (var stream = Files.list(sourceDir)) { + for (Path source : stream.filter(p -> p.toString().endsWith(".json")).toList()) { + Files.copy(source, targetDir.resolve(source.getFileName().toString())); + } + } + } + + private static boolean containsType(List items, LintingType type) + { + return items.stream().anyMatch(i -> i.getType() == type); + } + + private static void setSecondInputCodingSystemValue(Document doc, String value) throws Exception + { + Node node = (Node) XPathFactory.newInstance().newXPath() + .compile(SECOND_INPUT_CODING_SYSTEM_XPATH) + .evaluate(doc, XPathConstants.NODE); + if (node == null) + throw new IllegalStateException("XPath node not found: " + SECOND_INPUT_CODING_SYSTEM_XPATH); + node.setNodeValue(value); + } + + private static Document parseXml() throws Exception + { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + return dbf.newDocumentBuilder().parse(new InputSource(new StringReader(CODE_UNKNOWN_TASK_XML))); + } + + private static final class SilentLogger implements Logger + { + @Override + public void info(String message) + { + } + + @Override + public void warn(String message) + { + } + + @Override + public void error(String message) + { + } + + @Override + public void error(String message, Throwable throwable) + { + } + + @Override + public void debug(String message) + { + } + + @Override + public boolean verbose() + { + return false; + } + + @Override + public boolean isVerbose() + { + return false; + } + } +} + diff --git a/linter-core/src/test/java/dev/dsf/linter/service/SampleAppConfig.java b/linter-core/src/test/java/dev/dsf/linter/service/SampleAppConfig.java new file mode 100644 index 00000000..8dd797e6 --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/service/SampleAppConfig.java @@ -0,0 +1,25 @@ +package dev.dsf.linter.service; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Sample {@code @Configuration} class used by + * {@link SpringConfigurationLinterIntegrationTest} to simulate a real + * DSF plugin configuration. The {@code @Bean} method return type matches + * the {@code camunda:class} reference placed into the test BPMN file, + * so the linter can cross-reference them. + */ +@Configuration +public class SampleAppConfig { + + /** + * A {@code @Bean} method whose return type is a BPMN-delegate-like class. + * The actual behavior is irrelevant for the linter – only the return type + * is inspected. + */ + @Bean + public SampleDelegate sampleDelegate() { + return new SampleDelegate(); + } +} diff --git a/linter-core/src/test/java/dev/dsf/linter/service/SampleDelegate.java b/linter-core/src/test/java/dev/dsf/linter/service/SampleDelegate.java new file mode 100644 index 00000000..fef10d45 --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/service/SampleDelegate.java @@ -0,0 +1,8 @@ +package dev.dsf.linter.service; + +/** + * A stand-in for a BPMN service-task delegate class. Referenced via + * {@code camunda:class} in the integration test's BPMN file. + */ +public class SampleDelegate { +} diff --git a/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterIntegrationTest.java b/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterIntegrationTest.java new file mode 100644 index 00000000..c6aeed54 --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterIntegrationTest.java @@ -0,0 +1,143 @@ +package dev.dsf.linter.service; + +import dev.dsf.linter.logger.Logger; +import dev.dsf.linter.output.LinterSeverity; +import dev.dsf.linter.output.LintingType; +import dev.dsf.linter.output.item.AbstractLintItem; +import dev.dsf.linter.plugin.PluginDefinitionDiscovery.PluginAdapter; +import dev.dsf.linter.util.api.ApiVersion; +import dev.dsf.linter.util.api.ApiVersionHolder; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration-style tests for {@link SpringConfigurationLinter}. + * + *

    Verifies the core rule: every class referenced as a Camunda delegate or + * listener in a BPMN file must be provided as a {@code @Bean} in at least one + * of the {@code @Configuration} classes returned by + * {@code ProcessPluginDefinition#getSpringConfigurations()}.

    + */ +class SpringConfigurationLinterIntegrationTest { + + private Path tempProjectRoot; + + @BeforeEach + void setUp() throws IOException { + tempProjectRoot = Files.createTempDirectory("spring-config-integ-"); + } + + @AfterEach + void tearDown() { + ApiVersionHolder.clear(); + deleteRecursively(tempProjectRoot.toFile()); + } + + @Test + void missingRegistration_producesError() throws IOException { + ApiVersionHolder.setVersion(ApiVersion.V1); + // BPMN references SampleDelegate; Object.class has no @Bean for it → ERROR. + File bpmn = writeBpmn(tempProjectRoot, SampleDelegate.class.getName()); + + PluginAdapter adapter = stubAdapter(List.of(Object.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + AbstractLintItem errorItem = items.stream() + .filter(i -> i.getSeverity() == LinterSeverity.ERROR) + .findFirst() + .orElse(null); + assertNotNull(errorItem, "Expected an ERROR when the BPMN-referenced class has no @Bean"); + assertEquals(LintingType.PLUGIN_DEFINITION_SPRING_CONFIGURATION_MISSING, errorItem.getType()); + assertTrue(errorItem.getDescription().contains(SampleDelegate.class.getSimpleName()), + "Error description should mention the uncovered BPMN-referenced class"); + assertFalse(errorItem.getDescription().contains("ActivityPrototypeBeanCreator"), + "API V1 error must not mention ActivityPrototypeBeanCreator"); + + assertFalse(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.SUCCESS), + "No SUCCESS item expected when at least one reference is uncovered"); + } + + @Test + void registeredConfiguration_producesSuccess() throws IOException { + File bpmn = writeBpmn(tempProjectRoot, SampleDelegate.class.getName()); + + PluginAdapter adapter = stubAdapter(List.of(SampleAppConfig.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertFalse(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR), + "Expected no ERROR when the required configuration is registered"); + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.SUCCESS), + "Expected a SUCCESS item when all references resolve"); + } + + // ==================== Helpers ==================== + + private static PluginAdapter stubAdapter(List> springConfigs) { + return new PluginAdapter() { + @Override public String getName() { return "sample-plugin"; } + @Override public List getProcessModels() { return Collections.emptyList(); } + @Override public Map> getFhirResourcesByProcessId() { return Collections.emptyMap(); } + @Override public Class sourceClass() { return SampleAppConfig.class; } + @Override public String getResourceVersion() { return "1.0"; } + @Override public List> getSpringConfigurations() { return springConfigs; } + }; + } + + private static Logger silentLogger() { + return new Logger() { + @Override public void info(String message) {} + @Override public void warn(String message) {} + @Override public void error(String message) {} + @Override public void error(String message, Throwable throwable) {} + @Override public void debug(String message) {} + @Override public boolean verbose() { return false; } + @Override public boolean isVerbose() { return false; } + }; + } + + private static File writeBpmn(Path dir, String camundaClass) throws IOException { + String bpmn = """ + + + + + + + """.formatted(camundaClass); + Path p = dir.resolve("sample.bpmn"); + Files.writeString(p, bpmn, StandardCharsets.UTF_8); + return p.toFile(); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private static void deleteRecursively(File f) { + if (f == null || !f.exists()) return; + File[] kids = f.listFiles(); + if (kids != null) { + for (File k : kids) deleteRecursively(k); + } + f.delete(); + } +} diff --git a/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterTest.java b/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterTest.java new file mode 100644 index 00000000..8c4783bf --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/service/SpringConfigurationLinterTest.java @@ -0,0 +1,481 @@ +package dev.dsf.linter.service; + +import dev.dsf.linter.logger.Logger; +import dev.dsf.linter.output.LinterSeverity; +import dev.dsf.linter.output.LintingType; +import dev.dsf.linter.output.item.AbstractLintItem; +import dev.dsf.linter.plugin.PluginDefinitionDiscovery.PluginAdapter; +import dev.dsf.linter.util.api.ApiVersion; +import dev.dsf.linter.util.api.ApiVersionHolder; + +import dev.dsf.bpe.v2.spring.ActivityPrototypeBeanCreator; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Scope; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link SpringConfigurationLinter}. + * + *

    These tests exercise the decisions the linter makes from the + * {@link PluginAdapter#getSpringConfigurations()} result and BPMN input. + * The linter checks whether every BPMN-referenced class is provided as a + * {@code @Bean} in one of the registered {@code @Configuration} classes.

    + */ +class SpringConfigurationLinterTest { + + private Path tempProjectRoot; + + @BeforeEach + void setUp() throws IOException { + tempProjectRoot = Files.createTempDirectory("spring-config-linter-test-"); + } + + @AfterEach + void tearDown() throws IOException { + ApiVersionHolder.clear(); + if (tempProjectRoot != null) { + deleteRecursively(tempProjectRoot.toFile()); + } + } + + @Test + void emptySpringConfigurations_noBpmn_yieldsSuccess() { + // Empty registered list + no BPMN references → nothing to check → SUCCESS. + PluginAdapter adapter = stubAdapter(Collections.emptyList()); + + List items = SpringConfigurationLinter.lint( + adapter, Collections.emptyList(), tempProjectRoot.toFile(), null); + + assertEquals(1, items.size(), "Expected exactly one lint item"); + assertEquals(LinterSeverity.SUCCESS, items.getFirst().getSeverity()); + } + + @Test + void emptySpringConfigurations_withBpmn_yieldsError() throws IOException { + // Empty registered list + BPMN reference → no @Bean can cover it → ERROR. + File bpmn = writeBpmn(tempProjectRoot, "com.example.MyDelegate"); + PluginAdapter adapter = stubAdapter(Collections.emptyList()); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR), + "Expected ERROR when no registered config can cover a BPMN reference"); + } + + @Test + void nonEmptyConfigurations_noBpmn_noProjectScan_yieldsSuccess() { + PluginAdapter adapter = stubAdapter(List.of(DummyConfig.class)); + + List items = SpringConfigurationLinter.lint( + adapter, Collections.emptyList(), tempProjectRoot.toFile(), null); + + assertEquals(1, items.size()); + assertEquals(LinterSeverity.SUCCESS, items.getFirst().getSeverity()); + assertEquals(LintingType.SUCCESS, items.getFirst().getType()); + } + + @Test + void nullProjectDir_nonEmptyConfigurations_yieldsSuccess() { + PluginAdapter adapter = stubAdapter(List.of(DummyConfig.class)); + + List items = SpringConfigurationLinter.lint( + adapter, Collections.emptyList(), null, null); + + assertEquals(1, items.size()); + assertEquals(LinterSeverity.SUCCESS, items.getFirst().getSeverity()); + } + + @Test + void bpmnReference_notCoveredByRegisteredBean_yieldsError() throws IOException { + // DummyConfig has no @Bean methods at all, so the BPMN-referenced class + // cannot be resolved → the linter must report an ERROR. + File bpmn = writeBpmn(tempProjectRoot, + "dev.dsf.bpe.service.SelectPingTargets"); + + PluginAdapter adapter = stubAdapter(List.of(DummyConfig.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR), + "Expected ERROR when a BPMN-referenced class has no matching @Bean"); + assertFalse(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.SUCCESS), + "No SUCCESS item expected when at least one reference is uncovered"); + } + + @Test + void v1Uncovered_reportsBeanMissing_withoutActivityPrototypeBeanCreator() throws IOException { + ApiVersionHolder.setVersion(ApiVersion.V1); + File bpmn = writeBpmn(tempProjectRoot, "com.example.MissingV1Delegate"); + PluginAdapter adapter = stubAdapter(List.of(DummyConfig.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + AbstractLintItem error = items.stream() + .filter(i -> i.getSeverity() == LinterSeverity.ERROR) + .findFirst() + .orElseThrow(); + assertEquals(LintingType.PLUGIN_DEFINITION_SPRING_CONFIGURATION_MISSING, error.getType()); + assertTrue(error.getDescription().contains("not provided as a @Bean")); + assertTrue(error.getDescription().contains("Add a @Bean method returning MissingV1Delegate")); + assertFalse(error.getDescription().contains("ActivityPrototypeBeanCreator")); + } + + @Test + void v2Uncovered_withoutApbc_reportsActivityMissing_notV1BeanWording() throws IOException { + ApiVersionHolder.setVersion(ApiVersion.V2); + File bpmn = writeBpmn(tempProjectRoot, "com.example.MissingV2Activity"); + PluginAdapter adapter = stubAdapter(List.of(DummyConfig.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + AbstractLintItem error = items.stream() + .filter(i -> i.getSeverity() == LinterSeverity.ERROR) + .findFirst() + .orElseThrow(); + assertEquals(LintingType.PLUGIN_DEFINITION_ACTIVITY_PROTOTYPE_BEAN_MISSING, error.getType()); + assertTrue(error.getDescription().contains("ActivityPrototypeBeanCreator")); + assertTrue(error.getDescription().contains("prototype-scoped @Bean")); + assertFalse(error.getDescription().contains("Add a @Bean method returning")); + assertFalse(error.getDescription().contains("Alternatively")); + } + + @Test + void v2Uncovered_apbcOmitsClass_reportsActivityMissing() throws IOException { + ApiVersionHolder.setVersion(ApiVersion.V2); + File bpmn = writeBpmn(tempProjectRoot, ConfigWithApbc.OmittedActivity.class.getName()); + PluginAdapter adapter = stubAdapter(List.of(ConfigWithApbc.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + AbstractLintItem error = items.stream() + .filter(i -> i.getSeverity() == LinterSeverity.ERROR) + .findFirst() + .orElseThrow(); + assertEquals(LintingType.PLUGIN_DEFINITION_ACTIVITY_PROTOTYPE_BEAN_MISSING, error.getType()); + assertTrue(error.getDescription().contains("not registered via ActivityPrototypeBeanCreator")); + assertFalse(error.getDescription().contains("Add a @Bean method returning")); + assertFalse(items.stream().anyMatch(i -> i.getType() == LintingType.SPRING_BEAN_SCOPE_PROTOTYPE)); + assertFalse(items.stream().anyMatch(i -> i.getType() == LintingType.PLUGIN_DEFINITION_SPRING_CONFIGURATION_MISSING)); + } + + @Test + void v2Covered_viaApbc_reportsActivityPrototypeSuccess() throws IOException { + ApiVersionHolder.setVersion(ApiVersion.V2); + File bpmn = writeBpmn(tempProjectRoot, ConfigWithApbc.CoveredActivity.class.getName()); + PluginAdapter adapter = stubAdapter(List.of(ConfigWithApbc.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.SUCCESS + && i.getType() == LintingType.SPRING_ACTIVITY_PROTOTYPE_BEAN_CREATOR + && i.getDescription().contains("ActivityPrototypeBeanCreator")), + "Expected SUCCESS for ActivityPrototypeBeanCreator registration"); + assertFalse(items.stream().anyMatch(i -> i.getType() == LintingType.SPRING_BEAN_SCOPE_PROTOTYPE)); + assertFalse(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR)); + assertTrue(items.stream().anyMatch(i -> i.getType() == LintingType.SUCCESS + && i.getDescription().contains("prototype activities") + && !i.getDescription().contains("covered by a registered @Bean"))); + } + + @Test + void v2Covered_viaApbcAndBean_reportsRegisteredTwiceWarn() throws IOException { + ApiVersionHolder.setVersion(ApiVersion.V2); + File bpmn = writeBpmn(tempProjectRoot, ConfigWithApbcAndBean.DualActivity.class.getName()); + PluginAdapter adapter = stubAdapter(List.of(ConfigWithApbcAndBean.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.WARN + && i.getType() == LintingType.SPRING_ACTIVITY_REGISTERED_TWICE + && i.getDescription().contains("both via") + && i.getDescription().contains("ActivityPrototypeBeanCreator")), + "Expected WARN when activity is in APBC and declared as a @Bean"); + assertFalse(items.stream().anyMatch(i -> i.getType() == LintingType.SPRING_ACTIVITY_PROTOTYPE_BEAN_CREATOR)); + assertFalse(items.stream().anyMatch(i -> i.getType() == LintingType.SPRING_BEAN_SCOPE_PROTOTYPE)); + assertFalse(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR)); + } + + @Test + void v2Covered_viaBeanOnly_reportsRegisteredAsBeanWarn() throws IOException { + ApiVersionHolder.setVersion(ApiVersion.V2); + File bpmn = writeBpmn(tempProjectRoot, ConfigPrototype.AnyBean.class.getName()); + PluginAdapter adapter = stubAdapter(List.of(ConfigPrototype.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.WARN + && i.getType() == LintingType.SPRING_ACTIVITY_REGISTERED_AS_BEAN + && i.getDescription().contains("declared as a @Bean") + && i.getDescription().contains("ActivityPrototypeBeanCreator")), + "Expected WARN when V2 activity is a @Bean but not in APBC"); + assertFalse(items.stream().anyMatch(i -> i.getType() == LintingType.SPRING_BEAN_SCOPE_PROTOTYPE)); + assertFalse(items.stream().anyMatch(i -> i.getType() == LintingType.SPRING_ACTIVITY_PROTOTYPE_BEAN_CREATOR)); + assertFalse(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR)); + } + + @Test + void bpmnReference_coveredByRegisteredBean_yieldsSuccess() throws IOException { + // ConfigWithBean declares a @Bean for the exact BPMN-referenced class → SUCCESS. + File bpmn = writeBpmn(tempProjectRoot, + ConfigWithBean.BeanClass.class.getName()); + + PluginAdapter adapter = stubAdapter(List.of(ConfigWithBean.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertFalse(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR), + "No ERROR expected when the BPMN reference is covered by a registered @Bean"); + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.SUCCESS), + "Expected SUCCESS when all BPMN references are covered"); + } + + + // ==================== @Scope tests ==================== + + @Test + void beanWithoutScope_noMutableFields_yieldsWarn() throws IOException { + // ConfigNoScope provides a @Bean with no @Scope; the bean class has only + // final fields → WARN about missing scope, but no ERROR for mutable fields. + File bpmn = writeBpmn(tempProjectRoot, ConfigNoScope.ImmutableBean.class.getName()); + PluginAdapter adapter = stubAdapter(List.of(ConfigNoScope.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.WARN + && i.getType() == LintingType.SPRING_BEAN_SCOPE_MISSING), + "Expected WARN for missing @Scope"); + assertFalse(items.stream().anyMatch(i -> i.getType() == LintingType.SPRING_BEAN_SCOPE_MUTABLE_SINGLETON), + "No ERROR expected when bean class has no mutable fields"); + } + + @Test + void beanWithoutScope_mutableField_yieldsWarnAndError() throws IOException { + // ConfigNoScopeMutable provides a @Bean with no @Scope; the bean class has a + // mutable field → WARN for missing scope AND ERROR for mutable singleton. + File bpmn = writeBpmn(tempProjectRoot, ConfigNoScopeMutable.MutableBean.class.getName()); + PluginAdapter adapter = stubAdapter(List.of(ConfigNoScopeMutable.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.WARN + && i.getType() == LintingType.SPRING_BEAN_SCOPE_MISSING), + "Expected WARN for missing @Scope"); + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR + && i.getType() == LintingType.SPRING_BEAN_SCOPE_MUTABLE_SINGLETON), + "Expected ERROR for mutable fields on effective-singleton bean"); + } + + @Test + void beanWithExplicitSingleton_mutableField_yieldsWarnAndError() throws IOException { + // ConfigExplicitSingleton uses @Scope("singleton") explicitly; the bean class + // has a mutable field → WARN for explicit singleton AND ERROR for mutable fields. + File bpmn = writeBpmn(tempProjectRoot, ConfigExplicitSingleton.MutableBean.class.getName()); + PluginAdapter adapter = stubAdapter(List.of(ConfigExplicitSingleton.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.WARN + && i.getType() == LintingType.SPRING_BEAN_SCOPE_SINGLETON_EXPLICIT), + "Expected WARN for explicit singleton scope"); + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.ERROR + && i.getType() == LintingType.SPRING_BEAN_SCOPE_MUTABLE_SINGLETON), + "Expected ERROR for mutable fields on explicit-singleton bean"); + } + + @Test + void beanWithPrototypeScope_yieldsSuccess_evenWithMutableFields() throws IOException { + // ConfigPrototype uses @Scope("prototype"); the bean class has a mutable field + // but prototype scope is safe → SUCCESS, no ERROR for mutable fields. + File bpmn = writeBpmn(tempProjectRoot, ConfigPrototype.AnyBean.class.getName()); + PluginAdapter adapter = stubAdapter(List.of(ConfigPrototype.class)); + + List items = SpringConfigurationLinter.lint( + adapter, List.of(bpmn), tempProjectRoot.toFile(), silentLogger()); + + assertTrue(items.stream().anyMatch(i -> i.getSeverity() == LinterSeverity.SUCCESS + && i.getType() == LintingType.SPRING_BEAN_SCOPE_PROTOTYPE), + "Expected SUCCESS for prototype-scoped bean"); + assertFalse(items.stream().anyMatch(i -> i.getType() == LintingType.SPRING_BEAN_SCOPE_MUTABLE_SINGLETON), + "No ERROR expected for prototype-scoped bean regardless of mutable fields"); + } + + // ==================== Helpers ==================== + + private static PluginAdapter stubAdapter(List> springConfigs) { + return new PluginAdapter() { + @Override public String getName() { return "test-plugin"; } + @Override public List getProcessModels() { return Collections.emptyList(); } + @Override public Map> getFhirResourcesByProcessId() { return Collections.emptyMap(); } + @Override public Class sourceClass() { return DummyConfig.class; } + @Override public String getResourceVersion() { return "1.0"; } + @Override public List> getSpringConfigurations() { return springConfigs; } + }; + } + + private static Logger silentLogger() { + return new Logger() { + @Override public void info(String message) {} + @Override public void warn(String message) {} + @Override public void error(String message) {} + @Override public void error(String message, Throwable throwable) {} + @Override public void debug(String message) {} + @Override public boolean verbose() { return false; } + @Override public boolean isVerbose() { return false; } + }; + } + + private static File writeBpmn(Path dir, String camundaClass) throws IOException { + String bpmn = """ + + + + + + + """.formatted(camundaClass); + Path p = dir.resolve("sample.bpmn"); + Files.writeString(p, bpmn, StandardCharsets.UTF_8); + return p.toFile(); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private static void deleteRecursively(File f) { + if (f == null || !f.exists()) return; + File[] kids = f.listFiles(); + if (kids != null) { + for (File k : kids) deleteRecursively(k); + } + f.delete(); + } + + /** Placeholder with no @Bean methods – any BPMN reference will be uncovered. */ + static final class DummyConfig { + } + + /** Config that provides exactly one @Bean for {@link BeanClass}. */ + static final class ConfigWithBean { + static final class BeanClass { + } + + @Bean + public BeanClass beanClass() { + return new BeanClass(); + } + } + + /** Config with a @Bean and no @Scope; bean class has only final fields (immutable). */ + static final class ConfigNoScope { + @SuppressWarnings("unused") + static final class ImmutableBean { + private final int x = 0; + } + + @Bean + public ImmutableBean bean() { + return new ImmutableBean(); + } + } + + /** Config with a @Bean and no @Scope; bean class has a mutable instance field. */ + static final class ConfigNoScopeMutable { + @SuppressWarnings("unused") + static final class MutableBean { + private int counter; // mutable – not static, not final + } + + @Bean + public MutableBean bean() { + return new MutableBean(); + } + } + + /** Config with an explicit @Scope("singleton"); bean class has a mutable field. */ + static final class ConfigExplicitSingleton { + @SuppressWarnings("unused") + static final class MutableBean { + private String state; // mutable + } + + @Bean + @Scope("singleton") + public MutableBean bean() { + return new MutableBean(); + } + } + + /** Config with @Scope("prototype"); bean class has a mutable field (safe for prototype). */ + static final class ConfigPrototype { + @SuppressWarnings("unused") + static final class AnyBean { + private int x; // mutable, but prototype-scoped → no ERROR + } + + @Bean + @Scope("prototype") + public AnyBean bean() { + return new AnyBean(); + } + } + + /** API V2 config: ActivityPrototypeBeanCreator lists CoveredActivity only. */ + static final class ConfigWithApbc { + static final class CoveredActivity { + } + + static final class OmittedActivity { + } + + @Bean + public static ActivityPrototypeBeanCreator activityPrototypeBeanCreator() { + return new ActivityPrototypeBeanCreator(CoveredActivity.class); + } + } + + /** API V2 config: same activity in APBC and as a dedicated {@code @Bean}. */ + static final class ConfigWithApbcAndBean { + static final class DualActivity { + } + + @Bean + public static ActivityPrototypeBeanCreator activityPrototypeBeanCreator() { + return new ActivityPrototypeBeanCreator(DualActivity.class); + } + + @Bean + @Scope("prototype") + public DualActivity dualActivity() { + return new DualActivity(); + } + } +} diff --git a/linter-core/src/test/java/dev/dsf/linter/util/BpmnTaskListenerInputParameterTest.java b/linter-core/src/test/java/dev/dsf/linter/util/BpmnTaskListenerInputParameterTest.java index 2cbb3e74..04b6ecb0 100644 --- a/linter-core/src/test/java/dev/dsf/linter/util/BpmnTaskListenerInputParameterTest.java +++ b/linter-core/src/test/java/dev/dsf/linter/util/BpmnTaskListenerInputParameterTest.java @@ -33,7 +33,6 @@ * Test class for validating input parameters (practitionerRole and practitioners) * and taskOutput field injections (taskOutputSystem, taskOutputCode, taskOutputVersion) * in task listeners that extend DefaultUserTaskListener (API v2). - * * Tests various scenarios including: * - Task listener with practitionerRole having empty/null value * - Task listener with practitioners having empty/null value @@ -66,15 +65,6 @@ public void setUp() throws IOException { issues = new ArrayList<>(); } - /** - * Helper method to assert that a lint item has a specific LintingType. - */ - private void assertLintType(LintingType expectedType, BpmnElementLintItem item) { - assertNotNull(item, "Item should not be null"); - assertEquals(expectedType, item.getType(), - "Item should have type " + expectedType); - } - private BpmnModelInstance createModelFromXml(String bpmnXml) { return Bpmn.readModelFromStream( new java.io.ByteArrayInputStream(bpmnXml.getBytes(StandardCharsets.UTF_8))); @@ -82,7 +72,7 @@ private BpmnModelInstance createModelFromXml(String bpmnXml) { @Test @DisplayName("Should report ERROR when practitionerRole input parameter has empty value for DefaultUserTaskListener (API v2)") - public void testPractitionerRoleWithEmptyValueForDefaultUserTaskListener() throws Exception { + public void testPractitionerRoleWithEmptyValueForDefaultUserTaskListener() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String bpmnXml = String.format(""" @@ -122,14 +112,14 @@ public void testPractitionerRoleWithEmptyValueForDefaultUserTaskListener() throw List errors = issues.stream() .filter(item -> item.getSeverity() == LinterSeverity.ERROR) .toList(); - assertTrue(errors.stream().anyMatch(item -> item.getType() == LintingType.BPMN_PRACTITIONER_ROLE_HAS_NO_VALUE_OR_NULL), + assertTrue(errors.stream().anyMatch(item -> item.getType() == LintingType.BPMN_USER_TASK_LISTENER_PRACTITIONER_ROLE_INPUT_EMPTY), "Should report ERROR for empty practitionerRole when extending DefaultUserTaskListener"); } } @Test @DisplayName("Should report WARN when practitionerRole input parameter has empty value for non-DefaultUserTaskListener (API v2)") - public void testPractitionerRoleWithEmptyValueForNonDefaultUserTaskListener() throws Exception { + public void testPractitionerRoleWithEmptyValueForNonDefaultUserTaskListener() { // Given String className = "com.example.OtherListener"; String bpmnXml = String.format(""" @@ -170,14 +160,14 @@ public void testPractitionerRoleWithEmptyValueForNonDefaultUserTaskListener() th List warnings = issues.stream() .filter(item -> item.getSeverity() == LinterSeverity.WARN) .toList(); - assertTrue(warnings.stream().anyMatch(item -> item.getType() == LintingType.BPMN_PRACTITIONER_ROLE_HAS_NO_VALUE_OR_NULL), + assertTrue(warnings.stream().anyMatch(item -> item.getType() == LintingType.BPMN_USER_TASK_LISTENER_PRACTITIONER_ROLE_INPUT_EMPTY), "Should report WARN for empty practitionerRole when NOT extending DefaultUserTaskListener"); } } @Test @DisplayName("Should report ERROR when practitioners input parameter has empty value for DefaultUserTaskListener (API v2)") - public void testPractitionersWithEmptyValueForDefaultUserTaskListener() throws Exception { + public void testPractitionersWithEmptyValueForDefaultUserTaskListener() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String bpmnXml = String.format(""" @@ -217,14 +207,14 @@ public void testPractitionersWithEmptyValueForDefaultUserTaskListener() throws E List errors = issues.stream() .filter(item -> item.getSeverity() == LinterSeverity.ERROR) .toList(); - assertTrue(errors.stream().anyMatch(item -> item.getType() == LintingType.BPMN_PRACTITIONERS_HAS_NO_VALUE_OR_NULL), + assertTrue(errors.stream().anyMatch(item -> item.getType() == LintingType.BPMN_USER_TASK_LISTENER_PRACTITIONERS_INPUT_EMPTY), "Should report ERROR for empty practitioners when extending DefaultUserTaskListener"); } } @Test @DisplayName("Should report WARN when practitioners input parameter has empty value for non-DefaultUserTaskListener (API v2)") - public void testPractitionersWithEmptyValueForNonDefaultUserTaskListener() throws Exception { + public void testPractitionersWithEmptyValueForNonDefaultUserTaskListener() { // Given String className = "com.example.OtherListener"; String bpmnXml = String.format(""" @@ -265,14 +255,14 @@ public void testPractitionersWithEmptyValueForNonDefaultUserTaskListener() throw List warnings = issues.stream() .filter(item -> item.getSeverity() == LinterSeverity.WARN) .toList(); - assertTrue(warnings.stream().anyMatch(item -> item.getType() == LintingType.BPMN_PRACTITIONERS_HAS_NO_VALUE_OR_NULL), + assertTrue(warnings.stream().anyMatch(item -> item.getType() == LintingType.BPMN_USER_TASK_LISTENER_PRACTITIONERS_INPUT_EMPTY), "Should report WARN for empty practitioners when NOT extending DefaultUserTaskListener"); } } @Test @DisplayName("Should report success when practitionerRole has valid string value for DefaultUserTaskListener (API v2)") - public void testPractitionerRoleWithValidStringValueForDefaultUserTaskListener() throws Exception { + public void testPractitionerRoleWithValidStringValueForDefaultUserTaskListener() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String bpmnXml = String.format(""" @@ -322,7 +312,7 @@ public void testPractitionerRoleWithValidStringValueForDefaultUserTaskListener() @Test @DisplayName("Should report success when practitionerRole has valid string value for non-DefaultUserTaskListener (API v2)") - public void testPractitionerRoleWithValidStringValueForNonDefaultUserTaskListener() throws Exception { + public void testPractitionerRoleWithValidStringValueForNonDefaultUserTaskListener() { // Given String className = "com.example.OtherListener"; String bpmnXml = String.format(""" @@ -373,7 +363,7 @@ public void testPractitionerRoleWithValidStringValueForNonDefaultUserTaskListene @Test @DisplayName("Should report success when practitioners has valid list value for DefaultUserTaskListener (API v2)") - public void testPractitionersWithValidListValueForDefaultUserTaskListener() throws Exception { + public void testPractitionersWithValidListValueForDefaultUserTaskListener() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String bpmnXml = String.format(""" @@ -425,7 +415,7 @@ public void testPractitionersWithValidListValueForDefaultUserTaskListener() thro @Test @DisplayName("Should report success when practitioners has valid list value for non-DefaultUserTaskListener (API v2)") - public void testPractitionersWithValidListValueForNonDefaultUserTaskListener() throws Exception { + public void testPractitionersWithValidListValueForNonDefaultUserTaskListener() { // Given String className = "com.example.OtherListener"; String bpmnXml = String.format(""" @@ -478,7 +468,7 @@ public void testPractitionersWithValidListValueForNonDefaultUserTaskListener() t @Test @DisplayName("Should not validate input parameters for API v1") - public void testNoValidationForApiV1() throws Exception { + public void testNoValidationForApiV1() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String bpmnXml = String.format(""" @@ -517,8 +507,8 @@ public void testNoValidationForApiV1() throws Exception { // Then - should not have any warnings for input parameters List warnings = issues.stream() .filter(item -> item.getSeverity() == LinterSeverity.WARN) - .filter(item -> item.getType() == LintingType.BPMN_PRACTITIONER_ROLE_HAS_NO_VALUE_OR_NULL - || item.getType() == LintingType.BPMN_PRACTITIONERS_HAS_NO_VALUE_OR_NULL) + .filter(item -> item.getType() == LintingType.BPMN_USER_TASK_LISTENER_PRACTITIONER_ROLE_INPUT_EMPTY + || item.getType() == LintingType.BPMN_USER_TASK_LISTENER_PRACTITIONERS_INPUT_EMPTY) .toList(); assertTrue(warnings.isEmpty(), "Should not validate input parameters for API v1"); @@ -528,7 +518,7 @@ public void testNoValidationForApiV1() throws Exception { @Test @DisplayName("Should validate both practitionerRole and practitioners when both are present") - public void testBothParametersValidation() throws Exception { + public void testBothParametersValidation() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String bpmnXml = String.format(""" @@ -571,7 +561,7 @@ public void testBothParametersValidation() throws Exception { List errors = issues.stream() .filter(item -> item.getSeverity() == LinterSeverity.ERROR) .toList(); - assertTrue(errors.stream().anyMatch(item -> item.getType() == LintingType.BPMN_PRACTITIONERS_HAS_NO_VALUE_OR_NULL), + assertTrue(errors.stream().anyMatch(item -> item.getType() == LintingType.BPMN_USER_TASK_LISTENER_PRACTITIONERS_INPUT_EMPTY), "Should report ERROR for empty practitioners when extending DefaultUserTaskListener"); List successItems = issues.stream() @@ -585,7 +575,7 @@ public void testBothParametersValidation() throws Exception { @Test @DisplayName("Should skip validation when input parameter is not present") - public void testSkipValidationWhenParameterNotPresent() throws Exception { + public void testSkipValidationWhenParameterNotPresent() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String bpmnXml = String.format(""" @@ -620,8 +610,8 @@ public void testSkipValidationWhenParameterNotPresent() throws Exception { // Then - should not have any errors or warnings for input parameters when they are not present List inputParamIssues = issues.stream() - .filter(item -> item.getType() == LintingType.BPMN_PRACTITIONER_ROLE_HAS_NO_VALUE_OR_NULL - || item.getType() == LintingType.BPMN_PRACTITIONERS_HAS_NO_VALUE_OR_NULL) + .filter(item -> item.getType() == LintingType.BPMN_USER_TASK_LISTENER_PRACTITIONER_ROLE_INPUT_EMPTY + || item.getType() == LintingType.BPMN_USER_TASK_LISTENER_PRACTITIONERS_INPUT_EMPTY) .toList(); assertTrue(inputParamIssues.isEmpty(), "Should not validate when input parameters are not present"); @@ -632,7 +622,7 @@ public void testSkipValidationWhenParameterNotPresent() throws Exception { @Test @DisplayName("Should report ERROR when only taskOutputSystem is set (incomplete fields)") - public void testTaskOutputIncompleteOnlySystem() throws Exception { + public void testTaskOutputIncompleteOnlySystem() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String bpmnXml = String.format(""" @@ -681,7 +671,7 @@ public void testTaskOutputIncompleteOnlySystem() throws Exception { @Test @DisplayName("Should report ERROR when only taskOutputCode is set (incomplete fields)") - public void testTaskOutputIncompleteOnlyCode() throws Exception { + public void testTaskOutputIncompleteOnlyCode() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String bpmnXml = String.format(""" @@ -730,7 +720,7 @@ public void testTaskOutputIncompleteOnlyCode() throws Exception { @Test @DisplayName("Should report ERROR when taskOutputSystem references unknown CodeSystem") - public void testTaskOutputSystemUnknownCodeSystem() throws Exception { + public void testTaskOutputSystemUnknownCodeSystem() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String systemUrl = "http://dsf.dev/fhir/CodeSystem/unknown"; @@ -790,7 +780,7 @@ public void testTaskOutputSystemUnknownCodeSystem() throws Exception { @Test @DisplayName("Should report ERROR when taskOutputCode is unknown in CodeSystem") - public void testTaskOutputCodeUnknownInCodeSystem() throws Exception { + public void testTaskOutputCodeUnknownInCodeSystem() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String systemUrl = "http://dsf.dev/fhir/CodeSystem/test"; @@ -851,7 +841,7 @@ public void testTaskOutputCodeUnknownInCodeSystem() throws Exception { @Test @DisplayName("Should report WARN when taskOutputVersion has no placeholder") - public void testTaskOutputVersionNoPlaceholder() throws Exception { + public void testTaskOutputVersionNoPlaceholder() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String systemUrl = "http://dsf.dev/fhir/CodeSystem/test"; @@ -912,7 +902,7 @@ public void testTaskOutputVersionNoPlaceholder() throws Exception { @Test @DisplayName("Should report SUCCESS when all taskOutput fields are valid") - public void testTaskOutputAllFieldsValid() throws Exception { + public void testTaskOutputAllFieldsValid() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String systemUrl = "http://dsf.dev/fhir/CodeSystem/test"; @@ -977,7 +967,7 @@ public void testTaskOutputAllFieldsValid() throws Exception { @Test @DisplayName("Should skip taskOutput validation when no fields are set") - public void testTaskOutputSkipWhenNoFieldsSet() throws Exception { + public void testTaskOutputSkipWhenNoFieldsSet() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String bpmnXml = String.format(""" @@ -1024,7 +1014,7 @@ public void testTaskOutputSkipWhenNoFieldsSet() throws Exception { @Test @DisplayName("Should not validate taskOutput fields for API v1") - public void testTaskOutputNoValidationForApiV1() throws Exception { + public void testTaskOutputNoValidationForApiV1() { // Given String className = "com.example.DefaultUserTaskListenerImpl"; String bpmnXml = String.format(""" diff --git a/linter-core/src/test/java/dev/dsf/linter/util/FhirExtensionUrlTest.java b/linter-core/src/test/java/dev/dsf/linter/util/FhirExtensionUrlTest.java index be74ef8f..ac849653 100644 --- a/linter-core/src/test/java/dev/dsf/linter/util/FhirExtensionUrlTest.java +++ b/linter-core/src/test/java/dev/dsf/linter/util/FhirExtensionUrlTest.java @@ -4,13 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.dsf.linter.util.converter.JsonXmlConverter; import org.junit.jupiter.api.Test; -import org.w3c.dom.Document; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import java.io.StringReader; - -import org.xml.sax.InputSource; import static org.junit.jupiter.api.Assertions.*; @@ -65,11 +58,6 @@ void testExtensionUrlHandling() throws Exception System.out.println("Generated XML:"); System.out.println(xmlResult); - DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); - dbf.setNamespaceAware(true); - DocumentBuilder db = dbf.newDocumentBuilder(); - Document doc = db.parse(new InputSource(new StringReader(xmlResult))); - assertTrue(xmlResult.contains("extension"), "Should contain extension element"); boolean hasUrlAsChildElement = diff --git a/linter-core/src/test/java/dev/dsf/linter/util/ReportAutostartGoldenTest.java b/linter-core/src/test/java/dev/dsf/linter/util/ReportAutostartGoldenTest.java index 41a798e4..c2ea2c4d 100644 --- a/linter-core/src/test/java/dev/dsf/linter/util/ReportAutostartGoldenTest.java +++ b/linter-core/src/test/java/dev/dsf/linter/util/ReportAutostartGoldenTest.java @@ -220,13 +220,6 @@ void jsonToXml_matchesGoldenFile() throws Exception JsonNode node = mapper.readTree(json); String actualXml = JsonXmlConverter.convertJsonToXml(node); - // DEBUG: Print converted XML to console to aid troubleshooting - - System.out.println("----- DEBUG: Converted XML from " - + resourcePath + " -----"); - System.out.println(actualXml); - System.out.println("----- END DEBUG -----"); - // 3) Compare XMLs while ignoring whitespace and child order differences Diff diff = DiffBuilder .compare(Input.fromString(EXPECTED_XML)) @@ -246,7 +239,7 @@ void jsonToXml_matchesGoldenFile() throws Exception ) .build(); - assertFalse(diff.hasDifferences(), () -> "XML differs from golden file:\n" + diff.toString()); + assertFalse(diff.hasDifferences(), () -> "XML differs from golden file:\n" + diff); } } } diff --git a/linter-core/src/test/java/dev/dsf/linter/util/bpmn/linters/BpmnMessageEventImplementationLinterTest.java b/linter-core/src/test/java/dev/dsf/linter/util/bpmn/linters/BpmnMessageEventImplementationLinterTest.java new file mode 100644 index 00000000..24994ffc --- /dev/null +++ b/linter-core/src/test/java/dev/dsf/linter/util/bpmn/linters/BpmnMessageEventImplementationLinterTest.java @@ -0,0 +1,257 @@ +package dev.dsf.linter.util.bpmn.linters; + +import dev.dsf.linter.constants.BpmnElementType; +import dev.dsf.linter.output.LintingType; +import dev.dsf.linter.output.item.BpmnElementLintItem; +import dev.dsf.linter.util.api.ApiVersion; +import dev.dsf.linter.util.api.ApiVersionHolder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link BpmnMessageEventImplementationLinter}. + * + *

    Covers: + *

      + *
    • Class-not-found path (both element types, both API versions)
    • + *
    • V2 MessageEndEvent: correct LintingType {@code BPMN_END_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING}
    • + *
    • V2 MessageIntermediateThrowEvent: correct LintingType + * {@code BPMN_INTERMEDIATE_THROW_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING} (regression guard for the bug + * where both element types erroneously used the end-event type)
    • + *
    • V1 MessageEndEvent: checks for {@code JavaDelegate} (ERROR) and + * {@code AbstractTaskMessageSend} extension (WARN)
    • + *
    • V1 MessageIntermediateThrowEvent: same V1 rules with element-specific WARN type
    • + *
    + *

    + */ +class BpmnMessageEventImplementationLinterTest { + + /** A class that is guaranteed to be present in the JVM but implements no DSF interface. */ + private static final String WRONG_IMPL = "java.lang.String"; + + /** A class name that will never be found on the classpath. */ + private static final String NONEXISTENT_CLASS = "com.example.nonexistent.GhostClass"; + + private final File bpmnFile = new File("process.bpmn"); + + @AfterEach + void clearApiVersion() { + ApiVersionHolder.clear(); + } + + // ==================== CLASS NOT FOUND ==================== + + @Test + void classNotFound_messageEndEvent_v2_reportsClassNotFound() { + ApiVersionHolder.setVersion(ApiVersion.V2); + File projectRoot = tempDir(); + + List issues = lint(NONEXISTENT_CLASS, BpmnElementType.MESSAGE_END_EVENT, projectRoot); + + assertTrue( + issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_FOUND), + "Expected BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_FOUND for unknown class"); + } + + @Test + void classNotFound_messageIntermediateThrowEvent_v2_reportsClassNotFound() { + ApiVersionHolder.setVersion(ApiVersion.V2); + File projectRoot = tempDir(); + + List issues = lint(NONEXISTENT_CLASS, BpmnElementType.MESSAGE_INTERMEDIATE_THROW_EVENT, projectRoot); + + assertTrue( + issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_FOUND), + "Expected BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_FOUND for unknown class"); + } + + @Test + void classNotFound_messageEndEvent_v1_reportsClassNotFound() { + ApiVersionHolder.setVersion(ApiVersion.V1); + File projectRoot = tempDir(); + + List issues = lint(NONEXISTENT_CLASS, BpmnElementType.MESSAGE_END_EVENT, projectRoot); + + assertTrue( + issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_FOUND), + "Expected BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_FOUND for unknown class"); + } + + @Test + void classNotFound_messageIntermediateThrowEvent_v1_reportsClassNotFound() { + ApiVersionHolder.setVersion(ApiVersion.V1); + File projectRoot = tempDir(); + + List issues = lint(NONEXISTENT_CLASS, BpmnElementType.MESSAGE_INTERMEDIATE_THROW_EVENT, projectRoot); + + assertTrue( + issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_FOUND), + "Expected BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_FOUND for unknown class"); + } + + // ==================== V2: CORRECT LintingType PER ELEMENT TYPE ==================== + + /** + * Regression test: before the fix, both element types used + * {@code BPMN_END_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING}. After the fix, end events use + * that type and intermediate throw events use + * {@code BPMN_INTERMEDIATE_THROW_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING}. + */ + @Test + void v2_messageEndEvent_wrongInterface_reportsEndEventType() { + ApiVersionHolder.setVersion(ApiVersion.V2); + + List issues = lint(WRONG_IMPL, BpmnElementType.MESSAGE_END_EVENT, null); + + assertTrue( + issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_END_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING), + "MESSAGE_END_EVENT with wrong interface must report BPMN_END_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING"); + assertTrue( + issues.stream().noneMatch(i -> i.getType() == LintingType.BPMN_INTERMEDIATE_THROW_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING), + "MESSAGE_END_EVENT must NOT report BPMN_INTERMEDIATE_THROW_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING"); + } + + /** + * Regression test: intermediate throw events must report their OWN type, not the end-event type. + */ + @Test + void v2_messageIntermediateThrowEvent_wrongInterface_reportsIntermediateThrowType() { + ApiVersionHolder.setVersion(ApiVersion.V2); + + List issues = lint(WRONG_IMPL, BpmnElementType.MESSAGE_INTERMEDIATE_THROW_EVENT, null); + + assertTrue( + issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_INTERMEDIATE_THROW_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING), + "MESSAGE_INTERMEDIATE_THROW_EVENT with wrong interface must report BPMN_INTERMEDIATE_THROW_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING"); + assertTrue( + issues.stream().noneMatch(i -> i.getType() == LintingType.BPMN_END_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING), + "MESSAGE_INTERMEDIATE_THROW_EVENT must NOT report BPMN_END_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING"); + } + + @Test + void v2_twoElementTypes_produceDifferentLintingTypes() { + ApiVersionHolder.setVersion(ApiVersion.V2); + + List endEventIssues = lint(WRONG_IMPL, BpmnElementType.MESSAGE_END_EVENT, null); + List throwEventIssues = lint(WRONG_IMPL, BpmnElementType.MESSAGE_INTERMEDIATE_THROW_EVENT, null); + + LintingType endEventType = endEventIssues.stream() + .filter(i -> i.getType() == LintingType.BPMN_END_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING + || i.getType() == LintingType.BPMN_INTERMEDIATE_THROW_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING) + .map(BpmnElementLintItem::getType) + .findFirst().orElse(null); + + LintingType throwEventType = throwEventIssues.stream() + .filter(i -> i.getType() == LintingType.BPMN_END_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING + || i.getType() == LintingType.BPMN_INTERMEDIATE_THROW_EVENT_NO_INTERFACE_CLASS_IMPLEMENTING) + .map(BpmnElementLintItem::getType) + .findFirst().orElse(null); + + assertNotNull(endEventType, "End event must produce an interface-related LintingType"); + assertNotNull(throwEventType, "Intermediate throw event must produce an interface-related LintingType"); + assertNotEquals(endEventType, throwEventType, + "End event and intermediate throw event must produce DIFFERENT LintingTypes for V2 wrong-interface errors"); + } + + // ==================== V1: JavaDelegate + AbstractTaskMessageSend checks ==================== + + @Test + void v1_messageEndEvent_wrongInterface_reportsJavaDelegateError() { + ApiVersionHolder.setVersion(ApiVersion.V1); + + List issues = lint(WRONG_IMPL, BpmnElementType.MESSAGE_END_EVENT, null); + + assertTrue( + issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_IMPLEMENTING_JAVA_DELEGATE), + "V1 MessageEndEvent with wrong interface must report JavaDelegate not implemented"); + } + + @Test + void v1_messageIntermediateThrowEvent_wrongInterface_reportsJavaDelegateError() { + ApiVersionHolder.setVersion(ApiVersion.V1); + + List issues = lint(WRONG_IMPL, BpmnElementType.MESSAGE_INTERMEDIATE_THROW_EVENT, null); + + assertTrue( + issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_MESSAGE_SEND_EVENT_IMPLEMENTATION_CLASS_NOT_IMPLEMENTING_JAVA_DELEGATE), + "V1 MessageIntermediateThrowEvent with wrong interface must report JavaDelegate not implemented"); + } + + @Test + void v1_messageEndEvent_classNotExtendingAbstract_reportsElementSpecificWarn() { + ApiVersionHolder.setVersion(ApiVersion.V1); + + List issues = lint(WRONG_IMPL, BpmnElementType.MESSAGE_END_EVENT, null); + + assertTrue( + issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_MESSAGE_END_EVENT_IMPLEMENTATION_CLASS_NOT_EXTENDING_ABSTRACT_TASK_MESSAGE_SEND), + "V1 MessageEndEvent must warn when class does not extend AbstractTaskMessageSend"); + } + + @Test + void v1_messageIntermediateThrowEvent_classNotExtendingAbstract_reportsElementSpecificWarn() { + ApiVersionHolder.setVersion(ApiVersion.V1); + + List issues = lint(WRONG_IMPL, BpmnElementType.MESSAGE_INTERMEDIATE_THROW_EVENT, null); + + assertTrue( + issues.stream().anyMatch(i -> i.getType() == LintingType.BPMN_MESSAGE_INTERMEDIATE_THROW_EVENT_IMPLEMENTATION_CLASS_NOT_EXTENDING_ABSTRACT_TASK_MESSAGE_SEND), + "V1 MessageIntermediateThrowEvent must warn when class does not extend AbstractTaskMessageSend"); + } + + @Test + void v1_endEventAndThrowEvent_produceDifferentAbstractClassWarnTypes() { + ApiVersionHolder.setVersion(ApiVersion.V1); + + List endEventIssues = lint(WRONG_IMPL, BpmnElementType.MESSAGE_END_EVENT, null); + List throwEventIssues = lint(WRONG_IMPL, BpmnElementType.MESSAGE_INTERMEDIATE_THROW_EVENT, null); + + LintingType endEventWarn = endEventIssues.stream() + .filter(i -> i.getType() == LintingType.BPMN_MESSAGE_END_EVENT_IMPLEMENTATION_CLASS_NOT_EXTENDING_ABSTRACT_TASK_MESSAGE_SEND + || i.getType() == LintingType.BPMN_MESSAGE_INTERMEDIATE_THROW_EVENT_IMPLEMENTATION_CLASS_NOT_EXTENDING_ABSTRACT_TASK_MESSAGE_SEND) + .map(BpmnElementLintItem::getType) + .findFirst().orElse(null); + + LintingType throwEventWarn = throwEventIssues.stream() + .filter(i -> i.getType() == LintingType.BPMN_MESSAGE_END_EVENT_IMPLEMENTATION_CLASS_NOT_EXTENDING_ABSTRACT_TASK_MESSAGE_SEND + || i.getType() == LintingType.BPMN_MESSAGE_INTERMEDIATE_THROW_EVENT_IMPLEMENTATION_CLASS_NOT_EXTENDING_ABSTRACT_TASK_MESSAGE_SEND) + .map(BpmnElementLintItem::getType) + .findFirst().orElse(null); + + assertNotNull(endEventWarn, "End event must have an abstract-class-warn LintingType"); + assertNotNull(throwEventWarn, "Intermediate throw event must have an abstract-class-warn LintingType"); + assertNotEquals(endEventWarn, throwEventWarn, + "V1 abstract class warn must differ between end event and intermediate throw event"); + } + + // ==================== Helpers ==================== + + private List lint(String implClass, BpmnElementType elementType, File projectRoot) { + List issues = new ArrayList<>(); + String processId = "test_process"; + String elementId = "element_1"; + BpmnMessageEventImplementationLinter.lintMessageEventImplementationClass( + implClass, elementId, elementType, issues, bpmnFile, processId, + ApiVersionHolder.getVersion(), projectRoot); + return issues; + } + + private static File tempDir() { + try { + Path p = Files.createTempDirectory("linter-test-"); + p.toFile().deleteOnExit(); + return p.toFile(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/pom.xml b/pom.xml index 28787fd6..487299b8 100644 --- a/pom.xml +++ b/pom.xml @@ -4,32 +4,184 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - dev.dsf.utils.linter + dev.dsf.linter dsf-linter - 0.1.2 + 0.1.3 pom + DSF Linter + Linting tool for DSF plugins that validates BPMN, FHIR, and plugin configuration from JAR artifacts, with HTML/JSON reports. + https://dsf.dev/process-development/linter-tool/linter-tool.html + Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 + https://www.apache.org/licenses/LICENSE-2.0 repo - Heilbronn University of Applied Sciences + GECKO Institute, Heilbronn University of Applied Sciences + https://www.hs-heilbronn.de + + + Khalil Malla + dsf-gecko@hs-heilbronn.de + GECKO Institute, Heilbronn University of Applied Sciences + https://www.hs-heilbronn.de + + + + + scm:git:git://github.com/datasharingframework/dsf-linter.git + scm:git:ssh://github.com/datasharingframework/dsf-linter.git + https://github.com/datasharingframework/dsf-linter/tree/main + + linter-core linter-cli - 25 - 25 UTF-8 - \ No newline at end of file + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + 25 + + + + org.apache.maven.plugins + maven-source-plugin + 3.4.0 + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.12.0 + + none + true + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.6 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.2 + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + org.sonatype.central + central-publishing-maven-plugin + 0.10.0 + + + + + + + + github + GitHub Packages + https://maven.pkg.github.com/datasharingframework/dsf-linter + + + + + + generate-source-and-javadoc-jars + + true + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + attach-javadocs + + jar + + + + + + + + + publish-to-maven-central + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + + --pinentry-mode + loopback + + + + + + + + org.sonatype.central + central-publishing-maven-plugin + true + + + central + + false + validated + + + + + + + +