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,134 @@
1
+ /**
2
+ * lib/maven-repo.js — fan-out HTTP fetcher across the user's configured
3
+ * Maven repositories with Maven Central as a final fallback.
4
+ *
5
+ * Use cases:
6
+ * - Fetching a transitive POM (lib/transitive.js)
7
+ * - Checking whether an artifact exists at all (HEAD)
8
+ * - Reading <maven-metadata.xml> for latest-version discovery
9
+ * (lib/outdated.js)
10
+ *
11
+ * Repository entry shape (from ~/.fad-check/config.json or CLI):
12
+ * { name?, url, auth? } auth = "user:pass" (we wrap as Basic <base64>)
13
+ *
14
+ * URL convention: each repo URL must end at the directory under which Maven
15
+ * artifacts are laid out the standard way:
16
+ * <repo-url>/<groupId-with-/>/<artifactId>/<version>/<artifactId>-<version>.pom
17
+ *
18
+ * The first 2xx wins. Misses are silently aggregated; the caller decides
19
+ * what to do with "not found anywhere".
20
+ */
21
+ const MAVEN_CENTRAL = { name: "central", url: "https://repo1.maven.org/maven2/" };
22
+
23
+ function normalise(url) {
24
+ if (!url) return url;
25
+ return url.endsWith("/") ? url : url + "/";
26
+ }
27
+
28
+ /**
29
+ * Parse user:pass embedded in a URL (e.g. https://alice:s3cr3t@nexus.acme/...)
30
+ * Returns { url, auth } where auth is "user:pass" stripped of the URL part.
31
+ */
32
+ function splitUrlAuth(url) {
33
+ if (!url) return { url, auth: null };
34
+ try {
35
+ const u = new URL(url);
36
+ if (u.username || u.password) {
37
+ const auth = decodeURIComponent(u.username) + ":" + decodeURIComponent(u.password);
38
+ u.username = ""; u.password = "";
39
+ return { url: u.toString(), auth };
40
+ }
41
+ } catch { /* not a URL — return as-is */ }
42
+ return { url, auth: null };
43
+ }
44
+
45
+ /**
46
+ * Build the effective repository list: user-configured + extras (from --repo
47
+ * CLI) + Maven Central as final fallback. Dedupes by URL.
48
+ */
49
+ function buildRepoList(userRepos, extraRepos = []) {
50
+ const out = [];
51
+ const seen = new Set();
52
+ const push = r => {
53
+ if (!r?.url) return;
54
+ const { url, auth } = splitUrlAuth(normalise(r.url));
55
+ if (seen.has(url)) return;
56
+ seen.add(url);
57
+ out.push({ name: r.name || url, url, auth: r.auth || auth || null });
58
+ };
59
+ for (const r of userRepos || []) push(r);
60
+ for (const r of extraRepos || []) push(r);
61
+ push(MAVEN_CENTRAL);
62
+ return out;
63
+ }
64
+
65
+ function authHeader(auth) {
66
+ if (!auth) return null;
67
+ return "Basic " + Buffer.from(auth).toString("base64");
68
+ }
69
+
70
+ /**
71
+ * Try fetching `pathSuffix` (relative to each repo URL) from every repo
72
+ * in order. Returns the first 2xx response as { repo, response, body? }.
73
+ *
74
+ * opts:
75
+ * method "GET" (default) | "HEAD"
76
+ * fetcher custom fetch (for tests)
77
+ * readBody read response.text() into body (default false to save mem
78
+ * on HEAD calls; transitive.js sets true for POMs)
79
+ * userAgent default "fad-check-maven-repo"
80
+ * onMiss callback(repo, status) for telemetry (verbose mode)
81
+ */
82
+ async function tryRepos(repos, pathSuffix, opts = {}) {
83
+ const { method = "GET", fetcher = globalThis.fetch, readBody = false, userAgent = "fad-check-maven-repo", onMiss } = opts;
84
+ for (const repo of repos) {
85
+ const url = repo.url + pathSuffix.replace(/^\//, "");
86
+ const headers = { "User-Agent": userAgent };
87
+ const ah = authHeader(repo.auth);
88
+ if (ah) headers.Authorization = ah;
89
+ let r;
90
+ try {
91
+ r = await fetcher(url, { method, headers });
92
+ } catch (err) {
93
+ if (onMiss) onMiss(repo, `network: ${err.message}`);
94
+ continue;
95
+ }
96
+ if (r.ok) {
97
+ let body = null;
98
+ if (readBody && method !== "HEAD") {
99
+ try { body = await r.text(); } catch { /* ignore body read fail */ }
100
+ }
101
+ return { repo, response: r, body, url };
102
+ }
103
+ if (onMiss) onMiss(repo, `HTTP ${r.status}`);
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /** Convenience: HEAD an artifact (any of the listed repos) to check existence. */
109
+ function existsInAny(repos, pathSuffix, opts = {}) {
110
+ return tryRepos(repos, pathSuffix, { ...opts, method: "HEAD" });
111
+ }
112
+
113
+ /** Convenience: GET a POM (text/xml) — sets readBody=true. */
114
+ function fetchPomFromRepos(repos, groupId, artifactId, version, opts = {}) {
115
+ const p = `${groupId.replace(/\./g, "/")}/${artifactId}/${version}/${artifactId}-${version}.pom`;
116
+ return tryRepos(repos, p, { ...opts, method: "GET", readBody: true });
117
+ }
118
+
119
+ /** Convenience: GET maven-metadata.xml (for latest-version discovery). */
120
+ function fetchMavenMetadata(repos, groupId, artifactId, opts = {}) {
121
+ const p = `${groupId.replace(/\./g, "/")}/${artifactId}/maven-metadata.xml`;
122
+ return tryRepos(repos, p, { ...opts, method: "GET", readBody: true });
123
+ }
124
+
125
+ module.exports = {
126
+ MAVEN_CENTRAL,
127
+ buildRepoList,
128
+ tryRepos,
129
+ existsInAny,
130
+ fetchPomFromRepos,
131
+ fetchMavenMetadata,
132
+ splitUrlAuth,
133
+ authHeader,
134
+ };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * lib/maven-version.js — Maven-flavoured version parsing and comparison.
3
+ *
4
+ * Maven version ordering rules (approximation of Apache Maven's
5
+ * ComparableVersion):
6
+ * - Versions are split on `.` and `-` into segments.
7
+ * - Numeric segments compare numerically.
8
+ * - String segments compare via a qualifier ordering:
9
+ * alpha < beta < milestone < rc < snapshot < "" (release) < sp
10
+ * - Trailing zeros are insignificant: 1.0 == 1.0.0 == 1.
11
+ * - Known release qualifiers (final, release, ga) are treated as "".
12
+ */
13
+
14
+ // Lower number == lower precedence
15
+ const QUALIFIER_ORDER = {
16
+ "alpha": 1, "a": 1,
17
+ "beta": 2, "b": 2,
18
+ "milestone": 3, "m": 3,
19
+ "rc": 4, "cr": 4,
20
+ "snapshot": 5,
21
+ "": 6, "ga": 6, "final": 6, "release": 6,
22
+ "sp": 7,
23
+ };
24
+
25
+ function parseMavenVersion(versionStr) {
26
+ if (versionStr == null) return { original: "", segments: [] };
27
+ const original = String(versionStr).trim();
28
+ if (!original) return { original: "", segments: [] };
29
+
30
+ // Split on `.` and `-`, lowercase string segments
31
+ const raw = original.toLowerCase().split(/[.\-]/);
32
+ const segments = raw.map(s => {
33
+ if (/^\d+$/.test(s)) return { kind: "num", value: parseInt(s, 10) };
34
+ // Embedded numbers (e.g. "rc1" → ["rc", 1])
35
+ const m = s.match(/^([a-z]+)(\d+)$/);
36
+ if (m) return { kind: "qual+num", qual: m[1], num: parseInt(m[2], 10) };
37
+ return { kind: "str", value: s };
38
+ });
39
+ return { original, segments };
40
+ }
41
+
42
+ function qualifierRank(q) {
43
+ if (q == null) return QUALIFIER_ORDER[""];
44
+ const r = QUALIFIER_ORDER[q.toLowerCase()];
45
+ return r != null ? r : QUALIFIER_ORDER[""] - 0.5; // unknown qualifier sits just below release
46
+ }
47
+
48
+ function qualOf(seg) {
49
+ if (!seg) return null;
50
+ if (seg.kind === "str") return seg.value;
51
+ if (seg.kind === "qual+num") return seg.qual;
52
+ return null;
53
+ }
54
+
55
+ function cmpSegments(a, b) {
56
+ // a or b may be missing — treat as numeric 0 (trailing zeros are insignificant)
57
+ if (!a) {
58
+ if (b.kind === "num") return b.value === 0 ? 0 : -1;
59
+ // b is a qualifier (str or qual+num) — pre-release < release
60
+ return qualifierRank("") - qualifierRank(qualOf(b));
61
+ }
62
+ if (!b) {
63
+ if (a.kind === "num") return a.value === 0 ? 0 : 1;
64
+ return qualifierRank(qualOf(a)) - qualifierRank("");
65
+ }
66
+ if (a.kind === "num" && b.kind === "num") return a.value - b.value;
67
+ if (a.kind === "num") {
68
+ // number vs qualifier — numbers are "newer" than pre-release qualifiers
69
+ const r = qualifierRank(b.value);
70
+ return r < QUALIFIER_ORDER[""] ? 1 : -1;
71
+ }
72
+ if (b.kind === "num") {
73
+ const r = qualifierRank(a.value);
74
+ return r < QUALIFIER_ORDER[""] ? -1 : 1;
75
+ }
76
+ if (a.kind === "qual+num" && b.kind === "qual+num") {
77
+ const d = qualifierRank(a.qual) - qualifierRank(b.qual);
78
+ return d !== 0 ? d : a.num - b.num;
79
+ }
80
+ if (a.kind === "qual+num") return qualifierRank(a.qual) - qualifierRank(b.value);
81
+ if (b.kind === "qual+num") return qualifierRank(a.value) - qualifierRank(b.qual);
82
+ return qualifierRank(a.value) - qualifierRank(b.value);
83
+ }
84
+
85
+ function compareMavenVersions(aStr, bStr) {
86
+ const a = parseMavenVersion(aStr).segments;
87
+ const b = parseMavenVersion(bStr).segments;
88
+ const n = Math.max(a.length, b.length);
89
+ for (let i = 0; i < n; i++) {
90
+ const c = cmpSegments(a[i], b[i]);
91
+ if (c !== 0) return c < 0 ? -1 : 1;
92
+ }
93
+ return 0;
94
+ }
95
+
96
+ /**
97
+ * Check whether a dependency version falls within a CVE-specified range.
98
+ * spec shape: { version, status, lessThan, lessThanOrEqual, versionType }
99
+ * Returns true if depVersion is affected.
100
+ */
101
+ function isVersionAffected(depVersion, spec) {
102
+ if (!spec) return false;
103
+ if (spec.status && spec.status !== "affected") return false;
104
+
105
+ const dep = parseMavenVersion(depVersion);
106
+ if (!dep.segments.length) return false;
107
+
108
+ // Lower bound (inclusive)
109
+ if (spec.version && spec.version !== "0" && spec.version !== "*") {
110
+ if (compareMavenVersions(depVersion, spec.version) < 0) return false;
111
+ }
112
+ // Upper bound exclusive
113
+ if (spec.lessThan) {
114
+ if (compareMavenVersions(depVersion, spec.lessThan) >= 0) return false;
115
+ }
116
+ // Upper bound inclusive
117
+ if (spec.lessThanOrEqual) {
118
+ if (compareMavenVersions(depVersion, spec.lessThanOrEqual) > 0) return false;
119
+ }
120
+ // Exact match with no bounds — only affected if equal
121
+ if (!spec.lessThan && !spec.lessThanOrEqual && spec.version && spec.version !== "0" && spec.version !== "*") {
122
+ if (compareMavenVersions(depVersion, spec.version) !== 0) return false;
123
+ }
124
+ return true;
125
+ }
126
+
127
+ /**
128
+ * Parse a Maven version range expression like "[1.0,2.0)", "(,1.5]", "1.2.3".
129
+ * Returns { lower, lowerInclusive, upper, upperInclusive, exact } or null.
130
+ */
131
+ function parseRange(rangeStr) {
132
+ if (rangeStr == null) return null;
133
+ const s = String(rangeStr).trim();
134
+ if (!s) return null;
135
+ if (!/^[\[\(]/.test(s)) return { exact: s };
136
+ const open = s[0];
137
+ const close = s[s.length - 1];
138
+ const inner = s.slice(1, -1);
139
+ const [lo, hi] = inner.split(",").map(p => p.trim());
140
+ return {
141
+ lower: lo || null,
142
+ lowerInclusive: open === "[",
143
+ upper: hi || null,
144
+ upperInclusive: close === "]",
145
+ };
146
+ }
147
+
148
+ module.exports = {
149
+ parseMavenVersion,
150
+ compareMavenVersions,
151
+ isVersionAffected,
152
+ parseRange,
153
+ };
@@ -0,0 +1,224 @@
1
+ /**
2
+ * lib/npm/collect.js — walk a JS project root, parse every manifest /
3
+ * lockfile, and produce a Map<key, depRecord> matching the shape that
4
+ * lib/cve-match.collectResolvedDeps produces for Maven.
5
+ *
6
+ * key: "npm:<name>" (deliberately namespaced so it never
7
+ * collides with Maven "g:a" keys)
8
+ * depRecord: { groupId: "", artifactId: name, version, scope, pomPaths,
9
+ * ecosystem: "npm", resolved, integrity, lockType,
10
+ * manifestPaths: [absolute paths of files mentioning it] }
11
+ *
12
+ * Conflict resolution between manifests for the same package:
13
+ * - lockfile entries WIN over package.json range specs (concrete version)
14
+ * - if two lockfiles disagree, keep the highest semver-comparable version
15
+ * - dev/optional/peer downgrade to prod if any manifest puts the dep in prod
16
+ */
17
+ const path = require("path");
18
+ const fs = require("fs");
19
+ const { parsePackageJson, parsePackageLock, parseYarnLockV1, findJsManifests } = require("./parse");
20
+
21
+ const SCOPE_RANK = { prod: 4, peer: 3, optional: 2, dev: 1, transitive: 0 };
22
+
23
+ function rankScope(s) { return SCOPE_RANK[s] || 0; }
24
+
25
+ function semverCompare(a, b) {
26
+ // Lightweight semver compare — good enough for "keep highest"; not
27
+ // canonical (doesn't fully implement build metadata ordering). For CVE
28
+ // matching we'll defer to ecosystem-aware comparators when needed.
29
+ const norm = v => String(v || "").replace(/^[v=]+/, "").split(/[-+]/)[0].split(".").map(n => parseInt(n, 10) || 0);
30
+ const ax = norm(a), bx = norm(b);
31
+ const n = Math.max(ax.length, bx.length);
32
+ for (let i = 0; i < n; i++) {
33
+ const d = (ax[i] || 0) - (bx[i] || 0);
34
+ if (d !== 0) return d > 0 ? 1 : -1;
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ function isResolvedVersion(v) {
40
+ // Reject ranges/specifiers like "^1.0.0", "~2.0", ">=1.0.0", "*", "latest", "git+..."
41
+ if (!v) return false;
42
+ if (/^[\^~>=<*]/.test(v)) return false;
43
+ if (/^(latest|next|workspace:|git\+|file:|link:|http)/i.test(v)) return false;
44
+ // Bare semver-ish strings only (digits and at most one dash for prerelease tag)
45
+ return /^\d+\.\d+(\.\d+)?([.\-+]\S+)?$/.test(v) || /^\d+$/.test(v);
46
+ }
47
+
48
+ function upsert(out, dep, manifestPath, lockType) {
49
+ const key = `npm:${dep.name}`;
50
+ const existing = out.get(key);
51
+ // ecosystemType narrows "ecosystem" to the tool that produced the lockfile:
52
+ // "npm" for package-lock.json
53
+ // "yarn" for yarn.lock
54
+ // Used by the report to split sections per tool.
55
+ const ecosystemType = (lockType || "").startsWith("package-lock") ? "npm"
56
+ : (lockType || "").startsWith("yarn") ? "yarn"
57
+ : "npm";
58
+ const record = {
59
+ groupId: "",
60
+ artifactId: dep.name,
61
+ version: dep.version || null,
62
+ scope: dep.scope || "prod",
63
+ isDev: !!dep.isDev,
64
+ pomPaths: [manifestPath],
65
+ manifestPaths: [manifestPath],
66
+ ecosystem: "npm",
67
+ ecosystemType,
68
+ lockType,
69
+ depth: dep.depth ?? null,
70
+ resolved: dep.resolved || null,
71
+ integrity: dep.integrity || null,
72
+ from: dep.from || null,
73
+ };
74
+ if (!existing) {
75
+ out.set(key, record);
76
+ return;
77
+ }
78
+ // Merge
79
+ if (!existing.manifestPaths.includes(manifestPath)) {
80
+ existing.manifestPaths.push(manifestPath);
81
+ existing.pomPaths.push(manifestPath);
82
+ }
83
+ // Prefer the resolved (lockfile) version over a range from package.json
84
+ const incoming = record.version;
85
+ const have = existing.version;
86
+ if (isResolvedVersion(incoming) && !isResolvedVersion(have)) {
87
+ existing.version = incoming;
88
+ } else if (isResolvedVersion(incoming) && isResolvedVersion(have)) {
89
+ if (semverCompare(incoming, have) > 0) existing.version = incoming;
90
+ } else if (!have && incoming) {
91
+ existing.version = incoming;
92
+ }
93
+ // Stronger scope wins (prod > peer > optional > dev)
94
+ if (rankScope(record.scope) > rankScope(existing.scope)) existing.scope = record.scope;
95
+ // A dep is "dev" overall only if every occurrence is dev. Any prod use → not dev.
96
+ if (!record.isDev) existing.isDev = false;
97
+ // First non-null resolved/integrity wins
98
+ if (!existing.resolved && record.resolved) existing.resolved = record.resolved;
99
+ if (!existing.integrity && record.integrity) existing.integrity = record.integrity;
100
+ }
101
+
102
+ /**
103
+ * Walk `rootDir`, parse every manifest, return { deps, warnings }.
104
+ *
105
+ * Returns:
106
+ * {
107
+ * deps: Map<key, depRecord>, // unchanged shape from before
108
+ * warnings: [ // surfaced into console + report
109
+ * { type: "no-lockfile", manifestPath, message }, ...
110
+ * ],
111
+ * }
112
+ *
113
+ * Lockfile policy: we ONLY collect deps from a lockfile (package-lock.json or
114
+ * yarn.lock). A package.json without a sibling lockfile is intentionally
115
+ * skipped — its values are ranges ("^1.0.0") that can't be queried against
116
+ * OSV and create false negatives. The caller is warned so they can either
117
+ * run `npm install` / `yarn install` to generate a lock, or accept the gap.
118
+ *
119
+ * opts:
120
+ * ignoreTest — skip dev / optional dependencies (mirrors Maven's --ignore-test)
121
+ * deps2Exclude — RegExp tested against the npm name (covers private @scope/* packages)
122
+ * verbose
123
+ */
124
+ function collectNpmDeps(rootDir, opts = {}) {
125
+ const { ignoreTest, deps2Exclude, verbose } = opts;
126
+ const out = new Map();
127
+ const warnings = [];
128
+ const manifestGroups = findJsManifests(rootDir);
129
+ let parsedCount = 0;
130
+
131
+ for (const group of manifestGroups) {
132
+ const pj = group.packageJson ? safeParse(parsePackageJson, group.packageJson, verbose) : null;
133
+ const pl = group.packageLock ? safeParse(parsePackageLock, group.packageLock, verbose) : null;
134
+ const yl = group.yarnLock ? safeParse(parseYarnLockV1, group.yarnLock, verbose) : null;
135
+ const hasLock = !!(pl || yl);
136
+
137
+ // package.json without lockfile → warning, skip dep collection.
138
+ if (pj && !hasLock) {
139
+ warnings.push({
140
+ type: "no-lockfile",
141
+ manifestPath: group.packageJson,
142
+ packageName: pj.packageName || null,
143
+ message: `package.json without lockfile — skipped (run "npm install" or "yarn install" to generate one)`,
144
+ });
145
+ if (verbose) console.warn(`⚠️ ${group.packageJson}: no lockfile, skipped (${pj.deps.length} ranges in package.json)`);
146
+ }
147
+
148
+ // Yarn-Berry lockfile detected but unsupported → warning, skip.
149
+ if (yl?.unsupported === "yarn-berry") {
150
+ warnings.push({
151
+ type: "yarn-berry-unsupported",
152
+ manifestPath: group.yarnLock,
153
+ message: `yarn 2+/Berry lockfile not yet supported — skipped`,
154
+ });
155
+ if (verbose) console.warn(`⚠️ ${group.yarnLock}: yarn-berry not supported, skipped`);
156
+ }
157
+
158
+ // Used only as a source of scope info — never emits deps on its own.
159
+ const directScopes = pj ? new Map(pj.deps.map(d => [d.name, d.scope])) : null;
160
+
161
+ if (pl) {
162
+ parsedCount++;
163
+ for (const d of pl.deps) {
164
+ // In v1 npm dev/optional propagate; in v2/v3 the `dev` flag is reliable
165
+ const explicit = directScopes?.get(d.name);
166
+ const scope = explicit || d.scope || "prod";
167
+ if (ignoreTest && (scope === "dev" || scope === "optional")) continue;
168
+ if (deps2Exclude && deps2Exclude.test(d.name)) continue;
169
+ upsert(out, { ...d, scope }, group.packageLock, `package-lock-v${pl.lockfileVersion}`);
170
+ }
171
+ }
172
+ if (yl) {
173
+ parsedCount++;
174
+ // yarn-berry warning already pushed above; only emit deps for v1
175
+ if (yl.unsupported !== "yarn-berry") {
176
+ for (const d of yl.deps) {
177
+ const explicit = directScopes?.get(d.name);
178
+ const scope = explicit || "prod";
179
+ if (ignoreTest && (scope === "dev" || scope === "optional")) continue;
180
+ if (deps2Exclude && deps2Exclude.test(d.name)) continue;
181
+ upsert(out, { ...d, scope }, group.yarnLock, "yarn-v1");
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ if (verbose) console.log(`📦 npm/yarn: parsed ${parsedCount} manifests, ${out.size} unique packages${warnings.length ? `, ${warnings.length} warnings` : ""}`);
188
+ // Backward compat: the Map carries the warnings under a non-enumerable prop
189
+ // so existing callers that iterate `for (const [k,v] of map)` still work.
190
+ Object.defineProperty(out, "warnings", { value: warnings, enumerable: false });
191
+ return out;
192
+ }
193
+
194
+ function safeParse(fn, file, verbose) {
195
+ try { return fn(file); }
196
+ catch (e) {
197
+ if (verbose) console.warn(`⚠️ parse failed: ${file} — ${e.message}`);
198
+ return null;
199
+ }
200
+ }
201
+
202
+ function hasJsManifests(rootDir) {
203
+ try {
204
+ const stack = [rootDir];
205
+ const skip = new Set([
206
+ "node_modules", "bower_components", "jspm_packages",
207
+ ".git", ".idea", ".vscode", ".gradle", ".mvn",
208
+ "dist", "build", "out", "target", "coverage", ".next", ".nuxt",
209
+ ]);
210
+ while (stack.length) {
211
+ const cur = stack.pop();
212
+ let entries;
213
+ try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
214
+ catch { continue; }
215
+ for (const e of entries) {
216
+ if (e.isFile() && (e.name === "package.json" || e.name === "package-lock.json" || e.name === "yarn.lock")) return true;
217
+ if (e.isDirectory() && !skip.has(e.name)) stack.push(path.join(cur, e.name));
218
+ }
219
+ }
220
+ } catch { /* ignore */ }
221
+ return false;
222
+ }
223
+
224
+ module.exports = { collectNpmDeps, hasJsManifests };