@veda-hq/dvara 0.0.0-stage → 0.5.4
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 +21 -0
- package/README.md +201 -2
- package/dist/cli.js +124 -0
- package/dist/config.js +175 -0
- package/dist/context.js +67 -0
- package/dist/quiet.js +31 -0
- package/dist/records/baseline.js +152 -0
- package/dist/records/frozen.js +161 -0
- package/dist/records/gate.js +399 -0
- package/dist/records/git.js +312 -0
- package/dist/records/identity.js +263 -0
- package/dist/records/markdown.js +115 -0
- package/dist/records/paths.js +103 -0
- package/dist/records/rules.js +10 -0
- package/dist/records/scope.js +26 -0
- package/dist/records/types.js +4 -0
- package/dist/records.js +96 -0
- package/dist/supervisor.js +371 -0
- package/package.json +52 -3
- package/shell/keep-going.sh +86 -0
- package/shell/stages.sh +52 -0
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,202 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @veda-hq/dvara
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
द्वार, gate. Git hooks for repositories coding agents work in: cheap checks at
|
|
4
|
+
commit, expensive ones once at push.
|
|
5
|
+
|
|
6
|
+
This release holds the runner every hook goes through. Your hook stays a bash
|
|
7
|
+
file in your repository; dvara runs it.
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
- **One history row per run.** `dvara run <hook-file> [args…]` runs the hook
|
|
12
|
+
under bash in its own process group and appends a row to
|
|
13
|
+
`<historyDir>/history.jsonl`: kind, who started it (git, a person, a
|
|
14
|
+
background preflight, a benchmark), exit code and status, each stage's start,
|
|
15
|
+
duration and input, the base and tree it was judged against, the commits a
|
|
16
|
+
push updated, and the hook's peak memory.
|
|
17
|
+
- **Signals reach the hook.** Ctrl-C, SIGTERM, SIGHUP and SIGQUIT are forwarded
|
|
18
|
+
to the whole group. A run that was signalled is reported as cancelled, with
|
|
19
|
+
the signal's exit code, even when the hook trapped it and exited 0, so an
|
|
20
|
+
interrupted gate never lets a push through.
|
|
21
|
+
- **Quiet on success.** A hook listed in `quietHooks` (default `pre-commit`)
|
|
22
|
+
prints its warnings and one line when it passes, and everything when it
|
|
23
|
+
fails. The full output is kept in `<historyDir>/<hook>.log`.
|
|
24
|
+
`DVARA_VERBOSE=1` streams it.
|
|
25
|
+
- **Keep-going mode.** For a preflight, a run of the push hook ahead of the
|
|
26
|
+
push: each stage's `exit 1` is recorded and the hook carries on, so one run
|
|
27
|
+
lists every failure.
|
|
28
|
+
- **A watchdog, if you have one.** `watchdog` names a module the supervisor
|
|
29
|
+
attaches to each run; it may stop a hung hook and decide the verdict. Without
|
|
30
|
+
one, or when it fails to load, the hook runs exactly as it would anyway.
|
|
31
|
+
|
|
32
|
+
Every one of these is fail-open: telemetry, quiet output and the watchdog never
|
|
33
|
+
change a hook's verdict.
|
|
34
|
+
|
|
35
|
+
## A hook under dvara
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
#!/usr/bin/env bash
|
|
39
|
+
# .husky/pre-push, or wherever core.hooksPath points
|
|
40
|
+
if [ "${DVARA_MEASURED_HOOK:-}" != "pre-push" ]; then
|
|
41
|
+
exec node node_modules/@veda-hq/dvara/dist/cli.js run "$0" "$@"
|
|
42
|
+
fi
|
|
43
|
+
source node_modules/@veda-hq/dvara/shell/stages.sh
|
|
44
|
+
if [ "${DVARA_MODE:-}" = preflight ] && [ $# -eq 0 ]; then
|
|
45
|
+
source node_modules/@veda-hq/dvara/shell/keep-going.sh
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
dvara_stage lint
|
|
49
|
+
pnpm lint || exit 1
|
|
50
|
+
dvara_passed && echo "✅ lint"
|
|
51
|
+
|
|
52
|
+
dvara_stage test
|
|
53
|
+
pnpm test || exit 1
|
|
54
|
+
|
|
55
|
+
if [ "${DVARA_MODE:-}" = preflight ] && [ $# -eq 0 ]; then dvara_keep_going_report; fi
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`stages.sh` gives the hook:
|
|
59
|
+
|
|
60
|
+
| Helper | Does |
|
|
61
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
62
|
+
| `dvara_stage <name>` | marks the start of a stage |
|
|
63
|
+
| `dvara_record <type> <fields…>` | a fact for the row: `stage-input <stage> <kind> <digest>`, `proof <check> <outcome> <ms>`, `proof-reason …` |
|
|
64
|
+
| `dvara_record_refs "$stdin"` | the commits a push updates, from git's ref lines; never the ref names |
|
|
65
|
+
| `dvara_passed` | false once after keep-going mode carried on past a refusal, to guard a "✅" line |
|
|
66
|
+
|
|
67
|
+
`keep-going.sh` adds `dvara_keep_going_held`, `dvara_keep_going_skipped` and
|
|
68
|
+
`dvara_keep_going_report`. `DVARA_KEEP_GOING_NAME` and `DVARA_KEEP_GOING_TITLE`
|
|
69
|
+
rename it in its messages (default "preflight" and "Preflight").
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
`.dvara.json` at the repository's root, or the file `DVARA_CONFIG` names. Every
|
|
74
|
+
field is optional; an unknown field is an error.
|
|
75
|
+
|
|
76
|
+
| Field | Default | Meaning |
|
|
77
|
+
| ------------ | ------------------------------------------------ | -------------------------------------------------------------- |
|
|
78
|
+
| `historyDir` | `.dvara` | where the history, the quiet log and scratch files go |
|
|
79
|
+
| `quietHooks` | `["pre-commit"]` | hooks whose passing output is one line |
|
|
80
|
+
| `watchdog` | `null` | a watchdog module, relative to the root |
|
|
81
|
+
| `baseRefs` | `origin/main`, `origin/develop`, `origin/master` | the refs tried for the base a run is judged against |
|
|
82
|
+
| `stages` | `{}` | each hook's stages in order; `dvara stages <hook>` prints them |
|
|
83
|
+
|
|
84
|
+
`records`, an object, holds the settings of `dvara records` and `dvara frozen`
|
|
85
|
+
(below). Each field is optional; an unknown one is an error.
|
|
86
|
+
|
|
87
|
+
| Field | Default | Meaning |
|
|
88
|
+
| ---------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
|
89
|
+
| `baseline` | `.dvara/records-baseline.json` | the per-file baseline, relative to the root; read from git, so it must be tracked (un-ignore it if `.dvara/` is ignored) |
|
|
90
|
+
| `skip` | `[]` | path prefixes no rule reads, such as a checker's own fixtures |
|
|
91
|
+
| `frozen` | `{"paths": [], "except": []}` | `paths`: prefixes `dvara frozen` refuses changing and no rule reads; `except`: exact paths under them that stay editable |
|
|
92
|
+
| `identityPaths` | `[]` | regular expressions for the paths where a commit must be cited by its full id (`EVIDENCE-01`); none by default |
|
|
93
|
+
| `repository` | `null` | `owner/name` on GitHub: its commit URLs are this repository's, every other repository's are not checked |
|
|
94
|
+
| `allowPaths` | `[]` | regular expressions for machine paths that are durable by agreement, matched from where the path starts: anchor with `^` |
|
|
95
|
+
| `packageManager` | `null` | `pnpm`, `npm` or `yarn`: the other managers' install commands are refused; `null` checks none |
|
|
96
|
+
|
|
97
|
+
The environment it reads: `DVARA_MODE=preflight` (a preflight's rows get kind
|
|
98
|
+
`preflight`), `DVARA_BACKGROUND=1` (started detached), `DVARA_MEASUREMENT_CONTEXT=manual-benchmark`,
|
|
99
|
+
`DVARA_BOOTSTRAP_SECONDS` (seconds the hook spent selecting its runtime before
|
|
100
|
+
starting dvara, counted as a stage), `DVARA_VERBOSE=1`. It sets
|
|
101
|
+
`DVARA_MEASURED_HOOK`, `DVARA_STAGE_FILE` and `DVARA_RECORD_FILE` for the hook.
|
|
102
|
+
|
|
103
|
+
## A watchdog module
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
export function createWatchdog(options: {
|
|
107
|
+
rootPid;
|
|
108
|
+
stageFile;
|
|
109
|
+
hangsDir;
|
|
110
|
+
runId;
|
|
111
|
+
config;
|
|
112
|
+
}): {
|
|
113
|
+
disarm(): void;
|
|
114
|
+
stop(): void;
|
|
115
|
+
summary(): { mode: string };
|
|
116
|
+
enforced(): boolean;
|
|
117
|
+
};
|
|
118
|
+
export function resolveWatchdogConfig(
|
|
119
|
+
env,
|
|
120
|
+
platform,
|
|
121
|
+
perfLevels: string[],
|
|
122
|
+
): unknown;
|
|
123
|
+
export function exitDisposition(
|
|
124
|
+
summary,
|
|
125
|
+
): { code: number; status: string } | null;
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## The record gate: `dvara records`
|
|
129
|
+
|
|
130
|
+
Committed text should not cite what stops meaning anything once the session
|
|
131
|
+
that wrote it ends. The gate reads every text file that is not source code, a
|
|
132
|
+
binary, a lockfile (`*.lock`, `pnpm-lock.yaml`, `package-lock.json`), a
|
|
133
|
+
symlink or a submodule, a skipped or frozen path, the baseline or the config,
|
|
134
|
+
and refuses (rule `EVIDENCE-02`, one finding per line):
|
|
135
|
+
|
|
136
|
+
- a `file://` URL, a `/Users/<name>` or `/home/<name>` home path, a sandbox temp
|
|
137
|
+
root (`/private/tmp/`, `/private/var/`, `/var/folders/`), a literal `.git/`
|
|
138
|
+
path, or an agent-process marker ("I am Agent 3");
|
|
139
|
+
- another package manager's install command, when `packageManager` names one.
|
|
140
|
+
A clause that says not to ("never run `npm install`") is no finding.
|
|
141
|
+
|
|
142
|
+
Where `identityPaths` match, it also refuses (rule `EVIDENCE-01`) a commit cited
|
|
143
|
+
by less than its full 40-hex id, since git lengthens abbreviations as history
|
|
144
|
+
grows and clones disagree on their length:
|
|
145
|
+
|
|
146
|
+
- `abbreviated-id`: a 7–39 hex token the line makes a commit (after a commit
|
|
147
|
+
word, backticked on its own, in an `a..b` range, cut off with an ellipsis, or
|
|
148
|
+
in a short commit URL of `repository`), unless the line names it another kind
|
|
149
|
+
of hash (digest, sha256, checksum, …);
|
|
150
|
+
- `abbreviating-command`: a git command that prints abbreviations
|
|
151
|
+
(`--oneline`, `%h`, `--abbrev-commit`, `rev-parse --short`);
|
|
152
|
+
- `unresolved-id`: a full id the change adds that is not a commit in this
|
|
153
|
+
repository, compared with the ids the file cited at the merge base. No
|
|
154
|
+
baseline holds these: they are new by construction.
|
|
155
|
+
|
|
156
|
+
In Markdown, a fence's captured output is not read for tokens; its command
|
|
157
|
+
lines are (`$ ` lines of a transcript, every line of an `sh` fence). Fences are
|
|
158
|
+
found by CommonMark's rules (mdast-util-from-markdown), so a fence in a quote or
|
|
159
|
+
a list item counts.
|
|
160
|
+
|
|
161
|
+
Files written before the gate existed are held by the baseline: each file's
|
|
162
|
+
count per rule, which may only fall. A new finding in a legacy file, a fix that
|
|
163
|
+
leaves its allowance in place, an entry for a file that is gone or not read,
|
|
164
|
+
and a baseline edit that raises a count or adds a file are all refused; a
|
|
165
|
+
rename git detects carries its entry forward.
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
dvara records --staged # pre-commit: the index, against the first baseRefs that resolves
|
|
169
|
+
dvara records --base origin/main # CI: the files the branch changed, at HEAD
|
|
170
|
+
dvara records --all [--base REF] # every tracked file at HEAD
|
|
171
|
+
dvara records --all --write-baseline # record today's counts
|
|
172
|
+
dvara records --all --survey # counts per rule and check
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Exit 0: clean or within the baseline; 1: refused; 2: could not run. Inside the
|
|
176
|
+
package the gate takes any list of rules, `(path, text, tree) => Finding[]`;
|
|
177
|
+
the command runs the list `rulesFor` in `src/records/rules.ts` returns.
|
|
178
|
+
|
|
179
|
+
## Frozen paths: `dvara frozen`
|
|
180
|
+
|
|
181
|
+
A path under one of `records.frozen.paths` is history: no commit adds, changes
|
|
182
|
+
or deletes a file there, except the exact `records.frozen.except` paths.
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
dvara frozen --staged # pre-commit, merge commits included
|
|
186
|
+
dvara frozen --base origin/main [--head REF] # CI: what the branch changes since its merge base
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
In a merge, a path staged exactly as a merged-in parent has it is that
|
|
190
|
+
parent's change and passes; a hand resolution counts. With no frozen paths
|
|
191
|
+
configured it passes without asking git. Exit 0, 1 or 2 as above.
|
|
192
|
+
|
|
193
|
+
## Commands
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
dvara run <hook-file> [hook args…] # run a git hook under the supervisor
|
|
197
|
+
dvara stages <hook> # the stages .dvara.json lists for it
|
|
198
|
+
dvara shell <stages|keep-going> # the path of a shell helper
|
|
199
|
+
dvara records <--staged | --all [--base REF] [--write-baseline | --survey] | --base REF>
|
|
200
|
+
dvara frozen <--staged | --base REF [--head REF]>
|
|
201
|
+
dvara --version
|
|
202
|
+
```
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dvara: git hooks with cheap checks at commit and expensive ones once at push.
|
|
3
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { configName, loadConfig } from "./config.js";
|
|
7
|
+
import { frozen, parseArgs as parseFrozenArgs, USAGE as FROZEN_USAGE, } from "./records/frozen.js";
|
|
8
|
+
import { parseArgs as parseRecordsArgs, records, USAGE as RECORDS_USAGE, } from "./records/gate.js";
|
|
9
|
+
import { runner } from "./records/git.js";
|
|
10
|
+
import { supervise } from "./supervisor.js";
|
|
11
|
+
export const USAGE = [
|
|
12
|
+
"usage: dvara run <hook-file> [hook args…] run a git hook under the supervisor",
|
|
13
|
+
" dvara stages <hook> the hook stages the config lists, one per line",
|
|
14
|
+
" dvara shell <stages|keep-going> the path of a shell helper to source",
|
|
15
|
+
" dvara records <--staged | --all [--base REF] [--write-baseline | --survey] | --base REF>",
|
|
16
|
+
" the record gate: no new non-durable paths, a baseline that only falls",
|
|
17
|
+
" dvara frozen <--staged | --base REF [--head REF]>",
|
|
18
|
+
" refuse changes to the frozen paths",
|
|
19
|
+
" dvara --version",
|
|
20
|
+
].join("\n");
|
|
21
|
+
const SHELL_HELPERS = new Set(["stages", "keep-going"]);
|
|
22
|
+
export const ownVersion = () => {
|
|
23
|
+
const pkg = join(import.meta.dirname, "..", "package.json");
|
|
24
|
+
return JSON.parse(readFileSync(pkg, "utf8")).version;
|
|
25
|
+
};
|
|
26
|
+
/** The path of a shell helper this package ships. */
|
|
27
|
+
export const shellHelper = (name) => resolve(import.meta.dirname, "..", "shell", `${name}.sh`);
|
|
28
|
+
/**
|
|
29
|
+
* `dvara records` or `dvara frozen` at the root `cwd`: 2 on bad arguments, a
|
|
30
|
+
* config it cannot read, or git failing.
|
|
31
|
+
*/
|
|
32
|
+
const checkCommand = (command, args, { io, cwd, env }) => {
|
|
33
|
+
const parsed = command === "records" ? parseRecordsArgs(args) : parseFrozenArgs(args);
|
|
34
|
+
if ("error" in parsed) {
|
|
35
|
+
io.err(`dvara ${command}: ${parsed.error}`);
|
|
36
|
+
io.err(command === "records" ? RECORDS_USAGE : FROZEN_USAGE);
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const config = loadConfig(cwd, env);
|
|
41
|
+
const repo = { cwd, env };
|
|
42
|
+
if ("mode" in parsed)
|
|
43
|
+
return records(parsed, repo, io.out, {
|
|
44
|
+
records: config.records,
|
|
45
|
+
baseRefs: config.baseRefs,
|
|
46
|
+
own: [configName(env)],
|
|
47
|
+
});
|
|
48
|
+
const result = frozen(parsed, runner(repo), config.records.frozen, env);
|
|
49
|
+
for (const line of result.stdout)
|
|
50
|
+
io.out(line);
|
|
51
|
+
for (const line of result.stderr)
|
|
52
|
+
io.err(line);
|
|
53
|
+
return result.code;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
io.err(`dvara ${command}: ${error.message}`);
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/** Runs one command; resolves with the process's exit code. */
|
|
61
|
+
export const run = async (argv, context) => {
|
|
62
|
+
const { io, cwd, env } = context;
|
|
63
|
+
const [command, ...rest] = argv;
|
|
64
|
+
if (command === "--version") {
|
|
65
|
+
io.out(ownVersion());
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
if (command === "shell" &&
|
|
69
|
+
rest.length === 1 &&
|
|
70
|
+
SHELL_HELPERS.has(rest[0] ?? "")) {
|
|
71
|
+
io.out(shellHelper(rest[0] ?? ""));
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
if (command === "records" || command === "frozen")
|
|
75
|
+
return checkCommand(command, rest, context);
|
|
76
|
+
if ((command === "run" && rest.length > 0) ||
|
|
77
|
+
(command === "stages" && rest.length === 1)) {
|
|
78
|
+
// Git runs every hook at the top of the working tree.
|
|
79
|
+
let config;
|
|
80
|
+
try {
|
|
81
|
+
config = loadConfig(cwd, env);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
io.err(error.message);
|
|
85
|
+
return 2;
|
|
86
|
+
}
|
|
87
|
+
if (command === "stages") {
|
|
88
|
+
for (const name of config.stages[rest[0] ?? ""] ?? [])
|
|
89
|
+
io.out(name);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
return (context.supervise ?? supervise)(rest, {
|
|
93
|
+
...context.options,
|
|
94
|
+
root: cwd,
|
|
95
|
+
config,
|
|
96
|
+
env,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
io.err(USAGE);
|
|
100
|
+
return 2;
|
|
101
|
+
};
|
|
102
|
+
/** Whether this module is the script node was asked to run, through any symlink. */
|
|
103
|
+
export const isEntry = (entry, moduleUrl) => {
|
|
104
|
+
if (!entry)
|
|
105
|
+
return false;
|
|
106
|
+
try {
|
|
107
|
+
return (realpathSync(resolve(entry)) === realpathSync(fileURLToPath(moduleUrl)));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
/** The process's own output, one line per call. */
|
|
114
|
+
export const processIo = {
|
|
115
|
+
out: (line) => process.stdout.write(`${line}\n`),
|
|
116
|
+
err: (line) => process.stderr.write(`${line}\n`),
|
|
117
|
+
};
|
|
118
|
+
if (isEntry(process.argv[1], import.meta.url)) {
|
|
119
|
+
process.exitCode = await run(process.argv.slice(2), {
|
|
120
|
+
io: processIo,
|
|
121
|
+
cwd: process.cwd(),
|
|
122
|
+
env: process.env,
|
|
123
|
+
});
|
|
124
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// A repository's dvara settings: `.dvara.json` at its root, or the file
|
|
2
|
+
// DVARA_CONFIG names (relative to the root). Every field is optional.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, posix, resolve } from "node:path";
|
|
5
|
+
export const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn"];
|
|
6
|
+
export const DEFAULTS = {
|
|
7
|
+
historyDir: ".dvara",
|
|
8
|
+
quietHooks: ["pre-commit"],
|
|
9
|
+
watchdog: null,
|
|
10
|
+
baseRefs: ["origin/main", "origin/develop", "origin/master"],
|
|
11
|
+
stages: {},
|
|
12
|
+
records: {
|
|
13
|
+
baseline: ".dvara/records-baseline.json",
|
|
14
|
+
skip: [],
|
|
15
|
+
frozen: { paths: [], except: [] },
|
|
16
|
+
identityPaths: [],
|
|
17
|
+
repository: null,
|
|
18
|
+
allowPaths: [],
|
|
19
|
+
packageManager: null,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
export const CONFIG_FILE = ".dvara.json";
|
|
23
|
+
/** The config file's name relative to the root, as `loadConfig` reads it. */
|
|
24
|
+
export const configName = (env) => posix.normalize(env.DVARA_CONFIG || CONFIG_FILE);
|
|
25
|
+
const strings = (value, field) => {
|
|
26
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string"))
|
|
27
|
+
throw new Error(`${field} must be a list of strings`);
|
|
28
|
+
return value;
|
|
29
|
+
};
|
|
30
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
+
const onlyKnown = (entries, known, field) => {
|
|
32
|
+
for (const key of Object.keys(entries))
|
|
33
|
+
if (!known.includes(key))
|
|
34
|
+
throw new Error(`unknown field ${field}${key}`);
|
|
35
|
+
};
|
|
36
|
+
/** Whether `path`, normalized, leaves the root: `..`, `../x`, `a/../../x`. */
|
|
37
|
+
const escapes = (path) => {
|
|
38
|
+
const normal = posix.normalize(path);
|
|
39
|
+
return normal === ".." || normal.startsWith("../");
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Repository-relative path prefixes or paths: none empty, none absolute, none
|
|
43
|
+
* leaving the root (git prints no path there, so it would match nothing).
|
|
44
|
+
*/
|
|
45
|
+
const repoPaths = (value, field) => {
|
|
46
|
+
const list = strings(value, field);
|
|
47
|
+
for (const path of list)
|
|
48
|
+
if (path === "" || isAbsolute(path) || escapes(path))
|
|
49
|
+
throw new Error(`${field} must hold repository-relative paths`);
|
|
50
|
+
return list;
|
|
51
|
+
};
|
|
52
|
+
const patterns = (value, field) => strings(value, field).map((source, i) => {
|
|
53
|
+
try {
|
|
54
|
+
return new RegExp(source);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new Error(`${field}[${i}] is not a regular expression: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
const parseRecordsConfig = (value) => {
|
|
61
|
+
if (!isObject(value))
|
|
62
|
+
throw new Error("records must be an object");
|
|
63
|
+
onlyKnown(value, Object.keys(DEFAULTS.records), "records.");
|
|
64
|
+
const records = { ...DEFAULTS.records };
|
|
65
|
+
if ("baseline" in value) {
|
|
66
|
+
const path = value.baseline;
|
|
67
|
+
// Compared with the paths git prints: `./a/../b.json` is `b.json`. A
|
|
68
|
+
// folder (`.`, `archive/`) names no tracked file, so it is no baseline.
|
|
69
|
+
const normal = typeof path === "string" ? posix.normalize(path) : "";
|
|
70
|
+
if (typeof path !== "string" ||
|
|
71
|
+
path === "" ||
|
|
72
|
+
isAbsolute(path) ||
|
|
73
|
+
escapes(path) ||
|
|
74
|
+
normal === "." ||
|
|
75
|
+
normal.endsWith("/"))
|
|
76
|
+
throw new Error("records.baseline must be a file path inside the repository");
|
|
77
|
+
records.baseline = normal;
|
|
78
|
+
}
|
|
79
|
+
if ("skip" in value)
|
|
80
|
+
records.skip = repoPaths(value.skip, "records.skip");
|
|
81
|
+
if ("frozen" in value) {
|
|
82
|
+
const frozen = value.frozen;
|
|
83
|
+
if (!isObject(frozen))
|
|
84
|
+
throw new Error("records.frozen must be an object");
|
|
85
|
+
onlyKnown(frozen, ["paths", "except"], "records.frozen.");
|
|
86
|
+
records.frozen = {
|
|
87
|
+
paths: "paths" in frozen
|
|
88
|
+
? repoPaths(frozen.paths, "records.frozen.paths")
|
|
89
|
+
: [],
|
|
90
|
+
except: "except" in frozen
|
|
91
|
+
? repoPaths(frozen.except, "records.frozen.except")
|
|
92
|
+
: [],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if ("identityPaths" in value)
|
|
96
|
+
records.identityPaths = patterns(value.identityPaths, "records.identityPaths");
|
|
97
|
+
if ("repository" in value) {
|
|
98
|
+
const repository = value.repository;
|
|
99
|
+
if (repository !== null &&
|
|
100
|
+
(typeof repository !== "string" || !/^[\w.-]+\/[\w.-]+$/.test(repository)))
|
|
101
|
+
throw new Error('records.repository must be "owner/name" or null');
|
|
102
|
+
records.repository = repository;
|
|
103
|
+
}
|
|
104
|
+
if ("allowPaths" in value)
|
|
105
|
+
records.allowPaths = patterns(value.allowPaths, "records.allowPaths");
|
|
106
|
+
if ("packageManager" in value) {
|
|
107
|
+
const manager = value.packageManager;
|
|
108
|
+
if (manager !== null &&
|
|
109
|
+
!PACKAGE_MANAGERS.includes(manager))
|
|
110
|
+
throw new Error(`records.packageManager must be one of ${PACKAGE_MANAGERS.join(", ")}, or null`);
|
|
111
|
+
records.packageManager = manager;
|
|
112
|
+
}
|
|
113
|
+
return records;
|
|
114
|
+
};
|
|
115
|
+
/** The settings in `text` over the defaults; a field of the wrong type is an error, an unknown one too. */
|
|
116
|
+
export const parseConfig = (text) => {
|
|
117
|
+
const raw = JSON.parse(text);
|
|
118
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
119
|
+
throw new Error("the config must be an object");
|
|
120
|
+
const entries = raw;
|
|
121
|
+
const known = new Set(Object.keys(DEFAULTS));
|
|
122
|
+
for (const key of Object.keys(entries))
|
|
123
|
+
if (!known.has(key))
|
|
124
|
+
throw new Error(`unknown field ${key}`);
|
|
125
|
+
const config = { ...DEFAULTS };
|
|
126
|
+
if ("historyDir" in entries) {
|
|
127
|
+
if (typeof entries.historyDir !== "string" || entries.historyDir === "")
|
|
128
|
+
throw new Error("historyDir must be a non-empty string");
|
|
129
|
+
config.historyDir = entries.historyDir;
|
|
130
|
+
}
|
|
131
|
+
if ("quietHooks" in entries)
|
|
132
|
+
config.quietHooks = strings(entries.quietHooks, "quietHooks");
|
|
133
|
+
if ("watchdog" in entries) {
|
|
134
|
+
if (entries.watchdog !== null && typeof entries.watchdog !== "string")
|
|
135
|
+
throw new Error("watchdog must be a path or null");
|
|
136
|
+
config.watchdog = entries.watchdog;
|
|
137
|
+
}
|
|
138
|
+
if ("baseRefs" in entries)
|
|
139
|
+
config.baseRefs = strings(entries.baseRefs, "baseRefs");
|
|
140
|
+
if ("stages" in entries) {
|
|
141
|
+
const stages = entries.stages;
|
|
142
|
+
if (typeof stages !== "object" || stages === null || Array.isArray(stages))
|
|
143
|
+
throw new Error("stages must map each hook to its stage names");
|
|
144
|
+
const out = {};
|
|
145
|
+
for (const [hook, names] of Object.entries(stages))
|
|
146
|
+
out[hook] = strings(names, `stages.${hook}`);
|
|
147
|
+
config.stages = out;
|
|
148
|
+
}
|
|
149
|
+
if ("records" in entries)
|
|
150
|
+
config.records = parseRecordsConfig(entries.records);
|
|
151
|
+
return config;
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* The settings for `root`. No file is the defaults. A file that cannot be
|
|
155
|
+
* parsed is an error: a hook that silently ran on the defaults would write its
|
|
156
|
+
* history somewhere nobody reads.
|
|
157
|
+
*/
|
|
158
|
+
export const loadConfig = (root, env) => {
|
|
159
|
+
const path = resolve(root, env.DVARA_CONFIG || CONFIG_FILE);
|
|
160
|
+
let text;
|
|
161
|
+
try {
|
|
162
|
+
text = readFileSync(path, "utf8");
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (error.code === "ENOENT" && !env.DVARA_CONFIG)
|
|
166
|
+
return DEFAULTS;
|
|
167
|
+
throw new Error(`dvara: cannot read ${path}: ${error.message}`);
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
return parseConfig(text);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
throw new Error(`dvara: ${path}: ${error.message}`);
|
|
174
|
+
}
|
|
175
|
+
};
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Who started a run and on what machine: facts for the history row only.
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
const run = (file, args) => execFileSync(file, args, { encoding: "utf8", timeout: 5000 });
|
|
4
|
+
/**
|
|
5
|
+
* A preflight runs the pre-push hook ahead of a push but is not one: its rows
|
|
6
|
+
* get their own kind, so a reader of push rows never counts it. Git always
|
|
7
|
+
* passes a real push its remote, so one that inherited the mode stays a push.
|
|
8
|
+
*/
|
|
9
|
+
export const hookKind = (hookName, args, env) => hookName === "pre-push" && env.DVARA_MODE === "preflight" && args.length === 0
|
|
10
|
+
? "preflight"
|
|
11
|
+
: hookName;
|
|
12
|
+
/** Parent of each pid in a `ps -axo pid=,ppid=,comm=` table. */
|
|
13
|
+
export const processTable = (text) => {
|
|
14
|
+
const parents = new Map();
|
|
15
|
+
for (const line of text.trim().split("\n")) {
|
|
16
|
+
const match = /^(\d+)\s+(\d+)\s+(.+)$/.exec(line.trim());
|
|
17
|
+
if (!match)
|
|
18
|
+
continue;
|
|
19
|
+
const pid = Number(match[1]);
|
|
20
|
+
const parent = Number(match[2]);
|
|
21
|
+
// A digit run too long for a number is no process.
|
|
22
|
+
if (!Number.isFinite(pid) || !Number.isFinite(parent))
|
|
23
|
+
continue;
|
|
24
|
+
parents.set(pid, { parent, command: match[3] ?? "" });
|
|
25
|
+
}
|
|
26
|
+
return parents;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Who started this run: a benchmark, a background preflight, git, or a person
|
|
30
|
+
* (a parent chain that ends at init without passing git).
|
|
31
|
+
*/
|
|
32
|
+
export const invocationContext = (kind, env, ppid, exec = run) => {
|
|
33
|
+
if (env.DVARA_MEASUREMENT_CONTEXT === "manual-benchmark")
|
|
34
|
+
return "manual-benchmark";
|
|
35
|
+
// A detached background preflight's parent chain ends at init.
|
|
36
|
+
if (kind === "preflight" && env.DVARA_BACKGROUND === "1")
|
|
37
|
+
return "background";
|
|
38
|
+
try {
|
|
39
|
+
const parents = processTable(exec("ps", ["-axo", "pid=,ppid=,comm="]));
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
let pid = ppid;
|
|
42
|
+
for (let entry = parents.get(pid); entry && !seen.has(pid); entry = parents.get(pid)) {
|
|
43
|
+
seen.add(pid);
|
|
44
|
+
if (/(?:^|\/)git$/.test(entry.command))
|
|
45
|
+
return "git-hook";
|
|
46
|
+
pid = entry.parent;
|
|
47
|
+
}
|
|
48
|
+
return pid <= 1 ? "manual" : "unknown";
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "unknown";
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/** The CPU's performance-level names (macOS only), for a watchdog's mode. */
|
|
55
|
+
export const perfLevelNames = (platform, exec = run) => {
|
|
56
|
+
if (platform !== "darwin")
|
|
57
|
+
return [];
|
|
58
|
+
try {
|
|
59
|
+
const levels = Number(exec("sysctl", ["-n", "hw.nperflevels"]));
|
|
60
|
+
if (!Number.isFinite(levels))
|
|
61
|
+
return [];
|
|
62
|
+
return Array.from({ length: levels }, (_, i) => exec("sysctl", ["-n", `hw.perflevel${i}.name`]).trim());
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
};
|
package/dist/quiet.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Quiet on success. A passing commit hook that prints thirty lines of progress
|
|
2
|
+
// costs an agent session those lines on every later request, and says nothing
|
|
3
|
+
// one line cannot. The supervisor holds a quiet hook's output: on failure it is
|
|
4
|
+
// shown in full; on success it becomes the hook's warnings plus one summary
|
|
5
|
+
// line, and the full output is kept in a log. DVARA_VERBOSE=1 streams it.
|
|
6
|
+
/** Whether this run's output is held rather than streamed. */
|
|
7
|
+
export const isQuiet = (kind, quietHooks, env) => quietHooks.includes(kind) && env.DVARA_VERBOSE !== "1";
|
|
8
|
+
/**
|
|
9
|
+
* The warnings a passing run still shows: each line starting with ⚠ and the
|
|
10
|
+
* indented lines that continue it. A warning qualifies a pass; hiding it would
|
|
11
|
+
* turn the pass into an unqualified one.
|
|
12
|
+
*/
|
|
13
|
+
export const successNotices = (text) => {
|
|
14
|
+
const kept = [];
|
|
15
|
+
let inNotice = false;
|
|
16
|
+
for (const line of text.split("\n")) {
|
|
17
|
+
if (line.trimStart().startsWith("⚠")) {
|
|
18
|
+
inNotice = true;
|
|
19
|
+
kept.push(line);
|
|
20
|
+
}
|
|
21
|
+
else if (inNotice && /^\s+\S/.test(line)) {
|
|
22
|
+
kept.push(line);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
inNotice = false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return kept;
|
|
29
|
+
};
|
|
30
|
+
/** The one line a passing quiet run prints. */
|
|
31
|
+
export const successLine = (kind, durationMs, logPath) => `✅ ${kind} passed in ${(durationMs / 1000).toFixed(1)}s (full output: ${logPath})`;
|