forceupdate 0.1.0__py3-none-any.whl

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 (50) hide show
  1. forceupdate/__init__.py +9 -0
  2. forceupdate/__main__.py +5 -0
  3. forceupdate/advisories/__init__.py +30 -0
  4. forceupdate/advisories/cvss.py +95 -0
  5. forceupdate/advisories/osv.py +584 -0
  6. forceupdate/checks/__init__.py +49 -0
  7. forceupdate/checks/base.py +98 -0
  8. forceupdate/checks/deprecated.py +115 -0
  9. forceupdate/checks/outdated.py +183 -0
  10. forceupdate/checks/priority.py +82 -0
  11. forceupdate/checks/vulns.py +201 -0
  12. forceupdate/cli.py +309 -0
  13. forceupdate/detect/__init__.py +5 -0
  14. forceupdate/detect/languages.py +266 -0
  15. forceupdate/detect/manifests.py +478 -0
  16. forceupdate/engine.py +313 -0
  17. forceupdate/models.py +510 -0
  18. forceupdate/net/__init__.py +20 -0
  19. forceupdate/net/cache.py +217 -0
  20. forceupdate/net/http.py +270 -0
  21. forceupdate/parsers/__init__.py +43 -0
  22. forceupdate/parsers/base.py +22 -0
  23. forceupdate/parsers/json_spans.py +123 -0
  24. forceupdate/parsers/lockfiles.py +231 -0
  25. forceupdate/parsers/npm.py +1257 -0
  26. forceupdate/parsers/python.py +1421 -0
  27. forceupdate/parsers/toml_spans.py +297 -0
  28. forceupdate/registries/__init__.py +52 -0
  29. forceupdate/registries/base.py +294 -0
  30. forceupdate/registries/npm.py +153 -0
  31. forceupdate/registries/pypi.py +242 -0
  32. forceupdate/report/__init__.py +359 -0
  33. forceupdate/report/json.py +34 -0
  34. forceupdate/report/markdown.py +169 -0
  35. forceupdate/report/terminal.py +376 -0
  36. forceupdate/sources/__init__.py +1 -0
  37. forceupdate/sources/base.py +45 -0
  38. forceupdate/sources/local.py +183 -0
  39. forceupdate/update/__init__.py +8 -0
  40. forceupdate/update/constraints.py +317 -0
  41. forceupdate/update/planner.py +531 -0
  42. forceupdate/versions/__init__.py +40 -0
  43. forceupdate/versions/base.py +229 -0
  44. forceupdate/versions/npm.py +432 -0
  45. forceupdate/versions/pypi.py +220 -0
  46. forceupdate-0.1.0.dist-info/METADATA +79 -0
  47. forceupdate-0.1.0.dist-info/RECORD +50 -0
  48. forceupdate-0.1.0.dist-info/WHEEL +4 -0
  49. forceupdate-0.1.0.dist-info/entry_points.txt +2 -0
  50. forceupdate-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1257 @@
1
+ """Les parseurs npm : ``package.json``, les lockfiles de npm, Yarn et pnpm, et le lien
2
+ entre les deux.
3
+
4
+ Manifeste lu : ``package.json``, ses ``dependencies`` (``runtime``),
5
+ ``devDependencies`` (``dev``), ``peerDependencies`` (``peer``) et
6
+ ``optionalDependencies`` (``optional``).
7
+
8
+ Lockfiles lus :
9
+
10
+ - ``package-lock.json`` et ``npm-shrinkwrap.json``, formats 1 (npm 5 et 6), 2 et 3 ;
11
+ - ``yarn.lock`` de Yarn 1, et celui de Yarn 2 et plus (YAML) ;
12
+ - ``pnpm-lock.yaml`` des formats 5.4 (pnpm 7), 6.0 (pnpm 8) et 9.0 (pnpm 9 et 10).
13
+
14
+ Pour un projet (``parse_project``) :
15
+
16
+ - les dépendances directes sont celles du ``package.json``, avec leur contrainte
17
+ telle qu'écrite (``^1.2``, ``>=1 <2``, ``1.x || 2.x`` : E05 en tirera la borne
18
+ basse) et son emplacement exact ;
19
+ - leur version installée est celle que le lockfile donne au projet : le
20
+ ``node_modules/x`` que Node trouverait depuis son dossier (npm), son descripteur
21
+ (Yarn), son importeur (pnpm) ;
22
+ - le reste du lockfile donne les transitives, avec leur scope tiré du graphe. Une
23
+ autre version d'une directe (``node_modules/a/node_modules/x``) est une transitive.
24
+
25
+ Une dépendance ``catalog:`` (pnpm) garde sa contrainte telle qu'écrite ; la plage du
26
+ catalogue, lue dans ``pnpm-workspace.yaml``, va dans ``resolved_constraint``.
27
+
28
+ Un alias (``"mon-ms": "npm:ms@2.0.0"``) est vérifié sous le vrai nom du paquet
29
+ (``ms``), et sa contrainte est la partie après le ``@``. Une dépendance git, locale
30
+ (``file:``, ``link:``…) ou membre du workspace (``workspace:``) est « non
31
+ vérifiable », avec la raison.
32
+
33
+ Le lockfile d'un workspace, rattaché par E02 au plus proche dossier parent, n'est
34
+ gardé que si le projet en est membre : le lockfile le décrit, ou il correspond aux
35
+ ``workspaces`` du ``package.json`` racine ou aux ``packages`` du
36
+ ``pnpm-workspace.yaml``.
37
+
38
+ Repris de ``reference/argos_sca_parsers.py`` (L405-416, L648-797), puis adapté :
39
+ ``Dependency`` au lieu de ``SCAComponent``, contraintes gardées telles qu'écrites au
40
+ lieu de ``_strip_npm_version``, emplacements, Yarn 2+ et pnpm 6 et 9 lus avec
41
+ ``pyyaml`` au lieu d'une regex, lockfiles reliés aux manifestes, et un fichier
42
+ invalide signalé (``ParseError``) au lieu d'une liste vide.
43
+ """
44
+
45
+ import dataclasses
46
+ import functools
47
+ import json
48
+ import logging
49
+ import posixpath
50
+ import re
51
+ from collections.abc import Callable, Iterable, Mapping, Sequence
52
+ from dataclasses import dataclass, field
53
+ from typing import Any
54
+
55
+ import yaml
56
+
57
+ from forceupdate.detect.manifests import match_dependency_file
58
+ from forceupdate.models import Dependency, Ecosystem, Notice, Override, Project, Scope
59
+ from forceupdate.parsers.base import (
60
+ REASON_GIT,
61
+ REASON_LOCAL,
62
+ REASON_URL,
63
+ REASON_WORKSPACE,
64
+ ParseError,
65
+ ReadText,
66
+ )
67
+ from forceupdate.parsers.json_spans import BOM, JsonPath, string_spans
68
+ from forceupdate.parsers.lockfiles import (
69
+ Declared,
70
+ Edge,
71
+ LockedPackage,
72
+ Lockfile,
73
+ Start,
74
+ dirname,
75
+ in_workspace,
76
+ join,
77
+ relative_to,
78
+ transitives,
79
+ )
80
+ from forceupdate.versions.npm import NPM
81
+
82
+ logger = logging.getLogger(__name__)
83
+
84
+ REASON_PATCH = "modifiée par un correctif local (patch:)"
85
+
86
+ _SECTIONS = (
87
+ ("dependencies", Scope.RUNTIME),
88
+ ("devDependencies", Scope.DEV),
89
+ ("peerDependencies", Scope.PEER),
90
+ ("optionalDependencies", Scope.OPTIONAL),
91
+ )
92
+
93
+ _RANGE_STARTS = frozenset("0123456789^~<>=*")
94
+ """Premiers caractères d'une version ou d'une plage, jamais d'un nom de paquet courant."""
95
+
96
+ _PACKAGE_NAME = re.compile(r"(?:@[^/@\s]+/)?[^/@\s]+")
97
+
98
+ _GIT_PREFIXES = ("git+", "git:", "git@", "github:", "gitlab:", "bitbucket:", "gist:")
99
+ _LOCAL_PREFIXES = ("file:", "link:", "portal:", "exec:")
100
+
101
+ _YAML_LOADER = getattr(yaml, "CBaseLoader", yaml.BaseLoader)
102
+ """Chargeur YAML qui garde tous les scalaires en chaînes : ``1.10`` reste ``"1.10"``, et
103
+ rien d'autre que des chaînes, listes et dictionnaires n'est construit. Celui de libyaml
104
+ s'il est là, bien plus rapide sur un gros lockfile."""
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Contraintes
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class _Spec:
114
+ """Ce que dit la valeur d'une dépendance dans un ``package.json``."""
115
+
116
+ name: str
117
+ """Le paquet à vérifier : la clé, ou le vrai paquet d'un alias ``npm:``."""
118
+ constraint: str
119
+ span: tuple[int, int] | None
120
+ """Position de la contrainte dans la valeur ; ``None`` si on ne sait pas la réécrire."""
121
+ reason: str | None = None
122
+
123
+
124
+ def _split_spec(key: str, value: str) -> _Spec:
125
+ """Découpe la valeur d'une dépendance : plage, alias, git, chemin local…
126
+
127
+ La contrainte est gardée telle qu'écrite, sans les espaces autour. Celle d'une
128
+ dépendance non vérifiable est vide, et sans emplacement : une URL peut contenir
129
+ un token.
130
+ """
131
+ start = len(value) - len(value.lstrip())
132
+ end = len(value.rstrip())
133
+ text = value[start:end]
134
+ lowered = text.lower()
135
+ if lowered.startswith("npm:"):
136
+ return _split_alias(key, value, start + len("npm:"), end)
137
+ if lowered.startswith("catalog:"):
138
+ # Catalogue pnpm : la vraie plage est dans pnpm-workspace.yaml.
139
+ return _Spec(key, text, None)
140
+ reason = _spec_reason(text)
141
+ if reason is not None:
142
+ return _Spec(key, "", None, reason)
143
+ return _Spec(key, text, (start, end))
144
+
145
+
146
+ def _split_alias(key: str, value: str, start: int, end: int) -> _Spec:
147
+ """``npm:autre@^1.0`` : le paquet ``autre``, contrainte ``^1.0``. ``npm:^1.0`` (Yarn 2+)
148
+ désigne le paquet de la clé."""
149
+ rest = value[start:end]
150
+ if not rest or rest[0] in _RANGE_STARTS:
151
+ return _Spec(key, rest, (start, end))
152
+ match = _PACKAGE_NAME.match(rest)
153
+ if match is None:
154
+ return _Spec(key, rest, (start, end))
155
+ at = start + match.end()
156
+ if at < end and value[at] == "@":
157
+ return _Spec(match.group(), value[at + 1 : end], (at + 1, end))
158
+ # Alias sans version : il faudrait ajouter « @ » avec la contrainte.
159
+ return _Spec(match.group(), "", None)
160
+
161
+
162
+ def _spec_reason(spec: str) -> str | None:
163
+ """Pourquoi une dépendance n'est pas vérifiable, d'après sa valeur ; ``None`` si elle
164
+ vient du registre. La raison ne cite jamais l'URL."""
165
+ lowered = spec.lower()
166
+ if lowered.startswith("workspace:"):
167
+ return REASON_WORKSPACE
168
+ if lowered.startswith("patch:"):
169
+ return REASON_PATCH
170
+ if (
171
+ lowered.startswith(_LOCAL_PREFIXES)
172
+ or lowered.startswith((".", "/", "~/", "\\"))
173
+ or re.match(r"[a-z]:[\\/]", lowered)
174
+ ):
175
+ return REASON_LOCAL
176
+ if lowered.startswith(_GIT_PREFIXES):
177
+ return REASON_GIT
178
+ if "://" in lowered:
179
+ return REASON_GIT if lowered.split("#", 1)[0].endswith(".git") else REASON_URL
180
+ if "/" in lowered:
181
+ return REASON_GIT # raccourci GitHub : « utilisateur/depot#branche »
182
+ return None
183
+
184
+
185
+ def _catalog_name(constraint: str) -> str | None:
186
+ """Le catalogue pnpm que désigne une contrainte (``catalog:`` → ``default``,
187
+ ``catalog:react17`` → ``react17``) ; ``None`` si ce n'en est pas une."""
188
+ if not constraint.lower().startswith("catalog:"):
189
+ return None
190
+ return constraint[len("catalog:") :].strip() or "default"
191
+
192
+
193
+ def _real_name(key: str, spec: Any) -> str:
194
+ """Le paquet derrière une dépendance d'un lockfile : la clé, sauf pour un alias."""
195
+ return _split_spec(key, spec).name if isinstance(spec, str) else key
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # package.json
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class _Entry:
205
+ """Une dépendance d'un ``package.json``."""
206
+
207
+ declared: Declared
208
+ key: str
209
+ """Son nom sous ``node_modules`` : la clé du ``package.json`` (l'alias, s'il y en a un)."""
210
+ spec: str
211
+ """Sa valeur telle qu'écrite : Yarn 1 la reprend telle quelle dans ses descripteurs."""
212
+
213
+
214
+ @dataclass
215
+ class _PackageJson:
216
+ """Un ``package.json`` lu."""
217
+
218
+ name: str | None
219
+ entries: list[_Entry]
220
+ workspaces: list[str]
221
+ """Motifs des membres du workspace (``packages/*``), s'il y en a."""
222
+ package_manager: str | None
223
+ """L'outil déclaré dans ``packageManager`` (``npm``, ``yarn``, ``pnpm``)."""
224
+ overrides: list[Override] = field(default_factory=list)
225
+
226
+
227
+ def parse_package_json(path: str, content: str) -> list[Dependency]:
228
+ """Les dépendances d'un ``package.json`` : ``dependencies``, ``devDependencies``,
229
+ ``peerDependencies`` puis ``optionalDependencies``.
230
+
231
+ Lève ``ParseError`` si le JSON est invalide.
232
+ """
233
+ return [entry.declared.dependency for entry in _parse_package_json(path, content).entries]
234
+
235
+
236
+ def _load_json(content: str) -> dict[str, Any]:
237
+ try:
238
+ data = json.loads(content.removeprefix(BOM))
239
+ except ValueError as err:
240
+ raise ParseError(f"JSON invalide ({err}).") from err
241
+ if not isinstance(data, dict):
242
+ raise ParseError("JSON inattendu : un objet est attendu.")
243
+ return data
244
+
245
+
246
+ def _parse_package_json(path: str, content: str) -> _PackageJson:
247
+ data = _load_json(content)
248
+ spans = string_spans(content)
249
+ entries = []
250
+ for section, scope in _SECTIONS:
251
+ values = data.get(section)
252
+ if not isinstance(values, dict):
253
+ continue
254
+ for key, value in values.items():
255
+ if not isinstance(value, str):
256
+ continue
257
+ spec = _split_spec(key, value)
258
+ span = spans.get((section, key))
259
+ location = None
260
+ if span is not None and spec.span is not None:
261
+ location = span.location(*spec.span)
262
+ dependency = Dependency(
263
+ ecosystem=Ecosystem.NPM,
264
+ name=spec.name,
265
+ constraint=spec.constraint,
266
+ scope=scope,
267
+ file=path,
268
+ location=location,
269
+ unverifiable_reason=spec.reason,
270
+ )
271
+ entries.append(_Entry(Declared(dependency), key, value))
272
+ workspaces = data.get("workspaces")
273
+ if isinstance(workspaces, dict): # Yarn 1 : { "packages": [...], "nohoist": [...] }
274
+ workspaces = workspaces.get("packages")
275
+ manager = data.get("packageManager")
276
+ name = data.get("name")
277
+ return _PackageJson(
278
+ name=name if isinstance(name, str) else None,
279
+ entries=entries,
280
+ workspaces=_strings(workspaces),
281
+ package_manager=manager.split("@", 1)[0] if isinstance(manager, str) else None,
282
+ overrides=_overrides(path, data, spans),
283
+ )
284
+
285
+
286
+ def parse_overrides(path: str, content: str) -> list[Override]:
287
+ """Les versions forcées d'un ``package.json`` : ``overrides`` (npm), ``resolutions``
288
+ (Yarn), ``pnpm.overrides``, dans cet ordre.
289
+
290
+ Lève ``ParseError`` si le JSON est invalide.
291
+ """
292
+ return _parse_package_json(path, content).overrides
293
+
294
+
295
+ def _overrides(path: str, data: Mapping[str, Any], spans: Mapping[JsonPath, Any]) -> list[Override]:
296
+ """Les versions forcées, avec leur emplacement.
297
+
298
+ - npm : des clés imbriquées (``{"a": {"b": "1.0"}}`` force ``b`` sous ``a``), où ``"."``
299
+ force le paquet lui-même ; une clé peut porter une version (``a@2``) ;
300
+ - Yarn : un chemin (``**/lodash``, ``a/b``, ``@scope/a/@scope/b``) dont le dernier
301
+ paquet est forcé ;
302
+ - pnpm : ``nom``, ``nom@<2``, ou ``parent>nom``.
303
+ """
304
+ found: list[Override] = []
305
+
306
+ def add(name: str | None, key: str, value: Any, section: str, json_path: JsonPath) -> None:
307
+ if name is None or not isinstance(value, str):
308
+ return
309
+ spec = _split_spec(name, value)
310
+ span = spans.get(json_path)
311
+ location = None
312
+ if span is not None and spec.span is not None:
313
+ location = span.location(*spec.span)
314
+ found.append(
315
+ Override(
316
+ ecosystem=Ecosystem.NPM,
317
+ name=name,
318
+ constraint=spec.constraint,
319
+ key=key,
320
+ section=section,
321
+ file=path,
322
+ location=location,
323
+ )
324
+ )
325
+
326
+ def walk(values: Mapping[str, Any], json_path: JsonPath, parents: tuple[str, ...]) -> None:
327
+ for key, value in values.items():
328
+ if key == ".":
329
+ continue # la version du parent, prise avec lui
330
+ name = _selector_name(key)
331
+ chain = ">".join((*parents, key))
332
+ if isinstance(value, dict):
333
+ add(name, chain, value.get("."), "overrides", (*json_path, key, "."))
334
+ walk(value, (*json_path, key), (*parents, key))
335
+ else:
336
+ add(name, chain, value, "overrides", (*json_path, key))
337
+
338
+ npm = data.get("overrides")
339
+ if isinstance(npm, dict):
340
+ walk(npm, ("overrides",), ())
341
+ for key, value in _mapping(data.get("resolutions")).items():
342
+ add(_resolution_name(key), key, value, "resolutions", ("resolutions", key))
343
+ for key, value in _mapping(_mapping(data.get("pnpm")).get("overrides")).items():
344
+ name = _selector_name(key.rsplit(">", 1)[-1])
345
+ add(name, key, value, "pnpm.overrides", ("pnpm", "overrides", key))
346
+ return found
347
+
348
+
349
+ def _selector_name(key: str) -> str | None:
350
+ """Le paquet d'une clé de version forcée, sans sa version : ``a@2`` → ``a``,
351
+ ``@scope/a@<2`` → ``@scope/a`` ; ``None`` si ce n'est pas un nom de paquet."""
352
+ match = _PACKAGE_NAME.match(key)
353
+ if match is None or (match.end() < len(key) and key[match.end()] != "@"):
354
+ return None
355
+ return match.group()
356
+
357
+
358
+ def _resolution_name(key: str) -> str | None:
359
+ """Le paquet forcé par une clé de ``resolutions`` (Yarn) : le dernier du chemin
360
+ (``**/a/b`` → ``b``, ``a/@scope/b`` → ``@scope/b``), sans sa version."""
361
+ parts = key.split("/")
362
+ last = parts[-1]
363
+ if len(parts) >= 2 and parts[-2].startswith("@"):
364
+ last = f"{parts[-2]}/{last}"
365
+ return None if last in ("", "*", "**") else _selector_name(last)
366
+
367
+
368
+ def _strings(value: Any) -> list[str]:
369
+ return [item for item in value if isinstance(item, str)] if isinstance(value, list) else []
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Lockfiles
374
+ # ---------------------------------------------------------------------------
375
+
376
+
377
+ @dataclass(frozen=True, kw_only=True)
378
+ class NpmLockfile:
379
+ """Un lockfile npm, Yarn ou pnpm lu.
380
+
381
+ Ses paquets forment un graphe de nœuds (``LockedPackage.key``) : un même paquet
382
+ peut y être installé en plusieurs versions, à des endroits différents, et chaque
383
+ paquet désigne exactement celui qu'il reçoit.
384
+ """
385
+
386
+ lockfile: Lockfile
387
+ importers: Mapping[str, Mapping[str, str | None]] = field(default_factory=dict)
388
+ """Les projets que le lockfile décrit, par dossier relatif au sien (``.`` pour le
389
+ sien) : pour chacun, le nœud que reçoit chacune de ses dépendances, par nom sous
390
+ ``node_modules`` (l'alias s'il y en a un)."""
391
+ descriptors: Mapping[str, str] = field(default_factory=dict)
392
+ """Yarn : le nœud de chaque descripteur (``lodash@^4.17.0``). Yarn 1 ne décrit pas
393
+ les projets : c'est par là qu'on les relie au lockfile."""
394
+ nodes: Mapping[str, LockedPackage] = field(default_factory=dict)
395
+ """Les paquets, par nœud."""
396
+
397
+ def resolve(self, project: str, key: str, spec: str) -> LockedPackage | None:
398
+ """Le paquet que reçoit la dépendance ``key`` (valeur ``spec``) du projet
399
+ ``project``, un dossier relatif à celui du lockfile."""
400
+ importer = self.importers.get(project)
401
+ # Yarn 1 ne décrit pas les projets : on passe par le descripteur.
402
+ node = importer.get(key) if importer is not None else self.descriptors.get(f"{key}@{spec}")
403
+ return self.nodes.get(node) if node is not None else None
404
+
405
+
406
+ def _npm_lockfile(
407
+ path: str,
408
+ packages: Iterable[LockedPackage],
409
+ *,
410
+ importers: Mapping[str, Mapping[str, str | None]] | None = None,
411
+ descriptors: Mapping[str, str] | None = None,
412
+ ) -> NpmLockfile:
413
+ locked = tuple(packages)
414
+ return NpmLockfile(
415
+ lockfile=Lockfile(path=path, ecosystem=Ecosystem.NPM, packages=locked, graph=True),
416
+ importers=importers or {},
417
+ descriptors=descriptors or {},
418
+ nodes={package.node: package for package in locked},
419
+ )
420
+
421
+
422
+ def _edges(nodes: Iterable[str | None]) -> tuple[Edge, ...]:
423
+ return tuple((node, frozenset()) for node in nodes if node is not None)
424
+
425
+
426
+ def _flag_scope(entry: Mapping[str, Any]) -> Scope | None:
427
+ """Le scope que donnent les drapeaux d'un paquet (``dev``, ``optional``, ``peer``) :
428
+ ``true`` en JSON, ``"true"`` avec le chargeur YAML."""
429
+
430
+ def flag(name: str) -> bool:
431
+ return entry.get(name) in (True, "true")
432
+
433
+ if flag("dev") or flag("devOptional"):
434
+ return Scope.DEV
435
+ if flag("optional"):
436
+ return Scope.OPTIONAL
437
+ if flag("peer"):
438
+ return Scope.PEER
439
+ return None
440
+
441
+
442
+ def _mapping(value: Any) -> dict[str, Any]:
443
+ return value if isinstance(value, dict) else {}
444
+
445
+
446
+ # -- package-lock.json, npm-shrinkwrap.json ---------------------------------
447
+
448
+
449
+ def parse_package_lock_json(path: str, content: str) -> NpmLockfile:
450
+ """Un ``package-lock.json`` ou un ``npm-shrinkwrap.json``.
451
+
452
+ Formats 2 et 3 : ``packages``, où ``""`` est le projet, ``packages/a`` un membre
453
+ du workspace (ou la cible d'un ``file:``), et ``node_modules/x`` un paquet
454
+ installé. Format 1 (npm 5 et 6) : l'arbre ``dependencies``. Lève ``ParseError``
455
+ si le JSON est invalide.
456
+ """
457
+ data = _load_json(content)
458
+ packages = data.get("packages")
459
+ if not isinstance(packages, dict):
460
+ packages = _v1_packages(data.get("dependencies"))
461
+ return _package_lock(path, packages)
462
+
463
+
464
+ def _package_lock(path: str, raw: Mapping[str, Any]) -> NpmLockfile:
465
+ packages = {key: entry for key, entry in raw.items() if isinstance(entry, dict)}
466
+
467
+ def resolve(start: str, name: str) -> str | None:
468
+ """Le nœud de ``name`` que Node trouverait depuis le dossier ``start`` : dans son
469
+ ``node_modules``, puis dans ceux des dossiers au-dessus. Un lien mène à sa cible."""
470
+ directory = start
471
+ while True:
472
+ candidate = f"{directory}/node_modules/{name}" if directory else f"node_modules/{name}"
473
+ entry = packages.get(candidate)
474
+ if entry is not None:
475
+ target = entry.get("resolved")
476
+ if entry.get("link") is True and isinstance(target, str) and target in packages:
477
+ return target
478
+ return candidate
479
+ if not directory:
480
+ return None
481
+ directory = posixpath.dirname(directory)
482
+
483
+ locked = []
484
+ importers: dict[str, dict[str, str | None]] = {}
485
+ for key, entry in packages.items():
486
+ installed_name = _installed_name(key)
487
+ if installed_name is None: # un projet : la racine, un membre, la cible d'un file:
488
+ importers[key or "."] = {
489
+ dependency: resolve(key, dependency)
490
+ for section, _ in _SECTIONS
491
+ for dependency in _mapping(entry.get(section))
492
+ }
493
+ reason = _resolved_reason(entry.get("resolved"))
494
+ name = entry.get("name")
495
+ locked.append(
496
+ LockedPackage(
497
+ name=name if isinstance(name, str) else installed_name or key,
498
+ version=str(entry.get("version", "")),
499
+ dependencies=_edges(
500
+ resolve(key, dependency)
501
+ for section in ("dependencies", "optionalDependencies", "peerDependencies")
502
+ for dependency in _mapping(entry.get(section))
503
+ ),
504
+ scope=_flag_scope(entry) or Scope.RUNTIME,
505
+ local=installed_name is None or entry.get("link") is True or reason == REASON_LOCAL,
506
+ unverifiable_reason=reason,
507
+ key=key or ".",
508
+ )
509
+ )
510
+ return _npm_lockfile(path, locked, importers=importers)
511
+
512
+
513
+ def _installed_name(key: str) -> str | None:
514
+ """Le nom installé d'une clé de ``packages`` (``node_modules/a/node_modules/@s/x`` →
515
+ ``@s/x``) ; ``None`` pour un projet (``""``, ``packages/a``)."""
516
+ if not key.startswith("node_modules/") and "/node_modules/" not in key:
517
+ return None
518
+ return key.rsplit("node_modules/", 1)[1]
519
+
520
+
521
+ def _v1_packages(dependencies: Any) -> dict[str, dict[str, Any]]:
522
+ """L'arbre ``dependencies`` du format 1, mis sous la forme des ``packages`` des
523
+ formats 2 et 3 : ``node_modules/a/node_modules/b`` pour ``b`` rangé sous ``a``."""
524
+ top = _mapping(dependencies)
525
+ packages: dict[str, dict[str, Any]] = {"": {"dependencies": dict.fromkeys(top, "")}}
526
+
527
+ def walk(tree: Mapping[str, Any], prefix: str) -> None:
528
+ for key, entry in tree.items():
529
+ if not isinstance(entry, dict):
530
+ continue
531
+ location = f"{prefix}node_modules/{key}"
532
+ raw_version = str(entry.get("version", ""))
533
+ name, version, reason = _v1_version(key, raw_version)
534
+ packages[location] = {
535
+ **{flag: entry[flag] for flag in ("dev", "optional") if flag in entry},
536
+ "name": name,
537
+ "version": version,
538
+ # Une « version » git ou file: dit d'où vient le paquet.
539
+ "resolved": raw_version if reason else entry.get("resolved"),
540
+ "dependencies": _mapping(entry.get("requires")),
541
+ }
542
+ walk(_mapping(entry.get("dependencies")), f"{location}/")
543
+
544
+ walk(top, "")
545
+ return packages
546
+
547
+
548
+ def _v1_version(key: str, version: str) -> tuple[str, str, str | None]:
549
+ """Nom, version et raison d'un paquet du format 1 : la version peut être un alias
550
+ (``npm:ms@2.0.0``), un chemin (``file:…``) ou une URL git."""
551
+ if version.startswith("npm:"):
552
+ spec = _split_spec(key, version)
553
+ return spec.name, spec.constraint, None
554
+ reason = _spec_reason(version)
555
+ if reason is not None:
556
+ return key, "", reason
557
+ return key, version, None
558
+
559
+
560
+ def _resolved_reason(resolved: Any) -> str | None:
561
+ """La raison que donne l'URL ``resolved`` d'un paquet : git ou chemin local."""
562
+ if not isinstance(resolved, str):
563
+ return None
564
+ lowered = resolved.lower()
565
+ if lowered.startswith(_GIT_PREFIXES):
566
+ return REASON_GIT
567
+ if lowered.startswith("file:"):
568
+ return REASON_LOCAL
569
+ return None
570
+
571
+
572
+ # -- yarn.lock ----------------------------------------------------------------
573
+
574
+
575
+ def parse_yarn_lock(path: str, content: str) -> NpmLockfile:
576
+ """Un ``yarn.lock`` : le format de Yarn 1, ou le YAML de Yarn 2 et plus (reconnu à sa
577
+ section ``__metadata``).
578
+
579
+ Chaque bloc est un nœud, désigné par ses descripteurs (``lodash@^4.17.0``). Lève
580
+ ``ParseError`` si le fichier n'est pas lisible.
581
+ """
582
+ if re.search(r"^__metadata:", content, re.MULTILINE):
583
+ return _yarn_berry(path, content)
584
+ return _yarn_v1(path, content)
585
+
586
+
587
+ def _split_descriptor(descriptor: str) -> tuple[str, str]:
588
+ """``@s/x@^1.0`` → ``("@s/x", "^1.0")`` ; ``x@npm:^1.0`` → ``("x", "npm:^1.0")``."""
589
+ at = descriptor.find("@", 1)
590
+ if at == -1:
591
+ return descriptor, ""
592
+ return descriptor[:at], descriptor[at + 1 :]
593
+
594
+
595
+ def _descriptor_edges(descriptors: Mapping[str, str], *sections: Any) -> tuple[Edge, ...]:
596
+ """Les dépendances d'un paquet Yarn (``nom: plage``) : le nœud de leur descripteur."""
597
+ return _edges(
598
+ descriptors.get(f"{key}@{spec}")
599
+ for section in sections
600
+ for key, spec in _mapping(section).items()
601
+ )
602
+
603
+
604
+ def _yarn_v1(path: str, content: str) -> NpmLockfile:
605
+ """Le format de Yarn 1 : des blocs ``descripteurs:`` puis `` clé valeur``."""
606
+ descriptors: dict[str, str] = {}
607
+ blocks = []
608
+ for keys, entry in _yarn_v1_blocks(content).items():
609
+ version = entry.get("version")
610
+ names = [_split_descriptor(d) for d in _split_outside_quotes(keys, ",")]
611
+ if not isinstance(version, str) or not names:
612
+ continue
613
+ for key, spec in names:
614
+ descriptors[f"{key}@{spec}"] = keys
615
+ blocks.append((keys, entry, version, names[0]))
616
+ locked = []
617
+ for keys, entry, version, (key, spec) in blocks:
618
+ reason = None if spec.startswith("npm:") else _spec_reason(spec)
619
+ locked.append(
620
+ LockedPackage(
621
+ name=_real_name(key, spec),
622
+ version=version,
623
+ dependencies=_descriptor_edges(
624
+ descriptors, entry.get("dependencies"), entry.get("optionalDependencies")
625
+ ),
626
+ local=reason in (REASON_LOCAL, REASON_WORKSPACE),
627
+ unverifiable_reason=reason or _resolved_reason(entry.get("resolved")),
628
+ key=keys,
629
+ )
630
+ )
631
+ return _npm_lockfile(path, locked, descriptors=descriptors)
632
+
633
+
634
+ def _yarn_v1_blocks(content: str) -> dict[str, dict[str, Any]]:
635
+ """Les blocs d'un ``yarn.lock`` de Yarn 1, par ligne de descripteurs (brute).
636
+
637
+ Le format est fait de lignes ``clé valeur`` et ``clé:``, imbriquées par
638
+ l'indentation ; clés et valeurs sont entre guillemets quand il le faut.
639
+ """
640
+ root: dict[str, Any] = {}
641
+ stack: list[tuple[int, dict[str, Any]]] = [(-1, root)]
642
+ for number, raw in enumerate(content.split("\n"), start=1):
643
+ line = raw.rstrip()
644
+ text = line.lstrip(" ")
645
+ if not text or text.startswith("#"):
646
+ continue
647
+ indent = len(line) - len(text)
648
+ while stack[-1][0] >= indent:
649
+ stack.pop()
650
+ parent = stack[-1][1]
651
+ if text.endswith(":"):
652
+ child: dict[str, Any] = {}
653
+ parent[text[:-1] if indent == 0 else _unquote(text[:-1])] = child
654
+ stack.append((indent, child))
655
+ continue
656
+ words = _split_outside_quotes(text, " ")
657
+ if len(words) != 2 or indent == 0:
658
+ raise ParseError(f"yarn.lock illisible à la ligne {number}.")
659
+ parent[words[0]] = words[1]
660
+ return root
661
+
662
+
663
+ def _split_outside_quotes(text: str, separator: str) -> list[str]:
664
+ """Coupe ``text`` sur ``separator``, sauf entre guillemets, et retire les guillemets ;
665
+ sans les morceaux vides."""
666
+ parts = []
667
+ current: list[str] = []
668
+ quoted = False
669
+ index = 0
670
+ while index < len(text):
671
+ char = text[index]
672
+ if quoted and char == "\\":
673
+ current.append(text[index : index + 2])
674
+ index += 2
675
+ continue
676
+ if char == '"':
677
+ quoted = not quoted
678
+ if char == separator and not quoted:
679
+ parts.append("".join(current))
680
+ current = []
681
+ else:
682
+ current.append(char)
683
+ index += 1
684
+ parts.append("".join(current))
685
+ return [_unquote(part.strip()) for part in parts if part.strip()]
686
+
687
+
688
+ def _unquote(text: str) -> str:
689
+ if len(text) >= 2 and text.startswith('"') and text.endswith('"'):
690
+ try:
691
+ return str(json.loads(text))
692
+ except ValueError:
693
+ return text[1:-1]
694
+ return text
695
+
696
+
697
+ def _load_yaml(content: str) -> dict[str, Any]:
698
+ """Le document YAML de ``content`` ; le dernier s'il y en a plusieurs.
699
+
700
+ Depuis pnpm 11, ``pnpm-lock.yaml`` commence par un document qui fige pnpm
701
+ lui-même (``packageManagerDependencies``), puis vient le lockfile du projet.
702
+ """
703
+ try:
704
+ documents = list(yaml.load_all(content, Loader=_YAML_LOADER)) # que des chaînes
705
+ data = next((d for d in reversed(documents) if d is not None), None)
706
+ except yaml.YAMLError as err:
707
+ mark = getattr(err, "problem_mark", None)
708
+ problem = getattr(err, "problem", None)
709
+ if mark is not None and problem:
710
+ raise ParseError(f"YAML invalide (ligne {mark.line + 1} : {problem}).") from err
711
+ raise ParseError("YAML invalide.") from err
712
+ if not isinstance(data, dict):
713
+ raise ParseError("YAML inattendu : un dictionnaire est attendu.")
714
+ return data
715
+
716
+
717
+ def _yarn_berry(path: str, content: str) -> NpmLockfile:
718
+ """Le ``yarn.lock`` de Yarn 2 et plus : du YAML, une entrée par paquet résolu, et une
719
+ par membre du workspace (``nom@workspace:chemin``), qui liste ses dépendances."""
720
+ data = _load_yaml(content)
721
+ descriptors: dict[str, str] = {}
722
+ blocks = []
723
+ for keys, entry in data.items():
724
+ if keys == "__metadata" or not isinstance(entry, dict):
725
+ continue
726
+ resolution = entry.get("resolution")
727
+ version = entry.get("version")
728
+ if not isinstance(resolution, str) or not isinstance(version, str):
729
+ continue
730
+ for descriptor in keys.split(","):
731
+ descriptors[descriptor.strip()] = keys
732
+ blocks.append((keys, entry, resolution, version))
733
+ locked = []
734
+ importers: dict[str, dict[str, str | None]] = {}
735
+ for keys, entry, resolution, version in blocks:
736
+ name, reference = _split_descriptor(resolution)
737
+ reason = _berry_reason(reference)
738
+ local = reason in (REASON_LOCAL, REASON_WORKSPACE)
739
+ dependencies = _mapping(entry.get("dependencies"))
740
+ if reference.startswith("workspace:"):
741
+ importers[posixpath.normpath(reference.removeprefix("workspace:"))] = {
742
+ key: descriptors.get(f"{key}@{spec}") for key, spec in dependencies.items()
743
+ }
744
+ locked.append(
745
+ LockedPackage(
746
+ name=name,
747
+ version="" if local else version,
748
+ dependencies=_descriptor_edges(descriptors, dependencies),
749
+ local=local,
750
+ unverifiable_reason=reason,
751
+ key=keys,
752
+ )
753
+ )
754
+ return _npm_lockfile(path, locked, importers=importers, descriptors=descriptors)
755
+
756
+
757
+ def _berry_reason(reference: str) -> str | None:
758
+ """La raison d'une résolution de Yarn 2+ (``npm:1.0.0``, ``workspace:…``, URL git…).
759
+
760
+ Un ``patch:`` reste vérifiable : Yarn en met d'office sur ``typescript``,
761
+ ``fsevents`` ou ``resolve``, et c'est le paquet du registre qui est en dessous.
762
+ """
763
+ if reference.startswith(("npm:", "patch:")):
764
+ return None
765
+ return _spec_reason(reference)
766
+
767
+
768
+ # -- pnpm-lock.yaml -----------------------------------------------------------
769
+
770
+
771
+ def parse_pnpm_lock_yaml(path: str, content: str) -> NpmLockfile:
772
+ """Un ``pnpm-lock.yaml``, formats 5.4, 6.0 et 9.0.
773
+
774
+ Les ``importers`` sont les projets du workspace (sans workspace, avant le format
775
+ 9, le projet est écrit à la racine du fichier) ; chacun est aussi un nœud, que
776
+ tirent les projets qui en dépendent (``link:../lib``). Les paquets sont dans
777
+ ``snapshots`` (format 9) ou ``packages``. Lève ``ParseError`` si le YAML est
778
+ invalide.
779
+ """
780
+ data = _load_yaml(content)
781
+ v5 = str(data.get("lockfileVersion", "")).startswith("5")
782
+ raw_importers = data.get("importers")
783
+ if not isinstance(raw_importers, dict):
784
+ raw_importers = {".": data}
785
+ projects = {
786
+ posixpath.normpath(project): _mapping(entry)
787
+ for project, entry in raw_importers.items()
788
+ if isinstance(project, str)
789
+ }
790
+ metadata = _mapping(data.get("packages"))
791
+ snapshots = data.get("snapshots")
792
+ entries = _mapping(snapshots) if isinstance(snapshots, dict) else metadata
793
+ nodes = {
794
+ key.removeprefix("/"): entry
795
+ for key, entry in entries.items()
796
+ if isinstance(key, str) and isinstance(entry, dict)
797
+ }
798
+
799
+ def target(name: str, reference: Any, project: str | None) -> str | None:
800
+ """Le nœud que désigne une référence : ``4.17.21``, ``18.3.1(react@18.3.1)``, un
801
+ alias (``ms@2.0.0``, ``/ms@2.0.0``, ``/ms/2.0.0``), ou ``link:../lib`` depuis le
802
+ dossier ``project``."""
803
+ if isinstance(reference, dict): # formats 6 et 9 : {specifier, version}
804
+ reference = reference.get("version")
805
+ if not isinstance(reference, str):
806
+ return None
807
+ if reference.startswith("link:"):
808
+ if project is None:
809
+ return None
810
+ linked = posixpath.normpath(posixpath.join(project, reference.removeprefix("link:")))
811
+ return _importer_node(linked) if linked in projects else None
812
+ bare = reference.removeprefix("/")
813
+ qualified = f"{name}/{bare}" if v5 else f"{name}@{bare}"
814
+ return next((node for node in (qualified, bare) if node in nodes), None)
815
+
816
+ locked = []
817
+ importers: dict[str, dict[str, str | None]] = {}
818
+ for project, entry in projects.items():
819
+ importers[project] = {
820
+ key: target(key, reference, project)
821
+ for section, _ in _SECTIONS
822
+ for key, reference in _mapping(entry.get(section)).items()
823
+ }
824
+ locked.append(
825
+ LockedPackage(
826
+ name=project,
827
+ dependencies=_edges(
828
+ target(key, reference, project)
829
+ for section in ("dependencies", "optionalDependencies")
830
+ for key, reference in _mapping(entry.get(section)).items()
831
+ ),
832
+ local=True,
833
+ key=_importer_node(project),
834
+ )
835
+ )
836
+ for key, entry in nodes.items():
837
+ name, version = _pnpm_package_key(key, v5=v5)
838
+ details = _mapping(metadata.get(_without_peers(key, v5=v5))) or entry
839
+ reason = _pnpm_reason(version, _mapping(details.get("resolution")))
840
+ if reason is not None:
841
+ # Paquet git ou local : sa clé est une URL ou un chemin, mais pnpm écrit à
842
+ # côté son nom (format 6) et sa version.
843
+ name = details["name"] if isinstance(details.get("name"), str) else name
844
+ version = details["version"] if isinstance(details.get("version"), str) else ""
845
+ locked.append(
846
+ LockedPackage(
847
+ name=name,
848
+ version="" if reason in (REASON_LOCAL, REASON_WORKSPACE) else version,
849
+ dependencies=_edges(
850
+ target(dependency, reference, None)
851
+ for section in ("dependencies", "optionalDependencies")
852
+ for dependency, reference in _mapping(entry.get(section)).items()
853
+ ),
854
+ scope=_flag_scope(entry) or _flag_scope(details),
855
+ local=reason in (REASON_LOCAL, REASON_WORKSPACE),
856
+ unverifiable_reason=reason,
857
+ key=key,
858
+ )
859
+ )
860
+ return _npm_lockfile(path, locked, importers=importers)
861
+
862
+
863
+ def _importer_node(project: str) -> str:
864
+ """Le nœud d'un projet de ``importers`` : distinct des clés de paquets."""
865
+ return f"importer:{project}"
866
+
867
+
868
+ def _pnpm_package_key(key: str, *, v5: bool) -> tuple[str, str]:
869
+ """Nom et version d'une clé de paquet : ``@s/x@1.0.0(peer@2)`` (formats 6 et 9) ou
870
+ ``@s/x/1.0.0_peer@2`` (format 5). La version d'un paquet git ou local est sa
871
+ référence (``github.com/…``, ``file:…``)."""
872
+ if v5:
873
+ parts = key.split("/", 2) if key.startswith("@") else key.split("/", 1)
874
+ if len(parts) < (3 if key.startswith("@") else 2):
875
+ return key, ""
876
+ return "/".join(parts[:-1]), _without_peers(parts[-1], v5=True)
877
+ name, version = _split_descriptor(key)
878
+ return name, _without_peers(version, v5=False)
879
+
880
+
881
+ def _without_peers(version: str, *, v5: bool) -> str:
882
+ """Retire ce que pnpm ajoute après la version : ``(react@18.3.1)``, ou ``_react@18.3.1``
883
+ au format 5."""
884
+ cut = version.find("_" if v5 else "(")
885
+ return version if cut == -1 else version[:cut]
886
+
887
+
888
+ def _pnpm_reason(version: str, resolution: Mapping[str, Any]) -> str | None:
889
+ if version[:1].isdigit():
890
+ return None
891
+ if "directory" in resolution or version.startswith(("file:", "link:")):
892
+ return REASON_LOCAL
893
+ if resolution.get("type") == "git" or "git" in version.lower():
894
+ return REASON_GIT
895
+ return REASON_URL
896
+
897
+
898
+ # ---------------------------------------------------------------------------
899
+ # Un projet
900
+ # ---------------------------------------------------------------------------
901
+
902
+ _LOCK_PARSERS: dict[str, Callable[[str, str], NpmLockfile]] = {
903
+ "npm-shrinkwrap.json": parse_package_lock_json,
904
+ "package-lock.json": parse_package_lock_json,
905
+ "yarn.lock": parse_yarn_lock,
906
+ "pnpm-lock.yaml": parse_pnpm_lock_yaml,
907
+ }
908
+ """Les lockfiles npm, dans l'ordre de préférence quand ``packageManager`` ne dit rien.
909
+ npm ignore ``package-lock.json`` quand il y a un ``npm-shrinkwrap.json``."""
910
+
911
+ _MANAGER_LOCKS = {
912
+ "npm": ("npm-shrinkwrap.json", "package-lock.json"),
913
+ "yarn": ("yarn.lock",),
914
+ "pnpm": ("pnpm-lock.yaml",),
915
+ }
916
+
917
+
918
+ def parse_project(project: Project, read_text: ReadText, project_dirs: Sequence[str] = ()) -> None:
919
+ """Lit les fichiers npm de ``project`` et remplit ses dépendances.
920
+
921
+ Ajoute à ``project.dependencies`` les directes (dans l'ordre du ``package.json``)
922
+ puis les transitives, et à ``project.notices`` ce qui n'a pas pu être lu. Le
923
+ lockfile d'un workspace dont le projet n'est pas membre est retiré de
924
+ ``project.lockfiles``.
925
+
926
+ ``project_dirs`` sont les dossiers de tous les projets de la source : Yarn 1
927
+ n'écrit pas les membres de son workspace dans ``yarn.lock``, on les retrouve là.
928
+ """
929
+ _ProjectParser(project, read_text, project_dirs).run()
930
+
931
+
932
+ @functools.lru_cache(maxsize=4)
933
+ def _parse_lock_once(pattern: str, path: str, content: str) -> NpmLockfile:
934
+ """Lit un lockfile, une seule fois pour un même contenu : celui d'un workspace est
935
+ relu par chacun de ses membres (21 fois chez ariakit). Le résultat est partagé :
936
+ on ne le modifie pas."""
937
+ return _LOCK_PARSERS[pattern](path, content)
938
+
939
+
940
+ class _ProjectParser:
941
+ """Lit les fichiers npm d'un projet et relie le ``package.json`` au lockfile."""
942
+
943
+ def __init__(self, project: Project, read_text: ReadText, project_dirs: Sequence[str]) -> None:
944
+ self.project = project
945
+ self.read_text = read_text
946
+ self.project_dirs = project_dirs
947
+ self.notices: list[Notice] = []
948
+ self.manifest_path = next(
949
+ (path for path in project.manifests if _pattern(path) == "package.json"), None
950
+ )
951
+ self.locks = {
952
+ pattern: path
953
+ for path in project.lockfiles
954
+ if (pattern := _pattern(path)) in _LOCK_PARSERS
955
+ }
956
+
957
+ def run(self) -> None:
958
+ directs: list[Dependency] = []
959
+ found: list[Dependency] = []
960
+ manifest = self._manifest()
961
+ lock = self._lockfile(manifest)
962
+ if manifest is not None and lock is not None:
963
+ directs, found = self._link(manifest, lock)
964
+ elif manifest is not None:
965
+ directs = [entry.declared.dependency for entry in manifest.entries]
966
+ elif lock is not None and dirname(lock.lockfile.path) == self.project.path:
967
+ # Pas de package.json lisible : tout le lockfile est transitif.
968
+ lockfile = dataclasses.replace(lock.lockfile, shared=False)
969
+ found = transitives(lockfile, [], lambda package: False)
970
+ directs = self._with_catalogs(directs)
971
+ found.sort(key=lambda d: (d.file, d.name, NPM.sort_key(d.installed or "")))
972
+ self.project.dependencies.extend(directs + found)
973
+ if manifest is not None:
974
+ self.project.overrides.extend(manifest.overrides)
975
+ self.project.notices.extend(self.notices)
976
+
977
+ # -- lecture ------------------------------------------------------------
978
+
979
+ def _read(self, path: str) -> str | None:
980
+ try:
981
+ content = self.read_text(path)
982
+ except ValueError:
983
+ content = None
984
+ if content is None:
985
+ self.notices.append(
986
+ Notice(file=path, message="introuvable, illisible ou trop gros : pas lu.")
987
+ )
988
+ return content
989
+
990
+ def _read_quietly(self, path: str) -> str | None:
991
+ """Lit un fichier dont l'absence n'est pas une erreur (``pnpm-workspace.yaml``)."""
992
+ try:
993
+ return self.read_text(path)
994
+ except ValueError:
995
+ return None
996
+
997
+ def _manifest(self) -> _PackageJson | None:
998
+ if self.manifest_path is None:
999
+ return None
1000
+ content = self._read(self.manifest_path)
1001
+ if content is None:
1002
+ return None
1003
+ try:
1004
+ return _parse_package_json(self.manifest_path, content)
1005
+ except ParseError as err:
1006
+ self.notices.append(Notice(file=self.manifest_path, message=str(err)))
1007
+ return None
1008
+
1009
+ def _parse_lock(self, path: str) -> NpmLockfile | None:
1010
+ content = self._read(path)
1011
+ if content is None:
1012
+ return None
1013
+ try:
1014
+ return _parse_lock_once(_pattern(path) or "", path, content)
1015
+ except ParseError as err:
1016
+ self.notices.append(Notice(file=path, message=str(err)))
1017
+ return None
1018
+
1019
+ # -- lockfile -----------------------------------------------------------
1020
+
1021
+ def _lockfile(self, manifest: _PackageJson | None) -> NpmLockfile | None:
1022
+ """Le lockfile du projet, s'il en a un et qu'il en est bien membre.
1023
+
1024
+ Les autres lockfiles npm du même dossier reçoivent une remarque « pas lu ».
1025
+ """
1026
+ if not self.locks:
1027
+ return None
1028
+ lock_dir = dirname(next(iter(self.locks.values())))
1029
+ relative = relative_to(self.project.path, lock_dir)
1030
+ root = manifest if lock_dir == self.project.path else self._root_manifest(lock_dir)
1031
+ preferred = _MANAGER_LOCKS.get(root.package_manager or "", ()) if root else ()
1032
+ chosen = next(
1033
+ self.locks[name] for name in (*preferred, *_LOCK_PARSERS) if name in self.locks
1034
+ )
1035
+ lock = self._parse_lock(chosen)
1036
+ globs = root.workspaces if root is not None else []
1037
+ if _pattern(chosen) == "pnpm-lock.yaml":
1038
+ globs = self._pnpm_workspace(lock_dir)
1039
+
1040
+ known = lock is not None and relative in lock.importers
1041
+ if lock_dir != self.project.path and not (known or _in_globs(relative, globs)):
1042
+ logger.debug("%s n'est pas membre du workspace de %s", self.project.path, lock_dir)
1043
+ for path in self.locks.values():
1044
+ self.project.lockfiles.remove(path)
1045
+ self.notices = [n for n in self.notices if n.file not in self.locks.values()]
1046
+ return None
1047
+
1048
+ owner = self.manifest_path or "ce dossier"
1049
+ for path in self.locks.values():
1050
+ if path != chosen:
1051
+ message = f"pas lu : {chosen} est le lockfile pris pour {owner}."
1052
+ self.notices.append(Notice(file=path, message=message))
1053
+ if lock is None:
1054
+ return None
1055
+ if globs and not lock.importers:
1056
+ lock = self._yarn_v1_workspace(lock, lock_dir, globs)
1057
+ shared = len(lock.importers) > 1 or bool(globs)
1058
+ return dataclasses.replace(lock, lockfile=dataclasses.replace(lock.lockfile, shared=shared))
1059
+
1060
+ def _yarn_v1_workspace(self, lock: NpmLockfile, lock_dir: str, globs: list[str]) -> NpmLockfile:
1061
+ """Ajoute au ``yarn.lock`` d'un workspace Yarn 1 ses projets, que Yarn 1 n'écrit pas
1062
+ (Yarn 2+ et pnpm le font) : chacun devient un nœud, qui tire ses dépendances.
1063
+
1064
+ Sans ça, ``"a": "^1.0.0"`` vers le membre ``a`` serait pris pour le paquet ``a``
1065
+ du registre, et ce que ``a`` tire serait perdu.
1066
+ """
1067
+ manifests: dict[str, _PackageJson] = {}
1068
+ for directory in dict.fromkeys([lock_dir, *self.project_dirs]):
1069
+ inside = (
1070
+ lock_dir == "." or directory == lock_dir or directory.startswith(f"{lock_dir}/")
1071
+ )
1072
+ relative = relative_to(directory, lock_dir) if inside else None
1073
+ if relative is None or (relative != "." and not _in_globs(relative, globs)):
1074
+ continue
1075
+ path = join(directory, "package.json")
1076
+ content = self._read_quietly(path)
1077
+ try:
1078
+ if content is not None:
1079
+ manifests[relative] = _parse_package_json(path, content)
1080
+ except ParseError:
1081
+ continue # la remarque vient avec ce projet
1082
+ members = {m.name: relative for relative, m in manifests.items() if m.name is not None}
1083
+
1084
+ def node(entry: _Entry) -> str | None:
1085
+ if entry.key in members:
1086
+ return _importer_node(members[entry.key])
1087
+ return lock.descriptors.get(f"{entry.key}@{entry.spec}")
1088
+
1089
+ projects = [
1090
+ LockedPackage(
1091
+ name=manifest.name or relative,
1092
+ dependencies=_edges(
1093
+ node(entry)
1094
+ for entry in manifest.entries
1095
+ if entry.declared.dependency.scope in (Scope.RUNTIME, Scope.OPTIONAL)
1096
+ ),
1097
+ local=True,
1098
+ key=_importer_node(relative),
1099
+ )
1100
+ for relative, manifest in manifests.items()
1101
+ ]
1102
+ return _npm_lockfile(
1103
+ lock.lockfile.path,
1104
+ (*lock.lockfile.packages, *projects),
1105
+ importers={
1106
+ relative: {entry.key: node(entry) for entry in manifest.entries}
1107
+ for relative, manifest in manifests.items()
1108
+ },
1109
+ descriptors=lock.descriptors,
1110
+ )
1111
+
1112
+ def _root_manifest(self, directory: str) -> _PackageJson | None:
1113
+ """Le ``package.json`` à la racine du workspace ; ``None`` s'il n'est pas lisible
1114
+ (la remarque vient avec le projet racine)."""
1115
+ path = join(directory, "package.json")
1116
+ content = self._read_quietly(path)
1117
+ if content is None:
1118
+ return None
1119
+ try:
1120
+ return _parse_package_json(path, content)
1121
+ except ParseError:
1122
+ return None
1123
+
1124
+ def _pnpm_workspace(self, directory: str) -> list[str]:
1125
+ """Les motifs ``packages`` du ``pnpm-workspace.yaml`` de ``directory``."""
1126
+ path = join(directory, "pnpm-workspace.yaml")
1127
+ content = self._read_quietly(path)
1128
+ if content is None:
1129
+ return []
1130
+ try:
1131
+ return _strings(_load_yaml(content).get("packages"))
1132
+ except ParseError as err:
1133
+ if directory == self.project.path:
1134
+ self.notices.append(Notice(file=path, message=str(err)))
1135
+ return []
1136
+
1137
+ # -- catalogues pnpm ----------------------------------------------------
1138
+
1139
+ def _with_catalogs(self, directs: list[Dependency]) -> list[Dependency]:
1140
+ """Les directes en ``catalog:``, avec la plage de leur catalogue
1141
+ (``resolved_constraint``), lue dans le ``pnpm-workspace.yaml`` le plus proche.
1142
+
1143
+ Une valeur de catalogue qui n'est pas une plage (alias ``npm:``, git…) est lue
1144
+ comme dans un ``package.json``. Plage introuvable : une remarque, et E07 se fiera
1145
+ à la version installée.
1146
+ """
1147
+ if all(_catalog_name(d.constraint) is None for d in directs):
1148
+ return directs
1149
+ path, catalogs = self._catalogs()
1150
+ if catalogs is None:
1151
+ if path is None:
1152
+ message = "catalog: : aucun pnpm-workspace.yaml lisible, plage inconnue."
1153
+ self.notices.append(Notice(file=self.manifest_path or ".", message=message))
1154
+ return directs
1155
+ resolved = []
1156
+ for dependency in directs:
1157
+ catalog = _catalog_name(dependency.constraint)
1158
+ if catalog is None:
1159
+ resolved.append(dependency)
1160
+ continue
1161
+ value = _mapping(catalogs.get(catalog)).get(dependency.name)
1162
+ spec = _split_spec(dependency.name, value) if isinstance(value, str) else None
1163
+ if spec is None or _catalog_name(spec.constraint) is not None:
1164
+ message = f"{dependency.name} absent du catalogue « {catalog} » : plage inconnue."
1165
+ self.notices.append(Notice(file=path or ".", message=message))
1166
+ resolved.append(dependency)
1167
+ continue
1168
+ resolved.append(
1169
+ dataclasses.replace(
1170
+ dependency,
1171
+ name=spec.name,
1172
+ resolved_constraint=spec.constraint if spec.reason is None else None,
1173
+ unverifiable_reason=spec.reason,
1174
+ )
1175
+ )
1176
+ return resolved
1177
+
1178
+ def _catalogs(self) -> tuple[str | None, dict[str, Any] | None]:
1179
+ """Le ``pnpm-workspace.yaml`` le plus proche en remontant depuis le projet, et ses
1180
+ catalogues par nom : ``catalog`` est le catalogue ``default``, comme
1181
+ ``catalogs.default``. ``(None, None)`` s'il n'y en a pas ; ``(chemin, None)`` s'il
1182
+ est invalide (avec une remarque)."""
1183
+ directory = self.project.path
1184
+ while True:
1185
+ path = join(directory, "pnpm-workspace.yaml")
1186
+ content = self._read_quietly(path)
1187
+ if content is not None:
1188
+ break
1189
+ if directory == ".":
1190
+ return None, None
1191
+ directory = dirname(directory)
1192
+ try:
1193
+ data = _load_yaml(content)
1194
+ except ParseError as err:
1195
+ notice = Notice(file=path, message=str(err))
1196
+ if notice not in self.notices:
1197
+ self.notices.append(notice)
1198
+ return path, None
1199
+ catalogs = dict(_mapping(data.get("catalogs")))
1200
+ if isinstance(data.get("catalog"), dict):
1201
+ catalogs["default"] = data["catalog"]
1202
+ return path, catalogs
1203
+
1204
+ # -- lien ---------------------------------------------------------------
1205
+
1206
+ def _link(
1207
+ self, manifest: _PackageJson, lock: NpmLockfile
1208
+ ) -> tuple[list[Dependency], list[Dependency]]:
1209
+ """Les directes avec leur version installée, et les transitives que le projet tire
1210
+ du lockfile, en suivant le graphe depuis les nœuds de ses directes."""
1211
+ relative = relative_to(self.project.path, dirname(lock.lockfile.path))
1212
+ directs = []
1213
+ starts: list[Start] = []
1214
+ direct_nodes: set[str] = set()
1215
+ for entry in manifest.entries:
1216
+ dependency = entry.declared.dependency
1217
+ package = lock.resolve(relative, entry.key, entry.spec)
1218
+ if package is not None:
1219
+ starts.append((dependency.scope, (package.node, frozenset())))
1220
+ direct_nodes.add(package.node)
1221
+ if package.local and dependency.unverifiable_reason is None:
1222
+ # « "a": "^1.0.0" » que npm a résolu vers le membre packages/a : le
1223
+ # paquet « a » du registre n'a rien à voir.
1224
+ reason = package.unverifiable_reason or REASON_WORKSPACE
1225
+ dependency = dataclasses.replace(dependency, unverifiable_reason=reason)
1226
+ elif package.version and not package.local:
1227
+ dependency = dataclasses.replace(
1228
+ dependency, installed=package.version, lockfile=lock.lockfile.path
1229
+ )
1230
+ directs.append(dependency)
1231
+ # Un paquet en double à la même version (les « patch: » que Yarn met d'office) n'est
1232
+ # pas une transitive non plus.
1233
+ pairs = {(d.name, d.installed) for d in directs if d.installed}
1234
+ found = transitives(
1235
+ lock.lockfile,
1236
+ starts,
1237
+ lambda package: (
1238
+ package.node in direct_nodes or (package.name, package.version) in pairs
1239
+ ),
1240
+ )
1241
+ return directs, found
1242
+
1243
+
1244
+ def _pattern(path: str) -> str | None:
1245
+ """Le motif npm reconnu pour ``path`` (``package.json``, ``yarn.lock``…)."""
1246
+ match = match_dependency_file(path)
1247
+ if match is None or match.ecosystem is not Ecosystem.NPM:
1248
+ return None
1249
+ return match.pattern
1250
+
1251
+
1252
+ def _in_globs(path: str, globs: Iterable[str]) -> bool:
1253
+ """Vrai si ``path`` correspond aux motifs d'un workspace npm ou pnpm, où ``!motif``
1254
+ exclut."""
1255
+ members = [glob for glob in globs if not glob.startswith("!")]
1256
+ excluded = [glob[1:] for glob in globs if glob.startswith("!")]
1257
+ return in_workspace(path, members, excluded)