@veda-hq/pariksha 0.5.2 → 0.5.5

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.
@@ -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' };
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@veda-hq/pariksha",
3
- "version": "0.5.2",
4
- "description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: authority-citations, boundary, coverage-by-type, coverage-exemptions, doc-budgets, export-deletions.",
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.",
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 --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
  }