[#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
| New file |
| | |
| | | <!-- |
| | | 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 |
| | | ``` |
| | |
| | | <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> |
| | |
| | | </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> |
| | |
| | | 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. |
| | |
| | | + |
| | | 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. |
| | | + |
| | |
| | | [#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 |
| | |
| | | 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. |
| New file |
| | |
| | | # 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 |
| New file |
| | |
| | | /* |
| | | * 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); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | # 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 |