Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* 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

/**
* 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 AuditGroovydocLinksTask extends DefaultTask {

@InputDirectory
abstract DirectoryProperty getApiDocsDir()

@TaskAction
void auditLinks() {
File apiDir = apiDocsDir.get().asFile
if (!apiDir.exists()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard is unreachable: with @InputDirectory, Gradle fails input validation before the @TaskAction runs when the directory is missing.

logger.warn "API documentation directory does not exist: ${apiDir.absolutePath}"
return
}

List<String> 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.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import org.gradle.api.GradleException instead of the inline FQCN — it's already on the compile classpath here, and the project convention is explicit imports.

} else {
logger.lifecycle "No malformed Groovydoc links found in ${apiDir.absolutePath}"
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Pattern, String> NAV_LINK_PATTERNS = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These nav-link patterns can never match real Groovydoc output. The templates in groovy-groovydoc (checked 4.0.24, what 7.0.x uses) emit nav links double-quoted with a legitimate relative prefix: href="${classDoc.relativeRootPath}deprecated-list.html". So the single-quoted patterns are dead code — and if the quoting were "fixed", they'd flag every nested class page's perfectly valid nav links, since the prefix is correct there. That also means a "0 violations" run partly reflects this vacuity rather than clean docs.

The inner-class check below is the genuinely valid part: class cross-reference links are single-quoted in the templates, which is where the Outer/Inner.html vs Outer.Inner.html phantom-link bug lives, and the only-flag-if-the-dotted-file-exists guard is a smart false-positive suppressor.

Suggest dropping these patterns (or reworking them to detect links whose resolved target doesn't exist, like the inner-class check does). Two related smells that go away with them: the map values (the replacement strings) are never read anywhere, and findViolationsInFile only records the first match per pattern per file, so the "Found N malformed links" total in the task's exception would undercount.

(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<String> findViolations(File apiDir) {
List<String> violations = []

apiDir.eachFileRecurse { File file ->
if (file.name.endsWith('.html')) {
violations.addAll(findViolationsInFile(file))
}
}

violations
}

private static List<String> findViolationsInFile(File file) {
List<String> 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()
}
}
Comment on lines +62 to +67

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
}
}
Original file line number Diff line number Diff line change
@@ -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', """
<a href='deprecated-list.html'>Deprecated</a>
<a href='help-doc.html'>Help</a>
<a href='SomePackage.SomeClass.html'>SomeClass</a>
""")

expect:
GroovydocLinkAuditor.findViolations(apiDir).empty
}

void "flags a navigation link that is nested under a relative path prefix"() {
given:
writeHtml('index.html', "<a href='../deprecated-list.html'>Deprecated</a>")

when:
List<String> 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', '<p>Direction</p>')
and: 'another page links to it using a malformed slash-separated path'
writeHtml('index.html', "<a href='./Query/Order.Direction.html'>Direction</a>")

when:
List<String> 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', "<a href='./Query/Order.Direction.html'>Direction</a>")

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
}
}
37 changes: 27 additions & 10 deletions gradle/docs-dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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/"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Groovydoc resolves links first-match-wins in list order (SimpleGroovyClassDoc.getDocUrl iterates the links and returns on the first type.startsWith(prefix) hit). Broadening this entry to org.springframework. while it sits before the org.springframework.boot. entry means every Spring Boot class reference now matches here first and links into docs.spring.io/spring-framework/..., where the Boot packages don't exist — the Boot entry below becomes dead code and all Boot links 404.

Most-specific-first fixes it: move the org.springframework.boot. entry above this one.

}
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/"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: these three Jakarta links gate on the resolved artifact version but hardcode the spec version in the URL (3.0, 3.1, platform/10), so they'll silently go stale on the next spec bump. Deriving the URL segment from the resolved version (as done for Hibernate above) would keep them consistent.

}
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/"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question on these three: since the data modules merged into this repo in 7.0, org.grails.datastore.* / grails.gorm.* classes are generated in this repo's own aggregate groovydoc. gorm.grails.org/latest/api/ is the standalone pre-merge GORM site — is that the intended target, and are we sure these prefixes don't hijack references that should resolve locally within the combined docs?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, we host these directly now on our website, so we shoudlnt' refer to the older docs link

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/"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs.groovy-lang.org/latest is Groovy 5 while 7.0.x ships Groovy 4, so these links will point at the wrong major version's API docs. The rest of this block pins resolved versions — GroovySystem.version is available here to build the versioned URL (https://docs.groovy-lang.org/docs/groovy-${GroovySystem.version}/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<Map<String, String>>)
}
Expand Down
6 changes: 6 additions & 0 deletions grails-doc/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -125,6 +126,11 @@ combinedGroovydoc.configure { Groovydoc gdoc ->
gdoc.outputs.dir(gdoc.destinationDir)
}

def auditGroovydocLinks = tasks.register('auditGroovydocLinks', AuditGroovydocLinksTask) {
dependsOn combinedGroovydoc
apiDocsDir = combinedGroovydoc.get().destinationDir

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

combinedGroovydoc.get() realizes the task eagerly and requires the explicit dependsOn above. Provider wiring keeps it lazy and carries the dependency implicitly:

apiDocsDir = project.layout.dir(combinedGroovydoc.map { it.destinationDir })

(and then the dependsOn line can go.)

}
Comment on lines +129 to +132

String getVersion(String artifact) {
String version = configurations.runtimeClasspath
.resolvedConfiguration
Expand Down
Loading