fad-checker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CLAUDE.md +126 -0
  2. package/README.md +279 -0
  3. package/completions/fad-check.bash +23 -0
  4. package/completions/fad-check.zsh +27 -0
  5. package/data/cpe-coord-map.json +112 -0
  6. package/data/eol-mapping.json +34 -0
  7. package/data/known-obsolete.json +142 -0
  8. package/data/known-public-namespaces.json +79 -0
  9. package/docs/ARCHITECTURE.md +152 -0
  10. package/docs/USAGE.md +179 -0
  11. package/fad-check.js +665 -0
  12. package/lib/cache-archive.js +107 -0
  13. package/lib/config.js +87 -0
  14. package/lib/core.js +317 -0
  15. package/lib/cpe.js +287 -0
  16. package/lib/cve-download.js +369 -0
  17. package/lib/cve-match.js +228 -0
  18. package/lib/cve-report.js +1455 -0
  19. package/lib/maven-repo.js +134 -0
  20. package/lib/maven-version.js +153 -0
  21. package/lib/npm/collect.js +224 -0
  22. package/lib/npm/parse.js +291 -0
  23. package/lib/nvd.js +239 -0
  24. package/lib/osv.js +298 -0
  25. package/lib/outdated.js +265 -0
  26. package/lib/retire.js +211 -0
  27. package/lib/scan-completeness.js +67 -0
  28. package/lib/snyk.js +127 -0
  29. package/lib/transitive.js +410 -0
  30. package/package.json +35 -0
  31. package/test/core.test.js +153 -0
  32. package/test/cpe.test.js +148 -0
  33. package/test/cve-download.test.js +39 -0
  34. package/test/cve-match.test.js +108 -0
  35. package/test/cve-report.test.js +79 -0
  36. package/test/fixtures/complex-enterprise/api/pom.xml +32 -0
  37. package/test/fixtures/complex-enterprise/build/pom.xml +46 -0
  38. package/test/fixtures/complex-enterprise/dao/pom.xml +37 -0
  39. package/test/fixtures/complex-enterprise/pom.xml +66 -0
  40. package/test/fixtures/complex-enterprise/web/pom.xml +77 -0
  41. package/test/fixtures/cve-samples/cve-non-java.json +19 -0
  42. package/test/fixtures/cve-samples/cve-product-only.json +31 -0
  43. package/test/fixtures/cve-samples/cve-with-packagename.json +37 -0
  44. package/test/fixtures/cve-samples/nvd-log4shell.json +40 -0
  45. package/test/fixtures/cve-samples/nvd-npm-lodash.json +22 -0
  46. package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +26 -0
  47. package/test/fixtures/monorepo-mixed/packages/cli/package.json +14 -0
  48. package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +41 -0
  49. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +9 -0
  50. package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +71 -0
  51. package/test/fixtures/monorepo-mixed/packages/web-app/package.json +17 -0
  52. package/test/fixtures/monorepo-mixed/pom.xml +29 -0
  53. package/test/fixtures/monorepo-mixed/services/api/pom.xml +27 -0
  54. package/test/fixtures/monorepo-mixed/services/worker/pom.xml +28 -0
  55. package/test/fixtures/private-lib-detection/core/pom.xml +26 -0
  56. package/test/fixtures/private-lib-detection/plugin/pom.xml +23 -0
  57. package/test/fixtures/private-lib-detection/pom.xml +35 -0
  58. package/test/fixtures/simple/app/pom.xml +28 -0
  59. package/test/fixtures/simple/lib/pom.xml +18 -0
  60. package/test/fixtures/simple/pom.xml +24 -0
  61. package/test/maven-repo.test.js +111 -0
  62. package/test/maven-version.test.js +48 -0
  63. package/test/monorepo.test.js +132 -0
  64. package/test/npm.test.js +146 -0
  65. package/test/outdated.test.js +56 -0
  66. package/test/snyk.test.js +64 -0
  67. package/test/transitive.test.js +305 -0
@@ -0,0 +1,410 @@
1
+ /**
2
+ * lib/transitive.js — resolve transitive dependencies for a set of direct deps
3
+ * by walking POMs fetched from Maven Central.
4
+ *
5
+ * Implements a pragmatic subset of Maven's resolution rules:
6
+ * - parent POM chain (recursive)
7
+ * - <dependencyManagement> from parent + BOM imports (scope=import)
8
+ * - property substitution with project.version / project.groupId
9
+ * - scope propagation: compile/runtime/provided → compile/runtime/provided,
10
+ * test → not propagated, system → not propagated
11
+ * - <exclusion> blocks
12
+ * - <optional>true</optional> stops propagation
13
+ * - nearest-wins dependency mediation (BFS guarantees this naturally)
14
+ * - root-level dependencyManagement overrides transitive versions
15
+ *
16
+ * Out of scope (for simplicity / accuracy tradeoff):
17
+ * - <profile> activation inside transitive POMs (assumed dormant)
18
+ * - <relocation> handling (rare in modern artifacts)
19
+ * - non-central repositories (everything fetched from repo1.maven.org)
20
+ * - SNAPSHOT version resolution (we just return the literal version)
21
+ */
22
+ const fs = require("fs");
23
+ const path = require("path");
24
+ const os = require("os");
25
+ const { parseStringPromise } = require("xml2js");
26
+
27
+ const POM_CACHE_DIR = path.join(os.homedir(), ".fad-check", "poms-cache");
28
+ const MAVEN_CENTRAL = "https://repo1.maven.org/maven2";
29
+
30
+ // Maven's scope-propagation matrix (rows: direct dep scope, cols: transitive scope)
31
+ // Value is the resulting scope for the transitive, or null = not included.
32
+ const SCOPE_MATRIX = {
33
+ compile: { compile: "compile", provided: null, runtime: "runtime", test: null, system: null },
34
+ provided: { compile: "provided", provided: null, runtime: "provided", test: null, system: null },
35
+ runtime: { compile: "runtime", provided: null, runtime: "runtime", test: null, system: null },
36
+ test: { compile: "test", provided: null, runtime: "test", test: null, system: null },
37
+ };
38
+
39
+ function coord(v) { return v == null ? null : String(v).trim() || null; }
40
+
41
+ function pomPath(g, a, v) {
42
+ const gPath = g.replace(/\./g, "/");
43
+ return `${MAVEN_CENTRAL}/${gPath}/${a}/${v}/${a}-${v}.pom`;
44
+ }
45
+
46
+ function cachePath(g, a, v, dir = POM_CACHE_DIR) {
47
+ return path.join(dir, `${g.replace(/[/\\]/g, "_")}__${a}__${v}.pom`);
48
+ }
49
+
50
+ async function fetchPom(g, a, v, opts = {}) {
51
+ const { verbose, offline, fetcher = globalThis.fetch, cacheDir = POM_CACHE_DIR, repos } = opts;
52
+ const cf = cachePath(g, a, v, cacheDir);
53
+ if (fs.existsSync(cf)) {
54
+ const xml = await fs.promises.readFile(cf, "utf8");
55
+ if (xml === "__NOT_FOUND__") return null;
56
+ return xml;
57
+ }
58
+ if (offline) return null;
59
+ // Multi-repo path: try every user-configured repo, fall back to Maven
60
+ // Central via lib/maven-repo. Falling back to the legacy single-URL
61
+ // fetch only when no repos array is passed (keeps existing tests green).
62
+ if (Array.isArray(repos)) {
63
+ try {
64
+ const { fetchPomFromRepos } = require("./maven-repo");
65
+ const hit = await fetchPomFromRepos(repos, g, a, v, { fetcher, userAgent: "fad-check-transitive" });
66
+ if (hit?.body) {
67
+ await fs.promises.mkdir(cacheDir, { recursive: true });
68
+ await fs.promises.writeFile(cf, hit.body);
69
+ return hit.body;
70
+ }
71
+ await fs.promises.mkdir(cacheDir, { recursive: true });
72
+ await fs.promises.writeFile(cf, "__NOT_FOUND__");
73
+ if (verbose) console.warn(` not found in any repo: ${g}:${a}:${v}`);
74
+ return null;
75
+ } catch (err) {
76
+ if (verbose) console.warn(` multi-repo fetch failed: ${g}:${a}:${v} — ${err.message}`);
77
+ return null;
78
+ }
79
+ }
80
+ const url = pomPath(g, a, v);
81
+ try {
82
+ const res = await fetcher(url, { headers: { "User-Agent": "fad-check-transitive" } });
83
+ if (res.status === 404) {
84
+ await fs.promises.mkdir(cacheDir, { recursive: true });
85
+ await fs.promises.writeFile(cf, "__NOT_FOUND__");
86
+ if (verbose) console.warn(` 404: ${g}:${a}:${v}`);
87
+ return null;
88
+ }
89
+ if (!res.ok) {
90
+ if (verbose) console.warn(` HTTP ${res.status}: ${g}:${a}:${v}`);
91
+ return null;
92
+ }
93
+ const xml = await res.text();
94
+ await fs.promises.mkdir(cacheDir, { recursive: true });
95
+ await fs.promises.writeFile(cf, xml);
96
+ return xml;
97
+ } catch (err) {
98
+ if (verbose) console.warn(` fetch failed: ${g}:${a}:${v} — ${err.message}`);
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Parse a POM XML into a minimal descriptor.
105
+ * Returns: { groupId, artifactId, version, parent, properties, deps, depMgmt }
106
+ * parent : { groupId, artifactId, version } | null
107
+ * deps : array of { groupId, artifactId, version, scope, optional, exclusions: [{g,a}] }
108
+ * depMgmt: same shape as deps
109
+ * properties: { key: value } (no resolution yet)
110
+ */
111
+ async function parsePomXml(xml) {
112
+ let json;
113
+ try { json = await parseStringPromise(xml); }
114
+ catch { return null; }
115
+ const project = json?.project || {};
116
+ const parent = project.parent?.[0];
117
+ const parentRef = parent ? {
118
+ groupId: coord(parent.groupId?.[0]),
119
+ artifactId: coord(parent.artifactId?.[0]),
120
+ version: coord(parent.version?.[0]),
121
+ } : null;
122
+
123
+ const groupId = coord(project.groupId?.[0]) || parentRef?.groupId || null;
124
+ const artifactId = coord(project.artifactId?.[0]);
125
+ const version = coord(project.version?.[0]) || parentRef?.version || null;
126
+
127
+ const properties = {};
128
+ const propsNode = project.properties?.[0];
129
+ if (propsNode && typeof propsNode !== "string") {
130
+ for (const [k, v] of Object.entries(propsNode)) {
131
+ properties[k] = Array.isArray(v) ? v[0] : v;
132
+ }
133
+ }
134
+
135
+ const readDeps = (depsBlock) => {
136
+ if (!depsBlock?.dependency) return [];
137
+ return depsBlock.dependency.map(d => ({
138
+ groupId: coord(d.groupId?.[0]),
139
+ artifactId: coord(d.artifactId?.[0]),
140
+ version: coord(d.version?.[0]),
141
+ scope: coord(d.scope?.[0]) || "compile",
142
+ optional: d.optional?.[0] === "true",
143
+ type: coord(d.type?.[0]) || "jar",
144
+ exclusions: (d.exclusions?.[0]?.exclusion || []).map(e => ({
145
+ groupId: coord(e.groupId?.[0]),
146
+ artifactId: coord(e.artifactId?.[0]),
147
+ })),
148
+ })).filter(d => d.groupId && d.artifactId);
149
+ };
150
+
151
+ return {
152
+ groupId, artifactId, version,
153
+ parent: parentRef,
154
+ properties,
155
+ deps: readDeps(project.dependencies?.[0]),
156
+ depMgmt: readDeps(project.dependencyManagement?.[0]?.dependencies?.[0]),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Resolve ${prop} substitutions in a string using a properties map.
162
+ * Implements project.groupId/artifactId/version as built-ins.
163
+ * Loops if a property references another property.
164
+ */
165
+ function resolveProps(value, props, builtins, depth = 0) {
166
+ if (value == null || depth > 10) return value;
167
+ const out = String(value).replace(/\$\{\s*([\w._-]+)\s*\}/g, (m, k) => {
168
+ if (builtins && Object.prototype.hasOwnProperty.call(builtins, k)) return builtins[k];
169
+ if (props && Object.prototype.hasOwnProperty.call(props, k)) return resolveProps(props[k], props, builtins, depth + 1);
170
+ return m;
171
+ });
172
+ return out;
173
+ }
174
+
175
+ /**
176
+ * Build the "effective" POM for a g:a:v by walking the parent chain.
177
+ * Merges properties, depMgmt, and deps (child overrides parent).
178
+ * BOM imports inside depMgmt are recursively expanded.
179
+ */
180
+ async function effectivePom(g, a, v, opts = {}, seen = new Set()) {
181
+ const key = `${g}:${a}:${v}`;
182
+ if (seen.has(key)) return null;
183
+ seen.add(key);
184
+
185
+ const xml = await fetchPom(g, a, v, opts);
186
+ if (!xml) return null;
187
+ const pom = await parsePomXml(xml);
188
+ if (!pom) return null;
189
+
190
+ let merged = {
191
+ groupId: pom.groupId,
192
+ artifactId: pom.artifactId,
193
+ version: pom.version,
194
+ properties: { ...pom.properties },
195
+ depMgmt: [...pom.depMgmt],
196
+ deps: [...pom.deps],
197
+ };
198
+
199
+ if (pom.parent) {
200
+ const parentEff = await effectivePom(pom.parent.groupId, pom.parent.artifactId, pom.parent.version, opts, seen);
201
+ if (parentEff) {
202
+ merged.properties = { ...parentEff.properties, ...merged.properties };
203
+ merged.depMgmt = [...parentEff.depMgmt, ...merged.depMgmt];
204
+ // We do NOT inherit parent's <dependencies> declarations here — they're
205
+ // brought in transitively when we walk the parent ITSELF if needed. But
206
+ // Maven actually does inherit parent <dependencies>; do that:
207
+ merged.deps = [...parentEff.deps, ...merged.deps];
208
+ }
209
+ }
210
+
211
+ // Resolve property references in depMgmt and deps now that the property map
212
+ // is finalised (child + parent merged).
213
+ const builtins = {
214
+ "project.groupId": merged.groupId,
215
+ "project.artifactId": merged.artifactId,
216
+ "project.version": merged.version,
217
+ "pom.groupId": merged.groupId,
218
+ "pom.artifactId": merged.artifactId,
219
+ "pom.version": merged.version,
220
+ };
221
+ const resolveDep = d => ({
222
+ ...d,
223
+ groupId: resolveProps(d.groupId, merged.properties, builtins),
224
+ artifactId: resolveProps(d.artifactId, merged.properties, builtins),
225
+ version: resolveProps(d.version, merged.properties, builtins),
226
+ });
227
+ merged.depMgmt = merged.depMgmt.map(resolveDep);
228
+ merged.deps = merged.deps.map(resolveDep);
229
+
230
+ // Expand BOM imports inside depMgmt: any entry with scope=import + type=pom
231
+ // is replaced by the depMgmt entries from that imported POM.
232
+ const expanded = [];
233
+ for (const dm of merged.depMgmt) {
234
+ if (dm.scope === "import") {
235
+ const imported = await effectivePom(dm.groupId, dm.artifactId, dm.version, opts, new Set(seen));
236
+ if (imported) expanded.push(...imported.depMgmt);
237
+ } else {
238
+ expanded.push(dm);
239
+ }
240
+ }
241
+ merged.depMgmt = expanded;
242
+
243
+ return merged;
244
+ }
245
+
246
+ /**
247
+ * Build a map of managed versions from a list of depMgmt entries.
248
+ * Keyed by "g:a"; value is the entry (we use its version + scope).
249
+ */
250
+ function buildMgmt(depMgmt) {
251
+ const m = new Map();
252
+ for (const d of depMgmt) {
253
+ if (d.groupId && d.artifactId) m.set(`${d.groupId}:${d.artifactId}`, d);
254
+ }
255
+ return m;
256
+ }
257
+
258
+ /**
259
+ * BFS the transitive graph from a set of root deps.
260
+ *
261
+ * directDeps : iterable of { groupId, artifactId, version, scope, exclusions? }
262
+ * opts:
263
+ * rootDepMgmt — Map<g:a, managedEntry> for the project root depMgmt (highest priority)
264
+ * maxDepth — default 6
265
+ * parallelism — default 8
266
+ * verbose
267
+ * includedScopes — defaults to ["compile", "runtime", "provided"]
268
+ *
269
+ * Returns Map<g:a, { groupId, artifactId, version, scope, depth, via: [g:a,...] }>
270
+ * `via` is the path from a root direct dep down to (but not including) this node.
271
+ * The set EXCLUDES the original direct deps (caller already has those).
272
+ */
273
+ async function resolveTransitiveDeps(directDeps, opts = {}) {
274
+ const {
275
+ rootDepMgmt = new Map(),
276
+ maxDepth = 6,
277
+ verbose = false,
278
+ offline = false,
279
+ includedScopes = ["compile", "runtime", "provided"],
280
+ concurrency = 8,
281
+ fetcher, // optional injected fetch (used by tests)
282
+ cacheDir, // optional override of disk cache dir (used by tests)
283
+ repos, // optional repo list (lib/maven-repo). Falls back to repo1.maven.org alone.
284
+ } = opts;
285
+ const fetchOpts = { verbose, offline, fetcher, cacheDir, repos };
286
+
287
+ const visited = new Map(); // g:a -> { ...entry, depth }
288
+ const queue = [];
289
+
290
+ // Seed: every direct dep (we won't return them, but we walk their children).
291
+ for (const dep of directDeps) {
292
+ if (!dep.groupId || !dep.artifactId || !dep.version) continue;
293
+ queue.push({
294
+ groupId: dep.groupId,
295
+ artifactId: dep.artifactId,
296
+ version: dep.version,
297
+ scope: dep.scope || "compile",
298
+ depth: 0,
299
+ via: [],
300
+ rootExclusions: dep.exclusions || [],
301
+ });
302
+ visited.set(`${dep.groupId}:${dep.artifactId}`, { isDirect: true });
303
+ }
304
+
305
+ // out: Map<g:a, { ...entry, viaPaths: [[chain1], [chain2], ...] }>
306
+ // Multiple chains to the same transitive are accumulated; the BFS only
307
+ // walks deeper from the FIRST chain (nearest-wins) but records every
308
+ // alternate path so the report can show "brought in by X, Y, Z".
309
+ const out = new Map();
310
+
311
+ // Worker pool
312
+ const workers = Array.from({ length: concurrency }, async () => {
313
+ while (queue.length) {
314
+ const node = queue.shift();
315
+ if (!node) break;
316
+ if (node.depth >= maxDepth) continue;
317
+
318
+ let eff;
319
+ try { eff = await effectivePom(node.groupId, node.artifactId, node.version, fetchOpts); }
320
+ catch { continue; }
321
+ if (!eff) continue;
322
+
323
+ const mgmt = buildMgmt(eff.depMgmt);
324
+
325
+ for (const dep of eff.deps) {
326
+ if (!dep.groupId || !dep.artifactId) continue;
327
+ if (dep.optional) continue;
328
+
329
+ const childKey = `${dep.groupId}:${dep.artifactId}`;
330
+
331
+ // Exclusion check against ancestors
332
+ if (node.rootExclusions?.some(e =>
333
+ (!e.groupId || e.groupId === dep.groupId || e.groupId === "*") &&
334
+ (!e.artifactId || e.artifactId === dep.artifactId || e.artifactId === "*"))) continue;
335
+
336
+ // Scope propagation
337
+ const propagated = SCOPE_MATRIX[node.scope]?.[dep.scope || "compile"];
338
+ if (!propagated || !includedScopes.includes(propagated)) continue;
339
+
340
+ // Version resolution: root depMgmt > effective depMgmt > declared
341
+ let resolvedVersion = dep.version;
342
+ if (rootDepMgmt.has(childKey)) {
343
+ resolvedVersion = rootDepMgmt.get(childKey).version;
344
+ } else if (!resolvedVersion && mgmt.has(childKey)) {
345
+ resolvedVersion = mgmt.get(childKey).version;
346
+ } else if (mgmt.has(childKey) && !dep.version) {
347
+ resolvedVersion = mgmt.get(childKey).version;
348
+ }
349
+ if (!resolvedVersion) continue;
350
+ // Drop unresolved ${...} placeholders
351
+ if (/\$\{/.test(resolvedVersion)) continue;
352
+
353
+ const via = [...node.via, `${node.groupId}:${node.artifactId}`];
354
+
355
+ // Nearest-wins for version & BFS continuation: if already visited
356
+ // we don't recurse again, but we DO record the alternate via path
357
+ // so the report can show all chains that bring this transitive in.
358
+ if (visited.has(childKey)) {
359
+ const existing = out.get(childKey);
360
+ if (existing) {
361
+ existing.viaPaths = existing.viaPaths || [existing.via];
362
+ // Deduplicate by stringified chain
363
+ const sig = via.join("→");
364
+ if (!existing.viaPaths.some(p => p.join("→") === sig)) {
365
+ existing.viaPaths.push(via);
366
+ }
367
+ }
368
+ continue;
369
+ }
370
+ visited.set(childKey, { depth: node.depth + 1 });
371
+
372
+ out.set(childKey, {
373
+ groupId: dep.groupId,
374
+ artifactId: dep.artifactId,
375
+ version: resolvedVersion,
376
+ scope: propagated,
377
+ depth: node.depth + 1,
378
+ via,
379
+ viaPaths: [via],
380
+ });
381
+
382
+ queue.push({
383
+ groupId: dep.groupId,
384
+ artifactId: dep.artifactId,
385
+ version: resolvedVersion,
386
+ scope: propagated,
387
+ depth: node.depth + 1,
388
+ via,
389
+ rootExclusions: [...(node.rootExclusions || []), ...(dep.exclusions || [])],
390
+ });
391
+ }
392
+ if (verbose) process.stdout.write(`\r resolved ${out.size} transitives, queue=${queue.length}`);
393
+ }
394
+ });
395
+ await Promise.all(workers);
396
+ if (verbose) process.stdout.write(`\r resolved ${out.size} transitives \n`);
397
+
398
+ return out;
399
+ }
400
+
401
+ module.exports = {
402
+ resolveTransitiveDeps,
403
+ effectivePom,
404
+ parsePomXml,
405
+ fetchPom,
406
+ resolveProps,
407
+ buildMgmt,
408
+ POM_CACHE_DIR,
409
+ SCOPE_MATRIX,
410
+ };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "fad-checker",
3
+ "version": "1.0.0",
4
+ "description": "Fucking Autonomous Dependency Checker — multi-ecosystem CVE / EOL / outdated / vendored-JS scanner for Maven, npm and Yarn monorepos. Self-contained HTML + Word report.",
5
+ "license": "MIT",
6
+ "author": "N8tz <n8tz.js@gmail.com>",
7
+ "maintainers": [
8
+ "Nathan Braun <n8tz.js@gmail.com>"
9
+ ],
10
+ "contributors": [
11
+ "N8tz (https://github.com/N8tz)"
12
+ ],
13
+ "bin": {
14
+ "fad-checker": "fad-check.js",
15
+ "fad": "fad-check.js"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test test/*.test.js",
19
+ "build:linux": "bun build fad-check.js --compile --target=bun-linux-x64 --outfile=dist/fad-check-linux",
20
+ "build:win": "bun build fad-check.js --compile --target=bun-windows-x64 --outfile=dist/fad-check.exe",
21
+ "build:macos": "bun build fad-check.js --compile --target=bun-darwin-x64 --outfile=dist/fad-check-macos",
22
+ "build": "npm run build:linux && npm run build:win"
23
+ },
24
+ "dependencies": {
25
+ "chalk": "^4.1.2",
26
+ "commander": "^14.0.1",
27
+ "p-limit": "^3.1.0",
28
+ "retire": "^5.4.2",
29
+ "rimraf": "^6.0.1",
30
+ "xml2js": "^0.6.2"
31
+ },
32
+ "engines": {
33
+ "node": ">=20"
34
+ }
35
+ }
@@ -0,0 +1,153 @@
1
+ const { test } = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const path = require("path");
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const { parseStringPromise } = require("xml2js");
7
+
8
+ const core = require("../lib/core");
9
+
10
+ const FIXTURES = path.join(__dirname, "fixtures");
11
+ const SIMPLE = path.join(FIXTURES, "simple");
12
+ const COMPLEX = path.join(FIXTURES, "complex-enterprise");
13
+ const PRIVATE_FIX = path.join(FIXTURES, "private-lib-detection");
14
+
15
+ async function pipeline(src, { deps2Exclude } = {}) {
16
+ const store = core.newMetadataStore();
17
+ const props = {};
18
+ const pomFiles = core.findPomFiles(src);
19
+ for (const f of pomFiles) await core.parsePom(f, store);
20
+ for (const f of pomFiles) await core.getAllInheritedProps(f, store, props);
21
+ return { store, props, pomFiles };
22
+ }
23
+
24
+ test("findPomFiles skips target/.git/node_modules", () => {
25
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fad-check-test-"));
26
+ fs.mkdirSync(path.join(tmp, "target"));
27
+ fs.writeFileSync(path.join(tmp, "target", "pom.xml"), "<project/>");
28
+ fs.writeFileSync(path.join(tmp, "pom.xml"), "<project/>");
29
+ const found = core.findPomFiles(tmp);
30
+ assert.equal(found.length, 1, "target/ should be skipped");
31
+ assert.equal(found[0], path.join(tmp, "pom.xml"));
32
+ fs.rmSync(tmp, { recursive: true, force: true });
33
+ });
34
+
35
+ test("parsePom extracts groupId/artifactId/version + parent + profiles", async () => {
36
+ const { store } = await pipeline(SIMPLE);
37
+ const root = store.byPath[path.join(SIMPLE, "pom.xml")];
38
+ assert.equal(root.groupId, "com.example.simple");
39
+ assert.equal(root.artifactId, "simple-parent");
40
+ assert.equal(root.version, "1.0.0");
41
+ assert.equal(root.parentInfo, null);
42
+
43
+ const app = store.byPath[path.join(SIMPLE, "app", "pom.xml")];
44
+ assert.equal(app.parentInfo.groupId, "com.example.simple");
45
+ assert.equal(app.parentInfo.artifactId, "simple-parent");
46
+ });
47
+
48
+ test("byId does not get polluted with undefined keys", async () => {
49
+ const { store } = await pipeline(SIMPLE);
50
+ for (const key of Object.keys(store.byId)) {
51
+ assert.ok(!key.includes("undefined"), `byId key has 'undefined': ${key}`);
52
+ assert.ok(!key.includes("null"), `byId key has 'null': ${key}`);
53
+ }
54
+ });
55
+
56
+ test("relativePath as directory resolves to dir/pom.xml", async () => {
57
+ const { store } = await pipeline(SIMPLE);
58
+ const app = store.byPath[path.join(SIMPLE, "app", "pom.xml")];
59
+ // resolveParentPath populates parentDescr
60
+ core.resolveParentPath(app.pomPath, app.parentInfo, store);
61
+ assert.ok(app.parentDescr, "parent descriptor should be resolved");
62
+ assert.equal(app.parentDescr.artifactId, "simple-parent");
63
+ });
64
+
65
+ test("activeByDefault profile is detected and used for property overrides", async () => {
66
+ const { store, props } = await pipeline(COMPLEX);
67
+ const root = store.byPath[path.join(COMPLEX, "pom.xml")];
68
+ assert.equal(root.defaultProfileId, "dev");
69
+ const merged = props[path.join(COMPLEX, "pom.xml")];
70
+ // env.profile should be 'dev' (from activeByDefault) — but properties are
71
+ // stored as xml2js arrays; unwrap when reading.
72
+ const envProfile = merged.properties["env.profile"];
73
+ const val = Array.isArray(envProfile) ? envProfile[0] : envProfile;
74
+ assert.equal(val, "dev");
75
+ });
76
+
77
+ test("all-profile merge picks up deps from every profile", async () => {
78
+ const { props } = await pipeline(COMPLEX);
79
+ const root = props[path.join(COMPLEX, "pom.xml")];
80
+ const ids = root.dependencies.map(d => `${d.groupId?.[0]}:${d.artifactId?.[0]}`);
81
+ // Each profile contributed a dep — all three must be present.
82
+ assert.ok(ids.includes("com.h2database:h2"), "dev profile (h2) missing");
83
+ assert.ok(ids.includes("org.postgresql:postgresql"), "prod profile (postgres) missing");
84
+ assert.ok(ids.includes("com.acme.private:acme-oracle-driver"), "oracle profile (private) missing");
85
+ });
86
+
87
+ test("BOM import (scope=import) pulls in managed deps from local BOM", async () => {
88
+ const { props } = await pipeline(COMPLEX);
89
+ const api = props[path.join(COMPLEX, "api", "pom.xml")];
90
+ const mgmtIds = api.dependencyManagement.map(d => `${d.groupId?.[0]}:${d.artifactId?.[0]}`);
91
+ assert.ok(mgmtIds.includes("org.hibernate:hibernate-core"), "BOM hibernate not imported");
92
+ assert.ok(mgmtIds.includes("com.fasterxml.jackson.core:jackson-databind"), "BOM jackson not imported");
93
+ });
94
+
95
+ test("rewritePoms writes a clean tree in --target mode, target ≠ src", async () => {
96
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fad-check-target-"));
97
+ const { store, props, pomFiles } = await pipeline(COMPLEX);
98
+ const opts = {
99
+ srcRoot: COMPLEX, targetRoot: tmp,
100
+ deps2Exclude: /^com\.acme\.private$/, verbose: false, readOnly: false,
101
+ };
102
+ let wrote = 0;
103
+ for (const f of pomFiles) if (await core.rewritePoms(f, store, props, opts)) wrote++;
104
+ assert.ok(wrote >= 4, `expected ≥4 POMs written, got ${wrote}`);
105
+ const apiPomOut = path.join(tmp, "api", "pom.xml");
106
+ assert.ok(fs.existsSync(apiPomOut));
107
+ const apiOut = await parseStringPromise(fs.readFileSync(apiPomOut, "utf8"));
108
+ const deps = apiOut.project?.dependencies?.[0]?.dependency || [];
109
+ const ids = deps.map(d => `${d.groupId?.[0]}:${d.artifactId?.[0]}`);
110
+ // Private dep must be filtered out
111
+ assert.ok(!ids.includes("com.acme.private:acme-commons"), "private dep should be excluded");
112
+ // Public dep must remain
113
+ assert.ok(ids.includes("com.fasterxml.jackson.core:jackson-databind"), "public dep dropped unexpectedly");
114
+ // And excludedById must contain the private coord
115
+ assert.ok(store.excludedById["com.acme.private:acme-commons"], "excluded coord not flagged");
116
+ fs.rmSync(tmp, { recursive: true, force: true });
117
+ });
118
+
119
+ test("rewritePoms in --test (readOnly) mode does not crash with undefined target", async () => {
120
+ const { store, props, pomFiles } = await pipeline(SIMPLE);
121
+ const opts = { srcRoot: SIMPLE, targetRoot: undefined, deps2Exclude: null, verbose: false, readOnly: true };
122
+ // Should not throw despite target being undefined
123
+ for (const f of pomFiles) await core.rewritePoms(f, store, props, opts);
124
+ });
125
+
126
+ test("missing external parent is flagged in missingById", async () => {
127
+ const { store, props, pomFiles } = await pipeline(PRIVATE_FIX);
128
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fad-check-priv-"));
129
+ const opts = {
130
+ srcRoot: PRIVATE_FIX, targetRoot: tmp,
131
+ deps2Exclude: /^(com\.client\.private|org\.megacorp)/,
132
+ verbose: false, readOnly: false,
133
+ };
134
+ for (const f of pomFiles) await core.rewritePoms(f, store, props, opts);
135
+ // The root pom's external parent org.megacorp.parents:megacorp-super-parent must be in missingById
136
+ assert.ok(
137
+ store.missingById["org.megacorp.parents:megacorp-super-parent"] ||
138
+ store.missingById["org.megacorp.parents:megacorp-super-parent:9.9.9-PRIVATE"],
139
+ "external private parent not tracked as missing"
140
+ );
141
+ fs.rmSync(tmp, { recursive: true, force: true });
142
+ });
143
+
144
+ test("parent version in rewritten POM uses parent's version, not child's", async () => {
145
+ // Simple has child app with no own <version>; the rewritten parent ref should be 1.0.0
146
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "fad-check-pv-"));
147
+ const { store, props, pomFiles } = await pipeline(SIMPLE);
148
+ const opts = { srcRoot: SIMPLE, targetRoot: tmp, deps2Exclude: null, verbose: false, readOnly: false };
149
+ for (const f of pomFiles) await core.rewritePoms(f, store, props, opts);
150
+ const appOut = await parseStringPromise(fs.readFileSync(path.join(tmp, "app", "pom.xml"), "utf8"));
151
+ assert.equal(appOut.project.parent[0].version[0], "1.0.0", "parent.version must equal parent's version");
152
+ fs.rmSync(tmp, { recursive: true, force: true });
153
+ });