@nidvue/teamwork 1.1.2 → 1.4.2

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 (35) hide show
  1. package/CHANGELOG.md +326 -3
  2. package/README.md +80 -26
  3. package/package.json +1 -1
  4. package/skills/teamwork/AGENTS.md +57 -2
  5. package/skills/teamwork/SKILL.md +129 -25
  6. package/skills/teamwork/references/Adapters.md +4 -0
  7. package/skills/teamwork/references/Audit.md +3 -1
  8. package/skills/teamwork/references/Concurrency.md +22 -7
  9. package/skills/teamwork/references/Coordination.md +203 -0
  10. package/skills/teamwork/references/Dissent.md +10 -1
  11. package/skills/teamwork/references/Docs.md +3 -1
  12. package/skills/teamwork/references/Engineering.md +47 -8
  13. package/skills/teamwork/references/Evidence.md +18 -1
  14. package/skills/teamwork/references/Remotes.md +25 -7
  15. package/skills/teamwork/references/Roles.md +22 -4
  16. package/skills/teamwork/references/State.md +96 -12
  17. package/skills/teamwork/references/Testing.md +3 -1
  18. package/skills/teamwork/roles/ceo.md +50 -3
  19. package/skills/teamwork/roles/developer.md +8 -0
  20. package/skills/teamwork/roles/product-manager.md +4 -0
  21. package/skills/teamwork/roles/reviewer.md +97 -12
  22. package/skills/teamwork/scripts/check_release_consistency.py +1805 -0
  23. package/skills/teamwork/scripts/privacy_scan.py +105 -2
  24. package/skills/teamwork/src/teamwork/bundle.py +0 -7
  25. package/skills/teamwork/src/teamwork/config.py +53 -2
  26. package/skills/teamwork/src/teamwork/engine.py +2652 -385
  27. package/skills/teamwork/src/teamwork/gitutil.py +148 -10
  28. package/skills/teamwork/src/teamwork/harness.py +753 -0
  29. package/skills/teamwork/src/teamwork/hooks.py +400 -0
  30. package/skills/teamwork/src/teamwork/layout.py +10 -4
  31. package/skills/teamwork/src/teamwork/portable.py +6 -1
  32. package/skills/teamwork/src/teamwork/records.py +20 -0
  33. package/skills/teamwork/src/teamwork/resources.py +41 -7
  34. package/skills/teamwork/src/teamwork/version.py +1 -1
  35. package/skills/teamwork/templates/AGENTS.md +57 -2
@@ -0,0 +1,1805 @@
1
+ #!/usr/bin/env python3
2
+ """check_release_consistency.py - the release gate: published facts vs local facts.
3
+
4
+ Why this exists: an npm tarball, a git tag, a GitHub Release and a sentence in
5
+ ``README.md`` are four independent claims about "what version is out". Nothing in
6
+ the repository compared them, so a tarball built from a commit that was never
7
+ tagged shipped as a release while the docs still named an older version. This
8
+ checker makes that state fail a gate instead of being found by a reader.
9
+
10
+ It implements `docs/Team/Architecture.md` section 11 (the frozen release
11
+ consistency contract). It is read-only - it never tags, publishes, pushes or
12
+ writes - it runs offline by default, and a missing or unreadable input is an
13
+ error (exit 2), never a pass.
14
+
15
+ Public surface
16
+ --------------
17
+ Entry point and contract callables (section 11.1)::
18
+
19
+ main(argv: Optional[Sequence[str]] = None) -> int
20
+ payload_digest(root: Path, rev: Optional[str]) -> str # 11.3
21
+ _http_get_json(url: str) -> object # 11.1.4 network seam
22
+
23
+ One function per rule, each taking explicit inputs and no hidden global state so
24
+ a unit test can point it at a fixture tree::
25
+
26
+ rule_r1_tagged_version(repo_root) -> RuleResult
27
+ rule_r2_head_is_newer(repo_root) -> RuleResult
28
+ rule_r3_npm_payloads(repo_root, exceptions=(), fetch=None, fetch_bytes=None)
29
+ rule_r4_releases_have_tags(repo_root, fetch=None)
30
+ rule_r5_tags_have_releases(repo_root, fetch=None)
31
+ rule_r6_markdown_versions(repo_root) -> RuleResult
32
+ rule_r7_changelog(repo_root) -> RuleResult
33
+ rule_r8_install_channels(repo_root, fetch=None) -> RuleResult
34
+ rule_r9_markdown_tags(repo_root) -> RuleResult
35
+
36
+ ``fetch=None`` is what makes a rule offline: R3, R4, R5 and R8 print a ``SKIP``
37
+ whose detail says why, and R3, R7, R1, R2, R6 and R9 evaluate from the tree.
38
+ Passing ``fetch`` (and for R3 ``fetch_bytes``) is ``--online``.
39
+
40
+ Supporting helpers a test may use directly::
41
+
42
+ load_exceptions(path: Path) -> list[dict[str, str]]
43
+ markdown_paths(repo_root: Path) -> list[str]
44
+ RuleResult # rule, status, detail, findings, fatal
45
+ InputError # an input could not be read -> exit 2
46
+ HttpError # an HTTP status came back -> exit 2 (or R8's 404 case)
47
+
48
+ Design notes
49
+ ------------
50
+ * Python 3.9+, standard library only, no third-party import, no ``shell=True``.
51
+ * Every HTTP call funnels through one seam, ``_http_get``; ``_http_get_json`` is
52
+ the JSON face of it and ``_http_get_bytes`` the binary face (an npm ``.tgz`` is
53
+ not JSON, so section 11.2.3's tarball download cannot ride a JSON function).
54
+ Tests monkeypatch ``_http_get_json``/``_http_get_bytes``; R3/R4/R5/R8 take them
55
+ as parameters so a monkeypatch is not even required.* Status literals are exactly ``ok``, ``DRIFT`` and ``SKIP``. Exit code
56
+ precedence is ``2 > 1 > 0``: a run that finds drift *and* cannot read an input
57
+ exits ``2``.
58
+ * R9 matches the ``vX.Y.Z`` form only. Section 11.2.4 says "the token regex is
59
+ R6's without the project-context condition", but its DRIFT clause is about "a
60
+ ``vX.Y.Z`` token": a bare ``3.12.13`` in ``python: 3.12.13`` names a Python
61
+ version, not a tag, and turning it into "missing tag v3.12.13" would be a
62
+ defect rather than a finding. R6 owns bare version prose.
63
+
64
+ Measurement behind ``TARBALL_LAYOUT`` (11.2.3): the npm tarball does *not* keep
65
+ payload-relative paths. ``build_pi_package.copy_skill`` stages ``skills/teamwork/``
66
+ plus the pi-native layer at ``skills/teamwork-pi/`` and ``prompts/`` (= the tag
67
+ tree's ``templates/pi-package/``). Hashing the tarball where it sits produces a
68
+ digest a tag can never reproduce, so the shipped subtrees are re-laid out under
69
+ their payload paths before hashing. Measured on the published tarballs:
70
+ ``1.1.2`` -> ``a01d9f0cbee641c3`` = payload of ``3f4981d``; ``1.1.0`` ->
71
+ ``ebfc2538e3d3d605`` = payload of ``v1.1.0`` (identical once
72
+ ``scripts/build_pi_package.py`` is excluded, which is 11.5 example 3);
73
+ ``1.1.1`` -> ``726bc547afc41ead``, which matches no commit reachable from
74
+ ``git rev-list --all`` - 11.5 example 2. The commit count is deliberately not
75
+ stated: the measurement is over every reachable commit, and a number that rots
76
+ in a comment is exactly the defect class this gate removes.
77
+
78
+ Measurement behind the commit-only payload (11.3): the digest must be a
79
+ function of the revision, so the payload is read from a **git object**
80
+ (``git archive``) and never from the working tree. Reviewer-3 measured the two
81
+ paths disagreeing on one tree with a single committed link, and a digest that
82
+ moved when only ``.git/config`` changed - the working tree can hold a link
83
+ into ``.git``, and those bytes then land in the digest and in the printed
84
+ output. So ``rev=None`` means the commit ``HEAD`` names, both code paths are
85
+ the one path, and a link whose target leaves the repository is refused on
86
+ every path.
87
+
88
+ Exit-code policy for an input that cannot be read (11.1.1): section 11 makes a
89
+ missing or unreadable input exit ``2``, and 11.1.4 names a non-200 response, a
90
+ URL error and a timeout as that failed API call. Section 11.2.4 carves out one
91
+ case - R8's PyPI ``404`` means "the distribution is unpublished", which is a
92
+ fact about the release, not a failed read. R3 has the same shape: an HTTP
93
+ ``404`` on a version's ``dist.tarball`` is treated like a ``vX.Y.Z`` tag that
94
+ does not exist - DRIFT - because the tarball the registry advertises is absent,
95
+ which is exactly what the gate exists to report. Every other transport/status
96
+ failure (``5xx``, timeout, URL error) is *unreadable input*: ``fatal=True``,
97
+ exit ``2``, never a DRIFT finding. A network flake must be distinguishable from
98
+ a release defect, so 5xx/timeout can never share an exit code with drift.
99
+ """
100
+
101
+ from __future__ import annotations
102
+
103
+ import argparse
104
+ import fnmatch
105
+ import hashlib
106
+ import json
107
+ import os
108
+ import re
109
+ import subprocess
110
+ import sys
111
+ import tarfile
112
+ import tempfile
113
+ import urllib.error
114
+ import urllib.parse
115
+ import urllib.request
116
+ from collections.abc import Iterable, Sequence
117
+ from dataclasses import dataclass
118
+ from pathlib import Path
119
+ from typing import Any, Callable
120
+
121
+ __all__ = [
122
+ "HttpError",
123
+ "InputError",
124
+ "RuleResult",
125
+ "load_exceptions",
126
+ "main",
127
+ "markdown_paths",
128
+ "payload_digest",
129
+ "rule_r1_tagged_version",
130
+ "rule_r2_head_is_newer",
131
+ "rule_r3_npm_payloads",
132
+ "rule_r4_releases_have_tags",
133
+ "rule_r5_tags_have_releases",
134
+ "rule_r6_markdown_versions",
135
+ "rule_r7_changelog",
136
+ "rule_r8_install_channels",
137
+ "rule_r9_markdown_tags",
138
+ ]
139
+
140
+ #: The single source of the version string, and the only local version fact.
141
+ VERSION_PATH = "src/teamwork/version.py"
142
+
143
+ #: Bundled payload (11.3) - the shipped set, asserted by build_pi_package.py
144
+ #: against ``teamwork.bundle.BUNDLE_FILES``/``BUNDLE_TREES``.
145
+ PAYLOAD_FILES = ("SKILL.md", "AGENTS.md")
146
+ PAYLOAD_TREES = ("references", "roles", "templates", "scripts", "src")
147
+ #: 11.2.3: the build-time generator is not shipped payload, so it is excluded
148
+ #: from every digest on every run - a repository-side edit to it is not drift.
149
+ PAYLOAD_EXCLUDES = frozenset({"scripts/build_pi_package.py"})
150
+
151
+ #: Tarball path prefix -> payload path prefix (11.2.3 + build_pi_package.copy_skill).
152
+ TARBALL_LAYOUT = (
153
+ ("skills/teamwork", ""),
154
+ ("skills/teamwork-pi", "templates/pi-package/skills/teamwork-pi"),
155
+ ("prompts", "templates/pi-package/prompts"),
156
+ )
157
+
158
+ #: The environment variables that may carry a GitHub API token, in precedence
159
+ #: order: ``GITHUB_TOKEN`` is the GitHub Actions name, ``GH_TOKEN`` the ``gh``
160
+ #: CLI's convention. The names are literals in a tuple; the values read from
161
+ #: them are called *bearers* below, so that no line of this file reads like a
162
+ #: credential assignment.
163
+ GITHUB_BEARER_ENV = ("GITHUB_TOKEN", "GH_TOKEN")
164
+ #: The only hosts a GitHub token may be sent to. A token is a credential for
165
+ #: GitHub and for nothing else: the npm registry and the PyPI JSON API must
166
+ #: never receive it, or a token that is read from the environment leaks to a
167
+ #: third party.
168
+ GITHUB_BEARER_HOSTS = ("api.github.com", "github.com")
169
+
170
+ NPM_REGISTRY = "https://registry.npmjs.org/@nidvue%2Fteamwork"
171
+ GITHUB_RELEASES = "https://api.github.com/repos/Nidvue/Teamwork/releases"
172
+ PYPI_JSON = "https://pypi.org/pypi/teamwork-agents/json"
173
+ USER_AGENT = "teamwork-release-consistency/1.0 (+https://github.com/Nidvue/Teamwork)"
174
+ HTTP_TIMEOUT = 20
175
+
176
+ #: The exact literals of 11.2.4. Nothing else is a marker.
177
+ ILLUSTRATIVE = "<!-- release-consistency: illustrative -->"
178
+ CHANNEL_UNAVAILABLE = "<!-- release-consistency: channel-unavailable -->"
179
+
180
+ EXCEPTION_KEYS = ("version", "reason", "evidence", "recorded")
181
+
182
+ #: 11.2.4: historical evidence is never rewritten to satisfy a gate.
183
+ SCAN_EXCLUDED = "docs/Team/Archive.md"
184
+
185
+ #: 11.2.4 R6 scope - the narrower prose set. R8 and R9 scan every committed .md.
186
+ R6_GLOBS = ("README.md", "docs/index.md", "docs/Team/Release.md", "docs/guide/*.md")
187
+
188
+ SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
189
+ #: A version npm can publish: ``X.Y.Z`` plus an optional prerelease/build tail.
190
+ #: Wider than ``SEMVER`` on purpose - a published version that cannot be a tag
191
+ #: (``0.0.0-stage``) still has to be *nameable* in 11.4's record, or the only
192
+ #: escape left is the silent skip this rule exists to remove. It is still a
193
+ #: narrow format: prose, a branch name or a typo is not a version.
194
+ PUBLISHED_VERSION = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$")
195
+ VERSION_TAG = re.compile(r"^v(\d+\.\d+\.\d+)$")
196
+ VERSION_ASSIGN = re.compile(r"""__version__\s*=\s*["']([^"']+)["']""")
197
+ DIST_NAME_ASSIGN = re.compile(r"""DIST_NAME\s*=\s*["']([^"']+)["']""")
198
+ ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
199
+ #: 11.2.4 version token: a ``/`` or ``.`` abuts a URL/link destination, so it
200
+ #: does not match; that is what keeps ``.../compare/v1.1.2...HEAD`` out.
201
+ VERSION_TOKEN = re.compile(r"(?<![\w./-])v?(\d+\.\d+\.\d+)(?![\w./-])")
202
+ #: The one URL shape that *is* a version claim (11.2.4): the tag in a
203
+ #: ``releases/download/<tag>/<asset>`` link says which release the wheel came
204
+ #: from, so a superseded tag makes the install command install a superseded
205
+ #: wheel. VERSION_TOKEN cannot see it - a ``/`` abuts the token on both sides -
206
+ #: and the exclusion above was written to keep a ``compare/`` *range* out, not
207
+ #: a release tag, so the shape is read on its own. The lookbehind is the literal
208
+ #: path, so ``.../compare/v1.1.0...HEAD`` still matches nothing, and group 1 is
209
+ #: the bare tag.
210
+ RELEASE_ASSET_TAG = re.compile(r"(?<=releases/download/)v?(\d+\.\d+\.\d+)(?=/)")
211
+ #: 11.2.4 R9: the frozen tag-claim pattern, copied from the contract byte for
212
+ #: byte. It has exactly **one** capture group, so the claimed tag is always
213
+ #: ``f"v{match.group(1)}"`` and no branch of the alternation can say ``vNone``.
214
+ TAGGED_TOKEN = re.compile(
215
+ r"(?:(?<![\w./-])v"
216
+ r"|(?<![\w./-])(?:--(?:branch|tag|ref)[\s=]+|git\s+(?:checkout|switch)\s+"
217
+ r"|releases/tag/|compare/)v?)"
218
+ r"(\d+\.\d+\.\d+)(?![-/\w])(?!\.\w)"
219
+ )
220
+ PROJECT_CONTEXT = re.compile(r"(?i)teamwork")
221
+ #: 11.2.4 R6 pin token: a version inside ``--branch``/``--tag``/``--ref`` or
222
+ #: after ``git checkout``/``git switch`` is a version *claim* even when the line
223
+ #: never says "teamwork". ``docs/guide/install.md`` pins a tag in a console block
224
+ #: that names no project, so the project-context gate hid it, and R9 (which reads
225
+ #: the same shapes through ``TAGGED_TOKEN``) only asks whether the tag exists,
226
+ #: never whether it is stale. Kept separate from ``TAGGED_TOKEN`` on purpose: R6
227
+ #: must not start judging every bare ``vX.Y.Z`` against the tags - a bare version
228
+ #: is prose and is judged against HEAD, and ``TAGGED_TOKEN`` would also match the
229
+ #: ``releases/tag/`` and ``compare/`` shapes R6 reads another way.
230
+ PIN_VERSION = re.compile(
231
+ r"(?<![\w./-])(?:--(?:branch|tag|ref)[\s=]+|git\s+(?:checkout|switch)\s+)"
232
+ r"v?(\d+\.\d+\.\d+)(?![-/\w])(?!\.\w)"
233
+ )
234
+ #: 11.2.4: a foreign tool's version is not a teamwork version. Matched per
235
+ #: token, not per line: ``Install Teamwork 1.0.2 to match python: 3.12.13``
236
+ #: skips ``3.12.13`` and still flags our own stale ``1.0.2``.
237
+ FOREIGN_VERSION = re.compile(r"(?i)\b(?:python3?|cpython|node|npm|pi)\s*[:=]\s*v?(\d+\.\d+\.\d+)")
238
+ CHANGELOG_HEADING = re.compile(r"^## \[(\d+\.\d+\.\d+)\]")
239
+ FENCE_OPEN = re.compile(r"^(`{3,}|~{3,})")
240
+ PIP_PLAIN = re.compile(r"\bpip3?\s+install\s+(\S+)")
241
+ PIP_MODULE = re.compile(r"\b(?:python3?|py)\s+-m\s+pip3?\s+install\s+(\S+)")
242
+ PIPX = re.compile(r"\bpipx\s+install\s+(\S+)")
243
+ #: The two run-without-installing shapes: ``uvx --from <dist> <script>`` and
244
+ #: ``pipx run --spec <dist> <script>``. The script argument is read by
245
+ #: ``_trailing_script`` because prose continues after the distribution name and
246
+ #: a bare ``\S+`` would invent an executable out of the next English word.
247
+ UVX_FROM = re.compile(r"\buvx\s+--from\s+(\S+)")
248
+ PIPX_RUN = re.compile(r"\bpipx\s+run\s+--spec\s+(\S+)")
249
+ #: One shell-ish argument after the distribution name. ``_trailing_script`` skips
250
+ #: any ``--flag value`` pairs in between (``--python 3.12``), so the *first*
251
+ #: positional token is the script - requiring the script to be the token right
252
+ #: after the distribution dropped it, so ``uvx --from <dist> --python 3.12
253
+ #: wrongscript --version`` came back nameless and unfindable. The token stops at
254
+ #: a backtick, so an inline-code command (`` `uvx --from X wrongscript` ``) yields
255
+ #: the script and not the closing delimiter.
256
+ ARGUMENT = re.compile(r"[ \t]+([^\s`]+)")
257
+ #: A script argument is a bare word, and the command must end after it: a flag, a
258
+ #: closing quote/bracket or the end of the line (``teamwork --version``,
259
+ #: ``teamwork` ``). Prose that continues after the distribution
260
+ #: (``teamwork-agents` is the fastest way to try it``) therefore names none.
261
+ SCRIPT_WORD = re.compile(r"[A-Za-z0-9_-](?:[A-Za-z0-9_.-]*[A-Za-z0-9_-])?")
262
+ SCRIPT_TERMINATOR = re.compile(r"(?:[ \t]+-{1,2}\S|[ \t]*[`\"')\]]|[ \t]*$)")
263
+ NEGATED_CHANNEL = re.compile(
264
+ r"(?i)\b(no|not|never|without)\s+(?:a\s+|an\s+|any\s+)?"
265
+ r"`?(?:pip3?|pipx|uvx|uv)`?\s+(?:install|run|--from)"
266
+ )
267
+ #: A hazard statement: the line ties a distribution to *another* project, so a
268
+ #: distribution named on it is named in order to warn against it and is not an
269
+ #: advertisement ("the ``teamwork`` PyPI name is taken, so ``pip install
270
+ #: teamwork`` gets the wrong project"). Three shapes are recognised - a
271
+ #: project-like noun after a disqualifying adjective ("the wrong project"), a
272
+ #: name that is taken or unrelated, and a name that belongs to somebody else.
273
+ #: A bare "wrong" is deliberately **not** enough: the noun after it must be a
274
+ #: project-like word, so "the wrong way to install is ``pip install teamwork``"
275
+ #: stays an advertisement.
276
+ WARNING_CONTEXT = re.compile(
277
+ r"(?i)\b(?:"
278
+ r"(?:wrong|other|different|another|not\s+this|someone\s+else)\s+"
279
+ r"(?:project|package|distribution|tool|software|module)"
280
+ r"|name\b[^.]{0,30}?\btaken\b"
281
+ r"|\b(?:taken|unrelated)\b[^.]{0,40}\bname\b"
282
+ r"|belongs\s+to\s+(?:another\s+|some\s+|someone\s+else\s+|a\s+|an\s+|the\s+)?"
283
+ r"(?:project|package|distribution|tool|software|module|company|organisation"
284
+ r"|organization|team|owner)"
285
+ r")"
286
+ )
287
+
288
+
289
+ class InputError(RuntimeError):
290
+ """An input could not be read; the run must exit 2 rather than pass."""
291
+
292
+
293
+ class HttpError(InputError):
294
+ """An HTTP call returned a status other than 200.
295
+
296
+ ``status`` is kept so R8 can read the 404 that 11.2.4 makes a DRIFT
297
+ ("the distribution is unpublished") instead of the blanket exit 2 of 11.1.4.
298
+ """
299
+
300
+ def __init__(self, url: str, status: int | None) -> None:
301
+ self.url = url
302
+ self.status = status
303
+ super().__init__(f"{url}: HTTP {status}")
304
+
305
+
306
+ @dataclass(frozen=True)
307
+ class RuleResult:
308
+ """One rule's verdict: a table line, plus the findings behind it."""
309
+
310
+ rule: str
311
+ status: str
312
+ detail: str
313
+ findings: tuple[str, ...] = ()
314
+ #: An input could not be read in this rule - exit 2 wins over any drift.
315
+ fatal: bool = False
316
+
317
+
318
+ def _drift(rule: str, findings: Sequence[str], notes: Sequence[str] = ()) -> RuleResult:
319
+ findings = tuple(_one_line(text) for text in findings)
320
+ detail = "; ".join(list(findings) + list(notes))
321
+ return RuleResult(rule, "DRIFT", detail, findings)
322
+
323
+
324
+ def _ok(rule: str, detail: str) -> RuleResult:
325
+ return RuleResult(rule, "ok", _one_line(detail))
326
+
327
+
328
+ def _skip(rule: str, detail: str, fatal: bool = False) -> RuleResult:
329
+ return RuleResult(rule, "SKIP", _one_line(detail), fatal=fatal)
330
+
331
+
332
+ def _unreadable(
333
+ rule: str,
334
+ findings: Sequence[str],
335
+ notes: Sequence[str],
336
+ reason: str,
337
+ ) -> RuleResult:
338
+ """An input could not be read (11.1.1): exit 2, and every DRIFT finding kept.
339
+
340
+ The status stays ``DRIFT`` when there is one, so ``problems`` in ``--json``
341
+ still names it; ``fatal=True`` makes the exit code ``2`` win over ``1``.
342
+ With no drift it is the ``SKIP`` that carries the reason.
343
+ """
344
+ if findings:
345
+ detail = "; ".join([*findings, f"unreadable input: {reason}", *notes])
346
+ return RuleResult(rule, "DRIFT", _one_line(detail), tuple(findings), fatal=True)
347
+ return _skip(rule, "; ".join([f"unreadable input: {reason}", *notes]), fatal=True)
348
+
349
+
350
+ def _one_line(text: str) -> str:
351
+ """A detail is never empty and never contains a newline (11.1.2)."""
352
+ flat = " ".join(str(text).split())
353
+ return flat or "(no detail)"
354
+
355
+
356
+ # --------------------------------------------------------------------------- #
357
+ # Process and network seams
358
+ # --------------------------------------------------------------------------- #
359
+
360
+
361
+ #: The bearer for the current run, read **once** when an online run starts (so a
362
+ #: caller may change the environment between two runs and be sure each run saw
363
+ #: one value). ``None`` means "send no Authorization header at all", which is
364
+ #: the anonymous behaviour a local run must keep byte for byte.
365
+ _GITHUB_BEARER: str | None = None
366
+ #: Whether the bearer has already been resolved for this process. The lazy
367
+ #: fallback resolves it at the first request, so a caller that uses
368
+ #: ``_http_get_json`` directly (a test) still gets the bearer; an offline run
369
+ #: never reaches that point and therefore never reads the environment.
370
+ _BEARER_RESOLVED = False
371
+
372
+
373
+ def _github_bearer_from_env() -> str | None:
374
+ """The bearer in the environment, or ``None`` when there is none."""
375
+ for name in GITHUB_BEARER_ENV:
376
+ value = os.environ.get(name)
377
+ if value and value.strip():
378
+ return value.strip()
379
+ return None
380
+
381
+
382
+ def _set_github_bearer(bearer: str | None) -> None:
383
+ """Fix the bearer for this process; ``None`` clears it (anonymous again)."""
384
+ global _GITHUB_BEARER, _BEARER_RESOLVED
385
+ _GITHUB_BEARER = bearer.strip() if bearer and bearer.strip() else None
386
+ _BEARER_RESOLVED = True
387
+
388
+
389
+ def _current_github_bearer() -> str | None:
390
+ """The bearer of this run, resolving it from the environment at most once."""
391
+ if not _BEARER_RESOLVED:
392
+ _set_github_bearer(_github_bearer_from_env())
393
+ return _GITHUB_BEARER
394
+
395
+
396
+ def _is_github_host(url: str) -> bool:
397
+ """Whether ``url`` names a GitHub host a token may be sent to.
398
+
399
+ The host is taken from the **URL**, never from a global flag: the same
400
+ process fetches the npm registry and the PyPI JSON API, and only the GitHub
401
+ requests may carry the credential.
402
+ """
403
+ try:
404
+ host = (urllib.parse.urlsplit(url).hostname or "").lower()
405
+ except ValueError:
406
+ return False
407
+ return host in GITHUB_BEARER_HOSTS or host.endswith(".github.com")
408
+
409
+
410
+ def _redact(text: str) -> str:
411
+ """Strip any ``Authorization`` header value out of a message.
412
+
413
+ Nothing in this file quotes request headers today; this is the belt to
414
+ that braces, so a future error message cannot carry the token into the
415
+ output.
416
+ """
417
+ return re.sub(r"(?i)(authorization\s*[:=]\s*)(\S+)", r"\1<redacted>", str(text))
418
+
419
+
420
+ def _request_headers(url: str) -> dict[str, str]:
421
+ """The headers of one request, with the token only where it may go."""
422
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/json, */*"}
423
+ bearer = _current_github_bearer()
424
+ if bearer and _is_github_host(url):
425
+ headers["Authorization"] = f"Bearer {bearer}"
426
+ return headers
427
+
428
+
429
+ def _http_get(url: str) -> bytes:
430
+ request = urllib.request.Request(url, headers=_request_headers(url))
431
+ try:
432
+ with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT) as response:
433
+ status = getattr(response, "status", 200)
434
+ body = response.read()
435
+ except urllib.error.HTTPError as exc:
436
+ raise HttpError(url, exc.code) from exc
437
+ except (urllib.error.URLError, OSError, ValueError) as exc:
438
+ raise InputError(f"{url}: {_redact(str(exc))}") from exc
439
+ if status != 200:
440
+ raise HttpError(url, status)
441
+ return body
442
+
443
+
444
+ def _http_get_json(url: str) -> object:
445
+ """The single JSON network seam of 11.1.4. 20 s.
446
+
447
+ The request carries a GitHub token when one is in the environment and the
448
+ URL names a GitHub host; without a token the request is exactly the
449
+ anonymous request it always was.
450
+ """
451
+ body = _http_get(url)
452
+ try:
453
+ return json.loads(body.decode("utf-8"))
454
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
455
+ raise InputError(f"{url}: non-JSON body: {exc}") from exc
456
+
457
+
458
+ def _http_get_bytes(url: str) -> bytes:
459
+ """Binary twin of the seam: an npm ``.tgz`` is not a JSON document."""
460
+ return _http_get(url)
461
+
462
+
463
+ #: Every git invocation in this script pins ``core.autocrlf=false``. The
464
+ #: payload digest is computed from ``git archive`` bytes, and an ambient
465
+ #: ``core.autocrlf`` (the default on a Windows runner) would otherwise change
466
+ #: those bytes and with them the digest - the same tree would publish a
467
+ #: different digest per platform. One shared prefix keeps the text helper from
468
+ #: reintroducing the dependence a later byte-sensitive use would expose.
469
+ _GIT_PINNED = ("git", "-c", "core.autocrlf=false")
470
+
471
+
472
+ def _git_cmd(repo_root: Path, *args: str) -> list[str]:
473
+ """The argv for a git call whose output must not depend on the host."""
474
+ return [*_GIT_PINNED, "-C", str(repo_root), *args]
475
+
476
+
477
+ def _git_root(explicit: str | None) -> Path | None:
478
+ """Resolve ``--repo-root`` and require it to be the work tree top level."""
479
+ start = Path(explicit) if explicit else Path.cwd()
480
+ try:
481
+ proc = subprocess.run(
482
+ _git_cmd(start, "rev-parse", "--show-toplevel"),
483
+ capture_output=True,
484
+ timeout=120,
485
+ check=False,
486
+ )
487
+ except (OSError, subprocess.TimeoutExpired):
488
+ return None
489
+ if proc.returncode != 0:
490
+ return None
491
+ top = Path(proc.stdout.decode("utf-8", "replace").strip())
492
+ try:
493
+ if top.resolve() != start.resolve():
494
+ return None
495
+ return top.resolve()
496
+ except OSError:
497
+ return None
498
+
499
+
500
+ def _git(repo_root: Path, *args: str, missing_ok: bool = False) -> str | None:
501
+ try:
502
+ proc = subprocess.run(
503
+ _git_cmd(repo_root, *args),
504
+ capture_output=True,
505
+ timeout=300,
506
+ check=False,
507
+ )
508
+ except (OSError, subprocess.TimeoutExpired) as exc:
509
+ raise InputError(f"git {args[0]}: {exc}") from exc
510
+ if proc.returncode != 0:
511
+ if missing_ok:
512
+ return None
513
+ message = proc.stderr.decode("utf-8", "replace").strip()
514
+ raise InputError(f"git {' '.join(args)} failed: {message or proc.returncode}")
515
+ return proc.stdout.decode("utf-8", "replace")
516
+
517
+
518
+ def _git_bytes(repo_root: Path, *args: str) -> bytes:
519
+ # The archive is the digest's only input, so an ambient core.autocrlf must
520
+ # not change the bytes or the digest becomes platform-dependent.
521
+ try:
522
+ proc = subprocess.run(
523
+ _git_cmd(repo_root, *args),
524
+ capture_output=True,
525
+ timeout=300,
526
+ check=False,
527
+ )
528
+ except (OSError, subprocess.TimeoutExpired) as exc:
529
+ raise InputError(f"git {args[0]}: {exc}") from exc
530
+ if proc.returncode != 0:
531
+ message = proc.stderr.decode("utf-8", "replace").strip()
532
+ raise InputError(f"git {' '.join(args)} failed: {message or proc.returncode}")
533
+ return proc.stdout
534
+
535
+
536
+ # --------------------------------------------------------------------------- #
537
+ # Bundled payload digest (11.3)
538
+ # --------------------------------------------------------------------------- #
539
+
540
+
541
+ def _payload_skip(rel: Path) -> bool:
542
+ """Mirror ``teamwork.bundle._skip``: build noise is not payload."""
543
+ if "__pycache__" in rel.parts:
544
+ return True
545
+ if any(part.endswith(".egg-info") for part in rel.parts):
546
+ return True
547
+ return rel.suffix in (".pyc", ".pyo")
548
+
549
+
550
+ def _extract_tar(archive: tarfile.TarFile, destination: Path) -> None:
551
+ """Write every member of ``archive`` into ``destination`` without a link.
552
+
553
+ 11.3 says symlinks are **resolved** and that a file which cannot be read is
554
+ drift, not a skip. So a link is materialised as a regular file holding the
555
+ bytes of its target: the extracted tree then contains exactly what the
556
+ working tree reads, and ``git archive`` and the npm tarball agree on the same
557
+ tree instead of one of them failing on any tracked link at all.
558
+
559
+ A link is refused - ``InputError``, exit 2 - when its target is absolute,
560
+ escapes the tree, or cannot be read *inside the tree*. A *self-referential
561
+ or cyclic* link has no bytes to resolve to and the frozen text does not say
562
+ what it means, so it is refused too rather than guessed at.
563
+
564
+ The refusal message names the link's own path and never its target: a link
565
+ to ``../.git/config`` must not put that name into the digest or into the
566
+ printed output, where a remote URL carrying a token would leak.
567
+
568
+ Members are extracted one by one rather than with ``extractall``: ``filter=``
569
+ does not exist before 3.12, and on 3.12+ the unfiltered call raises a
570
+ ``DeprecationWarning`` that this repository turns into a test failure
571
+ (``pyproject.toml`` sets ``filterwarnings = ["error"]``).
572
+ """
573
+ regular: list[tarfile.TarInfo] = []
574
+ links: list[tarfile.TarInfo] = []
575
+ for member in archive.getmembers():
576
+ parts = Path(member.name).parts
577
+ if member.name.startswith(("/", "\\")) or ".." in parts:
578
+ raise InputError(f"archive member escapes the temporary tree: {member.name}")
579
+ if member.isdir():
580
+ (destination / member.name).mkdir(parents=True, exist_ok=True)
581
+ elif member.isreg():
582
+ regular.append(member)
583
+ elif member.issym() or member.islnk():
584
+ links.append(member)
585
+ else:
586
+ raise InputError(f"archive member is not a regular file: {member.name}")
587
+ for member in regular:
588
+ target = destination / member.name
589
+ target.parent.mkdir(parents=True, exist_ok=True)
590
+ try:
591
+ source = archive.extractfile(member)
592
+ except (KeyError, OSError, tarfile.TarError) as exc:
593
+ raise InputError(f"archive member cannot be read: {member.name}: {exc}") from exc
594
+ if source is None:
595
+ raise InputError(f"archive member cannot be read: {member.name}")
596
+ with source, open(target, "wb") as handle:
597
+ handle.write(source.read())
598
+ _materialise_links(links, destination)
599
+
600
+
601
+ def _link_target(member: tarfile.TarInfo, destination: Path) -> Path:
602
+ """Where ``member``'s link points, or ``InputError`` if that is outside.
603
+
604
+ The target is never echoed: the refusal must not carry the name of a file
605
+ outside the tree (``../.git/config``) into the digest or the output.
606
+ """
607
+ linkname = member.linkname
608
+ if not linkname:
609
+ raise InputError(f"archive member is a link with no target: {member.name}")
610
+ if linkname.startswith(("/", "\\")) or Path(linkname).is_absolute():
611
+ raise InputError(f"archive member links to an absolute path: {member.name}")
612
+ # A symlink's target is relative to the link's own directory; a tar hardlink
613
+ # names a path from the archive root.
614
+ base = destination if member.islnk() else (destination / member.name).parent
615
+ target = Path(os.path.normpath(str(base / linkname)))
616
+ try:
617
+ target.relative_to(destination)
618
+ except ValueError:
619
+ raise InputError(f"archive member links outside the tree: {member.name}") from None
620
+ if ".git" in target.parts:
621
+ # A link into the repository's own git directory is refused on every
622
+ # path: those bytes are not payload, and a remote URL carrying a token
623
+ # would otherwise reach the digest and the printed output.
624
+ raise InputError(f"archive member links into the git directory: {member.name}")
625
+ return target
626
+
627
+
628
+ def _materialise_links(links: Sequence[tarfile.TarInfo], destination: Path) -> None:
629
+ """Replace every link in ``destination`` with its resolved target's bytes.
630
+
631
+ Repeated in rounds so a link to another link resolves once that one does; a
632
+ round that resolves nothing is a broken, directory or cyclic target (or a
633
+ link to itself), which is refused.
634
+ """
635
+ pending = list(links)
636
+ while pending:
637
+ deferred: list[tarfile.TarInfo] = []
638
+ for member in pending:
639
+ target = _link_target(member, destination)
640
+ if not target.is_file():
641
+ deferred.append(member)
642
+ continue
643
+ try:
644
+ data = target.read_bytes()
645
+ except OSError as exc:
646
+ raise InputError(f"archive link target unreadable: {member.name}: {exc}") from exc
647
+ path = destination / member.name
648
+ path.parent.mkdir(parents=True, exist_ok=True)
649
+ path.write_bytes(data)
650
+ if len(deferred) == len(pending):
651
+ names = ", ".join(member.name for member in deferred)
652
+ raise InputError(
653
+ f"archive link does not resolve to a readable file (broken, cyclic "
654
+ f"or self-referential): {names}"
655
+ )
656
+ pending = deferred
657
+
658
+
659
+ def _digest_of_tree(root: Path) -> str:
660
+ """The 11.3 digest of the payload found at ``root``."""
661
+ return _digest_of_files(_payload_paths(root), root)
662
+
663
+
664
+ def _payload_paths(root: Path) -> list[Path]:
665
+ paths: list[Path] = []
666
+ for name in PAYLOAD_FILES:
667
+ candidate = root / name
668
+ if candidate.is_file():
669
+ paths.append(candidate)
670
+ for tree in PAYLOAD_TREES:
671
+ base = root / tree
672
+ if not base.is_dir():
673
+ continue
674
+ for path in sorted(base.rglob("*")):
675
+ try:
676
+ is_file = path.is_file()
677
+ except OSError as exc:
678
+ raise InputError(f"{path}: {exc}") from exc
679
+ if is_file:
680
+ paths.append(path)
681
+ return paths
682
+
683
+
684
+ def _digest_of_files(paths: Sequence[Path], root: Path) -> str:
685
+ lines: list[str] = []
686
+ for path in paths:
687
+ rel = path.relative_to(root)
688
+ if rel.as_posix() in PAYLOAD_EXCLUDES or _payload_skip(rel):
689
+ continue
690
+ try:
691
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
692
+ except OSError as exc:
693
+ raise InputError(f"{rel.as_posix()}: unreadable payload file: {exc}") from exc
694
+ lines.append(f"{digest} {rel.as_posix()}")
695
+ lines.sort()
696
+ joined = "\n".join(lines) + "\n"
697
+ return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16]
698
+
699
+
700
+ def payload_digest(root: Path, rev: str | None) -> str:
701
+ """Digest of the bundled payload of a *commit* (11.3).
702
+
703
+ The payload is always read from a git object: ``git archive <rev>`` is
704
+ extracted into a temporary directory and the tree is never checked out. The
705
+ working tree contributes no byte, so the digest is a function of the
706
+ revision alone and the two paths cannot disagree.
707
+
708
+ ``rev=None`` therefore means the commit ``HEAD`` names, not the working
709
+ tree. 11.3's sentence "with ``rev=None`` the working tree is read in place"
710
+ is not implementable safely - a working tree can hold a link into ``.git``,
711
+ and those bytes then reach the digest and the printed output (measured: the
712
+ digest moved when only ``.git/config`` changed) - so the clause is read as
713
+ "the commit the caller means", and the Architect is asked to amend 11.3
714
+ against that measurement.
715
+ """
716
+ root = Path(root)
717
+ revision = "HEAD" if rev is None else str(rev)
718
+ blob = _git_bytes(root, "archive", revision)
719
+ with tempfile.TemporaryDirectory(prefix="release-consistency-") as tmp:
720
+ archive_path = Path(tmp) / "payload.tar"
721
+ archive_path.write_bytes(blob)
722
+ destination = Path(tmp) / "tree"
723
+ destination.mkdir()
724
+ try:
725
+ with tarfile.open(archive_path) as archive:
726
+ _extract_tar(archive, destination)
727
+ except (tarfile.TarError, OSError) as exc:
728
+ raise InputError(f"git archive {rev}: {exc}") from exc
729
+ return _digest_of_tree(destination)
730
+
731
+
732
+ def _tarball_payload_digest(blob: bytes) -> str:
733
+ """Digest of an npm tarball's shipped payload, on the payload's own paths.
734
+
735
+ 11.2.3 publishes the tarball as ``package/skills/teamwork/`` plus the pi-native
736
+ layer ``package/skills/teamwork-pi/`` and ``package/prompts/`` (see
737
+ ``build_pi_package.copy_skill``). A tag tree keeps that layer under
738
+ ``templates/pi-package/``. The paths are therefore *mapped back* to the
739
+ repository's payload layout before hashing - without the map a tag tree and a
740
+ tarball can never share a digest, and 11.5's worked examples would be
741
+ unreachable. Everything outside the mapping (``package/package.json``,
742
+ ``package/README.md``, the notices) is not bundled payload and is ignored.
743
+ """
744
+ with tempfile.TemporaryDirectory(prefix="release-npm-") as tmp:
745
+ archive_path = Path(tmp) / "package.tgz"
746
+ archive_path.write_bytes(blob)
747
+ destination = Path(tmp) / "unpacked"
748
+ destination.mkdir()
749
+ try:
750
+ with tarfile.open(archive_path) as archive:
751
+ _extract_tar(archive, destination)
752
+ except (tarfile.TarError, OSError) as exc:
753
+ raise InputError(f"npm tarball: {exc}") from exc
754
+ package = destination / "package"
755
+ if not package.is_dir():
756
+ raise InputError("npm tarball: no package/ subtree")
757
+ # A staging root that mirrors the repository layout, so the digest is
758
+ # computed on payload-relative paths exactly as 11.3 requires.
759
+ staged = Path(tmp) / "payload"
760
+ staged.mkdir()
761
+ mapped = 0
762
+ for source_prefix, payload_prefix in TARBALL_LAYOUT:
763
+ base = package / source_prefix
764
+ if not base.is_dir():
765
+ continue
766
+ for path in sorted(base.rglob("*")):
767
+ if not path.is_file():
768
+ continue
769
+ target = staged / payload_prefix / path.relative_to(base)
770
+ target.parent.mkdir(parents=True, exist_ok=True)
771
+ target.write_bytes(path.read_bytes())
772
+ mapped += 1
773
+ if not mapped:
774
+ raise InputError("npm tarball: none of the shipped payload paths are present")
775
+ return _digest_of_tree(staged)
776
+
777
+
778
+ # --------------------------------------------------------------------------- #
779
+ # Local facts
780
+ # --------------------------------------------------------------------------- #
781
+
782
+
783
+ def _version_tuple(version: str) -> tuple[int, int, int] | None:
784
+ match = SEMVER.match(version)
785
+ if not match:
786
+ return None
787
+ return int(match.group(1)), int(match.group(2)), int(match.group(3))
788
+
789
+
790
+ def _read_version(repo_root: Path, rev: str | None = None) -> str | None:
791
+ """``__version__`` at ``rev``, or in the working tree when ``rev`` is None."""
792
+ if rev is None:
793
+ try:
794
+ text = (Path(repo_root) / VERSION_PATH).read_text(encoding="utf-8")
795
+ except FileNotFoundError:
796
+ return None
797
+ except OSError as exc:
798
+ raise InputError(f"{VERSION_PATH}: {exc}") from exc
799
+ else:
800
+ shown = _git(repo_root, "show", f"{rev}:{VERSION_PATH}", missing_ok=True)
801
+ if shown is None:
802
+ return None
803
+ text = shown
804
+ match = VERSION_ASSIGN.search(text)
805
+ return match.group(1) if match else None
806
+
807
+
808
+ def _dist_name(repo_root: Path) -> str | None:
809
+ text: str | None = None
810
+ try:
811
+ text = (Path(repo_root) / VERSION_PATH).read_text(encoding="utf-8")
812
+ except OSError:
813
+ text = None
814
+ if text is not None:
815
+ match = DIST_NAME_ASSIGN.search(text)
816
+ if match:
817
+ return match.group(1)
818
+ return _pyproject_name(repo_root)
819
+
820
+
821
+ def _pyproject_section(repo_root: Path, section: str) -> list[str]:
822
+ try:
823
+ text = (Path(repo_root) / "pyproject.toml").read_text(encoding="utf-8")
824
+ except OSError:
825
+ return []
826
+ lines: list[str] = []
827
+ inside = False
828
+ for line in text.splitlines():
829
+ stripped = line.strip()
830
+ if stripped.startswith("[") and stripped.endswith("]"):
831
+ inside = stripped == section
832
+ continue
833
+ if inside and stripped and not stripped.startswith("#"):
834
+ lines.append(stripped)
835
+ return lines
836
+
837
+
838
+ def _pyproject_name(repo_root: Path) -> str | None:
839
+ inside = False
840
+ for line in _pyproject_section(repo_root, "[project]"):
841
+ if inside:
842
+ value = line.split("=", 1)[1].strip().strip('"').strip("'")
843
+ return value or None
844
+ if line.startswith("name") and "=" in line:
845
+ inside = True
846
+ return None
847
+
848
+
849
+ def _console_scripts(repo_root: Path) -> list[str]:
850
+ scripts: list[str] = []
851
+ for line in _pyproject_section(repo_root, "[project.scripts]"):
852
+ if "=" not in line:
853
+ continue
854
+ name = line.split("=", 1)[0].strip().strip('"').strip("'")
855
+ if name:
856
+ scripts.append(name)
857
+ return scripts
858
+
859
+
860
+ def _version_tags(repo_root: Path) -> list[str]:
861
+ listed = _git(repo_root, "tag", "-l") or ""
862
+ tags = [line.strip() for line in listed.splitlines() if VERSION_TAG.match(line.strip())]
863
+
864
+ def sort_key(tag: str) -> tuple[int, int, int]:
865
+ return _version_tuple(tag[1:]) or (0, 0, 0)
866
+
867
+ return sorted(tags, key=sort_key)
868
+
869
+
870
+ def _is_ancestor(repo_root: Path, tag: str, rev: str = "HEAD") -> bool:
871
+ """``git merge-base --is-ancestor <tag> <rev>`` - exit 0 means reachable."""
872
+ try:
873
+ proc = subprocess.run(
874
+ _git_cmd(repo_root, "merge-base", "--is-ancestor", tag, rev),
875
+ capture_output=True,
876
+ timeout=300,
877
+ check=False,
878
+ )
879
+ except (OSError, subprocess.TimeoutExpired) as exc:
880
+ raise InputError(f"git merge-base --is-ancestor {tag} {rev}: {exc}") from exc
881
+ return proc.returncode == 0
882
+
883
+
884
+ def _reachable_version_tags(repo_root: Path) -> list[str]:
885
+ """The version tags whose commit is reachable from HEAD (11.2.2, set ``A``)."""
886
+ return [tag for tag in _version_tags(repo_root) if _is_ancestor(repo_root, tag, "HEAD")]
887
+
888
+
889
+ def _tags_at_head(repo_root: Path) -> set[str]:
890
+ """The version tags that point at HEAD (11.2.2, set ``O``)."""
891
+ listed = _git(repo_root, "tag", "--points-at", "HEAD") or ""
892
+ return {line.strip() for line in listed.splitlines() if VERSION_TAG.match(line.strip())}
893
+
894
+
895
+ def _complete_tag_set_error(repo_root: Path) -> str | None:
896
+ """Why the tag set cannot be trusted, or ``None`` when it can (11.2.2).
897
+
898
+ The complete tag set is a **required input**: a shallow clone cannot see
899
+ the tags, and a checkout with no version tag at all cannot be distinguished
900
+ from a blind one. Either is an input error (exit ``2``), never a pass.
901
+ """
902
+ shallow = (_git(repo_root, "rev-parse", "--is-shallow-repository") or "").strip()
903
+ if shallow == "true":
904
+ return (
905
+ "shallow clone: the complete tag set is required "
906
+ "(git rev-parse --is-shallow-repository is true)"
907
+ )
908
+ if _version_tags(repo_root):
909
+ return None
910
+ remotes = [
911
+ line.strip() for line in (_git(repo_root, "remote") or "").splitlines() if line.strip()
912
+ ]
913
+ if not remotes:
914
+ return None
915
+ carrying = _remote_carries_version_tags(repo_root, remotes)
916
+ if carrying is False:
917
+ # The remote is local and carries no version tag: the empty tag set is
918
+ # the complete tag set, not a blind checkout.
919
+ return None
920
+ if carrying is None:
921
+ return (
922
+ "no version tag in the local checkout while a remote is configured "
923
+ f"({', '.join(remotes)}): the complete tag set is required and cannot be confirmed"
924
+ )
925
+ return (
926
+ "no version tag in the local checkout while a remote is configured "
927
+ f"({', '.join(remotes)}): the complete tag set is required and cannot be confirmed"
928
+ )
929
+
930
+
931
+ def _remote_carries_version_tags(repo_root: Path, remotes: Sequence[str]) -> bool | None:
932
+ """Whether a remote carries version tags, or ``None`` when it cannot be told.
933
+
934
+ Only a **local** remote is asked (``git ls-remote --tags``), so the checker
935
+ never opens a socket for this: for a network remote the answer cannot be
936
+ confirmed offline, and an unconfirmed answer is reported as such by
937
+ ``_complete_tag_set_error`` instead of being guessed.
938
+ """
939
+ checked = False
940
+ for remote in remotes:
941
+ url = (_git(repo_root, "remote", "get-url", remote) or "").strip()
942
+ if not url:
943
+ continue
944
+ local = url[len("file://") :] if url.startswith("file://") else url
945
+ if not Path(local).exists():
946
+ continue
947
+ checked = True
948
+ try:
949
+ proc = subprocess.run(
950
+ _git_cmd(repo_root, "ls-remote", "--tags", remote),
951
+ capture_output=True,
952
+ timeout=300,
953
+ check=False,
954
+ )
955
+ except (OSError, subprocess.TimeoutExpired):
956
+ continue
957
+ if proc.returncode != 0:
958
+ continue
959
+ for line in proc.stdout.decode("utf-8", "replace").splitlines():
960
+ ref = line.split()[-1] if line.split() else ""
961
+ if ref.startswith("refs/tags/") and VERSION_TAG.match(ref[len("refs/tags/") :]):
962
+ return True
963
+ return False if checked else None
964
+
965
+
966
+ def _all_tags(repo_root: Path) -> list[str]:
967
+ """Every tag name, unfiltered - R4 compares release names against this."""
968
+ listed = _git(repo_root, "tag", "-l") or ""
969
+ return [line.strip() for line in listed.splitlines() if line.strip()]
970
+
971
+
972
+ def markdown_paths(repo_root: Path, globs: Sequence[str] = ("*.md",)) -> list[str]:
973
+ """Tracked markdown, read from the working tree, minus historical evidence.
974
+
975
+ ``docs/Team/Archive.md`` is out of scope for every scanner (11.2.4), and a
976
+ tracked path that no longer exists on disk is skipped rather than drifted.
977
+ """
978
+ listed = _git(repo_root, "ls-files", "--", *globs) or ""
979
+ found: list[str] = []
980
+ for line in listed.splitlines():
981
+ rel = line.strip()
982
+ if not rel or rel == SCAN_EXCLUDED:
983
+ continue
984
+ if globs != ("*.md",) and not any(fnmatch.fnmatchcase(rel, glob) for glob in globs):
985
+ continue
986
+ if not (Path(repo_root) / rel).is_file():
987
+ continue
988
+ found.append(rel)
989
+ return found
990
+
991
+
992
+ @dataclass(frozen=True)
993
+ class _MarkedLine:
994
+ number: int
995
+ text: str
996
+ exempted: bool
997
+ block: str
998
+
999
+
1000
+ def _marked_lines(text: str, marker: str = ILLUSTRATIVE) -> list[_MarkedLine]:
1001
+ """Split text into lines, resolving the fence-aware marker reach of 11.2.4.
1002
+
1003
+ A marker on a physical line exempts that line; a marker standing on a line of
1004
+ its own immediately above the opening fence exempts the whole fenced block.
1005
+ """
1006
+ raw = text.splitlines()
1007
+ blocks: dict[int, tuple[int, int]] = {}
1008
+ index = 0
1009
+ while index < len(raw):
1010
+ opened = FENCE_OPEN.match(raw[index].lstrip())
1011
+ if not opened:
1012
+ index += 1
1013
+ continue
1014
+ fence = opened.group(1)
1015
+ end = index
1016
+ for probe in range(index + 1, len(raw)):
1017
+ closing = FENCE_OPEN.match(raw[probe].lstrip())
1018
+ if closing and closing.group(1)[0] == fence[0] and len(closing.group(1)) >= len(fence):
1019
+ end = probe
1020
+ break
1021
+ else:
1022
+ end = len(raw) - 1
1023
+ for line_no in range(index, end + 1):
1024
+ blocks[line_no] = (index, end)
1025
+ index = end + 1
1026
+
1027
+ marked: list[_MarkedLine] = []
1028
+ for line_no, line in enumerate(raw):
1029
+ block = ""
1030
+ exempted = marker in line
1031
+ if line_no in blocks:
1032
+ start, end = blocks[line_no]
1033
+ block = "\n".join(raw[start : end + 1])
1034
+ if start > 0 and raw[start - 1].strip() == marker:
1035
+ exempted = True
1036
+ marked.append(_MarkedLine(line_no + 1, line, exempted, block))
1037
+ return marked
1038
+
1039
+
1040
+ # --------------------------------------------------------------------------- #
1041
+ # The exception record (11.4)
1042
+ # --------------------------------------------------------------------------- #
1043
+
1044
+
1045
+ def load_exceptions(path: Path) -> list[dict[str, str]]:
1046
+ """Read and validate ``release-exceptions.json``; any problem is exit 2."""
1047
+ where = str(path)
1048
+ try:
1049
+ raw = Path(path).read_text(encoding="utf-8")
1050
+ except FileNotFoundError as exc:
1051
+ raise InputError(f"{where}: missing (the initial state is an empty array)") from exc
1052
+ except OSError as exc:
1053
+ raise InputError(f"{where}: {exc}") from exc
1054
+ try:
1055
+ data = json.loads(raw)
1056
+ except json.JSONDecodeError as exc:
1057
+ raise InputError(f"{where}: invalid JSON: {exc}") from exc
1058
+ if not isinstance(data, list):
1059
+ raise InputError(f"{where}: top level must be a JSON array")
1060
+ entries: list[dict[str, str]] = []
1061
+ seen: set[str] = set()
1062
+ for index, entry in enumerate(data):
1063
+ label = f"{where}: entry {index}"
1064
+ if not isinstance(entry, dict):
1065
+ raise InputError(f"{label}: must be a JSON object")
1066
+ keys = set(entry)
1067
+ extra = sorted(keys - set(EXCEPTION_KEYS))
1068
+ missing = [key for key in EXCEPTION_KEYS if key not in keys]
1069
+ if extra:
1070
+ raise InputError(f"{label}: unknown key(s) {', '.join(extra)}")
1071
+ if missing:
1072
+ raise InputError(f"{label}: missing key(s) {', '.join(missing)}")
1073
+ version = entry["version"]
1074
+ if not isinstance(version, str) or not PUBLISHED_VERSION.match(version):
1075
+ raise InputError(
1076
+ f"{label}: version {version!r} is not an npm version (X.Y.Z, optionally "
1077
+ f"with a -prerelease/+build tail)"
1078
+ )
1079
+ if version in seen:
1080
+ raise InputError(f"{label}: duplicate version {version}")
1081
+ seen.add(version)
1082
+ for key in ("reason", "evidence"):
1083
+ value = entry[key]
1084
+ if not isinstance(value, str) or not value.strip():
1085
+ raise InputError(f"{label}: {key} is empty - a reviewer cannot judge it")
1086
+ recorded = entry["recorded"]
1087
+ if not isinstance(recorded, str) or not ISO_DATE.match(recorded):
1088
+ raise InputError(f"{label}: recorded {recorded!r} is not YYYY-MM-DD")
1089
+ entries.append({key: str(entry[key]) for key in EXCEPTION_KEYS})
1090
+ return entries
1091
+
1092
+
1093
+ def _exception_notes(exceptions: Iterable[dict[str, str]]) -> list[str]:
1094
+ return [f"{entry['version']} admitted by exception ({entry['reason']})" for entry in exceptions]
1095
+
1096
+
1097
+ def _stale_exceptions(exceptions: Iterable[dict[str, str]], tags: Sequence[str]) -> list[str]:
1098
+ """An exception for a version that *is* traceable is drift, not a pass."""
1099
+ findings: list[str] = []
1100
+ tag_set = set(tags)
1101
+ for entry in exceptions:
1102
+ version = entry["version"]
1103
+ if f"v{version}" in tag_set:
1104
+ findings.append(
1105
+ f"{version}: exception names a traceable version, tag v{version} exists; "
1106
+ f"remove the entry"
1107
+ )
1108
+ return findings
1109
+
1110
+
1111
+ # --------------------------------------------------------------------------- #
1112
+ # R1 / R2 - the tag and the local version string
1113
+ # --------------------------------------------------------------------------- #
1114
+
1115
+
1116
+ def rule_r1_tagged_version(repo_root: Path) -> RuleResult:
1117
+ """Every ``vX.Y.Z`` tag must carry that version in ``version.py`` at its commit."""
1118
+ error = _complete_tag_set_error(repo_root)
1119
+ if error is not None:
1120
+ raise InputError(error)
1121
+ tags = _version_tags(repo_root)
1122
+ findings: list[str] = []
1123
+ agreed = 0
1124
+ for tag in tags:
1125
+ wanted = tag[1:]
1126
+ actual = _read_version(repo_root, tag)
1127
+ if actual is None:
1128
+ findings.append(f"{tag}: {VERSION_PATH} missing at the tag or has no __version__")
1129
+ elif _version_tuple(actual) is None:
1130
+ findings.append(f"{tag}: __version__ {actual!r} is not X.Y.Z")
1131
+ elif actual != wanted:
1132
+ findings.append(f"{tag}: __version__ {actual} != tag version {wanted}")
1133
+ else:
1134
+ agreed += 1
1135
+ if findings:
1136
+ return _drift("R1", findings)
1137
+ return _ok("R1", f"{agreed} tag(s) agree with version.py")
1138
+
1139
+
1140
+ def rule_r2_head_is_newer(repo_root: Path) -> RuleResult:
1141
+ """HEAD must be strictly newer than every tag in ``S`` (11.2.2).
1142
+
1143
+ ``S = A - O``: the version tags **reachable from HEAD** (``A``) minus those
1144
+ that **point at HEAD** (``O``). A tag that is not an ancestor of HEAD is
1145
+ invisible to R2 - it says nothing about whether this checkout is ahead of
1146
+ its own history - so a maintenance branch is not permanently red and the
1147
+ count is never negative.
1148
+ """
1149
+ error = _complete_tag_set_error(repo_root)
1150
+ if error is not None:
1151
+ raise InputError(error)
1152
+ head = _read_version(repo_root)
1153
+ if head is None:
1154
+ raise InputError(f"{VERSION_PATH}: no __version__ at HEAD")
1155
+ head_tuple = _version_tuple(head)
1156
+ if head_tuple is None:
1157
+ raise InputError(f"{VERSION_PATH}: __version__ {head!r} is not X.Y.Z")
1158
+ reachable = _reachable_version_tags(repo_root)
1159
+ at_head = _tags_at_head(repo_root)
1160
+ comparable = [tag for tag in reachable if tag not in at_head]
1161
+ findings: list[str] = []
1162
+ for tag in comparable:
1163
+ tagged = _version_tuple(tag[1:])
1164
+ if tagged is not None and not head_tuple > tagged:
1165
+ findings.append(f"HEAD version {head} is not greater than tag {tag}")
1166
+ if findings:
1167
+ return _drift("R2", findings)
1168
+ return _ok("R2", f"HEAD {head} is newer than {len(comparable)} tag(s)")
1169
+
1170
+
1171
+ # --------------------------------------------------------------------------- #
1172
+ # R3 / R4 / R5 / R8 - the published half
1173
+ # --------------------------------------------------------------------------- #
1174
+
1175
+
1176
+ def rule_r3_npm_payloads(
1177
+ repo_root: Path,
1178
+ exceptions: Sequence[dict[str, str]] = (),
1179
+ fetch: Callable[[str], Any] | None = None,
1180
+ fetch_bytes: Callable[[str], bytes] | None = None,
1181
+ ) -> RuleResult:
1182
+ """Every npm version must equal a tagged payload digest, or be admitted (11.2.3)."""
1183
+ tags = _version_tags(repo_root)
1184
+ admitted = {entry["version"] for entry in exceptions}
1185
+ notes = _exception_notes(exceptions)
1186
+ findings = _stale_exceptions(exceptions, tags)
1187
+
1188
+ if fetch is None:
1189
+ notes = ["offline: npm registry not queried"] + notes
1190
+ if findings:
1191
+ return _drift("R3", findings, notes)
1192
+ return _skip("R3", "; ".join(notes))
1193
+
1194
+ try:
1195
+ data = fetch(NPM_REGISTRY)
1196
+ except InputError as exc:
1197
+ return _skip("R3", f"online: npm registry unreadable: {exc}", fatal=True)
1198
+ versions = data.get("versions") if isinstance(data, dict) else None
1199
+ if not isinstance(versions, dict):
1200
+ return _skip("R3", f"online: {NPM_REGISTRY} has no versions object", fatal=True)
1201
+
1202
+ get_bytes = fetch_bytes if fetch_bytes is not None else _http_get_bytes
1203
+ matched = 0
1204
+ unreadable: list[str] = []
1205
+ for version in sorted(versions, key=lambda item: _version_tuple(str(item)) or (0, 0, 0)):
1206
+ if version in admitted:
1207
+ continue
1208
+ # 11.2.3 takes *every* version key, and an npm version is satisfied by a
1209
+ # tag or by a 11.4 entry - there is no third path. A key that is not
1210
+ # X.Y.Z cannot have a ``vX.Y.Z`` tag, so without an entry it is drift:
1211
+ # calling it "ignored" would let a published version escape AC-REL-1 by
1212
+ # its shape alone (npm's ``0.0.0-stage`` placeholder did exactly that).
1213
+ if _version_tuple(str(version)) is None:
1214
+ findings.append(
1215
+ f"{version}: not X.Y.Z, so no vX.Y.Z tag can carry it, and no "
1216
+ f"release-exceptions.json entry admits it"
1217
+ )
1218
+ continue
1219
+ info = versions[version]
1220
+ dist = info.get("dist") if isinstance(info, dict) else None
1221
+ tarball = dist.get("tarball") if isinstance(dist, dict) else None
1222
+ if not isinstance(tarball, str) or not tarball:
1223
+ findings.append(f"{version}: npm record has no dist.tarball")
1224
+ continue
1225
+ try:
1226
+ digest = _tarball_payload_digest(get_bytes(tarball))
1227
+ except HttpError as exc:
1228
+ # A 404 is the registry's own answer that the tarball it advertises
1229
+ # does not exist - the release is missing, so it is drift. Anything
1230
+ # else (5xx, timeout, URL error) is unreadable input: exit 2, so a
1231
+ # network flake can never be mistaken for a release defect.
1232
+ if exc.status == 404:
1233
+ findings.append(f"{version}: tarball {tarball} is missing: {exc}")
1234
+ else:
1235
+ unreadable.append(f"{version}: tarball unreadable: {exc}")
1236
+ continue
1237
+ except InputError as exc:
1238
+ unreadable.append(f"{version}: tarball unreadable: {exc}")
1239
+ continue
1240
+ tag = f"v{version}"
1241
+ if tag not in tags:
1242
+ findings.append(f"{version}: no tag {tag} to compare; tarball payload digest {digest}")
1243
+ continue
1244
+ try:
1245
+ local = payload_digest(repo_root, tag)
1246
+ except InputError as exc:
1247
+ unreadable.append(f"{version}: {exc}")
1248
+ continue
1249
+ if local == digest:
1250
+ matched += 1
1251
+ else:
1252
+ findings.append(
1253
+ f"{version}: tarball payload digest {digest} != payload {local} at {tag}"
1254
+ )
1255
+ if unreadable:
1256
+ return _unreadable("R3", findings, notes, "; ".join(unreadable))
1257
+ if findings:
1258
+ return _drift("R3", findings, notes)
1259
+ return _ok(
1260
+ "R3",
1261
+ "; ".join([f"{matched} npm version(s) match a tagged payload digest"] + notes),
1262
+ )
1263
+
1264
+
1265
+ def _github_release_names(data: object) -> list[str]:
1266
+ if not isinstance(data, list):
1267
+ raise InputError("GitHub releases: response is not a JSON array")
1268
+ names: list[str] = []
1269
+ for item in data:
1270
+ if not isinstance(item, dict):
1271
+ continue
1272
+ for key in ("tag_name", "name"):
1273
+ value = item.get(key)
1274
+ if isinstance(value, str) and value.strip():
1275
+ names.append(value.strip())
1276
+ break
1277
+ return names
1278
+
1279
+
1280
+ def rule_r4_releases_have_tags(
1281
+ repo_root: Path, fetch: Callable[[str], Any] | None = None
1282
+ ) -> RuleResult:
1283
+ """Every GitHub Release must name a tag the repository has.
1284
+
1285
+ The comparison set is the whole of ``git tag -l``, not the ``vX.Y.Z`` subset:
1286
+ 11.2.4's version-tag filter is R1/R2/R5's, and a pre-release tag such as
1287
+ ``v1.1.3-rc1`` is a tag a release may legitimately carry. Filtering it out
1288
+ here would report an existing tag as missing.
1289
+ """
1290
+ error = _complete_tag_set_error(repo_root)
1291
+ if error is not None:
1292
+ raise InputError(error)
1293
+ tags = _all_tags(repo_root)
1294
+ if fetch is None:
1295
+ return _skip("R4", f"offline: GitHub releases API not queried; {len(tags)} tag(s) local")
1296
+ try:
1297
+ names = _github_release_names(fetch(GITHUB_RELEASES))
1298
+ except InputError as exc:
1299
+ return _skip("R4", f"online: GitHub API unreadable: {exc}", fatal=True)
1300
+ tag_set = set(tags)
1301
+ findings = [
1302
+ f"release {name} has no tag {name} in git tag -l" for name in names if name not in tag_set
1303
+ ]
1304
+ if findings:
1305
+ return _drift("R4", findings)
1306
+ return _ok("R4", f"{len(names)} release(s) name an existing tag")
1307
+
1308
+
1309
+ def rule_r5_tags_have_releases(
1310
+ repo_root: Path, fetch: Callable[[str], Any] | None = None
1311
+ ) -> RuleResult:
1312
+ """Every tag must have exactly the GitHub Release that publishes it."""
1313
+ error = _complete_tag_set_error(repo_root)
1314
+ if error is not None:
1315
+ raise InputError(error)
1316
+ tags = _version_tags(repo_root)
1317
+ if fetch is None:
1318
+ return _skip(
1319
+ "R5",
1320
+ f"offline: GitHub releases API not queried; cannot confirm {len(tags)} tag(s) are released",
1321
+ )
1322
+ try:
1323
+ names = set(_github_release_names(fetch(GITHUB_RELEASES)))
1324
+ except InputError as exc:
1325
+ return _skip("R5", f"online: GitHub API unreadable: {exc}", fatal=True)
1326
+ findings = [f"tag {tag} has no GitHub Release" for tag in tags if tag not in names]
1327
+ if findings:
1328
+ return _drift("R5", findings)
1329
+ return _ok("R5", f"{len(tags)} tag(s) have a GitHub Release")
1330
+
1331
+
1332
+ def _channel_claims(line: str) -> list[tuple[str, str | None]]:
1333
+ """``(distribution, executable-or-None)`` claims on one line (11.2.4)."""
1334
+ if _negated_install(line):
1335
+ return []
1336
+ claims: list[tuple[str, str | None]] = []
1337
+ for pattern in (PIP_MODULE, PIP_PLAIN, PIPX):
1338
+ for match in pattern.finditer(line):
1339
+ name = _clean_distribution(match.group(1))
1340
+ if name:
1341
+ claims.append((name, None))
1342
+ for pattern in (UVX_FROM, PIPX_RUN):
1343
+ for match in pattern.finditer(line):
1344
+ name = _clean_distribution(match.group(1))
1345
+ if name:
1346
+ claims.append((name, _trailing_script(line, match.end())))
1347
+ return claims
1348
+
1349
+
1350
+ def _trailing_script(line: str, start: int) -> str | None:
1351
+ """The console script after ``uvx --from <dist>`` / ``pipx run --spec <dist>``.
1352
+
1353
+ The script is the first *positional* argument. Intermediate ``--flag value``
1354
+ pairs are skipped first (a flag whose next token is another flag has no
1355
+ value), so ``uvx --from teamwork-agents --python 3.12 teamwork --version``
1356
+ reads ``teamwork`` rather than nothing.
1357
+
1358
+ Only a real argument counts: ``uvx --from teamwork-agents is the fastest way
1359
+ to try it`` continues as prose, and reading ``is`` as an executable would
1360
+ report a drift the text never claimed. The positional must therefore also
1361
+ end the command - a flag, a closing quote/bracket or the end of the line.
1362
+
1363
+ An inline-code command ends at its closing backtick, which ``ARGUMENT``
1364
+ leaves out of the token and ``SCRIPT_TERMINATOR`` accepts, so
1365
+ `` `uvx --from X wrongscript` `` names the script ``wrongscript``.
1366
+ """
1367
+ tokens = [(match.group(1), match.start(1)) for match in ARGUMENT.finditer(line, start)]
1368
+ index = 0
1369
+ while index < len(tokens):
1370
+ text, offset = tokens[index]
1371
+ if text.startswith("-"):
1372
+ has_value = index + 1 < len(tokens) and not tokens[index + 1][0].startswith("-")
1373
+ index += 2 if has_value else 1
1374
+ continue
1375
+ if not SCRIPT_WORD.fullmatch(text):
1376
+ return None
1377
+ if not SCRIPT_TERMINATOR.match(line, offset + len(text)):
1378
+ return None
1379
+ return text
1380
+ return None
1381
+
1382
+
1383
+ def _clean_distribution(token: str) -> str | None:
1384
+ """Reduce a pip/pipx/uvx package token to a distribution name, or ``None``.
1385
+
1386
+ A local path (``pip install .``, ``pip install -e ".[dev]"``) and a
1387
+ placeholder (``pip install <wheel>``) name no published distribution, so
1388
+ they are not channel claims. What survives is a PEP 503 name.
1389
+ """
1390
+ name = token.strip().strip("\"'`").strip()
1391
+ for stop in ("==", ">=", "<=", "!=", "~=", "[", ";", ","):
1392
+ name = name.split(stop, 1)[0]
1393
+ name = name.strip().strip("\"'`")
1394
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name):
1395
+ return None
1396
+ return name
1397
+
1398
+
1399
+ def _warned_claim(line: str, name: str, dist_name: str | None) -> bool:
1400
+ """A claim of a *foreign* distribution on a hazard line is not an ad.
1401
+
1402
+ 11.2.4 scopes R8 to an **advertised** channel. A line that ties a
1403
+ distribution to another project names it in order to warn against it, so
1404
+ that name is exempt. The exemption is per claim, not per line: a line
1405
+ that both warns and advertises keeps the shipped name under the normal
1406
+ check, including the ``[project.scripts]`` lookup.
1407
+ """
1408
+ if dist_name is None or name == dist_name:
1409
+ return False
1410
+ return bool(WARNING_CONTEXT.search(line))
1411
+
1412
+
1413
+ def _negated_install(line: str) -> bool:
1414
+ """A sentence that says there is *no* install channel advertises nothing.
1415
+
1416
+ 11.2.4 scopes R8 to markdown that *advertises* a channel; ``there is no pip
1417
+ install and no venv`` (CHANGELOG.md, SKILL.md) states the opposite, and so
1418
+ does ``no uvx --from ...`` or ``never pipx run ...``.
1419
+ """
1420
+ return bool(NEGATED_CHANNEL.search(line))
1421
+
1422
+
1423
+ def rule_r8_install_channels(
1424
+ repo_root: Path, fetch: Callable[[str], Any] | None = None
1425
+ ) -> RuleResult:
1426
+ """Install-channel prose must name what this repository ships (11.2.4)."""
1427
+ dist_name = _dist_name(repo_root)
1428
+ scripts = _console_scripts(repo_root)
1429
+ findings: list[str] = []
1430
+ claimed = 0
1431
+ marked = 0
1432
+ unmarked_lines: list[str] = []
1433
+ for rel in markdown_paths(repo_root):
1434
+ try:
1435
+ text = (Path(repo_root) / rel).read_text(encoding="utf-8")
1436
+ except OSError as exc:
1437
+ raise InputError(f"{rel}: {exc}") from exc
1438
+ for line in _marked_lines(text, CHANNEL_UNAVAILABLE):
1439
+ if line.exempted and _channel_claims(line.text):
1440
+ marked += 1
1441
+ for name, executable in _channel_claims(line.text):
1442
+ claimed += 1
1443
+ where = f"{rel}:{line.number}"
1444
+ if _warned_claim(line.text, name, dist_name):
1445
+ continue
1446
+ if dist_name is None:
1447
+ findings.append(
1448
+ f"{where}: cannot verify {name}, no DIST_NAME in {VERSION_PATH}"
1449
+ )
1450
+ elif name != dist_name:
1451
+ findings.append(f"{where}: channel names {name}, the project ships {dist_name}")
1452
+ elif executable and executable not in scripts:
1453
+ findings.append(
1454
+ f"{where}: channel runs {executable}, which is not a "
1455
+ f"[project.scripts] entry"
1456
+ )
1457
+ elif not line.exempted:
1458
+ unmarked_lines.append(where)
1459
+
1460
+ if fetch is None:
1461
+ notes = [
1462
+ f"offline: PyPI not queried; {claimed} channel line(s), "
1463
+ f"{marked} marked channel-unavailable"
1464
+ ]
1465
+ if findings:
1466
+ return _drift("R8", findings, notes)
1467
+ if dist_name is None:
1468
+ return _skip(
1469
+ "R8", f"cannot read DIST_NAME from {VERSION_PATH}; channel names unverifiable"
1470
+ )
1471
+ return _skip("R8", "; ".join(notes))
1472
+
1473
+ try:
1474
+ fetch(PYPI_JSON)
1475
+ published = True
1476
+ reason = "HTTP 200"
1477
+ except HttpError as exc:
1478
+ if exc.status == 404:
1479
+ published = False
1480
+ reason = "HTTP 404"
1481
+ else:
1482
+ return _skip("R8", f"online: PyPI unreadable: {exc}", fatal=True)
1483
+ except InputError as exc:
1484
+ return _skip("R8", f"online: PyPI unreadable: {exc}", fatal=True)
1485
+ if published:
1486
+ if findings:
1487
+ return _drift("R8", findings, [f"PyPI {reason}"])
1488
+ return _ok(
1489
+ "R8",
1490
+ f"PyPI {reason}; {claimed} channel line(s) resolve, {marked} marked",
1491
+ )
1492
+ for where in unmarked_lines:
1493
+ findings.append(f"{where}: PyPI {reason}, the channel does not resolve and is unmarked")
1494
+ if findings:
1495
+ return _drift("R8", findings)
1496
+ return _ok("R8", f"PyPI {reason}; {marked} channel line(s) marked channel-unavailable")
1497
+
1498
+
1499
+ # --------------------------------------------------------------------------- #
1500
+ # R6 / R7 / R9 - the committed prose
1501
+ # --------------------------------------------------------------------------- #
1502
+
1503
+
1504
+ def _project_tokens(text: str) -> list[tuple[re.Match[str], str]]:
1505
+ """``(token, kind)`` claims naming this project (11.2.4); kind is prose/asset.
1506
+
1507
+ A bare token is a claim, a token inside a URL is not - except the tag of a
1508
+ ``releases/download/<tag>/`` asset link, which names the release the install
1509
+ command downloads. Those are read by ``RELEASE_ASSET_TAG`` in addition to
1510
+ ``VERSION_TOKEN``; the two never overlap, because the ``/`` that hides a
1511
+ token from ``VERSION_TOKEN`` is exactly the path the lookbehind requires.
1512
+
1513
+ The kind is returned because the three kinds are judged against *different*
1514
+ baselines: prose may name any version up to HEAD, while a released asset or a
1515
+ pin can only name a tag - and between releases ``version.py`` leads the
1516
+ newest tag.
1517
+ """
1518
+ foreign = {match.span(1) for match in FOREIGN_VERSION.finditer(text)}
1519
+ claims = [(match, "prose") for match in VERSION_TOKEN.finditer(text)]
1520
+ claims += [(match, "asset") for match in RELEASE_ASSET_TAG.finditer(text)]
1521
+ found = [claim for claim in claims if claim[0].span(1) not in foreign]
1522
+ return sorted(found, key=lambda claim: claim[0].start())
1523
+
1524
+
1525
+ def _pinned_tokens(text: str) -> list[tuple[re.Match[str], str]]:
1526
+ """``(token, "pin")`` claims in a ``--branch``/``--tag``/checkout context.
1527
+
1528
+ Not gated on ``PROJECT_CONTEXT``: a pin names a revision of *this* project by
1529
+ construction, and the install guide's own tag pin sits in a console block that
1530
+ never spells the project name. A version in prose that is not a pin - a
1531
+ measurement, a drill, an interpreter version - is still not read here.
1532
+ """
1533
+ return [(match, "pin") for match in PIN_VERSION.finditer(text)]
1534
+
1535
+
1536
+ def rule_r6_markdown_versions(repo_root: Path) -> RuleResult:
1537
+ """Prose must not print a stale project version (11.2.4).
1538
+
1539
+ Two baselines, because there are two kinds of claim. Prose names a version
1540
+ of this project, so anything older than HEAD is stale. A
1541
+ ``releases/download/<tag>/`` link names a *released asset*, and a
1542
+ ``--branch``/``--tag``/``git checkout`` version names a *released pin*, so
1543
+ both are stale only when a newer tag exists - judging them against HEAD would
1544
+ call the newest release command wrong for the whole window between a release
1545
+ and the next bump, which is where this repository sits now (HEAD ``1.1.3``,
1546
+ newest tag ``v1.1.2``). A pin that names no tag at all is drift, because the
1547
+ install command it documents cannot resolve.
1548
+
1549
+ The pin shape has its own small pattern (``PIN_VERSION``) instead of reusing
1550
+ R9's ``TAGGED_TOKEN``: R6 must stay blind to a bare ``vX.Y.Z`` in prose (a
1551
+ measurement, a drill) and to the ``releases/tag/`` and ``compare/`` shapes it
1552
+ reads the other way, while a pin is a claim whatever the line says about the
1553
+ project - which is exactly the gate that hid ``install.md``'s tag pin.
1554
+ """
1555
+ head = _read_version(repo_root)
1556
+ if head is None:
1557
+ raise InputError(f"{VERSION_PATH}: no __version__ at HEAD")
1558
+ head_tuple = _version_tuple(head)
1559
+ if head_tuple is None:
1560
+ raise InputError(f"{VERSION_PATH}: __version__ {head!r} is not X.Y.Z")
1561
+ tags = _version_tags(repo_root)
1562
+ tag_tuples = {parsed for tag in tags if (parsed := _version_tuple(tag[1:])) is not None}
1563
+ newest = tags[-1] if tags else None
1564
+ newest_tuple = _version_tuple(newest[1:]) if newest is not None else None
1565
+ findings: list[str] = []
1566
+ illustrative = 0
1567
+ for rel in markdown_paths(repo_root, R6_GLOBS):
1568
+ try:
1569
+ text = (Path(repo_root) / rel).read_text(encoding="utf-8")
1570
+ except OSError as exc:
1571
+ raise InputError(f"{rel}: {exc}") from exc
1572
+ for line in _marked_lines(text):
1573
+ about_project = bool(
1574
+ PROJECT_CONTEXT.search(line.text) or PROJECT_CONTEXT.search(line.block)
1575
+ )
1576
+ tokens = _pinned_tokens(line.text)
1577
+ tokens += _project_tokens(line.text) if about_project else []
1578
+ tokens.sort(key=lambda claim: claim[0].start())
1579
+ if tokens and line.exempted:
1580
+ illustrative += 1
1581
+ continue
1582
+ for match, kind in tokens:
1583
+ printed = _version_tuple(match.group(1))
1584
+ if printed is None:
1585
+ continue
1586
+ if kind == "prose":
1587
+ if printed < head_tuple:
1588
+ findings.append(
1589
+ f"{rel}:{line.number}: prints {match.group(0)}, older than HEAD {head}"
1590
+ )
1591
+ elif printed not in tag_tuples:
1592
+ findings.append(
1593
+ f"{rel}:{line.number}: "
1594
+ + (
1595
+ f"pins {match.group(0)}, but git tag -l has no such tag"
1596
+ if kind == "pin"
1597
+ else f"installs the {match.group(0)} release asset, "
1598
+ "but git tag -l has no such tag"
1599
+ )
1600
+ )
1601
+ elif newest_tuple is not None and printed < newest_tuple:
1602
+ findings.append(
1603
+ f"{rel}:{line.number}: "
1604
+ + (
1605
+ f"pins {match.group(0)}, older than the newest tag {newest}"
1606
+ if kind == "pin"
1607
+ else f"installs the {match.group(0)} release asset, "
1608
+ f"older than the newest tag {newest}"
1609
+ )
1610
+ )
1611
+ if findings:
1612
+ return _drift("R6", findings, [f"{illustrative} illustrative"])
1613
+ return _ok("R6", f"no version older than {head}; {illustrative} illustrative")
1614
+
1615
+
1616
+ def rule_r7_changelog(repo_root: Path) -> RuleResult:
1617
+ """Every tagged version needs exactly one ``## [X.Y.Z]`` section in CHANGELOG.md."""
1618
+ path = Path(repo_root) / "CHANGELOG.md"
1619
+ try:
1620
+ text = path.read_text(encoding="utf-8")
1621
+ except FileNotFoundError:
1622
+ raise InputError("CHANGELOG.md: missing") from None
1623
+ except OSError as exc:
1624
+ raise InputError(f"CHANGELOG.md: {exc}") from exc
1625
+ counts: dict[str, int] = {}
1626
+ for line in text.splitlines():
1627
+ match = CHANGELOG_HEADING.match(line)
1628
+ if match:
1629
+ counts[match.group(1)] = counts.get(match.group(1), 0) + 1
1630
+ findings: list[str] = []
1631
+ covered = 0
1632
+ for tag in _version_tags(repo_root):
1633
+ version = tag[1:]
1634
+ found = counts.get(version, 0)
1635
+ if found == 0:
1636
+ findings.append(f"{tag} has no '## [{version}]' section")
1637
+ elif found > 1:
1638
+ findings.append(f"{tag} has {found} '## [{version}]' sections")
1639
+ else:
1640
+ covered += 1
1641
+ if findings:
1642
+ return _drift("R7", findings)
1643
+ return _ok("R7", f"{covered} tagged version(s) have exactly one CHANGELOG.md section")
1644
+
1645
+
1646
+ def rule_r9_markdown_tags(repo_root: Path) -> RuleResult:
1647
+ """Every ``vX.Y.Z`` token in committed markdown must name a real tag (11.2.4)."""
1648
+ tag_names = {line.strip() for line in (_git(repo_root, "tag", "-l") or "").splitlines()}
1649
+ findings: list[str] = []
1650
+ illustrative = 0
1651
+ for rel in markdown_paths(repo_root):
1652
+ try:
1653
+ text = (Path(repo_root) / rel).read_text(encoding="utf-8")
1654
+ except OSError as exc:
1655
+ raise InputError(f"{rel}: {exc}") from exc
1656
+ for line in _marked_lines(text):
1657
+ tokens = list(TAGGED_TOKEN.finditer(line.text))
1658
+ if line.exempted and tokens:
1659
+ illustrative += 1
1660
+ continue
1661
+ for match in tokens:
1662
+ tag = f"v{match.group(1)}"
1663
+ if tag not in tag_names:
1664
+ findings.append(f"{rel}:{line.number}: names {tag}, which is not a tag")
1665
+ if findings:
1666
+ return _drift("R9", findings, [f"{illustrative} illustrative"])
1667
+ return _ok("R9", f"every vX.Y.Z token names a tag; {illustrative} illustrative")
1668
+
1669
+
1670
+ # --------------------------------------------------------------------------- #
1671
+ # Entry point
1672
+ # --------------------------------------------------------------------------- #
1673
+
1674
+
1675
+ def _all_skip(reason: str) -> list[RuleResult]:
1676
+ return [
1677
+ _skip(rule, reason, fatal=True)
1678
+ for rule in ("R1", "R2", "R3", "R4", "R5", "R6", "R7", "R8", "R9")
1679
+ ]
1680
+
1681
+
1682
+ def _run_rule(rule: str, call: Callable[[], RuleResult]) -> RuleResult:
1683
+ try:
1684
+ return call()
1685
+ except InputError as exc:
1686
+ return _skip(rule, str(exc), fatal=True)
1687
+ except (OSError, ValueError) as exc:
1688
+ return _skip(rule, f"{type(exc).__name__}: {exc}", fatal=True)
1689
+
1690
+
1691
+ def _emit(results: Sequence[RuleResult], as_json: bool) -> int:
1692
+ problems = [
1693
+ {"rule": result.rule, "detail": finding}
1694
+ for result in results
1695
+ for finding in result.findings
1696
+ if result.status == "DRIFT"
1697
+ ]
1698
+ exit_code = 2 if any(result.fatal for result in results) else (1 if problems else 0)
1699
+ if as_json:
1700
+ payload = {
1701
+ "problems": problems,
1702
+ "rules": [
1703
+ {"id": result.rule, "status": result.status, "detail": result.detail}
1704
+ for result in results
1705
+ ],
1706
+ "exit_code": exit_code,
1707
+ }
1708
+ json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
1709
+ sys.stdout.write("\n")
1710
+ return exit_code
1711
+ for result in results:
1712
+ print(f"{result.rule} {result.status} {result.detail}")
1713
+ print(f"# release consistency: {len(problems)} problem(s)")
1714
+ return exit_code
1715
+
1716
+
1717
+ def main(argv: Sequence[str] | None = None) -> int:
1718
+ parser = argparse.ArgumentParser(
1719
+ prog="check_release_consistency.py",
1720
+ description="Prove that every published version claim agrees with the local one. "
1721
+ "Read-only; runs offline unless --online is given.",
1722
+ )
1723
+ parser.add_argument(
1724
+ "--offline", action="store_true", help="open no socket (the default when no flag is given)"
1725
+ )
1726
+ parser.add_argument(
1727
+ "--online",
1728
+ action="store_true",
1729
+ help="query npm, the GitHub API and PyPI for the published half of R3/R4/R5/R8",
1730
+ )
1731
+ parser.add_argument("--json", action="store_true", help="emit the JSON object of 11.1.3")
1732
+ parser.add_argument(
1733
+ "--exceptions",
1734
+ metavar="PATH",
1735
+ default=None,
1736
+ help="exception record (default: <repo-root>/release-exceptions.json)",
1737
+ )
1738
+ parser.add_argument(
1739
+ "--repo-root",
1740
+ metavar="PATH",
1741
+ default=None,
1742
+ help="git work tree top level to check (default: current directory)",
1743
+ )
1744
+ args = parser.parse_args(argv)
1745
+
1746
+ if args.offline and args.online:
1747
+ print(
1748
+ "# check_release_consistency: --offline and --online are mutually exclusive",
1749
+ file=sys.stderr,
1750
+ )
1751
+ return 2
1752
+
1753
+ repo_root = _git_root(args.repo_root)
1754
+ if repo_root is None:
1755
+ reason = (
1756
+ f"not a git work tree top level: {args.repo_root or Path.cwd()} "
1757
+ f"(git rev-parse --show-toplevel does not normalise to it)"
1758
+ )
1759
+ return _emit(_all_skip(reason), args.json)
1760
+
1761
+ exceptions_path = (
1762
+ Path(args.exceptions)
1763
+ if args.exceptions and Path(args.exceptions).is_absolute()
1764
+ else repo_root / (args.exceptions or "release-exceptions.json")
1765
+ )
1766
+ exceptions: list[dict[str, str]] = []
1767
+ exceptions_error: str | None = None
1768
+ try:
1769
+ exceptions = load_exceptions(exceptions_path)
1770
+ except InputError as exc:
1771
+ exceptions_error = str(exc)
1772
+
1773
+ if args.online:
1774
+ # The bearer is read once, here, and not per request.
1775
+ _set_github_bearer(_github_bearer_from_env())
1776
+ fetch = _http_get_json if args.online else None
1777
+ fetch_bytes = _http_get_bytes if args.online else None
1778
+
1779
+ results: list[RuleResult] = [
1780
+ _run_rule("R1", lambda: rule_r1_tagged_version(repo_root)),
1781
+ _run_rule("R2", lambda: rule_r2_head_is_newer(repo_root)),
1782
+ _run_rule(
1783
+ "R3",
1784
+ lambda: (
1785
+ _skip(
1786
+ "R3",
1787
+ f"{exceptions_error}; npm versions cannot be admitted or compared",
1788
+ fatal=True,
1789
+ )
1790
+ if exceptions_error
1791
+ else rule_r3_npm_payloads(repo_root, exceptions, fetch, fetch_bytes)
1792
+ ),
1793
+ ),
1794
+ _run_rule("R4", lambda: rule_r4_releases_have_tags(repo_root, fetch)),
1795
+ _run_rule("R5", lambda: rule_r5_tags_have_releases(repo_root, fetch)),
1796
+ _run_rule("R6", lambda: rule_r6_markdown_versions(repo_root)),
1797
+ _run_rule("R7", lambda: rule_r7_changelog(repo_root)),
1798
+ _run_rule("R8", lambda: rule_r8_install_channels(repo_root, fetch)),
1799
+ _run_rule("R9", lambda: rule_r9_markdown_tags(repo_root)),
1800
+ ]
1801
+ return _emit(results, args.json)
1802
+
1803
+
1804
+ if __name__ == "__main__":
1805
+ raise SystemExit(main())