stillhunter 0.0.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,357 @@
1
+ // finish: checks the hunter's claims, makes it account for every failed check
2
+ // and for what it never covered, re-runs each claimed probe twice from scratch
3
+ // and reports what reproduced.
4
+ import { copyFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { claimProblems, parseFinishInput, unaccounted } from './accounting.mjs';
7
+ import { atOrAbove, block, brief, flag, FOLDER, loadRun, minutesAndSeconds, readConfig, readStdin, remaining, severityOf } from './common.mjs';
8
+ import { coverage, coverageLines } from './coverage.mjs';
9
+ import { hiddenLine } from './flags.mjs';
10
+ import { KILL_GRACE_MS, PROBE_TIMEOUT_MS, runProbe, sessionPath } from './probe-host.mjs';
11
+ import { RESET_TIMEOUT_MS, resetAndSignIn, signedOut } from './reset.mjs';
12
+ import { allResults, latestResults, outcome, probesDir, signInProbe } from './results.mjs';
13
+ import { answers } from './target.mjs';
14
+ export const COVERAGE_REFUSAL_SECONDS = 180;
15
+ // The proof's own budget, and the most a finish call may take in all: the
16
+ // hunter's shell gives up on a command after 600 s and would then have no
17
+ // report at all. No claimed probe's proof starts after the budget; a proof
18
+ // started before it, retries and sign-ins included, is held to the wall limit.
19
+ export const PROOF_SECONDS = 420;
20
+ export const FINISH_WALL_SECONDS = 540;
21
+ // Lowers the wall limit, never raises it, so the tests can reach it in
22
+ // seconds. The hunter's shell runs only the runner and sets no variable.
23
+ export const WALL_ENV = 'STILLHUNTER_FINISH_WALL_SECONDS';
24
+ export const NOT_RERUN = 'Not re-run in time, not counted';
25
+ export const NOT_RERUNNABLE = 'Could not re-run, not counted';
26
+ const INTERMITTENT = 'Intermittent (1 of 2), not counted';
27
+ const RUN_MARGIN_MS = 10_000;
28
+ const MIN_RUN_MS = 15_000;
29
+ function readState(dir) {
30
+ try {
31
+ return { accountingRefused: false, coverageRefused: false, ...JSON.parse(readFileSync(path.join(dir, 'finish-state.json'), 'utf8')) };
32
+ }
33
+ catch {
34
+ return { accountingRefused: false, coverageRefused: false };
35
+ }
36
+ }
37
+ function refuse(ctx, lines) {
38
+ process.stdout.write(block([`stillhunter | finish refused | run ${ctx.runId}`, ...lines]));
39
+ process.exit(2);
40
+ }
41
+ // How long one proof run may be given so that, with the browser's start, the
42
+ // kill grace and teardown, it ends before the wall deadline; null when no
43
+ // useful run fits.
44
+ export function runTimeout(now, wallDeadline) {
45
+ const left = wallDeadline - now - KILL_GRACE_MS - RUN_MARGIN_MS;
46
+ return left < MIN_RUN_MS ? null : Math.min(PROBE_TIMEOUT_MS, left);
47
+ }
48
+ export function wallSeconds(env = process.env) {
49
+ const value = env[WALL_ENV];
50
+ const n = Number(value);
51
+ return value !== undefined && /^\d+$/.test(value) && n >= 1 && n < FINISH_WALL_SECONDS ? n : FINISH_WALL_SECONDS;
52
+ }
53
+ // A file written whole or not at all: a finish stopped while writing leaves
54
+ // the previous version, never a truncated one.
55
+ export function writeWhole(file, text, rename = renameSync) {
56
+ const temp = `${file}.${process.pid}.tmp`;
57
+ writeFileSync(temp, text);
58
+ // On Windows a rename onto a file another process has open (a reader, or a
59
+ // virus scanner looking at it) fails for a moment with EPERM, EACCES or
60
+ // EBUSY, so it is tried again for up to about 2 s.
61
+ for (let wait = 10;; wait *= 2) {
62
+ try {
63
+ return rename(temp, file);
64
+ }
65
+ catch (e) {
66
+ const code = e.code;
67
+ if (wait > 1000 || !['EPERM', 'EACCES', 'EBUSY'].includes(code ?? ''))
68
+ throw e;
69
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, wait);
70
+ }
71
+ }
72
+ }
73
+ // Claimed probes in the order they are proved: by the most severe finding
74
+ // each one carries, then in the order they were claimed.
75
+ export function proofOrder(findings) {
76
+ const rank = new Map();
77
+ for (const f of findings)
78
+ rank.set(f.probe, Math.min(rank.get(f.probe) ?? 3, ORDER[severityOf(f.severity)]));
79
+ return [...rank.keys()].sort((a, b) => rank.get(a) - rank.get(b));
80
+ }
81
+ // One re-run from scratch, or null when the wall deadline leaves no room for
82
+ // it (or cut it short), so the probe counts as not re-run in time.
83
+ async function proofRun(p, probe, label) {
84
+ const proofDir = path.join(p.dir, 'proof');
85
+ const host = (name, timeoutMs) => runProbe({
86
+ runDir: p.dir, probe: name, probeFile: path.join(probesDir(p.dir), `${name}.mjs`), target: p.ctx.target, browser: p.ctx.browser,
87
+ signInVariables: p.ctx.signInVariables, shotsDir: path.join(proofDir, 'shots'), timeoutMs,
88
+ });
89
+ const save = (r, file) => { writeFileSync(path.join(proofDir, file), JSON.stringify(r, null, 2) + '\n'); return r; };
90
+ // App data and, with it, any session go back to the start, and the probe
91
+ // that signed in before the claimed probe's hunt run signs in again (none,
92
+ // if none did then), so the claimed probe starts as its hunt run did. A
93
+ // hunt run with no record of that falls back to the run's sign-in probe.
94
+ let before = null;
95
+ if (p.config.reset) {
96
+ const resetMs = Math.min(RESET_TIMEOUT_MS, p.wallDeadline - Date.now() - KILL_GRACE_MS - RUN_MARGIN_MS - MIN_RUN_MS);
97
+ if (resetMs <= 0)
98
+ return null;
99
+ const huntStart = p.latest.get(probe)?.before;
100
+ before = await resetAndSignIn({
101
+ root: p.root, config: p.config, probe, signIn: huntStart ? (huntStart.signIn?.probe ?? null) : p.signIn, sessionFile: sessionPath(p.dir), resetTimeoutMs: resetMs,
102
+ signInTimeoutMs: () => runTimeout(Date.now(), p.wallDeadline),
103
+ runSignIn: async (name, t) => save(await host(name, t), `${probe}.${label}.signin.result.json`),
104
+ });
105
+ if (!before)
106
+ return null;
107
+ const s = before.signIn;
108
+ if (before.resetError)
109
+ p.notes.push(`before ${probe} ${label}: ${before.resetError}`);
110
+ if (s?.error)
111
+ p.notes.push(`before ${probe} ${label}: ${s.savedSession ? `the sign-in probe "${s.probe}" saved a session, then failed` : `signing in again with "${s.probe}" failed`}: ${s.error}`);
112
+ }
113
+ const t = runTimeout(Date.now(), p.wallDeadline);
114
+ if (t === null)
115
+ return null;
116
+ const r = await host(probe, t);
117
+ if (before)
118
+ r.before = before;
119
+ save(r, `${probe}.${label}.result.json`);
120
+ // A run the deadline shortened and that then timed out says nothing about the bug.
121
+ if (r.hostError === 'timeout' && t < PROBE_TIMEOUT_MS)
122
+ return null;
123
+ return r;
124
+ }
125
+ async function prove(p, probe) {
126
+ const runs = [];
127
+ for (const k of [1, 2]) {
128
+ let r = await proofRun(p, probe, String(k));
129
+ if (!r)
130
+ return null;
131
+ if (r.hostError) {
132
+ if ((await answers(p.ctx.target)) === null) {
133
+ p.notes.push(`${probe} re-run ${k}: ${r.error}; the app no longer answers at ${p.ctx.target}, so it was not retried`);
134
+ }
135
+ else {
136
+ p.notes.push(`${probe} re-run ${k}: ${r.error}; retried once`);
137
+ r = await proofRun(p, probe, `${k}.retry`);
138
+ if (!r)
139
+ return null;
140
+ }
141
+ }
142
+ runs.push(r);
143
+ }
144
+ return runs;
145
+ }
146
+ // Confirmed when the check failed in both re-runs, intermittent when in one.
147
+ // Otherwise it did not reproduce only if the check ran and held both times. A
148
+ // re-run says nothing about the bug when it never got to the check (the probe
149
+ // threw or timed out first, or the check is not in its result), or when it
150
+ // did not start as the hunt run did (its reset or its sign-in failed, and the
151
+ // hunt run's had not): then the claim could not be re-run.
152
+ export function judge(f, runs, hunt) {
153
+ const severity = severityOf(f.severity);
154
+ const reads = runs.map((r, i) => readRun(r, i + 1, f.check, hunt));
155
+ const failed = reads.flatMap((o) => (o.state === 'failed' ? [o.detail] : []));
156
+ if (failed.length === runs.length)
157
+ return { kind: 'confirmed', f: { ...f, severity, runs: failed } };
158
+ const why = reads.map((o, i) => (o.state === 'none' ? o.why : `${o.state} on re-run ${i + 1}`)).join('; ');
159
+ const kind = failed.length ? 'intermittent' : reads.every((o) => o.state === 'held') ? 'dropped' : 'notRerunnable';
160
+ return { kind, f: { ...f, severity, why } };
161
+ }
162
+ function readRun(r, k, check, hunt) {
163
+ if (r.before?.resetError && !hunt?.before?.resetError)
164
+ return { state: 'none', why: `re-run ${k} does not count: the reset before it failed, as it had not before the hunt run` };
165
+ if (signedOut(r.before) && !signedOut(hunt?.before))
166
+ return { state: 'none', why: `re-run ${k} does not count: it ran signed out, as the sign-in after the reset failed, which it had not before the hunt run` };
167
+ const o = outcome(r, check);
168
+ return o.state === 'missing' ? { state: 'none', why: notReached(r, k) } : o;
169
+ }
170
+ function notReached(r, k) {
171
+ const setup = [
172
+ ...(r.before?.resetError ? ['the reset before it failed'] : []),
173
+ ...(signedOut(r.before) ? ['it ran signed out: the sign-in after the reset failed'] : []),
174
+ ];
175
+ return `re-run ${k} never reached the check${setup.length ? ` (${setup.join('; ')})` : ''}: ${r.error ? brief(r.error) : 'the check is not in its result'}`;
176
+ }
177
+ // Said first among the claims that could not be re-run: when the sign-in
178
+ // after a reset failed with no session saved, the re-runs it came before ran
179
+ // signed out. Only the re-runs of the probes behind the listed claims count,
180
+ // so a list cut by severity, or a claim found signed out and re-run so, says
181
+ // nothing about it.
182
+ export function signInLine(byProbe, probes = byProbe.keys()) {
183
+ const runs = [...new Set(probes)].flatMap((p) => byProbe.get(p) ?? []).filter((r) => r.before);
184
+ const failed = runs.flatMap((r) => (signedOut(r.before) ? [r.before.signIn] : []));
185
+ const first = failed[0];
186
+ if (first) {
187
+ return failed.length === runs.length
188
+ ? `After reset, the sign-in probe "${first.probe}" failed: ${brief(first.error)}. Each claimed probe then ran signed out.`
189
+ : `After reset, the sign-in probe "${first.probe}" failed before ${failed.length} of ${runs.length} re-runs: ${brief(first.error)}. Those re-runs ran signed out.`;
190
+ }
191
+ return null;
192
+ }
193
+ const ORDER = { high: 0, medium: 1, low: 2 };
194
+ // --proof-seconds lowers the proof budget, never raises it.
195
+ export function proofSeconds(args) {
196
+ const value = flag(args, '--proof-seconds');
197
+ if (value === null)
198
+ return args.includes('--proof-seconds') ? null : PROOF_SECONDS;
199
+ const n = Number(value);
200
+ return /^\d+$/.test(value) && n >= 1 && n <= PROOF_SECONDS ? n : null;
201
+ }
202
+ export async function finish(args) {
203
+ const startedAt = Date.now();
204
+ const { ctx, dir, root } = loadRun(args);
205
+ const budget = proofSeconds(args);
206
+ if (budget === null)
207
+ refuse(ctx, [`--proof-seconds is a whole number from 1 to ${PROOF_SECONDS}.`]);
208
+ const parsed = parseFinishInput(readStdin());
209
+ if (!parsed.ok)
210
+ refuse(ctx, [parsed.message, 'Fix it and call finish again.']);
211
+ const input = parsed.input;
212
+ const latest = latestResults(dir);
213
+ const problems = claimProblems(input, latest);
214
+ if (problems.length)
215
+ refuse(ctx, [...problems.map((p) => `- ${p}`), 'Fix these and call finish again. Nothing was re-run.']);
216
+ const state = readState(dir);
217
+ const missing = unaccounted(input, latest);
218
+ const signIn = signInProbe(dir);
219
+ const cov = coverage(allResults(dir), ctx.target);
220
+ const left = remaining(ctx);
221
+ const refusal = [];
222
+ if (missing.length && !state.accountingRefused) {
223
+ state.accountingRefused = true;
224
+ refusal.push(`${missing.length} failed check${missing.length === 1 ? ' is' : 's are'} in neither findings nor dismissed:`, ...missing.map((m) => `- ${m.probe} / ${m.check}: ${m.detail}`), 'Claim each as a finding, or dismiss it with {"probe", "check", "reason"}: probe-wrong, duplicate (with "duplicateOf": the finding\'s title), intended, not-this-change or resilience-gap.');
225
+ }
226
+ if (left > COVERAGE_REFUSAL_SECONDS && (cov.neverFaulted.length || cov.neverNarrow.length) && !state.coverageRefused) {
227
+ state.coverageRefused = true;
228
+ refusal.push(`${left} s of the budget remain and the coverage has gaps:`, ...coverageLines(cov).slice(1), `Probe what belongs to ${ctx.mode === 'feature' ? 'this feature' : 'this change'}. What is outside it can stay: calling finish again accepts.`);
229
+ }
230
+ if (refusal.length) {
231
+ writeFileSync(path.join(dir, 'finish-state.json'), JSON.stringify(state, null, 2) + '\n');
232
+ refuse(ctx, [...refusal, 'This is said once; the next finish accepts and records what is still open.']);
233
+ }
234
+ writeFileSync(path.join(dir, 'leads.json'), JSON.stringify(input.leads, null, 2) + '\n');
235
+ const config = readConfig(root);
236
+ mkdirSync(path.join(dir, 'proof'), { recursive: true });
237
+ const proofDeadline = startedAt + budget * 1000;
238
+ const prover = { dir, root, ctx, config, latest, signIn, notes: [], wallDeadline: startedAt + wallSeconds() * 1000 };
239
+ const order = proofOrder(input.findings);
240
+ const byProbe = new Map();
241
+ const settle = (complete) => writeReport({ ctx, dir, input, missing, cov, latest, byProbe, notes: prover.notes, startedAt, budget, wall: wallSeconds(), order, complete });
242
+ // Most severe first, and the report is written before the proof and again
243
+ // after every proved probe, so a finish that is stopped part way still
244
+ // leaves its findings.
245
+ settle(false);
246
+ for (const probe of order) {
247
+ if (Date.now() >= proofDeadline)
248
+ break;
249
+ // Snapshot the exact script being proved, so keep saves the probe that was
250
+ // confirmed even if it is later re-run under the same name.
251
+ copyFileSync(path.join(probesDir(dir), `${probe}.mjs`), path.join(dir, 'proof', `${probe}.mjs`));
252
+ const runs = await prove(prover, probe);
253
+ if (runs)
254
+ byProbe.set(probe, runs);
255
+ settle(false);
256
+ }
257
+ process.stdout.write(block(settle(true)));
258
+ }
259
+ // Writes findings.json and report.txt from the probes proved so far and
260
+ // returns the printed report. A claim whose probe has no proof yet is listed
261
+ // as not re-run in time and not counted.
262
+ function writeReport(r) {
263
+ const { ctx, dir, input, missing, cov } = r;
264
+ const confirmed = [];
265
+ const intermittent = [];
266
+ const dropped = [];
267
+ const notRerunnable = [];
268
+ const notRerun = [];
269
+ for (const f of input.findings) {
270
+ const runs = r.byProbe.get(f.probe);
271
+ if (!runs) {
272
+ notRerun.push({ ...f, severity: severityOf(f.severity), why: 'no time to re-run it before the proof budget or the wall limit ran out' });
273
+ continue;
274
+ }
275
+ const j = judge(f, runs, r.latest.get(f.probe));
276
+ if (j.kind === 'confirmed')
277
+ confirmed.push(j.f);
278
+ else if (j.kind === 'intermittent')
279
+ intermittent.push(j.f);
280
+ else if (j.kind === 'notRerunnable')
281
+ notRerunnable.push(j.f);
282
+ else
283
+ dropped.push(j.f);
284
+ }
285
+ const bySeverity = (a, b) => ORDER[a.severity] - ORDER[b.severity];
286
+ confirmed.sort(bySeverity);
287
+ notRerunnable.sort(bySeverity);
288
+ notRerun.sort(bySeverity);
289
+ const shown = confirmed.filter((f) => atOrAbove(f.severity, ctx.minSeverity));
290
+ const shownIntermittent = intermittent.filter((f) => atOrAbove(f.severity, ctx.minSeverity));
291
+ const shownNotRerunnable = notRerunnable.filter((f) => atOrAbove(f.severity, ctx.minSeverity));
292
+ const shownNotRerun = notRerun.filter((f) => atOrAbove(f.severity, ctx.minSeverity));
293
+ const erred = [...r.latest].filter(([, x]) => x.error).map(([name, x]) => `${name}: ${x.error}`);
294
+ const finishSeconds = Math.round((Date.now() - r.startedAt) / 1000);
295
+ const proof = { budgetSeconds: r.budget, wallLimitSeconds: r.wall, finishSeconds, complete: r.complete, order: r.order, proved: [...r.byProbe.keys()] };
296
+ writeWhole(path.join(dir, 'findings.json'), JSON.stringify({ confirmed, intermittent, dropped, notRerunnable, notRerun, dismissed: input.dismissed, unaccounted: missing, coverage: cov, notes: r.notes, proof }, null, 2) + '\n');
297
+ const reportPath = `${FOLDER}/runs/${ctx.runId}/report.txt`;
298
+ const elapsed = Math.round((Date.now() - ctx.startedAt) / 1000);
299
+ const header = [
300
+ `stillhunter | ${minutesAndSeconds(elapsed)} | run ${ctx.runId} | ${confirmed.length} confirmed, ${intermittent.length} intermittent, ${dropped.length} did not reproduce${notRerunnable.length ? `, ${notRerunnable.length} could not re-run` : ''}${notRerun.length ? `, ${notRerun.length} not re-run in time` : ''}`,
301
+ `target: ${ctx.target} | asked: ${ctx.focus ?? 'the change'}${ctx.minSeverity === 'low' ? '' : ` | showing ${ctx.minSeverity} severity and above`}`,
302
+ ];
303
+ const findingLines = (list) => list.flatMap((f, i) => [
304
+ `${i + 1}. ${f.severity.toUpperCase().padEnd(6)} ${f.title}`,
305
+ ...(f.where ? [` where: ${f.where}`] : []),
306
+ ...(f.expected ? [` expected: ${f.expected}`] : []),
307
+ ` saw: ${f.runs[0] ?? f.actual ?? ''}`,
308
+ ` repro: ${FOLDER}/runs/${ctx.runId}/probes/${f.probe}.mjs (expectation "${f.check}")`,
309
+ ]);
310
+ const listBlock = (title, list, why = false) => (list.length
311
+ ? ['', `${title}:`, ...list.map((f) => `- ${f.title} (probe ${f.probe}, expectation "${f.check}")${why ? `: ${f.why}` : ''}`)]
312
+ : []);
313
+ // The sign-in's failure comes first, as it is the likely reason for the rest.
314
+ const rerunBlock = (list) => {
315
+ if (!list.length)
316
+ return [];
317
+ const signInTrouble = signInLine(r.byProbe, list.map((f) => f.probe));
318
+ return [
319
+ '', `${NOT_RERUNNABLE}:`,
320
+ ...(signInTrouble ? [signInTrouble] : []),
321
+ ...list.map((f) => `- ${f.title} (probe ${f.probe}, expectation "${f.check}"): ${f.why}`),
322
+ ];
323
+ };
324
+ const coverageSection = ['', 'Coverage', ...coverageLines(cov)];
325
+ // Findings below the floor, of every kind, are hidden from the printed
326
+ // report; report.txt lists all of them.
327
+ const hidden = hiddenLine((confirmed.length - shown.length) + (intermittent.length - shownIntermittent.length) + (notRerunnable.length - shownNotRerunnable.length) + (notRerun.length - shownNotRerun.length), ctx.minSeverity, reportPath);
328
+ const printed = [
329
+ ...header,
330
+ ...(shown.length ? ['', 'Findings (each reproduced twice from scratch)', ...findingLines(shown)] : ['', confirmed.length ? 'No finding at this severity reproduced.' : 'No finding reproduced.']),
331
+ ...(hidden ? [hidden] : []),
332
+ ...listBlock(INTERMITTENT, shownIntermittent, true),
333
+ ...rerunBlock(shownNotRerunnable),
334
+ ...listBlock(NOT_RERUN, shownNotRerun),
335
+ ...coverageSection,
336
+ ];
337
+ const full = [
338
+ ...header,
339
+ ...(confirmed.length ? ['', 'Findings (each reproduced twice from scratch)', ...findingLines(confirmed)] : ['', 'No finding reproduced.']),
340
+ ...listBlock(INTERMITTENT, intermittent, true),
341
+ ...rerunBlock(notRerunnable),
342
+ ...listBlock(NOT_RERUN, notRerun),
343
+ ...(dropped.length ? ['', 'Did not reproduce:', ...dropped.map((f) => `- ${f.title}: ${f.why}`)] : []),
344
+ ...(input.dismissed.length ? ['', 'Dismissed:', ...input.dismissed.map(dismissalLine)] : []),
345
+ ...(missing.length ? ['', 'Unaccounted (failed, neither claimed nor dismissed):', ...missing.map(unaccountedLine)] : []),
346
+ ...(erred.length ? ['', 'Probes whose latest run ended in an error:', ...erred.map((e) => `- ${e}`)] : []),
347
+ ...(r.notes.length ? ['', 'Proof notes:', ...r.notes.map((n) => `- ${n}`)] : []),
348
+ ...coverageSection,
349
+ '',
350
+ `Proof: ${r.byProbe.size} of ${r.order.length} claimed probes re-run in ${finishSeconds} s (budget ${r.budget} s)${r.complete ? '' : '; finish had not ended when this was written'}`,
351
+ `Leads (not tested to proof): ${input.leads.length}, in ${FOLDER}/runs/${ctx.runId}/leads.json`,
352
+ ];
353
+ writeWhole(path.join(dir, 'report.txt'), full.join('\n') + '\n');
354
+ return printed;
355
+ }
356
+ const dismissalLine = (d) => `- ${d.probe} / ${d.check}: ${d.reason}${d.duplicateOf ? ` of "${d.duplicateOf}"` : ''}${d.why ? `: ${d.why}` : ''}`;
357
+ const unaccountedLine = (u) => `- ${u.probe} / ${u.check}: ${u.detail}`;
package/dist/flags.mjs ADDED
@@ -0,0 +1,49 @@
1
+ // The flags `start` takes. The hunter turns what the person asked for into
2
+ // these; the runner then holds the hunt to them.
3
+ import { SEVERITIES } from './common.mjs';
4
+ export const DEPTHS = { quick: 4, default: 10, deep: 25 };
5
+ const MAX_MINUTES = 120;
6
+ export function parseStartFlags(args) {
7
+ let depth = 'default';
8
+ let minutes = null;
9
+ let minSeverity = 'low';
10
+ let focus = null;
11
+ for (let i = 0; i < args.length; i++) {
12
+ const name = args[i];
13
+ const value = args[i + 1];
14
+ if (name !== '--depth' && name !== '--minutes' && name !== '--min-severity' && name !== '--focus') {
15
+ return { ok: false, message: `Unknown start option ${name}. start takes --depth, --minutes, --min-severity and --focus.` };
16
+ }
17
+ if (value === undefined)
18
+ return { ok: false, message: `${name} needs a value.` };
19
+ i += 1;
20
+ if (name === '--depth') {
21
+ if (!(value in DEPTHS))
22
+ return { ok: false, message: `--depth is quick, default or deep, not "${value}".` };
23
+ depth = value;
24
+ }
25
+ else if (name === '--minutes') {
26
+ const n = Number(value);
27
+ if (!/^\d+$/.test(value) || n < 1 || n > MAX_MINUTES)
28
+ return { ok: false, message: `--minutes is a whole number from 1 to ${MAX_MINUTES}, not "${value}".` };
29
+ minutes = n;
30
+ }
31
+ else if (name === '--min-severity') {
32
+ if (!SEVERITIES.includes(value))
33
+ return { ok: false, message: `--min-severity is high, medium or low, not "${value}".` };
34
+ minSeverity = value;
35
+ }
36
+ else {
37
+ const text = value.trim().replace(/\s+/g, ' ');
38
+ focus = text === '' ? null : text.slice(0, 500);
39
+ }
40
+ }
41
+ return { ok: true, flags: { depth, minutes: minutes ?? DEPTHS[depth], minSeverity, focus } };
42
+ }
43
+ // The line that ends a report when the severity floor hid some findings.
44
+ export function hiddenLine(hidden, minSeverity, reportPath) {
45
+ if (hidden === 0)
46
+ return null;
47
+ const below = SEVERITIES.slice(SEVERITIES.indexOf(minSeverity) + 1).join('/');
48
+ return `${hidden} ${below} finding${hidden === 1 ? '' : 's'} hidden: ${reportPath}`;
49
+ }
package/dist/hunt.mjs ADDED
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ // Stillhunter's runner: the model reads the change, forms hypotheses and tests
3
+ // them with probe scripts; the runner keeps the scripts, runs them in a
4
+ // contained child process against a loopback-only browser and, at the end,
5
+ // re-runs every claimed finding from scratch so only reproducible ones are
6
+ // reported. Findings worth keeping become tests that replay with no model.
7
+ // stillhunter start [--depth quick|default|deep] [--minutes N] [--min-severity high|medium|low] [--focus "<text>"]
8
+ // stillhunter probe --run <id> --name <name> (script on stdin)
9
+ // stillhunter finish --run <id> [--proof-seconds N] (findings JSON on stdin)
10
+ // stillhunter diff [--file <path>] (the change, or one file's whole diff)
11
+ // stillhunter keep --run <id> --probe <name> --check <check> [--as <name>]
12
+ // stillhunter replay [names] [--watch]
13
+ import { randomBytes } from 'node:crypto';
14
+ import { mkdirSync, writeFileSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { chooseBrowser, installedOnThisMachine } from './browser.mjs';
17
+ import { baseRef, changeList, changeText, collectChange, ensureRunsIgnored, fileDiff, readSpecs } from './change.mjs';
18
+ import { block, brief, fail, FOLDER, flag, loadRun, PROBE_NAME, readConfig, readStdin, remaining, repoRoot, runsDir, signInVariableNames, stamp, findTargetUrl } from './common.mjs';
19
+ import { finish } from './finish.mjs';
20
+ import { parseStartFlags } from './flags.mjs';
21
+ import { keep, replay } from './keep.mjs';
22
+ import { runProbe, sessionPath } from './probe-host.mjs';
23
+ import { resetAndSignIn, resetApp, seconds } from './reset.mjs';
24
+ import { latestFile, nextRunNumber, numberedFile, probesDir, signInProbe } from './results.mjs';
25
+ import { answers } from './target.mjs';
26
+ const NO_CHANGE = 'No change found. Name a feature to hunt, for example: /stillhunter:hunt the checkout flow';
27
+ async function start(args) {
28
+ const root = repoRoot();
29
+ const parsed = parseStartFlags(args);
30
+ if (!parsed.ok)
31
+ fail(parsed.message);
32
+ const { depth, minutes, minSeverity, focus } = parsed.flags;
33
+ const base = baseRef(root);
34
+ const change = collectChange(root, base);
35
+ if (change.empty && !focus)
36
+ fail(NO_CHANGE);
37
+ const config = readConfig(root);
38
+ const target = await findTargetUrl(root, config);
39
+ if (!target)
40
+ fail(`No running app found. Start your dev server, or set "target": { "url": ... } in ${FOLDER}/config.json.`);
41
+ if ((await answers(target)) === null)
42
+ fail(`Nothing answers at ${target}. Start your dev server first.`);
43
+ const choice = chooseBrowser(config.browser ?? null, installedOnThisMachine());
44
+ if (!choice.ok)
45
+ fail(choice.message);
46
+ // Every probe and every proof re-run starts from the reset, so a reset that
47
+ // does not work must stop the hunt here, not surface at finish. Its time
48
+ // counts against the budget like any other.
49
+ const now = Date.now();
50
+ const resetError = await resetApp(root, config);
51
+ if (resetError)
52
+ fail(`The app's reset failed, so the hunt did not start: ${resetError}\nFix the reset ("reset" in ${FOLDER}/config.json, run in the repository root), then start again.`);
53
+ const resetSeconds = seconds(Date.now() - now);
54
+ const runId = `${stamp()}-hunt-${randomBytes(3).toString('hex').slice(0, 4)}`;
55
+ const dir = path.join(runsDir(root), runId);
56
+ mkdirSync(probesDir(dir), { recursive: true });
57
+ ensureRunsIgnored(path.join(root, FOLDER, '.gitignore'));
58
+ const mode = change.empty ? 'feature' : 'change';
59
+ const signInVariables = signInVariableNames();
60
+ const ctx = {
61
+ runId, root, target, browser: choice.name, base, startedAt: now, deadline: now + minutes * 60_000,
62
+ depth, budgetMinutes: minutes, minSeverity, focus, mode, signInVariables,
63
+ };
64
+ writeFileSync(path.join(dir, 'context.json'), JSON.stringify(ctx, null, 2) + '\n');
65
+ const specs = readSpecs(root);
66
+ const lines = [
67
+ `stillhunter | run ${runId}`,
68
+ `target: ${target} | browser: ${choice.name} | budget: ${minutes} min (depth ${depth}) | report: ${minSeverity === 'low' ? 'every severity' : `${minSeverity} severity and above`}`,
69
+ `focus: ${focus ?? 'none (the whole change)'}`,
70
+ mode === 'feature'
71
+ ? `mode: feature. There is no change against the base, so this hunt tests "${focus}" as it stands.`
72
+ : `mode: change. diff base: ${base.slice(0, 12)} (one file's whole diff: diff --file <file>)`,
73
+ `sign-in variables set in the environment (names only): ${signInVariables.length ? signInVariables.join(', ') : 'none'}`,
74
+ ...(config.reset ? [`reset: app data is reset before every probe and every re-run, back to what the app holds now, and the sign-in probe then signs in again; the reset took ${resetSeconds} s`] : []),
75
+ 'changed files:',
76
+ ...(change.files.length ? change.files.map((l) => ` ${l}`) : [' none']),
77
+ `specs: ${specs.length ? specs.map((s) => s.file).join(', ') : 'none'}`,
78
+ ...specs.flatMap((s) => ['', `=== spec: ${s.file} ===`, s.text.replace(/\n$/, '')]),
79
+ '',
80
+ `=== the change: git diff ${base.slice(0, 12)} of tracked files, then every untracked file ===`,
81
+ ...changeText(change),
82
+ ];
83
+ process.stdout.write(block(lines));
84
+ }
85
+ // Plain words for what went wrong before the probe ran, said first in what
86
+ // the probe prints: without them a probe that ran signed out, or on data the
87
+ // reset did not put back, looks like the app's fault.
88
+ function beforeProblems(before) {
89
+ if (!before)
90
+ return [];
91
+ const s = before.signIn;
92
+ return [
93
+ ...(before.resetError ? [`Before this probe, the ${before.resetError}. App data may not be in its reset state.`] : []),
94
+ ...(s?.error && s.savedSession ? [`After the reset, the sign-in probe "${s.probe}" saved a session, then failed: ${brief(s.error)}. This probe ran with that session. Keep the sign-in probe to signing in: it runs before every probe.`] : []),
95
+ ...(s?.error && !s.savedSession ? [`After the reset, the sign-in probe "${s.probe}" failed: ${brief(s.error)}. This probe ran signed out.`] : []),
96
+ ];
97
+ }
98
+ async function probe(args) {
99
+ const { ctx, dir, root } = loadRun(args);
100
+ const name = flag(args, '--name');
101
+ if (!name || !PROBE_NAME.test(name))
102
+ fail('Give the probe a --name of lowercase letters, digits and dashes.');
103
+ if (remaining(ctx) === 0) {
104
+ process.stdout.write(block([`stillhunter | probe refused | run ${ctx.runId}`, 'The budget is spent. Call finish now, accounting for every failed check.']));
105
+ process.exit(2);
106
+ }
107
+ const script = readStdin();
108
+ if (!/export\s+default/.test(script))
109
+ fail('The probe script on stdin needs `export default async function (rr) { ... }`.');
110
+ const probeFile = path.join(probesDir(dir), `${name}.mjs`);
111
+ writeFileSync(probeFile, script);
112
+ const n = nextRunNumber(dir, name);
113
+ const run = (probeName, timeoutMs) => runProbe({
114
+ runDir: dir, probe: probeName, probeFile: path.join(probesDir(dir), `${probeName}.mjs`), target: ctx.target, browser: ctx.browser,
115
+ signInVariables: ctx.signInVariables, shotsDir: path.join(dir, 'shots'), timeoutMs,
116
+ });
117
+ // The same start the proof gives each re-run: app data reset, then signed
118
+ // in again by the run's sign-in probe, so what a probe finds is what finish
119
+ // can prove. The probe runs whatever went wrong, and says so.
120
+ const config = readConfig(root);
121
+ const signIn = signInProbe(dir);
122
+ const before = config.reset
123
+ ? await resetAndSignIn({
124
+ root, config, probe: name, signIn, sessionFile: sessionPath(dir),
125
+ runSignIn: async (s, timeoutMs) => {
126
+ const r = await run(s, timeoutMs);
127
+ writeFileSync(path.join(probesDir(dir), `${name}.${n}.signin.result.json`), JSON.stringify(r, null, 2) + '\n');
128
+ return r;
129
+ },
130
+ })
131
+ : null;
132
+ const problems = beforeProblems(before);
133
+ const result = await run(name);
134
+ result.run = n;
135
+ if (before)
136
+ result.before = before;
137
+ const json = JSON.stringify(result, null, 2) + '\n';
138
+ writeFileSync(numberedFile(dir, name, n), json);
139
+ writeFileSync(latestFile(dir, name), json);
140
+ const shown = {
141
+ ...(problems.length ? { warning: problems.join(' ') } : {}),
142
+ probe: name,
143
+ run: n,
144
+ remainingSeconds: remaining(ctx),
145
+ ...(before ? { before } : {}),
146
+ resultFile: numberedFile(dir, name, n),
147
+ checks: result.checks,
148
+ notes: result.notes,
149
+ error: result.error,
150
+ shots: result.shots,
151
+ blocked: result.blocked,
152
+ refused: result.refused ?? [],
153
+ requests: { total: result.requests.length, last: result.requests.slice(-30).map(({ method, url, status, fault, via }) => ({ method, url, status, fault, via })) },
154
+ pages: result.pages,
155
+ final: result.final,
156
+ };
157
+ process.stdout.write(`${JSON.stringify(shown, null, 1)}\n`);
158
+ }
159
+ // The change, read without a shell: the list of changed files, or one file's
160
+ // whole diff against the base. The hunter's only Bash is this runner, so this
161
+ // is how it reads what start had to leave out.
162
+ function diff(args) {
163
+ const root = repoRoot();
164
+ const file = flag(args, '--file');
165
+ if (args.length !== 0 && !(args.length === 2 && file))
166
+ fail('Usage: diff [--file <path>]');
167
+ const base = baseRef(root);
168
+ if (file === null) {
169
+ const change = collectChange(root, base);
170
+ process.stdout.write(block([`stillhunter | diff | base ${base.slice(0, 12)} | changed and untracked files, with the size of each file's diff`, ...changeList(change)]));
171
+ return;
172
+ }
173
+ const r = fileDiff(root, base, file);
174
+ if (!r.ok)
175
+ fail(r.message);
176
+ process.stdout.write(block([`stillhunter | diff | base ${base.slice(0, 12)} | ${file}`, r.text.replace(/\n$/, '') || '(no difference)']));
177
+ }
178
+ const USAGE = 'Usage: stillhunter start [--depth quick|default|deep] [--minutes N] [--min-severity high|medium|low] [--focus "<text>"] | probe --run <id> --name <name> | finish --run <id> [--proof-seconds N] | diff [--file <path>] | keep --run <id> --probe <name> --check <check> [--as <name>] | replay [names] [--watch]';
179
+ const [sub, ...rest] = process.argv.slice(2);
180
+ if (sub === 'start')
181
+ await start(rest);
182
+ else if (sub === 'probe')
183
+ await probe(rest);
184
+ else if (sub === 'finish')
185
+ await finish(rest);
186
+ else if (sub === 'diff')
187
+ diff(rest);
188
+ else if (sub === 'keep')
189
+ keep(rest);
190
+ else if (sub === 'replay')
191
+ await replay(rest);
192
+ else
193
+ fail(USAGE);