@veda-hq/pariksha 0.5.3 → 0.5.7

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.3 <check> [flags]
8
- npx @veda-hq/pariksha@0.5.3 --version
7
+ npx @veda-hq/pariksha@0.5.7 <check> [flags]
8
+ npx @veda-hq/pariksha@0.5.7 --version
9
9
  ```
10
10
 
11
11
  Every check exits 0 when the repository passes, 1 when it refuses something
@@ -251,3 +251,98 @@ renames, colour, external diff tools and textconv in a user's git config
251
251
  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
+
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
+ The check refuses a settings file (default `.claude/settings.json`) that lacks
271
+ a key or holds another value, compared as JSON, naming the key, the value and
272
+ the reason. A missing settings file is a refusal; a requirements file it cannot
273
+ read or parse, or one listing no key, exits 2.
274
+
275
+ ## markdownlint rules
276
+
277
+ ```jsonc
278
+ // .markdownlint-cli2.jsonc
279
+ {
280
+ "customRules": ["@veda-hq/pariksha/markdownlint"],
281
+ "config": { "default": false, "pariksha-markdown": true }
282
+ }
283
+ ```
284
+
285
+ Not a check but a module: markdownlint custom rules for Markdown that reads
286
+ fine as source and renders mangled, where no built-in rule fails it for that
287
+ reason. `@veda-hq/pariksha/markdownlint` is an ES module whose default export
288
+ is the rules array, which markdownlint-cli2's `customRules` loads by package
289
+ name; `require()` of it returns the array too. Every rule carries the tag
290
+ `pariksha-markdown`, so the config above turns on these rules and nothing
291
+ else. They run on markdownlint's micromark tokens (`parser: "micromark"`)
292
+ and import nothing. Most read prose only, skipping code blocks, HTML blocks
293
+ and code spans; the fence and code-span rules read those,
294
+ `pariksha-replacement-character` reads every line, and
295
+ `pariksha-duplicate-adjacent-line` skips code and HTML blocks but compares
296
+ whole lines, code spans included.
297
+
298
+ | Rule | Fails |
299
+ | ---- | ----- |
300
+ | `pariksha-alert-marker-inline` | `> [!NOTE] body` on one line: GitHub renders the callout only with the marker alone |
301
+ | `pariksha-link-space-before-paren` | `[text] (./a.md)`: the space renders the link as text (targets that look like a URL, path, anchor or file only; task boxes `[ ]` and `[x]` pass) |
302
+ | `pariksha-code-span-crosses-line` | a code span that closes on a later line, or a path span ending in `/` continued in the next line's span |
303
+ | `pariksha-fence-unclosed` | a code fence that never closes |
304
+ | `pariksha-fence-empty` | a fence with nothing but blank lines in it |
305
+ | `pariksha-fence-swallows-heading` | a `# Heading` inside a fence of a language where it cannot be code: the fence above lost its close |
306
+ | `pariksha-table-row-orphaned` | a paragraph made only of `\| … \|` rows: a blank line cut them off their table |
307
+ | `pariksha-duplicate-adjacent-line` | a line of ten or more characters, with a letter, repeating the previous non-blank prose line (table rows may repeat) |
308
+ | `pariksha-replacement-character` | U+FFFD, bytes lost in an encoding round-trip |
309
+ | `pariksha-emphasis-mangle` | `store*id`: a single `*` between letters, where `_` was re-read as emphasis |
310
+ | `pariksha-dunder-mangle` | `**DEV**`: a formatter rewrote `__DEV__` as bold |
311
+ | `pariksha-lettered-step-indent` | `b.` indented unlike the `a.` above it, so it renders under another parent |
312
+
313
+ Options are lists, each replacing its default:
314
+
315
+ | Option | Rule | Default |
316
+ | ------ | ---- | ------- |
317
+ | `alert_types` | `pariksha-alert-marker-inline` | `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION` (matched case-insensitively) |
318
+ | `link_target_extensions` | `pariksha-link-space-before-paren` | `md`, `mdx`, `html`, `htm`, `ts`, `tsx`, `js`, `json`, `yaml`, `yml`, `png`, `svg`, `jpg`, `jpeg`, `pdf` |
319
+ | `dunder_words` | `pariksha-dunder-mangle` | `DEV`, `proto`, `esModule`, `typename`: names never an English word in bold |
320
+ | `no_hash_languages` | `pariksha-fence-swallows-heading` | `ts`, `tsx`, `typescript`, `js`, `jsx`, `javascript`, `json`, `jsonc`, `sql`, `css`, `swift`, `kotlin`, `java`, `go`, `rust`, `prisma` (matched case-insensitively; not `graphql` or `html`, where a `# ` line is a comment or text) |
321
+
322
+ ```jsonc
323
+ {
324
+ "config": {
325
+ "default": false,
326
+ "pariksha-markdown": { "dunder_words": ["DEV", "proto", "esModule", "typename", "dirname"] },
327
+ "pariksha-fence-swallows-heading": { "no_hash_languages": ["ts", "py"] }
328
+ },
329
+ "customRules": ["@veda-hq/pariksha/markdownlint"]
330
+ }
331
+ ```
332
+
333
+ Options set under the tag reach every rule, so every rule accepts all four
334
+ names. A rule's own key replaces the tag's object for that rule (markdownlint
335
+ takes the last key that names a rule, whole), so above
336
+ `pariksha-fence-swallows-heading` keeps the default `dunder_words`, which it
337
+ does not read. The same goes for an option of one rule set under another
338
+ rule's key: a rule cannot tell its own object from the tag's, so it accepts
339
+ every name some rule reads. Otherwise a configuration markdownlint cannot have
340
+ meant is refused, not ignored: an option name no rule reads (an array set as
341
+ the configuration reaches the rules as its indexes, `"0"`, and is refused as
342
+ such), a list option that is not a non-empty array of strings (to turn a rule
343
+ off, set it to `false`), or an entry that could never match (an extension with
344
+ its dot, a dunder word with its underscores, a language with a space). Each
345
+ rule that reads the bad configuration throws, naming itself and the option, so
346
+ markdownlint-cli2 reports up to twelve errors on line 1 of each file and the
347
+ run fails; with `"severity": "warning"` in the configuration they are reported
348
+ as warnings instead.
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,335 @@
1
+ // markdownlint custom rules for Markdown that reads fine as source and renders
2
+ // mangled: a `> [!NOTE]` alert run onto its body line, a link broken by `] (`,
3
+ // a fence that swallows the next section, a table split by a blank line,
4
+ // `__DEV__` turned into bold "DEV". No markdownlint built-in rule fails these
5
+ // for that reason, so each shape is one rule here, on markdownlint's own
6
+ // micromark tokens. Every rule carries the tag `pariksha-markdown`, so
7
+ //
8
+ // "config": { "default": false, "pariksha-markdown": true }
9
+ //
10
+ // turns on these rules and nothing else. The module's default export is the
11
+ // rules array, as markdownlint-cli2's `customRules` loads it.
12
+ //
13
+ // The rule API's types are declared here rather than imported, so the package
14
+ // depends on no markdownlint version.
15
+ export const TAG = 'pariksha-markdown';
16
+ /**
17
+ * The options, each a list, with their defaults. A rule reads its own; since
18
+ * options set under the tag reach every rule, a rule accepts the others'
19
+ * names too, and refuses only a name no rule here reads.
20
+ */
21
+ export const DEFAULT_OPTIONS = {
22
+ /** `pariksha-alert-marker-inline`: the alert types GitHub renders as callouts. */
23
+ alert_types: ['NOTE', 'TIP', 'IMPORTANT', 'WARNING', 'CAUTION'],
24
+ /** `pariksha-link-space-before-paren`: file extensions that make `] (name.ext)` look like a link target. */
25
+ link_target_extensions: ['md', 'mdx', 'html', 'htm', 'ts', 'tsx', 'js', 'json', 'yaml', 'yml', 'png', 'svg', 'jpg', 'jpeg', 'pdf'],
26
+ /** `pariksha-dunder-mangle`: names whose `__name__` a formatter rewrites as `**name**`; never an English word in bold. */
27
+ dunder_words: ['DEV', 'proto', 'esModule', 'typename'],
28
+ /** `pariksha-fence-swallows-heading`: fence languages where a `# Heading` line cannot be code. */
29
+ no_hash_languages: ['ts', 'tsx', 'typescript', 'js', 'jsx', 'javascript', 'json', 'jsonc', 'sql', 'css', 'swift', 'kotlin', 'java', 'go', 'rust', 'prisma'],
30
+ };
31
+ /** What each option's entries must look like, and how the refusal says so. */
32
+ const OPTION_SHAPES = {
33
+ alert_types: { pattern: /^[A-Za-z]+$/, says: 'letters only' },
34
+ link_target_extensions: { pattern: /^[A-Za-z0-9]+$/, says: 'letters and digits, without the dot' },
35
+ dunder_words: { pattern: /^(?!_)[\w$]+(?<!_)$/, says: 'an identifier without its surrounding underscores' },
36
+ no_hash_languages: { pattern: /^\S+$/, says: 'no whitespace' },
37
+ };
38
+ const isOptionName = (key) => Object.hasOwn(DEFAULT_OPTIONS, key);
39
+ const describe = (value) => {
40
+ if (value === null)
41
+ return 'null';
42
+ if (Array.isArray(value))
43
+ return 'an array';
44
+ if (typeof value === 'string')
45
+ return JSON.stringify(value);
46
+ return typeof value === 'object' ? 'an object' : `a ${typeof value}`;
47
+ };
48
+ /**
49
+ * The rule's configuration as markdownlint passes it, checked: an object whose
50
+ * every key is an option of this module. A key no rule here reads throws,
51
+ * which fails the lint run with the message. A key another rule reads is
52
+ * accepted, since options set under the tag reach every rule and a rule
53
+ * cannot tell the tag's object from its own. markdownlint always passes an
54
+ * object (a string or number becomes `{}`, an array its indexes as keys); the
55
+ * non-object refusal is for a caller that runs a rule directly.
56
+ */
57
+ export const readConfig = (rule, config) => {
58
+ if (config === undefined)
59
+ return {};
60
+ if (typeof config !== 'object' || config === null || Array.isArray(config))
61
+ throw new Error(`${rule}: its configuration must be true, false or an object of options; got ${describe(config)}`);
62
+ for (const key of Object.keys(config)) {
63
+ if (!isOptionName(key))
64
+ throw new Error(`${rule}: unknown option "${key}"; the options are ${Object.keys(DEFAULT_OPTIONS).join(', ')}`);
65
+ }
66
+ return config;
67
+ };
68
+ /** One list option: its default when unset, else a non-empty array of well-formed strings. */
69
+ export const listOption = (rule, config, name) => {
70
+ const value = readConfig(rule, config)[name];
71
+ if (value === undefined)
72
+ return DEFAULT_OPTIONS[name];
73
+ const shape = OPTION_SHAPES[name];
74
+ if (!Array.isArray(value) || value.length === 0)
75
+ throw new Error(`${rule}: option "${name}" must be a non-empty array of strings (to turn the rule off, set it to false); got ${describe(value)}`);
76
+ for (const entry of value) {
77
+ if (typeof entry !== 'string' || !shape.pattern.test(entry))
78
+ throw new Error(`${rule}: option "${name}" holds ${describe(entry)}; each entry must be a string of ${shape.says}`);
79
+ }
80
+ return value;
81
+ };
82
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&');
83
+ /** Every token, depth first, in document order. */
84
+ export const walk = (tokens) => {
85
+ const out = [];
86
+ const visit = (list) => {
87
+ for (const token of list) {
88
+ out.push(token);
89
+ visit(token.children);
90
+ }
91
+ };
92
+ visit(tokens);
93
+ return out;
94
+ };
95
+ const CODE_BLOCKS = new Set(['codeFenced', 'codeIndented', 'htmlFlow']);
96
+ /**
97
+ * The document as rules see prose: lines inside code blocks and HTML blocks
98
+ * are null, and on every other line each inline code span is blanked out.
99
+ */
100
+ export const proseLines = (params) => {
101
+ const lines = [...params.lines];
102
+ const all = walk(params.parsers.micromark.tokens);
103
+ for (const token of all) {
104
+ if (token.type !== 'codeText')
105
+ continue;
106
+ for (let line = token.startLine; line <= token.endLine; line += 1) {
107
+ const text = lines[line - 1] ?? '';
108
+ const from = line === token.startLine ? token.startColumn - 1 : 0;
109
+ const to = line === token.endLine ? token.endColumn - 1 : text.length;
110
+ lines[line - 1] = text.slice(0, from) + ' '.repeat(to - from) + text.slice(to);
111
+ }
112
+ }
113
+ const prose = lines;
114
+ for (const token of all) {
115
+ if (!CODE_BLOCKS.has(token.type))
116
+ continue;
117
+ for (let line = token.startLine; line <= token.endLine; line += 1)
118
+ prose[line - 1] = null;
119
+ }
120
+ return prose;
121
+ };
122
+ const rule = (name, description, check) => ({
123
+ names: [`pariksha-${name}`],
124
+ description,
125
+ tags: [TAG],
126
+ parser: 'micromark',
127
+ function: check,
128
+ });
129
+ /** An alert marker followed, on its own line, by more than whitespace. */
130
+ const alertPattern = (types) => new RegExp(`^\\s*(?:>\\s*)+\\[!(?:${types.map(escapeRegExp).join('|')})\\][ \\t]*\\S`, 'i');
131
+ /**
132
+ * `[text] (target)`: the space turns a link into literal text. Only targets
133
+ * that look like a link (URL, path, anchor, file) count, so prose such as
134
+ * "[1, 2] (sorted)" stays legal.
135
+ */
136
+ const linkSpacePattern = (extensions) => new RegExp(String.raw `\[[^\]]*\][ \t]+\((?:https?:\/\/|mailto:|\.{0,2}\/|#|[\w./-]+\.(?:${extensions.map(escapeRegExp).join('|')})\b)`, 'g');
137
+ /** A letter, one `*`, a letter: what `store_id` becomes once `_` is re-read as emphasis. */
138
+ const EMPHASIS_MANGLE = /(?<=[A-Za-z])\*(?=[A-Za-z])/;
139
+ /** `**name**` for a dunder name a formatter re-read as `__bold__` and rewrote. */
140
+ const dunderPattern = (words) => new RegExp(`\\*\\*(${words.map(escapeRegExp).join('|')})\\*\\*`, 'g');
141
+ const HEADING = /^#{1,6}\s+\S/;
142
+ const PIPE_ROW = /^\s*\|.*\|\s*$/;
143
+ const LETTERED_STEP = /^(\s*)(?:[-*+]\s+)?([a-z])[.)]\s+\S/;
144
+ const childrenOf = (token, type) => token.children.filter((child) => child.type === type);
145
+ const fences = (params) => walk(params.parsers.micromark.tokens).filter((t) => t.type === 'codeFenced');
146
+ const alertMarkerInline = rule('alert-marker-inline', 'Alert marker shares its line with content', (params, onError) => {
147
+ const alert = alertPattern(listOption('pariksha-alert-marker-inline', params.config, 'alert_types'));
148
+ proseLines(params).forEach((line, index) => {
149
+ if (line !== null && alert.test(line)) {
150
+ onError({
151
+ lineNumber: index + 1,
152
+ detail: 'GitHub renders `> [!TYPE]` as a callout only alone on its line; put the body on the next `>` line',
153
+ });
154
+ }
155
+ });
156
+ });
157
+ const linkSpaceBeforeParen = rule('link-space-before-paren', 'Space between `]` and `(` breaks a link', (params, onError) => {
158
+ const linkSpace = linkSpacePattern(listOption('pariksha-link-space-before-paren', params.config, 'link_target_extensions'));
159
+ proseLines(params).forEach((line, index) => {
160
+ if (line === null)
161
+ return;
162
+ for (const match of line.matchAll(linkSpace)) {
163
+ const label = match[0].slice(1, match[0].indexOf(']')).trim();
164
+ // `- [ ] (optional)` and `- [x] (done)` are task boxes, not links.
165
+ if (label === '' || label.toLowerCase() === 'x')
166
+ continue;
167
+ onError({ lineNumber: index + 1, detail: 'write `[text](target)`; with the space it renders as text' });
168
+ }
169
+ });
170
+ });
171
+ const codeSpanCrossesLine = rule('code-span-crosses-line', 'Code span broken across a line', (params, onError) => {
172
+ readConfig('pariksha-code-span-crosses-line', params.config);
173
+ const spans = walk(params.parsers.micromark.tokens).filter((t) => t.type === 'codeText');
174
+ for (const span of spans) {
175
+ if (span.endLine !== span.startLine) {
176
+ onError({
177
+ lineNumber: span.startLine,
178
+ detail: `the span closes ${span.endLine - span.startLine} line(s) later; the line break renders as a space inside it`,
179
+ });
180
+ continue;
181
+ }
182
+ // One path wrapped into two spans, `a/b/` then `c.ts` on the next line,
183
+ // renders as "a/b/ c.ts": a path nobody can copy.
184
+ const rest = (params.lines[span.startLine - 1] ?? '').slice(span.endColumn - 1);
185
+ if (rest.trim() !== '' || !span.text.replace(/`+$/, '').trimEnd().endsWith('/'))
186
+ continue;
187
+ // Document order: the first span on the next line is its leftmost.
188
+ const following = spans.find((t) => t.startLine === span.startLine + 1);
189
+ if (following !== undefined && (params.lines[following.startLine - 1] ?? '').slice(0, following.startColumn - 1).trim() === '') {
190
+ onError({
191
+ lineNumber: span.startLine,
192
+ detail: "a path span ends in `/` at the line break and goes on in the next line's span; join them",
193
+ });
194
+ }
195
+ }
196
+ });
197
+ const fenceUnclosed = rule('fence-unclosed', 'Code fence never closes', (params, onError) => {
198
+ readConfig('pariksha-fence-unclosed', params.config);
199
+ for (const fence of fences(params)) {
200
+ if (childrenOf(fence, 'codeFencedFence').length < 2)
201
+ onError({ lineNumber: fence.startLine, detail: 'everything after it renders as code' });
202
+ }
203
+ });
204
+ const fenceEmpty = rule('fence-empty', 'Empty code fence', (params, onError) => {
205
+ readConfig('pariksha-fence-empty', params.config);
206
+ for (const fence of fences(params)) {
207
+ const body = childrenOf(fence, 'codeFlowValue');
208
+ if (body.every((value) => value.text.trim() === '')) {
209
+ onError({ lineNumber: fence.startLine, detail: 'renders as a blank code block: a stray or orphan fence?' });
210
+ }
211
+ }
212
+ });
213
+ const fenceSwallowsHeading = rule('fence-swallows-heading', 'Markdown heading inside a code fence', (params, onError) => {
214
+ const languages = new Set(listOption('pariksha-fence-swallows-heading', params.config, 'no_hash_languages').map((lang) => lang.toLowerCase()));
215
+ for (const fence of fences(params)) {
216
+ const open = childrenOf(fence, 'codeFencedFence')[0];
217
+ const info = open === undefined ? undefined : childrenOf(open, 'codeFencedFenceInfo')[0];
218
+ if (info === undefined)
219
+ continue;
220
+ const lang = info.text.toLowerCase();
221
+ if (!languages.has(lang))
222
+ continue;
223
+ const heading = childrenOf(fence, 'codeFlowValue').find((value) => HEADING.test(value.text));
224
+ if (heading !== undefined) {
225
+ onError({
226
+ lineNumber: heading.startLine,
227
+ detail: `a ${lang} fence opened ${heading.startLine - fence.startLine} line(s) above holds this heading: it is missing its close`,
228
+ });
229
+ }
230
+ }
231
+ });
232
+ const tableRowOrphaned = rule('table-row-orphaned', 'Table row cut off from its table', (params, onError) => {
233
+ readConfig('pariksha-table-row-orphaned', params.config);
234
+ const paragraphs = walk(params.parsers.micromark.tokens).filter((t) => t.type === 'paragraph');
235
+ for (const paragraph of paragraphs) {
236
+ if (paragraph.text.split('\n').every((row) => PIPE_ROW.test(row))) {
237
+ onError({
238
+ lineNumber: paragraph.startLine,
239
+ detail: 'pipe rows with no header render as text: a blank line split the table; keep its rows together',
240
+ });
241
+ }
242
+ }
243
+ });
244
+ /** Ten characters and a letter: a line whose repeat is a mistake. */
245
+ const meaningful = (line) => line.length >= 10 && /[A-Za-z]/.test(line);
246
+ const duplicateAdjacentLine = rule('duplicate-adjacent-line', 'Line repeats the one before it', (params, onError) => {
247
+ readConfig('pariksha-duplicate-adjacent-line', params.config);
248
+ let previous = null;
249
+ proseLines(params).forEach((line, index) => {
250
+ if (line === null) {
251
+ previous = null;
252
+ return;
253
+ }
254
+ const text = (params.lines[index] ?? '').trim();
255
+ if (text === '')
256
+ return;
257
+ // Table rows repeat by design (templates, matrices).
258
+ if (!PIPE_ROW.test(text) && meaningful(text) && previous?.text === text) {
259
+ onError({ lineNumber: index + 1, detail: `identical to the line ${index + 1 - previous.line} above` });
260
+ }
261
+ previous = { text, line: index + 1 };
262
+ });
263
+ });
264
+ const replacementCharacter = rule('replacement-character', 'U+FFFD replacement character', (params, onError) => {
265
+ readConfig('pariksha-replacement-character', params.config);
266
+ params.lines.forEach((line, index) => {
267
+ if (line.includes('�'))
268
+ onError({ lineNumber: index + 1, detail: 'bytes were lost in an encoding round-trip' });
269
+ });
270
+ });
271
+ const emphasisMangle = rule('emphasis-mangle', 'Identifier lost its underscore to emphasis', (params, onError) => {
272
+ readConfig('pariksha-emphasis-mangle', params.config);
273
+ proseLines(params).forEach((line, index) => {
274
+ if (line !== null && EMPHASIS_MANGLE.test(line)) {
275
+ onError({ lineNumber: index + 1, detail: 'a single `*` between two letters (`store*id`): put the identifier in a code span' });
276
+ }
277
+ });
278
+ });
279
+ const dunderMangle = rule('dunder-mangle', 'Dunder identifier rewritten as bold', (params, onError) => {
280
+ const dunder = dunderPattern(listOption('pariksha-dunder-mangle', params.config, 'dunder_words'));
281
+ proseLines(params).forEach((line, index) => {
282
+ if (line === null)
283
+ return;
284
+ for (const match of line.matchAll(dunder)) {
285
+ const word = match[1] ?? '';
286
+ onError({ lineNumber: index + 1, detail: `renders as bold "${word}"; the identifier is \`__${word}__\` in a code span` });
287
+ }
288
+ });
289
+ });
290
+ const letteredStepIndent = rule('lettered-step-indent', 'Lettered step indented unlike its sibling', (params, onError) => {
291
+ readConfig('pariksha-lettered-step-indent', params.config);
292
+ let last = null;
293
+ proseLines(params).forEach((line, index) => {
294
+ if (line === null || HEADING.test(line)) {
295
+ last = null;
296
+ return;
297
+ }
298
+ const step = LETTERED_STEP.exec(line);
299
+ if (step === null) {
300
+ // Text at or left of the last step's indent ends its list, so a later
301
+ // `b.` belongs to another list; a deeper line continues it.
302
+ const lead = line.length - line.trimStart().length;
303
+ if (line.trim() !== '' && last !== null && lead <= last.indent)
304
+ last = null;
305
+ return;
306
+ }
307
+ const indent = (step[1] ?? '').length;
308
+ const letter = (step[2] ?? '').charCodeAt(0);
309
+ if (last !== null && letter === last.letter + 1 && indent !== last.indent) {
310
+ onError({
311
+ lineNumber: index + 1,
312
+ detail: `indented ${indent}, its sibling ${index + 1 - last.line} line(s) above ${last.indent}: it renders under a different parent`,
313
+ });
314
+ }
315
+ last = { letter, indent, line: index + 1 };
316
+ });
317
+ });
318
+ export const RULES = [
319
+ alertMarkerInline,
320
+ linkSpaceBeforeParen,
321
+ codeSpanCrossesLine,
322
+ fenceUnclosed,
323
+ fenceEmpty,
324
+ fenceSwallowsHeading,
325
+ tableRowOrphaned,
326
+ duplicateAdjacentLine,
327
+ replacementCharacter,
328
+ emphasisMangle,
329
+ dunderMangle,
330
+ letteredStepIndent,
331
+ ];
332
+ export default RULES;
333
+ // `require()` of this ES module returns the rules array itself, as a CommonJS
334
+ // rules module would, rather than its namespace.
335
+ export { RULES as 'module.exports' };
@@ -0,0 +1,86 @@
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 or holds another value.
7
+ import { isDeepStrictEqual } from 'node:util';
8
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
9
+ /**
10
+ * Parses the requirements file: `{ "<key>": { "value": <any JSON>, "why": "<reason>" }, … }`.
11
+ * Throws on anything else, so a malformed list never passes as an empty one.
12
+ */
13
+ export function parseRequirements(text) {
14
+ let raw;
15
+ try {
16
+ raw = JSON.parse(text);
17
+ }
18
+ catch (e) {
19
+ throw new Error(`the requirements are not JSON (${e.message})`);
20
+ }
21
+ if (!isRecord(raw))
22
+ throw new Error('the requirements must be an object of keys');
23
+ const out = [];
24
+ for (const [key, spec] of Object.entries(raw)) {
25
+ if (key === '$schema')
26
+ continue;
27
+ if (key === '' || key.split('.').some((part) => part === ''))
28
+ throw new Error(`"${key}" is not a key or dotted path`);
29
+ if (!isRecord(spec) || !Object.hasOwn(spec, 'value'))
30
+ throw new Error(`"${key}" needs { "value": …, "why": "…" }`);
31
+ const extra = Object.keys(spec).filter((k) => k !== 'value' && k !== 'why');
32
+ if (extra.length > 0)
33
+ throw new Error(`"${key}": unknown key ${extra.map((k) => `"${k}"`).join(', ')}`);
34
+ if (typeof spec.why !== 'string' || spec.why.trim() === '')
35
+ throw new Error(`"${key}" needs a "why"`);
36
+ out.push({ key, value: spec.value, why: spec.why });
37
+ }
38
+ if (out.length === 0)
39
+ throw new Error('the requirements list no key');
40
+ return out;
41
+ }
42
+ /** The value at a dotted path, or `{ missing: true }`. */
43
+ const lookup = (settings, key) => {
44
+ let at = settings;
45
+ for (const part of key.split('.')) {
46
+ if (!isRecord(at) || !Object.hasOwn(at, part))
47
+ return { missing: true };
48
+ at = at[part];
49
+ }
50
+ return { missing: false, value: at };
51
+ };
52
+ /** Every requirement the settings break: a missing key or a different value. */
53
+ export function findMissing(settingsText, requirements) {
54
+ let settings;
55
+ try {
56
+ settings = JSON.parse(settingsText);
57
+ }
58
+ catch (e) {
59
+ throw new Error(`the settings are not JSON (${e.message})`);
60
+ }
61
+ if (!isRecord(settings))
62
+ throw new Error('the settings must hold an object');
63
+ const findings = [];
64
+ for (const r of requirements) {
65
+ const got = lookup(settings, r.key);
66
+ if (got.missing)
67
+ findings.push({ key: r.key, expected: r.value, why: r.why });
68
+ else if (!isDeepStrictEqual(got.value, r.value))
69
+ findings.push({ key: r.key, expected: r.value, found: got.value, why: r.why });
70
+ }
71
+ return findings;
72
+ }
73
+ /** The check's lines: one per finding on err, or one pass line on out. */
74
+ export function formatFindings(findings, count, settingsPath, requirePath) {
75
+ const P = 'pariksha required-settings:';
76
+ if (findings.length === 0)
77
+ return { ok: true, out: [`${P} ${settingsPath} carries all ${count} required settings (${requirePath})`], err: [] };
78
+ return {
79
+ ok: false,
80
+ out: [],
81
+ err: findings.map((f) => {
82
+ const has = Object.hasOwn(f, 'found') ? `holds ${JSON.stringify(f.found)}` : 'is missing';
83
+ return `${P} ${settingsPath}: "${f.key}" ${has}; it must be ${JSON.stringify(f.expected)} (${f.why}). Put it back; to change the requirement, edit ${requirePath}.`;
84
+ }),
85
+ };
86
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@veda-hq/pariksha",
3
- "version": "0.5.3",
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.",
3
+ "version": "0.5.7",
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",
8
8
  "boundary",
9
9
  "ci",
10
- "coding agents"
10
+ "coding agents",
11
+ "markdownlint"
11
12
  ],
12
13
  "license": "MIT",
13
14
  "type": "module",
@@ -27,6 +28,11 @@
27
28
  "bin": {
28
29
  "pariksha": "dist/cli.js"
29
30
  },
31
+ "exports": {
32
+ "./markdownlint": "./dist/markdownlint-rules.js",
33
+ "./dist/*": "./dist/*",
34
+ "./package.json": "./package.json"
35
+ },
30
36
  "files": [
31
37
  "dist"
32
38
  ],
@@ -46,7 +52,7 @@
46
52
  "build": "tsc -p tsconfig.build.json",
47
53
  "typecheck": "tsc -p tsconfig.json",
48
54
  "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",
49
- "smoke": "node dist/cli.js --version && node dist/cli.js boundary --root ../..",
55
+ "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 --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');\"",
50
56
  "check": "pnpm typecheck && pnpm test && pnpm build && pnpm smoke"
51
57
  }
52
58
  }