@veda-hq/pariksha 0.1.0 → 0.3.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 +69 -9
- package/dist/cli.js +79 -15
- package/dist/doc-budgets.js +60 -0
- package/dist/export-deletions.js +125 -0
- package/dist/git.js +43 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -3,24 +3,84 @@
|
|
|
3
3
|
परीक्षा, examination. Repository checks: doc drift, rule enforcement,
|
|
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
|
|
9
|
+
```
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
Every check exits 0 when the repository passes, 1 when it refuses something
|
|
12
|
+
(each finding on stderr, with how to fix it), and 2 on a usage or input
|
|
13
|
+
error. `--root <dir>` (default: the current directory) names the repository.
|
|
14
|
+
Node 24 or later. MIT.
|
|
15
|
+
|
|
16
|
+
## `boundary`
|
|
10
17
|
|
|
11
18
|
```sh
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
npx @veda-hq/pariksha@0.1.0 --version
|
|
19
|
+
pariksha boundary [--root <dir>] [--deny <file>]
|
|
20
|
+
pariksha hash <word>
|
|
15
21
|
```
|
|
16
22
|
|
|
23
|
+
Refuses any file git tracks, by its path or its text, that names a word on
|
|
24
|
+
the repository's deny list.
|
|
25
|
+
|
|
17
26
|
- The deny list (default `.veda/deny.sha256`, relative to the root) holds one
|
|
18
27
|
SHA-256 hash of a lowercase word per line; blank lines and `#` comments are
|
|
19
28
|
ignored. `pariksha hash <word>` prints the line for a word.
|
|
20
29
|
- Words are runs of ASCII letters and digits, compared case-insensitively:
|
|
21
30
|
`foo-bar`, `foo_bar` and `@Foo/bar` all contain the word `foo`.
|
|
22
31
|
- Binary files (any NUL byte) are skipped. Untracked files are not read.
|
|
23
|
-
|
|
24
|
-
and line; line 0 is the file's path), 2 a usage or deny-list error.
|
|
32
|
+
Line 0 in a finding is the file's path.
|
|
25
33
|
|
|
26
|
-
|
|
34
|
+
## `doc-budgets`
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Keeps the documents agents read on every turn inside a byte budget. The
|
|
41
|
+
budgets are one Markdown table, under `## Budgets` (or `--section`) in the
|
|
42
|
+
`--table` file:
|
|
43
|
+
|
|
44
|
+
```md
|
|
45
|
+
## Budgets
|
|
46
|
+
|
|
47
|
+
| Doc | Budget |
|
|
48
|
+
| ----------- | -----: |
|
|
49
|
+
| `CLAUDE.md` | 9,000 |
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Refuses a file over its budget, a budgeted file that does not exist, and a
|
|
53
|
+
row that is not `` | `path` | bytes | ``, since nothing would enforce it.
|
|
54
|
+
The table file is read on every check too, so it must budget itself: a row
|
|
55
|
+
for its own path (relative to `--root`) is required.
|
|
56
|
+
|
|
57
|
+
## `export-deletions`
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
pariksha export-deletions [--base <ref>] [--root <dir>]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
An agent that removes a public function can remove its callers and their
|
|
64
|
+
tests too: everything typechecks and passes, so only a statement of intent
|
|
65
|
+
tells a correct removal from a coherent mistake. This check compares
|
|
66
|
+
`<base>...HEAD` and refuses an exported declaration (`export const`,
|
|
67
|
+
`function`, `class`, `interface`, `type`, `enum`) that is gone from every
|
|
68
|
+
TypeScript and JavaScript file at HEAD, unless a commit body in the range
|
|
69
|
+
declares it:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
Removes-Export: <symbol> — <why it is safe to remove>
|
|
73
|
+
Removes-File: <path> — <why the file is gone>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`Removes-File` covers every export of a file that no longer exists at HEAD.
|
|
77
|
+
A symbol still used anywhere (a move, a rename with its callers, a re-export)
|
|
78
|
+
is never reported; a mention in a comment does not count as a use. Bare
|
|
79
|
+
`export { a, b }` lists and files under `node_modules`, `dist`, `build`,
|
|
80
|
+
`coverage` and `generated` are not read. Without `--base`, the base is the
|
|
81
|
+
first of `origin/main`, `origin/develop`, `origin/master` and `main` that
|
|
82
|
+
exists. It reads the whole repository from any directory inside it, and
|
|
83
|
+
renames, colour, external diff tools and textconv in a user's git config
|
|
84
|
+
do not change what it reads. With no base, or when git cannot read the
|
|
85
|
+
range (no merge base, a shallow clone), it exits 2: nothing was checked,
|
|
86
|
+
so it is not a pass.
|
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// pariksha: repository checks.
|
|
2
|
+
// pariksha: repository checks. One subcommand per check.
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
|
-
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
5
|
-
import { join, resolve } from 'node:path';
|
|
4
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
5
|
+
import { join, relative, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
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';
|
|
8
11
|
export const DENY_FILE = '.veda/deny.sha256';
|
|
9
12
|
const USAGE = [
|
|
10
13
|
'usage: pariksha boundary [--root <dir>] [--deny <file>]',
|
|
14
|
+
' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
|
|
15
|
+
' pariksha export-deletions [--base <ref>] [--root <dir>]',
|
|
11
16
|
' pariksha hash <word>',
|
|
12
17
|
' pariksha --version',
|
|
13
18
|
].join('\n');
|
|
@@ -29,23 +34,19 @@ export const trackedFiles = (root) => {
|
|
|
29
34
|
}
|
|
30
35
|
return files;
|
|
31
36
|
};
|
|
32
|
-
|
|
37
|
+
/** `--name value` pairs, each name allowed once and from `allowed`; undefined on anything else. */
|
|
38
|
+
export const flags = (rest, allowed) => {
|
|
33
39
|
const out = new Map();
|
|
34
40
|
for (let i = 0; i < rest.length; i += 2) {
|
|
35
41
|
const name = rest[i];
|
|
36
42
|
const value = rest[i + 1];
|
|
37
|
-
if (
|
|
43
|
+
if (name === undefined || !allowed.includes(name) || !value || out.has(name))
|
|
38
44
|
return undefined;
|
|
39
45
|
out.set(name, value);
|
|
40
46
|
}
|
|
41
47
|
return out;
|
|
42
48
|
};
|
|
43
|
-
const boundary = (
|
|
44
|
-
const f = flags(rest);
|
|
45
|
-
if (!f) {
|
|
46
|
-
io.err(USAGE);
|
|
47
|
-
return 2;
|
|
48
|
-
}
|
|
49
|
+
const boundary = (f, io) => {
|
|
49
50
|
const root = resolve(f.get('--root') ?? '.');
|
|
50
51
|
const denyPath = resolve(root, f.get('--deny') ?? DENY_FILE);
|
|
51
52
|
if (!existsSync(denyPath)) {
|
|
@@ -72,6 +73,66 @@ const boundary = (rest, io) => {
|
|
|
72
73
|
io.out(`pariksha boundary: ${files.length} files, none names a denied word (${deny.size} on the list)`);
|
|
73
74
|
return 0;
|
|
74
75
|
};
|
|
76
|
+
const fileSize = (path) => {
|
|
77
|
+
try {
|
|
78
|
+
const s = statSync(path);
|
|
79
|
+
return s.isFile() ? s.size : undefined;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const docBudgets = (f, io) => {
|
|
86
|
+
const table = f.get('--table');
|
|
87
|
+
if (table === undefined) {
|
|
88
|
+
io.err(USAGE);
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
const root = resolve(f.get('--root') ?? '.');
|
|
92
|
+
const tablePath = resolve(root, table);
|
|
93
|
+
if (fileSize(tablePath) === undefined) {
|
|
94
|
+
io.err(`pariksha doc-budgets: no budget table at ${tablePath}`);
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
const outcome = checkBudgets(readFileSync(tablePath, 'utf8'), f.get('--section') ?? 'Budgets', relative(root, tablePath), (p) => fileSize(resolve(root, p)));
|
|
98
|
+
outcome.out.forEach(io.out);
|
|
99
|
+
outcome.err.forEach(io.err);
|
|
100
|
+
return outcome.ok ? 0 : 1;
|
|
101
|
+
};
|
|
102
|
+
const exportDeletions = (f, io) => {
|
|
103
|
+
const dir = resolve(f.get('--root') ?? '.');
|
|
104
|
+
const git = gitAtTop(dir);
|
|
105
|
+
if (git === undefined) {
|
|
106
|
+
io.err(`pariksha export-deletions: ${dir} is not in a git repository`);
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
109
|
+
const asked = f.get('--base');
|
|
110
|
+
const base = asked === undefined ? resolveBase(git) : resolveBase(git, [asked]);
|
|
111
|
+
if (base === undefined) {
|
|
112
|
+
// A shallow single-branch checkout lands here; passing it would pass unread work.
|
|
113
|
+
io.err(asked === undefined
|
|
114
|
+
? `pariksha export-deletions: none of ${BASE_CANDIDATES.join(', ')} names a commit; fetch one or pass --base`
|
|
115
|
+
: `pariksha export-deletions: --base ${asked} names no commit`);
|
|
116
|
+
return 2;
|
|
117
|
+
}
|
|
118
|
+
let report;
|
|
119
|
+
try {
|
|
120
|
+
report = checkExportDeletions(git, base);
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
io.err(`pariksha export-deletions: ${e.message}; nothing was checked, so this is not a pass`);
|
|
124
|
+
return 2;
|
|
125
|
+
}
|
|
126
|
+
const result = formatReport(report);
|
|
127
|
+
result.out.forEach(io.out);
|
|
128
|
+
result.err.forEach(io.err);
|
|
129
|
+
return result.ok ? 0 : 1;
|
|
130
|
+
};
|
|
131
|
+
const COMMANDS = {
|
|
132
|
+
boundary: { allowed: ['--root', '--deny'], run: boundary },
|
|
133
|
+
'doc-budgets': { allowed: ['--table', '--section', '--root'], run: docBudgets },
|
|
134
|
+
'export-deletions': { allowed: ['--base', '--root'], run: exportDeletions },
|
|
135
|
+
};
|
|
75
136
|
export const run = (args, io, version = ownVersion()) => {
|
|
76
137
|
const [command, ...rest] = args;
|
|
77
138
|
if (command === '--version' && rest.length === 0) {
|
|
@@ -82,10 +143,13 @@ export const run = (args, io, version = ownVersion()) => {
|
|
|
82
143
|
io.out(hash(rest[0]));
|
|
83
144
|
return 0;
|
|
84
145
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
146
|
+
const spec = command !== undefined && Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : undefined;
|
|
147
|
+
const f = spec && flags(rest, spec.allowed);
|
|
148
|
+
if (!spec || !f) {
|
|
149
|
+
io.err(USAGE);
|
|
150
|
+
return 2;
|
|
151
|
+
}
|
|
152
|
+
return spec.run(f, io);
|
|
89
153
|
};
|
|
90
154
|
const isMain = () => {
|
|
91
155
|
const entry = process.argv[1];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The doc-budgets check: documents that agents read on every turn stay inside
|
|
2
|
+
// a byte budget. The budgets live in one Markdown table, so the rule that sets
|
|
3
|
+
// them is also the only list of them:
|
|
4
|
+
//
|
|
5
|
+
// ## Budgets
|
|
6
|
+
//
|
|
7
|
+
// | Doc | Budget |
|
|
8
|
+
// | ----------- | -----: |
|
|
9
|
+
// | `CLAUDE.md` | 9,000 |
|
|
10
|
+
/** The text of the `## <section>` section, up to the next `## ` heading; '' when absent. */
|
|
11
|
+
export const section = (markdown, name) => {
|
|
12
|
+
// Line endings normalised and a leading newline added, so a CRLF file and a
|
|
13
|
+
// heading on the first line read like any other.
|
|
14
|
+
const text = `\n${markdown.replaceAll('\r\n', '\n')}`;
|
|
15
|
+
const start = text.indexOf(`\n## ${name}\n`);
|
|
16
|
+
if (start === -1)
|
|
17
|
+
return '';
|
|
18
|
+
const end = text.indexOf('\n## ', start + 1);
|
|
19
|
+
return text.slice(start, end === -1 ? undefined : end);
|
|
20
|
+
};
|
|
21
|
+
/** Table lines after the header row and its `| --- |` (or `|:--|`) separator. */
|
|
22
|
+
export const dataRows = (text) => text
|
|
23
|
+
.split('\n')
|
|
24
|
+
.filter((line) => line.startsWith('|'))
|
|
25
|
+
.slice(1)
|
|
26
|
+
.filter((line) => !/^\|\s*:?-/.test(line));
|
|
27
|
+
/** Rows of the form "| `path` | 1,200 |". */
|
|
28
|
+
export const parseBudgets = (text) => [...text.matchAll(/^\|\s*`([^`]+)`\s*\|\s*([\d,]+)\s*\|$/gm)].map(([, path = '', bytes = '']) => ({
|
|
29
|
+
path,
|
|
30
|
+
bytes: Number(bytes.replaceAll(',', '')),
|
|
31
|
+
}));
|
|
32
|
+
/**
|
|
33
|
+
* Every budget row is well formed, and every file it names is within budget.
|
|
34
|
+
* `size` returns a file's byte length, or undefined when it does not exist.
|
|
35
|
+
*/
|
|
36
|
+
export const checkBudgets = (table, sectionName, tablePath, size) => {
|
|
37
|
+
const text = section(table, sectionName);
|
|
38
|
+
const budgets = parseBudgets(text);
|
|
39
|
+
const rows = dataRows(text);
|
|
40
|
+
const err = [];
|
|
41
|
+
if (rows.length === 0)
|
|
42
|
+
err.push(`pariksha doc-budgets: ${tablePath} has no table under "## ${sectionName}"`);
|
|
43
|
+
else if (budgets.length !== rows.length)
|
|
44
|
+
err.push(`pariksha doc-budgets: ${tablePath}: ${rows.length - budgets.length} row(s) under "## ${sectionName}" are not "| \`path\` | bytes |", so nothing enforces them`);
|
|
45
|
+
// The table is read on every check, so it is a hot document too; a table
|
|
46
|
+
// without its own row could grow without limit while every row it lists passes.
|
|
47
|
+
if (rows.length > 0 && !budgets.some((b) => b.path === tablePath))
|
|
48
|
+
err.push(`pariksha doc-budgets: ${tablePath} sets budgets but has no row for itself; add "| \`${tablePath}\` | <bytes> |"`);
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const b of budgets) {
|
|
51
|
+
const bytes = size(b.path);
|
|
52
|
+
if (bytes === undefined)
|
|
53
|
+
err.push(`pariksha doc-budgets: ${b.path} has a budget but does not exist`);
|
|
54
|
+
else if (bytes > b.bytes)
|
|
55
|
+
err.push(`pariksha doc-budgets: ${b.path} is ${bytes} bytes, over its budget of ${b.bytes}; cut it`);
|
|
56
|
+
else
|
|
57
|
+
out.push(`pariksha doc-budgets: ${b.path} ${bytes} / ${b.bytes} bytes`);
|
|
58
|
+
}
|
|
59
|
+
return { ok: err.length === 0, out, err };
|
|
60
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The export-deletions check: a public export that disappears from the whole
|
|
2
|
+
// tree on a branch needs a stated reason, a commit trailer naming it.
|
|
3
|
+
//
|
|
4
|
+
// An agent that removes a public function can also remove its callers and the
|
|
5
|
+
// tests that used it: everything typechecks and passes, so nothing internal
|
|
6
|
+
// tells a correct removal from a coherent mistake. Only a statement of intent
|
|
7
|
+
// can, so the check asks for one, and stays quiet for anything that moved:
|
|
8
|
+
// a symbol still named anywhere in the tree's source is not reported.
|
|
9
|
+
//
|
|
10
|
+
// Removes-Export: <symbol> — <why> covers one symbol
|
|
11
|
+
// Removes-File: <path> — <why> covers every export of a file that
|
|
12
|
+
// no longer exists at HEAD
|
|
13
|
+
import { must } from './git.js';
|
|
14
|
+
export const SOURCE_GLOBS = ['*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs', '*.mts', '*.cts'];
|
|
15
|
+
/** Build output and vendored code never count as a source change, or as a use. */
|
|
16
|
+
const SKIPPED = ['node_modules', 'dist', 'build', 'coverage', 'generated'];
|
|
17
|
+
const SKIPPED_DIR = new RegExp(`(^|/)(${SKIPPED.join('|')})/`);
|
|
18
|
+
const SKIPPED_PATHSPECS = SKIPPED.map((d) => `:(exclude,glob)**/${d}/**`);
|
|
19
|
+
const PLAIN_DIFF = ['--no-renames', '--no-color', '--no-ext-diff', '--no-textconv'];
|
|
20
|
+
/**
|
|
21
|
+
* A removed named declaration on a `-` diff line. Bare re-export lists
|
|
22
|
+
* (`export { a, b }`) are left out: they shuffle in refactors and name nothing new.
|
|
23
|
+
*/
|
|
24
|
+
const EXPORT_DECL = /^-\s*export\s+(?:default\s+)?(?:declare\s+)?(?:async\s+)?(?:abstract\s+)?(?:const\s+enum|const|let|var|function|class|interface|type|enum|namespace)[\s*]+([A-Za-z_$][A-Za-z0-9_$]*)/;
|
|
25
|
+
/**
|
|
26
|
+
* A grep hit (`HEAD:path:line:text`) whose text starts a comment (`//`, `*`,
|
|
27
|
+
* `/*`) is a mention, not a use. Unanchored, so a colon in a path still matches.
|
|
28
|
+
*/
|
|
29
|
+
const COMMENT_HIT = /:\d+:\s*(?:\/\/|\*|\/\*)/;
|
|
30
|
+
const unique = (xs) => [...new Set(xs)].sort();
|
|
31
|
+
/** Symbols named by `Removes-Export:` trailers: the first word, trailing punctuation dropped. */
|
|
32
|
+
export const declaredExports = (messages) => unique(messages
|
|
33
|
+
.split('\n')
|
|
34
|
+
.map((line) => /^\s*removes-export:\s*(\S+)/i.exec(line)?.[1]?.replace(/[,;()]+$/, ''))
|
|
35
|
+
.filter((s) => s !== undefined && s !== ''));
|
|
36
|
+
/**
|
|
37
|
+
* Paths named by `Removes-File:` trailers. The path runs up to the " — " or
|
|
38
|
+
* " - " that starts the reason, so a path with a space in it survives.
|
|
39
|
+
*/
|
|
40
|
+
export const declaredFiles = (messages) => unique(messages
|
|
41
|
+
.split('\n')
|
|
42
|
+
.map((line) => /^\s*removes-file:\s*(.*)$/i
|
|
43
|
+
.exec(line)?.[1]
|
|
44
|
+
?.replace(/\s+(?:—|-)\s.*$/, '')
|
|
45
|
+
.trim()
|
|
46
|
+
.replace(/[,;()]+$/, ''))
|
|
47
|
+
.filter((s) => s !== undefined && s !== ''));
|
|
48
|
+
/** The symbols a unified diff removes from export declarations. */
|
|
49
|
+
export const removedExports = (diff) => diff
|
|
50
|
+
.split('\n')
|
|
51
|
+
.map((line) => EXPORT_DECL.exec(line)?.[1])
|
|
52
|
+
.filter((s) => s !== undefined);
|
|
53
|
+
/** True when some `git grep -n <sym> HEAD` hit is code rather than a comment. */
|
|
54
|
+
export const usedOutsideComments = (hits) => hits.split('\n').some((hit) => hit !== '' && !COMMENT_HIT.test(hit));
|
|
55
|
+
/**
|
|
56
|
+
* Compares `base...HEAD`: every export removed and named nowhere in HEAD's
|
|
57
|
+
* source must be declared. A git failure throws (with no merge base, say, in a
|
|
58
|
+
* shallow clone): the check cannot pass without having read the branch.
|
|
59
|
+
*/
|
|
60
|
+
export const checkExportDeletions = (git, base) => {
|
|
61
|
+
const messages = must(git, ['log', '--format=%B', `${base}..HEAD`]);
|
|
62
|
+
const symbols = new Set(declaredExports(messages));
|
|
63
|
+
const files = new Set(declaredFiles(messages));
|
|
64
|
+
// Renames off, so a file moved and edited lists its old path, whose removed
|
|
65
|
+
// lines the per-file diff then shows; colour, external diff tools and
|
|
66
|
+
// textconv off, so a user's config cannot rewrite the lines read here.
|
|
67
|
+
// NUL-separated, so no name comes back quoted.
|
|
68
|
+
const changed = must(git, ['diff', ...PLAIN_DIFF, '-z', '--name-only', '--diff-filter=ACMRD', `${base}...HEAD`, '--', ...SOURCE_GLOBS])
|
|
69
|
+
.split('\0')
|
|
70
|
+
.filter((f) => f !== '' && !SKIPPED_DIR.test(f));
|
|
71
|
+
const violations = [];
|
|
72
|
+
const declared = [];
|
|
73
|
+
const coveredFiles = [];
|
|
74
|
+
const used = new Map();
|
|
75
|
+
for (const file of changed) {
|
|
76
|
+
// `:(literal)` so a name like `src/[id].ts` names only itself.
|
|
77
|
+
const spec = `:(literal)${file}`;
|
|
78
|
+
const removed = removedExports(must(git, ['diff', ...PLAIN_DIFF, '-U0', `${base}...HEAD`, '--', spec]));
|
|
79
|
+
if (removed.length === 0)
|
|
80
|
+
continue;
|
|
81
|
+
// A file trailer covers a file only once HEAD has nothing at its path.
|
|
82
|
+
if (files.has(file) && must(git, ['ls-tree', '--name-only', 'HEAD', '--', spec]).trim() === '') {
|
|
83
|
+
coveredFiles.push(file);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
for (const symbol of removed) {
|
|
87
|
+
let stillUsed = used.get(symbol);
|
|
88
|
+
if (stillUsed === undefined) {
|
|
89
|
+
// git grep exits 1 when nothing matches, so undefined here is "no hit".
|
|
90
|
+
// Skipped trees are excluded here too: a stale committed build naming the symbol is no use.
|
|
91
|
+
stillUsed = usedOutsideComments(git.run(['grep', '--no-color', '-nwF', '-e', symbol, 'HEAD', '--', ...SOURCE_GLOBS, ...SKIPPED_PATHSPECS]) ?? '');
|
|
92
|
+
used.set(symbol, stillUsed);
|
|
93
|
+
}
|
|
94
|
+
if (stillUsed)
|
|
95
|
+
continue;
|
|
96
|
+
(symbols.has(symbol) ? declared : violations).push({ symbol, file });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { base, violations, declared, declaredFiles: coveredFiles };
|
|
100
|
+
};
|
|
101
|
+
/** The report as output lines; a refusal tells the author how to declare a removal. */
|
|
102
|
+
export const formatReport = (report) => {
|
|
103
|
+
const out = [
|
|
104
|
+
...report.declaredFiles.map((f) => `pariksha export-deletions: ${f} deleted; a Removes-File trailer declares its exports`),
|
|
105
|
+
...report.declared.map((d) => `pariksha export-deletions: ${d.symbol} removed from ${d.file}; a Removes-Export trailer declares it`),
|
|
106
|
+
];
|
|
107
|
+
if (report.violations.length === 0) {
|
|
108
|
+
out.push(`pariksha export-deletions: no undeclared export removals since ${report.base}`);
|
|
109
|
+
return { ok: true, out, err: [] };
|
|
110
|
+
}
|
|
111
|
+
const err = [
|
|
112
|
+
`pariksha export-deletions: ${report.violations.length} export(s) removed from the whole tree since ${report.base} with no stated reason:`,
|
|
113
|
+
...report.violations.map((v) => ` ${v.symbol} (removed from ${v.file})`),
|
|
114
|
+
'',
|
|
115
|
+
'Each is gone from every source file, not moved or renamed. If the removal is intended,',
|
|
116
|
+
'say so in a commit body:',
|
|
117
|
+
'',
|
|
118
|
+
' Removes-Export: <symbol> — <why it is safe to remove>',
|
|
119
|
+
'',
|
|
120
|
+
'A file deleted outright can be declared once, covering all its exports:',
|
|
121
|
+
'',
|
|
122
|
+
' Removes-File: <path> — <why the file is gone>',
|
|
123
|
+
];
|
|
124
|
+
return { ok: false, out, err };
|
|
125
|
+
};
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Git, as the checks read it: a function from arguments to stdout.
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
/**
|
|
4
|
+
* Settings that change what git prints, pinned so a user's config cannot:
|
|
5
|
+
* quoted non-ASCII paths would name no file, and a column field would hide a
|
|
6
|
+
* grep hit's text.
|
|
7
|
+
*/
|
|
8
|
+
export const PINNED = ['-c', 'core.quotePath=off', '-c', 'grep.column=false', '-c', 'color.ui=never'];
|
|
9
|
+
export const gitAt = (root) => ({
|
|
10
|
+
run: (args) => {
|
|
11
|
+
try {
|
|
12
|
+
return execFileSync('git', [...PINNED, ...args], {
|
|
13
|
+
cwd: root,
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
maxBuffer: 1 << 28,
|
|
16
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
17
|
+
timeout: 120_000,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Git at the top level of the repository holding `dir`, or undefined outside
|
|
27
|
+
* one. Paths git prints are relative to the top level, so every check runs
|
|
28
|
+
* there whichever directory it was started in.
|
|
29
|
+
*/
|
|
30
|
+
export const gitAtTop = (dir) => {
|
|
31
|
+
const top = gitAt(dir).run(['rev-parse', '--show-toplevel'])?.trim();
|
|
32
|
+
return top ? gitAt(top) : undefined;
|
|
33
|
+
};
|
|
34
|
+
/** A git call whose failure must stop the check rather than read as "nothing found". */
|
|
35
|
+
export const must = (git, args) => {
|
|
36
|
+
const out = git.run(args);
|
|
37
|
+
if (out === undefined)
|
|
38
|
+
throw new Error(`git ${args.join(' ')} failed`);
|
|
39
|
+
return out;
|
|
40
|
+
};
|
|
41
|
+
export const BASE_CANDIDATES = ['origin/main', 'origin/develop', 'origin/master', 'main'];
|
|
42
|
+
/** The first ref that names a commit, or undefined. */
|
|
43
|
+
export const resolveBase = (git, candidates = BASE_CANDIDATES) => candidates.find((ref) => git.run(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]) !== undefined);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veda-hq/pariksha",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Repository checks: doc drift, rule enforcement, test-quality ratchets.
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: boundary, doc-budgets, export-deletions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"repository checks",
|
|
7
7
|
"lint",
|