When the reactor root has already produced {@code
+ * target/generated-sources/license/THIRD-PARTY.txt} (full reactor build or {@code mvn
+ * license:aggregate-add-third-party} without {@code -N}), the distribution assembly root must also
+ * contain that file after {@code generate-resources} (copy-license-inventory). Standalone module
+ * test runs without a prior generation step skip the assembly assertion rather than inventing a
+ * hand-curated inventory.
+ */
+public class ThirdPartyInventoryPackagingTest {
+
+ @Test
+ void assemblyShipsGeneratedThirdPartyInventoryWhenAvailable() throws IOException {
+ Path repoRoot = resolveRepoRoot();
+ Path generated =
+ repoRoot
+ .resolve("target")
+ .resolve("generated-sources")
+ .resolve("license")
+ .resolve("THIRD-PARTY.txt");
+ assumeTrue(
+ Files.isRegularFile(generated) && Files.size(generated) > 0,
+ "THIRD-PARTY.txt not generated yet — run from repo root (no -N):"
+ + " mvnw license:aggregate-add-third-party");
+
+ Path assemblyCopy =
+ Paths.get("target")
+ .resolve("classes")
+ .resolve("distribution")
+ .resolve("THIRD-PARTY.txt")
+ .toAbsolutePath()
+ .normalize();
+ assumeTrue(
+ Files.isRegularFile(assemblyCopy),
+ "Assembly copy not present yet (generate-resources / copy-license-inventory not run).");
+
+ String text = Files.readString(assemblyCopy, StandardCharsets.UTF_8);
+ assertFalse(text.isBlank(), "Packaged THIRD-PARTY.txt must not be blank");
+ assertTrue(
+ text.toLowerCase().contains("license") || text.contains("("),
+ "Packaged THIRD-PARTY.txt should look like a license inventory");
+ }
+
+ @Test
+ void assemblyShipsStableLicenseAndNoticeWhenPresent() throws IOException {
+ Path repoRoot = resolveRepoRoot();
+ Path licenseSrc = repoRoot.resolve("LICENSE.txt");
+ Path noticeSrc = repoRoot.resolve("NOTICE.txt");
+ assumeTrue(Files.isRegularFile(licenseSrc), "LICENSE.txt missing at repo root");
+ assumeTrue(Files.isRegularFile(noticeSrc), "NOTICE.txt missing at repo root");
+
+ Path assemblyDir =
+ Paths.get("target").resolve("classes").resolve("distribution").toAbsolutePath().normalize();
+ assumeTrue(
+ Files.isDirectory(assemblyDir),
+ "Assembly directory not built yet — generate-resources not run");
+
+ Path licenseOut = assemblyDir.resolve("LICENSE.txt");
+ Path noticeOut = assemblyDir.resolve("NOTICE.txt");
+ assumeTrue(
+ Files.isRegularFile(licenseOut) && Files.isRegularFile(noticeOut),
+ "LICENSE.txt/NOTICE.txt not yet copied into assembly (copy-license-inventory not run)");
+
+ assertFalse(Files.readString(licenseOut, StandardCharsets.UTF_8).isBlank());
+ String notice = Files.readString(noticeOut, StandardCharsets.UTF_8);
+ assertTrue(
+ notice.contains("THIRD-PARTY.txt"),
+ "Packaged NOTICE.txt must point at the generated inventory");
+ }
+
+ /**
+ * Resolves the monorepo root whether Surefire runs from {@code modules/perc-distribution-tree} or
+ * another cwd. Portable Path API only.
+ */
+ private static Path resolveRepoRoot() {
+ Path cwd = Paths.get("").toAbsolutePath().normalize();
+ Path probe = cwd;
+ for (int i = 0; i < 8 && probe != null; i++) {
+ if (Files.isRegularFile(probe.resolve("LICENSE.txt"))
+ && Files.isRegularFile(probe.resolve("pom.xml"))) {
+ return probe;
+ }
+ probe = probe.getParent();
+ }
+ // Fallback: module is two levels under root.
+ return cwd.resolve("..").resolve("..").normalize();
+ }
+}
diff --git a/pom.xml b/pom.xml
index c7f211ae14..f509ea9862 100644
--- a/pom.xml
+++ b/pom.xml
@@ -183,6 +183,8 @@
Policy (issue #1689): the resource-bundle text and root {@code NOTICE.txt} are a stable,
+ * version-agnostic pointer only. The authoritative versioned inventory is build-generated by {@code
+ * org.codehaus.mojo:license-maven-plugin} as {@code THIRD-PARTY.txt}. Do not reintroduce
+ * hand-curated component lists or dependency version pins here.
+ */
+public class PSThirdPartyCopyrightTest {
+
+ /** Matches {@code vMAJOR.MINOR.PATCH}-style pins (e.g. {@code v1.3.1}, {@code v2.3.232}). */
+ private static final Pattern VERSION_PIN = Pattern.compile("\\bv\\d+\\.\\d+\\.\\d+\\b");
+
+ /** Matches bare {@code MAJOR.MINOR.PATCH} pins (no leading {@code v}). */
+ private static final Pattern BARE_VERSION_PIN =
+ Pattern.compile("(?
Date: Sun, 2 Aug 2026 09:56:11 -0400
Subject: [PATCH 2/6] feat(build): merge npm production licenses into
THIRD-PARTY.txt
Extend the #1689 inventory so the shipped file covers both Maven and npm.
- license-maven-plugin writes THIRD-PARTY-MAVEN.txt
- scripts/generate-third-party-inventory.py reads product package-lock.json
production packages (WebUI SPA lockfile) and merges both halves into a
single THIRD-PARTY.txt for the installer
- Unit tests for the merge script; packaging test asserts npm section
> Co-Authored by Grok Build using grok-4.5 with agent main.
---
.../ThirdPartyInventoryPackagingTest.java | 7 +
pom.xml | 39 ++-
scripts/README.md | 30 ++
scripts/generate-third-party-inventory.bat | 9 +
scripts/generate-third-party-inventory.py | 284 ++++++++++++++++++
.../test_generate_third_party_inventory.py | 117 ++++++++
src/license/README.md | 68 +++--
src/license/npm-package-locks.txt | 11 +
8 files changed, 542 insertions(+), 23 deletions(-)
create mode 100644 scripts/generate-third-party-inventory.bat
create mode 100644 scripts/generate-third-party-inventory.py
create mode 100644 scripts/test_generate_third_party_inventory.py
create mode 100644 src/license/npm-package-locks.txt
diff --git a/modules/perc-distribution-tree/src/test/java/com/percussion/distribution/install/ThirdPartyInventoryPackagingTest.java b/modules/perc-distribution-tree/src/test/java/com/percussion/distribution/install/ThirdPartyInventoryPackagingTest.java
index bc657c6fb9..c6cef7f2d2 100644
--- a/modules/perc-distribution-tree/src/test/java/com/percussion/distribution/install/ThirdPartyInventoryPackagingTest.java
+++ b/modules/perc-distribution-tree/src/test/java/com/percussion/distribution/install/ThirdPartyInventoryPackagingTest.java
@@ -70,6 +70,13 @@ void assemblyShipsGeneratedThirdPartyInventoryWhenAvailable() throws IOException
assertTrue(
text.toLowerCase().contains("license") || text.contains("("),
"Packaged THIRD-PARTY.txt should look like a license inventory");
+ // Merged inventory (Maven + npm) — issue #1689
+ assertTrue(
+ text.contains("Maven third-party dependencies"),
+ "Packaged THIRD-PARTY.txt should include the Maven inventory section");
+ assertTrue(
+ text.contains("npm third-party dependencies") || text.contains("npm:"),
+ "Packaged THIRD-PARTY.txt should include the npm production inventory");
}
@Test
diff --git a/pom.xml b/pom.xml
index f509ea9862..906f35a16d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -185,6 +185,8 @@
This type is intentionally free of product names and build-system coupling so any Intersoft + * (or other) multi-module project can reuse it. Callers supply paths and optional heading text. + * + *
+ * target/generated-sources/license/ + * THIRD-PARTY-MAVEN.txt ← produced by license-maven-plugin + * THIRD-PARTY-NPM.txt ← intermediate npm section (optional) + * THIRD-PARTY.txt ← merged inventory (ship / publish this file) + *+ * + *
A {@link #main(String[])} entry point supports {@code exec-maven-plugin:java} (or direct + * {@code java -cp …}). See {@link #main(String[])} for flags. + * + *
Paths use {@link java.nio.file} only (Windows / Linux / macOS). Written files use UTF-8 and LF
+ * line endings for stable cross-platform diffs.
+ *
+ * @since 0.0.1
+ */
+public final class ThirdPartyLicenseInventory {
+
+ /** Default Maven-only inventory file name. */
+ public static final String DEFAULT_MAVEN_FILE_NAME = "THIRD-PARTY-MAVEN.txt";
+
+ /** Default intermediate npm-only inventory file name. */
+ public static final String DEFAULT_NPM_FILE_NAME = "THIRD-PARTY-NPM.txt";
+
+ /** Default merged inventory file name (the file most products should ship). */
+ public static final String DEFAULT_MERGED_FILE_NAME = "THIRD-PARTY.txt";
+
+ private ThirdPartyLicenseInventory() {}
+
+ /**
+ * One production npm package taken from a package-lock {@code packages} entry.
+ *
+ * @param name package name (for example {@code react} or {@code @scope/pkg})
+ * @param version resolved version string
+ * @param license SPDX-ish license expression or {@code "Unknown license"}
+ * @param sourceLabel human-readable origin (typically a repo-relative lockfile path)
+ */
+ public record NpmPackage(String name, String version, String license, String sourceLabel)
+ implements Comparable Only lockfileVersion 2/3 style documents with a top-level {@code packages} object are
+ * supported. Entries marked {@code "dev": true} or {@code "devOptional": true} are skipped.
+ * Nested installs under {@code node_modules/…/node_modules/…} are included under the nested
+ * package name (last {@code node_modules/} segment).
+ *
+ * @param packageLockJson path to {@code package-lock.json}
+ * @param sourceRoot optional project root used to label {@link NpmPackage#sourceLabel()} as a
+ * relative path; may be {@code null} to use the absolute lock path
+ * @return sorted, de-duplicated production packages (name+version)
+ * @throws IOException if the file cannot be read
+ * @throws IllegalArgumentException if the document is not a supported package-lock
+ * @throws NullPointerException if {@code packageLockJson} is null
+ */
+ public static List List file format (UTF-8): one path per line, relative to {@code projectRoot}. Blank lines
+ * and lines whose first non-whitespace character is {@code #} are ignored. Paths may use {@code
+ * /} or {@code \} separators; they are resolved with {@link Path}.
+ *
+ * @param projectRoot root directory for resolving relative lock paths and source labels
+ * @param lockListFile list of package-lock.json paths
+ * @return sorted union of production packages
+ * @throws IOException if the list or a listed lockfile cannot be read
+ */
+ public static List Flags:
+ *
+ *
+ *
+ *
+ * @param args command-line arguments
+ */
+ public static void main(String[] args) {
+ int code = runMain(args, System.out, System.err);
+ if (code != 0) {
+ System.exit(code);
+ }
+ }
+
+ /**
+ * Testable {@link #main(String[])} implementation.
+ *
+ * @param args CLI args
+ * @param out stdout
+ * @param err stderr
+ * @return process exit code (0 success)
+ */
+ static int runMain(String[] args, PrintStream out, PrintStream err) {
+ Path root = null;
+ Path outDir = null;
+ Path lockList = null;
+ String title = null;
+ boolean requireMaven = false;
+ String mavenName = DEFAULT_MAVEN_FILE_NAME;
+ String npmName = DEFAULT_NPM_FILE_NAME;
+ String mergedName = DEFAULT_MERGED_FILE_NAME;
+
+ for (int i = 0; i < args.length; i++) {
+ String a = args[i];
+ try {
+ switch (a) {
+ case "--root" -> root = Path.of(requireValue(args, ++i, a));
+ case "--out-dir" -> outDir = Path.of(requireValue(args, ++i, a));
+ case "--lock-list" -> lockList = Path.of(requireValue(args, ++i, a));
+ case "--title" -> title = requireValue(args, ++i, a);
+ case "--require-maven" -> requireMaven = true;
+ case "--maven-name" -> mavenName = requireValue(args, ++i, a);
+ case "--npm-name" -> npmName = requireValue(args, ++i, a);
+ case "--merged-name" -> mergedName = requireValue(args, ++i, a);
+ case "--help", "-h" -> {
+ printUsage(out);
+ return 0;
+ }
+ default -> {
+ err.println("Unknown argument: " + a);
+ printUsage(err);
+ return 2;
+ }
+ }
+ } catch (IllegalArgumentException ex) {
+ err.println(ex.getMessage());
+ printUsage(err);
+ return 2;
+ }
+ }
+
+ if (root == null) {
+ err.println("--root is required");
+ printUsage(err);
+ return 2;
+ }
+
+ root = root.toAbsolutePath().normalize();
+ if (outDir == null) {
+ outDir = root.resolve("target").resolve("generated-sources").resolve("license");
+ } else {
+ outDir = outDir.toAbsolutePath().normalize();
+ }
+ if (lockList == null) {
+ lockList = root.resolve("src").resolve("license").resolve("npm-package-locks.txt");
+ } else {
+ lockList = lockList.toAbsolutePath().normalize();
+ }
+
+ try {
+ Path merged =
+ generateMergedInventory(
+ root, outDir, mavenName, npmName, mergedName, lockList, title, requireMaven);
+ long npmCount =
+ Files.isRegularFile(outDir.resolve(npmName))
+ ? readProductionPackagesFromLockList(root, lockList).size()
+ : 0L;
+ out.println(
+ "Wrote "
+ + merged
+ + " (Maven present="
+ + Files.isRegularFile(outDir.resolve(mavenName))
+ + ", npm packages="
+ + npmCount
+ + ")");
+ return 0;
+ } catch (IllegalStateException | IllegalArgumentException ex) {
+ err.println("ERROR: " + ex.getMessage());
+ return 1;
+ } catch (IOException ex) {
+ err.println("ERROR: " + ex.getMessage());
+ return 1;
+ }
+ }
+
+ private static void printUsage(PrintStream out) {
+ out.println(
+ "Usage: ThirdPartyLicenseInventory --root
Missing listed lockfiles are recorded in {@link NpmCollectionResult#missingLockFiles()} —
+ * they are not silently ignored without a trace. Use {@link
+ * #requireCompleteNpmSources(NpmCollectionResult, Path)} to fail the build when sources are
+ * incomplete.
+ *
* @param projectRoot root directory for resolving relative lock paths and source labels
* @param lockListFile list of package-lock.json paths
- * @return sorted union of production packages
- * @throws IOException if the list or a listed lockfile cannot be read
+ * @return packages plus any missing listed lock paths
+ * @throws IOException if the list file exists but cannot be read, or a present lockfile cannot be
+ * parsed
*/
- public static List When {@code requireCompleteSources} is {@code true} (typical for product builds), both the
+ * Maven inventory and the full npm lock-list (file present and every listed package-lock present)
+ * are required. When {@code false}, missing Maven inventory yields an empty Maven section;
+ * incomplete npm sources still fail only if the lock-list file is required by {@link
+ * #requireCompleteNpmSources} — callers should pass {@code true} for CI.
+ *
* @param projectRoot project / repository root
* @param outDir output directory (created if missing)
* @param mavenFileName Maven inventory file name under {@code outDir}
* @param npmFileName intermediate npm file name under {@code outDir}
* @param mergedFileName merged file name under {@code outDir}
- * @param lockListFile package-lock list file (absolute or relative paths per {@link
- * #readLockList(Path, Path)})
+ * @param lockListFile package-lock list file
* @param documentTitle title line for the merged document; may be null
- * @param requireMaven if true, fail when the Maven inventory file is missing
- * @return path to the merged inventory
+ * @param requireCompleteSources if true, fail when Maven inventory or any npm source is missing
+ * @return generate result including package count (no second lockfile pass required)
* @throws IOException on I/O failure
- * @throws IllegalStateException if {@code requireMaven} and the Maven file is absent
+ * @throws IllegalStateException if required sources are incomplete
*/
- public static Path generateMergedInventory(
+ public static GenerateResult generateMergedInventory(
Path projectRoot,
Path outDir,
String mavenFileName,
@@ -344,7 +439,7 @@ public static Path generateMergedInventory(
String mergedFileName,
Path lockListFile,
String documentTitle,
- boolean requireMaven)
+ boolean requireCompleteSources)
throws IOException {
Objects.requireNonNull(projectRoot, "projectRoot");
Objects.requireNonNull(outDir, "outDir");
@@ -358,10 +453,11 @@ public static Path generateMergedInventory(
Path npmPath = outDir.resolve(npmFileName);
Path mergedPath = outDir.resolve(mergedFileName);
+ boolean mavenPresent = Files.isRegularFile(mavenPath);
String mavenText;
- if (Files.isRegularFile(mavenPath)) {
+ if (mavenPresent) {
mavenText = Files.readString(mavenPath, StandardCharsets.UTF_8);
- } else if (requireMaven) {
+ } else if (requireCompleteSources) {
throw new IllegalStateException(
"Maven inventory missing: "
+ mavenPath
@@ -370,13 +466,19 @@ public static Path generateMergedInventory(
mavenText = "";
}
- List