@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.
package/README.md CHANGED
@@ -81,6 +81,19 @@ field is optional; an unknown field is an error.
81
81
  | `baseRefs` | `origin/main`, `origin/develop`, `origin/master` | the refs tried for the base a run is judged against |
82
82
  | `stages` | `{}` | each hook's stages in order; `dvara stages <hook>` prints them |
83
83
 
84
+ `records`, an object, holds the settings of `dvara records` and `dvara frozen`
85
+ (below). Each field is optional; an unknown one is an error.
86
+
87
+ | Field | Default | Meaning |
88
+ | ---------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
89
+ | `baseline` | `.dvara/records-baseline.json` | the per-file baseline, relative to the root; read from git, so it must be tracked (un-ignore it if `.dvara/` is ignored) |
90
+ | `skip` | `[]` | path prefixes no rule reads, such as a checker's own fixtures |
91
+ | `frozen` | `{"paths": [], "except": []}` | `paths`: prefixes `dvara frozen` refuses changing and no rule reads; `except`: exact paths under them that stay editable |
92
+ | `identityPaths` | `[]` | regular expressions for the paths where a commit must be cited by its full id (`EVIDENCE-01`); none by default |
93
+ | `repository` | `null` | `owner/name` on GitHub: its commit URLs are this repository's, every other repository's are not checked |
94
+ | `allowPaths` | `[]` | regular expressions for machine paths that are durable by agreement, matched from where the path starts: anchor with `^` |
95
+ | `packageManager` | `null` | `pnpm`, `npm` or `yarn`: the other managers' install commands are refused; `null` checks none |
96
+
84
97
  The environment it reads: `DVARA_MODE=preflight` (a preflight's rows get kind
85
98
  `preflight`), `DVARA_BACKGROUND=1` (started detached), `DVARA_MEASUREMENT_CONTEXT=manual-benchmark`,
86
99
  `DVARA_BOOTSTRAP_SECONDS` (seconds the hook spent selecting its runtime before
@@ -112,11 +125,78 @@ export function exitDisposition(
112
125
  ): { code: number; status: string } | null;
113
126
  ```
114
127
 
128
+ ## The record gate: `dvara records`
129
+
130
+ Committed text should not cite what stops meaning anything once the session
131
+ that wrote it ends. The gate reads every text file that is not source code, a
132
+ binary, a lockfile (`*.lock`, `pnpm-lock.yaml`, `package-lock.json`), a
133
+ symlink or a submodule, a skipped or frozen path, the baseline or the config,
134
+ and refuses (rule `EVIDENCE-02`, one finding per line):
135
+
136
+ - a `file://` URL, a `/Users/<name>` or `/home/<name>` home path, a sandbox temp
137
+ root (`/private/tmp/`, `/private/var/`, `/var/folders/`), a literal `.git/`
138
+ path, or an agent-process marker ("I am Agent 3");
139
+ - another package manager's install command, when `packageManager` names one.
140
+ A clause that says not to ("never run `npm install`") is no finding.
141
+
142
+ Where `identityPaths` match, it also refuses (rule `EVIDENCE-01`) a commit cited
143
+ by less than its full 40-hex id, since git lengthens abbreviations as history
144
+ grows and clones disagree on their length:
145
+
146
+ - `abbreviated-id`: a 7–39 hex token the line makes a commit (after a commit
147
+ word, backticked on its own, in an `a..b` range, cut off with an ellipsis, or
148
+ in a short commit URL of `repository`), unless the line names it another kind
149
+ of hash (digest, sha256, checksum, …);
150
+ - `abbreviating-command`: a git command that prints abbreviations
151
+ (`--oneline`, `%h`, `--abbrev-commit`, `rev-parse --short`);
152
+ - `unresolved-id`: a full id the change adds that is not a commit in this
153
+ repository, compared with the ids the file cited at the merge base. No
154
+ baseline holds these: they are new by construction.
155
+
156
+ In Markdown, a fence's captured output is not read for tokens; its command
157
+ lines are (`$ ` lines of a transcript, every line of an `sh` fence). Fences are
158
+ found by CommonMark's rules (mdast-util-from-markdown), so a fence in a quote or
159
+ a list item counts.
160
+
161
+ Files written before the gate existed are held by the baseline: each file's
162
+ count per rule, which may only fall. A new finding in a legacy file, a fix that
163
+ leaves its allowance in place, an entry for a file that is gone or not read,
164
+ and a baseline edit that raises a count or adds a file are all refused; a
165
+ rename git detects carries its entry forward.
166
+
167
+ ```sh
168
+ dvara records --staged # pre-commit: the index, against the first baseRefs that resolves
169
+ dvara records --base origin/main # CI: the files the branch changed, at HEAD
170
+ dvara records --all [--base REF] # every tracked file at HEAD
171
+ dvara records --all --write-baseline # record today's counts
172
+ dvara records --all --survey # counts per rule and check
173
+ ```
174
+
175
+ Exit 0: clean or within the baseline; 1: refused; 2: could not run. Inside the
176
+ package the gate takes any list of rules, `(path, text, tree) => Finding[]`;
177
+ the command runs the list `rulesFor` in `src/records/rules.ts` returns.
178
+
179
+ ## Frozen paths: `dvara frozen`
180
+
181
+ A path under one of `records.frozen.paths` is history: no commit adds, changes
182
+ or deletes a file there, except the exact `records.frozen.except` paths.
183
+
184
+ ```sh
185
+ dvara frozen --staged # pre-commit, merge commits included
186
+ dvara frozen --base origin/main [--head REF] # CI: what the branch changes since its merge base
187
+ ```
188
+
189
+ In a merge, a path staged exactly as a merged-in parent has it is that
190
+ parent's change and passes; a hand resolution counts. With no frozen paths
191
+ configured it passes without asking git. Exit 0, 1 or 2 as above.
192
+
115
193
  ## Commands
116
194
 
117
195
  ```sh
118
196
  dvara run <hook-file> [hook args…] # run a git hook under the supervisor
119
197
  dvara stages <hook> # the stages .dvara.json lists for it
120
198
  dvara shell <stages|keep-going> # the path of a shell helper
199
+ dvara records <--staged | --all [--base REF] [--write-baseline | --survey] | --base REF>
200
+ dvara frozen <--staged | --base REF [--head REF]>
121
201
  dvara --version
122
202
  ```
package/dist/cli.js CHANGED
@@ -3,12 +3,19 @@
3
3
  import { readFileSync, realpathSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { loadConfig } from "./config.js";
6
+ import { configName, loadConfig } from "./config.js";
7
+ import { frozen, parseArgs as parseFrozenArgs, USAGE as FROZEN_USAGE, } from "./records/frozen.js";
8
+ import { parseArgs as parseRecordsArgs, records, USAGE as RECORDS_USAGE, } from "./records/gate.js";
9
+ import { runner } from "./records/git.js";
7
10
  import { supervise } from "./supervisor.js";
8
11
  export const USAGE = [
9
12
  "usage: dvara run <hook-file> [hook args…] run a git hook under the supervisor",
10
13
  " dvara stages <hook> the hook stages the config lists, one per line",
11
14
  " dvara shell <stages|keep-going> the path of a shell helper to source",
15
+ " dvara records <--staged | --all [--base REF] [--write-baseline | --survey] | --base REF>",
16
+ " the record gate: no new non-durable paths, a baseline that only falls",
17
+ " dvara frozen <--staged | --base REF [--head REF]>",
18
+ " refuse changes to the frozen paths",
12
19
  " dvara --version",
13
20
  ].join("\n");
14
21
  const SHELL_HELPERS = new Set(["stages", "keep-going"]);
@@ -18,6 +25,38 @@ export const ownVersion = () => {
18
25
  };
19
26
  /** The path of a shell helper this package ships. */
20
27
  export const shellHelper = (name) => resolve(import.meta.dirname, "..", "shell", `${name}.sh`);
28
+ /**
29
+ * `dvara records` or `dvara frozen` at the root `cwd`: 2 on bad arguments, a
30
+ * config it cannot read, or git failing.
31
+ */
32
+ const checkCommand = (command, args, { io, cwd, env }) => {
33
+ const parsed = command === "records" ? parseRecordsArgs(args) : parseFrozenArgs(args);
34
+ if ("error" in parsed) {
35
+ io.err(`dvara ${command}: ${parsed.error}`);
36
+ io.err(command === "records" ? RECORDS_USAGE : FROZEN_USAGE);
37
+ return 2;
38
+ }
39
+ try {
40
+ const config = loadConfig(cwd, env);
41
+ const repo = { cwd, env };
42
+ if ("mode" in parsed)
43
+ return records(parsed, repo, io.out, {
44
+ records: config.records,
45
+ baseRefs: config.baseRefs,
46
+ own: [configName(env)],
47
+ });
48
+ const result = frozen(parsed, runner(repo), config.records.frozen, env);
49
+ for (const line of result.stdout)
50
+ io.out(line);
51
+ for (const line of result.stderr)
52
+ io.err(line);
53
+ return result.code;
54
+ }
55
+ catch (error) {
56
+ io.err(`dvara ${command}: ${error.message}`);
57
+ return 2;
58
+ }
59
+ };
21
60
  /** Runs one command; resolves with the process's exit code. */
22
61
  export const run = async (argv, context) => {
23
62
  const { io, cwd, env } = context;
@@ -32,6 +71,8 @@ export const run = async (argv, context) => {
32
71
  io.out(shellHelper(rest[0] ?? ""));
33
72
  return 0;
34
73
  }
74
+ if (command === "records" || command === "frozen")
75
+ return checkCommand(command, rest, context);
35
76
  if ((command === "run" && rest.length > 0) ||
36
77
  (command === "stages" && rest.length === 1)) {
37
78
  // Git runs every hook at the top of the working tree.
package/dist/config.js CHANGED
@@ -1,20 +1,117 @@
1
1
  // A repository's dvara settings: `.dvara.json` at its root, or the file
2
2
  // DVARA_CONFIG names (relative to the root). Every field is optional.
3
3
  import { readFileSync } from "node:fs";
4
- import { resolve } from "node:path";
4
+ import { isAbsolute, posix, resolve } from "node:path";
5
+ export const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn"];
5
6
  export const DEFAULTS = {
6
7
  historyDir: ".dvara",
7
8
  quietHooks: ["pre-commit"],
8
9
  watchdog: null,
9
10
  baseRefs: ["origin/main", "origin/develop", "origin/master"],
10
11
  stages: {},
12
+ records: {
13
+ baseline: ".dvara/records-baseline.json",
14
+ skip: [],
15
+ frozen: { paths: [], except: [] },
16
+ identityPaths: [],
17
+ repository: null,
18
+ allowPaths: [],
19
+ packageManager: null,
20
+ },
11
21
  };
12
22
  export const CONFIG_FILE = ".dvara.json";
23
+ /** The config file's name relative to the root, as `loadConfig` reads it. */
24
+ export const configName = (env) => posix.normalize(env.DVARA_CONFIG || CONFIG_FILE);
13
25
  const strings = (value, field) => {
14
26
  if (!Array.isArray(value) || !value.every((v) => typeof v === "string"))
15
27
  throw new Error(`${field} must be a list of strings`);
16
28
  return value;
17
29
  };
30
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
31
+ const onlyKnown = (entries, known, field) => {
32
+ for (const key of Object.keys(entries))
33
+ if (!known.includes(key))
34
+ throw new Error(`unknown field ${field}${key}`);
35
+ };
36
+ /** Whether `path`, normalized, leaves the root: `..`, `../x`, `a/../../x`. */
37
+ const escapes = (path) => {
38
+ const normal = posix.normalize(path);
39
+ return normal === ".." || normal.startsWith("../");
40
+ };
41
+ /**
42
+ * Repository-relative path prefixes or paths: none empty, none absolute, none
43
+ * leaving the root (git prints no path there, so it would match nothing).
44
+ */
45
+ const repoPaths = (value, field) => {
46
+ const list = strings(value, field);
47
+ for (const path of list)
48
+ if (path === "" || isAbsolute(path) || escapes(path))
49
+ throw new Error(`${field} must hold repository-relative paths`);
50
+ return list;
51
+ };
52
+ const patterns = (value, field) => strings(value, field).map((source, i) => {
53
+ try {
54
+ return new RegExp(source);
55
+ }
56
+ catch (error) {
57
+ throw new Error(`${field}[${i}] is not a regular expression: ${error.message}`);
58
+ }
59
+ });
60
+ const parseRecordsConfig = (value) => {
61
+ if (!isObject(value))
62
+ throw new Error("records must be an object");
63
+ onlyKnown(value, Object.keys(DEFAULTS.records), "records.");
64
+ const records = { ...DEFAULTS.records };
65
+ if ("baseline" in value) {
66
+ const path = value.baseline;
67
+ // Compared with the paths git prints: `./a/../b.json` is `b.json`. A
68
+ // folder (`.`, `archive/`) names no tracked file, so it is no baseline.
69
+ const normal = typeof path === "string" ? posix.normalize(path) : "";
70
+ if (typeof path !== "string" ||
71
+ path === "" ||
72
+ isAbsolute(path) ||
73
+ escapes(path) ||
74
+ normal === "." ||
75
+ normal.endsWith("/"))
76
+ throw new Error("records.baseline must be a file path inside the repository");
77
+ records.baseline = normal;
78
+ }
79
+ if ("skip" in value)
80
+ records.skip = repoPaths(value.skip, "records.skip");
81
+ if ("frozen" in value) {
82
+ const frozen = value.frozen;
83
+ if (!isObject(frozen))
84
+ throw new Error("records.frozen must be an object");
85
+ onlyKnown(frozen, ["paths", "except"], "records.frozen.");
86
+ records.frozen = {
87
+ paths: "paths" in frozen
88
+ ? repoPaths(frozen.paths, "records.frozen.paths")
89
+ : [],
90
+ except: "except" in frozen
91
+ ? repoPaths(frozen.except, "records.frozen.except")
92
+ : [],
93
+ };
94
+ }
95
+ if ("identityPaths" in value)
96
+ records.identityPaths = patterns(value.identityPaths, "records.identityPaths");
97
+ if ("repository" in value) {
98
+ const repository = value.repository;
99
+ if (repository !== null &&
100
+ (typeof repository !== "string" || !/^[\w.-]+\/[\w.-]+$/.test(repository)))
101
+ throw new Error('records.repository must be "owner/name" or null');
102
+ records.repository = repository;
103
+ }
104
+ if ("allowPaths" in value)
105
+ records.allowPaths = patterns(value.allowPaths, "records.allowPaths");
106
+ if ("packageManager" in value) {
107
+ const manager = value.packageManager;
108
+ if (manager !== null &&
109
+ !PACKAGE_MANAGERS.includes(manager))
110
+ throw new Error(`records.packageManager must be one of ${PACKAGE_MANAGERS.join(", ")}, or null`);
111
+ records.packageManager = manager;
112
+ }
113
+ return records;
114
+ };
18
115
  /** The settings in `text` over the defaults; a field of the wrong type is an error, an unknown one too. */
19
116
  export const parseConfig = (text) => {
20
117
  const raw = JSON.parse(text);
@@ -49,6 +146,8 @@ export const parseConfig = (text) => {
49
146
  out[hook] = strings(names, `stages.${hook}`);
50
147
  config.stages = out;
51
148
  }
149
+ if ("records" in entries)
150
+ config.records = parseRecordsConfig(entries.records);
52
151
  return config;
53
152
  };
54
153
  /**
@@ -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
+ };