@veda-hq/dvara 0.5.4 → 0.5.16
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 +24 -9
- package/dist/cli.js +10 -1
- package/dist/config.js +21 -8
- package/dist/stages.js +106 -0
- package/dist/supervisor.js +19 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,8 +29,21 @@ file in your repository; dvara runs it.
|
|
|
29
29
|
attaches to each run; it may stop a hung hook and decide the verdict. Without
|
|
30
30
|
one, or when it fails to load, the hook runs exactly as it would anyway.
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
- **A stage list, if you keep one.** `stages`, or the JSON file `stageList`
|
|
33
|
+
names (inside an installed package, say), gives each hook's stage names in
|
|
34
|
+
order. A passing run that marks a stage its hook's list does not have, or
|
|
35
|
+
marks one out of the list's order, is refused (exit 1) with the reason. So is
|
|
36
|
+
a run of a hook in `stageListRequired` (default `pre-push`) that marks a
|
|
37
|
+
stage and whose list file cannot be read, so a missing package never lets a
|
|
38
|
+
push that ran its stages through silently; any other hook passes then, with a
|
|
39
|
+
warning that its stages were not checked. A
|
|
40
|
+
stage may be marked again where it stands, a name may
|
|
41
|
+
appear more than once in a list, and dvara's own `startup` and
|
|
42
|
+
`runtime-selection` need no entry. A run that marks no stage reads no list.
|
|
43
|
+
|
|
44
|
+
Telemetry, quiet output and a watchdog that fails to load never change a
|
|
45
|
+
hook's verdict. Only a signal, an enforcing watchdog's stop and the stage list
|
|
46
|
+
do.
|
|
34
47
|
|
|
35
48
|
## A hook under dvara
|
|
36
49
|
|
|
@@ -73,13 +86,15 @@ rename it in its messages (default "preflight" and "Preflight").
|
|
|
73
86
|
`.dvara.json` at the repository's root, or the file `DVARA_CONFIG` names. Every
|
|
74
87
|
field is optional; an unknown field is an error.
|
|
75
88
|
|
|
76
|
-
| Field
|
|
77
|
-
|
|
|
78
|
-
| `historyDir`
|
|
79
|
-
| `quietHooks`
|
|
80
|
-
| `watchdog`
|
|
81
|
-
| `baseRefs`
|
|
82
|
-
| `stages`
|
|
89
|
+
| Field | Default | Meaning |
|
|
90
|
+
| ------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
|
91
|
+
| `historyDir` | `.dvara` | where the history, the quiet log and scratch files go |
|
|
92
|
+
| `quietHooks` | `["pre-commit"]` | hooks whose passing output is one line |
|
|
93
|
+
| `watchdog` | `null` | a watchdog module, relative to the root |
|
|
94
|
+
| `baseRefs` | `origin/main`, `origin/develop`, `origin/master` | the refs tried for the base a run is judged against |
|
|
95
|
+
| `stages` | `{}` | each hook's stages in order, which a passing run must keep to; `dvara stages <hook>` prints them |
|
|
96
|
+
| `stageList` | `null` | a JSON file holding the same map instead, relative to the root and read when a run ends; not both |
|
|
97
|
+
| `stageListRequired` | `["pre-push"]` | hooks a list file that cannot be read refuses; any other hook passes with a warning |
|
|
83
98
|
|
|
84
99
|
`records`, an object, holds the settings of `dvara records` and `dvara frozen`
|
|
85
100
|
(below). Each field is optional; an unknown one is an error.
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { configName, loadConfig } from "./config.js";
|
|
|
7
7
|
import { frozen, parseArgs as parseFrozenArgs, USAGE as FROZEN_USAGE, } from "./records/frozen.js";
|
|
8
8
|
import { parseArgs as parseRecordsArgs, records, USAGE as RECORDS_USAGE, } from "./records/gate.js";
|
|
9
9
|
import { runner } from "./records/git.js";
|
|
10
|
+
import { listFor, readStageLists } from "./stages.js";
|
|
10
11
|
import { supervise } from "./supervisor.js";
|
|
11
12
|
export const USAGE = [
|
|
12
13
|
"usage: dvara run <hook-file> [hook args…] run a git hook under the supervisor",
|
|
@@ -85,7 +86,15 @@ export const run = async (argv, context) => {
|
|
|
85
86
|
return 2;
|
|
86
87
|
}
|
|
87
88
|
if (command === "stages") {
|
|
88
|
-
|
|
89
|
+
let lists;
|
|
90
|
+
try {
|
|
91
|
+
lists = readStageLists(cwd, config);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
io.err(error.message);
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
for (const name of lists ? (listFor(lists, rest[0] ?? "") ?? []) : [])
|
|
89
98
|
io.out(name);
|
|
90
99
|
return 0;
|
|
91
100
|
}
|
package/dist/config.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// DVARA_CONFIG names (relative to the root). Every field is optional.
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { isAbsolute, posix, resolve } from "node:path";
|
|
5
|
+
import { parseStageLists } from "./stages.js";
|
|
5
6
|
export const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn"];
|
|
6
7
|
export const DEFAULTS = {
|
|
7
8
|
historyDir: ".dvara",
|
|
@@ -9,6 +10,8 @@ export const DEFAULTS = {
|
|
|
9
10
|
watchdog: null,
|
|
10
11
|
baseRefs: ["origin/main", "origin/develop", "origin/master"],
|
|
11
12
|
stages: {},
|
|
13
|
+
stageList: null,
|
|
14
|
+
stageListRequired: ["pre-push"],
|
|
12
15
|
records: {
|
|
13
16
|
baseline: ".dvara/records-baseline.json",
|
|
14
17
|
skip: [],
|
|
@@ -137,15 +140,25 @@ export const parseConfig = (text) => {
|
|
|
137
140
|
}
|
|
138
141
|
if ("baseRefs" in entries)
|
|
139
142
|
config.baseRefs = strings(entries.baseRefs, "baseRefs");
|
|
140
|
-
if ("stages" in entries)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
if ("stages" in entries)
|
|
144
|
+
config.stages = parseStageLists(entries.stages, "stages");
|
|
145
|
+
if ("stageList" in entries) {
|
|
146
|
+
const path = entries.stageList;
|
|
147
|
+
if (path !== null &&
|
|
148
|
+
(typeof path !== "string" ||
|
|
149
|
+
path === "" ||
|
|
150
|
+
isAbsolute(path) ||
|
|
151
|
+
escapes(path) ||
|
|
152
|
+
// Read with the platform's resolve: on Windows `..\x` and `C:x` leave the root.
|
|
153
|
+
path.includes("\\") ||
|
|
154
|
+
/^[A-Za-z]:/.test(path)))
|
|
155
|
+
throw new Error("stageList must be a file path inside the repository, with / separators, or null");
|
|
156
|
+
if (path !== null && "stages" in entries)
|
|
157
|
+
throw new Error("give stages or stageList, not both");
|
|
158
|
+
config.stageList = path;
|
|
148
159
|
}
|
|
160
|
+
if ("stageListRequired" in entries)
|
|
161
|
+
config.stageListRequired = strings(entries.stageListRequired, "stageListRequired");
|
|
149
162
|
if ("records" in entries)
|
|
150
163
|
config.records = parseRecordsConfig(entries.records);
|
|
151
164
|
return config;
|
package/dist/stages.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Each hook's stage names, in the order they run, and the check a passing run
|
|
2
|
+
// must pass: every stage it marks is in its hook's list, in the list's order.
|
|
3
|
+
// The lists are `stages` in the config, or the JSON file `stageList` names
|
|
4
|
+
// (inside an installed package, say), read when the run ends: a pre-commit
|
|
5
|
+
// that installs dependencies first finds the file its install put there.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
/** The stages dvara marks itself, which no list names. */
|
|
9
|
+
export const OWN_STAGES = new Set([
|
|
10
|
+
"startup",
|
|
11
|
+
"runtime-selection",
|
|
12
|
+
]);
|
|
13
|
+
/** A name a stage file line can carry: not empty, no tab or line break (records.ts splits on them). */
|
|
14
|
+
const isStageName = (name) => typeof name === "string" && name !== "" && !/[\t\r\n]/.test(name);
|
|
15
|
+
/** Each hook's names, from `value`; anything else is an error naming `field`. */
|
|
16
|
+
export const parseStageLists = (value, field) => {
|
|
17
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
18
|
+
throw new Error(`${field} must map each hook to its stage names`);
|
|
19
|
+
const entries = Object.entries(value).map(([hook, names]) => {
|
|
20
|
+
if (!Array.isArray(names) || !names.every(isStageName))
|
|
21
|
+
throw new Error(`${field}.${hook} must be a list of stage names`);
|
|
22
|
+
return [hook, names];
|
|
23
|
+
});
|
|
24
|
+
// fromEntries defines each key as its own, `__proto__` included.
|
|
25
|
+
return Object.fromEntries(entries);
|
|
26
|
+
};
|
|
27
|
+
/** `hook`'s own list, never one inherited from Object's prototype (`toString`). */
|
|
28
|
+
export const listFor = (lists, hook) => Object.hasOwn(lists, hook) ? lists[hook] : undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The lists a run is checked against: the file `stageList` names, else
|
|
31
|
+
* `stages`, else null (no lists: nothing is checked). A file that cannot be
|
|
32
|
+
* read or parsed is an error naming it.
|
|
33
|
+
*/
|
|
34
|
+
export const readStageLists = (root, config) => {
|
|
35
|
+
if (config.stageList === null)
|
|
36
|
+
return Object.keys(config.stages).length > 0 ? config.stages : null;
|
|
37
|
+
const path = resolve(root, config.stageList);
|
|
38
|
+
let text;
|
|
39
|
+
try {
|
|
40
|
+
text = readFileSync(path, "utf8");
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw new Error(`dvara: cannot read the stage list ${path}: ${error.message}. Is the package that ships it installed?`);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return parseStageLists(JSON.parse(text), "the stage list");
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
throw new Error(`dvara: ${path}: ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Why `marked` breaks `hook`'s list, or null when it keeps to it. A stage may
|
|
54
|
+
* be marked again where it stands (a loop), and a name may appear more than
|
|
55
|
+
* once in a list; each mark takes the first place at or after the last one's.
|
|
56
|
+
*/
|
|
57
|
+
export const stageViolation = (hook, marked, lists) => {
|
|
58
|
+
const names = marked.filter((name) => !OWN_STAGES.has(name));
|
|
59
|
+
if (names.length === 0)
|
|
60
|
+
return null;
|
|
61
|
+
const list = listFor(lists, hook);
|
|
62
|
+
if (list === undefined)
|
|
63
|
+
return `${hook} marked stage "${names[0]}", but the stage list has no ${hook} list`;
|
|
64
|
+
let at = 0;
|
|
65
|
+
for (const [index, name] of names.entries()) {
|
|
66
|
+
const found = list.indexOf(name, at);
|
|
67
|
+
if (found >= 0) {
|
|
68
|
+
at = found;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!list.includes(name))
|
|
72
|
+
return `${hook} marked stage "${name}", which its stage list does not have`;
|
|
73
|
+
return `${hook} marked stage "${name}" after "${names[index - 1] ?? ""}", out of its stage list's order`;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
};
|
|
77
|
+
const PASS = { refusal: null, warning: null };
|
|
78
|
+
/**
|
|
79
|
+
* What the stage list says of a passing run of `hook` that marked `marked`.
|
|
80
|
+
* With no stage of its own marked nothing is read. A list that cannot be read
|
|
81
|
+
* refuses a hook in `required`, since a check that cannot run must not pass
|
|
82
|
+
* there; any other hook passes with a warning saying it was not checked.
|
|
83
|
+
*/
|
|
84
|
+
export const stageVerdict = (hook, marked, lists, required) => {
|
|
85
|
+
if (marked.every((name) => OWN_STAGES.has(name)))
|
|
86
|
+
return PASS;
|
|
87
|
+
let read;
|
|
88
|
+
try {
|
|
89
|
+
read = lists();
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const message = error.message;
|
|
93
|
+
return required.includes(hook)
|
|
94
|
+
? { refusal: message, warning: null }
|
|
95
|
+
: {
|
|
96
|
+
refusal: null,
|
|
97
|
+
warning: `${message} ${hook}'s stages were not checked.`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (read === null)
|
|
101
|
+
return PASS;
|
|
102
|
+
const violation = stageViolation(hook, marked, read);
|
|
103
|
+
return violation === null
|
|
104
|
+
? PASS
|
|
105
|
+
: { refusal: `dvara: ${violation}.`, warning: null };
|
|
106
|
+
};
|
package/dist/supervisor.js
CHANGED
|
@@ -7,9 +7,12 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
9
|
// The hook supervisor: runs a git hook under bash, forwards the terminal's
|
|
10
|
-
// signals to it, and writes one history row per run. It
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// signals to it, and writes one history row per run. It changes the hook's
|
|
11
|
+
// verdict only to report a signalled run as cancelled, to let an enforcing
|
|
12
|
+
// watchdog's stop decide it, and to refuse a passing run that broke its stage
|
|
13
|
+
// list (stages.ts), or, for a hook in stageListRequired, whose list cannot be
|
|
14
|
+
// read. Every other failure of its
|
|
15
|
+
// own (telemetry, watchdog, quiet output) is fail-open.
|
|
13
16
|
import { execFileSync, spawn, spawnSync, } from "node:child_process";
|
|
14
17
|
import { createHash, randomUUID } from "node:crypto";
|
|
15
18
|
import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
@@ -19,6 +22,7 @@ import { pathToFileURL } from "node:url";
|
|
|
19
22
|
import { hookKind, invocationContext, perfLevelNames } from "./context.js";
|
|
20
23
|
import { isQuiet, successLine, successNotices } from "./quiet.js";
|
|
21
24
|
import { parseRecords, parseStages, treeUsage } from "./records.js";
|
|
25
|
+
import { readStageLists, stageVerdict } from "./stages.js";
|
|
22
26
|
/** Exit code per signal, shared by the pre-launch path and the exit handler. */
|
|
23
27
|
export const SIGNAL_EXIT_CODES = {
|
|
24
28
|
SIGINT: 130,
|
|
@@ -278,9 +282,19 @@ export const supervise = async ([hook, ...args], options) => {
|
|
|
278
282
|
if (imposed)
|
|
279
283
|
code = imposed.code;
|
|
280
284
|
const ended = Date.now();
|
|
285
|
+
const marks = parseStages(readOrNull(events), bootstrapMs);
|
|
286
|
+
// Only a run that would pass is checked: a failure keeps its own story.
|
|
287
|
+
const { refusal, warning } = code === 0 && !imposed && !signal
|
|
288
|
+
? stageVerdict(hookName, marks.map((mark) => mark.name), () => readStageLists(root, config), config.stageListRequired)
|
|
289
|
+
: { refusal: null, warning: null };
|
|
290
|
+
if (refusal !== null)
|
|
291
|
+
code = 1;
|
|
281
292
|
if (quiet)
|
|
282
293
|
report(code, ended - started);
|
|
283
|
-
|
|
294
|
+
if (refusal !== null)
|
|
295
|
+
stderr(`${refusal}\n`);
|
|
296
|
+
if (warning !== null)
|
|
297
|
+
stderr(`⚠️ ${warning}\n`);
|
|
284
298
|
const { pushed, declared, proof, proofDiagnostics } = parseRecords(readOrNull(records));
|
|
285
299
|
const verdict = (last) => imposed
|
|
286
300
|
? imposed.status
|
|
@@ -310,7 +324,7 @@ export const supervise = async ([hook, ...args], options) => {
|
|
|
310
324
|
durationMs: ended - started,
|
|
311
325
|
exitCode: code,
|
|
312
326
|
signal,
|
|
313
|
-
error,
|
|
327
|
+
error: error ?? refusal ?? warning ?? undefined,
|
|
314
328
|
status: verdict(false),
|
|
315
329
|
branch,
|
|
316
330
|
fingerprint,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veda-hq/dvara",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.16",
|
|
4
4
|
"description": "Git hooks for repositories coding agents work in: runs each hook under a supervisor that times its stages, forwards signals and keeps one history row per run, with quiet output on success and a keep-going mode for preflights.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"git hooks",
|