@veda-hq/pariksha 0.5.20 → 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 +50 -5
- package/dist/aggregate-gate-needs.js +84 -25
- package/dist/cli.js +51 -0
- package/dist/schema-roots.js +92 -0
- package/package.json +2 -2
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
|
|
|
@@ -1030,6 +1043,38 @@ Parsing those is the job of a command guard such as kavach's `git_safety`.
|
|
|
1030
1043
|
project's settings and then also skips the user's own `defaultMode`, so it
|
|
1031
1044
|
belongs in user settings only.
|
|
1032
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
|
+
|
|
1033
1078
|
## `shell-gates`
|
|
1034
1079
|
|
|
1035
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
|
@@ -32,6 +32,7 @@ import { checkClaims, formatOutcome, loadEslint, parseConfig as parseClaimsConfi
|
|
|
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
34
|
import { inheritRunner, runPythonFloor } from "./python-floor.js";
|
|
35
|
+
import { checkSchemaRoots, jsonFiles, readDocuments } from "./schema-roots.js";
|
|
35
36
|
export const DENY_FILE = '.veda/deny.sha256';
|
|
36
37
|
const USAGE = [
|
|
37
38
|
'usage: pariksha authority-citations [--mode staged|tree] [--authority <a,b>] [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]',
|
|
@@ -54,6 +55,7 @@ const USAGE = [
|
|
|
54
55
|
' pariksha pnpm-settings [--config <file>] [--root <dir>]',
|
|
55
56
|
' pariksha python-floor --python <interpreter> --floor-file <path> [--exclude <regex>] [--selector <path>] [--root <dir>]',
|
|
56
57
|
' pariksha required-settings --require <file> [--settings <file>] [--root <dir>]',
|
|
58
|
+
' pariksha schema-roots [--files-from <file>|-] [--root <dir>] [<file>...]',
|
|
57
59
|
' pariksha shell-gates [--config <file>] [--staged | --all | --base <ref> | --files-from <file>|-] [--only <checks>] [--root <dir>]',
|
|
58
60
|
' pariksha hash <word>',
|
|
59
61
|
' pariksha --version',
|
|
@@ -791,6 +793,52 @@ const pythonFloor = (f, io) => {
|
|
|
791
793
|
};
|
|
792
794
|
return runPythonFloor(options, io.python ?? inheritRunner, io.err);
|
|
793
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
|
+
};
|
|
794
842
|
const COMMANDS = {
|
|
795
843
|
'codec-registry': { allowed: ['--config', '--base', '--root'], switches: ['--no-base'], run: codecRegistry },
|
|
796
844
|
'coverage-exemptions': { allowed: ['--config', '--package', '--root'], run: coverageExemptions },
|
|
@@ -825,6 +873,9 @@ export const run = (args, io, version = ownVersion(), env = process.env) => {
|
|
|
825
873
|
io.out(hash(rest[0]));
|
|
826
874
|
return 0;
|
|
827
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);
|
|
828
879
|
const spec = command !== undefined && Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : undefined;
|
|
829
880
|
const f = spec && flags(rest, spec.allowed, spec.switches);
|
|
830
881
|
if (!spec || !f) {
|
|
@@ -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, python-floor, 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",
|