control-arm 0.1.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/.github/workflows/ci.yml +82 -0
- package/DESIGN.md +161 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/action.yml +95 -0
- package/bin/ca.mjs +245 -0
- package/fixtures/build.mjs +178 -0
- package/package.json +12 -0
- package/scripts/gh-api.mjs +90 -0
- package/scripts-analyze.mjs +86 -0
- package/scripts-recompute.mjs +53 -0
- package/src/assertions.mjs +350 -0
- package/src/html-report.mjs +125 -0
- package/src/identity.mjs +105 -0
- package/src/markdown-report.mjs +81 -0
- package/src/report.mjs +183 -0
- package/src/runner-json.mjs +127 -0
- package/src/runner.mjs +83 -0
- package/src/select-runner.mjs +58 -0
- package/src/tap.mjs +83 -0
- package/src/verdict.mjs +168 -0
- package/src/verify.mjs +322 -0
- package/src/worktree.mjs +197 -0
- package/test/assertions.test.mjs +130 -0
- package/test/fixtures.test.mjs +41 -0
- package/test/runner-json.test.mjs +69 -0
- package/test/select-runner.test.mjs +78 -0
- package/test/verdict.test.mjs +113 -0
package/bin/ca.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ca — does a test actually fail on the code it was written to catch?
|
|
4
|
+
*
|
|
5
|
+
* ca doctor [--repo .] can this repo be measured at all?
|
|
6
|
+
* ca verify <commit> [--repo .] [--runs 3] one commit, per-case verdicts
|
|
7
|
+
* ca audit [--repo .] [--n 100] [--since '12 months'] [--grep '^fix'] [--out f.csv]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { writeFile } from 'node:fs/promises';
|
|
12
|
+
import { git, removeWorktrees } from '../src/worktree.mjs';
|
|
13
|
+
import { verifyCommit, commitInfo } from '../src/verify.mjs';
|
|
14
|
+
import { CAUGHT, BLIND, INCONCLUSIVE, FLAKY, SKIPPED } from '../src/verdict.mjs';
|
|
15
|
+
import { renderVerify, renderAudit, MARK } from '../src/report.mjs';
|
|
16
|
+
import { renderHtml, issueBody } from '../src/html-report.mjs';
|
|
17
|
+
import { prComment } from '../src/markdown-report.mjs';
|
|
18
|
+
import { analyseCase, extractCase } from '../src/assertions.mjs';
|
|
19
|
+
|
|
20
|
+
const argv = process.argv.slice(2);
|
|
21
|
+
const cmd = argv[0];
|
|
22
|
+
const flag = (n, d = null) => { const i = argv.indexOf(`--${n}`); return i === -1 ? d : argv[i + 1]; };
|
|
23
|
+
const has = n => argv.includes(`--${n}`);
|
|
24
|
+
const repo = path.resolve(flag('repo', process.cwd()));
|
|
25
|
+
const workDir = path.resolve(flag('work', path.join(repo, '.ca-work')));
|
|
26
|
+
|
|
27
|
+
function die(msg, code = 2) { console.error(`ca: ${msg}`); process.exit(code); }
|
|
28
|
+
|
|
29
|
+
async function doctor() {
|
|
30
|
+
const checks = [];
|
|
31
|
+
const ok = (name, detail) => checks.push({ ok: true, name, detail });
|
|
32
|
+
const no = (name, detail) => checks.push({ ok: false, name, detail });
|
|
33
|
+
|
|
34
|
+
try { await git(repo, ['rev-parse', '--git-dir']); ok('git repository', repo); }
|
|
35
|
+
catch { no('git repository', `${repo} is not a git repo`); }
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const dirty = (await git(repo, ['status', '--porcelain'])).trim();
|
|
39
|
+
// Not a blocker — we never touch the user's tree — but worth saying out loud.
|
|
40
|
+
dirty ? ok('working tree', `${dirty.split('\n').length} uncommitted change(s) — untouched, we use worktrees`)
|
|
41
|
+
: ok('working tree', 'clean');
|
|
42
|
+
} catch { no('working tree', 'could not read status'); }
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const n = (await git(repo, ['rev-list', '--count', 'HEAD'])).trim();
|
|
46
|
+
ok('history', `${Number(n).toLocaleString()} commits`);
|
|
47
|
+
} catch { no('history', 'no commits'); }
|
|
48
|
+
|
|
49
|
+
let pkg = null;
|
|
50
|
+
try { pkg = JSON.parse(await (await import('node:fs/promises')).readFile(path.join(repo, 'package.json'), 'utf8')); } catch {}
|
|
51
|
+
pkg ? ok('package.json', pkg.name || '(unnamed)') : no('package.json', 'not found — only node:test repos are supported today');
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await (await import('node:fs/promises')).lstat(path.join(repo, 'node_modules'));
|
|
55
|
+
ok('node_modules', 'present — will be linked into worktrees');
|
|
56
|
+
} catch { no('node_modules', 'missing — run install first, or arm B cannot resolve imports'); }
|
|
57
|
+
|
|
58
|
+
if (pkg?.workspaces) {
|
|
59
|
+
checks.push({ ok: true, name: 'workspaces', detail: `${[].concat(pkg.workspaces).join(', ')} — workspace links will be re-pointed into the worktree (this is the trap that produces false BLIND)` });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const w = 22;
|
|
63
|
+
console.log(`\n ca doctor — ${repo}\n`);
|
|
64
|
+
for (const c of checks) console.log(` ${c.ok ? MARK.ok : MARK.no} ${c.name.padEnd(w)} ${c.detail}`);
|
|
65
|
+
const bad = checks.filter(c => !c.ok);
|
|
66
|
+
console.log(bad.length ? `\n ${bad.length} blocker(s).\n` : `\n Ready.\n`);
|
|
67
|
+
process.exit(bad.length ? 1 : 0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function verify() {
|
|
71
|
+
const sha = argv[1];
|
|
72
|
+
if (!sha || sha.startsWith('--')) die('usage: ca verify <commit>');
|
|
73
|
+
const runs = Number(flag('runs', 1));
|
|
74
|
+
const r = await verifyCommit({ repo, workDir, sha, against: flag('against'), runs, timeoutMs: Number(flag('timeout', 120_000)),
|
|
75
|
+
onStep: s => process.stderr.write(`\r … ${s} `) });
|
|
76
|
+
process.stderr.write('\r' + ' '.repeat(40) + '\r');
|
|
77
|
+
if (has('pr-comment')) console.log(prComment(r, { repoName: flag('repo', '.') }));
|
|
78
|
+
else console.log(renderVerify(r));
|
|
79
|
+
if (!has('keep')) await removeWorktrees(repo, workDir);
|
|
80
|
+
process.exit(r.verdict === BLIND ? 1 : 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function audit() {
|
|
84
|
+
const n = Number(flag('n', 100));
|
|
85
|
+
const since = flag('since', '12 months ago');
|
|
86
|
+
const grep = new RegExp(flag('grep', '^(fix|bug)'), 'i');
|
|
87
|
+
// `fix(test): ...` commits repair the TEST. Asking "did the test catch the bug" when
|
|
88
|
+
// the bug WAS the test is a category error, and it produces confident nonsense:
|
|
89
|
+
// Two such commits surfaced as still-open BLIND and neither is a finding. Measured
|
|
90
|
+
// contamination in one real repo: 12 of 1,209 (~1%) — small, but it lands squarely in
|
|
91
|
+
// the headline column. Opt back in with --include-test-fixes.
|
|
92
|
+
const dropTestFixes = !has('include-test-fixes');
|
|
93
|
+
const TEST_FIX = /^(fix|bug)\s*\((test|tests|ci|build|chore)\)/i;
|
|
94
|
+
const seed = Number(flag('seed', 1));
|
|
95
|
+
const runs = Number(flag('runs', 1));
|
|
96
|
+
|
|
97
|
+
process.stderr.write(` selecting commits (since ${since}, /${grep.source}/) …\n`);
|
|
98
|
+
const log = await git(repo, ['log', `--since=${since}`, '--format=%H|%s', '--no-merges']);
|
|
99
|
+
let candidates = log.trim().split('\n').map(l => { const i = l.indexOf('|'); return { sha: l.slice(0, i), subject: l.slice(i + 1) }; })
|
|
100
|
+
.filter(c => grep.test(c.subject))
|
|
101
|
+
.filter(c => !(dropTestFixes && TEST_FIX.test(c.subject)));
|
|
102
|
+
|
|
103
|
+
// Cheap pre-filter: only commits that ship BOTH a test and a source change can be judged.
|
|
104
|
+
const eligible = [];
|
|
105
|
+
for (const c of candidates) {
|
|
106
|
+
const info = await commitInfo(repo, c.sha);
|
|
107
|
+
if (info.testFiles.length && info.sourceFiles.length) eligible.push(info);
|
|
108
|
+
}
|
|
109
|
+
process.stderr.write(` ${candidates.length} matched the subject filter · ${eligible.length} ship both a test and a source change\n`);
|
|
110
|
+
|
|
111
|
+
// Deterministic pseudo-random draw. NOT the newest N, and not a set anyone chose —
|
|
112
|
+
// a sample you picked cannot measure coverage.
|
|
113
|
+
let s = seed >>> 0 || 1;
|
|
114
|
+
const rand = () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32;
|
|
115
|
+
const pool = [...eligible];
|
|
116
|
+
for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [pool[i], pool[j]] = [pool[j], pool[i]]; }
|
|
117
|
+
const sample = pool.slice(0, n);
|
|
118
|
+
process.stderr.write(` drawing ${sample.length} at random (seed ${seed})\n\n`);
|
|
119
|
+
|
|
120
|
+
const results = [];
|
|
121
|
+
const t0 = Date.now();
|
|
122
|
+
for (const [i, info] of sample.entries()) {
|
|
123
|
+
const el = ((Date.now() - t0) / 1000).toFixed(0);
|
|
124
|
+
process.stderr.write(`\r [${String(i + 1).padStart(3)}/${sample.length}] ${el}s ${info.short} ${info.subject.slice(0, 64).padEnd(64)}`);
|
|
125
|
+
try {
|
|
126
|
+
results.push(await verifyCommit({ repo, workDir, sha: info.sha, runs, timeoutMs: Number(flag('timeout', 120_000)) }));
|
|
127
|
+
} catch (e) {
|
|
128
|
+
results.push({ ...info, cases: [], verdict: INCONCLUSIVE, note: `harness error: ${String(e.message).slice(0, 160)}` });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
process.stderr.write('\r' + ' '.repeat(120) + '\r');
|
|
132
|
+
|
|
133
|
+
console.log(renderAudit(results, { since, n: sample.length, eligible: eligible.length, matched: candidates.length, seed, seconds: (Date.now() - t0) / 1000 }));
|
|
134
|
+
|
|
135
|
+
const htmlOut = flag('html');
|
|
136
|
+
if (htmlOut) {
|
|
137
|
+
const blind = results.filter(r => r.verdict === BLIND || r.stillOpen);
|
|
138
|
+
const answerable = results.filter(r => r.verdict === CAUGHT || r.verdict === BLIND).length;
|
|
139
|
+
const caught = results.filter(r => r.verdict === CAUGHT).length;
|
|
140
|
+
let slug = 'OWNER/REPO';
|
|
141
|
+
try {
|
|
142
|
+
const url = (await git(repo, ['remote', 'get-url', 'origin'])).trim();
|
|
143
|
+
const m = url.match(/github\.com[:/]([^/]+\/[^/.]+)/); if (m) slug = m[1];
|
|
144
|
+
} catch { /* no remote: the links still render, pointed at a placeholder */ }
|
|
145
|
+
const html = renderHtml({
|
|
146
|
+
repoSlug: slug, repoName: repo,
|
|
147
|
+
findings: blind.map(r => ({ sha: r.sha, date: r.date, subject: r.subject, verdict: r.verdict,
|
|
148
|
+
stillOpen: r.stillOpen?.status?.toUpperCase(),
|
|
149
|
+
cases: r.cases.filter(c => c.verdict !== 'SKIPPED').map(c => ({ verdict: c.verdict, name: c.name, why: c.why })) })),
|
|
150
|
+
meta: { repo: path.basename(repo), n: sample.length, seed, when: new Date().toISOString().slice(0, 10),
|
|
151
|
+
caught, blind: results.filter(r => r.verdict === BLIND).length,
|
|
152
|
+
inconclusive: results.filter(r => r.verdict === INCONCLUSIVE).length,
|
|
153
|
+
pct: answerable ? ((caught / answerable) * 100).toFixed(1) : '—' },
|
|
154
|
+
});
|
|
155
|
+
await writeFile(path.resolve(htmlOut), html);
|
|
156
|
+
console.log(` HTML report: ${path.resolve(htmlOut)}\n`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A MACHINE-READABLE SUMMARY, because a consumer should never have to parse the CSV.
|
|
160
|
+
//
|
|
161
|
+
// The first workflow to use this counted still-open findings with `awk -F','`, which
|
|
162
|
+
// does not respect quoting — and a commit subject like
|
|
163
|
+
// fix(web): the chart said "40 sales" under a headline saying 3,214
|
|
164
|
+
// shifts every field after it. Field 5 came back as '', 'INCONCLUSIVE' and
|
|
165
|
+
// ' TITLE-015/016)"' instead of the still_open column. The true answer was 3.
|
|
166
|
+
//
|
|
167
|
+
// CSV is for humans and spreadsheets. This is for scripts.
|
|
168
|
+
const jsonOut = flag('json');
|
|
169
|
+
if (jsonOut) {
|
|
170
|
+
const byStatus = (st) => results.filter(r => r.stillOpen?.status === st)
|
|
171
|
+
.map(r => ({ sha: r.sha, short: r.short, date: r.date, subject: r.subject }));
|
|
172
|
+
const answerable = results.filter(r => r.verdict === CAUGHT || r.verdict === BLIND).length;
|
|
173
|
+
await writeFile(path.resolve(jsonOut), JSON.stringify({
|
|
174
|
+
repo: path.basename(repo),
|
|
175
|
+
generated: new Date().toISOString(),
|
|
176
|
+
sample: { drawn: sample.length, matched: candidates.length, judgeable: eligible.length, seed, since },
|
|
177
|
+
commits: Object.fromEntries([CAUGHT, BLIND, FLAKY, INCONCLUSIVE, SKIPPED]
|
|
178
|
+
.map(v => [v.toLowerCase(), results.filter(r => r.verdict === v).length])),
|
|
179
|
+
answerable,
|
|
180
|
+
caught_pct: answerable ? Number(((results.filter(r => r.verdict === CAUGHT).length / answerable) * 100).toFixed(1)) : null,
|
|
181
|
+
still_open: byStatus('open'),
|
|
182
|
+
repaired_since: byStatus('repaired'),
|
|
183
|
+
cannot_tell: results.filter(r => r.verdict === BLIND && (!r.stillOpen || r.stillOpen.status === 'unknown'))
|
|
184
|
+
.map(r => ({ sha: r.sha, short: r.short, subject: r.subject })),
|
|
185
|
+
}, null, 2));
|
|
186
|
+
console.log(` JSON summary: ${path.resolve(jsonOut)}\n`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const out = flag('out');
|
|
190
|
+
if (out) {
|
|
191
|
+
const esc = v => `"${String(v ?? '').replace(/"/g, '""').replace(/\s+/g, ' ').slice(0, 400)}"`;
|
|
192
|
+
const rows = [['sha', 'date', 'subject', 'commit_verdict', 'still_open', 'file', 'case', 'case_verdict', 'reason', 'why_weak'].join(',')];
|
|
193
|
+
for (const r of results) {
|
|
194
|
+
const so = r.stillOpen?.status?.toUpperCase() || '';
|
|
195
|
+
if (!r.cases.length) rows.push([r.sha, r.date, esc(r.subject), r.verdict, so, '', '', r.verdict, esc(r.note), ''].join(','));
|
|
196
|
+
for (const c of r.cases) rows.push([r.sha, r.date, esc(r.subject), r.verdict, so, esc(c.file), esc(c.name), c.verdict, esc(c.reason), esc(c.why)].join(','));
|
|
197
|
+
}
|
|
198
|
+
await writeFile(path.resolve(out), rows.join('\n'));
|
|
199
|
+
console.log(` full per-case results: ${path.resolve(out)} (${rows.length - 1} rows)\n`);
|
|
200
|
+
}
|
|
201
|
+
if (!has('keep')) await removeWorktrees(repo, workDir);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function issues() {
|
|
205
|
+
const sha = argv[1];
|
|
206
|
+
if (!sha || sha.startsWith('--')) die('usage: ca issues <commit> [--apply]');
|
|
207
|
+
const r = await verifyCommit({ repo, workDir, sha, against: flag('against'), timeoutMs: Number(flag('timeout', 120_000)) });
|
|
208
|
+
if (r.verdict !== BLIND) {
|
|
209
|
+
console.log(`\n ${r.short} is ${r.verdict}, not BLIND — nothing to file.\n`);
|
|
210
|
+
if (!has('keep')) await removeWorktrees(repo, workDir);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const title = `test gap: ${r.subject.slice(0, 70)}`;
|
|
214
|
+
const body = issueBody({ ...r, stillOpen: r.stillOpen?.status?.toUpperCase() }, repo);
|
|
215
|
+
if (!has('apply')) {
|
|
216
|
+
// Default is a DRY RUN. Filing an issue is outward-facing and irreversible enough
|
|
217
|
+
// that it should never be the thing that happens when someone tries the command.
|
|
218
|
+
console.log(`\n DRY RUN — would file:\n\n title: ${title}\n`);
|
|
219
|
+
console.log(body.split('\n').map(l => ' | ' + l).join('\n'));
|
|
220
|
+
console.log(`\n Re-run with --apply to file it.\n`);
|
|
221
|
+
} else {
|
|
222
|
+
const { execFile } = await import('node:child_process');
|
|
223
|
+
const { promisify } = await import('node:util');
|
|
224
|
+
const { stdout } = await promisify(execFile)('gh',
|
|
225
|
+
['issue', 'create', '--repo', repo, '--title', title, '--body', body, '--label', 'test-gap'],
|
|
226
|
+
{ cwd: repo });
|
|
227
|
+
console.log(` filed: ${stdout.trim()}`);
|
|
228
|
+
}
|
|
229
|
+
if (!has('keep')) await removeWorktrees(repo, workDir);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const table = { doctor, verify, audit, issues };
|
|
233
|
+
if (!table[cmd]) {
|
|
234
|
+
console.log(`
|
|
235
|
+
ca — does a test actually fail on the code it was written to catch?
|
|
236
|
+
|
|
237
|
+
ca doctor can this repo be measured?
|
|
238
|
+
ca verify <commit> [--runs 3] one commit, per-case verdicts
|
|
239
|
+
ca audit --n 100 [--since '6 months'] [--grep '^fix'] [--seed 1] [--out r.csv]
|
|
240
|
+
|
|
241
|
+
common: --repo <path> --timeout <ms> --keep (leave worktrees for inspection)
|
|
242
|
+
`);
|
|
243
|
+
process.exit(table[cmd] ? 0 : 2);
|
|
244
|
+
}
|
|
245
|
+
table[cmd]().catch(e => { console.error(e); process.exit(2); });
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixture repos with KNOWN answers — this tool's own control arm.
|
|
3
|
+
*
|
|
4
|
+
* The tool's whole claim is "a test that cannot fail is decoration". A tool that asserts
|
|
5
|
+
* that about other people's tests, while its own suite only checks that it doesn't crash,
|
|
6
|
+
* is the same defect one level up. So: tiny git repos where the right verdict is known by
|
|
7
|
+
* construction, and `test/fixtures.test.mjs` asserts the tool returns it.
|
|
8
|
+
*
|
|
9
|
+
* If `ca` cannot tell 02-blind-direction from 01-caught-value, it does not ship.
|
|
10
|
+
*
|
|
11
|
+
* THE BUG THEY ALL SHARE. `priority()` maps a label to a number. The broken version joins
|
|
12
|
+
* its words with `\s*`, which matches whitespace and nothing else, so it reads
|
|
13
|
+
* "HIGH PRIORITY" but not "HIGH-PRIORITY" — one separator, a different answer. The fixed
|
|
14
|
+
* version accepts any run of real separators.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately a boring, universal domain: every issue tracker has priority labels, and
|
|
17
|
+
* the fixtures should not require knowing anybody's product to read. Only the TEST differs
|
|
18
|
+
* between fixtures; the bug is identical in all of them, so a verdict can only come from
|
|
19
|
+
* the test's quality.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '.build');
|
|
28
|
+
|
|
29
|
+
const BROKEN = `export function priority(label) {
|
|
30
|
+
const s = String(label).toLowerCase();
|
|
31
|
+
if (/high\\s*priority/.test(s)) return 1; // \\s* matches whitespace and nothing else
|
|
32
|
+
if (/low\\s*priority/.test(s)) return 3;
|
|
33
|
+
return 2;
|
|
34
|
+
}
|
|
35
|
+
export const SLA_HOURS = { 1: 4, 2: 24, 3: 72 };
|
|
36
|
+
`;
|
|
37
|
+
const FIXED = BROKEN
|
|
38
|
+
.replace('/high\\s*priority/', '/high[-_\\s.]*priority/')
|
|
39
|
+
.replace('/low\\s*priority/', '/low[-_\\s.]*priority/');
|
|
40
|
+
|
|
41
|
+
const FIXTURES = {
|
|
42
|
+
'01-caught-value': {
|
|
43
|
+
expect: 'CAUGHT',
|
|
44
|
+
why: 'asserts the exact priority for the hyphenated spelling',
|
|
45
|
+
test: `import { test } from 'node:test';
|
|
46
|
+
import assert from 'node:assert/strict';
|
|
47
|
+
import { priority, SLA_HOURS } from '../src/priority.mjs';
|
|
48
|
+
test('a hyphenated HIGH-PRIORITY label is priority 1, four-hour SLA', () => {
|
|
49
|
+
assert.equal(priority('HIGH-PRIORITY'), 1);
|
|
50
|
+
assert.equal(SLA_HOURS[priority('HIGH-PRIORITY')], 4);
|
|
51
|
+
});`,
|
|
52
|
+
},
|
|
53
|
+
'02-blind-direction': {
|
|
54
|
+
expect: 'BLIND',
|
|
55
|
+
why: 'asserts a DIRECTION (>0) that the wrong answer also satisfies',
|
|
56
|
+
test: `import { test } from 'node:test';
|
|
57
|
+
import assert from 'node:assert/strict';
|
|
58
|
+
import { priority, SLA_HOURS } from '../src/priority.mjs';
|
|
59
|
+
test('hyphenated labels are handled', () => {
|
|
60
|
+
const p = priority('HIGH-PRIORITY');
|
|
61
|
+
assert.ok(p, 'a priority comes back');
|
|
62
|
+
assert.ok(SLA_HOURS[p] > 0, 'it has an SLA');
|
|
63
|
+
assert.notEqual(p, undefined);
|
|
64
|
+
});`,
|
|
65
|
+
},
|
|
66
|
+
'03-blind-sourcetext': {
|
|
67
|
+
expect: 'BLIND',
|
|
68
|
+
why: 'greps the source instead of executing it',
|
|
69
|
+
test: `import { test } from 'node:test';
|
|
70
|
+
import assert from 'node:assert/strict';
|
|
71
|
+
import { readFileSync } from 'node:fs';
|
|
72
|
+
test('the separator class is tolerant', () => {
|
|
73
|
+
const src = readFileSync(new URL('../src/priority.mjs', import.meta.url), 'utf8');
|
|
74
|
+
assert.ok(src.includes('priority'), 'the rule mentions priority');
|
|
75
|
+
assert.ok(/high/.test(src));
|
|
76
|
+
});`,
|
|
77
|
+
},
|
|
78
|
+
'04-blind-overmock': {
|
|
79
|
+
expect: 'BLIND',
|
|
80
|
+
why: 'mocks the unit under test, so the real function never runs',
|
|
81
|
+
test: `import { test } from 'node:test';
|
|
82
|
+
import assert from 'node:assert/strict';
|
|
83
|
+
import { SLA_HOURS } from '../src/priority.mjs';
|
|
84
|
+
const priority = () => 1; // "stubbed for speed"
|
|
85
|
+
test('a hyphenated HIGH-PRIORITY label is priority 1', () => {
|
|
86
|
+
assert.equal(priority('HIGH-PRIORITY'), 1);
|
|
87
|
+
assert.equal(SLA_HOURS[1], 4);
|
|
88
|
+
});`,
|
|
89
|
+
},
|
|
90
|
+
'05-inconclusive-newexport': {
|
|
91
|
+
expect: 'INCONCLUSIVE',
|
|
92
|
+
why: 'imports a symbol the fix added — cannot even load at the parent',
|
|
93
|
+
fixedExtra: `export const SEPARATORS = /[-_\\s.]*/;\n`,
|
|
94
|
+
test: `import { test } from 'node:test';
|
|
95
|
+
import assert from 'node:assert/strict';
|
|
96
|
+
import { SEPARATORS } from '../src/priority.mjs';
|
|
97
|
+
test('the separator class is shared', () => {
|
|
98
|
+
assert.equal(SEPARATORS.source, '[-_\\\\s.]*');
|
|
99
|
+
});`,
|
|
100
|
+
},
|
|
101
|
+
'06-inconclusive-armA-red': {
|
|
102
|
+
expect: 'INCONCLUSIVE',
|
|
103
|
+
why: 'the case is not green on the fix either — the commit does not stand up',
|
|
104
|
+
test: `import { test } from 'node:test';
|
|
105
|
+
import assert from 'node:assert/strict';
|
|
106
|
+
import { priority } from '../src/priority.mjs';
|
|
107
|
+
test('a hyphenated HIGH-PRIORITY label is priority 1', () => {
|
|
108
|
+
assert.equal(priority('HIGH-PRIORITY'), 99);
|
|
109
|
+
});`,
|
|
110
|
+
},
|
|
111
|
+
'07-caught-mixed': {
|
|
112
|
+
expect: 'CAUGHT',
|
|
113
|
+
why: 'one discriminating case plus two regression guards green on both arms',
|
|
114
|
+
test: `import { test } from 'node:test';
|
|
115
|
+
import assert from 'node:assert/strict';
|
|
116
|
+
import { priority } from '../src/priority.mjs';
|
|
117
|
+
test('DISCRIMINATES: the hyphenated spelling is priority 1', () => {
|
|
118
|
+
assert.equal(priority('HIGH-PRIORITY'), 1);
|
|
119
|
+
});
|
|
120
|
+
test('GUARD: the spaced spelling is still priority 1', () => {
|
|
121
|
+
assert.equal(priority('HIGH PRIORITY'), 1);
|
|
122
|
+
});
|
|
123
|
+
test('GUARD: an unlabelled ticket is still the default priority 2', () => {
|
|
124
|
+
assert.equal(priority('needs triage'), 2);
|
|
125
|
+
});`,
|
|
126
|
+
},
|
|
127
|
+
'09-caught-slash-in-name': {
|
|
128
|
+
expect: 'CAUGHT',
|
|
129
|
+
why: 'the discriminating case has a SLASH in its name — it used to be silently dropped',
|
|
130
|
+
test: `import { test } from 'node:test';
|
|
131
|
+
import assert from 'node:assert/strict';
|
|
132
|
+
import { priority } from '../src/priority.mjs';
|
|
133
|
+
test('label parsing / separator handling', () => {
|
|
134
|
+
assert.equal(priority('HIGH-PRIORITY'), 1);
|
|
135
|
+
});`,
|
|
136
|
+
},
|
|
137
|
+
'08-skipped-notest': {
|
|
138
|
+
expect: 'SKIPPED',
|
|
139
|
+
why: 'the fix shipped no test at all',
|
|
140
|
+
test: null,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
function sh(cwd, args) { execFileSync('git', args, { cwd, stdio: 'pipe' }); }
|
|
145
|
+
|
|
146
|
+
export function buildFixtures() {
|
|
147
|
+
rmSync(ROOT, { recursive: true, force: true });
|
|
148
|
+
mkdirSync(ROOT, { recursive: true });
|
|
149
|
+
const built = {};
|
|
150
|
+
|
|
151
|
+
for (const [name, spec] of Object.entries(FIXTURES)) {
|
|
152
|
+
const dir = path.join(ROOT, name);
|
|
153
|
+
mkdirSync(path.join(dir, 'src'), { recursive: true });
|
|
154
|
+
mkdirSync(path.join(dir, 'tests'), { recursive: true });
|
|
155
|
+
sh(dir, ['init', '-q']);
|
|
156
|
+
sh(dir, ['config', 'user.email', 'ca@fixture']);
|
|
157
|
+
sh(dir, ['config', 'user.name', 'ca fixture']);
|
|
158
|
+
writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, type: 'module', private: true }, null, 2));
|
|
159
|
+
|
|
160
|
+
// --- parent: the bug, and whatever tests existed before (none) ---
|
|
161
|
+
writeFileSync(path.join(dir, 'src/priority.mjs'), BROKEN);
|
|
162
|
+
sh(dir, ['add', '-A']); sh(dir, ['commit', '-qm', 'feat: priority labels']);
|
|
163
|
+
|
|
164
|
+
// --- fix: source repaired, test added ---
|
|
165
|
+
writeFileSync(path.join(dir, 'src/priority.mjs'), FIXED + (spec.fixedExtra || ''));
|
|
166
|
+
if (spec.test) writeFileSync(path.join(dir, 'tests/priority.test.mjs'), spec.test + '\n');
|
|
167
|
+
sh(dir, ['add', '-A']); sh(dir, ['commit', '-qm', 'fix: a hyphen made a HIGH-PRIORITY ticket read as normal']);
|
|
168
|
+
|
|
169
|
+
built[name] = { dir, sha: execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD']).toString().trim(), ...spec };
|
|
170
|
+
}
|
|
171
|
+
return built;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
175
|
+
const b = buildFixtures();
|
|
176
|
+
for (const [n, f] of Object.entries(b)) console.log(` ${f.expect.padEnd(13)} ${n.padEnd(26)} ${f.why}`);
|
|
177
|
+
console.log(`\n ${Object.keys(b).length} fixtures in ${ROOT}\n`);
|
|
178
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "control-arm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Does a test actually fail on the code it was written to catch?",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "ca": "./bin/ca.mjs" },
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test test/*.test.mjs",
|
|
9
|
+
"fixtures": "node fixtures/build.mjs"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT"
|
|
12
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The few GitHub API calls these workflows need, over plain fetch.
|
|
4
|
+
*
|
|
5
|
+
* WHY NOT THE `gh` CLI. It is not installed on every runner. Measured: a self-hosted
|
|
6
|
+
* bare-metal runner failed with `gh: command not found` (exit 127) after every other step
|
|
7
|
+
* had passed — the tool ran, reached the right verdict, and then could not say so.
|
|
8
|
+
* GitHub-hosted runners ship `gh`; a self-hosted one ships whatever was installed on it,
|
|
9
|
+
* and a workflow that assumes otherwise works until it lands on the wrong machine.
|
|
10
|
+
*
|
|
11
|
+
* Node is already a hard requirement here — the tool is written in it — so this adds no
|
|
12
|
+
* dependency at all. Usage:
|
|
13
|
+
*
|
|
14
|
+
* gh-api.mjs upsert-pr-comment <repo> <pr> <body-file> <marker>
|
|
15
|
+
* gh-api.mjs upsert-issue <repo> <title> <body-file> <search> [label]
|
|
16
|
+
*
|
|
17
|
+
* Reads GITHUB_TOKEN / GH_TOKEN from the environment. Prints what it did, and exits
|
|
18
|
+
* non-zero only when the API refuses — never merely because there was nothing to do.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
22
|
+
const API = process.env.GITHUB_API_URL || 'https://api.github.com';
|
|
23
|
+
|
|
24
|
+
async function gh(path, init = {}) {
|
|
25
|
+
const res = await fetch(`${API}${path}`, {
|
|
26
|
+
...init,
|
|
27
|
+
headers: {
|
|
28
|
+
authorization: `Bearer ${TOKEN}`,
|
|
29
|
+
accept: 'application/vnd.github+json',
|
|
30
|
+
'content-type': 'application/json',
|
|
31
|
+
'x-github-api-version': '2022-11-28',
|
|
32
|
+
...(init.headers || {}),
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
throw new Error(`${init.method || 'GET'} ${path} → ${res.status} ${text.slice(0, 300)}`);
|
|
38
|
+
}
|
|
39
|
+
return res.status === 204 ? null : res.json();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
43
|
+
const read = async (f) => (await import('node:fs/promises')).readFile(f, 'utf8');
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
if (!TOKEN) throw new Error('no GITHUB_TOKEN / GH_TOKEN in the environment');
|
|
47
|
+
|
|
48
|
+
if (cmd === 'upsert-pr-comment') {
|
|
49
|
+
const [repo, pr, bodyFile, marker] = args;
|
|
50
|
+
const body = await read(bodyFile);
|
|
51
|
+
if (!body.trim()) { console.log('nothing to post'); process.exit(0); }
|
|
52
|
+
// ONE COMMENT PER PR, edited in place. A new comment per push turns a useful signal
|
|
53
|
+
// into noise by the third revision, and the marker is how we find ours again.
|
|
54
|
+
const comments = await gh(`/repos/${repo}/issues/${pr}/comments?per_page=100`);
|
|
55
|
+
const mine = comments.find(c => (c.body || '').startsWith(marker));
|
|
56
|
+
if (mine) {
|
|
57
|
+
await gh(`/repos/${repo}/issues/comments/${mine.id}`, { method: 'PATCH', body: JSON.stringify({ body }) });
|
|
58
|
+
console.log(`updated comment ${mine.id}`);
|
|
59
|
+
} else {
|
|
60
|
+
const made = await gh(`/repos/${repo}/issues/${pr}/comments`, { method: 'POST', body: JSON.stringify({ body }) });
|
|
61
|
+
console.log(`posted comment ${made.id}`);
|
|
62
|
+
}
|
|
63
|
+
} else if (cmd === 'upsert-issue') {
|
|
64
|
+
const [repo, title, bodyFile, search, label] = args;
|
|
65
|
+
const body = await read(bodyFile);
|
|
66
|
+
if (label) {
|
|
67
|
+
// Create the label if absent. `labels` on an issue with an unknown label is
|
|
68
|
+
// rejected outright, so this cannot be left to chance — but a failure here is
|
|
69
|
+
// a warning, not a reason to drop the finding on the floor.
|
|
70
|
+
try {
|
|
71
|
+
await gh(`/repos/${repo}/labels`, { method: 'POST', body: JSON.stringify({ name: label, color: '0E8A16', description: 'Test-gap findings from control-arm' }) });
|
|
72
|
+
} catch (e) { if (!/already_exists|422/.test(e.message)) console.log(`::warning::could not create label ${label}: ${e.message}`); }
|
|
73
|
+
}
|
|
74
|
+
const found = await gh(`/search/issues?q=${encodeURIComponent(`repo:${repo} is:issue is:open ${search}`)}`);
|
|
75
|
+
const hit = found.items?.[0];
|
|
76
|
+
if (hit) {
|
|
77
|
+
await gh(`/repos/${repo}/issues/${hit.number}`, { method: 'PATCH', body: JSON.stringify({ title, body }) });
|
|
78
|
+
console.log(`refreshed issue #${hit.number}`);
|
|
79
|
+
} else {
|
|
80
|
+
const made = await gh(`/repos/${repo}/issues`, { method: 'POST', body: JSON.stringify({ title, body, ...(label ? { labels: [label] } : {}) }) });
|
|
81
|
+
console.log(`filed issue #${made.number}`);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
console.error('usage: gh-api.mjs upsert-pr-comment|upsert-issue ...');
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
} catch (e) {
|
|
88
|
+
console.error(`::error::${e.message}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Segment the audit CSV by which runner the test file belongs to.
|
|
3
|
+
*
|
|
4
|
+
* `ca` ships one runner (node:test). A monorepo's fix commits touch vitest and jest test
|
|
5
|
+
* files too, and those come back INCONCLUSIVE for a reason that says nothing about the
|
|
6
|
+
* test — the tool simply cannot execute it. Reporting one blended ratio over both would
|
|
7
|
+
* be the same defect the tool exists to catch: a number fitted to a population it does
|
|
8
|
+
* not describe.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
|
|
12
|
+
const rows = [];
|
|
13
|
+
const raw = readFileSync(process.argv[2], 'utf8').split('\n').filter(Boolean);
|
|
14
|
+
const hdr = raw.shift().split(',');
|
|
15
|
+
for (const line of raw) {
|
|
16
|
+
// naive CSV with quoted fields
|
|
17
|
+
const f = []; let cur = '', q = false;
|
|
18
|
+
for (let i = 0; i < line.length; i++) {
|
|
19
|
+
const c = line[i];
|
|
20
|
+
if (q) { if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; } else if (c === '"') q = false; else cur += c; }
|
|
21
|
+
else if (c === '"') q = true;
|
|
22
|
+
else if (c === ',') { f.push(cur); cur = ''; }
|
|
23
|
+
else cur += c;
|
|
24
|
+
}
|
|
25
|
+
f.push(cur);
|
|
26
|
+
rows.push(Object.fromEntries(hdr.map((h, i) => [h, f[i]])));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const runnerOf = (file) => {
|
|
30
|
+
if (!file) return 'none';
|
|
31
|
+
if (file.startsWith('tests/')) return 'node:test (supported)';
|
|
32
|
+
if (file.startsWith('apps/web/')) return 'vitest (unsupported)';
|
|
33
|
+
if (file.startsWith('apps/mobile/')) return 'jest (unsupported)';
|
|
34
|
+
if (file.startsWith('apps/e2e/')) return 'playwright (unsupported)';
|
|
35
|
+
if (file.startsWith('packages/')) return 'node:test (supported)';
|
|
36
|
+
return 'other';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const commits = new Map();
|
|
40
|
+
for (const r of rows) {
|
|
41
|
+
if (!commits.has(r.sha)) commits.set(r.sha, { sha: r.sha, date: r.date, subject: r.subject, verdict: r.commit_verdict, cases: [] });
|
|
42
|
+
commits.get(r.sha).cases.push(r);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A commit belongs to the runner of its test files; mixed commits are called out.
|
|
46
|
+
const seg = new Map();
|
|
47
|
+
for (const c of commits.values()) {
|
|
48
|
+
const rs = [...new Set(c.cases.map(x => runnerOf(x.file)).filter(x => x !== 'none'))];
|
|
49
|
+
const key = rs.length === 0 ? 'no test file' : rs.length === 1 ? rs[0] : 'mixed';
|
|
50
|
+
if (!seg.has(key)) seg.set(key, []);
|
|
51
|
+
seg.get(key).push(c);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const V = ['CAUGHT', 'BLIND', 'FLAKY', 'INCONCLUSIVE', 'SKIPPED'];
|
|
55
|
+
console.log('\n COMMITS BY RUNNER SEGMENT\n');
|
|
56
|
+
console.log(' ' + 'segment'.padEnd(26) + V.map(v => v.slice(0, 6).padStart(7)).join('') + ' n');
|
|
57
|
+
for (const [k, list] of [...seg].sort((a, b) => b[1].length - a[1].length)) {
|
|
58
|
+
const t = V.map(v => String(list.filter(c => c.verdict === v).length).padStart(7)).join('');
|
|
59
|
+
console.log(' ' + k.padEnd(26) + t + String(list.length).padStart(5));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const supported = seg.get('node:test (supported)') || [];
|
|
63
|
+
const dec = supported.filter(c => c.verdict === 'CAUGHT' || c.verdict === 'BLIND');
|
|
64
|
+
console.log(`\n SUPPORTED SEGMENT ONLY (node:test)\n`);
|
|
65
|
+
console.log(` ${supported.length} commits · ${dec.length} the instrument could answer`);
|
|
66
|
+
if (dec.length) {
|
|
67
|
+
const caught = dec.filter(c => c.verdict === 'CAUGHT').length;
|
|
68
|
+
console.log(` CAUGHT ${caught}/${dec.length} = ${(caught / dec.length * 100).toFixed(1)}% BLIND ${dec.length - caught}/${dec.length} = ${((dec.length - caught) / dec.length * 100).toFixed(1)}%`);
|
|
69
|
+
}
|
|
70
|
+
const blind = supported.filter(c => c.verdict === 'BLIND');
|
|
71
|
+
if (blind.length) {
|
|
72
|
+
console.log(`\n BLIND COMMITS IN THE SUPPORTED SEGMENT (hand-audit these)\n`);
|
|
73
|
+
for (const c of blind) console.log(` ${c.sha.slice(0, 8)} ${c.date} ${c.subject.slice(0, 90)}`);
|
|
74
|
+
}
|
|
75
|
+
const why = new Map();
|
|
76
|
+
for (const c of supported.filter(c => c.verdict === 'INCONCLUSIVE')) {
|
|
77
|
+
for (const cs of c.cases.filter(x => x.case_verdict === 'INCONCLUSIVE')) {
|
|
78
|
+
const k = cs.reason.replace(/'[^']*'/g, "'…'").replace(/\d+/g, 'N').slice(0, 88);
|
|
79
|
+
why.set(k, (why.get(k) || 0) + 1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (why.size) {
|
|
83
|
+
console.log(`\n WHY INCONCLUSIVE, supported segment only\n`);
|
|
84
|
+
for (const [k, v] of [...why].sort((a, b) => b[1] - a[1]).slice(0, 14)) console.log(` ${String(v).padStart(4)} ${k}`);
|
|
85
|
+
}
|
|
86
|
+
console.log('');
|