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