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.linterdsf-linter
- 0.1.2
+ 0.1.3linter-cli
- 25
- 25
- UTF-8
+
+ true
- dev.dsf.utils.linter
+ dev.dsf.linterlinter-core${project.version}info.picoclipicocli
- 4.7.4
+ 4.7.7com.fasterxml.jackson.corejackson-annotations
- 2.18.0
+ 2.22org.jetbrains
@@ -48,7 +47,6 @@
org.apache.maven.pluginsmaven-shade-plugin
- 3.4.1package
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.linterdsf-linter
- 0.1.2
+ 0.1.3linter-core
-
- 25
- 25
- UTF-8
-
-
- 1.15.8
+ 1.18.9
@@ -34,14 +28,14 @@
org.camunda.bpm.modelcamunda-bpmn-model
- 7.22.0
+ 7.24.0org.junit.jupiterjunit-jupiter
- 5.11.3
+ 5.14.4test
@@ -73,25 +67,25 @@
com.fasterxml.jackson.corejackson-databind
- 2.18.0
+ 2.22.0com.fasterxml.jackson.corejackson-core
- 2.18.6
+ 2.21.4com.fasterxml.jackson.corejackson-annotations
- 2.18.0
+ 2.22org.xmlunitxmlunit-core
- 2.10.3
+ 2.12.0test
@@ -104,7 +98,16 @@
com.fasterxml.jackson.datatypejackson-datatype-jsr310
- 2.18.2
+ 2.22.0
+
+
+
+
+ jakarta.xml.bind
+ jakarta.xml.bind-api
+ 4.0.2
@@ -122,7 +125,7 @@
dev.dsfdsf-bpe-process-api-v1
- 1.7.0
+ 1.9.0runtime
@@ -130,7 +133,7 @@
dev.dsfdsf-bpe-process-api-v2
- 2.0.2
+ 2.1.0runtime
@@ -138,7 +141,7 @@
org.camunda.bpmcamunda-engine
- 7.20.0
+ 7.24.0runtime
@@ -146,52 +149,33 @@
ca.uhn.hapi.fhirhapi-fhir-base
- 5.1.0
+ 5.7.9runtime
-
+
+
+ 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.pluginsmaven-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.
+ *
+ *
+ * @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.
+ *
+ *
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
+ */
+@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.
+ *
+ *
+ * @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 ':'}.
+ *
+ *
+ * @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.
*
{@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.
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.
+ *
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.
+ *
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.
+ *
+ *
+ *
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.
+ *
+ *
Slice whose map key (slice name) equals {@code inputCode} - DSF convention
+ * where slice names mirror the code value (e.g., {@code message-name}).
+ *
+ *
+ * @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:
+ *
+ *
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.
+ *
binding.valueSet resolvable in the cache - input system must be
+ * listed in the ValueSet's {@code compose.include.system}.
+ *
binding.valueSet declared but not loaded - explicit validation
+ * failure because the expected context cannot be resolved.
+ *
No binding info available - explicit validation failure because no expected
+ * ValueSet context is available.
+ *
+ *
+ *
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:
+ *
+ *
Read all actual {@code (system, code)} pairs from {@code Task.input}.
+ *
Verify each pair against allowed pairs from the referenced StructureDefinition.
+ *
+ *
+ *
What is checked
+ *
+ *
If an actual Task input code exists in SD constraints but with a different system
+ * → {@link LintingType#FHIR_TASK_INPUT_FIXED_URI_MISMATCH}
+ *
If an actual Task input system exists in SD constraints but with a different code
+ * → {@link LintingType#FHIR_TASK_INPUT_FIXED_CODE_MISMATCH}
+ *
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}
+ *
+ *
+ * @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})
+ *
+ *
Load registered {@code @Configuration} classes from
+ * {@code getSpringConfigurations()}.
+ *
Scan plugin BPMN files for {@code camunda:class} references (service
+ * and send tasks, throw events with message definitions, execution and
+ * task listeners).
+ *
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).
+ *
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.
+ *
+ *
+ *
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 extends Annotation> 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
- *
- *
Primary: Search for ServiceLoader configuration files
- * ({@code META-INF/services/dev.dsf.bpe.v1.ProcessPluginDefinition} or
- * {@code ...v2...}) in common Maven/Gradle layouts.
- *
Fallback: If ServiceLoader config files are missing,
- * perform a directory walk to identify class files or resources with known DSF API package names.
- *
- *
- *
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
- * 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.
+ *
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()}.
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