@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 +91 -5
- package/dist/aggregate-gate-needs.js +84 -25
- package/dist/cli.js +74 -0
- package/dist/python-floor.js +32 -0
- package/dist/schema-roots.js +92 -0
- package/package.json +3 -2
- package/python/python-floor.py +631 -0
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.
|
|
8
|
-
npx @veda-hq/pariksha@0.
|
|
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>", …]
|
|
31
|
-
* Absent keys take the defaults;
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 = [
|
|
147
|
-
|
|
148
|
-
err.push(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
'
|
|
152
|
-
|
|
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.
|
|
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())
|