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/CHANGELOG.md +44 -0
- package/DESIGN.md +37 -21
- package/README.md +212 -25
- package/bin/ca.mjs +53 -6
- package/package.json +32 -2
- package/src/assertions.mjs +37 -1
- package/src/children.mjs +55 -0
- package/src/cli-spec.mjs +50 -0
- package/src/contract.mjs +193 -0
- package/src/identity.mjs +18 -5
- package/src/markdown-report.mjs +5 -1
- package/src/report.mjs +14 -1
- package/src/runner-json.mjs +47 -21
- package/src/runner.mjs +21 -8
- package/src/sample-warning.mjs +69 -0
- package/src/select-runner.mjs +7 -0
- package/src/tap.mjs +0 -10
- package/src/verify.mjs +397 -9
- package/src/worktree.mjs +11 -1
- package/.github/workflows/ci.yml +0 -82
- package/action.yml +0 -95
- package/fixtures/build.mjs +0 -178
- package/scripts/gh-api.mjs +0 -90
- package/scripts-analyze.mjs +0 -86
- package/scripts-recompute.mjs +0 -53
- package/test/assertions.test.mjs +0 -130
- package/test/fixtures.test.mjs +0 -41
- package/test/runner-json.test.mjs +0 -69
- package/test/select-runner.test.mjs +0 -78
- package/test/verdict.test.mjs +0 -113
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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', `${
|
|
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)
|
|
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
|
-
|
|
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);
|
package/.github/workflows/ci.yml
DELETED
|
@@ -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"
|