From c7adcf3597e172f660420027a987dbe1252c548c Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 1 Aug 2026 08:33:37 +0100 Subject: [PATCH 1/2] ci(codeql): extract the render backends, not the engine alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan built one module, and the Java extractor sees only what the build compiles — so the PDFBox and POI parsing paths, SVG and image handling, font loading and the ZIP/OPC writers were not partly analysed, they were outside the scan entirely, and a green result said nothing about them. The build now covers every published module that carries code. `-am` compiles their dependencies from source, so the scanned set is the closure of the list. CodeQlScopeGuardTest derives the expectation instead of repeating it: a module the verify gate compiles that carries main sources must be in the scan. It also pins `-am`, whose loss would read as a formatting change and would quietly turn the named modules back into resolved jars. --- .github/workflows/ci.yml | 2 +- .github/workflows/codeql.yml | 25 +-- CHANGELOG.md | 7 + .../documentation/CodeQlScopeGuardTest.java | 144 ++++++++++++++++++ 4 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 142c2dd7..4b8b4800 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: # in qa) run in build-and-test below, which now also covers docs-only PRs. run: | ./mvnw -B -ntp clean \ - "-Dtest=EnginePdfBoundaryTest,DocumentationCoverageTest,CanonicalSurfaceGuardTest,PackageMapGuardTest,VersionConsistencyGuardTest,CiGuardListGuardTest" \ + "-Dtest=EnginePdfBoundaryTest,DocumentationCoverageTest,CanonicalSurfaceGuardTest,PackageMapGuardTest,VersionConsistencyGuardTest,CiGuardListGuardTest,CodeQlScopeGuardTest" \ test -pl :graph-compose-core changes: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 388a40ca..e60f4ab5 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -50,15 +50,22 @@ jobs: languages: ${{ matrix.language }} queries: security-and-quality - - name: Build (engine module) - # Scoped to the engine module alone: no `-am`, so nothing but core is built - # and nothing but core reaches the extractor. Since the 2.0 split - # graph-compose-core is one of several artifacts published to Maven Central, - # which makes this narrower than both the publish train and the canonical - # verify gate in ci.yml — the render backends and templates ship unscanned. - # Widening it to the code-bearing published modules is tracked for 2.2, - # together with a triage pass on the standing alert set. - run: ./mvnw -B -ntp -DskipTests -pl :graph-compose-core package + - name: Build (published code-bearing modules) + # The extractor sees what the build compiles, so the module list is the + # analysis scope. It covers every published module that carries code, which + # is where the untrusted input actually lands: PDFBox and POI parsing, the + # SVG and image paths, font loading, and the ZIP/OPC writers all live in the + # backends rather than in core. `-am` is required — the backends need core + # built from source, and without it Maven resolves the reactor siblings from + # the repository and compiles nothing. It also pulls the fonts and emoji + # modules in as dependencies, so the scanned set is the closure of this list, + # not the list itself. + # + # Keep this list in step with the verify gate in ci.yml. A module that ships + # and is not named here ships unscanned. + run: | + ./mvnw -B -ntp -DskipTests package -am \ + -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing - name: Perform CodeQL analysis uses: github/codeql-action/analyze@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f5090f7..ca8d33c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,13 @@ follow semantic versioning; release dates are ISO 8601. version and link the tag it names. `SECURITY.md`, `SUPPORT.md`, `ROADMAP.md` and `.github/` are scanned for the first time; historical records are skipped by path, so a new archived page is covered the day it lands. +- **Code scanning reaches the render backends.** The scan compiled the engine module + alone, and the Java extractor sees only what the build compiles — so the PDFBox and + POI parsing paths, the SVG and image handling, font loading and the ZIP/OPC writers + were not partially analysed, they were absent from the scan, with nothing in a green + result to say so. The build now covers every published module that carries code, and + a guard fails the build when a module the verify gate compiles is missing from the + scan, so the next module is covered the day it lands. - **The package map is derived from the source tree.** A backend was findable only if someone remembered to list it, and the backend-neutral fixed-layout SPI was missing from the contributing guide — the one document a reader consults before adding an diff --git a/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java b/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java new file mode 100644 index 00000000..d575a244 --- /dev/null +++ b/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java @@ -0,0 +1,144 @@ +package com.demcha.documentation; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Keeps the CodeQL analysis scope from falling behind the build. + * + *

The Java extractor sees whatever the build compiles, so the {@code -pl} list in the + * scanning workflow is the analysis scope — a module absent from it is not + * partially covered, it is not scanned at all. That is invisible: the job stays green, + * the badge stays green, and the alert list simply never mentions the module. It held + * for a full release, with the PDFBox, POI, SVG, font and ZIP/OPC paths — the code most + * exposed to untrusted input — outside the scan while core was analysed. + * + *

The expectation is derived rather than listed: every module the verify gate builds + * that carries production sources must also be built for the scan. A module added to + * the reactor and to CI is therefore covered on the day it lands, and a module dropped + * from the scan fails here rather than going quiet. + */ +class CodeQlScopeGuardTest { + + private static final Path PROJECT_ROOT = RepoRoot.get(); + private static final Path CI_WORKFLOW = PROJECT_ROOT.resolve(".github/workflows/ci.yml"); + private static final Path CODEQL_WORKFLOW = PROJECT_ROOT.resolve(".github/workflows/codeql.yml"); + + /** A {@code -pl} value: the comma-separated run of module selectors that follows it. */ + private static final Pattern MODULE_LIST = Pattern.compile("-pl\\s+(:[^\\s\\\\]+)"); + + @Test + void everyCodeBearingModuleTheVerifyGateBuildsIsAlsoScanned() throws IOException { + Set verified = modulesWithSources(selectorsFrom(CI_WORKFLOW, "clean verify")); + Set scanned = modulesWithSources(selectorsFrom(CODEQL_WORKFLOW, "package")); + + assertThat(verified) + .describedAs("no code-bearing module was found in the ci.yml verify gate — the " + + "gate's -pl list or the module layout moved, and this guard is comparing " + + "two empty sets") + .isNotEmpty(); + + Set unscanned = new TreeSet<>(verified); + unscanned.removeAll(scanned); + + assertThat(unscanned) + .describedAs("a published module that CI compiles but CodeQL does not: the " + + "extractor only sees what the build compiles, so this code is not " + + "partially analysed, it is absent from the scan entirely — and nothing " + + "about the result says so") + .isEmpty(); + } + + /** + * The scan has to build its dependencies from source. + * + *

Without {@code -am} Maven resolves the reactor siblings from the repository and + * compiles only the named modules — which, for a list whose first entry everything + * else depends on, means the build fails or silently narrows. It is one token, and + * losing it would look like a formatting change. + */ + @Test + void theScanBuildsItsDependenciesFromSource() throws IOException { + String step = buildStep(CODEQL_WORKFLOW, "package"); + + assertThat(step) + .describedAs("the CodeQL build must pass -am, or the modules it names are " + + "resolved as jars instead of compiled — and a jar is not extracted") + .contains("-am"); + } + + /** The {@code -pl} selectors of the first {@code mvnw} invocation containing {@code goal}. */ + private static List selectorsFrom(Path workflow, String goal) throws IOException { + Matcher matcher = MODULE_LIST.matcher(buildStep(workflow, goal)); + assertThat(matcher.find()) + .describedAs("no -pl module list found in the %s invocation of %s", + goal, PROJECT_ROOT.relativize(workflow)) + .isTrue(); + return List.of(matcher.group(1).split(",")); + } + + /** + * The text of the {@code mvnw} invocation carrying {@code goal}, joined across the + * line continuations the longer commands are wrapped in. + */ + private static String buildStep(Path workflow, String goal) throws IOException { + List lines = Files.readAllLines(workflow); + for (int i = 0; i < lines.size(); i++) { + if (!lines.get(i).contains("mvnw")) { + continue; + } + StringBuilder command = new StringBuilder(lines.get(i)); + for (int j = i + 1; j < lines.size() && command.toString().strip().endsWith("\\"); j++) { + command.append(' ').append(lines.get(j)); + } + String joined = command.toString().replace("\\", " "); + if (joined.contains(goal) && joined.contains("-pl")) { + return joined; + } + } + throw new AssertionError("no mvnw invocation with '" + goal + "' and -pl in " + + PROJECT_ROOT.relativize(workflow)); + } + + /** Of the given {@code :artifact-id} selectors, those whose module carries main sources. */ + private static Set modulesWithSources(List selectors) throws IOException { + Set withSources = new TreeSet<>(); + for (String selector : selectors) { + String artifactId = selector.strip().replaceFirst("^:", ""); + Path module = moduleDirectoryOf(artifactId); + if (module != null && Files.isDirectory(module.resolve("src/main/java"))) { + withSources.add(artifactId); + } + } + return withSources; + } + + /** The reactor module directory declaring {@code artifactId}, or null when none does. */ + private static Path moduleDirectoryOf(String artifactId) throws IOException { + String rootPom = Files.readString(PROJECT_ROOT.resolve("pom.xml")); + Matcher modules = Pattern.compile("\\s*([^<]+?)\\s*").matcher(rootPom); + List candidates = new ArrayList<>(); + while (modules.find()) { + candidates.add(modules.group(1)); + } + for (String module : candidates) { + Path pom = PROJECT_ROOT.resolve(module).resolve("pom.xml"); + if (Files.isRegularFile(pom) + && Files.readString(pom).contains("" + artifactId + "")) { + return PROJECT_ROOT.resolve(module); + } + } + return null; + } +} From 99bc818c94ead95fdcacb025e4d55863dc75e22c Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 1 Aug 2026 09:09:02 +0100 Subject: [PATCH 2/2] ci(codeql): hold the scan against the publish inventory, not only against CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparing the scan to the verify gate answers whether two lists agree, and two lists agree perfectly when a module is missing from both — the shape an artifact takes when it joins the publish train and is forgotten everywhere else. The guard now also reads the deploy steps of the publish workflows, which is the inventory the promise is about. fonts and emoji are named outright rather than arriving through `-am`: reaching the extractor as somebody's dependency ends the day that dependency is dropped. Three defects in the guard itself: - the artifact id was matched anywhere in a pom, so a module bound to the first module that *depends* on it — `graph-compose-testing` resolved to `render-pptx`. Module coordinates are now read from the module's own pom, with the inherited `` block removed first. - the `-pl` value was captured up to the first space, so wrapping the long verify command across a continuation would have shrunk the expected set in silence. Selectors are collected token by token instead. - a command with no `-pl` at all made the guard throw, which would have punished widening the scan to the whole reactor. The stated reason for `-am` was wrong: core is in the selection, so the named modules build without it. --- .github/workflows/codeql.yml | 19 ++- CHANGELOG.md | 8 +- .../documentation/CodeQlScopeGuardTest.java | 145 ++++++++++++------ .../documentation/PublishedModules.java | 103 +++++++++++++ 4 files changed, 217 insertions(+), 58 deletions(-) create mode 100644 core/src/test/java/com/demcha/documentation/PublishedModules.java diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e60f4ab5..c3e763e8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -55,17 +55,20 @@ jobs: # analysis scope. It covers every published module that carries code, which # is where the untrusted input actually lands: PDFBox and POI parsing, the # SVG and image paths, font loading, and the ZIP/OPC writers all live in the - # backends rather than in core. `-am` is required — the backends need core - # built from source, and without it Maven resolves the reactor siblings from - # the repository and compiles nothing. It also pulls the fonts and emoji - # modules in as dependencies, so the scanned set is the closure of this list, - # not the list itself. + # backends rather than in core. # - # Keep this list in step with the verify gate in ci.yml. A module that ships - # and is not named here ships unscanned. + # Every deployed module is named outright, including fonts and emoji, which no + # other `-pl` list mentions. They would arrive through `-am` as somebody's + # dependency, and that is precisely the arrangement that ends quietly the day + # the dependency is dropped. `-am` stays for a future module whose upstream is + # not itself deployed. + # + # CodeQlScopeGuardTest holds this list against two inventories — what CI + # compiles and what the publish workflows deploy — so a module that ships and + # is not named here fails the build rather than shipping unscanned. run: | ./mvnw -B -ntp -DskipTests package -am \ - -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing + -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-fonts,:graph-compose-emoji - name: Perform CodeQL analysis uses: github/codeql-action/analyze@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index ca8d33c7..11bbd6d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,9 +42,11 @@ follow semantic versioning; release dates are ISO 8601. alone, and the Java extractor sees only what the build compiles — so the PDFBox and POI parsing paths, the SVG and image handling, font loading and the ZIP/OPC writers were not partially analysed, they were absent from the scan, with nothing in a green - result to say so. The build now covers every published module that carries code, and - a guard fails the build when a module the verify gate compiles is missing from the - scan, so the next module is covered the day it lands. + result to say so. Every deployed module that carries code is now named outright, and a + guard holds that list against two inventories that fail differently: what CI compiles, + and what a release deploys. The second is what catches an artifact added to the publish + train and forgotten everywhere else — the first cannot, because a module missing from + both lists leaves them in perfect agreement. - **The package map is derived from the source tree.** A backend was findable only if someone remembered to list it, and the backend-neutral fixed-layout SPI was missing from the contributing guide — the one document a reader consults before adding an diff --git a/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java b/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java index d575a244..7b587b37 100644 --- a/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java @@ -7,9 +7,9 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.TreeSet; -import java.util.regex.Matcher; import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.assertThat; @@ -24,10 +24,12 @@ * for a full release, with the PDFBox, POI, SVG, font and ZIP/OPC paths — the code most * exposed to untrusted input — outside the scan while core was analysed. * - *

The expectation is derived rather than listed: every module the verify gate builds - * that carries production sources must also be built for the scan. A module added to - * the reactor and to CI is therefore covered on the day it lands, and a module dropped - * from the scan fails here rather than going quiet. + *

The expectation is derived rather than listed, from two sources that fail + * differently. Against the verify gate, a module CI compiles must also be scanned — that + * catches the scan falling behind the build. Against the publish workflows, a module a + * release deploys must be scanned — that catches what the first comparison cannot, since + * a new artifact forgotten in both CI and the scan is missing from both sides of it and + * the sets agree perfectly. */ class CodeQlScopeGuardTest { @@ -35,8 +37,12 @@ class CodeQlScopeGuardTest { private static final Path CI_WORKFLOW = PROJECT_ROOT.resolve(".github/workflows/ci.yml"); private static final Path CODEQL_WORKFLOW = PROJECT_ROOT.resolve(".github/workflows/codeql.yml"); - /** A {@code -pl} value: the comma-separated run of module selectors that follows it. */ - private static final Pattern MODULE_LIST = Pattern.compile("-pl\\s+(:[^\\s\\\\]+)"); + /** + * A {@code -pl} value. Selectors are collected token by token rather than by one + * regex: a wrapped command joins into {@code -pl :a,:b, :c -am}, and a pattern that + * stopped at the first space would silently shrink the very set it compares against. + */ + private static final Pattern OPTION = Pattern.compile("^-{1,2}\\w.*"); @Test void everyCodeBearingModuleTheVerifyGateBuildsIsAlsoScanned() throws IOException { @@ -61,31 +67,60 @@ void everyCodeBearingModuleTheVerifyGateBuildsIsAlsoScanned() throws IOException } /** - * The scan has to build its dependencies from source. + * Everything a release deploys, and that carries code, is scanned. * - *

Without {@code -am} Maven resolves the reactor siblings from the repository and - * compiles only the named modules — which, for a list whose first entry everything - * else depends on, means the build fails or silently narrows. It is one token, and - * losing it would look like a formatting change. + *

The independent half. Comparing the scan against CI answers the narrower + * question of whether the two lists agree, and two lists agree perfectly when a + * module is missing from both — which is the shape a new artifact takes when it is + * added to the publish train and forgotten everywhere else. The inventory here comes + * from the publish workflows, so the question becomes the one the promise makes: + * does the scan cover what users can actually depend on.

*/ @Test - void theScanBuildsItsDependenciesFromSource() throws IOException { - String step = buildStep(CODEQL_WORKFLOW, "package"); + void everyDeployedModuleWithSourcesIsScanned() throws IOException { + Set deployed = withSources(PublishedModules.deployed(PROJECT_ROOT)); + Set scanned = modulesWithSources(selectorsFrom(CODEQL_WORKFLOW, "package")); + + assertThat(deployed) + .describedAs("no deployed module with sources was found — the publish workflows' " + + "deploy steps moved, and this guard is comparing against nothing") + .isNotEmpty(); + + Set unscanned = new TreeSet<>(deployed); + unscanned.removeAll(scanned); - assertThat(step) - .describedAs("the CodeQL build must pass -am, or the modules it names are " - + "resolved as jars instead of compiled — and a jar is not extracted") - .contains("-am"); + assertThat(unscanned) + .describedAs("a module a release deploys, and whose code a consumer therefore " + + "runs, is outside the scan. Naming it in the scan's -pl list is the fix; " + + "relying on it arriving as somebody's dependency is not, because that " + + "stops the day the dependency does") + .isEmpty(); } - /** The {@code -pl} selectors of the first {@code mvnw} invocation containing {@code goal}. */ + /** + * The {@code -pl} selectors of the {@code mvnw} invocation carrying {@code goal}, or + * every reactor module when the command carries no {@code -pl} at all — a build + * without one compiles the whole reactor, which is more coverage, not less. + */ private static List selectorsFrom(Path workflow, String goal) throws IOException { - Matcher matcher = MODULE_LIST.matcher(buildStep(workflow, goal)); - assertThat(matcher.find()) - .describedAs("no -pl module list found in the %s invocation of %s", + List tokens = List.of(buildStep(workflow, goal).strip().split("\\s+")); + int at = tokens.indexOf("-pl"); + if (at < 0) { + return PublishedModules.of(PROJECT_ROOT).stream().map(module -> ":" + module).toList(); + } + + StringBuilder selectors = new StringBuilder(); + for (String token : tokens.subList(at + 1, tokens.size())) { + if (OPTION.matcher(token).matches()) { + break; + } + selectors.append(token); + } + assertThat(selectors.length()) + .describedAs("the -pl in the %s invocation of %s is followed by no selectors", goal, PROJECT_ROOT.relativize(workflow)) - .isTrue(); - return List.of(matcher.group(1).split(",")); + .isPositive(); + return List.of(selectors.toString().split(",")); } /** @@ -94,6 +129,7 @@ private static List selectorsFrom(Path workflow, String goal) throws IOE */ private static String buildStep(Path workflow, String goal) throws IOException { List lines = Files.readAllLines(workflow); + List matches = new ArrayList<>(); for (int i = 0; i < lines.size(); i++) { if (!lines.get(i).contains("mvnw")) { continue; @@ -103,42 +139,57 @@ private static String buildStep(Path workflow, String goal) throws IOException { command.append(' ').append(lines.get(j)); } String joined = command.toString().replace("\\", " "); - if (joined.contains(goal) && joined.contains("-pl")) { - return joined; + if (joined.contains(goal)) { + matches.add(joined); } } - throw new AssertionError("no mvnw invocation with '" + goal + "' and -pl in " - + PROJECT_ROOT.relativize(workflow)); + + assertThat(matches) + .describedAs("no mvnw invocation carrying '%s' in %s — the step this guard reads " + + "was renamed or its goal changed", goal, PROJECT_ROOT.relativize(workflow)) + .isNotEmpty(); + + // A module selection is what this guard is after, so prefer the invocation that + // carries one. Falling back to the first match keeps a command that drops `-pl` + // entirely readable rather than throwing — building the whole reactor scans more, + // not less, and a guard that fails on the stricter setup would push toward the + // looser one. + return matches.stream().filter(command -> command.contains("-pl")).findFirst() + .orElse(matches.get(0)); + } + + /** Of the given module directories, the artifact ids of those carrying main sources. */ + private static Set withSources(java.util.List moduleDirectories) throws IOException { + Map byArtifactId = PublishedModules.byArtifactId(PROJECT_ROOT); + Set withSources = new TreeSet<>(); + byArtifactId.forEach((artifactId, directory) -> { + String name = directory.getFileName().toString(); + if (moduleDirectories.contains(name) + && Files.isDirectory(directory.resolve("src/main/java"))) { + withSources.add(artifactId); + } + }); + return withSources; } /** Of the given {@code :artifact-id} selectors, those whose module carries main sources. */ private static Set modulesWithSources(List selectors) throws IOException { + Map byArtifactId = PublishedModules.byArtifactId(PROJECT_ROOT); + + assertThat(byArtifactId) + .describedAs("no module resolved to a directory — the root pom's module list or " + + "the poms' own coordinates moved, and every selector below would be " + + "dropped as unknown") + .isNotEmpty(); + Set withSources = new TreeSet<>(); for (String selector : selectors) { String artifactId = selector.strip().replaceFirst("^:", ""); - Path module = moduleDirectoryOf(artifactId); + Path module = byArtifactId.get(artifactId); if (module != null && Files.isDirectory(module.resolve("src/main/java"))) { withSources.add(artifactId); } } return withSources; } - - /** The reactor module directory declaring {@code artifactId}, or null when none does. */ - private static Path moduleDirectoryOf(String artifactId) throws IOException { - String rootPom = Files.readString(PROJECT_ROOT.resolve("pom.xml")); - Matcher modules = Pattern.compile("\\s*([^<]+?)\\s*").matcher(rootPom); - List candidates = new ArrayList<>(); - while (modules.find()) { - candidates.add(modules.group(1)); - } - for (String module : candidates) { - Path pom = PROJECT_ROOT.resolve(module).resolve("pom.xml"); - if (Files.isRegularFile(pom) - && Files.readString(pom).contains("" + artifactId + "")) { - return PROJECT_ROOT.resolve(module); - } - } - return null; - } } diff --git a/core/src/test/java/com/demcha/documentation/PublishedModules.java b/core/src/test/java/com/demcha/documentation/PublishedModules.java new file mode 100644 index 00000000..2871fcf8 --- /dev/null +++ b/core/src/test/java/com/demcha/documentation/PublishedModules.java @@ -0,0 +1,103 @@ +package com.demcha.documentation; + +import java.io.IOException; +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.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The reactor's modules, resolved from the root {@code pom.xml} for the guards that + * reason about them. + * + *

Two guards need the same answer to "what is a module, and which directory is it" — + * one checks that every backend package is documented, the other that every compiled + * module is scanned. Answering it twice is how the lists this repository keeps fixing + * came apart in the first place.

+ */ +final class PublishedModules { + + private static final Pattern MODULE = Pattern.compile("\\s*([^<]+?)\\s*"); + private static final Pattern ARTIFACT_ID = Pattern.compile("\\s*([^<]+?)\\s*"); + private static final Pattern PARENT_BLOCK = + Pattern.compile(".*?", Pattern.DOTALL); + + private PublishedModules() { + } + + /** A publish workflow's deploy step: {@code -f /pom.xml … deploy}. */ + private static final Pattern DEPLOY_STEP = + Pattern.compile("-f\\s+([\\w-]+)/pom\\.xml"); + + /** + * The modules a release actually deploys, read from the publish workflows. + * + *

The independent inventory. Comparing the scan against CI alone answers a + * narrower question than the one that matters: a module added to the publish train + * and forgotten in both CI and the scan is missing from both sides of that + * comparison, which is precisely the shape that keeps it green.

+ */ + static List deployed(Path repoRoot) throws IOException { + Path workflows = repoRoot.resolve(".github/workflows"); + List deployed = new ArrayList<>(); + try (var files = Files.list(workflows)) { + for (Path workflow : files.sorted().toList()) { + String name = workflow.getFileName().toString(); + if (!name.startsWith("publish") || !name.endsWith(".yml")) { + continue; + } + for (String line : Files.readAllLines(workflow)) { + if (!line.contains("deploy")) { + continue; + } + Matcher module = DEPLOY_STEP.matcher(line); + if (module.find() && !deployed.contains(module.group(1))) { + deployed.add(module.group(1)); + } + } + } + } + return deployed; + } + + /** The module directories the root reactor builds, in declaration order. */ + static List of(Path repoRoot) throws IOException { + String rootPom = Files.readString(repoRoot.resolve("pom.xml")); + List modules = new ArrayList<>(); + Matcher matcher = MODULE.matcher(rootPom); + while (matcher.find()) { + modules.add(matcher.group(1)); + } + return modules; + } + + /** + * Each module's own artifact id, mapped to its directory. + * + *

Read from the module's own {@code } rather than by searching the + * poms for a name: every pom that depends on a module also contains that + * module's artifact id, so a search binds {@code graph-compose-testing} to whichever + * dependent happens to come first in the reactor.

+ */ + static Map byArtifactId(Path repoRoot) throws IOException { + Map modules = new LinkedHashMap<>(); + for (String module : of(repoRoot)) { + Path pom = repoRoot.resolve(module).resolve("pom.xml"); + if (!Files.isRegularFile(pom)) { + continue; + } + // The inherited coordinate sits in above the module's own; drop it + // so the first remaining artifactId is the module speaking about itself. + String ownCoordinates = PARENT_BLOCK.matcher(Files.readString(pom)).replaceFirst(""); + Matcher artifactId = ARTIFACT_ID.matcher(ownCoordinates); + if (artifactId.find()) { + modules.put(artifactId.group(1), repoRoot.resolve(module)); + } + } + return modules; + } +}