@veda-hq/pariksha 0.5.19 → 0.5.20

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/README.md CHANGED
@@ -4,8 +4,8 @@
4
4
  test-quality ratchets. Part of [Veda](https://github.com/veda-hq/veda).
5
5
 
6
6
  ```sh
7
- npx @veda-hq/pariksha@0.5.19 <check> [flags]
8
- npx @veda-hq/pariksha@0.5.19 --version
7
+ npx @veda-hq/pariksha@0.5.20 <check> [flags]
8
+ npx @veda-hq/pariksha@0.5.20 --version
9
9
  ```
10
10
 
11
11
  Every check exits 0 when the repository passes, 1 when it refuses something
@@ -931,6 +931,47 @@ refuse is not passed: it is printed as a selector not version-checked.
931
931
  line to stdout; a missing input file is a failure (exit 1), a bad config or
932
932
  an unreadable `package.json` exits 2.
933
933
 
934
+ ## `python-floor`
935
+
936
+ ```sh
937
+ pariksha python-floor --python <interpreter> --floor-file <path> [--exclude <regex>] [--selector <path>] [--root <dir>]
938
+ ```
939
+
940
+ Every tracked Python source runs on the Python the repository declares. The
941
+ floor drifts unseen: CI runners ship a new Python, and a PEP 604 union or a
942
+ fresh stdlib import reads as ordinary code until it fails on an older
943
+ interpreter, inside whatever was running it. Sources are `*.py` files, and
944
+ heredocs and single-quoted `python3 -c '...'` payloads in `*.sh` files and
945
+ under `.husky/`, read from the index (the commit being made, not the working
946
+ tree). Each is refused for:
947
+
948
+ 1. syntax the floor cannot parse (`ast` with `feature_version` at the floor);
949
+ 2. a stdlib module added after the floor, or removed at or before it;
950
+ 3. on a floor below 3.10, a PEP 604 `X | Y` that is evaluated.
951
+
952
+ A source it cannot read (an unknown encoding, a symlink, conflict stages) is
953
+ refused, never skipped.
954
+
955
+ - `--python` names the interpreter, at or above the floor; pariksha never
956
+ chooses or bundles one. Below the floor it refuses to judge (exit 2).
957
+ - `--floor-file` is the file, relative to the root and tracked, holding one
958
+ `MAJOR.MINOR` (`#` comments allowed).
959
+ - `--exclude` (default none) is a Python regular expression matched from the
960
+ start of each path; what it matches is not checked. One that does not
961
+ compile exits 2.
962
+ - `--selector` (default none) names what picks an interpreter at or above
963
+ the floor in this repository, for the message given when `--python` is
964
+ below it.
965
+
966
+ The check is a stdlib-only script shipped in the package
967
+ (`python/python-floor.py`), run as `<interpreter> python/python-floor.py`;
968
+ its stdout (the clean line), stderr (findings) and exit code are passed
969
+ through unchanged. An interpreter that cannot be started, or that is stopped
970
+ by a signal, exits 2. Two input errors exit 1, as the script always has: a
971
+ floor file that is not in the index, not a regular file there, or not one
972
+ `MAJOR.MINOR` (one line on stderr), and a `--root` outside a git repository
973
+ (a Python traceback).
974
+
934
975
  ## `required-settings`
935
976
 
936
977
  ```sh
package/dist/cli.js CHANGED
@@ -31,6 +31,7 @@ import { emptyRoots, growthSince, readBaseline, wholeId } from "./registry-ratch
31
31
  import { checkClaims, formatOutcome, loadEslint, parseConfig as parseClaimsConfig } from "./enforcement-claims.js";
32
32
  import { parseConfig as parseDuplicateConfig, runDuplicateTests } from "./duplicate-tests.js";
33
33
  import { DEFAULT_RUNS, npxVitest, parseConfig as parseFlakyConfig, parseRuns, runFlakyTests } from "./flaky-tests.js";
34
+ import { inheritRunner, runPythonFloor } from "./python-floor.js";
34
35
  export const DENY_FILE = '.veda/deny.sha256';
35
36
  const USAGE = [
36
37
  'usage: pariksha authority-citations [--mode staged|tree] [--authority <a,b>] [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]',
@@ -51,6 +52,7 @@ const USAGE = [
51
52
  ' pariksha masked-exit-codes [--config <file>] [--staged] [--root <dir>]',
52
53
  ' pariksha non-synced-resources [--config <file>] [--files-from <file>|-] [--root <dir>]',
53
54
  ' pariksha pnpm-settings [--config <file>] [--root <dir>]',
55
+ ' pariksha python-floor --python <interpreter> --floor-file <path> [--exclude <regex>] [--selector <path>] [--root <dir>]',
54
56
  ' pariksha required-settings --require <file> [--settings <file>] [--root <dir>]',
55
57
  ' pariksha shell-gates [--config <file>] [--staged | --all | --base <ref> | --files-from <file>|-] [--only <checks>] [--root <dir>]',
56
58
  ' pariksha hash <word>',
@@ -769,6 +771,26 @@ const flakyTests = (f, io) => {
769
771
  }
770
772
  return runFlakyTests(selected.config, runs, selected.mode, root, selected.files, io.vitest ?? npxVitest, io.out);
771
773
  };
774
+ /** python-floor: the packaged script under the `--python` the caller names, its output and exit code passed through. */
775
+ const pythonFloor = (f, io) => {
776
+ const python = f.get('--python');
777
+ const floorFile = f.get('--floor-file');
778
+ if (python === undefined || floorFile === undefined) {
779
+ io.err(USAGE);
780
+ return 2;
781
+ }
782
+ const root = f.get('--root');
783
+ const exclude = f.get('--exclude');
784
+ const selector = f.get('--selector');
785
+ const options = {
786
+ python,
787
+ floorFile,
788
+ ...(root === undefined ? {} : { root }),
789
+ ...(exclude === undefined ? {} : { exclude }),
790
+ ...(selector === undefined ? {} : { selector }),
791
+ };
792
+ return runPythonFloor(options, io.python ?? inheritRunner, io.err);
793
+ };
772
794
  const COMMANDS = {
773
795
  'codec-registry': { allowed: ['--config', '--base', '--root'], switches: ['--no-base'], run: codecRegistry },
774
796
  'coverage-exemptions': { allowed: ['--config', '--package', '--root'], run: coverageExemptions },
@@ -777,6 +799,7 @@ const COMMANDS = {
777
799
  'aggregate-gate-needs': { allowed: ['--config', '--root'], run: aggregateGateNeeds },
778
800
  'non-synced-resources': { allowed: ['--config', '--files-from', '--root'], run: nonSyncedResources },
779
801
  'pnpm-settings': { allowed: ['--config', '--root'], run: pnpmSettings },
802
+ 'python-floor': { allowed: ['--python', '--floor-file', '--exclude', '--selector', '--root'], run: pythonFloor },
780
803
  'authority-citations': { allowed: ['--mode', '--authority', '--issue-prefix', '--adr-dir', '--root'], run: authorityCitations },
781
804
  boundary: { allowed: ['--root', '--deny'], run: boundary },
782
805
  'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
@@ -0,0 +1,32 @@
1
+ // python-floor: every tracked Python source runs on the interpreter a repository
2
+ // declares. The check is python/python-floor.py, run under the interpreter the
3
+ // caller names; pariksha never chooses or bundles a Python, and passes the
4
+ // script's stdout, stderr and exit code through unchanged.
5
+ import { spawnSync } from 'node:child_process';
6
+ import { join } from 'node:path';
7
+ /** The packaged script: beside src/ in the repository, beside dist/ in the npm package. */
8
+ export const SCRIPT = join(import.meta.dirname, '..', 'python', 'python-floor.py');
9
+ export const inheritRunner = (python, args) => spawnSync(python, args, { stdio: 'inherit' });
10
+ /** The script's arguments: each option only when given, so its own defaults hold. */
11
+ export const scriptArgs = (options) => [
12
+ SCRIPT,
13
+ '--floor-file',
14
+ options.floorFile,
15
+ ...(options.root === undefined ? [] : ['--root', options.root]),
16
+ ...(options.exclude === undefined ? [] : ['--exclude', options.exclude]),
17
+ ...(options.selector === undefined ? [] : ['--selector', options.selector]),
18
+ ];
19
+ /** The script's exit code; 2, with a reason on `err`, when it could not run or did not exit. */
20
+ export const runPythonFloor = (options, runner, err) => {
21
+ const P = 'pariksha python-floor:';
22
+ const result = runner(options.python, scriptArgs(options));
23
+ if (result.error !== undefined) {
24
+ err(`${P} cannot run ${options.python}: ${result.error.message}; nothing was checked, so this is not a pass`);
25
+ return 2;
26
+ }
27
+ if (result.status === null) {
28
+ err(`${P} ${options.python} was stopped by ${String(result.signal)}; nothing was checked, so this is not a pass`);
29
+ return 2;
30
+ }
31
+ return result.status;
32
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@veda-hq/pariksha",
3
- "version": "0.5.19",
4
- "description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: absolute-paths, aggregate-gate-needs, authority-citations, boundary, codec-registry, coverage-by-type, coverage-exemptions, doc-budgets, doc-references, duplicate-tests, enforcement-claims, env-gate-switches, export-deletions, fault-injection-registry, flaky-tests, masked-exit-codes, non-synced-resources, pnpm-settings, required-settings, shell-gates. Also markdownlint rules for Markdown that renders mangled: @veda-hq/pariksha/markdownlint.",
3
+ "version": "0.5.20",
4
+ "description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: absolute-paths, aggregate-gate-needs, authority-citations, boundary, codec-registry, coverage-by-type, coverage-exemptions, doc-budgets, doc-references, duplicate-tests, enforcement-claims, env-gate-switches, export-deletions, fault-injection-registry, flaky-tests, masked-exit-codes, non-synced-resources, pnpm-settings, python-floor, required-settings, shell-gates. Also markdownlint rules for Markdown that renders mangled: @veda-hq/pariksha/markdownlint.",
5
5
  "keywords": [
6
6
  "repository checks",
7
7
  "lint",
@@ -36,6 +36,7 @@
36
36
  },
37
37
  "files": [
38
38
  "dist",
39
+ "python",
39
40
  "baselines",
40
41
  "NOTICE"
41
42
  ],
@@ -0,0 +1,631 @@
1
+ #!/usr/bin/env python3
2
+ """Every tracked Python source must run on the interpreter this repo declares.
3
+
4
+ WHY THIS EXISTS
5
+ ---------------
6
+ The floor in the ``--floor-file`` is only worth declaring if something
7
+ notices when code drifts above it. The drift is invisible in CI — Ubuntu
8
+ runners ship a new Python — and invisible in review, because a PEP 604 union or
9
+ a fresh stdlib import looks like ordinary modern Python. It surfaces on a
10
+ developer's Mac, from inside a validator, as a message about something else:
11
+
12
+ * ``scripts/run-validation.sh`` grew ``-> bytes | None`` annotations. Before
13
+ 3.10 those are evaluated at ``def`` time, so a clean ``git merge`` died with
14
+ ``TypeError: unsupported operand type(s) for |`` — which the Codex isolation
15
+ validator re-reported as "policy history gate rejected authorized
16
+ activation", i.e. as a trust-chain violation.
17
+ * ``validate-codex-workflow-ports.py`` grew ``import tomllib`` (3.11+), which
18
+ fails as ``ModuleNotFoundError`` with no hint that a Python version is the
19
+ cause.
20
+
21
+ Three checks, all measured against the declared floor rather than a constant,
22
+ so lowering the floor re-arms the ones that are currently inert:
23
+
24
+ 1. syntax — parsed with ``ast``'s ``feature_version`` set to the floor;
25
+ 2. stdlib — modules added after the floor, or removed at or before it;
26
+ 3. runtime PEP 604 — ``X | Y`` that is *evaluated*, which is every use before
27
+ 3.10 except an annotation guarded by ``from __future__ import annotations``.
28
+
29
+ Usage: python-floor.py --floor-file PATH [--exclude REGEX] [--selector PATH] [--root DIR] [--floor 3.11]
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import ast
36
+ import io
37
+ import re
38
+ import subprocess
39
+ import tokenize
40
+ import sys
41
+ from pathlib import Path
42
+
43
+
44
+ # The floor file, relative to the root: --floor-file, set by main().
45
+ FLOOR_FILE = Path()
46
+
47
+ # Top-level stdlib modules and the version that introduced them. Only entries
48
+ # at or after 3.9 matter: anything older predates every floor worth declaring.
49
+ # An import of a module absent from this table is assumed to be old enough,
50
+ # which is the safe default — a false negative here costs a portability bug,
51
+ # while a false positive would block a legitimate import.
52
+ ADDED_IN = {
53
+ "graphlib": (3, 9),
54
+ "zoneinfo": (3, 9),
55
+ "tomllib": (3, 11),
56
+ "annotationlib": (3, 14),
57
+ "compression": (3, 14),
58
+ }
59
+
60
+ # Modules removed by a given version, so raising the floor catches code that
61
+ # still imports them. PEP 594 took most of these out in 3.12.
62
+ REMOVED_IN = {
63
+ "asynchat": (3, 12),
64
+ "asyncore": (3, 12),
65
+ "distutils": (3, 12),
66
+ "imp": (3, 12),
67
+ "smtpd": (3, 12),
68
+ "aifc": (3, 13),
69
+ "cgi": (3, 13),
70
+ "cgitb": (3, 13),
71
+ "telnetlib": (3, 13),
72
+ }
73
+
74
+ # A heredoc that feeds Python: the command word on the line is a python
75
+ # interpreter. Matching on the command rather than the tag keeps a
76
+ # `cat <<PYTHON_NOTES` out of the scan and keeps `python3 - <<EOF` in it.
77
+ HEREDOC = re.compile(
78
+ r"\bpython3?(?:\.\d+)?\b[^\n<]*<<(-)?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2\s*$"
79
+ )
80
+
81
+ # `python3 -c '...'` with a single-quoted payload, which may span lines. Double
82
+ # quotes are skipped deliberately: the shell interpolates them, so the bytes in
83
+ # the file are not the bytes Python sees.
84
+ INLINE = re.compile(r"\bpython3?(?:\.\d+)?\b[^\n]*?\s-c\s+'")
85
+
86
+
87
+ class Finding:
88
+ def __init__(self, where: str, line: int, rule: str, detail: str, remedy: str) -> None:
89
+ self.where = where
90
+ self.line = line
91
+ self.rule = rule
92
+ self.detail = detail
93
+ self.remedy = remedy
94
+
95
+ def render(self) -> str:
96
+ return (
97
+ f" {self.where}:{self.line}\n"
98
+ f" {self.rule}: {self.detail}\n"
99
+ f" fix: {self.remedy}"
100
+ )
101
+
102
+
103
+ class Source:
104
+ """One unit of Python: a file, a heredoc body, or a `-c` payload."""
105
+
106
+ def __init__(self, where: str, first_line: int, text: str) -> None:
107
+ self.where = where
108
+ self.first_line = first_line
109
+ self.text = text
110
+
111
+ def at(self, node_line: int) -> int:
112
+ return self.first_line + node_line - 1
113
+
114
+
115
+ class Entry:
116
+ """One index entry: what a commit made now would record at ``path``."""
117
+
118
+ def __init__(self, mode: str, oid: str, stage: str, path: str) -> None:
119
+ self.mode = mode
120
+ self.oid = oid
121
+ self.stage = stage
122
+ self.path = path
123
+
124
+
125
+ # Index modes that are not a regular file's bytes: a symlink's blob is its
126
+ # target path, and a gitlink names a commit in another repository.
127
+ SYMLINK_MODE = "120000"
128
+ GITLINK_MODE = "160000"
129
+ REGULAR_MODES = {"100644", "100755"}
130
+
131
+
132
+ def index_entries(root: Path) -> list[Entry]:
133
+ """The index, not the working tree, because this gate runs at pre-commit.
134
+
135
+ Reading files from disk judged something other than the commit: a tracked
136
+ file deleted with the deletion unstaged was refused as unreadable although
137
+ the commit keeps it unchanged, and a broken staged file passed as long as
138
+ the copy on disk had been repaired without staging the repair.
139
+ """
140
+ out = subprocess.run(
141
+ ["git", "-C", str(root), "ls-files", "--stage", "-z"],
142
+ capture_output=True,
143
+ check=True,
144
+ ).stdout.decode()
145
+ entries: list[Entry] = []
146
+ for record in out.split("\0"):
147
+ if not record:
148
+ continue
149
+ meta, path = record.split("\t", 1)
150
+ mode, oid, stage = meta.split(" ")
151
+ entries.append(Entry(mode, oid, stage, path))
152
+ return entries
153
+
154
+
155
+ def read_blobs(root: Path, oids: list[str]) -> dict[str, bytes | None]:
156
+ """Blob bytes by object id, from one `git cat-file --batch`; None if missing."""
157
+ stdout = subprocess.run(
158
+ ["git", "-C", str(root), "cat-file", "--batch"],
159
+ input="".join(f"{oid}\n" for oid in oids).encode(),
160
+ capture_output=True,
161
+ check=True,
162
+ ).stdout
163
+ blobs: dict[str, bytes | None] = {}
164
+ offset = 0
165
+ for oid in oids:
166
+ end = stdout.index(b"\n", offset)
167
+ header = stdout[offset:end].decode().split(" ")
168
+ offset = end + 1
169
+ if len(header) != 3:
170
+ # `<oid> missing`: no content follows the header.
171
+ blobs[oid] = None
172
+ continue
173
+ size = int(header[2])
174
+ blobs[oid] = stdout[offset : offset + size]
175
+ offset += size + 1
176
+ return blobs
177
+
178
+
179
+ def decode(data: bytes, encoding: str) -> str:
180
+ # TextIOWrapper, as tokenize.open and Path.read_text use, so newline
181
+ # translation matches what reading the file from disk produced.
182
+ return io.TextIOWrapper(io.BytesIO(data), encoding=encoding).read()
183
+
184
+
185
+ def read_floor(root: Path, entries: list[Entry]) -> tuple[int, int]:
186
+ staged = [
187
+ entry
188
+ for entry in entries
189
+ if entry.path == FLOOR_FILE.as_posix() and entry.stage == "0"
190
+ ]
191
+ if not staged:
192
+ raise SystemExit(f"{FLOOR_FILE} is not in the index, so there is no declared floor")
193
+ # A symlink's blob is its target path, which could itself read as a version.
194
+ if staged[0].mode not in REGULAR_MODES:
195
+ raise SystemExit(f"{FLOOR_FILE} must be a regular file in the index")
196
+ blob = read_blobs(root, [staged[0].oid])[staged[0].oid]
197
+ if blob is None:
198
+ raise SystemExit(f"{FLOOR_FILE} is staged but its object is missing")
199
+ raw = decode(blob, "utf-8")
200
+ stripped = [
201
+ line.split("#", 1)[0].strip() for line in raw.splitlines()
202
+ ]
203
+ values = [line for line in stripped if line]
204
+ if len(values) != 1:
205
+ raise SystemExit(
206
+ f"{FLOOR_FILE} must hold exactly one version, found {len(values)}"
207
+ )
208
+ parts = values[0].split(".")
209
+ if len(parts) != 2 or not all(part.isdigit() for part in parts):
210
+ raise SystemExit(f"{FLOOR_FILE} must hold MAJOR.MINOR, found {values[0]!r}")
211
+ return (int(parts[0]), int(parts[1]))
212
+
213
+
214
+ # Paths nothing executes, no check reads and no edit may touch, matched from
215
+ # the start of the path: --exclude, set by main(); None excludes nothing.
216
+ FROZEN: re.Pattern[str] | None = None
217
+
218
+
219
+ def is_candidate(path: str) -> bool:
220
+ if FROZEN is not None and FROZEN.match(path):
221
+ return False
222
+ return path.endswith(".py") or path.endswith(".sh") or path.startswith(".husky/")
223
+
224
+
225
+ def collect(root: Path, entries: list[Entry]) -> tuple[list[Source], list[Finding]]:
226
+ """Every Python source a hook, validator or agent tool can execute.
227
+
228
+ Each source is its STAGED blob, so the gate judges the commit being made.
229
+ Unreadable sources are REPORTED, never skipped. A guard that quietly drops a
230
+ file it cannot decode passes while that file holds anything at all, which is
231
+ the failure mode this whole script exists to prevent.
232
+ """
233
+ sources: list[Source] = []
234
+ failures: list[Finding] = []
235
+ unmerged: set[str] = set()
236
+ regular: list[Entry] = []
237
+ for entry in entries:
238
+ if not is_candidate(entry.path) or entry.mode == GITLINK_MODE:
239
+ continue
240
+ if entry.stage != "0":
241
+ if entry.path not in unmerged:
242
+ unmerged.add(entry.path)
243
+ failures.append(
244
+ unreadable(
245
+ entry.path,
246
+ "unmerged: the index holds conflict stages",
247
+ "resolve the conflict and stage the result",
248
+ )
249
+ )
250
+ continue
251
+ if entry.mode == SYMLINK_MODE:
252
+ failures.append(
253
+ unreadable(
254
+ entry.path,
255
+ "a symlink: the gate reads staged blobs and does not follow links",
256
+ "track the file itself rather than a link to it",
257
+ )
258
+ )
259
+ continue
260
+ regular.append(entry)
261
+
262
+ blobs = read_blobs(root, [entry.oid for entry in regular])
263
+ for entry in regular:
264
+ path = entry.path
265
+ blob = blobs[entry.oid]
266
+ if blob is None:
267
+ failures.append(unreadable(path, f"staged object {entry.oid} is missing"))
268
+ continue
269
+ if path.endswith(".py"):
270
+ try:
271
+ # detect_encoding honours the PEP 263 coding declaration and a
272
+ # BOM, as tokenize.open does, so a validly-encoded non-UTF-8
273
+ # source is CHECKED rather than skipped.
274
+ encoding, _ = tokenize.detect_encoding(io.BytesIO(blob).readline)
275
+ text = decode(blob, encoding)
276
+ except (SyntaxError, UnicodeDecodeError, ValueError) as error:
277
+ failures.append(unreadable(path, f"{type(error).__name__}: {error}"))
278
+ continue
279
+ sources.append(Source(path, 1, text))
280
+ continue
281
+ try:
282
+ text = decode(blob, "utf-8")
283
+ except UnicodeDecodeError as error:
284
+ failures.append(unreadable(path, f"{type(error).__name__}: {error}"))
285
+ continue
286
+ sources.extend(embedded(path, text))
287
+ return sources, failures
288
+
289
+
290
+ def unreadable(
291
+ path: str,
292
+ detail: str,
293
+ remedy: str = "give the file a decodable encoding (a PEP 263 declaration for "
294
+ "Python), or untrack it",
295
+ ) -> Finding:
296
+ return Finding(
297
+ path,
298
+ 1,
299
+ "unreadable",
300
+ detail,
301
+ f"{remedy} — this gate will not skip a source it cannot read",
302
+ )
303
+
304
+
305
+ def embedded(path: str, text: str) -> list[Source]:
306
+ lines = text.splitlines()
307
+ found: list[Source] = []
308
+ index = 0
309
+ while index < len(lines):
310
+ line = lines[index]
311
+ heredoc = HEREDOC.search(line)
312
+ if heredoc is not None:
313
+ dash, tag = heredoc.group(1), heredoc.group(3)
314
+ start = index + 1
315
+ end = start
316
+ while end < len(lines) and lines[end].strip() != tag:
317
+ end += 1
318
+ body = lines[start:end]
319
+ # `<<-` tells the shell to strip leading TABS from every body line
320
+ # (and from the terminator, which `.strip()` above already covers).
321
+ # Python never sees them, so neither may this — otherwise a
322
+ # tab-indented heredoc reads as an IndentationError and is reported
323
+ # as syntax the floor cannot parse.
324
+ if dash:
325
+ body = [entry.lstrip("\t") for entry in body]
326
+ found.append(
327
+ Source(f"{path} <<{tag}", start + 1, "\n".join(body))
328
+ )
329
+ index = end + 1
330
+ continue
331
+ inline = INLINE.search(line)
332
+ if inline is not None:
333
+ body, end = inline_payload(lines, index, inline.end())
334
+ found.append(Source(f"{path} python -c", index + 1, body))
335
+ index = end + 1
336
+ continue
337
+ index += 1
338
+ return found
339
+
340
+
341
+ def inline_payload(lines: list[str], index: int, offset: int) -> tuple[str, int]:
342
+ """Text between the opening quote at ``offset`` and the next single quote."""
343
+ first = lines[index][offset:]
344
+ if "'" in first:
345
+ return first[: first.index("'")], index
346
+ collected = [first]
347
+ cursor = index + 1
348
+ while cursor < len(lines):
349
+ if "'" in lines[cursor]:
350
+ collected.append(lines[cursor][: lines[cursor].index("'")])
351
+ return "\n".join(collected), cursor
352
+ collected.append(lines[cursor])
353
+ cursor += 1
354
+ return "\n".join(collected), cursor - 1
355
+
356
+
357
+ def has_future_annotations(tree: ast.Module) -> bool:
358
+ return any(
359
+ isinstance(node, ast.ImportFrom)
360
+ and node.module == "__future__"
361
+ and any(alias.name == "annotations" for alias in node.names)
362
+ for node in tree.body
363
+ )
364
+
365
+
366
+ def annotation_nodes(tree: ast.Module) -> set[int]:
367
+ """id() of every expression that sits in an annotation position."""
368
+ marked: set[int] = set()
369
+ for node in ast.walk(tree):
370
+ found: list[ast.expr] = []
371
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
372
+ arguments = node.args
373
+ for arg in (
374
+ arguments.posonlyargs
375
+ + arguments.args
376
+ + arguments.kwonlyargs
377
+ + [arguments.vararg, arguments.kwarg]
378
+ ):
379
+ if arg is not None and arg.annotation is not None:
380
+ found.append(arg.annotation)
381
+ if node.returns is not None:
382
+ found.append(node.returns)
383
+ elif isinstance(node, ast.AnnAssign):
384
+ found.append(node.annotation)
385
+ for expression in found:
386
+ for descendant in ast.walk(expression):
387
+ marked.add(id(descendant))
388
+ return marked
389
+
390
+
391
+ def check_stdlib(source: Source, tree: ast.Module, floor: tuple[int, int]) -> list[Finding]:
392
+ findings: list[Finding] = []
393
+ for node in ast.walk(tree):
394
+ names: list[str] = []
395
+ if isinstance(node, ast.Import):
396
+ names = [alias.name for alias in node.names]
397
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
398
+ names = [node.module]
399
+ for name in names:
400
+ top = name.split(".")[0]
401
+ added = ADDED_IN.get(top)
402
+ if added is not None and added > floor:
403
+ findings.append(
404
+ Finding(
405
+ source.where,
406
+ source.at(node.lineno),
407
+ "stdlib-too-new",
408
+ f"{top} was added in Python {added[0]}.{added[1]}, "
409
+ f"above the declared floor {floor[0]}.{floor[1]}",
410
+ f"raise {FLOOR_FILE.as_posix()}, or use a module the floor has "
411
+ "(no __future__ import can supply a missing module)",
412
+ )
413
+ )
414
+ removed = REMOVED_IN.get(top)
415
+ if removed is not None and removed <= floor:
416
+ findings.append(
417
+ Finding(
418
+ source.where,
419
+ source.at(node.lineno),
420
+ "stdlib-removed",
421
+ f"{top} was removed in Python {removed[0]}.{removed[1]}, "
422
+ f"at or below the declared floor {floor[0]}.{floor[1]}",
423
+ "use the replacement module named in that release's changelog",
424
+ )
425
+ )
426
+ return findings
427
+
428
+
429
+ def type_expression_nodes(tree: ast.Module) -> set[int]:
430
+ """id() of expressions that are unambiguously types rather than values.
431
+
432
+ `|` is overloaded: set union, dict merge and `os.O_RDONLY | os.O_NOFOLLOW`
433
+ all use it and all predate PEP 604. Flagging every BitOr would bury the real
434
+ finding under false positives from this repo's own hooks, so only positions
435
+ where `|` cannot mean anything but a type union are considered.
436
+ """
437
+ marked: set[int] = set()
438
+
439
+ def mark(expression: ast.expr) -> None:
440
+ for descendant in ast.walk(expression):
441
+ marked.add(id(descendant))
442
+
443
+ for node in ast.walk(tree):
444
+ if isinstance(node, ast.Call):
445
+ callee = node.func
446
+ name = callee.attr if isinstance(callee, ast.Attribute) else getattr(callee, "id", "")
447
+ # cast(T, value); isinstance(x, T); issubclass(C, T)
448
+ if name == "cast" and node.args:
449
+ mark(node.args[0])
450
+ elif name in {"isinstance", "issubclass"} and len(node.args) > 1:
451
+ mark(node.args[1])
452
+ return marked
453
+
454
+
455
+ def is_optional_shorthand(node: ast.BinOp) -> bool:
456
+ """`X | None`, which can never be set union or integer bitwise-or."""
457
+ return any(
458
+ isinstance(side, ast.Constant) and side.value is None
459
+ for side in (node.left, node.right)
460
+ )
461
+
462
+
463
+ def check_runtime_unions(
464
+ source: Source, tree: ast.Module, floor: tuple[int, int]
465
+ ) -> list[Finding]:
466
+ """PEP 604 unions that are evaluated, on a floor that cannot evaluate them.
467
+
468
+ Inert at 3.10 and above, and deliberately so: it is the check that makes the
469
+ floor file mean something if anyone lowers it.
470
+
471
+ Conservative outside annotations. A bare alias of two names — `IntOrStr =
472
+ int | str` — is indistinguishable from a set union without type knowledge
473
+ and is not reported; `int | None` is, because `None` has no `__or__`.
474
+ """
475
+ if floor >= (3, 10):
476
+ return []
477
+ deferred = has_future_annotations(tree)
478
+ in_annotation = annotation_nodes(tree)
479
+ in_type_position = type_expression_nodes(tree)
480
+ findings: list[Finding] = []
481
+ for node in ast.walk(tree):
482
+ if not (isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr)):
483
+ continue
484
+ annotated = id(node) in in_annotation
485
+ if annotated:
486
+ if deferred:
487
+ continue
488
+ detail = (
489
+ "X | Y in an annotation is evaluated at definition time without "
490
+ "`from __future__ import annotations`"
491
+ )
492
+ elif id(node) in in_type_position or is_optional_shorthand(node):
493
+ detail = "X | Y in a type position outside an annotation is always evaluated"
494
+ else:
495
+ continue
496
+ findings.append(
497
+ Finding(
498
+ source.where,
499
+ source.at(node.lineno),
500
+ "runtime-pep604",
501
+ f"{detail}; PEP 604 unions need Python 3.10, floor is "
502
+ f"{floor[0]}.{floor[1]}",
503
+ "add `from __future__ import annotations` (annotations only), or "
504
+ "write Optional[X] / Union[X, Y]",
505
+ )
506
+ )
507
+ return findings
508
+
509
+
510
+ def check(source: Source, floor: tuple[int, int]) -> list[Finding]:
511
+ try:
512
+ tree = ast.parse(source.text, feature_version=floor)
513
+ except SyntaxError as error:
514
+ return [
515
+ Finding(
516
+ source.where,
517
+ source.at(error.lineno or 1),
518
+ "syntax-too-new",
519
+ f"{error.msg} — not valid Python {floor[0]}.{floor[1]}",
520
+ f"rewrite for the declared floor, or raise {FLOOR_FILE.as_posix()}",
521
+ )
522
+ ]
523
+ except ValueError as error:
524
+ return [
525
+ Finding(source.where, source.first_line, "unparseable", str(error), "check the source")
526
+ ]
527
+ return check_stdlib(source, tree, floor) + check_runtime_unions(source, tree, floor)
528
+
529
+
530
+ def main() -> int:
531
+ parser = argparse.ArgumentParser(description=__doc__)
532
+ parser.add_argument("--root", default=".")
533
+ parser.add_argument(
534
+ "--floor-file",
535
+ required=True,
536
+ help="the file declaring MAJOR.MINOR, relative to --root",
537
+ )
538
+ parser.add_argument(
539
+ "--exclude",
540
+ help="a regular expression matched from the start of each path; matches are not checked",
541
+ )
542
+ parser.add_argument(
543
+ "--selector",
544
+ help="what picks an interpreter at or above the floor, named when this one is below it",
545
+ )
546
+ parser.add_argument(
547
+ "--floor",
548
+ help="override MAJOR.MINOR for testing; defaults to the --floor-file",
549
+ )
550
+ args = parser.parse_args()
551
+ global FLOOR_FILE, FROZEN
552
+ FLOOR_FILE = Path(args.floor_file)
553
+ if args.exclude is not None:
554
+ try:
555
+ FROZEN = re.compile(args.exclude)
556
+ except re.error as error:
557
+ parser.error(f"--exclude is not a regular expression: {error}")
558
+ root = Path(args.root).resolve()
559
+ entries = index_entries(root)
560
+
561
+ if args.floor is None:
562
+ floor = read_floor(root, entries)
563
+ else:
564
+ parts = args.floor.split(".")
565
+ floor = (int(parts[0]), int(parts[1]))
566
+
567
+ # `feature_version` cannot describe a grammar newer than the running
568
+ # interpreter, so a stale Python would silently under-report. The hook
569
+ # selects an interpreter at or above the floor before calling this.
570
+ if sys.version_info[:2] < floor:
571
+ print(
572
+ f"❌ validate-hook-python-floor: running on Python "
573
+ f"{sys.version_info[0]}.{sys.version_info[1]}, which is below the "
574
+ f"declared floor {floor[0]}.{floor[1]}, so it cannot judge the floor.\n"
575
+ f" Run it under a newer interpreter"
576
+ + (
577
+ ""
578
+ if args.selector is None
579
+ else f" (the hook does this for you via {args.selector})"
580
+ )
581
+ + ".",
582
+ file=sys.stderr,
583
+ )
584
+ return 2
585
+
586
+ sources, unreadable_findings = collect(root, entries)
587
+ if not sources and not unreadable_findings:
588
+ print(
589
+ "❌ validate-hook-python-floor: found no Python to check, which means "
590
+ "the scan is broken rather than the repo clean.",
591
+ file=sys.stderr,
592
+ )
593
+ return 2
594
+
595
+ # A nested union (`a | b | c`) is several BinOp nodes on one line, and a
596
+ # line is the unit a developer fixes — so report each line once.
597
+ seen: set[tuple[str, int, str]] = set()
598
+ findings: list[Finding] = list(unreadable_findings)
599
+ for source in sources:
600
+ for finding in check(source, floor):
601
+ key = (finding.where, finding.line, finding.rule)
602
+ if key in seen:
603
+ continue
604
+ seen.add(key)
605
+ findings.append(finding)
606
+
607
+ if findings:
608
+ print(
609
+ f"❌ Python that cannot run on the declared floor "
610
+ f"{floor[0]}.{floor[1]} ({FLOOR_FILE.as_posix()}):\n",
611
+ file=sys.stderr,
612
+ )
613
+ for finding in findings:
614
+ print(finding.render(), file=sys.stderr)
615
+ print(
616
+ f"\n{len(findings)} finding(s) across {len(sources)} Python source(s). "
617
+ "These fail on a stock macOS interpreter, usually reported by whatever "
618
+ "validator was running rather than as a version problem.",
619
+ file=sys.stderr,
620
+ )
621
+ return 1
622
+
623
+ print(
624
+ f"✅ {len(sources)} Python source(s) run on Python "
625
+ f"{floor[0]}.{floor[1]} ({FLOOR_FILE.as_posix()})"
626
+ )
627
+ return 0
628
+
629
+
630
+ if __name__ == "__main__":
631
+ raise SystemExit(main())