From 15c7420731eea25aff23f02975918918dbe106a0 Mon Sep 17 00:00:00 2001 From: Sanjana Date: Sat, 18 Apr 2026 11:22:31 +0530 Subject: [PATCH 1/4] docs: fix broken external and internal links in Groovydocs and add audit mode --- .../doc/gradle/FixGroovydocLinksTask.groovy | 119 ++++++++++++++++++ gradle/docs-dependencies.gradle | 37 ++++-- grails-doc/build.gradle | 9 +- 3 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/FixGroovydocLinksTask.groovy diff --git a/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/FixGroovydocLinksTask.groovy b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/FixGroovydocLinksTask.groovy new file mode 100644 index 00000000000..cc9d9597789 --- /dev/null +++ b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/FixGroovydocLinksTask.groovy @@ -0,0 +1,119 @@ +/* + * 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.grails.doc.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.TaskAction +import java.nio.file.* +import java.util.regex.Matcher +import java.util.regex.Pattern + +/** + * A task that repairs malformed navigation links in generated Groovydocs. + * Specifically targets 'phantom' links and relative path issues in navigation files + * like overview-summary.html, deprecated-list.html, and help-doc.html. + */ +abstract class FixGroovydocLinksTask extends DefaultTask { + + /** + * If true, the task will fail the build if any malformed links are found. + * If false, the task will attempt to repair the links (patching mode). + */ + @org.gradle.api.tasks.Input + boolean auditMode = false + + @InputDirectory + abstract DirectoryProperty getApiDocsDir() + + @TaskAction + void fixLinks() { + File apiDir = apiDocsDir.get().asFile + if (!apiDir.exists()) { + logger.warn "API documentation directory does not exist: ${apiDir.absolutePath}" + return + } + + // Patterns common to Groovydoc navigation failures + Map replacements = [ + (Pattern.compile(/href='([^']+?)\/deprecated-list\.html'/)): "href='deprecated-list.html'", + (Pattern.compile(/href='([^']+?)\/help-doc\.html'/)): "href='help-doc.html'", + (Pattern.compile(/href='([^']+?)\/index-all\.html'/)): "href='index-all.html'", + (Pattern.compile(/href='([^']+?)\/overview-summary\.html'/)): "href='overview-summary.html'" + ] + + int totalFixes = 0 + List violations = [] + + apiDir.eachFileRecurse { File file -> + if (file.name.endsWith(".html")) { + String content = file.text + boolean changed = false + + replacements.each { pattern, replacement -> + Matcher matcher = pattern.matcher(content) + if (matcher.find()) { + content = matcher.replaceAll(replacement) + changed = true + violations << "Malformed nav link in ${file.name}" + } + } + + // Fix specific inner class path issues like Query/Order.Direction.html -> Query.Order.Direction.html + Pattern innerClassPattern = Pattern.compile(/href='([^']+?)\/([A-Z][A-Za-z0-9_]*?)\/([A-Z][A-Za-z0-9_.]*?\.html)'/) + Matcher innerMatcher = innerClassPattern.matcher(content) + while (innerMatcher.find()) { + String relPath = innerMatcher.group(1) + String outer = innerMatcher.group(2) + String inner = innerMatcher.group(3) + + Path currentPath = file.toPath().parent + Path targetPath = currentPath.resolve(relPath).resolve("${outer}.${inner}").normalize() + + if (Files.exists(targetPath)) { + String newHref = "href='${relPath}/${outer}.${inner}'" + content = content.replace(innerMatcher.group(0), newHref) + changed = true + violations << "Malformed inner class link in ${file.name}: ${innerMatcher.group(0)} -> ${newHref}" + } + } + + if (changed) { + totalFixes++ + if (!auditMode) { + file.text = content + } + } + } + } + + if (totalFixes > 0) { + if (auditMode) { + violations.take(10).each { logger.error(it) } + if (violations.size() > 10) logger.error("... and ${violations.size() - 10} more") + throw new org.gradle.api.GradleException("Found ${violations.size()} malformed links in Groovydoc. Please fix the source issue rather than patching. See logs for details.") + } else { + logger.lifecycle "Repaired ${totalFixes} HTML files in ${apiDir.absolutePath}" + } + } else { + logger.lifecycle "No malformed Groovydoc links found in ${apiDir.absolutePath}" + } + } +} diff --git a/gradle/docs-dependencies.gradle b/gradle/docs-dependencies.gradle index fd33607c98f..a032a7cc43d 100644 --- a/gradle/docs-dependencies.gradle +++ b/gradle/docs-dependencies.gradle @@ -31,16 +31,10 @@ dependencies { } String resolveProjectVersion(String artifact) { - String version = configurations.runtimeClasspath - .resolvedConfiguration - .resolvedArtifacts - .find { - it.moduleVersion.id.name == artifact - }?.moduleVersion?.id?.version - if (!version) { - return null + def component = configurations.runtimeClasspath.incoming.resolutionResult.allComponents.find { + it.moduleVersion?.name == artifact } - version + return component?.moduleVersion?.version } def configureGroovyDoc = tasks.register('configureGroovyDoc') { @@ -56,12 +50,35 @@ def configureGroovyDoc = tasks.register('configureGroovyDoc') { } def springVersion = resolveProjectVersion('spring-core') if (springVersion) { - links << [packages: 'org.springframework.core.', href: "https://docs.spring.io/spring-framework/docs/${springVersion}/javadoc-api/"] + links << [packages: 'org.springframework.', href: "https://docs.spring.io/spring-framework/docs/${springVersion}/javadoc-api/"] } def springBootVersion = resolveProjectVersion('spring-boot') if (springBootVersion) { links << [packages: 'org.springframework.boot.', href: "https://docs.spring.io/spring-boot/docs/${springBootVersion}/api/"] } + def hibernateVersion = resolveProjectVersion('hibernate-core') + if (hibernateVersion) { + def shortVersion = hibernateVersion.split('\\.').take(2).join('.') + links << [packages: 'org.hibernate.', href: "https://docs.jboss.org/hibernate/orm/${shortVersion}/javadocs/"] + } + def jakartaValidationVersion = resolveProjectVersion('jakarta.validation-api') + if (jakartaValidationVersion) { + links << [packages: 'jakarta.validation.', href: "https://jakarta.ee/specifications/bean-validation/3.0/apidocs/"] + } + def jakartaPersistenceVersion = resolveProjectVersion('jakarta.persistence-api') + if (jakartaPersistenceVersion) { + links << [packages: 'jakarta.persistence.', href: "https://jakarta.ee/specifications/persistence/3.1/apidocs/"] + } + def jakartaServletVersion = resolveProjectVersion('jakarta.servlet-api') + if (jakartaServletVersion) { + links << [packages: 'jakarta.servlet.', href: "https://jakarta.ee/specifications/platform/10/apidocs/"] + } + links << [packages: 'org.grails.datastore.', href: "https://gorm.grails.org/latest/api/"] + links << [packages: 'grails.gorm.', href: "https://gorm.grails.org/latest/api/"] + links << [packages: 'org.grails.gorm.', href: "https://gorm.grails.org/latest/api/"] + links << [packages: 'groovy.', href: "https://docs.groovy-lang.org/latest/html/gapi/"] + links << [packages: 'org.apache.groovy.', href: "https://docs.groovy-lang.org/latest/html/gapi/"] + links << [packages: 'org.codehaus.groovy.', href: "https://docs.groovy-lang.org/latest/html/gapi/"] if (it.ext.has('groovydocLinks')) { links.addAll(it.ext.groovydocLinks as List>) } diff --git a/grails-doc/build.gradle b/grails-doc/build.gradle index d7a5cf71b09..0bb7904fbc1 100644 --- a/grails-doc/build.gradle +++ b/grails-doc/build.gradle @@ -21,6 +21,7 @@ import grails.doc.git.FetchTagsTask import grails.doc.dropdown.CreateReleaseDropDownTask import grails.doc.gradle.PublishGuideTask import groovy.json.JsonSlurper +import org.grails.doc.gradle.FixGroovydocLinksTask import java.util.zip.ZipFile @@ -125,6 +126,12 @@ combinedGroovydoc.configure { Groovydoc gdoc -> gdoc.outputs.dir(gdoc.destinationDir) } +def fixGroovydocLinks = tasks.register('fixGroovydocLinks', FixGroovydocLinksTask) { + dependsOn combinedGroovydoc + apiDocsDir = combinedGroovydoc.get().destinationDir + auditMode = project.hasProperty('auditGroovydocLinks') +} + String getVersion(String artifact) { String version = configurations.runtimeClasspath .resolvedConfiguration @@ -654,7 +661,7 @@ createReleaseDropdownTask.configure { def docsTask = tasks.register('docs', Sync) docsTask.configure { Sync it -> - it.dependsOn(combinedGroovydoc, createReleaseDropdownTask, ':grails-data-docs-stage:docs', ':grails-data-docs-stage:groovydoc') + it.dependsOn(combinedGroovydoc, fixGroovydocLinks, createReleaseDropdownTask, ':grails-data-docs-stage:docs', ':grails-data-docs-stage:groovydoc') it.group = 'documentation' def manualDocsDir = project.layout.buildDirectory.dir('modified-guide') From c0bc0ef20cfba5c05ee2a6a8669de81be7781272 Mon Sep 17 00:00:00 2001 From: Sanjana Date: Wed, 22 Apr 2026 01:39:40 +0530 Subject: [PATCH 2/4] refactor: rename groovydoc link check to audit task and remove property control --- ....groovy => AuditGroovydocLinksTask.groovy} | 45 +++++-------------- grails-doc/build.gradle | 7 ++- 2 files changed, 13 insertions(+), 39 deletions(-) rename build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/{FixGroovydocLinksTask.groovy => AuditGroovydocLinksTask.groovy} (67%) diff --git a/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/FixGroovydocLinksTask.groovy b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/AuditGroovydocLinksTask.groovy similarity index 67% rename from build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/FixGroovydocLinksTask.groovy rename to build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/AuditGroovydocLinksTask.groovy index cc9d9597789..f4a567e9252 100644 --- a/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/FixGroovydocLinksTask.groovy +++ b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/AuditGroovydocLinksTask.groovy @@ -27,24 +27,17 @@ import java.util.regex.Matcher import java.util.regex.Pattern /** - * A task that repairs malformed navigation links in generated Groovydocs. + * A task that audits generated Groovydocs for malformed navigation links. * Specifically targets 'phantom' links and relative path issues in navigation files * like overview-summary.html, deprecated-list.html, and help-doc.html. */ -abstract class FixGroovydocLinksTask extends DefaultTask { - - /** - * If true, the task will fail the build if any malformed links are found. - * If false, the task will attempt to repair the links (patching mode). - */ - @org.gradle.api.tasks.Input - boolean auditMode = false +abstract class AuditGroovydocLinksTask extends DefaultTask { @InputDirectory abstract DirectoryProperty getApiDocsDir() @TaskAction - void fixLinks() { + void auditLinks() { File apiDir = apiDocsDir.get().asFile if (!apiDir.exists()) { logger.warn "API documentation directory does not exist: ${apiDir.absolutePath}" @@ -59,24 +52,20 @@ abstract class FixGroovydocLinksTask extends DefaultTask { (Pattern.compile(/href='([^']+?)\/overview-summary\.html'/)): "href='overview-summary.html'" ] - int totalFixes = 0 List violations = [] apiDir.eachFileRecurse { File file -> if (file.name.endsWith(".html")) { String content = file.text - boolean changed = false replacements.each { pattern, replacement -> Matcher matcher = pattern.matcher(content) if (matcher.find()) { - content = matcher.replaceAll(replacement) - changed = true - violations << "Malformed nav link in ${file.name}" + violations << "Malformed nav link in ${file.name}: ${matcher.group(0)}" } } - // Fix specific inner class path issues like Query/Order.Direction.html -> Query.Order.Direction.html + // Check specific inner class path issues like Query/Order.Direction.html -> Query.Order.Direction.html Pattern innerClassPattern = Pattern.compile(/href='([^']+?)\/([A-Z][A-Za-z0-9_]*?)\/([A-Z][A-Za-z0-9_.]*?\.html)'/) Matcher innerMatcher = innerClassPattern.matcher(content) while (innerMatcher.find()) { @@ -88,30 +77,16 @@ abstract class FixGroovydocLinksTask extends DefaultTask { Path targetPath = currentPath.resolve(relPath).resolve("${outer}.${inner}").normalize() if (Files.exists(targetPath)) { - String newHref = "href='${relPath}/${outer}.${inner}'" - content = content.replace(innerMatcher.group(0), newHref) - changed = true - violations << "Malformed inner class link in ${file.name}: ${innerMatcher.group(0)} -> ${newHref}" - } - } - - if (changed) { - totalFixes++ - if (!auditMode) { - file.text = content + violations << "Malformed inner class link in ${file.name}: ${innerMatcher.group(0)}" } } } } - if (totalFixes > 0) { - if (auditMode) { - violations.take(10).each { logger.error(it) } - if (violations.size() > 10) logger.error("... and ${violations.size() - 10} more") - throw new org.gradle.api.GradleException("Found ${violations.size()} malformed links in Groovydoc. Please fix the source issue rather than patching. See logs for details.") - } else { - logger.lifecycle "Repaired ${totalFixes} HTML files in ${apiDir.absolutePath}" - } + if (!violations.isEmpty()) { + violations.take(10).each { logger.error(it) } + if (violations.size() > 10) logger.error("... and ${violations.size() - 10} more") + throw new org.gradle.api.GradleException("Found ${violations.size()} malformed links in Groovydoc. Please fix the source issue rather than patching. See logs for details.") } else { logger.lifecycle "No malformed Groovydoc links found in ${apiDir.absolutePath}" } diff --git a/grails-doc/build.gradle b/grails-doc/build.gradle index 0bb7904fbc1..ddba680a449 100644 --- a/grails-doc/build.gradle +++ b/grails-doc/build.gradle @@ -21,7 +21,7 @@ import grails.doc.git.FetchTagsTask import grails.doc.dropdown.CreateReleaseDropDownTask import grails.doc.gradle.PublishGuideTask import groovy.json.JsonSlurper -import org.grails.doc.gradle.FixGroovydocLinksTask +import org.grails.doc.gradle.AuditGroovydocLinksTask import java.util.zip.ZipFile @@ -126,10 +126,9 @@ combinedGroovydoc.configure { Groovydoc gdoc -> gdoc.outputs.dir(gdoc.destinationDir) } -def fixGroovydocLinks = tasks.register('fixGroovydocLinks', FixGroovydocLinksTask) { +def auditGroovydocLinks = tasks.register('auditGroovydocLinks', AuditGroovydocLinksTask) { dependsOn combinedGroovydoc apiDocsDir = combinedGroovydoc.get().destinationDir - auditMode = project.hasProperty('auditGroovydocLinks') } String getVersion(String artifact) { @@ -661,7 +660,7 @@ createReleaseDropdownTask.configure { def docsTask = tasks.register('docs', Sync) docsTask.configure { Sync it -> - it.dependsOn(combinedGroovydoc, fixGroovydocLinks, createReleaseDropdownTask, ':grails-data-docs-stage:docs', ':grails-data-docs-stage:groovydoc') + it.dependsOn(combinedGroovydoc, auditGroovydocLinks, createReleaseDropdownTask, ':grails-data-docs-stage:docs', ':grails-data-docs-stage:groovydoc') it.group = 'documentation' def manualDocsDir = project.layout.buildDirectory.dir('modified-guide') From 06caab35512177b3c5e84d4a263fb428fa818629 Mon Sep 17 00:00:00 2001 From: Walter Duque de Estrada Date: Fri, 17 Jul 2026 16:40:29 -0500 Subject: [PATCH 3/4] Decouple auditGroovydocLinks from the default docs build and add test coverage jdaugherty flagged that wiring auditGroovydocLinks as an unconditional dependency of the docs task would fail the build the moment any violation is found - and the author's own investigation on this PR noted ~30 residual "phantom" link reports that aren't real errors, so merging as-is would have broken every future docs build. Keep the task registered and runnable on demand (./gradlew :grails-doc:auditGroovydocLinks) without wiring it into docsTask.dependsOn. Also extract the violation-detection logic into a Gradle-API-free GroovydocLinkAuditor so it's unit testable - build-logic/docs-core deliberately keeps Gradle classes off the test compile classpath (see docs-core/build.gradle), so the previous DefaultTask-only implementation had no path to test coverage. Co-Authored-By: Claude Sonnet 5 --- .../doc/gradle/AuditGroovydocLinksTask.groovy | 42 +-------- .../doc/gradle/GroovydocLinkAuditor.groovy | 85 +++++++++++++++++++ .../gradle/GroovydocLinkAuditorSpec.groovy | 82 ++++++++++++++++++ grails-doc/build.gradle | 2 +- 4 files changed, 169 insertions(+), 42 deletions(-) create mode 100644 build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/GroovydocLinkAuditor.groovy create mode 100644 build-logic/docs-core/src/test/groovy/org/grails/doc/gradle/GroovydocLinkAuditorSpec.groovy diff --git a/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/AuditGroovydocLinksTask.groovy b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/AuditGroovydocLinksTask.groovy index f4a567e9252..b809bf81ef1 100644 --- a/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/AuditGroovydocLinksTask.groovy +++ b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/AuditGroovydocLinksTask.groovy @@ -22,9 +22,6 @@ import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.TaskAction -import java.nio.file.* -import java.util.regex.Matcher -import java.util.regex.Pattern /** * A task that audits generated Groovydocs for malformed navigation links. @@ -44,45 +41,8 @@ abstract class AuditGroovydocLinksTask extends DefaultTask { return } - // Patterns common to Groovydoc navigation failures - Map replacements = [ - (Pattern.compile(/href='([^']+?)\/deprecated-list\.html'/)): "href='deprecated-list.html'", - (Pattern.compile(/href='([^']+?)\/help-doc\.html'/)): "href='help-doc.html'", - (Pattern.compile(/href='([^']+?)\/index-all\.html'/)): "href='index-all.html'", - (Pattern.compile(/href='([^']+?)\/overview-summary\.html'/)): "href='overview-summary.html'" - ] + List violations = GroovydocLinkAuditor.findViolations(apiDir) - List violations = [] - - apiDir.eachFileRecurse { File file -> - if (file.name.endsWith(".html")) { - String content = file.text - - replacements.each { pattern, replacement -> - Matcher matcher = pattern.matcher(content) - if (matcher.find()) { - violations << "Malformed nav link in ${file.name}: ${matcher.group(0)}" - } - } - - // Check specific inner class path issues like Query/Order.Direction.html -> Query.Order.Direction.html - Pattern innerClassPattern = Pattern.compile(/href='([^']+?)\/([A-Z][A-Za-z0-9_]*?)\/([A-Z][A-Za-z0-9_.]*?\.html)'/) - Matcher innerMatcher = innerClassPattern.matcher(content) - while (innerMatcher.find()) { - String relPath = innerMatcher.group(1) - String outer = innerMatcher.group(2) - String inner = innerMatcher.group(3) - - Path currentPath = file.toPath().parent - Path targetPath = currentPath.resolve(relPath).resolve("${outer}.${inner}").normalize() - - if (Files.exists(targetPath)) { - violations << "Malformed inner class link in ${file.name}: ${innerMatcher.group(0)}" - } - } - } - } - if (!violations.isEmpty()) { violations.take(10).each { logger.error(it) } if (violations.size() > 10) logger.error("... and ${violations.size() - 10} more") diff --git a/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/GroovydocLinkAuditor.groovy b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/GroovydocLinkAuditor.groovy new file mode 100644 index 00000000000..9a79823be08 --- /dev/null +++ b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/GroovydocLinkAuditor.groovy @@ -0,0 +1,85 @@ +/* + * 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.grails.doc.gradle + +import java.nio.file.Files +import java.nio.file.Path +import java.util.regex.Matcher +import java.util.regex.Pattern + +/** + * Scans generated Groovydoc HTML for malformed navigation and inner-class + * links. Kept free of the Gradle API so it can be unit tested directly - + * {@code build-logic/docs-core} deliberately keeps Gradle classes off the + * test compile classpath. + */ +class GroovydocLinkAuditor { + + // Patterns common to Groovydoc navigation failures + private static final Map NAV_LINK_PATTERNS = [ + (Pattern.compile(/href='([^']+?)\/deprecated-list\.html'/)): "href='deprecated-list.html'", + (Pattern.compile(/href='([^']+?)\/help-doc\.html'/)): "href='help-doc.html'", + (Pattern.compile(/href='([^']+?)\/index-all\.html'/)): "href='index-all.html'", + (Pattern.compile(/href='([^']+?)\/overview-summary\.html'/)): "href='overview-summary.html'" + ].asImmutable() + + // Inner class path issues like Query/Order.Direction.html -> Query.Order.Direction.html + private static final Pattern INNER_CLASS_LINK_PATTERN = + Pattern.compile(/href='([^']+?)\/([A-Z][A-Za-z0-9_]*?)\/([A-Z][A-Za-z0-9_.]*?\.html)'/) + + static List findViolations(File apiDir) { + List violations = [] + + apiDir.eachFileRecurse { File file -> + if (file.name.endsWith('.html')) { + violations.addAll(findViolationsInFile(file)) + } + } + + violations + } + + private static List findViolationsInFile(File file) { + List violations = [] + String content = file.text + + NAV_LINK_PATTERNS.each { Pattern pattern, String replacement -> + Matcher matcher = pattern.matcher(content) + if (matcher.find()) { + violations << "Malformed nav link in ${file.name}: ${matcher.group(0)}".toString() + } + } + + Matcher innerMatcher = INNER_CLASS_LINK_PATTERN.matcher(content) + while (innerMatcher.find()) { + String relPath = innerMatcher.group(1) + String outer = innerMatcher.group(2) + String inner = innerMatcher.group(3) + + Path currentPath = file.toPath().parent + Path targetPath = currentPath.resolve(relPath).resolve("${outer}.${inner}").normalize() + + if (Files.exists(targetPath)) { + violations << "Malformed inner class link in ${file.name}: ${innerMatcher.group(0)}".toString() + } + } + + violations + } +} diff --git a/build-logic/docs-core/src/test/groovy/org/grails/doc/gradle/GroovydocLinkAuditorSpec.groovy b/build-logic/docs-core/src/test/groovy/org/grails/doc/gradle/GroovydocLinkAuditorSpec.groovy new file mode 100644 index 00000000000..1a3cea65846 --- /dev/null +++ b/build-logic/docs-core/src/test/groovy/org/grails/doc/gradle/GroovydocLinkAuditorSpec.groovy @@ -0,0 +1,82 @@ +/* + * 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.grails.doc.gradle + +import spock.lang.Specification +import spock.lang.TempDir + +class GroovydocLinkAuditorSpec extends Specification { + + @TempDir + File apiDir + + void "reports no violations for clean navigation and inner class links"() { + given: + writeHtml('index.html', """ + Deprecated + Help + SomeClass + """) + + expect: + GroovydocLinkAuditor.findViolations(apiDir).empty + } + + void "flags a navigation link that is nested under a relative path prefix"() { + given: + writeHtml('index.html', "Deprecated") + + when: + List violations = GroovydocLinkAuditor.findViolations(apiDir) + + then: + violations.size() == 1 + violations[0].contains('deprecated-list.html') + violations[0].contains('index.html') + } + + void "flags an inner class link using a slash-separated path when the dotted file actually exists"() { + given: 'the real generated file uses the dotted Groovydoc naming convention' + writeHtml('Query.Order.Direction.html', '

Direction

') + and: 'another page links to it using a malformed slash-separated path' + writeHtml('index.html', "Direction") + + when: + List violations = GroovydocLinkAuditor.findViolations(apiDir) + + then: + violations.size() == 1 + violations[0].contains('Query/Order.Direction.html') + } + + void "does not flag a slash-separated inner class link when no dotted file exists to resolve to"() { + given: 'the referenced target was never generated, so this cannot be the known malformed-path case' + writeHtml('index.html', "Direction") + + expect: + GroovydocLinkAuditor.findViolations(apiDir).empty + } + + private File writeHtml(String relativePath, String content) { + File file = new File(apiDir, relativePath) + file.parentFile.mkdirs() + file.text = content + file + } +} diff --git a/grails-doc/build.gradle b/grails-doc/build.gradle index ddba680a449..8d4991992f0 100644 --- a/grails-doc/build.gradle +++ b/grails-doc/build.gradle @@ -660,7 +660,7 @@ createReleaseDropdownTask.configure { def docsTask = tasks.register('docs', Sync) docsTask.configure { Sync it -> - it.dependsOn(combinedGroovydoc, auditGroovydocLinks, createReleaseDropdownTask, ':grails-data-docs-stage:docs', ':grails-data-docs-stage:groovydoc') + it.dependsOn(combinedGroovydoc, createReleaseDropdownTask, ':grails-data-docs-stage:docs', ':grails-data-docs-stage:groovydoc') it.group = 'documentation' def manualDocsDir = project.layout.buildDirectory.dir('modified-guide') From 4912fc36236f75a64b7ba25ead3888501f45a186 Mon Sep 17 00:00:00 2001 From: Walter Duque de Estrada Date: Mon, 27 Jul 2026 12:09:39 -0500 Subject: [PATCH 4/4] Address jdaugherty and Copilot review feedback on groovydoc link audit Fixes the CHANGES_REQUESTED review comments left unaddressed on this PR: - Wire auditGroovydocLinks.apiDocsDir via Provider (project.layout.dir( combinedGroovydoc.map { it.destinationDir })) instead of an eager combinedGroovydoc.get(), so the dependency is implicit and the task isn't realized during configuration. - Drop the NAV_LINK_PATTERNS check entirely rather than reworking it: those patterns never matched real (double-quoted) Groovydoc output, so "0 violations" reflected dead code, not clean docs. Reworking it to flag unresolved targets was tried and reverted after verifying against real generated output - groovy-groovydoc's package-summary template omits the relative-root prefix on its bottom nav bar for every package, which would flag ~330 instances of that one known tool quirk per module. The inner-class check (the genuinely valid part, per review) is unaffected and still the sole detector. - Remove the AuditGroovydocLinksTask directory-exists guard: it's dead code, since @InputDirectory already fails task validation before @TaskAction runs if the directory is missing. - Import GradleException instead of using the inline FQCN. - Fix the org.springframework[.boot]. link ordering: Groovydoc resolves links first-match-wins, so the broader entry sitting before the more specific one was making every Spring Boot link resolve against the Framework javadocs, where the Boot classes don't exist. - Derive the Jakarta spec/platform versions from the resolved artifact versions instead of hardcoding them - the servlet link was already stale on this branch (hardcoded platform/10, but 7.0.x resolves servlet-api 6.1.0, i.e. platform 11). - Derive the Groovy javadoc link from the resolved groovy version instead of "latest" (which serves Groovy 5 docs; 7.0.x ships 4.0.32). - Remove the org.grails.datastore./grails.gorm./org.grails.gorm. external link mappings: those classes are generated directly in this repo's own aggregate Groovydoc since the data modules merged in 7.0, so mapping them externally to gorm.grails.org misdirects local links. Verified the corrected GroovydocLinkAuditor against real generated Groovydoc output (grails-core module and the full grails-doc aggregate combinedGroovydoc): 0 violations, with the fixed link-order/version values confirmed present in the resolved links list. Co-Authored-By: Claude Sonnet 5 --- .../doc/gradle/AuditGroovydocLinksTask.groovy | 15 +++---- .../doc/gradle/GroovydocLinkAuditor.groovy | 36 +++++++--------- .../gradle/GroovydocLinkAuditorSpec.groovy | 19 ++------ gradle/docs-dependencies.gradle | 43 +++++++++++++------ grails-doc/build.gradle | 3 +- 5 files changed, 56 insertions(+), 60 deletions(-) diff --git a/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/AuditGroovydocLinksTask.groovy b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/AuditGroovydocLinksTask.groovy index b809bf81ef1..0fd1770b982 100644 --- a/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/AuditGroovydocLinksTask.groovy +++ b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/AuditGroovydocLinksTask.groovy @@ -19,14 +19,17 @@ package org.grails.doc.gradle import org.gradle.api.DefaultTask +import org.gradle.api.GradleException import org.gradle.api.file.DirectoryProperty import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.TaskAction /** - * A task that audits generated Groovydocs for malformed navigation links. - * Specifically targets 'phantom' links and relative path issues in navigation files - * like overview-summary.html, deprecated-list.html, and help-doc.html. + * A task that audits generated Groovydocs for malformed inner-class links, where a + * slash-separated 'phantom' path (e.g. {@code Outer/Inner.html}) was emitted instead + * of the dotted Groovydoc naming convention ({@code Outer.Inner.html}). + * + * @see GroovydocLinkAuditor */ abstract class AuditGroovydocLinksTask extends DefaultTask { @@ -36,17 +39,13 @@ abstract class AuditGroovydocLinksTask extends DefaultTask { @TaskAction void auditLinks() { File apiDir = apiDocsDir.get().asFile - if (!apiDir.exists()) { - logger.warn "API documentation directory does not exist: ${apiDir.absolutePath}" - return - } List violations = GroovydocLinkAuditor.findViolations(apiDir) if (!violations.isEmpty()) { violations.take(10).each { logger.error(it) } if (violations.size() > 10) logger.error("... and ${violations.size() - 10} more") - throw new org.gradle.api.GradleException("Found ${violations.size()} malformed links in Groovydoc. Please fix the source issue rather than patching. See logs for details.") + throw new GradleException("Found ${violations.size()} malformed links in Groovydoc. Please fix the source issue rather than patching. See logs for details.") } else { logger.lifecycle "No malformed Groovydoc links found in ${apiDir.absolutePath}" } diff --git a/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/GroovydocLinkAuditor.groovy b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/GroovydocLinkAuditor.groovy index 9a79823be08..54c1340f6bc 100644 --- a/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/GroovydocLinkAuditor.groovy +++ b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/GroovydocLinkAuditor.groovy @@ -24,22 +24,23 @@ import java.util.regex.Matcher import java.util.regex.Pattern /** - * Scans generated Groovydoc HTML for malformed navigation and inner-class - * links. Kept free of the Gradle API so it can be unit tested directly - - * {@code build-logic/docs-core} deliberately keeps Gradle classes off the - * test compile classpath. + * Scans generated Groovydoc HTML for malformed inner-class links, where a + * slash-separated path segment (produced for an inner class, e.g. + * {@code Outer/Inner.html}) should have been the dotted Groovydoc naming + * convention ({@code Outer.Inner.html}). Kept free of the Gradle API so it + * can be unit tested directly - {@code build-logic/docs-core} deliberately + * keeps Gradle classes off the test compile classpath. + * + *

This intentionally does not audit the navigation links (deprecated-list.html, + * help-doc.html, etc.): the bottom nav bar that {@code groovy-groovydoc} emits on + * package-summary pages omits the relative-root prefix that every other occurrence + * carries, which is a pre-existing quirk of that template rather than a content + * issue this repository can fix - checking for it would flag hundreds of instances + * of the same known, low-impact tool behavior on every doc build.

*/ class GroovydocLinkAuditor { - // Patterns common to Groovydoc navigation failures - private static final Map NAV_LINK_PATTERNS = [ - (Pattern.compile(/href='([^']+?)\/deprecated-list\.html'/)): "href='deprecated-list.html'", - (Pattern.compile(/href='([^']+?)\/help-doc\.html'/)): "href='help-doc.html'", - (Pattern.compile(/href='([^']+?)\/index-all\.html'/)): "href='index-all.html'", - (Pattern.compile(/href='([^']+?)\/overview-summary\.html'/)): "href='overview-summary.html'" - ].asImmutable() - - // Inner class path issues like Query/Order.Direction.html -> Query.Order.Direction.html + // Inner class path issues like Query/Order.Direction.html -> Query.Order.Direction.html. private static final Pattern INNER_CLASS_LINK_PATTERN = Pattern.compile(/href='([^']+?)\/([A-Z][A-Za-z0-9_]*?)\/([A-Z][A-Za-z0-9_.]*?\.html)'/) @@ -58,13 +59,7 @@ class GroovydocLinkAuditor { private static List findViolationsInFile(File file) { List violations = [] String content = file.text - - NAV_LINK_PATTERNS.each { Pattern pattern, String replacement -> - Matcher matcher = pattern.matcher(content) - if (matcher.find()) { - violations << "Malformed nav link in ${file.name}: ${matcher.group(0)}".toString() - } - } + Path currentPath = file.toPath().parent Matcher innerMatcher = INNER_CLASS_LINK_PATTERN.matcher(content) while (innerMatcher.find()) { @@ -72,7 +67,6 @@ class GroovydocLinkAuditor { String outer = innerMatcher.group(2) String inner = innerMatcher.group(3) - Path currentPath = file.toPath().parent Path targetPath = currentPath.resolve(relPath).resolve("${outer}.${inner}").normalize() if (Files.exists(targetPath)) { diff --git a/build-logic/docs-core/src/test/groovy/org/grails/doc/gradle/GroovydocLinkAuditorSpec.groovy b/build-logic/docs-core/src/test/groovy/org/grails/doc/gradle/GroovydocLinkAuditorSpec.groovy index 1a3cea65846..90555ce49e7 100644 --- a/build-logic/docs-core/src/test/groovy/org/grails/doc/gradle/GroovydocLinkAuditorSpec.groovy +++ b/build-logic/docs-core/src/test/groovy/org/grails/doc/gradle/GroovydocLinkAuditorSpec.groovy @@ -26,11 +26,11 @@ class GroovydocLinkAuditorSpec extends Specification { @TempDir File apiDir - void "reports no violations for clean navigation and inner class links"() { + void "reports no violations for clean inner class links"() { given: writeHtml('index.html', """ - Deprecated - Help + Deprecated + Help SomeClass """) @@ -38,19 +38,6 @@ class GroovydocLinkAuditorSpec extends Specification { GroovydocLinkAuditor.findViolations(apiDir).empty } - void "flags a navigation link that is nested under a relative path prefix"() { - given: - writeHtml('index.html', "Deprecated") - - when: - List violations = GroovydocLinkAuditor.findViolations(apiDir) - - then: - violations.size() == 1 - violations[0].contains('deprecated-list.html') - violations[0].contains('index.html') - } - void "flags an inner class link using a slash-separated path when the dotted file actually exists"() { given: 'the real generated file uses the dotted Groovydoc naming convention' writeHtml('Query.Order.Direction.html', '

Direction

') diff --git a/gradle/docs-dependencies.gradle b/gradle/docs-dependencies.gradle index a032a7cc43d..1cd98457277 100644 --- a/gradle/docs-dependencies.gradle +++ b/gradle/docs-dependencies.gradle @@ -48,14 +48,18 @@ def configureGroovyDoc = tasks.register('configureGroovyDoc') { if (testContainersVersion) { links << [packages: 'org.testcontainers.', href: "https://javadoc.io/doc/org.testcontainers/testcontainers/${testContainersVersion}/"] } - def springVersion = resolveProjectVersion('spring-core') - if (springVersion) { - links << [packages: 'org.springframework.', href: "https://docs.spring.io/spring-framework/docs/${springVersion}/javadoc-api/"] - } + // Groovydoc resolves 'links' first-match-wins in declaration order, so the more + // specific org.springframework.boot. entry must come before the broader + // org.springframework. entry or every Boot class link resolves against the + // Framework javadocs instead, where the Boot classes don't exist. def springBootVersion = resolveProjectVersion('spring-boot') if (springBootVersion) { links << [packages: 'org.springframework.boot.', href: "https://docs.spring.io/spring-boot/docs/${springBootVersion}/api/"] } + def springVersion = resolveProjectVersion('spring-core') + if (springVersion) { + links << [packages: 'org.springframework.', href: "https://docs.spring.io/spring-framework/docs/${springVersion}/javadoc-api/"] + } def hibernateVersion = resolveProjectVersion('hibernate-core') if (hibernateVersion) { def shortVersion = hibernateVersion.split('\\.').take(2).join('.') @@ -63,22 +67,35 @@ def configureGroovyDoc = tasks.register('configureGroovyDoc') { } def jakartaValidationVersion = resolveProjectVersion('jakarta.validation-api') if (jakartaValidationVersion) { - links << [packages: 'jakarta.validation.', href: "https://jakarta.ee/specifications/bean-validation/3.0/apidocs/"] + def specVersion = jakartaValidationVersion.split('\\.').take(2).join('.') + links << [packages: 'jakarta.validation.', href: "https://jakarta.ee/specifications/bean-validation/${specVersion}/apidocs/"] } def jakartaPersistenceVersion = resolveProjectVersion('jakarta.persistence-api') if (jakartaPersistenceVersion) { - links << [packages: 'jakarta.persistence.', href: "https://jakarta.ee/specifications/persistence/3.1/apidocs/"] + def specVersion = jakartaPersistenceVersion.split('\\.').take(2).join('.') + links << [packages: 'jakarta.persistence.', href: "https://jakarta.ee/specifications/persistence/${specVersion}/apidocs/"] } def jakartaServletVersion = resolveProjectVersion('jakarta.servlet-api') if (jakartaServletVersion) { - links << [packages: 'jakarta.servlet.', href: "https://jakarta.ee/specifications/platform/10/apidocs/"] + def servletSpecVersion = jakartaServletVersion.split('\\.').take(2).join('.') + // The platform apidocs are versioned by Jakarta EE Platform release, not by the + // Servlet spec version, so the two can't be derived from one another directly. + def servletSpecToPlatformVersion = ['5.0': '9', '6.0': '10', '6.1': '11'] + def platformVersion = servletSpecToPlatformVersion[servletSpecVersion] + if (platformVersion) { + links << [packages: 'jakarta.servlet.', href: "https://jakarta.ee/specifications/platform/${platformVersion}/apidocs/"] + } + } + // No external link is registered for org.grails.datastore./grails.gorm./org.grails.gorm.: + // since the data modules merged into grails-core in 7.0, those classes are generated + // directly in this repo's own aggregate Groovydoc and resolve locally. Mapping them to + // the older, standalone gorm.grails.org site would misdirect those in-repo links. + def groovyVersion = resolveProjectVersion('groovy') + if (groovyVersion) { + links << [packages: 'groovy.', href: "https://docs.groovy-lang.org/${groovyVersion}/html/gapi/"] + links << [packages: 'org.apache.groovy.', href: "https://docs.groovy-lang.org/${groovyVersion}/html/gapi/"] + links << [packages: 'org.codehaus.groovy.', href: "https://docs.groovy-lang.org/${groovyVersion}/html/gapi/"] } - links << [packages: 'org.grails.datastore.', href: "https://gorm.grails.org/latest/api/"] - links << [packages: 'grails.gorm.', href: "https://gorm.grails.org/latest/api/"] - links << [packages: 'org.grails.gorm.', href: "https://gorm.grails.org/latest/api/"] - links << [packages: 'groovy.', href: "https://docs.groovy-lang.org/latest/html/gapi/"] - links << [packages: 'org.apache.groovy.', href: "https://docs.groovy-lang.org/latest/html/gapi/"] - links << [packages: 'org.codehaus.groovy.', href: "https://docs.groovy-lang.org/latest/html/gapi/"] if (it.ext.has('groovydocLinks')) { links.addAll(it.ext.groovydocLinks as List>) } diff --git a/grails-doc/build.gradle b/grails-doc/build.gradle index 8d4991992f0..ba1e602e281 100644 --- a/grails-doc/build.gradle +++ b/grails-doc/build.gradle @@ -127,8 +127,7 @@ combinedGroovydoc.configure { Groovydoc gdoc -> } def auditGroovydocLinks = tasks.register('auditGroovydocLinks', AuditGroovydocLinksTask) { - dependsOn combinedGroovydoc - apiDocsDir = combinedGroovydoc.get().destinationDir + apiDocsDir = project.layout.dir(combinedGroovydoc.map { it.destinationDir }) } String getVersion(String artifact) {