omp-conductor 0.3.25 → 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.
@@ -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
+ }