@veda-hq/pariksha 0.3.2 → 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 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.3.2 <check> [flags]
8
- npx @veda-hq/pariksha@0.3.2 --version
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
@@ -62,6 +62,76 @@ the repository's deny list.
62
62
  - Binary files (any NUL byte) are skipped. Untracked files are not read.
63
63
  Line 0 in a finding is the file's path.
64
64
 
65
+ ## `coverage-by-type`
66
+
67
+ ```sh
68
+ pariksha coverage-by-type [--config <file>] [--files-from <file>|-] [--root <dir>]
69
+ ```
70
+
71
+ A source file in a category needs the tests its category names: by default a
72
+ service, hook, utility or context a unit test, a repository a unit and an
73
+ integration test, a component or screen a UI (`.test.tsx`) test. A suite named
74
+ after its subject that never imports it is no test of it, so a candidate
75
+ counts only when it loads the subject at runtime, directly or through one
76
+ module it imports (a barrel, a collaborator). `import type`, a clause whose
77
+ every binding is type-only, and `typeof import()` load nothing and do not
78
+ count; `import()`, `require()`, `importActual` and `requireActual` do.
79
+
80
+ Only `.ts` and `.tsx` files are judged. Without `--files-from` every tracked
81
+ regular file is (not a symlink, not one deleted in the working tree), and a
82
+ tree with none in a category exits 2. With it, the listed paths are (one per
83
+ line or NUL-separated, relative to the root or absolute; `-` reads stdin, a
84
+ file path is relative to the current directory); a listed path that does not
85
+ exist or lies outside the root exits 2, and a listed symlink is skipped. The
86
+ JSON `--config` (relative to the root) replaces each default it names, and
87
+ refuses one that would judge nothing (no categories, an empty pattern, a
88
+ category needing no test):
89
+ `categories` (name to path regex, first match wins), `required` (category to
90
+ test kinds), `tests` (kind to candidate paths, `{dir}` and `{name}`),
91
+ `exclude` (path regexes), `aliases` (specifier prefix to a directory under the
92
+ importing file's nearest `package.json`), `maxHops` (default 1) and `hint` (a
93
+ line printed under a failure).
94
+
95
+ Needs `typescript` `>=5.4 <6`, the repository's own install first, else one
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.
134
+
65
135
  ## `doc-budgets`
66
136
 
67
137
  ```sh
@@ -21,7 +21,7 @@
21
21
  // again. tree mode judges every tracked file.
22
22
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
23
23
  import { join, posix } from 'node:path';
24
- import { must, PLAIN_DIFF } from './git.js';
24
+ import { must, PLAIN_DIFF } from "./git.js";
25
25
  export const DEFAULTS = { authorities: [], issuePrefixes: [], adrDir: 'docs/adr' };
26
26
  const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
27
  /**
package/dist/cli.js CHANGED
@@ -4,15 +4,20 @@ import { execFileSync } from 'node:child_process';
4
4
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
5
5
  import { join, relative, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { adrDirOf, DEFAULTS, findUncited, formatClaims, listAdrs, readOrUndefined, stagedSource, treeSource } from './authority-citations.js';
8
- import { findDenied, hash, isText, parseDenyList } from './boundary.js';
9
- import { checkBudgets } from './doc-budgets.js';
10
- import { checkExportDeletions, formatReport } from './export-deletions.js';
11
- import { BASE_CANDIDATES, gitAt, gitAtTop, resolveBase, topOf } from './git.js';
7
+ import { adrDirOf, DEFAULTS, findUncited, formatClaims, listAdrs, readOrUndefined, stagedSource, treeSource } from "./authority-citations.js";
8
+ import { findDenied, hash, isText, parseDenyList } from "./boundary.js";
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";
11
+ import { checkBudgets } from "./doc-budgets.js";
12
+ import { loadTypescript, typescriptLoaders } from "./typescript.js";
13
+ import { checkExportDeletions, formatReport } from "./export-deletions.js";
14
+ import { BASE_CANDIDATES, gitAt, gitAtTop, must, resolveBase, topOf } from "./git.js";
12
15
  export const DENY_FILE = '.veda/deny.sha256';
13
16
  const USAGE = [
14
17
  'usage: pariksha authority-citations [--mode staged|tree] [--authority <a,b>] [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]',
15
18
  ' pariksha boundary [--root <dir>] [--deny <file>]',
19
+ ' pariksha coverage-by-type [--config <file>] [--files-from <file>|-] [--root <dir>]',
20
+ ' pariksha coverage-exemptions --config <file> [--package <dir>] [--root <dir>]',
16
21
  ' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
17
22
  ' pariksha export-deletions [--base <ref>] [--root <dir>]',
18
23
  ' pariksha hash <word>',
@@ -24,7 +29,7 @@ export const ownVersion = () => {
24
29
  };
25
30
  /** The files git tracks under root, text ones only. */
26
31
  export const trackedFiles = (root) => {
27
- const names = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }).split('\0').filter(Boolean);
32
+ const names = must(gitAt(root), ['ls-files', '-z']).split('\0').filter(Boolean);
28
33
  const files = [];
29
34
  for (const path of names) {
30
35
  const full = join(root, path);
@@ -163,9 +168,97 @@ const authorityCitations = (f, io, env) => {
163
168
  result.err.forEach(io.err);
164
169
  return result.ok ? 0 : 1;
165
170
  };
171
+ const coverageByType = (f, io) => {
172
+ const dir = resolve(f.get('--root') ?? '.');
173
+ const root = topOf(dir);
174
+ if (root === undefined) {
175
+ io.err(`pariksha coverage-by-type: ${dir} is not in a git repository`);
176
+ return 2;
177
+ }
178
+ const ts = (io.typescript ?? (() => loadTypescript(typescriptLoaders(root))))();
179
+ if (typeof ts === 'string') {
180
+ io.err(`pariksha coverage-by-type: ${ts}`);
181
+ return 2;
182
+ }
183
+ try {
184
+ const configPath = f.get('--config');
185
+ const config = configPath === undefined ? DEFAULT_CONFIG : parseConfig(readFileSync(resolve(root, configPath), 'utf8'));
186
+ const from = f.get('--files-from');
187
+ const paths = from === undefined
188
+ ? treePaths(root, must(gitAt(root), ['ls-files', '-z']))
189
+ : listedPaths(root, from === '-' ? (io.stdin ?? (() => readFileSync(0, 'utf8')))() : readFileSync(resolve(from), 'utf8'));
190
+ const findings = findGaps(ts, config, root, paths);
191
+ if (from === undefined && findings.judged === 0) {
192
+ // The whole tree in no category is a config that checks nothing.
193
+ io.err(`pariksha coverage-by-type: none of ${findings.listed} tracked file(s) is in a category; nothing was checked, so this is not a pass`);
194
+ return 2;
195
+ }
196
+ const result = formatGaps(config, findings);
197
+ result.out.forEach(io.out);
198
+ result.err.forEach(io.err);
199
+ return result.ok ? 0 : 1;
200
+ }
201
+ catch (e) {
202
+ io.err(`pariksha coverage-by-type: ${e.message}; nothing was checked, so this is not a pass`);
203
+ return 2;
204
+ }
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
+ };
166
257
  const COMMANDS = {
258
+ 'coverage-exemptions': { allowed: ['--config', '--package', '--root'], run: coverageExemptions },
167
259
  'authority-citations': { allowed: ['--mode', '--authority', '--issue-prefix', '--adr-dir', '--root'], run: authorityCitations },
168
260
  boundary: { allowed: ['--root', '--deny'], run: boundary },
261
+ 'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
169
262
  'doc-budgets': { allowed: ['--table', '--section', '--root'], run: docBudgets },
170
263
  'export-deletions': { allowed: ['--base', '--root'], run: exportDeletions },
171
264
  };
@@ -0,0 +1,373 @@
1
+ // coverage-by-type: a source file in a category needs the tests its category names,
2
+ // and a test counts only if it loads its subject at runtime.
3
+ //
4
+ // A suite named after its subject that never imports it is no test of it: a page
5
+ // sat at 0% covered while `Page.test.tsx` tested a helper, and a filename match
6
+ // called the page covered. So a candidate suite counts only when it reaches the
7
+ // subject through a runtime import, directly or through one module it imports.
8
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
9
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
+ const KINDS_AT = (dirs, suffixes) => dirs.flatMap((d) => suffixes.map((s) => `${d}{name}${s}`));
11
+ export const DEFAULT_CONFIG = {
12
+ categories: [
13
+ ['service', /\/(services|domain)\/.+\.(ts|tsx)$/],
14
+ ['repository', /\/repositories?\/.+\.(ts|tsx)$/],
15
+ ['component', /\/components?\/.+\.(tsx)$/],
16
+ ['screen', /\/screens?\/.+\.(tsx)$/],
17
+ ['hook', /\/hooks?\/use[A-Z].+\.(ts|tsx)$/],
18
+ ['utility', /\/(utils|helpers|lib)\/.+\.(ts|tsx)$/],
19
+ ['context', /\/context\/.+\.(tsx)$/],
20
+ ],
21
+ required: new Map([
22
+ ['service', ['unit']],
23
+ ['repository', ['unit', 'integration']],
24
+ ['component', ['ui']],
25
+ ['screen', ['ui']],
26
+ ['hook', ['unit']],
27
+ ['utility', ['unit']],
28
+ ['context', ['unit']],
29
+ ]),
30
+ exclude: [
31
+ /\.(test|spec|test-d|spec-d|perf-test)\.(ts|tsx)$/,
32
+ /\.d\.ts$/,
33
+ /\.config\.(ts|js|mts|mjs)$/,
34
+ /index\.(ts|tsx)$/,
35
+ /types?\.(ts|tsx)$/,
36
+ /(^|\/)types\//,
37
+ /__mocks__\//,
38
+ /__tests__\//,
39
+ /\/e2e\//,
40
+ /\/test\//,
41
+ /node_modules/,
42
+ ],
43
+ tests: new Map([
44
+ ['unit', KINDS_AT(['{dir}/', '{dir}/__tests__/', '{dir}/test/', '{dir}/tests/'], ['.test.ts', '.test.tsx'])],
45
+ [
46
+ 'integration',
47
+ [
48
+ ...KINDS_AT(['{dir}/'], ['.integration.test.ts', '.integration.test.tsx']),
49
+ ...KINDS_AT(['{dir}/__tests__/integration/', '{dir}/test/integration/', '{dir}/tests/integration/'], ['.test.ts', '.test.tsx']),
50
+ ],
51
+ ],
52
+ ['ui', KINDS_AT(['{dir}/', '{dir}/__tests__/', '{dir}/test/', '{dir}/tests/'], ['.test.tsx'])],
53
+ ]),
54
+ aliases: new Map(),
55
+ maxHops: 1,
56
+ hint: undefined,
57
+ };
58
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
59
+ const isStrings = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string');
60
+ const regex = (source, where) => {
61
+ // An empty pattern matches every path: an exclusion would judge nothing.
62
+ if (source === '')
63
+ throw new Error(`${where}: an empty pattern matches every path`);
64
+ try {
65
+ // The product's own config file, read from its own checkout.
66
+ return new RegExp(source);
67
+ }
68
+ catch (e) {
69
+ throw new Error(`${where}: ${e.message}`);
70
+ }
71
+ };
72
+ const KEYS = new Set(['categories', 'required', 'exclude', 'tests', 'aliases', 'maxHops', 'hint']);
73
+ /** The config a JSON text names, over the defaults; throws naming the first bad key. */
74
+ export const parseConfig = (text) => {
75
+ let raw;
76
+ try {
77
+ raw = JSON.parse(text);
78
+ }
79
+ catch (e) {
80
+ throw new Error(`not JSON: ${e.message}`);
81
+ }
82
+ if (!isRecord(raw))
83
+ throw new Error('the config must be a JSON object');
84
+ const unknown = Object.keys(raw).find((k) => !KEYS.has(k));
85
+ if (unknown !== undefined)
86
+ throw new Error(`unknown key "${unknown}"`);
87
+ const stringMap = (key) => {
88
+ const v = raw[key];
89
+ if (v === undefined)
90
+ return undefined;
91
+ if (!isRecord(v) || !Object.values(v).every((s) => typeof s === 'string'))
92
+ throw new Error(`"${key}" must map names to strings`);
93
+ return new Map(Object.entries(v));
94
+ };
95
+ const listMap = (key) => {
96
+ const v = raw[key];
97
+ if (v === undefined)
98
+ return undefined;
99
+ if (!isRecord(v) || !Object.values(v).every(isStrings))
100
+ throw new Error(`"${key}" must map names to lists of strings`);
101
+ return new Map(Object.entries(v));
102
+ };
103
+ const cats = stringMap('categories');
104
+ if (cats?.size === 0)
105
+ throw new Error('"categories" is empty, so no file would be judged');
106
+ // JSON objects iterate integer-like keys first, which would reorder "first match wins".
107
+ const numeric = cats && [...cats.keys()].find((k) => /^(0|[1-9]\d*)$/.test(k));
108
+ if (numeric)
109
+ throw new Error(`category "${numeric}": a name that is a number loses its place in the order`);
110
+ const categories = cats ? [...cats].map(([name, src]) => [name, regex(src, `categories.${name}`)]) : DEFAULT_CONFIG.categories;
111
+ const required = listMap('required') ?? DEFAULT_CONFIG.required;
112
+ const tests = listMap('tests') ?? DEFAULT_CONFIG.tests;
113
+ const aliases = stringMap('aliases') ?? DEFAULT_CONFIG.aliases;
114
+ let exclude = DEFAULT_CONFIG.exclude;
115
+ if (raw['exclude'] !== undefined) {
116
+ if (!isStrings(raw['exclude']))
117
+ throw new Error('"exclude" must be a list of strings');
118
+ exclude = raw['exclude'].map((src, i) => regex(src, `exclude[${i}]`));
119
+ }
120
+ const maxHops = raw['maxHops'] ?? DEFAULT_CONFIG.maxHops;
121
+ if (typeof maxHops !== 'number' || !Number.isInteger(maxHops) || maxHops < 0)
122
+ throw new Error('"maxHops" must be a whole number, 0 or more');
123
+ const hint = raw['hint'] ?? DEFAULT_CONFIG.hint;
124
+ if (hint !== undefined && typeof hint !== 'string')
125
+ throw new Error('"hint" must be a string');
126
+ for (const [name] of categories) {
127
+ const kinds = required.get(name);
128
+ if (kinds === undefined)
129
+ throw new Error(`category "${name}" has no "required" entry`);
130
+ if (kinds.length === 0)
131
+ throw new Error(`category "${name}" requires no test, so it would never be refused`);
132
+ const missing = kinds.find((k) => !tests.has(k));
133
+ if (missing !== undefined)
134
+ throw new Error(`category "${name}" requires "${missing}", which "tests" does not define`);
135
+ }
136
+ for (const [kind, paths] of tests) {
137
+ const bad = paths.find((p) => !p.includes('{name}'));
138
+ if (bad !== undefined)
139
+ throw new Error(`tests.${kind}: "${bad}" has no {name}, so it names one file for every source`);
140
+ }
141
+ return { categories, required, exclude, tests, aliases, maxHops, hint };
142
+ };
143
+ const toPosix = (p) => p.split(sep).join('/');
144
+ /** Extensions a specifier may omit, or carry as its ESM-facing `.js` form. */
145
+ const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs'];
146
+ /** The call forms that load a module at runtime, by callee name. */
147
+ const RUNTIME_LOADERS = new Set(['require', 'importActual', 'requireActual']);
148
+ /**
149
+ * Whether an import or export clause survives to runtime. `import type` is erased
150
+ * and loads nothing; so is a named clause whose every binding is type-only. A
151
+ * clause-less import runs its module.
152
+ */
153
+ const clauseReachesRuntime = (ts, node) => {
154
+ if (ts.isExportDeclaration(node)) {
155
+ if (node.isTypeOnly)
156
+ return false;
157
+ const b = node.exportClause;
158
+ return b && ts.isNamedExports(b) ? b.elements.some((e) => !e.isTypeOnly) : true;
159
+ }
160
+ const clause = node.importClause;
161
+ if (!clause)
162
+ return true;
163
+ if (clause.isTypeOnly)
164
+ return false;
165
+ if (clause.name)
166
+ return true;
167
+ const b = clause.namedBindings;
168
+ return b && ts.isNamedImports(b) ? b.elements.some((e) => !e.isTypeOnly) : true;
169
+ };
170
+ /**
171
+ * Every module specifier a file loads at runtime: surviving imports and re-exports,
172
+ * `import()`, `require()`, and `importActual`/`requireActual`. Parsed, not matched:
173
+ * `typeof import('./x')` in a type loads nothing, and only syntax tells it apart.
174
+ * A template with substitutions is not resolved; its target needs evaluating.
175
+ */
176
+ export const moduleSpecifiers = (ts, source, fileName = 'file.tsx') => {
177
+ const parsed = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest);
178
+ const found = [];
179
+ const visit = (node) => {
180
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
181
+ if (clauseReachesRuntime(ts, node))
182
+ found.push(node.moduleSpecifier.text);
183
+ }
184
+ else if (ts.isCallExpression(node)) {
185
+ const [first] = node.arguments;
186
+ const callee = node.expression;
187
+ const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : ts.isIdentifier(callee) ? callee.text : undefined;
188
+ const isDynamicImport = callee.kind === ts.SyntaxKind.ImportKeyword;
189
+ const specifier = first && (ts.isStringLiteral(first) || ts.isNoSubstitutionTemplateLiteral(first)) ? first.text : undefined;
190
+ if (specifier !== undefined && (isDynamicImport || (name !== undefined && RUNTIME_LOADERS.has(name))))
191
+ found.push(specifier);
192
+ }
193
+ ts.forEachChild(node, visit);
194
+ };
195
+ ts.forEachChild(parsed, visit);
196
+ return found;
197
+ };
198
+ const stripModuleExtension = (p) => {
199
+ const ext = MODULE_EXTENSIONS.find((e) => p.endsWith(e));
200
+ return ext ? p.slice(0, -ext.length) : p;
201
+ };
202
+ const isFile = (p) => existsSync(p) && statSync(p).isFile();
203
+ /** The workspace a file belongs to: its nearest ancestor holding a package.json. */
204
+ const workspaceRootOf = (file) => {
205
+ for (let dir = dirname(resolve(file));; dir = dirname(dir)) {
206
+ if (existsSync(join(dir, 'package.json')))
207
+ return dir;
208
+ if (dirname(dir) === dir)
209
+ return undefined;
210
+ }
211
+ };
212
+ /** The walk from a test to its subject, with each file's specifiers read once. */
213
+ export class ImportWalk {
214
+ #specifiers = new Map();
215
+ #ts;
216
+ #config;
217
+ constructor(ts, config) {
218
+ this.#ts = ts;
219
+ this.#config = config;
220
+ }
221
+ #specifiersOf(file) {
222
+ let found = this.#specifiers.get(file);
223
+ if (found === undefined) {
224
+ found = moduleSpecifiers(this.#ts, readFileSync(file, 'utf8'), file);
225
+ this.#specifiers.set(file, found);
226
+ }
227
+ return found;
228
+ }
229
+ /** An absolute extensionless path, or undefined for what cannot be followed (a package, an unknown alias). */
230
+ #resolveSpecifier(specifier, fromFile) {
231
+ if (specifier.startsWith('.'))
232
+ return stripModuleExtension(resolve(dirname(fromFile), specifier));
233
+ for (const [prefix, dir] of this.#config.aliases) {
234
+ if (!specifier.startsWith(prefix))
235
+ continue;
236
+ const root = workspaceRootOf(fromFile);
237
+ return root === undefined ? undefined : stripModuleExtension(resolve(root, dir, specifier.slice(prefix.length)));
238
+ }
239
+ return undefined;
240
+ }
241
+ /** Whether a test reaches its subject, directly or through up to maxHops modules it imports. */
242
+ reaches(testFile, sourceFile) {
243
+ const target = stripModuleExtension(resolve(sourceFile));
244
+ const start = resolve(testFile);
245
+ const visited = new Set([start]);
246
+ let frontier = [start];
247
+ for (let hop = 0; hop <= this.#config.maxHops; hop++) {
248
+ const next = [];
249
+ for (const file of frontier) {
250
+ for (const specifier of this.#specifiersOf(file)) {
251
+ const resolved = this.#resolveSpecifier(specifier, file);
252
+ if (resolved === undefined)
253
+ continue;
254
+ if (resolved === target)
255
+ return true;
256
+ const onDisk = resolveToFile(resolved);
257
+ if (onDisk === undefined)
258
+ continue;
259
+ if (stripModuleExtension(onDisk) === target)
260
+ return true;
261
+ if (!visited.has(onDisk)) {
262
+ visited.add(onDisk);
263
+ next.push(onDisk);
264
+ }
265
+ }
266
+ }
267
+ frontier = next;
268
+ }
269
+ return false;
270
+ }
271
+ }
272
+ /** The file an extensionless path names: itself with an extension, or its directory's index. */
273
+ const resolveToFile = (extensionless) => MODULE_EXTENSIONS.map((e) => `${extensionless}${e}`).find(isFile) ?? MODULE_EXTENSIONS.map((e) => join(extensionless, `index${e}`)).find(isFile);
274
+ /** The category of a root-relative path, or undefined when none matches. */
275
+ export const categoryOf = (config, path) => config.categories.find(([, re]) => re.test(toPosix(path)))?.[0];
276
+ export const isExcluded = (config, path) => config.exclude.some((re) => re.test(toPosix(path)));
277
+ /** The test kinds found for one source file: a candidate that exists and reaches it. */
278
+ export const foundKinds = (config, walk, root, path) => {
279
+ const full = resolve(root, path);
280
+ const dir = dirname(full);
281
+ const name = full.slice(dir.length + 1).replace(/\.(ts|tsx)$/, '');
282
+ const found = new Set();
283
+ for (const [kind, patterns] of config.tests) {
284
+ const hit = patterns.some((p) => {
285
+ // Function replacements: a `$` in a path is text, never a replacement pattern.
286
+ const candidate = resolve(root, p.replaceAll('{dir}', () => dir).replaceAll('{name}', () => name));
287
+ return isFile(candidate) && walk.reaches(candidate, full);
288
+ });
289
+ if (hit)
290
+ found.add(kind);
291
+ }
292
+ return found;
293
+ };
294
+ /** The gaps among root-relative `paths`: each `.ts`/`.tsx` file, not excluded, in a category, missing a kind. */
295
+ export const findGaps = (ts, config, root, paths) => {
296
+ const walk = new ImportWalk(ts, config);
297
+ const gaps = [];
298
+ let judged = 0;
299
+ for (const path of paths) {
300
+ const rel = toPosix(path);
301
+ if (!/\.(ts|tsx)$/.test(rel) || isExcluded(config, rel))
302
+ continue;
303
+ const category = categoryOf(config, rel);
304
+ if (category === undefined)
305
+ continue;
306
+ judged++;
307
+ const found = foundKinds(config, walk, root, rel);
308
+ const missing = (config.required.get(category) ?? []).filter((k) => !found.has(k));
309
+ if (missing.length > 0)
310
+ gaps.push({ file: rel, category, missing });
311
+ }
312
+ return { gaps, judged, listed: paths.length };
313
+ };
314
+ /** A listing's entries: NUL-separated when it holds a NUL, else one per line; a leading BOM dropped. */
315
+ export const entries = (text) => {
316
+ const body = text.startsWith('') ? text.slice(1) : text;
317
+ return body.split(body.includes('\0') ? '\0' : /\r?\n/).filter(Boolean);
318
+ };
319
+ /** Every tracked regular file under root: not deleted in the working tree, not a symlink. */
320
+ export const treePaths = (root, listed) => entries(listed).filter((p) => lstatOrUndefined(join(root, p))?.isFile() === true);
321
+ /**
322
+ * The root-relative path of each listed entry (relative to root, or absolute).
323
+ * An entry naming nothing, or outside the root, throws: a list that cannot be
324
+ * read as given is not a list that passed. A symlink is not a source file and
325
+ * is skipped, as git's own listings do. Directories are resolved through their
326
+ * real path, so a symlinked checkout prefix still matches `^`-anchored rules.
327
+ */
328
+ export const listedPaths = (root, text) => {
329
+ const out = [];
330
+ for (const entry of entries(text)) {
331
+ const abs = resolve(root, entry);
332
+ const stat = lstatOrUndefined(abs);
333
+ if (stat === undefined)
334
+ throw new Error(`listed file ${entry} does not exist`);
335
+ if (stat.isSymbolicLink())
336
+ continue;
337
+ const rel = relative(root, join(realpathSync(dirname(abs)), basename(abs)));
338
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))
339
+ throw new Error(`listed file ${entry} is outside ${root}`);
340
+ if (stat.isFile())
341
+ out.push(toPosix(rel));
342
+ }
343
+ return out;
344
+ };
345
+ const lstatOrUndefined = (p) => {
346
+ try {
347
+ return lstatSync(p);
348
+ }
349
+ catch (e) {
350
+ if (e.code === 'ENOENT' || e.code === 'ENOTDIR')
351
+ return undefined;
352
+ throw e;
353
+ }
354
+ };
355
+ const P = 'pariksha coverage-by-type:';
356
+ export const formatGaps = (config, findings) => {
357
+ const { gaps, judged, listed } = findings;
358
+ if (gaps.length === 0)
359
+ return { ok: true, out: [`${P} ${judged} of ${listed} listed file(s) judged, each has the tests its category needs`], err: [] };
360
+ const err = [];
361
+ const byCategory = new Map();
362
+ for (const g of gaps)
363
+ byCategory.set(g.category, [...(byCategory.get(g.category) ?? []), g]);
364
+ for (const [category, list] of byCategory) {
365
+ err.push(`${P} ${category} (needs: ${(config.required.get(category) ?? []).join(', ')} tests)`);
366
+ for (const g of list)
367
+ err.push(` ${g.file}: missing ${g.missing.join(', ')}`);
368
+ }
369
+ err.push('', `${P} ${gaps.length} file(s) missing required tests. A suite counts only when it imports its subject at runtime.`);
370
+ if (config.hint !== undefined)
371
+ err.push(config.hint);
372
+ return { ok: false, out: [], err };
373
+ };
@@ -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
+ };
@@ -10,7 +10,7 @@
10
10
  // Removes-Export: <symbol> — <why> covers one symbol
11
11
  // Removes-File: <path> — <why> covers every export of a file that
12
12
  // no longer exists at HEAD
13
- import { must, PLAIN_DIFF } from './git.js';
13
+ import { must, PLAIN_DIFF } from "./git.js";
14
14
  export const SOURCE_GLOBS = ['*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs', '*.mts', '*.cts'];
15
15
  /** Build output and vendored code never count as a source change, or as a use. */
16
16
  const SKIPPED = ['node_modules', 'dist', 'build', 'coverage', 'generated'];
@@ -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.3.2",
4
- "description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: authority-citations, boundary, 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",
@@ -30,9 +30,17 @@
30
30
  "files": [
31
31
  "dist"
32
32
  ],
33
+ "peerDependencies": {
34
+ "typescript": ">=5.4 <6"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "typescript": {
38
+ "optional": true
39
+ }
40
+ },
33
41
  "devDependencies": {
34
42
  "@types/node": "24.19.2",
35
- "typescript": "7.0.2"
43
+ "typescript": "5.9.3"
36
44
  },
37
45
  "scripts": {
38
46
  "build": "tsc -p tsconfig.build.json",