@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.
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.2 <check> [flags]
8
- npx @veda-hq/pariksha@0.5.2 --version
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
@@ -155,6 +155,72 @@ row that is not `` | `path` | bytes | ``, since nothing would enforce it.
155
155
  The table file is read on every check too, so it must budget itself: a row
156
156
  for its own path (relative to `--root`) is required.
157
157
 
158
+ ## `env-gate-switches`
159
+
160
+ ```sh
161
+ pariksha env-gate-switches --config <file> [--mode staged|tree] [--root <dir>]
162
+ ```
163
+
164
+ A hook that reads `PUSH_SKIP` from the environment has a bypass anyone can
165
+ type: `PUSH_SKIP=1 git push`. This check refuses gate code that READS an
166
+ environment variable whose name has a switch word (`X_SKIP`, `SKIP_X`,
167
+ `X_PROBE_Y`): writing or deleting one is not a read. The JSON `--config`
168
+ (relative to the root) says where gate code is:
169
+
170
+ ```json
171
+ {
172
+ "gatePaths": ["^\\.husky/", "^scripts/", "^\\.github/workflows/"],
173
+ "notGateCode": ["\\.test\\.[cm]?[jt]sx?$", "(?:^|/)fixtures?/", "\\.md$"],
174
+ "notAGate": {
175
+ "SETUP_SKIP_MOBILE": { "files": ["scripts/setup.sh"], "reason": "installs no mobile toolchain; checks nothing" }
176
+ },
177
+ "words": ["SKIP", "PROBE"]
178
+ }
179
+ ```
180
+
181
+ - `gatePaths` (required) and `notGateCode` are path regexes: a file is gate
182
+ code when a gate path matches it and no `notGateCode` entry does.
183
+ - `notAGate` allows a switch that turns off something other than a check,
184
+ in the files listed only, with the reason a reviewer reads; the same name
185
+ anywhere else is still refused.
186
+ - `words` (default `SKIP` and `PROBE`) are upper-case words; a name made of
187
+ `_`-joined words, one of them a switch word, is a switch.
188
+ - Shell (`.sh`, and every file under `.husky/`) is read as bash reads it:
189
+ single quotes, escapes, comments and quoted heredoc bodies expand nothing,
190
+ and a bare name bash reads is a read: in arithmetic (`(( X_SKIP ))`,
191
+ `let`, `-eq`, a subscript), `printenv X_SKIP`, and `-v X_SKIP` in `[ ]`,
192
+ `[[ ]]` or `test`. A variable the file initialises at the top level before
193
+ reading it is the script's own decision, not a read; one initialised in a
194
+ branch, loop, function, group, subshell, pipeline or the end of a `&&`
195
+ list, or self-defaulted (`X_SKIP="${X_SKIP:-0}"`), is.
196
+ - TypeScript and JavaScript are read as a syntax tree, with a type checker
197
+ over the one file: dotted, bracketed, optional, destructured (nested too:
198
+ `const { env: { X_SKIP } } = process`) and `in` reads count, through
199
+ `process`, `globalThis.process`, `node:process` and any local bound to the
200
+ environment, with constant keys resolved (`env['X_' + 'SKIP']`); a mention
201
+ in a comment or string does not.
202
+ - Python reads are `os.environ[…]`, `.get(…)`, `.setdefault(…)`, `.pop(…)`,
203
+ `'X' in` / `'X' not in os.environ`, the same on `os.environ.copy()` or
204
+ `dict(os.environ)`, and `os.getenv(…)`, with the bytes forms (`environb`,
205
+ `getenvb`): under `os` by any name, through `from os import environ as e`
206
+ / `getenv as g`, and through a name assigned one (`env = os.environ`,
207
+ `read = os.getenv`), outside strings and comments (f-string fields are
208
+ code). A key held in a variable is not resolved. Workflow YAML reads are
209
+ `$X`, `env.X` and `env['X']` outside comments.
210
+
211
+ `tree` (the default) reads every tracked file in the working tree, skipping
212
+ one deleted there; when every tracked gate file is deleted, nothing was read
213
+ and it exits 2. `staged` (for a pre-commit hook) reads the files the index
214
+ adds, copies, modifies, renames or changes the type of (a symlink replaced
215
+ by a file), as the index holds them. In either mode, a repository with no
216
+ tracked gate code exits 2, since the config would check nothing, and a gate
217
+ file that is a symlink (in the index for staged, in the working tree for
218
+ tree) exits 2: its text is a path, and its target goes unchecked. A gate
219
+ file that cannot be read (one under a folder the check cannot open
220
+ included), a git call that fails, or a failure while reading a file's code
221
+ exits 2: an unread gate is never clean. Needs `typescript` only when a
222
+ TypeScript or JavaScript file is gate code.
223
+
158
224
  ## `export-deletions`
159
225
 
160
226
  ```sh
@@ -185,3 +251,78 @@ renames, colour, external diff tools and textconv in a user's git config
185
251
  do not change what it reads. With no base, or when git cannot read the
186
252
  range (no merge base, a shallow clone), it exits 2: nothing was checked,
187
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.
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { findDenied, hash, isText, parseDenyList } from "./boundary.js";
9
9
  import { DEFAULT_CONFIG, findGaps, formatGaps, listedPaths, parseConfig, treePaths } from "./coverage-by-type.js";
10
10
  import { checkBuild, checkPackage, checkVitest, formatViolations, loaderFailure, loadVitestExclude, missingConfigs, ownedBy, parseConfig as parseExemptions } from "./coverage-exemptions.js";
11
11
  import { checkBudgets } from "./doc-budgets.js";
12
+ import { diskReads, formatReads, listFiles, needsTypescript, parseConfig as parseGateConfig, readerFor, readGate } from "./env-gate-switches.js";
12
13
  import { loadTypescript, typescriptLoaders } from "./typescript.js";
13
14
  import { checkExportDeletions, formatReport } from "./export-deletions.js";
14
15
  import { BASE_CANDIDATES, gitAt, gitAtTop, must, resolveBase, topOf } from "./git.js";
@@ -19,6 +20,7 @@ const USAGE = [
19
20
  ' pariksha coverage-by-type [--config <file>] [--files-from <file>|-] [--root <dir>]',
20
21
  ' pariksha coverage-exemptions --config <file> [--package <dir>] [--root <dir>]',
21
22
  ' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
23
+ ' pariksha env-gate-switches --config <file> [--mode staged|tree] [--root <dir>]',
22
24
  ' pariksha export-deletions [--base <ref>] [--root <dir>]',
23
25
  ' pariksha hash <word>',
24
26
  ' pariksha --version',
@@ -254,12 +256,73 @@ const coverageExemptions = (f, io) => {
254
256
  return 2;
255
257
  }
256
258
  };
259
+ const envGateSwitches = (f, io) => {
260
+ const P = 'pariksha env-gate-switches:';
261
+ const configPath = f.get('--config');
262
+ const mode = f.get('--mode') ?? 'tree';
263
+ if (configPath === undefined || (mode !== 'staged' && mode !== 'tree')) {
264
+ io.err(USAGE);
265
+ return 2;
266
+ }
267
+ const dir = resolve(f.get('--root') ?? '.');
268
+ const root = topOf(dir);
269
+ if (root === undefined) {
270
+ io.err(`${P} ${dir} is not in a git repository`);
271
+ return 2;
272
+ }
273
+ let reader;
274
+ let words;
275
+ try {
276
+ const config = parseGateConfig(readFileSync(resolve(root, configPath), 'utf8'));
277
+ reader = readerFor(config);
278
+ words = config.words;
279
+ }
280
+ catch (e) {
281
+ io.err(`${P} ${e.message}; nothing was checked, so this is not a pass`);
282
+ return 2;
283
+ }
284
+ const reads = (io.gateReads ?? diskReads)(root);
285
+ let result;
286
+ try {
287
+ const listed = listFiles(reads, mode).filter((l) => reader.isGateCode(l.file));
288
+ // A tree with no gate code is a config whose gatePaths match nothing (a
289
+ // typo): staged mode would pass every commit under it, so both refuse.
290
+ if (!(mode === 'tree' ? listed.length > 0 : listFiles(reads, 'tree').some((l) => reader.isGateCode(l.file)))) {
291
+ io.err(`${P} no tracked file is gate code under the config's gatePaths; nothing was checked, so this is not a pass`);
292
+ return 2;
293
+ }
294
+ const sources = listed.flatMap((entry) => {
295
+ const text = readGate(reads, mode, entry);
296
+ return text === undefined ? [] : [{ file: entry.file, text }];
297
+ });
298
+ if (mode === 'tree' && sources.length === 0) {
299
+ // Staged mode reads the index, which holds every file it lists.
300
+ io.err(`${P} every tracked gate file (${listed.length}) is gone from the working tree; nothing was checked, so this is not a pass`);
301
+ return 2;
302
+ }
303
+ const ts = sources.some((s) => needsTypescript(s.file)) ? (io.typescript ?? (() => loadTypescript(typescriptLoaders(root))))() : undefined;
304
+ if (typeof ts === 'string') {
305
+ io.err(`${P} ${ts}`);
306
+ return 2;
307
+ }
308
+ result = formatReads(sources.flatMap((s) => reader.findSwitchReads(s.file, s.text, ts)), words, sources.length, configPath);
309
+ }
310
+ catch (e) {
311
+ // An unread gate, or one the reader failed on, is unchecked, never clean.
312
+ io.err(`${P} could not read gate code, so it is unchecked: ${e.message}`);
313
+ return 2;
314
+ }
315
+ result.out.forEach(io.out);
316
+ result.err.forEach(io.err);
317
+ return result.ok ? 0 : 1;
318
+ };
257
319
  const COMMANDS = {
258
320
  'coverage-exemptions': { allowed: ['--config', '--package', '--root'], run: coverageExemptions },
259
321
  'authority-citations': { allowed: ['--mode', '--authority', '--issue-prefix', '--adr-dir', '--root'], run: authorityCitations },
260
322
  boundary: { allowed: ['--root', '--deny'], run: boundary },
261
323
  'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
262
324
  'doc-budgets': { allowed: ['--table', '--section', '--root'], run: docBudgets },
325
+ 'env-gate-switches': { allowed: ['--config', '--mode', '--root'], run: envGateSwitches },
263
326
  'export-deletions': { allowed: ['--base', '--root'], run: exportDeletions },
264
327
  };
265
328
  export const run = (args, io, version = ownVersion(), env = process.env) => {