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,373 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import timedelta
5
+ from enum import StrEnum
6
+ import json
7
+ import math
8
+ from pathlib import Path
9
+ import re
10
+ import time
11
+ import tomllib
12
+ from types import MappingProxyType
13
+ from typing import TYPE_CHECKING, Final, Literal
14
+
15
+ from sarj_standards.libs.release._values import is_object_dict, string_object_dict
16
+ from sarj_standards.libs.release.process import ProcessFailureError, ProcessRunner, run_process
17
+
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable, Mapping
21
+
22
+ from sarj_standards.libs.release.registry import PublicationChecker
23
+
24
+
25
+ ManifestFormat = Literal["json", "toml"]
26
+ _GIT_NO_MATCH = 2
27
+ _GIT_MISSING_REVISION_CODES = frozenset((1, 128))
28
+ _TAGGER_EMAIL = "release-automation@sarj.ai"
29
+ _TAGGER_NAME = "sarj-ai release automation"
30
+
31
+
32
+ class ReleaseTargetId(StrEnum):
33
+ TYPESCRIPT = "typescript"
34
+ BOOTSTRAP = "bootstrap"
35
+ PYTHON = "python"
36
+ SQL = "sql"
37
+ IAC = "iac"
38
+ STANDARDS = "standards"
39
+ TSCONFIG = "tsconfig"
40
+ DOCS_UI = "docs-ui"
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class ReleaseTarget:
45
+ manifest: Path
46
+ format: ManifestFormat
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class ValidatedReleaseTag:
51
+ tag: str
52
+ target: str
53
+ version: str
54
+ manifest: Path
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class TagSyncResult:
59
+ created: tuple[str, ...]
60
+ existing: tuple[str, ...]
61
+
62
+
63
+ RELEASE_TARGETS: Final[Mapping[str, ReleaseTarget]] = MappingProxyType(
64
+ {
65
+ ReleaseTargetId.TYPESCRIPT: ReleaseTarget(Path("packages/typescript/package.json"), "json"),
66
+ ReleaseTargetId.BOOTSTRAP: ReleaseTarget(Path("packages/bootstrap/pyproject.toml"), "toml"),
67
+ ReleaseTargetId.PYTHON: ReleaseTarget(Path("packages/python/pyproject.toml"), "toml"),
68
+ ReleaseTargetId.SQL: ReleaseTarget(Path("packages/sql/pyproject.toml"), "toml"),
69
+ ReleaseTargetId.IAC: ReleaseTarget(Path("packages/iac/pyproject.toml"), "toml"),
70
+ ReleaseTargetId.STANDARDS: ReleaseTarget(Path("packages/standards/pyproject.toml"), "toml"),
71
+ ReleaseTargetId.TSCONFIG: ReleaseTarget(Path("packages/tsconfig/package.json"), "json"),
72
+ ReleaseTargetId.DOCS_UI: ReleaseTarget(Path("packages/docs-ui/package.json"), "json"),
73
+ }
74
+ )
75
+ RELEASE_ARTIFACT_PREFIXES: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType(
76
+ {
77
+ ReleaseTargetId.BOOTSTRAP: ("packages/bootstrap/src/",),
78
+ ReleaseTargetId.PYTHON: ("packages/python/src/",),
79
+ ReleaseTargetId.SQL: ("packages/sql/src/",),
80
+ ReleaseTargetId.IAC: ("packages/iac/src/",),
81
+ ReleaseTargetId.STANDARDS: ("packages/standards/src/",),
82
+ ReleaseTargetId.TYPESCRIPT: ("packages/typescript/src/",),
83
+ ReleaseTargetId.TSCONFIG: ("packages/tsconfig/base.json", "packages/tsconfig/strict.json"),
84
+ ReleaseTargetId.DOCS_UI: ("packages/docs-ui/src/",),
85
+ }
86
+ )
87
+ RELEASE_ARTIFACT_FILES: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType(
88
+ {
89
+ name: (target.manifest.as_posix(), str(target.manifest.parent / "LICENSE"))
90
+ for name, target in RELEASE_TARGETS.items()
91
+ }
92
+ )
93
+ _TAG_PATTERN: Final = re.compile(r"^(?P<target>[a-z][a-z0-9-]*)-v(?P<version>[^\s/]+)$")
94
+
95
+
96
+ def read_manifest_version(path: Path, manifest_format: ManifestFormat) -> str:
97
+ try:
98
+ data = _read_manifest(path, manifest_format)
99
+ except (OSError, json.JSONDecodeError, tomllib.TOMLDecodeError) as exc:
100
+ msg = f"could not read release manifest {path}: {exc}"
101
+ raise ValueError(msg) from exc
102
+ version = data.get("version")
103
+ if manifest_format == "toml" and not isinstance(version, str):
104
+ project = data.get("project")
105
+ project_data = string_object_dict(project, label="project table") if is_object_dict(project) else {}
106
+ version = project_data.get("version")
107
+ if not isinstance(version, str) or not version:
108
+ msg = f"release manifest {path} has no non-empty package version"
109
+ raise ValueError(msg)
110
+ return version
111
+
112
+
113
+ def _read_manifest(path: Path, manifest_format: ManifestFormat) -> dict[str, object]:
114
+ if manifest_format == "json":
115
+ untyped: object = json.loads(path.read_text(encoding="utf-8")) # pyright: ignore[reportAny]
116
+ return string_object_dict(untyped, label="release manifest")
117
+ with path.open("rb") as manifest:
118
+ untyped_toml: object = tomllib.load(manifest)
119
+ return string_object_dict(untyped_toml, label="release manifest")
120
+
121
+
122
+ def validate_release_tag(
123
+ tag: str,
124
+ repo_root: Path,
125
+ *,
126
+ targets: Mapping[str, ReleaseTarget] = RELEASE_TARGETS,
127
+ ) -> ValidatedReleaseTag:
128
+ match = _TAG_PATTERN.fullmatch(tag)
129
+ if match is None or match["target"] not in targets:
130
+ msg = f"unrecognized release tag: {tag}"
131
+ raise ValueError(msg)
132
+ target_name = match["target"]
133
+ target = targets[target_name]
134
+ resolved_root = repo_root.resolve()
135
+ manifest = (resolved_root / target.manifest).resolve()
136
+ try:
137
+ manifest.relative_to(resolved_root)
138
+ except ValueError as exc:
139
+ msg = f"release manifest escapes repository root: {target.manifest}"
140
+ raise ValueError(msg) from exc
141
+ actual = read_manifest_version(manifest, target.format)
142
+ tagged = match["version"]
143
+ if tagged != actual:
144
+ msg = f"{tag} does not match {target.manifest} version {actual}"
145
+ raise ValueError(msg)
146
+ return ValidatedReleaseTag(tag, target_name, actual, target.manifest)
147
+
148
+
149
+ def _current_tag(target_name: ReleaseTargetId, root: Path) -> str:
150
+ target = RELEASE_TARGETS.get(target_name)
151
+ if target is None:
152
+ msg = f"unsupported release target: {target_name}"
153
+ raise ValueError(msg)
154
+ version = read_manifest_version(root / target.manifest, target.format)
155
+ return f"{target_name}-v{version}"
156
+
157
+
158
+ def _remote_tag_exists(root: Path, tag: str, *, runner: ProcessRunner) -> bool:
159
+ try:
160
+ runner(
161
+ ("git", "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{tag}"),
162
+ cwd=root,
163
+ capture_output=True,
164
+ )
165
+ except ProcessFailureError as exc:
166
+ if exc.returncode == _GIT_NO_MATCH:
167
+ return False
168
+ raise
169
+ return True
170
+
171
+
172
+ def missing_remote_release_tags(
173
+ root: Path,
174
+ *,
175
+ runner: ProcessRunner = run_process,
176
+ ) -> tuple[str, ...]:
177
+ resolved = root.resolve()
178
+ tags = (_current_tag(ReleaseTargetId(target), resolved) for target in RELEASE_TARGETS)
179
+ return tuple(tag for tag in tags if not _remote_tag_exists(resolved, tag, runner=runner))
180
+
181
+
182
+ def verify_remote_release_tags(
183
+ root: Path,
184
+ *,
185
+ commit: str,
186
+ runner: ProcessRunner = run_process,
187
+ ) -> tuple[str, ...]:
188
+ resolved = root.resolve()
189
+ if not commit or commit.startswith("-"):
190
+ msg = "release tag verification requires an explicit publishing commit"
191
+ raise ValueError(msg)
192
+ resolved_commit = (
193
+ runner(
194
+ ("git", "rev-parse", "--verify", f"{commit}^{{commit}}"),
195
+ cwd=resolved,
196
+ capture_output=True,
197
+ ).stdout.strip()
198
+ or commit
199
+ )
200
+ missing: list[str] = []
201
+ for target_text in RELEASE_TARGETS:
202
+ target_name = ReleaseTargetId(target_text)
203
+ tag = _current_tag(target_name, resolved)
204
+ if not _remote_tag_exists(resolved, tag, runner=runner):
205
+ missing.append(tag)
206
+ continue
207
+ _require_remote_tag_commit(resolved, tag, target_name, resolved_commit, runner=runner)
208
+ return tuple(missing)
209
+
210
+
211
+ def create_release_tags(
212
+ root: Path,
213
+ targets: tuple[str, ...],
214
+ *,
215
+ commit: str,
216
+ runner: ProcessRunner = run_process,
217
+ publication_checker: PublicationChecker | None = None,
218
+ attempts: int = 1,
219
+ delay: timedelta = timedelta(0),
220
+ sleeper: Callable[[float], object] = time.sleep,
221
+ ) -> TagSyncResult:
222
+ from sarj_standards.libs.release.registry import ( # ruff: ignore[import-outside-top-level] -- avoid the tags/registry import cycle
223
+ publication_exists,
224
+ require_publication,
225
+ target_requirement,
226
+ )
227
+
228
+ resolved = root.resolve()
229
+ if not commit or commit.startswith("-"):
230
+ msg = "release tag creation requires an explicit publishing commit"
231
+ raise ValueError(msg)
232
+ if attempts < 1:
233
+ msg = "publication attempts must be at least one"
234
+ raise ValueError(msg)
235
+ delay_seconds = delay.total_seconds()
236
+ if not math.isfinite(delay_seconds) or delay_seconds < 0:
237
+ msg = "publication retry delay must be finite and non-negative"
238
+ raise ValueError(msg)
239
+ resolved_commit = (
240
+ runner(
241
+ ("git", "rev-parse", "--verify", f"{commit}^{{commit}}"),
242
+ cwd=resolved,
243
+ capture_output=True,
244
+ ).stdout.strip()
245
+ or commit
246
+ )
247
+ checker = publication_exists if publication_checker is None else publication_checker
248
+ created: list[str] = []
249
+ existing: list[str] = []
250
+ for target_text in dict.fromkeys(targets):
251
+ try:
252
+ target = ReleaseTargetId(target_text)
253
+ except ValueError as exc:
254
+ msg = f"unsupported release target: {target_text}"
255
+ raise ValueError(msg) from exc
256
+ tag = _current_tag(target, resolved)
257
+ if _remote_tag_exists(resolved, tag, runner=runner):
258
+ _require_remote_tag_commit(resolved, tag, target, resolved_commit, runner=runner)
259
+ existing.append(tag)
260
+ continue
261
+ requirement = target_requirement(resolved, target)
262
+ for attempt in range(attempts):
263
+ try:
264
+ require_publication(requirement, checker=checker)
265
+ break
266
+ except OSError, ValueError:
267
+ if attempt + 1 == attempts:
268
+ raise
269
+ _ = sleeper(delay_seconds)
270
+ if not _require_local_tag_commit(resolved, tag, resolved_commit, runner=runner):
271
+ version = tag.removeprefix(f"{target}-v")
272
+ runner(
273
+ (
274
+ "git",
275
+ "-c",
276
+ f"user.name={_TAGGER_NAME}",
277
+ "-c",
278
+ f"user.email={_TAGGER_EMAIL}",
279
+ "tag",
280
+ "-a",
281
+ tag,
282
+ commit,
283
+ "-m",
284
+ f"{target} {version}",
285
+ ),
286
+ cwd=resolved,
287
+ )
288
+ runner(("git", "push", "origin", f"refs/tags/{tag}"), cwd=resolved)
289
+ created.append(tag)
290
+ return TagSyncResult(tuple(created), tuple(existing))
291
+
292
+
293
+ def _require_remote_tag_commit(
294
+ root: Path,
295
+ tag: str,
296
+ target_name: ReleaseTargetId,
297
+ commit: str,
298
+ *,
299
+ runner: ProcessRunner,
300
+ ) -> None:
301
+ result = runner(
302
+ (
303
+ "git",
304
+ "ls-remote",
305
+ "--exit-code",
306
+ "--tags",
307
+ "origin",
308
+ f"refs/tags/{tag}",
309
+ f"refs/tags/{tag}^{{}}",
310
+ ),
311
+ cwd=root,
312
+ capture_output=True,
313
+ )
314
+ references = dict(line.split("\t", 1)[::-1] for line in result.stdout.splitlines() if "\t" in line)
315
+ actual = references.get(f"refs/tags/{tag}^{{}}", references.get(f"refs/tags/{tag}"))
316
+ if actual is None:
317
+ msg = f"existing remote tag {tag} has no resolvable object"
318
+ raise ValueError(msg)
319
+ try:
320
+ actual_commit = runner(
321
+ ("git", "rev-parse", "--verify", f"{actual}^{{commit}}"),
322
+ cwd=root,
323
+ capture_output=True,
324
+ ).stdout.strip()
325
+ except ProcessFailureError as exc:
326
+ msg = f"existing remote tag {tag} does not resolve to a commit"
327
+ raise ValueError(msg) from exc
328
+ if not actual_commit:
329
+ msg = f"existing remote tag {tag} does not resolve to a commit"
330
+ raise ValueError(msg)
331
+ actual = actual_commit
332
+ if actual == commit:
333
+ return
334
+ try:
335
+ runner(
336
+ ("git", "merge-base", "--is-ancestor", actual, commit),
337
+ cwd=root,
338
+ capture_output=True,
339
+ )
340
+ except ProcessFailureError as exc:
341
+ msg = f"existing remote tag {tag} points to {actual}, which is not an ancestor of publishing commit {commit}"
342
+ raise ValueError(msg) from exc
343
+ target_paths = (*RELEASE_ARTIFACT_FILES[target_name], *RELEASE_ARTIFACT_PREFIXES[target_name])
344
+ try:
345
+ runner(
346
+ ("git", "diff", "--quiet", actual or tag, commit, "--", *target_paths),
347
+ cwd=root,
348
+ capture_output=True,
349
+ )
350
+ except ProcessFailureError:
351
+ pass
352
+ else:
353
+ return
354
+ msg = f"existing remote tag {tag} points to {actual or 'an unknown object'}, not publishing commit {commit}"
355
+ raise ValueError(msg)
356
+
357
+
358
+ def _require_local_tag_commit(root: Path, tag: str, commit: str, *, runner: ProcessRunner) -> bool:
359
+ try:
360
+ result = runner(
361
+ ("git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"),
362
+ cwd=root,
363
+ capture_output=True,
364
+ )
365
+ except ProcessFailureError as exc:
366
+ if exc.returncode in _GIT_MISSING_REVISION_CODES:
367
+ return False
368
+ raise
369
+ actual = result.stdout.strip()
370
+ if actual == commit:
371
+ return True
372
+ msg = f"existing local tag {tag} points to {actual or 'an unknown object'}, not publishing commit {commit}"
373
+ raise ValueError(msg)
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ from pathlib import Path
6
+ from tempfile import TemporaryDirectory
7
+ from typing import TYPE_CHECKING, Literal
8
+
9
+ from sarj_standards.libs.release._values import is_object_dict, is_object_list, string_object_dict
10
+ from sarj_standards.libs.release.artifacts import (
11
+ load_package_json,
12
+ required_artifact_paths,
13
+ verify_package_tarball,
14
+ )
15
+ from sarj_standards.libs.release.process import (
16
+ ProcessResult,
17
+ ProcessRunner,
18
+ run_build_process,
19
+ run_process,
20
+ run_process_environment,
21
+ )
22
+
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Mapping
26
+
27
+
28
+ ReleaseMode = Literal["check", "pack", "publish"]
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class PackedArtifact:
33
+ path: Path
34
+ included_files: tuple[str, ...]
35
+
36
+
37
+ def pack_typescript(
38
+ package_root: Path,
39
+ destination: Path,
40
+ *,
41
+ runner: ProcessRunner = run_build_process,
42
+ ) -> PackedArtifact:
43
+ destination.mkdir(parents=True, exist_ok=True)
44
+ result = runner(
45
+ (
46
+ "npm",
47
+ "pack",
48
+ "--json",
49
+ "--ignore-scripts",
50
+ "--pack-destination",
51
+ str(destination.resolve()),
52
+ ),
53
+ cwd=package_root,
54
+ capture_output=True,
55
+ )
56
+ report = _npm_pack_report(result.stdout)
57
+ filename = report.get("filename")
58
+ if not isinstance(filename, str) or Path(filename).name != filename:
59
+ msg = "npm pack returned an invalid artifact manifest"
60
+ raise ValueError(msg)
61
+ included = _included_paths(report.get("files"))
62
+ package_manifest = load_package_json(package_root)
63
+ required = required_artifact_paths(package_manifest)
64
+ if not required:
65
+ msg = "package.json declares no publishable entry points"
66
+ raise ValueError(msg)
67
+ missing_report = next((path for path in required if path not in included), None)
68
+ if missing_report is not None:
69
+ msg = f"npm pack report omits exported entry point: {missing_report}"
70
+ raise ValueError(msg)
71
+ tarball = destination.resolve() / filename
72
+ name = package_manifest.get("name")
73
+ version = package_manifest.get("version")
74
+ if not isinstance(name, str) or not isinstance(version, str):
75
+ msg = "package.json must declare string name and version fields"
76
+ raise TypeError(msg)
77
+ verify_package_tarball(tarball, required, expected_name=name, expected_version=version)
78
+ return PackedArtifact(tarball, included)
79
+
80
+
81
+ def _npm_pack_report(output: str) -> dict[str, object]:
82
+ decoder = json.JSONDecoder()
83
+ reports: list[dict[str, object]] = []
84
+ for index, character in enumerate(output):
85
+ if character not in "[{":
86
+ continue
87
+ try:
88
+ candidate: object
89
+ candidate, _ = decoder.raw_decode(output[index:]) # pyright: ignore[reportAny]
90
+ except json.JSONDecodeError:
91
+ continue
92
+ report = _as_pack_report(candidate)
93
+ if report is not None:
94
+ reports.append(report)
95
+ if not reports:
96
+ msg = "npm pack returned no artifact manifest"
97
+ raise ValueError(msg)
98
+ return reports[-1]
99
+
100
+
101
+ def _as_pack_report(value: object) -> dict[str, object] | None:
102
+ if is_object_list(value):
103
+ if not value or not is_object_dict(value[0]):
104
+ return None
105
+ return _valid_pack_report(value[0])
106
+ if not is_object_dict(value):
107
+ return None
108
+ reports = tuple(
109
+ report
110
+ for item in string_object_dict(value, label="npm pack reports").values()
111
+ if (report := _valid_pack_report(item)) is not None
112
+ )
113
+ return reports[0] if len(reports) == 1 else None
114
+
115
+
116
+ def _valid_pack_report(value: object) -> dict[str, object] | None:
117
+ if not is_object_dict(value):
118
+ return None
119
+ report = string_object_dict(value, label="npm pack report")
120
+ return report if "filename" in report and "files" in report else None
121
+
122
+
123
+ def _included_paths(value: object) -> tuple[str, ...]:
124
+ if not is_object_list(value):
125
+ msg = "npm pack returned an invalid files manifest"
126
+ raise TypeError(msg)
127
+ included: list[str] = []
128
+ for item in value:
129
+ if not is_object_dict(item):
130
+ continue
131
+ path = string_object_dict(item, label="npm pack file").get("path")
132
+ if isinstance(path, str):
133
+ included.append(path)
134
+ return tuple(included)
135
+
136
+
137
+ def check_typescript(package_root: Path, *, runner: ProcessRunner = run_build_process) -> None:
138
+ for argv in (
139
+ ("npm", "ci", "--no-audit", "--no-fund"),
140
+ ("npm", "run", "lint"),
141
+ ("npm", "run", "typecheck"),
142
+ ("npm", "test"),
143
+ ):
144
+ runner(argv, cwd=package_root)
145
+
146
+
147
+ def run_typescript_release(
148
+ mode: ReleaseMode,
149
+ package_root: Path,
150
+ *,
151
+ destination: Path | None = None,
152
+ runner: ProcessRunner = run_process,
153
+ environment: Mapping[str, str] | None = None,
154
+ ) -> PackedArtifact | None:
155
+ if mode not in {"check", "pack", "publish"}:
156
+ msg = f"unsupported TypeScript release mode: {mode}"
157
+ raise ValueError(msg)
158
+ build_runner = run_build_process if runner is run_process else runner
159
+ if mode == "pack":
160
+ if destination is None:
161
+ msg = "pack mode requires a destination directory"
162
+ raise ValueError(msg)
163
+ return pack_typescript(package_root, destination, runner=build_runner)
164
+ if destination is not None:
165
+ msg = f"{mode} mode does not accept a destination directory"
166
+ raise ValueError(msg)
167
+ check_typescript(package_root, runner=build_runner)
168
+ with TemporaryDirectory(prefix="sarj-typescript-release-") as temporary:
169
+ artifact = pack_typescript(package_root, Path(temporary), runner=build_runner)
170
+ if mode == "publish":
171
+ publish_runner = runner
172
+ if runner is run_process and environment is not None:
173
+
174
+ def publish_runner(
175
+ argv: tuple[str, ...],
176
+ *,
177
+ cwd: Path,
178
+ capture_output: bool = False,
179
+ ) -> ProcessResult:
180
+ return run_process_environment(
181
+ argv,
182
+ cwd=cwd,
183
+ capture_output=capture_output,
184
+ environment=environment,
185
+ )
186
+
187
+ publish_runner(
188
+ ("npm", "publish", str(artifact.path), "--access", "public", "--ignore-scripts"),
189
+ cwd=package_root,
190
+ )
191
+ return None
File without changes