@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.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/dist/cli.js +124 -0
- package/dist/config.js +175 -0
- package/dist/context.js +67 -0
- package/dist/quiet.js +31 -0
- 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/records.js +96 -0
- package/dist/supervisor.js +371 -0
- package/package.json +52 -3
- package/shell/keep-going.sh +86 -0
- package/shell/stages.sh +52 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Git access for the record gate: one tree (the index at pre-commit, a commit
|
|
2
|
+
// in CI) read in batches, so a hundred staged files cost one `cat-file`
|
|
3
|
+
// process, not a hundred.
|
|
4
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
5
|
+
export const git = (repo, args, input) => execFileSync("git", args, {
|
|
6
|
+
cwd: repo.cwd,
|
|
7
|
+
env: repo.env ?? process.env,
|
|
8
|
+
encoding: "utf8",
|
|
9
|
+
...(input === undefined ? {} : { input }),
|
|
10
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
11
|
+
// All three piped: stderr goes into the thrown error, not the terminal.
|
|
12
|
+
stdio: "pipe",
|
|
13
|
+
});
|
|
14
|
+
export const runner = (repo) => (args) => git(repo, args);
|
|
15
|
+
/**
|
|
16
|
+
* A git question whose "no" is exit 1 (`rev-parse --verify --quiet` on a
|
|
17
|
+
* missing ref, `merge-base` with no common ancestor): null. Any other failure
|
|
18
|
+
* is git not answering, and is thrown rather than read as "no".
|
|
19
|
+
*/
|
|
20
|
+
export const tryGit = (repo, args) => {
|
|
21
|
+
const result = spawnSync("git", args, {
|
|
22
|
+
cwd: repo.cwd,
|
|
23
|
+
env: repo.env ?? process.env,
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
26
|
+
});
|
|
27
|
+
if (result.status === 0)
|
|
28
|
+
return result.stdout;
|
|
29
|
+
if (result.status === 1)
|
|
30
|
+
return null;
|
|
31
|
+
throw new Error(`git ${args[0] ?? ""} failed (${String(result.status ?? result.error)}): ${result.stderr}`);
|
|
32
|
+
};
|
|
33
|
+
const splitZ = (out) => out.split("\0").filter(Boolean);
|
|
34
|
+
/** Every path in the tree. */
|
|
35
|
+
export const listTree = (repo, ref) => ref === ":"
|
|
36
|
+
? splitZ(git(repo, ["ls-files", "-z"]))
|
|
37
|
+
: splitZ(git(repo, ["ls-tree", "-r", "-z", "--name-only", ref]));
|
|
38
|
+
/**
|
|
39
|
+
* Every path in the tree with its git mode (`100644`, `120000`, `160000`, …).
|
|
40
|
+
* The index prints `<mode> <id> <stage>\t<path>`, a commit's tree
|
|
41
|
+
* `<mode> <type> <id>\t<path>`: the mode leads both, the path follows the tab.
|
|
42
|
+
*/
|
|
43
|
+
export const listModes = (repo, ref) => {
|
|
44
|
+
const out = ref === ":"
|
|
45
|
+
? git(repo, ["ls-files", "-s", "-z"])
|
|
46
|
+
: git(repo, ["ls-tree", "-r", "-z", ref]);
|
|
47
|
+
return new Map(splitZ(out).map((entry) => [
|
|
48
|
+
entry.slice(entry.indexOf("\t") + 1),
|
|
49
|
+
entry.slice(0, entry.indexOf(" ")),
|
|
50
|
+
]));
|
|
51
|
+
};
|
|
52
|
+
/** A symlink (its blob is the target's path) or a submodule's commit. */
|
|
53
|
+
const LINK_MODES = new Set(["120000", "160000"]);
|
|
54
|
+
/**
|
|
55
|
+
* A path the tree lists whose object git cannot read. Nothing about it can be
|
|
56
|
+
* vouched for, so the gate stops rather than read it as absent.
|
|
57
|
+
*/
|
|
58
|
+
export class UnreadableObject extends Error {
|
|
59
|
+
// A plain field: type stripping has no parameter properties.
|
|
60
|
+
object;
|
|
61
|
+
constructor(object) {
|
|
62
|
+
super(`${object} is listed but its object cannot be read`);
|
|
63
|
+
this.object = object;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Of these paths, the ones the tree at `rev` lists (read from the root). */
|
|
67
|
+
export const listedIn = (repo, rev, paths) => {
|
|
68
|
+
if (paths.length === 0)
|
|
69
|
+
return new Set();
|
|
70
|
+
const out = git(repo, [
|
|
71
|
+
"ls-tree",
|
|
72
|
+
"--full-tree",
|
|
73
|
+
"--name-only",
|
|
74
|
+
"-z",
|
|
75
|
+
rev,
|
|
76
|
+
"--",
|
|
77
|
+
...paths,
|
|
78
|
+
]);
|
|
79
|
+
const wanted = new Set(paths);
|
|
80
|
+
return new Set(out.split("\0").filter((p) => wanted.has(p)));
|
|
81
|
+
};
|
|
82
|
+
/** Is `path` in the tree at `rev`? Asks the tree, not the blob. */
|
|
83
|
+
export const treeLists = (repo, rev, path) => listedIn(repo, rev, [path]).has(path);
|
|
84
|
+
/**
|
|
85
|
+
* The most object content one git process may print, in the batch and outside
|
|
86
|
+
* it alike: a blob the batch reads is not refused when asked on its own.
|
|
87
|
+
*/
|
|
88
|
+
export const OBJECT_BUFFER = 1024 * 1024 * 1024;
|
|
89
|
+
/**
|
|
90
|
+
* One object question outside the batch: its answer, or null when git has no
|
|
91
|
+
* such object (cat-file exits 128 for a missing name, as the batch says
|
|
92
|
+
* "missing").
|
|
93
|
+
*/
|
|
94
|
+
const askOne = (repo, args) => {
|
|
95
|
+
const result = spawnSync("git", args, {
|
|
96
|
+
cwd: repo.cwd,
|
|
97
|
+
env: repo.env ?? process.env,
|
|
98
|
+
encoding: "utf8",
|
|
99
|
+
maxBuffer: OBJECT_BUFFER,
|
|
100
|
+
});
|
|
101
|
+
return result.status === 0 ? result.stdout : null;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* The byte count in a `git cat-file --batch` header, `<oid> <type> <size>`.
|
|
105
|
+
* Any other header throws: reading on from a wrong size would hand every
|
|
106
|
+
* later blob in the batch the wrong bytes.
|
|
107
|
+
*/
|
|
108
|
+
export const batchObjectSize = (header) => {
|
|
109
|
+
const field = header.split(" ")[2] ?? "";
|
|
110
|
+
if (!/^\d+$/.test(field))
|
|
111
|
+
throw new Error(`git cat-file --batch: unexpected header "${header}"`);
|
|
112
|
+
return Number(field);
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Contents of many blobs in one process. `names` are `rev:path` object names;
|
|
116
|
+
* a missing one maps to null. Binary content comes back as text and is the
|
|
117
|
+
* caller's to skip.
|
|
118
|
+
*/
|
|
119
|
+
export const readBlobs = (repo, names) => {
|
|
120
|
+
const out = new Map();
|
|
121
|
+
// The batch protocol is one name per line: a path with a newline in it is
|
|
122
|
+
// asked on its own.
|
|
123
|
+
const batch = names.filter((name) => !name.includes("\n"));
|
|
124
|
+
for (const name of names.filter((n) => n.includes("\n")))
|
|
125
|
+
out.set(name, askOne(repo, ["cat-file", "blob", name]));
|
|
126
|
+
if (batch.length === 0)
|
|
127
|
+
return out;
|
|
128
|
+
const result = spawnSync("git", ["cat-file", "--batch"], {
|
|
129
|
+
cwd: repo.cwd,
|
|
130
|
+
env: repo.env ?? process.env,
|
|
131
|
+
input: `${batch.join("\n")}\n`,
|
|
132
|
+
maxBuffer: OBJECT_BUFFER,
|
|
133
|
+
});
|
|
134
|
+
if (result.status !== 0)
|
|
135
|
+
throw new Error(`git cat-file --batch failed: ${String(result.stderr)}`);
|
|
136
|
+
const buffer = result.stdout;
|
|
137
|
+
let at = 0;
|
|
138
|
+
for (const name of batch) {
|
|
139
|
+
const newline = buffer.indexOf(10, at);
|
|
140
|
+
const header = buffer.subarray(at, newline).toString("utf8");
|
|
141
|
+
at = newline + 1;
|
|
142
|
+
if (header.endsWith(" missing") || header.endsWith(" ambiguous")) {
|
|
143
|
+
out.set(name, null);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const size = batchObjectSize(header);
|
|
147
|
+
out.set(name, buffer.subarray(at, at + size).toString("utf8"));
|
|
148
|
+
at += size + 1;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
};
|
|
152
|
+
export const objectName = (ref, path) => ref === ":" ? `:${path}` : `${ref}:${path}`;
|
|
153
|
+
/**
|
|
154
|
+
* The object type (commit, tree, blob, tag) of each of these names (ids or
|
|
155
|
+
* `rev:path`) the object store has. git answers one line per name, in order;
|
|
156
|
+
* a found name is echoed as its id, so answers are matched by position.
|
|
157
|
+
*/
|
|
158
|
+
export const objectTypes = (repo, names) => {
|
|
159
|
+
const types = new Map();
|
|
160
|
+
// One name per line, as in readBlobs: a newline-bearing name goes alone.
|
|
161
|
+
const batch = names.filter((name) => !name.includes("\n"));
|
|
162
|
+
for (const name of names.filter((n) => n.includes("\n"))) {
|
|
163
|
+
const type = askOne(repo, ["cat-file", "-t", name]);
|
|
164
|
+
if (type !== null)
|
|
165
|
+
types.set(name, type.trim());
|
|
166
|
+
}
|
|
167
|
+
if (batch.length === 0)
|
|
168
|
+
return types;
|
|
169
|
+
const out = git(repo, ["cat-file", "--batch-check"], `${batch.join("\n")}\n`);
|
|
170
|
+
const lines = out.split("\n");
|
|
171
|
+
batch.forEach((name, i) => {
|
|
172
|
+
// `<oid> <type> <size>` for a found name, `<name> missing|ambiguous`
|
|
173
|
+
// for any other: the type is the field between the first and last space.
|
|
174
|
+
const line = lines[i];
|
|
175
|
+
if (line === undefined || / (missing|ambiguous)$/.test(line))
|
|
176
|
+
return;
|
|
177
|
+
types.set(name, line.slice(line.indexOf(" ") + 1, line.lastIndexOf(" ")));
|
|
178
|
+
});
|
|
179
|
+
return types;
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* The merge base of `a` with `b`, or with the merge of every one of `b`:
|
|
183
|
+
* `git merge-base A B C` answers for A against a hypothetical merge of B, C.
|
|
184
|
+
*/
|
|
185
|
+
export const mergeBase = (repo, a, ...b) => {
|
|
186
|
+
const out = tryGit(repo, ["merge-base", a, ...b]);
|
|
187
|
+
return out === null ? null : out.trim();
|
|
188
|
+
};
|
|
189
|
+
export const resolveRef = (repo, ref) => {
|
|
190
|
+
const out = tryGit(repo, [
|
|
191
|
+
"rev-parse",
|
|
192
|
+
"--verify",
|
|
193
|
+
"--quiet",
|
|
194
|
+
`${ref}^{commit}`,
|
|
195
|
+
]);
|
|
196
|
+
return out === null ? null : out.trim();
|
|
197
|
+
};
|
|
198
|
+
export const noQuestions = () => ({
|
|
199
|
+
shas: new Set(),
|
|
200
|
+
bases: new Set(),
|
|
201
|
+
});
|
|
202
|
+
/**
|
|
203
|
+
* A context that answers without git and records each question. Running the
|
|
204
|
+
* rules over it first lets `warm` ask git once per kind of question instead
|
|
205
|
+
* of once per file.
|
|
206
|
+
*/
|
|
207
|
+
export const recordingContext = (tree, asked) => ({
|
|
208
|
+
typesOf: (shas) => {
|
|
209
|
+
for (const sha of shas)
|
|
210
|
+
asked.shas.add(sha);
|
|
211
|
+
return new Map(shas.map((sha) => [sha, "commit"]));
|
|
212
|
+
},
|
|
213
|
+
baseText: (path) => {
|
|
214
|
+
if (typeof tree.base !== "string")
|
|
215
|
+
return tree.base;
|
|
216
|
+
asked.bases.add(path);
|
|
217
|
+
return null;
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
/** A tree context over one git tree, with an optional merge base for branch rules. */
|
|
221
|
+
export const treeContext = (repo, ref, base,
|
|
222
|
+
/** The path a file had at the base, when the branch renamed it. */
|
|
223
|
+
renamedFrom = () => undefined) => {
|
|
224
|
+
const modes = listModes(repo, ref);
|
|
225
|
+
const present = new Set(modes.keys());
|
|
226
|
+
const cache = new Map();
|
|
227
|
+
const baseCache = new Map();
|
|
228
|
+
/** Object type by name; null when the store does not have it. */
|
|
229
|
+
const objects = new Map();
|
|
230
|
+
const readInto = (into, rev, wanted) => {
|
|
231
|
+
const missing = wanted.filter((path) => !into.has(path));
|
|
232
|
+
const names = missing.map((path) => objectName(rev, path));
|
|
233
|
+
const blobs = readBlobs(repo, names);
|
|
234
|
+
missing.forEach((path, i) => into.set(path, blobs.get(names[i] ?? "") ?? null));
|
|
235
|
+
};
|
|
236
|
+
const probe = (names) => {
|
|
237
|
+
const missing = [...new Set(names)].filter((name) => !objects.has(name));
|
|
238
|
+
if (missing.length === 0)
|
|
239
|
+
return;
|
|
240
|
+
const types = objectTypes(repo, missing);
|
|
241
|
+
for (const name of missing)
|
|
242
|
+
objects.set(name, types.get(name) ?? null);
|
|
243
|
+
};
|
|
244
|
+
// Base texts are read under the base-side path and cached by head path.
|
|
245
|
+
const readBases = (rev, wanted) => {
|
|
246
|
+
const missing = wanted.filter((path) => !baseCache.has(path));
|
|
247
|
+
if (missing.length === 0)
|
|
248
|
+
return;
|
|
249
|
+
const direct = new Map();
|
|
250
|
+
readInto(direct, rev, missing);
|
|
251
|
+
const renamed = missing.filter((path) => direct.get(path) === null);
|
|
252
|
+
const sources = new Map();
|
|
253
|
+
for (const path of renamed) {
|
|
254
|
+
const from = renamedFrom(path);
|
|
255
|
+
if (from !== undefined)
|
|
256
|
+
sources.set(path, from);
|
|
257
|
+
}
|
|
258
|
+
const old = new Map();
|
|
259
|
+
readInto(old, rev, [...new Set(sources.values())]);
|
|
260
|
+
// Absent is what the base tree does not list: a listed path whose blob
|
|
261
|
+
// cannot be read is a broken store, not a file the branch adds.
|
|
262
|
+
const unread = [
|
|
263
|
+
...renamed,
|
|
264
|
+
...[...sources.values()].filter((from) => old.get(from) === null),
|
|
265
|
+
];
|
|
266
|
+
const listed = listedIn(repo, rev, [...new Set(unread)]);
|
|
267
|
+
const broken = unread.find((p) => listed.has(p));
|
|
268
|
+
if (broken !== undefined)
|
|
269
|
+
throw new UnreadableObject(`${rev}:${broken}`);
|
|
270
|
+
for (const path of missing) {
|
|
271
|
+
const from = sources.get(path);
|
|
272
|
+
baseCache.set(path, direct.get(path) ??
|
|
273
|
+
(from === undefined ? null : (old.get(from) ?? null)));
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
return {
|
|
277
|
+
base,
|
|
278
|
+
pathExists: (path) => present.has(path),
|
|
279
|
+
isLink: (path) => LINK_MODES.has(modes.get(path) ?? ""),
|
|
280
|
+
read: (path) => {
|
|
281
|
+
if (!present.has(path))
|
|
282
|
+
return null;
|
|
283
|
+
readInto(cache, ref, [path]);
|
|
284
|
+
const text = cache.get(path) ?? null;
|
|
285
|
+
// Listed but unreadable is a broken store, not an absent file.
|
|
286
|
+
if (text === null)
|
|
287
|
+
throw new UnreadableObject(objectName(ref, path));
|
|
288
|
+
return text;
|
|
289
|
+
},
|
|
290
|
+
typesOf: (shas) => {
|
|
291
|
+
probe(shas);
|
|
292
|
+
const types = new Map();
|
|
293
|
+
for (const sha of shas) {
|
|
294
|
+
const type = objects.get(sha);
|
|
295
|
+
if (type)
|
|
296
|
+
types.set(sha, type);
|
|
297
|
+
}
|
|
298
|
+
return types;
|
|
299
|
+
},
|
|
300
|
+
baseText: (path) => {
|
|
301
|
+
if (typeof base !== "string")
|
|
302
|
+
return base;
|
|
303
|
+
readBases(base, [path]);
|
|
304
|
+
return baseCache.get(path) ?? null;
|
|
305
|
+
},
|
|
306
|
+
warm: (asked) => {
|
|
307
|
+
if (typeof base === "string")
|
|
308
|
+
readBases(base, [...asked.bases]);
|
|
309
|
+
probe([...asked.shas]);
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
};
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { fenceKind, group, parseMarkdown } from "./markdown.js";
|
|
2
|
+
// Hex is read in either case: git accepts `ABC1234` as readily as `abc1234`.
|
|
3
|
+
const SHORT = /(?<![\w./=?#-])[0-9a-f]{7,39}(?![\w-])/gi;
|
|
4
|
+
/**
|
|
5
|
+
* The right endpoint of an `a..b` or `a...b` range. The left one is an id or
|
|
6
|
+
* a ref (`main`, `origin/main`, `HEAD~2`, `v1.2`): a ref's dots never run
|
|
7
|
+
* into the range's own. Bounded, as the windows below are: a page's line of
|
|
8
|
+
* megabytes is not a ref.
|
|
9
|
+
*/
|
|
10
|
+
const RANGE_RIGHT = /(?<![\w-])[\w-]{1,64}(?:[/.][\w-]{1,64}){0,8}(?:[~^]\d{0,4}){0,4}\.{2,3}([0-9a-f]{7,40})(?![\w-])/gi;
|
|
11
|
+
const FULL = /(?<![0-9a-f])[0-9a-f]{40}(?![0-9a-f])/gi;
|
|
12
|
+
const TEST_MERGE = /(?<![0-9a-f])([0-9a-f]{40})(?::refs\/(?:remotes\/)?| +-> +(?:refs\/(?:remotes\/)?)?)pull\/\d+\/merge\b/gi;
|
|
13
|
+
const COMMIT_WORD = /(?<![\w-])(commits?|committed|sha1?|head|main|merged?|merges|push(ed)?|revert(ed)?|cherry-pick(ed)?|resolved-by|rebased?|checkout|checked out|built from|landed|shipped|fix(ed)? in|tip)\b|"(sha|commit|head|base|merge)"\s*:/i;
|
|
14
|
+
const OTHER_HASH = /\b(sha-?256|sha-?512|md5|digests?|fingerprints?|checksums?|etags?|hash(es)?|keys?|nonces?|uuids?|run[- ]?ids?|versions?|state[- ]list)\b/i;
|
|
15
|
+
/** Turbo prints 16-hex task hashes: `cache miss, executing 40896a8f6fe567b8`. */
|
|
16
|
+
const TASK_HASH = /(cache (hit|miss)|executing|replaying)\W*$/i;
|
|
17
|
+
/** Bounded like the windows below: a command is short, a page's line is not. */
|
|
18
|
+
const ABBREVIATING_COMMAND = /\bgit\b[^\n]{0,300}?(--oneline|%h\b|--abbrev-commit|rev-parse\s+--short)/;
|
|
19
|
+
const DIFF_INDEX = /^\s*index\s/;
|
|
20
|
+
/** A shell line that a trailing backslash continues onto the next. */
|
|
21
|
+
const CONTINUED = /\\$/;
|
|
22
|
+
/** A commit word right before the token: `commit 1234567`, `sha: abcdefa`. */
|
|
23
|
+
const IMMEDIATE = new RegExp(`(?:${COMMIT_WORD.source})[\\s:=\`"'(]*$`, "i");
|
|
24
|
+
/** Raw captured output kept as a file: a log, not prose anyone edits. */
|
|
25
|
+
const CAPTURED = /\.(txt|log|diff|patch)$/;
|
|
26
|
+
const hasLetterAndDigit = (token) => /[a-f]/i.test(token) && /\d/.test(token);
|
|
27
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
28
|
+
/**
|
|
29
|
+
* A hex token's role on its line, for abbreviated and full ids alike:
|
|
30
|
+
* `explicit` when a commit word stands right before it or it is a range end
|
|
31
|
+
* or cut off; `label` when a checksum-like label (or a task hash's prefix)
|
|
32
|
+
* names it; `implied` when a commit word farther back only suggests it;
|
|
33
|
+
* `none` when nothing on the line names it.
|
|
34
|
+
*/
|
|
35
|
+
export const hexRole = (line, index, token) => {
|
|
36
|
+
// Bounded windows: a generated HTML page is one line of megabytes, and a
|
|
37
|
+
// copy of the rest of the line per token made that quadratic.
|
|
38
|
+
const before = line.slice(Math.max(0, index - 48), index);
|
|
39
|
+
const after = line.slice(index + token.length, index + token.length + 48);
|
|
40
|
+
// A diff's `index a..b` header pairs blobs, not commits.
|
|
41
|
+
const range = !DIFF_INDEX.test(line) &&
|
|
42
|
+
(/^\.{2,3}[0-9a-f]{7,}/i.test(after) ||
|
|
43
|
+
/[0-9a-f]{7,}\.{2,3}$/i.test(before));
|
|
44
|
+
const cut = /^(…|\.\.\.)(?!\.)/.test(after) && !range;
|
|
45
|
+
if (TASK_HASH.test(before))
|
|
46
|
+
return "label";
|
|
47
|
+
if (range || cut || IMMEDIATE.test(before))
|
|
48
|
+
return "explicit";
|
|
49
|
+
// A label at the token ("checksum `abc1234def`") outranks a commit word
|
|
50
|
+
// farther back on the line ("the merged archive has checksum …").
|
|
51
|
+
if (OTHER_HASH.test(before.slice(-20)) || OTHER_HASH.test(after.slice(0, 24)))
|
|
52
|
+
return "label";
|
|
53
|
+
if (COMMIT_WORD.test(before))
|
|
54
|
+
return "implied";
|
|
55
|
+
return OTHER_HASH.test(before) ? "label" : "none";
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* How an abbreviated token is cited as a commit: `explicit` (every hex token counts),
|
|
59
|
+
* `implied` (a token needs a letter and a digit: `1234567` is a number,
|
|
60
|
+
* `deadbeef` a word), or null when it is not a commit.
|
|
61
|
+
*/
|
|
62
|
+
export const citesAsCommit = (line, index, token) => {
|
|
63
|
+
const role = hexRole(line, index, token);
|
|
64
|
+
if (role === "explicit" || role === "implied")
|
|
65
|
+
return role;
|
|
66
|
+
if (role === "label")
|
|
67
|
+
return null;
|
|
68
|
+
// Sixteen hex digits with no commit word is a Turbo task hash's length.
|
|
69
|
+
if (token.length === 16)
|
|
70
|
+
return null;
|
|
71
|
+
const backticked = line[index - 1] === "`" && line[index + token.length] === "`";
|
|
72
|
+
return backticked ? "implied" : null;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Lines the token rule reads: all of a data file (JSON a script wrote names
|
|
76
|
+
* its commits in keys); prose and commands of Markdown; nothing of a captured
|
|
77
|
+
* log, which is output exactly as the tool printed it.
|
|
78
|
+
*/
|
|
79
|
+
const readable = (path, text) => {
|
|
80
|
+
if (CAPTURED.test(path))
|
|
81
|
+
return [];
|
|
82
|
+
const lines = text.split(/\r?\n/);
|
|
83
|
+
if (!/\.mdx?$/.test(path)) {
|
|
84
|
+
return lines.map((line, i) => ({ n: i + 1, text: line }));
|
|
85
|
+
}
|
|
86
|
+
const doc = parseMarkdown(text);
|
|
87
|
+
// A script fence holds commands, every line; a transcript's `$ ` lines are
|
|
88
|
+
// commands and the rest is what they printed; a source fence quotes a
|
|
89
|
+
// file. A body is read as the parser unquoted it: `> $ git log` in a
|
|
90
|
+
// quoted transcript is a command.
|
|
91
|
+
const script = new Map();
|
|
92
|
+
const transcript = new Map();
|
|
93
|
+
for (const fence of doc.fences) {
|
|
94
|
+
const kind = fenceKind(fence);
|
|
95
|
+
if (kind === "source")
|
|
96
|
+
continue;
|
|
97
|
+
const into = kind === "script" ? script : transcript;
|
|
98
|
+
for (const line of fence.body)
|
|
99
|
+
into.set(line.n, line.text);
|
|
100
|
+
}
|
|
101
|
+
const out = [];
|
|
102
|
+
// A transcript command ending in `\` goes on: its next line is the same
|
|
103
|
+
// command, though it has no `$ `.
|
|
104
|
+
let continues = false;
|
|
105
|
+
lines.forEach((line, i) => {
|
|
106
|
+
const n = i + 1;
|
|
107
|
+
if (!doc.inFence[n]) {
|
|
108
|
+
out.push({ n, text: line });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const scripted = script.get(n);
|
|
112
|
+
if (scripted !== undefined) {
|
|
113
|
+
out.push({ n, text: scripted });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const body = transcript.get(n);
|
|
117
|
+
const command = body !== undefined && (continues || /^\s*\$ /.test(body));
|
|
118
|
+
if (command)
|
|
119
|
+
out.push({ n, text: body });
|
|
120
|
+
continues = command && CONTINUED.test(body);
|
|
121
|
+
});
|
|
122
|
+
return out;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Each line's command with the lines a trailing `\` continues it onto joined
|
|
126
|
+
* in, keyed by its first line; a continuation line maps to null. Lines join
|
|
127
|
+
* only when adjacent in the file, so a skipped line breaks the command.
|
|
128
|
+
*/
|
|
129
|
+
const commands = (lines) => {
|
|
130
|
+
const out = [];
|
|
131
|
+
let open = null;
|
|
132
|
+
lines.forEach((line, i) => {
|
|
133
|
+
const previous = lines[i - 1];
|
|
134
|
+
if (open !== null && previous !== undefined && previous.n + 1 === line.n) {
|
|
135
|
+
open.text = `${open.text.slice(0, -1)} ${line.text}`;
|
|
136
|
+
out[open.at] = open.text;
|
|
137
|
+
out.push(null);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
open = { at: i, text: line.text };
|
|
141
|
+
out.push(line.text);
|
|
142
|
+
}
|
|
143
|
+
if (!CONTINUED.test(line.text))
|
|
144
|
+
open = null;
|
|
145
|
+
});
|
|
146
|
+
return out;
|
|
147
|
+
};
|
|
148
|
+
/** The 40-hex ids a text cites as this repository's objects. */
|
|
149
|
+
const citedIds = (text, repository) => {
|
|
150
|
+
const cited = [];
|
|
151
|
+
// GitHub's test merge of a pull request is fetched by id and kept by no
|
|
152
|
+
// branch, so no clone resolves it once the job ends. A log that names an
|
|
153
|
+
// id as that ref also prints it bare, as the checked-out head.
|
|
154
|
+
const testMerges = new Set([...text.matchAll(TEST_MERGE)].map((m) => group(m, 1).toLowerCase()));
|
|
155
|
+
const own = repository?.toLowerCase();
|
|
156
|
+
text.split(/\r?\n/).forEach((line, i) => {
|
|
157
|
+
for (const match of line.matchAll(FULL)) {
|
|
158
|
+
const sha = match[0].toLowerCase();
|
|
159
|
+
// Git's null id names the absent side of an added or deleted file.
|
|
160
|
+
if (testMerges.has(sha) || /^0+$/.test(sha))
|
|
161
|
+
continue;
|
|
162
|
+
const role = hexRole(line, match.index, match[0]);
|
|
163
|
+
if (role === "label")
|
|
164
|
+
continue;
|
|
165
|
+
const before = line.slice(Math.max(0, match.index - 120), match.index);
|
|
166
|
+
// Another repository's commit URL resolves on GitHub, not here; so
|
|
167
|
+
// does an Actions log's downloaded action.
|
|
168
|
+
const other = /github\.com\/([\w.-]+\/[\w.-]+)\/(commit|tree|blob)\/$/.exec(before) ??
|
|
169
|
+
/Download action repository '([\w.-]+\/[\w.-]+)(?:\/[^@']*)?@[^']*' \(SHA:$/.exec(before);
|
|
170
|
+
if (other && group(other, 1).toLowerCase() !== own)
|
|
171
|
+
continue;
|
|
172
|
+
cited.push({ n: i + 1, sha, asCommit: role !== "none" });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
return cited;
|
|
176
|
+
};
|
|
177
|
+
/** EVIDENCE-01 under these settings: `identityPaths` and `repository`. */
|
|
178
|
+
export const commitIdentity = (records) => {
|
|
179
|
+
const shortUrl = records.repository === null
|
|
180
|
+
? null
|
|
181
|
+
: new RegExp(`github\\.com\\/${escape(records.repository)}\\/commit\\/([0-9a-f]{7,39})(?![0-9a-f])`, "gi");
|
|
182
|
+
const finding = (path, n, check, message) => ({
|
|
183
|
+
rule: "EVIDENCE-01",
|
|
184
|
+
check,
|
|
185
|
+
file: path,
|
|
186
|
+
line: n,
|
|
187
|
+
message,
|
|
188
|
+
});
|
|
189
|
+
return (path, text, tree) => {
|
|
190
|
+
if (!records.identityPaths.some((p) => p.test(path)))
|
|
191
|
+
return [];
|
|
192
|
+
const found = [];
|
|
193
|
+
const lines = readable(path, text);
|
|
194
|
+
// A command continued with `\` is read whole, reported at its first line.
|
|
195
|
+
const joined = commands(lines);
|
|
196
|
+
lines.forEach(({ n, text: line }, i) => {
|
|
197
|
+
const command = joined[i] ?? null;
|
|
198
|
+
const abbreviating = command === null ? null : ABBREVIATING_COMMAND.exec(command);
|
|
199
|
+
// `--no-abbrev-commit` after `--oneline` or `--abbrev-commit` restores
|
|
200
|
+
// full ids; `%h` is abbreviated whatever follows.
|
|
201
|
+
const restored = command !== null &&
|
|
202
|
+
abbreviating !== null &&
|
|
203
|
+
/^--(oneline|abbrev-commit)$/.test(group(abbreviating, 1)) &&
|
|
204
|
+
!/%h\b/.test(command) &&
|
|
205
|
+
/--no-abbrev(-commit)?\b/.test(command.slice(abbreviating.index + abbreviating[0].length));
|
|
206
|
+
if (abbreviating !== null && !restored) {
|
|
207
|
+
found.push(finding(path, n, "abbreviating-command", "a git command that prints abbreviated commits (--oneline, %h, --abbrev-commit, rev-parse --short); use %H or git rev-parse"));
|
|
208
|
+
}
|
|
209
|
+
for (const match of line.matchAll(SHORT)) {
|
|
210
|
+
const token = match[0];
|
|
211
|
+
const cited = citesAsCommit(line, match.index, token);
|
|
212
|
+
if (cited === null)
|
|
213
|
+
continue;
|
|
214
|
+
if (cited === "implied" && !hasLetterAndDigit(token))
|
|
215
|
+
continue;
|
|
216
|
+
found.push(finding(path, n, "abbreviated-id", `abbreviated commit "${token}": cite the full 40-hex sha (git rev-parse ${token})`));
|
|
217
|
+
}
|
|
218
|
+
// SHORT's boundary skips a token after `..`; a range's right endpoint
|
|
219
|
+
// is a commit as surely as its left.
|
|
220
|
+
for (const match of line.matchAll(RANGE_RIGHT)) {
|
|
221
|
+
const token = group(match, 1);
|
|
222
|
+
if (token.length === 40 || DIFF_INDEX.test(line))
|
|
223
|
+
continue;
|
|
224
|
+
found.push(finding(path, n, "abbreviated-id", `abbreviated commit "${token}" ends a range: cite the full 40-hex sha (git rev-parse ${token})`));
|
|
225
|
+
}
|
|
226
|
+
for (const match of shortUrl === null ? [] : line.matchAll(shortUrl)) {
|
|
227
|
+
const token = group(match, 1);
|
|
228
|
+
found.push(finding(path, n, "abbreviated-id", `abbreviated commit URL "${token}": link the full 40-hex sha (git rev-parse ${token})`));
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
// Full ids anywhere, fences included, but only those the change adds:
|
|
232
|
+
// what a clone has differs by machine (a laptop keeps commits of deleted
|
|
233
|
+
// branches, a CI clone does not), so no baseline could hold legacy ones
|
|
234
|
+
// the same way everywhere. With no branch to compare, none are asked.
|
|
235
|
+
const baseText = tree.baseText(path);
|
|
236
|
+
if (baseText === undefined)
|
|
237
|
+
return found;
|
|
238
|
+
// Legacy means cited before in the same role: an id the base only printed
|
|
239
|
+
// as an object is new again once a line calls it a commit.
|
|
240
|
+
const old = baseText === null ? [] : citedIds(baseText, records.repository);
|
|
241
|
+
const legacyAny = new Set(old.map((c) => c.sha));
|
|
242
|
+
const legacyCommit = new Set(old.filter((c) => c.asCommit).map((c) => c.sha));
|
|
243
|
+
const full = citedIds(text, records.repository).filter((c) => c.asCommit ? !legacyCommit.has(c.sha) : !legacyAny.has(c.sha));
|
|
244
|
+
if (full.length === 0)
|
|
245
|
+
return found;
|
|
246
|
+
const types = tree.typesOf([...new Set(full.map((f) => f.sha))]);
|
|
247
|
+
for (const { n, sha, asCommit } of full) {
|
|
248
|
+
const type = types.get(sha);
|
|
249
|
+
// Git prints blob and tree ids too: every object resolves, unless the
|
|
250
|
+
// line names it a commit.
|
|
251
|
+
if (type !== undefined && (!asCommit || type === "commit"))
|
|
252
|
+
continue;
|
|
253
|
+
found.push({
|
|
254
|
+
...finding(path, n, "unresolved-id", type === undefined
|
|
255
|
+
? `${sha} does not resolve in this repository (git cat-file -t ${sha})`
|
|
256
|
+
: `${sha} is cited as a commit but is a ${type} (git cat-file -t ${sha})`),
|
|
257
|
+
// New by construction, so no legacy allowance may absorb it.
|
|
258
|
+
ratchet: false,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return found;
|
|
262
|
+
};
|
|
263
|
+
};
|