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,251 @@
1
+ # Design — Codecs & support multi-écosystème (C#/.NET, PHP, Python)
2
+
3
+ **Date** : 2026-05-29
4
+ **Statut** : approuvé (brainstorming) — prêt pour le plan d'implémentation
5
+
6
+ ## Objectif
7
+
8
+ 1. Réorganiser tout le code spécifique à un écosystème (aujourd'hui Maven + npm/yarn,
9
+ éparpillé dans `lib/core.js`, `lib/npm/*`, `lib/outdated.js`, `lib/osv.js`,
10
+ `lib/cve-report.js`, `fad-checker.js`) derrière une **interface codec** uniforme.
11
+ 2. Ajouter trois écosystèmes en **parité complète** (vuln, EOL, obsolete, outdated,
12
+ recette de fix dans le report) : **C#/.NET (nuget)**, **PHP (composer)**, **Python (pypi)**.
13
+
14
+ ## Décisions cadrées (brainstorming)
15
+
16
+ - **Parité complète d'emblée** pour les 3 nouveaux écosystèmes.
17
+ - **Big-bang** : extraction complète maven/npm vers des codecs (pas de cohabitation
18
+ « ancien monde / nouveau monde »). Le big-bang **réorganise, il ne réécrit pas** la
19
+ logique métier éprouvée (résolution POM/BOM, walker Maven Central, parsers lockfiles).
20
+ - **depRecord généralisé** : schéma neutre `{ ecosystem, namespace, name, version,
21
+ versions[], coordKey, manifestPaths[], … }`.
22
+ - **Règle de résolution de manifeste uniforme** (changement de philosophie) :
23
+ lockfile présent → on le prend ; sinon **fallback** sur le manifeste lâche
24
+ (`composer.json` / `requirements.txt` / `*.csproj` / `package.json`) **avec warning**.
25
+ Versions épinglées scannées, ranges/floating skippées individuellement + warning.
26
+ - **TOML** : dépendance légère **`smol-toml`** (maintenue, zéro dép transitive, TOML 1.0).
27
+
28
+ ### ⚠️ Changement de contrat assumé (npm)
29
+
30
+ Comportement actuel : un `package.json` sans `package-lock.json`/`yarn.lock` est
31
+ **entièrement skippé** (warning chapitre 0). Nouveau comportement : il est **parsé en
32
+ best-effort** (versions épinglées) avec warning `no-lockfile — résultats partiels`.
33
+ Le test existant `no-lockfile` est adapté en conséquence (vérifie désormais le
34
+ fallback+warning, plus le skip total).
35
+
36
+ ## Architecture
37
+
38
+ ### L'interface codec
39
+
40
+ `lib/codecs/codec.interface.js` documente le contrat (objet, pas de classe imposée) :
41
+
42
+ ```js
43
+ {
44
+ id, // "maven" | "npm" | "yarn" | "nuget" | "composer" | "pypi"
45
+ label, // "Maven", "npm", "NuGet", "Composer", "PyPI"
46
+ osvEcosystem, // "Maven" | "npm" | "NuGet" | "Packagist" | "PyPI" | null
47
+
48
+ manifestNames, // fichiers revendiqués par ce codec
49
+ detect(dir) -> bool, // ce repo contient-il mes manifestes ?
50
+
51
+ collect(dir, opts) -> { deps: Map<coordKey, depRecord>, warnings: [] },
52
+
53
+ coordKey(dep), // clé Map normalisée
54
+ formatCoord(dep), // affichage report
55
+ osvPackageName(dep), // nom pour OSV
56
+
57
+ checkRegistry(deps, opts) -> { outdated:[], deprecated:[] },
58
+ resolveEolProduct(dep) -> { product, label } | null,
59
+
60
+ recipe, // { label, pinSection, pinIntro, snippet(deps), directSection }
61
+ nativeScanners, // scanners au-delà du tronc commun OSV/NVD
62
+ }
63
+ ```
64
+
65
+ ### Le depRecord généralisé
66
+
67
+ ```js
68
+ {
69
+ ecosystem, // "maven"|"npm"|"yarn"|"nuget"|"composer"|"pypi"
70
+ namespace, // groupId (maven) / scope @org (npm) / vendor (composer) / "" sinon
71
+ name, // artifactId / package name
72
+ version, // version représentative (la plus haute) pour affichage/EOL/outdated
73
+ versions[], // toutes les versions concrètes distinctes (CVE/OSV itèrent dessus)
74
+ coordKey, // clé de la Map, ex "maven:org.apache:log4j", "pypi:requests"
75
+ scope, isDev,
76
+ manifestPaths[], // remplace pomPaths/manifestPaths
77
+ // transitif : via, viaPaths, depth
78
+ // lock-specific : lockType, resolved, integrity
79
+ }
80
+ ```
81
+
82
+ `coordKey` ne collisionne jamais entre écosystèmes (préfixe `ecosystem:`).
83
+
84
+ ### Registre des codecs
85
+
86
+ `lib/codecs/index.js` :
87
+ - `getCodec(id)` → codec
88
+ - `allCodecs()` → tous les codecs enregistrés
89
+ - `detectCodecs(srcDir)` → codecs dont `detect()` est vrai
90
+
91
+ ### Services partagés (agnostiques — plus aucun `if ecosystem === …`)
92
+
93
+ - `lib/osv.js` — querybatch groupé par `codec.osvEcosystem`, nom via `codec.osvPackageName`.
94
+ - `lib/nvd.js` — enrichment par CVE-id, totalement agnostique. **Activé pour tous les
95
+ écosystèmes** : dès qu'OSV remonte un `CVE-xxxx`, NVD/CPE s'appliquent.
96
+ - `lib/cpe.js` — refinement / filtrage faux positifs sur les matches, agnostique.
97
+ - Fetch `endoflife.date` — partagé ; le codec ne fournit que `{ product }`.
98
+ - Cache (`~/.fad-checker/`) — partagé.
99
+
100
+ ### Scanners rattachés à un codec (`nativeScanners`)
101
+
102
+ - `maven` → CVE-index local cvelistV5 (`lib/cve-download.js` + tier matching `lib/cve-match.js`).
103
+ - `npm`/`yarn` → retire.js (`lib/retire.js`).
104
+ - `nuget` / `composer` / `pypi` → aucun (couverts par OSV + NVD).
105
+
106
+ ### Flux d'orchestration (`fad-checker.js`)
107
+
108
+ ```
109
+ 1. detectCodecs(src) filtré par --ecosystem
110
+ 2. resolved = Map() ; pour chaque codec actif : merge codec.collect(src,opts).deps
111
+ 3. expansion transitive : déléguée au codec (maven → walker Maven Central ; autres → lock)
112
+ 4. vuln :
113
+ a. OSV (tronc commun) groupé par codec.osvEcosystem
114
+ b. nativeScanners par codec (maven: CVE-index ; npm/yarn: retire.js)
115
+ c. NVD enrichment (agnostique, par CVE-id)
116
+ d. CPE refinement (agnostique)
117
+ 5. EOL / obsolete / outdated : codec.resolveEolProduct + codec.checkRegistry
118
+ 6. render : sections pilotées par les codecs présents
119
+ ```
120
+
121
+ ## Spécifications par codec
122
+
123
+ ### maven (extrait)
124
+
125
+ `collect()` enveloppe `lib/core.js` (parse POM, résolution parent, merge multi-profils,
126
+ `<dependencyManagement>`, imports `scope=import`/BOM) **conservés tels quels** + walker
127
+ `lib/transitive.js`. WebJars : `webjarToNpm()` reste **dans le codec maven** (un WebJar
128
+ est un artifact Maven émettant une coord npm ; le codec maven route ces deps vers le
129
+ chemin npm pour EOL/registre).
130
+
131
+ | Aspect | Valeur |
132
+ |---|---|
133
+ | coordKey | `maven:groupId:artifactId` |
134
+ | osvEcosystem | `Maven` |
135
+ | nativeScanners | CVE-index cvelistV5 |
136
+ | registre | Maven Central Solr |
137
+ | obsolete | `data/known-obsolete.json` (curated) |
138
+ | EOL | `by_group_artifact` / `by_group_prefix` |
139
+ | recette | `<dependencyManagement>` + maj des deps directes |
140
+
141
+ ### npm / yarn (extrait)
142
+
143
+ `collect()` enveloppe `lib/npm/parse.js` + `lib/npm/collect.js` (parsers
144
+ package-lock v1/2/3, yarn.lock v1) **conservés**. Deux codecs partageant `osvEcosystem=npm`
145
+ et le préfixe coordKey `npm:` ; `ecosystemType` distingue pour report/recette.
146
+
147
+ | Aspect | Valeur |
148
+ |---|---|
149
+ | coordKey | `npm:name` (ou `npm:@scope/name`) |
150
+ | osvEcosystem | `npm` |
151
+ | nativeScanners | retire.js |
152
+ | registre | npm registry (`deprecated` + `dist-tags.latest`) |
153
+ | EOL | `by_npm_name` / `by_npm_scope` |
154
+ | recette | `overrides` (npm) / `resolutions` (yarn) |
155
+
156
+ ### nuget (C#/.NET) — nouveau
157
+
158
+ | Aspect | Valeur |
159
+ |---|---|
160
+ | manifestes | `packages.lock.json` (lock, résolu+transitif), `*.csproj` (`<PackageReference>`, XML via xml2js), `packages.config` (XML legacy) |
161
+ | CPM | `Directory.Packages.props` lu comme **table de versions** ; `PackageReference` sans `Version` résolu contre `<PackageVersion>`, sinon warning `unresolved-versions` |
162
+ | fallback | `.csproj` épinglé scanné ; floating (`1.*`, `[1.0,2.0)`) skip+warning |
163
+ | coordKey | `nuget:<name-lower>` (NuGet case-insensitive ; casse d'origine gardée pour l'affichage) |
164
+ | osvEcosystem | `NuGet` |
165
+ | registre | `api.nuget.org` registration index → version stable max + `deprecation.reasons`/`alternatePackage` |
166
+ | EOL | endoflife.date `dotnet` (target framework `net6.0`/`net48`…), `aspnet`, `entity-framework` via `by_nuget_name` |
167
+ | recette | maj `<PackageReference>` + note CPM `Directory.Packages.props` |
168
+
169
+ ### composer (PHP) — nouveau
170
+
171
+ | Aspect | Valeur |
172
+ |---|---|
173
+ | manifestes | `composer.lock` (JSON, `packages[]` + `packages-dev[]`, concret+transitif) ; `composer.json` sans lock → fallback+warning |
174
+ | coordKey | `composer:vendor/package` (case-insensitive normalisé) |
175
+ | osvEcosystem | `Packagist` |
176
+ | registre | `repo.packagist.org/p2/{vendor}/{pkg}.json` → dernière version + champ **`abandoned`** (≈ deprecated, peut pointer un remplaçant) |
177
+ | EOL | endoflife.date `php`, `laravel`, `symfony`, `drupal` via `by_composer_name` |
178
+ | recette | `composer require vendor/pkg:^x` / bloc `composer.json` |
179
+
180
+ ### pypi (Python) — nouveau
181
+
182
+ | Aspect | Valeur |
183
+ |---|---|
184
+ | manifestes | `poetry.lock` (TOML), `Pipfile.lock` (JSON), `uv.lock` / `pdm.lock` (TOML), `requirements.txt` (épinglé `==` seul) |
185
+ | fallback | `requirements.txt` à ranges → skip+warning ; `pyproject.toml`/`Pipfile` sans lock → fallback+warning |
186
+ | normalisation | PEP 503 : `Foo.Bar_baz` → `foo-bar-baz` pour clé/OSV/registre ; casse d'origine pour affichage |
187
+ | coordKey | `pypi:<name-pep503>` |
188
+ | osvEcosystem | `PyPI` |
189
+ | registre | `pypi.org/pypi/{name}/json` → `info.version` (latest) + détection `yanked` par version + classifier `Development Status :: 7 - Inactive` comme signal obsolete |
190
+ | EOL | endoflife.date `python`, `django`, `numpy`, `fastapi` via `by_pypi_name` |
191
+ | recette | `pip install 'pkg>=x'` / ligne `requirements.txt` / bloc selon le lock détecté |
192
+
193
+ ## Données
194
+
195
+ - `data/eol-mapping.json` gagne `by_nuget_name`, `by_composer_name`, `by_pypi_name`.
196
+ Ce fichier ne contient **que l'association coord → nom de produit endoflife.date** ;
197
+ les dates de cycle/EOL sont **récupérées en ligne** (endoflife.date) et **mises en
198
+ cache 7 jours**. Le mapping peut donc démarrer **minimal** (dotnet/php/python + quelques
199
+ frameworks) et s'enrichir au fil de l'eau par simple édition JSON, sans toucher au code.
200
+ - `data/known-obsolete.json` reste Maven-only : les autres écosystèmes ont des champs
201
+ registre authoritatifs (`deprecation` NuGet, `abandoned` Packagist, `yanked` PyPI).
202
+
203
+ ## CLI
204
+
205
+ - `--ecosystem` : enum → **liste** : `auto` (défaut, = `detect()` vrai) | `all` | liste
206
+ explicite `maven,nuget,pypi,…`.
207
+ - `--no-<id>` génériques : `--no-maven --no-npm --no-yarn --no-nuget --no-composer --no-pypi`.
208
+ - `--no-js` conservé comme **alias** de `--no-npm`+`--no-yarn` (rétro-compat).
209
+ - `--transitive` / `--transitive-depth` : s'appliquent au codec maven uniquement
210
+ (no-op documenté ailleurs ; les autres ont le transitif via lock).
211
+ - `--snyk` : inchangé (POM nettoyé maven).
212
+
213
+ ## Report (`lib/cve-report.js`)
214
+
215
+ - Sections par écosystème (1.a, 1.b, …) **générées dynamiquement** selon les codecs
216
+ présents, ordre stable : `maven, npm, yarn, nuget, composer, pypi`.
217
+ - `RECIPE_SPECS` / `ECO_LABELS` / `ECO_MANIFEST_KIND` alimentés depuis `codec.label`,
218
+ `codec.recipe`, `codec.manifestNames` — fin des maps codées en dur.
219
+ - `formatCoord(dep)` délégué au codec — fin des `if (ecosystem === "npm")` dans le rendu.
220
+
221
+ ## Tests
222
+
223
+ Objectif : les 96 tests existants restent verts + nouveaux tests.
224
+
225
+ - Fixtures : `csharp-app/` (.csproj + packages.lock.json + packages.config +
226
+ Directory.Packages.props), `php-app/` (composer.json + composer.lock),
227
+ `python-app/` (un répertoire par format : requirements.txt épinglé, poetry.lock,
228
+ Pipfile.lock, uv.lock).
229
+ - Un fichier de test par codec (`nuget.test.js`, `composer.test.js`, `pypi.test.js`) :
230
+ parsing, coordKey, normalisation de noms (PEP 503, case-insensitive NuGet),
231
+ fallback no-lockfile.
232
+ - Test de contrat paramétré sur `allCodecs()` : chaque codec implémente toute l'interface.
233
+ - Fixture `polyglot/` mêlant les 5 écosystèmes (ou extension de `monorepo-mixed`).
234
+
235
+ ## Ordre d'exécution du big-bang (chaque étape laisse le pipeline fonctionnel)
236
+
237
+ 1. `codec.interface.js` + `lib/codecs/index.js` (registre vide).
238
+ 2. Généraliser le `depRecord` (`namespace`/`name`/`coordKey`/`manifestPaths`) — adapter
239
+ collecteurs + report + tests existants.
240
+ 3. Extraire `maven` et `npm`/`yarn` dans des codecs (déplacement, pas réécriture) ;
241
+ brancher l'orchestrateur sur le registre. **→ les 96 tests repassent ici.**
242
+ 4. Rendre `osv.js` / `nvd.js` / `cpe.js` / endoflife agnostiques (via codec).
243
+ 5. Ajouter `composer` (lock JSON, le plus simple), puis `pypi`, puis `nuget`.
244
+ 6. Report dynamique + CLI liste + fixtures + tests nouveaux.
245
+
246
+ ## Hors périmètre (YAGNI)
247
+
248
+ - Pas de résolution de ranges/floating (cohérent lockfile-first ; ranges → warning).
249
+ - Pas de support yarn-berry (déjà `unsupported` aujourd'hui), pas de pnpm dans ce lot.
250
+ - Pas de scanner natif dédié pour nuget/composer/pypi (OSV + NVD suffisent à la parité).
251
+ - Pas de curation obsolete pour les nouveaux écosystèmes (champs registre authoritatifs).
package/fad-checker.js CHANGED
@@ -187,8 +187,14 @@ program
187
187
  .option("--no-retire", "skip retire.js vendored-JS scan")
188
188
  .option("--retire-refresh", "ignore retire cache and re-scan")
189
189
  .option("--transitive-depth <n>", "max transitive depth", "6")
190
- .option("--ecosystem <eco>", "force ecosystem: auto|maven|npm|both (default: auto)", "auto")
191
- .option("--no-js", "skip JS/npm/yarn manifests even if present (Maven-only)")
190
+ .option("--ecosystem <list>", "codecs to run: auto|all|<comma list> e.g. maven,npm,nuget,composer,pypi (default: auto = detected)", "auto")
191
+ .option("--no-maven", "skip the Maven codec")
192
+ .option("--no-npm", "skip the npm codec")
193
+ .option("--no-yarn", "skip the Yarn codec")
194
+ .option("--no-nuget", "skip the NuGet (C#/.NET) codec")
195
+ .option("--no-composer", "skip the Composer (PHP) codec")
196
+ .option("--no-pypi", "skip the PyPI (Python) codec")
197
+ .option("--no-js", "alias: skip JS/npm/yarn manifests even if present (Maven-only)")
192
198
  .option("--repo <url...>", "extra Maven repository URL(s) to try before Maven Central. Supports https://user:pass@host/path/. Repeatable.")
193
199
  .option("--add-repo <name>", "persist a Maven repo: --add-repo <name> <url> [--auth user:pass]")
194
200
  .option("--remove-repo <name>", "remove a persisted Maven repo by name")
@@ -242,45 +248,53 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
242
248
  console.log(chalk.gray(`📦 Maven repos: ${mavenRepos.map(r => r.name).join(" → ")}`));
243
249
  }
244
250
 
245
- const pomFiles = core.findPomFiles(options.src);
246
- const allPomMetadata = core.newMetadataStore();
247
- const allPropsByPom = {};
248
251
  let wrotePom = 0;
249
252
 
250
- // Ecosystem detection. --ecosystem flag overrides auto-detect.
251
- const { hasJsManifests } = require("./lib/npm/collect");
253
+ // --- Codec detection + selection ---
254
+ const { detectCodecs, allCodecs, getCodec } = require("./lib/codecs");
255
+ const { resolveActiveCodecs } = require("./lib/codecs/select");
252
256
  const eco = (options.ecosystem || "auto").toLowerCase();
253
- const hasMaven = pomFiles.length > 0;
254
- const hasJs = !options.js ? false : (eco === "npm" || eco === "both" || (eco === "auto" && hasJsManifests(options.src)));
255
- const runMaven = (eco === "maven" || eco === "both" || (eco === "auto" && hasMaven));
256
- const runNpm = hasJs && eco !== "maven";
257
+ const detected = (eco === "auto") ? detectCodecs(options.src).map(c => c.id) : allCodecs().map(c => c.id);
258
+ const noCodecs = ["maven", "npm", "yarn", "nuget", "composer", "pypi"].filter(id => options[id] === false);
259
+ const activeIds = resolveActiveCodecs(eco, detected, { noCodecs, noJs: !options.js });
260
+ const runMaven = activeIds.includes("maven");
261
+ const runNpm = activeIds.includes("npm") || activeIds.includes("yarn");
262
+
263
+ // --- Collect deps from every active codec into one Map (coordKeys never collide) ---
264
+ const resolved = new Map();
265
+ let mavenCtx = null;
266
+ const collectWarnings = [];
267
+ for (const id of activeIds) {
268
+ if (id === "yarn") continue; // the npm codec already collects yarn.lock
269
+ const codec = getCodec(id);
270
+ let res;
271
+ try {
272
+ res = await codec.collect(options.src, { ignoreTest: !!options.ignoreTest, deps2Exclude, verbose });
273
+ } catch (err) {
274
+ console.warn(chalk.red(`❌ ${id} collect failed:`), chalk.dim(err.message));
275
+ continue;
276
+ }
277
+ for (const [k, v] of res.deps) resolved.set(k, v);
278
+ if (res.warnings?.length) collectWarnings.push(...res.warnings);
279
+ if (id === "maven") mavenCtx = res._maven;
280
+ }
257
281
 
258
282
  if (!readOnly) {
259
283
  try { await rimraf(options.target); } catch (_) { /* fresh dir */ }
260
284
  }
261
285
 
262
- if (runMaven) console.log(chalk.blue(`🔍 Found ${pomFiles.length} pom.xml files`));
286
+ if (runMaven && mavenCtx) console.log(chalk.blue(`🔍 Found ${mavenCtx.pomFiles.length} pom.xml files`));
263
287
  if (runNpm) console.log(chalk.blue(`🔍 JS manifests detected — npm/yarn pipeline enabled`));
264
288
  console.log();
265
289
 
266
- if (runMaven) {
267
- // Phase 1: parse every pom
268
- for (const pom of pomFiles) {
269
- try { await core.parsePom(pom, allPomMetadata); }
270
- catch (err) { console.warn(chalk.red("❌ parse failed:"), chalk.gray(pom), chalk.dim(err.message)); }
271
- }
272
-
273
- // Phase 2: resolve inheritance & merge profile deps
274
- for (const pom of Object.keys(allPomMetadata.byPath)) {
275
- try { await core.getAllInheritedProps(pom, allPomMetadata, allPropsByPom); }
276
- catch (err) { console.error(`❌ inheritance failed for ${pom}:`, err.message); }
277
- }
278
-
279
- // Phase 3: rewrite cleaned POMs (or simulate, in --test mode)
290
+ // Maven POM rewrite (cleanup feature). Parse + inheritance already happened
291
+ // inside the maven codec's collect(); we reuse its metadata store here.
292
+ if (runMaven && mavenCtx) {
293
+ const { store, propsByPom, pomFiles } = mavenCtx;
280
294
  const rewriteOpts = { srcRoot: options.src, targetRoot: options.target, deps2Exclude, verbose, readOnly };
281
295
  for (const pom of pomFiles) {
282
296
  try {
283
- if (await core.rewritePoms(pom, allPomMetadata, allPropsByPom, rewriteOpts)) wrotePom++;
297
+ if (await core.rewritePoms(pom, store, propsByPom, rewriteOpts)) wrotePom++;
284
298
  } catch (err) {
285
299
  console.error(chalk.red(`❌ rewrite failed for ${pom}:`), err.message);
286
300
  }
@@ -289,7 +303,8 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
289
303
 
290
304
  // ---------- Summary: parents missing / excluded ----------
291
305
  let privateLibIds = [];
292
- if (runMaven) {
306
+ if (runMaven && mavenCtx) {
307
+ const allPomMetadata = mavenCtx.store; // reuse the codec's parsed metadata
293
308
  console.log(chalk.cyanBright("\n─────────────────────────────────────────────"));
294
309
  console.log(chalk.cyanBright("📦 Résumé des POM analysés :"));
295
310
  console.log(chalk.cyanBright("─────────────────────────────────────────────\n"));
@@ -350,7 +365,7 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
350
365
 
351
366
  // ---------- Report flow (CVE / EOL / Obsolete) ----------
352
367
  if (options.report) {
353
- await runReportFlow(allPomMetadata, allPropsByPom, { runMaven, runNpm, privateLibIds, mavenRepos });
368
+ await runReportFlow(resolved, { activeIds, runMaven, runNpm, privateLibIds, mavenRepos, collectWarnings });
354
369
  } else if (!readOnly) {
355
370
  const target = options.target;
356
371
  console.log(chalk.gray(`💡 Pour lancer Snyk depuis ${target} :`));
@@ -358,45 +373,35 @@ async function checkMavenLibExist(groupId, artifactId, repos) {
358
373
  }
359
374
  })();
360
375
 
361
- async function runReportFlow(allPomMetadata, allPropsByPom, ecoFlags = {}) {
362
- const { runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [] } = ecoFlags;
363
- const { collectResolvedDeps, expandWithTransitives, matchDepsAgainstCves } = require("./lib/cve-match");
364
- const { ensureCveIndex } = require("./lib/cve-download");
376
+ async function runReportFlow(resolved, ecoFlags = {}) {
377
+ const { activeIds = [], runMaven = true, runNpm = false, privateLibIds = [], mavenRepos = [], collectWarnings = [] } = ecoFlags;
378
+ const { expandWithTransitives } = require("./lib/cve-match");
365
379
  const { writeReports, computeStats } = require("./lib/cve-report");
380
+ const { getCodec } = require("./lib/codecs");
366
381
  const outdated = require("./lib/outdated");
367
382
  const offline = !!options.offline;
368
383
  if (offline) console.log(chalk.gray(" (--offline: cached data only, no network)"));
369
384
 
370
385
  console.log(chalk.bold.cyan("\n📋 Rapport CVE / EOL / Obsolète\n") + chalk.gray("─────────────────────────────"));
371
386
 
372
- // Collect deps + parent POMs
373
- const resolved = runMaven
374
- ? collectResolvedDeps(allPomMetadata, allPropsByPom, {
375
- ignoreTest: !!options.ignoreTest,
376
- deps2Exclude,
377
- })
378
- : new Map();
379
- const mavenCount = resolved.size;
380
- if (runMaven) console.log(chalk.blue(`📚 ${mavenCount} dépendances Maven directes (incl. parent POMs)`));
381
-
382
- // Merge npm/yarn deps into the same Map (keys are "npm:name", no collision with "g:a")
383
- let npmWarnings = [];
387
+ // Deps were already collected per-codec by main(). Just report the counts.
388
+ const byEcoCount = {};
389
+ for (const d of resolved.values()) byEcoCount[d.ecosystem] = (byEcoCount[d.ecosystem] || 0) + 1;
390
+ if (runMaven) console.log(chalk.blue(`📚 ${byEcoCount.maven || 0} dépendances Maven directes (incl. parent POMs)`));
391
+ if (runNpm) console.log(chalk.blue(`📦 ${byEcoCount.npm || 0} dépendances npm/yarn`));
392
+ for (const [ecoId, n] of Object.entries(byEcoCount)) {
393
+ if (ecoId === "maven" || ecoId === "npm") continue;
394
+ const label = (require("./lib/codecs").getCodec(ecoId)?.label) || ecoId;
395
+ console.log(chalk.blue(`📦 ${n} dépendances ${label}`));
396
+ }
397
+
398
+ // Warnings surfaced during collection (e.g. npm no-lockfile fallback).
399
+ const npmWarnings = collectWarnings || [];
384
400
  let scanWarnings = [];
385
- if (runNpm) {
386
- const { collectNpmDeps } = require("./lib/npm/collect");
387
- const npmDeps = collectNpmDeps(options.src, {
388
- ignoreTest: !!options.ignoreTest,
389
- deps2Exclude,
390
- verbose,
391
- });
392
- for (const [k, v] of npmDeps) resolved.set(k, v);
393
- console.log(chalk.blue(`📦 ${npmDeps.size} dépendances npm/yarn ajoutées (total: ${resolved.size})`));
394
- npmWarnings = npmDeps.warnings || [];
395
- if (npmWarnings.length) {
396
- console.log(chalk.yellow(`⚠️ ${npmWarnings.length} JS manifest(s) sans lockfile — non scannés :`));
397
- for (const w of npmWarnings) {
398
- console.log(chalk.yellow(` • ${w.manifestPath} — ${w.message}`));
399
- }
401
+ if (npmWarnings.length) {
402
+ console.log(chalk.yellow(`⚠️ ${npmWarnings.length} manifest warning(s) :`));
403
+ for (const w of npmWarnings) {
404
+ console.log(chalk.yellow(` • ${w.manifestPath} — ${w.message}`));
400
405
  }
401
406
  }
402
407
  const directCount = resolved.size;
@@ -419,7 +424,7 @@ async function runReportFlow(allPomMetadata, allPropsByPom, ecoFlags = {}) {
419
424
  }
420
425
  }
421
426
 
422
- if (options.transitive) {
427
+ if (options.transitive && runMaven) {
423
428
  await expandWithTransitives(resolved, {
424
429
  verbose,
425
430
  offline,
@@ -430,20 +435,20 @@ async function runReportFlow(allPomMetadata, allPropsByPom, ecoFlags = {}) {
430
435
  console.log(chalk.blue(`🌳 ${resolved.size - directCount} dépendances transitives ajoutées (total: ${resolved.size})`));
431
436
  }
432
437
 
433
- // 1. CVE
438
+ // 1. CVE — native scanner contributed by the maven codec (local cvelistV5 index).
434
439
  let cveMatches = [];
435
440
  let cveDataDate = null;
436
- if (!(options.cveOffline || offline) || fs.existsSync(require("./lib/cve-download").CVE_INDEX_PATH)) {
437
- try {
438
- const idx = await ensureCveIndex({
439
- force: !!options.cveRefresh && !offline,
440
- offline: !!options.cveOffline || offline,
441
- verbose,
442
- });
443
- cveDataDate = idx?.meta?.builtAt || null;
444
- cveMatches = matchDepsAgainstCves(resolved, idx);
445
- } catch (err) {
446
- console.warn(chalk.yellow("⚠️ CVE scan skipped:"), err.message);
441
+ if (runMaven) {
442
+ const sc = (getCodec("maven").nativeScanners || []).find(s => s.kind === "cve");
443
+ const indexExists = fs.existsSync(require("./lib/cve-download").CVE_INDEX_PATH);
444
+ if (sc && (!(options.cveOffline || offline) || indexExists)) {
445
+ try {
446
+ const r = await sc.scan(resolved, { cveRefresh: !!options.cveRefresh, cveOffline: !!options.cveOffline, offline, verbose });
447
+ cveMatches = r.matches || [];
448
+ cveDataDate = r.meta?.cveDataDate || null;
449
+ } catch (err) {
450
+ console.warn(chalk.yellow("⚠️ CVE scan skipped:"), err.message);
451
+ }
447
452
  }
448
453
  }
449
454
 
@@ -469,12 +474,26 @@ async function runReportFlow(allPomMetadata, allPropsByPom, ecoFlags = {}) {
469
474
  // WebJars (Maven artifacts wrapping npm/bower libs), so it runs even in
470
475
  // Maven-only mode. One fetch per package; no-ops when there are no targets.
471
476
  try {
472
- const { checkNpmRegistryDeps } = require("./lib/npm/registry");
477
+ const { checkNpmRegistryDeps } = require("./lib/codecs/npm/registry");
473
478
  const npmReg = await checkNpmRegistryDeps(resolved, { verbose, offline, allLibs: options.allLibs });
474
479
  obsoleteResults = obsoleteResults.concat(npmReg.deprecated);
475
480
  outdatedResults = outdatedResults.concat(npmReg.outdated);
476
481
  } catch (err) { console.warn(chalk.yellow("⚠️ npm registry check skipped:"), err.message); }
477
482
 
483
+ // 4b. Per-codec registry for ecosystems beyond maven/npm (composer/pypi/nuget).
484
+ // maven (Maven Central) + npm (registry) are already covered above; this loop
485
+ // drives each remaining active codec's own registry (Packagist abandoned, etc.).
486
+ for (const id of activeIds) {
487
+ if (id === "maven" || id === "npm" || id === "yarn") continue;
488
+ const codec = getCodec(id);
489
+ if (!codec?.checkRegistry) continue;
490
+ try {
491
+ const reg = await codec.checkRegistry(resolved, { verbose, offline, allLibs: options.allLibs });
492
+ obsoleteResults = obsoleteResults.concat(reg.deprecated || []);
493
+ outdatedResults = outdatedResults.concat(reg.outdated || []);
494
+ } catch (err) { console.warn(chalk.yellow(`⚠️ ${id} registry check skipped:`), err.message); }
495
+ }
496
+
478
497
  // Cross-section dedup: drop entries from outdated that already appear in EOL/Obsolete
479
498
  const eolKeys = new Set(eolResults.map(r => `${r.dep.groupId}:${r.dep.artifactId}`));
480
499
  const obsKeys = new Set(obsoleteResults.map(r => `${r.dep.groupId}:${r.dep.artifactId}`));
@@ -519,16 +538,23 @@ async function runReportFlow(allPomMetadata, allPropsByPom, ecoFlags = {}) {
519
538
  }
520
539
  }
521
540
 
522
- // 5. retire.js — scans vendored JS files (jquery copies, bootstrap, pdf.js, …)
523
- // that live in the source tree without any lockfile to back them.
541
+ // 5. retire.js — native "vendored" scanner contributed by the npm codec. Scans
542
+ // vendored JS files (jquery copies, bootstrap, pdf.js, …) that live in the
543
+ // source tree without any lockfile to back them.
544
+ // Not gated by an active npm ecosystem: retire scans the source tree for
545
+ // vendored .js (which can live in a Maven project's resources too). The
546
+ // scanner is owned by the npm codec but runs whenever --retire is on.
524
547
  let retireMatches = [];
525
548
  if (options.retire) {
526
- try {
527
- const { scanWithRetire } = require("./lib/retire");
528
- retireMatches = await scanWithRetire(options.src, { verbose, force: !!options.retireRefresh, offline });
529
- console.log(chalk.blue(`🔎 retire.js: ${retireMatches.length} vendored-JS finding(s)`));
530
- } catch (err) {
531
- console.warn(chalk.yellow("⚠️ retire.js skipped:"), err.message);
549
+ const sc = (getCodec("npm").nativeScanners || []).find(s => s.kind === "vendored");
550
+ if (sc) {
551
+ try {
552
+ const r = await sc.scan(resolved, { src: options.src, verbose, retireRefresh: !!options.retireRefresh, offline });
553
+ retireMatches = r.matches || [];
554
+ console.log(chalk.blue(`🔎 retire.js: ${retireMatches.length} vendored-JS finding(s)`));
555
+ } catch (err) {
556
+ console.warn(chalk.yellow("⚠️ retire.js skipped:"), err.message);
557
+ }
532
558
  }
533
559
  }
534
560
 
@@ -0,0 +1,27 @@
1
+ /**
2
+ * lib/codecs/codec.interface.js — contrat que tout codec doit respecter.
3
+ *
4
+ * Pas de classe imposée : un codec est un objet litéral exportant ces clés.
5
+ * Voir docs/superpowers/specs/2026-05-29-codecs-multi-ecosystem-design.md.
6
+ */
7
+ const REQUIRED_KEYS = [
8
+ "id", "label", "osvEcosystem", "manifestNames",
9
+ "detect", "collect", "coordKey", "formatCoord", "osvPackageName",
10
+ "checkRegistry", "resolveEolProduct", "recipe", "nativeScanners",
11
+ ];
12
+ const FUNCTION_KEYS = ["detect", "collect", "coordKey", "formatCoord", "osvPackageName", "checkRegistry", "resolveEolProduct"];
13
+
14
+ function assertCodecShape(codec) {
15
+ if (!codec || typeof codec !== "object") throw new Error("codec must be an object");
16
+ for (const k of REQUIRED_KEYS) {
17
+ if (!(k in codec)) throw new Error(`codec "${codec.id || "?"}" missing required key: ${k}`);
18
+ }
19
+ for (const k of FUNCTION_KEYS) {
20
+ if (typeof codec[k] !== "function") throw new Error(`codec "${codec.id}" key ${k} must be a function`);
21
+ }
22
+ if (!Array.isArray(codec.manifestNames)) throw new Error(`codec "${codec.id}" manifestNames must be an array`);
23
+ if (!Array.isArray(codec.nativeScanners)) throw new Error(`codec "${codec.id}" nativeScanners must be an array`);
24
+ return true;
25
+ }
26
+
27
+ module.exports = { REQUIRED_KEYS, FUNCTION_KEYS, assertCodecShape };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * lib/composer/parse.js — parse PHP Composer manifests.
3
+ *
4
+ * composer.lock — { packages: [{name, version}], "packages-dev": [...] }
5
+ * versions are concrete (resolved) — the ideal source.
6
+ * composer.json — { require: {name: range}, "require-dev": {...} }
7
+ * used only as a no-lock fallback (pinned exact versions).
8
+ *
9
+ * Composer names are "vendor/package", case-insensitive (lowercased by convention).
10
+ */
11
+ const fs = require("fs");
12
+
13
+ // "1.2.3" | "v1.2.3" | "dev-main" | "1.0.x-dev". Strip a leading "v" for OSV/registry.
14
+ function normVersion(v) {
15
+ if (!v) return null;
16
+ return String(v).replace(/^v/, "");
17
+ }
18
+ function isConcrete(v) {
19
+ if (!v) return false;
20
+ return /^\d+(\.\d+)*([.\-+]\S+)?$/.test(String(v).replace(/^v/, ""));
21
+ }
22
+ function splitName(full) {
23
+ const i = full.indexOf("/");
24
+ if (i < 0) return { vendor: "", pkg: full };
25
+ return { vendor: full.slice(0, i), pkg: full.slice(i + 1) };
26
+ }
27
+
28
+ function parseComposerLock(filePath) {
29
+ const json = JSON.parse(fs.readFileSync(filePath, "utf8"));
30
+ const deps = [];
31
+ const push = (arr, scope) => {
32
+ for (const p of arr || []) {
33
+ if (!p.name) continue;
34
+ const { vendor, pkg } = splitName(p.name);
35
+ deps.push({ name: p.name, vendor, pkg, version: normVersion(p.version), scope, isDev: scope === "dev" });
36
+ }
37
+ };
38
+ push(json.packages, "prod");
39
+ push(json["packages-dev"], "dev");
40
+ return { manifestPath: filePath, manifestType: "composer.lock", deps };
41
+ }
42
+
43
+ function parseComposerJson(filePath) {
44
+ const json = JSON.parse(fs.readFileSync(filePath, "utf8"));
45
+ const deps = [];
46
+ const push = (obj, scope) => {
47
+ for (const [name, version] of Object.entries(obj || {})) {
48
+ // Platform requirements aren't packages.
49
+ if (name === "php" || name.startsWith("ext-") || name.startsWith("lib-")) continue;
50
+ const { vendor, pkg } = splitName(name);
51
+ deps.push({ name, vendor, pkg, version: String(version), scope, isDev: scope === "dev" });
52
+ }
53
+ };
54
+ push(json.require, "prod");
55
+ push(json["require-dev"], "dev");
56
+ return { manifestPath: filePath, manifestType: "composer.json", deps };
57
+ }
58
+
59
+ module.exports = { parseComposerLock, parseComposerJson, normVersion, isConcrete, splitName };