@veda-hq/pariksha 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Veda Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,86 @@
1
- # Temporary Holding Version
1
+ # @veda-hq/pariksha
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ परीक्षा, examination. Repository checks: doc drift, rule enforcement,
4
+ test-quality ratchets. Part of [Veda](https://github.com/veda-hq/veda).
5
+
6
+ ```sh
7
+ npx @veda-hq/pariksha@0.3.1 <check> [flags]
8
+ npx @veda-hq/pariksha@0.3.1 --version
9
+ ```
10
+
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`
17
+
18
+ ```sh
19
+ pariksha boundary [--root <dir>] [--deny <file>]
20
+ pariksha hash <word>
21
+ ```
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
+
26
+ - The deny list (default `.veda/deny.sha256`, relative to the root) holds one
27
+ SHA-256 hash of a lowercase word per line; blank lines and `#` comments are
28
+ ignored. `pariksha hash <word>` prints the line for a word.
29
+ - Words are runs of ASCII letters and digits, compared case-insensitively:
30
+ `foo-bar`, `foo_bar` and `@Foo/bar` all contain the word `foo`.
31
+ - Binary files (any NUL byte) are skipped. Untracked files are not read.
32
+ Line 0 in a finding is the file's path.
33
+
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.
@@ -0,0 +1,44 @@
1
+ // The boundary check: no tracked file names a denied name. The deny list holds
2
+ // SHA-256 hashes of lowercase words, so a repository can refuse a name without
3
+ // writing it down.
4
+ import { createHash } from 'node:crypto';
5
+ export const hash = (word) => createHash('sha256').update(word.toLowerCase()).digest('hex');
6
+ /** The deny list's hashes: one per line; blank lines and # comments ignored. */
7
+ export const parseDenyList = (text) => {
8
+ const hashes = new Set();
9
+ for (const raw of text.split('\n')) {
10
+ const line = raw.replace(/#.*/, '').trim().toLowerCase();
11
+ if (line === '')
12
+ continue;
13
+ if (!/^[0-9a-f]{64}$/.test(line))
14
+ throw new Error(`not a SHA-256 hash: "${line}"`);
15
+ hashes.add(line);
16
+ }
17
+ return hashes;
18
+ };
19
+ /** A file is text unless it holds a NUL byte. */
20
+ export const isText = (bytes) => !bytes.includes(0);
21
+ /** Every word of every file whose hash is denied, by file and line. */
22
+ export const findDenied = (files, deny) => {
23
+ const hits = [];
24
+ const seen = new Map();
25
+ const denied = (word) => {
26
+ let d = seen.get(word);
27
+ if (d === undefined) {
28
+ d = deny.has(hash(word));
29
+ seen.set(word, d);
30
+ }
31
+ return d;
32
+ };
33
+ for (const file of files) {
34
+ // A path names things too: a file called after a denied name is refused.
35
+ const lines = [file.path, ...file.text.split('\n')];
36
+ lines.forEach((text, i) => {
37
+ for (const word of text.toLowerCase().match(/[a-z0-9]+/g) ?? []) {
38
+ if (denied(word))
39
+ hits.push({ file: file.path, line: i, word });
40
+ }
41
+ });
42
+ }
43
+ return hits;
44
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ // pariksha: repository checks. One subcommand per check.
3
+ import { execFileSync } from 'node:child_process';
4
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
5
+ import { join, relative, resolve } from 'node:path';
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';
11
+ export const DENY_FILE = '.veda/deny.sha256';
12
+ const USAGE = [
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>]',
16
+ ' pariksha hash <word>',
17
+ ' pariksha --version',
18
+ ].join('\n');
19
+ export const ownVersion = () => {
20
+ const pkg = join(import.meta.dirname, '..', 'package.json');
21
+ return JSON.parse(readFileSync(pkg, 'utf8')).version;
22
+ };
23
+ /** The files git tracks under root, text ones only. */
24
+ export const trackedFiles = (root) => {
25
+ const names = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }).split('\0').filter(Boolean);
26
+ const files = [];
27
+ for (const path of names) {
28
+ const full = join(root, path);
29
+ if (!existsSync(full))
30
+ continue; // deleted in the working tree, not yet committed
31
+ const bytes = readFileSync(full);
32
+ if (isText(bytes))
33
+ files.push({ path, text: bytes.toString('utf8') });
34
+ }
35
+ return files;
36
+ };
37
+ /** `--name value` pairs, each name allowed once and from `allowed`; undefined on anything else. */
38
+ export const flags = (rest, allowed) => {
39
+ const out = new Map();
40
+ for (let i = 0; i < rest.length; i += 2) {
41
+ const name = rest[i];
42
+ const value = rest[i + 1];
43
+ if (name === undefined || !allowed.includes(name) || !value || out.has(name))
44
+ return undefined;
45
+ out.set(name, value);
46
+ }
47
+ return out;
48
+ };
49
+ const boundary = (f, io) => {
50
+ const root = resolve(f.get('--root') ?? '.');
51
+ const denyPath = resolve(root, f.get('--deny') ?? DENY_FILE);
52
+ if (!existsSync(denyPath)) {
53
+ io.err(`pariksha boundary: no deny list at ${denyPath}`);
54
+ return 2;
55
+ }
56
+ let deny;
57
+ try {
58
+ deny = parseDenyList(readFileSync(denyPath, 'utf8'));
59
+ }
60
+ catch (e) {
61
+ io.err(`pariksha boundary: ${denyPath}: ${e.message}`);
62
+ return 2;
63
+ }
64
+ const files = trackedFiles(root);
65
+ const hits = findDenied(files, deny);
66
+ if (hits.length > 0) {
67
+ for (const h of hits) {
68
+ const at = h.line === 0 ? `${h.file} (its path)` : `${h.file}:${h.line}`;
69
+ io.err(`pariksha boundary: ${at}: "${h.word}" is on the deny list`);
70
+ }
71
+ return 1;
72
+ }
73
+ io.out(`pariksha boundary: ${files.length} files, none names a denied word (${deny.size} on the list)`);
74
+ return 0;
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
+ };
136
+ export const run = (args, io, version = ownVersion()) => {
137
+ const [command, ...rest] = args;
138
+ if (command === '--version' && rest.length === 0) {
139
+ io.out(version);
140
+ return 0;
141
+ }
142
+ if (command === 'hash' && rest.length === 1 && rest[0]) {
143
+ io.out(hash(rest[0]));
144
+ return 0;
145
+ }
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);
153
+ };
154
+ const isMain = () => {
155
+ const entry = process.argv[1];
156
+ return entry !== undefined && realpathSync(entry) === fileURLToPath(import.meta.url);
157
+ };
158
+ if (isMain()) {
159
+ process.exitCode = run(process.argv.slice(2), {
160
+ out: (line) => console.info(line),
161
+ err: (line) => console.error(line),
162
+ });
163
+ }
@@ -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,6 +1,44 @@
1
1
  {
2
2
  "name": "@veda-hq/pariksha",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.1",
4
+ "description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: boundary, doc-budgets, export-deletions.",
5
+ "keywords": [
6
+ "repository checks",
7
+ "lint",
8
+ "boundary",
9
+ "ci",
10
+ "coding agents"
11
+ ],
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "engines": {
15
+ "node": ">=24.15"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/veda-hq/veda.git",
20
+ "directory": "packages/pariksha"
21
+ },
22
+ "homepage": "https://github.com/veda-hq/veda/tree/main/packages/pariksha#readme",
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "bin": {
28
+ "pariksha": "dist/cli.js"
29
+ },
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "devDependencies": {
34
+ "@types/node": "24.19.2",
35
+ "typescript": "7.0.2"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.build.json",
39
+ "typecheck": "tsc -p tsconfig.json",
40
+ "test": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-functions=95 --test-coverage-branches=90 --test-coverage-exclude=src/**/*.test.ts src/**/*.test.ts",
41
+ "smoke": "node dist/cli.js --version && node dist/cli.js boundary --root ../..",
42
+ "check": "pnpm typecheck && pnpm test && pnpm build && pnpm smoke"
43
+ }
6
44
  }