fad-checker 2.0.0 → 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 (93) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +58 -10
  3. package/data/eol-mapping.json +5 -5
  4. package/fad-checker.js +289 -203
  5. package/lib/codecs/npm/collect.js +26 -17
  6. package/lib/codecs/npm/parse.js +114 -10
  7. package/lib/codecs/npm/registry.js +4 -3
  8. package/lib/codecs/npm.codec.js +1 -1
  9. package/lib/codecs/nuget.codec.js +4 -2
  10. package/lib/codecs/pypi/parse.js +104 -12
  11. package/lib/codecs/pypi.codec.js +27 -6
  12. package/lib/core.js +35 -1
  13. package/lib/cve-match.js +15 -5
  14. package/lib/deps-descriptor.js +110 -0
  15. package/lib/nvd.js +4 -3
  16. package/lib/osv.js +7 -4
  17. package/lib/outdated.js +7 -2
  18. package/lib/retire.js +77 -13
  19. package/lib/transitive.js +3 -3
  20. package/lib/ui.js +87 -0
  21. package/package.json +3 -2
  22. package/CLAUDE.md +0 -147
  23. package/docs/ARCHITECTURE.md +0 -182
  24. package/docs/USAGE.md +0 -187
  25. package/docs/superpowers/plans/2026-05-29-codec-composer.md +0 -556
  26. package/docs/superpowers/plans/2026-05-29-codec-foundation.md +0 -851
  27. package/docs/superpowers/plans/2026-05-29-codec-nuget.md +0 -432
  28. package/docs/superpowers/plans/2026-05-29-codec-pypi.md +0 -450
  29. package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +0 -251
  30. package/test/cli-ecosystem.test.js +0 -30
  31. package/test/codec-edge-cases.test.js +0 -131
  32. package/test/codec-integration.test.js +0 -119
  33. package/test/codecs.test.js +0 -92
  34. package/test/composer.test.js +0 -71
  35. package/test/core.test.js +0 -153
  36. package/test/cpe.test.js +0 -166
  37. package/test/cve-download.test.js +0 -39
  38. package/test/cve-match.test.js +0 -217
  39. package/test/cve-report.test.js +0 -180
  40. package/test/dep-record.test.js +0 -31
  41. package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
  42. package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
  43. package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
  44. package/test/fixtures/complex-enterprise/pom.xml +0 -66
  45. package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
  46. package/test/fixtures/csharp-config/packages.config +0 -4
  47. package/test/fixtures/csharp-csproj/Directory.Packages.props +0 -5
  48. package/test/fixtures/csharp-csproj/app.csproj +0 -7
  49. package/test/fixtures/csharp-lock/packages.lock.json +0 -6
  50. package/test/fixtures/cve-samples/cve-non-java.json +0 -19
  51. package/test/fixtures/cve-samples/cve-product-only.json +0 -31
  52. package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
  53. package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
  54. package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
  55. package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
  56. package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
  57. package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
  58. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -10
  59. package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
  60. package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
  61. package/test/fixtures/monorepo-mixed/pom.xml +0 -29
  62. package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
  63. package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
  64. package/test/fixtures/php-app/composer.json +0 -5
  65. package/test/fixtures/php-app/composer.lock +0 -10
  66. package/test/fixtures/polyglot/cs/packages.lock.json +0 -1
  67. package/test/fixtures/polyglot/js/package-lock.json +0 -1
  68. package/test/fixtures/polyglot/js/package.json +0 -1
  69. package/test/fixtures/polyglot/mvn/pom.xml +0 -7
  70. package/test/fixtures/polyglot/php/composer.lock +0 -1
  71. package/test/fixtures/polyglot/py/poetry.lock +0 -3
  72. package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
  73. package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
  74. package/test/fixtures/private-lib-detection/pom.xml +0 -35
  75. package/test/fixtures/python-pipenv/Pipfile.lock +0 -1
  76. package/test/fixtures/python-poetry/poetry.lock +0 -7
  77. package/test/fixtures/python-reqs/requirements.txt +0 -5
  78. package/test/fixtures/python-uv/uv.lock +0 -3
  79. package/test/fixtures/simple/app/pom.xml +0 -28
  80. package/test/fixtures/simple/lib/pom.xml +0 -18
  81. package/test/fixtures/simple/pom.xml +0 -24
  82. package/test/maven-repo.test.js +0 -111
  83. package/test/maven-version.test.js +0 -57
  84. package/test/monorepo.test.js +0 -132
  85. package/test/npm-registry.test.js +0 -64
  86. package/test/npm.test.js +0 -150
  87. package/test/nuget.test.js +0 -66
  88. package/test/osv.test.js +0 -62
  89. package/test/outdated.test.js +0 -101
  90. package/test/pypi.test.js +0 -72
  91. package/test/snyk.test.js +0 -64
  92. package/test/transitive.test.js +0 -305
  93. package/test/webjar.test.js +0 -33
@@ -16,7 +16,7 @@
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
20
  const { makeDepRecord } = require("../../dep-record");
21
21
 
22
22
  const SCOPE_RANK = { prod: 4, peer: 3, optional: 2, dev: 1, transitive: 0 };
@@ -133,7 +133,8 @@ function collectNpmDeps(rootDir, opts = {}) {
133
133
  const pj = group.packageJson ? safeParse(parsePackageJson, group.packageJson, verbose) : null;
134
134
  const pl = group.packageLock ? safeParse(parsePackageLock, group.packageLock, verbose) : null;
135
135
  const yl = group.yarnLock ? safeParse(parseYarnLockV1, group.yarnLock, verbose) : null;
136
- const hasLock = !!(pl || yl);
136
+ const pn = group.pnpmLock ? safeParse(parsePnpmLock, group.pnpmLock, verbose) : null;
137
+ const hasLock = !!(pl || yl || pn);
137
138
 
138
139
  // package.json without lockfile → best-effort fallback (changed contract):
139
140
  // scan pinned exact versions ("1.2.3"), skip ranges ("^1.0.0"), and warn
@@ -159,14 +160,14 @@ function collectNpmDeps(rootDir, opts = {}) {
159
160
  if (verbose) console.warn(`⚠️ ${group.packageJson}: no lockfile — best-effort (${pinned} pinned, ${ranges} ranges skipped)`);
160
161
  }
161
162
 
162
- // Yarn-Berry lockfile detected but unsupported → warning, skip.
163
- if (yl?.unsupported === "yarn-berry") {
163
+ // Yarn-Berry lockfile that failed to parse → warning (deps still empty).
164
+ if (yl?.parseError) {
164
165
  warnings.push({
165
- type: "yarn-berry-unsupported",
166
+ type: "parse-error",
166
167
  manifestPath: group.yarnLock,
167
- message: `yarn 2+/Berry lockfile not yet supported — skipped`,
168
+ message: `yarn 2+/Berry lockfile parse failed: ${yl.parseError}`,
168
169
  });
169
- if (verbose) console.warn(`⚠️ ${group.yarnLock}: yarn-berry not supported, skipped`);
170
+ if (verbose) console.warn(`⚠️ ${group.yarnLock}: yarn-berry parse failed — ${yl.parseError}`);
170
171
  }
171
172
 
172
173
  // Used only as a source of scope info — never emits deps on its own.
@@ -185,15 +186,23 @@ function collectNpmDeps(rootDir, opts = {}) {
185
186
  }
186
187
  if (yl) {
187
188
  parsedCount++;
188
- // yarn-berry warning already pushed above; only emit deps for v1
189
- if (yl.unsupported !== "yarn-berry") {
190
- for (const d of yl.deps) {
191
- const explicit = directScopes?.get(d.name);
192
- const scope = explicit || "prod";
193
- if (ignoreTest && (scope === "dev" || scope === "optional")) continue;
194
- if (deps2Exclude && deps2Exclude.test(d.name)) continue;
195
- upsert(out, { ...d, scope }, group.yarnLock, "yarn-v1");
196
- }
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");
197
206
  }
198
207
  }
199
208
  }
@@ -251,7 +260,7 @@ function hasJsManifests(rootDir) {
251
260
  try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
252
261
  catch { continue; }
253
262
  for (const e of entries) {
254
- 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;
255
264
  if (e.isDirectory() && !skip.has(e.name)) stack.push(path.join(cur, e.name));
256
265
  }
257
266
  }
@@ -24,6 +24,7 @@
24
24
  */
25
25
  const fs = require("fs");
26
26
  const path = require("path");
27
+ const yaml = require("js-yaml");
27
28
 
28
29
  function isWorkspaceVersion(v) {
29
30
  // npm v9+ uses "*" / "" for workspace-local refs; yarn uses "workspace:*"
@@ -189,14 +190,8 @@ function parsePackageLock(filePath) {
189
190
  function parseYarnLockV1(filePath) {
190
191
  const raw = fs.readFileSync(filePath, "utf8");
191
192
  if (raw.includes("__metadata:")) {
192
- // Berry / yarn 2+ uses YAML. Punt for now return empty deps + a flag.
193
- return {
194
- manifestPath: filePath,
195
- manifestType: "yarn.lock",
196
- lockfileVersion: "berry",
197
- deps: [],
198
- unsupported: "yarn-berry",
199
- };
193
+ // Berry / yarn 2+ uses YAML — parse it with js-yaml.
194
+ return parseYarnBerry(raw, filePath);
200
195
  }
201
196
  const out = {
202
197
  manifestPath: filePath,
@@ -247,6 +242,112 @@ function parseYarnLockV1(filePath) {
247
242
  return out;
248
243
  }
249
244
 
245
+ /* -------- yarn.lock v2+ (Berry) ----------
246
+ Berry lockfiles are YAML. Top-level keys are comma-separated descriptor lists
247
+ ("lodash@npm:^4.17.0, lodash@npm:^4.17.21:") whose value carries `version` and
248
+ `resolution`. The workspace's own packages resolve to "@workspace:" — skipped.
249
+ */
250
+ function identFromDescriptor(d) {
251
+ const s = String(d || "").trim().replace(/^"|"$/g, "");
252
+ const scoped = s.startsWith("@");
253
+ const at = s.indexOf("@", scoped ? 1 : 0); // the @ before the range/protocol
254
+ if (at <= 0) return null;
255
+ return s.slice(0, at);
256
+ }
257
+
258
+ function parseYarnBerry(raw, filePath) {
259
+ const out = { manifestPath: filePath, manifestType: "yarn.lock", lockfileVersion: "berry", deps: [] };
260
+ let doc;
261
+ try { doc = yaml.load(raw) || {}; }
262
+ catch (e) { out.parseError = e.message; return out; }
263
+ const seen = new Set();
264
+ for (const [key, val] of Object.entries(doc)) {
265
+ if (key === "__metadata") continue;
266
+ if (!val || typeof val !== "object" || !val.version) continue;
267
+ if (/@workspace:/.test(val.resolution || "")) continue; // the local package itself
268
+ const version = String(val.version);
269
+ const names = new Set();
270
+ for (const desc of String(key).split(",")) {
271
+ const name = identFromDescriptor(desc);
272
+ if (name) names.add(name);
273
+ }
274
+ for (const name of names) {
275
+ const k = `${name}@${version}`;
276
+ if (seen.has(k)) continue; seen.add(k);
277
+ out.deps.push({ name, version, scope: "prod", depth: 0, resolved: val.resolution || null });
278
+ }
279
+ }
280
+ return out;
281
+ }
282
+
283
+ /* -------- pnpm-lock.yaml ----------
284
+ YAML. The full resolved set lives in `packages` (v5/v6) and `snapshots` (v9);
285
+ `importers.*` carry the per-workspace direct deps with dev classification.
286
+ Package keys vary by lockfileVersion:
287
+ v9: "name@1.2.3" / "@scope/name@1.2.3"
288
+ v6: "/name@1.2.3(peers)" / "/@scope/name@1.2.3(peers)"
289
+ v5: "/name/1.2.3" / "/@scope/name/1.2.3"
290
+ */
291
+ function pnpmNameVersion(key) {
292
+ let k = String(key || "");
293
+ if (k.startsWith("/")) k = k.slice(1);
294
+ const paren = k.indexOf("("); // strip peer-deps suffix
295
+ if (paren !== -1) k = k.slice(0, paren);
296
+ const at = k.lastIndexOf("@"); // @-form (v6/v9)
297
+ if (at > 0 && /^\d/.test(k.slice(at + 1))) return { name: k.slice(0, at), version: k.slice(at + 1) };
298
+ const slash = k.lastIndexOf("/"); // slash-form (v5)
299
+ if (slash > 0 && /^\d/.test(k.slice(slash + 1))) return { name: k.slice(0, slash), version: k.slice(slash + 1) };
300
+ return null;
301
+ }
302
+
303
+ function parsePnpmLock(filePath) {
304
+ const raw = fs.readFileSync(filePath, "utf8");
305
+ let doc;
306
+ try { doc = yaml.load(raw) || {}; }
307
+ catch (e) { throw new Error(`pnpm-lock parse failed (${filePath}): ${e.message}`); }
308
+ const out = { manifestPath: filePath, manifestType: "pnpm-lock", lockfileVersion: doc.lockfileVersion || null, deps: [] };
309
+ const seen = new Set();
310
+ const push = (name, version, scope, isDev) => {
311
+ if (!name || !version) return;
312
+ const v = String(version).split("(")[0]; // peer-resolved suffix
313
+ if (!/^\d/.test(v)) return;
314
+ const k = `${name}@${v}`;
315
+ if (seen.has(k)) return; seen.add(k);
316
+ out.deps.push({ name, version: v, scope: scope || "prod", isDev: !!isDev, depth: 0 });
317
+ };
318
+ // Direct deps from importers FIRST (v6/v9) — these carry the authoritative
319
+ // dev/optional classification. In v9 the `packages` section has no dev flag,
320
+ // so seeding dev/optional here (dedup by name@version) keeps the right scope.
321
+ const importers = doc.importers || ((doc.dependencies || doc.devDependencies) ? { ".": doc } : null);
322
+ if (importers && typeof importers === "object") {
323
+ const readDir = (obj, scope, isDev) => {
324
+ for (const [name, spec] of Object.entries(obj || {})) {
325
+ const version = typeof spec === "string" ? spec : (spec && (spec.version || spec.specifier));
326
+ push(name, version, scope, isDev);
327
+ }
328
+ };
329
+ for (const imp of Object.values(importers)) {
330
+ if (!imp || typeof imp !== "object") continue;
331
+ readDir(imp.devDependencies, "dev", true);
332
+ readDir(imp.optionalDependencies, "optional", false);
333
+ readDir(imp.dependencies, "prod", false);
334
+ }
335
+ }
336
+ // Full resolved set (direct + transitive) fills in anything not already seen.
337
+ for (const sec of [doc.packages, doc.snapshots]) {
338
+ if (!sec || typeof sec !== "object") continue;
339
+ for (const [key, entry] of Object.entries(sec)) {
340
+ const nv = pnpmNameVersion(key);
341
+ if (!nv) continue;
342
+ const name = (entry && entry.name) || nv.name;
343
+ const version = (entry && entry.version) || nv.version;
344
+ const isDev = !!(entry && entry.dev); // v6 carries a dev flag
345
+ push(name, version, isDev ? "dev" : "prod", isDev);
346
+ }
347
+ }
348
+ return out;
349
+ }
350
+
250
351
  /* -------- discovery ---------- */
251
352
  // Dirs that hold packaged / generated content — never our own source.
252
353
  // Conservative list: only well-known build-output / package-cache dirs.
@@ -266,7 +367,7 @@ function findJsManifests(rootDir, opts = {}) {
266
367
  try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
267
368
  catch { continue; }
268
369
  // Group lockfile per directory so we can prefer lock > package.json
269
- const here = { dir: cur, packageJson: null, packageLock: null, yarnLock: null };
370
+ const here = { dir: cur, packageJson: null, packageLock: null, yarnLock: null, pnpmLock: null };
270
371
  for (const e of entries) {
271
372
  const p = path.join(cur, e.name);
272
373
  if (e.isDirectory()) {
@@ -276,9 +377,10 @@ function findJsManifests(rootDir, opts = {}) {
276
377
  if (e.name === "package.json") here.packageJson = p;
277
378
  else if (e.name === "package-lock.json") here.packageLock = p;
278
379
  else if (e.name === "yarn.lock") here.yarnLock = p;
380
+ else if (e.name === "pnpm-lock.yaml") here.pnpmLock = p;
279
381
  }
280
382
  }
281
- if (here.packageJson || here.packageLock || here.yarnLock) found.push(here);
383
+ if (here.packageJson || here.packageLock || here.yarnLock || here.pnpmLock) found.push(here);
282
384
  }
283
385
  return found;
284
386
  }
@@ -287,5 +389,7 @@ module.exports = {
287
389
  parsePackageJson,
288
390
  parsePackageLock,
289
391
  parseYarnLockV1,
392
+ parseYarnBerry,
393
+ parsePnpmLock,
290
394
  findJsManifests,
291
395
  };
@@ -118,7 +118,7 @@ async function fetchPackument(name, opts = {}) {
118
118
  * opts: { verbose, offline, allLibs, concurrency = 8 }
119
119
  */
120
120
  async function checkNpmRegistryDeps(resolvedDeps, opts = {}) {
121
- const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
121
+ const { verbose, offline, allLibs = true, concurrency = 8, onProgress } = opts;
122
122
  // Targets = npm deps (queried by their own name) + WebJars (queried by their
123
123
  // derived npm-equivalent name; version matches upstream). The original dep
124
124
  // is kept for display/results so the report shows e.g. org.webjars:angularjs.
@@ -138,7 +138,7 @@ async function checkNpmRegistryDeps(resolvedDeps, opts = {}) {
138
138
 
139
139
  const cacheKey = t => `${t.npmName}@${t.version}`;
140
140
  const liveCount = offline ? 0 : targets.filter(t => !cache.entries[cacheKey(t)]).length;
141
- if (liveCount && !offline) {
141
+ if (liveCount && !offline && !onProgress) {
142
142
  console.log(`📦 npm registry: checking ${targets.length} packages for deprecation${allLibs ? " + outdated" : ""} (${liveCount} live, ${targets.length - liveCount} cached)…`);
143
143
  }
144
144
 
@@ -147,6 +147,7 @@ async function checkNpmRegistryDeps(resolvedDeps, opts = {}) {
147
147
  let processed = 0;
148
148
  const startedAt = Date.now();
149
149
  const printProgress = (final = false) => {
150
+ if (onProgress) { onProgress(processed, targets.length); return; }
150
151
  if (!liveCount) return;
151
152
  const elapsed = Math.round((Date.now() - startedAt) / 1000);
152
153
  const pct = Math.round((processed / targets.length) * 100);
@@ -199,7 +200,7 @@ async function checkNpmRegistryDeps(resolvedDeps, opts = {}) {
199
200
 
200
201
  result.deprecated.sort((a, b) => a.dep.artifactId.localeCompare(b.dep.artifactId));
201
202
  result.outdated.sort((a, b) => a.dep.artifactId.localeCompare(b.dep.artifactId));
202
- if (liveCount && !offline) console.log(` npm registry: ${result.deprecated.length} deprecated, ${result.outdated.length} outdated`);
203
+ if (liveCount && !offline && !onProgress) console.log(` npm registry: ${result.deprecated.length} deprecated, ${result.outdated.length} outdated`);
203
204
  return result;
204
205
  }
205
206
 
@@ -25,7 +25,7 @@ const retireScanner = {
25
25
 
26
26
  const base = {
27
27
  osvEcosystem: "npm",
28
- manifestNames: ["package.json", "package-lock.json", "yarn.lock"],
28
+ manifestNames: ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"],
29
29
  detect(dir) { return hasJsManifests(dir); },
30
30
  coordKey(d) { return coordKeyFor("npm", "", d.name || d.artifactId); },
31
31
  formatCoord(d) { return d.name || d.artifactId; },
@@ -13,6 +13,8 @@ const { makeDepRecord, coordKeyFor } = require("../dep-record");
13
13
  const N = require("./nuget/parse");
14
14
 
15
15
  const SKIP = new Set([".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "bin", "obj", "target", "packages"]);
16
+ // MSBuild project files share the same <PackageReference> schema — C#, F#, VB.
17
+ const PROJ_RE = /\.(csproj|fsproj|vbproj)$/i;
16
18
 
17
19
  function findNugetDirs(dir) {
18
20
  const groups = [];
@@ -21,7 +23,7 @@ function findNugetDirs(dir) {
21
23
  const cur = stack.pop();
22
24
  let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
23
25
  const files = entries.filter(e => e.isFile()).map(e => e.name);
24
- const csprojs = files.filter(f => f.toLowerCase().endsWith(".csproj"));
26
+ const csprojs = files.filter(f => PROJ_RE.test(f));
25
27
  if (files.includes("packages.lock.json") || files.includes("packages.config") || csprojs.length) {
26
28
  groups.push({ dir: cur, files, csprojs });
27
29
  }
@@ -34,7 +36,7 @@ module.exports = {
34
36
  id: "nuget",
35
37
  label: "NuGet",
36
38
  osvEcosystem: "NuGet",
37
- manifestNames: ["packages.lock.json", "*.csproj", "packages.config"],
39
+ manifestNames: ["packages.lock.json", "*.csproj", "*.fsproj", "*.vbproj", "packages.config"],
38
40
 
39
41
  detect(dir) { return findNugetDirs(dir).length > 0; },
40
42
 
@@ -3,12 +3,16 @@
3
3
  *
4
4
  * poetry.lock / uv.lock / pdm.lock — TOML, [[package]] name/version arrays.
5
5
  * Pipfile.lock — JSON, { default:{}, develop:{} }, version "==x".
6
+ * pyproject.toml — TOML, PEP 621 [project] + poetry table (fallback).
6
7
  * requirements.txt — text; only "==" pins are queryable (fallback).
8
+ * -r/-c includes are followed recursively;
9
+ * a -c constraint pin upgrades a range to scannable.
7
10
  *
8
11
  * All names are PEP 503 normalized (lowercase, runs of -_. → single -) so they
9
12
  * match OSV / PyPI / the resolved-deps key.
10
13
  */
11
14
  const fs = require("fs");
15
+ const path = require("path");
12
16
  const TOML = require("smol-toml");
13
17
 
14
18
  function pep503(name) { return String(name || "").toLowerCase().replace(/[-_.]+/g, "-"); }
@@ -17,6 +21,15 @@ function stripOp(v) { return String(v || "").replace(/^[=~!<>]+/, "").trim(); }
17
21
  function isPinned(spec) { return /^==\s*\d[\w.\-+!]*$/.test(String(spec || "").trim()); }
18
22
  function isConcrete(v) { return /^\d+(\.\d+)*([.\-+]\S+)?$/.test(String(v || "")); }
19
23
 
24
+ // Split a PEP 508 requirement string ("requests[extra]==2.31.0 ; marker") into
25
+ // { name, spec } — env markers (after ";") and extras ("[...]") are dropped.
26
+ function splitPep508(req) {
27
+ const s = String(req || "").split(";")[0].trim();
28
+ const m = s.match(/^([A-Za-z0-9._-]+)\s*(\[[^\]]*\])?\s*(.*)$/);
29
+ if (!m) return { name: null, spec: "" };
30
+ return { name: m[1], spec: m[3].trim() };
31
+ }
32
+
20
33
  // poetry.lock / uv.lock / pdm.lock all use [[package]] name/version arrays.
21
34
  function parseTomlPackages(filePath, type) {
22
35
  const data = TOML.parse(fs.readFileSync(filePath, "utf8"));
@@ -50,22 +63,101 @@ function parsePipfileLock(filePath) {
50
63
  return { manifestPath: filePath, manifestType: "Pipfile.lock", deps };
51
64
  }
52
65
 
53
- function parseRequirementsTxt(filePath) {
54
- const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
66
+ // pyproject.toml — fallback when no lockfile. Reads PEP 621 [project] and the
67
+ // poetry tool table. Only exact pins are scannable (PEP 621 "==x"; poetry exact
68
+ // "x"); ranges (^/~/>=/wildcards) are counted in `skipped` like requirements.txt.
69
+ function parsePyprojectToml(filePath) {
70
+ const data = TOML.parse(fs.readFileSync(filePath, "utf8"));
55
71
  const deps = [];
56
72
  let skipped = 0;
73
+ const seen = new Set();
74
+ const push = (rawName, version, scope) => {
75
+ const name = pep503(rawName);
76
+ if (!name || name === "python") return;
77
+ const k = `${name}@${version}`;
78
+ if (seen.has(k)) return; seen.add(k);
79
+ deps.push({ name, version, scope, isDev: scope === "dev" });
80
+ };
81
+ // PEP 508 specs ("requests==2.31.0") — exact "==" pins only.
82
+ const addSpec = (req, scope) => {
83
+ const { name, spec } = splitPep508(req);
84
+ if (!name) return;
85
+ if (isPinned(spec)) push(name, stripOp(spec), scope);
86
+ else skipped++;
87
+ };
88
+ // Poetry constraints ("^2.0" / "==4.2" / bare exact "4.2.0" / { version }).
89
+ const addPoetry = (name, val, scope) => {
90
+ if (pep503(name) === "python") return; // the interpreter, not a dep
91
+ const spec = typeof val === "string" ? val : (val && typeof val === "object" ? (val.version || "") : "");
92
+ const s = String(spec || "").trim();
93
+ if (isConcrete(s)) push(name, s, scope); // poetry: bare version == exact
94
+ else if (isPinned(s)) push(name, stripOp(s), scope);
95
+ else skipped++;
96
+ };
97
+
98
+ // PEP 621 [project]
99
+ const proj = data.project || {};
100
+ for (const req of (Array.isArray(proj.dependencies) ? proj.dependencies : [])) addSpec(req, "prod");
101
+ for (const [group, arr] of Object.entries(proj["optional-dependencies"] || {})) {
102
+ const isDev = /^(dev|test|tests|lint|docs|typing|type|check)$/i.test(group);
103
+ for (const req of (Array.isArray(arr) ? arr : [])) addSpec(req, isDev ? "dev" : "prod");
104
+ }
105
+ // Poetry [tool.poetry]
106
+ const poetry = (data.tool && data.tool.poetry) || {};
107
+ for (const [name, v] of Object.entries(poetry.dependencies || {})) addPoetry(name, v, "prod");
108
+ for (const [name, v] of Object.entries(poetry["dev-dependencies"] || {})) addPoetry(name, v, "dev");
109
+ for (const grp of Object.values(poetry.group || {})) {
110
+ for (const [name, v] of Object.entries((grp && grp.dependencies) || {})) addPoetry(name, v, "dev");
111
+ }
112
+ return { manifestPath: filePath, manifestType: "pyproject.toml", deps, skipped };
113
+ }
114
+
115
+ // Walk a requirements.txt graph, following -r/--requirement (deps) and
116
+ // -c/--constraint (version pins) includes recursively. `kind` is "req" for the
117
+ // dependency graph and "constraint" for files reached via -c.
118
+ function collectReqGraph(filePath, seen, kind, acc) {
119
+ const real = path.resolve(filePath);
120
+ if (seen.has(real)) return; // cycle / re-include guard
121
+ seen.add(real);
122
+ if (seen.size > 200) return; // sanity bound
123
+ let lines;
124
+ try { lines = fs.readFileSync(real, "utf8").split(/\r?\n/); }
125
+ catch { acc.missing.push(real); return; }
126
+ const dir = path.dirname(real);
57
127
  for (const raw of lines) {
58
- const line = raw.replace(/#.*$/, "").trim();
128
+ const line = raw.replace(/(^|\s)#.*$/, "").trim();
59
129
  if (!line) continue;
60
- if (line.startsWith("-")) continue; // -e ., -r other.txt, --flags
61
- const m = line.match(/^([A-Za-z0-9._-]+)\s*(\[[^\]]*\])?\s*(.*)$/);
62
- if (!m) continue;
63
- const name = pep503(m[1]);
64
- const spec = m[3].split(";")[0].trim(); // drop env markers
65
- if (isPinned(spec)) deps.push({ name, version: stripOp(spec), scope: "prod", isDev: false });
66
- else skipped++;
130
+ const rMatch = line.match(/^(?:-r|--requirement)[=\s]+(.+)$/);
131
+ if (rMatch) { collectReqGraph(path.resolve(dir, rMatch[1].trim()), seen, kind, acc); continue; }
132
+ const cMatch = line.match(/^(?:-c|--constraint)[=\s]+(.+)$/);
133
+ if (cMatch) { collectReqGraph(path.resolve(dir, cMatch[1].trim()), seen, "constraint", acc); continue; }
134
+ if (line.startsWith("-")) continue; // -e ., --hash, other flags
135
+ const { name, spec } = splitPep508(line);
136
+ if (!name) continue;
137
+ const n = pep503(name);
138
+ if (kind === "constraint") acc.constraints.set(n, spec);
139
+ else acc.reqs.push({ name: n, spec });
140
+ }
141
+ }
142
+
143
+ function parseRequirementsTxt(filePath) {
144
+ const acc = { reqs: [], constraints: new Map(), missing: [] };
145
+ collectReqGraph(filePath, new Set(), "req", acc);
146
+ const deps = [];
147
+ let skipped = 0;
148
+ const seen = new Set();
149
+ for (const r of acc.reqs) {
150
+ let version = null;
151
+ if (isPinned(r.spec)) version = stripOp(r.spec);
152
+ else if (acc.constraints.has(r.name) && isPinned(acc.constraints.get(r.name))) {
153
+ version = stripOp(acc.constraints.get(r.name)); // range pinned by -c constraint
154
+ }
155
+ if (!version) { skipped++; continue; }
156
+ const k = `${r.name}@${version}`;
157
+ if (seen.has(k)) continue; seen.add(k);
158
+ deps.push({ name: r.name, version, scope: "prod", isDev: false });
67
159
  }
68
- return { manifestPath: filePath, manifestType: "requirements.txt", deps, skipped };
160
+ return { manifestPath: filePath, manifestType: "requirements.txt", deps, skipped, missing: acc.missing };
69
161
  }
70
162
 
71
- module.exports = { pep503, isConcrete, parsePoetryLock, parseUvLock, parsePdmLock, parsePipfileLock, parseRequirementsTxt };
163
+ module.exports = { pep503, isConcrete, splitPep508, parsePoetryLock, parseUvLock, parsePdmLock, parsePipfileLock, parsePyprojectToml, parseRequirementsTxt };
@@ -19,6 +19,8 @@ const LOCKS = [
19
19
  ["pdm.lock", P.parsePdmLock],
20
20
  ];
21
21
 
22
+ const FALLBACK_MANIFESTS = ["pyproject.toml", "requirements.txt"];
23
+
22
24
  function findPyDirs(dir) {
23
25
  const groups = [];
24
26
  const stack = [dir];
@@ -26,7 +28,7 @@ function findPyDirs(dir) {
26
28
  const cur = stack.pop();
27
29
  let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
28
30
  const names = new Set(entries.filter(e => e.isFile()).map(e => e.name));
29
- if ([...LOCKS.map(l => l[0]), "requirements.txt"].some(n => names.has(n))) groups.push({ dir: cur, names });
31
+ if ([...LOCKS.map(l => l[0]), ...FALLBACK_MANIFESTS].some(n => names.has(n))) groups.push({ dir: cur, names });
30
32
  for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
31
33
  }
32
34
  return groups;
@@ -36,7 +38,7 @@ module.exports = {
36
38
  id: "pypi",
37
39
  label: "PyPI",
38
40
  osvEcosystem: "PyPI",
39
- manifestNames: ["poetry.lock", "Pipfile.lock", "uv.lock", "pdm.lock", "requirements.txt"],
41
+ manifestNames: ["poetry.lock", "Pipfile.lock", "uv.lock", "pdm.lock", "pyproject.toml", "requirements.txt"],
40
42
 
41
43
  detect(dir) { return findPyDirs(dir).length > 0; },
42
44
 
@@ -55,14 +57,33 @@ module.exports = {
55
57
  const fp = path.join(g.dir, lock[0]);
56
58
  try { const { deps } = lock[1](fp); for (const d of deps) add(d, fp); }
57
59
  catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `${lock[0]} parse failed: ${e.message}` }); }
58
- } else if (g.names.has("requirements.txt")) {
60
+ continue; // lockfile is authoritative for this directory
61
+ }
62
+ // No lockfile → best-effort from pyproject.toml and/or requirements.txt.
63
+ let pinned = 0, skipped = 0;
64
+ const sources = [];
65
+ if (g.names.has("pyproject.toml")) {
66
+ const fp = path.join(g.dir, "pyproject.toml");
67
+ try {
68
+ const r = P.parsePyprojectToml(fp);
69
+ for (const d of r.deps) add(d, fp);
70
+ pinned += r.deps.length; skipped += r.skipped;
71
+ if (r.deps.length || r.skipped) sources.push("pyproject.toml");
72
+ } catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `pyproject.toml parse failed: ${e.message}` }); }
73
+ }
74
+ if (g.names.has("requirements.txt")) {
59
75
  const fp = path.join(g.dir, "requirements.txt");
60
76
  try {
61
- const { deps, skipped } = P.parseRequirementsTxt(fp);
62
- for (const d of deps) add(d, fp);
63
- warnings.push({ type: "no-lockfile", manifestPath: fp, message: `requirements.txt (no lockfile) — best-effort: ${deps.length} pinned, ${skipped} range(s) skipped` });
77
+ const r = P.parseRequirementsTxt(fp);
78
+ for (const d of r.deps) add(d, fp);
79
+ pinned += r.deps.length; skipped += r.skipped;
80
+ sources.push("requirements.txt");
81
+ for (const miss of (r.missing || [])) warnings.push({ type: "missing-include", manifestPath: fp, message: `referenced requirements file not found: ${miss}` });
64
82
  } catch (e) { warnings.push({ type: "parse-error", manifestPath: fp, message: `requirements.txt parse failed: ${e.message}` }); }
65
83
  }
84
+ if (sources.length) {
85
+ warnings.push({ type: "no-lockfile", manifestPath: g.dir, message: `no lockfile (${sources.join(" + ")}) — best-effort: ${pinned} pinned, ${skipped} range(s) skipped` });
86
+ }
66
87
  }
67
88
  return { deps: out, warnings };
68
89
  },
package/lib/core.js CHANGED
@@ -99,10 +99,20 @@ async function parsePom(pomPath, allPomMetadata) {
99
99
  groupId,
100
100
  artifactId,
101
101
  version,
102
+ // Maven model expressions for the current project. These are built-ins,
103
+ // not <properties> — ${project.version} always means THIS project's
104
+ // version (never overridable by a property or an imported BOM). The
105
+ // pom.* aliases mirror Maven's legacy synonyms.
102
106
  localVars: {
103
107
  "project.groupId": groupId,
104
108
  "project.artifactId": artifactId,
105
109
  "project.version": version,
110
+ "pom.groupId": groupId,
111
+ "pom.artifactId": artifactId,
112
+ "pom.version": version,
113
+ ...(parentInfo?.groupId ? { "project.parent.groupId": parentInfo.groupId } : {}),
114
+ ...(parentInfo?.artifactId ? { "project.parent.artifactId": parentInfo.artifactId } : {}),
115
+ ...(parentInfo?.version ? { "project.parent.version": parentInfo.version } : {}),
106
116
  },
107
117
  };
108
118
 
@@ -199,7 +209,31 @@ async function getAllInheritedProps(pomPath, allPomMetadata, cache) {
199
209
  merged.dependencyManagement.push(...imported.dependencyManagement);
200
210
  }
201
211
 
202
- resolveParentPath(pomPath, parentInfo, allPomMetadata);
212
+ // Cache the (still-mutating) object BEFORE recursing into the parent so a
213
+ // malformed parent cycle (A→B→A) returns this partial result instead of
214
+ // recursing forever. `merged` is mutated in place afterwards, so the cached
215
+ // reference ends up fully populated.
216
+ cache[pomPath] = merged;
217
+
218
+ // Inherit <properties> from a LOCAL parent POM as the BASE layer — the child
219
+ // overrides the parent, exactly like native Maven. (dependencyManagement and
220
+ // parent <dependencies> need no special handling here: collectResolvedDeps
221
+ // already merges them across the whole tree by g:a.) External parents
222
+ // (e.g. spring-boot-starter-parent) aren't in the source tree, so their
223
+ // properties can't be inherited without a network fetch — those versions
224
+ // still surface as unresolved, as before.
225
+ const parentPath = resolveParentPath(pomPath, parentInfo, allPomMetadata);
226
+ if (parentPath && parentPath !== pomPath) {
227
+ const parentMerged = await getAllInheritedProps(parentPath, allPomMetadata, cache);
228
+ merged.properties = { ...parentMerged.properties, ...merged.properties };
229
+ }
230
+
231
+ // Built-in project.* coordinates (${project.version}, ${project.groupId}, …).
232
+ // Applied LAST so the current POM's own values win — even over an inherited
233
+ // parent property or an imported BOM's project.* — letting an intra-reactor
234
+ // dep declared "<version>${project.version}</version>" resolve to THIS
235
+ // module's version.
236
+ merged.properties = { ...merged.properties, ...(meta.localVars || {}) };
203
237
 
204
238
  cache[pomPath] = merged;
205
239
  return merged;
package/lib/cve-match.js CHANGED
@@ -12,11 +12,21 @@ const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, NONE: 0, UNKNOW
12
12
  function resolveDepVersion(rawVersion, allProps) {
13
13
  if (!rawVersion) return null;
14
14
  // Resolve ${prop} references using the merged property map for this POM.
15
- return String(rawVersion).replace(/\$\{\s*([\w._-]+)\s*\}/g, (m, k) => {
16
- const v = allProps?.[k];
17
- if (Array.isArray(v)) return v[0];
18
- return v != null ? v : m;
19
- });
15
+ // Maven interpolates recursively (e.g. ${project.version} ${revision}
16
+ // "1.0"), so we re-substitute until the string stops changing. Bounded +
17
+ // stop-on-no-progress guards against property cycles. Unknown refs are left
18
+ // verbatim, so they still surface as "unresolved-versions" in chapter 0.
19
+ let v = String(rawVersion);
20
+ for (let i = 0; i < 10 && v.includes("${"); i++) {
21
+ const next = v.replace(/\$\{\s*([\w._-]+)\s*\}/g, (m, k) => {
22
+ const val = allProps?.[k];
23
+ if (Array.isArray(val)) return val[0];
24
+ return val != null ? val : m;
25
+ });
26
+ if (next === v) break; // no progress → remaining refs are unresolvable
27
+ v = next;
28
+ }
29
+ return v;
20
30
  }
21
31
 
22
32
  /**