diff --git a/.agents/skills/mono-repo-integration/SKILL.md b/.agents/skills/mono-repo-integration/SKILL.md index 5e58b769236..f76d77a44ca 100644 --- a/.agents/skills/mono-repo-integration/SKILL.md +++ b/.agents/skills/mono-repo-integration/SKILL.md @@ -1,3 +1,14 @@ +--- +name: mono-repo-integration +description: Step-by-step process for merging a previously-standalone Grails plugin repository (e.g. grails-spring-security, grails-redis) into the grails-core monorepo as one or more Gradle subprojects, wiring it into the shared build, publishing, docs, and CI the same way the existing modules are. +license: Apache-2.0 +compatibility: opencode, claude, grok, gemini, copilot, cursor, windsurf +metadata: + audience: maintainers + frameworks: grails + versions: 7 +--- + ---- -name: mono-repo-integration -description: Step-by-step process for merging a previously-standalone Grails plugin repository (e.g. grails-spring-security, grails-redis) into the grails-core monorepo as one or more Gradle subprojects, wiring it into the shared build, publishing, docs, and CI the same way the existing modules are. -license: Apache-2.0 -compatibility: opencode, claude, grok, gemini, copilot, cursor, windsurf -metadata: - audience: maintainers - frameworks: grails - versions: 7 ---- ## What I Do diff --git a/.agents/skills/violation-fixer/SKILL.md b/.agents/skills/violation-fixer/SKILL.md index f22596e3b07..262bf45ce9c 100644 --- a/.agents/skills/violation-fixer/SKILL.md +++ b/.agents/skills/violation-fixer/SKILL.md @@ -30,7 +30,7 @@ Activate this skill when: | Plugin | Applied to | Responsibility | |--------|-----------|----------------| -| `org.apache.grails.gradle.grails-code-style` | Every subproject | Applies Checkstyle and CodeNarc; registers per-project `codeStyle` task; redirects XML reports to root `build/reports/code-style/` | +| `org.apache.grails.gradle.grails-code-style` | Every subproject | Applies Checkstyle, CodeNarc, and code analysis; registers per-project `codeStyle` task; redirects XML reports to root `build/reports/code-style/` | | `org.apache.grails.gradle.grails-code-analysis` | Every subproject | Applies PMD and SpotBugs (both opt-in); registers per-project `codeAnalysis` task; redirects XML reports to root `build/reports/code-analysis/` | | `org.apache.grails.gradle.grails-jacoco` | Every subproject | Applies JaCoCo; wires `jacocoTestReport` to run after each `test` task | | `org.apache.grails.gradle.grails-violation-aggregation` | **Root project only** | Registers `aggregateViolations` and `aggregateJacocoCoverage` tasks; writes Markdown summaries to `build/reports/violations/` | @@ -44,6 +44,7 @@ Activate this skill when: | `./gradlew codeStyle` | per-project | Runs Checkstyle and CodeNarc for that project | | `./gradlew codeAnalysis` | per-project | Runs PMD and/or SpotBugs for that project (when enabled) | | `./gradlew aggregateViolations` | root | Runs all checks across every module, then writes `*_VIOLATIONS.md` to `build/reports/violations/` | +| `./gradlew validateRepositoryConventions` | root | Validates canonical skill metadata, AGENTS paths, GitHub Action pins, message keys, and RAT provenance | | `./gradlew aggregateJacocoCoverage` | root | Runs JaCoCo reports across every module, then writes `JACOCO_COVERAGE.md` to `build/reports/violations/` | | `./gradlew codenarcFix` | per-project | Auto-fixes a subset of CodeNarc violations | @@ -59,6 +60,9 @@ Activate this skill when: # Full multi-module check + report ./gradlew aggregateViolations +# Repository conventions only +./gradlew validateRepositoryConventions + # Include test sources in style checks ./gradlew aggregateViolations -Pgrails.code-style.enabled.tests=true @@ -85,8 +89,9 @@ After running `aggregateViolations`, these files appear under `build/reports/vio |------|------|-----------------| | `build/reports/violations/CODENARC_VIOLATIONS.md` | CodeNarc | Yes | | `build/reports/violations/CHECKSTYLE_VIOLATIONS.md` | Checkstyle | Yes | -| `build/reports/violations/PMD_VIOLATIONS.md` | PMD | Yes — contains `No violations found!` when PMD is disabled | -| `build/reports/violations/SPOTBUGS_VIOLATIONS.md` | SpotBugs | Yes — contains `No violations found!` when SpotBugs is disabled | +| `build/reports/violations/PMD_VIOLATIONS.md` | PMD | Yes - reports `PMD is disabled.` when PMD is disabled | +| `build/reports/violations/SPOTBUGS_VIOLATIONS.md` | SpotBugs | Yes - reports `SpotBugs is disabled.` when SpotBugs is disabled | +| `build/reports/violations/REPOSITORY_CONVENTIONS.md` | Repository conventions | Yes - lists skill, Action, or message-key failures after RAT succeeds | After running `aggregateJacocoCoverage`: @@ -98,6 +103,16 @@ All reports are inside `build/` and are excluded from version control via `.giti Each file is a Markdown table grouped by module, with columns: **Class**, **Tool**, **Violation**, **Line**, **Message**. +## Repository Conventions + +Run `./gradlew validateRepositoryConventions` to write `build/reports/violations/REPOSITORY_CONVENTIONS.md`. Fix the reported source rather than suppressing the validation. + +| Finding | Fix | +|---------|-----| +| Skill | Start `SKILL.md` with YAML front matter, supply `name`, `description`, and `license`, match the directory name, and synchronize its literal path in `AGENTS.md`. | +| GitHub Action | Pin external references to one lowercase 40-hex SHA for that action across workflows and repository-local `action.yml` or `action.yaml` manifests. Local `./...` uses are permitted. Pin Docker `uses`, Docker action `runs.image`, and workflow job/service container images to immutable `@sha256:` digests. | +| Message key | Remove or rename the duplicate logical key in the reported `messages*.properties` file, preserving escaped separators and continuation semantics. | + --- ## Tool Details @@ -140,9 +155,9 @@ Common violations: | `FileTabCharacter` | Replace tabs with 4 spaces | | `NewlineAtEndOfFile` | Ensure file ends with `\n` | -### PMD (Java/Groovy — opt-in) +### PMD (Java/Groovy - opt-in) -Enable: `-Pgrails.code-analysis.enabled.pmd=true` +Enable all projects with `-Pgrails.code-analysis.enabled.pmd=true`, or enable selected project paths with `-Pgrails.code-analysis.enabled.pmd.projects=:project-a,:project-b`. The root build enforces PMD only for the clean-baseline paths configured in `gradle.properties`. PMD excludes sources under each project's configured build directory. Rule file: `build/code-analysis/pmd/pmd.xml`. @@ -185,10 +200,12 @@ All properties can be set in `gradle.properties` or passed as `-P` flags: | Property | Default | Description | |----------|---------|-------------| -| `grails.code-analysis.enabled.pmd` | `false` | Enable PMD | -| `grails.code-analysis.enabled.spotbugs` | `false` | Enable SpotBugs | +| `grails.code-analysis.enabled.pmd` | `false` | Enable PMD for every project | +| `grails.code-analysis.enabled.pmd.projects` | unset | Comma-separated project paths for selective PMD enablement | +| `grails.code-analysis.enabled.spotbugs` | `false` | Enable SpotBugs for every project | +| `grails.code-analysis.enabled.spotbugs.projects` | unset | Comma-separated project paths for selective SpotBugs enablement | | `grails.code-analysis.enabled.tests` | `false` | Also analyse test source sets | -| `grails.code-analysis.ignoreFailures` | `false` | Collect reports without failing build | +| `grails.code-analysis.ignoreFailures` | `false` | Collect ordinary findings without failing the build; missing expected XML always fails | | `grails.code-analysis.dir.pmd` | (auto) | Custom path to PMD config dir | | `skipCodeStyle` | unset | If present, all analysis tasks are also skipped | @@ -211,11 +228,11 @@ All XML reports are consolidated at: ``` build/reports/code-style/ ← XML inputs for style aggregation ├── checkstyle/ -│ ├── grails-core-checkstyleMain.xml -│ ├── grails-web-mvc-checkstyleMain.xml +│ ├── -checkstyleMain.xml +│ ├── -checkstyleCli.xml │ └── ... └── codenarc/ - ├── grails-core-codenarcMain.xml + ├── -codenarcMain.xml └── ... build/reports/code-analysis/ ← XML inputs for analysis aggregation (if enabled) @@ -227,7 +244,8 @@ build/reports/violations/ ← Markdown summaries written by aggregateViola ├── CHECKSTYLE_VIOLATIONS.md ├── PMD_VIOLATIONS.md ├── SPOTBUGS_VIOLATIONS.md +├── REPOSITORY_CONVENTIONS.md └── JACOCO_COVERAGE.md ← written by aggregateJacocoCoverage ``` -The module name is derived from the filename: everything before the last `-` (e.g. `grails-core-checkstyleMain.xml` → module `grails-core`). +The filename prefix is the UTF-8 hexadecimal encoding of the full Gradle project path. Aggregation decodes it back to paths such as `:grails-core`, preventing nested projects with the same leaf name from colliding. diff --git a/.github/workflows/codeanalysis.yml b/.github/workflows/codeanalysis.yml index cff9c5faf22..7032c1b688e 100644 --- a/.github/workflows/codeanalysis.yml +++ b/.github/workflows/codeanalysis.yml @@ -45,7 +45,7 @@ jobs: cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use) develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }} - name: "🔎 Check Core Projects" - run: ./gradlew aggregateAnalysisViolations --continue -Pgrails.code-analysis.enabled.pmd=true -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true + run: ./gradlew aggregateAnalysisViolations --continue - name: "📤 Upload Reports" if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -60,6 +60,24 @@ jobs: file="build/reports/violations/$report" [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true done + - name: "🔎 Run Core SpotBugs Advisory" + if: always() + continue-on-error: true + run: | + rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md + ./gradlew aggregateAnalysisViolations --continue "-Pgrails.code-analysis.enabled.pmd.projects=" -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true + - name: "📤 Upload SpotBugs Advisory Reports" + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: core-spotbugs-advisory-reports + path: build/reports/violations/ + - name: "📋 Publish SpotBugs Advisory Report in Job Summary" + if: always() + run: | + echo "## 🔎 SpotBugs Advisory Report - Core Projects" >> $GITHUB_STEP_SUMMARY + file="build/reports/violations/SPOTBUGS_VIOLATIONS.md" + [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true check_gradle_plugin_projects: name: "Gradle Plugin Projects" runs-on: ubuntu-24.04 @@ -80,7 +98,7 @@ jobs: develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }} - name: "🔎 Check Gradle Plugin Projects" working-directory: grails-gradle - run: ./gradlew aggregateAnalysisViolations --continue -Pgrails.code-analysis.enabled.pmd=true -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true + run: ./gradlew aggregateAnalysisViolations --continue -Pgrails.code-analysis.enabled.pmd=true -Pgrails.code-analysis.ignoreFailures=true - name: "📤 Upload Reports" if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -95,3 +113,22 @@ jobs: file="grails-gradle/build/reports/violations/$report" [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true done + - name: "🔎 Run Gradle Plugin SpotBugs Advisory" + if: always() + continue-on-error: true + working-directory: grails-gradle + run: | + rm -f build/reports/violations/SPOTBUGS_VIOLATIONS.md + ./gradlew aggregateAnalysisViolations --continue -Pgrails.code-analysis.enabled.spotbugs=true -Pgrails.code-analysis.ignoreFailures=true + - name: "📤 Upload Gradle Plugin SpotBugs Advisory Reports" + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gradle-plugin-spotbugs-advisory-reports + path: grails-gradle/build/reports/violations/ + - name: "📋 Publish Gradle Plugin SpotBugs Advisory Report in Job Summary" + if: always() + run: | + echo "## 🔎 SpotBugs Advisory Report - Gradle Plugin Projects" >> $GITHUB_STEP_SUMMARY + file="grails-gradle/build/reports/violations/SPOTBUGS_VIOLATIONS.md" + [ -f "$file" ] && cat "$file" >> $GITHUB_STEP_SUMMARY || true diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 23949238a1d..74a01eda3ee 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -37,7 +37,7 @@ jobs: distribution: liberica java-version: 21 - name: "🗄️ Restore dependency jar cache" - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs. # Keyed by branch version so each release branch maintains its own warm cache. @@ -81,7 +81,7 @@ jobs: distribution: liberica java-version: ${{ matrix.java }} - name: "🗄️ Restore dependency jar cache" - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs. # Keyed by branch version so each release branch maintains its own warm cache. @@ -143,7 +143,7 @@ jobs: distribution: liberica java-version: ${{ matrix.java }} - name: "🗄️ Restore dependency jar cache" - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs. # Keyed by branch version so each release branch maintains its own warm cache. @@ -193,7 +193,7 @@ jobs: distribution: liberica java-version: ${{ matrix.java }} - name: "🗄️ Restore dependency jar cache" - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs. # Keyed by branch version so each release branch maintains its own warm cache. @@ -242,7 +242,7 @@ jobs: distribution: 'liberica' java-version: ${{ matrix.java }} - name: "🗄️ Restore dependency jar cache" - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs. # Keyed by branch version so each release branch maintains its own warm cache. @@ -323,7 +323,7 @@ jobs: distribution: liberica java-version: ${{ matrix.java }} - name: "🗄️ Restore dependency jar cache" - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs. # Keyed by branch version so each release branch maintains its own warm cache. @@ -495,7 +495,7 @@ jobs: distribution: liberica java-version: ${{ matrix.java }} - name: "🗄️ Restore dependency jar cache" - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs. # Keyed by branch version so each release branch maintains its own warm cache. @@ -547,7 +547,7 @@ jobs: distribution: liberica java-version: ${{ matrix.java }} - name: "🗄️ Restore dependency jar cache" - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs. # Keyed by branch version so each release branch maintains its own warm cache. @@ -628,7 +628,7 @@ jobs: distribution: liberica java-version: 21 - name: "🗄️ Restore dependency jar cache" - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs. # Keyed by branch version so each release branch maintains its own warm cache. @@ -694,7 +694,7 @@ jobs: distribution: liberica java-version: 21 - name: "🗄️ Restore dependency jar cache" - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs. # Keyed by branch version so each release branch maintains its own warm cache. @@ -812,7 +812,7 @@ jobs: distribution: liberica java-version: 21 - name: "🗄️ Restore dependency jar cache" - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs. # Keyed by branch version so each release branch maintains its own warm cache. @@ -923,7 +923,7 @@ jobs: distribution: liberica java-version: 21 - name: "🗄️ Restore dependency jar cache" - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: # Cache only downloaded dependency jars and wrapper distributions, never Grails build outputs. # Keyed by branch version so each release branch maintains its own warm cache. @@ -950,7 +950,7 @@ jobs: compression-level: 0 # Already a zip - name: "🚀 Publish to Github Pages" if: github.event_name == 'push' - uses: apache/grails-github-actions/deploy-github-pages@asf + uses: apache/grails-github-actions/deploy-github-pages@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf env: GH_TOKEN: ${{ secrets.GRAILS_GHTOKEN }} GRADLE_PUBLISH_RELEASE: 'false' diff --git a/.github/workflows/groovy-joint-workflow.yml b/.github/workflows/groovy-joint-workflow.yml index ba298712b2b..9b698b685b8 100644 --- a/.github/workflows/groovy-joint-workflow.yml +++ b/.github/workflows/groovy-joint-workflow.yml @@ -105,7 +105,7 @@ jobs: runs-on: ubuntu-latest services: mongodb: - image: mongo:8 + image: mongo@sha256:951c2ff9fc6bdb6cb89b1dfea4a0e8ae3ee4fb287c0bf579b2bba54c7803f75d # mongo:8 ports: - 27017:27017 options: >- @@ -163,4 +163,4 @@ jobs: -PskipMicronautProjects -PmaxTestParallel=3 env: - GRAILS_INCLUDE_MAVEN_LOCAL: true \ No newline at end of file + GRAILS_INCLUDE_MAVEN_LOCAL: true diff --git a/.github/workflows/release-close.yml b/.github/workflows/release-close.yml index 70e3223f313..3fbe281c718 100644 --- a/.github/workflows/release-close.yml +++ b/.github/workflows/release-close.yml @@ -45,7 +45,7 @@ jobs: ref: ${{ env.TAG }} token: ${{ secrets.GITHUB_TOKEN }} - name: "⚙️ Run post-release" - uses: apache/grails-github-actions/post-release@asf + uses: apache/grails-github-actions/post-release@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf env: PR_LABELS: skip-changelog RELEASE_SCRIPT_PATH: '.github/scripts/setSnapshotGrailsVersion.sh' diff --git a/.github/workflows/release-publish-docs.yml b/.github/workflows/release-publish-docs.yml index b4820647804..dfaedbfb317 100644 --- a/.github/workflows/release-publish-docs.yml +++ b/.github/workflows/release-publish-docs.yml @@ -74,7 +74,7 @@ jobs: # The docs themselves have no code dependency on Micronaut. run: ./gradlew grails-doc:build -PgithubBranch=${TARGET_BRANCH} - name: "🚀 Publish to GitHub Pages" - uses: apache/grails-github-actions/deploy-github-pages@asf + uses: apache/grails-github-actions/deploy-github-pages@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf env: GH_TOKEN: ${{ secrets.GRAILS_GHTOKEN }} # To be able to push to grails-website repo GRADLE_PUBLISH_RELEASE: 'true' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 728e5acd1ba..df4e82c5dde 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,7 +87,7 @@ jobs: cache-provider: basic # 'basic' uses the MIT-licensed, open-source cache provider; the default 'enhanced' provider (v6+) is proprietary (Gradle commercial Terms of Use) develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }} - name: "⚙️ Run pre-release" - uses: apache/grails-github-actions/pre-release@asf + uses: apache/grails-github-actions/pre-release@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf env: RELEASE_VERSION: ${{ env.VERSION }} RELEASE_SCRIPT_PATH: '.github/scripts/setReleasedGrailsVersion.sh' @@ -224,7 +224,7 @@ jobs: # staged from a JDK 25 runner. This is a NEW reproducibility pin - # keep $JAVA_VERSION_MICRONAUT synced with the secondary JDK in # etc/bin/Dockerfile so verifiers can reproduce the resulting JARs. - uses: actions/setup-java@v4 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: liberica java-version: ${{ env.JAVA_VERSION_MICRONAUT }} @@ -247,7 +247,7 @@ jobs: # downstream checksum/artifact-list combination steps all expect the # default JDK 21 toolchain. Also keeps any future steps that touch the # repository's own (non-Micronaut) Gradle config on the documented JDK. - uses: actions/setup-java@v4 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: liberica java-version: ${{ env.JAVA_VERSION }} @@ -334,6 +334,7 @@ jobs: path: grails ref: ${{ env.TAG }} token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false - name: "🗑️ Remove unnecessary files" run: | rm -f grails/gradle/wrapper/gradle-wrapper.jar @@ -373,11 +374,18 @@ jobs: find . -depth \( -type f -o -type d \) -exec touch -d "@${SOURCE_DATE_EPOCH}" {} + - name: "📦 Create source distribution ZIP" run: > - zip -r + zip -yr ${DIST_NAME}-${VERSION}-src.zip grails -x 'grails/.git/*' -x 'grails/.github/*' + -x 'grails/.omo/*' + -x 'grails/.claude/*' + -x 'grails/.clinerules' + -x 'grails/.cursorrules' + -x 'grails/.windsurfrules' + -x 'grails/CLAUDE.md' + -x 'grails/GEMINI.md' -x 'grails/.mailmap' - name: "🔐 Set up GPG" # use env var to not expose the key env: @@ -690,7 +698,7 @@ jobs: # Runs on the JDK 21 pin, where settings.gradle auto-prunes the Micronaut island. run: ./gradlew grails-doc:build -PgithubBranch=${TARGET_BRANCH} - name: "🚀 Publish to GitHub Pages" - uses: apache/grails-github-actions/deploy-github-pages@asf + uses: apache/grails-github-actions/deploy-github-pages@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf env: GH_TOKEN: ${{ secrets.GRAILS_GHTOKEN }} # To be able to push to grails-website repo GRADLE_PUBLISH_RELEASE: 'true' @@ -765,7 +773,7 @@ jobs: ref: ${{ env.TAG }} token: ${{ secrets.GITHUB_TOKEN }} - name: "⚙️ Run post-release" - uses: apache/grails-github-actions/post-release@asf + uses: apache/grails-github-actions/post-release@fe90f651c47c5230cdd70b6fee0e0d250fea30ac # asf env: PR_LABELS: skip-changelog RELEASE_SCRIPT_PATH: '.github/scripts/setSnapshotGrailsVersion.sh' diff --git a/.github/workflows/vulnerability-scan.yml b/.github/workflows/vulnerability-scan.yml index 274f74a0646..cad52286635 100644 --- a/.github/workflows/vulnerability-scan.yml +++ b/.github/workflows/vulnerability-scan.yml @@ -36,9 +36,9 @@ jobs: contents: read steps: - name: "📥 Checkout repository" - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "☕️ Setup JDK" - uses: actions/setup-java@v4 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: liberica java-version: 17 diff --git a/AGENTS.md b/AGENTS.md index 5ec9ac09512..c3bfdc53483 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,9 @@ limitations under the License. # Style check ./gradlew codeStyle +# Repository conventions +./gradlew validateRepositoryConventions + # Out of memory? Set: export GRADLE_OPTS="-Xms2G -Xmx5G" ``` @@ -52,7 +55,7 @@ export GRADLE_OPTS="-Xms2G -Xmx5G" 9. **Test via public APIs** - Tests must exercise behavior through the same APIs an end user calls; never invoke internal implementations, package-private methods, or bypass the public surface directly 10. **Always review and extend tests** - Review existing unit and functional tests before making changes; every code change must include new or enhanced tests that cover the affected behavior 11. **Every code touch must update all tests for the changed class** - When a class is modified, find and update every test that covers it — unit, integration, and TCK. Do not leave any existing test out of sync with the new code. -12. **Clean violations before commit** - Before every automated commit, run `./gradlew clean aggregateViolations :grails-test-report:check --continue` from the root and ensure that `build/reports/violations/CHECKSTYLE_VIOLATIONS.md`, `build/reports/violations/CODENARC_VIOLATIONS.md`, `build/reports/violations/PMD_VIOLATIONS.md`, and `build/reports/violations/SPOTBUGS_VIOLATIONS.md` report no issues. Also review the test result reports under `grails-test-report/build/reports/tests/` and ensure there are no failures. The aggregate reports are wired as test finalizers and will be attempted after failures, but `--continue` is required for comprehensive full-suite reports. +12. **Clean violations before commit** - Before every automated commit, run `./gradlew clean aggregateViolations :grails-test-report:check --continue` from the root. Ensure Checkstyle, CodeNarc, and PMD reports have no issues for their enabled projects; PMD is enforced only for the project paths in `grails.code-analysis.enabled.pmd.projects`. Disabled tools report their disabled status, not a clean result. Also review the test result reports under `grails-test-report/build/reports/tests/` and ensure there are no failures. The aggregate reports are wired as test finalizers and will be attempted after failures, but `--continue` is required for comprehensive full-suite reports. 13. **Mandatory test coverage** - Any class touched in a commit MUST be covered with tests that verify all behavior. You must run ALL tests in the affected module(s) and ensure they pass before committing. 14. **The BOM must manage the latest version** - `validateDependencyVersions` enforces that the BOM (`dependencies.gradle`) manages a version `>=` every transitively-resolved version. When it fails, **bump the version in `dependencies.gradle`** so the BOM wins — never silence it with `allowedBomOverrides` or an exclusion unless there is an explicit, documented conflict or an agreed-upon workaround. See [Dependency Management](#dependency-management). @@ -66,7 +69,7 @@ export GRADLE_OPTS="-Xms2G -Xmx5G" > - Writing Hibernate code → Read `.agents/skills/hibernate-developer/SKILL.md` > - Fixing style/analysis violations → Read `.agents/skills/violation-fixer/SKILL.md` > - Fixing broken test → Read `.agents/skills/test-fixer/SKILL.md` -> - Indexing code -> Read `.agents/skills/codebase-memory/SKILL.md` +> - Integrating a standalone repository -> Read `.agents/skills/mono-repo-integration/SKILL.md` > > Use your file reading capability to load the skill content before proceeding with any code changes. @@ -79,6 +82,19 @@ export GRADLE_OPTS="-Xms2G -Xmx5G" | **hibernate-developer** | `.agents/skills/hibernate-developer/SKILL.md` | Hibernate 7 mapping, binders, generators | | **violation-fixer** | `.agents/skills/violation-fixer/SKILL.md` | Fix style/analysis violations (CodeNarc, Checkstyle, PMD, SpotBugs) | | **test-fixer** | `.agents/skills/test-fixer/SKILL.md` | Aggregate and fix test failures | +| **mono-repo-integration** | `.agents/skills/mono-repo-integration/SKILL.md` | Integrate a standalone repository into the monorepo | + +## Repository Conventions + +Run `./gradlew validateRepositoryConventions` to check canonical skill metadata and AGENTS.md synchronization, external GitHub Action SHA pins in workflows and local composite actions, immutable Docker digests, duplicate message keys, and RAT license provenance. The task writes `build/reports/violations/REPOSITORY_CONVENTIONS.md` and is included by `aggregateStyleViolations` and `aggregateViolations`. + +Review-only checklist: + +- Semantic documentation +- Quote and type preference +- Test sufficiency +- Compatibility +- Architecture ## Technology Stack @@ -251,7 +267,7 @@ class MyService { } 1. **Fork & branch** from the target release branch (e.g., `7.0.x`) 2. **Run tests** before submitting: `./gradlew build --rerun-tasks` 3. **Run code style checks**: `./gradlew codeStyle` -4. **Clean violations**: Before committing, run `./gradlew clean aggregateViolations` from the root and ensure that `build/reports/violations/CHECKSTYLE_VIOLATIONS.md`, `build/reports/violations/CODENARC_VIOLATIONS.md`, `build/reports/violations/PMD_VIOLATIONS.md`, and `build/reports/violations/SPOTBUGS_VIOLATIONS.md` have no issues. +4. **Clean violations**: Before committing, run `./gradlew clean aggregateViolations` from the root. Ensure Checkstyle, CodeNarc, and PMD reports have no issues for their enabled projects; PMD is limited to `grails.code-analysis.enabled.pmd.projects` and disabled tools report their disabled status. 5. **Verify test coverage**: Ensure any touched class is covered by tests verifying all behavior. You must run ALL tests in the affected module(s) and ensure they pass before submission. 6. **Squash commits** into a single meaningful commit message 6. **Reference issues** in PR description (e.g., "Fixes #1234") diff --git a/build-logic/plugins/build.gradle b/build-logic/plugins/build.gradle index c75af3b4ab6..1c26afbe296 100644 --- a/build-logic/plugins/build.gradle +++ b/build-logic/plugins/build.gradle @@ -40,6 +40,7 @@ dependencies { implementation "org.cyclonedx.bom:org.cyclonedx.bom.gradle.plugin:${gradleProperties.gradleCycloneDxPluginVersion}" implementation "com.github.spotbugs.snom:spotbugs-gradle-plugin:${gradleProperties.spotbugsPluginVersion}" implementation "org.sonatype.gradle.plugins:scan-gradle-plugin:${gradleProperties.sonatypeScanPluginVersion}" + implementation 'org.yaml:snakeyaml:2.4' testImplementation "org.spockframework:spock-core:${gradleBomDependencyVersions['gradle-spock.version']}" testImplementation gradleTestKit() diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GradleUtils.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GradleUtils.groovy index 906740662a7..35cad48dc13 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GradleUtils.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GradleUtils.groovy @@ -19,9 +19,15 @@ package org.apache.grails.buildsrc +import java.nio.charset.StandardCharsets +import java.nio.file.Path +import java.util.HexFormat + import groovy.transform.CompileStatic import org.gradle.api.Project +import org.gradle.api.Task import org.gradle.api.file.Directory +import org.gradle.api.file.RegularFile import org.gradle.api.provider.Provider @CompileStatic @@ -44,6 +50,42 @@ class GradleUtils { .orElse(defaultValue) } + static String projectPathKey(Project project) { + HexFormat.of().formatHex(project.path.getBytes(StandardCharsets.UTF_8)) + } + + static String projectPathFromKey(String key) { + new String(HexFormat.of().parseHex(key), StandardCharsets.UTF_8) + } + + static String reportFileName(Project project, String taskName) { + "${projectPathKey(project)}-${taskName}.xml" + } + + static Provider reportMarker(Project project, String tool, String taskName) { + project.rootProject.layout.buildDirectory.file("reports/aggregation-markers/${tool}/${reportFileName(project, taskName)}.marker") + } + + static void configureReportMarker(Task task, Directory rootDirectory, Provider report, + Provider marker) { + Path rootPath = rootDirectory.asFile.toPath().toAbsolutePath().normalize() + task.outputs.file(marker) + task.doFirst { + Path reportPath = report.get().asFile.toPath().toAbsolutePath().normalize() + Path relativeReportPath + try { + relativeReportPath = rootPath.relativize(reportPath) + } catch (IllegalArgumentException exception) { + throw new IllegalStateException("Cannot create a report marker for '${reportPath}' relative to '${rootPath}'", exception) + } + File reportFile = reportPath.toFile() + reportFile.delete() + File markerFile = marker.get().asFile + markerFile.parentFile.mkdirs() + markerFile.text = relativeReportPath.toString().replace(File.separator, '/') + } + } + static T lookupProperty(Project project, String name, T defaultValue = null) { T v = lookupPropertyByType(project, name, defaultValue?.class) as T return v == null ? defaultValue : v diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy index fdfdfde58b9..f49e1405de4 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPlugin.groovy @@ -48,9 +48,11 @@ class GrailsCodeAnalysisPlugin implements Plugin { static String PMD_DIR_PROPERTY = 'grails.code-analysis.dir.pmd' static String PMD_ENABLED_PROPERTY = 'grails.code-analysis.enabled.pmd' + static String PMD_ENABLED_PROJECTS_PROPERTY = 'grails.code-analysis.enabled.pmd.projects' static String PMD_CONFIG_FILE_NAME = 'pmd.xml' static String SPOTBUGS_ENABLED_PROPERTY = 'grails.code-analysis.enabled.spotbugs' + static String SPOTBUGS_ENABLED_PROJECTS_PROPERTY = 'grails.code-analysis.enabled.spotbugs.projects' static String IGNORE_FAILURES_PROPERTY = 'grails.code-analysis.ignoreFailures' static String TEST_ANALYSIS_PROPERTY = 'grails.code-analysis.enabled.tests' @@ -108,8 +110,7 @@ class GrailsCodeAnalysisPlugin implements Plugin { } static void configurePmd(Project project) { - def pmdEnabled = GradleUtils.booleanProvider(project, PMD_ENABLED_PROPERTY) - if (!pmdEnabled.get()) { + if (!isToolEnabled(project, PMD_ENABLED_PROPERTY, PMD_ENABLED_PROJECTS_PROPERTY)) { return } @@ -117,6 +118,8 @@ class GrailsCodeAnalysisPlugin implements Plugin { def ignoreFailures = GradleUtils.booleanProvider(project, IGNORE_FAILURES_PROPERTY) def testStylingEnabled = GradleUtils.booleanProvider(project, TEST_ANALYSIS_PROPERTY) + def skipCodeStyle = project.providers.gradleProperty('skipCodeStyle') + def projectBuildDirectory = project.layout.buildDirectory.map { it.asFile.toPath().toAbsolutePath().normalize() } project.extensions.configure(PmdExtension) { it.ruleSetFiles = project.files(project.extensions.getByType(GrailsCodeAnalysisExtension).pmdDirectory.file(PMD_CONFIG_FILE_NAME)) @@ -128,26 +131,30 @@ class GrailsCodeAnalysisPlugin implements Plugin { project.tasks.withType(Pmd).configureEach { it.group = 'verification' - it.onlyIf { !project.hasProperty('skipCodeStyle') } + it.onlyIf { !skipCodeStyle.present } it.ignoreFailures = ignoreFailures.get() if (it.name.contains('Test') || it.name.contains('test')) { it.enabled = testStylingEnabled.get() } + it.exclude { org.gradle.api.file.FileTreeElement element -> + element.file.toPath().toAbsolutePath().normalize().startsWith(projectBuildDirectory.get()) + } + it.reports.xml.required.set(true) it.reports.xml.outputLocation.set( - project.extensions.getByType(GrailsCodeAnalysisExtension) - .reportsDirectory.get() - .dir('pmd') - .file("${project.name}-${it.name}.xml") + project.extensions.getByType(GrailsCodeAnalysisExtension) + .reportsDirectory + .file("pmd/${GradleUtils.reportFileName(project, it.name)}") ) + GradleUtils.configureReportMarker(it, project.rootProject.layout.projectDirectory, it.reports.xml.outputLocation, + GradleUtils.reportMarker(project, 'pmd', it.name)) } } static void configureSpotbugs(Project project) { - def spotbugsEnabled = GradleUtils.booleanProvider(project, SPOTBUGS_ENABLED_PROPERTY) - if (!spotbugsEnabled.get()) { + if (!isToolEnabled(project, SPOTBUGS_ENABLED_PROPERTY, SPOTBUGS_ENABLED_PROJECTS_PROPERTY)) { return } @@ -155,6 +162,7 @@ class GrailsCodeAnalysisPlugin implements Plugin { def ignoreFailures = GradleUtils.booleanProvider(project, IGNORE_FAILURES_PROPERTY) def testStylingEnabled = GradleUtils.booleanProvider(project, TEST_ANALYSIS_PROPERTY) + def skipCodeStyle = project.providers.gradleProperty('skipCodeStyle') project.extensions.configure(SpotBugsExtension) { it.effort.set(Effort.valueOf('MAX')) @@ -170,16 +178,25 @@ class GrailsCodeAnalysisPlugin implements Plugin { def xmlReport = spotBugsReports.maybeCreate('xml') xmlReport.required.set(true) xmlReport.outputLocation.set( - project.extensions.getByType(GrailsCodeAnalysisExtension) - .reportsDirectory.get() - .dir('spotbugs') - .file("${project.name}-${it.name}.xml") + project.extensions.getByType(GrailsCodeAnalysisExtension) + .reportsDirectory + .file("spotbugs/${GradleUtils.reportFileName(project, it.name)}") ) - it.onlyIf { !project.hasProperty('skipCodeStyle') } + GradleUtils.configureReportMarker(it, project.rootProject.layout.projectDirectory, xmlReport.outputLocation, + GradleUtils.reportMarker(project, 'spotbugs', it.name)) + it.onlyIf { !skipCodeStyle.present } if (it.name.contains('Test') || it.name.contains('test')) { it.enabled = testStylingEnabled.get() } } } + + static boolean isToolEnabled(Project project, String enabledProperty, String enabledProjectsProperty) { + GradleUtils.booleanProvider(project, enabledProperty).get() || + project.providers.gradleProperty(enabledProjectsProperty) + .map { it.split(',')*.trim().contains(project.path) } + .orElse(false) + .get() + } } diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy index 605c8149db3..66a2a81c962 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy @@ -63,6 +63,7 @@ class GrailsCodeStylePlugin implements Plugin { void apply(Project project) { initExtension(project) configureCodeStyle(project) + project.pluginManager.apply(GrailsCodeAnalysisPlugin) } private static void initExtension(Project project) { @@ -133,6 +134,7 @@ class GrailsCodeStylePlugin implements Plugin { project.pluginManager.apply(CheckstylePlugin) def ignoreFailures = GradleUtils.booleanProvider(project, IGNORE_FAILURES_PROPERTY) + def skipCodeStyle = project.providers.gradleProperty('skipCodeStyle') project.extensions.configure(CheckstyleExtension) { // Explicit `it` is required in extension configuration @@ -145,7 +147,7 @@ class GrailsCodeStylePlugin implements Plugin { project.tasks.withType(Checkstyle).configureEach { Checkstyle task -> task.group = 'verification' - task.onlyIf { !project.hasProperty('skipCodeStyle') } + task.onlyIf { !skipCodeStyle.present } task.ignoreFailures = ignoreFailures.get() if (task.name.toLowerCase().contains('test')) { @@ -161,11 +163,12 @@ class GrailsCodeStylePlugin implements Plugin { // Redirect XML report output to a single directory to consolidate // reports across all subprojects into one known location task.reports.xml.outputLocation.set( - project.extensions.getByType(GrailsCodeStyleExtension) - .reportsDirectory.get() - .dir('checkstyle') - .file("${project.name}-${task.name}.xml") + project.extensions.getByType(GrailsCodeStyleExtension) + .reportsDirectory + .file("checkstyle/${GradleUtils.reportFileName(project, task.name)}") ) + GradleUtils.configureReportMarker(task, project.rootProject.layout.projectDirectory, task.reports.xml.outputLocation, + GradleUtils.reportMarker(project, 'checkstyle', task.name)) } } @@ -176,6 +179,7 @@ class GrailsCodeStylePlugin implements Plugin { def ignoreFailures = GradleUtils.booleanProvider(project, IGNORE_FAILURES_PROPERTY) def codenarcFix = GradleUtils.booleanProvider(project, CODENARC_FIX_PROPERTY) + def skipCodeStyle = project.providers.gradleProperty('skipCodeStyle') project.extensions.configure(CodeNarcExtension) { it.configFile = project.extensions.getByType(GrailsCodeStyleExtension) @@ -186,7 +190,7 @@ class GrailsCodeStylePlugin implements Plugin { project.tasks.withType(CodeNarc).configureEach { CodeNarc task -> task.group = 'verification' - task.onlyIf { !project.hasProperty('skipCodeStyle') } + task.onlyIf { !skipCodeStyle.present } task.ignoreFailures = ignoreFailures.get() if (codenarcFix.get()) { @@ -201,11 +205,12 @@ class GrailsCodeStylePlugin implements Plugin { // reports across all subprojects into one known location task.reports.xml.required.set(true) task.reports.xml.outputLocation.set( - project.extensions.getByType(GrailsCodeStyleExtension) - .reportsDirectory.get() - .dir('codenarc') - .file("${project.name}-${task.name}.xml") + project.extensions.getByType(GrailsCodeStyleExtension) + .reportsDirectory + .file("codenarc/${GradleUtils.reportFileName(project, task.name)}") ) + GradleUtils.configureReportMarker(task, project.rootProject.layout.projectDirectory, task.reports.xml.outputLocation, + GradleUtils.reportMarker(project, 'codenarc', task.name)) } } diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy index a5b70265499..554fb88df81 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy @@ -74,6 +74,8 @@ class GrailsViolationAggregationPlugin implements Plugin { static final String DEFAULT_JACOCO_EXCLUDED_CLASS_PREFIXES = 'org.grails.orm.hibernate.support.hibernate7.' + private static final String CANONICAL_ROOT_MARKER = '.github/workflows' + @Override void apply(Project project) { if (project != project.rootProject) { @@ -84,82 +86,217 @@ class GrailsViolationAggregationPlugin implements Plugin { } def violationsDir = project.layout.buildDirectory.dir('reports/violations') - def styleXmlDir = project.layout.buildDirectory.dir('reports/code-style') - def analysisXmlDir = project.layout.buildDirectory.dir('reports/code-analysis') - - def styleTask = registerStyleAggregation(project, styleXmlDir, violationsDir) - def analysisTask = registerAnalysisAggregation(project, analysisXmlDir, violationsDir) + TaskProvider repositoryConventionsTask = project.file(CANONICAL_ROOT_MARKER).isDirectory() ? + registerRepositoryConventions(project, violationsDir) : null + def styleTask = registerStyleAggregation(project, violationsDir) + def analysisTask = registerAnalysisAggregation(project, violationsDir) registerJacocoAggregation(project, violationsDir) project.tasks.register('aggregateViolations') { Task task -> task.group = 'verification' task.description = 'Aggregates all violation reports (style + analysis) into build/reports/violations/' task.dependsOn(styleTask, analysisTask) + if (repositoryConventionsTask) { + task.dependsOn(repositoryConventionsTask) + } + } + } + + private static TaskProvider registerRepositoryConventions(Project root, Provider violationsDir) { + root.tasks.register('validateRepositoryConventions', RepositoryConventionsTask) { RepositoryConventionsTask task -> + task.group = 'verification' + task.description = 'Validates repository conventions and writes build/reports/violations/REPOSITORY_CONVENTIONS.md' + task.repositoryDirectory.set(root.layout.projectDirectory) + task.conventionSources.from( + root.file('AGENTS.md'), + root.fileTree('.agents/skills') { include '*/SKILL.md' }, + root.fileTree('.github/workflows') { include '**/*.yml', '**/*.yaml' }, + root.fileTree('.') { + include '**/action.yml', '**/action.yaml' + exclude '**/build/**', '**/generated/**', '**/.gradle/**', '**/.git/**', '**/.hg/**', '**/.svn/**' + }, + root.fileTree('.') { + include '**/messages*.properties' + exclude '**/build/**', '**/generated/**' + } + ) + task.reportFile.set(violationsDir.map { it.file('REPOSITORY_CONVENTIONS.md') }) + task.outputs.upToDateWhen { false } + task.dependsOn(root.tasks.matching { Task candidate -> candidate.name == 'rat' }) } } - private static TaskProvider registerStyleAggregation(Project root, Provider styleXmlDir, Provider violationsDir) { - // Wire property flags as Providers — values are resolved at task execution time, not at apply() time, - // and Providers are configuration-cache safe to capture in task actions + private static TaskProvider registerStyleAggregation(Project root, Provider violationsDir) { + Directory rootDirectory = root.layout.projectDirectory def checkStyleTests = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.TEST_STYLING_PROPERTY) + def ignoreFailures = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.IGNORE_FAILURES_PROPERTY) def codenarcEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CODENARC_ENABLED_PROPERTY, true) def checkstyleEnabled = GradleUtils.booleanProvider(root, GrailsCodeStylePlugin.CHECKSTYLE_ENABLED_PROPERTY, true) + def codenarcMarkers = root.files() + def checkstyleMarkers = root.files() + def codenarcReports = root.files() + def checkstyleReports = root.files() + def codenarcMarkdown = root.layout.buildDirectory.file('reports/violations/CODENARC_VIOLATIONS.md') + def checkstyleMarkdown = root.layout.buildDirectory.file('reports/violations/CHECKSTYLE_VIOLATIONS.md') + def cleanupTask = root.tasks.register('cleanAggregateStyleReports') { + it.doLast { + deleteReports(codenarcMarkers.files, codenarcReports.files) + deleteReports(checkstyleMarkers.files, checkstyleReports.files) + } + } - def aggregateTask = root.tasks.register('aggregateStyleViolations') { + def writerTask = root.tasks.register('writeStyleViolations') { it.group = 'verification' - it.description = 'Aggregates CodeNarc and Checkstyle violation reports into build/reports/violations/' - it.outputs.file(root.file('build/reports/violations/CODENARC_VIOLATIONS.md')) - it.outputs.file(root.file('build/reports/violations/CHECKSTYLE_VIOLATIONS.md')) + it.description = 'Writes CodeNarc and Checkstyle violation reports into build/reports/violations/' + it.inputs.files(codenarcMarkers).optional() + it.inputs.files(checkstyleMarkers).optional() + it.inputs.property('ignoreFailures', ignoreFailures) + it.outputs.file(codenarcMarkdown) + it.outputs.file(checkstyleMarkdown) + it.outputs.upToDateWhen { false } + it.doFirst { + codenarcMarkdown.get().asFile.delete() + checkstyleMarkdown.get().asFile.delete() + } it.doLast { - parseStyleViolations(styleXmlDir.get(), violationsDir.get(), - checkStyleTests.get(), codenarcEnabled.get(), checkstyleEnabled.get()) + parseStyleViolations(codenarcMarkers.files, checkstyleMarkers.files, rootDirectory, violationsDir.get(), + checkStyleTests.get(), codenarcEnabled.get(), + checkstyleEnabled.get(), ignoreFailures.get()) } } - root.subprojects { Project sub -> - sub.pluginManager.withPlugin('codenarc') { - aggregateTask.configure { - it.dependsOn(sub.tasks.withType(CodeNarc)) - } + def aggregateTask = root.tasks.register('aggregateStyleViolations') { + it.group = 'verification' + it.description = 'Aggregates CodeNarc and Checkstyle violations into build/reports/violations/' + it.dependsOn(writerTask) + } + if (root.tasks.names.contains('validateRepositoryConventions')) { + aggregateTask.configure { it.dependsOn(root.tasks.named('validateRepositoryConventions')) } + } + def finalizeTask = root.tasks.register('finalizeStyleViolations') { + it.group = 'verification' + it.dependsOn(writerTask) + } + root.allprojects { Project sub -> + sub.tasks.withType(CodeNarc).all { CodeNarc codeNarcTask -> + codenarcMarkers.from(GradleUtils.reportMarker(sub, 'codenarc', codeNarcTask.name)) + def reportLocation = codeNarcTask.reports.xml.outputLocation + codenarcReports.from { reportLocation.get().asFile } + codeNarcTask.dependsOn(cleanupTask) + codeNarcTask.finalizedBy(finalizeTask) + writerTask.configure { it.mustRunAfter(codeNarcTask) } + aggregateTask.configure { it.dependsOn(codeNarcTask) } } - sub.pluginManager.withPlugin('checkstyle') { - aggregateTask.configure { - it.dependsOn(sub.tasks.withType(Checkstyle)) - } + sub.tasks.withType(Checkstyle).all { Checkstyle checkstyleTask -> + checkstyleMarkers.from(GradleUtils.reportMarker(sub, 'checkstyle', checkstyleTask.name)) + def reportLocation = checkstyleTask.reports.xml.outputLocation + checkstyleReports.from { reportLocation.get().asFile } + checkstyleTask.dependsOn(cleanupTask) + checkstyleTask.finalizedBy(finalizeTask) + writerTask.configure { it.mustRunAfter(checkstyleTask) } + aggregateTask.configure { it.dependsOn(checkstyleTask) } } } aggregateTask } - private static TaskProvider registerAnalysisAggregation(Project root, Provider analysisXmlDir, Provider violationsDir) { + private static TaskProvider registerAnalysisAggregation(Project root, Provider violationsDir) { + Directory rootDirectory = root.layout.projectDirectory def checkAnalysisTests = GradleUtils.booleanProvider(root, GrailsCodeAnalysisPlugin.TEST_ANALYSIS_PROPERTY) + def ignoreFailures = GradleUtils.booleanProvider(root, GrailsCodeAnalysisPlugin.IGNORE_FAILURES_PROPERTY) def pmdEnabled = GradleUtils.booleanProvider(root, GrailsCodeAnalysisPlugin.PMD_ENABLED_PROPERTY) + def pmdEnabledProjectPaths = root.providers.gradleProperty(GrailsCodeAnalysisPlugin.PMD_ENABLED_PROJECTS_PROPERTY) + .map { configuredProjectPaths(it) } + .orElse([]) def spotbugsEnabled = GradleUtils.booleanProvider(root, GrailsCodeAnalysisPlugin.SPOTBUGS_ENABLED_PROPERTY) + def spotbugsEnabledProjectPaths = root.providers.gradleProperty(GrailsCodeAnalysisPlugin.SPOTBUGS_ENABLED_PROJECTS_PROPERTY) + .map { configuredProjectPaths(it) } + .orElse([]) + List knownProjectPaths = root.allprojects.findAll { Project candidate -> candidate != root } + .collect { Project candidate -> candidate.path }.sort() + def pmdMarkers = root.files() + def spotbugsMarkers = root.files() + def pmdReports = root.files() + def spotbugsReports = root.files() + def pmdMarkdown = root.layout.buildDirectory.file('reports/violations/PMD_VIOLATIONS.md') + def spotbugsMarkdown = root.layout.buildDirectory.file('reports/violations/SPOTBUGS_VIOLATIONS.md') + def cleanupTask = root.tasks.register('cleanAggregateAnalysisReports') { + it.doLast { + deleteReports(pmdMarkers.files, pmdReports.files) + deleteReports(spotbugsMarkers.files, spotbugsReports.files) + } + } - def aggregateTask = root.tasks.register('aggregateAnalysisViolations') { + def writerTask = root.tasks.register('writeAnalysisViolations') { it.group = 'verification' - it.description = 'Aggregates PMD and SpotBugs violation reports into build/reports/violations/' - it.outputs.file(root.file('build/reports/violations/PMD_VIOLATIONS.md')) - it.outputs.file(root.file('build/reports/violations/SPOTBUGS_VIOLATIONS.md')) + it.description = 'Writes PMD and SpotBugs violation reports into build/reports/violations/' + it.inputs.files(pmdMarkers).optional() + it.inputs.files(spotbugsMarkers).optional() + it.inputs.property('ignoreFailures', ignoreFailures) + it.inputs.property('pmdEnabled', pmdEnabled) + it.inputs.property('pmdEnabledProjectPaths', pmdEnabledProjectPaths) + it.inputs.property('spotbugsEnabled', spotbugsEnabled) + it.inputs.property('spotbugsEnabledProjectPaths', spotbugsEnabledProjectPaths) + it.inputs.property('knownProjectPaths', knownProjectPaths) + it.outputs.file(pmdMarkdown) + it.outputs.file(spotbugsMarkdown) + it.outputs.upToDateWhen { false } it.doLast { - parseAnalysisViolations(analysisXmlDir.get(), violationsDir.get(), - checkAnalysisTests.get(), pmdEnabled.get(), spotbugsEnabled.get()) + parseAnalysisViolations(pmdMarkers.files, spotbugsMarkers.files, rootDirectory, violationsDir.get(), + checkAnalysisTests.get(), pmdEnabled.get(), pmdEnabledProjectPaths.get(), + spotbugsEnabled.get(), spotbugsEnabledProjectPaths.get(), ignoreFailures.get()) + } + it.doFirst { + pmdMarkdown.get().asFile.delete() + spotbugsMarkdown.get().asFile.delete() + validateConfiguredProjectPaths(GrailsCodeAnalysisPlugin.PMD_ENABLED_PROJECTS_PROPERTY, + pmdEnabledProjectPaths.get(), knownProjectPaths) + validateConfiguredProjectPaths(GrailsCodeAnalysisPlugin.SPOTBUGS_ENABLED_PROJECTS_PROPERTY, + spotbugsEnabledProjectPaths.get(), knownProjectPaths) } } - root.subprojects { Project sub -> - sub.pluginManager.withPlugin('pmd') { - aggregateTask.configure { - it.dependsOn(sub.tasks.withType(Pmd)) - } + def aggregateTask = root.tasks.register('aggregateAnalysisViolations') { + it.group = 'verification' + it.description = 'Aggregates PMD and SpotBugs violations into build/reports/violations/' + it.dependsOn(writerTask) + } + def finalizeTask = root.tasks.register('finalizeAnalysisViolations') { + it.group = 'verification' + it.dependsOn(writerTask) + } + root.allprojects { Project sub -> + sub.tasks.withType(Pmd).all { Pmd pmdTask -> + pmdMarkers.from(GradleUtils.reportMarker(sub, 'pmd', pmdTask.name)) + def reportLocation = pmdTask.reports.xml.outputLocation + pmdReports.from { reportLocation.get().asFile } + pmdTask.dependsOn(cleanupTask) + pmdTask.finalizedBy(finalizeTask) + writerTask.configure { it.mustRunAfter(pmdTask) } + aggregateTask.configure { it.dependsOn(pmdTask) } } - sub.pluginManager.withPlugin('com.github.spotbugs') { - aggregateTask.configure { - it.dependsOn(sub.tasks.withType(SpotBugsTask)) - } + sub.tasks.withType(SpotBugsTask).all { SpotBugsTask spotbugsTask -> + spotbugsMarkers.from(GradleUtils.reportMarker(sub, 'spotbugs', spotbugsTask.name)) + def reportLocation = spotbugsTask.reports.maybeCreate('xml').outputLocation + spotbugsReports.from { reportLocation.get().asFile } + spotbugsTask.dependsOn(cleanupTask) + spotbugsTask.finalizedBy(finalizeTask) + writerTask.configure { it.mustRunAfter(spotbugsTask) } + aggregateTask.configure { it.dependsOn(spotbugsTask) } } } aggregateTask } + private static List configuredProjectPaths(String value) { + value.split(',')*.trim().findAll() + } + + private static void validateConfiguredProjectPaths(String property, List configuredPaths, List knownPaths) { + List unknownPaths = configuredPaths.findAll { String path -> !knownPaths.contains(path) }.sort() + if (!unknownPaths.isEmpty()) { + throw new GradleException("Unknown project path(s) in ${property}: ${unknownPaths.join(', ')}. Run './gradlew projects' to list valid subproject paths.") + } + } + private static void registerJacocoAggregation(Project root, Provider violationsDir) { // Collect all potential CSV paths at configuration time — Project must not be referenced from task actions def jacocoCsvFiles = root.files( @@ -205,8 +342,8 @@ class GrailsViolationAggregationPlugin implements Plugin { } private static String resolveModule(String fileName) { - int lastDash = fileName.lastIndexOf('-') - lastDash != -1 ? fileName.substring(0, lastDash) : fileName + int separator = fileName.indexOf('-') + separator > 0 ? GradleUtils.projectPathFromKey(fileName.substring(0, separator)) : fileName } private static boolean isTestFile(String fileName) { @@ -214,7 +351,7 @@ class GrailsViolationAggregationPlugin implements Plugin { } @CompileDynamic - private static void writeReport(Directory violationsDir, String fileName, List violations, String title) { + private static void writeReport(Directory violationsDir, String fileName, List violations, String title, String disabledMessage = null) { def reportFile = new File( violationsDir.asFile.tap { it.mkdirs() }, fileName @@ -223,7 +360,9 @@ class GrailsViolationAggregationPlugin implements Plugin { text.append("# ${title}\n") text.append("Generated on: ${LocalDateTime.now().format(TIMESTAMP_FORMAT)}\n\n") - if (violations.isEmpty()) { + if (disabledMessage) { + text.append("${disabledMessage}\n") + } else if (violations.isEmpty()) { text.append('No violations found! 🎉\n') } else { def uniqueViolations = violations.unique().sort { v -> "${v.module}:${v.className}:${v.line}" } @@ -243,9 +382,11 @@ class GrailsViolationAggregationPlugin implements Plugin { } @CompileDynamic - private static void parseStyleViolations(Directory styleXmlDir, Directory violationsDir, - boolean checkStyleTests, boolean codenarcEnabled, boolean checkstyleEnabled) { + private static void parseStyleViolations(Set codenarcMarkers, Set checkstyleMarkers, Directory rootDirectory, + Directory violationsDir, boolean checkStyleTests, + boolean codenarcEnabled, boolean checkstyleEnabled, boolean ignoreFailures) { def slurper = createSecureSlurper() + def missingReports = [] def shouldSkipClass = { boolean includeTests, String className, String filePath = null -> if (includeTests) { @@ -259,13 +400,19 @@ class GrailsViolationAggregationPlugin implements Plugin { // CodeNarc def codenarcViolations = [] - def codenarcDir = styleXmlDir.dir('codenarc').asFile - if (codenarcDir.exists() && codenarcEnabled) { - codenarcDir.eachFileMatch(~/.*\.xml/) { file -> - if (file.size() == 0 || (!checkStyleTests && isTestFile(file.name))) { + if (codenarcEnabled) { + codenarcMarkers.each { File marker -> + if (!marker.exists() || (!checkStyleTests && isTestFile(marker.name))) { + return + } + File file = reportForMarker(marker, rootDirectory) + if (!file || !file.exists() || file.size() == 0) { + def violation = missingReportViolation(resolveModule(marker.name), 'CodeNarc') + codenarcViolations << violation + missingReports << violation return } - def module = resolveModule(file.name) + def module = resolveModule(marker.name) def xml = slurper.parse(file) xml.Package.each { pkg -> pkg.File.each { f -> @@ -296,13 +443,19 @@ class GrailsViolationAggregationPlugin implements Plugin { // Checkstyle def checkstyleViolations = [] - def checkstyleDir = styleXmlDir.dir('checkstyle').asFile - if (checkstyleDir.exists() && checkstyleEnabled) { - checkstyleDir.eachFileMatch(~/.*\.xml/) { file -> - if (file.size() == 0 || (!checkStyleTests && isTestFile(file.name))) { + if (checkstyleEnabled) { + checkstyleMarkers.each { File marker -> + if (!marker.exists() || (!checkStyleTests && isTestFile(marker.name))) { + return + } + File file = reportForMarker(marker, rootDirectory) + if (!file || !file.exists() || file.size() == 0) { + def violation = missingReportViolation(resolveModule(marker.name), 'Checkstyle') + checkstyleViolations << violation + missingReports << violation return } - def module = resolveModule(file.name) + def module = resolveModule(marker.name) def xml = slurper.parse(file) xml.file.each { f -> String filePath = f.@name.text() @@ -332,12 +485,20 @@ class GrailsViolationAggregationPlugin implements Plugin { } } writeReport(violationsDir, 'CHECKSTYLE_VIOLATIONS.md', checkstyleViolations, 'Checkstyle Violations Summary') + if (!missingReports.isEmpty()) { + throw new GradleException('Expected style XML reports were not generated. See build/reports/violations/ for details.') + } } @CompileDynamic - private static void parseAnalysisViolations(Directory analysisXmlDir, Directory violationsDir, - boolean checkAnalysisTests, boolean pmdEnabled, boolean spotbugsEnabled) { + private static void parseAnalysisViolations(Set pmdMarkers, Set spotbugsMarkers, Directory rootDirectory, + Directory violationsDir, boolean checkAnalysisTests, boolean pmdEnabled, + List pmdEnabledProjects, boolean spotbugsEnabled, List spotbugsEnabledProjects, + boolean ignoreFailures) { def slurper = createSecureSlurper() + def missingReports = [] + boolean pmdEnabledForAnyProject = pmdEnabled || !pmdEnabledProjects.isEmpty() + boolean spotbugsEnabledForAnyProject = spotbugsEnabled || !spotbugsEnabledProjects.isEmpty() def shouldSkipClass = { boolean includeTests, String className -> if (includeTests) { @@ -348,61 +509,105 @@ class GrailsViolationAggregationPlugin implements Plugin { // PMD def pmdViolations = [] - def pmdDir = analysisXmlDir.dir('pmd').asFile - if (pmdDir.exists() && pmdEnabled) { - pmdDir.eachFileMatch(~/.*\.xml/) { file -> - if (file.size() == 0 || (!checkAnalysisTests && isTestFile(file.name))) { + if (pmdEnabledForAnyProject) { + pmdMarkers.each { File marker -> + if (!marker.exists() || (!checkAnalysisTests && isTestFile(marker.name))) { return } - def module = resolveModule(file.name) - def xml = slurper.parse(file) - xml.file.each { f -> - f.violation.each { v -> - def className = "${v.@package}.${v.@class}" - if (shouldSkipClass(checkAnalysisTests, className)) { - return + def module = resolveModule(marker.name) + File file = reportForMarker(marker, rootDirectory) + if (!file || !file.exists() || file.size() == 0) { + def violation = missingReportViolation(module, 'PMD') + pmdViolations << violation + missingReports << violation + } else { + def xml = slurper.parse(file) + xml.file.each { f -> + f.violation.each { v -> + def className = "${v.@package}.${v.@class}" + if (shouldSkipClass(checkAnalysisTests, className)) { + return + } + pmdViolations << [ + module : module, + className: className, + tool : 'PMD', + type : v.@rule.text(), + line : v.@beginline.text(), + message : v.text().trim() + ] } - pmdViolations << [ - module : module, - className: className, - tool : 'PMD', - type : v.@rule.text(), - line : v.@beginline.text(), - message : v.text().trim() - ] } } } } - writeReport(violationsDir, 'PMD_VIOLATIONS.md', pmdViolations, 'PMD Violations Summary') + writeReport(violationsDir, 'PMD_VIOLATIONS.md', pmdViolations, 'PMD Violations Summary', + pmdEnabledForAnyProject ? null : 'PMD is disabled.') // SpotBugs def spotbugsViolations = [] - def spotbugsDir = analysisXmlDir.dir('spotbugs').asFile - if (spotbugsDir.exists() && spotbugsEnabled) { - spotbugsDir.eachFileMatch(~/.*\.xml/) { file -> - if (file.size() == 0 || (!checkAnalysisTests && isTestFile(file.name))) { + if (spotbugsEnabledForAnyProject) { + spotbugsMarkers.each { File marker -> + if (!marker.exists() || (!checkAnalysisTests && isTestFile(marker.name))) { return } - def module = resolveModule(file.name) - def xml = slurper.parse(file) - xml.BugInstance.each { b -> - def className = b.Class.@classname.text() - if (shouldSkipClass(checkAnalysisTests, className)) { - return + def module = resolveModule(marker.name) + File file = reportForMarker(marker, rootDirectory) + if (!file || !file.exists() || file.size() == 0) { + def violation = missingReportViolation(module, 'SpotBugs') + spotbugsViolations << violation + missingReports << violation + } else { + def xml = slurper.parse(file) + xml.BugInstance.each { b -> + def className = b.Class.@classname.text() + if (shouldSkipClass(checkAnalysisTests, className)) { + return + } + spotbugsViolations << [ + module : module, + className: className, + tool : 'SpotBugs', + type : b.@type.text(), + line : b.SourceLine.@start.text(), + message : b.LongMessage.text().trim() + ] } - spotbugsViolations << [ - module : module, - className: className, - tool : 'SpotBugs', - type : b.@type.text(), - line : b.SourceLine.@start.text(), - message : b.LongMessage.text().trim() - ] } } } - writeReport(violationsDir, 'SPOTBUGS_VIOLATIONS.md', spotbugsViolations, 'SpotBugs Violations Summary') + writeReport(violationsDir, 'SPOTBUGS_VIOLATIONS.md', spotbugsViolations, 'SpotBugs Violations Summary', + spotbugsEnabledForAnyProject ? null : 'SpotBugs is disabled.') + boolean hasFindings = (pmdViolations + spotbugsViolations).any { it.type != 'MissingReport' } + if (!missingReports.isEmpty() || (!ignoreFailures && hasFindings)) { + throw new GradleException('Code analysis violations were found. See build/reports/violations/ for details.') + } + } + + private static Map missingReportViolation(String module, String tool) { + [ + module : module, + className: '', + tool : tool, + type : 'MissingReport', + line : '', + message : 'Expected XML report was not generated.' + ] + } + + private static File reportForMarker(File marker, Directory rootDirectory) { + String relativeReportPath = marker.text.trim() + if (!relativeReportPath || new File(relativeReportPath).absolute) { + return null + } + new File(rootDirectory.asFile, relativeReportPath) + } + + private static void deleteReports(Set markers, Set reports) { + reports.each { File report -> report.delete() } + markers.each { File marker -> + marker.delete() + } } @CompileDynamic diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy new file mode 100644 index 00000000000..009d4a3fc93 --- /dev/null +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/RepositoryConventionsTask.groovy @@ -0,0 +1,474 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.grails.buildsrc + +import groovy.transform.CompileStatic + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.yaml.snakeyaml.LoaderOptions +import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.constructor.SafeConstructor +import org.yaml.snakeyaml.error.YAMLException + +import java.nio.charset.StandardCharsets +import java.util.Set +import java.util.regex.Matcher +import java.util.regex.Pattern + +@CompileStatic +abstract class RepositoryConventionsTask extends DefaultTask { + + private static final Pattern AGENT_SKILL_PATH = Pattern.compile(/\.agents\/skills\/[A-Za-z0-9_-]+\/SKILL\.md/) + private static final Pattern COMMIT_SHA = Pattern.compile(/^[0-9a-f]{40}$/) + private static final Pattern DOCKER_IMAGE_DIGEST = Pattern.compile(/^docker:\/\/[^@\s]+@sha256:[0-9a-f]{64}$/) + private static final Pattern CONTAINER_IMAGE_DIGEST = Pattern.compile(/^[^@\s]+@sha256:[0-9a-f]{64}$/) + + @Internal + abstract DirectoryProperty getRepositoryDirectory() + + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + abstract ConfigurableFileCollection getConventionSources() + + @OutputFile + abstract RegularFileProperty getReportFile() + + @TaskAction + void validateRepositoryConventions() { + File root = repositoryDirectory.get().asFile + List files = conventionSources.files.toList() + List violations = [] + validateSkills(root, files, violations) + validateActions(root, files, violations) + validateProperties(root, files, violations) + writeReport(violations) + if (!violations.isEmpty()) { + List safeViolations = violations.collect { String violation -> sanitizeViolation(violation) } + throw new GradleException("Repository convention violations:\n - ${safeViolations.join('\n - ')}\nSee ${reportFile.get().asFile}") + } + } + + private static void validateSkills(File root, List files, List violations) { + List skills = files.findAll { relativePath(root, it) ==~ /^\.agents\/skills\/[^\/]+\/SKILL\.md$/ }.sort() + Map names = [:] + Set canonicalPaths = [] + skills.each { File skill -> + String path = relativePath(root, skill) + String directoryName = skill.parentFile.name + Map metadata = frontMatter(skill, path, violations) + ['name', 'description', 'license'].each { String key -> + if (!metadata[key]) { + violations.add("${path}: skill front matter is missing '${key}'".toString()) + } + } + String name = metadata['name'] + if (name && name != directoryName) { + violations.add("${path}: skill name '${name}' does not match directory '${directoryName}'".toString()) + } + if (name && names.containsKey(name)) { + violations.add("${path}: skill name '${name}' duplicates ${relativePath(root, names[name])}".toString()) + } else if (name) { + names[name] = skill + } + canonicalPaths << path + } + + File agents = new File(root, 'AGENTS.md') + if (!agents.isFile()) { + violations << 'AGENTS.md: file is missing' + return + } + Set documentedPaths = [] + Matcher matcher = AGENT_SKILL_PATH.matcher(agents.text) + while (matcher.find()) { + documentedPaths << matcher.group() + } + canonicalPaths.each { String path -> + if (!documentedPaths.contains(path)) { + violations.add("AGENTS.md: missing canonical skill path '${path}'".toString()) + } + } + documentedPaths.each { String path -> + if (!new File(root, path).isFile()) { + violations.add("AGENTS.md: skill path '${path}' does not exist".toString()) + } + } + } + + private static Map frontMatter(File skill, String path, List violations) { + List lines = skill.readLines() + if (lines.isEmpty() || lines[0] != '---') { + return [:] + } + int end = -1 + for (int index = 1; index < lines.size(); index++) { + if (lines[index] == '---') { + end = index + break + } + } + if (end < 0) { + return [:] + } + Object document + try { + LoaderOptions options = new LoaderOptions() + options.setAllowDuplicateKeys(false) + document = new Yaml(new SafeConstructor(options)).load(lines.subList(1, end).join('\n')) + } catch (YAMLException exception) { + violations.add("${path}: malformed skill front matter: ${exception.message}".toString()) + return [:] + } + if (!(document instanceof Map)) { + violations.add("${path}: skill front matter must be a YAML mapping".toString()) + return [:] + } + Map values = [:] + ['name', 'description', 'license'].each { String key -> + Object value = ((Map) document).get(key) + if (value instanceof String) { + values[key] = (String) value + } else if (value != null) { + violations.add("${path}: skill front matter field '${key}' must be a string".toString()) + } + } + values + } + + private static void validateActions(File root, List files, List violations) { + Map actionShas = [:] + Map actionFiles = [:] + Set validatedManifests = [] + files.findAll { File file -> isActionManifest(root, file) }.sort().each { File manifest -> + validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests) + } + } + + private static void validateActionManifest(File root, File manifest, Map actionShas, + Map actionFiles, List violations, Set validatedManifests) { + String canonicalPath = manifest.canonicalPath + if (!validatedManifests.add(canonicalPath)) { + return + } + String path = relativePath(root, manifest) + Object document = parseYaml(manifest, path, violations) + if (document != null) { + validateDockerActionImage(document, path, violations) + if (isWorkflowManifest(root, manifest)) { + validateWorkflowContainerImages(document, path, violations) + validateWorkflowUses(root, document, path, actionShas, actionFiles, violations, validatedManifests) + } else { + validateCompositeActionUses(root, document, path, actionShas, actionFiles, violations, validatedManifests) + } + } + } + + private static boolean isActionManifest(File root, File file) { + String path = relativePath(root, file) + isWorkflowManifest(root, file) || + path ==~ /(?:^|.*\/)action\.ya?ml$/ + } + + private static boolean isWorkflowManifest(File root, File file) { + relativePath(root, file) ==~ /^\.github\/workflows\/[^\/]+\.ya?ml$/ + } + + private static Object parseYaml(File manifest, String path, List violations) { + try { + LoaderOptions options = new LoaderOptions() + options.setAllowDuplicateKeys(false) + new Yaml(new SafeConstructor(options)).load(manifest.text) + } catch (YAMLException exception) { + violations.add("${path}: malformed YAML: ${exception.message}".toString()) + null + } + } + + private static void validateDockerActionImage(Object document, String path, List violations) { + if (!(document instanceof Map)) { + return + } + Object runs = ((Map) document).get('runs') + Object using = runs instanceof Map ? ((Map) runs).get('using') : null + if (!(using instanceof String) || !((String) using).equalsIgnoreCase('docker')) { + return + } + Object image = ((Map) runs).get('image') + String location = '$.runs.image' + if (!(image instanceof String)) { + violations.add("${path}:${location}: Docker action image must be a string".toString()) + } else if (((String) image).regionMatches(true, 0, 'docker://', 0, 'docker://'.length()) && !DOCKER_IMAGE_DIGEST.matcher((String) image).matches()) { + violations.add("${path}:${location}: Docker action image '${image}' must use an immutable sha256 digest".toString()) + } + } + + private static void validateWorkflowContainerImages(Object document, String path, List violations) { + if (!(document instanceof Map)) { + return + } + Object jobs = ((Map) document).get('jobs') + if (!(jobs instanceof Map)) { + return + } + ((Map) jobs).each { Object jobName, Object job -> + if (!(job instanceof Map)) { + return + } + String jobLocation = "\$.jobs.${jobName}" + Map jobDefinition = (Map) job + if (jobDefinition.containsKey('container')) { + Object container = jobDefinition.get('container') + if (container instanceof Map) { + validateContainerImage(((Map) container).get('image'), "${jobLocation}.container.image", path, violations) + } else { + validateContainerImage(container, "${jobLocation}.container", path, violations) + } + } + Object services = jobDefinition.get('services') + if (services instanceof Map) { + ((Map) services).each { Object serviceName, Object service -> + if (service instanceof Map && ((Map) service).containsKey('image')) { + validateContainerImage(((Map) service).get('image'), "${jobLocation}.services.${serviceName}.image", path, + violations) + } + } + } + } + } + + private static void validateContainerImage(Object image, String location, String path, List violations) { + if (!(image instanceof String)) { + violations.add("${path}:${location}: container image must be a string".toString()) + } else if (!CONTAINER_IMAGE_DIGEST.matcher((String) image).matches()) { + violations.add("${path}:${location}: container image '${image}' must use an immutable sha256 digest".toString()) + } + } + + private static void validateWorkflowUses(File root, Object document, String path, Map actionShas, + Map actionFiles, List violations, Set validatedManifests) { + if (!(document instanceof Map)) { + return + } + Map workflow = (Map) document + validateStepUses(root, workflow.get('steps'), '$.steps', path, actionShas, actionFiles, violations, validatedManifests) + Object jobs = workflow.get('jobs') + if (!(jobs instanceof Map)) { + return + } + ((Map) jobs).each { Object jobName, Object job -> + if (!(job instanceof Map)) { + return + } + Map jobDefinition = (Map) job + String jobLocation = "\$.jobs.${jobName}" + if (jobDefinition.containsKey('uses')) { + validateActionUse(root, jobDefinition.get('uses'), "${jobLocation}.uses", path, actionShas, actionFiles, violations, + validatedManifests) + } + validateStepUses(root, jobDefinition.get('steps'), "${jobLocation}.steps", path, actionShas, actionFiles, violations, + validatedManifests) + } + } + + private static void validateCompositeActionUses(File root, Object document, String path, Map actionShas, + Map actionFiles, List violations, Set validatedManifests) { + if (!(document instanceof Map)) { + return + } + Object runs = ((Map) document).get('runs') + if (runs instanceof Map) { + validateStepUses(root, ((Map) runs).get('steps'), '$.runs.steps', path, actionShas, actionFiles, violations, + validatedManifests) + } + } + + private static void validateStepUses(File root, Object steps, String location, String path, Map actionShas, + Map actionFiles, List violations, Set validatedManifests) { + if (!(steps instanceof Iterable)) { + return + } + int index = 0 + ((Iterable) steps).each { Object step -> + if (step instanceof Map && ((Map) step).containsKey('uses')) { + validateActionUse(root, ((Map) step).get('uses'), "${location}[${index}].uses", path, actionShas, actionFiles, + violations, validatedManifests) + } + index++ + } + } + + private static void validateActionUse(File root, Object value, String location, String path, Map actionShas, + Map actionFiles, List violations, Set validatedManifests) { + if (!(value instanceof String)) { + violations.add("${path}:${location}: 'uses' must be a string".toString()) + return + } + String use = (String) value + if (use.startsWith('./')) { + validateLocalAction(root, use, location, path, actionShas, actionFiles, violations, validatedManifests) + return + } + if (use.startsWith('docker://')) { + if (!DOCKER_IMAGE_DIGEST.matcher(use).matches()) { + violations.add("${path}:${location}: Docker action '${use}' must use an immutable sha256 digest".toString()) + } + return + } + int separator = use.lastIndexOf('@') + if (separator <= 0 || separator == use.length() - 1) { + violations.add("${path}:${location}: action '${use}' must use a lowercase 40-hex commit SHA".toString()) + return + } + String action = use.substring(0, separator) + String sha = use.substring(separator + 1) + if (!COMMIT_SHA.matcher(sha).matches()) { + violations.add("${path}:${location}: action '${action}' uses '${sha}', not a lowercase 40-hex commit SHA".toString()) + } else if (actionShas.containsKey(action) && actionShas[action] != sha) { + violations.add("${path}:${location}: action '${action}' uses ${sha}, inconsistent with ${actionShas[action]} in ${actionFiles[action]}".toString()) + } else { + actionShas[action] = sha + actionFiles[action] = path + } + } + + private static void validateLocalAction(File root, String use, String location, String path, + Map actionShas, Map actionFiles, List violations, + Set validatedManifests) { + File canonicalRoot = root.canonicalFile + File target = new File(root, use.substring(2)).canonicalFile + if (!target.toPath().startsWith(canonicalRoot.toPath())) { + violations.add("${path}:${location}: local action '${use}' resolves outside the repository".toString()) + return + } + if (target.isFile() && target.name ==~ /.*\.ya?ml/) { + validateActionManifest(root, target, actionShas, actionFiles, violations, validatedManifests) + return + } + ['action.yml', 'action.yaml'].each { String manifestName -> + File manifest = new File(target, manifestName) + if (manifest.isFile()) { + validateActionManifest(root, manifest, actionShas, actionFiles, violations, validatedManifests) + } + } + } + + private static void validateProperties(File root, List files, List violations) { + files.findAll { File file -> file.name.startsWith('messages') && file.name.endsWith('.properties') }.sort().each { File file -> + Map keys = [:] + logicalPropertiesLines(file).each { PropertiesLine line -> + String key = propertyKey(line.content) + if (!key) { + return + } + if (keys.containsKey(key)) { + violations.add("${relativePath(root, file)}:${line.number}: duplicate message key '${key}' (first declared at line ${keys[key]})".toString()) + } else { + keys[key] = line.number + } + } + } + } + + private static List logicalPropertiesLines(File file) { + List result = [] + String content = null + int start = 0 + file.readLines(StandardCharsets.UTF_8.name()).eachWithIndex { String line, int index -> + if (content == null) { + String trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!')) { + return + } + content = line + start = index + 1 + } else { + content += line.replaceFirst(/^\s+/, '') + } + if (continues(content)) { + content = content.substring(0, content.length() - 1) + } else { + result << new PropertiesLine(start, content) + content = null + } + } + if (content != null) { + result << new PropertiesLine(start, content) + } + result + } + + private static boolean continues(String line) { + int count = 0 + for (int index = line.length() - 1; index >= 0 && line.charAt(index) == '\\'; index--) { + count++ + } + count % 2 == 1 + } + + private static String propertyKey(String line) { + Properties properties = new Properties() + try { + properties.load(new StringReader(line)) + } catch (IllegalArgumentException ignored) { + return null + } + properties.stringPropertyNames().find() + } + + private void writeReport(List violations) { + File output = reportFile.get().asFile + output.parentFile.mkdirs() + StringBuilder text = new StringBuilder('# Repository Conventions\n\n') + if (violations.isEmpty()) { + text.append('No violations found!\n') + } else { + text.append('| Violation |\n| :--- |\n') + violations.each { String violation -> text.append("| ${sanitizeViolation(violation)} |\n") } + } + output.text = text.toString() + } + + private static String sanitizeViolation(String violation) { + violation.replace('\r', '\\r').replace('\n', '\\n').replace('|', '\\|') + } + + private static String relativePath(File root, File file) { + root.toPath().relativize(file.toPath()).toString().replace(File.separatorChar, '/' as char) + } + + private static final class PropertiesLine { + final int number + final String content + + PropertiesLine(int number, String content) { + this.number = number + this.content = content + } + } +} diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPluginSpec.groovy new file mode 100644 index 00000000000..c29ac4e6592 --- /dev/null +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisPluginSpec.groovy @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.grails.buildsrc + +import java.nio.file.Path + +import org.gradle.testkit.runner.GradleRunner +import spock.lang.Specification +import spock.lang.TempDir + +class GrailsCodeAnalysisPluginSpec extends Specification { + + @TempDir + Path testProjectDir + + def "PMD project allowlist enables only listed projects"() { + given: + writeMultiProjectBuild('grails.code-analysis.enabled.pmd.projects=:selected') + + when: + def result = run('tasks', '--all', '--configuration-cache') + + then: + result.output.contains('selected:pmdMain') + !result.output.contains('excluded:pmdMain') + } + + def "global PMD opt-in remains compatible with project selection"() { + given: + writeMultiProjectBuild('grails.code-analysis.enabled.pmd=true') + + when: + def result = run('tasks', '--all') + + then: + result.output.contains('selected:pmdMain') + result.output.contains('excluded:pmdMain') + } + + def "PMD excludes generated build sources"() { + given: + testProjectDir.resolve('gradle.properties').toFile().text = 'grails.code-analysis.enabled.pmd=true\n' + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'java' + id 'org.apache.grails.gradle.grails-code-analysis' + } + layout.buildDirectory.set(layout.projectDirectory.dir('generated-build')) + sourceSets.main.java.srcDir('generated-build/generated/sources') + tasks.register('assertPmdSources') { + doLast { + assert !tasks.named('pmdMain').get().source.files.any { it.name == 'Generated.java' } + } + } + """ + def generated = testProjectDir.resolve('generated-build/generated/sources/Generated.java').toFile() + generated.parentFile.mkdirs() + generated.text = 'class Generated {}' + + when: + def result = run('assertPmdSources') + + then: + result.output.contains('BUILD SUCCESSFUL') + } + + def "PMD includes normal sources when the checkout has a build ancestor"() { + given: + Path projectDirectory = testProjectDir.resolve('build/checkouts/project') + writePmdBuild(projectDirectory, ''' + tasks.register('assertPmdSources') { + doLast { + assert tasks.named('pmdMain').get().source.files.any { it.name == 'Source.java' } + } + } + ''') + def source = projectDirectory.resolve('src/main/java/Source.java').toFile() + source.parentFile.mkdirs() + source.text = 'class Source {}' + + when: + def result = run(projectDirectory, 'assertPmdSources') + + then: + result.output.contains('BUILD SUCCESSFUL') + } + + private void writePmdBuild(Path projectDirectory, String additionalConfiguration) { + projectDirectory.toFile().mkdirs() + projectDirectory.resolve('gradle.properties').toFile().text = 'grails.code-analysis.enabled.pmd=true\n' + projectDirectory.resolve('build.gradle').toFile().text = """ + plugins { + id 'java' + id 'org.apache.grails.gradle.grails-code-analysis' + } + ${additionalConfiguration} + """ + } + + private void writeMultiProjectBuild(String property) { + testProjectDir.resolve('gradle.properties').toFile().text = "${property}\n" + testProjectDir.resolve('settings.gradle').toFile().text = "include 'selected', 'excluded'" + testProjectDir.resolve('selected').toFile().mkdirs() + testProjectDir.resolve('excluded').toFile().mkdirs() + ['selected', 'excluded'].each { projectName -> + testProjectDir.resolve("${projectName}/build.gradle").toFile().text = """ + plugins { + id 'java' + id 'org.apache.grails.gradle.grails-code-analysis' + } + """ + } + } + + private def run(String... arguments) { + run(testProjectDir, arguments) + } + + private def run(Path projectDirectory, String... arguments) { + GradleRunner.create() + .withProjectDir(projectDirectory.toFile()) + .withArguments(arguments + ['--stacktrace']) + .withPluginClasspath() + .build() + } +} diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsCodeStylePluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsCodeStylePluginSpec.groovy index 46c9246992a..812ab5fb7ef 100644 --- a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsCodeStylePluginSpec.groovy +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsCodeStylePluginSpec.groovy @@ -84,6 +84,21 @@ class Test{ fixedContent.count('\n\n') == 3 // ConsecutiveBlankLines } + def "applying code style also registers code analysis"() { + given: + groovyFile.text = 'class Test {}' + + when: + def result = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('tasks', '--group=verification') + .withPluginClasspath() + .build() + + then: + result.output.contains('codeAnalysis') + } + def "test codenarcFix task does not break strings with single quotes"() { given: "a file with double quoted strings containing single quotes" groovyFile.text = """package org.test diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPluginSpec.groovy index beb8d66908e..e81b044071d 100644 --- a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPluginSpec.groovy +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPluginSpec.groovy @@ -22,6 +22,7 @@ import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import spock.lang.Specification import spock.lang.TempDir +import spock.lang.Unroll import java.nio.file.Path @@ -53,9 +54,11 @@ class GrailsViolationAggregationPluginSpec extends Specification { result.output.contains('must be applied to the root project only') } - def "all aggregation tasks are registered on root"() { + def "canonical roots register repository convention validation"() { given: "root project with aggregation plugin" testProjectDir.resolve('settings.gradle').toFile().text = '' + testProjectDir.resolve('AGENTS.md').toFile().text = '' + testProjectDir.resolve('.github/workflows').toFile().mkdirs() testProjectDir.resolve('build.gradle').toFile().text = """ plugins { id 'org.apache.grails.gradle.grails-violation-aggregation' @@ -73,11 +76,33 @@ class GrailsViolationAggregationPluginSpec extends Specification { result.output.contains('aggregateStyleViolations') result.output.contains('aggregateAnalysisViolations') result.output.contains('aggregateViolations') + result.output.contains('validateRepositoryConventions') result.output.contains('aggregateJacocoCoverage') } - def "aggregateStyleViolations writes CodeNarc and Checkstyle reports to build/reports/violations/"() { - given: "a root project with a subproject that has codestyle XML reports" + def "canonical roots report a missing AGENTS.md"() { + given: + testProjectDir.resolve('settings.gradle').toFile().text = '' + testProjectDir.resolve('.github/workflows').toFile().mkdirs() + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'org.apache.grails.gradle.grails-violation-aggregation' + } + """ + + when: + def result = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('validateRepositoryConventions', '--stacktrace') + .withPluginClasspath() + .buildAndFail() + + then: + result.output.contains('AGENTS.md: file is missing') + } + + def "nested builds ignore stale legacy style XML without AGENTS.md"() { + given: "a nested build root with a stale leaf-keyed style report" testProjectDir.resolve('settings.gradle').toFile().text = "include 'app-module'" testProjectDir.resolve('build.gradle').toFile().text = """ plugins { @@ -96,43 +121,40 @@ class GrailsViolationAggregationPluginSpec extends Specification { implementation localGroovy() } """ - def srcFile = moduleDir.resolve('src/main/groovy/com/example/AppClass.groovy').toFile() - srcFile.parentFile.mkdirs() - srcFile.text = 'package com.example\nclass AppClass {}' - - // Pre-populate XML reports in the standard consolidated location (build/reports/code-style/) def checkstyleDir = testProjectDir.resolve('build/reports/code-style/checkstyle').toFile() checkstyleDir.mkdirs() - new File(checkstyleDir, 'app-module-checkstyleMain.xml').text = """ + new File(checkstyleDir, 'app-module-checkstyleMain.xml').text = ''' - - + + -""" +''' def codenarcDir = testProjectDir.resolve('build/reports/code-style/codenarc').toFile() codenarcDir.mkdirs() - new File(codenarcDir, 'app-module-codenarcMain.xml').text = """ + new File(codenarcDir, 'app-module-codenarcMain.xml').text = ''' -The class is empty +Legacy report -""" +''' - when: "running aggregateStyleViolations skipping the actual style tasks" + when: "running aggregateStyleViolations" def result = GradleRunner.create() .withProjectDir(testProjectDir.toFile()) - .withArguments('aggregateStyleViolations', '-x', 'checkstyleMain', '-x', 'codenarcMain', '--stacktrace') + .withArguments('aggregateStyleViolations', '--stacktrace') .withPluginClasspath() .build() then: "task succeeds" - result.task(':aggregateStyleViolations').outcome == TaskOutcome.SUCCESS + result.task(':aggregateStyleViolations').outcome in [TaskOutcome.SUCCESS, TaskOutcome.UP_TO_DATE] + result.task(':writeStyleViolations').outcome == TaskOutcome.SUCCESS + result.task(':validateRepositoryConventions') == null and: "reports land in build/reports/violations/ — NOT in the repo root" def violationsDir = testProjectDir.resolve('build/reports/violations').toFile() @@ -141,15 +163,496 @@ class GrailsViolationAggregationPluginSpec extends Specification { !testProjectDir.resolve('CHECKSTYLE_VIOLATIONS.md').toFile().exists() !testProjectDir.resolve('CODENARC_VIOLATIONS.md').toFile().exists() - and: "checkstyle report contains the violation" + and: "legacy Checkstyle XML is ignored" def checkstyleMd = new File(violationsDir, 'CHECKSTYLE_VIOLATIONS.md').text - checkstyleMd.contains('## Module: app-module') - checkstyleMd.contains('JavadocPackageCheck') + !checkstyleMd.contains('Legacy report.') - and: "codenarc report contains the violation" + and: "legacy CodeNarc XML is ignored" def codenarcMd = new File(violationsDir, 'CODENARC_VIOLATIONS.md').text - codenarcMd.contains('## Module: app-module') - codenarcMd.contains('EmptyClass') + !codenarcMd.contains('Legacy report') + } + + def "aggregateAnalysisViolations recognizes the PMD project allowlist and reports disabled SpotBugs"() { + given: + testProjectDir.resolve('gradle.properties').toFile().text = '''grails.code-analysis.enabled.pmd.projects=:app-module +grails.code-analysis.ignoreFailures=true +pmdVersion=7.25.0 +''' + testProjectDir.resolve('settings.gradle').toFile().text = "include 'app-module'" + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'org.apache.grails.gradle.grails-violation-aggregation' + } + """ + def moduleDir = testProjectDir.resolve('app-module') + moduleDir.toFile().mkdirs() + moduleDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'java' + id 'org.apache.grails.gradle.grails-code-style' + } + repositories { mavenCentral() } + """ + def sourceFile = moduleDir.resolve('src/main/java/com/example/App.java').toFile() + sourceFile.parentFile.mkdirs() + sourceFile.text = '''package com.example; + +public class App { + private void unused() { + } +} +''' + + when: + def result = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateAnalysisViolations', '--configuration-cache', '--stacktrace') + .withPluginClasspath() + .build() + + then: + result.task(':app-module:pmdMain').outcome == TaskOutcome.SUCCESS + result.task(':aggregateAnalysisViolations').outcome in [TaskOutcome.SUCCESS, TaskOutcome.UP_TO_DATE] + result.task(':writeAnalysisViolations').outcome == TaskOutcome.SUCCESS + def violationsDir = testProjectDir.resolve('build/reports/violations').toFile() + def pmdReport = new File(violationsDir, 'PMD_VIOLATIONS.md').text + pmdReport.contains('UnusedPrivateMethod') + new File(violationsDir, 'SPOTBUGS_VIOLATIONS.md').text.contains('SpotBugs is disabled.') + + when: "the generated configuration inputs settle after the first run" + def updatedResult = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateAnalysisViolations', '--configuration-cache', '--stacktrace') + .withPluginClasspath() + .build() + + then: + updatedResult.output.contains('Configuration cache entry stored') + + when: "running the stable lifecycle from the reused configuration cache" + def reusedResult = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateAnalysisViolations', '--configuration-cache', '--stacktrace') + .withPluginClasspath() + .build() + + then: + reusedResult.output.contains('Reusing configuration cache') + reusedResult.task(':writeAnalysisViolations').outcome == TaskOutcome.SUCCESS + } + + def "aggregateAnalysisViolations fails for PMD violations in an allowlisted project"() { + given: + testProjectDir.resolve('gradle.properties').toFile().text = '''grails.code-analysis.enabled.pmd.projects=:app-module +pmdVersion=7.25.0 +''' + testProjectDir.resolve('settings.gradle').toFile().text = "include 'app-module'" + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'org.apache.grails.gradle.grails-violation-aggregation' + } + """ + def moduleDir = testProjectDir.resolve('app-module') + moduleDir.toFile().mkdirs() + moduleDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'java' + id 'org.apache.grails.gradle.grails-code-style' + } + repositories { mavenCentral() } + """ + def sourceFile = moduleDir.resolve('src/main/java/com/example/App.java').toFile() + sourceFile.parentFile.mkdirs() + sourceFile.text = '''package com.example; + +public class App { + private void unused() { + } +} +''' + + when: + def result = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateAnalysisViolations', '--stacktrace') + .withPluginClasspath() + .buildAndFail() + + then: + result.task(':app-module:pmdMain').outcome == TaskOutcome.FAILED + result.task(':writeAnalysisViolations').outcome == TaskOutcome.FAILED + def pmdReport = testProjectDir.resolve('build/reports/violations/PMD_VIOLATIONS.md').toFile().text + pmdReport.contains('UnusedPrivateMethod') + } + + def "aggregateAnalysisViolations fails when an executed task removes its XML report"() { + given: + testProjectDir.resolve('gradle.properties').toFile().text = '''grails.code-analysis.enabled.pmd.projects=:app-module +pmdVersion=7.25.0 +''' + testProjectDir.resolve('settings.gradle').toFile().text = "include 'app-module'" + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'org.apache.grails.gradle.grails-violation-aggregation' + } + """ + def moduleDir = testProjectDir.resolve('app-module') + moduleDir.toFile().mkdirs() + moduleDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'java' + id 'org.apache.grails.gradle.grails-code-style' + } + repositories { mavenCentral() } + tasks.named('pmdMain') { + doLast { + reports.xml.outputLocation.get().asFile.delete() + } + } + """ + def sourceFile = moduleDir.resolve('src/main/java/com/example/App.java').toFile() + sourceFile.parentFile.mkdirs() + sourceFile.text = '''package com.example; + +public class App { + public void used() { + } +} +''' + + when: + def result = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateAnalysisViolations', '--stacktrace') + .withPluginClasspath() + .buildAndFail() + + then: + result.task(':app-module:pmdMain').outcome == TaskOutcome.SUCCESS + result.task(':writeAnalysisViolations').outcome == TaskOutcome.FAILED + def pmdReport = testProjectDir.resolve('build/reports/violations/PMD_VIOLATIONS.md').toFile().text + pmdReport.contains('MissingReport') + } + + def "direct PMD task aggregates only its executed report"() { + given: + testProjectDir.resolve('gradle.properties').toFile().text = '''grails.code-analysis.enabled.pmd.projects=:one,:two +grails.code-analysis.ignoreFailures=true +pmdVersion=7.25.0 +''' + testProjectDir.resolve('settings.gradle').toFile().text = "include 'one', 'two'" + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'org.apache.grails.gradle.grails-violation-aggregation' + } + """ + ['one', 'two'].each { projectName -> + def moduleDir = testProjectDir.resolve(projectName) + moduleDir.toFile().mkdirs() + moduleDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'java' + id 'org.apache.grails.gradle.grails-code-style' + } + repositories { mavenCentral() } + """ + def sourceFile = moduleDir.resolve('src/main/java/com/example/App.java').toFile() + sourceFile.parentFile.mkdirs() + sourceFile.text = 'package com.example; public class App {}' + } + + when: + def result = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments(':one:pmdMain', '--stacktrace') + .withPluginClasspath() + .build() + + then: + result.task(':one:pmdMain').outcome == TaskOutcome.SUCCESS + result.task(':writeAnalysisViolations').outcome == TaskOutcome.SUCCESS + !testProjectDir.resolve('build/reports/violations/PMD_VIOLATIONS.md').toFile().text.contains('MissingReport') + } + + def "NO-SOURCE analyzers are omitted from aggregation"() { + given: + testProjectDir.resolve('gradle.properties').toFile().text = '''grails.code-analysis.enabled.pmd=true +pmdVersion=7.25.0 +''' + testProjectDir.resolve('settings.gradle').toFile().text = "include 'common', 'model'" + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'org.apache.grails.gradle.grails-violation-aggregation' + } + """ + ['common', 'model'].each { projectName -> + testProjectDir.resolve(projectName).toFile().mkdirs() + testProjectDir.resolve("${projectName}/build.gradle").toFile().text = """ + plugins { + id 'java' + id 'org.apache.grails.gradle.grails-code-style' + } + """ + } + + when: + def result = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateAnalysisViolations', '--stacktrace') + .withPluginClasspath() + .build() + + then: + result.task(':aggregateAnalysisViolations').outcome == TaskOutcome.SUCCESS + !testProjectDir.resolve('build/reports/violations/PMD_VIOLATIONS.md').toFile().text.contains('MissingReport') + } + + def "renamed analysis XML reports aggregate through root-relative cache-safe markers"() { + given: + testProjectDir.resolve('gradle.properties').toFile().text = '''grails.code-analysis.enabled.pmd.projects=:app-module +grails.code-analysis.ignoreFailures=true +pmdVersion=7.25.0 +''' + testProjectDir.resolve('settings.gradle').toFile().text = "include 'app-module'" + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'org.apache.grails.gradle.grails-violation-aggregation' + } + """ + def moduleDir = testProjectDir.resolve('app-module') + moduleDir.toFile().mkdirs() + moduleDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'java' + id 'org.apache.grails.gradle.grails-code-style' + } + repositories { mavenCentral() } + grailsCodeAnalysis.reportsDirectory.set(layout.buildDirectory.dir('custom-analysis')) + tasks.named('pmdMain') { + reports.xml.outputLocation.set(layout.buildDirectory.file('custom-analysis/pmd/renamed-pmd.xml')) + } + """ + def sourceFile = moduleDir.resolve('src/main/java/com/example/App.java').toFile() + sourceFile.parentFile.mkdirs() + sourceFile.text = '''package com.example; + +public class App { + private void unused() { + } +} +''' + + when: + def result = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateAnalysisViolations', '--configuration-cache', '--stacktrace') + .withPluginClasspath() + .build() + + then: + result.task(':aggregateAnalysisViolations').outcome == TaskOutcome.SUCCESS + moduleDir.resolve('build/custom-analysis/pmd/renamed-pmd.xml').toFile().isFile() + testProjectDir.resolve('build/reports/violations/PMD_VIOLATIONS.md').toFile().text.contains('UnusedPrivateMethod') + File[] markers = testProjectDir.resolve('build/reports/aggregation-markers/pmd').toFile().listFiles() + markers.length == 1 + def marker = markers[0] + marker.text.trim() == 'app-module/build/custom-analysis/pmd/renamed-pmd.xml' + !new File(marker.text.trim()).absolute + + when: + GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateAnalysisViolations', '--configuration-cache', '--stacktrace') + .withPluginClasspath() + .build() + def reusedResult = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateAnalysisViolations', '--configuration-cache', '--stacktrace') + .withPluginClasspath() + .build() + + then: + reusedResult.output.contains('Reusing configuration cache.') + } + + def "renamed style XML reports aggregate through root-relative markers"() { + given: + testProjectDir.resolve('gradle.properties').toFile().text = '''grails.code-style.ignoreFailures=true +codenarcVersion=3.6.0-groovy-4.0 +''' + testProjectDir.resolve('settings.gradle').toFile().text = "include 'app-module'" + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'org.apache.grails.gradle.grails-violation-aggregation' + } + """ + def moduleDir = testProjectDir.resolve('app-module') + moduleDir.toFile().mkdirs() + moduleDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'groovy' + id 'org.apache.grails.gradle.grails-code-style' + } + repositories { mavenCentral() } + dependencies { implementation localGroovy() } + grailsCodeStyle.reportsDirectory.set(layout.buildDirectory.dir('custom-style')) + tasks.named('codenarcMain') { + reports.xml.outputLocation.set(layout.buildDirectory.file('custom-style/codenarc/renamed-codenarc.xml')) + } + """ + def sourceFile = moduleDir.resolve('src/main/groovy/com/example/App.groovy').toFile() + sourceFile.parentFile.mkdirs() + sourceFile.text = 'package com.example\nclass App {}' + + when: + def result = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateStyleViolations', '--stacktrace') + .withPluginClasspath() + .build() + + then: + result.task(':aggregateStyleViolations').outcome in [TaskOutcome.SUCCESS, TaskOutcome.UP_TO_DATE] + moduleDir.resolve('build/custom-style/codenarc/renamed-codenarc.xml').toFile().isFile() + testProjectDir.resolve('build/reports/violations/CODENARC_VIOLATIONS.md').toFile().text.contains('App | CodeNarc') + File[] markers = testProjectDir.resolve('build/reports/aggregation-markers/codenarc').toFile().listFiles() + markers.length == 1 + def marker = markers[0] + marker.text.trim() == 'app-module/build/custom-style/codenarc/renamed-codenarc.xml' + !new File(marker.text.trim()).absolute + } + + def "aggregateAnalysisViolations distinguishes duplicate nested project leaf names"() { + given: + testProjectDir.resolve('gradle.properties').toFile().text = '''grails.code-analysis.enabled.pmd.projects=:one:shared,:two:shared +grails.code-analysis.ignoreFailures=true +pmdVersion=7.25.0 +''' + testProjectDir.resolve('settings.gradle').toFile().text = "include 'one:shared', 'two:shared'" + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'org.apache.grails.gradle.grails-violation-aggregation' + } + """ + ['one', 'two'].each { parent -> + def moduleDir = testProjectDir.resolve("${parent}/shared") + moduleDir.toFile().mkdirs() + moduleDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'java' + id 'org.apache.grails.gradle.grails-code-style' + } + repositories { mavenCentral() } + """ + def sourceFile = moduleDir.resolve('src/main/java/com/example/App.java').toFile() + sourceFile.parentFile.mkdirs() + sourceFile.text = '''package com.example; + +public class App { + private void unused() { + } +} +''' + } + + when: + def result = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateAnalysisViolations', '--stacktrace') + .withPluginClasspath() + .build() + + then: + result.task(':aggregateAnalysisViolations').outcome in [TaskOutcome.SUCCESS, TaskOutcome.UP_TO_DATE] + def pmdReport = testProjectDir.resolve('build/reports/violations/PMD_VIOLATIONS.md').toFile().text + pmdReport.contains('## Module: :one:shared') + pmdReport.contains('## Module: :two:shared') + } + + def "analysis validation failure removes reports from a previous run"() { + given: + def propertiesFile = testProjectDir.resolve('gradle.properties').toFile() + propertiesFile.text = '''grails.code-analysis.enabled.pmd.projects=:app-module +pmdVersion=7.25.0 +''' + testProjectDir.resolve('settings.gradle').toFile().text = "include 'app-module'" + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'org.apache.grails.gradle.grails-violation-aggregation' + } + """ + def moduleDir = testProjectDir.resolve('app-module') + moduleDir.toFile().mkdirs() + moduleDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'java' + id 'org.apache.grails.gradle.grails-code-style' + } + repositories { mavenCentral() } + """ + def sourceFile = moduleDir.resolve('src/main/java/com/example/App.java').toFile() + sourceFile.parentFile.mkdirs() + sourceFile.text = 'package com.example; public class App { public void used() {} }' + + when: "writing valid reports" + GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateAnalysisViolations', '--stacktrace') + .withPluginClasspath() + .build() + + then: + def pmdReport = testProjectDir.resolve('build/reports/violations/PMD_VIOLATIONS.md').toFile() + def spotbugsReport = testProjectDir.resolve('build/reports/violations/SPOTBUGS_VIOLATIONS.md').toFile() + pmdReport.exists() + spotbugsReport.exists() + + when: "the next run fails before parsing reports" + propertiesFile.text = '''grails.code-analysis.enabled.pmd.projects=:missing +pmdVersion=7.25.0 +''' + def failedResult = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateAnalysisViolations', '--stacktrace') + .withPluginClasspath() + .buildAndFail() + + then: + failedResult.task(':writeAnalysisViolations').outcome == TaskOutcome.FAILED + !pmdReport.exists() + !spotbugsReport.exists() + } + + @Unroll + def "aggregateAnalysisViolations rejects unknown #tool project paths"() { + given: + testProjectDir.resolve('gradle.properties').toFile().text = "${property}=${projectPath}" + testProjectDir.resolve('settings.gradle').toFile().text = "include 'known'" + testProjectDir.resolve('build.gradle').toFile().text = """ + plugins { + id 'org.apache.grails.gradle.grails-violation-aggregation' + } + """ + testProjectDir.resolve('known').toFile().mkdirs() + testProjectDir.resolve('known/build.gradle').toFile().text = '' + testProjectDir.resolve('build/reports/code-analysis').toFile().mkdirs() + + when: + def result = GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments('aggregateAnalysisViolations', '--stacktrace') + .withPluginClasspath() + .buildAndFail() + + then: + result.output.contains("Unknown project path(s) in ${property}: ${projectPath}") + result.output.contains("Run './gradlew projects' to list valid subproject paths.") + + where: + tool | property | projectPath + 'PMD' | GrailsCodeAnalysisPlugin.PMD_ENABLED_PROJECTS_PROPERTY | ':missing' + 'SpotBugs' | GrailsCodeAnalysisPlugin.SPOTBUGS_ENABLED_PROJECTS_PROPERTY | ':missing' + 'PMD root-path' | GrailsCodeAnalysisPlugin.PMD_ENABLED_PROJECTS_PROPERTY | ':' } def "aggregateJacocoCoverage handles no csv reports gracefully"() { diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/RepositoryConventionsTaskSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/RepositoryConventionsTaskSpec.groovy new file mode 100644 index 00000000000..2357c8709a2 --- /dev/null +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/RepositoryConventionsTaskSpec.groovy @@ -0,0 +1,635 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.grails.buildsrc + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import spock.lang.Specification +import spock.lang.TempDir + +import java.nio.charset.StandardCharsets +import java.nio.file.Path + +class RepositoryConventionsTaskSpec extends Specification { + + private static final String SHA = '0123456789abcdef0123456789abcdef01234567' + + @TempDir + Path testProjectDir + + def "validateRepositoryConventions passes valid conventions, runs RAT, and is included by aggregateViolations"() { + given: + writeBuild(true) + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('valid.yml', "uses: actions/checkout@${SHA}") + writeProperties('src/main/resources/messages.properties', '''escaped\\=key=one +continued\\ + key=one +''') + testProjectDir.resolve('build/reports/code-analysis').toFile().mkdirs() + + when: + def result = run('aggregateViolations') + + then: + result.task(':validateRepositoryConventions').outcome == TaskOutcome.SUCCESS + result.task(':rat').outcome == TaskOutcome.SUCCESS + testProjectDir.resolve('build/rat-ran').toFile().exists() + testProjectDir.resolve('build/reports/violations/REPOSITORY_CONVENTIONS.md').toFile().text.contains('No violations found!') + } + + def "validateRepositoryConventions reports skill metadata and AGENTS inventory failures"() { + given: + writeBuild() + writeSkill('expected-name', 'different-name', false) + writeSkill('duplicate-name', 'different-name') + writeAgents('.agents/skills/missing/SKILL.md') + writeWorkflow('valid.yml', "uses: actions/checkout@${SHA}") + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains("skill front matter is missing 'license'") + result.output.contains("skill name 'different-name' does not match directory 'expected-name'") + result.output.contains("skill name 'different-name' duplicates .agents/skills/duplicate-name/SKILL.md") + result.output.contains("missing canonical skill path '.agents/skills/expected-name/SKILL.md'") + result.output.contains("skill path '.agents/skills/missing/SKILL.md' does not exist") + } + + def "validateRepositoryConventions parses quoted, inline, nested, and composite action uses"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflowContent('valid.yml', """jobs: + reusable: + "uses": actions/reusable@${SHA} + build: + steps: + - { "uses": actions/checkout@${SHA} } + - uses: docker://alpine@sha256:${'a' * 64} + - uses: ./.github/actions/sample +""") + writeCompositeAction('sample', """runs: + using: composite + steps: + - uses: actions/setup-java@${SHA} +""", 'yaml') + + when: + def result = run('validateRepositoryConventions') + + then: + result.task(':validateRepositoryConventions').outcome == TaskOutcome.SUCCESS + } + + def "validateRepositoryConventions ignores non-semantic uses fields"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflowContent('valid.yml', """jobs: + reusable: + uses: actions/reusable@${SHA} + build: + env: + uses: mutable-value + steps: + - uses: actions/checkout@${SHA} + with: + uses: mutable-value + env: + uses: mutable-value +steps: + - uses: actions/cache@${SHA} +""") + writeCompositeAction('sample', """inputs: + uses: + description: An ordinary action input +runs: + using: composite + steps: + - uses: actions/setup-java@${SHA} +""") + + when: + def result = run('validateRepositoryConventions') + + then: + result.task(':validateRepositoryConventions').outcome == TaskOutcome.SUCCESS + } + + def "validateRepositoryConventions accepts immutable Docker action and workflow container images"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflowContent('valid.yml', """jobs: + string-container: + container: alpine@sha256:${'a' * 64} + env: + image: mongo:8 + services: + redis: + image: redis@sha256:${'b' * 64} + map-container: + container: + image: mongo@sha256:${'c' * 64} + build: + steps: + - uses: ./.github/actions/docker + - uses: ./.github/actions/build +""") + writeCompositeAction('docker', """runs: + using: docker + image: docker://alpine@sha256:${'d' * 64} +""") + writeActionManifest('.github/actions/build', '''runs: + using: docker + image: Dockerfile +''') + + when: + def result = run('validateRepositoryConventions') + + then: + result.task(':validateRepositoryConventions').outcome == TaskOutcome.SUCCESS + } + + def "validateRepositoryConventions rejects mutable and malformed Docker action and workflow container images"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflowContent('invalid.yml', '''jobs: + string-container: + container: alpine:3 + services: + redis: + image: redis:7 + map-container: + container: + image: mongo:8 + malformed-container: + container: [] + services: + postgres: + image: [postgres] + build: + steps: + - uses: ./.github/actions/docker + - uses: ./.github/actions/build + - uses: ./.github/actions/malformed +''') + writeCompositeAction('docker', '''runs: + using: Docker + image: docker://alpine:3 +''') + writeActionManifest('.github/actions/build', '''runs: + using: docker + image: Docker://redis:7 +''') + writeCompositeAction('malformed', '''runs: + using: docker + image: [Dockerfile] +''') + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains(".github/workflows/invalid.yml:\$.jobs.string-container.container: container image 'alpine:3' must use an immutable sha256 digest") + result.output.contains(".github/workflows/invalid.yml:\$.jobs.string-container.services.redis.image: container image 'redis:7' must use an immutable sha256 digest") + result.output.contains(".github/workflows/invalid.yml:\$.jobs.map-container.container.image: container image 'mongo:8' must use an immutable sha256 digest") + result.output.contains('.github/workflows/invalid.yml:$.jobs.malformed-container.container: container image must be a string') + result.output.contains('.github/workflows/invalid.yml:$.jobs.malformed-container.services.postgres.image: container image must be a string') + result.output.contains(".github/actions/docker/action.yml:\$.runs.image: Docker action image 'docker://alpine:3' must use an immutable sha256 digest") + result.output.contains(".github/actions/build/action.yml:\$.runs.image: Docker action image 'Docker://redis:7' must use an immutable sha256 digest") + result.output.contains('.github/actions/malformed/action.yml:$.runs.image: Docker action image must be a string') + } + + def "validateRepositoryConventions rejects mutable action and Docker references across workflows and composite actions"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflowContent('one.yml', """jobs: + reusable: + uses: actions/reusable@v4 + build: + steps: + - { "uses": actions/checkout@v4 } + - uses: ./local-action + - uses: docker://alpine:3 + - uses: docker://registry@invalid/alpine@sha256:${'b' * 64} + - uses: actions/checkout@${SHA} + - uses: actions/cache@${SHA.toUpperCase()} +""") + writeWorkflow('two.yml', "uses: actions/checkout@${'f' * 40}") + writeCompositeAction('sample', '''runs: + using: composite + steps: + - uses: actions/setup-java@v4 +''', 'yaml') + writeCompositeAction('legacy', '''runs: + using: composite + steps: + - uses: actions/setup-node@v4 +''') + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains("action 'actions/reusable' uses 'v4'") + result.output.contains("action 'actions/checkout' uses 'v4'") + result.output.contains("action 'actions/cache' uses '${SHA.toUpperCase()}'") + result.output.contains("Docker action 'docker://alpine:3' must use an immutable sha256 digest") + result.output.contains("Docker action 'docker://registry@invalid/alpine@sha256:${'b' * 64}' must use an immutable sha256 digest") + result.output.contains(".github/actions/sample/action.yaml:\$.runs.steps[0].uses: action 'actions/setup-java' uses 'v4'") + result.output.contains(".github/actions/legacy/action.yml:\$.runs.steps[0].uses: action 'actions/setup-node' uses 'v4'") + result.output.contains("action 'actions/checkout' uses ${'f' * 40}, inconsistent with ${SHA}") + !result.output.contains('local-action') + } + + def "validateRepositoryConventions reports malformed YAML with its file"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflowContent('broken.yaml', 'jobs: [') + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains('.github/workflows/broken.yaml: malformed YAML:') + } + + def "validateRepositoryConventions rejects duplicate uses keys as malformed YAML"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflowContent('duplicate.yml', """steps: + - uses: actions/checkout@${SHA} + uses: actions/setup-java@v4 +""") + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains('.github/workflows/duplicate.yml: malformed YAML:') + result.output.contains('found duplicate key uses') + } + + def "validateRepositoryConventions scans local composite manifests outside .github actions"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('valid.yml', 'uses: ./tools/actions/sample') + writeWorkflow('build-path.yml', 'uses: ./.github/actions/build') + writeActionManifest('tools/actions/sample', '''runs: + using: composite + steps: + - uses: actions/setup-java@v4 +''') + writeActionManifest('.github/actions/build', '''runs: + using: composite + steps: + - uses: actions/setup-node@v4 +''') + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains("tools/actions/sample/action.yml:\$.runs.steps[0].uses: action 'actions/setup-java' uses 'v4'") + result.output.contains(".github/actions/build/action.yml:\$.runs.steps[0].uses: action 'actions/setup-node' uses 'v4'") + !result.output.contains('./tools/actions/sample') + } + + def "validateRepositoryConventions rejects local action paths outside the repository"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('escape.yml', 'uses: ./../outside') + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains("local action './../outside' resolves outside the repository") + } + + def "validateRepositoryConventions sanitizes decoded property keys in reports and exceptions"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('valid.yml', "uses: actions/checkout@${SHA}") + writeProperties('src/main/resources/messages.properties', '''injected\\r\\n|forged=one +injected\\r\\n|forged=two +''') + + when: + def result = runAndFail('validateRepositoryConventions') + def report = testProjectDir.resolve('build/reports/violations/REPOSITORY_CONVENTIONS.md').toFile().text + + then: + result.output.contains('injected\\r\\n\\|forged') + report.contains('injected\\r\\n\\|forged') + !report.contains('\r') + } + + def "validateRepositoryConventions sanitizes front matter violations once in reports and exceptions"() { + given: + writeBuild() + writeSkillContent('sample', '''--- +name: sample +description: Test skill +license: Apache-2.0 +"duplicate|key": one +"duplicate|key": two +--- +''') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('valid.yml', "uses: actions/checkout@${SHA}") + + when: + def result = runAndFail('validateRepositoryConventions') + def report = testProjectDir.resolve('build/reports/violations/REPOSITORY_CONVENTIONS.md').toFile().text + + then: + result.output.contains('duplicate key duplicate\\|key') + !result.output.contains('duplicate key duplicate\\\\|key') + report.contains('duplicate key duplicate\\|key') + !report.contains('duplicate key duplicate\\\\|key') + } + + def "validateRepositoryConventions reads duplicate UTF-8 message keys independently of the Gradle default charset"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('valid.yml', "uses: actions/checkout@${SHA}") + def build = testProjectDir.resolve('build.gradle').toFile() + build << ''' +tasks.register('verifyDefaultEncoding') { + doLast { + assert java.nio.charset.Charset.defaultCharset() == java.nio.charset.StandardCharsets.ISO_8859_1 + } +} +''' + testProjectDir.resolve('gradle.properties').toFile().text = 'org.gradle.jvmargs=-Dfile.encoding=ISO-8859-1\n' + def file = testProjectDir.resolve('src/main/resources/messages.properties').toFile() + file.parentFile.mkdirs() + file.setText('caf\u00e9=one\ncaf\u00e9=two\n', StandardCharsets.UTF_8.name()) + + when: + def result = runAndFail('verifyDefaultEncoding', 'validateRepositoryConventions') + + then: + result.task(':verifyDefaultEncoding').outcome == TaskOutcome.SUCCESS + result.output.contains("duplicate message key 'caf\u00e9'") + } + + def "validateRepositoryConventions requires skill front matter to begin on line one"() { + given: + writeBuild() + writeSkill('sample', 'sample') + def skill = testProjectDir.resolve('.agents/skills/sample/SKILL.md').toFile() + skill.text = "\n${skill.text}" + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('valid.yml', "uses: actions/checkout@${SHA}") + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains("skill front matter is missing 'name'") + result.output.contains("skill front matter is missing 'description'") + result.output.contains("skill front matter is missing 'license'") + } + + def "validateRepositoryConventions accepts quoted and block scalar skill metadata"() { + given: + writeBuild() + writeSkillContent('sample', '''--- +name: "sample" +description: |- + Test skill + with multiple lines +license: 'Apache-2.0' +--- +''') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('valid.yml', "uses: actions/checkout@${SHA}") + + when: + def result = run('validateRepositoryConventions') + + then: + result.task(':validateRepositoryConventions').outcome == TaskOutcome.SUCCESS + } + + def "validateRepositoryConventions rejects nested skill metadata fields"() { + given: + writeBuild() + writeSkillContent('sample', '''--- +metadata: + name: sample + description: Test skill + license: Apache-2.0 +--- +''') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('valid.yml', "uses: actions/checkout@${SHA}") + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains("skill front matter is missing 'name'") + result.output.contains("skill front matter is missing 'description'") + result.output.contains("skill front matter is missing 'license'") + } + + def "validateRepositoryConventions rejects malformed and duplicate skill front matter"() { + given: + writeBuild() + writeSkillContent('malformed', '''--- +name: [ +--- +''') + writeSkillContent('duplicate', '''--- +name: duplicate +name: duplicate-again +description: Test skill +license: Apache-2.0 +--- +''') + writeAgents('.agents/skills/malformed/SKILL.md') + writeWorkflow('valid.yml', "uses: actions/checkout@${SHA}") + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains('.agents/skills/malformed/SKILL.md: malformed skill front matter:') + result.output.contains('.agents/skills/duplicate/SKILL.md: malformed skill front matter:') + result.output.contains('found duplicate key name') + } + + def "validateRepositoryConventions rejects non-string metadata and non-mapping front matter"() { + given: + writeBuild() + writeSkillContent('typed', '''--- +name: [typed] +description: Test skill +license: Apache-2.0 +--- +''') + writeSkillContent('root', '''--- +- name: root +--- +''') + writeAgents('.agents/skills/typed/SKILL.md') + writeWorkflow('valid.yml', "uses: actions/checkout@${SHA}") + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains(".agents/skills/typed/SKILL.md: skill front matter field 'name' must be a string") + result.output.contains('.agents/skills/root/SKILL.md: skill front matter must be a YAML mapping') + } + + def "validateRepositoryConventions detects duplicate logical message keys and ignores generated trees"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('valid.yml', "uses: actions/checkout@${SHA}") + writeProperties('src/main/resources/messages.properties', '''# message=ignored +message=one +message=two +escaped\\=key=one +escaped\\=key=two +continued\\ + key=one +continuedkey=two +''') + writeProperties('build/generated/messages.properties', '''message=one +message=two +''') + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains("duplicate message key 'message'") + result.output.contains("duplicate message key 'escaped=key'") + result.output.contains("duplicate message key 'continuedkey'") + !result.output.contains('build/generated/messages.properties') + } + + private void writeBuild(boolean includeRat = false) { + testProjectDir.resolve('settings.gradle').toFile().text = '' + testProjectDir.resolve('build.gradle').toFile().text = '''plugins { + id 'org.apache.grails.gradle.grails-violation-aggregation' +} +''' + (includeRat ? ''' +tasks.register('rat') { + doLast { + file('build').mkdirs() + file('build/rat-ran').text = 'ran' + } +} +''' : '') + } + + private void writeSkill(String directory, String name, boolean withLicense = true) { + writeSkillContent(directory, """--- +name: ${name} +description: Test skill +${withLicense ? 'license: Apache-2.0' : ''} +--- +""") + } + + private void writeSkillContent(String directory, String content) { + def file = testProjectDir.resolve(".agents/skills/${directory}/SKILL.md").toFile() + file.parentFile.mkdirs() + file.text = content + } + + private void writeAgents(String path) { + testProjectDir.resolve('AGENTS.md').toFile().text = "Read `${path}`.\n" + } + + private void writeWorkflow(String name, String uses) { + writeWorkflowContent(name, "steps:\n - ${uses}\n") + } + + private void writeWorkflowContent(String name, String content) { + def file = testProjectDir.resolve(".github/workflows/${name}").toFile() + file.parentFile.mkdirs() + file.text = content + } + + private void writeCompositeAction(String name, String content, String extension = 'yml') { + writeActionManifest(".github/actions/${name}", content, extension) + } + + private void writeActionManifest(String directory, String content, String extension = 'yml') { + def file = testProjectDir.resolve("${directory}/action.${extension}").toFile() + file.parentFile.mkdirs() + file.text = content + } + + private void writeProperties(String path, String content) { + def file = testProjectDir.resolve(path).toFile() + file.parentFile.mkdirs() + file.text = content + } + + private def run(String task) { + GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments(task, '--stacktrace') + .withPluginClasspath() + .build() + } + + private def runAndFail(String task, String... additionalArguments) { + List arguments = [task, '--stacktrace'] + arguments.addAll(additionalArguments) + GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withArguments(arguments) + .withPluginClasspath() + .buildAndFail() + } +} diff --git a/gradle.properties b/gradle.properties index bfcdd8de1a6..4f88d68646c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -72,6 +72,9 @@ pmdVersion=7.25.0 spotbugsPluginVersion=6.4.8 sonatypeScanPluginVersion=3.1.6 +# PMD is enforced only for projects with a clean baseline. Add project paths after clearing debt. +grails.code-analysis.enabled.pmd.projects=:grails-data-graphql-core,:grails-data-mongodb-spring-data,:grails-datasource,:grails-testing-support-core + # This prevents the Grails Gradle Plugin from unnecessarily excluding slf4j-simple in the generated POMs # https://github.com/apache/grails-gradle-plugin/issues/222 slf4jPreventExclusion=true diff --git a/gradle/rat-root-config.gradle b/gradle/rat-root-config.gradle index cfbd56e6486..ad1bf83b032 100644 --- a/gradle/rat-root-config.gradle +++ b/gradle/rat-root-config.gradle @@ -20,6 +20,9 @@ apply plugin: 'org.nosphere.apache.rat' tasks.named('rat') { def allExcludes = [ '.asf.yaml', // ASF metadata for github integration excluded from src zip + '.omo/**', // local agent work state excluded from src zip + '.claude/**', '.clinerules', '.cursorrules', '.windsurfrules', // local agent configuration excluded from src zip + 'CLAUDE.md', 'GEMINI.md', // local agent guidance excluded from src zip '.mailmap', // authorship mapping for git history, will not be included in src zip 'CODE_OF_CONDUCT.md', 'BUILD_DATE', // build artifact for storing the build date / verifying @@ -154,4 +157,4 @@ tasks.named('rat') { excludes = allExcludes // never cache license audits it.outputs.upToDateWhen { false } -} \ No newline at end of file +} diff --git a/grails-spring-security/ui/plugin/grails-app/i18n/messages.spring-security-ui.properties b/grails-spring-security/ui/plugin/grails-app/i18n/messages.spring-security-ui.properties index e9028dbf8ed..27082bf06a9 100644 --- a/grails-spring-security/ui/plugin/grails-app/i18n/messages.spring-security-ui.properties +++ b/grails-spring-security/ui/plugin/grails-app/i18n/messages.spring-security-ui.properties @@ -139,7 +139,6 @@ spring.security.ui.forgotPassword.email.line1 You (or someone pretending to be y spring.security.ui.forgotPassword.email.line2 If you didn't make this request then ignore the email; no changes have been made. spring.security.ui.forgotPassword.email.line3 If you did make the request, then click spring.security.ui.forgotPassword.email.line4 to reset your password. -spring.security.ui.forgotPassword.email.line4 to reset your password. spring.security.ui.securityQuestions.title Security Questions