mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
23 hours ago 5b0d64176be5700afa46446c2d04431ba03fa7a1
[#1271] Validate cross-guide AsciiDoc xrefs in CI (#1272)

Documentation builds currently accept missing cross-guide AsciiDoc files
and fragments. This adds a required `check-cross-guide-xrefs` execution
to the documentation module's Linux `verify` lifecycle, after reference
generation and preprocessing. Missing target files or anchors fail the
build with the referring source, reference and source line when
available.

The resolver uses the existing AsciidoctorJ/JRuby engine for converted
file xrefs, nested/generated includes, explicit anchors and
automatically generated section IDs. It rejects unpublished staging
targets and targets outside the supplied source root, and fails on
unreadable inputs, traversal errors, parse warnings and empty
selections. Guide `index.adoc` files are read explicitly as UTF-8 and
use the Antora plugin's literal `include::./` to `* xref:` rewrite.
Other include forms remain inline and contribute anchors to the index.
Standalone quoted patterns expand relative to the supplied source root.
`CHECKS.md` documents invocation, publication rules, diagnostics and
exit statuses.

The first complete-source check also exposed four existing broken
fragment targets, corrected to the generated `ldifdiff-1` and
`makeldif-template-5` anchors. The branch is based on `master` at
5154f34503, including the guides merged in #1268.

Fixes #1271.

## Validation

- Complete documentation-module suite on the current revision: 26 tests
passed on Java 11 and Java 27, with no failures, errors or skips. Ruby
fixtures cover 24 regression checks and 12 publication checks, including
nested/generated anchors, automatic IDs, ignored
literal/comment/conditional macros, staging targets and discovery
exclusions, published index anchors and retained inline includes,
non-ASCII indexes under a US-ASCII default encoding, outside-root
targets and sibling-prefix boundaries, WARN diagnostics, external URLs,
directory symlinks and exact error statuses.
- Three tests launch separate JVMs through the actual JRuby CLI and
verify exit statuses 0, 1 and 2, together with diagnostic routing to
stdout on success and stderr on failure. Permission-test skips are named
in the visible regression summary when `chmod` cannot restrict reads; an
emulated nonrestrictive-read run reports 22 checks plus both named
skips.
- Actual Maven xref execution from the module directory passed on the
current revision: 600 file xrefs in 72 prepared guide pages. The
preceding review round refreshed the preprocessed sources; preprocessing
was not repeated for the current checker/test changes. Indexes use the
publication rewrite, so linked chapter content is not counted again
through the index.
- The actual Administration Guide index passed through the JRuby CLI on
JDK 11 and 17 with `-Dfile.encoding=US-ASCII`: 23 file xrefs each, exit
0.
- Four in-memory mutations on the current revision were caught:
replacing the missing-file diagnosis, removing explicit UTF-8 index
decoding, deleting the outside-root guard and weakening the
sibling-prefix boundary. Compiled Java CLI tests caught both
stream-routing mutations: stdout-only failed both error cases;
stderr-only failed the success case.
- In the preceding review round, eleven in-process mutations were
caught: WARN threshold, invalid-reference and discovery statuses,
external-URL filter, directory-symlink guard, staging-target guard,
index transformation and all four discovery exclusions. A separate `exit
status` to `exit 0` mutation caused both CLI failure-case tests to fail.
Those earlier mutations were not repeated for this update.
- During the initial implementation, removing a target file, renaming
`upgrade-zip`, and renaming `dsconfig-create-backend` inside a nested
generated partial each caused failure. The actual Maven execution exited
1 for the generated-anchor mutation and named referring files and lines;
an unmatched glob exited 2. Those manual guide mutations were not
repeated for this update.
- Independent code review and `git diff --check` passed on the current
revision.

The complete module `verify` has no passing result and was not rerun for
this update. An earlier attempt generated partials, preprocessed sources
and built three PDFs, then was stopped after more than ten minutes
overall while rendering the Reference PDF. The module tests and required
xref execution passed separately as described above. Existing Maven
model/plugin and JRuby/JDK warnings remain.
4 files modified
4 files added
624 ■■■■■ changed files
opendj-doc-generated-ref/CHECKS.md 100 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/pom.xml 29 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-admin-tools.adoc 2 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-import-export.adoc 4 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-ldap-operations.adoc 2 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/resources/asciidoc/check-xrefs.rb 177 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/CheckXrefsTest.java 95 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/test/resources/asciidoc/check-xrefs-test.rb 215 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/CHECKS.md
New file
@@ -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
```
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>
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.
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
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.
opendj-doc-generated-ref/src/main/resources/asciidoc/check-xrefs.rb
New file
@@ -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
opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/CheckXrefsTest.java
New file
@@ -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);
        }
    }
}
opendj-doc-generated-ref/src/test/resources/asciidoc/check-xrefs-test.rb
New file
@@ -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