@veda-hq/dvara 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/dist/cli.js +124 -0
- package/dist/config.js +175 -0
- package/dist/context.js +67 -0
- package/dist/quiet.js +31 -0
- 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/records.js +96 -0
- package/dist/supervisor.js +371 -0
- package/package.json +52 -3
- package/shell/keep-going.sh +86 -0
- package/shell/stages.sh +52 -0
|
@@ -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/records.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// What a supervised hook tells its supervisor while it runs: stage markers
|
|
2
|
+
// (`dvara_stage`) and records (`dvara_record`), one line each in files the
|
|
3
|
+
// supervisor names. Parsed here into the history row's fields.
|
|
4
|
+
/**
|
|
5
|
+
* A duration field. Empty is unmeasured, not zero; text that is not a finite
|
|
6
|
+
* number is null too, as JSON would have written NaN and Infinity.
|
|
7
|
+
*/
|
|
8
|
+
export const recordedMs = (text) => {
|
|
9
|
+
if (!text)
|
|
10
|
+
return null;
|
|
11
|
+
const ms = Number(text);
|
|
12
|
+
return Number.isFinite(ms) ? ms : null;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Records, one tab-separated line each: `refs-source <stdin|none>`,
|
|
16
|
+
* `ref <local> <remote>`, `stage-input <stage> <kind> <digest>`,
|
|
17
|
+
* `proof <check> <outcome> <ms>`, and
|
|
18
|
+
* `proof-reason <check> <reason> <waitMs> <executionMs> <previousExecutionMs>`.
|
|
19
|
+
* Anything else is ignored.
|
|
20
|
+
*/
|
|
21
|
+
export const parseRecords = (text) => {
|
|
22
|
+
const declared = new Map();
|
|
23
|
+
const proof = [];
|
|
24
|
+
const proofDiagnostics = [];
|
|
25
|
+
if (text === null)
|
|
26
|
+
return { pushed: null, declared, proof, proofDiagnostics };
|
|
27
|
+
const pushed = { source: "none", refs: [] };
|
|
28
|
+
for (const line of text.split("\n").filter(Boolean)) {
|
|
29
|
+
const [type, ...f] = line.split("\t");
|
|
30
|
+
if (type === "refs-source")
|
|
31
|
+
pushed.source = f[0] ?? "";
|
|
32
|
+
else if (type === "ref")
|
|
33
|
+
pushed.refs.push({ local: f[0] ?? "", remote: f[1] ?? "" });
|
|
34
|
+
else if (type === "stage-input")
|
|
35
|
+
declared.set(f[0] ?? "", { kind: f[1] ?? "", digest: f[2] ?? "" });
|
|
36
|
+
else if (type === "proof") {
|
|
37
|
+
const ms = Number(f[2]);
|
|
38
|
+
proof.push({
|
|
39
|
+
check: f[0] ?? "",
|
|
40
|
+
outcome: f[1] ?? "",
|
|
41
|
+
ms: Number.isFinite(ms) ? ms : null,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
else if (type === "proof-reason")
|
|
45
|
+
proofDiagnostics.push({
|
|
46
|
+
check: f[0] ?? "",
|
|
47
|
+
reason: f[1] ?? "",
|
|
48
|
+
waitMs: recordedMs(f[2]),
|
|
49
|
+
executionMs: recordedMs(f[3]),
|
|
50
|
+
previousExecutionMs: recordedMs(f[4]),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return { pushed, declared, proof, proofDiagnostics };
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Stage markers, `<seconds>\t<name>` per line (bash's `$SECONDS`), offset by
|
|
57
|
+
* the time the hook spent choosing its runtime before the supervisor started,
|
|
58
|
+
* which becomes a stage of its own. A marker whose time is not a number stays
|
|
59
|
+
* NaN: JSON writes it as null, which voids its neighbours' durations rather
|
|
60
|
+
* than inventing them.
|
|
61
|
+
*/
|
|
62
|
+
export const parseStages = (text, bootstrapMs) => {
|
|
63
|
+
const stages = (text ?? "")
|
|
64
|
+
.trim()
|
|
65
|
+
.split("\n")
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.map((line) => {
|
|
68
|
+
const [seconds, name = ""] = line.split("\t");
|
|
69
|
+
const at = Number(seconds);
|
|
70
|
+
return {
|
|
71
|
+
name,
|
|
72
|
+
startMs: Number.isFinite(at) ? bootstrapMs + at * 1000 : Number.NaN,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
if (bootstrapMs)
|
|
76
|
+
stages.unshift({ name: "runtime-selection", startMs: 0 });
|
|
77
|
+
return stages;
|
|
78
|
+
};
|
|
79
|
+
/** Resident memory (KiB) and process count of `rootPid`'s tree, from a `ps -axo pid=,ppid=,rss=` table. */
|
|
80
|
+
export const treeUsage = (table, rootPid) => {
|
|
81
|
+
const rows = table
|
|
82
|
+
.trim()
|
|
83
|
+
.split("\n")
|
|
84
|
+
.map((row) => row.trim().split(/\s+/).map(Number));
|
|
85
|
+
const ids = new Set([rootPid]);
|
|
86
|
+
// A Set visits what is added while iterating it: a breadth-first walk.
|
|
87
|
+
for (const id of ids)
|
|
88
|
+
for (const [pid, ppid] of rows)
|
|
89
|
+
if (ppid === id && pid !== undefined)
|
|
90
|
+
ids.add(pid);
|
|
91
|
+
let rssKiB = 0;
|
|
92
|
+
for (const [pid, , rss] of rows)
|
|
93
|
+
if (pid !== undefined && ids.has(pid))
|
|
94
|
+
rssKiB += rss ?? 0;
|
|
95
|
+
return { rssKiB, processes: ids.size };
|
|
96
|
+
};
|