omp-conductor 0.3.24 → 0.4.0
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 +775 -49
- package/package.json +1 -1
- package/src/approval-surface.ts +36 -1
- package/src/board.ts +30 -6
- package/src/briefs/orchestrator.md +114 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +164 -4
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1213 -78
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +80 -4
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +98 -25
- package/src/orchestrator.ts +32 -5
- package/src/plugin.ts +267 -15
- package/src/release-policy.ts +191 -29
- package/src/reports.ts +440 -0
- package/src/session-host.ts +307 -0
- package/src/setup-host.ts +19 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +555 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +863 -10
- package/src/unblock.ts +53 -3
- package/src/usage.ts +726 -0
- package/src/verbs/actions.ts +142 -0
- package/src/verbs/client.ts +207 -0
- package/src/verbs/ledger.ts +77 -0
- package/src/verbs/protocol.ts +465 -0
- package/src/verbs/server.ts +1098 -0
- package/src/verbs/socket.ts +446 -0
- package/src/worker.ts +36 -7
- package/src/worktree.ts +227 -120
- package/systemd/omp-conductor.service.example +96 -8
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settlement audit: checking a worker's own account of its work against the
|
|
3
|
+
* pull request it actually pushed.
|
|
4
|
+
*
|
|
5
|
+
* Two lines of the worker report template were taken entirely on faith
|
|
6
|
+
* (`briefs/worker.md:161-164`):
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* gates: <exact commands run and their results>
|
|
10
|
+
* changed: <files touched, one line>
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* #85 stopped believing the line above them — `state: pushed-green` is now
|
|
14
|
+
* checked against GitHub by `verifyPushedGreenClaim` rather than believed. This
|
|
15
|
+
* module does the same job for `changed:`, and for the epic's ranked item 7,
|
|
16
|
+
* "don't weaken tests you didn't write, checked by diff review", where until now
|
|
17
|
+
* the reviewer was the worker reviewing itself (#128).
|
|
18
|
+
*
|
|
19
|
+
* Everything here is **advisory**. A flag never changes a run's state, never
|
|
20
|
+
* blocks a merge and never fails a run. The judgement a flag invites — "was
|
|
21
|
+
* deleting that test correct?" — needs context this code does not have, so the
|
|
22
|
+
* code's whole job is to put the evidence in front of whoever does.
|
|
23
|
+
*
|
|
24
|
+
* That posture fixes the accuracy bar, and it is asymmetric. A missed weakening
|
|
25
|
+
* costs one unflagged PR that a reviewer might still catch. A false flag costs
|
|
26
|
+
* attention on a *clean* run, and an audit that cries wolf gets ignored — at
|
|
27
|
+
* which point it is worth less than nothing, because the fleet believes it is
|
|
28
|
+
* being checked. So **every heuristic below resolves ambiguity towards
|
|
29
|
+
* silence**, and each one names the blind spot it accepts to stay quiet.
|
|
30
|
+
*
|
|
31
|
+
* Pure by construction: the analyser takes a parsed diff, the report text and
|
|
32
|
+
* the dispatching issue's text, and returns flags. Fetching the diff is the
|
|
33
|
+
* tracker's job (`Tracker.prDiff`) — the same split `prStateFrom`/`prState`
|
|
34
|
+
* already use — so every rule here is pinned by a recorded diff rather than by
|
|
35
|
+
* a live repository.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { PrDiff, PrDiffFile, SettlementFlag } from "./types.ts";
|
|
39
|
+
|
|
40
|
+
// ------------------------------------------------------------------ diff parse
|
|
41
|
+
|
|
42
|
+
const FILE_HEADER = "diff --git ";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Unquote a git path. Paths containing a space, a quote, or a non-ASCII byte are
|
|
46
|
+
* emitted C-quoted (`"a/my file.ts"`), and only the escapes git actually writes
|
|
47
|
+
* are undone — an unrecognised escape keeps its backslash rather than being
|
|
48
|
+
* silently eaten, because a wrong path here is a flag naming a file that does
|
|
49
|
+
* not exist.
|
|
50
|
+
*/
|
|
51
|
+
function unquote(raw: string): string {
|
|
52
|
+
if (!raw.startsWith('"') || !raw.endsWith('"') || raw.length < 2) return raw;
|
|
53
|
+
return raw
|
|
54
|
+
.slice(1, -1)
|
|
55
|
+
.replaceAll(/\\([\\"nt])/g, (_, ch: string) =>
|
|
56
|
+
ch === "n" ? "\n" : ch === "t" ? "\t" : ch,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One path out of a diff header, or undefined for `/dev/null` (how git spells
|
|
62
|
+
* "this side has no file").
|
|
63
|
+
*
|
|
64
|
+
* `prefixed` is not cosmetic. `---`, `+++` and `diff --git` carry git's `a/`
|
|
65
|
+
* and `b/` prefixes; `rename from` and `rename to` do not. Stripping two
|
|
66
|
+
* characters off a rename header turns `a/old.ts` into `old.ts` — a path that
|
|
67
|
+
* exists nowhere, on a flag that tells a reviewer to go and look at it.
|
|
68
|
+
*/
|
|
69
|
+
function diffPath(raw: string, prefixed: boolean): string | undefined {
|
|
70
|
+
// Some producers append a tab and a timestamp; git does not, but a diff that
|
|
71
|
+
// travelled through another tool might.
|
|
72
|
+
const path = unquote(raw.split("\t")[0]?.trim() ?? "");
|
|
73
|
+
if (path === "" || path === "/dev/null") return undefined;
|
|
74
|
+
return prefixed && (path.startsWith("a/") || path.startsWith("b/")) ? path.slice(2) : path;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Both paths off a `diff --git` line. Only a fallback: the `---`/`+++` and
|
|
79
|
+
* `rename to` lines are unambiguous and are preferred everywhere they exist.
|
|
80
|
+
* This line is not parseable in general — `diff --git a/x b/y b/z` is genuinely
|
|
81
|
+
* ambiguous when a path contains ` b/` — so it is used only for the header
|
|
82
|
+
* shapes that carry nothing else (binary and mode-only changes).
|
|
83
|
+
*/
|
|
84
|
+
function headerPaths(line: string): { a?: string; b?: string } {
|
|
85
|
+
const rest = line.slice(FILE_HEADER.length);
|
|
86
|
+
const quoted = /^("(?:[^"\\]|\\.)*") ("(?:[^"\\]|\\.)*")$/.exec(rest);
|
|
87
|
+
if (quoted?.[1] !== undefined && quoted[2] !== undefined) {
|
|
88
|
+
return { a: diffPath(quoted[1], true), b: diffPath(quoted[2], true) };
|
|
89
|
+
}
|
|
90
|
+
const mid = rest.indexOf(" b/");
|
|
91
|
+
if (mid < 0) return {};
|
|
92
|
+
return { a: diffPath(rest.slice(0, mid), true), b: diffPath(rest.slice(mid + 1), true) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function fileFrom(section: string[]): PrDiffFile | undefined {
|
|
96
|
+
let status: PrDiffFile["status"] = "modified";
|
|
97
|
+
let previousPath: string | undefined;
|
|
98
|
+
let renameTo: string | undefined;
|
|
99
|
+
let minus: string | undefined;
|
|
100
|
+
let plus: string | undefined;
|
|
101
|
+
let hunkAt = -1;
|
|
102
|
+
|
|
103
|
+
for (let i = 1; i < section.length; i++) {
|
|
104
|
+
const line = section[i] ?? "";
|
|
105
|
+
// Header lines run until the first hunk; everything after is content, and
|
|
106
|
+
// content can legitimately start with `--- ` or `+++ `.
|
|
107
|
+
if (line.startsWith("@@")) {
|
|
108
|
+
hunkAt = i;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (line.startsWith("new file mode")) status = "added";
|
|
112
|
+
else if (line.startsWith("deleted file mode")) status = "removed";
|
|
113
|
+
else if (line.startsWith("rename from ")) {
|
|
114
|
+
previousPath = diffPath(line.slice("rename from ".length), false);
|
|
115
|
+
status = "renamed";
|
|
116
|
+
} else if (line.startsWith("rename to ")) {
|
|
117
|
+
renameTo = diffPath(line.slice("rename to ".length), false);
|
|
118
|
+
status = "renamed";
|
|
119
|
+
} else if (line.startsWith("--- ")) minus = diffPath(line.slice(4), true);
|
|
120
|
+
else if (line.startsWith("+++ ")) plus = diffPath(line.slice(4), true);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const header = headerPaths(section[0] ?? "");
|
|
124
|
+
const path = plus ?? renameTo ?? (status === "removed" ? minus : undefined) ?? header.b ?? header.a;
|
|
125
|
+
if (path === undefined) return undefined;
|
|
126
|
+
|
|
127
|
+
const file: PrDiffFile = { path, status };
|
|
128
|
+
if (previousPath !== undefined && previousPath !== path) file.previousPath = previousPath;
|
|
129
|
+
// The trailing newline of the diff itself lands in the last file's section;
|
|
130
|
+
// it is not part of any hunk and would show up in every fixture comparison.
|
|
131
|
+
if (hunkAt >= 0) file.hunks = section.slice(hunkAt).join("\n").replace(/\n+$/, "");
|
|
132
|
+
return file;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Split a unified diff into per-file records.
|
|
137
|
+
*
|
|
138
|
+
* Kept here rather than in the GitHub adapter because the format is git's, not
|
|
139
|
+
* GitHub's: a Gitea or local-file tracker producing the same text gets the same
|
|
140
|
+
* parser, and the analyser below is pinned by raw diffs a human can read.
|
|
141
|
+
*/
|
|
142
|
+
export function parsePrDiff(raw: string, truncated = false): PrDiff {
|
|
143
|
+
const files: PrDiffFile[] = [];
|
|
144
|
+
let section: string[] | undefined;
|
|
145
|
+
const close = (): void => {
|
|
146
|
+
if (section === undefined) return;
|
|
147
|
+
const file = fileFrom(section);
|
|
148
|
+
if (file !== undefined) files.push(file);
|
|
149
|
+
};
|
|
150
|
+
for (const line of raw.split("\n")) {
|
|
151
|
+
if (line.startsWith(FILE_HEADER)) {
|
|
152
|
+
close();
|
|
153
|
+
section = [line];
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
section?.push(line);
|
|
157
|
+
}
|
|
158
|
+
close();
|
|
159
|
+
return { files, truncated };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** One added or removed line of a hunk, with the line number of the side it
|
|
163
|
+
* came from: the post-image for `+`, the pre-image for `-`. */
|
|
164
|
+
interface ChangedLine {
|
|
165
|
+
added: boolean;
|
|
166
|
+
text: string;
|
|
167
|
+
line: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function* changedLines(hunks: string): Generator<ChangedLine> {
|
|
171
|
+
let oldLine = 0;
|
|
172
|
+
let newLine = 0;
|
|
173
|
+
for (const raw of hunks.split("\n")) {
|
|
174
|
+
const header = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
|
|
175
|
+
if (header !== null) {
|
|
176
|
+
oldLine = Number(header[1]);
|
|
177
|
+
newLine = Number(header[2]);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// A blank context line is a single space, but tooling that trims trailing
|
|
181
|
+
// whitespace turns it into an empty string — treated as context so the
|
|
182
|
+
// line numbers downstream stay aligned with the file.
|
|
183
|
+
if (raw === "") {
|
|
184
|
+
oldLine++;
|
|
185
|
+
newLine++;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const sign = raw[0];
|
|
189
|
+
if (sign === "+") {
|
|
190
|
+
yield { added: true, text: raw.slice(1), line: newLine };
|
|
191
|
+
newLine++;
|
|
192
|
+
} else if (sign === "-") {
|
|
193
|
+
yield { added: false, text: raw.slice(1), line: oldLine };
|
|
194
|
+
oldLine++;
|
|
195
|
+
} else if (sign === " ") {
|
|
196
|
+
oldLine++;
|
|
197
|
+
newLine++;
|
|
198
|
+
}
|
|
199
|
+
// `` and any other marker advances neither side.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ------------------------------------------------------------- path vocabulary
|
|
204
|
+
|
|
205
|
+
function basename(path: string): string {
|
|
206
|
+
return path.slice(path.lastIndexOf("/") + 1);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Files whose contents are data the audit must not read as code. A recorded
|
|
211
|
+
* fixture, a golden file or a snapshot legitimately *contains* the text
|
|
212
|
+
* `it.skip(` — that is what makes it a fixture — and this module's own test
|
|
213
|
+
* suite is the first thing that would trip over it.
|
|
214
|
+
*/
|
|
215
|
+
const DATA_DIR = /(?:^|\/)(?:__fixtures__|fixtures|testdata|test-data|__snapshots__|snapshots|golden|recordings|cassettes)\//;
|
|
216
|
+
|
|
217
|
+
/** Code this repository did not write, so weakening it is not this fleet's
|
|
218
|
+
* "tests you didn't write" case — it is a vendored upstream. */
|
|
219
|
+
const VENDORED = /(?:^|\/)(?:node_modules|vendor|third_party|dist|build)\//;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Test files, by the naming conventions of the languages this loop has actually
|
|
223
|
+
* dispatched work in. Deliberately conservative: a file this misses is a file
|
|
224
|
+
* the weakening rules stay silent about, which is the safe direction.
|
|
225
|
+
*/
|
|
226
|
+
const TEST_FILE_NAME =
|
|
227
|
+
/(?:^|\/)(?:test_[^/]+\.py|conftest\.py|[^/]+_test\.(?:py|go|rb|rs|ts|tsx|js|jsx)|[^/]+\.(?:test|spec)\.[cm]?[jt]sx?)$/;
|
|
228
|
+
const TEST_DIR = /(?:^|\/)(?:tests?|__tests__|specs?)\//;
|
|
229
|
+
|
|
230
|
+
function isTestPath(path: string): boolean {
|
|
231
|
+
return TEST_FILE_NAME.test(path) || TEST_DIR.test(path);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Lockfiles are derived from a manifest the worker did disclose, and no edit
|
|
236
|
+
* hides in one. Reconciling them produces an omission flag on most dependency
|
|
237
|
+
* changes — the single largest source of noise this check could have, bought
|
|
238
|
+
* for no signal at all.
|
|
239
|
+
*/
|
|
240
|
+
const DERIVED_FILE: Record<string, true> = {
|
|
241
|
+
"bun.lock": true,
|
|
242
|
+
"bun.lockb": true,
|
|
243
|
+
"package-lock.json": true,
|
|
244
|
+
"pnpm-lock.yaml": true,
|
|
245
|
+
"yarn.lock": true,
|
|
246
|
+
"Cargo.lock": true,
|
|
247
|
+
"poetry.lock": true,
|
|
248
|
+
"uv.lock": true,
|
|
249
|
+
"go.sum": true,
|
|
250
|
+
"Gemfile.lock": true,
|
|
251
|
+
"composer.lock": true,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// -------------------------------------------------------- `changed:` reconcile
|
|
255
|
+
|
|
256
|
+
const CHANGED_LINE = /^[ \t>]*changed:[ \t]*(.*)$/im;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A token from `changed:` that is worth holding the PR to. The line is prose
|
|
260
|
+
* written by a model — `omp/src/foo.ts, omp/src/foo.test.ts (new)` is a normal
|
|
261
|
+
* shape — so anything without a path separator is commentary unless it ends in
|
|
262
|
+
* an extension that starts with a letter and is at least two characters. That
|
|
263
|
+
* last clause is not fussiness: without it `e.g`, `i.e` and `0.3.18` all parse
|
|
264
|
+
* as filenames, and each one becomes a "claimed a file you never touched" flag
|
|
265
|
+
* on a report that did nothing wrong.
|
|
266
|
+
*/
|
|
267
|
+
const CLAIMED_PATH = /^(?:[\w.@~+-]+\/)+[\w.@~*+-]*$|^[\w.@~+-]*\.[A-Za-z][A-Za-z0-9]{1,7}$/;
|
|
268
|
+
|
|
269
|
+
/** Every path-shaped token on the report's `changed:` line. An absent line and
|
|
270
|
+
* a line naming nothing are the same answer: nothing was disclosed. */
|
|
271
|
+
export function claimedPaths(report: string): string[] {
|
|
272
|
+
const line = CHANGED_LINE.exec(report)?.[1] ?? "";
|
|
273
|
+
const seen = new Set<string>();
|
|
274
|
+
for (const raw of line.split(/[\s,;]+/)) {
|
|
275
|
+
const token = raw.replace(/^[`'"([*-]+/, "").replace(/[`'")\].,:;]+$/, "");
|
|
276
|
+
if (token === "" || token === "none") continue;
|
|
277
|
+
const normalised = token.replace(/^\.?\//, "");
|
|
278
|
+
if (!CLAIMED_PATH.test(normalised)) continue;
|
|
279
|
+
seen.add(normalised);
|
|
280
|
+
}
|
|
281
|
+
return [...seen];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Whether one claim covers one path. Every rule here is deliberately generous,
|
|
286
|
+
* because each one that fails produces an omission flag on an honest report:
|
|
287
|
+
*
|
|
288
|
+
* - suffix match, so `foo.test.ts` covers `omp/src/foo.test.ts` and a worker
|
|
289
|
+
* that wrote the full path is covered by a claim of either shape;
|
|
290
|
+
* - directory match, so `omp/src` covers everything under it — a token whose
|
|
291
|
+
* last segment has an extension is a filename and never a directory;
|
|
292
|
+
* - `*` as a within-segment wildcard, because `omp/src/tracker/*` is how the
|
|
293
|
+
* issues in this repo themselves name a file set.
|
|
294
|
+
*/
|
|
295
|
+
function covers(claim: string, path: string): boolean {
|
|
296
|
+
// Callers pass `previousPath ?? ""` for the rename side. Without this guard a
|
|
297
|
+
// directory-shaped claim like `omp/src/` "covers" the empty string, and every
|
|
298
|
+
// unmatched claim silently disappears.
|
|
299
|
+
if (path === "") return false;
|
|
300
|
+
if (claim === path) return true;
|
|
301
|
+
if (claim.includes("*")) {
|
|
302
|
+
const pattern = new RegExp(
|
|
303
|
+
`^${claim.replaceAll(/[.*+?^${}()|[\]\\]/g, (ch) => (ch === "*" ? "[^/]*" : `\\${ch}`))}$`,
|
|
304
|
+
);
|
|
305
|
+
return pattern.test(path) || pattern.test(basename(path));
|
|
306
|
+
}
|
|
307
|
+
if (path.endsWith(`/${claim}`) || claim.endsWith(`/${path}`)) return true;
|
|
308
|
+
const last = claim.slice(claim.lastIndexOf("/") + 1);
|
|
309
|
+
return !last.includes(".") && path.startsWith(`${claim}/`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ------------------------------------------------------ test-weakening lexicon
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Strip string literals and the trailing line comment, so a marker that is only
|
|
316
|
+
* *mentioned* is not read as a marker that was *added*. `it("skip the retry")`
|
|
317
|
+
* and `# it.skip(...) — see #91` are both silent after this.
|
|
318
|
+
*
|
|
319
|
+
* Single-line only, which is the accepted blind spot: a diff shows added lines
|
|
320
|
+
* without the file around them, so a line inside a multi-line template literal
|
|
321
|
+
* is indistinguishable from code. {@link DATA_DIR} covers the case that
|
|
322
|
+
* actually occurs — recorded fixtures — and the anchored patterns below cover
|
|
323
|
+
* most of the rest.
|
|
324
|
+
*/
|
|
325
|
+
function splitCode(line: string, hashComments: boolean): { code: string; comment: string } {
|
|
326
|
+
let code = "";
|
|
327
|
+
let quote: string | undefined;
|
|
328
|
+
for (let i = 0; i < line.length; i++) {
|
|
329
|
+
const ch = line[i];
|
|
330
|
+
if (quote !== undefined) {
|
|
331
|
+
if (ch === "\\") i++;
|
|
332
|
+
else if (ch === quote) quote = undefined;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
336
|
+
quote = ch;
|
|
337
|
+
// A placeholder, not nothing: `it.skip("x", fn)` must still parse as a
|
|
338
|
+
// call, and `describe(name)` must not collapse into `describe()`.
|
|
339
|
+
code += '""';
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (ch === "/" && line[i + 1] === "/") return { code, comment: line.slice(i + 2) };
|
|
343
|
+
if (hashComments && ch === "#") return { code, comment: line.slice(i + 1) };
|
|
344
|
+
code += ch;
|
|
345
|
+
}
|
|
346
|
+
return { code, comment: "" };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** `#` opens a comment here, and `#[ignore]` does not exist. Rust and TypeScript
|
|
350
|
+
* are excluded on purpose: `#[ignore]` and `#private` are code. */
|
|
351
|
+
const HASH_COMMENTS = /\.(?:py|rb|sh|bash|yml|yaml|toml|cfg|pl)$/;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A disabled or focused test. Anchored on a runner identifier at the start of
|
|
355
|
+
* the statement, which is what keeps `iterator.skip(3)` and `{ skip: false }`
|
|
356
|
+
* out — an unanchored `\.skip\(` flags both.
|
|
357
|
+
*
|
|
358
|
+
* The marker must then be *called* (`it.skip(`) or chained into one
|
|
359
|
+
* (`test.skip.each(`). Accepting a bare `.` after it looked harmless and was
|
|
360
|
+
* not: `it("x", () => { plan.todo.push(1) })` reads as a todo-marked test.
|
|
361
|
+
*/
|
|
362
|
+
const DISABLED_TEST: readonly RegExp[] = [
|
|
363
|
+
/^(?:await\s+)?(?:it|test|describe|context|suite|bench)\b.*?\.\s*(?:skip|only|todo|failing)\s*(?:\(|\.\s*(?:each|for)\b)/,
|
|
364
|
+
/^(?:await\s+)?[xf](?:it|test|describe|context)\s*\(/,
|
|
365
|
+
/^@(?:pytest\.mark\.(?:skip|skipif|xfail)|unittest\.skip)/,
|
|
366
|
+
/^#\[ignore\b/,
|
|
367
|
+
// Go's is a statement anywhere in a line — `if testing.Short() { t.Skip() }`
|
|
368
|
+
// is the idiomatic form — and the `t.` receiver is specific enough to anchor
|
|
369
|
+
// on instead.
|
|
370
|
+
/\bt\.Skip(?:f|Now)?\s*\(/,
|
|
371
|
+
];
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* An assertion, as a whole statement. Anchored for the same reason as above: a
|
|
375
|
+
* mid-line `expect` is usually a helper's name or a comment about one.
|
|
376
|
+
*/
|
|
377
|
+
const ASSERTION =
|
|
378
|
+
/^(?:await\s+)?(?:expect|assert|assert_[a-z_]+|assertEquals?|assertTrue|assertFalse|assertThat|assertRaises|assertRaisesRegex|self\.assert[A-Za-z]*|should|chai\.|t\.(?:Error|Fatal)f?|require\.[A-Z][A-Za-z]*|Expect)\s*[.(]/;
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* A named timeout and its value. Only a *raised* one is a finding — a brand-new
|
|
382
|
+
* timeout on a new test is not a weakening — so the value is compared against
|
|
383
|
+
* the same key on the pre-image side and silence is the answer whenever the key
|
|
384
|
+
* appears on only one side.
|
|
385
|
+
*/
|
|
386
|
+
const TIMEOUT =
|
|
387
|
+
/\b(timeout|timeoutMs|timeout_ms|timeoutSeconds|deadline|maxDuration|wallClock(?:Ms)?|setTimeout|jest\.setTimeout|retries)\b\s*[:=(]\s*(\d[\d_]*)/gi;
|
|
388
|
+
|
|
389
|
+
function timeouts(text: string): { key: string; value: number }[] {
|
|
390
|
+
const found: { key: string; value: number }[] = [];
|
|
391
|
+
for (const match of text.matchAll(TIMEOUT)) {
|
|
392
|
+
const key = match[1]?.toLowerCase();
|
|
393
|
+
const raw = match[2]?.replaceAll("_", "");
|
|
394
|
+
if (key === undefined || raw === undefined) continue;
|
|
395
|
+
const value = Number(raw);
|
|
396
|
+
if (Number.isSafeInteger(value)) found.push({ key, value });
|
|
397
|
+
}
|
|
398
|
+
return found;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ------------------------------------------------------------------- attribute
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Whether the dispatching issue named this file. A flag on a file the issue
|
|
405
|
+
* never mentions is the "tests you didn't write" case and is louder, so the
|
|
406
|
+
* match is lenient in the direction that produces *fewer* loud flags: the bare
|
|
407
|
+
* basename counts, and a common one like `index.test.ts` will match almost any
|
|
408
|
+
* issue that mentions a test at all.
|
|
409
|
+
*
|
|
410
|
+
* Deliberately *not* the containing directory. That rule was tried and it
|
|
411
|
+
* attributes everything: this package's entire source lives under `omp/src`,
|
|
412
|
+
* so any issue naming one file there would vouch for every test file in the
|
|
413
|
+
* repository, and the mark would stop distinguishing anything.
|
|
414
|
+
*/
|
|
415
|
+
function namedByIssue(issueText: string, file: PrDiffFile): boolean {
|
|
416
|
+
const haystack = issueText.toLowerCase();
|
|
417
|
+
for (const path of [file.path, file.previousPath]) {
|
|
418
|
+
if (path === undefined) continue;
|
|
419
|
+
const lower = path.toLowerCase();
|
|
420
|
+
if (haystack.includes(lower) || haystack.includes(basename(lower))) return true;
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Evidence text is a source line, and a source line can be minified. */
|
|
426
|
+
const EVIDENCE_LIMIT = 120;
|
|
427
|
+
|
|
428
|
+
function evidence(text: string): string {
|
|
429
|
+
const trimmed = text.trim();
|
|
430
|
+
return trimmed.length <= EVIDENCE_LIMIT ? trimmed : `${trimmed.slice(0, EVIDENCE_LIMIT - 1)}…`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// -------------------------------------------------------------------- analyser
|
|
434
|
+
|
|
435
|
+
export interface SettlementAudit {
|
|
436
|
+
/** The worker's final report, verbatim. */
|
|
437
|
+
report: string;
|
|
438
|
+
/** The dispatching issue's title and body — the attribution source. */
|
|
439
|
+
issueText: string;
|
|
440
|
+
diff: PrDiff;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Every advisory finding about one settled pull request, in a stable order:
|
|
445
|
+
* the `changed:` reconciliation first, then test weakening in diff order.
|
|
446
|
+
*
|
|
447
|
+
* An empty array is the expected result for an honest run, and is load-bearing:
|
|
448
|
+
* the settlement report says nothing at all when this returns nothing.
|
|
449
|
+
*/
|
|
450
|
+
export function analyseSettlement(audit: SettlementAudit): SettlementFlag[] {
|
|
451
|
+
const flags: SettlementFlag[] = [];
|
|
452
|
+
reconcileChanged(audit, flags);
|
|
453
|
+
detectWeakening(audit, flags);
|
|
454
|
+
return flags;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function reconcileChanged(audit: SettlementAudit, flags: SettlementFlag[]): void {
|
|
458
|
+
const touched = audit.diff.files.filter((f) => DERIVED_FILE[basename(f.path)] !== true);
|
|
459
|
+
const claims = claimedPaths(audit.report);
|
|
460
|
+
|
|
461
|
+
if (claims.length === 0) {
|
|
462
|
+
// The degenerate case of the same check: with no usable `changed:` line
|
|
463
|
+
// every file is undisclosed, and saying so once beats saying it per file.
|
|
464
|
+
// Reporting it at all is what stops the check being defeated by writing
|
|
465
|
+
// `changed: see the PR` — or by dropping the line entirely.
|
|
466
|
+
if (touched.length > 0) {
|
|
467
|
+
flags.push({
|
|
468
|
+
kind: "changed-line-missing",
|
|
469
|
+
file: "(report)",
|
|
470
|
+
detail:
|
|
471
|
+
"the report's `changed:` line is missing or names no paths, so none of the " +
|
|
472
|
+
`${touched.length} file(s) the PR touched was disclosed`,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (const file of touched) {
|
|
479
|
+
if (claims.some((claim) => covers(claim, file.path))) continue;
|
|
480
|
+
if (file.previousPath !== undefined && claims.some((claim) => covers(claim, file.previousPath ?? ""))) {
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
flags.push({
|
|
484
|
+
kind: "undisclosed-file",
|
|
485
|
+
file: file.path,
|
|
486
|
+
detail: `${file.status} by the PR but absent from the report's \`changed:\` line`,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// The weaker direction, and it stays weak on purpose: a worker often names a
|
|
491
|
+
// file it edited and then reverted, or one it renamed away from. Worth
|
|
492
|
+
// surfacing because a `changed:` line that describes a different PR is the
|
|
493
|
+
// signature of a report written from memory rather than from `git diff`.
|
|
494
|
+
for (const claim of claims) {
|
|
495
|
+
if (audit.diff.files.some((f) => covers(claim, f.path) || covers(claim, f.previousPath ?? ""))) {
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
flags.push({
|
|
499
|
+
kind: "unmatched-claim",
|
|
500
|
+
file: claim,
|
|
501
|
+
detail: "named by the report's `changed:` line but not touched by the PR",
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function detectWeakening(audit: SettlementAudit, flags: SettlementFlag[]): void {
|
|
507
|
+
// A test file that left one path and arrived at another is a move, not a
|
|
508
|
+
// deletion. `status: renamed` covers the renames git detected; the basename
|
|
509
|
+
// pairing covers the ones it did not, because a move plus a heavy edit is
|
|
510
|
+
// emitted as an unrelated delete and add. It can hide a genuine deletion that
|
|
511
|
+
// happens to share a basename with an added file — accepted, as a false
|
|
512
|
+
// negative for a false positive on every legitimate move.
|
|
513
|
+
const arrived = new Set(
|
|
514
|
+
audit.diff.files
|
|
515
|
+
.filter((f) => f.status === "added" && isTestPath(f.path))
|
|
516
|
+
.map((f) => basename(f.path)),
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
for (const file of audit.diff.files) {
|
|
520
|
+
// A recorded fixture legitimately *contains* `it.skip(`; that is what makes
|
|
521
|
+
// it a fixture. Vendored trees are somebody else's tests entirely.
|
|
522
|
+
if (DATA_DIR.test(file.path) || VENDORED.test(file.path)) continue;
|
|
523
|
+
if (!isTestPath(file.path) && !isTestPath(file.previousPath ?? "")) continue;
|
|
524
|
+
const unattributed = namedByIssue(audit.issueText, file) ? undefined : true;
|
|
525
|
+
const mark = (flag: Omit<SettlementFlag, "unattributed">): void => {
|
|
526
|
+
flags.push(unattributed === undefined ? flag : { ...flag, unattributed });
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
if (file.status === "removed") {
|
|
530
|
+
if (arrived.has(basename(file.path))) continue;
|
|
531
|
+
mark({
|
|
532
|
+
kind: "test-file-deleted",
|
|
533
|
+
file: file.path,
|
|
534
|
+
detail: "test file deleted, and no rename in this PR accounts for it",
|
|
535
|
+
});
|
|
536
|
+
// Its every line is a removal; listing them as separate findings would
|
|
537
|
+
// bury the one that matters.
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (file.hunks === undefined) continue;
|
|
542
|
+
scanHunks(file, file.hunks, mark);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function scanHunks(
|
|
547
|
+
file: PrDiffFile,
|
|
548
|
+
hunks: string,
|
|
549
|
+
mark: (flag: Omit<SettlementFlag, "unattributed">) => void,
|
|
550
|
+
): void {
|
|
551
|
+
const hashComments = HASH_COMMENTS.test(file.path);
|
|
552
|
+
const disabledBefore = new Set<string>();
|
|
553
|
+
const disabledAfter: { line: number; text: string }[] = [];
|
|
554
|
+
const commentedAssertions: { line: number; text: string }[] = [];
|
|
555
|
+
const timeoutBefore = new Map<string, number>();
|
|
556
|
+
const timeoutAfter = new Map<string, { value: number; line: number; text: string }>();
|
|
557
|
+
let assertionsAdded = 0;
|
|
558
|
+
let assertionsRemoved = 0;
|
|
559
|
+
let firstRemovedAssertion: { line: number; text: string } | undefined;
|
|
560
|
+
|
|
561
|
+
for (const change of changedLines(hunks)) {
|
|
562
|
+
const { code, comment } = splitCode(change.text, hashComments);
|
|
563
|
+
const statement = code.trim();
|
|
564
|
+
const disabled = DISABLED_TEST.some((re) => re.test(statement));
|
|
565
|
+
|
|
566
|
+
if (disabled) {
|
|
567
|
+
if (change.added) disabledAfter.push({ line: change.line, text: change.text });
|
|
568
|
+
// Normalised, so a marker that only moved or was re-indented is not new.
|
|
569
|
+
else disabledBefore.add(statement.replaceAll(/\s+/g, " "));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (ASSERTION.test(statement)) {
|
|
573
|
+
if (change.added) assertionsAdded++;
|
|
574
|
+
else {
|
|
575
|
+
assertionsRemoved++;
|
|
576
|
+
firstRemovedAssertion ??= { line: change.line, text: change.text };
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// A commented-out assertion is unambiguous — nobody does it by accident —
|
|
581
|
+
// but only when the comment body is a whole statement. `// expect() is
|
|
582
|
+
// called by the helper` is prose and must stay silent, so the body has to
|
|
583
|
+
// end the way a statement ends.
|
|
584
|
+
const commented = comment.trim();
|
|
585
|
+
if (change.added && ASSERTION.test(commented) && /[;)]$/.test(commented)) {
|
|
586
|
+
commentedAssertions.push({ line: change.line, text: commented });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
for (const { key, value } of timeouts(code)) {
|
|
590
|
+
if (change.added) {
|
|
591
|
+
const prior = timeoutAfter.get(key);
|
|
592
|
+
if (prior === undefined || value > prior.value) {
|
|
593
|
+
timeoutAfter.set(key, { value, line: change.line, text: change.text });
|
|
594
|
+
}
|
|
595
|
+
} else {
|
|
596
|
+
timeoutBefore.set(key, Math.max(timeoutBefore.get(key) ?? 0, value));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
for (const found of disabledAfter) {
|
|
602
|
+
if (disabledBefore.has(splitCode(found.text, hashComments).code.trim().replaceAll(/\s+/g, " "))) {
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
mark({
|
|
606
|
+
kind: "test-disabled",
|
|
607
|
+
file: file.path,
|
|
608
|
+
line: found.line,
|
|
609
|
+
detail: `added \`${evidence(found.text)}\``,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
for (const found of commentedAssertions) {
|
|
614
|
+
mark({
|
|
615
|
+
kind: "assertions-removed",
|
|
616
|
+
file: file.path,
|
|
617
|
+
line: found.line,
|
|
618
|
+
detail: `assertion commented out: \`${evidence(found.text)}\``,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Net, not gross. A rewritten test moves its assertions, and counting removals
|
|
623
|
+
// alone flags every honest refactor — measured against this package's own
|
|
624
|
+
// history, that is most test edits.
|
|
625
|
+
if (assertionsRemoved > assertionsAdded && firstRemovedAssertion !== undefined) {
|
|
626
|
+
mark({
|
|
627
|
+
kind: "assertions-removed",
|
|
628
|
+
file: file.path,
|
|
629
|
+
line: firstRemovedAssertion.line,
|
|
630
|
+
detail:
|
|
631
|
+
`${assertionsRemoved - assertionsAdded} more assertion(s) removed than added ` +
|
|
632
|
+
`(first: \`${evidence(firstRemovedAssertion.text)}\`)`,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
for (const [key, after] of timeoutAfter) {
|
|
637
|
+
const before = timeoutBefore.get(key);
|
|
638
|
+
if (before === undefined || after.value <= before) continue;
|
|
639
|
+
mark({
|
|
640
|
+
kind: "test-timeout-raised",
|
|
641
|
+
file: file.path,
|
|
642
|
+
line: after.line,
|
|
643
|
+
detail: `\`${key}\` raised from ${before} to ${after.value}`,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// -------------------------------------------------------------------- printing
|
|
649
|
+
|
|
650
|
+
/** Beyond this the report is a wall of text nobody reads; the count still tells
|
|
651
|
+
* the truth about how much was suppressed. */
|
|
652
|
+
const RENDERED_FLAGS = 20;
|
|
653
|
+
|
|
654
|
+
const HEADING = "settlement audit";
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* The flag block appended to a settlement report, or no lines at all.
|
|
658
|
+
*
|
|
659
|
+
* Silence is the design: an honest run must add nothing to its report, or the
|
|
660
|
+
* block becomes furniture and stops being read. The one exception is a diff
|
|
661
|
+
* that could not be read in full — then the absence of flags is not evidence of
|
|
662
|
+
* anything, and saying so is the honest report.
|
|
663
|
+
*/
|
|
664
|
+
export function formatSettlementFlags(
|
|
665
|
+
flags: readonly SettlementFlag[],
|
|
666
|
+
diff: { truncated: boolean } = { truncated: false },
|
|
667
|
+
): string[] {
|
|
668
|
+
if (flags.length === 0) {
|
|
669
|
+
return diff.truncated
|
|
670
|
+
? [`${HEADING}: no flags, but the PR diff was too large to read in full — this is not a clean bill`]
|
|
671
|
+
: [];
|
|
672
|
+
}
|
|
673
|
+
const lines = [
|
|
674
|
+
`${HEADING}: ${flags.length} advisory flag(s) — the run's state is unchanged by them` +
|
|
675
|
+
(diff.truncated ? ", and the PR diff was too large to read in full" : ""),
|
|
676
|
+
];
|
|
677
|
+
for (const flag of flags.slice(0, RENDERED_FLAGS)) {
|
|
678
|
+
lines.push(
|
|
679
|
+
` ${flag.kind} ${flag.file}${flag.line === undefined ? "" : `:${flag.line}`} — ${flag.detail}` +
|
|
680
|
+
(flag.unattributed === true ? " [unattributed: the dispatching issue never names this file]" : ""),
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
if (flags.length > RENDERED_FLAGS) {
|
|
684
|
+
lines.push(` … and ${flags.length - RENDERED_FLAGS} more`);
|
|
685
|
+
}
|
|
686
|
+
return lines;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** The one-line form for `omp-conductor status`, where a flagged run has to be
|
|
690
|
+
* visible long after its escalation was delivered and deduplicated. */
|
|
691
|
+
export function settlementFlagSummary(flags: readonly SettlementFlag[] | undefined): string | undefined {
|
|
692
|
+
if (flags === undefined || flags.length === 0) return undefined;
|
|
693
|
+
const kinds = [...new Set(flags.map((f) => f.kind))].join(", ");
|
|
694
|
+
const loud = flags.some((f) => f.unattributed === true) ? ", some unattributed" : "";
|
|
695
|
+
return `${HEADING}: ${flags.length} flag(s) — ${kinds}${loud}`;
|
|
696
|
+
}
|