@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 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.5 <check> [flags]
8
- npx @veda-hq/pariksha@0.5.5 --version
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.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
  }