control-arm 0.1.0 → 1.2.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/src/verify.mjs CHANGED
@@ -88,7 +88,144 @@ const BUILD_ONLY_RE = /(^|\/)(Podfile(\.lock)?|Gemfile(\.lock)?|Cartfile.*|packa
88
88
  const TEST_DIR_RE = /(^|\/)(tests?|__tests__|spec|specs)\//i;
89
89
  const TEST_SUFFIX_RE = /\.(test|spec)\.(m?[jt]sx?)$/i;
90
90
  const CODE_RE = /\.(m?[jt]sx?)$/i;
91
- const TEST_RE = (f) => CODE_RE.test(f) && (TEST_SUFFIX_RE.test(f) || TEST_DIR_RE.test(f));
91
+ // What arm B may carry over from the fix: things a test can actually LOAD. Restricted after
92
+ // the first run copied PNG screenshots out of docs/ into the worktree — pointless, and
93
+ // `transplant` writes through a string, so a binary would arrive corrupted anyway.
94
+ const LOADABLE_RE = /\.(m?[jt]sx?|json|ya?ml|sql|csv|graphql|snap)$/i;
95
+ /**
96
+ * TYPE tests — `.test-d.ts`, `.test-types.ts`, and the tsd/expect-type conventions.
97
+ *
98
+ * These assert about the TYPE SYSTEM and are run by a typechecker, not by executing the
99
+ * file. This tool cannot run one, and — far worse — did not even recognise one as a test.
100
+ *
101
+ * FOUND ON A PUBLIC REPOSITORY (remeda, 2026-09-26) and it inverted the answer:
102
+ *
103
+ * fix(startsWith, endsWith): reject disjoint literal prefixes at compile time
104
+ * endsWith.test-d.ts 439 +++ <- the actual guard, ignored
105
+ * endsWith.test.ts 16 +- <- judged instead, correctly unchanged
106
+ *
107
+ * The fix was type-level, so the runtime tests pass on both arms — as they should. The
108
+ * tool called that BLIND: an accusation, about a repository whose tests are fine, with the
109
+ * real guard sitting right there in the commit unread. Across ten such commits it reported
110
+ * 75% BLIND where the truthful answer is "I cannot judge type-level fixes".
111
+ *
112
+ * So a commit whose test changes are type tests is now INCONCLUSIVE, by the same rule that
113
+ * a skipped case blocks BLIND: a guard we cannot see is not a guard that is absent.
114
+ */
115
+ const TYPE_TEST_RE = /\.(test-d|test-types|type-test|types\.test)\.(m?tsx?)$|\.d\.test\.tsx?$/i;
116
+
117
+ export const isTypeTest = (f) => TYPE_TEST_RE.test(f);
118
+
119
+ const TEST_RE = (f) => CODE_RE.test(f) && (TEST_SUFFIX_RE.test(f) || TEST_DIR_RE.test(f) || TYPE_TEST_RE.test(f));
120
+
121
+ /**
122
+ * A "fix" whose source change is entirely comments.
123
+ *
124
+ * Found auditing auctionmate 2026-09-25: `fix(web): correct the framing — this was LIVE
125
+ * mispricing, not a dormant enum risk` came back BLIND and read as an open defect. Its one
126
+ * source hunk changes only a comment block — it corrects how an earlier fix was WRITTEN UP.
127
+ * There is no behaviour that differs from the parent, so no test could tell the two apart,
128
+ * and BLIND is not merely unhelpful there, it is wrong: it accuses a test of missing a bug
129
+ * that does not exist in the diff.
130
+ *
131
+ * The bias is deliberate. A line that is not clearly a comment makes the commit judgeable,
132
+ * because a false "comment-only" HIDES a finding, while a missed decline only costs a
133
+ * verdict on something harmless.
134
+ */
135
+ const HASH_COMMENT_EXT = /\.(py|rb|sh|bash|zsh|ya?ml|toml|tf|pl|r|jl)$/i;
136
+ const DASH_COMMENT_EXT = /\.(sql|lua|hs|adb|ads)$/i;
137
+ const XML_COMMENT_EXT = /\.(html?|xml|svg|vue|svelte)$/i;
138
+
139
+ export function isCommentOrBlank(file, raw) {
140
+ const line = raw.trim();
141
+ if (line === '') return true;
142
+ if (XML_COMMENT_EXT.test(file) && (line.startsWith('<!--') || line.startsWith('-->'))) return true;
143
+ if (HASH_COMMENT_EXT.test(file)) return line.startsWith('#');
144
+ if (DASH_COMMENT_EXT.test(file) && line.startsWith('--')) return true;
145
+ // C-family, including JSX's {/* … */}. `*` must be followed by space or end of line, or
146
+ // `*ptr = 0;` would read as a comment.
147
+ if (line.startsWith('//') || line.startsWith('/*') || line.startsWith('*/')
148
+ || line.startsWith('{/*') || line.startsWith('*/}')) return true;
149
+ return /^\*(\s|$)/.test(line);
150
+ }
151
+
152
+ /**
153
+ * Does this test text reach any file the commit MODIFIED?
154
+ *
155
+ * Only asked when arm B needed files the commit ADDED in order to load at all. If the test
156
+ * names nothing the commit modified, the most likely reading is that it tests the new code
157
+ * the commit introduced — and the bug lives in the modified lines, which such a test never
158
+ * touches. Calling that BLIND would accuse a test of missing a bug it was never pointed at,
159
+ * which is this tool's worst possible output.
160
+ *
161
+ * Deliberately crude, and deliberately biased toward "yes, it reaches". A false "reaches"
162
+ * costs a BLIND that a human then dismisses; a false "does not reach" silently drops a real
163
+ * finding. Matching is on the module basename, so an indirect import through a barrel file
164
+ * is missed — that direction is the safe one.
165
+ */
166
+ export function testReachesModified(testSrc, modifiedFiles) {
167
+ for (const f of modifiedFiles) {
168
+ const base = f.split('/').pop().replace(/\.[^.]+$/, '');
169
+ if (base.length < 3) continue;
170
+ if (new RegExp(`\\b${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(testSrc)) return true;
171
+ }
172
+ return false;
173
+ }
174
+
175
+ /**
176
+ * Exports this commit ADDED to files it already had.
177
+ *
178
+ * The largest remaining reason arm B cannot answer: the test imports a symbol the commit
179
+ * added to an EXISTING file, so at the parent the module graph will not link and every case
180
+ * in the file dies with `does not provide an export named 'X'`. Measured over 300 auctionmate
181
+ * fix commits — 21 of the 82 unanswerable ones, 26%.
182
+ *
183
+ * Those added files are NOT transplanted, and must not be: a modified file carries the
184
+ * repair, so putting it on the parent would hand arm B the fix. What CAN be done honestly is
185
+ * say so. "The test imports something this commit introduced, so it could not have existed
186
+ * at the parent" is a category; `SyntaxError: does not provide an export` is a stack trace.
187
+ */
188
+ export function exportsAddedToExistingFiles(diffText) {
189
+ const names = new Set();
190
+ for (const line of diffText.split('\n')) {
191
+ if (!line.startsWith('+') || line.startsWith('+++')) continue;
192
+ const m = line.slice(1).match(/^\s*export\s+(?:async\s+)?(?:function\*?|class|const|let|var)\s+([A-Za-z_$][\w$]*)/);
193
+ if (m) names.add(m[1]);
194
+ const re = /^\s*export\s*\{([^}]*)\}/.exec(line.slice(1));
195
+ if (re) for (const part of re[1].split(',')) {
196
+ const n = part.trim().split(/\s+as\s+/).pop().trim();
197
+ if (/^[A-Za-z_$][\w$]*$/.test(n)) names.add(n);
198
+ }
199
+ }
200
+ return names;
201
+ }
202
+
203
+ /** The symbol an ESM link failure is complaining about, if it names one. */
204
+ export function missingExportName(loadFailure) {
205
+ const m = /does not provide an export named ['"`]?([A-Za-z_$][\w$]*)/.exec(String(loadFailure || ''));
206
+ return m ? m[1] : null;
207
+ }
208
+
209
+ /** True when EVERY changed line in every source file is a comment or blank. */
210
+ export function diffIsCommentOnly(diffText) {
211
+ let sawAChangedLine = false;
212
+ let file = '';
213
+ for (const line of diffText.split('\n')) {
214
+ if (line.startsWith('+++ ') || line.startsWith('--- ')) continue;
215
+ if (line.startsWith('diff --git ')) {
216
+ const m = line.match(/ b\/(.+)$/);
217
+ file = m ? m[1] : '';
218
+ continue;
219
+ }
220
+ if (line.startsWith('+') || line.startsWith('-')) {
221
+ sawAChangedLine = true;
222
+ if (!isCommentOrBlank(file, line.slice(1))) return false;
223
+ }
224
+ }
225
+ // No changed lines at all (a pure rename or mode change) is not a comment-only fix —
226
+ // let the normal path decide, rather than declining something unexamined.
227
+ return sawAChangedLine;
228
+ }
92
229
 
93
230
  /**
94
231
  * IS THIS A REPAIR, OR IS IT NEW CODE? — and why the answer changes what CAUGHT means.
@@ -127,6 +264,18 @@ export function commitKind(subject, sourceAddedOnly) {
127
264
  return { kind: 'fix', newCode: !!sourceAddedOnly, prefix };
128
265
  }
129
266
  if (prefix === 'feat' || prefix === 'feature') return { kind: 'feature', newCode: true, prefix };
267
+ // A `test:` commit is not claiming to have fixed anything, so BLIND — "your fix shipped
268
+ // a test that could not catch it" — is the wrong sentence to say about one. The common
269
+ // shape is a BACKFILL: guards written for bugs repaired weeks earlier. Its parent does
270
+ // not contain those bugs, so the new tests pass there by construction.
271
+ //
272
+ // Found by running this tool over its own author's work, 2026-09-26: a commit titled
273
+ // `test: cover three fixes whose own tests were green on the broken code` came back
274
+ // BLIND with 32 cases green either way. Every one of them was correct; the question was
275
+ // wrong. It escapes the existing test-only decline because such a commit often DOES
276
+ // carry a source change — extracting logic out of a file nothing can execute is usually
277
+ // what makes the test possible at all.
278
+ if (prefix === 'test') return { kind: 'test', newCode: !!sourceAddedOnly, prefix };
130
279
  if (prefix) return { kind: 'other', newCode: !!sourceAddedOnly, prefix };
131
280
  return { kind: 'unknown', newCode: !!sourceAddedOnly, prefix: null };
132
281
  }
@@ -146,18 +295,34 @@ export function commitKind(subject, sourceAddedOnly) {
146
295
  export async function commitInfo(repo, sha, against = null, withDiffStat = false) {
147
296
  const out = await git(repo, ['show', '--no-patch', '--format=%H%n%s%n%ad', '--date=short', sha]);
148
297
  const [full, subject, date] = out.trim().split('\n');
298
+ // Computed once. It was resolved inline twice before, and the name-status call below
299
+ // would have made it three.
300
+ const mergeBase = against ? (await git(repo, ['merge-base', against, sha])).trim() : null;
149
301
  // With a base, the changed set is the WHOLE branch, not just the tip commit — a PR's
150
302
  // test may have arrived in commit 1 and its source change in commit 3.
151
- const files = against
152
- ? (await git(repo, ['diff', '--name-only', `${(await git(repo, ['merge-base', against, sha])).trim()}...${sha}`])).trim().split('\n').filter(Boolean)
153
- : (await git(repo, ['show', '--name-only', '--format=', sha])).trim().split('\n').filter(Boolean);
303
+ // `--name-only` lists a RENAMED file under BOTH its old and new path, and a DELETED
304
+ // file under a path that no longer exists at this commit. Treating the old path as a
305
+ // test file the commit ships means `git show <sha>:<old path>` throws later — which
306
+ // took the entire run down with a stack trace instead of reporting anything at all.
307
+ //
308
+ // Found on a real commit that renamed profitBarHelp.test.ts to .tsx.
309
+ const deletedOut = await git(repo,
310
+ against ? ['diff', '--name-status', '--diff-filter=D', mergeBase + '...' + sha]
311
+ : ['show', '--name-status', '--diff-filter=D', '--format=', sha]).catch(() => '');
312
+ const deleted = new Set(deletedOut.trim().split('\n')
313
+ .map(l => l.split(/\t/).pop())
314
+ .filter(Boolean));
315
+
316
+ const files = (against
317
+ ? (await git(repo, ['diff', '--name-only', `${mergeBase}...${sha}`])).trim().split('\n').filter(Boolean)
318
+ : (await git(repo, ['show', '--name-only', '--format=', sha])).trim().split('\n').filter(Boolean)
319
+ ).filter(f => !deleted.has(f));
154
320
  // Deletions in non-test source: a repair usually changes lines, new code only adds.
155
321
  const numstat = !withDiffStat ? '' : against
156
- ? await git(repo, ['diff', '--numstat', `${(await git(repo, ['merge-base', against, sha])).trim()}...${sha}`])
322
+ ? await git(repo, ['diff', '--numstat', `${mergeBase}...${sha}`])
157
323
  : await git(repo, ['show', '--numstat', '--format=', sha]);
158
324
  let srcDeletions = 0;
159
325
  for (const line of numstat.trim().split('\n')) {
160
- const [, del, file] = line.split(/\t/).length === 3 ? ['', ...line.split(/\t/).slice(1)] : [];
161
326
  const parts = line.split(/\t/);
162
327
  if (parts.length !== 3) continue;
163
328
  const [, d, f] = parts;
@@ -166,12 +331,30 @@ export async function commitInfo(repo, sha, against = null, withDiffStat = false
166
331
  }
167
332
  const kindInfo = commitKind(subject, srcDeletions === 0);
168
333
 
334
+ // ADDED vs MODIFIED matters for arm B. A file the commit ADDED did not exist at the
335
+ // parent, so putting it there cannot un-break anything — the behavioural repair lives in
336
+ // the lines of MODIFIED files, which arm B must keep in their broken state.
337
+ const nameStatus = !withDiffStat ? '' : against
338
+ ? await git(repo, ['diff', '--name-status', `${mergeBase}...${sha}`]).catch(() => '')
339
+ : await git(repo, ['show', '--name-status', '--format=', sha]).catch(() => '');
340
+ const added = [], modified = [];
341
+ for (const line of nameStatus.trim().split('\n')) {
342
+ const parts = line.split(/\t/);
343
+ if (parts.length < 2) continue;
344
+ const [st, f] = [parts[0], parts[parts.length - 1]];
345
+ if (TEST_RE(f) || DOC_RE.test(f)) continue;
346
+ if (st.startsWith('A')) added.push(f);
347
+ else if (st.startsWith('M') || st.startsWith('R')) modified.push(f);
348
+ }
349
+
169
350
  return {
170
351
  sha: full, short: full.slice(0, 8), subject, date,
171
352
  ...kindInfo, srcDeletions,
172
353
  files,
173
354
  testFiles: files.filter(f => TEST_RE(f)),
174
355
  sourceFiles: files.filter(f => !TEST_RE(f) && !DOC_RE.test(f)),
356
+ addedSourceFiles: added,
357
+ modifiedSourceFiles: modified,
175
358
  };
176
359
  }
177
360
 
@@ -194,11 +377,30 @@ export async function verifyCommit({ repo, workDir, sha, against = null, runs =
194
377
 
195
378
  if (info.testFiles.length === 0) { result.note = 'no test file in the commit'; return result; }
196
379
  if (info.sourceFiles.length === 0) { result.note = 'test-only commit — no source change to be blind to'; return result; }
380
+ if (info.kind === 'test') {
381
+ result.note = 'a `test:` commit — it is not claiming to fix a bug, so there is no bug in its '
382
+ + 'parent for these tests to be blind to (they are usually backfilled for older ones)';
383
+ return result;
384
+ }
197
385
  if (info.sourceFiles.every(f => BUILD_ONLY_RE.test(f))) {
198
386
  result.note = 'build-only change (lockfiles / project files) — a build catches this, not a unit test';
199
387
  return result;
200
388
  }
201
389
 
390
+ // A comment-only source change has no behaviour to be blind to. Judging it produces a
391
+ // BLIND that reads as an open defect and is not one — measured on auctionmate, where
392
+ // one of four "still open" findings was a commit that reworded a comment block.
393
+ {
394
+ const args = against
395
+ ? ['diff', `${(await git(repo, ['merge-base', against, sha])).trim()}...${sha}`]
396
+ : ['diff', `${sha}^`, sha];
397
+ const srcDiff = await git(repo, [...args, '--', ...info.sourceFiles]).catch(() => '');
398
+ if (srcDiff && diffIsCommentOnly(srcDiff)) {
399
+ result.note = 'comment-only source change — the behaviour is identical to the parent, so no test could tell them apart';
400
+ return result;
401
+ }
402
+ }
403
+
202
404
  const parent = against
203
405
  ? (await git(repo, ['merge-base', against, sha])).trim()
204
406
  : (await git(repo, ['rev-parse', `${sha}^`])).trim();
@@ -215,12 +417,82 @@ export async function verifyCommit({ repo, workDir, sha, against = null, runs =
215
417
  await linkDependencies(repo, parentDir);
216
418
  await linkEnvFiles(repo, parentDir);
217
419
 
420
+ // ── files the commit ADDED go onto the parent too ─────────────────────────────────
421
+ //
422
+ // Measured on 300 auctionmate fix commits: 82 came back INCONCLUSIVE, and 39 of those
423
+ // — nearly half — failed for one reason. The test imports a FILE the commit added, so
424
+ // at the parent the module graph cannot even be built and every case in the file dies
425
+ // at link time. 1,020 cases were lost this way across just 206 (commit, file) pairs;
426
+ // one unresolvable import takes a whole file down, and the biggest took 41 cases with
427
+ // it.
428
+ //
429
+ // A file the commit ADDED did not exist at the parent, so nothing at the parent can
430
+ // depend on it and putting it there cannot un-break anything. The repair lives in the
431
+ // lines of MODIFIED files, and those stay exactly as the parent left them — which is
432
+ // what keeps arm B a picture of the bug.
433
+ //
434
+ // The asymmetry this rests on: after the transplant a test that FAILS on arm B has
435
+ // failed on an assertion about the parent's behaviour, which is trustworthy. A test
436
+ // that PASSES might simply never reach a modified file — so that direction is guarded
437
+ // below rather than reported as BLIND.
438
+ const transplantedAdded = [];
439
+ for (const rel of (info.addedSourceFiles || []).filter(f => LOADABLE_RE.test(f))) {
440
+ try {
441
+ await transplant(repo, sha, rel, parentDir);
442
+ transplantedAdded.push(rel);
443
+ } catch {
444
+ // Unreadable at the fix (submodule, symlink, binary) — arm B just stays as it
445
+ // was, which is the pre-existing behaviour.
446
+ }
447
+ }
448
+ result.transplantedAdded = transplantedAdded;
449
+
218
450
  const perFile = [];
451
+ // Which symbols this commit introduced into files it already had — used only to
452
+ // explain an arm B link failure in words instead of a stack trace (see
453
+ // exportsAddedToExistingFiles).
454
+ let addedExports = new Set();
455
+ if ((info.modifiedSourceFiles || []).length) {
456
+ const dargs = against
457
+ ? ['diff', `${(await git(repo, ['merge-base', against, sha])).trim()}...${sha}`]
458
+ : ['diff', `${sha}^`, sha];
459
+ const d = await git(repo, [...dargs, '--', ...info.modifiedSourceFiles]).catch(() => '');
460
+ addedExports = exportsAddedToExistingFiles(d);
461
+ }
462
+
219
463
  for (const rel of info.testFiles) {
464
+ // A TYPE test asserts about the type system and is checked by a typechecker, not by
465
+ // executing the file. Skipping it silently is what made a type-level fix look BLIND
466
+ // while its 439-line guard sat unread in the same commit.
467
+ if (isTypeTest(rel)) {
468
+ perFile.push({
469
+ file: rel, runner: 'typecheck', pkgDir: '',
470
+ skip: 'a TYPE test — asserted against the type system, not by running the file. '
471
+ + 'control-arm cannot run one, so this commit cannot be judged: the guard exists '
472
+ + 'and is simply not visible to this method.',
473
+ });
474
+ continue;
475
+ }
476
+
220
477
  // Chosen from the FIX worktree: the parent may predate the config file entirely,
221
478
  // and the question is which runner the test was written for.
222
479
  const { flavour, pkgDir } = await selectRunner(fixDir, rel);
223
480
  const runner = RUNNERS[flavour];
481
+
482
+ // DECLINE BY NAME. A runner we do not have must say which one it is, and must not
483
+ // be attempted with a different one. Running a Playwright spec under node:test
484
+ // produced `arm A did not run (node): test failed`, which blames the author's test
485
+ // for the tool's own gap — the worst kind of wrong message, because it is
486
+ // actionable and points at the wrong thing.
487
+ if (!runner) {
488
+ perFile.push({
489
+ file: rel, runner: flavour, pkgDir,
490
+ skip: `no ${flavour} runner — this file is a ${flavour} test and control-arm cannot execute it. `
491
+ + `Supported today: node:test, vitest, jest.`,
492
+ });
493
+ continue;
494
+ }
495
+
224
496
  const opts = { relTestPath: rel, pkgDir, timeoutMs };
225
497
 
226
498
  const a = await runner.execute({ worktreeDir: fixDir, ...opts });
@@ -251,7 +523,21 @@ export async function verifyCommit({ repo, workDir, sha, against = null, runs =
251
523
  if (f.skip) { result.cases.push({ file: f.file, name: '(file)', verdict: INCONCLUSIVE, reason: f.skip }); continue; }
252
524
  for (const aCase of f.armA.cases) {
253
525
  const perRun = f.runs.map(({ b, identity }) => {
254
- if (!b.ok) return { verdict: INCONCLUSIVE, reason: `did not run on the parent: ${b.loadFailure}` };
526
+ if (!b.ok) {
527
+ // Name the category when the link failure is about a symbol this very
528
+ // commit introduced. The test could not have existed at the parent, which
529
+ // is a different statement from "we could not run it".
530
+ const missing = missingExportName(b.loadFailure);
531
+ if (missing && addedExports.has(missing)) {
532
+ return {
533
+ verdict: INCONCLUSIVE,
534
+ reason: `the test imports \`${missing}\`, which THIS commit added to a file it `
535
+ + `modified — the test could not have existed at the parent, so there is nothing `
536
+ + `to be blind to`,
537
+ };
538
+ }
539
+ return { verdict: INCONCLUSIVE, reason: `did not run on the parent: ${b.loadFailure}` };
540
+ }
255
541
  const bCase = b.cases.find(c => c.name === aCase.name) || null;
256
542
  return classify({ armA: aCase, armB: bCase, identity });
257
543
  });
@@ -273,9 +559,60 @@ export async function verifyCommit({ repo, workDir, sha, against = null, runs =
273
559
  }
274
560
  result.verdict = rollUp(result.cases);
275
561
 
562
+ // A BLIND that only became reachable by transplanting added files needs one more
563
+ // question answered: does the test go anywhere near what the commit actually changed?
564
+ // If it names nothing the commit modified, it is testing the new code, and BLIND would
565
+ // be an accusation rather than a finding. CAUGHT is left alone — a test that FAILED on
566
+ // the parent has failed on the parent's behaviour, however it got there.
567
+ if (result.verdict === BLIND && (result.transplantedAdded || []).length) {
568
+ // A file whose only changed lines are comments is not a behavioural change, so it
569
+ // does not count as something arm B could be blind to. Found on 022c9997, whose
570
+ // whole diff is: two ADDED scripts, an ADDED test, two docs — and one pre-existing
571
+ // file in which every changed line is a comment. The defect there was that NOTHING
572
+ // RAN the coverage gate, and the fix IS a test. A test cannot fail on the absence
573
+ // of itself, so BLIND was meaningless; but the added scripts carry real code, so
574
+ // the whole-diff comment-only check above does not fire either.
575
+ const mods = [];
576
+ for (const f of info.modifiedSourceFiles || []) {
577
+ const args = against
578
+ ? ['diff', `${(await git(repo, ['merge-base', against, sha])).trim()}...${sha}`]
579
+ : ['diff', `${sha}^`, sha];
580
+ const d = await git(repo, [...args, '--', f]).catch(() => '');
581
+ if (!d || !diffIsCommentOnly(d)) mods.push(f);
582
+ }
583
+
584
+ // NO modified source at all is the clearest case, and the first version of this
585
+ // guard got it exactly backwards by defaulting `reaches` to true. If the commit
586
+ // only ADDED source, then everything it changed is in the files arm B just
587
+ // received — arm B holds the whole fix, so a green test there says nothing about
588
+ // any bug. Caught on bbd0db0d, which adds ProfitBarHelp.tsx and its test and
589
+ // modifies nothing: it was reported BLIND, and there was no old behaviour left in
590
+ // arm B for the test to be blind TO.
591
+ if (mods.length === 0) {
592
+ result.verdict = INCONCLUSIVE;
593
+ result.note = 'every behavioural change here is in files this commit ADDED (any edit to '
594
+ + 'existing source is comment-only), and arm B needed those files to load — so arm B '
595
+ + 'holds the whole change and there is no earlier behaviour to be blind to';
596
+ return result;
597
+ }
598
+
599
+ let reaches = false;
600
+ for (const f of perFile) {
601
+ if (reaches) break;
602
+ try {
603
+ reaches = testReachesModified(await git(repo, ['show', `${sha}:${f.file}`]), mods);
604
+ } catch { /* unreadable — leave `reaches` alone */ }
605
+ }
606
+ if (!reaches) {
607
+ result.verdict = INCONCLUSIVE;
608
+ result.note = 'arm B could only load with files this commit ADDED, and the test names '
609
+ + 'nothing it modified — most likely a test for the new code, not for the bug';
610
+ }
611
+ }
612
+
276
613
  // --- ARM C: is this BLIND finding still open? ---------------------------------------
277
614
  if (result.verdict === BLIND) {
278
- result.stillOpen = await armC({ repo, parentDir, perFile, timeoutMs });
615
+ result.stillOpen = await armC({ repo, parentDir, fixDir, perFile, timeoutMs, info });
279
616
  }
280
617
  return result;
281
618
  }
@@ -300,7 +637,7 @@ export async function verifyCommit({ repo, workDir, sha, against = null, runs =
300
637
  * So: take the CURRENT version of the test file, put it on the parent's broken code, and
301
638
  * run it. If it fails now, the gap was repaired and the finding is history, not a ticket.
302
639
  */
303
- async function armC({ repo, parentDir, perFile, timeoutMs }) {
640
+ async function armC({ repo, parentDir, fixDir, perFile, timeoutMs, info = {} }) {
304
641
  const files = perFile.filter(f => !f.skip);
305
642
  if (files.length === 0) return { status: 'unknown', reason: 'no runnable test file' };
306
643
 
@@ -318,5 +655,56 @@ async function armC({ repo, parentDir, perFile, timeoutMs }) {
318
655
  return { status: 'repaired', reason: `the CURRENT "${killer.name}" fails on this bug — the gap was closed after this commit`, by: killer.name, file: f.file };
319
656
  }
320
657
  }
658
+ // ── the commit's own files said nothing. Look wider. ────────────────────────────────
659
+ //
660
+ // Until now arm C re-ran only the test files the COMMIT touched, which makes it blind
661
+ // to the most ordinary way a gap gets closed: someone writes the missing test in a
662
+ // NEW file. Measured 2026-09-25 — of nine commits reported "still open", FOUR had been
663
+ // fixed that same day, each by a test in a different file:
664
+ //
665
+ // 4f6b0492 fixed in tests/auctionCtxUserSettings.test.js arm C re-ran salesTierFromSettings
666
+ // 3e54540e fixed in fetchAuditSourceLink.test.ts arm C re-ran lotSourceUrl
667
+ // 7de3d66f fixed in VinDemoForm.test.tsx arm C re-ran vin.test.ts
668
+ // d76bdb7d fixed in tests/postVerdictShaGuard.test.js arm C re-ran agentDaemonAllowlist
669
+ //
670
+ // A 44% false "still open" rate, always in the direction of crying wolf — and this is
671
+ // the one column anybody acts on. So also try the CURRENT tests that name what the
672
+ // commit changed, capped, because each one costs a run.
673
+ const already = new Set(files.map(f => f.file));
674
+ const candidates = [];
675
+ for (const src of info.modifiedSourceFiles || []) {
676
+ const base = src.split('/').pop().replace(/\.[^.]+$/, '');
677
+ if (base.length < 3) continue;
678
+ const hits = await git(repo, ['grep', '-l', '--', base, 'HEAD']).catch(() => '');
679
+ for (const line of hits.split('\n')) {
680
+ const f = line.replace(/^HEAD:/, '').trim();
681
+ if (!f || already.has(f) || !TEST_RE(f)) continue;
682
+ already.add(f);
683
+ candidates.push(f);
684
+ }
685
+ }
686
+ for (const rel of candidates.slice(0, 6)) {
687
+ let dest;
688
+ try { dest = await transplant(repo, 'HEAD', rel, parentDir); } catch { continue; }
689
+ let flavour, pkgDir;
690
+ try { ({ flavour, pkgDir } = await selectRunner(fixDir, rel)); } catch { continue; }
691
+ const runner = RUNNERS[flavour] || nodeTest;
692
+ const r = await runner.execute({ worktreeDir: parentDir, relTestPath: rel, pkgDir, timeoutMs });
693
+ if (!r.ok) continue;
694
+ const killer = r.cases.find(c => isDisagreement(c));
695
+ if (killer) {
696
+ return {
697
+ // A DIFFERENT status from the commit's own file on purpose. That file is
698
+ // topically tied to the fix; this one was found by name-matching, so it
699
+ // may be failing on the parent for a reason of its own. Reported as worth
700
+ // checking rather than as settled — overclaiming here HIDES a real gap,
701
+ // which is worse than the crying-wolf it replaces.
702
+ status: 'repaired-elsewhere',
703
+ reason: `a LATER test elsewhere, "${killer.name}" in ${rel}, fails on this bug — likely closed after this commit, worth confirming`,
704
+ by: killer.name, file: rel,
705
+ };
706
+ }
707
+ }
708
+
321
709
  return { status: 'open', reason: 'even the CURRENT tests are green on this bug — still unguarded today' };
322
710
  }
package/src/worktree.mjs CHANGED
@@ -181,7 +181,17 @@ export async function linkEnvFiles(repoRoot, worktreeDir) {
181
181
 
182
182
  /** Put the fix's version of a test file onto the parent tree. The transplant. */
183
183
  export async function transplant(repo, sha, relPath, worktreeDir) {
184
- const content = await git(repo, ['show', `${sha}:${relPath}`]);
184
+ // BELT AND BRACES. The caller now filters deleted paths, but a read that cannot find
185
+ // its path must still degrade to "this file cannot be judged" rather than take the run
186
+ // down. A diagnostic that dies with a stack trace has told the user nothing, and has
187
+ // done it in the most alarming way available.
188
+ let content;
189
+ try {
190
+ content = await git(repo, ['show', `${sha}:${relPath}`]);
191
+ } catch (e) {
192
+ const why = String(e.stderr || e.message || '').split('\n')[0].slice(0, 160);
193
+ throw Object.assign(new Error(`cannot read ${relPath} at ${sha.slice(0, 8)} — ${why}`), { soft: true });
194
+ }
185
195
  const dest = path.join(worktreeDir, relPath);
186
196
  await mkdir(path.dirname(dest), { recursive: true });
187
197
  await writeFile(dest, content);
@@ -1,82 +0,0 @@
1
- # The tool that asks whether your tests can fail, running its own.
2
- #
3
- # It shipped for a day without this. That is the defect it exists to find, one level up:
4
- # a suite that is never executed by anything but its author's terminal is not a gate, and
5
- # "46/46 green" meant "green on one laptop, when I remembered".
6
- #
7
- # GitHub-hosted runners on purpose: control-arm has no dependencies and must work on a
8
- # stock box. Pinning it to a self-hosted fleet would hide exactly the assumptions —
9
- # a preinstalled binary, a warm cache, a particular git version — that break for the
10
- # first stranger who clones it.
11
- name: CI
12
-
13
- on:
14
- push:
15
- branches: [master, main]
16
- pull_request:
17
- workflow_dispatch:
18
-
19
- concurrency:
20
- group: ci-${{ github.ref }}
21
- cancel-in-progress: true
22
-
23
- jobs:
24
- test:
25
- name: node ${{ matrix.node }}
26
- runs-on: ubuntu-latest
27
- strategy:
28
- fail-fast: false
29
- matrix:
30
- # 20 is the oldest LTS with a stable node:test reporter API, which the TAP parser
31
- # depends on. 24 is what it is developed against. A break in either is worth knowing.
32
- node: ['20', '22', '24']
33
- steps:
34
- - uses: actions/checkout@v4
35
- with:
36
- # FULL HISTORY, not the default shallow clone. The dogfood step verifies the tool
37
- # against one of its OWN past commits, and `git show 1845c1d3` on a depth-1
38
- # checkout fails with "unknown revision". Caught by this workflow's first run,
39
- # which is the argument for having it.
40
- fetch-depth: 0
41
- - uses: actions/setup-node@v4
42
- with:
43
- node-version: ${{ matrix.node }}
44
-
45
- - name: Unit + fixtures
46
- run: node --test --test-concurrency=1 test/*.test.mjs
47
-
48
- # DOGFOOD. The fixtures prove the verdicts on repos built to have known answers.
49
- # This proves the whole two-arm machinery works on a REAL history with real commits,
50
- # worktrees and module resolution — which is where every bug so far has come from.
51
- - name: Judge its own history
52
- run: |
53
- set -euo pipefail
54
- git config --global user.email ci@control-arm
55
- git config --global user.name ci
56
-
57
- OUT=$(node bin/ca.mjs verify 1845c1d3 --repo "$GITHUB_WORKSPACE" --timeout 120000)
58
- echo "$OUT"
59
-
60
- # That commit added the rule "a SKIPPED case blocks BLIND", with a test written
61
- # for it. If the tool cannot still see that test discriminate, the tool is broken
62
- # — regardless of what its unit suite says.
63
- # Match the VERDICT WORD, not the whole line. This grep was 'VERDICT CAUGHT'
64
- # and broke the moment a glyph was added between them — a cosmetic change that
65
- # failed a correctness gate, which trains people to edit the gate rather than
66
- # believe it. Anchor on what the check is actually about.
67
- echo "$OUT" | grep -qE '^ *VERDICT .*\bCAUGHT\b' \
68
- || { echo "::error::control-arm no longer judges its own fix correctly"; exit 1; }
69
- # And it must be the STRONG claim: 1845c1d3 is a repair, so a "weak evidence"
70
- # qualifier here would mean the new-code heuristic has started misfiring on fixes.
71
- echo "$OUT" | grep -q 'weak evidence' \
72
- && { echo "::error::a repair was labelled new code — the kind heuristic is wrong"; exit 1; }
73
- echo "$OUT" | grep -q 'module identity verified' \
74
- || { echo "::error::module identity was not proven — a verdict here is not trustworthy"; exit 1; }
75
-
76
- - name: Determinism
77
- run: |
78
- set -euo pipefail
79
- A=$(node bin/ca.mjs verify 1845c1d3 --repo "$GITHUB_WORKSPACE" --work /tmp/d1 --timeout 120000 | tail -14)
80
- B=$(node bin/ca.mjs verify 1845c1d3 --repo "$GITHUB_WORKSPACE" --work /tmp/d2 --timeout 120000 | tail -14)
81
- [ "$A" = "$B" ] || { echo "::error::two runs disagreed — the tool is not deterministic"; exit 1; }
82
- echo "two independent runs are byte-identical"