staysfixed 0.7.2 → 0.8.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 +342 -0
- package/README.md +191 -55
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +18 -5
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +549 -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/watch-flags.js +54 -0
- package/src/core/config.js +23 -3
- package/src/guard/run.js +49 -1
- package/src/report/console.js +15 -2
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +11 -2
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +728 -66
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +94 -24
- package/src/v2/adapters/web.js +142 -9
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +9 -1
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +530 -66
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +42 -24
- package/src/v2/cluster.js +164 -13
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +285 -45
- package/src/v2/init.js +162 -61
- 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 +10 -11
- 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 +40 -10
- 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 +29 -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';
|
|
@@ -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.
|
|
@@ -326,6 +327,12 @@ export async function check(options = {}) {
|
|
|
326
327
|
// the coverage sentence went on the end. Left there, a window would show a greener,
|
|
327
328
|
// shorter answer than the terminal beside it, and the two would disagree about the same
|
|
328
329
|
// run. So it is told again, with the settled one, and only then put away.
|
|
330
|
+
// Housekeeping, after the answer is settled and never before it. It can only ever remove
|
|
331
|
+
// recordings of builds nothing is comparing against, and if it says anything at all it
|
|
332
|
+
// says it on the same summary as everything else.
|
|
333
|
+
const tidied = await tidyTheStore(project, outcome);
|
|
334
|
+
if (tidied) outcome.summary = `${outcome.summary} ${tidied}`;
|
|
335
|
+
|
|
329
336
|
events.emit({ type: 'check:done', at: events.elapsed(), message: outcome.summary, verdict: outcome });
|
|
330
337
|
if (screen) {
|
|
331
338
|
await screen.finish();
|
|
@@ -333,7 +340,11 @@ export async function check(options = {}) {
|
|
|
333
340
|
}
|
|
334
341
|
return outcome;
|
|
335
342
|
} catch (e) {
|
|
336
|
-
|
|
343
|
+
// A store that would not take this run's records is very often the whole reason the
|
|
344
|
+
// run then failed — a reference named by a commit cannot be resolved if nothing could
|
|
345
|
+
// register it — and without this the person is handed a bare permission error from a
|
|
346
|
+
// folder they have never heard of, with nothing joining the two facts up.
|
|
347
|
+
const outcome = blocked(options, e, project?.storeTrouble);
|
|
337
348
|
// A run that never happened still has to reach a person, because "no answer" looks
|
|
338
349
|
// exactly like "nothing changed" from the outside. It is only written down where a
|
|
339
350
|
// store already exists: a check aimed at a folder that was never set up must not leave
|
|
@@ -391,12 +402,18 @@ export async function check(options = {}) {
|
|
|
391
402
|
async function settle(verdict, store, product, guards) {
|
|
392
403
|
/** @type {import('./escalate.js').Decisions} */
|
|
393
404
|
let decisions;
|
|
405
|
+
/** @type {string} */
|
|
406
|
+
let bookkeepingTrouble = '';
|
|
394
407
|
try {
|
|
395
408
|
decisions = await readDecisions(store, product);
|
|
396
|
-
} catch {
|
|
409
|
+
} catch (e) {
|
|
397
410
|
// 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
|
|
411
|
+
// should rather than less. That is the only safe direction for this to fail in — and it
|
|
412
|
+
// is still a fact the reader needs. Without it the accounting says nothing was waived,
|
|
413
|
+
// which reads as "there were no waivers" rather than "nobody could read them", and an
|
|
414
|
+
// agent looking at a wall of findings it waived last week has no idea why they are back.
|
|
399
415
|
decisions = noDecisions(product);
|
|
416
|
+
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
417
|
}
|
|
401
418
|
|
|
402
419
|
const decided = decide(verdict.findings ?? [], decisions, { guards: guards ?? [] });
|
|
@@ -414,9 +431,13 @@ async function settle(verdict, store, product, guards) {
|
|
|
414
431
|
// that worked has changed". It is arithmetically true — nothing was compared, so
|
|
415
432
|
// nothing came back different — and it is the exact sentence that would let a real
|
|
416
433
|
// regression through. It is not a pass. It is no answer at all.
|
|
417
|
-
|
|
434
|
+
const nothing = comparedNothing(verdict);
|
|
435
|
+
if (nothing) {
|
|
418
436
|
verdict.ok = false;
|
|
419
|
-
verdict.summary =
|
|
437
|
+
verdict.summary =
|
|
438
|
+
nothing === 'no reference'
|
|
439
|
+
? `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}`
|
|
440
|
+
: `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
441
|
}
|
|
421
442
|
|
|
422
443
|
// And what was NOT looked at, in the same breath as the good news, on every run
|
|
@@ -426,11 +447,18 @@ async function settle(verdict, store, product, guards) {
|
|
|
426
447
|
// already reads. It goes last so it is the thing left in the reader's head.
|
|
427
448
|
verdict.summary = `${verdict.summary} ${whatWasNotChecked(verdict.coverage)}`;
|
|
428
449
|
}
|
|
450
|
+
if (bookkeepingTrouble) verdict.summary = `${verdict.summary} ${bookkeepingTrouble}`;
|
|
429
451
|
|
|
430
452
|
try {
|
|
431
453
|
await rememberCheck(store, { product, verdict, decided });
|
|
432
|
-
} catch {
|
|
433
|
-
// Nothing here is worth failing a finished check over.
|
|
454
|
+
} catch (e) {
|
|
455
|
+
// Nothing here is worth failing a finished check over — but it is worth a sentence.
|
|
456
|
+
// This record is the only thing that knows what the finding ids in this reply mean, so
|
|
457
|
+
// when it is not written the agent's very next move, `staysfixed_explain <id>` or
|
|
458
|
+
// `staysfixed_prove <id>`, answers "the last check has no finding called that". Which
|
|
459
|
+
// is a flat denial of something it was handed thirty seconds earlier, and it used to
|
|
460
|
+
// arrive with nothing anywhere explaining why.
|
|
461
|
+
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
462
|
}
|
|
435
463
|
return verdict;
|
|
436
464
|
}
|
|
@@ -567,25 +595,37 @@ export function whatWasNotChecked(coverage) {
|
|
|
567
595
|
/**
|
|
568
596
|
* Was there anything on the other side to compare against at all?
|
|
569
597
|
*
|
|
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.
|
|
598
|
+
* TWO WAYS TO COMPARE NOTHING, and only one of them was caught here.
|
|
574
599
|
*
|
|
575
|
-
* The
|
|
576
|
-
*
|
|
577
|
-
*
|
|
600
|
+
* The first is per journey: the engine records one gap for each journey it had no stored
|
|
601
|
+
* record for, and when that count reaches every journey walked, the run held nothing against
|
|
602
|
+
* anything. The gaps are recognised by the words the engine writes into them, and a test
|
|
603
|
+
* walks a real project into exactly this state, so a rewording fails loudly rather than
|
|
604
|
+
* quietly switching the guard off.
|
|
578
605
|
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
606
|
+
* The second is the COLD START, and it never records a per-journey gap at all, so this used
|
|
607
|
+
* to miss it completely. A product nobody has ever shipped with the hook in place has no
|
|
608
|
+
* reference: there is no old build, so there is no journey to be missing a record FOR. The
|
|
609
|
+
* run walked everything, compared none of it, found no differences, and set `ok: true`.
|
|
610
|
+
*
|
|
611
|
+
* The command line has always caught that one on its own and exits 2. `--json` and the MCP
|
|
612
|
+
* surface do not read the command line's arithmetic — they read this verdict — so the two
|
|
613
|
+
* interfaces an agent actually uses answered `ok: true` to "did I break anything" on a
|
|
614
|
+
* project where nothing had been compared. That is the exact failure this whole tool exists
|
|
615
|
+
* to prevent, produced by the tool, to a reader with no way of noticing.
|
|
616
|
+
*
|
|
617
|
+
* @param {CheckOutcome} verdict
|
|
618
|
+
* @returns {'no reference'|'no stored record'|null}
|
|
581
619
|
*/
|
|
582
|
-
function comparedNothing(
|
|
620
|
+
function comparedNothing(verdict) {
|
|
621
|
+
if (!verdict.reference || verdict.reference.id === '') return 'no reference';
|
|
622
|
+
const coverage = verdict.coverage;
|
|
583
623
|
const walked = coverage?.journeys ?? 0;
|
|
584
|
-
if (walked === 0) return
|
|
624
|
+
if (walked === 0) return null;
|
|
585
625
|
const nothingToCompare = (coverage?.gaps ?? []).filter((gap) =>
|
|
586
626
|
/never been walked against|no stored record of the old build/i.test(`${gap.what} ${gap.why}`),
|
|
587
627
|
).length;
|
|
588
|
-
return nothingToCompare >= walked;
|
|
628
|
+
return nothingToCompare >= walked ? 'no stored record' : null;
|
|
589
629
|
}
|
|
590
630
|
|
|
591
631
|
/**
|
|
@@ -603,9 +643,12 @@ function plainly(what) {
|
|
|
603
643
|
*
|
|
604
644
|
* @param {CheckOptions} options
|
|
605
645
|
* @param {unknown} e
|
|
646
|
+
* @param {string} [storeTrouble] What the store would not do earlier in this same run,
|
|
647
|
+
* when there was any. It goes in front of the error, because it usually IS the error's
|
|
648
|
+
* cause and the error on its own reads as something else entirely.
|
|
606
649
|
* @returns {CheckOutcome}
|
|
607
650
|
*/
|
|
608
|
-
function blocked(options, e) {
|
|
651
|
+
function blocked(options, e, storeTrouble) {
|
|
609
652
|
const product = options.product ?? path.basename(path.resolve(options.cwd ?? options.root ?? process.cwd()));
|
|
610
653
|
const empty = { id: '', product };
|
|
611
654
|
return {
|
|
@@ -621,11 +664,16 @@ function blocked(options, e) {
|
|
|
621
664
|
differencesReal: 0,
|
|
622
665
|
differencesNoise: 0,
|
|
623
666
|
newlyUnstable: [],
|
|
624
|
-
coverage: {
|
|
667
|
+
coverage: {
|
|
668
|
+
paths: 0,
|
|
669
|
+
journeys: 0,
|
|
670
|
+
byChannel: {},
|
|
671
|
+
gaps: [{ what: 'Everything.', why: storeTrouble ? `${storeTrouble} ${messageOf(e)}` : messageOf(e) }],
|
|
672
|
+
},
|
|
625
673
|
// The hint is the half that tells a person what to DO about it, and dropping it
|
|
626
674
|
// turns a helpful error into a dead end. Anything that blocks a run has to carry
|
|
627
675
|
// 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)}${
|
|
676
|
+
summary: `The check could not be run, so this is not a pass and not a failure. ${storeTrouble ? `${storeTrouble} ` : ''}${messageOf(e)}${
|
|
629
677
|
e instanceof Error && /** @type {any} */ (e).hint ? ` ${/** @type {any} */ (e).hint}` : ''
|
|
630
678
|
}`,
|
|
631
679
|
durationMs: 0,
|
|
@@ -662,7 +710,10 @@ export async function prove(options = {}) {
|
|
|
662
710
|
|
|
663
711
|
const project = await openProject(options);
|
|
664
712
|
try {
|
|
665
|
-
|
|
713
|
+
// From the commit the old build is at, not from the working tree. Without this, an agent
|
|
714
|
+
// that committed its work before asking to prove a cause was told there was no change to
|
|
715
|
+
// undo — about a change sitting one commit back, which git can hand over exactly.
|
|
716
|
+
const changed = await whatChanged(project.root, { since: project.referenceSha });
|
|
666
717
|
const wanted = (options.revert ?? []).map((f) => f.replace(/^\.\//, ''));
|
|
667
718
|
const narrowed = wanted.length
|
|
668
719
|
? { ...changed, hunks: changed.hunks.filter((h) => wanted.some((w) => h.file === w || h.file.startsWith(`${w}/`))) }
|
|
@@ -720,7 +771,14 @@ export async function explain(options = {}) {
|
|
|
720
771
|
else out.push(` ${d.path} — was ${short(d.reference)}, now ${short(d.candidate)}`);
|
|
721
772
|
}
|
|
722
773
|
if (differences.length > 40) out.push(` and ${differences.length - 40} more.`);
|
|
723
|
-
|
|
774
|
+
// The count goes on the end when the list was cut. Six file names with nothing after them
|
|
775
|
+
// read as the whole list, so an agent that opened all six believed it had seen everywhere
|
|
776
|
+
// this finding lives — and the file it needed was the seventh.
|
|
777
|
+
if (f.nearFiles?.length) {
|
|
778
|
+
const shown = f.nearFiles.slice(0, 6);
|
|
779
|
+
const more = f.nearFiles.length - shown.length;
|
|
780
|
+
out.push('', `Nearest code: ${shown.join(', ')}${more > 0 ? `, and ${more} more ${more === 1 ? 'file' : 'files'} this finding also comes from` : ''}.`);
|
|
781
|
+
}
|
|
724
782
|
// No full stop of our own: the reason is already a whole sentence and adding one gave the
|
|
725
783
|
// agent "...costs a real person real money.." on the reply it reads when it is trying to
|
|
726
784
|
// understand something it is not allowed to waive.
|
|
@@ -1009,11 +1067,20 @@ async function waitForItsWindow(pid, stopped) {
|
|
|
1009
1067
|
* @property {import('./types.js').Store} store
|
|
1010
1068
|
* @property {BuildFingerprint} candidate
|
|
1011
1069
|
* @property {string} [against] The reference build's own id, once a name has been resolved.
|
|
1070
|
+
* @property {number} keepBuilds How many builds of this product other than the reference keep
|
|
1071
|
+
* their full record. Everything older is thinned out at the end of a run.
|
|
1072
|
+
* @property {string} [referenceSha] The commit the build you were happy with is at. It is
|
|
1073
|
+
* what makes a COMMITTED change measurable: the change from that commit to the working
|
|
1074
|
+
* tree is the change, and reading the working tree alone goes blind the moment an agent
|
|
1075
|
+
* commits its work — which is what an agent does at the end of a task.
|
|
1012
1076
|
* @property {Journey[]} journeys
|
|
1013
1077
|
* @property {CoverageGap[]} gaps Holes found while working out WHAT to walk, before a
|
|
1014
1078
|
* single journey ran. An adapter that fell over listing its journeys belongs here, and it
|
|
1015
1079
|
* has to reach the verdict: a channel that silently dropped out is the worst thing this
|
|
1016
1080
|
* tool can do.
|
|
1081
|
+
* @property {string} storeTrouble Empty on a normal run. A plain sentence when the store
|
|
1082
|
+
* would not take this run's records — the run went ahead anyway, and every answer it
|
|
1083
|
+
* produces has to carry the admission that nothing about it was kept.
|
|
1017
1084
|
* @property {import('./run.js').Walker} walk
|
|
1018
1085
|
* @property {(reference: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} bootReference
|
|
1019
1086
|
* @property {(capture: Capture) => Capture} normalise
|
|
@@ -1035,6 +1102,254 @@ function projectRootFor(options) {
|
|
|
1035
1102
|
return config ? rootForConfig(config) : from;
|
|
1036
1103
|
}
|
|
1037
1104
|
|
|
1105
|
+
/**
|
|
1106
|
+
* How many builds keep their whole record before the old ones are thinned out.
|
|
1107
|
+
*
|
|
1108
|
+
* A setting rather than a constant, because a project running fifty checks a day and one
|
|
1109
|
+
* running three a week want different answers and neither of them is wrong. Five is the
|
|
1110
|
+
* default: enough that `--against` and `staysfixed_prove` can still reach back over a few
|
|
1111
|
+
* commits, small enough that a record committed into somebody's repository stops growing.
|
|
1112
|
+
*/
|
|
1113
|
+
const KEEP_BUILDS = 5;
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* The fewest captures any build keeps. One is still a stored record a later check can be
|
|
1117
|
+
* compared against; nought would delete the evidence that the build ever ran.
|
|
1118
|
+
*/
|
|
1119
|
+
const KEEP_CAPTURES_PER_JOURNEY = 1;
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* THE RETENTION POLICY, and why it is a count rather than an age.
|
|
1123
|
+
*
|
|
1124
|
+
* Thinning was never enough on its own. `pruneBuild` throws away captures INSIDE a build and
|
|
1125
|
+
* can never remove the folder, so the thing that actually grows — one build folder per check,
|
|
1126
|
+
* in a directory this tool asks people to commit — grew anyway. Nine folders in one afternoon
|
|
1127
|
+
* on a throwaway project, and nothing in the tool could ever have removed one.
|
|
1128
|
+
*
|
|
1129
|
+
* So there are three tiers, and this is the third:
|
|
1130
|
+
*
|
|
1131
|
+
* 1. The newest `keepBuilds` keep every recording they took. This is the working set —
|
|
1132
|
+
* `--against` and `staysfixed_prove` reach back over these.
|
|
1133
|
+
* 2. Everything behind them is thinned to one recording per journey. The build is still
|
|
1134
|
+
* there and can still be compared against; it just stops holding every take.
|
|
1135
|
+
* 3. Past `keepBuilds` times this number, the folder goes altogether — oldest first.
|
|
1136
|
+
*
|
|
1137
|
+
* WHY NOT AN AGE. An age bounds nothing. A project checked on every commit writes fifty
|
|
1138
|
+
* folders a day, so thirty days of them is fifteen hundred folders in somebody's git history
|
|
1139
|
+
* — and the same rule on a project checked twice a week deletes a two-month-old build that is
|
|
1140
|
+
* the only other thing in the store. The count is what grows, so the count is what is capped.
|
|
1141
|
+
*
|
|
1142
|
+
* WHY A SECOND TIER AT ALL, rather than removing everything past `keepBuilds`. The thinned
|
|
1143
|
+
* tier IS the grace period, and it is a cheap one: a thinned build holds one recording per
|
|
1144
|
+
* journey, so fifteen of them cost about what one untouched build costs. It buys back the
|
|
1145
|
+
* case this policy would otherwise get wrong — somebody who ran thirty checks in an afternoon
|
|
1146
|
+
* and then wants `--against` on the build from before lunch.
|
|
1147
|
+
*
|
|
1148
|
+
* Four, so a `keepBuilds` of five means at most twenty folders of this product, and the
|
|
1149
|
+
* sentence a person reads has one number in it rather than two.
|
|
1150
|
+
*/
|
|
1151
|
+
const KEEP_THINNED_MULTIPLE = 4;
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* @param {Record<string, any>} config
|
|
1155
|
+
* @returns {number}
|
|
1156
|
+
*/
|
|
1157
|
+
function keepBuildsFrom(config) {
|
|
1158
|
+
const asked = Number(config?.keepBuilds);
|
|
1159
|
+
return Number.isFinite(asked) && asked >= 1 ? Math.floor(asked) : KEEP_BUILDS;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* How long the test-suite harvest gets, out of the settings file: `suite: {budgetMs}`.
|
|
1164
|
+
*
|
|
1165
|
+
* Ninety seconds is the right DEFAULT — it is a statement about how long anybody waits inside
|
|
1166
|
+
* an edit-and-check loop before switching the tool off — but it was also the only answer
|
|
1167
|
+
* available. A project whose suite takes four minutes got ninety seconds and the rest of its
|
|
1168
|
+
* files named as gaps, with no way anywhere in the tool to say "I am willing to wait". A
|
|
1169
|
+
* limit that decides something has to be visible and, where it safely can be, adjustable.
|
|
1170
|
+
*
|
|
1171
|
+
* Zero means no budget: harvest every file however long it takes. That is a thing to ask for
|
|
1172
|
+
* on purpose and it is never a default.
|
|
1173
|
+
*
|
|
1174
|
+
* Nothing said comes back as null rather than as the default, so the number lives in one
|
|
1175
|
+
* place — `DEFAULT_HARVEST_BUDGET_MS`, in the file that applies it — instead of being copied
|
|
1176
|
+
* here where the two could drift.
|
|
1177
|
+
*
|
|
1178
|
+
* Exported so the reading of the setting can be tested on its own. Everything downstream of
|
|
1179
|
+
* it costs a real test suite being run twice, and a setting nobody can check the reading of
|
|
1180
|
+
* is a setting that will one day quietly stop being read.
|
|
1181
|
+
*
|
|
1182
|
+
* @param {Record<string, any>} config
|
|
1183
|
+
* @returns {number|null} Null when the settings say nothing, or say something that is not a
|
|
1184
|
+
* number of milliseconds.
|
|
1185
|
+
*/
|
|
1186
|
+
export function suiteBudgetFrom(config) {
|
|
1187
|
+
const asked = Number(config?.suite?.budgetMs);
|
|
1188
|
+
return Number.isFinite(asked) && asked >= 0 ? Math.floor(asked) : null;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Thin out the record of builds nobody is going to ask about again.
|
|
1193
|
+
*
|
|
1194
|
+
* WHY THIS EXISTS AT ALL. `.staysfixed/` is deliberately kept in git — the record of what
|
|
1195
|
+
* working means is the promise, and a fresh checkout with no record has nothing to compare
|
|
1196
|
+
* against. That decision is right and it has a bill attached: one build folder per check,
|
|
1197
|
+
* for ever, inside somebody's repository history. Measured on a throwaway Express project
|
|
1198
|
+
* after about ten checks: 492KB across 118 files and nine build folders in one afternoon.
|
|
1199
|
+
*
|
|
1200
|
+
* WHAT IS NEVER TOUCHED. The build this product calls working — `pruneBuild` and `removeBuild`
|
|
1201
|
+
* both refuse it outright and this refuses it again before asking. The build just walked. The
|
|
1202
|
+
* build named by `--against`, because somebody is plainly still using it. And the newest few
|
|
1203
|
+
* after that. See `KEEP_THINNED_MULTIPLE` for what happens to everything else, and why.
|
|
1204
|
+
*
|
|
1205
|
+
* WHEN IT DOES NOT RUN. On a blocked run there is no answer to trust and nothing is touched.
|
|
1206
|
+
* And, most importantly, when the list of builds came back with anything missing from it: a
|
|
1207
|
+
* damaged record is now reported rather than silently omitted, and deciding what is old on a
|
|
1208
|
+
* list that is short is how the evidence for "this used to work" gets deleted. That holds for
|
|
1209
|
+
* the removal tier above all — a folder that is thinned can be walked again, a folder that is
|
|
1210
|
+
* gone cannot. It is said out loud rather than skipped quietly, because a housekeeping step
|
|
1211
|
+
* that stops running is exactly the sort of thing nobody notices for a year.
|
|
1212
|
+
*
|
|
1213
|
+
* @param {Project} project
|
|
1214
|
+
* @param {CheckOutcome} outcome
|
|
1215
|
+
* @returns {Promise<string>} One plain sentence when something was removed or when it was
|
|
1216
|
+
* deliberately not attempted. Empty when there was simply nothing to do.
|
|
1217
|
+
*/
|
|
1218
|
+
async function tidyTheStore(project, outcome) {
|
|
1219
|
+
/** @type {string[]} */
|
|
1220
|
+
const said = [];
|
|
1221
|
+
|
|
1222
|
+
// A run clears up after itself first, and before the `blocked` gate, because a run that was
|
|
1223
|
+
// blocked is exactly the kind that died holding a half-written file. Scoped to the build
|
|
1224
|
+
// this run just wrote: clearing up after itself must never reach into another product's
|
|
1225
|
+
// folder in the same store. They are invisible — nothing reads a `.part` file — and an
|
|
1226
|
+
// invisible pile of half-written megabytes is how a tool gets blamed for a full disk.
|
|
1227
|
+
try {
|
|
1228
|
+
const swept = await sweepIncomplete(project.store, { buildId: project.candidate.id, olderThanMs: 0 });
|
|
1229
|
+
if (swept.removed > 0) {
|
|
1230
|
+
said.push(`${swept.removed} half-written ${swept.removed === 1 ? 'file' : 'files'} left behind by an earlier run that died were cleared away.`);
|
|
1231
|
+
}
|
|
1232
|
+
} catch (e) {
|
|
1233
|
+
// Never fatal. A folder that will not be swept costs disk; losing the verdict over it
|
|
1234
|
+
// would cost the whole run.
|
|
1235
|
+
said.push(`Half-written files from earlier runs could not be cleared away: ${messageOf(e)}`);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
if (outcome.blocked === true) return said.join(' ');
|
|
1239
|
+
|
|
1240
|
+
/** @type {string[]} */
|
|
1241
|
+
const problems = [];
|
|
1242
|
+
/** @type {import('./types.js').BuildRecord[]} */
|
|
1243
|
+
let builds;
|
|
1244
|
+
try {
|
|
1245
|
+
builds = await listBuilds(project.store, { product: project.product, onProblem: (m) => problems.push(m) });
|
|
1246
|
+
} catch (e) {
|
|
1247
|
+
said.push(`The stored record could not be listed, so nothing old was cleared out of it: ${messageOf(e)}`);
|
|
1248
|
+
return said.join(' ');
|
|
1249
|
+
}
|
|
1250
|
+
if (problems.length > 0) {
|
|
1251
|
+
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.`);
|
|
1252
|
+
return said.join(' ');
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const spared = new Set([project.candidate.id]);
|
|
1256
|
+
if (project.against) spared.add(project.against);
|
|
1257
|
+
for (const record of builds) if (record.isReference) spared.add(record.fingerprint.id);
|
|
1258
|
+
|
|
1259
|
+
// `listBuilds` hands them back newest first, so the newest few survive the slice.
|
|
1260
|
+
const unspared = builds.filter((b) => !spared.has(b.fingerprint.id));
|
|
1261
|
+
const cap = project.keepBuilds * KEEP_THINNED_MULTIPLE;
|
|
1262
|
+
const thinning = unspared.slice(project.keepBuilds, cap);
|
|
1263
|
+
const doomed = unspared.slice(cap);
|
|
1264
|
+
if (thinning.length === 0 && doomed.length === 0) return said.join(' ');
|
|
1265
|
+
|
|
1266
|
+
let removed = 0;
|
|
1267
|
+
let thinned = 0;
|
|
1268
|
+
let folders = 0;
|
|
1269
|
+
let evidence = 0;
|
|
1270
|
+
/** @type {string[]} */
|
|
1271
|
+
const refused = [];
|
|
1272
|
+
for (const record of thinning) {
|
|
1273
|
+
try {
|
|
1274
|
+
const done = await pruneBuild(project.store, record.fingerprint.id, { keepPerJourney: KEEP_CAPTURES_PER_JOURNEY });
|
|
1275
|
+
if (done.removed > 0) {
|
|
1276
|
+
removed += done.removed;
|
|
1277
|
+
thinned += 1;
|
|
1278
|
+
}
|
|
1279
|
+
} catch (e) {
|
|
1280
|
+
// A build that will not be pruned is kept, which is the safe direction — and it is
|
|
1281
|
+
// still worth naming, because a store that quietly stops being tidied grows for ever.
|
|
1282
|
+
refused.push(`${record.fingerprint.id} (${messageOf(e)})`);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
// Oldest first, which is what `unspared` already is once the newest have been sliced off
|
|
1286
|
+
// the front. A removal that stops half way therefore leaves the NEWER of the old builds
|
|
1287
|
+
// standing, which is the direction anybody would choose if asked.
|
|
1288
|
+
for (const record of doomed.slice().reverse()) {
|
|
1289
|
+
try {
|
|
1290
|
+
const done = await removeBuild(project.store, record.fingerprint.id);
|
|
1291
|
+
folders += 1;
|
|
1292
|
+
evidence += done.captures;
|
|
1293
|
+
} catch (e) {
|
|
1294
|
+
refused.push(`${record.fingerprint.id} (${messageOf(e)})`);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
if (removed > 0) {
|
|
1299
|
+
said.push(
|
|
1300
|
+
`${removed} old ${removed === 1 ? 'recording was' : 'recordings were'} cleared out of ${thinned} ${thinned === 1 ? 'build' : 'builds'} nobody is comparing against any more.`,
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
if (folders > 0) {
|
|
1304
|
+
said.push(
|
|
1305
|
+
// "at most `cap`" counts only the ones this is allowed to touch. The build you called
|
|
1306
|
+
// working, the one just walked and anything named by --against are outside the count
|
|
1307
|
+
// entirely, so the sentence says so rather than quoting a number that is not the number
|
|
1308
|
+
// of folders on the disk.
|
|
1309
|
+
`${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.`,
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
if (removed > 0 || folders > 0) {
|
|
1313
|
+
said.push(`The build you were happy with, the one just walked and the newest ${project.keepBuilds} were left alone.`);
|
|
1314
|
+
}
|
|
1315
|
+
if (refused.length > 0) {
|
|
1316
|
+
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('; ')}`);
|
|
1317
|
+
}
|
|
1318
|
+
return said.join(' ');
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* The rules that rewrite the two folders this run happens to be using.
|
|
1323
|
+
*
|
|
1324
|
+
* TWO ROOTS, TWO IDS, and that is the whole of this function. `mergeRules` keys by rule id
|
|
1325
|
+
* and the later one wins, so calling `machineRules` twice for two different folders handed
|
|
1326
|
+
* the same id — `path.project-root` — to both, and the second one, the scratch copy, deleted
|
|
1327
|
+
* the first. The rule that was supposed to rewrite somebody's real checkout to `<project>`
|
|
1328
|
+
* was therefore not in the rule set on any run this tool has ever done, and an absolute path
|
|
1329
|
+
* under their actual project was compared literally.
|
|
1330
|
+
*
|
|
1331
|
+
* Both are wanted. A product's output carries paths under the real checkout AND under the
|
|
1332
|
+
* throwaway copy it is walked in, and neither of those is a fact about the product.
|
|
1333
|
+
*
|
|
1334
|
+
* Exported so the collision can be tested for directly. It came back the moment two folders
|
|
1335
|
+
* were normalised in one run, and it will come back again the moment there are three.
|
|
1336
|
+
*
|
|
1337
|
+
* @param {{root: string, scratch: string}} where
|
|
1338
|
+
* @returns {NormaliseRule[]}
|
|
1339
|
+
*/
|
|
1340
|
+
export function pathRules(where) {
|
|
1341
|
+
return [
|
|
1342
|
+
...machineRules({ root: where.root, home: os.homedir(), tmp: os.tmpdir() }),
|
|
1343
|
+
...machineRules({ root: where.scratch }).map((rule) => ({
|
|
1344
|
+
...rule,
|
|
1345
|
+
id: 'path.scratch-copy',
|
|
1346
|
+
what: 'The throwaway folder this run copied the build into to walk it.',
|
|
1347
|
+
why: 'It is a fresh temporary folder every run, so any path under it differs between two runs of the same build.',
|
|
1348
|
+
wouldHide: 'Nothing about the product. It only ever replaces a prefix this tool chose a moment ago.',
|
|
1349
|
+
})),
|
|
1350
|
+
];
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1038
1353
|
/**
|
|
1039
1354
|
* @param {CheckOptions} options
|
|
1040
1355
|
* @returns {Promise<Project>}
|
|
@@ -1045,10 +1360,27 @@ async function openProject(options) {
|
|
|
1045
1360
|
const configFile = options.configFile ?? findConfigFile(root) ?? null;
|
|
1046
1361
|
const aim = aimOf(options);
|
|
1047
1362
|
const config = aimAt(await readConfig(configFile), aim);
|
|
1048
|
-
const
|
|
1363
|
+
const fromPackage = await packageName(root);
|
|
1364
|
+
const product = options.product ?? String(config.product ?? fromPackage.name ?? path.basename(root));
|
|
1049
1365
|
|
|
1050
1366
|
const store = openStore({ root });
|
|
1051
|
-
|
|
1367
|
+
/** Everything about the store that would not work, in the words the reader gets. */
|
|
1368
|
+
/** @type {string[]} */
|
|
1369
|
+
const storeTrouble = [];
|
|
1370
|
+
// A damaged package.json only matters when it was going to be what named this product —
|
|
1371
|
+
// and then it matters a great deal, because the name is the key everything is stored
|
|
1372
|
+
// under. Said out loud rather than fixed silently: guessing the old name would be worse.
|
|
1373
|
+
// It is NOT store trouble: the run is written down perfectly well, just under a name
|
|
1374
|
+
// nothing else in this project's history uses.
|
|
1375
|
+
const namingTrouble =
|
|
1376
|
+
fromPackage.damaged !== '' && options.product === undefined && config.product === undefined
|
|
1377
|
+
? fromPackage.damaged
|
|
1378
|
+
: '';
|
|
1379
|
+
try {
|
|
1380
|
+
await ensureStore(store);
|
|
1381
|
+
} catch (e) {
|
|
1382
|
+
storeTrouble.push(`The folder Stays Fixed keeps its records in could not be made: ${messageOf(e)}`);
|
|
1383
|
+
}
|
|
1052
1384
|
|
|
1053
1385
|
const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-check-'));
|
|
1054
1386
|
const evidenceDir = path.join(scratch, 'evidence');
|
|
@@ -1079,18 +1411,76 @@ async function openProject(options) {
|
|
|
1079
1411
|
}
|
|
1080
1412
|
|
|
1081
1413
|
const candidate = await fingerprintWorkingTree(root, product);
|
|
1082
|
-
|
|
1414
|
+
// BOOKKEEPING MAY NOT COST THE ANSWER. Until 2026-08-30 these two writes were unguarded,
|
|
1415
|
+
// so a store that would not take them — a full disk, a permission taken away, a read-only
|
|
1416
|
+
// checkout — stopped the check dead before it had walked a single thing. What that threw
|
|
1417
|
+
// away is much larger than what it protected: the run could have opened the product,
|
|
1418
|
+
// walked it twice, compared it against the old build and handed back a real answer, and
|
|
1419
|
+
// all that was really lost was the note saying it had happened.
|
|
1420
|
+
//
|
|
1421
|
+
// So a failure here is remembered and said out loud, and the run carries on. Where the
|
|
1422
|
+
// record turns out to have been load-bearing after all — a reference named by a commit
|
|
1423
|
+
// that nothing can now register — the run still ends blocked, but it ends blocked SAYING
|
|
1424
|
+
// the store is the reason, instead of handing somebody a bare permission error from a
|
|
1425
|
+
// folder they have never heard of.
|
|
1426
|
+
try {
|
|
1427
|
+
await saveBuild(store, candidate);
|
|
1428
|
+
} catch (e) {
|
|
1429
|
+
storeTrouble.push(`The record of the build you have could not be written: ${messageOf(e)}`);
|
|
1430
|
+
}
|
|
1083
1431
|
|
|
1084
1432
|
// A name like "HEAD", "v0.13.0" or a branch is what a person types; the store only knows
|
|
1085
1433
|
// builds. Turning the name into a commit here, and putting that commit in the store, is
|
|
1086
1434
|
// what lets a check be aimed at any point in history without every commit having been
|
|
1087
1435
|
// walked before. Without it "HEAD" matches nothing and the check reports itself blocked.
|
|
1088
1436
|
const reference = options.against ? await fingerprintCommit(root, product, options.against) : null;
|
|
1089
|
-
if (reference)
|
|
1437
|
+
if (reference) {
|
|
1438
|
+
try {
|
|
1439
|
+
await saveBuild(store, reference);
|
|
1440
|
+
} catch (e) {
|
|
1441
|
+
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)}`);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Which commit the build you were happy with is at. With `--against` it is the commit that
|
|
1446
|
+
// was just named; without it, it is whatever the store's own reference pointer holds, which
|
|
1447
|
+
// is the usual case because a reference is cut by a person shipping. Either way it is the
|
|
1448
|
+
// thing that makes a committed change measurable, and nothing here had ever asked for it.
|
|
1449
|
+
/** @type {string|undefined} */
|
|
1450
|
+
let referenceSha = reference?.gitSha ?? undefined;
|
|
1451
|
+
if (!referenceSha) {
|
|
1452
|
+
try {
|
|
1453
|
+
const record = await referenceFor(store, product);
|
|
1454
|
+
referenceSha = record?.fingerprint?.gitSha ?? undefined;
|
|
1455
|
+
} catch (e) {
|
|
1456
|
+
// A damaged reference pointer is loud elsewhere. Here it costs the ordering, not the run.
|
|
1457
|
+
storeTrouble.push(`Which build counts as working could not be read, so a change you have already committed cannot be measured: ${messageOf(e)}`);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// It goes in the coverage list because that is the one list every reader already meets —
|
|
1462
|
+
// the command line prints it, the build server's table prints it, and the closing sentence
|
|
1463
|
+
// counts it. A fact that only exists on a field somebody has to know to look for is a fact
|
|
1464
|
+
// most readers never meet.
|
|
1465
|
+
/** @type {CoverageGap[]} */
|
|
1466
|
+
const gaps = [...gathered.gaps];
|
|
1467
|
+
if (namingTrouble !== '') {
|
|
1468
|
+
gaps.push({
|
|
1469
|
+
what: `This run was recorded against a product called "${product}", which is the name of the folder rather than the name of the project.`,
|
|
1470
|
+
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.`,
|
|
1471
|
+
unlockedBy: `Fix package.json, or put the name you want in your settings file as product: '<name>'. Until then every comparison starts from nothing.`,
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
if (storeTrouble.length > 0) {
|
|
1475
|
+
gaps.push({
|
|
1476
|
+
what: 'This run was NOT written down, so the next check has nothing from today to compare against.',
|
|
1477
|
+
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.`,
|
|
1478
|
+
unlockedBy: 'Free some disk space, or fix the permissions on the .staysfixed folder, and run the check again.',
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1090
1481
|
|
|
1091
1482
|
const rules = mergeRules(DEFAULT_RULES, [
|
|
1092
|
-
...
|
|
1093
|
-
...machineRules({ root: scratch }),
|
|
1483
|
+
...pathRules({ root, scratch }),
|
|
1094
1484
|
...(await loadRules(path.join(root, '.staysfixed', 'rules.json'))),
|
|
1095
1485
|
]);
|
|
1096
1486
|
|
|
@@ -1117,9 +1507,12 @@ async function openProject(options) {
|
|
|
1117
1507
|
product,
|
|
1118
1508
|
store,
|
|
1119
1509
|
candidate,
|
|
1510
|
+
keepBuilds: keepBuildsFrom(config),
|
|
1511
|
+
referenceSha,
|
|
1120
1512
|
against: reference ? reference.id : options.against,
|
|
1121
1513
|
journeys,
|
|
1122
|
-
gaps
|
|
1514
|
+
gaps,
|
|
1515
|
+
storeTrouble: storeTrouble.join(' '),
|
|
1123
1516
|
walk,
|
|
1124
1517
|
bootReference,
|
|
1125
1518
|
normalise,
|
|
@@ -1401,9 +1794,63 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
1401
1794
|
/** @type {CoverageGap[]} */
|
|
1402
1795
|
const gaps = [];
|
|
1403
1796
|
|
|
1404
|
-
const named =
|
|
1797
|
+
const named =
|
|
1798
|
+
options.journeys && !['code', 'config', 'suite'].includes(options.journeys) ? options.journeys : null;
|
|
1405
1799
|
if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
|
|
1406
1800
|
|
|
1801
|
+
// The project's own test suite, when somebody asked for it in those words and never
|
|
1802
|
+
// otherwise. This RUNS their tests — twice each, inside the same scratch clone everything
|
|
1803
|
+
// else uses, under a time budget — and that is a cost nobody gets charged by accident, so
|
|
1804
|
+
// it is off unless `--journeys suite` says so.
|
|
1805
|
+
//
|
|
1806
|
+
// It is worth switching on because it sees what walking a product cannot. On the fixture
|
|
1807
|
+
// where a total quietly stops rounding pennies, and the command line only ever adds whole
|
|
1808
|
+
// pounds, the discovered journeys produce nothing at all — the output does not move by one
|
|
1809
|
+
// character — and the harvested ones produce five findings.
|
|
1810
|
+
//
|
|
1811
|
+
// Loaded here rather than at the top of the file: a copy of this tool without the harvest
|
|
1812
|
+
// in it still runs every other kind of check, and saying so is better than failing to start.
|
|
1813
|
+
if (options.journeys === 'suite') {
|
|
1814
|
+
try {
|
|
1815
|
+
const { journeysFromSuite, DEFAULT_HARVEST_BUDGET_MS } = await import('./journeys/index.js');
|
|
1816
|
+
// The settings file gets a say in how long this is allowed to take. Left out, the
|
|
1817
|
+
// harvest applies its own default, which is why nothing is passed rather than the
|
|
1818
|
+
// default being copied to here — see `suiteBudgetFrom`.
|
|
1819
|
+
const budgetMs = suiteBudgetFrom(config);
|
|
1820
|
+
const suite = await journeysFromSuite({
|
|
1821
|
+
root,
|
|
1822
|
+
surface: options.surface === 'auto' ? undefined : options.surface,
|
|
1823
|
+
...(budgetMs === null ? {} : { suite: { budgetMs } }),
|
|
1824
|
+
// The harvest talks while it works, and it can take most of a minute. Its sentences
|
|
1825
|
+
// go into the same stream as everything else rather than nowhere.
|
|
1826
|
+
log: (message) => options.events?.emit({ type: 'note', at: options.events.elapsed(), message }),
|
|
1827
|
+
signal: options.signal,
|
|
1828
|
+
});
|
|
1829
|
+
// Said out loud, always, and before the harvest's own findings. A run held to ninety
|
|
1830
|
+
// seconds and a run allowed four minutes produce different amounts of coverage, and if
|
|
1831
|
+
// the number that decided it is invisible the two runs read as the same run.
|
|
1832
|
+
const applied = budgetMs ?? DEFAULT_HARVEST_BUDGET_MS;
|
|
1833
|
+
options.events?.emit({
|
|
1834
|
+
type: 'note',
|
|
1835
|
+
at: options.events.elapsed(),
|
|
1836
|
+
message:
|
|
1837
|
+
applied === 0
|
|
1838
|
+
? '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.'
|
|
1839
|
+
: `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.`,
|
|
1840
|
+
});
|
|
1841
|
+
journeys.push(...suite.journeys);
|
|
1842
|
+
gaps.push(...suite.gaps);
|
|
1843
|
+
} catch (e) {
|
|
1844
|
+
// A harvest that fell over is a hole, never a pass. Everything else this project has is
|
|
1845
|
+
// still walked, and the verdict carries the fact that its tests were not among it.
|
|
1846
|
+
gaps.push({
|
|
1847
|
+
what: 'Nothing was checked through this project\'s own test suite, because it could not be harvested.',
|
|
1848
|
+
why: messageOf(e),
|
|
1849
|
+
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.',
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1407
1854
|
for (const adapter of ADAPTERS) {
|
|
1408
1855
|
if (adapter === sourceAdapter && named && options.journeys !== 'code') {
|
|
1409
1856
|
// A journeys file names exactly what to walk. The contract read is still added,
|
|
@@ -1448,7 +1895,10 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
1448
1895
|
gaps.push({
|
|
1449
1896
|
what: `You asked for the journey "${wanted}" and there is no journey by that name, so it was not walked.`,
|
|
1450
1897
|
why: 'A name that matches nothing narrows the run to nothing rather than to what you meant.',
|
|
1451
|
-
|
|
1898
|
+
// The count goes on the end when the list was cut. Twelve names with nothing after
|
|
1899
|
+
// them read as the whole list, and somebody hunting for a name they mistyped would
|
|
1900
|
+
// conclude it is not there — when it is, at number thirteen.
|
|
1901
|
+
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
1902
|
});
|
|
1453
1903
|
}
|
|
1454
1904
|
}
|
|
@@ -1530,28 +1980,10 @@ async function readConfig(configFile) {
|
|
|
1530
1980
|
// Which build is which
|
|
1531
1981
|
// ---------------------------------------------------------------------------
|
|
1532
1982
|
|
|
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';
|
|
1983
|
+
// The pathspec that keeps Stays Fixed's own folder out of "what has changed here" lives in
|
|
1984
|
+
// rank.js, next to the other reader of the same two git calls. One name, so the fingerprint
|
|
1985
|
+
// and the distance measure can never disagree about what counts as the agent's edit — they
|
|
1986
|
+
// did for a fortnight, and only the fingerprint half had been fixed.
|
|
1555
1987
|
|
|
1556
1988
|
/**
|
|
1557
1989
|
* The build you have, named by what is actually in it.
|
|
@@ -1636,6 +2068,17 @@ async function fingerprintCommit(root, product, name) {
|
|
|
1636
2068
|
return build;
|
|
1637
2069
|
}
|
|
1638
2070
|
|
|
2071
|
+
/**
|
|
2072
|
+
* What to call a reference build in a sentence: the name a person typed, or its id.
|
|
2073
|
+
*
|
|
2074
|
+
* @param {BuildFingerprint} reference
|
|
2075
|
+
* @param {string} [asked]
|
|
2076
|
+
* @returns {string}
|
|
2077
|
+
*/
|
|
2078
|
+
function nameOfReference(reference, asked) {
|
|
2079
|
+
return asked && asked.trim() !== '' ? `${asked} (${reference.id})` : reference.id;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
1639
2082
|
/**
|
|
1640
2083
|
* Put the old build back on this machine so it can be walked live.
|
|
1641
2084
|
*
|
|
@@ -1731,24 +2174,45 @@ async function git(cwd, args) {
|
|
|
1731
2174
|
}
|
|
1732
2175
|
|
|
1733
2176
|
/**
|
|
2177
|
+
* The project's package.json, and — separately — whether there is one that could not be read.
|
|
2178
|
+
*
|
|
2179
|
+
* NO PACKAGE.JSON AND A DAMAGED ONE USED TO BE THE SAME NULL, and the difference decides what
|
|
2180
|
+
* a product is CALLED. With nothing else naming it, the name falls back to the folder name, so
|
|
2181
|
+
* a package.json with a stray comma in it silently renames the product: the store keys every
|
|
2182
|
+
* record under the new name, and every record kept under the real one — including the build
|
|
2183
|
+
* somebody called working — is orphaned. The run then says, perfectly calmly, that nothing has
|
|
2184
|
+
* ever been recorded here.
|
|
2185
|
+
*
|
|
1734
2186
|
* @param {string} root
|
|
1735
|
-
* @returns {Promise<Record<string, any>|null>}
|
|
2187
|
+
* @returns {Promise<{pkg: Record<string, any>|null, damaged: string}>}
|
|
2188
|
+
* `damaged` is empty except when the file is there and unreadable, when it is the sentence
|
|
2189
|
+
* a person gets.
|
|
1736
2190
|
*/
|
|
1737
2191
|
async function packageJson(root) {
|
|
2192
|
+
const file = path.join(root, 'package.json');
|
|
2193
|
+
/** @type {string} */
|
|
2194
|
+
let raw;
|
|
1738
2195
|
try {
|
|
1739
|
-
|
|
1740
|
-
} catch {
|
|
1741
|
-
|
|
2196
|
+
raw = await fsp.readFile(file, 'utf8');
|
|
2197
|
+
} catch (e) {
|
|
2198
|
+
const code = /** @type {{code?: string}} */ (e)?.code;
|
|
2199
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return { pkg: null, damaged: '' };
|
|
2200
|
+
return { pkg: null, damaged: `${file} is there and could not be read: ${messageOf(e)}` };
|
|
2201
|
+
}
|
|
2202
|
+
try {
|
|
2203
|
+
return { pkg: JSON.parse(raw), damaged: '' };
|
|
2204
|
+
} catch (e) {
|
|
2205
|
+
return { pkg: null, damaged: `${file} is not readable as JSON: ${messageOf(e)}` };
|
|
1742
2206
|
}
|
|
1743
2207
|
}
|
|
1744
2208
|
|
|
1745
2209
|
/**
|
|
1746
2210
|
* @param {string} root
|
|
1747
|
-
* @returns {Promise<string|null>}
|
|
2211
|
+
* @returns {Promise<{name: string|null, damaged: string}>}
|
|
1748
2212
|
*/
|
|
1749
2213
|
async function packageName(root) {
|
|
1750
|
-
const pkg = await packageJson(root);
|
|
1751
|
-
return typeof pkg?.name === 'string' ? pkg.name : null;
|
|
2214
|
+
const { pkg, damaged } = await packageJson(root);
|
|
2215
|
+
return { name: typeof pkg?.name === 'string' ? pkg.name : null, damaged };
|
|
1752
2216
|
}
|
|
1753
2217
|
|
|
1754
2218
|
/**
|
|
@@ -1756,7 +2220,7 @@ async function packageName(root) {
|
|
|
1756
2220
|
* @returns {Promise<string|null>}
|
|
1757
2221
|
*/
|
|
1758
2222
|
async function packageVersion(root) {
|
|
1759
|
-
const pkg = await packageJson(root);
|
|
2223
|
+
const { pkg } = await packageJson(root);
|
|
1760
2224
|
return typeof pkg?.version === 'string' ? pkg.version : null;
|
|
1761
2225
|
}
|
|
1762
2226
|
|