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