@veda-hq/pariksha 0.3.1 → 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 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.1 <check> [flags]
8
- npx @veda-hq/pariksha@0.3.1 --version
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
@@ -13,6 +13,37 @@ Every check exits 0 when the repository passes, 1 when it refuses something
13
13
  error. `--root <dir>` (default: the current directory) names the repository.
14
14
  Node 24 or later. MIT.
15
15
 
16
+ ## `authority-citations`
17
+
18
+ ```sh
19
+ pariksha authority-citations [--mode staged|tree] [--authority <a,b>]
20
+ [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]
21
+ ```
22
+
23
+ A coherent fabrication passes every internal check: an agent that deletes a
24
+ public API and calls the deletion "an owner decision" is consistent with
25
+ itself. So a line that invokes an authority (`owner decision`,
26
+ `founder decided`, `product rule`, `approved by` / `decided by` /
27
+ `authorized by` / `mandated by` / `per` an office, a `--authority` name or an
28
+ `@handle`) must cite, within three lines, something only that authority could
29
+ have produced:
30
+
31
+ - resolved: a commit id naming a commit here, or `ADR-NNNN` /
32
+ `<adr-dir>/NNNN` naming an existing `NNNN-*.md` other than the claiming file
33
+ (default `docs/adr`);
34
+ - shape-checked: an issue id with a `--issue-prefix` (`ABC-123`), a GitHub
35
+ pull request or issue URL, or `PR #123`.
36
+
37
+ `ALLOW-AUTHORITY-PHRASE: <reason>` in the window marks words used without
38
+ asserting authority; the reason is required. TypeScript, JavaScript, SQL,
39
+ Prisma, shell and Markdown files are read, outside `node_modules`, `dist`,
40
+ `build`, `coverage`, `generated`, `.tmp` and `.local`.
41
+
42
+ `staged` (the default, for a pre-commit or pre-merge-commit hook) judges only
43
+ the lines the commit adds; on a merge, only lines new against every parent,
44
+ so text a merged branch already carried is not judged again. `tree` judges
45
+ every tracked file. When git cannot read the change it exits 2.
46
+
16
47
  ## `boundary`
17
48
 
18
49
  ```sh
@@ -31,6 +62,41 @@ the repository's deny list.
31
62
  - Binary files (any NUL byte) are skipped. Untracked files are not read.
32
63
  Line 0 in a finding is the file's path.
33
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
+
34
100
  ## `doc-budgets`
35
101
 
36
102
  ```sh
@@ -0,0 +1,192 @@
1
+ // The authority-citations check: a line that invokes an authority ("owner
2
+ // decision", "approved by the founder", "per the CTO") must cite something only
3
+ // that authority could have produced, outside the file making the claim.
4
+ //
5
+ // A claim asserted inside the file it justifies cannot be checked from inside
6
+ // that file: an agent that deletes a public API and calls it "an owner
7
+ // decision" passes every internal-consistency gate, because a coherent
8
+ // fabrication is consistent. So the check asks for an anchor within three lines
9
+ // of the claim:
10
+ //
11
+ // resolved here: an ADR number whose file exists (not the claiming file),
12
+ // a commit id that names a commit in this repository
13
+ // shape-checked only: an issue id with a configured prefix (ABC-123), a
14
+ // GitHub pull request or issue URL, "PR #123"
15
+ //
16
+ // `ALLOW-AUTHORITY-PHRASE: <reason>` in the same window marks words that are
17
+ // used without asserting authority; the reason is required.
18
+ //
19
+ // staged mode judges only lines the commit adds; on a merge, only lines new
20
+ // against every parent, so text a merged branch already carried is not judged
21
+ // again. tree mode judges every tracked file.
22
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
23
+ import { join, posix } from 'node:path';
24
+ import { must, PLAIN_DIFF } from "./git.js";
25
+ export const DEFAULTS = { authorities: [], issuePrefixes: [], adrDir: 'docs/adr' };
26
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
+ /**
28
+ * An authority is a person or an office, never a subsystem: requiring one after
29
+ * "approved by" and "per" keeps "decided by the diff status" out.
30
+ */
31
+ export const phrasePattern = (authorities) => {
32
+ const names = ['owner', 'founder', 'CTO', 'CEO', 'management', 'leadership', ...authorities.map(escape), '@[A-Za-z0-9._-]+'];
33
+ const who = `(?:the )?(?:${names.join('|')})`;
34
+ return new RegExp(`owner (?:decision|decided)|founder (?:decision|decided)|product rule|(?:approved|decided|authorized|mandated) by ${who}|per ${who}`, 'i');
35
+ };
36
+ const SOURCE_EXT = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts|sql|prisma|sh|md)$/;
37
+ /** Vendored, generated and scratch trees carry no authored claims. */
38
+ const SKIPPED_PATH = /(^|\/)(node_modules|dist|build|coverage|\.tmp|\.local|generated)\//;
39
+ const WINDOW = 3;
40
+ /** The lines within WINDOW of the 1-based line `center`, joined. */
41
+ export const windowOf = (lines, center) => lines.slice(Math.max(0, center - 1 - WINDOW), center + WINDOW).join('\n');
42
+ /** True when the window holds the pragma with a reason, or a citation that resolves. */
43
+ export const hasCitation = (window, claimingFile, opts, git, adrFiles) => {
44
+ if (/ALLOW-AUTHORITY-PHRASE:[^\S\n]*\S/.test(window))
45
+ return true;
46
+ if (opts.issuePrefixes.length > 0 && new RegExp(`\\b(?:${opts.issuePrefixes.map(escape).join('|')})-[0-9]+\\b`).test(window))
47
+ return true;
48
+ if (/https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/(?:pull|issues)\/[0-9]+/.test(window))
49
+ return true;
50
+ if (/\bPR #[0-9]+\b/.test(window))
51
+ return true;
52
+ for (const [, n] of window.matchAll(new RegExp(`(?:ADR-|${escape(opts.adrDir)}/)([0-9]{4})`, 'g'))) {
53
+ if (adrFiles.some((f) => f.startsWith(`${opts.adrDir}/${n}-`) && f.endsWith('.md') && f !== claimingFile))
54
+ return true;
55
+ }
56
+ for (const [sha] of window.matchAll(/\b[0-9a-f]{7,40}\b/g)) {
57
+ if (git.run(['cat-file', '-e', `${sha}^{commit}`]) !== undefined)
58
+ return true;
59
+ }
60
+ return false;
61
+ };
62
+ /** 1-based line numbers a `-U0` diff adds. */
63
+ export const addedLines = (diff) => {
64
+ const out = new Set();
65
+ for (const line of diff.split('\n')) {
66
+ const m = /^@@ [^+]*\+([0-9]+)(?:,([0-9]+))? @@/.exec(line);
67
+ if (!m)
68
+ continue;
69
+ const start = Number(m[1]);
70
+ const count = m[2] === undefined ? 1 : Number(m[2]);
71
+ for (let i = 0; i < count; i++)
72
+ out.add(start + i);
73
+ }
74
+ return out;
75
+ };
76
+ /**
77
+ * The other parents of the merge being committed; none outside a merge.
78
+ * MERGE_HEAD exists once a merge stops (a conflict, --no-commit). A clean
79
+ * merge runs the pre-merge-commit hook before writing MERGE_HEAD, leaving only
80
+ * the GITHEAD_<sha> variables `git merge` exports: one counts only when git is
81
+ * merging (AUTO_MERGE is the index tree) and the index is exactly the clean
82
+ * merge of HEAD and that commit, so a stale or hand-set variable hides nothing.
83
+ * Anything else falls back to the HEAD diff, which judges more, never less.
84
+ */
85
+ export const mergeParents = (git, env, readFile) => {
86
+ const parents = [];
87
+ const listed = readFile(must(git, ['rev-parse', '--path-format=absolute', '--git-path', 'MERGE_HEAD']).trim());
88
+ for (const candidate of (listed ?? '').split('\n').filter(Boolean)) {
89
+ const sha = git.run(['rev-parse', '-q', '--verify', `${candidate}^{commit}`])?.trim();
90
+ if (sha)
91
+ parents.push(sha);
92
+ }
93
+ // An index with unresolved conflicts has no tree, and nothing can be committed from it.
94
+ const indexTree = git.run(['write-tree'])?.trim();
95
+ if (!indexTree || git.run(['rev-parse', '-q', '--verify', 'AUTO_MERGE^{tree}'])?.trim() !== indexTree)
96
+ return parents;
97
+ for (const name of Object.keys(env)) {
98
+ const sha = /^GITHEAD_([0-9a-f]{40}|[0-9a-f]{64})$/.exec(name)?.[1];
99
+ if (sha === undefined || parents.includes(sha))
100
+ continue;
101
+ if (git.run(['merge-tree', '--write-tree', 'HEAD', sha])?.trim() === indexTree)
102
+ parents.push(sha);
103
+ }
104
+ return parents;
105
+ };
106
+ const nulList = (out) => out.split('\0').filter(Boolean);
107
+ const literal = (file) => `:(literal)${file}`;
108
+ /** The staged change: what the commit being made adds. */
109
+ export const stagedSource = (git, env, readFile) => {
110
+ const parents = mergeParents(git, env, readFile);
111
+ const added = (file, against) => addedLines(must(git, ['diff', ...PLAIN_DIFF, '--cached', '-U0', ...against, '--', literal(file)]));
112
+ return {
113
+ // T: a symlink or submodule replaced by a file brings in its every line.
114
+ files: nulList(must(git, ['diff', ...PLAIN_DIFF, '--cached', '-z', '--name-only', '--diff-filter=ACMT'])),
115
+ // `:0:` names stage 0 explicitly, so a path such as `0:a.md` is read as itself.
116
+ content: (file) => must(git, ['show', `:0:${file}`]),
117
+ judged: (file) => {
118
+ let lines = added(file, []);
119
+ for (const parent of parents) {
120
+ const vsParent = added(file, [parent]);
121
+ lines = new Set([...lines].filter((n) => vsParent.has(n)));
122
+ }
123
+ return lines;
124
+ },
125
+ };
126
+ };
127
+ /** Every tracked file, as it is in the working tree. */
128
+ export const treeSource = (git, root, readFile) => ({
129
+ files: nulList(must(git, ['ls-files', '-z'])),
130
+ content: (file) => readFile(join(root, file)),
131
+ judged: () => undefined,
132
+ });
133
+ /** Every uncited claim the source holds. */
134
+ export const findUncited = (source, opts, git, adrFiles) => {
135
+ const phrase = phrasePattern(opts.authorities);
136
+ const claims = [];
137
+ for (const file of source.files) {
138
+ if (!SOURCE_EXT.test(file) || SKIPPED_PATH.test(file))
139
+ continue;
140
+ const text = source.content(file);
141
+ if (text === undefined || !phrase.test(text))
142
+ continue;
143
+ const lines = text.split('\n');
144
+ const judged = source.judged(file);
145
+ lines.forEach((line, i) => {
146
+ const n = i + 1;
147
+ const m = phrase.exec(line);
148
+ if (!m || (judged !== undefined && !judged.has(n)))
149
+ return;
150
+ if (hasCitation(windowOf(lines, n), file, opts, git, adrFiles))
151
+ return;
152
+ claims.push({ file, line: n, phrase: m[0], text: line.slice(0, 120) });
153
+ });
154
+ }
155
+ return claims;
156
+ };
157
+ /**
158
+ * An ADR directory as the claiming files' paths spell it: `./docs//adr/`
159
+ * is `docs/adr`, so an ADR citing itself is still recognised as itself.
160
+ */
161
+ export const adrDirOf = (dir) => posix.normalize(dir).replace(/\/+$/, '') || '.';
162
+ /** Files (not directories) under the ADR directory, relative to the root. */
163
+ export const listAdrs = (root, adrDir) => {
164
+ const dir = join(root, adrDir);
165
+ return existsSync(dir) ? readdirSync(dir).filter((f) => statSync(join(dir, f)).isFile()).map((f) => `${adrDir}/${f}`) : [];
166
+ };
167
+ /** A file's text, or undefined when it is not there (gone, or now a directory); any other failure throws. */
168
+ export const readOrUndefined = (path) => {
169
+ try {
170
+ return readFileSync(path, 'utf8');
171
+ }
172
+ catch (e) {
173
+ if (['ENOENT', 'ENOTDIR', 'EISDIR'].includes(e.code ?? ''))
174
+ return undefined;
175
+ throw e;
176
+ }
177
+ };
178
+ export const formatClaims = (claims, opts) => {
179
+ if (claims.length === 0)
180
+ return { ok: true, out: ['pariksha authority-citations: every authority claim cites an anchor'], err: [] };
181
+ const err = [];
182
+ let last = '';
183
+ for (const c of claims) {
184
+ if (c.file !== last)
185
+ err.push(`pariksha authority-citations: uncited authority claim(s) in ${c.file}:`);
186
+ last = c.file;
187
+ err.push(` ${c.line}: [${c.phrase}] ${c.text}`);
188
+ }
189
+ const files = new Set(claims.map((c) => c.file)).size;
190
+ err.push('', 'An authority claim must anchor in something only that authority can produce. Cite one of:', ' a commit id (owner decision, f1a0da2d)', ` an existing ADR ADR-0040, in ${opts.adrDir}/`, ...(opts.issuePrefixes.length > 0 ? [` an issue id ${opts.issuePrefixes[0]}-447`] : []), ' a GitHub pull request or issue PR #269, or its full URL', 'A comment or a document an agent could have written is not a citation. If no such', 'artifact exists, the decision has not been made. Not an authority claim? Add', ' ALLOW-AUTHORITY-PHRASE: <reason>', '', `${claims.length} claim(s) across ${files} file(s).`);
191
+ return { ok: false, out: [], err };
192
+ };
package/dist/cli.js CHANGED
@@ -4,13 +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 { findDenied, hash, isText, parseDenyList } from './boundary.js';
8
- import { checkBudgets } from './doc-budgets.js';
9
- import { checkExportDeletions, formatReport } from './export-deletions.js';
10
- import { BASE_CANDIDATES, gitAtTop, resolveBase } 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, 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";
11
13
  export const DENY_FILE = '.veda/deny.sha256';
12
14
  const USAGE = [
13
- 'usage: pariksha boundary [--root <dir>] [--deny <file>]',
15
+ 'usage: pariksha authority-citations [--mode staged|tree] [--authority <a,b>] [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]',
16
+ ' pariksha boundary [--root <dir>] [--deny <file>]',
17
+ ' pariksha coverage-by-type [--config <file>] [--files-from <file>|-] [--root <dir>]',
14
18
  ' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
15
19
  ' pariksha export-deletions [--base <ref>] [--root <dir>]',
16
20
  ' pariksha hash <word>',
@@ -22,7 +26,7 @@ export const ownVersion = () => {
22
26
  };
23
27
  /** The files git tracks under root, text ones only. */
24
28
  export const trackedFiles = (root) => {
25
- const names = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }).split('\0').filter(Boolean);
29
+ const names = must(gitAt(root), ['ls-files', '-z']).split('\0').filter(Boolean);
26
30
  const files = [];
27
31
  for (const path of names) {
28
32
  const full = join(root, path);
@@ -128,12 +132,82 @@ const exportDeletions = (f, io) => {
128
132
  result.err.forEach(io.err);
129
133
  return result.ok ? 0 : 1;
130
134
  };
135
+ /** A comma-separated flag value as a list; empty entries dropped. */
136
+ const list = (value) => (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
137
+ const authorityCitations = (f, io, env) => {
138
+ const mode = f.get('--mode') ?? 'staged';
139
+ if (mode !== 'staged' && mode !== 'tree') {
140
+ io.err(USAGE);
141
+ return 2;
142
+ }
143
+ const dir = resolve(f.get('--root') ?? '.');
144
+ const root = topOf(dir);
145
+ if (root === undefined) {
146
+ io.err(`pariksha authority-citations: ${dir} is not in a git repository`);
147
+ return 2;
148
+ }
149
+ const git = gitAt(root);
150
+ const opts = {
151
+ authorities: list(f.get('--authority')),
152
+ issuePrefixes: list(f.get('--issue-prefix')),
153
+ adrDir: adrDirOf(f.get('--adr-dir') ?? DEFAULTS.adrDir),
154
+ };
155
+ let result;
156
+ try {
157
+ const source = mode === 'tree' ? treeSource(git, root, readOrUndefined) : stagedSource(git, env, readOrUndefined);
158
+ result = formatClaims(findUncited(source, opts, git, listAdrs(root, opts.adrDir)), opts);
159
+ }
160
+ catch (e) {
161
+ io.err(`pariksha authority-citations: ${e.message}; nothing was checked, so this is not a pass`);
162
+ return 2;
163
+ }
164
+ result.out.forEach(io.out);
165
+ result.err.forEach(io.err);
166
+ return result.ok ? 0 : 1;
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
+ };
131
203
  const COMMANDS = {
204
+ 'authority-citations': { allowed: ['--mode', '--authority', '--issue-prefix', '--adr-dir', '--root'], run: authorityCitations },
132
205
  boundary: { allowed: ['--root', '--deny'], run: boundary },
206
+ 'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
133
207
  'doc-budgets': { allowed: ['--table', '--section', '--root'], run: docBudgets },
134
208
  'export-deletions': { allowed: ['--base', '--root'], run: exportDeletions },
135
209
  };
136
- export const run = (args, io, version = ownVersion()) => {
210
+ export const run = (args, io, version = ownVersion(), env = process.env) => {
137
211
  const [command, ...rest] = args;
138
212
  if (command === '--version' && rest.length === 0) {
139
213
  io.out(version);
@@ -149,7 +223,7 @@ export const run = (args, io, version = ownVersion()) => {
149
223
  io.err(USAGE);
150
224
  return 2;
151
225
  }
152
- return spec.run(f, io);
226
+ return spec.run(f, io, env);
153
227
  };
154
228
  const isMain = () => {
155
229
  const entry = process.argv[1];
@@ -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
+ };
@@ -10,13 +10,12 @@
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 } 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'];
17
17
  const SKIPPED_DIR = new RegExp(`(^|/)(${SKIPPED.join('|')})/`);
18
18
  const SKIPPED_PATHSPECS = SKIPPED.map((d) => `:(exclude,glob)**/${d}/**`);
19
- const PLAIN_DIFF = ['--no-renames', '--no-color', '--no-ext-diff', '--no-textconv'];
20
19
  /**
21
20
  * A removed named declaration on a `-` diff line. Bare re-export lists
22
21
  * (`export { a, b }`) are left out: they shuffle in refactors and name nothing new.
package/dist/git.js CHANGED
@@ -5,12 +5,17 @@ import { execFileSync } from 'node:child_process';
5
5
  * quoted non-ASCII paths would name no file, and a column field would hide a
6
6
  * grep hit's text.
7
7
  */
8
- export const PINNED = ['-c', 'core.quotePath=off', '-c', 'grep.column=false', '-c', 'color.ui=never'];
8
+ export const PINNED = ['-c', 'core.quotePath=off', '-c', 'grep.column=false', '-c', 'color.ui=never', '-c', 'core.precomposeUnicode=false'];
9
+ /** Variables that change how git reads a pathspec, so `:(literal)` would name no file. */
10
+ const PATHSPEC_ENV = ['GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS'];
11
+ /** The environment git runs in: the caller's, less the pathspec switches. */
12
+ export const gitEnv = (env) => Object.fromEntries(Object.entries(env).filter(([k]) => !PATHSPEC_ENV.includes(k)));
9
13
  export const gitAt = (root) => ({
10
14
  run: (args) => {
11
15
  try {
12
16
  return execFileSync('git', [...PINNED, ...args], {
13
17
  cwd: root,
18
+ env: gitEnv(process.env),
14
19
  encoding: 'utf8',
15
20
  maxBuffer: 1 << 28,
16
21
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -28,9 +33,17 @@ export const gitAt = (root) => ({
28
33
  * there whichever directory it was started in.
29
34
  */
30
35
  export const gitAtTop = (dir) => {
31
- const top = gitAt(dir).run(['rev-parse', '--show-toplevel'])?.trim();
32
- return top ? gitAt(top) : undefined;
36
+ const top = topOf(dir);
37
+ return top === undefined ? undefined : gitAt(top);
33
38
  };
39
+ /** The top level of the repository holding `dir`, or undefined outside one. */
40
+ export const topOf = (dir) => gitAt(dir).run(['rev-parse', '--show-toplevel'])?.trim() || undefined;
41
+ /**
42
+ * Diff output a user's config cannot reshape: no colour, external tool,
43
+ * textconv or rename pairing, and lines even for a file git or its
44
+ * attributes class as binary, which would otherwise show no hunk at all.
45
+ */
46
+ export const PLAIN_DIFF = ['--no-renames', '--no-color', '--no-ext-diff', '--no-textconv', '--text'];
34
47
  /** A git call whose failure must stop the check rather than read as "nothing found". */
35
48
  export const must = (git, args) => {
36
49
  const out = git.run(args);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@veda-hq/pariksha",
3
- "version": "0.3.1",
4
- "description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: 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": "7.0.2"
43
+ "typescript": "5.9.3"
36
44
  },
37
45
  "scripts": {
38
46
  "build": "tsc -p tsconfig.build.json",