@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,161 @@
|
|
|
1
|
+
// `dvara frozen`: frozen paths refuse changes. Nothing adds, changes or
|
|
2
|
+
// deletes a file under one of `records.frozen.paths`, except the exact paths
|
|
3
|
+
// `records.frozen.except` keeps editable.
|
|
4
|
+
//
|
|
5
|
+
// --staged pre-commit, merge commits included: what the index
|
|
6
|
+
// changes against HEAD. In a merge, a path staged
|
|
7
|
+
// exactly as a merged-in parent has it is that
|
|
8
|
+
// parent's change, judged where it was made, and
|
|
9
|
+
// passes; content no parent had (a hand resolution)
|
|
10
|
+
// counts. The branch check sees the rest.
|
|
11
|
+
// --base REF [--head REF]
|
|
12
|
+
// the branch check: what HEAD (or --head) changes
|
|
13
|
+
// since its merge base with REF.
|
|
14
|
+
//
|
|
15
|
+
// Exit 0: no frozen path changes. 1: refused. 2: could not run.
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { parseArgs as parseFlags } from "node:util";
|
|
18
|
+
import { isFrozenPath } from "./scope.js";
|
|
19
|
+
export const USAGE = "usage: dvara frozen --staged | --base REF [--head REF]";
|
|
20
|
+
/** The mode `args` ask for, or what is wrong with them. */
|
|
21
|
+
export const parseArgs = (args) => {
|
|
22
|
+
let values;
|
|
23
|
+
try {
|
|
24
|
+
({ values } = parseFlags({
|
|
25
|
+
args: [...args],
|
|
26
|
+
options: {
|
|
27
|
+
staged: { type: "boolean" },
|
|
28
|
+
base: { type: "string" },
|
|
29
|
+
head: { type: "string" },
|
|
30
|
+
},
|
|
31
|
+
strict: true,
|
|
32
|
+
allowPositionals: false,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return { error: error.message };
|
|
37
|
+
}
|
|
38
|
+
const { staged, base, head } = values;
|
|
39
|
+
if (staged && base === undefined && head === undefined)
|
|
40
|
+
return { kind: "staged" };
|
|
41
|
+
if (!staged && base !== undefined)
|
|
42
|
+
return { kind: "range", base, head: head ?? "HEAD" };
|
|
43
|
+
return { error: "give --staged, or --base REF [--head REF]" };
|
|
44
|
+
};
|
|
45
|
+
/** `git diff --name-status -z` output, as status and path pairs. */
|
|
46
|
+
export const parseNameStatus = (out) => {
|
|
47
|
+
const fields = out.split("\0");
|
|
48
|
+
const changes = [];
|
|
49
|
+
for (let i = 0; i + 1 < fields.length; i += 2)
|
|
50
|
+
changes.push({ status: fields[i] ?? "", path: fields[i + 1] ?? "" });
|
|
51
|
+
return changes;
|
|
52
|
+
};
|
|
53
|
+
const DIFF = ["diff", "--name-status", "--no-renames", "-z"];
|
|
54
|
+
const SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
|
|
55
|
+
const GITHEAD = "GITHEAD_";
|
|
56
|
+
/** The SHA lines of the git-dir file `name`; none when it is absent. */
|
|
57
|
+
const gitFileShas = (git, name) => {
|
|
58
|
+
const file = git([
|
|
59
|
+
"rev-parse",
|
|
60
|
+
"--path-format=absolute",
|
|
61
|
+
"--git-path",
|
|
62
|
+
name,
|
|
63
|
+
]).trim();
|
|
64
|
+
if (!existsSync(file))
|
|
65
|
+
return [];
|
|
66
|
+
return readFileSync(file, "utf8")
|
|
67
|
+
.split("\n")
|
|
68
|
+
.filter((line) => SHA.test(line));
|
|
69
|
+
};
|
|
70
|
+
/** The tree of git's own clean merge of HEAD and `head`; none on a conflict. */
|
|
71
|
+
export const mergedTree = (git, head) => {
|
|
72
|
+
try {
|
|
73
|
+
return git(["merge-tree", "--write-tree", "--no-messages", "HEAD", head])
|
|
74
|
+
.split("\n")[0]
|
|
75
|
+
?.trim();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// merge-tree exits 1 on a conflict; with no clean merge to compare,
|
|
79
|
+
// `head` is not trusted, the stricter answer.
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Whether the index is exactly a clean merge of HEAD and `heads`, so it holds
|
|
85
|
+
* no content those commits do not: the tree git's default strategy writes to
|
|
86
|
+
* AUTO_MERGE before pre-merge-commit runs, or, for one merged commit under any
|
|
87
|
+
* strategy, git's own merge of HEAD and it.
|
|
88
|
+
*/
|
|
89
|
+
export const isCleanMerge = (git, heads) => {
|
|
90
|
+
const trees = gitFileShas(git, "AUTO_MERGE");
|
|
91
|
+
const [only] = heads;
|
|
92
|
+
if (heads.length === 1 && only !== undefined) {
|
|
93
|
+
const tree = mergedTree(git, only);
|
|
94
|
+
if (tree !== undefined)
|
|
95
|
+
trees.push(tree);
|
|
96
|
+
}
|
|
97
|
+
return trees.some((tree) => git(["diff", "--cached", "--name-only", tree]) === "");
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* The commits a merge in progress takes in; none outside a merge. A merge
|
|
101
|
+
* finished by `git commit` lists them in MERGE_HEAD. A clean `git merge` runs
|
|
102
|
+
* pre-merge-commit before it writes MERGE_HEAD, and names each merged commit
|
|
103
|
+
* only in the `GITHEAD_<sha>` variable it sets for its hooks. Anyone can set
|
|
104
|
+
* such a variable, so those commits count only when the index is a clean
|
|
105
|
+
* merge of them.
|
|
106
|
+
*/
|
|
107
|
+
export const mergeHeads = (git, env) => {
|
|
108
|
+
const heads = new Set(gitFileShas(git, "MERGE_HEAD"));
|
|
109
|
+
const named = Object.keys(env)
|
|
110
|
+
.filter((name) => name.startsWith(GITHEAD))
|
|
111
|
+
.map((name) => name.slice(GITHEAD.length))
|
|
112
|
+
.filter((sha) => SHA.test(sha));
|
|
113
|
+
if (isCleanMerge(git, named))
|
|
114
|
+
for (const sha of named)
|
|
115
|
+
heads.add(sha);
|
|
116
|
+
return [...heads];
|
|
117
|
+
};
|
|
118
|
+
/** What `mode` changes, before the frozen filter. */
|
|
119
|
+
export const changes = (git, mode, env = {}) => {
|
|
120
|
+
if (mode.kind === "range")
|
|
121
|
+
return parseNameStatus(git([...DIFF, `${mode.base}...${mode.head}`]));
|
|
122
|
+
const staged = parseNameStatus(git([...DIFF, "--cached"]));
|
|
123
|
+
const kept = mergeHeads(git, env).map((sha) => new Set(parseNameStatus(git([...DIFF, "--cached", sha])).map((c) => c.path)));
|
|
124
|
+
return staged.filter((change) => kept.every((paths) => paths.has(change.path)));
|
|
125
|
+
};
|
|
126
|
+
/** The verdict on `frozen`, the frozen changes of `mode`. */
|
|
127
|
+
export const report = (mode, frozen, paths) => {
|
|
128
|
+
if (frozen.length === 0)
|
|
129
|
+
return {
|
|
130
|
+
code: 0,
|
|
131
|
+
stdout: ["✅ frozen: no frozen path changes"],
|
|
132
|
+
stderr: [],
|
|
133
|
+
};
|
|
134
|
+
const undo = mode.kind === "staged"
|
|
135
|
+
? "Unstage them (git restore --staged -- <path>), then commit again."
|
|
136
|
+
: // The range is `base...head`, the changes since the merge base: the
|
|
137
|
+
// content to go back to is the merge base's, not what base has today.
|
|
138
|
+
// The refs stay out of the command: a ref may hold shell syntax, and
|
|
139
|
+
// this line is meant to be copied.
|
|
140
|
+
`Drop them from the branch: restore each path to its content at the merge base with ${mode.base} (git restore --source=<merge base> --staged --worktree -- <path>; git merge-base names it), then commit.`;
|
|
141
|
+
return {
|
|
142
|
+
code: 1,
|
|
143
|
+
stdout: [],
|
|
144
|
+
stderr: [
|
|
145
|
+
`❌ frozen: ${mode.kind === "staged" ? "this commit" : `this branch (since ${mode.base})`} changes ${frozen.length} file(s) under ${paths.paths.join(", ")}:`,
|
|
146
|
+
...frozen.map((change) => ` ${change.status} ${change.path}`),
|
|
147
|
+
" These paths are frozen: no file there is added, changed or deleted.",
|
|
148
|
+
...(paths.except.length === 0
|
|
149
|
+
? []
|
|
150
|
+
: [` Still editable: ${paths.except.join(", ")}.`]),
|
|
151
|
+
` ${undo}`,
|
|
152
|
+
],
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
/** The verdict for `mode` in the repository `git` runs in; a failing git throws. */
|
|
156
|
+
export const frozen = (mode, git, paths, env = {}) => {
|
|
157
|
+
// Nothing frozen: nothing to ask git.
|
|
158
|
+
if (paths.paths.length === 0)
|
|
159
|
+
return { code: 0, stdout: ["✅ frozen: no paths are frozen"], stderr: [] };
|
|
160
|
+
return report(mode, changes(git, mode, env).filter((change) => isFrozenPath(change.path, paths)), paths);
|
|
161
|
+
};
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// `dvara records`: the record gate. It selects files, reads them from one git
|
|
2
|
+
// tree (the index at pre-commit, HEAD in CI), runs the rules over them and
|
|
3
|
+
// holds legacy findings by a per-file baseline whose counts only fall.
|
|
4
|
+
//
|
|
5
|
+
// --staged pre-commit: every staged file, read from the
|
|
6
|
+
// index (what is being committed, not the working
|
|
7
|
+
// tree); branch rules against the merge base with
|
|
8
|
+
// the first of `baseRefs` that resolves.
|
|
9
|
+
// --all [--base REF] every tracked file at HEAD; with --base, branch
|
|
10
|
+
// rules and the baseline's only-falls guard against
|
|
11
|
+
// the merge base with REF.
|
|
12
|
+
// --base REF the files changed since the merge base with REF.
|
|
13
|
+
// --all --write-baseline record today's counts.
|
|
14
|
+
// --all --survey counts per rule and check.
|
|
15
|
+
//
|
|
16
|
+
// Exit 0: clean or within the baseline. 1: refused. 2: could not run.
|
|
17
|
+
import { lstatSync, mkdirSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { baselineIncreases, countFindings, parseBaseline, pathMap, ratchet, renderBaseline, staleEntries, } from "./baseline.js";
|
|
20
|
+
import { git, listTree, mergeBase, noQuestions, objectName, readBlobs, recordingContext, resolveRef, treeContext, treeLists, UnreadableObject, } from "./git.js";
|
|
21
|
+
import { rulesFor } from "./rules.js";
|
|
22
|
+
import { isReadPath } from "./scope.js";
|
|
23
|
+
import { RULE_IDS, } from "./types.js";
|
|
24
|
+
export const USAGE = "usage: dvara records --staged | --all [--base REF] [--write-baseline | --survey] | --base REF";
|
|
25
|
+
export const parseArgs = (args) => {
|
|
26
|
+
let mode = null;
|
|
27
|
+
let base = null;
|
|
28
|
+
let writeBaseline = false;
|
|
29
|
+
let survey = false;
|
|
30
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
31
|
+
const arg = args[i];
|
|
32
|
+
if (arg === "--staged" || arg === "--all") {
|
|
33
|
+
if (mode !== null)
|
|
34
|
+
return { error: `${arg}: one of --staged or --all` };
|
|
35
|
+
mode = arg === "--staged" ? "staged" : "all";
|
|
36
|
+
}
|
|
37
|
+
else if (arg === "--base") {
|
|
38
|
+
// A second --base would silently replace the scope the first named.
|
|
39
|
+
if (base !== null)
|
|
40
|
+
return { error: "--base given twice" };
|
|
41
|
+
const value = args[i + 1];
|
|
42
|
+
if (value === undefined || value.startsWith("--"))
|
|
43
|
+
return { error: "--base needs a ref" };
|
|
44
|
+
base = value;
|
|
45
|
+
i += 1;
|
|
46
|
+
}
|
|
47
|
+
else if (arg === "--write-baseline")
|
|
48
|
+
writeBaseline = true;
|
|
49
|
+
else if (arg === "--survey")
|
|
50
|
+
survey = true;
|
|
51
|
+
else
|
|
52
|
+
return { error: `unknown argument: ${String(arg)}` };
|
|
53
|
+
}
|
|
54
|
+
if (mode === null && base === null)
|
|
55
|
+
return { error: "nothing to check" };
|
|
56
|
+
const options = {
|
|
57
|
+
mode: mode ?? "base",
|
|
58
|
+
base,
|
|
59
|
+
writeBaseline,
|
|
60
|
+
survey,
|
|
61
|
+
};
|
|
62
|
+
if (options.mode === "staged" && options.base !== null)
|
|
63
|
+
return {
|
|
64
|
+
error: "--staged compares with the configured base refs; drop --base",
|
|
65
|
+
};
|
|
66
|
+
if (options.writeBaseline && options.survey)
|
|
67
|
+
return { error: "--write-baseline and --survey: pick one" };
|
|
68
|
+
if ((options.writeBaseline || options.survey) && options.mode !== "all")
|
|
69
|
+
return {
|
|
70
|
+
error: "--write-baseline and --survey read the whole tree: add --all",
|
|
71
|
+
};
|
|
72
|
+
return options;
|
|
73
|
+
};
|
|
74
|
+
/** The paths in `--name-status -z` output: each change is `<status>\0<path>\0`. */
|
|
75
|
+
const changedPaths = (out) => [...out.matchAll(/[^\0]+\0([^\0]+)\0/g)].map((m) => m[1] ?? "");
|
|
76
|
+
/** Renames git detects in a diff, new path to old path. */
|
|
77
|
+
const renamesIn = (repo, diffArgs) => {
|
|
78
|
+
const listing = git(repo, [
|
|
79
|
+
"diff",
|
|
80
|
+
...diffArgs,
|
|
81
|
+
"-M",
|
|
82
|
+
"--diff-filter=R",
|
|
83
|
+
"--name-status",
|
|
84
|
+
"-z",
|
|
85
|
+
]);
|
|
86
|
+
// Each rename is `R<score>\0<old>\0<new>\0`.
|
|
87
|
+
const out = new Map();
|
|
88
|
+
for (const m of listing.matchAll(/[^\0]+\0([^\0]+)\0([^\0]+)\0/g))
|
|
89
|
+
out.set(m[2] ?? "", m[1] ?? "");
|
|
90
|
+
return out;
|
|
91
|
+
};
|
|
92
|
+
const firstResolving = (repo, refs) => {
|
|
93
|
+
for (const ref of refs) {
|
|
94
|
+
const commit = resolveRef(repo, ref);
|
|
95
|
+
if (commit !== null)
|
|
96
|
+
return commit;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
};
|
|
100
|
+
const plan = (options, repo, settings) => {
|
|
101
|
+
const baselinePath = settings.records.baseline;
|
|
102
|
+
const head = resolveRef(repo, "HEAD");
|
|
103
|
+
if (options.mode === "staged") {
|
|
104
|
+
const args = ["diff", "--cached", "--name-status", "-z", "--no-renames"];
|
|
105
|
+
// With no HEAD yet, `diff --cached` lists everything staged.
|
|
106
|
+
const staged = changedPaths(git(repo, args));
|
|
107
|
+
let base = null;
|
|
108
|
+
let changes = staged;
|
|
109
|
+
if (head !== null) {
|
|
110
|
+
const upstream = firstResolving(repo, settings.baseRefs);
|
|
111
|
+
// A merge in progress commits both parents: the base is the upstream's
|
|
112
|
+
// merge base with that merge, and what the other parent brings
|
|
113
|
+
// unchanged from the base is not this change's to answer for.
|
|
114
|
+
const merging = resolveRef(repo, "MERGE_HEAD");
|
|
115
|
+
base =
|
|
116
|
+
upstream === null
|
|
117
|
+
? head
|
|
118
|
+
: ((merging === null
|
|
119
|
+
? mergeBase(repo, upstream, head)
|
|
120
|
+
: mergeBase(repo, upstream, head, merging)) ?? head);
|
|
121
|
+
if (merging !== null) {
|
|
122
|
+
const fromBase = new Set(changedPaths(git(repo, [...args, base])));
|
|
123
|
+
changes = staged.filter((p) => fromBase.has(p));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const branchBase = base;
|
|
127
|
+
return {
|
|
128
|
+
ref: ":",
|
|
129
|
+
branchBase,
|
|
130
|
+
// Counts only fall relative to the upstream: an allowance the branch
|
|
131
|
+
// itself added and then adjusts is still compared with the upstream's.
|
|
132
|
+
baselineBefore: branchBase,
|
|
133
|
+
check: changes,
|
|
134
|
+
baselineChanged: changes.includes(baselinePath),
|
|
135
|
+
renames: () => branchBase === null
|
|
136
|
+
? new Map()
|
|
137
|
+
: renamesIn(repo, ["--cached", branchBase]),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
let base;
|
|
141
|
+
if (options.base !== null) {
|
|
142
|
+
const target = resolveRef(repo, options.base);
|
|
143
|
+
if (target === null || head === null)
|
|
144
|
+
return { error: `cannot resolve ${options.base} or HEAD` };
|
|
145
|
+
const found = mergeBase(repo, head, target);
|
|
146
|
+
if (found === null)
|
|
147
|
+
return { error: `no merge base between HEAD and ${options.base}` };
|
|
148
|
+
base = found;
|
|
149
|
+
}
|
|
150
|
+
if (options.mode === "all")
|
|
151
|
+
return {
|
|
152
|
+
ref: "HEAD",
|
|
153
|
+
branchBase: base,
|
|
154
|
+
baselineBefore: base ?? null,
|
|
155
|
+
check: listTree(repo, "HEAD"),
|
|
156
|
+
baselineChanged: base !== undefined,
|
|
157
|
+
// Asked only when the baseline moved, which takes a base.
|
|
158
|
+
renames: () => renamesIn(repo, [String(base), "HEAD"]),
|
|
159
|
+
};
|
|
160
|
+
const changes = changedPaths(git(repo, [
|
|
161
|
+
"diff",
|
|
162
|
+
"--name-status",
|
|
163
|
+
"-z",
|
|
164
|
+
"--no-renames",
|
|
165
|
+
String(base),
|
|
166
|
+
"HEAD",
|
|
167
|
+
]));
|
|
168
|
+
return {
|
|
169
|
+
ref: "HEAD",
|
|
170
|
+
branchBase: base,
|
|
171
|
+
baselineBefore: base ?? null,
|
|
172
|
+
check: changes,
|
|
173
|
+
baselineChanged: changes.includes(baselinePath),
|
|
174
|
+
renames: () => renamesIn(repo, [String(base), "HEAD"]),
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
/** Files whose baseline entry differs between two baselines. */
|
|
178
|
+
const changedEntries = (before, after) => {
|
|
179
|
+
const keys = new Set([
|
|
180
|
+
...Object.keys(before.files),
|
|
181
|
+
...Object.keys(after.files),
|
|
182
|
+
]);
|
|
183
|
+
// An equal entry written in another order re-checks its file needlessly,
|
|
184
|
+
// never wrongly.
|
|
185
|
+
return [...keys].filter((key) => JSON.stringify(before.files[key]) !== JSON.stringify(after.files[key]));
|
|
186
|
+
};
|
|
187
|
+
const format = (finding) => {
|
|
188
|
+
const label = finding.check === finding.rule
|
|
189
|
+
? finding.rule
|
|
190
|
+
: `${finding.rule}/${finding.check}`;
|
|
191
|
+
return ` ${finding.file}:${finding.line} [${label}] ${finding.message}`;
|
|
192
|
+
};
|
|
193
|
+
const survey = (findings, files, log) => {
|
|
194
|
+
const byRule = new Map();
|
|
195
|
+
const byCheck = new Map();
|
|
196
|
+
const byFile = countFindings(findings);
|
|
197
|
+
for (const f of findings) {
|
|
198
|
+
byRule.set(f.rule, (byRule.get(f.rule) ?? 0) + 1);
|
|
199
|
+
byCheck.set(f.check, (byCheck.get(f.check) ?? 0) + 1);
|
|
200
|
+
}
|
|
201
|
+
log(`files checked: ${files}; files with findings: ${byFile.size}; findings: ${findings.length}`);
|
|
202
|
+
for (const rule of RULE_IDS) {
|
|
203
|
+
const filesWith = [...byFile.values()].filter((c) => (c[rule] ?? 0) > 0).length;
|
|
204
|
+
log(`rule ${rule}: ${byRule.get(rule) ?? 0} findings in ${filesWith} files`);
|
|
205
|
+
}
|
|
206
|
+
for (const [check, n] of [...byCheck].sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
207
|
+
log(`check ${check}: ${n}`);
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* Runs the gate; resolves with its exit code. A git store that cannot answer
|
|
211
|
+
* for an object it lists is exit 2; git itself failing throws.
|
|
212
|
+
*/
|
|
213
|
+
export const records = (options, repo, log, settings) => {
|
|
214
|
+
try {
|
|
215
|
+
return runChecks(options, repo, log, settings);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (!(error instanceof UnreadableObject))
|
|
219
|
+
throw error;
|
|
220
|
+
log(`dvara records: ${error.message}`);
|
|
221
|
+
return 2;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const runChecks = (options, repo, log, settings) => {
|
|
225
|
+
const started = Date.now();
|
|
226
|
+
const baselinePath = settings.records.baseline;
|
|
227
|
+
const own = [baselinePath, ...(settings.own ?? [])];
|
|
228
|
+
const rules = settings.rules ?? rulesFor(settings.records);
|
|
229
|
+
const planned = plan(options, repo, settings);
|
|
230
|
+
if ("error" in planned) {
|
|
231
|
+
log(`dvara records: ${planned.error}`);
|
|
232
|
+
return 2;
|
|
233
|
+
}
|
|
234
|
+
// A file the branch renamed keeps its history: its base text is the old
|
|
235
|
+
// path's, so what it already cited stays legacy.
|
|
236
|
+
let baseRenames = null;
|
|
237
|
+
const renamedFrom = (path) => {
|
|
238
|
+
const base = planned.branchBase;
|
|
239
|
+
if (typeof base !== "string")
|
|
240
|
+
return undefined;
|
|
241
|
+
baseRenames ??= renamesIn(repo, planned.ref === ":" ? ["--cached", base] : [base, "HEAD"]);
|
|
242
|
+
return baseRenames.get(path);
|
|
243
|
+
};
|
|
244
|
+
const tree = treeContext(repo, planned.ref, planned.branchBase, renamedFrom);
|
|
245
|
+
const recording = options.writeBaseline || options.survey;
|
|
246
|
+
let baseline = { files: pathMap() };
|
|
247
|
+
let before = null;
|
|
248
|
+
// The commit that adds the baseline has nothing to compare it with, so it
|
|
249
|
+
// must match the tree exactly: every entry is checked, over and under.
|
|
250
|
+
let adding = false;
|
|
251
|
+
try {
|
|
252
|
+
if (!recording) {
|
|
253
|
+
baseline = parseBaseline(tree.read(baselinePath), baselinePath);
|
|
254
|
+
if (planned.baselineChanged) {
|
|
255
|
+
// A root commit has no base at all: its baseline is a first one.
|
|
256
|
+
if (planned.baselineBefore === null)
|
|
257
|
+
adding = true;
|
|
258
|
+
else {
|
|
259
|
+
const name = `${planned.baselineBefore}:${baselinePath}`;
|
|
260
|
+
const previous = readBlobs(repo, [name]).get(name) ?? null;
|
|
261
|
+
if (previous !== null)
|
|
262
|
+
before = parseBaseline(previous, name);
|
|
263
|
+
else if (treeLists(repo, planned.baselineBefore, baselinePath))
|
|
264
|
+
// The base has a baseline git cannot read: not a first baseline.
|
|
265
|
+
throw new UnreadableObject(name);
|
|
266
|
+
else
|
|
267
|
+
adding = true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
if (error instanceof UnreadableObject)
|
|
274
|
+
throw error;
|
|
275
|
+
log(`dvara records: ${error.message}`);
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
278
|
+
// A baseline edit re-checks every file whose entry it changed, so a lowered
|
|
279
|
+
// or removed allowance must match the tree in the same change.
|
|
280
|
+
const moved = before === null
|
|
281
|
+
? []
|
|
282
|
+
: changedEntries(before, baseline).filter((p) => tree.pathExists(p));
|
|
283
|
+
const paths = [
|
|
284
|
+
...new Set([
|
|
285
|
+
...planned.check,
|
|
286
|
+
...moved,
|
|
287
|
+
...(adding ? Object.keys(baseline.files) : []),
|
|
288
|
+
]),
|
|
289
|
+
]
|
|
290
|
+
.sort()
|
|
291
|
+
// No rule reads a binary, the gate's own files, a skipped or a frozen
|
|
292
|
+
// path, so they are not read at all. Nor a symlink or a submodule: they
|
|
293
|
+
// are skipped, not refused. A symlink's blob is its target's path, not a
|
|
294
|
+
// record (the target, when tracked, is read under its own path, and a
|
|
295
|
+
// link like `CLAUDE.md -> AGENTS.md` is common); a submodule's entry is a
|
|
296
|
+
// commit of another repository, which this store need not have.
|
|
297
|
+
.filter((p) => isReadPath(p, settings.records, own) && !tree.isLink(p));
|
|
298
|
+
const blobs = readBlobs(repo, paths.map((p) => objectName(planned.ref, p)));
|
|
299
|
+
const texts = [];
|
|
300
|
+
for (const path of paths) {
|
|
301
|
+
const text = blobs.get(objectName(planned.ref, path)) ?? null;
|
|
302
|
+
if (text === null) {
|
|
303
|
+
// A path the snapshot lists whose object git cannot read is a broken
|
|
304
|
+
// store, not an absent file: nothing about it can be vouched for.
|
|
305
|
+
if (tree.pathExists(path)) {
|
|
306
|
+
log(`dvara records: ${path} is in the ${planned.ref === ":" ? "index" : "commit"} but its object cannot be read`);
|
|
307
|
+
return 2;
|
|
308
|
+
}
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (text.includes("\0")) {
|
|
312
|
+
// Binary content under a text name is skipped, except Markdown: a
|
|
313
|
+
// document every rule reads cannot opt out by carrying a NUL.
|
|
314
|
+
if (/\.mdx?$/.test(path)) {
|
|
315
|
+
log(`dvara records: ${path} contains a NUL byte; a Markdown record must be text`);
|
|
316
|
+
return 1;
|
|
317
|
+
}
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
texts.push([path, text]);
|
|
321
|
+
}
|
|
322
|
+
const check = (path, text, context) => rules.flatMap((rule) => rule(path, text, context));
|
|
323
|
+
// A dry pass records what the rules will ask git; one batch answers it.
|
|
324
|
+
const asked = noQuestions();
|
|
325
|
+
const dry = recordingContext(tree, asked);
|
|
326
|
+
for (const [path, text] of texts)
|
|
327
|
+
check(path, text, dry);
|
|
328
|
+
tree.warm(asked);
|
|
329
|
+
const checked = texts.map(([path]) => path);
|
|
330
|
+
const findings = texts.flatMap(([path, text]) => check(path, text, tree));
|
|
331
|
+
if (options.writeBaseline) {
|
|
332
|
+
const files = pathMap();
|
|
333
|
+
const held = findings.filter((f) => f.ratchet !== false);
|
|
334
|
+
for (const [file, counts] of countFindings(held))
|
|
335
|
+
files[file] = counts;
|
|
336
|
+
const target = join(repo.cwd, baselinePath);
|
|
337
|
+
// Writing through a symlink would replace its target, which may lie
|
|
338
|
+
// outside the repository.
|
|
339
|
+
if (lstatSync(target, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
340
|
+
log(`dvara records: ${baselinePath} is a symlink; replace it with a file before writing the baseline`);
|
|
341
|
+
return 2;
|
|
342
|
+
}
|
|
343
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
344
|
+
writeFileSync(target, renderBaseline({ files }));
|
|
345
|
+
log(`dvara records: wrote ${baselinePath} (${Object.keys(files).length} files, ${held.length} findings)`);
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
if (options.survey) {
|
|
349
|
+
survey(findings, checked.length, log);
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
const scope = options.mode === "all" || adding
|
|
353
|
+
? Object.keys(baseline.files)
|
|
354
|
+
: [...planned.check, ...moved];
|
|
355
|
+
const result = ratchet(checked, findings, baseline);
|
|
356
|
+
const inScope = pathMap();
|
|
357
|
+
for (const p of scope) {
|
|
358
|
+
const entry = baseline.files[p];
|
|
359
|
+
if (entry !== undefined)
|
|
360
|
+
inScope[p] = entry;
|
|
361
|
+
}
|
|
362
|
+
// An allowance must answer to a file the gate reads: one for a path that
|
|
363
|
+
// is gone, or that the gate skips (binary, NUL-bearing, frozen, skipped,
|
|
364
|
+
// its own), or renamed to such a path, could never be lowered and is
|
|
365
|
+
// refused.
|
|
366
|
+
const read = new Set(checked);
|
|
367
|
+
const stale = staleEntries({ files: inScope }, (p) => read.has(p));
|
|
368
|
+
const raised = before === null
|
|
369
|
+
? []
|
|
370
|
+
: baselineIncreases(before, baseline, planned.renames());
|
|
371
|
+
const ms = Date.now() - started;
|
|
372
|
+
if (result.over.length === 0 &&
|
|
373
|
+
result.under.length === 0 &&
|
|
374
|
+
stale.length === 0 &&
|
|
375
|
+
raised.length === 0) {
|
|
376
|
+
// Nothing is over: the baseline holds every finding.
|
|
377
|
+
log(`✅ records: ${checked.length} file(s) checked, ${findings.length} legacy finding(s) held by ${baselinePath} (${ms} ms)`);
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
log("❌ records: refused");
|
|
381
|
+
for (const over of result.over) {
|
|
382
|
+
const note = over.allowed > 0
|
|
383
|
+
? ` (baseline allows ${over.allowed}, now ${over.now})`
|
|
384
|
+
: "";
|
|
385
|
+
log(`${over.file} — ${over.rule}${note}:`);
|
|
386
|
+
for (const finding of over.findings)
|
|
387
|
+
log(format(finding));
|
|
388
|
+
}
|
|
389
|
+
for (const under of result.under)
|
|
390
|
+
log(`${under.file} — ${under.rule}: ${under.now} left of ${under.allowed}; lower its count in ${baselinePath} to ${under.now}`);
|
|
391
|
+
for (const path of stale) {
|
|
392
|
+
const why = tree.pathExists(path) ? "not a file the gate reads" : "gone";
|
|
393
|
+
log(`${path} — ${why}; remove its entry from ${baselinePath}`);
|
|
394
|
+
}
|
|
395
|
+
for (const problem of raised)
|
|
396
|
+
log(`${baselinePath}: ${problem}`);
|
|
397
|
+
log(`(${ms} ms)`);
|
|
398
|
+
return 1;
|
|
399
|
+
};
|