@veda-hq/pariksha 0.5.5 → 0.5.8
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 +38 -2
- package/baselines/claude-settings.json +14 -0
- package/dist/cli.js +50 -0
- package/dist/required-settings.js +120 -0
- package/package.json +6 -4
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.
|
|
8
|
-
npx @veda-hq/pariksha@0.5.
|
|
7
|
+
npx @veda-hq/pariksha@0.5.8 <check> [flags]
|
|
8
|
+
npx @veda-hq/pariksha@0.5.8 --version
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Every check exits 0 when the repository passes, 1 when it refuses something
|
|
@@ -252,6 +252,42 @@ do not change what it reads. With no base, or when git cannot read the
|
|
|
252
252
|
range (no merge base, a shallow clone), it exits 2: nothing was checked,
|
|
253
253
|
so it is not a pass.
|
|
254
254
|
|
|
255
|
+
## `required-settings`
|
|
256
|
+
|
|
257
|
+
```sh
|
|
258
|
+
pariksha required-settings --require <file> [--settings <file>] [--root <dir>]
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
A setting a project depends on, such as Claude Code's `advisorModel`, is one
|
|
262
|
+
committed line, and one edit deletes it without any test noticing. The
|
|
263
|
+
requirements file lists each key (a dotted path for a nested one) with the
|
|
264
|
+
value it must hold and why:
|
|
265
|
+
|
|
266
|
+
```json
|
|
267
|
+
{ "advisorModel": { "value": "fable", "why": "sessions work with the advisor on" } }
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
A list the project only adds to, such as a permission list, takes
|
|
271
|
+
`"includes"` instead of `"value"`: the key must be a list holding each entry,
|
|
272
|
+
among any others.
|
|
273
|
+
|
|
274
|
+
The check refuses a settings file (default `.claude/settings.json`) that lacks
|
|
275
|
+
a key, holds another value, or lacks a required list entry, compared as JSON,
|
|
276
|
+
naming the key, what is wrong and the reason. A missing settings file is a
|
|
277
|
+
refusal; a requirements file it cannot read or parse, or one listing no key,
|
|
278
|
+
exits 2.
|
|
279
|
+
|
|
280
|
+
`baselines/claude-settings.json` (`@veda-hq/pariksha/baselines/claude-settings.json`)
|
|
281
|
+
is the permission baseline every repository agents work in keeps: allow
|
|
282
|
+
`mcp__ccd_pr__set_monitor`, deny `Bash(*--no-verify*)`, ask
|
|
283
|
+
`Bash(*git push*--force*)` and `Bash(*git push* -f*)`. Point `--require` at it
|
|
284
|
+
in the package's install. Permission globs are a coarse backstop: they cannot
|
|
285
|
+
tell `git commit -n` from `git log -n 5`, nor see `git -C dir push --force`.
|
|
286
|
+
Parsing those is the job of a command guard such as kavach's `git_safety`.
|
|
287
|
+
`permissions.defaultMode: "auto"` is not in it: Claude Code ignores `auto` in a
|
|
288
|
+
project's settings and then also skips the user's own `defaultMode`, so it
|
|
289
|
+
belongs in user settings only.
|
|
290
|
+
|
|
255
291
|
## markdownlint rules
|
|
256
292
|
|
|
257
293
|
```jsonc
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions.allow": {
|
|
3
|
+
"includes": ["mcp__ccd_pr__set_monitor"],
|
|
4
|
+
"why": "an agent turns on the desktop app's CI monitor for the pull request it opens without a prompt"
|
|
5
|
+
},
|
|
6
|
+
"permissions.deny": {
|
|
7
|
+
"includes": ["Bash(*--no-verify*)"],
|
|
8
|
+
"why": "no git command skips the repository's hooks"
|
|
9
|
+
},
|
|
10
|
+
"permissions.ask": {
|
|
11
|
+
"includes": ["Bash(*git push*--force*)", "Bash(*git push* -f*)"],
|
|
12
|
+
"why": "a force push rewrites published history, so a person approves each one"
|
|
13
|
+
}
|
|
14
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { checkBudgets } from "./doc-budgets.js";
|
|
|
12
12
|
import { diskReads, formatReads, listFiles, needsTypescript, parseConfig as parseGateConfig, readerFor, readGate } from "./env-gate-switches.js";
|
|
13
13
|
import { loadTypescript, typescriptLoaders } from "./typescript.js";
|
|
14
14
|
import { checkExportDeletions, formatReport } from "./export-deletions.js";
|
|
15
|
+
import { findMissing, formatFindings, parseRequirements } from "./required-settings.js";
|
|
15
16
|
import { BASE_CANDIDATES, gitAt, gitAtTop, must, resolveBase, topOf } from "./git.js";
|
|
16
17
|
export const DENY_FILE = '.veda/deny.sha256';
|
|
17
18
|
const USAGE = [
|
|
@@ -22,6 +23,7 @@ const USAGE = [
|
|
|
22
23
|
' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
|
|
23
24
|
' pariksha env-gate-switches --config <file> [--mode staged|tree] [--root <dir>]',
|
|
24
25
|
' pariksha export-deletions [--base <ref>] [--root <dir>]',
|
|
26
|
+
' pariksha required-settings --require <file> [--settings <file>] [--root <dir>]',
|
|
25
27
|
' pariksha hash <word>',
|
|
26
28
|
' pariksha --version',
|
|
27
29
|
].join('\n');
|
|
@@ -316,6 +318,53 @@ const envGateSwitches = (f, io) => {
|
|
|
316
318
|
result.err.forEach(io.err);
|
|
317
319
|
return result.ok ? 0 : 1;
|
|
318
320
|
};
|
|
321
|
+
export const DEFAULT_SETTINGS = '.claude/settings.json';
|
|
322
|
+
const requiredSettings = (f, io) => {
|
|
323
|
+
const P = 'pariksha required-settings:';
|
|
324
|
+
const requirePath = f.get('--require');
|
|
325
|
+
if (requirePath === undefined) {
|
|
326
|
+
io.err(USAGE);
|
|
327
|
+
return 2;
|
|
328
|
+
}
|
|
329
|
+
const root = resolve(f.get('--root') ?? '.');
|
|
330
|
+
const settingsPath = f.get('--settings') ?? DEFAULT_SETTINGS;
|
|
331
|
+
const read = (path, what) => {
|
|
332
|
+
try {
|
|
333
|
+
return readFileSync(resolve(root, path), 'utf8');
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
io.err(`${P} cannot read the ${what} ${resolve(root, path)}; nothing was checked, so this is not a pass`);
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
const requireText = read(requirePath, 'requirements');
|
|
341
|
+
if (requireText === undefined)
|
|
342
|
+
return 2;
|
|
343
|
+
let requirements;
|
|
344
|
+
try {
|
|
345
|
+
requirements = parseRequirements(requireText);
|
|
346
|
+
}
|
|
347
|
+
catch (e) {
|
|
348
|
+
io.err(`${P} ${requirePath}: ${e.message}; nothing was checked, so this is not a pass`);
|
|
349
|
+
return 2;
|
|
350
|
+
}
|
|
351
|
+
const settingsText = read(settingsPath, 'settings');
|
|
352
|
+
// A missing settings file lacks every required key: a refusal, not a usage error.
|
|
353
|
+
if (settingsText === undefined)
|
|
354
|
+
return 1;
|
|
355
|
+
let findings;
|
|
356
|
+
try {
|
|
357
|
+
findings = findMissing(settingsText, requirements);
|
|
358
|
+
}
|
|
359
|
+
catch (e) {
|
|
360
|
+
io.err(`${P} ${settingsPath}: ${e.message}`);
|
|
361
|
+
return 1;
|
|
362
|
+
}
|
|
363
|
+
const result = formatFindings(findings, requirements.length, settingsPath, requirePath);
|
|
364
|
+
result.out.forEach(io.out);
|
|
365
|
+
result.err.forEach(io.err);
|
|
366
|
+
return result.ok ? 0 : 1;
|
|
367
|
+
};
|
|
319
368
|
const COMMANDS = {
|
|
320
369
|
'coverage-exemptions': { allowed: ['--config', '--package', '--root'], run: coverageExemptions },
|
|
321
370
|
'authority-citations': { allowed: ['--mode', '--authority', '--issue-prefix', '--adr-dir', '--root'], run: authorityCitations },
|
|
@@ -324,6 +373,7 @@ const COMMANDS = {
|
|
|
324
373
|
'doc-budgets': { allowed: ['--table', '--section', '--root'], run: docBudgets },
|
|
325
374
|
'env-gate-switches': { allowed: ['--config', '--mode', '--root'], run: envGateSwitches },
|
|
326
375
|
'export-deletions': { allowed: ['--base', '--root'], run: exportDeletions },
|
|
376
|
+
'required-settings': { allowed: ['--require', '--settings', '--root'], run: requiredSettings },
|
|
327
377
|
};
|
|
328
378
|
export const run = (args, io, version = ownVersion(), env = process.env) => {
|
|
329
379
|
const [command, ...rest] = args;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// required-settings: a settings file carries every value a project requires of it.
|
|
2
|
+
//
|
|
3
|
+
// A setting a project depends on (Claude Code's `advisorModel`, say) lives in
|
|
4
|
+
// one committed line, and one edit deletes it without any test noticing. The
|
|
5
|
+
// project lists what it requires, with why, and this check refuses a settings
|
|
6
|
+
// file that lacks a key, holds another value, or drops an entry from a list
|
|
7
|
+
// (a permission rule, say) the project requires.
|
|
8
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
9
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
|
+
/**
|
|
11
|
+
* Parses the requirements file:
|
|
12
|
+
* `{ "<key>": { "value": <any JSON>, "why": "…" } | { "includes": [<any JSON>, …], "why": "…" }, … }`.
|
|
13
|
+
* Throws on anything else, so a malformed list never passes as an empty one.
|
|
14
|
+
*/
|
|
15
|
+
export function parseRequirements(text) {
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = JSON.parse(text);
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
throw new Error(`the requirements are not JSON (${e.message})`);
|
|
22
|
+
}
|
|
23
|
+
if (!isRecord(raw))
|
|
24
|
+
throw new Error('the requirements must be an object of keys');
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const [key, spec] of Object.entries(raw)) {
|
|
27
|
+
if (key === '$schema')
|
|
28
|
+
continue;
|
|
29
|
+
if (key === '' || key.split('.').some((part) => part === ''))
|
|
30
|
+
throw new Error(`"${key}" is not a key or dotted path`);
|
|
31
|
+
const shape = `"${key}" needs { "value": …, "why": "…" } or { "includes": […], "why": "…" }`;
|
|
32
|
+
if (!isRecord(spec))
|
|
33
|
+
throw new Error(shape);
|
|
34
|
+
const hasValue = Object.hasOwn(spec, 'value');
|
|
35
|
+
const hasIncludes = Object.hasOwn(spec, 'includes');
|
|
36
|
+
if (hasValue === hasIncludes)
|
|
37
|
+
throw new Error(shape);
|
|
38
|
+
const extra = Object.keys(spec).filter((k) => k !== 'value' && k !== 'includes' && k !== 'why');
|
|
39
|
+
if (extra.length > 0)
|
|
40
|
+
throw new Error(`"${key}": unknown key ${extra.map((k) => `"${k}"`).join(', ')}`);
|
|
41
|
+
if (typeof spec.why !== 'string' || spec.why.trim() === '')
|
|
42
|
+
throw new Error(`"${key}" needs a "why"`);
|
|
43
|
+
if (!hasIncludes) {
|
|
44
|
+
out.push({ key, kind: 'value', expected: spec.value, why: spec.why });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const includes = spec.includes;
|
|
48
|
+
if (!Array.isArray(includes) || includes.length === 0)
|
|
49
|
+
throw new Error(`"${key}": "includes" must list at least one entry`);
|
|
50
|
+
out.push({ key, kind: 'includes', expected: includes, why: spec.why });
|
|
51
|
+
}
|
|
52
|
+
if (out.length === 0)
|
|
53
|
+
throw new Error('the requirements list no key');
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/** The value at a dotted path, or `{ missing: true }`. */
|
|
57
|
+
const lookup = (settings, key) => {
|
|
58
|
+
let at = settings;
|
|
59
|
+
for (const part of key.split('.')) {
|
|
60
|
+
if (!isRecord(at) || !Object.hasOwn(at, part))
|
|
61
|
+
return { missing: true };
|
|
62
|
+
at = at[part];
|
|
63
|
+
}
|
|
64
|
+
return { missing: false, value: at };
|
|
65
|
+
};
|
|
66
|
+
/** The finding for one requirement, or undefined when the settings meet it. */
|
|
67
|
+
const judge = (settings, r) => {
|
|
68
|
+
const got = lookup(settings, r.key);
|
|
69
|
+
const base = { key: r.key, kind: r.kind, why: r.why };
|
|
70
|
+
if (r.kind === 'value') {
|
|
71
|
+
if (got.missing)
|
|
72
|
+
return { ...base, expected: r.expected };
|
|
73
|
+
return isDeepStrictEqual(got.value, r.expected) ? undefined : { ...base, expected: r.expected, found: got.value };
|
|
74
|
+
}
|
|
75
|
+
const wanted = r.expected;
|
|
76
|
+
if (got.missing)
|
|
77
|
+
return { ...base, expected: wanted };
|
|
78
|
+
if (!Array.isArray(got.value))
|
|
79
|
+
return { ...base, expected: wanted, found: got.value };
|
|
80
|
+
const list = got.value;
|
|
81
|
+
const lacking = wanted.filter((entry) => !list.some((have) => isDeepStrictEqual(have, entry)));
|
|
82
|
+
return lacking.length === 0 ? undefined : { ...base, expected: lacking, found: got.value };
|
|
83
|
+
};
|
|
84
|
+
/** Every requirement the settings break: a missing key, a different value, or a list without a required entry. */
|
|
85
|
+
export function findMissing(settingsText, requirements) {
|
|
86
|
+
let settings;
|
|
87
|
+
try {
|
|
88
|
+
settings = JSON.parse(settingsText);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
throw new Error(`the settings are not JSON (${e.message})`);
|
|
92
|
+
}
|
|
93
|
+
if (!isRecord(settings))
|
|
94
|
+
throw new Error('the settings must hold an object');
|
|
95
|
+
return requirements.flatMap((r) => judge(settings, r) ?? []);
|
|
96
|
+
}
|
|
97
|
+
/** One finding as a line: what is wrong and how to fix it. */
|
|
98
|
+
const describe = (f) => {
|
|
99
|
+
if (f.kind === 'value') {
|
|
100
|
+
const has = Object.hasOwn(f, 'found') ? `holds ${JSON.stringify(f.found)}` : 'is missing';
|
|
101
|
+
return `"${f.key}" ${has}; it must be ${JSON.stringify(f.expected)}`;
|
|
102
|
+
}
|
|
103
|
+
const entries = f.expected.map((e) => JSON.stringify(e)).join(', ');
|
|
104
|
+
if (!Object.hasOwn(f, 'found'))
|
|
105
|
+
return `"${f.key}" is missing; it must be a list holding ${entries}`;
|
|
106
|
+
if (!Array.isArray(f.found))
|
|
107
|
+
return `"${f.key}" holds ${JSON.stringify(f.found)}, not a list; it must be a list holding ${entries}`;
|
|
108
|
+
return `"${f.key}" lacks ${entries}`;
|
|
109
|
+
};
|
|
110
|
+
/** The check's lines: one per finding on err, or one pass line on out. */
|
|
111
|
+
export function formatFindings(findings, count, settingsPath, requirePath) {
|
|
112
|
+
const P = 'pariksha required-settings:';
|
|
113
|
+
if (findings.length === 0)
|
|
114
|
+
return { ok: true, out: [`${P} ${settingsPath} carries all ${count} required settings (${requirePath})`], err: [] };
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
out: [],
|
|
118
|
+
err: findings.map((f) => `${P} ${settingsPath}: ${describe(f)} (${f.why}). Put it back; to change the requirement, edit ${requirePath}.`),
|
|
119
|
+
};
|
|
120
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veda-hq/pariksha",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: authority-citations, boundary, coverage-by-type, coverage-exemptions, doc-budgets, env-gate-switches, export-deletions. Also markdownlint rules for Markdown that renders mangled: @veda-hq/pariksha/markdownlint.",
|
|
3
|
+
"version": "0.5.8",
|
|
4
|
+
"description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: authority-citations, boundary, coverage-by-type, coverage-exemptions, doc-budgets, env-gate-switches, export-deletions, required-settings. Also markdownlint rules for Markdown that renders mangled: @veda-hq/pariksha/markdownlint.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"repository checks",
|
|
7
7
|
"lint",
|
|
@@ -31,10 +31,12 @@
|
|
|
31
31
|
"exports": {
|
|
32
32
|
"./markdownlint": "./dist/markdownlint-rules.js",
|
|
33
33
|
"./dist/*": "./dist/*",
|
|
34
|
+
"./baselines/*": "./baselines/*",
|
|
34
35
|
"./package.json": "./package.json"
|
|
35
36
|
},
|
|
36
37
|
"files": [
|
|
37
|
-
"dist"
|
|
38
|
+
"dist",
|
|
39
|
+
"baselines"
|
|
38
40
|
],
|
|
39
41
|
"peerDependencies": {
|
|
40
42
|
"typescript": ">=5.4 <6"
|
|
@@ -52,7 +54,7 @@
|
|
|
52
54
|
"build": "tsc -p tsconfig.build.json",
|
|
53
55
|
"typecheck": "tsc -p tsconfig.json",
|
|
54
56
|
"test": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-functions=95 --test-coverage-branches=90 --test-coverage-exclude=src/**/*.test.ts src/**/*.test.ts",
|
|
55
|
-
"smoke": "node dist/cli.js --version && node dist/cli.js boundary --root ../.. && node --input-type=module -e \"import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const id = '@veda-hq/pariksha/markdownlint'; const req = createRequire(process.cwd() + '/'); const required = req(id); const imported = (await import(pathToFileURL(req.resolve(id, { paths: [process.cwd()] })).href)).default; if (!Array.isArray(required) || required !== imported || required.length !== 12) throw new Error(id + ' does not load as markdownlint-cli2 loads it');\"",
|
|
57
|
+
"smoke": "node dist/cli.js --version && node dist/cli.js boundary --root ../.. && node dist/cli.js required-settings --require .veda/required-settings.json --root ../.. && node dist/cli.js required-settings --require packages/pariksha/baselines/claude-settings.json --root ../.. && node --input-type=module -e \"import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const id = '@veda-hq/pariksha/markdownlint'; const req = createRequire(process.cwd() + '/'); const required = req(id); const imported = (await import(pathToFileURL(req.resolve(id, { paths: [process.cwd()] })).href)).default; if (!Array.isArray(required) || required !== imported || required.length !== 12) throw new Error(id + ' does not load as markdownlint-cli2 loads it');\"",
|
|
56
58
|
"check": "pnpm typecheck && pnpm test && pnpm build && pnpm smoke"
|
|
57
59
|
}
|
|
58
60
|
}
|