Skip to content

Commit 9074284

Browse files
authored
Merge branch 'feat/grails-code-analysis-plugin' into feat/grails-jacoco-plugin
2 parents 58a12d5 + b18a523 commit 9074284

9 files changed

Lines changed: 224 additions & 26 deletions

File tree

.agents/skills/violation-fixer/SKILL.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Activate this skill when:
3131
| Plugin | Applied to | Responsibility |
3232
|--------|-----------|----------------|
3333
| `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/codestyle/` |
34-
| `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/codestyle/` |
34+
| `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/codeanalysis/` |
3535
| `org.apache.grails.gradle.grails-jacoco` | Every subproject | Applies JaCoCo; wires `jacocoTestReport` to run after each `test` task |
3636
| `org.apache.grails.gradle.grails-violation-aggregation` | **Root project only** | Registers `aggregateViolations` and `aggregateJacocoCoverage` tasks; writes Markdown summaries to `build/reports/violations/` |
3737

@@ -214,11 +214,13 @@ build/reports/codestyle/ ← XML inputs for style aggregation
214214
│ ├── grails-core-checkstyleMain.xml
215215
│ ├── grails-web-mvc-checkstyleMain.xml
216216
│ └── ...
217-
├── codenarc/
218-
│ ├── grails-core-codenarcMain.xml
219-
│ └── ...
220-
├── pmd/ (if enabled)
221-
└── spotbugs/ (if enabled)
217+
└── codenarc/
218+
├── grails-core-codenarcMain.xml
219+
└── ...
220+
221+
build/reports/codeanalysis/ ← XML inputs for analysis aggregation (if enabled)
222+
├── pmd/
223+
└── spotbugs/
222224
223225
build/reports/violations/ ← Markdown summaries written by aggregateViolations
224226
├── CODENARC_VIOLATIONS.md

.github/workflows/codeanalysis.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ jobs:
4444
with:
4545
develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
4646
- name: "🔎 Check Core Projects"
47-
run: ./gradlew aggregateAnalysisViolations --continue
47+
run: ./gradlew aggregateAnalysisViolations --continue -Pgrails.codeanalysis.enabled.pmd=true -Pgrails.codeanalysis.enabled.spotbugs=true
4848
- name: "📤 Upload Reports"
4949
if: always()
5050
uses: actions/upload-artifact@v7.0.1
@@ -78,7 +78,7 @@ jobs:
7878
develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
7979
- name: "🔎 Check Gradle Plugin Projects"
8080
working-directory: grails-gradle
81-
run: ./gradlew aggregateAnalysisViolations --continue
81+
run: ./gradlew aggregateAnalysisViolations --continue -Pgrails.codeanalysis.enabled.pmd=true -Pgrails.codeanalysis.enabled.spotbugs=true
8282
- name: "📤 Upload Reports"
8383
if: always()
8484
uses: actions/upload-artifact@v7.0.1

build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeAnalysisExtension.groovy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import org.gradle.api.model.ObjectFactory
3030
class GrailsCodeAnalysisExtension {
3131

3232
/**
33-
* Defaults to project.rootProject.buildDir/codestyle/pmd.
33+
* Defaults to project.rootProject.buildDir/codeanalysis/pmd.
3434
* Directory for PMD configuration files (e.g. pmd.xml).
3535
*/
3636
final DirectoryProperty pmdDirectory

build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStyleExtension.groovy

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import groovy.transform.CompileStatic
2525
import org.gradle.api.Project
2626
import org.gradle.api.file.DirectoryProperty
2727
import org.gradle.api.model.ObjectFactory
28-
import org.gradle.api.provider.Property
2928

3029
@CompileStatic
3130
class GrailsCodeStyleExtension {

build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsCodeStylePlugin.groovy

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,17 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
109109
}
110110

111111
private static void createOrLoad(Path expectedPath, String defaultResource, Project project) {
112-
boolean defaultPath = expectedPath.startsWith(project.rootProject.buildDir.toPath())
113-
if (!Files.exists(expectedPath) || expectedPath.size() == 0 || defaultPath) {
114-
def defaultValue = GrailsCodeStylePlugin.getResourceAsStream(defaultResource)
115-
if (!defaultValue) {
116-
throw new IllegalStateException("Could not locate default configuration file: ${defaultResource}")
117-
}
118-
// TODO: This really need to use gradle caching instead
119-
project.logger.info("Replacing code style configuration")
120-
expectedPath.text = defaultValue.text
112+
def defaultValue = GrailsCodeStylePlugin.getResourceAsStream(defaultResource)
113+
if (!defaultValue) {
114+
throw new IllegalStateException("Could not locate default configuration file: ${defaultResource}")
115+
}
116+
String defaultText = defaultValue.text
117+
boolean missing = !Files.exists(expectedPath) || expectedPath.size() == 0
118+
// Only rewrite when missing or the on-disk content differs from the bundled
119+
// resource, so repeated builds across many subprojects don't churn the file.
120+
if (missing || expectedPath.text != defaultText) {
121+
project.logger.debug("Writing code style configuration to ${expectedPath}")
122+
expectedPath.text = defaultText
121123
}
122124
}
123125

@@ -228,15 +230,23 @@ class GrailsCodeStylePlugin implements Plugin<Project> {
228230
content = content.replaceAll(/(class\s+[^{]+\{\n)([ \t]*[^ \s\n\/])/, '$1\n$2')
229231

230232
// 2. SpaceAroundMapEntryColon
231-
content = content.replaceAll(/([\[,]\s*(?:[\w\-.]+|'[^']+'|"[^"]+")):([^\s\/])/, '$1: $2')
232-
content = content.replaceAll(/(\(\s*(?:[\w\-.]+|'[^']+'|"[^"]+")):([^\s\/])/, '$1: $2')
233+
// (?!:) prevents matching the first : in a :: method reference (e.g. String::trim)
234+
content = content.replaceAll(/([\[,]\s*(?:[\w\-.]+|'[^']+'|"[^"]+")):(?!:)([^\s\/])/, '$1: $2')
235+
content = content.replaceAll(/(\(\s*(?:[\w\-.]+|'[^']+'|"[^"]+")):(?!:)([^\s\/])/, '$1: $2')
233236

234237
// 3. UnnecessaryGString
235-
content = content.replaceAll(/(?<!\\)(?<!")"([^"$\n\\]*)"(?!")/) { all, inner ->
238+
// The alternation skips over single-quoted strings so their embedded double-quote
239+
// content is never touched. The (?<!}) lookbehind prevents fusing the closing "
240+
// of a GString with the opening " of an adjacent plain string (e.g. obj."${x}"("y")).
241+
content = content.replaceAll(/(?<!\\)'(?:[^'\\\n]|\\.)*'|(?<!\\)(?<!")(?<!})"([^"$\n\\]*)"(?!")/) { List<String> args ->
242+
if (args[1] == null) {
243+
return args[0] // single-quoted string matched — leave it untouched
244+
}
245+
String inner = args[1]
236246
if (!inner.contains("'")) {
237247
return "'$inner'"
238248
}
239-
return all
249+
return args[0]
240250
}
241251

242252
// 4. UnnecessarySemicolon

build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPlugin.groovy

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
6161

6262
private static final Logger LOGGER = Logging.getLogger(GrailsViolationAggregationPlugin)
6363

64+
/**
65+
* Comma-separated list of fully-qualified class-name prefixes to exclude from the aggregated
66+
* JaCoCo coverage report. Configure via {@code -Pgrails.jacoco.aggregation.excludedClassPrefixes=...}
67+
* or in {@code gradle.properties}.
68+
*
69+
* <p>Defaults to {@link #DEFAULT_JACOCO_EXCLUDED_CLASS_PREFIXES}: the Hibernate 7 support classes
70+
* share fully-qualified names with their Hibernate 5 counterparts, and JaCoCo cannot aggregate
71+
* coverage for two different classes with the same name (it fails with
72+
* "Can't add different class with same name"). Excluding one variant keeps the aggregate valid.
73+
*/
74+
static final String JACOCO_EXCLUDED_CLASS_PREFIXES_PROPERTY = 'grails.jacoco.aggregation.excludedClassPrefixes'
75+
76+
static final String DEFAULT_JACOCO_EXCLUDED_CLASS_PREFIXES = 'org.grails.orm.hibernate.support.hibernate7.'
77+
6478
@Override
6579
void apply(Project project) {
6680
if (project != project.rootProject) {
@@ -153,13 +167,23 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
153167
root.allprojects.collect { Project p -> p.file('build/reports/jacoco/test/jacocoTestReport.csv') }
154168
)
155169

170+
// Resolve the excluded class-name prefixes as a Provider so the value is captured
171+
// configuration-cache-safely and read at task execution time.
172+
Provider<List<String>> excludedClassPrefixes = root.providers
173+
.gradleProperty(JACOCO_EXCLUDED_CLASS_PREFIXES_PROPERTY)
174+
.orElse(DEFAULT_JACOCO_EXCLUDED_CLASS_PREFIXES)
175+
.map { String value ->
176+
value.split(',').collect { it.trim() }.findAll { !it.isEmpty() }
177+
}
178+
156179
TaskProvider<Task> aggregateTask = root.tasks.register('aggregateJacocoCoverage') { Task task ->
157180
task.group = 'verification'
158181
task.description = 'Aggregates JaCoCo coverage reports from all subprojects into build/reports/violations/'
159182
task.inputs.files(jacocoCsvFiles).optional(true)
183+
task.inputs.property('excludedClassPrefixes', excludedClassPrefixes)
160184
task.outputs.file(root.file('build/reports/violations/JACOCO_COVERAGE.md'))
161185
task.doLast {
162-
parseJacocoCoverage(jacocoCsvFiles, violationsDir.get())
186+
parseJacocoCoverage(jacocoCsvFiles, violationsDir.get(), excludedClassPrefixes.get())
163187
}
164188
}
165189
root.subprojects { Project sub ->
@@ -175,7 +199,10 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
175199
private static void parseStyleViolations(Directory styleXmlDir, Directory violationsDir,
176200
boolean checkStyleTests, boolean codenarcEnabled, boolean checkstyleEnabled) {
177201
def slurper = new XmlSlurper()
202+
slurper.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true)
178203
slurper.setFeature('http://apache.org/xml/features/nonvalidating/load-external-dtd', false)
204+
slurper.setFeature('http://xml.org/sax/features/external-general-entities', false)
205+
slurper.setFeature('http://xml.org/sax/features/external-parameter-entities', false)
179206
slurper.setFeature('http://xml.org/sax/features/namespaces', false)
180207

181208
def getModule = { String fileName ->
@@ -300,7 +327,10 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
300327
private static void parseAnalysisViolations(Directory analysisXmlDir, Directory violationsDir,
301328
boolean checkAnalysisTests, boolean pmdEnabled, boolean spotbugsEnabled) {
302329
def slurper = new XmlSlurper()
330+
slurper.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true)
303331
slurper.setFeature('http://apache.org/xml/features/nonvalidating/load-external-dtd', false)
332+
slurper.setFeature('http://xml.org/sax/features/external-general-entities', false)
333+
slurper.setFeature('http://xml.org/sax/features/external-parameter-entities', false)
304334
slurper.setFeature('http://xml.org/sax/features/namespaces', false)
305335

306336
def getModule = { String fileName ->
@@ -406,7 +436,7 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
406436
}
407437

408438
@CompileDynamic
409-
private static void parseJacocoCoverage(FileCollection csvFiles, Directory violationsDir) {
439+
private static void parseJacocoCoverage(FileCollection csvFiles, Directory violationsDir, List<String> excludedClassPrefixes) {
410440
def jacocoCoverage = []
411441
csvFiles.each { File csvReport ->
412442
if (csvReport.exists()) {
@@ -442,7 +472,11 @@ class GrailsViolationAggregationPlugin implements Plugin<Project> {
442472
return
443473
}
444474

445-
jacocoCoverage.removeIf { it.className.startsWith('org.grails.orm.hibernate.support.hibernate7.') }
475+
// Drop classes whose fully-qualified names collide across Hibernate variants (see
476+
// JACOCO_EXCLUDED_CLASS_PREFIXES_PROPERTY) so the aggregate report stays valid.
477+
if (excludedClassPrefixes) {
478+
jacocoCoverage.removeIf { entry -> excludedClassPrefixes.any { prefix -> entry.className.startsWith(prefix) } }
479+
}
446480

447481
def outDir = violationsDir.asFile
448482
outDir.mkdirs()

build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsCodeStylePluginSpec.groovy

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,92 @@ class Test {
132132
and: "escaped quotes are NOT broken"
133133
groovyFile.text.contains('"\\"\\$it\\""')
134134
}
135+
136+
def "test codenarcFix task does not corrupt method references (::)"() {
137+
given: "a file with :: method references"
138+
groovyFile.text = """package org.test
139+
140+
import java.util.Arrays
141+
142+
class Test {
143+
def result = Arrays.stream(items).map(String::trim).toList()
144+
def other = items.collect(String::valueOf)
145+
def cors = parseConfigList(config, 'allowedOrigins', corsConfig::setAllowedOrigins)
146+
}
147+
"""
148+
when: "running codenarcFix"
149+
def result = GradleRunner.create()
150+
.withProjectDir(testProjectDir.toFile())
151+
.withArguments('codenarcFix', '--stacktrace')
152+
.withPluginClasspath()
153+
.build()
154+
155+
then: "task finished successfully"
156+
result.task(':codenarcFix').outcome == org.gradle.testkit.runner.TaskOutcome.SUCCESS
157+
158+
and: "method references are NOT broken"
159+
def content = groovyFile.text
160+
content.contains('String::trim')
161+
content.contains('String::valueOf')
162+
content.contains('corsConfig::setAllowedOrigins')
163+
!content.contains('String: :trim')
164+
!content.contains('String: :valueOf')
165+
!content.contains('corsConfig: :setAllowedOrigins')
166+
}
167+
168+
def "test codenarcFix task does not corrupt adjacent GString and plain string"() {
169+
given: "a file with a GString method name followed by a plain string argument"
170+
groovyFile.text = '''package org.test
171+
172+
class Test {
173+
def invoke(invoker, name) {
174+
invoker."${name}"("plain arg")
175+
invoker."${name}"("-Pargs=${someVar}")
176+
}
177+
}
178+
'''
179+
when: "running codenarcFix"
180+
def result = GradleRunner.create()
181+
.withProjectDir(testProjectDir.toFile())
182+
.withArguments('codenarcFix', '--stacktrace')
183+
.withPluginClasspath()
184+
.build()
185+
186+
then: "task finished successfully"
187+
result.task(':codenarcFix').outcome == org.gradle.testkit.runner.TaskOutcome.SUCCESS
188+
189+
and: "adjacent GString boundaries are NOT fused"
190+
def content = groovyFile.text
191+
content.contains('invoker."${name}"(\'plain arg\')')
192+
content.contains('invoker."${name}"("-Pargs=${someVar}")')
193+
!content.contains('invoker."${name}\'(\'plain arg")')
194+
!content.contains('invoker."${name}\'(\'plain arg\')')
195+
}
196+
197+
def "test codenarcFix task does not corrupt double quotes inside single-quoted strings"() {
198+
given: "a file with single-quoted strings containing double quotes"
199+
groovyFile.text = """package org.test
200+
201+
class Test {
202+
def d1 = description('Accepts format "hh:mm:ss"')
203+
def d2 = writeLine('[cols="2,5,2", options="header"]')
204+
def d3 = 'value with "double quotes" inside'
205+
}
206+
"""
207+
when: "running codenarcFix"
208+
def result = GradleRunner.create()
209+
.withProjectDir(testProjectDir.toFile())
210+
.withArguments('codenarcFix', '--stacktrace')
211+
.withPluginClasspath()
212+
.build()
213+
214+
then: "task finished successfully"
215+
result.task(':codenarcFix').outcome == org.gradle.testkit.runner.TaskOutcome.SUCCESS
216+
217+
and: "double quotes inside single-quoted strings are NOT changed"
218+
def content = groovyFile.text
219+
content.contains('\'Accepts format "hh:mm:ss"\'')
220+
content.contains('\'[cols="2,5,2", options="header"]\'')
221+
content.contains('\'value with "double quotes" inside\'')
222+
}
135223
}

build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GrailsViolationAggregationPluginSpec.groovy

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,4 +174,68 @@ class GrailsViolationAggregationPluginSpec extends Specification {
174174
and: "no report file is created"
175175
!testProjectDir.resolve('build/reports/violations/JACOCO_COVERAGE.md').toFile().exists()
176176
}
177+
178+
def "aggregateJacocoCoverage excludes the default hibernate7 support classes"() {
179+
given: "a root project with a jacoco csv containing an h7 support class and a normal class"
180+
testProjectDir.resolve('settings.gradle').toFile().text = ''
181+
testProjectDir.resolve('build.gradle').toFile().text = """
182+
plugins {
183+
id 'org.apache.grails.gradle.grails-violation-aggregation'
184+
}
185+
"""
186+
writeJacocoCsv([
187+
'app,org.grails.orm.hibernate.support.hibernate7,HibernateSupport,10,0',
188+
'app,org.example.kept,KeptClass,0,20',
189+
])
190+
191+
when:
192+
def result = GradleRunner.create()
193+
.withProjectDir(testProjectDir.toFile())
194+
.withArguments('aggregateJacocoCoverage', '--stacktrace')
195+
.withPluginClasspath()
196+
.build()
197+
198+
then: "task succeeds and the report drops the colliding h7 class but keeps the normal one"
199+
result.task(':aggregateJacocoCoverage').outcome == TaskOutcome.SUCCESS
200+
def report = testProjectDir.resolve('build/reports/violations/JACOCO_COVERAGE.md').toFile()
201+
report.exists()
202+
def text = report.text
203+
text.contains('org.example.kept.KeptClass')
204+
!text.contains('org.grails.orm.hibernate.support.hibernate7.HibernateSupport')
205+
}
206+
207+
def "aggregateJacocoCoverage exclusion prefixes are configurable via property"() {
208+
given: "a root project and a custom exclusion prefix that keeps the h7 class and drops a custom one"
209+
testProjectDir.resolve('settings.gradle').toFile().text = ''
210+
testProjectDir.resolve('build.gradle').toFile().text = """
211+
plugins {
212+
id 'org.apache.grails.gradle.grails-violation-aggregation'
213+
}
214+
"""
215+
writeJacocoCsv([
216+
'app,org.grails.orm.hibernate.support.hibernate7,HibernateSupport,10,0',
217+
'app,com.example.skip,SkipMe,5,5',
218+
'app,org.example.kept,KeptClass,0,20',
219+
])
220+
221+
when:
222+
def result = GradleRunner.create()
223+
.withProjectDir(testProjectDir.toFile())
224+
.withArguments('aggregateJacocoCoverage', '-Pgrails.jacoco.aggregation.excludedClassPrefixes=com.example.skip', '--stacktrace')
225+
.withPluginClasspath()
226+
.build()
227+
228+
then: "the custom prefix is dropped while the default h7 class is now retained"
229+
result.task(':aggregateJacocoCoverage').outcome == TaskOutcome.SUCCESS
230+
def text = testProjectDir.resolve('build/reports/violations/JACOCO_COVERAGE.md').toFile().text
231+
text.contains('org.example.kept.KeptClass')
232+
text.contains('org.grails.orm.hibernate.support.hibernate7.HibernateSupport')
233+
!text.contains('com.example.skip.SkipMe')
234+
}
235+
236+
private void writeJacocoCsv(List<String> dataRows) {
237+
def csv = testProjectDir.resolve('build/reports/jacoco/test/jacocoTestReport.csv').toFile()
238+
csv.parentFile.mkdirs()
239+
csv.text = (['GROUP,PACKAGE,CLASS,INSTRUCTION_MISSED,INSTRUCTION_COVERED'] + dataRows).join('\n') + '\n'
240+
}
177241
}

grails-gradle/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import java.time.format.DateTimeFormatter
2323
plugins {
2424
id 'org.apache.grails.buildsrc.properties'
2525
id 'org.apache.grails.buildsrc.dependency-validator'
26+
id 'org.apache.grails.gradle.grails-violation-aggregation'
2627
}
2728

2829
allprojects {

0 commit comments

Comments
 (0)