diff --git a/.agents/skills/groovy-developer/SKILL.md b/.agents/skills/groovy-developer/SKILL.md index c18297d25fe..db858946bde 100644 --- a/.agents/skills/groovy-developer/SKILL.md +++ b/.agents/skills/groovy-developer/SKILL.md @@ -112,7 +112,7 @@ def firstTitle = books?.first()?.title ?: "No books" ### Ternary Operator ```groovy // Use ternary operators only for simple conditions. -// Split long ternary expressions into multiple lines. +// Split long ternary expressions into multiple lines. // Align `?` and `:` branches for readability. String message = condition ? "Value when true" 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..b5eaf92a9cd 100644 --- a/.agents/skills/violation-fixer/SKILL.md +++ b/.agents/skills/violation-fixer/SKILL.md @@ -1,6 +1,6 @@ --- name: violation-fixer -description: Guide for running, interpreting, and fixing code style and analysis violations in grails-core using GrailsCodeStylePlugin, GrailsCodeAnalysisPlugin, and GrailsViolationAggregationPlugin — covering CodeNarc, Checkstyle, PMD, SpotBugs, and JaCoCo +description: Guide for running, interpreting, and fixing code style and analysis violations in grails-core using GrailsCodeStylePlugin, GrailsCodeAnalysisPlugin, and GrailsViolationAggregationPlugin - covering CodeNarc, Checkstyle, PMD, SpotBugs, and JaCoCo license: Apache-2.0 --- \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('.agents/skills/sample/SKILL.md: skill front matter must start on line 1') + !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 UTF-8 BOM-prefixed skill front matter"() { + given: + writeBuild() + writeSkillContent('sample', '''--- +name: sample +description: Test skill +license: Apache-2.0 +--- +''') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('valid.yml', 'uses: actions/checkout@v6') + + when: + def result = run('validateRepositoryConventions') + + then: + result.task(':validateRepositoryConventions').outcome == TaskOutcome.SUCCESS + } + + def "validateRepositoryConventions reports unterminated skill front matter once"() { + given: + writeBuild() + writeSkillContent('sample', '''--- +name: sample +description: Test skill +license: Apache-2.0 +''') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('valid.yml', 'uses: actions/checkout@v6') + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains('.agents/skills/sample/SKILL.md: skill front matter block is unterminated') + !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 rejects invalid skill directory names"() { + given: + writeBuild() + writeSkill('bad.name', 'bad.name') + writeWorkflow('valid.yml', 'uses: actions/checkout@v6') + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains(".agents/skills/bad.name/SKILL.md: skill directory 'bad.name' must match [A-Za-z0-9_-]+") + } + + 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('grails-app/i18n/messages.properties', '''# message=ignored +message=one +message=two +escaped\\=key=one +escaped\\=key=two +continued\\ + key=one +continuedkey=two +''') + writeProperties('build/generated/grails-app/i18n/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/grails-app/i18n/messages.properties') + } + + def "validateRepositoryConventions reports malformed properties lines and scans non-message bundles"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('valid.yml', 'uses: actions/checkout@v6') + writeProperties('grails-app/i18n/spring-security-core.properties', '''duplicate=one +duplicate=two +malformed=\\u12x +''') + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains("duplicate message key 'duplicate'") + result.output.contains('spring-security-core.properties:3: malformed properties line:') + } + + def "validateRepositoryConventions does not continue logical properties lines ending in an even number of backslashes"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflow('valid.yml', 'uses: actions/checkout@v6') + writeProperties('grails-app/i18n/messages.properties', '''escaped\\\\=one +escaped\\\\=two +''') + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains("duplicate message key 'escaped\\'") + } + + def "validateRepositoryConventions explains why container image expressions are rejected"() { + given: + writeBuild() + writeSkill('sample', 'sample') + writeAgents('.agents/skills/sample/SKILL.md') + writeWorkflowContent('expression.yml', '''jobs: + build: + container: ${{ matrix.mongo-image }} + steps: + - uses: actions/checkout@v6 +''') + + when: + def result = runAndFail('validateRepositoryConventions') + + then: + result.output.contains('container images must be literal name@sha256: values because expression values cannot be verified as immutable') + } + + private void writeBuild(boolean includeRat = false) { + testProjectDir.resolve('settings.gradle').toFile().text = '' + testProjectDir.resolve('.asf.yaml').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 static File findRepositoryRoot() { + File directory = new File('.').canonicalFile + while (directory != null) { + if (new File(directory, '.asf.yaml').isFile()) { + return directory + } + directory = directory.parentFile + } + throw new IllegalStateException("Unable to locate repository root containing .asf.yaml from ${new File('.').canonicalPath}") + } + + 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 e201edf6563..d78c1ed9137 100644 --- a/gradle.properties +++ b/gradle.properties @@ -71,6 +71,7 @@ jacocoVersion=0.8.14 pmdVersion=7.25.0 spotbugsPluginVersion=6.4.8 sonatypeScanPluginVersion=3.1.6 +snakeyamlVersion=2.6 # This prevents the Grails Gradle Plugin from unnecessarily excluding slf4j-simple in the generated POMs # https://github.com/apache/grails-gradle-plugin/issues/222 diff --git a/gradle/rat-root-config.gradle b/gradle/rat-root-config.gradle index cfbd56e6486..ab0135dd925 100644 --- a/gradle/rat-root-config.gradle +++ b/gradle/rat-root-config.gradle @@ -19,7 +19,14 @@ apply plugin: 'org.nosphere.apache.rat' tasks.named('rat') { def allExcludes = [ - '.asf.yaml', // ASF metadata for github integration excluded from src zip + '.asf.yaml', // ASF metadata for GitHub integration; kept in source archives, excluded from RAT only + '.omo/**', // local agent work state excluded from src zip + '.worktrees/**', // standard location for agent/tooling git worktrees, never shipped + '.claude/settings.json', // JSON files cannot contain license headers + // Symlinks to AGENTS.md and .agents/skills/**, which carry the license header. They ship in the + // src zip as links, but cannot be audited here: on platforms without symlink support git + // materializes them as plain text files containing the link target. + '.claude/skills/**', '.clinerules', '.cursorrules', '.windsurfrules', 'CLAUDE.md', 'GEMINI.md', '.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 @@ -67,6 +74,7 @@ tasks.named('rat') { 'build-logic/plugins/build/**', // exclude build artifacts 'build-logic/docs-core/build/**', // exclude build artifacts 'grails-test-examples/*/build/**', // build directories + 'grails-test-examples/plugins/*/build/**', // nested plugin example build directories 'grails-gsp/*/build/**', // build directories 'grails-test-examples/gsp-spring-boot/app/build', // build directories '*/build', // root build directories @@ -124,7 +132,10 @@ tasks.named('rat') { 'grails-forge/**/src/main/resources/**', // src/main/resources are included in generated application and should not include a license 'grails-forge/**/src/test/resources/**', // src/test/resources are used in tests against files included in generated application and should not include a license 'grails-gradle/**/build/**', // grails-gradle does not have a build package name so exclude any build directories + 'grails-gradle/plugins/bin/**', // generated IDE output for the Gradle plugin build + 'grails-bom/micronaut/build/**', // excluded from the Java 21 project graph but still generated locally 'grails-forge/*/build/**', // grails-forge build directories + 'grails-forge/grails-forge-*/bin/**', // generated IDE output for Grails Forge modules 'grails-forge/build/**', // grails-forge build directories 'grails-spring-security/plugin/src/main/templates/**', // template files that people are expected to use in the end application 'grails-spring-security/ldap/examples/functional-test-app/grails-app/ldap-servers/d1/data/users.ldif', // test database that does not support comments @@ -149,9 +160,13 @@ tasks.named('rat') { '**/*.log', // exclude log files 'local-tasks.gradle', // exclude local helper scripts '**/spring-configuration-metadata.json', // JSON files cannot contain license headers - ] + rootProject.subprojects.collect{"${rootProject.projectDir.relativePath(it.layout.buildDirectory.get().asFile).toString()}/**/*" } + ] + rootProject.subprojects.collectMany { project -> + String projectPath = rootProject.projectDir.relativePath(project.projectDir).toString().replace(File.separator, '/') + String buildPath = rootProject.projectDir.relativePath(project.layout.buildDirectory.get().asFile).toString().replace(File.separator, '/') + ["${buildPath}/**/*", "${projectPath}/bin/**/*"] + } // logger.lifecycle("Excludes for RAT task: ${allExcludes.join(', \n')}") excludes = allExcludes // never cache license audits it.outputs.upToDateWhen { false } -} \ No newline at end of file +} diff --git a/grails-data-graphql/core/build.gradle b/grails-data-graphql/core/build.gradle index 9d88322dc59..c916d049f7c 100644 --- a/grails-data-graphql/core/build.gradle +++ b/grails-data-graphql/core/build.gradle @@ -30,6 +30,11 @@ plugins { version = projectVersion group = 'org.apache.grails.data' +grailsCodeAnalysis { + // PMD baseline is clean for this module; keep it blocking. + pmdEnabled = true +} + ext { gormApiDocs = true pomTitle = 'GORM for GraphQL' diff --git a/grails-data-mongodb/spring-data/build.gradle b/grails-data-mongodb/spring-data/build.gradle index d78fc1565e7..c3c0a111c2e 100644 --- a/grails-data-mongodb/spring-data/build.gradle +++ b/grails-data-mongodb/spring-data/build.gradle @@ -32,6 +32,11 @@ plugins { version = projectVersion group = 'org.apache.grails' +grailsCodeAnalysis { + // PMD baseline is clean for this module; keep it blocking. + pmdEnabled = true +} + ext { gormApiDocs = true pomTitle = 'GORM for MongoDB - Spring Data Integration' diff --git a/grails-datasource/build.gradle b/grails-datasource/build.gradle index aadc75b49e5..61313746f68 100644 --- a/grails-datasource/build.gradle +++ b/grails-datasource/build.gradle @@ -34,6 +34,11 @@ plugins { version = projectVersion group = 'org.apache.grails' +grailsCodeAnalysis { + // PMD baseline is clean for this module; keep it blocking. + pmdEnabled = true +} + dependencies { implementation platform(project(':grails-bom')) @@ -76,4 +81,4 @@ dependencies { apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') -} \ No newline at end of file +} diff --git a/grails-profiles/base/build.gradle b/grails-profiles/base/build.gradle index bdbe3f90886..c9c75d7b1a1 100644 --- a/grails-profiles/base/build.gradle +++ b/grails-profiles/base/build.gradle @@ -37,8 +37,10 @@ dependencies { api 'org.apache.groovy:groovy-jmx' // stop script uses GroovyMBean } -TaskProvider copyWrapper = tasks.register('copyGrailsWrapperScripts', Copy) -copyWrapper.configure { Copy copy -> +TaskProvider copyWrapper = tasks.register('copyGrailsWrapperScripts', Sync) +def wrapperFiles = ['grails-wrapper.jar', 'grailsw', 'grailsw.bat'] +def sourceSkeleton = project.layout.projectDirectory.dir('skeleton').asFile +copyWrapper.configure { Sync copy -> Project wrapperProject = rootProject.project(':grails-wrapper') copy.dependsOn(wrapperProject.tasks.named('installDist')) copy.from(wrapperProject.layout.buildDirectory.dir('install/apache-grails-wrapper-bin')) { @@ -46,17 +48,19 @@ copyWrapper.configure { Copy copy -> include '**/grailsw.bat' include '**/grailsw' } - copy.into(project.layout.projectDirectory.dir('skeleton')) + copy.into(project.layout.buildDirectory.dir('generated/grails-wrapper')) } tasks.named('sourcesJar').configure { - it.dependsOn(copyWrapper) + it.exclude { details -> details.file.parentFile == sourceSkeleton && wrapperFiles.contains(details.name) } + it.from(copyWrapper) { spec -> + spec.into('skeleton') + } } tasks.named('processProfileResources').configure { - it.dependsOn(copyWrapper) -} - -tasks.named('compileProfile').configure { - it.dependsOn(copyWrapper) + it.exclude { details -> details.file.parentFile == sourceSkeleton && wrapperFiles.contains(details.name) } + it.from(copyWrapper) { spec -> + spec.into('skeleton') + } } diff --git a/grails-profiles/profile/build.gradle b/grails-profiles/profile/build.gradle index 827eaf49d23..4d936b69445 100644 --- a/grails-profiles/profile/build.gradle +++ b/grails-profiles/profile/build.gradle @@ -34,22 +34,30 @@ dependencies { profileRuntimeApi project(':grails-profiles-base') } -TaskProvider copyWrapper = tasks.register('copyGrailsWrapperScripts', Copy) -copyWrapper.configure { Copy copy -> +TaskProvider copyWrapper = tasks.register('copyGrailsWrapperScripts', Sync) +def wrapperFiles = ['grails-wrapper.jar', 'grailsw', 'grailsw.bat'] +def sourceSkeleton = project.layout.projectDirectory.dir('skeleton').asFile +copyWrapper.configure { Sync copy -> Project wrapperProject = rootProject.project(':grails-wrapper') copy.dependsOn(wrapperProject.tasks.named('installDist')) - copy.from(wrapperProject.layout.buildDirectory.dir('install/grails-wrapper')) - copy.into(project.layout.projectDirectory.dir('skeleton')) + copy.from(wrapperProject.layout.buildDirectory.dir('install/apache-grails-wrapper-bin')) { + include '**/grails-wrapper.jar' + include '**/grailsw.bat' + include '**/grailsw' + } + copy.into(project.layout.buildDirectory.dir('generated/grails-wrapper')) } tasks.named('sourcesJar').configure { - it.dependsOn(copyWrapper) + it.exclude { details -> details.file.parentFile == sourceSkeleton && wrapperFiles.contains(details.name) } + it.from(copyWrapper) { spec -> + spec.into('skeleton') + } } tasks.named('processProfileResources').configure { - it.dependsOn(copyWrapper) -} - -tasks.named('compileProfile').configure { - it.dependsOn(copyWrapper) + it.exclude { details -> details.file.parentFile == sourceSkeleton && wrapperFiles.contains(details.name) } + it.from(copyWrapper) { spec -> + spec.into('skeleton') + } } 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 diff --git a/grails-test-examples/mail/src/integration-test/groovy/grails/plugins/mail/MailServiceSpec.groovy b/grails-test-examples/mail/src/integration-test/groovy/grails/plugins/mail/MailServiceSpec.groovy index 56259d7aaa8..d80bc862c0f 100644 --- a/grails-test-examples/mail/src/integration-test/groovy/grails/plugins/mail/MailServiceSpec.groovy +++ b/grails-test-examples/mail/src/integration-test/groovy/grails/plugins/mail/MailServiceSpec.groovy @@ -740,7 +740,7 @@ class MailServiceSpec extends Specification { } then: 'the message should have the correct content' - message.text == "Hello\nWorld!" + message.text.replace('\r\n', '\n') == "Hello\nWorld!" where: view << ['/_testemails/newLineTest', '/_testemails/newLineTagTest'] diff --git a/grails-testing-support-core/build.gradle b/grails-testing-support-core/build.gradle index f0ab23baf88..ae2f97bd766 100644 --- a/grails-testing-support-core/build.gradle +++ b/grails-testing-support-core/build.gradle @@ -34,6 +34,11 @@ plugins { version = projectVersion group = 'org.apache.grails.testing' +grailsCodeAnalysis { + // PMD baseline is clean for this module; keep it blocking. + pmdEnabled = true +} + ext { pomDescription = 'Support for writing concise expressive tests for Grails artifacts' } @@ -79,4 +84,4 @@ dependencies { apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') -} \ No newline at end of file +}