View Javadoc
1   ///////////////////////////////////////////////////////////////////////////////////////////////
2   // checkstyle: Checks Java source code and other text files for adherence to a set of rules.
3   // Copyright (C) 2001-2026 the original author or authors.
4   //
5   // This library is free software; you can redistribute it and/or
6   // modify it under the terms of the GNU Lesser General Public
7   // License as published by the Free Software Foundation; either
8   // version 2.1 of the License, or (at your option) any later version.
9   //
10  // This library is distributed in the hope that it will be useful,
11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13  // Lesser General Public License for more details.
14  //
15  // You should have received a copy of the GNU Lesser General Public
16  // License along with this library; if not, write to the Free Software
17  // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
18  ///////////////////////////////////////////////////////////////////////////////////////////////
19  
20  package com.puppycrawl.tools.checkstyle.internal;
21  
22  import static com.google.common.truth.Truth.assertWithMessage;
23  
24  import java.beans.PropertyDescriptor;
25  import java.io.File;
26  import java.io.IOException;
27  import java.nio.file.Files;
28  import java.nio.file.Path;
29  import java.util.ArrayList;
30  import java.util.Arrays;
31  import java.util.Collections;
32  import java.util.HashMap;
33  import java.util.HashSet;
34  import java.util.List;
35  import java.util.Locale;
36  import java.util.Map;
37  import java.util.Set;
38  import java.util.stream.Collectors;
39  import java.util.stream.Stream;
40  
41  import javax.xml.parsers.ParserConfigurationException;
42  
43  import org.apache.commons.beanutils.PropertyUtils;
44  import org.junit.jupiter.api.Test;
45  import org.w3c.dom.Document;
46  import org.w3c.dom.Element;
47  import org.w3c.dom.NodeList;
48  import org.xml.sax.SAXException;
49  
50  import com.puppycrawl.tools.checkstyle.AbstractPathTestSupport;
51  import com.puppycrawl.tools.checkstyle.bdd.InlineConfigParser;
52  import com.puppycrawl.tools.checkstyle.bdd.TestInputConfiguration;
53  import com.puppycrawl.tools.checkstyle.bdd.TestInputViolation;
54  import com.puppycrawl.tools.checkstyle.internal.utils.CheckUtil;
55  import com.puppycrawl.tools.checkstyle.internal.utils.XdocUtil;
56  import com.puppycrawl.tools.checkstyle.internal.utils.XmlUtil;
57  
58  public class XdocsExampleFileTest {
59  
60      private static final Set<String> COMMON_PROPERTIES = Set.of(
61          "severity",
62          "id",
63          "fileExtensions",
64          "tabWidth",
65          "fileContents",
66          "tokens",
67          "javadocTokens",
68          "violateExecutionOnNonTightHtml"
69      );
70  
71      /**
72       * Modules using the external-XML-config verification path
73       * (verifyWithExternalXmlConfig) instead of InlineConfigParser's inline-comment
74       * config format. Their config lives in a separate external XML file rather
75       * than embedded as a comment block in the resource file itself, so
76       * InlineConfigParser cannot parse them at all - this is a scope limit, not a
77       * pending duplicate-violation finding. Remove an entry here only once a
78       * parser for that format is added to this test.
79       *
80       * <p>See <a href="https://github.com/checkstyle/checkstyle/issues/18809">...</a>
81       * for background on why these checks use a separate config mechanism (their
82       * "config" often overlaps with literal file-header content being validated).
83       */
84      private static final Set<String> UNSUPPORTED_CONFIG_FORMAT_MODULES = Set.of(
85          "checks/header/header/",
86          "checks/header/multifileregexpheader/",
87          "checks/header/regexpheader/",
88          "checks/imports/importcontrol/"
89      );
90  
91      /**
92       * Modules with confirmed or as-yet-unreviewed duplicate-behavior examples,
93       * temporarily suppressed until each is reviewed and either fixed (the examples
94       * are made behaviorally distinct) or confirmed intentional and documented.
95       *
96       * <p>Remove an entry here once its examples are fixed or the duplication is
97       * confirmed acceptable and reflected in xdocs commentary. Do not add new
98       * entries without reviewing the flagged examples first - see
99       * <a href="https://github.com/checkstyle/checkstyle/issues/21072">...</a>
100      */
101     private static final Set<String> SUPPRESSED_UNIQUENESS_CHECK_MODULES = Set.of(
102         "checks/coding/hiddenfield/",
103         "checks/coding/returncount/",
104         "checks/imports/avoidstarimport/",
105         "checks/javadoc/javadocvariable/",
106         "checks/javadoc/missingjavadoctype/",
107         "checks/naming/illegalidentifiername/",
108         "checks/naming/patternvariablename/",
109         "checks/outertypefilename/",
110         "checks/regexp/regexpmultiline/",
111         "checks/regexp/regexpsingleline/"
112     );
113 
114     /**
115      * Modules whose numerically-first example (Example1) is not the default-config
116      * example, temporarily suppressed pending reordering or renumbering of examples.
117      *
118      * <p>Until: <a href="https://github.com/checkstyle/checkstyle/issues/21207">...</a>
119      */
120     private static final Set<String> MODULES_WITHOUT_DEFAULT_FIRST_EXAMPLE = Set.of(
121         "checks/translation"
122     );
123 
124     @Test
125     public void testAllCheckPropertiesAreUsedInXdocsExamples() throws Exception {
126         final Map<String, Set<String>> usedPropertiesByCheck =
127             XdocUtil.extractUsedPropertiesFromXdocsExamples();
128         final List<String> failures = new ArrayList<>();
129 
130         for (Class<?> checkClass : CheckUtil.getCheckstyleChecks()) {
131             final String checkSimpleName = checkClass.getSimpleName();
132 
133             final Set<String> definedProperties = Arrays.stream(
134                     PropertyUtils.getPropertyDescriptors(checkClass))
135                 .filter(descriptor -> descriptor.getWriteMethod() != null)
136                 .map(PropertyDescriptor::getName)
137                 .filter(property -> !COMMON_PROPERTIES.contains(property))
138                 .collect(Collectors.toUnmodifiableSet());
139 
140             final Set<String> usedProperties =
141                 usedPropertiesByCheck.getOrDefault(checkSimpleName, Collections.emptySet());
142 
143             for (String property : definedProperties) {
144                 if (!usedProperties.contains(property)) {
145                     failures.add("Missing property in xdoc: '"
146                             + property + "' of " + checkSimpleName);
147                 }
148             }
149         }
150         if (!failures.isEmpty()) {
151             assertWithMessage("Xdocs are missing properties:\n" + String.join("\n", failures))
152                     .fail();
153         }
154     }
155 
156     @Test
157     public void testAllExampleFilesHaveCorrespondingTestMethods() throws Exception {
158         final Path examplesResources = Path.of("src/xdocs-examples/resources");
159         final Path examplesNonCompilable = Path.of("src/xdocs-examples/resources-noncompilable");
160         final Path examplesTestRoot = Path.of(
161             "src/xdocs-examples/java/com/puppycrawl/tools/checkstyle/checks");
162         final List<String> failures = new ArrayList<>();
163 
164         try (Stream<Path> testFiles = Files.walk(examplesTestRoot)) {
165             testFiles
166                 .filter(path -> path.toString().endsWith("ExamplesTest.java"))
167                 .forEach(testFile -> {
168                     try {
169                         scanFile(testFile, examplesResources, examplesNonCompilable, failures);
170                     }
171                     catch (IOException exception) {
172                         throw new IllegalStateException("Error processing: "
173                                      + testFile, exception);
174                     }
175                 });
176         }
177         if (!failures.isEmpty()) {
178             assertWithMessage("Example files are missing corresponding test methods:\n"
179                     + String.join("\n", failures))
180                     .fail();
181         }
182     }
183 
184     @Test
185     public void testAllExampleFilesAreReferencedInXdocs() throws Exception {
186         final Set<String> referencedPaths = collectReferencedExamplePaths();
187         final Path xdocsExamplesBase = Path.of("src/xdocs-examples");
188         final List<Path> exampleRoots = List.of(
189             xdocsExamplesBase.resolve("resources"),
190             xdocsExamplesBase.resolve("resources-noncompilable")
191         );
192         final List<String> failures = new ArrayList<>();
193 
194         for (Path root : exampleRoots) {
195             if (Files.exists(root)) {
196                 try (Stream<Path> paths = Files.walk(root)) {
197                     paths
198                         .filter(path -> {
199                             final String fileName = path.getFileName().toString();
200                             return Files.isRegularFile(path)
201                                 && (fileName.startsWith("Example")
202                                     || fileName.startsWith("UseCase"));
203                         })
204                         .forEach(exampleFile -> {
205                             final String relative = xdocsExamplesBase
206                                 .relativize(exampleFile)
207                                 .toString()
208                                 .replace(File.separatorChar, '/');
209                             if (!referencedPaths.contains(relative)) {
210                                 failures.add(relative);
211                             }
212                         });
213                 }
214             }
215         }
216 
217         if (!failures.isEmpty()) {
218             assertWithMessage(
219                 "The following example files are not referenced in any xml.template file:\n"
220                     + String.join("\n", failures))
221                 .fail();
222         }
223     }
224 
225     @Test
226     public void testAllModuleExamplesAreBehaviorallyUnique() throws Exception {
227         final Path examplesTestRoot = Path.of(
228                 "src/xdocs-examples/java/com/puppycrawl/tools/checkstyle/checks");
229         final Path examplesResources = Path.of("src/xdocs-examples/resources");
230         final Path examplesNonCompilable = Path.of("src/xdocs-examples/resources-noncompilable");
231         final List<String> failures = new ArrayList<>();
232 
233         try (Stream<Path> testFiles = Files.walk(examplesTestRoot)) {
234             testFiles
235                     .filter(path -> path.toString().endsWith("ExamplesTest.java"))
236                     .forEach(testFile -> {
237                         try {
238                             checkUniquenessForModule(testFile, examplesResources,
239                                     examplesNonCompilable, failures);
240                         }
241                         catch (IOException exception) {
242                             throw new IllegalStateException("Error processing: "
243                                     + testFile, exception);
244                         }
245                     });
246         }
247 
248         if (!failures.isEmpty()) {
249             assertWithMessage("Found examples with duplicate behavior:\n"
250                     + String.join("\n", failures))
251                     .fail();
252         }
253     }
254 
255     /**
256      * Tests that when a module has a default-config example (module element with zero
257      * configured properties), that example is the numerically-first one (Example1) in
258      * its directory. Convention is that a module's baseline/default behavior should be
259      * the first thing a reader encounters, with later examples layering on property
260      * configuration - this catches cases where the default example exists but is
261      * out of order.
262      *
263      * <p>This is distinct from testEveryModuleHasDefaultConfigExample, which
264      * only checks that a default-config example exists somewhere; a module can pass
265      * that test while failing this one if its default example isn't Example1.
266      *
267      * @throws IOException if an I/O error occurs
268      */
269     @Test
270     public void testDefaultConfigExampleIsFirst() throws IOException {
271         final List<String> violations = Collections.synchronizedList(new ArrayList<>());
272 
273         try (Stream<Path> pathStream = Files.walk(
274                 XdocsExamplesAstConsistencyTest.XDOCS_ROOT)) {
275             pathStream
276                 .filter(Files::isDirectory)
277                 .filter(XdocsExamplesAstConsistencyTest::isModuleDirectory)
278                 .parallel()
279                 .forEach(dir -> processDirectoryForDefaultConfigOrderCheck(dir, violations));
280         }
281 
282         final String message = formatDefaultConfigOrderViolationsMessage(violations);
283 
284         assertWithMessage(message)
285             .that(violations)
286             .isEmpty();
287     }
288 
289     private static Set<String> collectReferencedExamplePaths() throws Exception {
290         final Set<String> referenced = new HashSet<>();
291 
292         for (Path template : XdocUtil.getXdocsTemplatesFilePaths()) {
293             final String input = Files.readString(template);
294             final Document document = XmlUtil.getRawXml(template.toString(), input, input);
295             final NodeList macros = document.getElementsByTagName("macro");
296 
297             for (int idx = 0; idx < macros.getLength(); idx++) {
298                 final Element macro = (Element) macros.item(idx);
299                 if ("example".equals(macro.getAttribute("name"))) {
300                     final String path = getMacroParamValue(macro, "path");
301                     if (path != null && !path.isEmpty()) {
302                         referenced.add(normalizePath(path));
303                     }
304                 }
305             }
306         }
307         return referenced;
308     }
309 
310     private static String getMacroParamValue(Element macro, String paramName) {
311         String result = null;
312         final NodeList params = macro.getElementsByTagName("param");
313 
314         for (int idx = 0; idx < params.getLength(); idx++) {
315             final Element param = (Element) params.item(idx);
316             if (paramName.equals(param.getAttribute("name"))) {
317                 result = param.getAttribute("value");
318                 break;
319             }
320         }
321         return result;
322     }
323 
324     private static String normalizePath(String path) {
325         String result = path;
326         if (result.startsWith("/")) {
327             result = result.substring(1);
328         }
329         return result;
330     }
331 
332     private static void scanFile(Path testFile, Path examplesResources, Path examplesNonCompilable,
333             List<String> failures)
334             throws IOException {
335         final String testContent = Files.readString(testFile);
336 
337         final String className = Path.of("src/xdocs-examples/java").toAbsolutePath()
338                 .relativize(testFile.toAbsolutePath()).toString()
339                 .replace(File.separator, ".")
340                 .replaceFirst("\\.java$", "");
341 
342         try {
343             final Class<?> testClass = Class.forName(className);
344             final AbstractPathTestSupport instance = (AbstractPathTestSupport) testClass
345                     .getDeclaredConstructor().newInstance();
346             final String packageLocation = instance.getPackageLocation();
347 
348             scanExampleDirectory(examplesResources.resolve(packageLocation),
349                     testContent, testFile, failures);
350             scanExampleDirectory(examplesNonCompilable.resolve(packageLocation),
351                     testContent, testFile, failures);
352         }
353         catch (ReflectiveOperationException exception) {
354             throw new IllegalStateException("Failed to instantiate " + className, exception);
355         }
356     }
357 
358     private static void scanExampleDirectory(Path exampleDir, String testContent,
359             Path testFile, List<String> failures) throws IOException {
360         if (Files.exists(exampleDir) && Files.isDirectory(exampleDir)) {
361             try (Stream<Path> exampleFiles = Files.list(exampleDir)) {
362                 exampleFiles
363                     .filter(path -> {
364                         final String fileName = path.getFileName()
365                                 .toString();
366                         return fileName.matches("Example\\d+\\.java");
367                     })
368                     .forEach(exampleFile -> {
369                         final String fileName = exampleFile.getFileName()
370                                 .toString();
371                         if (!testContent.contains("\"" + fileName + "\"")) {
372                             failures.add("Missing test for " + fileName + " in "
373                                         + testFile.getFileName());
374                         }
375                     });
376             }
377         }
378     }
379 
380     private static void checkUniquenessForModule(Path testFile, Path examplesResources,
381              Path examplesNonCompilable, List<String> failures) throws IOException {
382         final String className = Path.of("src/xdocs-examples/java").toAbsolutePath()
383                 .relativize(testFile.toAbsolutePath()).toString()
384                 .replace(File.separator, ".")
385                 .replaceFirst("\\.java$", "");
386 
387         try {
388             final Class<?> testClass = Class.forName(className);
389             final AbstractPathTestSupport instance = (AbstractPathTestSupport) testClass
390                     .getDeclaredConstructor().newInstance();
391             final String packageLocation = instance.getPackageLocation();
392 
393             checkUniquenessInDirectory(examplesResources.resolve(packageLocation), failures);
394             checkUniquenessInDirectory(examplesNonCompilable.resolve(packageLocation), failures);
395         }
396         catch (ReflectiveOperationException exception) {
397             throw new IllegalStateException("Failed to instantiate " + className, exception);
398         }
399     }
400 
401     private static void checkUniquenessInDirectory(Path exampleDir, List<String> failures)
402             throws IOException {
403         if (Files.exists(exampleDir) && Files.isDirectory(exampleDir)) {
404             final String normalizedDirPath = exampleDir.toString()
405                     .replace(File.separatorChar, '/') + "/";
406 
407             final boolean unsupportedFormat = UNSUPPORTED_CONFIG_FORMAT_MODULES.stream()
408                     .anyMatch(normalizedDirPath::endsWith);
409 
410             if (!unsupportedFormat) {
411                 final String moduleName = exampleDir.getFileName().toString();
412                 final boolean suppressed = SUPPRESSED_UNIQUENESS_CHECK_MODULES.stream()
413                         .anyMatch(normalizedDirPath::endsWith);
414                 final Map<String, List<String>> signatureToExamples = collectSignatures(
415                         exampleDir, suppressed, failures);
416 
417                 reportDuplicates(moduleName, suppressed, signatureToExamples, failures);
418             }
419         }
420     }
421 
422     private static Map<String, List<String>> collectSignatures(Path exampleDir,
423                boolean suppressed, List<String> failures) throws IOException {
424         final Map<String, List<String>> signatureToExamples = new HashMap<>();
425 
426         try (Stream<Path> exampleFiles = Files.list(exampleDir)) {
427             final List<Path> examples = exampleFiles
428                     .filter(path -> {
429                         return path.getFileName().toString()
430                                 .matches("Example\\d+\\.java");
431                     })
432                     .sorted()
433                     .toList();
434 
435             if (examples.size() >= 2) {
436                 for (Path exampleFile : examples) {
437                     final String signature = buildSignature(exampleFile, suppressed, failures);
438                     if (signature != null) {
439                         signatureToExamples
440                                 .computeIfAbsent(signature, key -> new ArrayList<>())
441                                 .add(exampleFile.getFileName().toString());
442                     }
443                 }
444             }
445         }
446 
447         return signatureToExamples;
448     }
449 
450     private static void reportDuplicates(String moduleName, boolean suppressed,
451              Map<String, List<String>> signatureToExamples, List<String> failures) {
452         if (!suppressed) {
453             signatureToExamples.forEach((signature, examples) -> {
454                 if (examples.size() > 1) {
455                     failures.add(String.format(Locale.ROOT,
456                             "Module '%s': examples %s produce identical violations (%s).",
457                             moduleName, examples, signature));
458                 }
459             });
460         }
461     }
462 
463     /**
464      * Builds a signature from an example's expected violations, restricted to the
465      * region between "// xdoc section - start" and "// xdoc section - end" (the
466      * only part actually rendered to users in the generated HTML), with line
467      * numbers normalized relative to the start marker.
468      *
469      * <p>Line number is included (not stripped) because all examples of a check
470      * are guaranteed structurally identical by AST (enforced separately by
471      * XdocsExamplesAstConsistencyTest) - only comments/config differ - so a given
472      * relative line number means the same structural position within the visible
473      * section across every example of that module, making it a meaningful part of
474      * the comparison rather than noise. Restricting to the visible section and
475      * normalizing against the start marker (rather than the absolute file line)
476      * avoids false collisions/false negatives caused by incidental whitespace or
477      * config-block padding above the marker, which is invisible to users and
478      * should never affect whether two examples look identical in the rendered
479      * docs - see review discussion on #21072.
480      *
481      * <p>Returns null if any violation message in the file is unspecified, if
482      * the file has zero violations within the visible section, or if the markers
483      * themselves cannot be found, since none of those are a reliable duplicate
484      * signal - see InlineConfigParser.SUPPRESSED_VALIDATE_MESSAGE_FILES /
485      * SUPPRESSED_CHECKS.
486      *
487      * @param suppressed whether this module is in SUPPRESSED_UNIQUENESS_CHECK_MODULES;
488      *     when true, parse failures are silently skipped instead of recorded, so
489      *     unrelated pre-existing parsing issues don't block review of the
490      *     duplicate-violation finding itself.
491      */
492     private static String buildSignature(Path exampleFile, boolean suppressed,
493              List<String> failures) {
494         String signature;
495         try {
496             final TestInputConfiguration parsed =
497                     InlineConfigParser.parse(exampleFile.toString());
498             final List<TestInputViolation> violations = parsed.violations();
499 
500             final boolean hasUnspecifiedMessage = violations.stream()
501                     .anyMatch(violation -> violation.message() == null);
502 
503             final int[] sectionBounds = findVisibleSectionBounds(exampleFile);
504 
505             if (hasUnspecifiedMessage || sectionBounds == null || violations.isEmpty()) {
506                 signature = null;
507             }
508             else {
509                 final int startLine = sectionBounds[0];
510                 final int endLine = sectionBounds[1];
511 
512                 final List<TestInputViolation> visibleViolations = violations.stream()
513                         .filter(violation -> {
514                             return violation.lineNo() > startLine
515                                     && violation.lineNo() < endLine;
516                         })
517                         .toList();
518 
519                 if (visibleViolations.isEmpty()) {
520                     signature = null;
521                 }
522                 else {
523                     signature = visibleViolations.stream()
524                             .sorted()
525                             .map(violation -> {
526                                 final int relativeLine = violation.lineNo() - startLine;
527                                 return relativeLine + ":" + violation.message();
528                             })
529                             .collect(Collectors.joining("|"));
530                 }
531             }
532         }
533         // -@cs[IllegalCatch] InlineConfigParser.parse declares "throws Exception";
534         catch (Exception exception) {
535             if (!suppressed) {
536                 failures.add("Failed to parse " + exampleFile + ": " + exception.getMessage());
537             }
538             signature = null;
539         }
540         return signature;
541     }
542 
543     /**
544      * Locates the 1-based line numbers of the "// xdoc section - start" and
545      * "// xdoc section - end" marker comments in a file.
546      *
547      * @return a two-element array {startLine, endLine}, or null if either marker
548      *     is missing (in which case the file is skipped from comparison rather
549      *     than guessed at).
550      */
551     private static int[] findVisibleSectionBounds(Path exampleFile) throws IOException {
552         final List<String> lines = Files.readAllLines(exampleFile);
553         int startLine = -1;
554         int endLine = -1;
555 
556         for (int index = 0; index < lines.size(); index++) {
557             final String trimmed = lines.get(index).trim();
558             if (XdocsExamplesAstConsistencyTest.XDOC_START_MARKER.equals(trimmed)) {
559                 startLine = index + 1;
560             }
561             else if (XdocsExamplesAstConsistencyTest.XDOC_END_MARKER.equals(trimmed)) {
562                 endLine = index + 1;
563             }
564         }
565 
566         int[] result = null;
567         if (startLine != -1 && endLine != -1) {
568             result = new int[] {startLine, endLine};
569         }
570         return result;
571     }
572 
573     /**
574      * Processes a single module directory: if the module has a default-config example
575      * anywhere, checks that its numerically-first example (Example1) is that default
576      * example.
577      *
578      * @param dir the directory to check
579      * @param violations a thread-safe list to collect any discovered violations
580      */
581     private static void processDirectoryForDefaultConfigOrderCheck(Path dir,
582                                                                    List<String> violations) {
583         try {
584             final List<Path> examples = new ArrayList<>(
585                 XdocsExamplesAstConsistencyTest.getExamplePropertyCoverageFiles(dir));
586             examples.addAll(XdocsExamplesAstConsistencyTest
587                 .getNonCompilableExamplePropertyCoverageFiles(dir));
588 
589             final String moduleName = XdocsExamplesAstConsistencyTest
590                 .toModuleClassSimpleName(dir.getFileName().toString());
591             final String relativePath = XdocsExamplesAstConsistencyTest.XDOCS_ROOT
592                 .relativize(dir).toString().replace(File.separatorChar, '/');
593 
594             if (moduleName != null && !examples.isEmpty()
595                 && !XdocsExamplesAstConsistencyTest.isModuleWithNoProperties(examples)
596                 && !MODULES_WITHOUT_DEFAULT_FIRST_EXAMPLE.contains(relativePath)) {
597                 final String xmlModuleName =
598                     XdocsExamplesAstConsistencyTest.stripCheckSuffix(moduleName);
599                 checkDefaultConfigExampleOrder(examples, xmlModuleName,
600                     relativePath, violations);
601             }
602         }
603         catch (IOException | ParserConfigurationException | SAXException exception) {
604             throw new IllegalStateException("Failed processing directory: " + dir, exception);
605         }
606     }
607 
608     /**
609      * Checks that a module's default-config example is its numerically-first one.
610      *
611      * @param examples the example files for the module
612      * @param xmlModuleName the module's simple name as it appears in the embedded XML
613      * @param relativePath the module directory path relative to XDOCS_ROOT
614      * @param violations a thread-safe list to collect any discovered violations
615      * @throws IOException if reading a file fails
616      * @throws ParserConfigurationException if a document builder cannot be created
617      * @throws SAXException if the XML content is malformed
618      */
619     private static void checkDefaultConfigExampleOrder(List<Path> examples,
620             String xmlModuleName, String relativePath, List<String> violations)
621             throws IOException, ParserConfigurationException, SAXException {
622         final Path firstExample = examples.stream()
623             .filter(example -> {
624                 return example.getFileName().toString()
625                     .matches("Example1(\\..+)?");
626             })
627             .findFirst()
628             .orElse(null);
629 
630         if (firstExample != null && !isDefaultConfig(firstExample, xmlModuleName)) {
631             boolean anyDefaultExists = false;
632             for (Path example : examples) {
633                 if (isDefaultConfig(example, xmlModuleName)) {
634                     anyDefaultExists = true;
635                     break;
636                 }
637             }
638 
639             if (anyDefaultExists) {
640                 violations.add("Directory: " + relativePath
641                     + "\nDefault-config example exists but is not "
642                     + firstExample.getFileName()
643                     + " (should be the first example).");
644             }
645         }
646     }
647 
648     /**
649      * Checks whether an example's module config block has zero configured properties.
650      *
651      * @param example the example file
652      * @param moduleName the module's simple name as it appears in the embedded XML
653      * @return true if the example demonstrates the default (zero-property) configuration
654      * @throws IOException if reading the file fails
655      * @throws ParserConfigurationException if a document builder cannot be created
656      * @throws SAXException if the XML content is malformed
657      */
658     private static boolean isDefaultConfig(Path example, String moduleName)
659             throws IOException, ParserConfigurationException, SAXException {
660         final String xmlBlock =
661             XdocsExamplesAstConsistencyTest.extractXmlConfigBlock(example);
662         final Element moduleElement;
663         if (xmlBlock == null) {
664             moduleElement = null;
665         }
666         else {
667             moduleElement = XdocsExamplesAstConsistencyTest
668                 .parseConfigModuleElement(xmlBlock, moduleName);
669         }
670         return moduleElement != null
671             && XdocsExamplesAstConsistencyTest.collectPropertyNames(moduleElement).isEmpty();
672     }
673 
674     /**
675      * Formats default-config-ordering violations into a single, readable error message.
676      *
677      * @param violations the list of violation strings
678      * @return a formatted string detailing all found ordering issues
679      */
680     private static String formatDefaultConfigOrderViolationsMessage(List<String> violations) {
681         final StringBuilder builder = new StringBuilder(1024);
682         if (!violations.isEmpty()) {
683             builder.append("Found ").append(violations.size())
684                 .append(" module(s) where the default-config example is not first.\n\n");
685 
686             violations.stream()
687                 .sorted()
688                 .forEach(violation -> builder.append(violation).append("\n\n"));
689         }
690         return builder.toString();
691     }
692 
693 }