@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.
- package/README.md +104 -9
- package/dist/cli.js +52 -2
- package/dist/config.js +121 -9
- 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/stages.js +106 -0
- package/dist/supervisor.js +19 -5
- package/package.json +5 -2
|
@@ -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);
|
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",
|
|
@@ -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
|
}
|