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.
- package/CHANGELOG.md +47 -0
- package/CLAUDE.md +28 -9
- package/README.md +27 -13
- package/completions/fad-checker.bash +4 -1
- package/completions/fad-checker.zsh +9 -0
- package/data/eol-mapping.json +17 -0
- package/docs/ARCHITECTURE.md +35 -7
- package/docs/USAGE.md +15 -7
- package/docs/superpowers/plans/2026-05-29-codec-composer.md +556 -0
- package/docs/superpowers/plans/2026-05-29-codec-foundation.md +851 -0
- package/docs/superpowers/plans/2026-05-29-codec-nuget.md +432 -0
- package/docs/superpowers/plans/2026-05-29-codec-pypi.md +450 -0
- package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +251 -0
- package/fad-checker.js +109 -83
- package/lib/codecs/codec.interface.js +27 -0
- package/lib/codecs/composer/parse.js +59 -0
- package/lib/codecs/composer/registry.js +92 -0
- package/lib/codecs/composer.codec.js +93 -0
- package/lib/codecs/index.js +37 -0
- package/lib/codecs/maven.codec.js +71 -0
- package/lib/{npm → codecs/npm}/collect.js +32 -18
- package/lib/codecs/npm.codec.js +52 -0
- package/lib/codecs/nuget/parse.js +75 -0
- package/lib/codecs/nuget/registry.js +88 -0
- package/lib/codecs/nuget.codec.js +92 -0
- package/lib/codecs/pypi/parse.js +71 -0
- package/lib/codecs/pypi/registry.js +89 -0
- package/lib/codecs/pypi.codec.js +81 -0
- package/lib/codecs/recipes.js +108 -0
- package/lib/codecs/select.js +27 -0
- package/lib/codecs/yarn.codec.js +19 -0
- package/lib/cve-match.js +30 -14
- package/lib/cve-report.js +130 -83
- package/lib/dep-record.js +60 -0
- package/lib/osv.js +33 -19
- package/lib/outdated.js +13 -2
- package/package.json +3 -2
- package/test/cli-ecosystem.test.js +30 -0
- package/test/codec-edge-cases.test.js +131 -0
- package/test/codec-integration.test.js +119 -0
- package/test/codecs.test.js +92 -0
- package/test/composer.test.js +71 -0
- package/test/cve-match.test.js +40 -0
- package/test/cve-report.test.js +9 -0
- package/test/dep-record.test.js +31 -0
- package/test/fixtures/csharp-config/packages.config +4 -0
- package/test/fixtures/csharp-csproj/Directory.Packages.props +5 -0
- package/test/fixtures/csharp-csproj/app.csproj +7 -0
- package/test/fixtures/csharp-lock/packages.lock.json +6 -0
- package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +2 -1
- package/test/fixtures/php-app/composer.json +5 -0
- package/test/fixtures/php-app/composer.lock +10 -0
- package/test/fixtures/polyglot/cs/packages.lock.json +1 -0
- package/test/fixtures/polyglot/js/package-lock.json +1 -0
- package/test/fixtures/polyglot/js/package.json +1 -0
- package/test/fixtures/polyglot/mvn/pom.xml +7 -0
- package/test/fixtures/polyglot/php/composer.lock +1 -0
- package/test/fixtures/polyglot/py/poetry.lock +3 -0
- package/test/fixtures/python-pipenv/Pipfile.lock +1 -0
- package/test/fixtures/python-poetry/poetry.lock +7 -0
- package/test/fixtures/python-reqs/requirements.txt +5 -0
- package/test/fixtures/python-uv/uv.lock +3 -0
- package/test/monorepo.test.js +1 -1
- package/test/npm-registry.test.js +1 -1
- package/test/npm.test.js +8 -4
- package/test/nuget.test.js +66 -0
- package/test/osv.test.js +62 -0
- package/test/pypi.test.js +72 -0
- package/test/webjar.test.js +1 -1
- /package/lib/{npm → codecs/npm}/parse.js +0 -0
- /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
|
|
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
|
-
|
|
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
|
+
});
|
package/test/osv.test.js
ADDED
|
@@ -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
|
+
});
|
package/test/webjar.test.js
CHANGED
|
@@ -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
|