From 5b0d64176be5700afa46446c2d04431ba03fa7a1 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Thu, 05 Nov 2026 17:17:43 +0000
Subject: [PATCH] [#1271] Validate cross-guide AsciiDoc xrefs in CI (#1272)
---
opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-ldap-operations.adoc | 2
opendj-doc-generated-ref/src/main/resources/asciidoc/check-xrefs.rb | 177 ++++++++++++++++
opendj-doc-generated-ref/pom.xml | 29 ++
opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/CheckXrefsTest.java | 95 ++++++++
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-admin-tools.adoc | 2
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-import-export.adoc | 4
opendj-doc-generated-ref/src/test/resources/asciidoc/check-xrefs-test.rb | 215 +++++++++++++++++++
opendj-doc-generated-ref/CHECKS.md | 100 +++++++++
8 files changed, 620 insertions(+), 4 deletions(-)
diff --git a/opendj-doc-generated-ref/CHECKS.md b/opendj-doc-generated-ref/CHECKS.md
new file mode 100644
index 0000000..79e033e
--- /dev/null
+++ b/opendj-doc-generated-ref/CHECKS.md
@@ -0,0 +1,100 @@
+<!--
+ The contents of this file are subject to the terms of the Common Development and
+ Distribution License (the License). You may not use this file except in compliance with the
+ License.
+
+ You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ specific language governing permission and limitations under the License.
+
+ When distributing Covered Software, include this CDDL Header Notice in each file and include
+ the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ Header, with the fields enclosed by brackets [] replaced by your own identifying
+ information: "Portions copyright [year] [name of copyright owner]".
+
+ Copyright 2026 3A Systems, LLC.
+-->
+
+# Documentation checks
+
+The Linux documentation build runs the cross-guide xref check during `verify`,
+after `package` has generated the reference partials and preprocessed the guides.
+It uses the same Asciidoctor 2.0 engine bundled with AsciidoctorJ as the existing
+attribute check; no system Ruby or extra gems are required.
+
+From the repository root, build the dependencies and documentation with:
+
+```sh
+mvn -Pdistribution-unix,man-pages verify
+```
+
+On Linux these profiles are enabled automatically. On macOS enable both explicitly.
+The documentation module needs the core sources JAR and the server and LDAP toolkit
+ZIPs produced by the dependency build. A standalone module build therefore requires
+those artifacts to have been installed locally first.
+
+After generation and preprocessing, rerun just the xref check **from this module**:
+
+```sh
+mvn -Pman-pages exec:exec@check-cross-guide-xrefs
+```
+
+The default source root is `target/asciidoc/source`, resolved against this module.
+To check another prepared tree, pass an absolute path:
+
+```sh
+mvn -Pman-pages exec:exec@check-cross-guide-xrefs -Dxref.sourceDirectory=/path/to/prepared/source
+```
+
+The checker discovers every `.adoc` page, including Getting Started, Release Notes
+and How-To when present. `partials`, `attachments`, `images` and the `man-pages` staging directory are not
+independent guide pages; their content is checked in the context of the pages that include it.
+A file xref into one of these staging directories fails: the target is not a
+published page. File xref targets outside the supplied source root also fail,
+even when those files exist locally. Guide `index.adoc` pages are read as UTF-8
+and checked as Antora publishes them, with `include::./<chapter>.adoc[]` lines
+replaced by links. Other include forms stay inline, and their anchors belong
+to the index. A fragment on an index target must belong to that published index,
+rather than a chapter linked by the rewrite.
+Do not run against `src/main/asciidoc`: generated partials are required inputs.
+
+For a subset, invoke the same script with the module's dependency classpath:
+
+```sh
+mvn dependency:build-classpath -Dmdep.outputFile=target/xref-classpath.txt
+java -cp "$(cat target/xref-classpath.txt)" org.jruby.Main \
+ src/main/resources/asciidoc/check-xrefs.rb target/asciidoc/source \
+ 'getting-started/*.adoc' 'release-notes/*.adoc' 'how-to/*.adoc'
+```
+
+Quote patterns: the checker expands each pattern relative to the supplied source
+root, including when invoked from the module directory. Every requested pattern
+must match at least one input; a missing root, unreadable input or failed traversal
+also fails the command. Braces and brackets in the root path are literal.
+
+The resolver checks file xrefs and their fragments after Asciidoctor substitutions,
+using each target page's anchor catalog. Explicit anchors, automatically generated
+section IDs (including custom prefixes/separators), conditional content and nested
+includes use Asciidoctor's own rules. It ignores macros in comments, escaped macros
+and examples without macro substitution. External URLs and internal fragment-only
+xrefs are outside this check's scope.
+
+Failures name the referring source, reference, target and source line when
+Asciidoctor supplies it. The line is the start of the containing block; for some
+included paragraphs it is the including directive. Include/parse warnings fail the
+check rather than allowing a partially parsed target to pass. Exit status is 0 for
+success, 1 for invalid references/inputs found while checking pages, and 2 for CLI
+or discovery failures.
+
+The module's TestNG suite runs the Ruby regression fixtures through the bundled
+JRuby. It exercises valid references and failures after deleting targets or
+renaming generated anchors, nested includes, automatic IDs, quoted patterns and
+missing inputs. It also pins staging targets and discovery exclusions, published
+index anchors, WARN diagnostics, external URLs and directory symlinks. Separate
+JVM processes verify the actual CLI exit statuses 0, 1 and 2 and their diagnostic
+streams (stdout on success, stderr on failure). When `chmod` cannot restrict
+reads, the regression summary names the skipped unreadable-file and
+unreadable-directory checks. To run these tests without regenerating the documentation:
+
+```sh
+mvn compiler:testCompile surefire:test
+```
diff --git a/opendj-doc-generated-ref/pom.xml b/opendj-doc-generated-ref/pom.xml
index 13d2793..0625e54 100644
--- a/opendj-doc-generated-ref/pom.xml
+++ b/opendj-doc-generated-ref/pom.xml
@@ -36,6 +36,7 @@
<sdkDocTargetVersion>${project.version}</sdkDocTargetVersion>
<!-- The Asciidoctor of asciidoctor-maven-plugin, which the tests of the doc extensions also run on -->
<asciidoctorj.version>2.5.11</asciidoctorj.version>
+ <xref.sourceDirectory>${project.build.directory}/asciidoc/source</xref.sourceDirectory>
</properties>
<dependencies>
@@ -716,6 +717,34 @@
</dependencies>
</plugin>
+ <!-- verify runs after package has generated and preprocessed every partial. -->
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>exec-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>check-cross-guide-xrefs</id>
+ <phase>verify</phase>
+ <goals>
+ <goal>exec</goal>
+ </goals>
+ <configuration>
+ <executable>${java.home}/bin/java</executable>
+ <!-- Reuse the AsciidoctorJ/JRuby dependency of the extension tests. -->
+ <classpathScope>test</classpathScope>
+ <arguments>
+ <argument>-Xmx512m</argument>
+ <argument>-cp</argument>
+ <classpath/>
+ <argument>org.jruby.Main</argument>
+ <argument>${project.basedir}/src/main/resources/asciidoc/check-xrefs.rb</argument>
+ <argument>${xref.sourceDirectory}</argument>
+ </arguments>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+
<!-- Format man pages -->
<plugin>
<groupId>org.openidentityplatform.commons</groupId>
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-admin-tools.adoc b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-admin-tools.adoc
index 2e758be..7a95fd5 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-admin-tools.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-admin-tools.adoc
@@ -302,7 +302,7 @@
Display differences between two LDIF files, with the resulting output having LDIF format.
+
-For details see xref:../reference/admin-tools-ref.adoc#ldif-diff-1[ldif-diff(1)] in the __Reference__.
+For details see xref:../reference/admin-tools-ref.adoc#ldifdiff-1[ldif-diff(1)] in the __Reference__.
`ldifmodify`::
Similar to the `ldapmodify` command, modify specified attribute values for specified entries in an LDIF file.
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-import-export.adoc b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-import-export.adoc
index 3155bfc..0d173cb 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-import-export.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-import-export.adoc
@@ -51,7 +51,7 @@
+
The resulting test data template depends on what data you expect to encounter in production. Base your work on your knowledge of the production data, and on the sample template, `/path/to/opendj/config/MakeLDIF/example.template`, and associated data.
+
-See xref:../reference/admin-tools-ref.adoc#make-ldif-template-5[make-ldif.template(5)] in the __Reference__ for reference information about template files.
+See xref:../reference/admin-tools-ref.adoc#makeldif-template-5[make-ldif.template(5)] in the __Reference__ for reference information about template files.
. Create additional data files for the content in your template to be selected randomly from a file, rather than generated by an expression.
+
@@ -191,7 +191,7 @@
[#ldif-tools]
=== Other Tools For Working With LDIF Data
-This section demonstrates the `ldifsearch`, `ldifmodify` and `ldif-diff` commands, described in xref:../reference/admin-tools-ref.adoc#ldifsearch-1[ldifsearch(1)] in the __Reference__, xref:../reference/admin-tools-ref.adoc#ldifmodify-1[ldifmodify(1)] in the __Reference__, and xref:../reference/admin-tools-ref.adoc#ldif-diff-1[ldif-diff(1)] in the __Reference__.
+This section demonstrates the `ldifsearch`, `ldifmodify` and `ldif-diff` commands, described in xref:../reference/admin-tools-ref.adoc#ldifsearch-1[ldifsearch(1)] in the __Reference__, xref:../reference/admin-tools-ref.adoc#ldifmodify-1[ldifmodify(1)] in the __Reference__, and xref:../reference/admin-tools-ref.adoc#ldifdiff-1[ldif-diff(1)] in the __Reference__.
[#ldifsearch-example]
==== Searching in LDIF With ldifsearch
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-ldap-operations.adoc b/opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-ldap-operations.adoc
index 30c336a..452f6a5 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-ldap-operations.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-ldap-operations.adoc
@@ -233,7 +233,7 @@
Display differences between two LDIF files, with the resulting output having LDIF format.
+
-For details see xref:../reference/admin-tools-ref.adoc#ldif-diff-1[ldif-diff(1)] in the __Reference__.
+For details see xref:../reference/admin-tools-ref.adoc#ldifdiff-1[ldif-diff(1)] in the __Reference__.
`ldifmodify`::
Similar to the `ldapmodify` command, modify specified attribute values for specified entries in an LDIF file.
diff --git a/opendj-doc-generated-ref/src/main/resources/asciidoc/check-xrefs.rb b/opendj-doc-generated-ref/src/main/resources/asciidoc/check-xrefs.rb
new file mode 100644
index 0000000..36333d6
--- /dev/null
+++ b/opendj-doc-generated-ref/src/main/resources/asciidoc/check-xrefs.rb
@@ -0,0 +1,177 @@
+# The contents of this file are subject to the terms of the Common Development and
+# Distribution License (the License). You may not use this file except in compliance with the
+# License.
+#
+# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+# specific language governing permission and limitations under the License.
+#
+# When distributing Covered Software, include this CDDL Header Notice in each file and include
+# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+# Header, with the fields enclosed by brackets [] replaced by your own identifying
+# information: "Portions copyright [year] [name of copyright owner]".
+#
+# Copyright 2026 3A Systems, LLC.
+
+# Uses the Asciidoctor bundled in AsciidoctorJ; no system Ruby gems are needed.
+require 'rubygems'
+Gem.use_paths 'uri:classloader:/' if defined?(JRUBY_VERSION)
+require 'asciidoctor'
+require 'asciidoctor/converter/html5'
+require 'find'
+require 'set'
+require_relative 'extensions/nested-open-block'
+
+module CheckXrefs
+ # Collect only macros actually converted into links, after substitutions. This
+ # excludes comments, inactive conditionals, escaped macros and literal examples.
+ class Converter < Asciidoctor::Converter::Html5Converter
+ attr_reader :xrefs
+
+ def initialize *args
+ super
+ @xrefs = []
+ end
+
+ def convert_inline_anchor node
+ if node.type == :xref && (path = node.attr 'path') && path !~ %r{\A(?:[a-z][a-z0-9+.-]*:|//)}i
+ parent = node.parent
+ parent = parent.parent while parent && !parent.source_location
+ @xrefs << [path, (node.attr 'fragment'), node.target, parent && parent.source_location]
+ end
+ super
+ end
+ end
+
+ class Resolver
+ # Staging inputs are included by pages, never published as pages themselves.
+ UNPUBLISHED = %w[partials attachments images man-pages].freeze
+
+ attr_reader :errors, :checked
+
+ def initialize root
+ @root = File.realpath root
+ raise IOError, "not a source directory: #{root}" unless File.directory? @root
+ @documents = {}
+ @errors = []
+ @checked = 0
+ end
+
+ def inputs patterns
+ # Find raises on unreadable directories. Dir.glob silently skips them.
+ files = []
+ Find.find(@root, ignore_error: false) do |path|
+ if File.symlink?(path) && File.directory?(path)
+ raise IOError, "cannot traverse symbolic directory: #{path}"
+ end
+ files << path.delete_prefix(@root + '/') if path.end_with? '.adoc'
+ end
+ if patterns.empty?
+ selected = files.reject {|path| UNPUBLISHED.include? path.split('/').first }
+ else
+ selected = patterns.flat_map do |pattern|
+ matches = files.select {|path| File.fnmatch? pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB }
+ raise IOError, "no input files match #{pattern.inspect} under #{@root}" if matches.empty?
+ matches
+ end
+ end
+ raise IOError, "no input .adoc pages under #{@root}" if selected.empty?
+ selected.uniq.sort.map {|path| File.join @root, path }
+ end
+
+ def load_page path
+ if @documents.key? path
+ cached = @documents[path]
+ raise cached if Exception === cached
+ return cached
+ end
+ raise IOError, "missing or unreadable file: #{path}" unless File.file?(path) && File.readable?(path)
+ logger = Asciidoctor::MemoryLogger.new
+ converter = Converter.new 'html5'
+ options = { safe: :unsafe, sourcemap: true,
+ base_dir: File.dirname(path), converter: converter, logger: logger,
+ attributes: { 'relfilesuffix' => '.adoc' } }
+ if File.basename(path) == 'index.adoc'
+ # AntoraMojo publishes guide indexes as chapter links, not an aggregate.
+ source = File.read(path, mode: 'rb:utf-8').gsub 'include::./', '* xref:'
+ options[:attributes].merge! 'docfile' => path, 'docdir' => File.dirname(path),
+ 'docname' => 'index', 'docfilesuffix' => '.adoc'
+ document = Asciidoctor.load source, options
+ else
+ document = Asciidoctor.load_file path, options
+ end
+ document.convert
+ # Include failures and the maximum include depth are errors; warnings such as a
+ # missing include tag also mean that the document was only partly traversed and
+ # must fail the check.
+ diagnostics = logger.messages.select {|entry| (Asciidoctor::Logger::Severity.const_get entry[:severity]) >= Asciidoctor::Logger::Severity::WARN }
+ unless diagnostics.empty?
+ messages = diagnostics.map do |entry|
+ message = entry[:message]
+ if Hash === message
+ cursor = message[:source_location]
+ "#{cursor && cursor.file}:#{cursor && cursor.lineno}: #{message[:text]}"
+ else
+ message.to_s
+ end
+ end
+ raise IOError, messages.join("\n")
+ end
+ ids = document.catalog[:refs].keys.to_set
+ ids << document.id if document.id
+ @documents[path] = [ids, converter.xrefs]
+ rescue StandardError => e
+ @documents[path] = e
+ raise
+ end
+
+ def check pages
+ pages.each do |page|
+ begin
+ _, refs = load_page page
+ refs.each do |path, fragment, reference, location|
+ @checked += 1
+ source = location && location.file || page
+ line = location && location.lineno
+ prefix = "#{source}#{line ? ':' + line.to_s : ''}: xref:#{reference}"
+ begin
+ target = File.expand_path path, File.dirname(page)
+ raise IOError, "outside the source root: #{target}" unless target.start_with?(@root + '/')
+ if UNPUBLISHED.include?(target.delete_prefix(@root + '/').split('/').first)
+ raise IOError, "not a published page: #{target}"
+ end
+ ids, = load_page target
+ if fragment && !fragment.empty? && !ids.include?(fragment)
+ @errors << "#{prefix}: missing anchor ##{fragment} in #{target}"
+ end
+ rescue StandardError => e
+ @errors << "#{prefix}: #{e.message}"
+ end
+ end
+ rescue StandardError => e
+ @errors << "#{page}: #{e.message}"
+ end
+ end
+ end
+ end
+
+ # Return status and report so the tests exercise exactly the CLI's behavior.
+ def self.run args
+ return [2, 'Usage: check-xrefs.rb SOURCE_ROOT [PATTERN ...]'] if args.empty?
+ resolver = Resolver.new args.first
+ pages = resolver.inputs args.drop(1)
+ resolver.check pages
+ if resolver.errors.empty?
+ [0, "Checked #{resolver.checked} file xrefs in #{pages.size} pages"]
+ else
+ [1, resolver.errors.uniq.join("\n")]
+ end
+ rescue StandardError => e
+ [2, "xref check: #{e.message}"]
+ end
+end
+
+if $PROGRAM_NAME == __FILE__
+ status, report = CheckXrefs.run ARGV
+ (status == 0 ? $stdout : $stderr).puts report
+ exit status
+end
diff --git a/opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/CheckXrefsTest.java b/opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/CheckXrefsTest.java
new file mode 100644
index 0000000..85f5991
--- /dev/null
+++ b/opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/CheckXrefsTest.java
@@ -0,0 +1,95 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.openidentityplatform.opendj.doc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.jruby.embed.ScriptingContainer;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/** Exercises the resolver and its CLI with actual AsciiDoc fixtures. */
+@Test
+public class CheckXrefsTest extends ForgeRockTestCase {
+ @Test
+ public void validatesCrossGuideReferences() {
+ final ScriptingContainer ruby = new ScriptingContainer();
+ try {
+ ruby.runScriptlet("require 'rubygems'; Gem.use_paths('uri:classloader:/')");
+ ruby.put("test_file", new File(System.getProperty("basedir", "."),
+ "src/test/resources/asciidoc/check-xrefs-test.rb").getAbsolutePath());
+ ruby.runScriptlet("require test_file");
+ final String regressionReport = ruby.runScriptlet("check_xrefs_test").toString();
+ System.out.println(regressionReport);
+ assertThat(regressionReport).contains("xref regression checks passed");
+ assertThat(ruby.runScriptlet("check_xrefs_publication_test").toString()).endsWith("publication checks passed");
+ } finally {
+ ruby.terminate();
+ }
+ }
+
+ @DataProvider(name = "cliCases")
+ public Object[][] cliCases() {
+ return new Object[][] {
+ { "= Valid\n", "*.adoc", 0, "Checked 0 file xrefs" },
+ { "= Broken\n\nxref:missing.adoc[]\n", "*.adoc", 1, "missing.adoc" },
+ { "= Valid\n", "absent/*.adoc", 2, "no input files match" }
+ };
+ }
+
+ /** A successful JVM launch must not hide a nonzero checker exit from Maven. */
+ @Test(dataProvider = "cliCases")
+ public void returnsCliExitStatus(String source, String pattern, int status, String diagnostic) throws Exception {
+ final Path root = Files.createTempDirectory("xref-cli-");
+ final Path page = root.resolve("page.adoc");
+ final Path output = root.resolve("output.log");
+ final Path errors = root.resolve("errors.log");
+ Process process = null;
+ try {
+ Files.write(page, source.getBytes(StandardCharsets.UTF_8));
+ final String executable = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java";
+ process = new ProcessBuilder(
+ new File(System.getProperty("java.home"), "bin/" + executable).getAbsolutePath(),
+ "-Xmx256m", "-cp",
+ System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")),
+ "org.jruby.Main",
+ new File(System.getProperty("basedir", "."),
+ "src/main/resources/asciidoc/check-xrefs.rb").getAbsolutePath(),
+ root.toString(), pattern)
+ .redirectOutput(output.toFile()).redirectError(errors.toFile()).start();
+ assertThat(process.waitFor(60, TimeUnit.SECONDS)).as("checker process finished").isTrue();
+ final String report = new String(Files.readAllBytes(output), StandardCharsets.UTF_8);
+ final String errorReport = new String(Files.readAllBytes(errors), StandardCharsets.UTF_8);
+ assertThat(process.exitValue()).as(report + errorReport).isEqualTo(status);
+ assertThat(status == 0 ? report : errorReport).contains(diagnostic);
+ assertThat(status == 0 ? errorReport : report).doesNotContain(diagnostic);
+ } finally {
+ if (process != null && process.isAlive()) {
+ process.destroyForcibly().waitFor();
+ }
+ Files.deleteIfExists(output);
+ Files.deleteIfExists(errors);
+ Files.deleteIfExists(page);
+ Files.delete(root);
+ }
+ }
+}
diff --git a/opendj-doc-generated-ref/src/test/resources/asciidoc/check-xrefs-test.rb b/opendj-doc-generated-ref/src/test/resources/asciidoc/check-xrefs-test.rb
new file mode 100644
index 0000000..34e3f97
--- /dev/null
+++ b/opendj-doc-generated-ref/src/test/resources/asciidoc/check-xrefs-test.rb
@@ -0,0 +1,215 @@
+# The contents of this file are subject to the terms of the Common Development and
+# Distribution License (the License). You may not use this file except in compliance with the
+# License.
+#
+# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+# specific language governing permission and limitations under the License.
+#
+# When distributing Covered Software, include this CDDL Header Notice in each file and include
+# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+# Header, with the fields enclosed by brackets [] replaced by your own identifying
+# information: "Portions copyright [year] [name of copyright owner]".
+#
+# Copyright 2026 3A Systems, LLC.
+
+require 'tmpdir'
+require 'fileutils'
+require_relative '../../../main/resources/asciidoc/check-xrefs'
+
+# These run against the same CLI entry point as Maven, in one JRuby VM.
+def check_xrefs_test
+ count = 0
+ skipped = []
+ Dir.mktmpdir('xref-') do |temporary|
+ root = File.join temporary, 'sources-{1}[1]'
+ FileUtils.mkdir_p root
+ write = lambda do |name, text|
+ path = File.join root, name
+ FileUtils.mkdir_p File.dirname(path)
+ File.write path, text
+ end
+ write.call 'reference/page.adoc', "= Reference\n\ninclude::../partials/generated.adoc[]\n"
+ write.call 'partials/generated.adoc', "include::nested/anchors.adoc[]\n"
+ write.call 'partials/nested/anchors.adoc', "[#generated]\n== Generated Section\n\n=== Automatic Section\n\nInline [[inline-anchor]] anchor.\n"
+ write.call 'getting-started/page.adoc', "= Start\n\nxref:../reference/page.adoc#generated[Generated]\n"
+ assert_check = lambda do |expected, patterns = ['getting-started/*.adoc'], expected_status = (expected ? 1 : 0)|
+ status, output = CheckXrefs.run [root, *patterns]
+ if expected
+ raise "expected failure containing #{expected.inspect}, got #{status}: #{output}" unless status == expected_status && output.include?(expected)
+ else
+ raise "expected success, got #{status}: #{output}" unless status == 0
+ end
+ count += 1
+ output
+ end
+ assert_check.call nil
+ write.call 'man-pages/staged.adoc', 'xref:unpublished-page.adoc[]'
+ %w[partials attachments images].each do |folder|
+ write.call "#{folder}/unpublished.adoc", 'xref:missing.adoc[]'
+ end
+ assert_check.call nil, []
+ write.call 'getting-started/page.adoc', "= Start\n\nxref:https://example.com/page.adoc#fragment[External]\n"
+ assert_check.call nil
+ ['_automatic_section', 'inline-anchor'].each do |id|
+ write.call 'getting-started/page.adoc', "= Start\n\nxref:../reference/page.adoc##{id}[Target]\n"
+ assert_check.call nil
+ end
+ write.call 'getting-started/page.adoc', "= Start\n\nxref:../reference/page.adoc[Target]\n"
+ assert_check.call nil
+ write.call 'getting-started/page.adoc', "= Start\n\nxref:../reference/missing.adoc#gone[Target]\n"
+ output = assert_check.call "missing or unreadable file: #{File.join File.realpath(root), 'reference/missing.adoc'}"
+ raise output unless output.include?('page.adoc:3:')
+ write.call 'getting-started/page.adoc', "= Start\n\nxref:../reference/page.adoc#generated[Target]\n"
+ File.write File.join(root, 'partials/nested/anchors.adoc'), "== Renamed\n"
+ assert_check.call '#generated'
+ File.delete File.join(root, 'partials/nested/anchors.adoc')
+ assert_check.call 'anchors.adoc'
+ assert_check.call 'no input', ['how-to/*.adoc'], 2
+ assert_check.call 'no input', ['getting-started/*.adoc', 'how-to/*.adoc'], 2
+ write.call 'reference/page.adoc', "= Target\n:idprefix: custom-\n:idseparator: -\n\n== Automatic Section\n"
+ write.call 'getting-started/page.adoc', "= Start\n:target: ../reference/page.adoc\n\nxref:{target}#custom-automatic-section[Target]\n"
+ assert_check.call nil
+ # WARN-level diagnostics must reject a partly parsed target.
+ write.call 'partials/tagged.adoc', "// tag::present[]\n== Tagged\n// end::present[]\n"
+ write.call 'reference/tagged.adoc', "= Tagged\n\ninclude::../partials/tagged.adoc[tag=nosuch]\n"
+ write.call 'getting-started/page.adoc', "= Start\n\nxref:../reference/tagged.adoc[Target]\n"
+ assert_check.call 'nosuch'
+ # Commented, escaped, conditional and verbatim macros are not published links.
+ write.call 'getting-started/page.adoc', <<~'ADOC'
+ = Start
+
+ // xref:../missing.adoc[]
+ ifdef::never-defined[]
+ xref:../missing.adoc[]
+ endif::[]
+ \xref:../missing.adoc[]
+
+ ----
+ xref:../missing.adoc[]
+ ----
+ ADOC
+ assert_check.call nil
+ write.call 'getting-started/page.adoc', "= Start\n\ninclude::../partials/ref.adoc[]\n"
+ write.call 'partials/ref.adoc', "xref:../reference/page.adoc#missing[Target]\n"
+ output = assert_check.call '#missing'
+ # Asciidoctor may attribute the first included paragraph to its include directive.
+ raise output unless output.include?('ref.adoc:1:') || output.include?('page.adoc:3:')
+ # AsciiDoc cells use nested documents, but still belong to the publishing page.
+ write.call 'getting-started/page.adoc', "= Start\n\n|===\na|xref:../reference/page.adoc#missing[Target]\n|===\n"
+ assert_check.call '#missing'
+ write.call 'getting-started/page.adoc', "= Start\n\nxref:../reference/page.adoc#custom-automatic-section[Target]\n"
+ File.delete File.join(root, 'reference/page.adoc')
+ assert_check.call "missing or unreadable file: #{File.join File.realpath(root), 'reference/page.adoc'}"
+ FileUtils.mkdir_p File.join(root, 'reference/page.adoc')
+ assert_check.call "missing or unreadable file: #{File.join File.realpath(root), 'reference/page.adoc'}"
+ status, output = CheckXrefs.run [File.join(root, 'absent')]
+ raise output unless status == 2
+ count += 1
+ write.call 'getting-started/unreadable.adoc', '= Unreadable'
+ unreadable = File.join root, 'getting-started/unreadable.adoc'
+ begin
+ File.chmod 0, unreadable
+ if File.readable? unreadable
+ skipped << 'unreadable file (chmod does not restrict reads)'
+ else
+ assert_check.call "missing or unreadable file: #{File.realpath unreadable}"
+ end
+ ensure
+ File.chmod 0600, unreadable
+ end
+ directory = File.join root, 'unreadable-directory'
+ FileUtils.mkdir_p directory
+ begin
+ File.chmod 0, directory
+ if File.readable? directory
+ skipped << 'unreadable directory (chmod does not restrict reads)'
+ else
+ assert_check.call 'unreadable-directory', ['getting-started/*.adoc'], 2
+ end
+ ensure
+ File.chmod 0700, directory
+ end
+ status, output = CheckXrefs.run []
+ raise output unless status == 2
+ count += 1
+ # A symlinked guide directory must fail traversal instead of being skipped.
+ FileUtils.mkdir_p File.join(temporary, 'elsewhere')
+ File.write File.join(temporary, 'elsewhere/linked.adoc'), "= Linked\n\nxref:missing.adoc[]\n"
+ linked = File.join root, 'linked-guide'
+ File.symlink File.join(temporary, 'elsewhere'), linked
+ begin
+ assert_check.call 'cannot traverse symbolic directory', [], 2
+ ensure
+ File.unlink linked
+ end
+ # A dangling file symlink fails when the page is loaded.
+ File.symlink File.join(root, 'absent'), File.join(root, 'getting-started/broken.adoc')
+ assert_check.call 'broken.adoc'
+ end
+ "#{count} xref regression checks passed#{skipped.empty? ? '' : "; skipped: #{skipped.join(', ')}"}"
+end
+
+# Targets must have the anchors of the published page, not staging/aggregate input.
+def check_xrefs_publication_test
+ failures = []
+ count = 0
+ Dir.mktmpdir('xref-publication-') do |temporary|
+ root = File.join temporary, 'sources'
+ write = lambda do |name, text|
+ path = File.join root, name
+ FileUtils.mkdir_p File.dirname(path)
+ File.write path, text
+ end
+ assert_result = lambda do |status_expected, text_expected|
+ status, report = CheckXrefs.run [root, 'start/*.adoc']
+ failures << "expected #{status_expected} containing #{text_expected.inspect}, got #{status}: #{report}" unless
+ status == status_expected && report.include?(text_expected)
+ count += 1
+ end
+ %w[partials attachments images man-pages].each do |folder|
+ write.call "#{folder}/fragment.adoc", "[#in-partial]\n== Fragment\n"
+ write.call 'start/page.adoc', "= Start\n\nxref:../#{folder}/fragment.adoc#in-partial[Target]\n"
+ assert_result.call 1, 'not a published page'
+ end
+ write.call 'guide/chapter.adoc', "[#chapter-id]\n== Chapter\n"
+ write.call 'guide/index.adoc', "= Guide\n\n[#index-id]\n== Contents\n\ninclude::./chapter.adoc[]\n"
+ write.call 'start/page.adoc', "= Start\n\nxref:../guide/index.adoc#chapter-id[Chapter]\n"
+ assert_result.call 1, 'missing anchor #chapter-id'
+ write.call 'start/page.adoc', "= Start\n\nxref:../guide/index.adoc#index-id[Contents]\n"
+ assert_result.call 0, 'Checked 1 file xrefs'
+ write.call 'start/page.adoc', "= Start\n\nxref:../guide/chapter.adoc#chapter-id[Chapter]\n"
+ assert_result.call 0, 'Checked 1 file xrefs'
+ # The converted index must retain its source path and line in diagnostics.
+ write.call 'guide/index.adoc', "= Guide\n\ninclude::./missing.adoc[]\n"
+ status, report = CheckXrefs.run [root, 'guide/index.adoc']
+ failures << "expected missing index chapter diagnostic, got #{status}: #{report}" unless
+ status == 1 && report.include?('index.adoc:3:') && report.include?('missing.adoc')
+ count += 1
+ # Include forms that Antora leaves inline contribute anchors to the index.
+ write.call 'guide/index.adoc', "= Guide\n\ninclude::chapter.adoc[]\n"
+ write.call 'start/page.adoc', "= Start\n\nxref:../guide/index.adoc#chapter-id[Chapter]\n"
+ assert_result.call 0, 'Checked 1 file xrefs'
+ # Index loading must use UTF-8 regardless of the runtime's default encoding.
+ write.call 'guide/index.adoc', "= Guide\n:authors: Nemanja Lukić\n\ninclude::./chapter.adoc[]\n"
+ previous = Encoding.default_external
+ begin
+ Encoding.default_external = Encoding::US_ASCII
+ status, report = CheckXrefs.run [root, 'guide/index.adoc']
+ ensure
+ Encoding.default_external = previous
+ end
+ failures << "expected a non-ASCII index to load, got #{status}: #{report}" unless
+ status == 0 && report.include?('Checked 1 file xrefs')
+ count += 1
+ # Existing files outside the published source tree cannot satisfy an xref.
+ File.write File.join(temporary, 'outside.adoc'), "[#outside]\n== Outside\n"
+ write.call 'start/page.adoc', "= Start\n\nxref:../../outside.adoc#outside[Outside]\n"
+ assert_result.call 1, "outside the source root: #{File.join File.realpath(temporary), 'outside.adoc'}"
+ FileUtils.mkdir_p File.join(temporary, 'sources-sibling')
+ File.write File.join(temporary, 'sources-sibling/page.adoc'), "= Sibling\n"
+ write.call 'start/page.adoc', "= Start\n\nxref:../../sources-sibling/page.adoc[Sibling]\n"
+ assert_result.call 1, 'outside the source root'
+ end
+ raise failures.join("\n") unless failures.empty?
+ "#{count} publication checks passed"
+end
--
Gitblit v1.10.0