@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.
@@ -0,0 +1,152 @@
1
+ // The ratchet for files written before the gate existed.
2
+ //
3
+ // A legacy file's current count of findings per rule is recorded here, and the
4
+ // gate holds each count where it is or lower. Three refusals keep the numbers
5
+ // honest:
6
+ //
7
+ // - a file over its recorded count (a new violation in a legacy file);
8
+ // - a file under it (the fix lands with the lower number, so it cannot creep
9
+ // back up to the old allowance), and an entry for a file that is gone or
10
+ // that the gate does not read;
11
+ // - a baseline edit that raises a count or adds a file, other than a rename
12
+ // git detects in the same change carrying its entry forward at the same or
13
+ // lower counts.
14
+ //
15
+ // The commit that adds the baseline has no earlier one to compare with; the
16
+ // gate then checks every file it names, so each entry must equal the tree.
17
+ import { RULE_IDS } from "./types.js";
18
+ /**
19
+ * A map keyed by repository path with no prototype: a tracked file named
20
+ * `__proto__` or `constructor` is an entry like any other.
21
+ */
22
+ export const pathMap = () => Object.create(null);
23
+ /** A JSON object: not an array, not null. */
24
+ const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
25
+ const isRuleId = (rule) => RULE_IDS.includes(rule);
26
+ /** The baseline in `text`; `where` names it in errors. Absent or blank is empty. */
27
+ export const parseBaseline = (text, where) => {
28
+ if (text === null || text.trim() === "")
29
+ return { files: pathMap() };
30
+ let raw;
31
+ try {
32
+ raw = JSON.parse(text);
33
+ }
34
+ catch (error) {
35
+ throw new Error(`${where}: ${error.message}`);
36
+ }
37
+ if (!isJsonObject(raw) || !("files" in raw))
38
+ throw new Error(`${where}: expected {"files": {...}}`);
39
+ const files = raw.files;
40
+ if (!isJsonObject(files))
41
+ throw new Error(`${where}: "files" must be an object`);
42
+ const out = pathMap();
43
+ for (const [path, counts] of Object.entries(files)) {
44
+ if (!isJsonObject(counts))
45
+ throw new Error(`${where}: ${path} must map rule ids to counts`);
46
+ const entry = {};
47
+ for (const [rule, count] of Object.entries(counts)) {
48
+ if (!isRuleId(rule))
49
+ throw new Error(`${where}: ${path} names unknown rule ${rule}`);
50
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 1)
51
+ throw new Error(`${where}: ${path} ${rule} must be a positive integer`);
52
+ entry[rule] = count;
53
+ }
54
+ out[path] = entry;
55
+ }
56
+ return { files: out };
57
+ };
58
+ /** The baseline as committed: paths sorted, zero counts and empty entries dropped. */
59
+ export const renderBaseline = (baseline) => {
60
+ const files = pathMap();
61
+ for (const path of Object.keys(baseline.files).sort()) {
62
+ const counts = {};
63
+ for (const rule of RULE_IDS) {
64
+ const count = baseline.files[path]?.[rule];
65
+ if (count !== undefined && count > 0)
66
+ counts[rule] = count;
67
+ }
68
+ if (Object.keys(counts).length > 0)
69
+ files[path] = counts;
70
+ }
71
+ return `${JSON.stringify({ files }, null, 2)}\n`;
72
+ };
73
+ export const countFindings = (findings) => {
74
+ const out = new Map();
75
+ for (const finding of findings) {
76
+ const counts = out.get(finding.file) ?? {};
77
+ counts[finding.rule] = (counts[finding.rule] ?? 0) + 1;
78
+ out.set(finding.file, counts);
79
+ }
80
+ return out;
81
+ };
82
+ /** Compare the checked files' findings with their allowances. */
83
+ export const ratchet = (checked, findings, baseline) => {
84
+ const result = { over: [], under: [] };
85
+ const held = findings.filter((f) => f.ratchet !== false);
86
+ const counts = countFindings(held);
87
+ // A finding no baseline may hold is over, whatever the file's allowance.
88
+ const unheld = findings.filter((f) => f.ratchet === false);
89
+ for (const file of new Set(unheld.map((f) => f.file))) {
90
+ for (const rule of RULE_IDS) {
91
+ const mine = unheld.filter((f) => f.file === file && f.rule === rule);
92
+ if (mine.length === 0)
93
+ continue;
94
+ result.over.push({
95
+ file,
96
+ rule,
97
+ allowed: 0,
98
+ now: mine.length,
99
+ findings: mine,
100
+ });
101
+ }
102
+ }
103
+ for (const file of checked) {
104
+ const now = counts.get(file) ?? {};
105
+ const allowed = baseline.files[file] ?? {};
106
+ for (const rule of RULE_IDS) {
107
+ const n = now[rule] ?? 0;
108
+ const a = allowed[rule] ?? 0;
109
+ if (n > a)
110
+ result.over.push({
111
+ file,
112
+ rule,
113
+ allowed: a,
114
+ now: n,
115
+ findings: held.filter((f) => f.file === file && f.rule === rule),
116
+ });
117
+ else if (n < a)
118
+ result.under.push({ file, rule, allowed: a, now: n });
119
+ }
120
+ }
121
+ return result;
122
+ };
123
+ /** Baseline entries for files `exists` does not vouch for. */
124
+ export const staleEntries = (baseline, exists) => Object.keys(baseline.files).filter((path) => !exists(path));
125
+ /**
126
+ * Refusals for a baseline edit that raises any allowance. A new entry is
127
+ * accepted only as a rename git detected in the same change (`renames`, new
128
+ * path to old path), whose old entry is removed and whose counts are no
129
+ * higher: fixing one legacy file must not fund a violation in another.
130
+ */
131
+ export const baselineIncreases = (before, after, renames = new Map()) => {
132
+ const problems = [];
133
+ const carriesForward = (from, counts) => {
134
+ const old = before.files[from];
135
+ return (!(from in after.files) &&
136
+ old !== undefined &&
137
+ RULE_IDS.every((rule) => (counts[rule] ?? 0) <= (old[rule] ?? 0)));
138
+ };
139
+ for (const [path, counts] of Object.entries(after.files)) {
140
+ const old = before.files[path];
141
+ if (old === undefined) {
142
+ const from = renames.get(path);
143
+ if (from === undefined || !carriesForward(from, counts))
144
+ problems.push(`${path}: a new baseline entry; fix the file instead of recording its findings`);
145
+ continue;
146
+ }
147
+ for (const rule of RULE_IDS)
148
+ if ((counts[rule] ?? 0) > (old[rule] ?? 0))
149
+ problems.push(`${path}: ${rule} raised from ${old[rule] ?? 0} to ${counts[rule] ?? 0}; counts only fall`);
150
+ }
151
+ return problems;
152
+ };
@@ -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
+ };