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.
- forceupdate/__init__.py +9 -0
- forceupdate/__main__.py +5 -0
- forceupdate/advisories/__init__.py +30 -0
- forceupdate/advisories/cvss.py +95 -0
- forceupdate/advisories/osv.py +584 -0
- forceupdate/checks/__init__.py +49 -0
- forceupdate/checks/base.py +98 -0
- forceupdate/checks/deprecated.py +115 -0
- forceupdate/checks/outdated.py +183 -0
- forceupdate/checks/priority.py +82 -0
- forceupdate/checks/vulns.py +201 -0
- forceupdate/cli.py +309 -0
- forceupdate/detect/__init__.py +5 -0
- forceupdate/detect/languages.py +266 -0
- forceupdate/detect/manifests.py +478 -0
- forceupdate/engine.py +313 -0
- forceupdate/models.py +510 -0
- forceupdate/net/__init__.py +20 -0
- forceupdate/net/cache.py +217 -0
- forceupdate/net/http.py +270 -0
- forceupdate/parsers/__init__.py +43 -0
- forceupdate/parsers/base.py +22 -0
- forceupdate/parsers/json_spans.py +123 -0
- forceupdate/parsers/lockfiles.py +231 -0
- forceupdate/parsers/npm.py +1257 -0
- forceupdate/parsers/python.py +1421 -0
- forceupdate/parsers/toml_spans.py +297 -0
- forceupdate/registries/__init__.py +52 -0
- forceupdate/registries/base.py +294 -0
- forceupdate/registries/npm.py +153 -0
- forceupdate/registries/pypi.py +242 -0
- forceupdate/report/__init__.py +359 -0
- forceupdate/report/json.py +34 -0
- forceupdate/report/markdown.py +169 -0
- forceupdate/report/terminal.py +376 -0
- forceupdate/sources/__init__.py +1 -0
- forceupdate/sources/base.py +45 -0
- forceupdate/sources/local.py +183 -0
- forceupdate/update/__init__.py +8 -0
- forceupdate/update/constraints.py +317 -0
- forceupdate/update/planner.py +531 -0
- forceupdate/versions/__init__.py +40 -0
- forceupdate/versions/base.py +229 -0
- forceupdate/versions/npm.py +432 -0
- forceupdate/versions/pypi.py +220 -0
- forceupdate-0.1.0.dist-info/METADATA +79 -0
- forceupdate-0.1.0.dist-info/RECORD +50 -0
- forceupdate-0.1.0.dist-info/WHEEL +4 -0
- forceupdate-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|