code-standards 7.0.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 (99) hide show
  1. code_standards-7.0.0.dist-info/METADATA +53 -0
  2. code_standards-7.0.0.dist-info/RECORD +99 -0
  3. code_standards-7.0.0.dist-info/WHEEL +4 -0
  4. code_standards-7.0.0.dist-info/entry_points.txt +3 -0
  5. code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
  6. sarj_standards/__init__.py +30 -0
  7. sarj_standards/__main__.py +5 -0
  8. sarj_standards/_meta.py +22 -0
  9. sarj_standards/api.py +890 -0
  10. sarj_standards/cli/__init__.py +0 -0
  11. sarj_standards/cli/main.py +2466 -0
  12. sarj_standards/configs/cli-reference.v1.json +1 -0
  13. sarj_standards/configs/doctor.config.json +22 -0
  14. sarj_standards/configs/eslint.application.mjs +1366 -0
  15. sarj_standards/configs/eslint.peers.json +44 -0
  16. sarj_standards/configs/eslint.strict.mjs +1060 -0
  17. sarj_standards/configs/markdownlint.strict.yaml +12 -0
  18. sarj_standards/configs/pyright.strict.json +96 -0
  19. sarj_standards/configs/ruff.application.toml +363 -0
  20. sarj_standards/configs/ruff.strict.toml +338 -0
  21. sarj_standards/configs/rule-inventory.v1.json +1 -0
  22. sarj_standards/configs/rule-ledger.json +846 -0
  23. sarj_standards/configs/rule-warning-levels.v1.json +1 -0
  24. sarj_standards/configs/taplo.strict.toml +14 -0
  25. sarj_standards/configs/yamllint.strict.yaml +25 -0
  26. sarj_standards/libs/__init__.py +0 -0
  27. sarj_standards/libs/adoption/__init__.py +0 -0
  28. sarj_standards/libs/adoption/configs.py +36 -0
  29. sarj_standards/libs/adoption/doctor.py +1346 -0
  30. sarj_standards/libs/adoption/exclusions.py +66 -0
  31. sarj_standards/libs/adoption/hooks.py +423 -0
  32. sarj_standards/libs/adoption/launcher.py +240 -0
  33. sarj_standards/libs/adoption/lifecycle.py +493 -0
  34. sarj_standards/libs/adoption/manifest.py +550 -0
  35. sarj_standards/libs/adoption/packagemanager.py +285 -0
  36. sarj_standards/libs/adoption/retired_suppressions.py +371 -0
  37. sarj_standards/libs/adoption/scaffold.py +1660 -0
  38. sarj_standards/libs/adoption/service.py +441 -0
  39. sarj_standards/libs/adoption/transaction.py +274 -0
  40. sarj_standards/libs/adoption/upgrade.py +516 -0
  41. sarj_standards/libs/adoption/uvtool.py +62 -0
  42. sarj_standards/libs/catalogs/__init__.py +9 -0
  43. sarj_standards/libs/catalogs/slack_automations.py +627 -0
  44. sarj_standards/libs/corpus/__init__.py +25 -0
  45. sarj_standards/libs/corpus/manifest.py +211 -0
  46. sarj_standards/libs/corpus/snapshot.py +222 -0
  47. sarj_standards/libs/diagnostics/__init__.py +65 -0
  48. sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
  49. sarj_standards/libs/diagnostics/baseline.py +131 -0
  50. sarj_standards/libs/diagnostics/models.py +574 -0
  51. sarj_standards/libs/diagnostics/serialize.py +290 -0
  52. sarj_standards/libs/diagnostics/source.py +172 -0
  53. sarj_standards/libs/filesystem.py +11 -0
  54. sarj_standards/libs/linting/__init__.py +0 -0
  55. sarj_standards/libs/linting/analysis.py +422 -0
  56. sarj_standards/libs/linting/external.py +1454 -0
  57. sarj_standards/libs/linting/library_policy.py +688 -0
  58. sarj_standards/libs/linting/policy.py +152 -0
  59. sarj_standards/libs/linting/runner.py +442 -0
  60. sarj_standards/libs/linting/textlint.py +1605 -0
  61. sarj_standards/libs/release/__init__.py +98 -0
  62. sarj_standards/libs/release/_values.py +24 -0
  63. sarj_standards/libs/release/artifacts.py +191 -0
  64. sarj_standards/libs/release/causality.py +80 -0
  65. sarj_standards/libs/release/changes.py +48 -0
  66. sarj_standards/libs/release/process.py +128 -0
  67. sarj_standards/libs/release/publish.py +85 -0
  68. sarj_standards/libs/release/registry.py +271 -0
  69. sarj_standards/libs/release/release_age.py +218 -0
  70. sarj_standards/libs/release/rollout.py +1163 -0
  71. sarj_standards/libs/release/tags.py +373 -0
  72. sarj_standards/libs/release/typescript.py +191 -0
  73. sarj_standards/libs/repository/__init__.py +0 -0
  74. sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
  75. sarj_standards/libs/repository/comment_corpus.py +536 -0
  76. sarj_standards/libs/repository/config_generation.py +146 -0
  77. sarj_standards/libs/repository/docs.py +347 -0
  78. sarj_standards/libs/repository/hooks.py +118 -0
  79. sarj_standards/libs/repository/ledger.py +99 -0
  80. sarj_standards/libs/repository/repository.py +744 -0
  81. sarj_standards/libs/repository/rule_authoring.py +246 -0
  82. sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
  83. sarj_standards/libs/repository/rule_changes.py +318 -0
  84. sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
  85. sarj_standards/libs/repository/rule_lifecycle.py +167 -0
  86. sarj_standards/libs/repository/rule_maintenance.py +225 -0
  87. sarj_standards/libs/rules/__init__.py +74 -0
  88. sarj_standards/libs/rules/catalog.py +145 -0
  89. sarj_standards/libs/rules/contracts.py +382 -0
  90. sarj_standards/libs/rules/corpus_runner.py +365 -0
  91. sarj_standards/libs/rules/evaluation.py +177 -0
  92. sarj_standards/libs/setup/__init__.py +4 -0
  93. sarj_standards/libs/setup/repository.py +40 -0
  94. sarj_standards/py.typed +0 -0
  95. sarj_standards/schemas/__init__.py +4 -0
  96. sarj_standards/schemas/_paths.py +7 -0
  97. sarj_standards/schemas/rule-catalog.v1.json +1 -0
  98. sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
  99. sarj_standards/schemas/slack-automations.v1.schema.json +1751 -0
@@ -0,0 +1,688 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ from pathlib import Path
6
+ import re
7
+ import tomllib
8
+ from typing import TYPE_CHECKING, Final, Literal, NamedTuple
9
+
10
+ from packaging.requirements import InvalidRequirement, Requirement
11
+ from packaging.utils import canonicalize_name
12
+
13
+ from sarj_standards.libs.adoption.manifest import as_table, list_field
14
+
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Iterable, Mapping
18
+
19
+
20
+ Ecosystem = Literal["python", "typescript"]
21
+ Category = Literal["obsolete", "platform-redundant", "preferred-stack"]
22
+
23
+
24
+ class _Location(NamedTuple):
25
+ line: int
26
+ column: int
27
+
28
+
29
+ class _PackageKey(NamedTuple):
30
+ ecosystem: Ecosystem
31
+ normalized_name: str
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class LibraryMapping:
36
+ id: str
37
+ ecosystem: Ecosystem
38
+ category: Category
39
+ imports: tuple[str, ...]
40
+ packages: tuple[str, ...]
41
+ replacement: str
42
+ message: str
43
+
44
+
45
+ def _mapping( # ruff: ignore[too-many-positional-arguments] - compact declarations keep the catalog auditable.
46
+ id_: str,
47
+ ecosystem: Ecosystem,
48
+ category: Category,
49
+ names: str,
50
+ replacement: str,
51
+ message: str,
52
+ *,
53
+ imports: str | None = None,
54
+ ) -> LibraryMapping:
55
+ packages = tuple(part.strip() for part in names.split(","))
56
+ import_names = tuple(
57
+ clean_name for part in (names if imports is None else imports).split(",") if (clean_name := part.strip())
58
+ )
59
+ return LibraryMapping(id_, ecosystem, category, import_names, packages, replacement, message)
60
+
61
+
62
+ # Adapters and the manifest scanner derive from this sole policy source.
63
+ CATALOG: Final[tuple[LibraryMapping, ...]] = (
64
+ _mapping(
65
+ "LIB001",
66
+ "python",
67
+ "preferred-stack",
68
+ "argparse,optparse",
69
+ "Typer",
70
+ "The application profile standardizes command-line interfaces on Typer.",
71
+ ),
72
+ _mapping(
73
+ "LIB002",
74
+ "python",
75
+ "preferred-stack",
76
+ "click",
77
+ "Typer",
78
+ "Use Typer for typed command-line interfaces instead of direct Click APIs.",
79
+ ),
80
+ _mapping(
81
+ "LIB003",
82
+ "python",
83
+ "preferred-stack",
84
+ "pandas",
85
+ "Polars",
86
+ "Use Polars; migration must account for its expressions and lack of a pandas index model.",
87
+ ),
88
+ _mapping(
89
+ "LIB004",
90
+ "python",
91
+ "preferred-stack",
92
+ "requests",
93
+ "HTTPX",
94
+ "Use HTTPX; review timeout defaults, exception types, streaming, and client lifetimes.",
95
+ ),
96
+ _mapping(
97
+ "LIB005",
98
+ "python",
99
+ "preferred-stack",
100
+ "ujson",
101
+ "orjson",
102
+ "Use orjson; its dumps function returns bytes and option semantics differ.",
103
+ ),
104
+ _mapping(
105
+ "LIB006",
106
+ "python",
107
+ "preferred-stack",
108
+ "flask",
109
+ "FastAPI",
110
+ "The application profile standardizes HTTP APIs on FastAPI; this is an architectural migration.",
111
+ ),
112
+ _mapping(
113
+ "LIB007",
114
+ "python",
115
+ "preferred-stack",
116
+ "marshmallow,cerberus",
117
+ "Pydantic",
118
+ "The application profile standardizes validation and serialization on Pydantic.",
119
+ ),
120
+ _mapping(
121
+ "LIB008",
122
+ "python",
123
+ "platform-redundant",
124
+ "pytz",
125
+ "zoneinfo",
126
+ "Use zoneinfo; explicitly review DST ambiguity, localization, and fold behavior.",
127
+ ),
128
+ _mapping(
129
+ "LIB009",
130
+ "python",
131
+ "platform-redundant",
132
+ "pkg_resources",
133
+ "importlib.metadata/importlib.resources/packaging",
134
+ "Do not use pkg_resources; choose the focused importlib or packaging API.",
135
+ imports="pkg_resources",
136
+ ),
137
+ _mapping("LIB010", "python", "obsolete", "tomli", "tomllib", "Python 3.14 provides tomllib."),
138
+ _mapping("LIB011", "python", "obsolete", "pathlib2", "pathlib", "Python 3.14 provides pathlib."),
139
+ _mapping(
140
+ "LIB012",
141
+ "python",
142
+ "obsolete",
143
+ "backports.zoneinfo",
144
+ "zoneinfo",
145
+ "Python 3.14 provides zoneinfo.",
146
+ imports="backports.zoneinfo",
147
+ ),
148
+ _mapping(
149
+ "LIB013",
150
+ "python",
151
+ "obsolete",
152
+ "importlib-metadata",
153
+ "importlib.metadata",
154
+ "Python 3.14 provides importlib.metadata.",
155
+ imports="importlib_metadata",
156
+ ),
157
+ _mapping(
158
+ "LIB014",
159
+ "python",
160
+ "obsolete",
161
+ "importlib-resources",
162
+ "importlib.resources",
163
+ "Python 3.14 provides importlib.resources.",
164
+ imports="importlib_resources",
165
+ ),
166
+ _mapping(
167
+ "LIB015",
168
+ "python",
169
+ "obsolete",
170
+ "dataclasses",
171
+ "dataclasses (stdlib)",
172
+ "Remove the obsolete dataclasses backport on Python 3.14.",
173
+ imports="",
174
+ ),
175
+ _mapping(
176
+ "LIB016",
177
+ "python",
178
+ "obsolete",
179
+ "enum34",
180
+ "enum",
181
+ "Remove the obsolete enum34 backport on Python 3.14.",
182
+ imports="enum34",
183
+ ),
184
+ _mapping(
185
+ "LIB017",
186
+ "python",
187
+ "obsolete",
188
+ "futures",
189
+ "concurrent.futures",
190
+ "Remove the obsolete futures backport on Python 3.14.",
191
+ imports="futures",
192
+ ),
193
+ _mapping(
194
+ "LIB018",
195
+ "python",
196
+ "obsolete",
197
+ "backports.cached-property",
198
+ "functools.cached_property",
199
+ "Python 3.14 provides functools.cached_property.",
200
+ imports="backports.cached_property",
201
+ ),
202
+ _mapping(
203
+ "LIB019",
204
+ "python",
205
+ "obsolete",
206
+ "boto",
207
+ "boto3",
208
+ "Boto 2 is obsolete; migrate to boto3 and review API differences.",
209
+ ),
210
+ _mapping(
211
+ "LIB020",
212
+ "python",
213
+ "obsolete",
214
+ "aioredis",
215
+ "redis.asyncio",
216
+ "aioredis was merged into redis-py; use redis.asyncio.",
217
+ ),
218
+ _mapping("LIB021", "python", "obsolete", "nose", "pytest", "Nose is unmaintained; use pytest."),
219
+ _mapping("LIB022", "python", "platform-redundant", "mock", "unittest.mock", "Python 3.14 provides unittest.mock."),
220
+ _mapping(
221
+ "LIB101",
222
+ "typescript",
223
+ "preferred-stack",
224
+ "request,node-fetch,cross-fetch,isomorphic-fetch,axios",
225
+ "ky",
226
+ "The application profile standardizes HTTP clients on Ky; review errors, retries, hooks, and response parsing.",
227
+ ),
228
+ _mapping(
229
+ "LIB102",
230
+ "typescript",
231
+ "preferred-stack",
232
+ "moment,dayjs",
233
+ "date-fns",
234
+ "The application profile standardizes date utilities on date-fns; migration is not API-compatible.",
235
+ ),
236
+ _mapping(
237
+ "LIB103",
238
+ "typescript",
239
+ "preferred-stack",
240
+ "lodash,lodash-es,underscore",
241
+ "remeda",
242
+ "The application profile standardizes collection utilities on Remeda and native APIs.",
243
+ ),
244
+ _mapping(
245
+ "LIB104",
246
+ "typescript",
247
+ "preferred-stack",
248
+ "classnames",
249
+ "clsx",
250
+ "Use clsx for conditional class-name composition.",
251
+ ),
252
+ _mapping(
253
+ "LIB105",
254
+ "typescript",
255
+ "preferred-stack",
256
+ "joi,yup,superstruct,io-ts,runtypes",
257
+ "zod",
258
+ "The application profile standardizes runtime validation on Zod; schemas are not drop-in compatible.",
259
+ ),
260
+ _mapping(
261
+ "LIB106",
262
+ "typescript",
263
+ "preferred-stack",
264
+ "jsonwebtoken",
265
+ "jose",
266
+ "Use jose; review key formats and async signing and verification APIs.",
267
+ ),
268
+ _mapping(
269
+ "LIB107",
270
+ "typescript",
271
+ "preferred-stack",
272
+ "express,koa",
273
+ "hono",
274
+ "The application profile standardizes servers on Hono; Node deployments also need @hono/node-server.",
275
+ ),
276
+ _mapping(
277
+ "LIB108",
278
+ "typescript",
279
+ "preferred-stack",
280
+ "jest,mocha",
281
+ "vitest",
282
+ "The application profile standardizes tests on Vitest; review globals, timers, mocks, and environment setup.",
283
+ ),
284
+ _mapping(
285
+ "LIB109",
286
+ "typescript",
287
+ "preferred-stack",
288
+ "sinon",
289
+ "Vitest mocks",
290
+ "Use Vitest spies, mocks, and fake timers instead of Sinon.",
291
+ ),
292
+ _mapping(
293
+ "LIB110",
294
+ "typescript",
295
+ "preferred-stack",
296
+ "commander,yargs",
297
+ "citty",
298
+ "The application profile standardizes command-line interfaces on citty.",
299
+ ),
300
+ _mapping(
301
+ "LIB111",
302
+ "typescript",
303
+ "platform-redundant",
304
+ "bluebird",
305
+ "native Promise",
306
+ "Use native Promise, adding p-limit or p-map only for the extensions actually needed.",
307
+ ),
308
+ _mapping(
309
+ "LIB112",
310
+ "typescript",
311
+ "platform-redundant",
312
+ "rimraf,fs-extra",
313
+ "node:fs/promises",
314
+ "Prefer node:fs/promises; verify recursive removal, copy, path, and error semantics.",
315
+ ),
316
+ _mapping(
317
+ "LIB113",
318
+ "typescript",
319
+ "platform-redundant",
320
+ "abort-controller",
321
+ "AbortController",
322
+ "Node 22 provides global AbortController.",
323
+ ),
324
+ _mapping(
325
+ "LIB114",
326
+ "typescript",
327
+ "platform-redundant",
328
+ "querystring",
329
+ "URLSearchParams",
330
+ "Use URLSearchParams and explicitly review repeated keys, escaping, arrays, and object coercion.",
331
+ ),
332
+ _mapping(
333
+ "LIB115",
334
+ "typescript",
335
+ "preferred-stack",
336
+ "dotenv",
337
+ "@dotenvx/dotenvx",
338
+ "The application profile standardizes environment loading on @dotenvx/dotenvx.",
339
+ ),
340
+ _mapping("LIB116", "typescript", "preferred-stack", "chalk", "picocolors", "Use picocolors for terminal colors."),
341
+ _mapping(
342
+ "LIB117",
343
+ "typescript",
344
+ "obsolete",
345
+ "faker",
346
+ "@faker-js/faker",
347
+ "The original faker package is abandoned; use @faker-js/faker.",
348
+ ),
349
+ _mapping("LIB118", "typescript", "obsolete", "node-sass", "sass", "node-sass is end-of-life; use Dart Sass."),
350
+ _mapping(
351
+ "LIB119",
352
+ "typescript",
353
+ "obsolete",
354
+ "tslint",
355
+ "eslint",
356
+ "TSLint is deprecated; use ESLint with typescript-eslint.",
357
+ ),
358
+ )
359
+
360
+
361
+ def catalog() -> tuple[LibraryMapping, ...]:
362
+ return CATALOG
363
+
364
+
365
+ def python_banned_api() -> dict[str, str]:
366
+ return {
367
+ name: f"{entry.id}: {entry.message} Replace with {entry.replacement}."
368
+ for entry in CATALOG
369
+ if entry.ecosystem == "python"
370
+ for name in entry.imports
371
+ }
372
+
373
+
374
+ @dataclass(frozen=True, slots=True)
375
+ class RestrictedImport:
376
+ name: str
377
+ message: str
378
+
379
+
380
+ def typescript_restricted_imports() -> tuple[RestrictedImport, ...]:
381
+ return tuple(
382
+ RestrictedImport(name, f"{entry.id}: {entry.message} Replace with {entry.replacement}.")
383
+ for entry in CATALOG
384
+ if entry.ecosystem == "typescript"
385
+ for name in entry.imports
386
+ )
387
+
388
+
389
+ @dataclass(frozen=True, slots=True)
390
+ class Finding:
391
+ id: str
392
+ path: Path
393
+ line: int
394
+ column: int
395
+ package: str
396
+ replacement: str
397
+ message: str
398
+
399
+ def render(self) -> str:
400
+ return f"{self.path}:{self.line}:{self.column} {self.id} {self.message} Replace with {self.replacement}."
401
+
402
+
403
+ class ManifestPolicyError(ValueError):
404
+ """An applicable dependency manifest cannot be parsed safely."""
405
+
406
+
407
+ _IGNORED_DIRS: Final = frozenset(
408
+ {
409
+ ".cache",
410
+ ".git",
411
+ ".hg",
412
+ ".mypy_cache",
413
+ ".next",
414
+ ".pytest_cache",
415
+ ".ruff_cache",
416
+ ".svn",
417
+ ".tox",
418
+ ".uv-cache",
419
+ ".venv",
420
+ "build",
421
+ "dist",
422
+ "node_modules",
423
+ "site-packages",
424
+ "vendor",
425
+ "venv",
426
+ }
427
+ )
428
+ _IGNORED_MANIFEST_DIRS: Final = frozenset({"fixture", "fixtures", "template", "templates", "test", "tests"})
429
+ _GENERATED_MARKERS: Final = (
430
+ "autogenerated by uv",
431
+ "autogenerated by pip-compile",
432
+ "auto-generated",
433
+ "generated by pip-compile",
434
+ "do not edit this file",
435
+ )
436
+ _REQUIREMENTS_NAME: Final = re.compile(r"^requirements(?:[-_.].*)?\.(?:txt|in)$", re.IGNORECASE)
437
+
438
+
439
+ def scan(root: Path, *, allowed_ids: Iterable[str] = ()) -> tuple[Finding, ...]:
440
+ root = root.resolve()
441
+ return _scan_manifests(root, _manifest_paths(root), allowed_ids=allowed_ids)
442
+
443
+
444
+ def scan_paths(
445
+ root: Path,
446
+ paths: Iterable[str | Path],
447
+ *,
448
+ allowed_ids: Iterable[str] = (),
449
+ ) -> tuple[Finding, ...]:
450
+ root = root.resolve()
451
+ selected: set[Path] = set()
452
+ for raw_path in paths:
453
+ candidate = Path(raw_path)
454
+ candidate = candidate if candidate.is_absolute() else root / candidate
455
+ resolved = candidate.resolve()
456
+ if not resolved.is_relative_to(root):
457
+ msg = f"dependency manifest escapes repository root: {raw_path}"
458
+ raise ManifestPolicyError(msg)
459
+ if resolved.is_dir():
460
+ selected.update(_manifest_paths(resolved))
461
+ elif resolved.is_file() and accepts_path(resolved, root):
462
+ selected.add(resolved)
463
+ return _scan_manifests(root, tuple(sorted(selected)), allowed_ids=allowed_ids)
464
+
465
+
466
+ def _scan_manifests(
467
+ root: Path,
468
+ paths: Iterable[Path],
469
+ *,
470
+ allowed_ids: Iterable[str],
471
+ ) -> tuple[Finding, ...]:
472
+ allowed = frozenset(allowed_ids)
473
+ package_index = _package_index()
474
+ findings: list[Finding] = []
475
+ for path in paths:
476
+ dependencies: tuple[tuple[Path, Ecosystem, str], ...]
477
+ if path.name == "pyproject.toml":
478
+ dependencies = tuple((path, ecosystem, package) for ecosystem, package in _pyproject_dependencies(path))
479
+ elif path.name == "package.json":
480
+ dependencies = tuple((path, ecosystem, package) for ecosystem, package in _package_json_dependencies(path))
481
+ else:
482
+ dependencies = _requirements_dependencies(path, root, frozenset())
483
+ for source, ecosystem, package in dependencies:
484
+ entry = package_index.get(_PackageKey(ecosystem, _normalize(package, ecosystem)))
485
+ if entry is None or entry.id in allowed:
486
+ continue
487
+ text = source.read_text(encoding="utf-8-sig")
488
+ line, column = _location(text, package)
489
+ findings.append(
490
+ Finding(entry.id, source.relative_to(root), line, column, package, entry.replacement, entry.message)
491
+ )
492
+ return tuple(sorted(set(findings), key=lambda item: (str(item.path), item.line, item.id, item.package)))
493
+
494
+
495
+ def _package_index() -> dict[_PackageKey, LibraryMapping]:
496
+ return {
497
+ _PackageKey(entry.ecosystem, _normalize(name, entry.ecosystem)): entry
498
+ for entry in CATALOG
499
+ for name in entry.packages
500
+ }
501
+
502
+
503
+ def _normalize(name: str, ecosystem: Ecosystem) -> str:
504
+ if ecosystem == "python":
505
+ return canonicalize_name(name)
506
+ return name.strip().lower()
507
+
508
+
509
+ def _manifest_paths(root: Path) -> tuple[Path, ...]:
510
+ paths: list[Path] = []
511
+ for path in root.rglob("*"):
512
+ relative_parts = path.relative_to(root).parts
513
+ if any(part in _IGNORED_DIRS for part in relative_parts):
514
+ continue
515
+ if not path.is_file():
516
+ continue
517
+ if accepts_path(path, root):
518
+ paths.append(path)
519
+ return tuple(sorted(paths))
520
+
521
+
522
+ def accepts_path(path: Path, root: Path) -> bool:
523
+ relative_parts = path.relative_to(root).parts
524
+ in_requirements_dir = "requirements" in relative_parts[:-1]
525
+ ignored_requirements_fixture = any(part.lower() in _IGNORED_MANIFEST_DIRS for part in relative_parts[:-1])
526
+ return path.name in {"pyproject.toml", "package.json"} or (
527
+ not ignored_requirements_fixture
528
+ and (
529
+ _REQUIREMENTS_NAME.match(path.name) is not None or (in_requirements_dir and path.suffix in {".txt", ".in"})
530
+ )
531
+ )
532
+
533
+
534
+ def _requirement_name(spec: str, where: str) -> str:
535
+ try:
536
+ return Requirement(spec).name
537
+ except InvalidRequirement as exc:
538
+ msg = f"invalid dependency in {where}: {spec!r}"
539
+ raise ManifestPolicyError(msg) from exc
540
+
541
+
542
+ def _pyproject_dependencies(path: Path) -> tuple[tuple[Ecosystem, str], ...]:
543
+ data = _read_toml(path)
544
+ result: list[tuple[Ecosystem, str]] = []
545
+ project = _table(data.get("project"))
546
+ lists = [_string_list(project.get("dependencies"), f"{path} project.dependencies")]
547
+ optional = _table(project.get("optional-dependencies"))
548
+ lists.extend(
549
+ _string_list(value, f"{path} project.optional-dependencies.{name}") for name, value in optional.items()
550
+ )
551
+ groups = _table(data.get("dependency-groups"))
552
+ for name, value in groups.items():
553
+ lists.append(_dependency_group_list(value, f"{path} dependency-groups.{name}"))
554
+ tool = _table(data.get("tool"))
555
+ poetry = _table(tool.get("poetry"))
556
+ poetry_tables = [_table(poetry.get("dependencies")), _table(poetry.get("dev-dependencies"))]
557
+ poetry_tables.extend(_table(_table(group).get("dependencies")) for group in _table(poetry.get("group")).values())
558
+ for table in poetry_tables:
559
+ result.extend(("python", name) for name in _table(table) if name.lower() != "python")
560
+ pdm = _table(tool.get("pdm"))
561
+ lists.extend(
562
+ _string_list(value, f"{path} tool.pdm.dev-dependencies.{name}")
563
+ for name, value in _table(pdm.get("dev-dependencies")).items()
564
+ )
565
+ uv = _table(tool.get("uv"))
566
+ lists.append(_string_list(uv.get("dev-dependencies"), f"{path} tool.uv.dev-dependencies"))
567
+ for specifications in lists:
568
+ result.extend(("python", _requirement_name(spec, str(path))) for spec in specifications)
569
+ return tuple(result)
570
+
571
+
572
+ def _read_toml(path: Path) -> dict[str, object]:
573
+ try:
574
+ parsed: object = tomllib.loads(path.read_text(encoding="utf-8-sig"))
575
+ except (OSError, UnicodeError, tomllib.TOMLDecodeError) as exc:
576
+ msg = f"cannot parse {path}: {exc}"
577
+ raise ManifestPolicyError(msg) from exc
578
+ return as_table(parsed)
579
+
580
+
581
+ def _table(value: object) -> Mapping[str, object]:
582
+ return as_table(value)
583
+
584
+
585
+ def _string_list(value: object, where: str) -> tuple[str, ...]:
586
+ if value is None:
587
+ return ()
588
+ values = list_field({"value": value}, "value")
589
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in values):
590
+ msg = f"{where} must be a list of dependency strings"
591
+ raise ManifestPolicyError(msg)
592
+ return tuple(item for item in values if isinstance(item, str))
593
+
594
+
595
+ def _dependency_group_list(value: object, where: str) -> tuple[str, ...]:
596
+ items = list_field({"value": value}, "value")
597
+ if not isinstance(value, list):
598
+ msg = f"{where} must be a list"
599
+ raise ManifestPolicyError(msg)
600
+ specifications: list[str] = []
601
+ for item in items:
602
+ if isinstance(item, str):
603
+ specifications.append(item)
604
+ continue
605
+ include = _table(item).get("include-group")
606
+ if isinstance(item, dict) and isinstance(include, str):
607
+ continue
608
+ msg = f"{where} entries must be dependency strings or include-group tables"
609
+ raise ManifestPolicyError(msg)
610
+ return tuple(specifications)
611
+
612
+
613
+ def _package_json_dependencies(path: Path) -> tuple[tuple[Ecosystem, str], ...]:
614
+ try:
615
+ parsed: object = json.loads(path.read_text(encoding="utf-8-sig")) # pyright: ignore[reportAny] - narrowed at the parser boundary
616
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
617
+ msg = f"cannot parse {path}: {exc}"
618
+ raise ManifestPolicyError(msg) from exc
619
+ if not isinstance(parsed, dict):
620
+ msg = f"{path} must contain a JSON object"
621
+ raise ManifestPolicyError(msg)
622
+ data = as_table(parsed) # pyright: ignore[reportUnknownArgumentType] - json object leaves are narrowed below
623
+ result: list[tuple[Ecosystem, str]] = []
624
+ for field in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"):
625
+ dependencies = data.get(field)
626
+ if dependencies is None:
627
+ continue
628
+ dependency_table = as_table(dependencies)
629
+ if not isinstance(dependencies, dict) or not all(isinstance(spec, str) for spec in dependency_table.values()):
630
+ msg = f"{path} {field} must map package names to string versions"
631
+ raise ManifestPolicyError(msg)
632
+ for name, value in dependency_table.items():
633
+ if not isinstance(value, str):
634
+ continue
635
+ spec = value
636
+ alias = re.match(r"^npm:((?:@[^/]+/)?[^@]+)(?:@|$)", spec)
637
+ result.append(("typescript", alias.group(1) if alias else name))
638
+ return tuple(result)
639
+
640
+
641
+ def _requirements_dependencies(
642
+ path: Path, root: Path, seen: frozenset[Path]
643
+ ) -> tuple[tuple[Path, Ecosystem, str], ...]:
644
+ resolved = path.resolve()
645
+ if resolved in seen:
646
+ msg = f"cyclic requirements include at {path}"
647
+ raise ManifestPolicyError(msg)
648
+ try:
649
+ text = path.read_text(encoding="utf-8-sig")
650
+ except (OSError, UnicodeError) as exc:
651
+ msg = f"cannot read {path}: {exc}"
652
+ raise ManifestPolicyError(msg) from exc
653
+ if any(marker in text[:500].lower() for marker in _GENERATED_MARKERS):
654
+ return ()
655
+ result: list[tuple[Path, Ecosystem, str]] = []
656
+ for raw in text.splitlines():
657
+ line = raw.strip()
658
+ if not line or line.startswith(("#", "-c ", "--constraint ")):
659
+ continue
660
+ if line.startswith(("-r ", "--requirement ")):
661
+ target = line.split(maxsplit=1)[1].strip()
662
+ included = (path.parent / target).resolve()
663
+ if not included.is_relative_to(root) or not included.is_file():
664
+ msg = f"requirements include from {path} is missing or outside the scan root: {target}"
665
+ raise ManifestPolicyError(msg)
666
+ result.extend(_requirements_dependencies(included, root, seen | {resolved}))
667
+ continue
668
+ editable = line.removeprefix("-e ").removeprefix("--editable ")
669
+ editable = re.split(r"\s+#", editable, maxsplit=1)[0].rstrip()
670
+ if not editable:
671
+ continue
672
+ if egg := re.search(r"[#&]egg=([^&]+)", editable):
673
+ result.append((path, "python", egg.group(1)))
674
+ continue
675
+ if editable.startswith((".", "/", "http://", "https://", "git+", "hg+", "svn+", "bzr+")):
676
+ continue
677
+ # Strip pip-only hash/options while retaining PEP 508 markers and URLs.
678
+ spec = re.split(r"\s+(?:--hash|--config-settings|--global-option)\b", editable, maxsplit=1)[0]
679
+ result.append((path, "python", _requirement_name(spec, str(path))))
680
+ return tuple(result)
681
+
682
+
683
+ def _location(text: str, package: str) -> _Location:
684
+ pattern = re.compile(re.escape(package), re.IGNORECASE)
685
+ match = pattern.search(text)
686
+ if match is None:
687
+ return _Location(1, 1)
688
+ return _Location(text.count("\n", 0, match.start()) + 1, match.start() - text.rfind("\n", 0, match.start()))