@veda-hq/dvara 0.5.0 → 0.5.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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veda-hq/dvara",
3
- "version": "0.5.0",
3
+ "version": "0.5.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",
@@ -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
  }