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,271 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from dataclasses import dataclass
5
+ from datetime import timedelta
6
+ import json
7
+ import math
8
+ from pathlib import Path
9
+ import re
10
+ import sys
11
+ import time
12
+ import tomllib
13
+ from types import MappingProxyType
14
+ from typing import TYPE_CHECKING, ClassVar, Final, Literal, Protocol
15
+ from urllib.error import HTTPError
16
+ from urllib.parse import quote
17
+ from urllib.request import Request, urlopen
18
+
19
+ from packaging.utils import InvalidSdistFilename, InvalidWheelFilename, parse_sdist_filename, parse_wheel_filename
20
+ from packaging.version import InvalidVersion, Version
21
+ from pydantic import BaseModel, ConfigDict, Field
22
+
23
+ from sarj_standards.libs.release._values import is_object_dict, is_object_list, string_object_dict
24
+ from sarj_standards.libs.release.tags import RELEASE_TARGETS, read_manifest_version
25
+
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Callable, Mapping, Sequence
29
+
30
+
31
+ RegistryKind = Literal["npm", "pypi"]
32
+
33
+
34
+ @dataclass(frozen=True, slots=True, order=True)
35
+ class RegistryRequirement:
36
+ registry: RegistryKind
37
+ name: str
38
+ version: str
39
+
40
+
41
+ class _PypiSimpleFile(BaseModel):
42
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore", strict=True)
43
+
44
+ filename: str = Field(min_length=1)
45
+
46
+
47
+ class _PypiSimpleResponse(BaseModel):
48
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore", strict=True)
49
+
50
+ files: tuple[_PypiSimpleFile, ...]
51
+
52
+
53
+ class PublicationChecker(Protocol):
54
+ def __call__(self, requirement: RegistryRequirement, /) -> bool: ...
55
+
56
+
57
+ class _Args(argparse.Namespace):
58
+ root: Path = Path()
59
+ attempts: int = 6
60
+ delay: timedelta = timedelta(seconds=10)
61
+
62
+
63
+ _TARGET_PACKAGES: Final[Mapping[str, tuple[RegistryKind, str]]] = MappingProxyType(
64
+ {
65
+ "typescript": ("npm", "@sarj/eslint-plugin"),
66
+ "bootstrap": ("pypi", "sarj-standards-bootstrap"),
67
+ "python": ("pypi", "sarj-python-lint"),
68
+ "sql": ("pypi", "sarj-sql-lint"),
69
+ "iac": ("pypi", "sarj-iac-lint"),
70
+ "standards": ("pypi", "code-standards"),
71
+ "tsconfig": ("npm", "@sarj/tsconfig"),
72
+ "docs-ui": ("npm", "@sarj/docs-ui"),
73
+ }
74
+ )
75
+ _EXACT_DEPENDENCY = re.compile(r"^(?P<name>[A-Za-z0-9_.-]+)==(?P<version>[^;\s]+)$")
76
+ _HTTP_OK = 200
77
+ _HTTP_NOT_FOUND = 404
78
+
79
+
80
+ def target_requirement(root: Path, target_name: str) -> RegistryRequirement:
81
+ target = RELEASE_TARGETS.get(target_name)
82
+ package = _TARGET_PACKAGES.get(target_name)
83
+ if target is None or package is None:
84
+ msg = f"unsupported release target: {target_name}"
85
+ raise ValueError(msg)
86
+ registry, name = package
87
+ version = read_manifest_version(root.resolve() / target.manifest, target.format)
88
+ return RegistryRequirement(registry, name, version)
89
+
90
+
91
+ def publication_exists(requirement: RegistryRequirement) -> bool:
92
+ if requirement.registry == "pypi":
93
+ # uv and pip resolve through the Simple API, so version JSON visibility alone does not make a wheel resolvable.
94
+ url = f"https://pypi.org/simple/{quote(requirement.name, safe='')}/"
95
+ accept = "application/vnd.pypi.simple.v1+json"
96
+ else:
97
+ url = f"https://registry.npmjs.org/{quote(requirement.name, safe='')}/{quote(requirement.version, safe='')}"
98
+ accept = "application/json"
99
+ request = Request( # ruff: ignore[suspicious-url-open-usage] -- URL is constructed only from fixed HTTPS registry origins.
100
+ url, headers={"Accept": accept}
101
+ )
102
+ try:
103
+ return _request_publication(request, requirement)
104
+ except HTTPError as exc:
105
+ if exc.code == _HTTP_NOT_FOUND:
106
+ return False
107
+ raise
108
+
109
+
110
+ def _request_publication(request: Request, requirement: RegistryRequirement) -> bool:
111
+ with urlopen(request, timeout=15) as response: # ruff: ignore[suspicious-url-open-usage] # pyright: ignore[reportAny] -- fixed registry origins
112
+ if response.status != _HTTP_OK: # pyright: ignore[reportAny]
113
+ return False
114
+ if requirement.registry == "npm":
115
+ return True
116
+ payload: bytes = response.read() # pyright: ignore[reportAny] -- urllib response is untyped.
117
+ document = _PypiSimpleResponse.model_validate_json(payload)
118
+ return any(_pypi_filename_has_version(item.filename, requirement.version) for item in document.files)
119
+
120
+
121
+ def _pypi_filename_has_version(filename: str, version: str) -> bool:
122
+ try:
123
+ expected = Version(version)
124
+ if filename.endswith(".whl"):
125
+ _name, actual, _build, _tags = parse_wheel_filename(filename)
126
+ else:
127
+ _name, actual = parse_sdist_filename(filename)
128
+ except InvalidSdistFilename, InvalidVersion, InvalidWheelFilename:
129
+ return False
130
+ return actual == expected
131
+
132
+
133
+ def require_publication(
134
+ requirement: RegistryRequirement,
135
+ *,
136
+ checker: PublicationChecker = publication_exists,
137
+ ) -> None:
138
+ if checker(requirement):
139
+ return
140
+ msg = f"{requirement.registry} publication is unavailable: {requirement.name}@{requirement.version}"
141
+ raise ValueError(msg)
142
+
143
+
144
+ def lint_config_requirements(root: Path) -> tuple[RegistryRequirement, ...]:
145
+ resolved = root.resolve()
146
+ pyproject = resolved / "packages/standards/pyproject.toml"
147
+ try:
148
+ with pyproject.open("rb") as stream:
149
+ parsed: object = tomllib.load(stream)
150
+ except (OSError, tomllib.TOMLDecodeError) as exc:
151
+ msg = f"could not read compatibility-bundle manifest {pyproject}: {exc}"
152
+ raise ValueError(msg) from exc
153
+ project = string_object_dict(parsed, label="standards manifest")
154
+ project_table = project.get("project")
155
+ if not is_object_dict(project_table):
156
+ msg = f"{pyproject} has no project table"
157
+ raise ValueError(msg)
158
+ dependencies = string_object_dict(project_table, label="standards project").get("dependencies")
159
+ if not is_object_list(dependencies):
160
+ msg = f"{pyproject} has no dependency list"
161
+ raise ValueError(msg)
162
+ requirements: list[RegistryRequirement] = []
163
+ for dependency in dependencies:
164
+ if not isinstance(dependency, str) or not dependency.startswith("sarj-"):
165
+ continue
166
+ match = _EXACT_DEPENDENCY.fullmatch(dependency)
167
+ if match is None:
168
+ msg = f"compatibility-bundle sibling must use an exact pin: {dependency}"
169
+ raise ValueError(msg)
170
+ requirements.append(RegistryRequirement("pypi", match["name"], match["version"]))
171
+
172
+ peers_path = resolved / "packages/standards/src/sarj_standards/configs/eslint.peers.json"
173
+ try:
174
+ peers_value: object = json.loads(peers_path.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
175
+ except (OSError, json.JSONDecodeError) as exc:
176
+ msg = f"could not read compatibility-bundle peers {peers_path}: {exc}"
177
+ raise ValueError(msg) from exc
178
+ peers = string_object_dict(peers_value, label="ESLint peer manifest").get("peers")
179
+ if not is_object_dict(peers):
180
+ msg = f"{peers_path} has no peers object"
181
+ raise ValueError(msg)
182
+ plugin_version = string_object_dict(peers, label="ESLint peers").get("@sarj/eslint-plugin")
183
+ if not isinstance(plugin_version, str) or not plugin_version:
184
+ msg = f"{peers_path} has no exact @sarj/eslint-plugin version"
185
+ raise ValueError(msg)
186
+ requirements.append(RegistryRequirement("npm", "@sarj/eslint-plugin", plugin_version))
187
+ return tuple(sorted(requirements))
188
+
189
+
190
+ def require_lint_config_dependencies(
191
+ root: Path,
192
+ *,
193
+ checker: PublicationChecker = publication_exists,
194
+ ) -> tuple[RegistryRequirement, ...]:
195
+ requirements = lint_config_requirements(root)
196
+ for requirement in requirements:
197
+ require_publication(requirement, checker=checker)
198
+ return requirements
199
+
200
+
201
+ def wait_for_lint_config_dependencies(
202
+ root: Path,
203
+ *,
204
+ attempts: int = 6,
205
+ delay: timedelta = timedelta(seconds=10),
206
+ checker: PublicationChecker = publication_exists,
207
+ sleeper: Callable[[float], object] = time.sleep,
208
+ ) -> tuple[RegistryRequirement, ...]:
209
+ if attempts < 1:
210
+ message = "publication attempts must be at least one"
211
+ raise ValueError(message)
212
+ delay_seconds = delay.total_seconds()
213
+ if not math.isfinite(delay_seconds) or delay_seconds < 0:
214
+ message = "publication retry delay must be finite and non-negative"
215
+ raise ValueError(message)
216
+ requirements = lint_config_requirements(root)
217
+ missing = set(requirements)
218
+ last_errors: dict[RegistryRequirement, str] = {}
219
+ for attempt in range(attempts):
220
+ for requirement in tuple(sorted(missing)):
221
+ try:
222
+ available = checker(requirement)
223
+ except OSError as exc:
224
+ last_errors[requirement] = f"{type(exc).__name__}: {exc}"
225
+ continue
226
+ if available:
227
+ missing.remove(requirement)
228
+ last_errors.pop(requirement, None)
229
+ if not missing:
230
+ return requirements
231
+ if attempt + 1 < attempts:
232
+ _ = sleeper(delay_seconds)
233
+ rendered = ", ".join(
234
+ f"{requirement.name}@{requirement.version}"
235
+ + (f" ({last_errors[requirement]})" if requirement in last_errors else "")
236
+ for requirement in sorted(missing)
237
+ )
238
+ message = f"publications unavailable after {attempts} attempt(s): {rendered}"
239
+ raise ValueError(message)
240
+
241
+
242
+ def main(argv: Sequence[str] | None = None) -> int:
243
+ parser = argparse.ArgumentParser()
244
+ parser.add_argument("--root", type=Path, required=True)
245
+ parser.add_argument("--attempts", type=int, default=6)
246
+ parser.add_argument(
247
+ "--delay-seconds",
248
+ dest="delay",
249
+ type=_duration_from_seconds,
250
+ default=timedelta(seconds=10),
251
+ )
252
+ args = parser.parse_args(argv, namespace=_Args())
253
+ try:
254
+ requirements = wait_for_lint_config_dependencies(
255
+ args.root,
256
+ attempts=args.attempts,
257
+ delay=args.delay,
258
+ )
259
+ except (OSError, TypeError, ValueError) as exc:
260
+ sys.stderr.write(f"error: {exc}\n")
261
+ return 2
262
+ sys.stdout.write(f"verified {len(requirements)} exact compatibility-bundle publications\n")
263
+ return 0
264
+
265
+
266
+ def _duration_from_seconds(value: str) -> timedelta:
267
+ return timedelta(seconds=float(value))
268
+
269
+
270
+ if __name__ == "__main__":
271
+ raise SystemExit(main())
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from dataclasses import dataclass
5
+ from datetime import UTC, datetime, timedelta
6
+ import json
7
+ from typing import TYPE_CHECKING, NamedTuple, Protocol, Self
8
+ from urllib.parse import quote
9
+ from urllib.request import Request, urlopen
10
+
11
+ from sarj_standards.libs.release._values import is_object_dict, string_object_dict
12
+
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable, Mapping
16
+ from pathlib import Path
17
+
18
+ _SCOPED_PACKAGE_PARTS = 2
19
+
20
+
21
+ class _PublicationTime(NamedTuple):
22
+ available: bool
23
+ published: datetime | None
24
+
25
+
26
+ def load_exact_exclusions(path: Path) -> frozenset[str]:
27
+ try:
28
+ lines = path.read_text(encoding="utf-8").splitlines()
29
+ except OSError as exc:
30
+ msg = f"could not read release-age exclusions {path}: {exc}"
31
+ raise ValueError(msg) from exc
32
+ exclusions: set[str] = set()
33
+ for line_number, raw_line in enumerate(lines, start=1):
34
+ value = raw_line.partition("#")[0].strip()
35
+ if not value:
36
+ continue
37
+ name, separator, version = value.rpartition("@")
38
+ if not separator or not name or not version or (name.startswith("@") and "/" not in name):
39
+ msg = f"{path}:{line_number}: expected an exact package@version exclusion"
40
+ raise ValueError(msg)
41
+ exclusions.add(value)
42
+ return frozenset(exclusions)
43
+
44
+
45
+ class PackumentFetcher(Protocol):
46
+ def __call__(self, package_name: str, /) -> Mapping[str, object]: ...
47
+
48
+
49
+ @dataclass(frozen=True, slots=True, order=True)
50
+ class PackageIdentity:
51
+ name: str
52
+ version: str
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class ReleaseAgePolicy:
57
+ minimum_age: timedelta = timedelta(days=14)
58
+ exclusions: frozenset[str] = frozenset()
59
+
60
+ def __post_init__(self) -> None:
61
+ if self.minimum_age < timedelta(0):
62
+ msg = "minimum release age must be non-negative"
63
+ raise ValueError(msg)
64
+
65
+ @classmethod
66
+ def from_strings(cls, days_value: str | None, exclusions: str | None) -> Self:
67
+ raw_days = "14" if days_value is None else days_value
68
+ try:
69
+ parsed_days = int(raw_days, 10)
70
+ except ValueError as exc:
71
+ msg = "MIN_RELEASE_AGE_DAYS must be a non-negative integer"
72
+ raise ValueError(msg) from exc
73
+ if parsed_days < 0 or str(parsed_days) != raw_days.strip():
74
+ msg = "MIN_RELEASE_AGE_DAYS must be a non-negative integer"
75
+ raise ValueError(msg)
76
+ parsed_exclusions = frozenset(value for item in (exclusions or "").split(",") if (value := item.strip()))
77
+ return cls(timedelta(days=parsed_days), parsed_exclusions)
78
+
79
+
80
+ @dataclass(frozen=True, slots=True, order=True)
81
+ class ReleaseAgeFailure:
82
+ identity: PackageIdentity
83
+ detail: str
84
+
85
+ def __str__(self) -> str:
86
+ return f"{self.identity.name}@{self.identity.version}: {self.detail}"
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class ReleaseAgeReport:
91
+ checked: tuple[PackageIdentity, ...]
92
+ failures: tuple[ReleaseAgeFailure, ...]
93
+
94
+ @property
95
+ def passed(self) -> bool:
96
+ return not self.failures
97
+
98
+
99
+ def locked_registry_packages(lockfile: Path, policy: ReleaseAgePolicy) -> tuple[PackageIdentity, ...]:
100
+ packages_value = _load_object(lockfile).get("packages")
101
+ if packages_value is None:
102
+ return ()
103
+ packages = string_object_dict(packages_value, label="package-lock packages")
104
+ identities: set[PackageIdentity] = set()
105
+ for lock_path, metadata_value in packages.items():
106
+ name = _package_name(lock_path)
107
+ if name is None or not is_object_dict(metadata_value):
108
+ continue
109
+ metadata = string_object_dict(metadata_value, label=f"package metadata for {lock_path}")
110
+ version = metadata.get("version")
111
+ resolved = metadata.get("resolved")
112
+ if not isinstance(version, str) or not version:
113
+ continue
114
+ if isinstance(resolved, str) and not resolved.startswith("https://registry.npmjs.org/"):
115
+ msg = f"lockfile package {name}@{version} resolves outside registry.npmjs.org: {resolved}"
116
+ raise ValueError(msg)
117
+ identity = PackageIdentity(name, version)
118
+ if name not in policy.exclusions and f"{name}@{version}" not in policy.exclusions:
119
+ identities.add(identity)
120
+ return tuple(sorted(identities))
121
+
122
+
123
+ def _load_object(path: Path) -> dict[str, object]:
124
+ try:
125
+ untyped: object = json.loads(path.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
126
+ except (OSError, json.JSONDecodeError) as exc:
127
+ msg = f"could not read npm lockfile {path}: {exc}"
128
+ raise ValueError(msg) from exc
129
+ return string_object_dict(untyped, label="npm lockfile")
130
+
131
+
132
+ def _package_name(lock_path: str) -> str | None:
133
+ marker = "node_modules/"
134
+ if marker not in lock_path:
135
+ return None
136
+ tail = lock_path.rpartition(marker)[2]
137
+ parts = tail.split("/")
138
+ if not parts[0]:
139
+ return None
140
+ if parts[0].startswith("@"):
141
+ return "/".join(parts[:_SCOPED_PACKAGE_PARTS]) if len(parts) >= _SCOPED_PACKAGE_PARTS and parts[1] else None
142
+ return parts[0]
143
+
144
+
145
+ def fetch_npm_packument(package_name: str) -> Mapping[str, object]:
146
+ url = f"https://registry.npmjs.org/{quote(package_name, safe='')}"
147
+ request = Request(url, headers={"Accept": "application/json"})
148
+ with urlopen(request, timeout=15) as response: # ruff: ignore[suspicious-url-open-usage] # pyright: ignore[reportAny] -- fixed trusted origin
149
+ untyped: object = json.load(response) # pyright: ignore[reportAny]
150
+ return string_object_dict(untyped, label=f"npm registry response for {package_name}")
151
+
152
+
153
+ def _utc_now() -> datetime:
154
+ return datetime.now(UTC)
155
+
156
+
157
+ def check_lockfile_release_age(
158
+ lockfile: Path,
159
+ policy: ReleaseAgePolicy | None = None,
160
+ *,
161
+ fetcher: PackumentFetcher = fetch_npm_packument,
162
+ clock: Callable[[], datetime] = _utc_now,
163
+ concurrency: int = 12,
164
+ ) -> ReleaseAgeReport:
165
+ if concurrency < 1:
166
+ msg = "release-age concurrency must be at least one"
167
+ raise ValueError(msg)
168
+ now = clock()
169
+ if now.tzinfo is None:
170
+ msg = "release-age clock must return a timezone-aware datetime"
171
+ raise ValueError(msg)
172
+ now = now.astimezone(UTC)
173
+ effective_policy = ReleaseAgePolicy() if policy is None else policy
174
+ identities = locked_registry_packages(lockfile, effective_policy)
175
+ cutoff = now - effective_policy.minimum_age
176
+
177
+ def check_one(identity: PackageIdentity) -> ReleaseAgeFailure | None:
178
+ return _check_identity(identity, cutoff=cutoff, now=now, fetcher=fetcher)
179
+
180
+ with ThreadPoolExecutor(max_workers=min(concurrency, max(1, len(identities)))) as executor:
181
+ checked = executor.map(check_one, identities)
182
+ failures = tuple(sorted(failure for failure in checked if failure is not None))
183
+ return ReleaseAgeReport(identities, failures)
184
+
185
+
186
+ def _check_identity(
187
+ identity: PackageIdentity,
188
+ *,
189
+ cutoff: datetime,
190
+ now: datetime,
191
+ fetcher: PackumentFetcher,
192
+ ) -> ReleaseAgeFailure | None:
193
+ available, published = _publication_time(fetcher(identity.name), identity.version)
194
+ if not available:
195
+ return ReleaseAgeFailure(identity, "publication time unavailable")
196
+ if published is None:
197
+ return ReleaseAgeFailure(identity, "unknown days old")
198
+ if published > cutoff:
199
+ age_days = (now - published).total_seconds() / timedelta(days=1).total_seconds()
200
+ return ReleaseAgeFailure(identity, f"{age_days:.1f} days old")
201
+ return None
202
+
203
+
204
+ def _publication_time(packument: Mapping[str, object], version: str) -> _PublicationTime:
205
+ time_value = packument.get("time")
206
+ if not is_object_dict(time_value):
207
+ return _PublicationTime(available=False, published=None)
208
+ published = string_object_dict(time_value, label="npm publication times").get(version)
209
+ if not isinstance(published, str):
210
+ return _PublicationTime(available=False, published=None)
211
+ try:
212
+ parsed = datetime.fromisoformat(published)
213
+ except ValueError:
214
+ return _PublicationTime(available=True, published=None)
215
+ return _PublicationTime(
216
+ available=True,
217
+ published=parsed.astimezone(UTC) if parsed.tzinfo is not None else None,
218
+ )