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