@nidvue/teamwork 1.1.1 → 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.
- package/CHANGELOG.md +368 -3
- package/README.md +80 -26
- package/package.json +1 -1
- package/skills/teamwork/AGENTS.md +57 -2
- package/skills/teamwork/SKILL.md +133 -27
- package/skills/teamwork/references/Adapters.md +4 -0
- package/skills/teamwork/references/Audit.md +3 -1
- package/skills/teamwork/references/Concurrency.md +22 -7
- package/skills/teamwork/references/Coordination.md +203 -0
- package/skills/teamwork/references/Dissent.md +10 -1
- package/skills/teamwork/references/Docs.md +3 -1
- package/skills/teamwork/references/Engineering.md +47 -8
- package/skills/teamwork/references/Evidence.md +18 -1
- package/skills/teamwork/references/Remotes.md +29 -8
- package/skills/teamwork/references/Roles.md +22 -4
- package/skills/teamwork/references/State.md +96 -12
- package/skills/teamwork/references/Testing.md +3 -1
- package/skills/teamwork/roles/ceo.md +50 -3
- package/skills/teamwork/roles/developer.md +8 -0
- package/skills/teamwork/roles/product-manager.md +4 -0
- package/skills/teamwork/roles/reviewer.md +97 -12
- package/skills/teamwork/scripts/check_release_consistency.py +1805 -0
- package/skills/teamwork/scripts/privacy_scan.py +196 -82
- package/skills/teamwork/src/teamwork/bundle.py +0 -7
- package/skills/teamwork/src/teamwork/config.py +53 -2
- package/skills/teamwork/src/teamwork/engine.py +2722 -428
- package/skills/teamwork/src/teamwork/gitutil.py +148 -10
- package/skills/teamwork/src/teamwork/harness.py +753 -0
- package/skills/teamwork/src/teamwork/hooks.py +400 -0
- package/skills/teamwork/src/teamwork/layout.py +10 -4
- package/skills/teamwork/src/teamwork/portable.py +6 -1
- package/skills/teamwork/src/teamwork/records.py +20 -0
- package/skills/teamwork/src/teamwork/resources.py +41 -7
- package/skills/teamwork/src/teamwork/version.py +1 -1
- 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())
|