@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
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
|
-
|
|
33
|
-
|
|
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
|
|
77
|
-
|
|
|
78
|
-
| `historyDir`
|
|
79
|
-
| `quietHooks`
|
|
80
|
-
| `watchdog`
|
|
81
|
-
| `baseRefs`
|
|
82
|
-
| `stages`
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
};
|