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.
- code_standards-7.0.0.dist-info/METADATA +53 -0
- code_standards-7.0.0.dist-info/RECORD +99 -0
- code_standards-7.0.0.dist-info/WHEEL +4 -0
- code_standards-7.0.0.dist-info/entry_points.txt +3 -0
- code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
- sarj_standards/__init__.py +30 -0
- sarj_standards/__main__.py +5 -0
- sarj_standards/_meta.py +22 -0
- sarj_standards/api.py +890 -0
- sarj_standards/cli/__init__.py +0 -0
- sarj_standards/cli/main.py +2466 -0
- sarj_standards/configs/cli-reference.v1.json +1 -0
- sarj_standards/configs/doctor.config.json +22 -0
- sarj_standards/configs/eslint.application.mjs +1366 -0
- sarj_standards/configs/eslint.peers.json +44 -0
- sarj_standards/configs/eslint.strict.mjs +1060 -0
- sarj_standards/configs/markdownlint.strict.yaml +12 -0
- sarj_standards/configs/pyright.strict.json +96 -0
- sarj_standards/configs/ruff.application.toml +363 -0
- sarj_standards/configs/ruff.strict.toml +338 -0
- sarj_standards/configs/rule-inventory.v1.json +1 -0
- sarj_standards/configs/rule-ledger.json +846 -0
- sarj_standards/configs/rule-warning-levels.v1.json +1 -0
- sarj_standards/configs/taplo.strict.toml +14 -0
- sarj_standards/configs/yamllint.strict.yaml +25 -0
- sarj_standards/libs/__init__.py +0 -0
- sarj_standards/libs/adoption/__init__.py +0 -0
- sarj_standards/libs/adoption/configs.py +36 -0
- sarj_standards/libs/adoption/doctor.py +1346 -0
- sarj_standards/libs/adoption/exclusions.py +66 -0
- sarj_standards/libs/adoption/hooks.py +423 -0
- sarj_standards/libs/adoption/launcher.py +240 -0
- sarj_standards/libs/adoption/lifecycle.py +493 -0
- sarj_standards/libs/adoption/manifest.py +550 -0
- sarj_standards/libs/adoption/packagemanager.py +285 -0
- sarj_standards/libs/adoption/retired_suppressions.py +371 -0
- sarj_standards/libs/adoption/scaffold.py +1660 -0
- sarj_standards/libs/adoption/service.py +441 -0
- sarj_standards/libs/adoption/transaction.py +274 -0
- sarj_standards/libs/adoption/upgrade.py +516 -0
- sarj_standards/libs/adoption/uvtool.py +62 -0
- sarj_standards/libs/catalogs/__init__.py +9 -0
- sarj_standards/libs/catalogs/slack_automations.py +627 -0
- sarj_standards/libs/corpus/__init__.py +25 -0
- sarj_standards/libs/corpus/manifest.py +211 -0
- sarj_standards/libs/corpus/snapshot.py +222 -0
- sarj_standards/libs/diagnostics/__init__.py +65 -0
- sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
- sarj_standards/libs/diagnostics/baseline.py +131 -0
- sarj_standards/libs/diagnostics/models.py +574 -0
- sarj_standards/libs/diagnostics/serialize.py +290 -0
- sarj_standards/libs/diagnostics/source.py +172 -0
- sarj_standards/libs/filesystem.py +11 -0
- sarj_standards/libs/linting/__init__.py +0 -0
- sarj_standards/libs/linting/analysis.py +422 -0
- sarj_standards/libs/linting/external.py +1454 -0
- sarj_standards/libs/linting/library_policy.py +688 -0
- sarj_standards/libs/linting/policy.py +152 -0
- sarj_standards/libs/linting/runner.py +442 -0
- sarj_standards/libs/linting/textlint.py +1605 -0
- sarj_standards/libs/release/__init__.py +98 -0
- sarj_standards/libs/release/_values.py +24 -0
- sarj_standards/libs/release/artifacts.py +191 -0
- sarj_standards/libs/release/causality.py +80 -0
- sarj_standards/libs/release/changes.py +48 -0
- sarj_standards/libs/release/process.py +128 -0
- sarj_standards/libs/release/publish.py +85 -0
- sarj_standards/libs/release/registry.py +271 -0
- sarj_standards/libs/release/release_age.py +218 -0
- sarj_standards/libs/release/rollout.py +1163 -0
- sarj_standards/libs/release/tags.py +373 -0
- sarj_standards/libs/release/typescript.py +191 -0
- sarj_standards/libs/repository/__init__.py +0 -0
- sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
- sarj_standards/libs/repository/comment_corpus.py +536 -0
- sarj_standards/libs/repository/config_generation.py +146 -0
- sarj_standards/libs/repository/docs.py +347 -0
- sarj_standards/libs/repository/hooks.py +118 -0
- sarj_standards/libs/repository/ledger.py +99 -0
- sarj_standards/libs/repository/repository.py +744 -0
- sarj_standards/libs/repository/rule_authoring.py +246 -0
- sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
- sarj_standards/libs/repository/rule_changes.py +318 -0
- sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
- sarj_standards/libs/repository/rule_lifecycle.py +167 -0
- sarj_standards/libs/repository/rule_maintenance.py +225 -0
- sarj_standards/libs/rules/__init__.py +74 -0
- sarj_standards/libs/rules/catalog.py +145 -0
- sarj_standards/libs/rules/contracts.py +382 -0
- sarj_standards/libs/rules/corpus_runner.py +365 -0
- sarj_standards/libs/rules/evaluation.py +177 -0
- sarj_standards/libs/setup/__init__.py +4 -0
- sarj_standards/libs/setup/repository.py +40 -0
- sarj_standards/py.typed +0 -0
- sarj_standards/schemas/__init__.py +4 -0
- sarj_standards/schemas/_paths.py +7 -0
- sarj_standards/schemas/rule-catalog.v1.json +1 -0
- sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
- 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
|
+
)
|