fad-checker 2.0.0 → 2.1.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 +65 -0
- package/README.md +147 -25
- package/completions/fad-checker.bash +1 -1
- package/completions/fad-checker.zsh +14 -1
- package/data/eol-mapping.json +5 -5
- package/data/license-policy.json +97 -0
- package/fad-checker-report/cve-report.doc +630 -0
- package/fad-checker-report/cve-report.html +748 -0
- package/fad-checker.js +556 -245
- package/lib/cache-archive.js +3 -0
- package/lib/codecs/codec.interface.js +3 -0
- package/lib/codecs/composer/parse.js +3 -0
- package/lib/codecs/composer/registry.js +13 -5
- package/lib/codecs/composer.codec.js +3 -0
- package/lib/codecs/go/parse.js +65 -0
- package/lib/codecs/go/registry.js +74 -0
- package/lib/codecs/go.codec.js +76 -0
- package/lib/codecs/index.js +7 -2
- package/lib/codecs/maven/jar-scan.js +199 -0
- package/lib/codecs/maven.codec.js +17 -1
- package/lib/codecs/npm/collect.js +37 -17
- package/lib/codecs/npm/parse.js +117 -10
- package/lib/codecs/npm/registry.js +14 -5
- package/lib/codecs/npm.codec.js +4 -1
- package/lib/codecs/nuget/parse.js +3 -0
- package/lib/codecs/nuget/registry.js +11 -4
- package/lib/codecs/nuget.codec.js +7 -2
- package/lib/codecs/pypi/parse.js +111 -14
- package/lib/codecs/pypi/registry.js +24 -5
- package/lib/codecs/pypi.codec.js +30 -6
- package/lib/codecs/recipes.js +28 -1
- package/lib/codecs/ruby/parse.js +42 -0
- package/lib/codecs/ruby/registry.js +76 -0
- package/lib/codecs/ruby.codec.js +66 -0
- package/lib/codecs/select.js +3 -0
- package/lib/codecs/yarn.codec.js +3 -0
- package/lib/config.js +3 -0
- package/lib/core.js +38 -1
- package/lib/cpe.js +30 -5
- package/lib/csaf-export.js +159 -0
- package/lib/cve-download.js +3 -0
- package/lib/cve-match.js +27 -6
- package/lib/cve-report.js +157 -28
- package/lib/dep-record.js +15 -2
- package/lib/deps-descriptor.js +113 -0
- package/lib/epss.js +115 -0
- package/lib/gate.js +45 -0
- package/lib/json-export.js +110 -0
- package/lib/kev.js +88 -0
- package/lib/license-policy.js +110 -0
- package/lib/maven-license.js +52 -0
- package/lib/maven-repo.js +3 -0
- package/lib/maven-version.js +8 -3
- package/lib/nvd.js +17 -6
- package/lib/osv.js +75 -23
- package/lib/outdated.js +10 -2
- package/lib/priority.js +90 -0
- package/lib/purl.js +77 -0
- package/lib/retire.js +80 -13
- package/lib/sarif-export.js +134 -0
- package/lib/sbom-export.js +153 -0
- package/lib/scan-completeness.js +3 -0
- package/lib/snyk.js +3 -0
- package/lib/suppress.js +113 -0
- package/lib/transitive.js +6 -3
- package/lib/ui.js +90 -0
- package/package.json +49 -2
- package/CLAUDE.md +0 -147
- package/docs/ARCHITECTURE.md +0 -182
- package/docs/USAGE.md +0 -187
- package/docs/superpowers/plans/2026-05-29-codec-composer.md +0 -556
- package/docs/superpowers/plans/2026-05-29-codec-foundation.md +0 -851
- package/docs/superpowers/plans/2026-05-29-codec-nuget.md +0 -432
- package/docs/superpowers/plans/2026-05-29-codec-pypi.md +0 -450
- package/docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md +0 -251
- package/test/cli-ecosystem.test.js +0 -30
- package/test/codec-edge-cases.test.js +0 -131
- package/test/codec-integration.test.js +0 -119
- package/test/codecs.test.js +0 -92
- package/test/composer.test.js +0 -71
- package/test/core.test.js +0 -153
- package/test/cpe.test.js +0 -166
- package/test/cve-download.test.js +0 -39
- package/test/cve-match.test.js +0 -217
- package/test/cve-report.test.js +0 -180
- package/test/dep-record.test.js +0 -31
- package/test/fixtures/complex-enterprise/api/pom.xml +0 -32
- package/test/fixtures/complex-enterprise/build/pom.xml +0 -46
- package/test/fixtures/complex-enterprise/dao/pom.xml +0 -37
- package/test/fixtures/complex-enterprise/pom.xml +0 -66
- package/test/fixtures/complex-enterprise/web/pom.xml +0 -77
- package/test/fixtures/csharp-config/packages.config +0 -4
- package/test/fixtures/csharp-csproj/Directory.Packages.props +0 -5
- package/test/fixtures/csharp-csproj/app.csproj +0 -7
- package/test/fixtures/csharp-lock/packages.lock.json +0 -6
- package/test/fixtures/cve-samples/cve-non-java.json +0 -19
- package/test/fixtures/cve-samples/cve-product-only.json +0 -31
- package/test/fixtures/cve-samples/cve-with-packagename.json +0 -37
- package/test/fixtures/cve-samples/nvd-log4shell.json +0 -40
- package/test/fixtures/cve-samples/nvd-npm-lodash.json +0 -22
- package/test/fixtures/monorepo-mixed/libs/common-bom/pom.xml +0 -26
- package/test/fixtures/monorepo-mixed/packages/cli/package.json +0 -14
- package/test/fixtures/monorepo-mixed/packages/cli/yarn.lock +0 -41
- package/test/fixtures/monorepo-mixed/packages/no-lock/package.json +0 -10
- package/test/fixtures/monorepo-mixed/packages/web-app/package-lock.json +0 -71
- package/test/fixtures/monorepo-mixed/packages/web-app/package.json +0 -17
- package/test/fixtures/monorepo-mixed/pom.xml +0 -29
- package/test/fixtures/monorepo-mixed/services/api/pom.xml +0 -27
- package/test/fixtures/monorepo-mixed/services/worker/pom.xml +0 -28
- package/test/fixtures/php-app/composer.json +0 -5
- package/test/fixtures/php-app/composer.lock +0 -10
- package/test/fixtures/polyglot/cs/packages.lock.json +0 -1
- package/test/fixtures/polyglot/js/package-lock.json +0 -1
- package/test/fixtures/polyglot/js/package.json +0 -1
- package/test/fixtures/polyglot/mvn/pom.xml +0 -7
- package/test/fixtures/polyglot/php/composer.lock +0 -1
- package/test/fixtures/polyglot/py/poetry.lock +0 -3
- package/test/fixtures/private-lib-detection/core/pom.xml +0 -26
- package/test/fixtures/private-lib-detection/plugin/pom.xml +0 -23
- package/test/fixtures/private-lib-detection/pom.xml +0 -35
- package/test/fixtures/python-pipenv/Pipfile.lock +0 -1
- package/test/fixtures/python-poetry/poetry.lock +0 -7
- package/test/fixtures/python-reqs/requirements.txt +0 -5
- package/test/fixtures/python-uv/uv.lock +0 -3
- package/test/fixtures/simple/app/pom.xml +0 -28
- package/test/fixtures/simple/lib/pom.xml +0 -18
- package/test/fixtures/simple/pom.xml +0 -24
- package/test/maven-repo.test.js +0 -111
- package/test/maven-version.test.js +0 -57
- package/test/monorepo.test.js +0 -132
- package/test/npm-registry.test.js +0 -64
- package/test/npm.test.js +0 -150
- package/test/nuget.test.js +0 -66
- package/test/osv.test.js +0 -62
- package/test/outdated.test.js +0 -101
- package/test/pypi.test.js +0 -72
- package/test/snyk.test.js +0 -64
- package/test/transitive.test.js +0 -305
- package/test/webjar.test.js +0 -33
|
@@ -1,450 +0,0 @@
|
|
|
1
|
-
# Plan C — Codec PyPI (Python) Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
|
4
|
-
|
|
5
|
-
**Goal:** Ajouter le codec `pypi` (Python) en parité complète — vuln (OSV PyPI), yanked/inactive (PyPI registry), outdated (PyPI), EOL (endoflife.date), recette — sur l'interface codec.
|
|
6
|
-
|
|
7
|
-
**Architecture:** `lib/python/parse.js` parse les 4 formats de lock (poetry.lock/uv.lock/pdm.lock via `smol-toml`, Pipfile.lock en JSON) + `requirements.txt` (pinned, fallback). `lib/python/registry.js` interroge PyPI JSON (latest + yanked + classifier inactif). `lib/codecs/pypi.codec.js` assemble. La boucle `codec.checkRegistry` (Plan B) et OSV `PyPI` (Plan A) sont déjà câblées — **aucune modif orchestrateur**. Noms normalisés PEP 503.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** Node.js, node --test, `smol-toml` (déjà installé).
|
|
10
|
-
|
|
11
|
-
---
|
|
12
|
-
|
|
13
|
-
## File Structure
|
|
14
|
-
|
|
15
|
-
| Fichier | Rôle | Action |
|
|
16
|
-
|---|---|---|
|
|
17
|
-
| `lib/python/parse.js` | pep503 + parsers poetry/pipfile/uv/pdm/requirements | Créer |
|
|
18
|
-
| `lib/python/registry.js` | PyPI JSON → latest + yanked + inactive | Créer |
|
|
19
|
-
| `lib/codecs/pypi.codec.js` | codec pypi | Créer |
|
|
20
|
-
| `lib/codecs/index.js` | enregistrer pypi | Modifier |
|
|
21
|
-
| `lib/codecs/recipes.js` | recette pypi | Modifier |
|
|
22
|
-
| `lib/outdated.js` | findEolProduct → branche pypi | Modifier |
|
|
23
|
-
| `data/eol-mapping.json` | `by_pypi_name` (django, numpy, fastapi…) | Modifier |
|
|
24
|
-
| `test/fixtures/python-*/` | un dossier par format | Créer |
|
|
25
|
-
| `test/pypi.test.js` | parsers + codec + PEP 503 + fallback | Créer |
|
|
26
|
-
| `CLAUDE.md`, `docs/ARCHITECTURE.md` | docs | Modifier |
|
|
27
|
-
|
|
28
|
-
**Invariant :** `npm test` reste vert à chaque tâche.
|
|
29
|
-
|
|
30
|
-
---
|
|
31
|
-
|
|
32
|
-
### Task 1: PEP 503 + parsers + fixtures
|
|
33
|
-
|
|
34
|
-
**Files:** Create `lib/python/parse.js`, fixtures, `test/pypi.test.js`.
|
|
35
|
-
|
|
36
|
-
- [ ] **Step 1: Fixtures** (un répertoire par format)
|
|
37
|
-
|
|
38
|
-
`test/fixtures/python-poetry/poetry.lock` :
|
|
39
|
-
```toml
|
|
40
|
-
[[package]]
|
|
41
|
-
name = "requests"
|
|
42
|
-
version = "2.31.0"
|
|
43
|
-
|
|
44
|
-
[[package]]
|
|
45
|
-
name = "Flask-SQLAlchemy"
|
|
46
|
-
version = "3.0.5"
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
`test/fixtures/python-pipenv/Pipfile.lock` :
|
|
50
|
-
```json
|
|
51
|
-
{ "default": { "django": { "version": "==4.2.0" } }, "develop": { "pytest": { "version": "==7.4.0" } } }
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
`test/fixtures/python-uv/uv.lock` :
|
|
55
|
-
```toml
|
|
56
|
-
[[package]]
|
|
57
|
-
name = "numpy"
|
|
58
|
-
version = "1.26.0"
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
`test/fixtures/python-reqs/requirements.txt` :
|
|
62
|
-
```
|
|
63
|
-
# comment
|
|
64
|
-
fastapi==0.103.0
|
|
65
|
-
flask>=2.0 # range → skipped
|
|
66
|
-
-e .
|
|
67
|
-
urllib3==2.0.4
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
- [ ] **Step 2: Écrire le test qui échoue**
|
|
71
|
-
|
|
72
|
-
```js
|
|
73
|
-
// test/pypi.test.js
|
|
74
|
-
const test = require("node:test");
|
|
75
|
-
const assert = require("node:assert");
|
|
76
|
-
const path = require("path");
|
|
77
|
-
const { pep503, parsePoetryLock, parsePipfileLock, parseUvLock, parseRequirementsTxt } = require("../lib/python/parse");
|
|
78
|
-
|
|
79
|
-
const F = n => path.join(__dirname, "fixtures", n);
|
|
80
|
-
|
|
81
|
-
test("pep503 normalizes names (lowercase, collapse separators to -)", () => {
|
|
82
|
-
assert.strictEqual(pep503("Flask-SQLAlchemy"), "flask-sqlalchemy");
|
|
83
|
-
assert.strictEqual(pep503("zope.interface"), "zope-interface");
|
|
84
|
-
assert.strictEqual(pep503("My__Pkg"), "my-pkg");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("parsePoetryLock returns PEP503 names + versions", () => {
|
|
88
|
-
const r = parsePoetryLock(F("python-poetry/poetry.lock"));
|
|
89
|
-
const m = Object.fromEntries(r.deps.map(d => [d.name, d.version]));
|
|
90
|
-
assert.strictEqual(m["requests"], "2.31.0");
|
|
91
|
-
assert.strictEqual(m["flask-sqlalchemy"], "3.0.5"); // normalized
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("parsePipfileLock splits default/develop, strips ==", () => {
|
|
95
|
-
const r = parsePipfileLock(F("python-pipenv/Pipfile.lock"));
|
|
96
|
-
const m = Object.fromEntries(r.deps.map(d => [d.name, d]));
|
|
97
|
-
assert.strictEqual(m["django"].version, "4.2.0");
|
|
98
|
-
assert.strictEqual(m["django"].scope, "prod");
|
|
99
|
-
assert.strictEqual(m["pytest"].scope, "dev");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("parseUvLock reads [[package]]", () => {
|
|
103
|
-
const r = parseUvLock(F("python-uv/uv.lock"));
|
|
104
|
-
assert.strictEqual(r.deps.find(d => d.name === "numpy").version, "1.26.0");
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("parseRequirementsTxt keeps == pins, skips ranges/flags/comments", () => {
|
|
108
|
-
const r = parseRequirementsTxt(F("python-reqs/requirements.txt"));
|
|
109
|
-
const names = r.deps.map(d => d.name).sort();
|
|
110
|
-
assert.deepStrictEqual(names, ["fastapi", "urllib3"]);
|
|
111
|
-
assert.strictEqual(r.skipped, 1); // flask>=2.0
|
|
112
|
-
});
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
- [ ] **Step 3: Lancer (échec attendu)**
|
|
116
|
-
|
|
117
|
-
Run: `node --test test/pypi.test.js`
|
|
118
|
-
Expected: FAIL — `Cannot find module '../lib/python/parse'`
|
|
119
|
-
|
|
120
|
-
- [ ] **Step 4: Implémenter**
|
|
121
|
-
|
|
122
|
-
```js
|
|
123
|
-
// lib/python/parse.js
|
|
124
|
-
const fs = require("fs");
|
|
125
|
-
const TOML = require("smol-toml");
|
|
126
|
-
|
|
127
|
-
// PEP 503: lowercase + collapse runs of -, _, . to a single -.
|
|
128
|
-
function pep503(name) { return String(name || "").toLowerCase().replace(/[-_.]+/g, "-"); }
|
|
129
|
-
|
|
130
|
-
function stripOp(v) { return String(v || "").replace(/^[=~!<>]+/, "").trim(); }
|
|
131
|
-
function isPinned(spec) { return /^==\s*\d[\w.\-+!]*$/.test(String(spec || "").trim()); }
|
|
132
|
-
function isConcrete(v) { return /^\d+(\.\d+)*([.\-+]\S+)?$/.test(String(v || "")); }
|
|
133
|
-
|
|
134
|
-
// poetry.lock / uv.lock / pdm.lock all use [[package]] name/version arrays.
|
|
135
|
-
function parseTomlPackages(filePath, type) {
|
|
136
|
-
const data = TOML.parse(fs.readFileSync(filePath, "utf8"));
|
|
137
|
-
const pkgs = Array.isArray(data.package) ? data.package : [];
|
|
138
|
-
const deps = [];
|
|
139
|
-
for (const p of pkgs) {
|
|
140
|
-
if (!p.name || !p.version) continue;
|
|
141
|
-
// pdm marks groups; poetry/uv don't reliably → default prod.
|
|
142
|
-
const groups = Array.isArray(p.groups) ? p.groups : null;
|
|
143
|
-
const isDev = groups ? groups.every(g => g === "dev") : false;
|
|
144
|
-
deps.push({ name: pep503(p.name), version: String(p.version), scope: isDev ? "dev" : "prod", isDev });
|
|
145
|
-
}
|
|
146
|
-
return { manifestPath: filePath, manifestType: type, deps };
|
|
147
|
-
}
|
|
148
|
-
const parsePoetryLock = f => parseTomlPackages(f, "poetry.lock");
|
|
149
|
-
const parseUvLock = f => parseTomlPackages(f, "uv.lock");
|
|
150
|
-
const parsePdmLock = f => parseTomlPackages(f, "pdm.lock");
|
|
151
|
-
|
|
152
|
-
function parsePipfileLock(filePath) {
|
|
153
|
-
const json = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
154
|
-
const deps = [];
|
|
155
|
-
const push = (obj, scope) => {
|
|
156
|
-
for (const [name, meta] of Object.entries(obj || {})) {
|
|
157
|
-
const v = stripOp(meta.version);
|
|
158
|
-
if (!v) continue;
|
|
159
|
-
deps.push({ name: pep503(name), version: v, scope, isDev: scope === "dev" });
|
|
160
|
-
}
|
|
161
|
-
};
|
|
162
|
-
push(json.default, "prod");
|
|
163
|
-
push(json.develop, "dev");
|
|
164
|
-
return { manifestPath: filePath, manifestType: "Pipfile.lock", deps };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function parseRequirementsTxt(filePath) {
|
|
168
|
-
const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
|
169
|
-
const deps = [];
|
|
170
|
-
let skipped = 0;
|
|
171
|
-
for (let raw of lines) {
|
|
172
|
-
const line = raw.replace(/#.*$/, "").trim();
|
|
173
|
-
if (!line) continue;
|
|
174
|
-
if (line.startsWith("-")) continue; // -e ., -r other.txt, --flags
|
|
175
|
-
const m = line.match(/^([A-Za-z0-9._-]+)\s*(\[[^\]]*\])?\s*(.*)$/);
|
|
176
|
-
if (!m) continue;
|
|
177
|
-
const name = pep503(m[1]);
|
|
178
|
-
const spec = m[3].split(";")[0].trim(); // drop env markers
|
|
179
|
-
if (isPinned(spec)) deps.push({ name, version: stripOp(spec), scope: "prod", isDev: false });
|
|
180
|
-
else skipped++;
|
|
181
|
-
}
|
|
182
|
-
return { manifestPath: filePath, manifestType: "requirements.txt", deps, skipped };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
module.exports = { pep503, isConcrete, parsePoetryLock, parseUvLock, parsePdmLock, parsePipfileLock, parseRequirementsTxt };
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
- [ ] **Step 5: Lancer (succès)** — `node --test test/pypi.test.js` → PASS (5).
|
|
189
|
-
- [ ] **Step 6: Commit** — `git add lib/python/parse.js test/pypi.test.js test/fixtures/python-* && git commit -m "pypi: PEP503 + poetry/pipfile/uv/pdm/requirements parsers"`
|
|
190
|
-
|
|
191
|
-
---
|
|
192
|
-
|
|
193
|
-
### Task 2: Registre PyPI (latest + yanked + inactive)
|
|
194
|
-
|
|
195
|
-
**Files:** Create `lib/python/registry.js`; add test.
|
|
196
|
-
|
|
197
|
-
- [ ] **Step 1: Test qui échoue**
|
|
198
|
-
|
|
199
|
-
```js
|
|
200
|
-
// ajout test/pypi.test.js
|
|
201
|
-
const { pypiToFindings } = require("../lib/python/registry");
|
|
202
|
-
test("pypiToFindings extracts latest, yanked-for-version, inactive classifier", () => {
|
|
203
|
-
const data = {
|
|
204
|
-
info: { version: "2.1.0", classifiers: ["Development Status :: 7 - Inactive"] },
|
|
205
|
-
releases: { "2.0.4": [{ yanked: true, yanked_reason: "security" }], "2.1.0": [{ yanked: false }] },
|
|
206
|
-
};
|
|
207
|
-
const f = pypiToFindings(data, { version: "2.0.4" });
|
|
208
|
-
assert.strictEqual(f.outdated.latest, "2.1.0");
|
|
209
|
-
assert.strictEqual(f.yanked.reason, "security");
|
|
210
|
-
assert.strictEqual(f.inactive, true);
|
|
211
|
-
const f2 = pypiToFindings(data, { version: "2.1.0" });
|
|
212
|
-
assert.strictEqual(f2.yanked, null); // current version not yanked
|
|
213
|
-
assert.strictEqual(f2.outdated, null); // already latest
|
|
214
|
-
});
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
- [ ] **Step 2: Lancer (échec attendu)** — `pypiToFindings is not a function`.
|
|
218
|
-
|
|
219
|
-
- [ ] **Step 3: Implémenter**
|
|
220
|
-
|
|
221
|
-
```js
|
|
222
|
-
// lib/python/registry.js
|
|
223
|
-
const fs = require("fs");
|
|
224
|
-
const path = require("path");
|
|
225
|
-
const os = require("os");
|
|
226
|
-
let pLimit; try { pLimit = require("p-limit"); } catch { pLimit = () => (fn) => fn(); }
|
|
227
|
-
|
|
228
|
-
const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
|
|
229
|
-
const CACHE_PATH = path.join(CACHE_DIR, "pypi-cache.json");
|
|
230
|
-
const CACHE_MAX_AGE_MS = 24 * 3600 * 1000;
|
|
231
|
-
const API = "https://pypi.org/pypi";
|
|
232
|
-
|
|
233
|
-
function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); } catch { return { entries: {}, meta: {} }; } }
|
|
234
|
-
function saveCache(d) { try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_PATH, JSON.stringify(d)); } catch { /* ignore */ } }
|
|
235
|
-
function cmp(a, b) {
|
|
236
|
-
const pa = String(a).split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
|
|
237
|
-
const pb = String(b).split(/[.\-+]/).map(n => parseInt(n, 10) || 0);
|
|
238
|
-
for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d > 0 ? 1 : -1; }
|
|
239
|
-
return 0;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function pypiToFindings(data, { version }) {
|
|
243
|
-
const out = { outdated: null, yanked: null, inactive: false };
|
|
244
|
-
const latest = data.info?.version;
|
|
245
|
-
if (latest && cmp(latest, version) > 0) out.outdated = { latest };
|
|
246
|
-
const rel = data.releases?.[version];
|
|
247
|
-
if (Array.isArray(rel) && rel.length && rel.every(f => f.yanked)) {
|
|
248
|
-
out.yanked = { reason: rel.find(f => f.yanked_reason)?.yanked_reason || null };
|
|
249
|
-
}
|
|
250
|
-
if ((data.info?.classifiers || []).some(c => /Development Status :: 7 - Inactive/i.test(c))) out.inactive = true;
|
|
251
|
-
return out;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
async function fetchProject(name, { offline }) {
|
|
255
|
-
if (offline) return null;
|
|
256
|
-
try {
|
|
257
|
-
const res = await fetch(`${API}/${name}/json`, { headers: { "User-Agent": "fad-checker-pypi" } });
|
|
258
|
-
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
259
|
-
return await res.json();
|
|
260
|
-
} catch (e) { return { error: e.message }; }
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
async function checkPypiRegistryDeps(deps, opts = {}) {
|
|
264
|
-
const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
|
|
265
|
-
const targets = [...deps.values()].filter(d => d.ecosystem === "pypi" && d.version);
|
|
266
|
-
const result = { deprecated: [], outdated: [] };
|
|
267
|
-
if (!targets.length) return result;
|
|
268
|
-
const cache = loadCache();
|
|
269
|
-
const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
|
|
270
|
-
if (!fresh && !offline) cache.entries = {};
|
|
271
|
-
const limit = pLimit(concurrency);
|
|
272
|
-
await Promise.all(targets.map(t => limit(async () => {
|
|
273
|
-
const key = `${t.name}@${t.version}`;
|
|
274
|
-
let ex = cache.entries[key];
|
|
275
|
-
if (!ex) {
|
|
276
|
-
const data = await fetchProject(t.name, { offline });
|
|
277
|
-
if (data && !data.error) { const f = pypiToFindings(data, { version: t.version }); ex = { yanked: f.yanked, inactive: f.inactive, latest: f.outdated?.latest || null }; cache.entries[key] = ex; }
|
|
278
|
-
else ex = { yanked: null, inactive: false, latest: null };
|
|
279
|
-
}
|
|
280
|
-
if (ex.yanked) result.deprecated.push({ dep: t, severity: "HIGH", replacement: null, reason: `Version yanked on PyPI${ex.yanked.reason ? `: ${ex.yanked.reason}` : ""}`, source: "pypi" });
|
|
281
|
-
else if (ex.inactive) result.deprecated.push({ dep: t, severity: "LOW", replacement: null, reason: "Marked 'Development Status :: 7 - Inactive' on PyPI", source: "pypi" });
|
|
282
|
-
if (allLibs && ex.latest) result.outdated.push({ dep: t, latest: ex.latest, releaseDate: null });
|
|
283
|
-
})));
|
|
284
|
-
cache.meta = { fetchedAt: Date.now() }; saveCache(cache);
|
|
285
|
-
return result;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
module.exports = { pypiToFindings, checkPypiRegistryDeps };
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
- [ ] **Step 4: Lancer (succès)** — PASS (6).
|
|
292
|
-
- [ ] **Step 5: Commit** — `git add lib/python/registry.js test/pypi.test.js && git commit -m "pypi: PyPI registry (latest + yanked + inactive)"`
|
|
293
|
-
|
|
294
|
-
---
|
|
295
|
-
|
|
296
|
-
### Task 3: Codec pypi + register + recipe + EOL
|
|
297
|
-
|
|
298
|
-
**Files:** Create `lib/codecs/pypi.codec.js`; modify `index.js`, `recipes.js`, `outdated.js`, `eol-mapping.json`, `codecs.test.js`.
|
|
299
|
-
|
|
300
|
-
- [ ] **Step 1: Test qui échoue**
|
|
301
|
-
|
|
302
|
-
```js
|
|
303
|
-
// ajout test/pypi.test.js
|
|
304
|
-
const pypi = require("../lib/codecs/pypi.codec");
|
|
305
|
-
const { assertCodecShape } = require("../lib/codecs/codec.interface");
|
|
306
|
-
test("pypi codec: shape, detect, collect, coordKey pypi:<name>", async () => {
|
|
307
|
-
assertCodecShape(pypi);
|
|
308
|
-
assert.strictEqual(pypi.detect(F("python-poetry")), true);
|
|
309
|
-
const { deps } = await pypi.collect(F("python-poetry"), {});
|
|
310
|
-
const r = deps.get("pypi:requests");
|
|
311
|
-
assert.ok(r);
|
|
312
|
-
assert.strictEqual(r.ecosystem, "pypi");
|
|
313
|
-
assert.strictEqual(pypi.osvPackageName(r), "requests");
|
|
314
|
-
});
|
|
315
|
-
test("pypi collect: requirements.txt fallback warns + scans pins only", async () => {
|
|
316
|
-
const { deps, warnings } = await pypi.collect(F("python-reqs"), {});
|
|
317
|
-
assert.ok(deps.has("pypi:fastapi"));
|
|
318
|
-
assert.ok(!deps.has("pypi:flask"));
|
|
319
|
-
assert.ok(warnings.find(w => w.type === "no-lockfile"));
|
|
320
|
-
});
|
|
321
|
-
```
|
|
322
|
-
Also update the registry-count assertion in `test/codecs.test.js`:
|
|
323
|
-
`assert.deepStrictEqual(ids, ["composer", "maven", "npm", "pypi", "yarn"]);`
|
|
324
|
-
|
|
325
|
-
- [ ] **Step 2: Lancer (échec attendu)**.
|
|
326
|
-
|
|
327
|
-
- [ ] **Step 3a: Codec**
|
|
328
|
-
|
|
329
|
-
```js
|
|
330
|
-
// lib/codecs/pypi.codec.js
|
|
331
|
-
const fs = require("fs");
|
|
332
|
-
const path = require("path");
|
|
333
|
-
const { makeDepRecord, coordKeyFor } = require("../dep-record");
|
|
334
|
-
const P = require("../python/parse");
|
|
335
|
-
|
|
336
|
-
const SKIP = new Set([".git", ".idea", ".vscode", "node_modules", "dist", "build", "out", "target", "__pycache__", ".venv", "venv", ".tox", ".mypy_cache"]);
|
|
337
|
-
// Lock precedence within a directory; if none, requirements.txt fallback.
|
|
338
|
-
const LOCKS = [
|
|
339
|
-
["poetry.lock", P.parsePoetryLock],
|
|
340
|
-
["Pipfile.lock", P.parsePipfileLock],
|
|
341
|
-
["uv.lock", P.parseUvLock],
|
|
342
|
-
["pdm.lock", P.parsePdmLock],
|
|
343
|
-
];
|
|
344
|
-
|
|
345
|
-
function findPyDirs(dir) {
|
|
346
|
-
const groups = [];
|
|
347
|
-
const stack = [dir];
|
|
348
|
-
while (stack.length) {
|
|
349
|
-
const cur = stack.pop();
|
|
350
|
-
let entries; try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
351
|
-
const names = new Set(entries.filter(e => e.isFile()).map(e => e.name));
|
|
352
|
-
if ([...LOCKS.map(l => l[0]), "requirements.txt"].some(n => names.has(n))) groups.push({ dir: cur, names });
|
|
353
|
-
for (const e of entries) if (e.isDirectory() && !SKIP.has(e.name)) stack.push(path.join(cur, e.name));
|
|
354
|
-
}
|
|
355
|
-
return groups;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
module.exports = {
|
|
359
|
-
id: "pypi",
|
|
360
|
-
label: "PyPI",
|
|
361
|
-
osvEcosystem: "PyPI",
|
|
362
|
-
manifestNames: ["poetry.lock", "Pipfile.lock", "uv.lock", "pdm.lock", "requirements.txt"],
|
|
363
|
-
|
|
364
|
-
detect(dir) { return findPyDirs(dir).length > 0; },
|
|
365
|
-
|
|
366
|
-
async collect(dir, opts = {}) {
|
|
367
|
-
const { ignoreTest, deps2Exclude } = opts;
|
|
368
|
-
const out = new Map();
|
|
369
|
-
const warnings = [];
|
|
370
|
-
const add = (d, manifestPath) => {
|
|
371
|
-
if (ignoreTest && d.isDev) return;
|
|
372
|
-
if (deps2Exclude && deps2Exclude.test(d.name)) return;
|
|
373
|
-
out.set(coordKeyFor("pypi", "", d.name), makeDepRecord({ ecosystem: "pypi", namespace: "", name: d.name, version: d.version, manifestPath, scope: d.scope, isDev: d.isDev }));
|
|
374
|
-
};
|
|
375
|
-
for (const g of findPyDirs(dir)) {
|
|
376
|
-
const lock = LOCKS.find(([n]) => g.names.has(n));
|
|
377
|
-
if (lock) {
|
|
378
|
-
const { deps } = lock[1](path.join(g.dir, lock[0]));
|
|
379
|
-
for (const d of deps) add(d, path.join(g.dir, lock[0]));
|
|
380
|
-
} else if (g.names.has("requirements.txt")) {
|
|
381
|
-
const fp = path.join(g.dir, "requirements.txt");
|
|
382
|
-
const { deps, skipped } = P.parseRequirementsTxt(fp);
|
|
383
|
-
for (const d of deps) add(d, fp);
|
|
384
|
-
warnings.push({ type: "no-lockfile", manifestPath: fp, message: `requirements.txt (no lockfile) — best-effort: ${deps.length} pinned, ${skipped} range(s) skipped` });
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return { deps: out, warnings };
|
|
388
|
-
},
|
|
389
|
-
|
|
390
|
-
coordKey(d) { return coordKeyFor("pypi", "", d.name); },
|
|
391
|
-
formatCoord(d) { return d.name; },
|
|
392
|
-
osvPackageName(d) { return d.name; },
|
|
393
|
-
|
|
394
|
-
async checkRegistry(deps, opts = {}) {
|
|
395
|
-
const { checkPypiRegistryDeps } = require("../python/registry");
|
|
396
|
-
return checkPypiRegistryDeps(deps, opts);
|
|
397
|
-
},
|
|
398
|
-
resolveEolProduct(d) { return require("../outdated").findEolProduct(d); },
|
|
399
|
-
recipe: require("./recipes").pypi,
|
|
400
|
-
nativeScanners: [],
|
|
401
|
-
};
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
- [ ] **Step 3b: Register** — `index.js`: `const pypi = require("./pypi.codec");` and add to `[maven, npm, yarn, composer, pypi]`.
|
|
405
|
-
|
|
406
|
-
- [ ] **Step 3c: Recipe** — `recipes.js`:
|
|
407
|
-
```js
|
|
408
|
-
function pipInstallSnippet(items) { return items.map(it => `pip install '${it.artifactId}>=${esc(it.fixVersion)}'`).join("\n"); }
|
|
409
|
-
const pypi = { label: "PyPI", pinSection: "A. Upgrade the affected packages", pinIntro: cnt => `Upgrade the ${cnt} affected package${cnt > 1 ? "s" : ""} and re-lock:`, snippet: pipInstallSnippet, directSection: "B. Then regenerate your lockfile (poetry lock / pip freeze)" };
|
|
410
|
-
module.exports = { …, pypi, pipInstallSnippet };
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
- [ ] **Step 3d: EOL** — `outdated.js` findEolProduct, add before maven block:
|
|
414
|
-
```js
|
|
415
|
-
if (dep.ecosystem === "pypi") return EOL_MAPPING.by_pypi_name?.[(dep.name || dep.artifactId || "").toLowerCase()] || null;
|
|
416
|
-
```
|
|
417
|
-
|
|
418
|
-
- [ ] **Step 3e: Mapping** — `eol-mapping.json` add:
|
|
419
|
-
```json
|
|
420
|
-
"by_pypi_name": {
|
|
421
|
-
"django": { "product": "django", "label": "Django" },
|
|
422
|
-
"numpy": { "product": "numpy", "label": "NumPy" },
|
|
423
|
-
"fastapi": { "product": "fastapi", "label": "FastAPI" }
|
|
424
|
-
}
|
|
425
|
-
```
|
|
426
|
-
|
|
427
|
-
- [ ] **Step 4: Lancer la suite complète** — `npm test` → PASS (tous + pypi).
|
|
428
|
-
- [ ] **Step 5: Smoke test** — `node fad-checker.js -s ./test/fixtures/python-poetry --offline` → détecte pypi, collecte requests + flask-sqlalchemy, report OK.
|
|
429
|
-
- [ ] **Step 6: Commit** — `git add -A && git commit -m "pypi: codec + registry wiring + EOL + recipe"`
|
|
430
|
-
|
|
431
|
-
---
|
|
432
|
-
|
|
433
|
-
### Task 4: Docs + dépendance
|
|
434
|
-
|
|
435
|
-
**Files:** `CLAUDE.md`, `docs/ARCHITECTURE.md` (+ `package.json` déjà mis à jour par `npm install smol-toml --save`).
|
|
436
|
-
|
|
437
|
-
- [ ] **Step 1** : module maps + liste écosystèmes (ajouter Python/PyPI) ; mentionner `smol-toml` (dépendance TOML) ; fixtures `python-*`.
|
|
438
|
-
- [ ] **Step 2** : `npm test` puis commit `git add CLAUDE.md docs/ARCHITECTURE.md package.json package-lock.json && git commit -m "Docs + smol-toml dep: PyPI (Python) codec"`.
|
|
439
|
-
|
|
440
|
-
---
|
|
441
|
-
|
|
442
|
-
## Self-Review (effectuée)
|
|
443
|
-
|
|
444
|
-
- Vuln pypi → OSV `PyPI` (Plan A) ✓ ; 4 formats lock + requirements fallback → Task 1 ✓ ;
|
|
445
|
-
yanked/inactive + outdated → PyPI Task 2 + boucle checkRegistry (Plan B, déjà câblée) ✓ ;
|
|
446
|
-
EOL → findEolProduct + mapping Task 3 ✓ ; recette → 3c ✓ ; report/CLI → automatiques via le
|
|
447
|
-
registre (Plan A) ✓.
|
|
448
|
-
- **PEP 503** appliqué à tous les noms (clé/OSV/registre) — `Flask-SQLAlchemy`→`flask-sqlalchemy`.
|
|
449
|
-
- **Aucune modif orchestrateur** : la boucle générique `codec.checkRegistry` (Plan B) couvre pypi.
|
|
450
|
-
- **Cohérence types** : checkRegistry → {deprecated[], outdated[]} ; deprecated {dep,severity,replacement,reason,source}.
|