@veda-hq/pariksha 0.5.1 → 0.5.3
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 +106 -5
- package/dist/cli.js +119 -1
- package/dist/coverage-by-type.js +0 -23
- package/dist/coverage-exemptions.js +189 -0
- package/dist/env-gate-switches.js +1041 -0
- package/dist/load-vitest-config.js +11 -0
- package/dist/typescript.js +26 -0
- package/package.json +2 -2
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.3 <check> [flags]
|
|
8
|
+
npx @veda-hq/pariksha@0.5.3 --version
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Every check exits 0 when the repository passes, 1 when it refuses something
|
|
@@ -93,9 +93,44 @@ importing file's nearest `package.json`), `maxHops` (default 1) and `hint` (a
|
|
|
93
93
|
line printed under a failure).
|
|
94
94
|
|
|
95
95
|
Needs `typescript` `>=5.4 <6`, the repository's own install first, else one
|
|
96
|
-
beside pariksha (an optional peer dependency: only
|
|
97
|
-
TypeScript 7 exports no stable compiler API yet,
|
|
98
|
-
installed version.
|
|
96
|
+
beside pariksha (an optional peer dependency: only the checks that read code
|
|
97
|
+
or tsconfig files load it). TypeScript 7 exports no stable compiler API yet,
|
|
98
|
+
so they exit 2 naming the installed version.
|
|
99
|
+
|
|
100
|
+
## `coverage-exemptions`
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
pariksha coverage-exemptions --config <file> [--package <dir>] [--root <dir>]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Each vitest config's `coverage.exclude` holds every pattern the product
|
|
107
|
+
exempts, and no production tsconfig's `include` brings those files back. The
|
|
108
|
+
JSON `--config` (relative to the root) names both lists:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"vitest": { "configs": ["apps/api/vitest.config.ts"], "requiredExcludes": ["**/*.test.ts", "**/__mocks__/**"] },
|
|
113
|
+
"build": { "configs": ["apps/api/tsconfig.build.json"], "forbiddenIncludes": ["__mocks__", ".test."] },
|
|
114
|
+
"loader": "tsx"
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- Each vitest config is imported in its own Node process, in the config's
|
|
119
|
+
folder, after `loader` when one is named (a relative path is the root's
|
|
120
|
+
file; a package name resolves as `node --import` does), so a config that
|
|
121
|
+
imports the product's TypeScript loads as the product's tooling loads it. A
|
|
122
|
+
default export may be an object or a factory, sync or async. The answer
|
|
123
|
+
comes back on fd 3, so nothing a config prints can pose as it, and the
|
|
124
|
+
process exits once it answers. A config that fails to load is a finding,
|
|
125
|
+
with its error; a loader that does not load exits 2.
|
|
126
|
+
- Each build config is read as TypeScript reads it (comments allowed); only
|
|
127
|
+
its own `include` is judged, not one it `extends`.
|
|
128
|
+
- `--package <dir>` judges only the configs under that folder (a package's
|
|
129
|
+
own run), which must be a normalized folder under the root.
|
|
130
|
+
- A listed config that is not a file, a tsconfig TypeScript cannot read, and
|
|
131
|
+
a config naming no configs or empty lists exit 2.
|
|
132
|
+
|
|
133
|
+
Needs `typescript` for the build configs.
|
|
99
134
|
|
|
100
135
|
## `doc-budgets`
|
|
101
136
|
|
|
@@ -120,6 +155,72 @@ row that is not `` | `path` | bytes | ``, since nothing would enforce it.
|
|
|
120
155
|
The table file is read on every check too, so it must budget itself: a row
|
|
121
156
|
for its own path (relative to `--root`) is required.
|
|
122
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
|
+
|
|
123
224
|
## `export-deletions`
|
|
124
225
|
|
|
125
226
|
```sh
|
package/dist/cli.js
CHANGED
|
@@ -6,8 +6,11 @@ import { join, relative, resolve } from 'node:path';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { adrDirOf, DEFAULTS, findUncited, formatClaims, listAdrs, readOrUndefined, stagedSource, treeSource } from "./authority-citations.js";
|
|
8
8
|
import { findDenied, hash, isText, parseDenyList } from "./boundary.js";
|
|
9
|
-
import { DEFAULT_CONFIG, findGaps, formatGaps, listedPaths,
|
|
9
|
+
import { DEFAULT_CONFIG, findGaps, formatGaps, listedPaths, parseConfig, treePaths } from "./coverage-by-type.js";
|
|
10
|
+
import { checkBuild, checkPackage, checkVitest, formatViolations, loaderFailure, loadVitestExclude, missingConfigs, ownedBy, parseConfig as parseExemptions } from "./coverage-exemptions.js";
|
|
10
11
|
import { checkBudgets } from "./doc-budgets.js";
|
|
12
|
+
import { diskReads, formatReads, listFiles, needsTypescript, parseConfig as parseGateConfig, readerFor, readGate } from "./env-gate-switches.js";
|
|
13
|
+
import { loadTypescript, typescriptLoaders } from "./typescript.js";
|
|
11
14
|
import { checkExportDeletions, formatReport } from "./export-deletions.js";
|
|
12
15
|
import { BASE_CANDIDATES, gitAt, gitAtTop, must, resolveBase, topOf } from "./git.js";
|
|
13
16
|
export const DENY_FILE = '.veda/deny.sha256';
|
|
@@ -15,7 +18,9 @@ const USAGE = [
|
|
|
15
18
|
'usage: pariksha authority-citations [--mode staged|tree] [--authority <a,b>] [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]',
|
|
16
19
|
' pariksha boundary [--root <dir>] [--deny <file>]',
|
|
17
20
|
' pariksha coverage-by-type [--config <file>] [--files-from <file>|-] [--root <dir>]',
|
|
21
|
+
' pariksha coverage-exemptions --config <file> [--package <dir>] [--root <dir>]',
|
|
18
22
|
' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
|
|
23
|
+
' pariksha env-gate-switches --config <file> [--mode staged|tree] [--root <dir>]',
|
|
19
24
|
' pariksha export-deletions [--base <ref>] [--root <dir>]',
|
|
20
25
|
' pariksha hash <word>',
|
|
21
26
|
' pariksha --version',
|
|
@@ -200,11 +205,124 @@ const coverageByType = (f, io) => {
|
|
|
200
205
|
return 2;
|
|
201
206
|
}
|
|
202
207
|
};
|
|
208
|
+
const coverageExemptions = (f, io) => {
|
|
209
|
+
const configPath = f.get('--config');
|
|
210
|
+
if (configPath === undefined) {
|
|
211
|
+
io.err(USAGE);
|
|
212
|
+
return 2;
|
|
213
|
+
}
|
|
214
|
+
const dir = resolve(f.get('--root') ?? '.');
|
|
215
|
+
const root = topOf(dir);
|
|
216
|
+
if (root === undefined) {
|
|
217
|
+
io.err(`pariksha coverage-exemptions: ${dir} is not in a git repository`);
|
|
218
|
+
return 2;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const config = parseExemptions(readFileSync(resolve(root, configPath), 'utf8'));
|
|
222
|
+
const pkg = f.get('--package');
|
|
223
|
+
const refused = pkg === undefined ? undefined : checkPackage(root, pkg);
|
|
224
|
+
if (refused !== undefined) {
|
|
225
|
+
io.err(`pariksha coverage-exemptions: --package ${refused}`);
|
|
226
|
+
return 2;
|
|
227
|
+
}
|
|
228
|
+
const vitest = ownedBy(config.vitest.configs, pkg);
|
|
229
|
+
const build = ownedBy(config.build.configs, pkg);
|
|
230
|
+
const missing = missingConfigs(root, [...vitest, ...build]);
|
|
231
|
+
if (missing.length > 0) {
|
|
232
|
+
io.err(`pariksha coverage-exemptions: ${missing.join(', ')} named in ${configPath} but not a file; nothing was checked, so this is not a pass`);
|
|
233
|
+
return 2;
|
|
234
|
+
}
|
|
235
|
+
const noLoader = vitest.length > 0 ? loaderFailure(root, config.loader) : undefined;
|
|
236
|
+
if (noLoader !== undefined) {
|
|
237
|
+
io.err(`pariksha coverage-exemptions: ${noLoader}; nothing was checked, so this is not a pass`);
|
|
238
|
+
return 2;
|
|
239
|
+
}
|
|
240
|
+
let violations = checkVitest(config, vitest, (c) => loadVitestExclude(root, c, config.loader));
|
|
241
|
+
if (build.length > 0) {
|
|
242
|
+
const ts = (io.typescript ?? (() => loadTypescript(typescriptLoaders(root))))();
|
|
243
|
+
if (typeof ts === 'string') {
|
|
244
|
+
io.err(`pariksha coverage-exemptions: ${ts}`);
|
|
245
|
+
return 2;
|
|
246
|
+
}
|
|
247
|
+
violations = [...violations, ...checkBuild(ts, config, root, build)];
|
|
248
|
+
}
|
|
249
|
+
const result = formatViolations(violations, vitest.length, build.length, pkg);
|
|
250
|
+
result.out.forEach(io.out);
|
|
251
|
+
result.err.forEach(io.err);
|
|
252
|
+
return result.ok ? 0 : 1;
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
io.err(`pariksha coverage-exemptions: ${e.message}; nothing was checked, so this is not a pass`);
|
|
256
|
+
return 2;
|
|
257
|
+
}
|
|
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
|
+
};
|
|
203
319
|
const COMMANDS = {
|
|
320
|
+
'coverage-exemptions': { allowed: ['--config', '--package', '--root'], run: coverageExemptions },
|
|
204
321
|
'authority-citations': { allowed: ['--mode', '--authority', '--issue-prefix', '--adr-dir', '--root'], run: authorityCitations },
|
|
205
322
|
boundary: { allowed: ['--root', '--deny'], run: boundary },
|
|
206
323
|
'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
|
|
207
324
|
'doc-budgets': { allowed: ['--table', '--section', '--root'], run: docBudgets },
|
|
325
|
+
'env-gate-switches': { allowed: ['--config', '--mode', '--root'], run: envGateSwitches },
|
|
208
326
|
'export-deletions': { allowed: ['--base', '--root'], run: exportDeletions },
|
|
209
327
|
};
|
|
210
328
|
export const run = (args, io, version = ownVersion(), env = process.env) => {
|
package/dist/coverage-by-type.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// sat at 0% covered while `Page.test.tsx` tested a helper, and a filename match
|
|
6
6
|
// called the page covered. So a candidate suite counts only when it reaches the
|
|
7
7
|
// subject through a runtime import, directly or through one module it imports.
|
|
8
|
-
import { createRequire } from 'node:module';
|
|
9
8
|
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
10
9
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
11
10
|
const KINDS_AT = (dirs, suffixes) => dirs.flatMap((d) => suffixes.map((s) => `${d}{name}${s}`));
|
|
@@ -141,28 +140,6 @@ export const parseConfig = (text) => {
|
|
|
141
140
|
}
|
|
142
141
|
return { categories, required, exclude, tests, aliases, maxHops, hint };
|
|
143
142
|
};
|
|
144
|
-
/** Where typescript is looked for: the product's own install at `root` first, then beside pariksha (npx installs no optional peer). */
|
|
145
|
-
export const typescriptLoaders = (root) => [createRequire(join(root, 'package.json')), createRequire(import.meta.url)];
|
|
146
|
-
/** TypeScript from the first loader that has it; a message naming why when it cannot be used. */
|
|
147
|
-
export const loadTypescript = (loaders = typescriptLoaders(process.cwd())) => {
|
|
148
|
-
let mod;
|
|
149
|
-
for (const load of loaders) {
|
|
150
|
-
try {
|
|
151
|
-
mod = load('typescript');
|
|
152
|
-
break;
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
// not installed there; try the next
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (mod === undefined)
|
|
159
|
-
return 'needs the typescript package (>=5.4 <6) in the repository or beside pariksha';
|
|
160
|
-
const ts = mod;
|
|
161
|
-
if (typeof ts.createSourceFile !== 'function') {
|
|
162
|
-
return `needs the TypeScript compiler API, which typescript ${String(ts.version)} does not export; install typescript >=5.4 <6`;
|
|
163
|
-
}
|
|
164
|
-
return ts;
|
|
165
|
-
};
|
|
166
143
|
const toPosix = (p) => p.split(sep).join('/');
|
|
167
144
|
/** Extensions a specifier may omit, or carry as its ESM-facing `.js` form. */
|
|
168
145
|
const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs'];
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// coverage-exemptions: each vitest config excludes from coverage what the product
|
|
10
|
+
// exempts (tests, mocks, declarations), and no production build config includes
|
|
11
|
+
// those files back.
|
|
12
|
+
//
|
|
13
|
+
// An exemption list that drifts lets test files count as covered source, and a
|
|
14
|
+
// build config that includes `__mocks__` ships them. Both are configuration a
|
|
15
|
+
// reviewer reads past, so the lists are named once, in the product's config,
|
|
16
|
+
// and every config is held to them.
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
19
|
+
import { dirname, isAbsolute, join, posix, resolve } from 'node:path';
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
21
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
22
|
+
const isStrings = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s !== '');
|
|
23
|
+
const section = (raw, key, list) => {
|
|
24
|
+
const v = raw[key] ?? { configs: [], [list]: [] };
|
|
25
|
+
if (!isRecord(v))
|
|
26
|
+
throw new Error(`"${key}" must be an object`);
|
|
27
|
+
const extra = Object.keys(v).find((k) => k !== 'configs' && k !== list);
|
|
28
|
+
if (extra !== undefined)
|
|
29
|
+
throw new Error(`${key}: unknown key "${extra}"`);
|
|
30
|
+
const configs = v['configs'] ?? [];
|
|
31
|
+
const rules = v[list] ?? [];
|
|
32
|
+
if (!isStrings(configs))
|
|
33
|
+
throw new Error(`${key}.configs must be a list of non-empty paths`);
|
|
34
|
+
if (!isStrings(rules))
|
|
35
|
+
throw new Error(`${key}.${list} must be a list of non-empty strings`);
|
|
36
|
+
const bad = configs.find((c) => !isRepoPath(c));
|
|
37
|
+
if (bad !== undefined)
|
|
38
|
+
throw new Error(`${key}.configs: "${bad}" is not a normalized root-relative path`);
|
|
39
|
+
// A config with nothing to hold it to would pass whatever it says.
|
|
40
|
+
if (configs.length > 0 && rules.length === 0)
|
|
41
|
+
throw new Error(`${key}.${list} is empty, so its configs would always pass`);
|
|
42
|
+
return { configs, rules };
|
|
43
|
+
};
|
|
44
|
+
/** The config a JSON text names; throws naming the first bad key. */
|
|
45
|
+
export const parseConfig = (text) => {
|
|
46
|
+
let raw;
|
|
47
|
+
try {
|
|
48
|
+
raw = JSON.parse(text);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
throw new Error(`not JSON: ${e.message}`);
|
|
52
|
+
}
|
|
53
|
+
if (!isRecord(raw))
|
|
54
|
+
throw new Error('the config must be a JSON object');
|
|
55
|
+
const unknown = Object.keys(raw).find((k) => !['vitest', 'build', 'loader'].includes(k));
|
|
56
|
+
if (unknown !== undefined)
|
|
57
|
+
throw new Error(`unknown key "${unknown}"`);
|
|
58
|
+
const vitest = section(raw, 'vitest', 'requiredExcludes');
|
|
59
|
+
const build = section(raw, 'build', 'forbiddenIncludes');
|
|
60
|
+
if (vitest.configs.length + build.configs.length === 0)
|
|
61
|
+
throw new Error('no vitest or build config is named, so nothing would be checked');
|
|
62
|
+
const loader = raw['loader'];
|
|
63
|
+
if (loader !== undefined && (typeof loader !== 'string' || loader === ''))
|
|
64
|
+
throw new Error('"loader" must be a module name');
|
|
65
|
+
return {
|
|
66
|
+
vitest: { configs: vitest.configs, requiredExcludes: vitest.rules },
|
|
67
|
+
build: { configs: build.configs, forbiddenIncludes: build.rules },
|
|
68
|
+
loader,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
/** A normalized relative POSIX path that stays inside the root. */
|
|
72
|
+
const isRepoPath = (p) => !isAbsolute(p) && posix.normalize(p) === p && p !== '.' && p !== '..' && !p.startsWith('../') && !p.endsWith('/');
|
|
73
|
+
/** A `--package` folder, checked: a value that matches no config would check nothing and pass. */
|
|
74
|
+
export const checkPackage = (root, dir) => {
|
|
75
|
+
if (!isRepoPath(dir))
|
|
76
|
+
return `"${dir}" is not a normalized root-relative folder`;
|
|
77
|
+
const full = join(root, dir);
|
|
78
|
+
if (statSync(full, { throwIfNoEntry: false })?.isDirectory() !== true)
|
|
79
|
+
return `"${dir}" is not a folder under the root`;
|
|
80
|
+
// On a case-insensitive disk `Apps/Server` is a folder too, but owns no config spelled `apps/server/…`.
|
|
81
|
+
if (realpathSync.native(full) !== join(realpathSync.native(root), dir))
|
|
82
|
+
return `"${dir}" is not spelled as the folder on disk is`;
|
|
83
|
+
return undefined;
|
|
84
|
+
};
|
|
85
|
+
/** The configs that belong to `dir`, or all of them. */
|
|
86
|
+
export const ownedBy = (configs, dir) => (dir === undefined ? [...configs] : configs.filter((c) => c.startsWith(`${dir}/`)));
|
|
87
|
+
/** A vitest config's `coverage.exclude`, as the config file exports it. */
|
|
88
|
+
export const readExclude = async (file) => {
|
|
89
|
+
const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href)));
|
|
90
|
+
const raw = mod.default;
|
|
91
|
+
// A config may export a factory, sync or async (`defineConfig(async () => …)`).
|
|
92
|
+
const config = (await (typeof raw === 'function' ? raw() : raw));
|
|
93
|
+
return config?.test?.coverage?.exclude || config?.coverage?.exclude;
|
|
94
|
+
};
|
|
95
|
+
/** The child script beside a module: the `.ts` source under test, the `.js` build when installed. */
|
|
96
|
+
export const loaderScriptBeside = (moduleUrl) => fileURLToPath(new URL(`./load-vitest-config${moduleUrl.endsWith('.ts') ? '.ts' : '.js'}`, moduleUrl));
|
|
97
|
+
const LOADER_SCRIPT = loaderScriptBeside(import.meta.url);
|
|
98
|
+
/** The `--import` argument for a loader: a relative path is the root's file, a bare name resolves from the config's folder. */
|
|
99
|
+
const importArg = (root, loader) => (loader.startsWith('.') ? pathToFileURL(resolve(root, loader)).href : loader);
|
|
100
|
+
/** Why a child failed: the error's own line (`Error: …`), the value thrown when it is not an Error, else its first line. */
|
|
101
|
+
export const failureOf = (stderr, status) => {
|
|
102
|
+
const lines = stderr.split('\n');
|
|
103
|
+
const named = lines.find((l) => /^\w*Error\b/.test(l));
|
|
104
|
+
if (named !== undefined)
|
|
105
|
+
return named.trim();
|
|
106
|
+
// `throw 'text'` prints Node's own frame, a caret line, then the value.
|
|
107
|
+
const caret = lines.findIndex((l) => /^\s*\^+\s*$/.test(l));
|
|
108
|
+
const thrown = caret >= 0 ? lines.slice(caret + 1).find((l) => /\S/.test(l)) : undefined;
|
|
109
|
+
return (thrown ?? lines.find((l) => /\S/.test(l)) ?? `exit ${String(status)}`).trim();
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* One vitest config's exclusions, loaded in its own Node process under the
|
|
113
|
+
* product's loader, in the config's own folder (as the product's tooling runs
|
|
114
|
+
* it, so its tsconfig `paths` and relative reads hold). One process per config:
|
|
115
|
+
* two configs loaded side by side in one process can race on a shared module.
|
|
116
|
+
* The answer comes back on fd 3, which nothing a config prints can reach.
|
|
117
|
+
*/
|
|
118
|
+
export const loadVitestExclude = (root, config, loader, timeout = 120_000) => {
|
|
119
|
+
const file = join(root, config);
|
|
120
|
+
const args = [...(loader === undefined ? [] : ['--import', importArg(root, loader)]), LOADER_SCRIPT, file];
|
|
121
|
+
const r = spawnSync(process.execPath, args, { cwd: dirname(file), encoding: 'utf8', timeout, maxBuffer: 1 << 26, stdio: ['ignore', 'pipe', 'pipe', 'pipe'] });
|
|
122
|
+
const answer = r.status === 0 && r.error === undefined ? (r.output[3] ?? '') : '';
|
|
123
|
+
if (answer === '')
|
|
124
|
+
return { ok: false, error: r.error?.message ?? failureOf(r.stderr, r.status) };
|
|
125
|
+
try {
|
|
126
|
+
return { ok: true, exclude: JSON.parse(answer) };
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return { ok: false, error: `the config's coverage.exclude is not data: ${answer.slice(0, 80)}` };
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
/** Whether the loader itself loads, from the root; a missing one would otherwise be blamed on every config. */
|
|
133
|
+
export const loaderFailure = (root, loader) => {
|
|
134
|
+
if (loader === undefined)
|
|
135
|
+
return undefined;
|
|
136
|
+
const r = spawnSync(process.execPath, ['--import', importArg(root, loader), '-e', ''], { cwd: root, encoding: 'utf8', timeout: 120_000 });
|
|
137
|
+
return r.status === 0 && r.error === undefined ? undefined : `loader ${loader} does not load: ${r.error?.message ?? failureOf(r.stderr, r.status)}`;
|
|
138
|
+
};
|
|
139
|
+
/** The violations among the vitest configs. */
|
|
140
|
+
export const checkVitest = (config, configs, load) => {
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const file of configs) {
|
|
143
|
+
const r = load(file);
|
|
144
|
+
if (!r.ok) {
|
|
145
|
+
out.push({ file, message: `cannot load the config: ${r.error}` });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!Array.isArray(r.exclude) || r.exclude.length === 0) {
|
|
149
|
+
out.push({ file, message: 'has no coverage.exclude' });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const exclude = r.exclude;
|
|
153
|
+
for (const pattern of config.vitest.requiredExcludes) {
|
|
154
|
+
if (!exclude.includes(pattern))
|
|
155
|
+
out.push({ file, message: `coverage.exclude lacks ${pattern}` });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
};
|
|
160
|
+
/** The violations among the build configs; a config that cannot be read throws. */
|
|
161
|
+
export const checkBuild = (ts, config, root, configs) => {
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const file of configs) {
|
|
164
|
+
const read = ts.readConfigFile(join(root, file), (p) => readFileSync(p, 'utf8'));
|
|
165
|
+
if (read.error !== undefined)
|
|
166
|
+
throw new Error(`${file}: ${ts.flattenDiagnosticMessageText(read.error.messageText, '\n')}`);
|
|
167
|
+
const include = read.config?.include ?? [];
|
|
168
|
+
if (!Array.isArray(include) || !include.every((e) => typeof e === 'string'))
|
|
169
|
+
throw new Error(`${file}: "include" is not a list of strings`);
|
|
170
|
+
for (const pattern of config.build.forbiddenIncludes) {
|
|
171
|
+
if (include.some((entry) => entry.includes(pattern)))
|
|
172
|
+
out.push({ file, message: `include brings test files into the build: ${pattern}` });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
};
|
|
177
|
+
/** Every listed config exists as a file; a missing one would otherwise pass unread. */
|
|
178
|
+
export const missingConfigs = (root, configs) => configs.filter((c) => statSync(join(root, c), { throwIfNoEntry: false })?.isFile() !== true);
|
|
179
|
+
const P = 'pariksha coverage-exemptions:';
|
|
180
|
+
export const formatViolations = (violations, vitest, build, dir) => {
|
|
181
|
+
const scope = dir === undefined ? '' : ` under ${dir}`;
|
|
182
|
+
if (violations.length === 0)
|
|
183
|
+
return { ok: true, out: [`${P} ${vitest} vitest and ${build} build config(s)${scope} checked, none drifts`], err: [] };
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
out: [],
|
|
187
|
+
err: [...violations.map((v) => `${P} ${v.file}: ${v.message}`), `${P} ${violations.length} problem(s). Exempt test files from coverage in every vitest config, and keep them out of every build.`],
|
|
188
|
+
};
|
|
189
|
+
};
|