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 new file mode 100644 index 00000000000..0fd1770b982 --- /dev/null +++ b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/AuditGroovydocLinksTask.groovy @@ -0,0 +1,53 @@ +/* + * 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.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 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 { + + @InputDirectory + abstract DirectoryProperty getApiDocsDir() + + @TaskAction + void auditLinks() { + File apiDir = apiDocsDir.get().asFile + + 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 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 new file mode 100644 index 00000000000..54c1340f6bc --- /dev/null +++ b/build-logic/docs-core/src/main/groovy/org/grails/doc/gradle/GroovydocLinkAuditor.groovy @@ -0,0 +1,79 @@ +/* + * 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 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 { + + // 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 + Path currentPath = file.toPath().parent + + 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 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..90555ce49e7 --- /dev/null +++ b/build-logic/docs-core/src/test/groovy/org/grails/doc/gradle/GroovydocLinkAuditorSpec.groovy @@ -0,0 +1,69 @@ +/* + * 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 inner class links"() { + given: + writeHtml('index.html', """ + Deprecated + Help + SomeClass + """) + + expect: + GroovydocLinkAuditor.findViolations(apiDir).empty + } + + 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/gradle/docs-dependencies.gradle b/gradle/docs-dependencies.gradle index fd33607c98f..1cd98457277 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') { @@ -54,14 +48,54 @@ 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.core.', 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('.') + links << [packages: 'org.hibernate.', href: "https://docs.jboss.org/hibernate/orm/${shortVersion}/javadocs/"] + } + def jakartaValidationVersion = resolveProjectVersion('jakarta.validation-api') + if (jakartaValidationVersion) { + 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) { + 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) { + 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/"] + } 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..ba1e602e281 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.AuditGroovydocLinksTask import java.util.zip.ZipFile @@ -125,6 +126,10 @@ combinedGroovydoc.configure { Groovydoc gdoc -> gdoc.outputs.dir(gdoc.destinationDir) } +def auditGroovydocLinks = tasks.register('auditGroovydocLinks', AuditGroovydocLinksTask) { + apiDocsDir = project.layout.dir(combinedGroovydoc.map { it.destinationDir }) +} + String getVersion(String artifact) { String version = configurations.runtimeClasspath .resolvedConfiguration