@veda-hq/dvara 0.5.4 → 0.6.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/README.md CHANGED
@@ -29,8 +29,28 @@ 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
+ - **A push judges what git pushes.** Git picks the commits a push sends
44
+ before `pre-push` starts; the hook's checks read the worktree. A run of
45
+ `pre-push` (not a preflight) records HEAD when it starts and again when the
46
+ hook ends, and a passing run whose HEAD moved in between (something
47
+ committed, merged or checked out in the worktree mid-push) is refused
48
+ (exit 1), naming both commits: push again. A HEAD that cannot be read at
49
+ either end changes nothing. The refusal is the row's `error`.
50
+
51
+ Telemetry, quiet output and a watchdog that fails to load never change a
52
+ hook's verdict. Only a signal, an enforcing watchdog's stop, the stage list
53
+ and a HEAD that moved during a push do.
34
54
 
35
55
  ## A hook under dvara
36
56
 
@@ -73,13 +93,16 @@ rename it in its messages (default "preflight" and "Preflight").
73
93
  `.dvara.json` at the repository's root, or the file `DVARA_CONFIG` names. Every
74
94
  field is optional; an unknown field is an error.
75
95
 
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 |
96
+ | Field | Default | Meaning |
97
+ | ------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
98
+ | `historyDir` | `.dvara` | where the history, the quiet log and scratch files go |
99
+ | `quietHooks` | `["pre-commit"]` | hooks whose passing output is one line |
100
+ | `watchdog` | `null` | a watchdog module, relative to the root |
101
+ | `baseRefs` | `origin/main`, `origin/develop`, `origin/master` | the refs tried for the base a run is judged against; to `dvara records`, every one that resolves is pushed history |
102
+ | `stages` | `{}` | each hook's stages in order, which a passing run must keep to; `dvara stages <hook>` prints them |
103
+ | `stageList` | `null` | a JSON file holding the same map instead, relative to the root and read when a run ends; not both |
104
+ | `stageListRequired` | `["pre-push"]` | hooks a list file that cannot be read refuses; any other hook passes with a warning |
105
+ | `trailerKeys` | `[]` | the repository's own trailer keys, which `dvara trailers` checks beside git's |
83
106
 
84
107
  `records`, an object, holds the settings of `dvara records` and `dvara frozen`
85
108
  (below). Each field is optional; an unknown one is an error.
@@ -152,6 +175,13 @@ grows and clones disagree on their length:
152
175
  - `unresolved-id`: a full id the change adds that is not a commit in this
153
176
  repository, compared with the ids the file cited at the merge base. No
154
177
  baseline holds these: they are new by construction.
178
+ - `unpushed-id`: a full id the change adds that is a commit here but on no
179
+ pushed history: neither HEAD (nor, at pre-commit, a merge's other parent)
180
+ nor any of `baseRefs` or `--base` reaches it. A commit made here and never
181
+ pushed, or one on a branch since deleted, resolves in this clone and in no
182
+ CI clone. One `git rev-list` answers for every citation. A shallow clone
183
+ cannot tell where a commit stands, so it judges none. No baseline holds
184
+ these either.
155
185
 
156
186
  In Markdown, a fence's captured output is not read for tokens; its command
157
187
  lines are (`$ ` lines of a transcript, every line of an `sh` fence). Fences are
@@ -190,6 +220,26 @@ In a merge, a path staged exactly as a merged-in parent has it is that
190
220
  parent's change and passes; a hand resolution counts. With no frozen paths
191
221
  configured it passes without asking git. Exit 0, 1 or 2 as above.
192
222
 
223
+ ## Trailers: `dvara trailers`
224
+
225
+ Git reads a message's trailers only from its last paragraph, so a trailer
226
+ line above a blank line is prose to git: the commit lands without it, and
227
+ nothing says so. For a commit-msg hook, `dvara trailers <commit-msg-file>`
228
+ refuses (exit 1) a line whose key is `Co-Authored-By`, `Signed-off-by` or one
229
+ of `trailerKeys` or a `--key KEY` (any case, `Key: value` at the start of the line) in any
230
+ paragraph before the last, or in the title's paragraph, which git never reads
231
+ trailers from (so a message of one paragraph keeps none). The title line
232
+ itself is never judged. Each is named with its line number. The file is
233
+ read raw: comment lines (`#`) are dropped, the scissors line ends the
234
+ message, whitespace-only lines separate paragraphs and trailing ones start
235
+ none. Exit 2: bad arguments, or a config or file it cannot read.
236
+
237
+ ```bash
238
+ #!/usr/bin/env bash
239
+ # .husky/commit-msg
240
+ exec node node_modules/@veda-hq/dvara/dist/cli.js trailers --key Slice "$1"
241
+ ```
242
+
193
243
  ## Commands
194
244
 
195
245
  ```sh
@@ -198,5 +248,6 @@ dvara stages <hook> # the stages .dvara.json lists for it
198
248
  dvara shell <stages|keep-going> # the path of a shell helper
199
249
  dvara records <--staged | --all [--base REF] [--write-baseline | --survey] | --base REF>
200
250
  dvara frozen <--staged | --base REF [--head REF]>
251
+ dvara trailers [--key KEY]… <commit-msg-file> # refuse trailer lines git would not read
201
252
  dvara --version
202
253
  ```
package/dist/cli.js CHANGED
@@ -7,7 +7,9 @@ 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";
12
+ import { GIT_TRAILER_KEYS, misplacedTrailers, refusal } from "./trailers.js";
11
13
  export const USAGE = [
12
14
  "usage: dvara run <hook-file> [hook args…] run a git hook under the supervisor",
13
15
  " dvara stages <hook> the hook stages the config lists, one per line",
@@ -16,8 +18,11 @@ export const USAGE = [
16
18
  " the record gate: no new non-durable paths, a baseline that only falls",
17
19
  " dvara frozen <--staged | --base REF [--head REF]>",
18
20
  " refuse changes to the frozen paths",
21
+ " dvara trailers [--key KEY]… <commit-msg-file>",
22
+ " refuse trailer lines git would not read (commit-msg)",
19
23
  " dvara --version",
20
24
  ].join("\n");
25
+ export const TRAILERS_USAGE = "usage: dvara trailers [--key KEY]… <commit-msg-file>";
21
26
  const SHELL_HELPERS = new Set(["stages", "keep-going"]);
22
27
  export const ownVersion = () => {
23
28
  const pkg = join(import.meta.dirname, "..", "package.json");
@@ -25,19 +30,80 @@ export const ownVersion = () => {
25
30
  };
26
31
  /** The path of a shell helper this package ships. */
27
32
  export const shellHelper = (name) => resolve(import.meta.dirname, "..", "shell", `${name}.sh`);
33
+ const CHECK_USAGE = {
34
+ records: RECORDS_USAGE,
35
+ frozen: FROZEN_USAGE,
36
+ trailers: TRAILERS_USAGE,
37
+ };
38
+ /** A trailer key, as `--key` and `trailerKeys` take one. */
39
+ const TRAILER_KEY = /^[A-Za-z0-9-]+$/;
40
+ /**
41
+ * `dvara trailers` takes the commit message file git passes, and any number
42
+ * of `--key <Key>`: a repository's own keys, given on the command line so a
43
+ * hook can name them without a config field an older dvara would refuse.
44
+ */
45
+ export const parseTrailersArgs = (args) => {
46
+ const files = [];
47
+ const keys = [];
48
+ for (let i = 0; i < args.length; i += 1) {
49
+ const arg = args[i] ?? "";
50
+ if (arg !== "--key") {
51
+ files.push(arg);
52
+ continue;
53
+ }
54
+ const key = args[i + 1];
55
+ if (key === undefined || !TRAILER_KEY.test(key))
56
+ return { error: "--key takes a trailer key: letters, digits and -" };
57
+ keys.push(key);
58
+ i += 1;
59
+ }
60
+ const [file] = files;
61
+ return file === undefined || files.length !== 1
62
+ ? { error: "give one commit message file" }
63
+ : { file, keys };
64
+ };
65
+ /**
66
+ * `dvara trailers <file>`: 1 when a known trailer line sits above the last
67
+ * paragraph. The file is read relative to `cwd`, as git runs a hook at the
68
+ * top of the working tree and passes `.git/COMMIT_EDITMSG`.
69
+ */
70
+ const trailers = (file, keys, { io, cwd }) => {
71
+ let text;
72
+ try {
73
+ text = readFileSync(resolve(cwd, file), "utf8");
74
+ }
75
+ catch (error) {
76
+ throw new Error(`cannot read ${file}: ${error.message}`, {
77
+ cause: error,
78
+ });
79
+ }
80
+ const misplaced = misplacedTrailers(text, [...GIT_TRAILER_KEYS, ...keys]);
81
+ if (misplaced.length === 0)
82
+ return 0;
83
+ for (const line of refusal(file, misplaced))
84
+ io.err(line);
85
+ return 1;
86
+ };
28
87
  /**
29
- * `dvara records` or `dvara frozen` at the root `cwd`: 2 on bad arguments, a
30
- * config it cannot read, or git failing.
88
+ * `dvara records`, `dvara frozen` or `dvara trailers` at the root `cwd`: 2 on
89
+ * bad arguments, a config or file it cannot read, or git failing.
31
90
  */
32
- const checkCommand = (command, args, { io, cwd, env }) => {
33
- const parsed = command === "records" ? parseRecordsArgs(args) : parseFrozenArgs(args);
91
+ const checkCommand = (command, args, context) => {
92
+ const { io, cwd, env } = context;
93
+ const parsed = command === "records"
94
+ ? parseRecordsArgs(args)
95
+ : command === "frozen"
96
+ ? parseFrozenArgs(args)
97
+ : parseTrailersArgs(args);
34
98
  if ("error" in parsed) {
35
99
  io.err(`dvara ${command}: ${parsed.error}`);
36
- io.err(command === "records" ? RECORDS_USAGE : FROZEN_USAGE);
100
+ io.err(CHECK_USAGE[command]);
37
101
  return 2;
38
102
  }
39
103
  try {
40
104
  const config = loadConfig(cwd, env);
105
+ if ("file" in parsed)
106
+ return trailers(parsed.file, [...config.trailerKeys, ...parsed.keys], context);
41
107
  const repo = { cwd, env };
42
108
  if ("mode" in parsed)
43
109
  return records(parsed, repo, io.out, {
@@ -71,7 +137,7 @@ export const run = async (argv, context) => {
71
137
  io.out(shellHelper(rest[0] ?? ""));
72
138
  return 0;
73
139
  }
74
- if (command === "records" || command === "frozen")
140
+ if (command === "records" || command === "frozen" || command === "trailers")
75
141
  return checkCommand(command, rest, context);
76
142
  if ((command === "run" && rest.length > 0) ||
77
143
  (command === "stages" && rest.length === 1)) {
@@ -85,7 +151,15 @@ export const run = async (argv, context) => {
85
151
  return 2;
86
152
  }
87
153
  if (command === "stages") {
88
- for (const name of config.stages[rest[0] ?? ""] ?? [])
154
+ let lists;
155
+ try {
156
+ lists = readStageLists(cwd, config);
157
+ }
158
+ catch (error) {
159
+ io.err(error.message);
160
+ return 2;
161
+ }
162
+ for (const name of lists ? (listFor(lists, rest[0] ?? "") ?? []) : [])
89
163
  io.out(name);
90
164
  return 0;
91
165
  }
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,9 @@ 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"],
15
+ trailerKeys: [],
12
16
  records: {
13
17
  baseline: ".dvara/records-baseline.json",
14
18
  skip: [],
@@ -137,14 +141,31 @@ export const parseConfig = (text) => {
137
141
  }
138
142
  if ("baseRefs" in entries)
139
143
  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;
144
+ if ("stages" in entries)
145
+ config.stages = parseStageLists(entries.stages, "stages");
146
+ if ("stageList" in entries) {
147
+ const path = entries.stageList;
148
+ if (path !== null &&
149
+ (typeof path !== "string" ||
150
+ path === "" ||
151
+ isAbsolute(path) ||
152
+ escapes(path) ||
153
+ // Read with the platform's resolve: on Windows `..\x` and `C:x` leave the root.
154
+ path.includes("\\") ||
155
+ /^[A-Za-z]:/.test(path)))
156
+ throw new Error("stageList must be a file path inside the repository, with / separators, or null");
157
+ if (path !== null && "stages" in entries)
158
+ throw new Error("give stages or stageList, not both");
159
+ config.stageList = path;
160
+ }
161
+ if ("stageListRequired" in entries)
162
+ config.stageListRequired = strings(entries.stageListRequired, "stageListRequired");
163
+ if ("trailerKeys" in entries) {
164
+ const keys = strings(entries.trailerKeys, "trailerKeys");
165
+ // Git reads a key as letters, digits and `-`; a key with other characters matches no line.
166
+ if (!keys.every((key) => /^[A-Za-z0-9-]+$/.test(key)))
167
+ throw new Error("trailerKeys must hold trailer keys: letters, digits and -");
168
+ config.trailerKeys = keys;
148
169
  }
149
170
  if ("records" in entries)
150
171
  config.records = parseRecordsConfig(entries.records);
package/dist/head.js ADDED
@@ -0,0 +1,30 @@
1
+ // HEAD at both ends of a push hook. Git has chosen what it pushes before
2
+ // pre-push starts; the hook's checks read the worktree. Something that
3
+ // commits, merges or checks out in the worktree while the hook runs (an app
4
+ // merging main in, a second session) leaves the checks judging one commit
5
+ // while git pushes another, and the push goes out unchecked.
6
+ import { execFileSync } from "node:child_process";
7
+ /** The commit HEAD names, or null when git cannot say (no commit yet, no repository). */
8
+ export const readHead = (root, env) => {
9
+ try {
10
+ return execFileSync("git", ["rev-parse", "HEAD"], {
11
+ cwd: root,
12
+ env,
13
+ // git's complaint is not the hook's output.
14
+ stdio: "pipe",
15
+ })
16
+ .toString()
17
+ .trim();
18
+ }
19
+ catch {
20
+ // fail-open: a HEAD git cannot read is no evidence that it moved.
21
+ return null;
22
+ }
23
+ };
24
+ /**
25
+ * The refusal for a hook during which HEAD moved, or null. A HEAD that could
26
+ * not be read at either end is no evidence of a move, so it refuses nothing.
27
+ */
28
+ export const headMoved = (hook, start, end) => start === null || end === null || start === end
29
+ ? null
30
+ : `dvara: HEAD moved from ${start} to ${end} while ${hook} ran (something committed, merged or checked out in this worktree mid-push), so the checks did not judge what git is pushing. Push again.`;
@@ -241,7 +241,14 @@ const runChecks = (options, repo, log, settings) => {
241
241
  baseRenames ??= renamesIn(repo, planned.ref === ":" ? ["--cached", base] : [base, "HEAD"]);
242
242
  return baseRenames.get(path);
243
243
  };
244
- const tree = treeContext(repo, planned.ref, planned.branchBase, renamedFrom);
244
+ // Pushed history: HEAD's, and that of the ref CI names and the base refs.
245
+ const pushed = [
246
+ ...new Set([
247
+ ...(options.base === null ? [] : [options.base]),
248
+ ...settings.baseRefs,
249
+ ]),
250
+ ];
251
+ const tree = treeContext(repo, planned.ref, planned.branchBase, renamedFrom, pushed);
245
252
  const recording = options.writeBaseline || options.survey;
246
253
  let baseline = { files: pathMap() };
247
254
  let before = null;
@@ -195,9 +195,32 @@ export const resolveRef = (repo, ref) => {
195
195
  ]);
196
196
  return out === null ? null : out.trim();
197
197
  };
198
+ /**
199
+ * Of `commits`, the ones no commit of `tips` reaches, in one walk:
200
+ * `git rev-list <commits> --not <tips>` prints exactly the commits reachable
201
+ * from the first and from none of the second, so a cited commit that is an
202
+ * ancestor of a tip (or a tip itself) is not printed. The walk covers only
203
+ * what the tips do not reach, which for a branch's citations is a handful of
204
+ * commits. Only commits are asked: rev-list walks commits and lists no blob
205
+ * or tree, so one passed here would read as reached.
206
+ */
207
+ export const unreachable = (repo, commits, tips) => {
208
+ if (commits.length === 0)
209
+ return new Set();
210
+ const input = [...commits, ...tips.map((tip) => `^${tip}`)].join("\n");
211
+ const printed = new Set(git(repo, ["rev-list", "--stdin"], `${input}\n`).split("\n"));
212
+ return new Set(commits.filter((commit) => printed.has(commit)));
213
+ };
214
+ /**
215
+ * Whether the clone is shallow. Its history stops at a cut, so a commit
216
+ * below the cut that the store has anyway cannot be shown an ancestor of
217
+ * anything: a shallow clone cannot judge where a commit stands.
218
+ */
219
+ export const isShallow = (repo) => git(repo, ["rev-parse", "--is-shallow-repository"]).trim() === "true";
198
220
  export const noQuestions = () => ({
199
221
  shas: new Set(),
200
222
  bases: new Set(),
223
+ history: new Set(),
201
224
  });
202
225
  /**
203
226
  * A context that answers without git and records each question. Running the
@@ -210,6 +233,13 @@ export const recordingContext = (tree, asked) => ({
210
233
  asked.shas.add(sha);
211
234
  return new Map(shas.map((sha) => [sha, "commit"]));
212
235
  },
236
+ // Every id passed for a commit above, so blobs and missing ids are asked
237
+ // here too: `warm` sends git only the ones that are commits.
238
+ onHistory: (commits) => {
239
+ for (const commit of commits)
240
+ asked.history.add(commit);
241
+ return null;
242
+ },
213
243
  baseText: (path) => {
214
244
  if (typeof tree.base !== "string")
215
245
  return tree.base;
@@ -220,13 +250,57 @@ export const recordingContext = (tree, asked) => ({
220
250
  /** A tree context over one git tree, with an optional merge base for branch rules. */
221
251
  export const treeContext = (repo, ref, base,
222
252
  /** The path a file had at the base, when the branch renamed it. */
223
- renamedFrom = () => undefined) => {
253
+ renamedFrom = () => undefined,
254
+ /**
255
+ * The base refs whose history counts as pushed, beside HEAD: a cited
256
+ * commit off all of them exists in this clone only.
257
+ */
258
+ pushed = []) => {
224
259
  const modes = listModes(repo, ref);
225
260
  const present = new Set(modes.keys());
226
261
  const cache = new Map();
227
262
  const baseCache = new Map();
228
263
  /** Object type by name; null when the store does not have it. */
229
264
  const objects = new Map();
265
+ /** Whether each commit judged is off pushed history. */
266
+ const off = new Map();
267
+ let standing = null;
268
+ /** What pushed history is here, asked once and only when a commit needs it. */
269
+ const history = () => {
270
+ if (standing !== null)
271
+ return standing;
272
+ if (isShallow(repo))
273
+ return (standing = { shallow: true, tips: [], refs: [] });
274
+ const tips = [];
275
+ const refs = [];
276
+ // A staged merge commits MERGE_HEAD as a parent, so its history is the
277
+ // new commit's too. A ref that does not resolve here is no tip.
278
+ for (const name of [
279
+ "HEAD",
280
+ ...(ref === ":" ? ["MERGE_HEAD"] : []),
281
+ ...pushed,
282
+ ]) {
283
+ const id = resolveRef(repo, name);
284
+ if (id === null)
285
+ continue;
286
+ tips.push(id);
287
+ if (pushed.includes(name) && !refs.includes(name))
288
+ refs.push(name);
289
+ }
290
+ return (standing = { shallow: false, tips, refs });
291
+ };
292
+ const judge = (commits) => {
293
+ probe(commits);
294
+ const fresh = [...new Set(commits)].filter((commit) => objects.get(commit) === "commit" && !off.has(commit));
295
+ if (fresh.length === 0)
296
+ return;
297
+ const { shallow, tips } = history();
298
+ if (shallow)
299
+ return;
300
+ const unreached = unreachable(repo, fresh, tips);
301
+ for (const commit of fresh)
302
+ off.set(commit, unreached.has(commit));
303
+ };
230
304
  const readInto = (into, rev, wanted) => {
231
305
  const missing = wanted.filter((path) => !into.has(path));
232
306
  const names = missing.map((path) => objectName(rev, path));
@@ -297,6 +371,15 @@ renamedFrom = () => undefined) => {
297
371
  }
298
372
  return types;
299
373
  },
374
+ onHistory: (commits) => {
375
+ judge(commits);
376
+ if (standing?.shallow)
377
+ return null;
378
+ return {
379
+ unreached: new Set(commits.filter((commit) => off.get(commit) === true)),
380
+ refs: standing?.refs ?? [],
381
+ };
382
+ },
300
383
  baseText: (path) => {
301
384
  if (typeof base !== "string")
302
385
  return base;
@@ -307,6 +390,7 @@ renamedFrom = () => undefined) => {
307
390
  if (typeof base === "string")
308
391
  readBases(base, [...asked.bases]);
309
392
  probe([...asked.shas]);
393
+ judge([...asked.history]);
310
394
  },
311
395
  };
312
396
  };
@@ -244,8 +244,26 @@ export const commitIdentity = (records) => {
244
244
  if (full.length === 0)
245
245
  return found;
246
246
  const types = tree.typesOf([...new Set(full.map((f) => f.sha))]);
247
+ // A commit this clone has may still be one CI's clone lacks: one made
248
+ // here and never pushed, or on a branch since deleted. Only history HEAD
249
+ // or a base ref reaches is fetched there. Asked of every commit, however
250
+ // the line names it: CI fails to resolve a bare id the same way.
251
+ const commits = [...types]
252
+ .filter(([, t]) => t === "commit")
253
+ .map(([s]) => s);
254
+ const history = commits.length === 0 ? null : tree.onHistory(commits);
247
255
  for (const { n, sha, asCommit } of full) {
248
256
  const type = types.get(sha);
257
+ if (type === "commit" && history?.unreached.has(sha)) {
258
+ const refs = history.refs.length === 0
259
+ ? "a base ref (none resolves in this clone)"
260
+ : history.refs.join(" or ");
261
+ found.push({
262
+ ...finding(path, n, "unpushed-id", `${sha} is not on pushed history: it is neither an ancestor of HEAD nor of ${refs}, so CI cannot resolve it. Cite a commit that is merged or on this branch.`),
263
+ ratchet: false,
264
+ });
265
+ continue;
266
+ }
249
267
  // Git prints blob and tree ids too: every object resolves, unless the
250
268
  // line names it a commit.
251
269
  if (type !== undefined && (!asCommit || type === "commit"))
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,13 @@ 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, 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, and to refuse a passing pre-push during which HEAD moved (head.ts).
15
+ // Every other failure of its own (telemetry, watchdog, quiet output) is
16
+ // fail-open.
13
17
  import { execFileSync, spawn, spawnSync, } from "node:child_process";
14
18
  import { createHash, randomUUID } from "node:crypto";
15
19
  import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
@@ -17,8 +21,10 @@ import { cpus, totalmem } from "node:os";
17
21
  import { relative, resolve } from "node:path";
18
22
  import { pathToFileURL } from "node:url";
19
23
  import { hookKind, invocationContext, perfLevelNames } from "./context.js";
24
+ import { headMoved, readHead } from "./head.js";
20
25
  import { isQuiet, successLine, successNotices } from "./quiet.js";
21
26
  import { parseRecords, parseStages, treeUsage } from "./records.js";
27
+ import { readStageLists, stageVerdict } from "./stages.js";
22
28
  /** Exit code per signal, shared by the pre-launch path and the exit handler. */
23
29
  export const SIGNAL_EXIT_CODES = {
24
30
  SIGINT: 130,
@@ -96,7 +102,8 @@ export const judgedAgainst = (root, env, baseRefs) => {
96
102
  const clean = diff === null || untracked === null
97
103
  ? null
98
104
  : diff === "" && untracked === "";
99
- return { branch, fingerprint, base, tree, clean };
105
+ const head = revision?.trim() || null;
106
+ return { head, branch, fingerprint, base, tree, clean };
100
107
  };
101
108
  /**
102
109
  * Run `hook` under supervision and resolve with the exit code git must see.
@@ -163,7 +170,7 @@ export const supervise = async ([hook, ...args], options) => {
163
170
  for (const [name, listener] of listeners)
164
171
  signals.off(name, listener);
165
172
  };
166
- const { branch, fingerprint, base, tree, clean } = judgedAgainst(root, env, config.baseRefs);
173
+ const { head, branch, fingerprint, base, tree, clean } = judgedAgainst(root, env, config.baseRefs);
167
174
  let watchdogModule = null;
168
175
  if (config.watchdog) {
169
176
  try {
@@ -278,9 +285,27 @@ export const supervise = async ([hook, ...args], options) => {
278
285
  if (imposed)
279
286
  code = imposed.code;
280
287
  const ended = Date.now();
288
+ const marks = parseStages(readOrNull(events), bootstrapMs);
289
+ // Only a run that would pass is checked: a failure keeps its own story.
290
+ const passing = code === 0 && !imposed && !signal;
291
+ const { refusal: stageRefusal, warning } = passing
292
+ ? stageVerdict(hookName, marks.map((mark) => mark.name), () => readStageLists(root, config), config.stageListRequired)
293
+ : { refusal: null, warning: null };
294
+ // A push, not a preflight: only a push has a commit git sends. Asked
295
+ // after the stage list, so a run that broke both says both.
296
+ const moved = passing && kind === "pre-push"
297
+ ? headMoved(hookName, head, readHead(root, env))
298
+ : null;
299
+ const refusals = [stageRefusal, moved].filter((r) => r !== null);
300
+ const refusal = refusals.length === 0 ? null : refusals.join("\n");
301
+ if (refusal !== null)
302
+ code = 1;
281
303
  if (quiet)
282
304
  report(code, ended - started);
283
- const marks = parseStages(readOrNull(events), bootstrapMs);
305
+ if (refusal !== null)
306
+ stderr(`${refusal}\n`);
307
+ if (warning !== null)
308
+ stderr(`⚠️ ${warning}\n`);
284
309
  const { pushed, declared, proof, proofDiagnostics } = parseRecords(readOrNull(records));
285
310
  const verdict = (last) => imposed
286
311
  ? imposed.status
@@ -310,7 +335,7 @@ export const supervise = async ([hook, ...args], options) => {
310
335
  durationMs: ended - started,
311
336
  exitCode: code,
312
337
  signal,
313
- error,
338
+ error: error ?? refusal ?? warning ?? undefined,
314
339
  status: verdict(false),
315
340
  branch,
316
341
  fingerprint,
@@ -0,0 +1,61 @@
1
+ // `dvara trailers <commit-msg-file>`, for a commit-msg hook. Git reads a
2
+ // message's trailers (Co-Authored-By, Signed-off-by, a repository's own) only
3
+ // from its last paragraph. A trailer line above a blank line is prose to git:
4
+ // the commit lands, and the trailer is silently not there. This finds such
5
+ // lines, so the hook can refuse the commit before that happens.
6
+ /** The keys git and its hosts use themselves; a repository adds its own in `trailerKeys`. */
7
+ export const GIT_TRAILER_KEYS = [
8
+ "Co-Authored-By",
9
+ "Signed-off-by",
10
+ ];
11
+ /**
12
+ * Git's scissors line, with its default comment character: everything below
13
+ * it (the diff `commit -v` shows) is not the message.
14
+ */
15
+ const SCISSORS = "# ------------------------ >8 ------------------------";
16
+ /** A trailer key, as git reads one: letters, digits and `-`, then a colon. */
17
+ const KEY = /^([A-Za-z0-9-]+)[ \t]*:/;
18
+ /**
19
+ * The lines in `message` whose key is one of `keys` (any case) and that sit
20
+ * in a paragraph before the last, or in the title's paragraph (the title line
21
+ * aside), which git never reads trailers from. Comment lines are dropped, as git drops
22
+ * them, so a comment neither splits a paragraph nor ends one; blank lines
23
+ * (whitespace only) separate paragraphs; trailing ones start no new one.
24
+ */
25
+ export const misplacedTrailers = (message, keys) => {
26
+ const known = new Set(keys.map((key) => key.toLowerCase()));
27
+ const paragraphs = [];
28
+ let open = null;
29
+ for (const [i, text] of message.split(/\r?\n/).entries()) {
30
+ if (text === SCISSORS)
31
+ break;
32
+ if (text.startsWith("#"))
33
+ continue;
34
+ if (text.trim() === "") {
35
+ open = null;
36
+ continue;
37
+ }
38
+ if (open === null) {
39
+ open = [];
40
+ paragraphs.push(open);
41
+ }
42
+ open.push({ line: i + 1, text });
43
+ }
44
+ // Git reads no trailers from the title's paragraph either, so a message of
45
+ // one paragraph keeps none; the title line itself is never a trailer.
46
+ const unread = paragraphs.length > 1 ? paragraphs.slice(0, -1) : paragraphs;
47
+ return unread
48
+ .flat()
49
+ .slice(1)
50
+ .filter(({ text }) => {
51
+ const key = KEY.exec(text)?.[1];
52
+ return key !== undefined && known.has(key.toLowerCase());
53
+ });
54
+ };
55
+ export const ADVICE = "Git reads trailers only from the last paragraph. Move these lines into the final block with no blank line between them (one `-F <file>` message), then check with `git log -1 --format=%(trailers)`.";
56
+ /** The refusal for `misplaced` lines of `file`, one line per entry. */
57
+ export const refusal = (file, misplaced) => [
58
+ `❌ trailers: ${file} has trailer lines git does not read (above a blank line, or in the title's paragraph):`,
59
+ ...misplaced.map(({ line, text }) => ` ${file}:${line} ${text}`),
60
+ ADVICE,
61
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veda-hq/dvara",
3
- "version": "0.5.4",
3
+ "version": "0.6.4",
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",