@veda-hq/pariksha 0.5.1 → 0.5.2
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 +40 -5
- package/dist/cli.js +56 -1
- package/dist/coverage-by-type.js +0 -23
- package/dist/coverage-exemptions.js +189 -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.2 <check> [flags]
|
|
8
|
+
npx @veda-hq/pariksha@0.5.2 --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
|
|
package/dist/cli.js
CHANGED
|
@@ -6,8 +6,10 @@ 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 { loadTypescript, typescriptLoaders } from "./typescript.js";
|
|
11
13
|
import { checkExportDeletions, formatReport } from "./export-deletions.js";
|
|
12
14
|
import { BASE_CANDIDATES, gitAt, gitAtTop, must, resolveBase, topOf } from "./git.js";
|
|
13
15
|
export const DENY_FILE = '.veda/deny.sha256';
|
|
@@ -15,6 +17,7 @@ const USAGE = [
|
|
|
15
17
|
'usage: pariksha authority-citations [--mode staged|tree] [--authority <a,b>] [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]',
|
|
16
18
|
' pariksha boundary [--root <dir>] [--deny <file>]',
|
|
17
19
|
' pariksha coverage-by-type [--config <file>] [--files-from <file>|-] [--root <dir>]',
|
|
20
|
+
' pariksha coverage-exemptions --config <file> [--package <dir>] [--root <dir>]',
|
|
18
21
|
' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
|
|
19
22
|
' pariksha export-deletions [--base <ref>] [--root <dir>]',
|
|
20
23
|
' pariksha hash <word>',
|
|
@@ -200,7 +203,59 @@ const coverageByType = (f, io) => {
|
|
|
200
203
|
return 2;
|
|
201
204
|
}
|
|
202
205
|
};
|
|
206
|
+
const coverageExemptions = (f, io) => {
|
|
207
|
+
const configPath = f.get('--config');
|
|
208
|
+
if (configPath === undefined) {
|
|
209
|
+
io.err(USAGE);
|
|
210
|
+
return 2;
|
|
211
|
+
}
|
|
212
|
+
const dir = resolve(f.get('--root') ?? '.');
|
|
213
|
+
const root = topOf(dir);
|
|
214
|
+
if (root === undefined) {
|
|
215
|
+
io.err(`pariksha coverage-exemptions: ${dir} is not in a git repository`);
|
|
216
|
+
return 2;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const config = parseExemptions(readFileSync(resolve(root, configPath), 'utf8'));
|
|
220
|
+
const pkg = f.get('--package');
|
|
221
|
+
const refused = pkg === undefined ? undefined : checkPackage(root, pkg);
|
|
222
|
+
if (refused !== undefined) {
|
|
223
|
+
io.err(`pariksha coverage-exemptions: --package ${refused}`);
|
|
224
|
+
return 2;
|
|
225
|
+
}
|
|
226
|
+
const vitest = ownedBy(config.vitest.configs, pkg);
|
|
227
|
+
const build = ownedBy(config.build.configs, pkg);
|
|
228
|
+
const missing = missingConfigs(root, [...vitest, ...build]);
|
|
229
|
+
if (missing.length > 0) {
|
|
230
|
+
io.err(`pariksha coverage-exemptions: ${missing.join(', ')} named in ${configPath} but not a file; nothing was checked, so this is not a pass`);
|
|
231
|
+
return 2;
|
|
232
|
+
}
|
|
233
|
+
const noLoader = vitest.length > 0 ? loaderFailure(root, config.loader) : undefined;
|
|
234
|
+
if (noLoader !== undefined) {
|
|
235
|
+
io.err(`pariksha coverage-exemptions: ${noLoader}; nothing was checked, so this is not a pass`);
|
|
236
|
+
return 2;
|
|
237
|
+
}
|
|
238
|
+
let violations = checkVitest(config, vitest, (c) => loadVitestExclude(root, c, config.loader));
|
|
239
|
+
if (build.length > 0) {
|
|
240
|
+
const ts = (io.typescript ?? (() => loadTypescript(typescriptLoaders(root))))();
|
|
241
|
+
if (typeof ts === 'string') {
|
|
242
|
+
io.err(`pariksha coverage-exemptions: ${ts}`);
|
|
243
|
+
return 2;
|
|
244
|
+
}
|
|
245
|
+
violations = [...violations, ...checkBuild(ts, config, root, build)];
|
|
246
|
+
}
|
|
247
|
+
const result = formatViolations(violations, vitest.length, build.length, pkg);
|
|
248
|
+
result.out.forEach(io.out);
|
|
249
|
+
result.err.forEach(io.err);
|
|
250
|
+
return result.ok ? 0 : 1;
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
io.err(`pariksha coverage-exemptions: ${e.message}; nothing was checked, so this is not a pass`);
|
|
254
|
+
return 2;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
203
257
|
const COMMANDS = {
|
|
258
|
+
'coverage-exemptions': { allowed: ['--config', '--package', '--root'], run: coverageExemptions },
|
|
204
259
|
'authority-citations': { allowed: ['--mode', '--authority', '--issue-prefix', '--adr-dir', '--root'], run: authorityCitations },
|
|
205
260
|
boundary: { allowed: ['--root', '--deny'], run: boundary },
|
|
206
261
|
'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
|
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
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Answers, on fd 3, with the coverage exclusions of one vitest config, for
|
|
2
|
+
// coverage-exemptions. Runs in its own Node process, under the product's loader
|
|
3
|
+
// (`--import tsx`), because a config imports the product's TypeScript as the
|
|
4
|
+
// product compiles it. It exits once answered: a config's open timer or handle
|
|
5
|
+
// must not hold the run.
|
|
6
|
+
import { writeSync } from 'node:fs';
|
|
7
|
+
import { readExclude } from "./coverage-exemptions.js";
|
|
8
|
+
const exclude = await readExclude(process.argv[2] ?? '');
|
|
9
|
+
// A function or symbol stringifies to undefined; it is no list either way.
|
|
10
|
+
writeSync(3, JSON.stringify(exclude ?? null) ?? 'null');
|
|
11
|
+
process.exit(0);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The TypeScript compiler API, for the checks that read code or tsconfig files.
|
|
2
|
+
// An optional peer dependency: only those checks load it.
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
/** Where typescript is looked for: the product's own install at `root` first, then beside pariksha (npx installs no optional peer). */
|
|
6
|
+
export const typescriptLoaders = (root) => [createRequire(join(root, 'package.json')), createRequire(import.meta.url)];
|
|
7
|
+
/** TypeScript from the first loader that has it; a message naming why when it cannot be used. */
|
|
8
|
+
export const loadTypescript = (loaders = typescriptLoaders(process.cwd())) => {
|
|
9
|
+
let mod;
|
|
10
|
+
for (const load of loaders) {
|
|
11
|
+
try {
|
|
12
|
+
mod = load('typescript');
|
|
13
|
+
break;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// not installed there; try the next
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (mod === undefined)
|
|
20
|
+
return 'needs the typescript package (>=5.4 <6) in the repository or beside pariksha';
|
|
21
|
+
const ts = mod;
|
|
22
|
+
if (typeof ts.createSourceFile !== 'function') {
|
|
23
|
+
return `needs the TypeScript compiler API, which typescript ${String(ts.version)} does not export; install typescript >=5.4 <6`;
|
|
24
|
+
}
|
|
25
|
+
return ts;
|
|
26
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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, doc-budgets, export-deletions.",
|
|
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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"repository checks",
|
|
7
7
|
"lint",
|