@veda-hq/kavach 0.0.0-stage → 0.5.6

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.
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Is a push or a preflight running in this worktree? Checks that refuse
3
+ * commits or heavy work while one runs read it from here.
4
+ *
5
+ * A push: the consumer's pre-push hook runs `cli.js push-marker write
6
+ * <git-dir> <pid>` when a real push starts and `cli.js push-marker clear
7
+ * <git-dir> <pid>` on exit. Each push writes its own marker, named by its pid
8
+ * ({@link markerName}), into the worktree's own git folder, so a second push
9
+ * in the same worktree neither overwrites nor clears the first's. A
10
+ * preflight: a background preflight keeps the run that owns the copy in a
11
+ * run record ({@link PREFLIGHT_RECORD} by default). Both records name a pid
12
+ * and its start identity, judged as {@link isGone} judges them: a dead pid,
13
+ * or a live one that provably started later (a reused pid), is no run. So a
14
+ * marker a killed push left behind blocks nothing, and a push in another
15
+ * worktree, with its own git folder, never blocks this one.
16
+ *
17
+ * `clear` removes that pid's marker only, and `write` first removes the
18
+ * markers of pushes that ended.
19
+ */
20
+ import { execFileSync } from 'node:child_process';
21
+ import { readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
22
+ import { isAbsolute, join, resolve } from 'node:path';
23
+ /** The push markers' name prefix, in the worktree's git folder. */
24
+ export const PUSH_MARKER_PREFIX = 'kavach-push-in-progress.';
25
+ /** The default preflight run record, relative to the worktree. */
26
+ export const PREFLIGHT_RECORD = join('.tmp', 'preflight-bg', 'current.json');
27
+ /**
28
+ * The format every start identity is written in, as a prefix of the identity.
29
+ *
30
+ * Two identities are only comparable when they were read the same way. A
31
+ * record written by a reader that read `lstart` in local time, or in any
32
+ * future format, would differ from this reading for a process that is still
33
+ * the same one, and a difference is what declares a run ended. So an identity
34
+ * in any other format is treated like an unknown one: liveness alone decides.
35
+ */
36
+ export const START_IDENTITY_FORMAT = 'lstart-utc-c/1';
37
+ function isCurrentIdentity(identity) {
38
+ return identity !== null && identity.startsWith(`${START_IDENTITY_FORMAT} `);
39
+ }
40
+ /**
41
+ * Has the run provably ended: its pid is dead, or now belongs to a process
42
+ * that started later (a reused pid)?
43
+ *
44
+ * An unreadable current start is asked about liveness once more: a reused pid
45
+ * held by a short-lived process can pass the first probe and exit before `ps`
46
+ * reads its start, and the first answer is then stale. The recheck is
47
+ * `kill(0)` again, so a process that still runs, but whose start `ps` cannot
48
+ * read, stays live (a stale push marker once refused an edit this way).
49
+ */
50
+ export function isGone(run, probe) {
51
+ if (!probe.isAlive(run.pid))
52
+ return true;
53
+ if (!isCurrentIdentity(run.startIdentity))
54
+ return false;
55
+ const current = probe.startIdentityOf(run.pid);
56
+ if (current === null)
57
+ return !probe.isAlive(run.pid);
58
+ return isCurrentIdentity(current) && current !== run.startIdentity;
59
+ }
60
+ function errorCode(error) {
61
+ return typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : undefined;
62
+ }
63
+ /** The real probe over `calls`. A `ps` reading is decoded with `String()`, which reads a Buffer as UTF-8. */
64
+ export function processProbe(calls) {
65
+ return {
66
+ isAlive: (pid) => {
67
+ try {
68
+ calls.kill(pid, 0);
69
+ }
70
+ catch (error) {
71
+ // Only ESRCH says no such process. EPERM (it exists and belongs to
72
+ // someone else) or anything else is no evidence, so `ps` decides.
73
+ if (errorCode(error) === 'ESRCH')
74
+ return false;
75
+ }
76
+ // Signal 0 also reaches a zombie (exited, not yet reaped), which would
77
+ // read as a live run for as long as its parent never waits. Only a
78
+ // state that says `Z` is dead; a `ps` that fails or prints nothing is
79
+ // not evidence, and the conservative reading of "unknown" is alive.
80
+ try {
81
+ const state = String(calls.exec('ps', ['-o', 'stat=', '-p', String(pid)], {
82
+ stdio: ['ignore', 'pipe', 'ignore'],
83
+ env: { ...process.env, LC_ALL: 'C' },
84
+ })).trim();
85
+ return !state.startsWith('Z');
86
+ }
87
+ catch {
88
+ return true;
89
+ }
90
+ },
91
+ startIdentityOf: (pid) => {
92
+ try {
93
+ // Pinned to UTC and the C locale. `lstart` is printed in local time, so
94
+ // without this two readers whose shells carry different TZ values read
95
+ // different strings for the same live process, and the second judges
96
+ // a live run a reused pid.
97
+ const started = String(calls.exec('ps', ['-o', 'lstart=', '-p', String(pid)], {
98
+ stdio: ['ignore', 'pipe', 'ignore'],
99
+ env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' },
100
+ })).trim();
101
+ return started === '' ? null : `${START_IDENTITY_FORMAT} ${started}`;
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ },
107
+ };
108
+ }
109
+ /** The real probe. */
110
+ export const defaultProbe = processProbe({
111
+ exec: (file, args, options) => execFileSync(file, args, options),
112
+ kill: (pid, signal) => {
113
+ process.kill(pid, signal);
114
+ },
115
+ });
116
+ /** A run record's pid and start identity; `null` for anything else. */
117
+ export function parseRun(raw) {
118
+ let value;
119
+ try {
120
+ value = JSON.parse(raw);
121
+ }
122
+ catch {
123
+ // fail-open: a torn or foreign record names no run.
124
+ return null;
125
+ }
126
+ if (typeof value !== 'object' || value === null)
127
+ return null;
128
+ const { pid, startIdentity } = value;
129
+ if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 1)
130
+ return null;
131
+ if (startIdentity !== null && typeof startIdentity !== 'string')
132
+ return null;
133
+ return { pid, startIdentity };
134
+ }
135
+ /** The live run recorded at `path`; `null` when none is recorded or it ended. */
136
+ export function liveRun(path, read, probe) {
137
+ let raw;
138
+ try {
139
+ raw = read(path);
140
+ }
141
+ catch {
142
+ // fail-open: no record (or none readable) is no run.
143
+ return null;
144
+ }
145
+ const run = parseRun(raw);
146
+ return run === null || isGone(run, probe) ? null : run;
147
+ }
148
+ /**
149
+ * The git folder of the checkout at `root`, read from `root/.git` without
150
+ * running git: a folder in the primary checkout, a `gitdir:` file in a
151
+ * linked worktree.
152
+ */
153
+ export function gitDir(root, read) {
154
+ const dotGit = join(root, '.git');
155
+ let text;
156
+ try {
157
+ text = read(dotGit);
158
+ }
159
+ catch {
160
+ // fail-open: a folder (the primary checkout) or nothing reads as .git itself.
161
+ return dotGit;
162
+ }
163
+ const named = /^gitdir: (.+)$/m.exec(text)?.[1];
164
+ if (named === undefined)
165
+ return dotGit;
166
+ return isAbsolute(named) ? named : resolve(root, named);
167
+ }
168
+ /** The marker push `pid` writes: one per push, so two pushes never share one. */
169
+ export function markerName(pid) {
170
+ return `${PUSH_MARKER_PREFIX}${pid}.json`;
171
+ }
172
+ const MARKER_NAME = /^kavach-push-in-progress\.\d+\.json$/;
173
+ /** A live push in the checkout at `root`, read from the first marker whose pid still runs. */
174
+ export function livePush(root, read, list, probe) {
175
+ const dir = gitDir(root, read);
176
+ let names;
177
+ try {
178
+ names = list(dir);
179
+ }
180
+ catch {
181
+ // fail-open: an unreadable git folder names no push.
182
+ return null;
183
+ }
184
+ for (const name of names.filter((entry) => MARKER_NAME.test(entry))) {
185
+ const run = liveRun(join(dir, name), read, probe);
186
+ if (run !== null)
187
+ return run;
188
+ }
189
+ return null;
190
+ }
191
+ /** The live preflight in the checkout at `root`, from its run record (relative to `root`). */
192
+ export function livePreflight(root, read, probe, record = PREFLIGHT_RECORD) {
193
+ return liveRun(resolve(root, record), read, probe);
194
+ }
195
+ export const readText = (path) => readFileSync(path, 'utf8');
196
+ export const listDir = (dir) => readdirSync(dir);
197
+ /**
198
+ * Remove the markers in `dir` whose push provably ended: a push killed before
199
+ * its exit trap ran leaves one, and each left behind is one more pid to be
200
+ * reused. A marker that does not parse names no push and stays.
201
+ */
202
+ export function sweepMarkers(dir, effects) {
203
+ let names;
204
+ try {
205
+ names = effects.list(dir);
206
+ }
207
+ catch {
208
+ // fail-open: an unreadable git folder has nothing to sweep.
209
+ return;
210
+ }
211
+ for (const name of names.filter((entry) => MARKER_NAME.test(entry))) {
212
+ const path = join(dir, name);
213
+ try {
214
+ const run = parseRun(effects.read(path));
215
+ if (run !== null && isGone(run, effects.probe))
216
+ effects.remove(path);
217
+ }
218
+ catch {
219
+ // fail-open: a marker another sweep took first is already gone.
220
+ }
221
+ }
222
+ }
223
+ /**
224
+ * Write the marker for `pid`, atomically, after sweeping the ended pushes'
225
+ * markers. Throws when its start is unknown.
226
+ */
227
+ export function writeMarker(dir, pid, effects) {
228
+ const startIdentity = effects.probe.startIdentityOf(pid);
229
+ if (startIdentity === null)
230
+ throw new Error(`no process ${pid}`);
231
+ sweepMarkers(dir, effects);
232
+ const path = join(dir, markerName(pid));
233
+ const staging = `${path}.tmp`;
234
+ effects.write(staging, `${JSON.stringify({ pid, startIdentity, startedAt: effects.now().toISOString() })}\n`);
235
+ effects.rename(staging, path);
236
+ }
237
+ /** Remove the marker `pid` wrote; another push's marker stays. */
238
+ export function clearMarker(dir, pid, effects) {
239
+ effects.remove(join(dir, markerName(pid)));
240
+ }
241
+ export const liveEffects = {
242
+ probe: defaultProbe,
243
+ read: readText,
244
+ list: listDir,
245
+ write: (path, text) => writeFileSync(path, text),
246
+ rename: renameSync,
247
+ remove: (path) => rmSync(path, { force: true }),
248
+ now: () => new Date(),
249
+ };
250
+ /** A caught value as text, never throwing. */
251
+ function errorDetail(error) {
252
+ try {
253
+ if (error instanceof Error)
254
+ return error.message || error.name || 'Error';
255
+ return String(error);
256
+ }
257
+ catch {
258
+ // fail-open: describing the error must not raise a second one.
259
+ return '[unprintable error]';
260
+ }
261
+ }
262
+ /** `push-marker write|clear <git-dir> <pid>`; returns the exit status. Never fails a push: errors only warn. */
263
+ export function main(args, effects, warn) {
264
+ const [verb, dir, pidText] = args;
265
+ const pid = Number(pidText);
266
+ if ((verb !== 'write' && verb !== 'clear') || dir === undefined || dir === '' || !(pid > 1)) {
267
+ warn('usage: push-marker write|clear <git-dir> <pid>');
268
+ return 2;
269
+ }
270
+ try {
271
+ if (verb === 'write')
272
+ writeMarker(dir, pid, effects);
273
+ else
274
+ clearMarker(dir, pid, effects);
275
+ return 0;
276
+ }
277
+ catch (error) {
278
+ // fail-open: the marker is advisory; the push carries on after the warning.
279
+ warn(`push marker: could not ${verb} (${errorDetail(error)})`);
280
+ return 1;
281
+ }
282
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The rule registry: every checkable rule a consumer's agents learned the
3
+ * hard way, the agent-memory notes it came from, where it came from, and what
4
+ * enforces it now. The consumer owns the file; kavach validates it, words
5
+ * refusals from it, and answers the count of rules still unenforced.
6
+ *
7
+ * A registry is JSON:
8
+ *
9
+ * ```json
10
+ * {
11
+ * "owner": "the release manager",
12
+ * "rules": [
13
+ * {
14
+ * "id": "H-01",
15
+ * "title": "Resolve review threads only after fixes are pushed",
16
+ * "notes": ["never-sync-threads-before-pushing-fixes"],
17
+ * "origin": "work 12",
18
+ * "enforcer": { "pr": 34, "files": ["rules/threads-after-push.ts"] }
19
+ * }
20
+ * ]
21
+ * }
22
+ * ```
23
+ *
24
+ * `owner` (optional) is who a refusal without a pass tells the agent to ask;
25
+ * absent, "the repository owner". `origin` (optional) is printed after the
26
+ * rule's title. `enforcer` is `null` for a rule nothing checks yet. Other keys
27
+ * are left alone, so a consumer may keep its own bookkeeping beside these.
28
+ *
29
+ * A rule counts as enforced only when its enforcer names files that exist in
30
+ * the checkout, so a renamed or deleted enforcer reads as unenforced instead
31
+ * of being trusted from the record.
32
+ *
33
+ * CLI: `cli.js count --registry <json> [--root <dir>] [--json]` prints the
34
+ * count and the unenforced rules; `--json` prints the same as JSON.
35
+ */
36
+ import { readFileSync, statSync } from 'node:fs';
37
+ import { join, resolve } from 'node:path';
38
+ import { onlyKnown, option } from './args.js';
39
+ /** Who a refusal says to ask when the registry names no one. */
40
+ export const DEFAULT_OWNER = 'the repository owner';
41
+ /** The environment variable the CLIs read the registry path from. */
42
+ export const REGISTRY_ENV = 'KAVACH_RULE_REGISTRY';
43
+ const RULE_ID = /^[A-Z]-\d{2}$/;
44
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
45
+ const REPO_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[^\s]+$/;
46
+ function record(value, where) {
47
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
48
+ throw new Error(`${where}: expected an object`);
49
+ }
50
+ return value;
51
+ }
52
+ function text(value, where, shape) {
53
+ if (typeof value !== 'string' || value.trim() === '') {
54
+ throw new Error(`${where}: expected a non-empty string`);
55
+ }
56
+ if (shape !== undefined && !shape.test(value)) {
57
+ throw new Error(`${where}: ${JSON.stringify(value)} does not match ${shape}`);
58
+ }
59
+ return value;
60
+ }
61
+ function texts(value, where, shape) {
62
+ if (!Array.isArray(value) || value.length === 0) {
63
+ throw new Error(`${where}: expected a non-empty list`);
64
+ }
65
+ return value.map((item, i) => text(item, `${where}[${i}]`, shape));
66
+ }
67
+ function enforcer(value, where) {
68
+ if (value === null)
69
+ return null;
70
+ const raw = record(value, where);
71
+ const pr = raw['pr'];
72
+ if (typeof pr !== 'number' || !Number.isSafeInteger(pr) || pr <= 0) {
73
+ throw new Error(`${where}.pr: expected a positive integer`);
74
+ }
75
+ return { pr, files: texts(raw['files'], `${where}.files`, REPO_PATH) };
76
+ }
77
+ /** Validate a parsed registry file; throws naming the first bad field. */
78
+ export function parseRegistry(value) {
79
+ const raw = record(value, 'registry');
80
+ const owner = raw['owner'] === undefined ? undefined : text(raw['owner'], 'registry.owner');
81
+ if (!Array.isArray(raw['rules'])) {
82
+ throw new Error('registry.rules: expected a list');
83
+ }
84
+ const seen = new Set();
85
+ const rules = raw['rules'].map((item, i) => {
86
+ const where = `registry.rules[${i}]`;
87
+ const row = record(item, where);
88
+ const id = text(row['id'], `${where}.id`, RULE_ID);
89
+ if (seen.has(id))
90
+ throw new Error(`${where}.id: ${id} is listed twice`);
91
+ seen.add(id);
92
+ const origin = row['origin'] === undefined ? undefined : text(row['origin'], `${where}.origin`);
93
+ return {
94
+ id,
95
+ title: text(row['title'], `${where}.title`),
96
+ notes: texts(row['notes'], `${where}.notes`, SLUG),
97
+ ...(origin === undefined ? {} : { origin }),
98
+ enforcer: enforcer(row['enforcer'], `${where}.enforcer`),
99
+ };
100
+ });
101
+ return { ...(owner === undefined ? {} : { owner }), rules };
102
+ }
103
+ export function loadRegistry(path) {
104
+ return parseRegistry(JSON.parse(readFileSync(path).toString()));
105
+ }
106
+ /** Is the rule enforced in the checkout at `root`: an enforcer whose paths are all files. */
107
+ export function isEnforced(rule, root) {
108
+ return (rule.enforcer !== null &&
109
+ rule.enforcer.files.every((file) => statSync(join(root, file), { throwIfNoEntry: false })?.isFile() === true));
110
+ }
111
+ export function count(registry, root) {
112
+ return {
113
+ total: registry.rules.length,
114
+ unenforced: registry.rules.filter((rule) => !isEnforced(rule, root)),
115
+ };
116
+ }
117
+ export function renderCount(result) {
118
+ const lines = [`${result.unenforced.length} of ${result.total} checkable memory rules unenforced`];
119
+ for (const rule of result.unenforced) {
120
+ lines.push(` ${rule.id} ${rule.title}${rule.origin === undefined ? '' : ` (${rule.origin})`}`);
121
+ }
122
+ return lines.join('\n');
123
+ }
124
+ /** Lines to standard output and standard error, for the CLIs. */
125
+ export const STDIO = {
126
+ out: (line) => {
127
+ process.stdout.write(`${line}\n`);
128
+ },
129
+ err: (line) => {
130
+ process.stderr.write(`${line}\n`);
131
+ },
132
+ };
133
+ const USAGE = 'usage: count --registry <json> [--root <dir>] [--json]';
134
+ /**
135
+ * `count`: the registry from `--registry` (else `KAVACH_RULE_REGISTRY`), the
136
+ * checkout from `--root` (else `cwd`), both relative to `cwd`. Exit 2 on a
137
+ * usage error or a registry that does not load.
138
+ */
139
+ export function main(argv, io, cwd = process.cwd(), env = process.env) {
140
+ let result;
141
+ try {
142
+ onlyKnown(argv, ['--registry', '--root'], ['--json']);
143
+ const path = option(argv, '--registry') ?? env[REGISTRY_ENV];
144
+ if (path === undefined || path === '')
145
+ throw new Error(`no registry: pass --registry or set ${REGISTRY_ENV}`);
146
+ result = count(loadRegistry(resolve(cwd, path)), resolve(cwd, option(argv, '--root') ?? '.'));
147
+ }
148
+ catch (error) {
149
+ io.err(`count: ${error instanceof Error ? error.message : String(error)}`);
150
+ io.err(USAGE);
151
+ return 2;
152
+ }
153
+ if (argv.includes('--json')) {
154
+ io.out(JSON.stringify({
155
+ total: result.total,
156
+ unenforced: result.unenforced.map((rule) => rule.id),
157
+ }));
158
+ }
159
+ else {
160
+ io.out(renderCount(result));
161
+ }
162
+ return 0;
163
+ }