@veda-hq/pariksha 0.5.19 → 0.6.4

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.6.4 <check> [flags]
8
+ npx @veda-hq/pariksha@0.6.4 --version
9
9
  ```
10
10
 
11
11
  Every check exits 0 when the repository passes, 1 when it refuses something
@@ -72,19 +72,32 @@ step's `if:`, or any `${{ … }}` in the job, outside quoted literals. Text in
72
72
  a `run:` script and an action input named `if` are not reads. A non-gating
73
73
  job (a change detector) need not be gated, but must be declared once read.
74
74
 
75
+ The same command refuses a workflow that holds a gate and can also be
76
+ dispatched (`workflow_dispatch` in `on:`, as one event, in a list or as a
77
+ key), one line per gate under its own id, `dispatch-on-gate-workflow`. A
78
+ dispatch run from a branch with an open pull request posts every job's check
79
+ on the head commit, the gate among them as skipped, and that replaces the
80
+ pull request run's success: a merge queue requiring the gate reads it as
81
+ failed and dequeues the pull request. Ad-hoc runs belong in a separate
82
+ workflow with no gate-named job.
83
+
75
84
  ```json
76
- { "id": "CI-001", "workflowsDir": ".github/workflows", "gateName": "gate$", "nonGating": ["detect-changes"] }
85
+ { "id": "CI-001", "workflowsDir": ".github/workflows", "gateName": "gate$", "nonGating": ["detect-changes"], "dispatchAllowed": [] }
77
86
  ```
78
87
 
79
88
  - `id` (default `aggregate-gate-needs`) names the check in its messages: a
80
- project's rule id, say.
89
+ project's rule id, say. The dispatch refusal keeps its own id.
81
90
  - `workflowsDir` (default `.github/workflows`, relative to the root).
82
91
  - `gateName` (default `gate$`) is a regex, matched case-insensitively.
83
92
  - `nonGating` (default none) lists job ids that gate nothing.
93
+ - `dispatchAllowed` (default none) lists workflow file names (`release.yml`)
94
+ that may be dispatched beside a gate: one no merge queue requires, such as
95
+ an approval gate in a release workflow.
84
96
 
85
97
  A workflow that does not parse is reported as a gap, never passed. No
86
98
  workflow to read exits 1 (`no workflow files found — the lookup failed`),
87
- as the original did; a bad config exits 2.
99
+ as the original did; a bad config exits 2. The clean line is unchanged: it
100
+ prints only when both refusals find nothing.
88
101
 
89
102
  ## `authority-citations`
90
103
 
@@ -931,6 +944,47 @@ refuse is not passed: it is printed as a selector not version-checked.
931
944
  line to stdout; a missing input file is a failure (exit 1), a bad config or
932
945
  an unreadable `package.json` exits 2.
933
946
 
947
+ ## `python-floor`
948
+
949
+ ```sh
950
+ pariksha python-floor --python <interpreter> --floor-file <path> [--exclude <regex>] [--selector <path>] [--root <dir>]
951
+ ```
952
+
953
+ Every tracked Python source runs on the Python the repository declares. The
954
+ floor drifts unseen: CI runners ship a new Python, and a PEP 604 union or a
955
+ fresh stdlib import reads as ordinary code until it fails on an older
956
+ interpreter, inside whatever was running it. Sources are `*.py` files, and
957
+ heredocs and single-quoted `python3 -c '...'` payloads in `*.sh` files and
958
+ under `.husky/`, read from the index (the commit being made, not the working
959
+ tree). Each is refused for:
960
+
961
+ 1. syntax the floor cannot parse (`ast` with `feature_version` at the floor);
962
+ 2. a stdlib module added after the floor, or removed at or before it;
963
+ 3. on a floor below 3.10, a PEP 604 `X | Y` that is evaluated.
964
+
965
+ A source it cannot read (an unknown encoding, a symlink, conflict stages) is
966
+ refused, never skipped.
967
+
968
+ - `--python` names the interpreter, at or above the floor; pariksha never
969
+ chooses or bundles one. Below the floor it refuses to judge (exit 2).
970
+ - `--floor-file` is the file, relative to the root and tracked, holding one
971
+ `MAJOR.MINOR` (`#` comments allowed).
972
+ - `--exclude` (default none) is a Python regular expression matched from the
973
+ start of each path; what it matches is not checked. One that does not
974
+ compile exits 2.
975
+ - `--selector` (default none) names what picks an interpreter at or above
976
+ the floor in this repository, for the message given when `--python` is
977
+ below it.
978
+
979
+ The check is a stdlib-only script shipped in the package
980
+ (`python/python-floor.py`), run as `<interpreter> python/python-floor.py`;
981
+ its stdout (the clean line), stderr (findings) and exit code are passed
982
+ through unchanged. An interpreter that cannot be started, or that is stopped
983
+ by a signal, exits 2. Two input errors exit 1, as the script always has: a
984
+ floor file that is not in the index, not a regular file there, or not one
985
+ `MAJOR.MINOR` (one line on stderr), and a `--root` outside a git repository
986
+ (a Python traceback).
987
+
934
988
  ## `required-settings`
935
989
 
936
990
  ```sh
@@ -989,6 +1043,38 @@ Parsing those is the job of a command guard such as kavach's `git_safety`.
989
1043
  project's settings and then also skips the user's own `defaultMode`, so it
990
1044
  belongs in user settings only.
991
1045
 
1046
+ ## `schema-roots`
1047
+
1048
+ ```sh
1049
+ pariksha schema-roots [--files-from <file>|-] [--root <dir>] [<file>...]
1050
+ ```
1051
+
1052
+ The Anthropic API refuses a tool's `input_schema`, or a structured output's
1053
+ schema, with `allOf`, `anyOf` or `oneOf` at its root, answering 400:
1054
+ "input_schema does not support oneOf, allOf, or anyOf at the top level". The
1055
+ request fails before the first tool call, so an agent built on that schema
1056
+ dies at its start every time. This reads the JSON files it is given and refuses each such
1057
+ schema:
1058
+
1059
+ - the value under `input_schema`, `inputSchema`, `output_schema`,
1060
+ `outputSchema` or `json_schema`, and the `schema` inside a `json_schema`,
1061
+ at any depth, in objects and arrays (a request's `tools` list, say);
1062
+ - the `schema` of an object whose `type` is `json_schema`, a structured
1063
+ output format;
1064
+ - the root of a file named `*.schema.json`.
1065
+
1066
+ A combinator below a schema's root is allowed, and a schema found is not read
1067
+ further: a property inside it named `input_schema` is a property. Each
1068
+ finding goes to stderr as `<file>: <json path>: top-level <keyword>`, with
1069
+ the fix: express the condition in code after the call returns.
1070
+
1071
+ The files are the ones named on the command line and the ones `--files-from`
1072
+ lists, one path per line (a file, or `-` for stdin); relative paths are read
1073
+ from the root. Only `.json` files are read, so a list of changed files can
1074
+ hold others; YAML is not read. No file to read passes. The first `.json`
1075
+ file that cannot be read or is not plain JSON (one with comments, say) exits
1076
+ 2, naming it, and no finding is printed; so does a list that cannot be read.
1077
+
992
1078
  ## `shell-gates`
993
1079
 
994
1080
  ```sh
@@ -15,20 +15,32 @@
15
15
  // `${{ … }}` in the job. Not read: text in a `run:` script, quoted literals in
16
16
  // an expression, and an action input that happens to be called `if`. A
17
17
  // workflow that does not parse is reported, never read as having no gaps.
18
+ //
19
+ // The same workflows are also refused a `workflow_dispatch` trigger beside a
20
+ // gate (dispatch-on-gate-workflow): a dispatch run from a branch with an open
21
+ // pull request posts every job's check on the head commit, the gate among
22
+ // them as skipped, and that replaces the pull request run's success. A merge
23
+ // queue requiring the gate then reads it as failed and dequeues the pull
24
+ // request. `dispatchAllowed` names the workflows (by file name) where that
25
+ // cannot happen, for a project with no merge queue on the gate.
18
26
  import { readdirSync, readFileSync } from 'node:fs';
19
- import { join } from 'node:path';
27
+ import { basename, join } from 'node:path';
20
28
  import { load as parseYaml } from 'js-yaml';
21
29
  export const DEFAULT_GATE_CONFIG = {
22
30
  workflowsDir: '.github/workflows',
23
31
  gateName: /gate$/i,
24
32
  nonGating: new Set(),
25
33
  id: 'aggregate-gate-needs',
34
+ dispatchAllowed: new Set(),
26
35
  };
36
+ /** What the messages call the dispatch refusal: its own rule, not the project's needs rule. */
37
+ export const DISPATCH_ID = 'dispatch-on-gate-workflow';
27
38
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
28
39
  /**
29
40
  * `{ "id"?: "<label>", "workflowsDir"?: "<dir>", "gateName"?: "<regex>",
30
- * "nonGating"?: ["<job id>", …] }`; `gateName` matches case-insensitively.
31
- * Absent keys take the defaults; anything else throws.
41
+ * "nonGating"?: ["<job id>", …], "dispatchAllowed"?: ["<file name>", …] }`;
42
+ * `gateName` matches case-insensitively. Absent keys take the defaults;
43
+ * anything else throws.
32
44
  */
33
45
  export const parseGateConfig = (text) => {
34
46
  let raw;
@@ -40,7 +52,7 @@ export const parseGateConfig = (text) => {
40
52
  }
41
53
  if (!isRecord(raw))
42
54
  throw new Error('the config must be a JSON object');
43
- const unknown = Object.keys(raw).find((k) => !['id', 'workflowsDir', 'gateName', 'nonGating'].includes(k));
55
+ const unknown = Object.keys(raw).find((k) => !['id', 'workflowsDir', 'gateName', 'nonGating', 'dispatchAllowed'].includes(k));
44
56
  if (unknown !== undefined)
45
57
  throw new Error(`unknown key "${unknown}"`);
46
58
  const text_ = (key, fallback) => {
@@ -60,10 +72,15 @@ export const parseGateConfig = (text) => {
60
72
  catch (e) {
61
73
  throw new Error(`"gateName" is not a regex: ${e.message}`);
62
74
  }
63
- const nonGating = 'nonGating' in raw ? raw['nonGating'] : [];
64
- if (!Array.isArray(nonGating) || nonGating.some((j) => typeof j !== 'string' || j === ''))
65
- throw new Error('"nonGating" must be a list of job ids');
66
- return { id, workflowsDir, gateName, nonGating: new Set(nonGating) };
75
+ const names = (key, what) => {
76
+ const value = key in raw ? raw[key] : [];
77
+ if (!Array.isArray(value) || value.some((j) => typeof j !== 'string' || j === ''))
78
+ throw new Error(`"${key}" must be a list of ${what}`);
79
+ return new Set(value);
80
+ };
81
+ const nonGating = names('nonGating', 'job ids');
82
+ const dispatchAllowed = names('dispatchAllowed', 'workflow file names');
83
+ return { id, workflowsDir, gateName, nonGating, dispatchAllowed };
67
84
  };
68
85
  /** The `.yml` and `.yaml` files in the workflows folder, as root-relative paths; none when it cannot be read. */
69
86
  export const workflowFiles = (root, dir) => {
@@ -107,23 +124,31 @@ export const consumedNeeds = (job) => {
107
124
  return [...ids];
108
125
  };
109
126
  const asArray = (needs) => (needs === undefined ? [] : Array.isArray(needs) ? needs : [needs]);
127
+ /** The workflow at `relative`, or undefined when it does not parse; a scalar or null document is no job map, read as empty. */
128
+ const readWorkflow = (root, relative) => {
129
+ let parsed;
130
+ try {
131
+ parsed = parseYaml(readFileSync(join(root, relative), 'utf8'));
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
137
+ };
138
+ /** The ids of the workflow's gates: jobs whose `name:` (or id), or id, matches `gateName`. */
139
+ const gatesOf = (jobs, config) => Object.keys(jobs).filter((id) => config.gateName.test(jobs[id]?.name ?? id) || config.gateName.test(id));
110
140
  /** Each gate, in each of `files` (root-relative), whose `needs:` leaves out a job it gates or reads. */
111
141
  export const findGateGaps = (root, files, config) => {
112
142
  const gaps = [];
113
143
  for (const relative of files) {
114
- let document;
115
- try {
116
- // A workflow that parses to a scalar or null is no job map: read as empty.
117
- const parsed = parseYaml(readFileSync(join(root, relative), 'utf8'));
118
- document = typeof parsed === 'object' && parsed !== null ? parsed : {};
119
- }
120
- catch {
144
+ const document = readWorkflow(root, relative);
145
+ if (document === undefined) {
121
146
  gaps.push({ workflow: relative, gate: '(unparseable)', missing: ['<file did not parse>'] });
122
147
  continue;
123
148
  }
124
149
  const jobs = document.jobs ?? {};
125
150
  const jobIds = Object.keys(jobs);
126
- const gateIds = jobIds.filter((id) => config.gateName.test(jobs[id]?.name ?? id) || config.gateName.test(id));
151
+ const gateIds = gatesOf(jobs, config);
127
152
  for (const gateId of gateIds) {
128
153
  const declared = new Set(asArray(jobs[gateId]?.needs));
129
154
  const missing = jobIds.filter((id) => id !== gateId && !gateIds.includes(id) && !config.nonGating.has(id) && !declared.has(id));
@@ -135,19 +160,53 @@ export const findGateGaps = (root, files, config) => {
135
160
  }
136
161
  return gaps;
137
162
  };
138
- /** The verdict on the workflows under `root`, worded as the original check worded it. */
163
+ /** Whether the workflow's `on:` (one event, a list, or a map) holds `workflow_dispatch`; a map's value is usually null. */
164
+ export const hasDispatch = (on) => {
165
+ if (typeof on === 'string')
166
+ return on === 'workflow_dispatch';
167
+ if (Array.isArray(on))
168
+ return on.includes('workflow_dispatch');
169
+ return isRecord(on) && Object.hasOwn(on, 'workflow_dispatch');
170
+ };
171
+ /**
172
+ * Each gate, in each of `files` (root-relative), whose workflow can also be
173
+ * dispatched, outside `dispatchAllowed`. A workflow that does not parse is
174
+ * left to findGateGaps, which reports it.
175
+ */
176
+ export const findDispatchedGates = (root, files, config) => {
177
+ const found = [];
178
+ for (const relative of files) {
179
+ if (config.dispatchAllowed.has(basename(relative)))
180
+ continue;
181
+ const document = readWorkflow(root, relative);
182
+ if (document === undefined || !hasDispatch(document.on))
183
+ continue;
184
+ for (const gate of gatesOf(document.jobs ?? {}, config))
185
+ found.push({ workflow: relative, gate });
186
+ }
187
+ return found;
188
+ };
189
+ /** The refusal of one dispatched gate, one line. */
190
+ export const dispatchMessage = (found) => `❌ ${DISPATCH_ID}: ${found.workflow}: workflow_dispatch on the workflow holding the required gate ${found.gate}. ` +
191
+ "A dispatch run posts the gate as skipped on the PR's head commit, which a merge queue reads as failed and dequeues the PR. " +
192
+ 'Move ad-hoc runs to a separate workflow with no gate-named job.';
193
+ /** The verdict on the workflows under `root`: the needs gaps worded as the original check worded them, then each dispatched gate. */
139
194
  export const checkGates = (root, config) => {
140
195
  const files = workflowFiles(root, config.workflowsDir);
141
196
  if (files.length === 0)
142
197
  return { ok: false, out: [], err: [`❌ ${config.id}: no workflow files found — the lookup failed`] };
143
198
  const gaps = findGateGaps(root, files, config);
144
- if (gaps.length === 0)
199
+ const dispatched = findDispatchedGates(root, files, config).map(dispatchMessage);
200
+ if (gaps.length === 0 && dispatched.length === 0)
145
201
  return { ok: true, out: [`✅ ${config.id}: every aggregate gate depends on all its jobs (${files.length} workflow(s))`], err: [] };
146
- const err = [`\n❌ ${config.id}: ${gaps.length} aggregate gate(s) with an incomplete needs list\n`];
147
- for (const gap of gaps)
148
- err.push(` ${gap.workflow} → job "${gap.gate}"`, ` not depended on: ${gap.missing.join(', ')}\n`);
149
- err.push('The merge queue checks only the gate. A job absent from its `needs:` cannot\n' +
150
- 'block a merge however it fails — and a failure upstream turns into a SKIP\n' +
151
- 'downstream, which the gate then accepts.\n');
152
- return { ok: false, out: [], err };
202
+ const err = [];
203
+ if (gaps.length > 0) {
204
+ err.push(`\n❌ ${config.id}: ${gaps.length} aggregate gate(s) with an incomplete needs list\n`);
205
+ for (const gap of gaps)
206
+ err.push(` ${gap.workflow} → job "${gap.gate}"`, ` not depended on: ${gap.missing.join(', ')}\n`);
207
+ err.push('The merge queue checks only the gate. A job absent from its `needs:` cannot\n' +
208
+ 'block a merge however it fails — and a failure upstream turns into a SKIP\n' +
209
+ 'downstream, which the gate then accepts.\n');
210
+ }
211
+ return { ok: false, out: [], err: [...err, ...dispatched] };
153
212
  };
package/dist/cli.js CHANGED
@@ -31,6 +31,8 @@ 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";
35
+ import { checkSchemaRoots, jsonFiles, readDocuments } from "./schema-roots.js";
34
36
  export const DENY_FILE = '.veda/deny.sha256';
35
37
  const USAGE = [
36
38
  'usage: pariksha authority-citations [--mode staged|tree] [--authority <a,b>] [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]',
@@ -51,7 +53,9 @@ const USAGE = [
51
53
  ' pariksha masked-exit-codes [--config <file>] [--staged] [--root <dir>]',
52
54
  ' pariksha non-synced-resources [--config <file>] [--files-from <file>|-] [--root <dir>]',
53
55
  ' pariksha pnpm-settings [--config <file>] [--root <dir>]',
56
+ ' pariksha python-floor --python <interpreter> --floor-file <path> [--exclude <regex>] [--selector <path>] [--root <dir>]',
54
57
  ' pariksha required-settings --require <file> [--settings <file>] [--root <dir>]',
58
+ ' pariksha schema-roots [--files-from <file>|-] [--root <dir>] [<file>...]',
55
59
  ' pariksha shell-gates [--config <file>] [--staged | --all | --base <ref> | --files-from <file>|-] [--only <checks>] [--root <dir>]',
56
60
  ' pariksha hash <word>',
57
61
  ' pariksha --version',
@@ -769,6 +773,72 @@ const flakyTests = (f, io) => {
769
773
  }
770
774
  return runFlakyTests(selected.config, runs, selected.mode, root, selected.files, io.vitest ?? npxVitest, io.out);
771
775
  };
776
+ /** python-floor: the packaged script under the `--python` the caller names, its output and exit code passed through. */
777
+ const pythonFloor = (f, io) => {
778
+ const python = f.get('--python');
779
+ const floorFile = f.get('--floor-file');
780
+ if (python === undefined || floorFile === undefined) {
781
+ io.err(USAGE);
782
+ return 2;
783
+ }
784
+ const root = f.get('--root');
785
+ const exclude = f.get('--exclude');
786
+ const selector = f.get('--selector');
787
+ const options = {
788
+ python,
789
+ floorFile,
790
+ ...(root === undefined ? {} : { root }),
791
+ ...(exclude === undefined ? {} : { exclude }),
792
+ ...(selector === undefined ? {} : { selector }),
793
+ };
794
+ return runPythonFloor(options, io.python ?? inheritRunner, io.err);
795
+ };
796
+ /** `rest` split into its `--name value` pairs and the bare words around them, the files a command names. */
797
+ export const splitFiles = (rest) => {
798
+ const flagArgs = [];
799
+ const files = [];
800
+ for (let i = 0; i < rest.length; i += 1) {
801
+ const word = rest[i];
802
+ if (!word.startsWith('--')) {
803
+ files.push(word);
804
+ continue;
805
+ }
806
+ flagArgs.push(...rest.slice(i, i + 2));
807
+ i += 1;
808
+ }
809
+ return { flagArgs, files };
810
+ };
811
+ /**
812
+ * schema-roots: the `.json` files named on the command line and in the
813
+ * `--files-from` list, relative ones read from the root; other files are
814
+ * not read, and none at all is a pass. The first file that cannot be read or
815
+ * parsed exits 2, naming it, with no finding printed.
816
+ */
817
+ const schemaRoots = (rest, io) => {
818
+ const P = 'pariksha schema-roots:';
819
+ const { flagArgs, files: named } = splitFiles(rest);
820
+ const f = flags(flagArgs, ['--files-from', '--root']);
821
+ if (!f) {
822
+ io.err(USAGE);
823
+ return 2;
824
+ }
825
+ const root = resolve(f.get('--root') ?? '.');
826
+ const from = f.get('--files-from');
827
+ let documents;
828
+ try {
829
+ const listed = [...named, ...(from === undefined ? [] : entries(listFrom(from, io)))];
830
+ const files = [...new Set(jsonFiles(listed).map((p) => relative(root, resolve(root, p))))];
831
+ documents = readDocuments(files, (file) => readFileSync(resolve(root, file), 'utf8'));
832
+ }
833
+ catch (e) {
834
+ io.err(`${P} ${e.message}; nothing was checked, so this is not a pass`);
835
+ return 2;
836
+ }
837
+ const result = checkSchemaRoots(documents);
838
+ result.out.forEach(io.out);
839
+ result.err.forEach(io.err);
840
+ return result.ok ? 0 : 1;
841
+ };
772
842
  const COMMANDS = {
773
843
  'codec-registry': { allowed: ['--config', '--base', '--root'], switches: ['--no-base'], run: codecRegistry },
774
844
  'coverage-exemptions': { allowed: ['--config', '--package', '--root'], run: coverageExemptions },
@@ -777,6 +847,7 @@ const COMMANDS = {
777
847
  'aggregate-gate-needs': { allowed: ['--config', '--root'], run: aggregateGateNeeds },
778
848
  'non-synced-resources': { allowed: ['--config', '--files-from', '--root'], run: nonSyncedResources },
779
849
  'pnpm-settings': { allowed: ['--config', '--root'], run: pnpmSettings },
850
+ 'python-floor': { allowed: ['--python', '--floor-file', '--exclude', '--selector', '--root'], run: pythonFloor },
780
851
  'authority-citations': { allowed: ['--mode', '--authority', '--issue-prefix', '--adr-dir', '--root'], run: authorityCitations },
781
852
  boundary: { allowed: ['--root', '--deny'], run: boundary },
782
853
  'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
@@ -802,6 +873,9 @@ export const run = (args, io, version = ownVersion(), env = process.env) => {
802
873
  io.out(hash(rest[0]));
803
874
  return 0;
804
875
  }
876
+ // The one check that takes files as bare words, so not through `flags`.
877
+ if (command === 'schema-roots')
878
+ return schemaRoots(rest, io);
805
879
  const spec = command !== undefined && Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : undefined;
806
880
  const f = spec && flags(rest, spec.allowed, spec.switches);
807
881
  if (!spec || !f) {
@@ -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
+ };
@@ -0,0 +1,92 @@
1
+ // schema-roots: no tool or output schema with a combinator at its root.
2
+ //
3
+ // The Anthropic API refuses a tool's `input_schema`, or a structured output's
4
+ // schema, whose root holds `allOf`, `anyOf` or `oneOf` (a 400, "input_schema
5
+ // does not support oneOf, allOf, or anyOf at the top level"). The request
6
+ // fails before the model's first tool call, so an agent built on that schema
7
+ // dies at its start, every time, with nothing wrong in its own code. This
8
+ // reads JSON documents and refuses each such schema it finds:
9
+ //
10
+ // - the value under `input_schema`, `inputSchema`, `output_schema`,
11
+ // `outputSchema` or `json_schema`, and the `schema` inside a `json_schema`;
12
+ // - the `schema` of an object whose `type` is `json_schema` (a structured
13
+ // output format: `{ "type": "json_schema", "schema": {…} }`);
14
+ // - the root of a file named `*.schema.json`.
15
+ //
16
+ // A schema found is not read further: a combinator below its root is allowed,
17
+ // and a property inside it that happens to be called `input_schema` is a
18
+ // property, not a tool.
19
+ import { basename } from 'node:path';
20
+ export const COMBINATORS = ['allOf', 'anyOf', 'oneOf'];
21
+ /** The keys whose value is a tool's or an output's schema. */
22
+ export const SCHEMA_KEYS = ['input_schema', 'inputSchema', 'output_schema', 'outputSchema', 'json_schema'];
23
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
24
+ /** `path` one step further: `[0]`, `.name`, or `["a key"]` for a key that is no identifier. */
25
+ const step = (path, key) => typeof key === 'number' ? `${path}[${key}]` : /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`;
26
+ /** Each tool or output schema in `document`, the JSON of `file`, whose root holds a combinator. */
27
+ export const findRootCombinators = (file, document) => {
28
+ const found = [];
29
+ const atRoot = (schema, path) => {
30
+ if (!isRecord(schema))
31
+ return;
32
+ for (const keyword of COMBINATORS)
33
+ if (Object.hasOwn(schema, keyword))
34
+ found.push({ file, path, keyword });
35
+ };
36
+ const walk = (value, path) => {
37
+ if (Array.isArray(value)) {
38
+ value.forEach((item, i) => walk(item, step(path, i)));
39
+ return;
40
+ }
41
+ if (!isRecord(value))
42
+ return;
43
+ const format = value['type'] === 'json_schema' && isRecord(value['schema']);
44
+ for (const [key, child] of Object.entries(value)) {
45
+ const at = step(path, key);
46
+ if (format && key === 'schema')
47
+ atRoot(child, at);
48
+ else if (SCHEMA_KEYS.includes(key)) {
49
+ atRoot(child, at);
50
+ if (key === 'json_schema' && isRecord(child))
51
+ atRoot(child['schema'], step(at, 'schema'));
52
+ }
53
+ else
54
+ walk(child, at);
55
+ }
56
+ };
57
+ if (basename(file).endsWith('.schema.json'))
58
+ atRoot(document, '$');
59
+ else
60
+ walk(document, '$');
61
+ return found;
62
+ };
63
+ /** The refusal of one schema, one line. */
64
+ export const rootMessage = (found) => `pariksha schema-roots: ${found.file}: ${found.path}: top-level ${found.keyword}. ` +
65
+ 'The API rejects tool and output schemas with oneOf, allOf or anyOf at the root; express the condition in code after the call returns.';
66
+ /** The JSON files among `paths`: the ones this check reads. */
67
+ export const jsonFiles = (paths) => paths.filter((p) => /\.json$/i.test(p));
68
+ /** A file's text as JSON, a leading byte-order mark dropped; throws `not JSON: …`. */
69
+ export const parseJson = (text) => {
70
+ try {
71
+ return JSON.parse(text.startsWith('') ? text.slice(1) : text);
72
+ }
73
+ catch (e) {
74
+ throw new Error(`not JSON: ${e.message}`);
75
+ }
76
+ };
77
+ /** Each of `files`, read by `read` and parsed; throws on the first that cannot be, naming it. */
78
+ export const readDocuments = (files, read) => files.map((file) => {
79
+ try {
80
+ return { file, json: parseJson(read(file)) };
81
+ }
82
+ catch (e) {
83
+ throw new Error(`${file}: ${e.message}`);
84
+ }
85
+ });
86
+ /** The verdict on `documents`, each a file's path and its parsed JSON. */
87
+ export const checkSchemaRoots = (documents) => {
88
+ const found = documents.flatMap((d) => findRootCombinators(d.file, d.json));
89
+ if (found.length > 0)
90
+ return { ok: false, out: [], err: found.map(rootMessage) };
91
+ return { ok: true, out: [`pariksha schema-roots: ${documents.length} JSON file(s), no tool or output schema with oneOf, allOf or anyOf at its root`], err: [] };
92
+ };
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.6.4",
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, schema-roots, 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())