staysfixed 0.7.2 → 0.9.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 +429 -0
- package/README.md +193 -57
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +19 -6
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +564 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/status.js +45 -1
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +54 -3
- package/src/core/paths.js +15 -0
- package/src/guard/run.js +70 -3
- package/src/report/console.js +50 -6
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +18 -11
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +783 -71
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +134 -24
- package/src/v2/adapters/web.js +149 -18
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +66 -3
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +653 -69
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +65 -42
- package/src/v2/cluster.js +220 -14
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +353 -54
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +183 -66
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +90 -16
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +160 -24
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +55 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- package/src/v2/watch/panel.js +167 -17
package/src/v2/check.js
CHANGED
|
@@ -36,13 +36,13 @@ import { warn, detail } from '../core/log.js';
|
|
|
36
36
|
import { findConfigFile, rootForConfig } from '../core/paths.js';
|
|
37
37
|
import { sha256 } from '../core/hash.js';
|
|
38
38
|
|
|
39
|
-
import { openStore, ensureStore, saveBuild, newCaptureId, storeExists } from './store.js';
|
|
39
|
+
import { openStore, ensureStore, saveBuild, newCaptureId, storeExists, referenceFor, listBuilds, pruneBuild, removeBuild, sweepIncomplete } from './store.js';
|
|
40
40
|
import { decide, noDecisions, readDecisions, rememberCheck, readCheckRecord } from './escalate.js';
|
|
41
41
|
import { sortObservations } from './observation.js';
|
|
42
42
|
import { DEFAULT_RULES, machineRules, mergeRules, normaliseCapture, loadRules } from './normalise.js';
|
|
43
43
|
import { runCheck, makeCheckEvents } from './run.js';
|
|
44
44
|
import { proveCause } from './cause.js';
|
|
45
|
-
import { whatChanged } from './rank.js';
|
|
45
|
+
import { whatChanged, NOT_THE_TOOLS_OWN_FOLDER } from './rank.js';
|
|
46
46
|
|
|
47
47
|
import { attachWatcher, watchOptionsFrom } from './watch/index.js';
|
|
48
48
|
import { guardTheScreen, describeGuard } from './watch/focus.js';
|
|
@@ -95,7 +95,7 @@ const exec = promisify(execFile);
|
|
|
95
95
|
* quietly checked something else and found nothing", and those two read identically
|
|
96
96
|
* without this. It is only ever set when the run really did reach that surface.
|
|
97
97
|
*
|
|
98
|
-
* @typedef {Verdict & {blocked?: boolean, accounted?: import('./escalate.js').Accounting, target?: {surface: string, at: string|null}}} CheckOutcome
|
|
98
|
+
* @typedef {Verdict & {blocked?: boolean, comparedNothing?: 'no reference'|'no stored record'|null, accounted?: import('./escalate.js').Accounting, target?: {surface: string, at: string|null}}} CheckOutcome
|
|
99
99
|
*/
|
|
100
100
|
|
|
101
101
|
/**
|
|
@@ -109,7 +109,8 @@ const exec = promisify(execFile);
|
|
|
109
109
|
* @property {string} [against] A commit, tag or stored build to compare against.
|
|
110
110
|
* @property {boolean} [paired] Boot the old build live from the start.
|
|
111
111
|
* @property {boolean} [storedOnly] Never boot the old build, not even to prove a suspicion.
|
|
112
|
-
* @property {string} [journeys] A path to a journeys file, or 'code' / 'config'.
|
|
112
|
+
* @property {string} [journeys] A path to a journeys file, or 'code' / 'config' / 'suite'.
|
|
113
|
+
* 'suite' RUNS the project's own tests — see gatherJourneys.
|
|
113
114
|
* @property {Surface|'auto'} [surface] Aim the whole run at one kind of product.
|
|
114
115
|
* @property {string} [at] Where that product is: a URL for the web, the built app
|
|
115
116
|
* for a desktop, an APK or an .app bundle for a phone.
|
|
@@ -314,8 +315,31 @@ export async function check(options = {}) {
|
|
|
314
315
|
// "nothing was walked" on a run that walked plenty.
|
|
315
316
|
await countTheDoors(verdict, project);
|
|
316
317
|
|
|
318
|
+
// THE GUARDS. `settle` takes the names so that a difference touching a bug somebody
|
|
319
|
+
// already had once is sealed and no agent may wave it through — and both call sites used
|
|
320
|
+
// to hand it nothing, so that class was empty on every run this tool has ever done. The
|
|
321
|
+
// one seal that exists because a person was burned before could never fire.
|
|
322
|
+
//
|
|
323
|
+
// Running them is version 1's job and needs its browser driver, which this path does not
|
|
324
|
+
// have. That is a real hole and it is now SAID rather than left silent: measured on
|
|
325
|
+
// 2026-08-30, a check on a project with a guard sitting in `.staysfixed/guards` printed
|
|
326
|
+
// the word "guard" exactly zero times. A tool built to catch silent breakage must not do
|
|
327
|
+
// nothing silently.
|
|
328
|
+
const named = await guardNames(project.root);
|
|
329
|
+
if (named.length > 0 && verdict.coverage) {
|
|
330
|
+
verdict.coverage.gaps = [
|
|
331
|
+
...(verdict.coverage.gaps ?? []),
|
|
332
|
+
{
|
|
333
|
+
what: `${named.length} guard${named.length === 1 ? '' : 's'} written against bugs that already happened once`,
|
|
334
|
+
why:
|
|
335
|
+
`They are sealed by name, so nothing touching one can be waved through quietly — but they were not RUN on this check. ` +
|
|
336
|
+
`\`staysfixed check --guards\` walks them. ${named.map((n) => `"${n}"`).join(', ')}`,
|
|
337
|
+
},
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
|
|
317
341
|
/** @type {CheckOutcome} */
|
|
318
|
-
const outcome = await settle(verdict, project.store, project.product);
|
|
342
|
+
const outcome = await settle(verdict, project.store, project.product, named);
|
|
319
343
|
// Only a run that really did reach the surface it was aimed at may say so. The
|
|
320
344
|
// confirmation is what lets a caller tell "it went there and found nothing" from
|
|
321
345
|
// "it checked something else and found nothing", and those are not the same answer.
|
|
@@ -326,6 +350,12 @@ export async function check(options = {}) {
|
|
|
326
350
|
// the coverage sentence went on the end. Left there, a window would show a greener,
|
|
327
351
|
// shorter answer than the terminal beside it, and the two would disagree about the same
|
|
328
352
|
// run. So it is told again, with the settled one, and only then put away.
|
|
353
|
+
// Housekeeping, after the answer is settled and never before it. It can only ever remove
|
|
354
|
+
// recordings of builds nothing is comparing against, and if it says anything at all it
|
|
355
|
+
// says it on the same summary as everything else.
|
|
356
|
+
const tidied = await tidyTheStore(project, outcome);
|
|
357
|
+
if (tidied) outcome.summary = `${outcome.summary} ${tidied}`;
|
|
358
|
+
|
|
329
359
|
events.emit({ type: 'check:done', at: events.elapsed(), message: outcome.summary, verdict: outcome });
|
|
330
360
|
if (screen) {
|
|
331
361
|
await screen.finish();
|
|
@@ -333,7 +363,11 @@ export async function check(options = {}) {
|
|
|
333
363
|
}
|
|
334
364
|
return outcome;
|
|
335
365
|
} catch (e) {
|
|
336
|
-
|
|
366
|
+
// A store that would not take this run's records is very often the whole reason the
|
|
367
|
+
// run then failed — a reference named by a commit cannot be resolved if nothing could
|
|
368
|
+
// register it — and without this the person is handed a bare permission error from a
|
|
369
|
+
// folder they have never heard of, with nothing joining the two facts up.
|
|
370
|
+
const outcome = blocked(options, e, project?.storeTrouble);
|
|
337
371
|
// A run that never happened still has to reach a person, because "no answer" looks
|
|
338
372
|
// exactly like "nothing changed" from the outside. It is only written down where a
|
|
339
373
|
// store already exists: a check aimed at a folder that was never set up must not leave
|
|
@@ -391,12 +425,18 @@ export async function check(options = {}) {
|
|
|
391
425
|
async function settle(verdict, store, product, guards) {
|
|
392
426
|
/** @type {import('./escalate.js').Decisions} */
|
|
393
427
|
let decisions;
|
|
428
|
+
/** @type {string} */
|
|
429
|
+
let bookkeepingTrouble = '';
|
|
394
430
|
try {
|
|
395
431
|
decisions = await readDecisions(store, product);
|
|
396
|
-
} catch {
|
|
432
|
+
} catch (e) {
|
|
397
433
|
// Unreadable bookkeeping means nothing is accounted for, which reports MORE than it
|
|
398
|
-
// should rather than less. That is the only safe direction for this to fail in
|
|
434
|
+
// should rather than less. That is the only safe direction for this to fail in — and it
|
|
435
|
+
// is still a fact the reader needs. Without it the accounting says nothing was waived,
|
|
436
|
+
// which reads as "there were no waivers" rather than "nobody could read them", and an
|
|
437
|
+
// agent looking at a wall of findings it waived last week has no idea why they are back.
|
|
399
438
|
decisions = noDecisions(product);
|
|
439
|
+
bookkeepingTrouble = `The record of what was already accounted for could not be read (${messageOf(e)}), so nothing was treated as accounted for. Anything you waived before is in this list again.`;
|
|
400
440
|
}
|
|
401
441
|
|
|
402
442
|
const decided = decide(verdict.findings ?? [], decisions, { guards: guards ?? [] });
|
|
@@ -414,9 +454,21 @@ async function settle(verdict, store, product, guards) {
|
|
|
414
454
|
// that worked has changed". It is arithmetically true — nothing was compared, so
|
|
415
455
|
// nothing came back different — and it is the exact sentence that would let a real
|
|
416
456
|
// regression through. It is not a pass. It is no answer at all.
|
|
417
|
-
|
|
457
|
+
const nothing = comparedNothing(verdict);
|
|
458
|
+
// Carried as a FIELD, not only folded into a sentence. Every other surface has to be
|
|
459
|
+
// able to ask this question without reading English: the MCP server rendered its own
|
|
460
|
+
// headline from the difference count alone, never looked at `ok`, and answered a
|
|
461
|
+
// machine "NOTHING UNACCOUNTED FOR. Everything that worked before still works" with
|
|
462
|
+
// `ok: true` and `isError: false` on a project that had nothing on record at all —
|
|
463
|
+
// while the terminal, on the same run, correctly said it was no answer. The agent is
|
|
464
|
+
// the reader that matters most here, and it was the one being told the untrue thing.
|
|
465
|
+
verdict.comparedNothing = nothing;
|
|
466
|
+
if (nothing) {
|
|
418
467
|
verdict.ok = false;
|
|
419
|
-
verdict.summary =
|
|
468
|
+
verdict.summary =
|
|
469
|
+
nothing === 'no reference'
|
|
470
|
+
? `NOTHING WAS ACTUALLY COMPARED. There is no build of this product on record as working, so this run had nothing whatever to hold today's behaviour against. This is not a pass and not a failure — it is no answer. ${verdict.summary}`
|
|
471
|
+
: `NOTHING WAS ACTUALLY COMPARED. Every journey was walked on the build you have, and not one of them had anything on record from the build you were happy with, so there was nothing to hold them against. This is not a pass and not a failure — it is no answer. ${verdict.summary}`;
|
|
420
472
|
}
|
|
421
473
|
|
|
422
474
|
// And what was NOT looked at, in the same breath as the good news, on every run
|
|
@@ -426,11 +478,18 @@ async function settle(verdict, store, product, guards) {
|
|
|
426
478
|
// already reads. It goes last so it is the thing left in the reader's head.
|
|
427
479
|
verdict.summary = `${verdict.summary} ${whatWasNotChecked(verdict.coverage)}`;
|
|
428
480
|
}
|
|
481
|
+
if (bookkeepingTrouble) verdict.summary = `${verdict.summary} ${bookkeepingTrouble}`;
|
|
429
482
|
|
|
430
483
|
try {
|
|
431
484
|
await rememberCheck(store, { product, verdict, decided });
|
|
432
|
-
} catch {
|
|
433
|
-
// Nothing here is worth failing a finished check over.
|
|
485
|
+
} catch (e) {
|
|
486
|
+
// Nothing here is worth failing a finished check over — but it is worth a sentence.
|
|
487
|
+
// This record is the only thing that knows what the finding ids in this reply mean, so
|
|
488
|
+
// when it is not written the agent's very next move, `staysfixed_explain <id>` or
|
|
489
|
+
// `staysfixed_prove <id>`, answers "the last check has no finding called that". Which
|
|
490
|
+
// is a flat denial of something it was handed thirty seconds earlier, and it used to
|
|
491
|
+
// arrive with nothing anywhere explaining why.
|
|
492
|
+
verdict.summary = `${verdict.summary} This run could not be written down (${messageOf(e)}), so asking to explain or to prove one of the ids above will say it has never heard of it.`;
|
|
434
493
|
}
|
|
435
494
|
return verdict;
|
|
436
495
|
}
|
|
@@ -567,25 +626,123 @@ export function whatWasNotChecked(coverage) {
|
|
|
567
626
|
/**
|
|
568
627
|
* Was there anything on the other side to compare against at all?
|
|
569
628
|
*
|
|
570
|
-
*
|
|
571
|
-
* reaches every journey that was walked, the run compared nothing whatever — and a run
|
|
572
|
-
* that compared nothing produces zero differences, which is indistinguishable from a
|
|
573
|
-
* product that did not change.
|
|
629
|
+
* TWO WAYS TO COMPARE NOTHING, and only one of them was caught here.
|
|
574
630
|
*
|
|
575
|
-
* The
|
|
576
|
-
*
|
|
577
|
-
*
|
|
631
|
+
* The first is per journey: the engine records one gap for each journey it had no stored
|
|
632
|
+
* record for, and when that count reaches every journey walked, the run held nothing against
|
|
633
|
+
* anything. The gaps are recognised by the words the engine writes into them, and a test
|
|
634
|
+
* walks a real project into exactly this state, so a rewording fails loudly rather than
|
|
635
|
+
* quietly switching the guard off.
|
|
578
636
|
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
637
|
+
* The second is the COLD START, and it never records a per-journey gap at all, so this used
|
|
638
|
+
* to miss it completely. A product nobody has ever shipped with the hook in place has no
|
|
639
|
+
* reference: there is no old build, so there is no journey to be missing a record FOR. The
|
|
640
|
+
* run walked everything, compared none of it, found no differences, and set `ok: true`.
|
|
641
|
+
*
|
|
642
|
+
* The command line has always caught that one on its own and exits 2. `--json` and the MCP
|
|
643
|
+
* surface do not read the command line's arithmetic — they read this verdict — so the two
|
|
644
|
+
* interfaces an agent actually uses answered `ok: true` to "did I break anything" on a
|
|
645
|
+
* project where nothing had been compared. That is the exact failure this whole tool exists
|
|
646
|
+
* to prevent, produced by the tool, to a reader with no way of noticing.
|
|
647
|
+
*
|
|
648
|
+
* @param {CheckOutcome} verdict
|
|
649
|
+
* @returns {'no reference'|'no stored record'|null}
|
|
581
650
|
*/
|
|
582
|
-
function comparedNothing(
|
|
651
|
+
function comparedNothing(verdict) {
|
|
652
|
+
if (!verdict.reference || verdict.reference.id === '') return 'no reference';
|
|
653
|
+
const coverage = verdict.coverage;
|
|
583
654
|
const walked = coverage?.journeys ?? 0;
|
|
584
|
-
if (walked === 0) return
|
|
655
|
+
if (walked === 0) return null;
|
|
585
656
|
const nothingToCompare = (coverage?.gaps ?? []).filter((gap) =>
|
|
586
657
|
/never been walked against|no stored record of the old build/i.test(`${gap.what} ${gap.why}`),
|
|
587
658
|
).length;
|
|
588
|
-
return nothingToCompare >= walked;
|
|
659
|
+
return nothingToCompare >= walked ? 'no stored record' : null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* The guards this project has, by name.
|
|
664
|
+
*
|
|
665
|
+
* Names only: sealing a difference by the guard it touches needs the name, and nothing on
|
|
666
|
+
* this path can run one. `loadGuards` wants only the folder, and it is the same reader
|
|
667
|
+
* version 1 uses, so a guard version 1 accepts is a guard this counts.
|
|
668
|
+
*
|
|
669
|
+
* @param {string} root
|
|
670
|
+
* @returns {Promise<string[]>}
|
|
671
|
+
*/
|
|
672
|
+
export async function guardNames(root) {
|
|
673
|
+
try {
|
|
674
|
+
const { loadGuards } = await import('../guard/load.js');
|
|
675
|
+
const guards = await loadGuards(/** @type {any} */ ({ paths: { guards: path.join(root, '.staysfixed', 'guards') } }));
|
|
676
|
+
return guards.map((g) => String(g?.name ?? '')).filter(Boolean);
|
|
677
|
+
} catch {
|
|
678
|
+
// A guards folder that will not load must never stop a check running. Version 1 says the
|
|
679
|
+
// same about the same folder, and a check that refuses is worse than one without a seal.
|
|
680
|
+
return [];
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Copies left behind by runs that never finished.
|
|
686
|
+
*
|
|
687
|
+
* A check copies the whole project into a scratch folder, and a run that is killed — Ctrl-C,
|
|
688
|
+
* a laptop closing, a CI job cancelled — never gets to delete it. Nothing else ever did
|
|
689
|
+
* either: measured on 2026-08-30, an ordinary machine had 777 MB of `staysfixed-check-*`
|
|
690
|
+
* sitting in the temporary folder, one copy of it 485 MB, and a later run added to the pile
|
|
691
|
+
* rather than clearing it. A tool that quietly fills somebody's disk is not one they keep.
|
|
692
|
+
*
|
|
693
|
+
* The rule is the one the browser sweep already uses: never touch something in use. A folder
|
|
694
|
+
* whose owner is still running is left completely alone, and one with no owner recorded is
|
|
695
|
+
* only taken once it is far older than any real run could be. A dead process id that has
|
|
696
|
+
* since been reused reads as "still running" and the folder survives, which is the safe way
|
|
697
|
+
* round to be wrong.
|
|
698
|
+
*
|
|
699
|
+
* @returns {Promise<void>}
|
|
700
|
+
*/
|
|
701
|
+
export async function sweepAbandonedScratch() {
|
|
702
|
+
const AN_HOUR = 60 * 60 * 1000;
|
|
703
|
+
const MOST_PER_RUN = 20;
|
|
704
|
+
let names = [];
|
|
705
|
+
try {
|
|
706
|
+
names = await fsp.readdir(os.tmpdir());
|
|
707
|
+
} catch {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
let taken = 0;
|
|
711
|
+
for (const name of names) {
|
|
712
|
+
if (taken >= MOST_PER_RUN) break;
|
|
713
|
+
if (!name.startsWith('staysfixed-check-')) continue;
|
|
714
|
+
const dir = path.join(os.tmpdir(), name);
|
|
715
|
+
let abandoned = false;
|
|
716
|
+
try {
|
|
717
|
+
const owner = JSON.parse(await fsp.readFile(path.join(dir, 'owner.json'), 'utf8'));
|
|
718
|
+
abandoned = typeof owner?.pid !== 'number' || !processAlive(owner.pid);
|
|
719
|
+
} catch {
|
|
720
|
+
// No owner recorded: either an older copy or one that died before it could say. Age is
|
|
721
|
+
// all there is to go on, and an hour is well past the longest run this tool makes.
|
|
722
|
+
try {
|
|
723
|
+
abandoned = Date.now() - (await fsp.stat(dir)).mtimeMs > AN_HOUR;
|
|
724
|
+
} catch {
|
|
725
|
+
abandoned = false;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (!abandoned) continue;
|
|
729
|
+
await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
730
|
+
taken += 1;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Is that process still running? Signal 0 asks without sending anything.
|
|
736
|
+
* @param {number} pid
|
|
737
|
+
* @returns {boolean}
|
|
738
|
+
*/
|
|
739
|
+
function processAlive(pid) {
|
|
740
|
+
try {
|
|
741
|
+
process.kill(pid, 0);
|
|
742
|
+
return true;
|
|
743
|
+
} catch {
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
589
746
|
}
|
|
590
747
|
|
|
591
748
|
/**
|
|
@@ -603,9 +760,12 @@ function plainly(what) {
|
|
|
603
760
|
*
|
|
604
761
|
* @param {CheckOptions} options
|
|
605
762
|
* @param {unknown} e
|
|
763
|
+
* @param {string} [storeTrouble] What the store would not do earlier in this same run,
|
|
764
|
+
* when there was any. It goes in front of the error, because it usually IS the error's
|
|
765
|
+
* cause and the error on its own reads as something else entirely.
|
|
606
766
|
* @returns {CheckOutcome}
|
|
607
767
|
*/
|
|
608
|
-
function blocked(options, e) {
|
|
768
|
+
function blocked(options, e, storeTrouble) {
|
|
609
769
|
const product = options.product ?? path.basename(path.resolve(options.cwd ?? options.root ?? process.cwd()));
|
|
610
770
|
const empty = { id: '', product };
|
|
611
771
|
return {
|
|
@@ -621,11 +781,16 @@ function blocked(options, e) {
|
|
|
621
781
|
differencesReal: 0,
|
|
622
782
|
differencesNoise: 0,
|
|
623
783
|
newlyUnstable: [],
|
|
624
|
-
coverage: {
|
|
784
|
+
coverage: {
|
|
785
|
+
paths: 0,
|
|
786
|
+
journeys: 0,
|
|
787
|
+
byChannel: {},
|
|
788
|
+
gaps: [{ what: 'Everything.', why: storeTrouble ? `${storeTrouble} ${messageOf(e)}` : messageOf(e) }],
|
|
789
|
+
},
|
|
625
790
|
// The hint is the half that tells a person what to DO about it, and dropping it
|
|
626
791
|
// turns a helpful error into a dead end. Anything that blocks a run has to carry
|
|
627
792
|
// both halves all the way out to whoever reads the summary.
|
|
628
|
-
summary: `The check could not be run, so this is not a pass and not a failure. ${messageOf(e)}${
|
|
793
|
+
summary: `The check could not be run, so this is not a pass and not a failure. ${storeTrouble ? `${storeTrouble} ` : ''}${messageOf(e)}${
|
|
629
794
|
e instanceof Error && /** @type {any} */ (e).hint ? ` ${/** @type {any} */ (e).hint}` : ''
|
|
630
795
|
}`,
|
|
631
796
|
durationMs: 0,
|
|
@@ -662,7 +827,10 @@ export async function prove(options = {}) {
|
|
|
662
827
|
|
|
663
828
|
const project = await openProject(options);
|
|
664
829
|
try {
|
|
665
|
-
|
|
830
|
+
// From the commit the old build is at, not from the working tree. Without this, an agent
|
|
831
|
+
// that committed its work before asking to prove a cause was told there was no change to
|
|
832
|
+
// undo — about a change sitting one commit back, which git can hand over exactly.
|
|
833
|
+
const changed = await whatChanged(project.root, { since: project.referenceSha });
|
|
666
834
|
const wanted = (options.revert ?? []).map((f) => f.replace(/^\.\//, ''));
|
|
667
835
|
const narrowed = wanted.length
|
|
668
836
|
? { ...changed, hunks: changed.hunks.filter((h) => wanted.some((w) => h.file === w || h.file.startsWith(`${w}/`))) }
|
|
@@ -720,7 +888,14 @@ export async function explain(options = {}) {
|
|
|
720
888
|
else out.push(` ${d.path} — was ${short(d.reference)}, now ${short(d.candidate)}`);
|
|
721
889
|
}
|
|
722
890
|
if (differences.length > 40) out.push(` and ${differences.length - 40} more.`);
|
|
723
|
-
|
|
891
|
+
// The count goes on the end when the list was cut. Six file names with nothing after them
|
|
892
|
+
// read as the whole list, so an agent that opened all six believed it had seen everywhere
|
|
893
|
+
// this finding lives — and the file it needed was the seventh.
|
|
894
|
+
if (f.nearFiles?.length) {
|
|
895
|
+
const shown = f.nearFiles.slice(0, 6);
|
|
896
|
+
const more = f.nearFiles.length - shown.length;
|
|
897
|
+
out.push('', `Nearest code: ${shown.join(', ')}${more > 0 ? `, and ${more} more ${more === 1 ? 'file' : 'files'} this finding also comes from` : ''}.`);
|
|
898
|
+
}
|
|
724
899
|
// No full stop of our own: the reason is already a whole sentence and adding one gave the
|
|
725
900
|
// agent "...costs a real person real money.." on the reply it reads when it is trying to
|
|
726
901
|
// understand something it is not allowed to waive.
|
|
@@ -1009,11 +1184,20 @@ async function waitForItsWindow(pid, stopped) {
|
|
|
1009
1184
|
* @property {import('./types.js').Store} store
|
|
1010
1185
|
* @property {BuildFingerprint} candidate
|
|
1011
1186
|
* @property {string} [against] The reference build's own id, once a name has been resolved.
|
|
1187
|
+
* @property {number} keepBuilds How many builds of this product other than the reference keep
|
|
1188
|
+
* their full record. Everything older is thinned out at the end of a run.
|
|
1189
|
+
* @property {string} [referenceSha] The commit the build you were happy with is at. It is
|
|
1190
|
+
* what makes a COMMITTED change measurable: the change from that commit to the working
|
|
1191
|
+
* tree is the change, and reading the working tree alone goes blind the moment an agent
|
|
1192
|
+
* commits its work — which is what an agent does at the end of a task.
|
|
1012
1193
|
* @property {Journey[]} journeys
|
|
1013
1194
|
* @property {CoverageGap[]} gaps Holes found while working out WHAT to walk, before a
|
|
1014
1195
|
* single journey ran. An adapter that fell over listing its journeys belongs here, and it
|
|
1015
1196
|
* has to reach the verdict: a channel that silently dropped out is the worst thing this
|
|
1016
1197
|
* tool can do.
|
|
1198
|
+
* @property {string} storeTrouble Empty on a normal run. A plain sentence when the store
|
|
1199
|
+
* would not take this run's records — the run went ahead anyway, and every answer it
|
|
1200
|
+
* produces has to carry the admission that nothing about it was kept.
|
|
1017
1201
|
* @property {import('./run.js').Walker} walk
|
|
1018
1202
|
* @property {(reference: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} bootReference
|
|
1019
1203
|
* @property {(capture: Capture) => Capture} normalise
|
|
@@ -1035,6 +1219,254 @@ function projectRootFor(options) {
|
|
|
1035
1219
|
return config ? rootForConfig(config) : from;
|
|
1036
1220
|
}
|
|
1037
1221
|
|
|
1222
|
+
/**
|
|
1223
|
+
* How many builds keep their whole record before the old ones are thinned out.
|
|
1224
|
+
*
|
|
1225
|
+
* A setting rather than a constant, because a project running fifty checks a day and one
|
|
1226
|
+
* running three a week want different answers and neither of them is wrong. Five is the
|
|
1227
|
+
* default: enough that `--against` and `staysfixed_prove` can still reach back over a few
|
|
1228
|
+
* commits, small enough that a record committed into somebody's repository stops growing.
|
|
1229
|
+
*/
|
|
1230
|
+
const KEEP_BUILDS = 5;
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* The fewest captures any build keeps. One is still a stored record a later check can be
|
|
1234
|
+
* compared against; nought would delete the evidence that the build ever ran.
|
|
1235
|
+
*/
|
|
1236
|
+
const KEEP_CAPTURES_PER_JOURNEY = 1;
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* THE RETENTION POLICY, and why it is a count rather than an age.
|
|
1240
|
+
*
|
|
1241
|
+
* Thinning was never enough on its own. `pruneBuild` throws away captures INSIDE a build and
|
|
1242
|
+
* can never remove the folder, so the thing that actually grows — one build folder per check,
|
|
1243
|
+
* in a directory this tool asks people to commit — grew anyway. Nine folders in one afternoon
|
|
1244
|
+
* on a throwaway project, and nothing in the tool could ever have removed one.
|
|
1245
|
+
*
|
|
1246
|
+
* So there are three tiers, and this is the third:
|
|
1247
|
+
*
|
|
1248
|
+
* 1. The newest `keepBuilds` keep every recording they took. This is the working set —
|
|
1249
|
+
* `--against` and `staysfixed_prove` reach back over these.
|
|
1250
|
+
* 2. Everything behind them is thinned to one recording per journey. The build is still
|
|
1251
|
+
* there and can still be compared against; it just stops holding every take.
|
|
1252
|
+
* 3. Past `keepBuilds` times this number, the folder goes altogether — oldest first.
|
|
1253
|
+
*
|
|
1254
|
+
* WHY NOT AN AGE. An age bounds nothing. A project checked on every commit writes fifty
|
|
1255
|
+
* folders a day, so thirty days of them is fifteen hundred folders in somebody's git history
|
|
1256
|
+
* — and the same rule on a project checked twice a week deletes a two-month-old build that is
|
|
1257
|
+
* the only other thing in the store. The count is what grows, so the count is what is capped.
|
|
1258
|
+
*
|
|
1259
|
+
* WHY A SECOND TIER AT ALL, rather than removing everything past `keepBuilds`. The thinned
|
|
1260
|
+
* tier IS the grace period, and it is a cheap one: a thinned build holds one recording per
|
|
1261
|
+
* journey, so fifteen of them cost about what one untouched build costs. It buys back the
|
|
1262
|
+
* case this policy would otherwise get wrong — somebody who ran thirty checks in an afternoon
|
|
1263
|
+
* and then wants `--against` on the build from before lunch.
|
|
1264
|
+
*
|
|
1265
|
+
* Four, so a `keepBuilds` of five means at most twenty folders of this product, and the
|
|
1266
|
+
* sentence a person reads has one number in it rather than two.
|
|
1267
|
+
*/
|
|
1268
|
+
const KEEP_THINNED_MULTIPLE = 4;
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* @param {Record<string, any>} config
|
|
1272
|
+
* @returns {number}
|
|
1273
|
+
*/
|
|
1274
|
+
function keepBuildsFrom(config) {
|
|
1275
|
+
const asked = Number(config?.keepBuilds);
|
|
1276
|
+
return Number.isFinite(asked) && asked >= 1 ? Math.floor(asked) : KEEP_BUILDS;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* How long the test-suite harvest gets, out of the settings file: `suite: {budgetMs}`.
|
|
1281
|
+
*
|
|
1282
|
+
* Ninety seconds is the right DEFAULT — it is a statement about how long anybody waits inside
|
|
1283
|
+
* an edit-and-check loop before switching the tool off — but it was also the only answer
|
|
1284
|
+
* available. A project whose suite takes four minutes got ninety seconds and the rest of its
|
|
1285
|
+
* files named as gaps, with no way anywhere in the tool to say "I am willing to wait". A
|
|
1286
|
+
* limit that decides something has to be visible and, where it safely can be, adjustable.
|
|
1287
|
+
*
|
|
1288
|
+
* Zero means no budget: harvest every file however long it takes. That is a thing to ask for
|
|
1289
|
+
* on purpose and it is never a default.
|
|
1290
|
+
*
|
|
1291
|
+
* Nothing said comes back as null rather than as the default, so the number lives in one
|
|
1292
|
+
* place — `DEFAULT_HARVEST_BUDGET_MS`, in the file that applies it — instead of being copied
|
|
1293
|
+
* here where the two could drift.
|
|
1294
|
+
*
|
|
1295
|
+
* Exported so the reading of the setting can be tested on its own. Everything downstream of
|
|
1296
|
+
* it costs a real test suite being run twice, and a setting nobody can check the reading of
|
|
1297
|
+
* is a setting that will one day quietly stop being read.
|
|
1298
|
+
*
|
|
1299
|
+
* @param {Record<string, any>} config
|
|
1300
|
+
* @returns {number|null} Null when the settings say nothing, or say something that is not a
|
|
1301
|
+
* number of milliseconds.
|
|
1302
|
+
*/
|
|
1303
|
+
export function suiteBudgetFrom(config) {
|
|
1304
|
+
const asked = Number(config?.suite?.budgetMs);
|
|
1305
|
+
return Number.isFinite(asked) && asked >= 0 ? Math.floor(asked) : null;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Thin out the record of builds nobody is going to ask about again.
|
|
1310
|
+
*
|
|
1311
|
+
* WHY THIS EXISTS AT ALL. `.staysfixed/` is deliberately kept in git — the record of what
|
|
1312
|
+
* working means is the promise, and a fresh checkout with no record has nothing to compare
|
|
1313
|
+
* against. That decision is right and it has a bill attached: one build folder per check,
|
|
1314
|
+
* for ever, inside somebody's repository history. Measured on a throwaway Express project
|
|
1315
|
+
* after about ten checks: 492KB across 118 files and nine build folders in one afternoon.
|
|
1316
|
+
*
|
|
1317
|
+
* WHAT IS NEVER TOUCHED. The build this product calls working — `pruneBuild` and `removeBuild`
|
|
1318
|
+
* both refuse it outright and this refuses it again before asking. The build just walked. The
|
|
1319
|
+
* build named by `--against`, because somebody is plainly still using it. And the newest few
|
|
1320
|
+
* after that. See `KEEP_THINNED_MULTIPLE` for what happens to everything else, and why.
|
|
1321
|
+
*
|
|
1322
|
+
* WHEN IT DOES NOT RUN. On a blocked run there is no answer to trust and nothing is touched.
|
|
1323
|
+
* And, most importantly, when the list of builds came back with anything missing from it: a
|
|
1324
|
+
* damaged record is now reported rather than silently omitted, and deciding what is old on a
|
|
1325
|
+
* list that is short is how the evidence for "this used to work" gets deleted. That holds for
|
|
1326
|
+
* the removal tier above all — a folder that is thinned can be walked again, a folder that is
|
|
1327
|
+
* gone cannot. It is said out loud rather than skipped quietly, because a housekeeping step
|
|
1328
|
+
* that stops running is exactly the sort of thing nobody notices for a year.
|
|
1329
|
+
*
|
|
1330
|
+
* @param {Project} project
|
|
1331
|
+
* @param {CheckOutcome} outcome
|
|
1332
|
+
* @returns {Promise<string>} One plain sentence when something was removed or when it was
|
|
1333
|
+
* deliberately not attempted. Empty when there was simply nothing to do.
|
|
1334
|
+
*/
|
|
1335
|
+
async function tidyTheStore(project, outcome) {
|
|
1336
|
+
/** @type {string[]} */
|
|
1337
|
+
const said = [];
|
|
1338
|
+
|
|
1339
|
+
// A run clears up after itself first, and before the `blocked` gate, because a run that was
|
|
1340
|
+
// blocked is exactly the kind that died holding a half-written file. Scoped to the build
|
|
1341
|
+
// this run just wrote: clearing up after itself must never reach into another product's
|
|
1342
|
+
// folder in the same store. They are invisible — nothing reads a `.part` file — and an
|
|
1343
|
+
// invisible pile of half-written megabytes is how a tool gets blamed for a full disk.
|
|
1344
|
+
try {
|
|
1345
|
+
const swept = await sweepIncomplete(project.store, { buildId: project.candidate.id, olderThanMs: 0 });
|
|
1346
|
+
if (swept.removed > 0) {
|
|
1347
|
+
said.push(`${swept.removed} half-written ${swept.removed === 1 ? 'file' : 'files'} left behind by an earlier run that died were cleared away.`);
|
|
1348
|
+
}
|
|
1349
|
+
} catch (e) {
|
|
1350
|
+
// Never fatal. A folder that will not be swept costs disk; losing the verdict over it
|
|
1351
|
+
// would cost the whole run.
|
|
1352
|
+
said.push(`Half-written files from earlier runs could not be cleared away: ${messageOf(e)}`);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (outcome.blocked === true) return said.join(' ');
|
|
1356
|
+
|
|
1357
|
+
/** @type {string[]} */
|
|
1358
|
+
const problems = [];
|
|
1359
|
+
/** @type {import('./types.js').BuildRecord[]} */
|
|
1360
|
+
let builds;
|
|
1361
|
+
try {
|
|
1362
|
+
builds = await listBuilds(project.store, { product: project.product, onProblem: (m) => problems.push(m) });
|
|
1363
|
+
} catch (e) {
|
|
1364
|
+
said.push(`The stored record could not be listed, so nothing old was cleared out of it: ${messageOf(e)}`);
|
|
1365
|
+
return said.join(' ');
|
|
1366
|
+
}
|
|
1367
|
+
if (problems.length > 0) {
|
|
1368
|
+
said.push(`Nothing old was cleared out of the stored record this time. ${problems.join(' ')} Deciding what is old from a list that is missing something is how the evidence for "this used to work" gets deleted, so it was not attempted.`);
|
|
1369
|
+
return said.join(' ');
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const spared = new Set([project.candidate.id]);
|
|
1373
|
+
if (project.against) spared.add(project.against);
|
|
1374
|
+
for (const record of builds) if (record.isReference) spared.add(record.fingerprint.id);
|
|
1375
|
+
|
|
1376
|
+
// `listBuilds` hands them back newest first, so the newest few survive the slice.
|
|
1377
|
+
const unspared = builds.filter((b) => !spared.has(b.fingerprint.id));
|
|
1378
|
+
const cap = project.keepBuilds * KEEP_THINNED_MULTIPLE;
|
|
1379
|
+
const thinning = unspared.slice(project.keepBuilds, cap);
|
|
1380
|
+
const doomed = unspared.slice(cap);
|
|
1381
|
+
if (thinning.length === 0 && doomed.length === 0) return said.join(' ');
|
|
1382
|
+
|
|
1383
|
+
let removed = 0;
|
|
1384
|
+
let thinned = 0;
|
|
1385
|
+
let folders = 0;
|
|
1386
|
+
let evidence = 0;
|
|
1387
|
+
/** @type {string[]} */
|
|
1388
|
+
const refused = [];
|
|
1389
|
+
for (const record of thinning) {
|
|
1390
|
+
try {
|
|
1391
|
+
const done = await pruneBuild(project.store, record.fingerprint.id, { keepPerJourney: KEEP_CAPTURES_PER_JOURNEY });
|
|
1392
|
+
if (done.removed > 0) {
|
|
1393
|
+
removed += done.removed;
|
|
1394
|
+
thinned += 1;
|
|
1395
|
+
}
|
|
1396
|
+
} catch (e) {
|
|
1397
|
+
// A build that will not be pruned is kept, which is the safe direction — and it is
|
|
1398
|
+
// still worth naming, because a store that quietly stops being tidied grows for ever.
|
|
1399
|
+
refused.push(`${record.fingerprint.id} (${messageOf(e)})`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
// Oldest first, which is what `unspared` already is once the newest have been sliced off
|
|
1403
|
+
// the front. A removal that stops half way therefore leaves the NEWER of the old builds
|
|
1404
|
+
// standing, which is the direction anybody would choose if asked.
|
|
1405
|
+
for (const record of doomed.slice().reverse()) {
|
|
1406
|
+
try {
|
|
1407
|
+
const done = await removeBuild(project.store, record.fingerprint.id);
|
|
1408
|
+
folders += 1;
|
|
1409
|
+
evidence += done.captures;
|
|
1410
|
+
} catch (e) {
|
|
1411
|
+
refused.push(`${record.fingerprint.id} (${messageOf(e)})`);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
if (removed > 0) {
|
|
1416
|
+
said.push(
|
|
1417
|
+
`${removed} old ${removed === 1 ? 'recording was' : 'recordings were'} cleared out of ${thinned} ${thinned === 1 ? 'build' : 'builds'} nobody is comparing against any more.`,
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
if (folders > 0) {
|
|
1421
|
+
said.push(
|
|
1422
|
+
// "at most `cap`" counts only the ones this is allowed to touch. The build you called
|
|
1423
|
+
// working, the one just walked and anything named by --against are outside the count
|
|
1424
|
+
// entirely, so the sentence says so rather than quoting a number that is not the number
|
|
1425
|
+
// of folders on the disk.
|
|
1426
|
+
`${folders} ${folders === 1 ? 'build older than that was' : 'builds older than that were'} removed from the stored record altogether, taking ${evidence} ${evidence === 1 ? 'recording' : 'recordings'} with ${evidence === 1 ? 'it' : 'them'}: besides the ones never touched, this product keeps at most ${cap} builds, and ${folders === 1 ? 'that one had' : 'those had'} fallen off the end.`,
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
if (removed > 0 || folders > 0) {
|
|
1430
|
+
said.push(`The build you were happy with, the one just walked and the newest ${project.keepBuilds} were left alone.`);
|
|
1431
|
+
}
|
|
1432
|
+
if (refused.length > 0) {
|
|
1433
|
+
said.push(`${refused.length} older ${refused.length === 1 ? 'build was' : 'builds were'} left as ${refused.length === 1 ? 'it is' : 'they are'} because ${refused.length === 1 ? 'it' : 'they'} could not be tidied: ${refused.join('; ')}`);
|
|
1434
|
+
}
|
|
1435
|
+
return said.join(' ');
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* The rules that rewrite the two folders this run happens to be using.
|
|
1440
|
+
*
|
|
1441
|
+
* TWO ROOTS, TWO IDS, and that is the whole of this function. `mergeRules` keys by rule id
|
|
1442
|
+
* and the later one wins, so calling `machineRules` twice for two different folders handed
|
|
1443
|
+
* the same id — `path.project-root` — to both, and the second one, the scratch copy, deleted
|
|
1444
|
+
* the first. The rule that was supposed to rewrite somebody's real checkout to `<project>`
|
|
1445
|
+
* was therefore not in the rule set on any run this tool has ever done, and an absolute path
|
|
1446
|
+
* under their actual project was compared literally.
|
|
1447
|
+
*
|
|
1448
|
+
* Both are wanted. A product's output carries paths under the real checkout AND under the
|
|
1449
|
+
* throwaway copy it is walked in, and neither of those is a fact about the product.
|
|
1450
|
+
*
|
|
1451
|
+
* Exported so the collision can be tested for directly. It came back the moment two folders
|
|
1452
|
+
* were normalised in one run, and it will come back again the moment there are three.
|
|
1453
|
+
*
|
|
1454
|
+
* @param {{root: string, scratch: string}} where
|
|
1455
|
+
* @returns {NormaliseRule[]}
|
|
1456
|
+
*/
|
|
1457
|
+
export function pathRules(where) {
|
|
1458
|
+
return [
|
|
1459
|
+
...machineRules({ root: where.root, home: os.homedir(), tmp: os.tmpdir() }),
|
|
1460
|
+
...machineRules({ root: where.scratch }).map((rule) => ({
|
|
1461
|
+
...rule,
|
|
1462
|
+
id: 'path.scratch-copy',
|
|
1463
|
+
what: 'The throwaway folder this run copied the build into to walk it.',
|
|
1464
|
+
why: 'It is a fresh temporary folder every run, so any path under it differs between two runs of the same build.',
|
|
1465
|
+
wouldHide: 'Nothing about the product. It only ever replaces a prefix this tool chose a moment ago.',
|
|
1466
|
+
})),
|
|
1467
|
+
];
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1038
1470
|
/**
|
|
1039
1471
|
* @param {CheckOptions} options
|
|
1040
1472
|
* @returns {Promise<Project>}
|
|
@@ -1045,14 +1477,34 @@ async function openProject(options) {
|
|
|
1045
1477
|
const configFile = options.configFile ?? findConfigFile(root) ?? null;
|
|
1046
1478
|
const aim = aimOf(options);
|
|
1047
1479
|
const config = aimAt(await readConfig(configFile), aim);
|
|
1048
|
-
const
|
|
1480
|
+
const fromPackage = await packageName(root);
|
|
1481
|
+
const product = options.product ?? String(config.product ?? fromPackage.name ?? path.basename(root));
|
|
1049
1482
|
|
|
1050
1483
|
const store = openStore({ root });
|
|
1051
|
-
|
|
1484
|
+
/** Everything about the store that would not work, in the words the reader gets. */
|
|
1485
|
+
/** @type {string[]} */
|
|
1486
|
+
const storeTrouble = [];
|
|
1487
|
+
// A damaged package.json only matters when it was going to be what named this product —
|
|
1488
|
+
// and then it matters a great deal, because the name is the key everything is stored
|
|
1489
|
+
// under. Said out loud rather than fixed silently: guessing the old name would be worse.
|
|
1490
|
+
// It is NOT store trouble: the run is written down perfectly well, just under a name
|
|
1491
|
+
// nothing else in this project's history uses.
|
|
1492
|
+
const namingTrouble =
|
|
1493
|
+
fromPackage.damaged !== '' && options.product === undefined && config.product === undefined
|
|
1494
|
+
? fromPackage.damaged
|
|
1495
|
+
: '';
|
|
1496
|
+
try {
|
|
1497
|
+
await ensureStore(store);
|
|
1498
|
+
} catch (e) {
|
|
1499
|
+
storeTrouble.push(`The folder Stays Fixed keeps its records in could not be made: ${messageOf(e)}`);
|
|
1500
|
+
}
|
|
1052
1501
|
|
|
1502
|
+
await sweepAbandonedScratch();
|
|
1053
1503
|
const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-check-'));
|
|
1054
1504
|
const evidenceDir = path.join(scratch, 'evidence');
|
|
1055
1505
|
await fsp.mkdir(evidenceDir, { recursive: true });
|
|
1506
|
+
// Who this belongs to, so a later run can tell an abandoned copy from one in use.
|
|
1507
|
+
await fsp.writeFile(path.join(scratch, 'owner.json'), JSON.stringify({ pid: process.pid, at: new Date().toISOString() })).catch(() => {});
|
|
1056
1508
|
|
|
1057
1509
|
// Working out what there is to walk comes FIRST, before anything is asked of git. Somebody
|
|
1058
1510
|
// standing in a folder they have not set up yet should be told to run `init`, not told
|
|
@@ -1079,18 +1531,76 @@ async function openProject(options) {
|
|
|
1079
1531
|
}
|
|
1080
1532
|
|
|
1081
1533
|
const candidate = await fingerprintWorkingTree(root, product);
|
|
1082
|
-
|
|
1534
|
+
// BOOKKEEPING MAY NOT COST THE ANSWER. Until 2026-08-30 these two writes were unguarded,
|
|
1535
|
+
// so a store that would not take them — a full disk, a permission taken away, a read-only
|
|
1536
|
+
// checkout — stopped the check dead before it had walked a single thing. What that threw
|
|
1537
|
+
// away is much larger than what it protected: the run could have opened the product,
|
|
1538
|
+
// walked it twice, compared it against the old build and handed back a real answer, and
|
|
1539
|
+
// all that was really lost was the note saying it had happened.
|
|
1540
|
+
//
|
|
1541
|
+
// So a failure here is remembered and said out loud, and the run carries on. Where the
|
|
1542
|
+
// record turns out to have been load-bearing after all — a reference named by a commit
|
|
1543
|
+
// that nothing can now register — the run still ends blocked, but it ends blocked SAYING
|
|
1544
|
+
// the store is the reason, instead of handing somebody a bare permission error from a
|
|
1545
|
+
// folder they have never heard of.
|
|
1546
|
+
try {
|
|
1547
|
+
await saveBuild(store, candidate);
|
|
1548
|
+
} catch (e) {
|
|
1549
|
+
storeTrouble.push(`The record of the build you have could not be written: ${messageOf(e)}`);
|
|
1550
|
+
}
|
|
1083
1551
|
|
|
1084
1552
|
// A name like "HEAD", "v0.13.0" or a branch is what a person types; the store only knows
|
|
1085
1553
|
// builds. Turning the name into a commit here, and putting that commit in the store, is
|
|
1086
1554
|
// what lets a check be aimed at any point in history without every commit having been
|
|
1087
1555
|
// walked before. Without it "HEAD" matches nothing and the check reports itself blocked.
|
|
1088
1556
|
const reference = options.against ? await fingerprintCommit(root, product, options.against) : null;
|
|
1089
|
-
if (reference)
|
|
1557
|
+
if (reference) {
|
|
1558
|
+
try {
|
|
1559
|
+
await saveBuild(store, reference);
|
|
1560
|
+
} catch (e) {
|
|
1561
|
+
storeTrouble.push(`The record of ${nameOfReference(reference, options.against)} could not be written, so a check aimed at it by name may find nothing on record to match: ${messageOf(e)}`);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// Which commit the build you were happy with is at. With `--against` it is the commit that
|
|
1566
|
+
// was just named; without it, it is whatever the store's own reference pointer holds, which
|
|
1567
|
+
// is the usual case because a reference is cut by a person shipping. Either way it is the
|
|
1568
|
+
// thing that makes a committed change measurable, and nothing here had ever asked for it.
|
|
1569
|
+
/** @type {string|undefined} */
|
|
1570
|
+
let referenceSha = reference?.gitSha ?? undefined;
|
|
1571
|
+
if (!referenceSha) {
|
|
1572
|
+
try {
|
|
1573
|
+
const record = await referenceFor(store, product);
|
|
1574
|
+
referenceSha = record?.fingerprint?.gitSha ?? undefined;
|
|
1575
|
+
} catch (e) {
|
|
1576
|
+
// A damaged reference pointer is loud elsewhere. Here it costs the ordering, not the run.
|
|
1577
|
+
storeTrouble.push(`Which build counts as working could not be read, so a change you have already committed cannot be measured: ${messageOf(e)}`);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// It goes in the coverage list because that is the one list every reader already meets —
|
|
1582
|
+
// the command line prints it, the build server's table prints it, and the closing sentence
|
|
1583
|
+
// counts it. A fact that only exists on a field somebody has to know to look for is a fact
|
|
1584
|
+
// most readers never meet.
|
|
1585
|
+
/** @type {CoverageGap[]} */
|
|
1586
|
+
const gaps = [...gathered.gaps];
|
|
1587
|
+
if (namingTrouble !== '') {
|
|
1588
|
+
gaps.push({
|
|
1589
|
+
what: `This run was recorded against a product called "${product}", which is the name of the folder rather than the name of the project.`,
|
|
1590
|
+
why: `${namingTrouble} Nothing else names this product either, so the folder name was used. Anything recorded under the name inside that file is a different product as far as this run is concerned, including the build you called working.`,
|
|
1591
|
+
unlockedBy: `Fix package.json, or put the name you want in your settings file as product: '<name>'. Until then every comparison starts from nothing.`,
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
if (storeTrouble.length > 0) {
|
|
1595
|
+
gaps.push({
|
|
1596
|
+
what: 'This run was NOT written down, so the next check has nothing from today to compare against.',
|
|
1597
|
+
why: `${storeTrouble.join(' ')} Whatever this run reports below still stands — the product was walked and compared exactly as usual — but none of it reached the disk.`,
|
|
1598
|
+
unlockedBy: 'Free some disk space, or fix the permissions on the .staysfixed folder, and run the check again.',
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1090
1601
|
|
|
1091
1602
|
const rules = mergeRules(DEFAULT_RULES, [
|
|
1092
|
-
...
|
|
1093
|
-
...machineRules({ root: scratch }),
|
|
1603
|
+
...pathRules({ root, scratch }),
|
|
1094
1604
|
...(await loadRules(path.join(root, '.staysfixed', 'rules.json'))),
|
|
1095
1605
|
]);
|
|
1096
1606
|
|
|
@@ -1117,9 +1627,12 @@ async function openProject(options) {
|
|
|
1117
1627
|
product,
|
|
1118
1628
|
store,
|
|
1119
1629
|
candidate,
|
|
1630
|
+
keepBuilds: keepBuildsFrom(config),
|
|
1631
|
+
referenceSha,
|
|
1120
1632
|
against: reference ? reference.id : options.against,
|
|
1121
1633
|
journeys,
|
|
1122
|
-
gaps
|
|
1634
|
+
gaps,
|
|
1635
|
+
storeTrouble: storeTrouble.join(' '),
|
|
1123
1636
|
walk,
|
|
1124
1637
|
bootReference,
|
|
1125
1638
|
normalise,
|
|
@@ -1401,9 +1914,63 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
1401
1914
|
/** @type {CoverageGap[]} */
|
|
1402
1915
|
const gaps = [];
|
|
1403
1916
|
|
|
1404
|
-
const named =
|
|
1917
|
+
const named =
|
|
1918
|
+
options.journeys && !['code', 'config', 'suite'].includes(options.journeys) ? options.journeys : null;
|
|
1405
1919
|
if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
|
|
1406
1920
|
|
|
1921
|
+
// The project's own test suite, when somebody asked for it in those words and never
|
|
1922
|
+
// otherwise. This RUNS their tests — twice each, inside the same scratch clone everything
|
|
1923
|
+
// else uses, under a time budget — and that is a cost nobody gets charged by accident, so
|
|
1924
|
+
// it is off unless `--journeys suite` says so.
|
|
1925
|
+
//
|
|
1926
|
+
// It is worth switching on because it sees what walking a product cannot. On the fixture
|
|
1927
|
+
// where a total quietly stops rounding pennies, and the command line only ever adds whole
|
|
1928
|
+
// pounds, the discovered journeys produce nothing at all — the output does not move by one
|
|
1929
|
+
// character — and the harvested ones produce five findings.
|
|
1930
|
+
//
|
|
1931
|
+
// Loaded here rather than at the top of the file: a copy of this tool without the harvest
|
|
1932
|
+
// in it still runs every other kind of check, and saying so is better than failing to start.
|
|
1933
|
+
if (options.journeys === 'suite') {
|
|
1934
|
+
try {
|
|
1935
|
+
const { journeysFromSuite, DEFAULT_HARVEST_BUDGET_MS } = await import('./journeys/index.js');
|
|
1936
|
+
// The settings file gets a say in how long this is allowed to take. Left out, the
|
|
1937
|
+
// harvest applies its own default, which is why nothing is passed rather than the
|
|
1938
|
+
// default being copied to here — see `suiteBudgetFrom`.
|
|
1939
|
+
const budgetMs = suiteBudgetFrom(config);
|
|
1940
|
+
const suite = await journeysFromSuite({
|
|
1941
|
+
root,
|
|
1942
|
+
surface: options.surface === 'auto' ? undefined : options.surface,
|
|
1943
|
+
...(budgetMs === null ? {} : { suite: { budgetMs } }),
|
|
1944
|
+
// The harvest talks while it works, and it can take most of a minute. Its sentences
|
|
1945
|
+
// go into the same stream as everything else rather than nowhere.
|
|
1946
|
+
log: (message) => options.events?.emit({ type: 'note', at: options.events.elapsed(), message }),
|
|
1947
|
+
signal: options.signal,
|
|
1948
|
+
});
|
|
1949
|
+
// Said out loud, always, and before the harvest's own findings. A run held to ninety
|
|
1950
|
+
// seconds and a run allowed four minutes produce different amounts of coverage, and if
|
|
1951
|
+
// the number that decided it is invisible the two runs read as the same run.
|
|
1952
|
+
const applied = budgetMs ?? DEFAULT_HARVEST_BUDGET_MS;
|
|
1953
|
+
options.events?.emit({
|
|
1954
|
+
type: 'note',
|
|
1955
|
+
at: options.events.elapsed(),
|
|
1956
|
+
message:
|
|
1957
|
+
applied === 0
|
|
1958
|
+
? 'The test-suite harvest was given no time budget at all, so every test file was run however long it took. Your settings asked for that with suite.budgetMs: 0.'
|
|
1959
|
+
: `The test-suite harvest was held to ${Math.round(applied / 1000)} seconds${budgetMs === null ? ', which is the default' : ', which your settings asked for'}. Anything it did not reach in that time is named below rather than skipped quietly; change it with suite.budgetMs.`,
|
|
1960
|
+
});
|
|
1961
|
+
journeys.push(...suite.journeys);
|
|
1962
|
+
gaps.push(...suite.gaps);
|
|
1963
|
+
} catch (e) {
|
|
1964
|
+
// A harvest that fell over is a hole, never a pass. Everything else this project has is
|
|
1965
|
+
// still walked, and the verdict carries the fact that its tests were not among it.
|
|
1966
|
+
gaps.push({
|
|
1967
|
+
what: 'Nothing was checked through this project\'s own test suite, because it could not be harvested.',
|
|
1968
|
+
why: messageOf(e),
|
|
1969
|
+
unlockedBy: 'Run the suite yourself to see what it does, or point the check at a journeys file instead. Nothing your tests can see is being watched until this works.',
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1407
1974
|
for (const adapter of ADAPTERS) {
|
|
1408
1975
|
if (adapter === sourceAdapter && named && options.journeys !== 'code') {
|
|
1409
1976
|
// A journeys file names exactly what to walk. The contract read is still added,
|
|
@@ -1448,7 +2015,10 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
1448
2015
|
gaps.push({
|
|
1449
2016
|
what: `You asked for the journey "${wanted}" and there is no journey by that name, so it was not walked.`,
|
|
1450
2017
|
why: 'A name that matches nothing narrows the run to nothing rather than to what you meant.',
|
|
1451
|
-
|
|
2018
|
+
// The count goes on the end when the list was cut. Twelve names with nothing after
|
|
2019
|
+
// them read as the whole list, and somebody hunting for a name they mistyped would
|
|
2020
|
+
// conclude it is not there — when it is, at number thirteen.
|
|
2021
|
+
unlockedBy: `The journeys this project has are: ${journeys.map((j) => j.name).slice(0, 12).join(', ') || 'none'}${journeys.length > 12 ? `, and ${journeys.length - 12} more` : ''}.`,
|
|
1452
2022
|
});
|
|
1453
2023
|
}
|
|
1454
2024
|
}
|
|
@@ -1530,28 +2100,10 @@ async function readConfig(configFile) {
|
|
|
1530
2100
|
// Which build is which
|
|
1531
2101
|
// ---------------------------------------------------------------------------
|
|
1532
2102
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
* way to the front door. A build is told from another build by what git says is in the
|
|
1538
|
-
* working tree — the diff, plus the list of files git does not know about. Stays Fixed's own
|
|
1539
|
-
* folder is a file git does not know about, and it gains files every single time the tool
|
|
1540
|
-
* runs. So the untracked list changed on every run, the digest changed with it, and every
|
|
1541
|
-
* run of an UNCHANGED project produced a brand new build id.
|
|
1542
|
-
*
|
|
1543
|
-
* The consequences were all silent. Two runs on identical source were two different builds,
|
|
1544
|
-
* so the second could never find the first one's record. A clean checkout was never clean, so
|
|
1545
|
-
* it never got its commit's id, so `--against HEAD` matched nothing and the stored-record
|
|
1546
|
-
* comparison — the fast path the whole design rests on — could not work at all. Measured on
|
|
1547
|
-
* a scratch product: five runs, one unchanged source file, five different build ids and five
|
|
1548
|
-
* runs reporting NOTHING WAS ACTUALLY COMPARED.
|
|
1549
|
-
*
|
|
1550
|
-
* Excluded rather than gitignored, and that difference matters: gitignoring it would fix the
|
|
1551
|
-
* fingerprint and would also throw away the observation files the design says to keep
|
|
1552
|
-
* forever. What a project's own tooling wrote about a project is never part of the project.
|
|
1553
|
-
*/
|
|
1554
|
-
const NOT_THE_TOOLS_OWN_FOLDER = ':(exclude).staysfixed';
|
|
2103
|
+
// The pathspec that keeps Stays Fixed's own folder out of "what has changed here" lives in
|
|
2104
|
+
// rank.js, next to the other reader of the same two git calls. One name, so the fingerprint
|
|
2105
|
+
// and the distance measure can never disagree about what counts as the agent's edit — they
|
|
2106
|
+
// did for a fortnight, and only the fingerprint half had been fixed.
|
|
1555
2107
|
|
|
1556
2108
|
/**
|
|
1557
2109
|
* The build you have, named by what is actually in it.
|
|
@@ -1564,7 +2116,7 @@ const NOT_THE_TOOLS_OWN_FOLDER = ':(exclude).staysfixed';
|
|
|
1564
2116
|
* @param {string} product
|
|
1565
2117
|
* @returns {Promise<BuildFingerprint>}
|
|
1566
2118
|
*/
|
|
1567
|
-
async function fingerprintWorkingTree(root, product) {
|
|
2119
|
+
export async function fingerprintWorkingTree(root, product) {
|
|
1568
2120
|
const sha = await git(root, ['rev-parse', 'HEAD']);
|
|
1569
2121
|
if (!sha) {
|
|
1570
2122
|
// REFUSING IS THE ONLY HONEST ANSWER HERE, and the alternative is the worst bug this
|
|
@@ -1636,6 +2188,17 @@ async function fingerprintCommit(root, product, name) {
|
|
|
1636
2188
|
return build;
|
|
1637
2189
|
}
|
|
1638
2190
|
|
|
2191
|
+
/**
|
|
2192
|
+
* What to call a reference build in a sentence: the name a person typed, or its id.
|
|
2193
|
+
*
|
|
2194
|
+
* @param {BuildFingerprint} reference
|
|
2195
|
+
* @param {string} [asked]
|
|
2196
|
+
* @returns {string}
|
|
2197
|
+
*/
|
|
2198
|
+
function nameOfReference(reference, asked) {
|
|
2199
|
+
return asked && asked.trim() !== '' ? `${asked} (${reference.id})` : reference.id;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
1639
2202
|
/**
|
|
1640
2203
|
* Put the old build back on this machine so it can be walked live.
|
|
1641
2204
|
*
|
|
@@ -1731,24 +2294,45 @@ async function git(cwd, args) {
|
|
|
1731
2294
|
}
|
|
1732
2295
|
|
|
1733
2296
|
/**
|
|
2297
|
+
* The project's package.json, and — separately — whether there is one that could not be read.
|
|
2298
|
+
*
|
|
2299
|
+
* NO PACKAGE.JSON AND A DAMAGED ONE USED TO BE THE SAME NULL, and the difference decides what
|
|
2300
|
+
* a product is CALLED. With nothing else naming it, the name falls back to the folder name, so
|
|
2301
|
+
* a package.json with a stray comma in it silently renames the product: the store keys every
|
|
2302
|
+
* record under the new name, and every record kept under the real one — including the build
|
|
2303
|
+
* somebody called working — is orphaned. The run then says, perfectly calmly, that nothing has
|
|
2304
|
+
* ever been recorded here.
|
|
2305
|
+
*
|
|
1734
2306
|
* @param {string} root
|
|
1735
|
-
* @returns {Promise<Record<string, any>|null>}
|
|
2307
|
+
* @returns {Promise<{pkg: Record<string, any>|null, damaged: string}>}
|
|
2308
|
+
* `damaged` is empty except when the file is there and unreadable, when it is the sentence
|
|
2309
|
+
* a person gets.
|
|
1736
2310
|
*/
|
|
1737
2311
|
async function packageJson(root) {
|
|
2312
|
+
const file = path.join(root, 'package.json');
|
|
2313
|
+
/** @type {string} */
|
|
2314
|
+
let raw;
|
|
1738
2315
|
try {
|
|
1739
|
-
|
|
1740
|
-
} catch {
|
|
1741
|
-
|
|
2316
|
+
raw = await fsp.readFile(file, 'utf8');
|
|
2317
|
+
} catch (e) {
|
|
2318
|
+
const code = /** @type {{code?: string}} */ (e)?.code;
|
|
2319
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return { pkg: null, damaged: '' };
|
|
2320
|
+
return { pkg: null, damaged: `${file} is there and could not be read: ${messageOf(e)}` };
|
|
2321
|
+
}
|
|
2322
|
+
try {
|
|
2323
|
+
return { pkg: JSON.parse(raw), damaged: '' };
|
|
2324
|
+
} catch (e) {
|
|
2325
|
+
return { pkg: null, damaged: `${file} is not readable as JSON: ${messageOf(e)}` };
|
|
1742
2326
|
}
|
|
1743
2327
|
}
|
|
1744
2328
|
|
|
1745
2329
|
/**
|
|
1746
2330
|
* @param {string} root
|
|
1747
|
-
* @returns {Promise<string|null>}
|
|
2331
|
+
* @returns {Promise<{name: string|null, damaged: string}>}
|
|
1748
2332
|
*/
|
|
1749
2333
|
async function packageName(root) {
|
|
1750
|
-
const pkg = await packageJson(root);
|
|
1751
|
-
return typeof pkg?.name === 'string' ? pkg.name : null;
|
|
2334
|
+
const { pkg, damaged } = await packageJson(root);
|
|
2335
|
+
return { name: typeof pkg?.name === 'string' ? pkg.name : null, damaged };
|
|
1752
2336
|
}
|
|
1753
2337
|
|
|
1754
2338
|
/**
|
|
@@ -1756,7 +2340,7 @@ async function packageName(root) {
|
|
|
1756
2340
|
* @returns {Promise<string|null>}
|
|
1757
2341
|
*/
|
|
1758
2342
|
async function packageVersion(root) {
|
|
1759
|
-
const pkg = await packageJson(root);
|
|
2343
|
+
const { pkg } = await packageJson(root);
|
|
1760
2344
|
return typeof pkg?.version === 'string' ? pkg.version : null;
|
|
1761
2345
|
}
|
|
1762
2346
|
|