fad-checker 1.0.6 → 2.0.1

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 (79) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +77 -15
  3. package/completions/fad-checker.bash +4 -1
  4. package/completions/fad-checker.zsh +9 -0
  5. package/data/eol-mapping.json +17 -0
  6. package/fad-checker.js +353 -241
  7. package/lib/codecs/codec.interface.js +27 -0
  8. package/lib/codecs/composer/parse.js +59 -0
  9. package/lib/codecs/composer/registry.js +92 -0
  10. package/lib/codecs/composer.codec.js +93 -0
  11. package/lib/codecs/index.js +37 -0
  12. package/lib/codecs/maven.codec.js +71 -0
  13. package/lib/{npm → codecs/npm}/collect.js +58 -35
  14. package/lib/{npm → codecs/npm}/parse.js +114 -10
  15. package/lib/{npm → codecs/npm}/registry.js +4 -3
  16. package/lib/codecs/npm.codec.js +52 -0
  17. package/lib/codecs/nuget/parse.js +75 -0
  18. package/lib/codecs/nuget/registry.js +88 -0
  19. package/lib/codecs/nuget.codec.js +94 -0
  20. package/lib/codecs/pypi/parse.js +163 -0
  21. package/lib/codecs/pypi/registry.js +89 -0
  22. package/lib/codecs/pypi.codec.js +102 -0
  23. package/lib/codecs/recipes.js +108 -0
  24. package/lib/codecs/select.js +27 -0
  25. package/lib/codecs/yarn.codec.js +19 -0
  26. package/lib/core.js +35 -1
  27. package/lib/cve-match.js +18 -11
  28. package/lib/cve-report.js +34 -70
  29. package/lib/dep-record.js +60 -0
  30. package/lib/deps-descriptor.js +110 -0
  31. package/lib/nvd.js +4 -3
  32. package/lib/osv.js +29 -18
  33. package/lib/outdated.js +20 -4
  34. package/lib/retire.js +77 -13
  35. package/lib/transitive.js +3 -3
  36. package/lib/ui.js +87 -0
  37. package/package.json +4 -2
  38. package/CLAUDE.md +0 -129
  39. package/docs/ARCHITECTURE.md +0 -154
  40. package/docs/USAGE.md +0 -179
  41. package/test/core.test.js +0 -153
  42. package/test/cpe.test.js +0 -166
  43. package/test/cve-download.test.js +0 -39
  44. package/test/cve-match.test.js +0 -217
  45. package/test/cve-report.test.js +0 -171
  46. package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
  47. package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
  48. package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
  49. package/test/fixtures/complex-enterprise/pom.xml +0 -66
  50. package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
  51. package/test/fixtures/cve-samples/cve-non-java.json +0 -19
  52. package/test/fixtures/cve-samples/cve-product-only.json +0 -31
  53. package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
  54. package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
  55. package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
  56. package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
  57. package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
  58. package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
  59. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -9
  60. package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
  61. package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
  62. package/test/fixtures/monorepo-mixed/pom.xml +0 -29
  63. package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
  64. package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
  65. package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
  66. package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
  67. package/test/fixtures/private-lib-detection/pom.xml +0 -35
  68. package/test/fixtures/simple/app/pom.xml +0 -28
  69. package/test/fixtures/simple/lib/pom.xml +0 -18
  70. package/test/fixtures/simple/pom.xml +0 -24
  71. package/test/maven-repo.test.js +0 -111
  72. package/test/maven-version.test.js +0 -57
  73. package/test/monorepo.test.js +0 -132
  74. package/test/npm-registry.test.js +0 -64
  75. package/test/npm.test.js +0 -146
  76. package/test/outdated.test.js +0 -101
  77. package/test/snyk.test.js +0 -64
  78. package/test/transitive.test.js +0 -305
  79. package/test/webjar.test.js +0 -33
@@ -0,0 +1,27 @@
1
+ /**
2
+ * lib/codecs/codec.interface.js — contrat que tout codec doit respecter.
3
+ *
4
+ * Pas de classe imposée : un codec est un objet litéral exportant ces clés.
5
+ * Voir docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md.
6
+ */
7
+ const REQUIRED_KEYS = [
8
+ "id", "label", "osvEcosystem", "manifestNames",
9
+ "detect", "collect", "coordKey", "formatCoord", "osvPackageName",
10
+ "checkRegistry", "resolveEolProduct", "recipe", "nativeScanners",
11
+ ];
12
+ const FUNCTION_KEYS = ["detect", "collect", "coordKey", "formatCoord", "osvPackageName", "checkRegistry", "resolveEolProduct"];
13
+
14
+ function assertCodecShape(codec) {
15
+ if (!codec || typeof codec !== "object") throw new Error("codec must be an object");
16
+ for (const k of REQUIRED_KEYS) {
17
+ if (!(k in codec)) throw new Error(`codec "${codec.id || "?"}" missing required key: ${k}`);
18
+ }
19
+ for (const k of FUNCTION_KEYS) {
20
+ if (typeof codec[k] !== "function") throw new Error(`codec "${codec.id}" key ${k} must be a function`);
21
+ }
22
+ if (!Array.isArray(codec.manifestNames)) throw new Error(`codec "${codec.id}" manifestNames must be an array`);
23
+ if (!Array.isArray(codec.nativeScanners)) throw new Error(`codec "${codec.id}" nativeScanners must be an array`);
24
+ return true;
25
+ }
26
+
27
+ module.exports = { REQUIRED_KEYS, FUNCTION_KEYS, assertCodecShape };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * lib/composer/parse.js — parse PHP Composer manifests.
3
+ *
4
+ * composer.lock — { packages: [{name, version}], "packages-dev": [...] }
5
+ * versions are concrete (resolved) — the ideal source.
6
+ * composer.json — { require: {name: range}, "require-dev": {...} }
7
+ * used only as a no-lock fallback (pinned exact versions).
8
+ *
9
+ * Composer names are "vendor/package", case-insensitive (lowercased by convention).
10
+ */
11
+ const fs = require("fs");
12
+
13
+ // "1.2.3" | "v1.2.3" | "dev-main" | "1.0.x-dev". Strip a leading "v" for OSV/registry.
14
+ function normVersion(v) {
15
+ if (!v) return null;
16
+ return String(v).replace(/^v/, "");
17
+ }
18
+ function isConcrete(v) {
19
+ if (!v) return false;
20
+ return /^\d+(\.\d+)*([.\-+]\S+)?$/.test(String(v).replace(/^v/, ""));
21
+ }
22
+ function splitName(full) {
23
+ const i = full.indexOf("/");
24
+ if (i < 0) return { vendor: "", pkg: full };
25
+ return { vendor: full.slice(0, i), pkg: full.slice(i + 1) };
26
+ }
27
+
28
+ function parseComposerLock(filePath) {
29
+ const json = JSON.parse(fs.readFileSync(filePath, "utf8"));
30
+ const deps = [];
31
+ const push = (arr, scope) => {
32
+ for (const p of arr || []) {
33
+ if (!p.name) continue;
34
+ const { vendor, pkg } = splitName(p.name);
35
+ deps.push({ name: p.name, vendor, pkg, version: normVersion(p.version), scope, isDev: scope === "dev" });
36
+ }
37
+ };
38
+ push(json.packages, "prod");
39
+ push(json["packages-dev"], "dev");
40
+ return { manifestPath: filePath, manifestType: "composer.lock", deps };
41
+ }
42
+
43
+ function parseComposerJson(filePath) {
44
+ const json = JSON.parse(fs.readFileSync(filePath, "utf8"));
45
+ const deps = [];
46
+ const push = (obj, scope) => {
47
+ for (const [name, version] of Object.entries(obj || {})) {
48
+ // Platform requirements aren't packages.
49
+ if (name === "php" || name.startsWith("ext-") || name.startsWith("lib-")) continue;
50
+ const { vendor, pkg } = splitName(name);
51
+ deps.push({ name, vendor, pkg, version: String(version), scope, isDev: scope === "dev" });
52
+ }
53
+ };
54
+ push(json.require, "prod");
55
+ push(json["require-dev"], "dev");
56
+ return { manifestPath: filePath, manifestType: "composer.json", deps };
57
+ }
58
+
59
+ module.exports = { parseComposerLock, parseComposerJson, normVersion, isConcrete, splitName };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * lib/composer/registry.js — query Packagist for a package's latest stable
3
+ * version and its `abandoned` flag (≈ npm `deprecated`).
4
+ *
5
+ * API: https://packagist.org/packages/{vendor}/{pkg}.json
6
+ * → { package: { abandoned: bool|string, versions: { "<v>": {...} } } }
7
+ *
8
+ * Cached in ~/.fad-checker/packagist-cache.json for 24h.
9
+ */
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const os = require("os");
13
+ let pLimit; try { pLimit = require("p-limit"); } catch { pLimit = () => (fn) => fn(); }
14
+
15
+ const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
16
+ const CACHE_PATH = path.join(CACHE_DIR, "packagist-cache.json");
17
+ const CACHE_MAX_AGE_MS = 24 * 3600 * 1000;
18
+ const API = "https://packagist.org/packages";
19
+
20
+ function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); } catch { return { entries: {}, meta: {} }; } }
21
+ function saveCache(d) { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_PATH, JSON.stringify(d)); } catch { /* ignore */ } }
22
+
23
+ function isStable(v) { return /^\d+(\.\d+)*$/.test(String(v || "").replace(/^v/, "")); }
24
+ function cmp(a, b) {
25
+ const pa = String(a).replace(/^v/, "").split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
26
+ const pb = String(b).replace(/^v/, "").split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
27
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d > 0 ? 1 : -1; }
28
+ return 0;
29
+ }
30
+
31
+ // Extract {abandoned, outdated} from a Packagist `package` object.
32
+ function packagistToFindings(pkg, { version }) {
33
+ const out = { abandoned: null, outdated: null };
34
+ if (pkg.abandoned === true) out.abandoned = { replacement: null };
35
+ else if (typeof pkg.abandoned === "string") out.abandoned = { replacement: pkg.abandoned };
36
+ const stable = Object.keys(pkg.versions || {}).map(v => v.replace(/^v/, "")).filter(isStable);
37
+ if (stable.length) {
38
+ const latest = stable.sort(cmp).at(-1);
39
+ if (latest && cmp(latest, String(version).replace(/^v/, "")) > 0) out.outdated = { latest };
40
+ }
41
+ return out;
42
+ }
43
+
44
+ async function fetchPackage(name, { offline }) {
45
+ if (offline) return null;
46
+ try {
47
+ const res = await fetch(`${API}/${name}.json`, { headers: { "User-Agent": "fad-checker-packagist" } });
48
+ if (!res.ok) return { error: `HTTP ${res.status}` };
49
+ const json = await res.json();
50
+ return json.package || { error: "no package field" };
51
+ } catch (e) { return { error: e.message }; }
52
+ }
53
+
54
+ // Mirror of checkNpmRegistryDeps: returns { deprecated:[], outdated:[] }.
55
+ async function checkComposerRegistryDeps(deps, opts = {}) {
56
+ const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
57
+ const targets = [...deps.values()].filter(d => d.ecosystem === "composer" && d.version);
58
+ const result = { deprecated: [], outdated: [] };
59
+ if (!targets.length) return result;
60
+ const cache = loadCache();
61
+ const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
62
+ if (!fresh && !offline) cache.entries = {};
63
+ const limit = pLimit(concurrency);
64
+ await Promise.all(targets.map(t => limit(async () => {
65
+ const name = `${t.namespace}/${t.name}`.toLowerCase();
66
+ const key = `${name}@${t.version}`;
67
+ let ex = cache.entries[key];
68
+ if (!ex) {
69
+ const pkg = await fetchPackage(name, { offline });
70
+ if (pkg && !pkg.error) {
71
+ const f = packagistToFindings(pkg, { version: t.version });
72
+ ex = { abandoned: f.abandoned, latest: f.outdated?.latest || null };
73
+ cache.entries[key] = ex;
74
+ } else {
75
+ ex = { abandoned: null, latest: null };
76
+ }
77
+ }
78
+ if (ex.abandoned) {
79
+ result.deprecated.push({ dep: t, severity: "MEDIUM", replacement: ex.abandoned.replacement, reason: "Package marked abandoned on Packagist", source: "packagist" });
80
+ if (verbose) process.stdout.write(` abandoned: ${name}@${t.version}\n`);
81
+ }
82
+ if (allLibs && ex.latest) {
83
+ result.outdated.push({ dep: t, latest: ex.latest, releaseDate: null });
84
+ if (verbose) process.stdout.write(` outdated: ${name} ${t.version} → ${ex.latest}\n`);
85
+ }
86
+ })));
87
+ cache.meta = { fetchedAt: Date.now() };
88
+ saveCache(cache);
89
+ return result;
90
+ }
91
+
92
+ module.exports = { packagistToFindings, checkComposerRegistryDeps };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * lib/codecs/composer.codec.js — codec PHP/Composer.
3
+ *
4
+ * Vuln scanning is OSV (ecosystem "Packagist", wired in Plan A). This codec adds
5
+ * collection (composer.lock, composer.json fallback), Packagist registry
6
+ * (abandoned + outdated), and EOL via endoflife.date.
7
+ */
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const { makeDepRecord, coordKeyFor } = require("../dep-record");
11
+ const { parseComposerLock, parseComposerJson, isConcrete } = require("./composer/parse");
12
+
13
+ const SKIP = new Set(["vendor", ".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "target"]);
14
+
15
+ function findComposerManifests(dir) {
16
+ const groups = [];
17
+ const stack = [dir];
18
+ while (stack.length) {
19
+ const cur = stack.pop();
20
+ let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
21
+ const names = new Set(entries.filter(e => e.isFile()).map(e => e.name));
22
+ if (names.has("composer.json") || names.has("composer.lock")) {
23
+ groups.push({
24
+ dir: cur,
25
+ composerJson: names.has("composer.json") ? path.join(cur, "composer.json") : null,
26
+ composerLock: names.has("composer.lock") ? path.join(cur, "composer.lock") : null,
27
+ });
28
+ }
29
+ for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
30
+ }
31
+ return groups;
32
+ }
33
+
34
+ module.exports = {
35
+ id: "composer",
36
+ label: "Composer",
37
+ osvEcosystem: "Packagist",
38
+ manifestNames: ["composer.json", "composer.lock"],
39
+
40
+ detect(dir) { return findComposerManifests(dir).length > 0; },
41
+
42
+ async collect(dir, opts = {}) {
43
+ const { ignoreTest, deps2Exclude } = opts;
44
+ const out = new Map();
45
+ const warnings = [];
46
+ for (const g of findComposerManifests(dir)) {
47
+ if (g.composerLock) {
48
+ let parsed;
49
+ try { parsed = parseComposerLock(g.composerLock); }
50
+ catch (e) { warnings.push({ type: "parse-error", manifestPath: g.composerLock, message: `composer.lock parse failed: ${e.message}` }); continue; }
51
+ const { deps } = parsed;
52
+ for (const d of deps) {
53
+ if (ignoreTest && d.isDev) continue;
54
+ if (deps2Exclude && deps2Exclude.test(d.name)) continue;
55
+ out.set(coordKeyFor("composer", d.vendor, d.pkg),
56
+ makeDepRecord({ ecosystem: "composer", namespace: d.vendor, name: d.pkg, version: d.version, manifestPath: g.composerLock, scope: d.scope, isDev: d.isDev }));
57
+ }
58
+ } else if (g.composerJson) {
59
+ // No lockfile → best-effort: pinned exact versions only + warning.
60
+ let parsed;
61
+ try { parsed = parseComposerJson(g.composerJson); }
62
+ catch (e) { warnings.push({ type: "parse-error", manifestPath: g.composerJson, message: `composer.json parse failed: ${e.message}` }); continue; }
63
+ const { deps } = parsed;
64
+ let pinned = 0, ranges = 0;
65
+ for (const d of deps) {
66
+ if (ignoreTest && d.isDev) continue;
67
+ if (deps2Exclude && deps2Exclude.test(d.name)) continue;
68
+ if (isConcrete(d.version)) {
69
+ out.set(coordKeyFor("composer", d.vendor, d.pkg),
70
+ makeDepRecord({ ecosystem: "composer", namespace: d.vendor, name: d.pkg, version: String(d.version).replace(/^v/, ""), manifestPath: g.composerJson, scope: d.scope, isDev: d.isDev }));
71
+ pinned++;
72
+ } else {
73
+ ranges++;
74
+ }
75
+ }
76
+ warnings.push({ type: "no-lockfile", manifestPath: g.composerJson, message: `composer.json without composer.lock — best-effort: ${pinned} pinned, ${ranges} range(s) skipped (run "composer install")` });
77
+ }
78
+ }
79
+ return { deps: out, warnings };
80
+ },
81
+
82
+ coordKey(d) { return coordKeyFor("composer", d.namespace || "", d.name); },
83
+ formatCoord(d) { return `${d.namespace || ""}/${d.name}`; },
84
+ osvPackageName(d) { return `${d.namespace || ""}/${d.name}`; },
85
+
86
+ async checkRegistry(deps, opts = {}) {
87
+ const { checkComposerRegistryDeps } = require("./composer/registry");
88
+ return checkComposerRegistryDeps(deps, opts);
89
+ },
90
+ resolveEolProduct(d) { return require("../outdated").findEolProduct(d); },
91
+ recipe: require("./recipes").composer,
92
+ nativeScanners: [],
93
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * lib/codecs/index.js — registre des codecs.
3
+ *
4
+ * getCodec(id) → le codec, ou null
5
+ * allCodecs() → tous les codecs enregistrés, dans l'ordre report stable
6
+ * detectCodecs(dir) → les codecs dont detect() est vrai sur ce répertoire
7
+ */
8
+ const { assertCodecShape } = require("./codec.interface");
9
+ const maven = require("./maven.codec");
10
+ const npm = require("./npm.codec");
11
+ const yarn = require("./yarn.codec");
12
+ const composer = require("./composer.codec");
13
+ const pypi = require("./pypi.codec");
14
+ const nuget = require("./nuget.codec");
15
+
16
+ // Ordre stable pour le report (maven, npm, yarn, puis les nouveaux écosystèmes).
17
+ const ORDER = ["maven", "npm", "yarn", "nuget", "composer", "pypi"];
18
+
19
+ const REGISTRY = new Map();
20
+ for (const c of [maven, npm, yarn, composer, pypi, nuget]) {
21
+ assertCodecShape(c);
22
+ REGISTRY.set(c.id, c);
23
+ }
24
+
25
+ function getCodec(id) { return REGISTRY.get(id) || null; }
26
+
27
+ function allCodecs() {
28
+ return [...REGISTRY.values()].sort((a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id));
29
+ }
30
+
31
+ // yarn est détecté via le même arbre JS que npm ; on ne le renvoie pas en
32
+ // doublon de détection (npm.collect ramasse déjà yarn.lock).
33
+ function detectCodecs(dir) {
34
+ return allCodecs().filter(c => c.id !== "yarn" && c.detect(dir));
35
+ }
36
+
37
+ module.exports = { getCodec, allCodecs, detectCodecs, ORDER };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * lib/codecs/maven.codec.js — codec Maven.
3
+ *
4
+ * N'INTRODUIT AUCUNE LOGIQUE NOUVELLE : enveloppe la résolution POM/BOM de
5
+ * lib/core.js (parse, parent, merge multi-profils, dependencyManagement, imports
6
+ * scope=import), le collecteur lib/cve-match.js, le walker transitif
7
+ * lib/transitive.js et le scanner natif CVE-index (cvelistV5).
8
+ */
9
+ const core = require("../core");
10
+ const { collectResolvedDeps, expandWithTransitives, matchDepsAgainstCves } = require("../cve-match");
11
+ const { coordKeyFor } = require("../dep-record");
12
+
13
+ // Scanner natif : CVE-index local cvelistV5. scan(deps,opts) → {matches, meta}.
14
+ const cveIndexScanner = {
15
+ id: "cve-index",
16
+ kind: "cve", // résultats → chapitre CVE (mergés avec OSV)
17
+ async scan(deps, opts = {}) {
18
+ const { ensureCveIndex } = require("../cve-download");
19
+ const idx = await ensureCveIndex({
20
+ force: !!opts.cveRefresh && !opts.offline,
21
+ offline: !!opts.cveOffline || !!opts.offline,
22
+ verbose: opts.verbose,
23
+ });
24
+ return { matches: matchDepsAgainstCves(deps, idx), meta: { cveDataDate: idx?.meta?.builtAt || null } };
25
+ },
26
+ };
27
+
28
+ module.exports = {
29
+ id: "maven",
30
+ label: "Maven",
31
+ osvEcosystem: "Maven",
32
+ manifestNames: ["pom.xml"],
33
+
34
+ detect(dir) { return core.findPomFiles(dir).length > 0; },
35
+
36
+ // collect enveloppe parse + inheritance + collectResolvedDeps existants.
37
+ // Expose _maven (store/propsByPom/pomFiles) pour que l'orchestrateur garde la
38
+ // phase de réécriture des POM nettoyés (lib/core.rewritePoms).
39
+ async collect(dir, opts = {}) {
40
+ const pomFiles = core.findPomFiles(dir);
41
+ const store = core.newMetadataStore();
42
+ const propsByPom = {};
43
+ for (const pom of pomFiles) {
44
+ try { await core.parsePom(pom, store); } catch { /* logged by orchestrator */ }
45
+ }
46
+ for (const pom of Object.keys(store.byPath)) {
47
+ try { await core.getAllInheritedProps(pom, store, propsByPom); } catch { /* logged */ }
48
+ }
49
+ const deps = collectResolvedDeps(store, propsByPom, { ignoreTest: opts.ignoreTest, deps2Exclude: opts.deps2Exclude });
50
+ return { deps, warnings: [], _maven: { store, propsByPom, pomFiles } };
51
+ },
52
+
53
+ coordKey(d) { return coordKeyFor("maven", d.namespace || d.groupId, d.name || d.artifactId); },
54
+ formatCoord(d) { return `${d.namespace || d.groupId}:${d.name || d.artifactId}`; },
55
+ osvPackageName(d) { return `${d.namespace || d.groupId}:${d.name || d.artifactId}`; },
56
+
57
+ async checkRegistry(deps, opts = {}) {
58
+ const outdated = require("../outdated");
59
+ const out = opts.allLibs ? await outdated.checkOutdatedDeps(deps, opts) : [];
60
+ const deprecated = outdated.checkObsoleteDeps(deps);
61
+ return { outdated: out, deprecated };
62
+ },
63
+ resolveEolProduct(d) { return require("../outdated").findEolProduct(d); },
64
+
65
+ recipe: require("./recipes").maven,
66
+
67
+ nativeScanners: [cveIndexScanner],
68
+
69
+ // Exposé pour l'orchestrateur (étape transitive, --transitive).
70
+ expandTransitives: expandWithTransitives,
71
+ };
@@ -16,7 +16,8 @@
16
16
  */
17
17
  const path = require("path");
18
18
  const fs = require("fs");
19
- const { parsePackageJson, parsePackageLock, parseYarnLockV1, findJsManifests } = require("./parse");
19
+ const { parsePackageJson, parsePackageLock, parseYarnLockV1, parsePnpmLock, findJsManifests } = require("./parse");
20
+ const { makeDepRecord } = require("../../dep-record");
20
21
 
21
22
  const SCOPE_RANK = { prod: 4, peer: 3, optional: 2, dev: 1, transitive: 0 };
22
23
 
@@ -55,30 +56,30 @@ function upsert(out, dep, manifestPath, lockType) {
55
56
  const ecosystemType = (lockType || "").startsWith("package-lock") ? "npm"
56
57
  : (lockType || "").startsWith("yarn") ? "yarn"
57
58
  : "npm";
58
- const record = {
59
- groupId: "",
60
- artifactId: dep.name,
59
+ const record = makeDepRecord({
60
+ ecosystem: "npm",
61
+ ecosystemType,
62
+ namespace: "",
63
+ name: dep.name,
61
64
  version: dep.version || null,
65
+ manifestPath,
62
66
  scope: dep.scope || "prod",
63
67
  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
- };
68
+ });
69
+ // npm-specific extras (not part of the shared depRecord contract)
70
+ record.lockType = lockType;
71
+ record.depth = dep.depth ?? null;
72
+ record.resolved = dep.resolved || null;
73
+ record.integrity = dep.integrity || null;
74
+ record.from = dep.from || null;
74
75
  if (!existing) {
75
76
  out.set(key, record);
76
77
  return;
77
78
  }
78
- // Merge
79
+ // Merge. NB: pomPaths shares the manifestPaths array reference (makeDepRecord),
80
+ // so we push once — pushing to both would duplicate the entry.
79
81
  if (!existing.manifestPaths.includes(manifestPath)) {
80
82
  existing.manifestPaths.push(manifestPath);
81
- existing.pomPaths.push(manifestPath);
82
83
  }
83
84
  // Prefer the resolved (lockfile) version over a range from package.json
84
85
  const incoming = record.version;
@@ -132,27 +133,41 @@ function collectNpmDeps(rootDir, opts = {}) {
132
133
  const pj = group.packageJson ? safeParse(parsePackageJson, group.packageJson, verbose) : null;
133
134
  const pl = group.packageLock ? safeParse(parsePackageLock, group.packageLock, verbose) : null;
134
135
  const yl = group.yarnLock ? safeParse(parseYarnLockV1, group.yarnLock, verbose) : null;
135
- const hasLock = !!(pl || yl);
136
+ const pn = group.pnpmLock ? safeParse(parsePnpmLock, group.pnpmLock, verbose) : null;
137
+ const hasLock = !!(pl || yl || pn);
136
138
 
137
- // package.json without lockfile → warning, skip dep collection.
139
+ // package.json without lockfile → best-effort fallback (changed contract):
140
+ // scan pinned exact versions ("1.2.3"), skip ranges ("^1.0.0"), and warn
141
+ // that results are partial. Previously this manifest was skipped entirely.
138
142
  if (pj && !hasLock) {
143
+ let pinned = 0, ranges = 0;
144
+ for (const d of pj.deps) {
145
+ if (ignoreTest && (d.scope === "dev" || d.scope === "optional")) continue;
146
+ if (deps2Exclude && deps2Exclude.test(d.name)) continue;
147
+ if (isResolvedVersion(d.version)) {
148
+ upsert(out, { name: d.name, version: d.version, scope: d.scope || "prod", isDev: !!d.isDev }, group.packageJson, "package-json-nolock");
149
+ pinned++;
150
+ } else {
151
+ ranges++;
152
+ }
153
+ }
139
154
  warnings.push({
140
155
  type: "no-lockfile",
141
156
  manifestPath: group.packageJson,
142
157
  packageName: pj.packageName || null,
143
- message: `package.json without lockfile — skipped (run "npm install" or "yarn install" to generate one)`,
158
+ message: `package.json without lockfile — best-effort: ${pinned} pinned dep(s) scanned, ${ranges} range(s) skipped (run "npm install" or "yarn install" for full coverage)`,
144
159
  });
145
- if (verbose) console.warn(`⚠️ ${group.packageJson}: no lockfile, skipped (${pj.deps.length} ranges in package.json)`);
160
+ if (verbose) console.warn(`⚠️ ${group.packageJson}: no lockfile best-effort (${pinned} pinned, ${ranges} ranges skipped)`);
146
161
  }
147
162
 
148
- // Yarn-Berry lockfile detected but unsupported → warning, skip.
149
- if (yl?.unsupported === "yarn-berry") {
163
+ // Yarn-Berry lockfile that failed to parse → warning (deps still empty).
164
+ if (yl?.parseError) {
150
165
  warnings.push({
151
- type: "yarn-berry-unsupported",
166
+ type: "parse-error",
152
167
  manifestPath: group.yarnLock,
153
- message: `yarn 2+/Berry lockfile not yet supported — skipped`,
168
+ message: `yarn 2+/Berry lockfile parse failed: ${yl.parseError}`,
154
169
  });
155
- if (verbose) console.warn(`⚠️ ${group.yarnLock}: yarn-berry not supported, skipped`);
170
+ if (verbose) console.warn(`⚠️ ${group.yarnLock}: yarn-berry parse failed — ${yl.parseError}`);
156
171
  }
157
172
 
158
173
  // Used only as a source of scope info — never emits deps on its own.
@@ -171,15 +186,23 @@ function collectNpmDeps(rootDir, opts = {}) {
171
186
  }
172
187
  if (yl) {
173
188
  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
- }
189
+ const lockType = yl.lockfileVersion === "berry" ? "yarn-berry" : "yarn-v1";
190
+ for (const d of yl.deps) {
191
+ const explicit = directScopes?.get(d.name);
192
+ const scope = explicit || d.scope || "prod";
193
+ if (ignoreTest && (scope === "dev" || scope === "optional")) continue;
194
+ if (deps2Exclude && deps2Exclude.test(d.name)) continue;
195
+ upsert(out, { ...d, scope }, group.yarnLock, lockType);
196
+ }
197
+ }
198
+ if (pn) {
199
+ parsedCount++;
200
+ for (const d of pn.deps) {
201
+ const explicit = directScopes?.get(d.name);
202
+ const scope = explicit || d.scope || "prod";
203
+ if (ignoreTest && (scope === "dev" || scope === "optional")) continue;
204
+ if (deps2Exclude && deps2Exclude.test(d.name)) continue;
205
+ upsert(out, { ...d, scope }, group.pnpmLock, "pnpm");
183
206
  }
184
207
  }
185
208
  }
@@ -237,7 +260,7 @@ function hasJsManifests(rootDir) {
237
260
  try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
238
261
  catch { continue; }
239
262
  for (const e of entries) {
240
- if (e.isFile() && (e.name === "package.json" || e.name === "package-lock.json" || e.name === "yarn.lock")) return true;
263
+ if (e.isFile() && (e.name === "package.json" || e.name === "package-lock.json" || e.name === "yarn.lock" || e.name === "pnpm-lock.yaml")) return true;
241
264
  if (e.isDirectory() && !skip.has(e.name)) stack.push(path.join(cur, e.name));
242
265
  }
243
266
  }