fad-checker 1.0.5 → 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 (71) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/CLAUDE.md +28 -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 +109 -83
  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 +30 -14
  33. package/lib/cve-report.js +130 -83
  34. package/lib/dep-record.js +60 -0
  35. package/lib/osv.js +33 -19
  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-match.test.js +40 -0
  44. package/test/cve-report.test.js +9 -0
  45. package/test/dep-record.test.js +31 -0
  46. package/test/fixtures/csharp-config/packages.config +4 -0
  47. package/test/fixtures/csharp-csproj/Directory.Packages.props +5 -0
  48. package/test/fixtures/csharp-csproj/app.csproj +7 -0
  49. package/test/fixtures/csharp-lock/packages.lock.json +6 -0
  50. package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +2 -1
  51. package/test/fixtures/php-app/composer.json +5 -0
  52. package/test/fixtures/php-app/composer.lock +10 -0
  53. package/test/fixtures/polyglot/cs/packages.lock.json +1 -0
  54. package/test/fixtures/polyglot/js/package-lock.json +1 -0
  55. package/test/fixtures/polyglot/js/package.json +1 -0
  56. package/test/fixtures/polyglot/mvn/pom.xml +7 -0
  57. package/test/fixtures/polyglot/php/composer.lock +1 -0
  58. package/test/fixtures/polyglot/py/poetry.lock +3 -0
  59. package/test/fixtures/python-pipenv/Pipfile.lock +1 -0
  60. package/test/fixtures/python-poetry/poetry.lock +7 -0
  61. package/test/fixtures/python-reqs/requirements.txt +5 -0
  62. package/test/fixtures/python-uv/uv.lock +3 -0
  63. package/test/monorepo.test.js +1 -1
  64. package/test/npm-registry.test.js +1 -1
  65. package/test/npm.test.js +8 -4
  66. package/test/nuget.test.js +66 -0
  67. package/test/osv.test.js +62 -0
  68. package/test/pypi.test.js +72 -0
  69. package/test/webjar.test.js +1 -1
  70. /package/lib/{npm → codecs/npm}/parse.js +0 -0
  71. /package/lib/{npm → codecs/npm}/registry.js +0 -0
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