@veda-hq/dvara 0.5.0 → 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.
@@ -0,0 +1,115 @@
1
+ // Where a Markdown file's fenced code blocks are, line by line: the one
2
+ // reading EVIDENCE-01 needs to tell commands from captured output. Which
3
+ // lines open and close a fence is CommonMark's answer, from
4
+ // mdast-util-from-markdown; the lines stay the file's own, so every finding
5
+ // keeps its source line.
6
+ import { fromMarkdown } from "mdast-util-from-markdown";
7
+ /** A regular expression match's group: '' when it did not take part. */
8
+ export const group = (match, index) => match[index] ?? "";
9
+ /** 1-based line `n` of `lines`: '' past the end. */
10
+ const lineAt = (lines, n) => lines[n - 1] ?? "";
11
+ /** A fenced block's opener, read where mdast places the code node. */
12
+ const FENCE_MARKER = /^(`{3,}|~{3,})/;
13
+ /** A closer: the opener's character, alone on its line. */
14
+ const FENCE_CLOSER = /^\s*(`+|~+)\s*$/;
15
+ /**
16
+ * A line with its `quotes` blockquote markers taken off: a fence inside a
17
+ * quote (`> ```text`) is a fence, and its body lines carry the same `> `.
18
+ * mdast counts the quotes, so a marker may sit at every indent: a quote nested
19
+ * in a list item starts past the item's own indentation.
20
+ */
21
+ const unquote = (line, quotes) => {
22
+ let inner = line;
23
+ for (let depth = 0; depth < quotes; depth += 1) {
24
+ inner = inner.replace(/^[ \t]*> ?/, "");
25
+ }
26
+ return inner;
27
+ };
28
+ export const parseMarkdown = (text) => {
29
+ const lines = text.split(/\r?\n/);
30
+ // Frontmatter is not Markdown: a `---` block on the first line is skipped,
31
+ // and its lines are never in a fence.
32
+ let offset = 0;
33
+ if (lines[0]?.trim() === "---") {
34
+ const end = lines.findIndex((line, i) => i > 0 && line.trim() === "---");
35
+ if (end !== -1)
36
+ offset = end + 1;
37
+ }
38
+ const fences = [];
39
+ const inFence = new Array(lines.length + 1).fill(false);
40
+ const readFence = (node, parent, quotes) => {
41
+ // mdast gives every code node its position and value.
42
+ const { start, end } = node.position;
43
+ const value = node.value;
44
+ const n = start.line + offset;
45
+ // An indented code block starts at its indentation, not at a marker.
46
+ const marker = FENCE_MARKER.exec(lineAt(lines, n).slice(start.column - 1));
47
+ if (marker === null)
48
+ return;
49
+ const inner = (at) => unquote(lineAt(lines, at), quotes);
50
+ const last = end.line + offset;
51
+ const closer = FENCE_CLOSER.exec(inner(last));
52
+ const bodyLines = value === "" ? 0 : value.split("\n").length;
53
+ // Closed: the last line is a closer mdast left out of the body (a
54
+ // closer-shaped line indented as body text stays in it). A one-line
55
+ // fence has no body line to leave out, so it is never closed. A closed
56
+ // fence's value holds the lines between the markers, never one more:
57
+ // trailing blank lines count, and a lone blank line is '' (0 < 2).
58
+ const closed = closer !== null &&
59
+ group(closer, 1).startsWith(group(marker, 1).charAt(0)) &&
60
+ group(closer, 1).length >= group(marker, 1).length &&
61
+ bodyLines !== last - n;
62
+ // Unclosed, a fence runs to the end of its container: the document's
63
+ // last line, or the line before mdast's end when that is a line start.
64
+ let close = last;
65
+ if (!closed && parent.type === "root")
66
+ close = lines.length;
67
+ else if (!closed && end.column === 1)
68
+ close = last - 1;
69
+ const body = [];
70
+ for (let at = n + 1; at <= (closed ? close - 1 : close); at += 1) {
71
+ body.push({ n: at, text: inner(at) });
72
+ }
73
+ for (let at = n; at <= close; at += 1)
74
+ inFence[at] = true;
75
+ fences.push({
76
+ open: n,
77
+ close,
78
+ lang: (node.lang ?? "").toLowerCase(),
79
+ body,
80
+ });
81
+ };
82
+ const visit = (node, parent, quotes) => {
83
+ if (node.type === "code")
84
+ readFence(node, parent, quotes);
85
+ const inner = quotes + (node.type === "blockquote" ? 1 : 0);
86
+ for (const child of node.children ?? [])
87
+ visit(child, node, inner);
88
+ };
89
+ // A lone CR is a line ending to CommonMark but not to `lines`: it is read
90
+ // as a space.
91
+ const root = fromMarkdown(lines.slice(offset).join("\n").replaceAll("\r", " "));
92
+ visit(root, root, 0);
93
+ return { lines, fences, inFence };
94
+ };
95
+ const SCRIPT_LANGS = new Set(["sh", "bash", "zsh", "shell"]);
96
+ const TRANSCRIPT_LANGS = new Set([
97
+ "",
98
+ "text",
99
+ "txt",
100
+ "console",
101
+ "shell-session",
102
+ "output",
103
+ "log",
104
+ "plaintext",
105
+ ]);
106
+ /**
107
+ * A fence by its info string: `script` (`sh`, `bash`, …) is commands to run,
108
+ * every line; `transcript` (none, `text`, `console`, …) has `$ ` command
109
+ * lines and what they printed; `source` (`ts`, `json`, …) quotes a file.
110
+ */
111
+ export const fenceKind = (fence) => {
112
+ if (SCRIPT_LANGS.has(fence.lang))
113
+ return "script";
114
+ return TRANSCRIPT_LANGS.has(fence.lang) ? "transcript" : "source";
115
+ };
@@ -0,0 +1,103 @@
1
+ import { isCodePath } from "./scope.js";
2
+ /** Each manager's install commands; a global install is not a dependency. */
3
+ const INSTALL = {
4
+ npm: String.raw `npm\s+(install|i|ci|add)`,
5
+ yarn: String.raw `yarn\s+(install|add)`,
6
+ pnpm: String.raw `pnpm\s+(install|i|add)`,
7
+ };
8
+ const installPattern = (manager) => {
9
+ const others = Object.keys(INSTALL).filter((m) => m !== manager);
10
+ return {
11
+ check: "install-command",
12
+ re: new RegExp(String.raw `\b(${others.map((m) => INSTALL[m]).join("|")})\b(?!\s+(-g|--global)\b)`),
13
+ what: `${others.join("/")} install: this repository installs with ${manager} only`,
14
+ negatable: true,
15
+ };
16
+ };
17
+ /**
18
+ * Everywhere a reader may follow text: prose, data and configuration. Code
19
+ * is not this rule's to read: a path there is a fixture, or a URI scheme the
20
+ * code handles.
21
+ */
22
+ const patterns = (manager) => [
23
+ {
24
+ check: "file-url",
25
+ // `file://<placeholder>/…` is already anonymised by its writer.
26
+ re: /\bfile:\/\/(?!<)/i,
27
+ what: "a file:// URL resolves only on the machine that wrote it",
28
+ path: true,
29
+ },
30
+ {
31
+ check: "home-path",
32
+ re: /(?<![\w.~-])\/Users\/(?!Shared\b)[A-Za-z0-9._-]+/,
33
+ what: "a /Users/<name> home path names one machine and its operator",
34
+ path: true,
35
+ },
36
+ {
37
+ check: "home-path",
38
+ // The hosted CI runner's home is the same on every run. The name ends at
39
+ // a path boundary: `/`, the line's end, a space or punctuation (a
40
+ // sentence's closing `.` is not part of it).
41
+ re: /(?<![\w.~-])\/home\/(?!runner\b)[a-z_][a-z0-9._-]*?(?:\/|(?=$|[^\w.-]|\.(?!\w)))/,
42
+ what: "a /home/<name> path names one machine and its operator",
43
+ path: true,
44
+ },
45
+ {
46
+ check: "temp-root",
47
+ re: /(?<![\w.-])\/(private\/tmp|private\/var|var\/folders)\//,
48
+ what: "a sandbox temp root is gone after the session",
49
+ path: true,
50
+ },
51
+ {
52
+ check: "git-path",
53
+ // An entry under `.git/` of every kind (`FETCH_HEAD`, `packed-refs`, …);
54
+ // a bare mention of the folder, `.git/` alone, names no path in it.
55
+ re: /(?<![\w-])\.git\/[\w.-]+/,
56
+ what: "a literal .git/ path is not the repository layout (worktrees move it)",
57
+ path: true,
58
+ },
59
+ ...(manager === null ? [] : [installPattern(manager)]),
60
+ {
61
+ check: "agent-marker",
62
+ re: /\b(I am|I'm|as) Agent[\s-]*\d+\b/i,
63
+ what: "an agent-process marker is session narration, not a record",
64
+ },
65
+ ];
66
+ const NEGATION = /\b(never|not|no|don't|do not|instead of|rather than|refuse[sd]?|forbid(s|den)?|ban(s|ned)?)\b/i;
67
+ /** Whether the clause a match sits in says not to do it. */
68
+ const negated = (text, at) => {
69
+ // A negation covers its own clause only: in "never npm install; run yarn
70
+ // install", the second instruction stands.
71
+ const clauses = text.slice(0, at).split(/[;.!?]|\bbut\b/i);
72
+ return NEGATION.test(clauses.at(-1) ?? "");
73
+ };
74
+ const scan = (file, text, list, allowed) => {
75
+ const found = [];
76
+ text.split(/\r?\n/).forEach((line, i) => {
77
+ // One finding per line: `file:///Users/…` is one non-durable reference.
78
+ for (const pattern of list) {
79
+ // No pattern carries the `g` flag (`exec` would then keep state
80
+ // across lines), so each scan takes a global copy.
81
+ const match = [
82
+ ...line.matchAll(new RegExp(pattern.re.source, `${pattern.re.flags}g`)),
83
+ ].find((m) => !(pattern.negatable && negated(line, m.index)) &&
84
+ !(pattern.path && allowed.some((a) => a.test(line.slice(m.index)))));
85
+ if (!match)
86
+ continue;
87
+ found.push({
88
+ rule: "EVIDENCE-02",
89
+ check: pattern.check,
90
+ file,
91
+ line: i + 1,
92
+ message: `${pattern.what}: "${match[0]}"`,
93
+ });
94
+ break;
95
+ }
96
+ });
97
+ return found;
98
+ };
99
+ /** EVIDENCE-02 under these settings: `allowPaths` and `packageManager`. */
100
+ export const durablePaths = (records) => {
101
+ const list = patterns(records.packageManager);
102
+ return (file, text) => isCodePath(file) ? [] : scan(file, text, list, records.allowPaths);
103
+ };
@@ -0,0 +1,10 @@
1
+ import { commitIdentity } from "./identity.js";
2
+ import { durablePaths } from "./paths.js";
3
+ /**
4
+ * The rule set for these settings: EVIDENCE-02 everywhere text is read, and
5
+ * EVIDENCE-01 where `records.identityPaths` match (none by default).
6
+ */
7
+ export const rulesFor = (records) => [
8
+ durablePaths(records),
9
+ commitIdentity(records),
10
+ ];
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A frozen path: under one of `frozen.paths` and not one of the exact
3
+ * `frozen.except` paths, which stay editable.
4
+ */
5
+ export const isFrozenPath = (path, frozen) => frozen.paths.some((prefix) => path.startsWith(prefix)) &&
6
+ !frozen.except.includes(path);
7
+ /**
8
+ * Files never scanned as text: binaries, lockfiles and generated bundles.
9
+ * Everything else a repository tracks is text a reader may follow.
10
+ */
11
+ const NOT_TEXT = /(\.(png|jpe?g|gif|webp|ico|icns|pdf|zip|gz|tgz|br|woff2?|ttf|otf|eot|mp4|mov|mp3|wav|jar|keystore|jks|p12|der|so|dylib|a|bin|db|sqlite|tflite|onnx|pbxproj|xcworkspacedata|lock)|pnpm-lock\.yaml|package-lock\.json)$/i;
12
+ export const isTextPath = (path) => !NOT_TEXT.test(path);
13
+ /**
14
+ * Source files, by extension in either case (`main.C`): the path denylist
15
+ * does not read code.
16
+ */
17
+ export const isCodePath = (path) => /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|py|sh|bash|zsh|rb|swift|kt|java|m|mm|h|c|cc|cpp|go|rs|sql|prisma|css|scss)$/i.test(path);
18
+ /**
19
+ * Whether any rule reads `path`: not a binary, not a frozen path, not under a
20
+ * skipped prefix, and none of `own`, the gate's own files (its baseline and
21
+ * the config, which quote the very shapes the rules refuse).
22
+ */
23
+ export const isReadPath = (path, records, own) => isTextPath(path) &&
24
+ !own.includes(path) &&
25
+ !records.skip.some((prefix) => path.startsWith(prefix)) &&
26
+ !isFrozenPath(path, records.frozen);
@@ -0,0 +1,4 @@
1
+ // What the record rules share: the findings they report and the tree they ask
2
+ // about. Every rule is pure: the tree arrives as functions, so the same rule
3
+ // checks the index at pre-commit and a commit in CI.
4
+ export const RULE_IDS = ["EVIDENCE-01", "EVIDENCE-02"];
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.0",
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",
@@ -42,10 +42,13 @@
42
42
  "@types/node": "24.19.2",
43
43
  "typescript": "7.0.2"
44
44
  },
45
+ "dependencies": {
46
+ "mdast-util-from-markdown": "2.0.3"
47
+ },
45
48
  "scripts": {
46
49
  "build": "tsc -p tsconfig.build.json",
47
50
  "typecheck": "tsc -p tsconfig.json",
48
- "test": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-functions=95 --test-coverage-branches=90 --test-coverage-exclude=src/**/*.test.ts --test-coverage-include=src/**/*.ts src/**/*.test.ts",
51
+ "test": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-functions=95 --test-coverage-branches=90 --test-coverage-exclude='src/**/*.test.ts' --test-coverage-include='src/**/*.ts' 'src/**/*.test.ts'",
49
52
  "smoke": "node dist/cli.js --version && node dist/cli.js shell stages",
50
53
  "check": "pnpm typecheck && pnpm test && pnpm build && pnpm smoke"
51
54
  }