@veda-hq/pariksha 0.1.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -9
- package/dist/authority-citations.js +192 -0
- package/dist/cli.js +117 -17
- package/dist/doc-budgets.js +60 -0
- package/dist/export-deletions.js +124 -0
- package/dist/git.js +56 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -3,24 +3,115 @@
|
|
|
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.2 <check> [flags]
|
|
8
|
+
npx @veda-hq/pariksha@0.3.2 --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
|
+
## `authority-citations`
|
|
10
17
|
|
|
11
18
|
```sh
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
npx @veda-hq/pariksha@0.1.0 --version
|
|
19
|
+
pariksha authority-citations [--mode staged|tree] [--authority <a,b>]
|
|
20
|
+
[--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]
|
|
15
21
|
```
|
|
16
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
|
+
|
|
47
|
+
## `boundary`
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
pariksha boundary [--root <dir>] [--deny <file>]
|
|
51
|
+
pariksha hash <word>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Refuses any file git tracks, by its path or its text, that names a word on
|
|
55
|
+
the repository's deny list.
|
|
56
|
+
|
|
17
57
|
- The deny list (default `.veda/deny.sha256`, relative to the root) holds one
|
|
18
58
|
SHA-256 hash of a lowercase word per line; blank lines and `#` comments are
|
|
19
59
|
ignored. `pariksha hash <word>` prints the line for a word.
|
|
20
60
|
- Words are runs of ASCII letters and digits, compared case-insensitively:
|
|
21
61
|
`foo-bar`, `foo_bar` and `@Foo/bar` all contain the word `foo`.
|
|
22
62
|
- 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.
|
|
63
|
+
Line 0 in a finding is the file's path.
|
|
25
64
|
|
|
26
|
-
|
|
65
|
+
## `doc-budgets`
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Keeps the documents agents read on every turn inside a byte budget. The
|
|
72
|
+
budgets are one Markdown table, under `## Budgets` (or `--section`) in the
|
|
73
|
+
`--table` file:
|
|
74
|
+
|
|
75
|
+
```md
|
|
76
|
+
## Budgets
|
|
77
|
+
|
|
78
|
+
| Doc | Budget |
|
|
79
|
+
| ----------- | -----: |
|
|
80
|
+
| `CLAUDE.md` | 9,000 |
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Refuses a file over its budget, a budgeted file that does not exist, and a
|
|
84
|
+
row that is not `` | `path` | bytes | ``, since nothing would enforce it.
|
|
85
|
+
The table file is read on every check too, so it must budget itself: a row
|
|
86
|
+
for its own path (relative to `--root`) is required.
|
|
87
|
+
|
|
88
|
+
## `export-deletions`
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
pariksha export-deletions [--base <ref>] [--root <dir>]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
An agent that removes a public function can remove its callers and their
|
|
95
|
+
tests too: everything typechecks and passes, so only a statement of intent
|
|
96
|
+
tells a correct removal from a coherent mistake. This check compares
|
|
97
|
+
`<base>...HEAD` and refuses an exported declaration (`export const`,
|
|
98
|
+
`function`, `class`, `interface`, `type`, `enum`) that is gone from every
|
|
99
|
+
TypeScript and JavaScript file at HEAD, unless a commit body in the range
|
|
100
|
+
declares it:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
Removes-Export: <symbol> — <why it is safe to remove>
|
|
104
|
+
Removes-File: <path> — <why the file is gone>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`Removes-File` covers every export of a file that no longer exists at HEAD.
|
|
108
|
+
A symbol still used anywhere (a move, a rename with its callers, a re-export)
|
|
109
|
+
is never reported; a mention in a comment does not count as a use. Bare
|
|
110
|
+
`export { a, b }` lists and files under `node_modules`, `dist`, `build`,
|
|
111
|
+
`coverage` and `generated` are not read. Without `--base`, the base is the
|
|
112
|
+
first of `origin/main`, `origin/develop`, `origin/master` and `main` that
|
|
113
|
+
exists. It reads the whole repository from any directory inside it, and
|
|
114
|
+
renames, colour, external diff tools and textconv in a user's git config
|
|
115
|
+
do not change what it reads. With no base, or when git cannot read the
|
|
116
|
+
range (no merge base, a shallow clone), it exits 2: nothing was checked,
|
|
117
|
+
so it is not a pass.
|
|
@@ -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
|
@@ -1,13 +1,20 @@
|
|
|
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
|
+
import { adrDirOf, DEFAULTS, findUncited, formatClaims, listAdrs, readOrUndefined, stagedSource, treeSource } from './authority-citations.js';
|
|
7
8
|
import { findDenied, hash, isText, parseDenyList } from './boundary.js';
|
|
9
|
+
import { checkBudgets } from './doc-budgets.js';
|
|
10
|
+
import { checkExportDeletions, formatReport } from './export-deletions.js';
|
|
11
|
+
import { BASE_CANDIDATES, gitAt, gitAtTop, resolveBase, topOf } from './git.js';
|
|
8
12
|
export const DENY_FILE = '.veda/deny.sha256';
|
|
9
13
|
const USAGE = [
|
|
10
|
-
'usage: pariksha
|
|
14
|
+
'usage: pariksha authority-citations [--mode staged|tree] [--authority <a,b>] [--issue-prefix <A,B>] [--adr-dir <dir>] [--root <dir>]',
|
|
15
|
+
' pariksha boundary [--root <dir>] [--deny <file>]',
|
|
16
|
+
' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
|
|
17
|
+
' pariksha export-deletions [--base <ref>] [--root <dir>]',
|
|
11
18
|
' pariksha hash <word>',
|
|
12
19
|
' pariksha --version',
|
|
13
20
|
].join('\n');
|
|
@@ -29,23 +36,19 @@ export const trackedFiles = (root) => {
|
|
|
29
36
|
}
|
|
30
37
|
return files;
|
|
31
38
|
};
|
|
32
|
-
|
|
39
|
+
/** `--name value` pairs, each name allowed once and from `allowed`; undefined on anything else. */
|
|
40
|
+
export const flags = (rest, allowed) => {
|
|
33
41
|
const out = new Map();
|
|
34
42
|
for (let i = 0; i < rest.length; i += 2) {
|
|
35
43
|
const name = rest[i];
|
|
36
44
|
const value = rest[i + 1];
|
|
37
|
-
if (
|
|
45
|
+
if (name === undefined || !allowed.includes(name) || !value || out.has(name))
|
|
38
46
|
return undefined;
|
|
39
47
|
out.set(name, value);
|
|
40
48
|
}
|
|
41
49
|
return out;
|
|
42
50
|
};
|
|
43
|
-
const boundary = (
|
|
44
|
-
const f = flags(rest);
|
|
45
|
-
if (!f) {
|
|
46
|
-
io.err(USAGE);
|
|
47
|
-
return 2;
|
|
48
|
-
}
|
|
51
|
+
const boundary = (f, io) => {
|
|
49
52
|
const root = resolve(f.get('--root') ?? '.');
|
|
50
53
|
const denyPath = resolve(root, f.get('--deny') ?? DENY_FILE);
|
|
51
54
|
if (!existsSync(denyPath)) {
|
|
@@ -72,7 +75,101 @@ const boundary = (rest, io) => {
|
|
|
72
75
|
io.out(`pariksha boundary: ${files.length} files, none names a denied word (${deny.size} on the list)`);
|
|
73
76
|
return 0;
|
|
74
77
|
};
|
|
75
|
-
|
|
78
|
+
const fileSize = (path) => {
|
|
79
|
+
try {
|
|
80
|
+
const s = statSync(path);
|
|
81
|
+
return s.isFile() ? s.size : undefined;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const docBudgets = (f, io) => {
|
|
88
|
+
const table = f.get('--table');
|
|
89
|
+
if (table === undefined) {
|
|
90
|
+
io.err(USAGE);
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
const root = resolve(f.get('--root') ?? '.');
|
|
94
|
+
const tablePath = resolve(root, table);
|
|
95
|
+
if (fileSize(tablePath) === undefined) {
|
|
96
|
+
io.err(`pariksha doc-budgets: no budget table at ${tablePath}`);
|
|
97
|
+
return 2;
|
|
98
|
+
}
|
|
99
|
+
const outcome = checkBudgets(readFileSync(tablePath, 'utf8'), f.get('--section') ?? 'Budgets', relative(root, tablePath), (p) => fileSize(resolve(root, p)));
|
|
100
|
+
outcome.out.forEach(io.out);
|
|
101
|
+
outcome.err.forEach(io.err);
|
|
102
|
+
return outcome.ok ? 0 : 1;
|
|
103
|
+
};
|
|
104
|
+
const exportDeletions = (f, io) => {
|
|
105
|
+
const dir = resolve(f.get('--root') ?? '.');
|
|
106
|
+
const git = gitAtTop(dir);
|
|
107
|
+
if (git === undefined) {
|
|
108
|
+
io.err(`pariksha export-deletions: ${dir} is not in a git repository`);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
const asked = f.get('--base');
|
|
112
|
+
const base = asked === undefined ? resolveBase(git) : resolveBase(git, [asked]);
|
|
113
|
+
if (base === undefined) {
|
|
114
|
+
// A shallow single-branch checkout lands here; passing it would pass unread work.
|
|
115
|
+
io.err(asked === undefined
|
|
116
|
+
? `pariksha export-deletions: none of ${BASE_CANDIDATES.join(', ')} names a commit; fetch one or pass --base`
|
|
117
|
+
: `pariksha export-deletions: --base ${asked} names no commit`);
|
|
118
|
+
return 2;
|
|
119
|
+
}
|
|
120
|
+
let report;
|
|
121
|
+
try {
|
|
122
|
+
report = checkExportDeletions(git, base);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
io.err(`pariksha export-deletions: ${e.message}; nothing was checked, so this is not a pass`);
|
|
126
|
+
return 2;
|
|
127
|
+
}
|
|
128
|
+
const result = formatReport(report);
|
|
129
|
+
result.out.forEach(io.out);
|
|
130
|
+
result.err.forEach(io.err);
|
|
131
|
+
return result.ok ? 0 : 1;
|
|
132
|
+
};
|
|
133
|
+
/** A comma-separated flag value as a list; empty entries dropped. */
|
|
134
|
+
const list = (value) => (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
135
|
+
const authorityCitations = (f, io, env) => {
|
|
136
|
+
const mode = f.get('--mode') ?? 'staged';
|
|
137
|
+
if (mode !== 'staged' && mode !== 'tree') {
|
|
138
|
+
io.err(USAGE);
|
|
139
|
+
return 2;
|
|
140
|
+
}
|
|
141
|
+
const dir = resolve(f.get('--root') ?? '.');
|
|
142
|
+
const root = topOf(dir);
|
|
143
|
+
if (root === undefined) {
|
|
144
|
+
io.err(`pariksha authority-citations: ${dir} is not in a git repository`);
|
|
145
|
+
return 2;
|
|
146
|
+
}
|
|
147
|
+
const git = gitAt(root);
|
|
148
|
+
const opts = {
|
|
149
|
+
authorities: list(f.get('--authority')),
|
|
150
|
+
issuePrefixes: list(f.get('--issue-prefix')),
|
|
151
|
+
adrDir: adrDirOf(f.get('--adr-dir') ?? DEFAULTS.adrDir),
|
|
152
|
+
};
|
|
153
|
+
let result;
|
|
154
|
+
try {
|
|
155
|
+
const source = mode === 'tree' ? treeSource(git, root, readOrUndefined) : stagedSource(git, env, readOrUndefined);
|
|
156
|
+
result = formatClaims(findUncited(source, opts, git, listAdrs(root, opts.adrDir)), opts);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
io.err(`pariksha authority-citations: ${e.message}; nothing was checked, so this is not a pass`);
|
|
160
|
+
return 2;
|
|
161
|
+
}
|
|
162
|
+
result.out.forEach(io.out);
|
|
163
|
+
result.err.forEach(io.err);
|
|
164
|
+
return result.ok ? 0 : 1;
|
|
165
|
+
};
|
|
166
|
+
const COMMANDS = {
|
|
167
|
+
'authority-citations': { allowed: ['--mode', '--authority', '--issue-prefix', '--adr-dir', '--root'], run: authorityCitations },
|
|
168
|
+
boundary: { allowed: ['--root', '--deny'], run: boundary },
|
|
169
|
+
'doc-budgets': { allowed: ['--table', '--section', '--root'], run: docBudgets },
|
|
170
|
+
'export-deletions': { allowed: ['--base', '--root'], run: exportDeletions },
|
|
171
|
+
};
|
|
172
|
+
export const run = (args, io, version = ownVersion(), env = process.env) => {
|
|
76
173
|
const [command, ...rest] = args;
|
|
77
174
|
if (command === '--version' && rest.length === 0) {
|
|
78
175
|
io.out(version);
|
|
@@ -82,10 +179,13 @@ export const run = (args, io, version = ownVersion()) => {
|
|
|
82
179
|
io.out(hash(rest[0]));
|
|
83
180
|
return 0;
|
|
84
181
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
182
|
+
const spec = command !== undefined && Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : undefined;
|
|
183
|
+
const f = spec && flags(rest, spec.allowed);
|
|
184
|
+
if (!spec || !f) {
|
|
185
|
+
io.err(USAGE);
|
|
186
|
+
return 2;
|
|
187
|
+
}
|
|
188
|
+
return spec.run(f, io, env);
|
|
89
189
|
};
|
|
90
190
|
const isMain = () => {
|
|
91
191
|
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,124 @@
|
|
|
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, PLAIN_DIFF } 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
|
+
/**
|
|
20
|
+
* A removed named declaration on a `-` diff line. Bare re-export lists
|
|
21
|
+
* (`export { a, b }`) are left out: they shuffle in refactors and name nothing new.
|
|
22
|
+
*/
|
|
23
|
+
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_$]*)/;
|
|
24
|
+
/**
|
|
25
|
+
* A grep hit (`HEAD:path:line:text`) whose text starts a comment (`//`, `*`,
|
|
26
|
+
* `/*`) is a mention, not a use. Unanchored, so a colon in a path still matches.
|
|
27
|
+
*/
|
|
28
|
+
const COMMENT_HIT = /:\d+:\s*(?:\/\/|\*|\/\*)/;
|
|
29
|
+
const unique = (xs) => [...new Set(xs)].sort();
|
|
30
|
+
/** Symbols named by `Removes-Export:` trailers: the first word, trailing punctuation dropped. */
|
|
31
|
+
export const declaredExports = (messages) => unique(messages
|
|
32
|
+
.split('\n')
|
|
33
|
+
.map((line) => /^\s*removes-export:\s*(\S+)/i.exec(line)?.[1]?.replace(/[,;()]+$/, ''))
|
|
34
|
+
.filter((s) => s !== undefined && s !== ''));
|
|
35
|
+
/**
|
|
36
|
+
* Paths named by `Removes-File:` trailers. The path runs up to the " — " or
|
|
37
|
+
* " - " that starts the reason, so a path with a space in it survives.
|
|
38
|
+
*/
|
|
39
|
+
export const declaredFiles = (messages) => unique(messages
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map((line) => /^\s*removes-file:\s*(.*)$/i
|
|
42
|
+
.exec(line)?.[1]
|
|
43
|
+
?.replace(/\s+(?:—|-)\s.*$/, '')
|
|
44
|
+
.trim()
|
|
45
|
+
.replace(/[,;()]+$/, ''))
|
|
46
|
+
.filter((s) => s !== undefined && s !== ''));
|
|
47
|
+
/** The symbols a unified diff removes from export declarations. */
|
|
48
|
+
export const removedExports = (diff) => diff
|
|
49
|
+
.split('\n')
|
|
50
|
+
.map((line) => EXPORT_DECL.exec(line)?.[1])
|
|
51
|
+
.filter((s) => s !== undefined);
|
|
52
|
+
/** True when some `git grep -n <sym> HEAD` hit is code rather than a comment. */
|
|
53
|
+
export const usedOutsideComments = (hits) => hits.split('\n').some((hit) => hit !== '' && !COMMENT_HIT.test(hit));
|
|
54
|
+
/**
|
|
55
|
+
* Compares `base...HEAD`: every export removed and named nowhere in HEAD's
|
|
56
|
+
* source must be declared. A git failure throws (with no merge base, say, in a
|
|
57
|
+
* shallow clone): the check cannot pass without having read the branch.
|
|
58
|
+
*/
|
|
59
|
+
export const checkExportDeletions = (git, base) => {
|
|
60
|
+
const messages = must(git, ['log', '--format=%B', `${base}..HEAD`]);
|
|
61
|
+
const symbols = new Set(declaredExports(messages));
|
|
62
|
+
const files = new Set(declaredFiles(messages));
|
|
63
|
+
// Renames off, so a file moved and edited lists its old path, whose removed
|
|
64
|
+
// lines the per-file diff then shows; colour, external diff tools and
|
|
65
|
+
// textconv off, so a user's config cannot rewrite the lines read here.
|
|
66
|
+
// NUL-separated, so no name comes back quoted.
|
|
67
|
+
const changed = must(git, ['diff', ...PLAIN_DIFF, '-z', '--name-only', '--diff-filter=ACMRD', `${base}...HEAD`, '--', ...SOURCE_GLOBS])
|
|
68
|
+
.split('\0')
|
|
69
|
+
.filter((f) => f !== '' && !SKIPPED_DIR.test(f));
|
|
70
|
+
const violations = [];
|
|
71
|
+
const declared = [];
|
|
72
|
+
const coveredFiles = [];
|
|
73
|
+
const used = new Map();
|
|
74
|
+
for (const file of changed) {
|
|
75
|
+
// `:(literal)` so a name like `src/[id].ts` names only itself.
|
|
76
|
+
const spec = `:(literal)${file}`;
|
|
77
|
+
const removed = removedExports(must(git, ['diff', ...PLAIN_DIFF, '-U0', `${base}...HEAD`, '--', spec]));
|
|
78
|
+
if (removed.length === 0)
|
|
79
|
+
continue;
|
|
80
|
+
// A file trailer covers a file only once HEAD has nothing at its path.
|
|
81
|
+
if (files.has(file) && must(git, ['ls-tree', '--name-only', 'HEAD', '--', spec]).trim() === '') {
|
|
82
|
+
coveredFiles.push(file);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (const symbol of removed) {
|
|
86
|
+
let stillUsed = used.get(symbol);
|
|
87
|
+
if (stillUsed === undefined) {
|
|
88
|
+
// git grep exits 1 when nothing matches, so undefined here is "no hit".
|
|
89
|
+
// Skipped trees are excluded here too: a stale committed build naming the symbol is no use.
|
|
90
|
+
stillUsed = usedOutsideComments(git.run(['grep', '--no-color', '-nwF', '-e', symbol, 'HEAD', '--', ...SOURCE_GLOBS, ...SKIPPED_PATHSPECS]) ?? '');
|
|
91
|
+
used.set(symbol, stillUsed);
|
|
92
|
+
}
|
|
93
|
+
if (stillUsed)
|
|
94
|
+
continue;
|
|
95
|
+
(symbols.has(symbol) ? declared : violations).push({ symbol, file });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { base, violations, declared, declaredFiles: coveredFiles };
|
|
99
|
+
};
|
|
100
|
+
/** The report as output lines; a refusal tells the author how to declare a removal. */
|
|
101
|
+
export const formatReport = (report) => {
|
|
102
|
+
const out = [
|
|
103
|
+
...report.declaredFiles.map((f) => `pariksha export-deletions: ${f} deleted; a Removes-File trailer declares its exports`),
|
|
104
|
+
...report.declared.map((d) => `pariksha export-deletions: ${d.symbol} removed from ${d.file}; a Removes-Export trailer declares it`),
|
|
105
|
+
];
|
|
106
|
+
if (report.violations.length === 0) {
|
|
107
|
+
out.push(`pariksha export-deletions: no undeclared export removals since ${report.base}`);
|
|
108
|
+
return { ok: true, out, err: [] };
|
|
109
|
+
}
|
|
110
|
+
const err = [
|
|
111
|
+
`pariksha export-deletions: ${report.violations.length} export(s) removed from the whole tree since ${report.base} with no stated reason:`,
|
|
112
|
+
...report.violations.map((v) => ` ${v.symbol} (removed from ${v.file})`),
|
|
113
|
+
'',
|
|
114
|
+
'Each is gone from every source file, not moved or renamed. If the removal is intended,',
|
|
115
|
+
'say so in a commit body:',
|
|
116
|
+
'',
|
|
117
|
+
' Removes-Export: <symbol> — <why it is safe to remove>',
|
|
118
|
+
'',
|
|
119
|
+
'A file deleted outright can be declared once, covering all its exports:',
|
|
120
|
+
'',
|
|
121
|
+
' Removes-File: <path> — <why the file is gone>',
|
|
122
|
+
];
|
|
123
|
+
return { ok: false, out, err };
|
|
124
|
+
};
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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', '-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)));
|
|
13
|
+
export const gitAt = (root) => ({
|
|
14
|
+
run: (args) => {
|
|
15
|
+
try {
|
|
16
|
+
return execFileSync('git', [...PINNED, ...args], {
|
|
17
|
+
cwd: root,
|
|
18
|
+
env: gitEnv(process.env),
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
maxBuffer: 1 << 28,
|
|
21
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
22
|
+
timeout: 120_000,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* Git at the top level of the repository holding `dir`, or undefined outside
|
|
32
|
+
* one. Paths git prints are relative to the top level, so every check runs
|
|
33
|
+
* there whichever directory it was started in.
|
|
34
|
+
*/
|
|
35
|
+
export const gitAtTop = (dir) => {
|
|
36
|
+
const top = topOf(dir);
|
|
37
|
+
return top === undefined ? undefined : gitAt(top);
|
|
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'];
|
|
47
|
+
/** A git call whose failure must stop the check rather than read as "nothing found". */
|
|
48
|
+
export const must = (git, args) => {
|
|
49
|
+
const out = git.run(args);
|
|
50
|
+
if (out === undefined)
|
|
51
|
+
throw new Error(`git ${args.join(' ')} failed`);
|
|
52
|
+
return out;
|
|
53
|
+
};
|
|
54
|
+
export const BASE_CANDIDATES = ['origin/main', 'origin/develop', 'origin/master', 'main'];
|
|
55
|
+
/** The first ref that names a commit, or undefined. */
|
|
56
|
+
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.
|
|
4
|
-
"description": "Repository checks: doc drift, rule enforcement, test-quality ratchets.
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: authority-citations, boundary, doc-budgets, export-deletions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"repository checks",
|
|
7
7
|
"lint",
|