@veda-hq/pariksha 0.5.3 → 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.
- package/README.md +77 -2
- package/dist/markdownlint-rules.js +335 -0
- package/package.json +10 -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.5 <check> [flags]
|
|
8
|
+
npx @veda-hq/pariksha@0.5.5 --version
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Every check exits 0 when the repository passes, 1 when it refuses something
|
|
@@ -251,3 +251,78 @@ 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
|
+
## markdownlint rules
|
|
256
|
+
|
|
257
|
+
```jsonc
|
|
258
|
+
// .markdownlint-cli2.jsonc
|
|
259
|
+
{
|
|
260
|
+
"customRules": ["@veda-hq/pariksha/markdownlint"],
|
|
261
|
+
"config": { "default": false, "pariksha-markdown": true }
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Not a check but a module: markdownlint custom rules for Markdown that reads
|
|
266
|
+
fine as source and renders mangled, where no built-in rule fails it for that
|
|
267
|
+
reason. `@veda-hq/pariksha/markdownlint` is an ES module whose default export
|
|
268
|
+
is the rules array, which markdownlint-cli2's `customRules` loads by package
|
|
269
|
+
name; `require()` of it returns the array too. Every rule carries the tag
|
|
270
|
+
`pariksha-markdown`, so the config above turns on these rules and nothing
|
|
271
|
+
else. They run on markdownlint's micromark tokens (`parser: "micromark"`)
|
|
272
|
+
and import nothing. Most read prose only, skipping code blocks, HTML blocks
|
|
273
|
+
and code spans; the fence and code-span rules read those,
|
|
274
|
+
`pariksha-replacement-character` reads every line, and
|
|
275
|
+
`pariksha-duplicate-adjacent-line` skips code and HTML blocks but compares
|
|
276
|
+
whole lines, code spans included.
|
|
277
|
+
|
|
278
|
+
| Rule | Fails |
|
|
279
|
+
| ---- | ----- |
|
|
280
|
+
| `pariksha-alert-marker-inline` | `> [!NOTE] body` on one line: GitHub renders the callout only with the marker alone |
|
|
281
|
+
| `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) |
|
|
282
|
+
| `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 |
|
|
283
|
+
| `pariksha-fence-unclosed` | a code fence that never closes |
|
|
284
|
+
| `pariksha-fence-empty` | a fence with nothing but blank lines in it |
|
|
285
|
+
| `pariksha-fence-swallows-heading` | a `# Heading` inside a fence of a language where it cannot be code: the fence above lost its close |
|
|
286
|
+
| `pariksha-table-row-orphaned` | a paragraph made only of `\| … \|` rows: a blank line cut them off their table |
|
|
287
|
+
| `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) |
|
|
288
|
+
| `pariksha-replacement-character` | U+FFFD, bytes lost in an encoding round-trip |
|
|
289
|
+
| `pariksha-emphasis-mangle` | `store*id`: a single `*` between letters, where `_` was re-read as emphasis |
|
|
290
|
+
| `pariksha-dunder-mangle` | `**DEV**`: a formatter rewrote `__DEV__` as bold |
|
|
291
|
+
| `pariksha-lettered-step-indent` | `b.` indented unlike the `a.` above it, so it renders under another parent |
|
|
292
|
+
|
|
293
|
+
Options are lists, each replacing its default:
|
|
294
|
+
|
|
295
|
+
| Option | Rule | Default |
|
|
296
|
+
| ------ | ---- | ------- |
|
|
297
|
+
| `alert_types` | `pariksha-alert-marker-inline` | `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION` (matched case-insensitively) |
|
|
298
|
+
| `link_target_extensions` | `pariksha-link-space-before-paren` | `md`, `mdx`, `html`, `htm`, `ts`, `tsx`, `js`, `json`, `yaml`, `yml`, `png`, `svg`, `jpg`, `jpeg`, `pdf` |
|
|
299
|
+
| `dunder_words` | `pariksha-dunder-mangle` | `DEV`, `proto`, `esModule`, `typename`: names never an English word in bold |
|
|
300
|
+
| `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) |
|
|
301
|
+
|
|
302
|
+
```jsonc
|
|
303
|
+
{
|
|
304
|
+
"config": {
|
|
305
|
+
"default": false,
|
|
306
|
+
"pariksha-markdown": { "dunder_words": ["DEV", "proto", "esModule", "typename", "dirname"] },
|
|
307
|
+
"pariksha-fence-swallows-heading": { "no_hash_languages": ["ts", "py"] }
|
|
308
|
+
},
|
|
309
|
+
"customRules": ["@veda-hq/pariksha/markdownlint"]
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Options set under the tag reach every rule, so every rule accepts all four
|
|
314
|
+
names. A rule's own key replaces the tag's object for that rule (markdownlint
|
|
315
|
+
takes the last key that names a rule, whole), so above
|
|
316
|
+
`pariksha-fence-swallows-heading` keeps the default `dunder_words`, which it
|
|
317
|
+
does not read. The same goes for an option of one rule set under another
|
|
318
|
+
rule's key: a rule cannot tell its own object from the tag's, so it accepts
|
|
319
|
+
every name some rule reads. Otherwise a configuration markdownlint cannot have
|
|
320
|
+
meant is refused, not ignored: an option name no rule reads (an array set as
|
|
321
|
+
the configuration reaches the rules as its indexes, `"0"`, and is refused as
|
|
322
|
+
such), a list option that is not a non-empty array of strings (to turn a rule
|
|
323
|
+
off, set it to `false`), or an entry that could never match (an extension with
|
|
324
|
+
its dot, a dunder word with its underscores, a language with a space). Each
|
|
325
|
+
rule that reads the bad configuration throws, naming itself and the option, so
|
|
326
|
+
markdownlint-cli2 reports up to twelve errors on line 1 of each file and the
|
|
327
|
+
run fails; with `"severity": "warning"` in the configuration they are reported
|
|
328
|
+
as warnings instead.
|
|
@@ -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.
|
|
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.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
|
}
|