@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 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
- Every one of these is fail-open: telemetry, quiet output and the watchdog never
33
- change a hook's verdict.
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 | 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 |
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
- for (const name of config.stages[rest[0] ?? ""] ?? [])
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
- 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;
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
+ };
@@ -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 never changes the
11
- // hook's verdict except to report a signalled run as cancelled; every failure
12
- // of its own (telemetry, watchdog, quiet output) is fail-open.
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
- const marks = parseStages(readOrNull(events), bootstrapMs);
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.4",
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",