fad-checker 1.0.6 → 2.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 (70) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/CLAUDE.md +27 -9
  3. package/README.md +27 -13
  4. package/completions/fad-checker.bash +4 -1
  5. package/completions/fad-checker.zsh +9 -0
  6. package/data/eol-mapping.json +17 -0
  7. package/docs/ARCHITECTURE.md +35 -7
  8. package/docs/USAGE.md +15 -7
  9. package/docs/superpowers/plans/2026-05-29-codec-composer.md +556 -0
  10. package/docs/superpowers/plans/2026-05-29-codec-foundation.md +851 -0
  11. package/docs/superpowers/plans/2026-05-29-codec-nuget.md +432 -0
  12. package/docs/superpowers/plans/2026-05-29-codec-pypi.md +450 -0
  13. package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +251 -0
  14. package/fad-checker.js +108 -82
  15. package/lib/codecs/codec.interface.js +27 -0
  16. package/lib/codecs/composer/parse.js +59 -0
  17. package/lib/codecs/composer/registry.js +92 -0
  18. package/lib/codecs/composer.codec.js +93 -0
  19. package/lib/codecs/index.js +37 -0
  20. package/lib/codecs/maven.codec.js +71 -0
  21. package/lib/{npm → codecs/npm}/collect.js +32 -18
  22. package/lib/codecs/npm.codec.js +52 -0
  23. package/lib/codecs/nuget/parse.js +75 -0
  24. package/lib/codecs/nuget/registry.js +88 -0
  25. package/lib/codecs/nuget.codec.js +92 -0
  26. package/lib/codecs/pypi/parse.js +71 -0
  27. package/lib/codecs/pypi/registry.js +89 -0
  28. package/lib/codecs/pypi.codec.js +81 -0
  29. package/lib/codecs/recipes.js +108 -0
  30. package/lib/codecs/select.js +27 -0
  31. package/lib/codecs/yarn.codec.js +19 -0
  32. package/lib/cve-match.js +3 -6
  33. package/lib/cve-report.js +34 -70
  34. package/lib/dep-record.js +60 -0
  35. package/lib/osv.js +22 -14
  36. package/lib/outdated.js +13 -2
  37. package/package.json +3 -2
  38. package/test/cli-ecosystem.test.js +30 -0
  39. package/test/codec-edge-cases.test.js +131 -0
  40. package/test/codec-integration.test.js +119 -0
  41. package/test/codecs.test.js +92 -0
  42. package/test/composer.test.js +71 -0
  43. package/test/cve-report.test.js +9 -0
  44. package/test/dep-record.test.js +31 -0
  45. package/test/fixtures/csharp-config/packages.config +4 -0
  46. package/test/fixtures/csharp-csproj/Directory.Packages.props +5 -0
  47. package/test/fixtures/csharp-csproj/app.csproj +7 -0
  48. package/test/fixtures/csharp-lock/packages.lock.json +6 -0
  49. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +2 -1
  50. package/test/fixtures/php-app/composer.json +5 -0
  51. package/test/fixtures/php-app/composer.lock +10 -0
  52. package/test/fixtures/polyglot/cs/packages.lock.json +1 -0
  53. package/test/fixtures/polyglot/js/package-lock.json +1 -0
  54. package/test/fixtures/polyglot/js/package.json +1 -0
  55. package/test/fixtures/polyglot/mvn/pom.xml +7 -0
  56. package/test/fixtures/polyglot/php/composer.lock +1 -0
  57. package/test/fixtures/polyglot/py/poetry.lock +3 -0
  58. package/test/fixtures/python-pipenv/Pipfile.lock +1 -0
  59. package/test/fixtures/python-poetry/poetry.lock +7 -0
  60. package/test/fixtures/python-reqs/requirements.txt +5 -0
  61. package/test/fixtures/python-uv/uv.lock +3 -0
  62. package/test/monorepo.test.js +1 -1
  63. package/test/npm-registry.test.js +1 -1
  64. package/test/npm.test.js +8 -4
  65. package/test/nuget.test.js +66 -0
  66. package/test/osv.test.js +62 -0
  67. package/test/pypi.test.js +72 -0
  68. package/test/webjar.test.js +1 -1
  69. /package/lib/{npm → codecs/npm}/parse.js +0 -0
  70. /package/lib/{npm → codecs/npm}/registry.js +0 -0
@@ -0,0 +1,92 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert");
3
+ const path = require("path");
4
+ const { assertCodecShape } = require("../lib/codecs/codec.interface");
5
+
6
+ const COMPLETE_STUB = {
7
+ id: "x", label: "X", osvEcosystem: "npm",
8
+ manifestNames: ["x.json"],
9
+ detect: () => false,
10
+ collect: async () => ({ deps: new Map(), warnings: [] }),
11
+ coordKey: d => `x:${d.name}`,
12
+ formatCoord: d => d.name,
13
+ osvPackageName: d => d.name,
14
+ checkRegistry: async () => ({ outdated: [], deprecated: [] }),
15
+ resolveEolProduct: () => null,
16
+ recipe: { label: "X", pinSection: "", pinIntro: () => "", snippet: () => "", directSection: "" },
17
+ nativeScanners: [],
18
+ };
19
+
20
+ test("assertCodecShape accepts a complete codec stub", () => {
21
+ assert.doesNotThrow(() => assertCodecShape(COMPLETE_STUB));
22
+ });
23
+
24
+ test("assertCodecShape rejects a codec missing a required method", () => {
25
+ assert.throws(() => assertCodecShape({ id: "y" }), /missing|y/i);
26
+ });
27
+
28
+ const maven = require("../lib/codecs/maven.codec");
29
+
30
+ test("maven codec detects the simple fixture and collects deps with bare g:a coordKeys", async () => {
31
+ const dir = path.join(__dirname, "fixtures", "simple");
32
+ assert.strictEqual(maven.detect(dir), true);
33
+ const { deps } = await maven.collect(dir, {});
34
+ assert.ok(deps.size > 0);
35
+ for (const [k, d] of deps) {
36
+ assert.strictEqual(d.ecosystem, "maven");
37
+ assert.strictEqual(d.coordKey, k);
38
+ assert.ok(!k.startsWith("npm:") && !k.startsWith("nuget:"), `maven key ${k} must stay bare`);
39
+ }
40
+ });
41
+
42
+ const npm = require("../lib/codecs/npm.codec");
43
+
44
+ test("npm codec collects from monorepo-mixed with npm: coordKeys", async () => {
45
+ const dir = path.join(__dirname, "fixtures", "monorepo-mixed");
46
+ assert.strictEqual(npm.detect(dir), true);
47
+ const { deps } = await npm.collect(dir, {});
48
+ assert.ok(deps.size > 0);
49
+ for (const [k, d] of deps) {
50
+ assert.ok(k.startsWith("npm:"), `key ${k} should be npm-namespaced`);
51
+ assert.strictEqual(d.ecosystem, "npm");
52
+ assert.strictEqual(d.coordKey, k);
53
+ }
54
+ });
55
+
56
+ test("yarn codec is a no-op collector (npm codec does the JS scan)", async () => {
57
+ const yarn = require("../lib/codecs/yarn.codec");
58
+ assert.strictEqual(yarn.id, "yarn");
59
+ const { deps } = await yarn.collect("/whatever", {});
60
+ assert.strictEqual(deps.size, 0);
61
+ });
62
+
63
+ const { getCodec, allCodecs, detectCodecs } = require("../lib/codecs");
64
+
65
+ test("registry exposes maven/npm/yarn and validates their shape", () => {
66
+ const ids = allCodecs().map(c => c.id).sort();
67
+ assert.deepStrictEqual(ids, ["composer", "maven", "npm", "nuget", "pypi", "yarn"]);
68
+ for (const c of allCodecs()) assertCodecShape(c);
69
+ assert.strictEqual(getCodec("maven").id, "maven");
70
+ assert.strictEqual(getCodec("nope"), null);
71
+ });
72
+
73
+ test("detectCodecs finds maven+npm on monorepo-mixed, not yarn duplicate", () => {
74
+ const dir = path.join(__dirname, "fixtures", "monorepo-mixed");
75
+ const detected = detectCodecs(dir).map(c => c.id);
76
+ assert.ok(detected.includes("maven"));
77
+ assert.ok(detected.includes("npm"));
78
+ assert.ok(!detected.includes("yarn"));
79
+ });
80
+
81
+ const recipes = require("../lib/codecs/recipes");
82
+
83
+ test("each codec recipe exposes label + snippet function", () => {
84
+ for (const c of allCodecs()) {
85
+ assert.strictEqual(typeof c.recipe.label, "string");
86
+ assert.strictEqual(typeof c.recipe.snippet, "function");
87
+ }
88
+ // snippet output matches the legacy format
89
+ const xml = recipes.maven.snippet([{ groupId: "g", artifactId: "a", fixVersion: "1.0" }]);
90
+ assert.match(xml, /<dependencyManagement>/);
91
+ assert.match(xml, /<artifactId>a<\/artifactId>/);
92
+ });
@@ -0,0 +1,71 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert");
3
+ const path = require("path");
4
+ const { parseComposerLock, parseComposerJson } = require("../lib/codecs/composer/parse");
5
+
6
+ const FIX = path.join(__dirname, "fixtures", "php-app");
7
+
8
+ test("parseComposerLock reads prod + dev packages, strips leading v", () => {
9
+ const r = parseComposerLock(path.join(FIX, "composer.lock"));
10
+ const byName = Object.fromEntries(r.deps.map(d => [d.name, d]));
11
+ assert.strictEqual(byName["guzzlehttp/guzzle"].version, "7.4.5");
12
+ assert.strictEqual(byName["guzzlehttp/guzzle"].scope, "prod");
13
+ assert.strictEqual(byName["symfony/console"].version, "6.2.10"); // "v6.2.10" → "6.2.10"
14
+ assert.strictEqual(byName["phpunit/phpunit"].scope, "dev");
15
+ assert.strictEqual(byName["phpunit/phpunit"].isDev, true);
16
+ });
17
+
18
+ test("parseComposerJson reads require + require-dev with pinned-vs-range info", () => {
19
+ const r = parseComposerJson(path.join(FIX, "composer.json"));
20
+ const byName = Object.fromEntries(r.deps.map(d => [d.name, d]));
21
+ assert.strictEqual(byName["monolog/monolog"].version, "2.9.1");
22
+ assert.strictEqual(byName["guzzlehttp/guzzle"].version, "^7.0");
23
+ assert.strictEqual(byName["phpunit/phpunit"].scope, "dev");
24
+ });
25
+
26
+ const { packagistToFindings } = require("../lib/codecs/composer/registry");
27
+
28
+ test("packagistToFindings extracts latest stable + abandoned flag", () => {
29
+ const pkg = {
30
+ "abandoned": "psr/log",
31
+ "versions": {
32
+ "2.9.1": { "version": "2.9.1" },
33
+ "3.0.0": { "version": "3.0.0" },
34
+ "dev-main": { "version": "dev-main" },
35
+ "2.8.0": { "version": "2.8.0" },
36
+ },
37
+ };
38
+ const f = packagistToFindings(pkg, { version: "2.9.1" });
39
+ assert.strictEqual(f.outdated.latest, "3.0.0");
40
+ assert.deepStrictEqual(f.abandoned, { replacement: "psr/log" });
41
+
42
+ const f2 = packagistToFindings({ "abandoned": true, "versions": { "1.0.0": {} } }, { version: "1.0.0" });
43
+ assert.deepStrictEqual(f2.abandoned, { replacement: null });
44
+ assert.strictEqual(f2.outdated, null);
45
+ });
46
+
47
+ const composer = require("../lib/codecs/composer.codec");
48
+ const { assertCodecShape } = require("../lib/codecs/codec.interface");
49
+
50
+ test("composer codec: shape, detect, collect with composer:vendor/pkg coordKeys", async () => {
51
+ assertCodecShape(composer);
52
+ assert.strictEqual(composer.detect(FIX), true);
53
+ const { deps } = await composer.collect(FIX, {});
54
+ const g = deps.get("composer:guzzlehttp/guzzle");
55
+ assert.ok(g, "guzzle should be collected under composer:guzzlehttp/guzzle");
56
+ assert.strictEqual(g.ecosystem, "composer");
57
+ assert.strictEqual(g.namespace, "guzzlehttp");
58
+ assert.strictEqual(g.name, "guzzle");
59
+ assert.strictEqual(composer.osvPackageName(g), "guzzlehttp/guzzle");
60
+ assert.strictEqual(composer.formatCoord(g), "guzzlehttp/guzzle");
61
+ });
62
+
63
+ test("composer collect falls back to composer.json (pinned only) with warning when no lock", async () => {
64
+ const os2 = require("os"); const fs2 = require("fs"); const p2 = require("path");
65
+ const dir = fs2.mkdtempSync(p2.join(os2.tmpdir(), "composer-nolock-"));
66
+ fs2.writeFileSync(p2.join(dir, "composer.json"), JSON.stringify({ name: "x/y", require: { "a/pinned": "1.2.3", "b/range": "^2.0" } }));
67
+ const { deps, warnings } = await composer.collect(dir, {});
68
+ assert.ok(deps.has("composer:a/pinned"));
69
+ assert.ok(!deps.has("composer:b/range"));
70
+ assert.ok(warnings.find(w => w.type === "no-lockfile"));
71
+ });
@@ -169,3 +169,12 @@ test("writeReports writes both files to disk", async () => {
169
169
  assert.ok(html.includes("log4j:log4j"));
170
170
  fs.rmSync(tmp, { recursive: true, force: true });
171
171
  });
172
+
173
+ const { depDisplayName, depDisplayParts } = require("../lib/cve-report");
174
+
175
+ test("depDisplayName delegates to codec and preserves legacy display", () => {
176
+ assert.strictEqual(depDisplayName({ ecosystem: "maven", ecosystemType: "maven", namespace: "org.apache", name: "log4j", groupId: "org.apache", artifactId: "log4j" }), "org.apache:log4j");
177
+ assert.strictEqual(depDisplayName({ ecosystem: "npm", ecosystemType: "npm", namespace: "", name: "lodash", artifactId: "lodash" }), "npm:lodash");
178
+ const parts = depDisplayParts({ ecosystem: "npm", ecosystemType: "npm", namespace: "", name: "lodash", artifactId: "lodash" });
179
+ assert.deepStrictEqual(parts, { groupLine: "npm:", nameLine: "lodash" });
180
+ });
@@ -0,0 +1,31 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert");
3
+ const { makeDepRecord, coordKeyFor } = require("../lib/dep-record");
4
+
5
+ test("maven depRecord builds bare g:a coordKey and keeps groupId/artifactId aliases", () => {
6
+ const d = makeDepRecord({ ecosystem: "maven", namespace: "org.apache", name: "log4j", version: "2.14.0", manifestPath: "/p/pom.xml", scope: "compile" });
7
+ assert.strictEqual(d.coordKey, "org.apache:log4j"); // clé Maven brute (pas de préfixe)
8
+ assert.strictEqual(d.groupId, "org.apache"); // alias rétro-compat
9
+ assert.strictEqual(d.artifactId, "log4j"); // alias rétro-compat
10
+ assert.deepStrictEqual(d.versions, ["2.14.0"]);
11
+ assert.strictEqual(d.isDev, false);
12
+ });
13
+
14
+ test("npm depRecord has empty namespace and npm-prefixed coordKey", () => {
15
+ const d = makeDepRecord({ ecosystem: "npm", namespace: "", name: "lodash", version: "4.17.20", manifestPath: "/p/package-lock.json", scope: "prod" });
16
+ assert.strictEqual(d.coordKey, "npm:lodash");
17
+ assert.strictEqual(d.groupId, "");
18
+ assert.strictEqual(d.artifactId, "lodash");
19
+ });
20
+
21
+ test("coordKeyFor composes ecosystem + namespace + name", () => {
22
+ assert.strictEqual(coordKeyFor("composer", "guzzlehttp", "guzzle"), "composer:guzzlehttp/guzzle");
23
+ assert.strictEqual(coordKeyFor("pypi", "", "requests"), "pypi:requests");
24
+ assert.strictEqual(coordKeyFor("nuget", "", "Newtonsoft.Json"), "nuget:newtonsoft.json");
25
+ });
26
+
27
+ test("pomPaths shares the manifestPaths array reference (push stays in sync)", () => {
28
+ const d = makeDepRecord({ ecosystem: "maven", namespace: "g", name: "a", version: "1.0", manifestPath: "/p/pom.xml" });
29
+ d.manifestPaths.push("/q/pom.xml");
30
+ assert.deepStrictEqual(d.pomPaths, ["/p/pom.xml", "/q/pom.xml"]);
31
+ });
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <packages>
3
+ <package id="EntityFramework" version="6.4.4" targetFramework="net48" />
4
+ </packages>
@@ -0,0 +1,5 @@
1
+ <Project>
2
+ <ItemGroup>
3
+ <PackageVersion Include="Managed" Version="6.0.0" />
4
+ </ItemGroup>
5
+ </Project>
@@ -0,0 +1,7 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+ <ItemGroup>
3
+ <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
4
+ <PackageReference Include="Floating" Version="1.*" />
5
+ <PackageReference Include="Managed" />
6
+ </ItemGroup>
7
+ </Project>
@@ -0,0 +1,6 @@
1
+ { "version": 1, "dependencies": {
2
+ "net6.0": {
3
+ "Newtonsoft.Json": { "type": "Direct", "resolved": "13.0.1" },
4
+ "Serilog": { "type": "Direct", "resolved": "2.12.0" },
5
+ "System.Buffers": { "type": "Transitive", "resolved": "4.5.1" }
6
+ } } }
@@ -4,6 +4,7 @@
4
4
  "private": true,
5
5
  "dependencies": {
6
6
  "left-pad": "^1.0.0",
7
- "chalk": "^4.0.0"
7
+ "chalk": "^4.0.0",
8
+ "semver": "7.5.0"
8
9
  }
9
10
  }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "acme/site",
3
+ "require": { "guzzlehttp/guzzle": "^7.0", "monolog/monolog": "2.9.1" },
4
+ "require-dev": { "phpunit/phpunit": "^10.0" }
5
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "packages": [
3
+ { "name": "guzzlehttp/guzzle", "version": "7.4.5" },
4
+ { "name": "monolog/monolog", "version": "2.9.1" },
5
+ { "name": "symfony/console", "version": "v6.2.10" }
6
+ ],
7
+ "packages-dev": [
8
+ { "name": "phpunit/phpunit", "version": "10.1.0" }
9
+ ]
10
+ }
@@ -0,0 +1 @@
1
+ { "version":1,"dependencies":{"net6.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}} }
@@ -0,0 +1 @@
1
+ { "name":"poly-js","lockfileVersion":3,"packages":{"node_modules/lodash":{"version":"4.17.21"}} }
@@ -0,0 +1 @@
1
+ { "name":"poly-js","dependencies":{"lodash":"^4.17.0"} }
@@ -0,0 +1,7 @@
1
+ <project xmlns="http://maven.apache.org/POM/4.0.0">
2
+ <modelVersion>4.0.0</modelVersion>
3
+ <groupId>com.acme</groupId><artifactId>poly</artifactId><version>1.0.0</version>
4
+ <dependencies>
5
+ <dependency><groupId>org.apache.logging.log4j</groupId><artifactId>log4j-core</artifactId><version>2.14.0</version></dependency>
6
+ </dependencies>
7
+ </project>
@@ -0,0 +1 @@
1
+ { "packages":[{"name":"guzzlehttp/guzzle","version":"7.4.5"}],"packages-dev":[] }
@@ -0,0 +1,3 @@
1
+ [[package]]
2
+ name = "requests"
3
+ version = "2.31.0"
@@ -0,0 +1 @@
1
+ { "default": { "django": { "version": "==4.2.0" } }, "develop": { "pytest": { "version": "==7.4.0" } } }
@@ -0,0 +1,7 @@
1
+ [[package]]
2
+ name = "requests"
3
+ version = "2.31.0"
4
+
5
+ [[package]]
6
+ name = "Flask-SQLAlchemy"
7
+ version = "3.0.5"
@@ -0,0 +1,5 @@
1
+ # comment
2
+ fastapi==0.103.0
3
+ flask>=2.0 # range -> skipped
4
+ -e .
5
+ urllib3==2.0.4
@@ -0,0 +1,3 @@
1
+ [[package]]
2
+ name = "numpy"
3
+ version = "1.26.0"
@@ -11,7 +11,7 @@ const assert = require("node:assert/strict");
11
11
  const path = require("path");
12
12
  const core = require("../lib/core");
13
13
  const { collectResolvedDeps } = require("../lib/cve-match");
14
- const { collectNpmDeps, hasJsManifests } = require("../lib/npm/collect");
14
+ const { collectNpmDeps, hasJsManifests } = require("../lib/codecs/npm/collect");
15
15
 
16
16
  const FIX = path.join(__dirname, "fixtures", "monorepo-mixed");
17
17
 
@@ -1,6 +1,6 @@
1
1
  const { test } = require("node:test");
2
2
  const assert = require("node:assert/strict");
3
- const { packumentToFindings } = require("../lib/npm/registry");
3
+ const { packumentToFindings } = require("../lib/codecs/npm/registry");
4
4
 
5
5
  const dep = (name, version) => ({ ecosystem: "npm", groupId: "", artifactId: name, version });
6
6
 
package/test/npm.test.js CHANGED
@@ -6,8 +6,8 @@ const {
6
6
  parsePackageLock,
7
7
  parseYarnLockV1,
8
8
  findJsManifests,
9
- } = require("../lib/npm/parse");
10
- const { collectNpmDeps } = require("../lib/npm/collect");
9
+ } = require("../lib/codecs/npm/parse");
10
+ const { collectNpmDeps } = require("../lib/codecs/npm/collect");
11
11
 
12
12
  const FIX = path.join(__dirname, "fixtures", "monorepo-mixed");
13
13
 
@@ -110,13 +110,17 @@ test("collectNpmDeps skips package.json when a lockfile is present in the same d
110
110
  }
111
111
  });
112
112
 
113
- test("collectNpmDeps emits a 'no-lockfile' warning for package.json without sibling lock", () => {
113
+ test("collectNpmDeps no-lockfile fallback: warns + scans pinned, skips ranges", () => {
114
114
  const map = collectNpmDeps(FIX, { verbose: false });
115
115
  assert.ok(Array.isArray(map.warnings), "warnings array should be present");
116
116
  const w = map.warnings.find(x => x.type === "no-lockfile" && x.manifestPath.includes("no-lock"));
117
117
  assert.ok(w, "expected a no-lockfile warning for packages/no-lock/package.json");
118
- // And no left-pad / no @acme/no-lock dep should leak into the Map (range-only)
118
+ assert.match(w.message, /best-effort/, "warning should mention best-effort/partial results");
119
+ // Range-only deps must NOT leak into the Map (can't query OSV with "^1.0.0").
119
120
  assert.equal(map.has("npm:left-pad"), false, "left-pad ^1.0.0 must not be collected (unresolved range)");
121
+ // Pinned exact versions ARE now collected best-effort (changed contract).
122
+ assert.ok(map.has("npm:semver"), "semver 7.5.0 (pinned) should be collected from no-lock package.json");
123
+ assert.equal(map.get("npm:semver").version, "7.5.0");
120
124
  });
121
125
 
122
126
  test("parsePackageLock tags flattened-transitive entries as scope='transitive'", () => {
@@ -0,0 +1,66 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert");
3
+ const path = require("path");
4
+ const { parsePackagesLockJson, parseCsproj, parsePackagesConfig, parseDirectoryPackagesProps } = require("../lib/codecs/nuget/parse");
5
+ const F = n => path.join(__dirname, "fixtures", n);
6
+
7
+ test("parsePackagesLockJson reads resolved versions + Direct/Transitive scope", async () => {
8
+ const r = await parsePackagesLockJson(F("csharp-lock/packages.lock.json"));
9
+ const m = Object.fromEntries(r.deps.map(d => [d.name, d]));
10
+ assert.strictEqual(m["Newtonsoft.Json"].version, "13.0.1");
11
+ assert.strictEqual(m["System.Buffers"].scope, "transitive");
12
+ });
13
+
14
+ test("parseDirectoryPackagesProps returns a name→version map (CPM)", async () => {
15
+ const m = await parseDirectoryPackagesProps(F("csharp-csproj/Directory.Packages.props"));
16
+ assert.strictEqual(m["managed"], "6.0.0"); // keyed lowercase
17
+ });
18
+
19
+ test("parseCsproj: pinned scanned, floating skipped, CPM resolved against props", async () => {
20
+ const cpm = await parseDirectoryPackagesProps(F("csharp-csproj/Directory.Packages.props"));
21
+ const r = await parseCsproj(F("csharp-csproj/app.csproj"), cpm);
22
+ const m = Object.fromEntries(r.deps.map(d => [d.name, d.version]));
23
+ assert.strictEqual(m["Newtonsoft.Json"], "13.0.1");
24
+ assert.strictEqual(m["Managed"], "6.0.0"); // resolved via CPM
25
+ assert.ok(!("Floating" in m)); // "1.*" skipped
26
+ assert.strictEqual(r.skipped, 1);
27
+ });
28
+
29
+ test("parsePackagesConfig reads legacy id/version", async () => {
30
+ const r = await parsePackagesConfig(F("csharp-config/packages.config"));
31
+ assert.strictEqual(r.deps.find(d => d.name === "EntityFramework").version, "6.4.4");
32
+ });
33
+
34
+ const { nugetRegistrationToFindings } = require("../lib/codecs/nuget/registry");
35
+ test("nugetRegistrationToFindings extracts latest stable + deprecation for version", () => {
36
+ const reg = { items: [ { items: [
37
+ { catalogEntry: { version: "13.0.1", deprecation: { reasons: ["Legacy"], alternatePackage: { id: "NewPkg" } } } },
38
+ { catalogEntry: { version: "13.0.3" } },
39
+ { catalogEntry: { version: "14.0.0-preview" } },
40
+ ] } ] };
41
+ const f = nugetRegistrationToFindings(reg, { version: "13.0.1" });
42
+ assert.strictEqual(f.outdated.latest, "13.0.3");
43
+ assert.deepStrictEqual(f.deprecated, { reason: "Legacy", replacement: "NewPkg" });
44
+ const f2 = nugetRegistrationToFindings(reg, { version: "13.0.3" });
45
+ assert.strictEqual(f2.deprecated, null);
46
+ assert.strictEqual(f2.outdated, null);
47
+ });
48
+
49
+ const nuget = require("../lib/codecs/nuget.codec");
50
+ const { assertCodecShape } = require("../lib/codecs/codec.interface");
51
+ test("nuget codec: shape, detect, collect lockfile, case-insensitive key", async () => {
52
+ assertCodecShape(nuget);
53
+ assert.strictEqual(nuget.detect(F("csharp-lock")), true);
54
+ const { deps } = await nuget.collect(F("csharp-lock"), {});
55
+ const j = deps.get("nuget:newtonsoft.json"); // key lowercased
56
+ assert.ok(j);
57
+ assert.strictEqual(j.name, "Newtonsoft.Json"); // display keeps original case
58
+ assert.strictEqual(nuget.osvPackageName(j), "Newtonsoft.Json");
59
+ });
60
+ test("nuget codec: csproj uses CPM + skips floating with warning", async () => {
61
+ const { deps, warnings } = await nuget.collect(F("csharp-csproj"), {});
62
+ assert.ok(deps.has("nuget:newtonsoft.json"));
63
+ assert.ok(deps.has("nuget:managed")); // resolved via Directory.Packages.props
64
+ assert.ok(!deps.has("nuget:floating"));
65
+ assert.ok(warnings.find(w => w.type === "no-lockfile"));
66
+ });
@@ -0,0 +1,62 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert");
3
+ const { osvEcosystemFor, osvPkgName } = require("../lib/osv");
4
+
5
+ test("osvEcosystemFor maps codec ids to OSV ecosystem names", () => {
6
+ assert.strictEqual(osvEcosystemFor({ ecosystem: "maven" }), "Maven");
7
+ assert.strictEqual(osvEcosystemFor({ ecosystem: "npm" }), "npm");
8
+ assert.strictEqual(osvEcosystemFor({ ecosystem: "yarn" }), "npm");
9
+ assert.strictEqual(osvEcosystemFor({ ecosystem: "nuget" }), "NuGet");
10
+ assert.strictEqual(osvEcosystemFor({ ecosystem: "composer" }), "Packagist");
11
+ assert.strictEqual(osvEcosystemFor({ ecosystem: "pypi" }), "PyPI");
12
+ });
13
+
14
+ test("osvPkgName delegates to codec for maven (g:a) and npm (bare name)", () => {
15
+ assert.strictEqual(osvPkgName({ ecosystem: "maven", namespace: "org.apache", name: "log4j", groupId: "org.apache", artifactId: "log4j" }), "org.apache:log4j");
16
+ assert.strictEqual(osvPkgName({ ecosystem: "npm", namespace: "", name: "lodash", artifactId: "lodash" }), "lodash");
17
+ });
18
+
19
+ const { queryOsvForDeps, OSV_CACHE_DIR } = require("../lib/osv");
20
+ const { makeDepRecord } = require("../lib/dep-record");
21
+ const fs = require("fs");
22
+
23
+ // Regression: queryOsvForDeps must send the codec's package name + ecosystem to
24
+ // OSV for EVERY ecosystem. A prior bug cherry-picked only groupId/artifactId when
25
+ // rebuilding per-version deps, so composer/pypi/nuget queried name=undefined.
26
+ test("queryOsvForDeps sends correct package name + ecosystem per codec (mock fetcher)", async () => {
27
+ // Purge sentinel cache entries so the mock fetcher is always exercised
28
+ // (queryBatch writes a per-dep cache after a live batch — would self-poison).
29
+ try {
30
+ for (const f of fs.readdirSync(OSV_CACHE_DIR)) {
31
+ if (f.includes("9.9.9-fadtest")) fs.unlinkSync(require("path").join(OSV_CACHE_DIR, f));
32
+ }
33
+ } catch { /* dir may not exist yet */ }
34
+ const captured = [];
35
+ const fetcher = async (url, opts) => {
36
+ if (url.includes("/querybatch")) {
37
+ const body = JSON.parse(opts.body);
38
+ captured.push(...body.queries);
39
+ return { ok: true, json: async () => ({ results: body.queries.map(() => ({ vulns: [] })) }) };
40
+ }
41
+ return { ok: true, json: async () => ({}) };
42
+ };
43
+ const deps = new Map();
44
+ for (const [eco, ns, name, ver, expName, expEco] of [
45
+ ["pypi", "", "django", "9.9.9-fadtest", "django", "PyPI"],
46
+ ["composer", "guzzlehttp", "guzzle", "9.9.9-fadtest", "guzzlehttp/guzzle", "Packagist"],
47
+ ["nuget", "", "Newtonsoft.Json", "9.9.9-fadtest", "Newtonsoft.Json", "NuGet"],
48
+ ["maven", "org.apache", "log4j-core", "9.9.9-fadtest", "org.apache:log4j-core", "Maven"],
49
+ ["npm", "", "lodash", "9.9.9-fadtest", "lodash", "npm"],
50
+ ]) {
51
+ const r = makeDepRecord({ ecosystem: eco, namespace: ns, name, version: ver, manifestPath: "x" });
52
+ r._exp = { name: expName, eco: expEco };
53
+ deps.set(r.coordKey, r);
54
+ }
55
+ await queryOsvForDeps(deps, { fetcher });
56
+ for (const d of deps.values()) {
57
+ const q = captured.find(c => c.package.name === d._exp.name);
58
+ assert.ok(q, `expected an OSV query with package.name="${d._exp.name}" (${d.ecosystem})`);
59
+ assert.strictEqual(q.package.ecosystem, d._exp.eco, `ecosystem for ${d._exp.name}`);
60
+ assert.strictEqual(q.version, d.version);
61
+ }
62
+ });
@@ -0,0 +1,72 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert");
3
+ const path = require("path");
4
+ const { pep503, parsePoetryLock, parsePipfileLock, parseUvLock, parseRequirementsTxt } = require("../lib/codecs/pypi/parse");
5
+
6
+ const F = n => path.join(__dirname, "fixtures", n);
7
+
8
+ test("pep503 normalizes names (lowercase, collapse separators to -)", () => {
9
+ assert.strictEqual(pep503("Flask-SQLAlchemy"), "flask-sqlalchemy");
10
+ assert.strictEqual(pep503("zope.interface"), "zope-interface");
11
+ assert.strictEqual(pep503("My__Pkg"), "my-pkg");
12
+ });
13
+
14
+ test("parsePoetryLock returns PEP503 names + versions", () => {
15
+ const r = parsePoetryLock(F("python-poetry/poetry.lock"));
16
+ const m = Object.fromEntries(r.deps.map(d => [d.name, d.version]));
17
+ assert.strictEqual(m["requests"], "2.31.0");
18
+ assert.strictEqual(m["flask-sqlalchemy"], "3.0.5"); // normalized
19
+ });
20
+
21
+ test("parsePipfileLock splits default/develop, strips ==", () => {
22
+ const r = parsePipfileLock(F("python-pipenv/Pipfile.lock"));
23
+ const m = Object.fromEntries(r.deps.map(d => [d.name, d]));
24
+ assert.strictEqual(m["django"].version, "4.2.0");
25
+ assert.strictEqual(m["django"].scope, "prod");
26
+ assert.strictEqual(m["pytest"].scope, "dev");
27
+ });
28
+
29
+ test("parseUvLock reads [[package]]", () => {
30
+ const r = parseUvLock(F("python-uv/uv.lock"));
31
+ assert.strictEqual(r.deps.find(d => d.name === "numpy").version, "1.26.0");
32
+ });
33
+
34
+ test("parseRequirementsTxt keeps == pins, skips ranges/flags/comments", () => {
35
+ const r = parseRequirementsTxt(F("python-reqs/requirements.txt"));
36
+ const names = r.deps.map(d => d.name).sort();
37
+ assert.deepStrictEqual(names, ["fastapi", "urllib3"]);
38
+ assert.strictEqual(r.skipped, 1); // flask>=2.0
39
+ });
40
+
41
+ const { pypiToFindings } = require("../lib/codecs/pypi/registry");
42
+ test("pypiToFindings extracts latest, yanked-for-version, inactive classifier", () => {
43
+ const data = {
44
+ info: { version: "2.1.0", classifiers: ["Development Status :: 7 - Inactive"] },
45
+ releases: { "2.0.4": [{ yanked: true, yanked_reason: "security" }], "2.1.0": [{ yanked: false }] },
46
+ };
47
+ const f = pypiToFindings(data, { version: "2.0.4" });
48
+ assert.strictEqual(f.outdated.latest, "2.1.0");
49
+ assert.strictEqual(f.yanked.reason, "security");
50
+ assert.strictEqual(f.inactive, true);
51
+ const f2 = pypiToFindings(data, { version: "2.1.0" });
52
+ assert.strictEqual(f2.yanked, null);
53
+ assert.strictEqual(f2.outdated, null);
54
+ });
55
+
56
+ const pypi = require("../lib/codecs/pypi.codec");
57
+ const { assertCodecShape } = require("../lib/codecs/codec.interface");
58
+ test("pypi codec: shape, detect, collect, coordKey pypi:<name>", async () => {
59
+ assertCodecShape(pypi);
60
+ assert.strictEqual(pypi.detect(F("python-poetry")), true);
61
+ const { deps } = await pypi.collect(F("python-poetry"), {});
62
+ const r = deps.get("pypi:requests");
63
+ assert.ok(r);
64
+ assert.strictEqual(r.ecosystem, "pypi");
65
+ assert.strictEqual(pypi.osvPackageName(r), "requests");
66
+ });
67
+ test("pypi collect: requirements.txt fallback warns + scans pins only", async () => {
68
+ const { deps, warnings } = await pypi.collect(F("python-reqs"), {});
69
+ assert.ok(deps.has("pypi:fastapi"));
70
+ assert.ok(!deps.has("pypi:flask"));
71
+ assert.ok(warnings.find(w => w.type === "no-lockfile"));
72
+ });
@@ -1,6 +1,6 @@
1
1
  const { test } = require("node:test");
2
2
  const assert = require("node:assert/strict");
3
- const { webjarToNpm } = require("../lib/npm/collect");
3
+ const { webjarToNpm } = require("../lib/codecs/npm/collect");
4
4
 
5
5
  test("webjarToNpm derives the npm name from org.webjars.npm (deterministic mirror)", () => {
6
6
  assert.deepEqual(
File without changes
File without changes