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

Valery Kharseko
17 hours ago 4870ff34abcd5f0f8e9262956a581ca0160fe4be
[#1147] Fail the doc build when a page leaves an AsciiDoc attribute unresolved (#1148)

Fixes #1147

### Problem

A page that uses an attribute it does not define, or a verbatim block
that uses an attribute without attribute subs, publishes `{name}`
literally, and the build stays green. The only render in the build is
the PDF book, where an attribute defined in an earlier chapter also
resolves in the later ones; the site renders every chapter as a page of
its own, so a page can be broken on the site while the PDF is fine
(`chap-uninstall.adoc` before #1140). The `antora` goal of
`doc-maven-plugin` only rewrites the `.adoc` files, it renders nothing.

### Change

- New `check-attribute-references` execution of
`asciidoctor-maven-plugin` in the `man-pages` profile (phase `verify`,
so it runs on the ubuntu legs of the PR build). It renders every `.adoc`
of `target/asciidoc/source` on its own to throwaway HTML, with each
file's own directory as the base, and fails on any WARN that mentions an
attribute:
1. an attribute that is not defined: `attribute-missing=warn`;
2. an attribute in a listing, literal or passthrough block without
attribute subs, or in a literal table cell (`l|`, `cols="1l"`): the new
`literal-attribute-references.rb` tree processor. A reference counts
when its name is an attribute at that point of the page, an intrinsic
one such as `{nbsp}`, or one that a `:name:` entry anywhere in the doc
sources sets - the last catches a page that neither defines nor
substitutes the attribute, and since the page is among those sources,
its own entry counts even where it has unset the attribute. Braces
around any other name (`{SSHA}` values, `{cn}` in MakeLDIF templates)
are left alone. Without attribute subs a backslash does not escape, so
`\{name}` is reported too, as published with its backslash.
The advice in the warning is `subs="+attributes"`, which keeps the
block's default subs; a literal cell takes no subs, so it is told to
become an `a|` cell with such a listing.
- `LiteralAttributeReferencesTest` reads the execution's configuration
from the pom - the required extensions, the attributes, the `failIf` -
renders pages with it, and checks which of them would fail the build: 13
that must fail, 9 that must pass. Its sources directory sits under
`target` and holds one of the pages, as the build's does, and it asserts
that `sourceDirectory` and `literal-attribute-sources` are the same
directory. The pom module binds `testCompile` and `test` for it, and the
plugin and the test share `asciidoctorj` `${asciidoctorj.version}`
(2.5.11, the plugin's own).
- The 16 blocks of the guides with `subs="attributes"` take
`subs="+attributes"`. The replacing form had dropped
`specialcharacters`, so `chap-writing-plugins` published `(build
<unknown>, revision <unknown>)` as raw tags, which a browser hides; the
`>`, `>>>>` and `&&` of the other blocks were raw as well. The rendered
HTML of the six pages differs only in that escaping.
- The two literal placeholders the check found on master are escaped
like the generated reference does since #1142: `{options}` in the
`windows-service` synopsis and `{path}` in the password policy chapter
(both were shown with braces, but by accident).

Asciidoctor resets the document attributes to the header before tree
processors run, so the extension replays the attribute entries of the
body in document order, as the converter does, on a copy of each
document's attributes. The document itself is left alone: playing the
entries back on it would also hand the compat mode of the last
`compat-mode` entry to the converter.

### Limits

- `asciidoctor-maven-plugin` 2.2.6 evaluates `failIf` after each file,
so the build stops at the first page that fails; fix it and run `mvn
-Pdistribution-unix,man-pages -pl opendj-doc-generated-ref
asciidoctor:process-asciidoc@check-attribute-references` again for the
next one (both profiles activate by themselves only on Linux). This is
noted in the pom.
- Asciidoctor names no file for a missing attribute; it is the page of
the `Converted` line that follows the warning.
- The plugin makes every Maven project property an attribute
(`product.name` is `{product-name}`), and the site defines none of them,
so a page that uses one passes the check and still shows the braces on
the site. The plugin has no switch for it; no page uses one today. This
is noted in the pom.
- The `partials` directory is rendered as well (234 of the 512 files),
although Antora only includes its files and never publishes them as
pages; the plugin has no exclusion, and an `_` prefix would break the
includes. So the man pages are checked twice, and a partial that relies
on an attribute of the chapter that includes it would fail here although
the site resolves it; none of the 11 partials that exist only there
holds an attribute reference. This is noted in the pom.

### Verification

- Current master (with #1145 and #1146), with the extension of this
round: 512 pages, no warning at all, `BUILD SUCCESS`; the check itself
took 43 s in the CI job of the previous round. The hand-written pages
were pre-processed again from this branch; the generated reference pages
come from my last module build.
- Before #1140 it reported exactly what #1140 fixed: the undefined
`{opendj-version}` in `chap-uninstall.adoc` and the listing without subs
in `chap-monitoring.adoc`.
- Three mutants on the pre-processed copies each fail the build:
`:opendj-version:` removed from `chap-uninstall`; `subs="attributes"`
removed from a listing in `chap-monitoring`; both the subs and the
definition removed from `chap-monitoring` (caught only through the
`:name:` entries of the other pages).
- `LiteralAttributeReferencesTest`: 22 green. Each of 16 mutants turns
it red - in the extension: no report at all, the subs guard inverted, no
literal-cell branch, no intrinsic names, no replay of body entries, no
unset in the replay, the replay on the document instead of a copy, the
sources path back in the glob pattern, `\{name}` skipped, the old
`subs="attributes"` advice; in the pom: a typo in `containsText`,
`failIf` on `ERROR`, no `attribute-missing`, no
`literal-attribute-sources`, the extension not required,
`sourceDirectory` narrowed to `man-pages`. The previous round's
extension, which played the entries back on the document, fails the two
compat-mode rows.
- The HTML rendered with and without the extension is identical on all
512 pages (only the `Last updated` line set aside), so the extension
changes nothing in the output.
- Edge cases checked by hand: header and body attributes, unset
attributes, compat mode set or unset in the body, literal paragraphs,
passthrough blocks, listings inside AsciiDoc table cells, literal cells,
`{SSHA}`/`{givenName}` braces, a sources path with `{` and `[`.
- Not run locally: the full module build with the PDF (killed for memory
on my machine); the check ran on the sources the build had already
pre-processed. CI runs the whole path.
9 files modified
2 files added
430 ■■■■■ changed files
opendj-doc-generated-ref/pom.xml 110 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-monitoring.adoc 6 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-pwd-policy.adoc 4 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-troubleshooting.adoc 2 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc 16 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-uninstall.adoc 2 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc 4 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/man-pages/man-windows-service.adoc 4 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-writing-plugins.adoc 2 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/resources/asciidoc/extensions/literal-attribute-references.rb 92 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/LiteralAttributeReferencesTest.java 188 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/pom.xml
@@ -34,8 +34,24 @@
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <docTargetVersion>${project.version}</docTargetVersion>
        <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>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.asciidoctor</groupId>
            <artifactId>asciidoctorj</artifactId>
            <version>${asciidoctorj.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.openidentityplatform.commons</groupId>
            <artifactId>build-tools</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build><finalName>${project.groupId}.${project.artifactId}</finalName>
        <plugins>
@@ -356,6 +372,34 @@
                    </execution>
                </executions>
            </plugin>
            <!-- A pom module compiles and runs no tests by itself: these test the Asciidoctor extensions -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <executions>
                    <execution>
                        <id>compile-extension-tests</id>
                        <phase>test-compile</phase>
                        <goals>
                            <goal>testCompile</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <executions>
                    <execution>
                        <id>test-extensions</id>
                        <phase>test</phase>
                        <goals>
                            <goal>test</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
@@ -603,7 +647,73 @@
                                    <outputDirectory>${project.build.directory}/asciidoc/man-pages</outputDirectory>
                                </configuration>
                            </execution>
                            <!--
                              Fail the build when a page would publish an attribute reference as literal
                              text, which neither the PDF below nor the Antora site reports. Every .adoc of
                              the pre-processed sources is rendered on its own, the way Antora renders each
                              chapter as a page (partials aside, see below), so a page that relies on an
                              attribute another chapter defines fails here although the PDF book resolves
                              it. attribute-missing=warn reports a reference to an attribute that is not
                              defined;
                              literal-attribute-references.rb reports one in a verbatim block without
                              subs="+attributes" or in a literal table cell. The HTML output is thrown away.
                              LiteralAttributeReferencesTest renders its pages with the configuration of
                              this execution.
                              The plugin stops at the first page that fails, so fix it and run again to see
                              the next one: mvn -Pdistribution-unix,man-pages -pl opendj-doc-generated-ref
                              asciidoctor:process-asciidoc@check-attribute-references, after a build of this
                              module (both profiles activate by themselves only on Linux). Asciidoctor names
                              no file for a missing attribute: it is the page of the "Converted" line that
                              follows the warning. A literal {name} in text is written \{name}.
                              Maven project properties are attributes here (product.name is {product-name})
                              but not on the site, so a page that uses one passes this check and still shows
                              the braces there.
                              The partials directory is rendered too, although Antora only includes its files
                              and never publishes them as pages: the plugin has no exclusion, and a name that
                              starts with _ would break the includes. So every man page is checked twice, and
                              a partial that relies on an attribute of the chapter that includes it fails
                              here although the site resolves it.
                            -->
                            <execution>
                                <id>check-attribute-references</id>
                                <phase>verify</phase>
                                <goals>
                                    <goal>process-asciidoc</goal>
                                </goals>
                                <configuration>
                                    <requires>
                                        <require>${project.basedir}/src/main/resources/asciidoc/extensions/nested-open-block.rb</require>
                                        <require>${project.basedir}/src/main/resources/asciidoc/extensions/literal-attribute-references.rb</require>
                                    </requires>
                                    <backend>html5</backend>
                                    <sourceDirectory>${project.build.directory}/asciidoc/source</sourceDirectory>
                                    <outputDirectory>${project.build.directory}/asciidoc/attribute-check</outputDirectory>
                                    <preserveDirectories>true</preserveDirectories>
                                    <relativeBaseDir>true</relativeBaseDir>
                                    <sourcemap>true</sourcemap>
                                    <attributes>
                                        <attribute-missing>warn</attribute-missing>
                                        <literal-attribute-sources>${project.build.directory}/asciidoc/source</literal-attribute-sources>
                                    </attributes>
                                    <logHandler>
                                        <failIf>
                                            <severity>WARN</severity>
                                            <containsText>attribute</containsText>
                                        </failIf>
                                    </logHandler>
                                </configuration>
                            </execution>
                        </executions>
                        <dependencies>
                            <dependency>
                                <groupId>org.asciidoctor</groupId>
                                <artifactId>asciidoctorj</artifactId>
                                <version>${asciidoctorj.version}</version>
                            </dependency>
                        </dependencies>
                    </plugin>
                    <!-- Format man pages -->
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-monitoring.adoc
@@ -79,7 +79,7 @@
To run the OpenDMK installer, use the self-extracting .jar:
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ java -jar ~/Downloads/opendmk-1.0-b02-*.jar
$ cd ~/Downloads/
@@ -150,7 +150,7 @@
----
Use a command such as `snmpwalk` to check that the SNMP listen port works:
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ snmpwalk -v 2c -c OpenDJ@OpenDJ localhost:11161
SNMPv2-SMI::mib-2.66.1.1.1.1 = STRING: "OpenDJ {opendj-version}..."
@@ -248,7 +248,7 @@
The `status` command takes administrative credentials to read the configuration, as does the control panel:
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ status --bindDN "cn=Directory Manager" --bindPassword password
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-pwd-policy.adoc
@@ -12,7 +12,7 @@
  information: "Portions copyright [year] [name of copyright owner]".
 
  Copyright 2017 ForgeRock AS.
  Portions Copyright 2024 3A Systems LLC.
  Portions Copyright 2024-2026 3A Systems LLC.
////
:figure-caption!:
@@ -1009,7 +1009,7 @@
 --authzID "u:bjensen" \
 --newPassword '!ABcd$%^'
----
In the preceding example, the character set of ASCII punctuation, ``!\"#\$%&\'\(\)*+,-./:\;\\<=\>?@[\\]^_\`{\|}~``, is hard to read because of all the escape characters. In practice it can be easier to enter sequences like that by using `dsconfig` in interactive mode, and letting it do the escaping for you. You can also use the `--commandFilePath {path}` option to save the result of your interactive session to a file for use in scripts later.
In the preceding example, the character set of ASCII punctuation, ``!\"#\$%&\'\(\)*+,-./:\;\\<=\>?@[\\]^_\`{\|}~``, is hard to read because of all the escape characters. In practice it can be easier to enter sequences like that by using `dsconfig` in interactive mode, and letting it do the escaping for you. You can also use the `--commandFilePath \{path}` option to save the result of your interactive session to a file for use in scripts later.
An attempt to set an invalid password fails as shown in the following example:
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-troubleshooting.adoc
@@ -808,7 +808,7 @@
When you cannot resolve a problem yourself, and want to ask for help, clearly identify the problem and how you reproduce it, and also the version of OpenDJ you use to reproduce the problem. The version includes both a version number and also a build time stamp:
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ dsconfig --version
OpenDJ {opendj-version}
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc
@@ -383,7 +383,7 @@
. Run the `status` command, described in xref:../reference/admin-tools-ref.adoc#status-1[status(1)] in the __Reference__, to make sure your OpenDJ server is working as expected as shown in the following example:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ /path/to/opendj/bin/status
@@ -437,7 +437,7 @@
. Install the OpenDJ directory server package. Use `apt-get install ./<file>.deb` (rather than `dpkg -i`) so the required Java runtime dependency (`default-jre-headless`) is resolved and installed automatically:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ sudo apt-get install ./opendj_{opendj-version}-1_all.deb
----
@@ -463,7 +463,7 @@
. (Optional)  Check OpenDJ directory server status:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ systemctl is-active opendj
active
@@ -525,7 +525,7 @@
. Install the OpenDJ directory server package. Use `dnf install ./<file>.rpm` (rather than `rpm -i`) so the required Java runtime dependency (`java-headless >= 11`) is resolved and installed automatically:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
# dnf install ./opendj-{opendj-version}-1.noarch.rpm
Post Install - initial install
@@ -552,7 +552,7 @@
. (Optional)  Check OpenDJ directory server status:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
# systemctl is-active opendj
active
@@ -623,7 +623,7 @@
* Silent: run the following command (optionally set the installation directory with the `OPENDJ` property):
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
C:\> msiexec /i opendj-{opendj-version}.msi /quiet OPENDJ="C:\opendj"
----
@@ -701,7 +701,7 @@
. Prepare an installation script:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ cat /net/install/dj/1/setup.sh
#!/bin/sh
@@ -756,7 +756,7 @@
. Run your installation script:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ /net/install/dj/1/setup.sh
Archive:  /net/install/dj/opendj-{opendj-version}.zip
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-uninstall.adoc
@@ -176,7 +176,7 @@
. Uninstall the package, either through __Settings > Apps__ (or __Control Panel > Programs and Features__) by selecting OpenDJ and choosing Uninstall, or from the command-line:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
C:\> msiexec /x opendj-{opendj-version}.msi /quiet
----
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc
@@ -153,7 +153,7 @@
====
The following example upgrades an OpenDJ 2.6.3 directory server, backing up the current server directory in case the upgrade process fails. In this example, the server properties are updated to use Java 11, and the Local DB backend is migrated to a JE backend:
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ cd /path/to/
$ sed -e "s/default.java-home=.*/default.java-home=\/path\/to\/jdk11/" \
@@ -268,7 +268,7 @@
. Install the newer package (GUI or silent). The installer detects the existing installation â€” the location recorded in the registry by a previous x64 package, or the default directory of the older 32-bit package (`C:\Program Files (x86)\OpenDJ`) â€” and installs into the same directory, so your configured instance data (`config`, `db`, `logs`) is kept and only the program files are replaced. If the older server was installed in a custom directory the installer cannot detect, select that directory in the wizard or pass it explicitly on the command line: rather than installing a fresh server into the default directory while emptying the old one, the installer refuses to continue whenever nothing has recorded where the old server lives and the directory it is about to install into holds no OpenDJ server -- which also catches a mistyped directory name. That refusal also covers an old server that really is installed in `C:\Program Files\OpenDJ`, because the 32-bit packages recorded no location at all â€” and that one case the wizard cannot resolve: choosing the default directory in the wizard leaves the installer with the same values it would have had if you had chosen nothing, so pass `OPENDJ` on the command line instead â€” it can be given with or without `/quiet`, so a wizard installation takes it just as a silent one does. The installer further refuses to install into a directory other than the one it detected, unless the directory you name holds an OpenDJ server itself (see the note below): it replaces an installation in place and cannot move one, so uninstall the existing server first if you want it somewhere else.
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
C:\> msiexec /i opendj-{opendj-version}.msi /quiet OPENDJ="C:\path\to\opendj"
----
opendj-doc-generated-ref/src/main/asciidoc/man-pages/man-windows-service.adoc
@@ -13,7 +13,7 @@
  information: "Portions Copyright [year] [name of copyright owner]".
  Copyright 2015-2016 ForgeRock AS.
  Portions Copyright 2024 3A Systems LLC.
  Portions Copyright 2024-2026 3A Systems LLC.
////
@@ -28,7 +28,7 @@
windows-service - register OpenDJ as a Windows Service
== Synopsis
`windows-service` {options}
`windows-service` \{options}
== Description
This utility can be used to run OpenDJ directory server as a Windows Service.
opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-writing-plugins.adoc
@@ -170,7 +170,7 @@
. Install the example plugin in OpenDJ directory server:
+
[source, console, subs="attributes"]
[source, console, subs="+attributes"]
----
$ cd /path/to/opendj
opendj-doc-generated-ref/src/main/resources/asciidoc/extensions/literal-attribute-references.rb
New file
@@ -0,0 +1,92 @@
# 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.
# Warns about an attribute reference that a verbatim block publishes as literal text.
#
# A listing, literal or passthrough block replaces {name} only when its subs include
# attributes (subs="+attributes"). Without them the braces reach the page as they are,
# and Asciidoctor says nothing: `unzip opendj-{opendj-version}.zip` was published that
# way. A literal table cell (l|) never substitutes attributes and takes no subs. A
# reference to an attribute that is not defined at all, outside such a block, is
# Asciidoctor's own warning once attribute-missing is set to warn.
#
# A reference is reported when its name is an attribute at that point of the document,
# an intrinsic one such as {nbsp}, or one that an attribute entry anywhere under the
# directory named by the literal-attribute-sources attribute sets. The last catches a
# page that neither defines the attribute nor substitutes it, and since the directory
# holds the page itself, it also counts a name that the page has unset by the time the
# block stands: such braces are written \{name} in a block with subs="+attributes".
# Braces around any other name are meant literally - {SSHA} password values, {cn} in a
# MakeLDIF template - and are left alone. Without attribute subs a backslash does not
# escape the reference, so \{name} is published with its backslash and is reported too.
require 'set'
class LiteralAttributeReferences < Asciidoctor::Extensions::TreeProcessor
  include Asciidoctor::Logging
  ReferenceRx = /(\\)?\{(\w[\w-]*)\}/
  EntryRx = /^:(\w[\w-]*):/
  @names_by_dir = {}
  # The names that an attribute entry sets in some .adoc file under dir. The directory is
  # the base of the glob, not a part of the pattern, so braces in its path match as such.
  def self.names_in dir
    @names_by_dir[dir] ||= Dir.glob('**/*.adoc', base: dir).each_with_object(Set.new) do |path, names|
      File.foreach((File.join dir, path), encoding: 'UTF-8') {|line| names << $1.downcase if EntryRx =~ line }
    end
  end
  def process document
    dir = document.attr 'literal-attribute-sources'
    names = dir ? (LiteralAttributeReferences.names_in dir) : Set.new
    # The parser has already reset the document attributes to the header, so the entries
    # of the body are replayed in document order, as the converter does, on a copy of
    # them. The document itself is left as the converter expects it: playing the entries
    # back on it would also carry the compat mode of the last one over to the converter.
    # An AsciiDoc table cell is a document of its own.
    attributes_of = Hash.new {|copies, doc| copies[doc] = doc.attributes.dup }
    document.find_by traverse_documents: true do |block|
      attributes = attributes_of[block.document]
      (block.attributes[:attribute_entries] || []).each do |entry|
        entry.negate ? (attributes.delete entry.name) : (attributes[entry.name] = entry.value)
      end
      if Asciidoctor::Table::Cell === block
        # The text of a literal cell only escapes special characters, so its braces stay.
        check block, block.text, attributes, names, 'literal table cell', 'use an a| cell with a listing that has subs="+attributes"' if block.content_model == :verbatim
      elsif Asciidoctor::Block === block &&
          (block.content_model == :verbatim || block.content_model == :raw) && !(block.subs.include? :attributes)
        # subs="attributes" would replace the default subs of the block, so the advice adds to them.
        check block, (block.lines.join Asciidoctor::LF), attributes, names, %(#{block.context} block), 'add subs="+attributes"'
      end
      false
    end
    nil
  end
  def check block, text, attributes, names, what, advice
    text.scan(ReferenceRx) do |escaped, name|
      key = name.downcase
      next unless (attributes.key? key) || (names.include? key) || (Asciidoctor::INTRINSIC_ATTRIBUTES.key? key)
      message = escaped ?
          %(\\{#{name}} is published with its backslash: the #{what} does not substitute attributes, #{advice}) :
          %(attribute {#{name}} is published as literal text: the #{what} does not substitute attributes, #{advice})
      logger.warn message_with_context message, source_location: block.source_location
    end
  end
end
Asciidoctor::Extensions.register do
  tree_processor LiteralAttributeReferences
end
opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/LiteralAttributeReferencesTest.java
New file
@@ -0,0 +1,188 @@
/*
 * 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.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;
import org.asciidoctor.Asciidoctor;
import org.asciidoctor.Options;
import org.asciidoctor.SafeMode;
import org.asciidoctor.log.LogRecord;
import org.asciidoctor.log.Severity;
import org.forgerock.testng.ForgeRockTestCase;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
 * Renders pages with the configuration of the check-attribute-references execution of this module's
 * pom - its extensions, its attributes and its failIf - and checks which of them would fail the build.
 * Reading the configuration from the pom keeps the execution itself under test, not only
 * literal-attribute-references.rb.
 */
@Test
public class LiteralAttributeReferencesTest extends ForgeRockTestCase {
    private static final String EXECUTION = "check-attribute-references";
    /** A page that unsets an attribute and then names it in a listing, kept among the sources. */
    private static final String UNSET_PAGE = "= Title\n:u: 1\n\n== Section\n:u!:\n\n----\nx-{u}\n----\n";
    private final List<LogRecord> records = new ArrayList<>();
    private final Map<String, Object> attributes = new LinkedHashMap<>();
    private Asciidoctor asciidoctor;
    private String backend;
    private boolean sourcemap;
    private Severity failSeverity;
    private String failText;
    @BeforeClass
    public void readExecution() throws Exception {
        final File module = new File(System.getProperty("basedir", "."));
        // The pre-processed sources sit under the build directory. Braces and brackets in its path
        // must not turn it into a glob pattern.
        final Path buildDirectory = Files.createTempDirectory(
                Files.createDirectories(new File(module, "target").toPath()), "attribute-check-{1}[1]-");
        final Path sources = Files.createDirectories(buildDirectory.resolve("asciidoc/source/other-guide"));
        Files.write(sources.resolve("chap-other.adoc"), ":elsewhere: 1\n".getBytes(StandardCharsets.UTF_8));
        // The build renders the pages of the directory it collects the entries from, so a page is among them.
        Files.write(sources.resolve("chap-unset.adoc"), UNSET_PAGE.getBytes(StandardCharsets.UTF_8));
        final XPath xpath = XPathFactory.newInstance().newXPath();
        final Element configuration = (Element) xpath.evaluate(
                "//execution[id='" + EXECUTION + "']/configuration",
                DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new File(module, "pom.xml")),
                XPathConstants.NODE);
        assertThat(configuration).as("configuration of the " + EXECUTION + " execution").isNotNull();
        // The fixture stands for both directories, which holds only while they are one.
        assertThat(xpath.evaluate("sourceDirectory", configuration).trim())
                .as("the check renders the directory whose attribute entries it collects")
                .isEqualTo(xpath.evaluate("attributes/literal-attribute-sources", configuration).trim());
        asciidoctor = Asciidoctor.Factory.create();
        asciidoctor.registerLogHandler(records::add);
        final NodeList requires = (NodeList) xpath.evaluate("requires/require", configuration, XPathConstants.NODESET);
        for (int i = 0; i < requires.getLength(); i++) {
            asciidoctor.requireLibrary(requires.item(i).getTextContent().trim()
                    .replace("${project.basedir}", module.getAbsolutePath()));
        }
        final NodeList entries = (NodeList) xpath.evaluate("attributes/*", configuration, XPathConstants.NODESET);
        for (int i = 0; i < entries.getLength(); i++) {
            final Node entry = entries.item(i);
            attributes.put(entry.getNodeName(), entry.getTextContent().trim()
                    .replace("${project.build.directory}", buildDirectory.toString()));
        }
        backend = xpath.evaluate("backend", configuration);
        sourcemap = Boolean.parseBoolean(xpath.evaluate("sourcemap", configuration));
        failSeverity = Severity.valueOf(xpath.evaluate("logHandler/failIf/severity", configuration));
        failText = xpath.evaluate("logHandler/failIf/containsText", configuration);
    }
    @AfterClass(alwaysRun = true)
    public void shutdown() {
        if (asciidoctor != null) {
            asciidoctor.shutdown();
        }
    }
    /** Returns the messages that make the execution fail the build on this page. */
    private List<String> failures(final String page) {
        records.clear();
        asciidoctor.convert(page, Options.builder()
                .safe(SafeMode.UNSAFE)
                .backend(backend)
                .sourcemap(sourcemap)
                .attributes(new LinkedHashMap<>(attributes))
                .toFile(false)
                .build());
        return records.stream()
                .filter(r -> r.getSeverity().ordinal() >= failSeverity.ordinal() && r.getMessage().contains(failText))
                .map(LogRecord::getMessage)
                .collect(Collectors.toList());
    }
    @DataProvider
    public Object[][] failingPages() {
        return new Object[][] {
            { ":v: 1\n\n----\nunzip x-{v}.zip\n----\n",
              "attribute {v} is published as literal text: the listing block does not substitute attributes, "
                      + "add subs=\"+attributes\"" },
            { ":v: 1\n\n literal x-{v}\n", "attribute {v} is published as literal text: the literal block" },
            { ":v: 1\n\n++++\n<p>{v}</p>\n++++\n", "attribute {v} is published as literal text: the pass block" },
            { ":v: 1\n\n|===\nl|cell x-{v}\n|===\n",
              "attribute {v} is published as literal text: the literal table cell does not substitute attributes, "
                      + "use an a| cell with a listing that has subs=\"+attributes\"" },
            { ":v: 1\n\n[cols=\"1l\"]\n|===\n|cell x-{v}\n|===\n", "the literal table cell" },
            { ":v: 1\n\n|===\na|\n----\nx-{v}\n----\n|===\n", "attribute {v} is published as literal text: the listing" },
            { ":v: 1\n\n----\nunzip x-\\{v}.zip\n----\n", "\\{v} is published with its backslash: the listing block" },
            { "----\nPATH{nbsp}x\n----\n", "attribute {nbsp} is published as literal text" },
            // A body entry counts from where it stands, not only a header one.
            { "= Title\n\n== Section\n:late: 1\n\n----\nx-{late}\n----\n", "attribute {late} is published as literal text" },
            // A page that neither defines nor substitutes an attribute another page defines.
            { "----\nx-{elsewhere}\n----\n", "attribute {elsewhere} is published as literal text" },
            // An entry of the page itself counts, even where the page has unset the attribute.
            { UNSET_PAGE, "attribute {u} is published as literal text" },
            { "tool {undefinedthing}\n", "undefinedthing" },
            // The converter starts from the compat mode of the header, not from that of the last entry.
            { "= Title\n\n`{undefinedthing}`\n\n:compat-mode:\n\ny\n", "undefinedthing" },
        };
    }
    @Test(dataProvider = "failingPages")
    public void pageFailsTheBuild(final String page, final String message) {
        assertThat(failures(page)).as(page).anySatisfy(failure -> assertThat(failure).contains(message));
    }
    @DataProvider
    public Object[][] passingPages() {
        return new Object[][] {
            { ":v: 1\n\n[subs=\"+attributes\"]\n----\nunzip x-{v}.zip\n----\n" },
            { ":v: 1\n\n[subs=\"+attributes\"]\n----\nunzip x-\\{v}.zip\n----\n" },
            { ":v: 1\n\nunzip x-{v}.zip and \\{v}\n" },
            { ":v: 1\n\n|===\na|\n[subs=\"+attributes\"]\n----\nx-{v}\n----\n|===\n" },
            // Braces that are no attribute anywhere are meant literally.
            { "----\nuserPassword: {SSHA}abc\ncn: {cn}\n----\n" },
            { "|===\nl|{SSHA}abc\n|===\n" },
            // A built-in attribute that no entry sets is no longer one where the body has unset it.
            { "= Title\n\n== Section\n:figure-caption!:\n\n----\nx-{figure-caption}\n----\n" },
            // The converter starts again from the header: a reference before a body unset still resolves.
            { "= Title\n:v: 1\n\nx {v}\n\n:v!:\n\ny\n" },
            { "= Title\n:compat-mode:\n\n`{cn}`\n\n:compat-mode!:\n\ny\n" },
        };
    }
    @Test(dataProvider = "passingPages")
    public void pagePassesTheBuild(final String page) {
        assertThat(failures(page)).as(page).isEmpty();
    }
}