staysfixed 0.4.0 → 0.6.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 +159 -3
- package/README.md +83 -6
- package/package.json +1 -1
- package/src/v2/adapters/contract.js +84 -6
- package/src/v2/adapters/electron.js +5 -5
- package/src/v2/adapters/http.js +7 -6
- package/src/v2/adapters/process.js +17 -6
- package/src/v2/adapters/source.js +62 -11
- package/src/v2/adapters/web.js +6 -6
- package/src/v2/cause.js +15 -8
- package/src/v2/check.js +121 -23
- package/src/v2/cli.js +16 -3
- package/src/v2/coverage.js +9 -1
- package/src/v2/detect.js +1 -1
- package/src/v2/doctor.js +15 -3
- package/src/v2/journeys/from-routes.js +3 -1
- package/src/v2/observation.js +62 -1
- package/src/v2/remote.js +4 -5
- package/src/v2/run.js +125 -9
- package/src/v2/sealed.js +10 -6
- package/src/v2/selfcheck.js +197 -32
- package/src/v2/types.js +6 -0
- package/src/v2/watch/focus.js +215 -0
package/src/v2/observation.js
CHANGED
|
@@ -78,6 +78,17 @@ const MAX_PATH_LENGTH = 512;
|
|
|
78
78
|
/** Deepest value we will store. Past this something is recursing, not observing. */
|
|
79
79
|
const MAX_VALUE_DEPTH = 64;
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The share of its own addresses a build may disagree with itself about before the run stops
|
|
83
|
+
* counting as a measurement at all. Half is not a tuned number and nothing depends on its
|
|
84
|
+
* exact value: it is the point past which more of the comparison has been thrown away than
|
|
85
|
+
* kept, and no answer computed from what is left deserves to be called clean.
|
|
86
|
+
*/
|
|
87
|
+
const STORM_SHARE = 0.5;
|
|
88
|
+
|
|
89
|
+
/** Below this many addresses the share means nothing — three out of four is not a storm. */
|
|
90
|
+
const STORM_FLOOR = 12;
|
|
91
|
+
|
|
81
92
|
/** Control characters and newlines, which would break the store and every log line. */
|
|
82
93
|
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
83
94
|
|
|
@@ -787,6 +798,42 @@ export function mergeWobble(wobbles) {
|
|
|
787
798
|
};
|
|
788
799
|
}
|
|
789
800
|
|
|
801
|
+
/**
|
|
802
|
+
* When a wobble measurement stops being a measurement.
|
|
803
|
+
*
|
|
804
|
+
* Subtraction is set subtraction: a difference at an address the build cannot answer the same
|
|
805
|
+
* way twice is dropped. That is right, and it has one failure shape, which is the worst shape
|
|
806
|
+
* this tool has. If the second run of the new build FALLS OVER — the app crashed half way, a
|
|
807
|
+
* port was taken, a device went to sleep, a first run wrote a cache the second one read — then
|
|
808
|
+
* most of the addresses the first run answered are missing from the second, every one of them
|
|
809
|
+
* is filed as unsteady, every real difference at them is subtracted, and the run ends
|
|
810
|
+
* "nothing that already worked has changed". Confident, clean, and about nothing.
|
|
811
|
+
*
|
|
812
|
+
* So the share is looked at. A product that disagrees with itself about a handful of addresses
|
|
813
|
+
* is normal — a timestamp, an id, a port. A product that disagrees with itself about MOST of
|
|
814
|
+
* them did not wobble; something went wrong with the run. This is not a tolerance: no number
|
|
815
|
+
* here decides whether any difference is real. It decides one thing only — whether this run is
|
|
816
|
+
* entitled to say the word "clean".
|
|
817
|
+
*
|
|
818
|
+
* @param {Wobble} wobble
|
|
819
|
+
* @returns {{stormy: boolean, share: number, looked: number, vanished: number, why: string}}
|
|
820
|
+
*/
|
|
821
|
+
export function wobbleStorm(wobble) {
|
|
822
|
+
const unstable = wobble.unstable.length;
|
|
823
|
+
const looked = unstable + wobble.steady;
|
|
824
|
+
const vanished = wobble.entries.filter((e) => e.kind === 'vanished').length;
|
|
825
|
+
const share = looked === 0 ? 0 : unstable / looked;
|
|
826
|
+
if (!wobble.measured || looked < STORM_FLOOR || share <= STORM_SHARE) {
|
|
827
|
+
return { stormy: false, share, looked, vanished, why: '' };
|
|
828
|
+
}
|
|
829
|
+
const percent = Math.round(share * 100);
|
|
830
|
+
const why =
|
|
831
|
+
`The new build was run twice and gave a different answer at ${unstable} of the ${looked} addresses it was asked about — ${percent}% of them` +
|
|
832
|
+
(vanished > 0 ? `, and ${vanished} address${vanished === 1 ? '' : 'es'} the first run answered were missing from the second altogether` : '') +
|
|
833
|
+
'. That is not a product wobbling; that is a run that went wrong. Everything it disagreed with itself about is dropped before anything is compared, so on this run the comparison covered almost nothing. This is not a pass and not a failure — there is no answer here. Run it again on a quiet machine, and if it happens twice, something in the product or its setup does not survive being started a second time.';
|
|
834
|
+
return { stormy: true, share, looked, vanished, why };
|
|
835
|
+
}
|
|
836
|
+
|
|
790
837
|
/**
|
|
791
838
|
* Subtract the measured noise from the differences.
|
|
792
839
|
*
|
|
@@ -807,6 +854,12 @@ export function mergeWobble(wobbles) {
|
|
|
807
854
|
*/
|
|
808
855
|
export function subtractWobble(differences, wobble, opts = {}) {
|
|
809
856
|
const unstableNow = new Set(wobble.unstable);
|
|
857
|
+
// NOT symmetric, and that is deliberate. Subtracting the OLD build's wobble as well was
|
|
858
|
+
// tried on 2026-08-30 and taken straight back out: a path the old build answered randomly
|
|
859
|
+
// and the new build now answers the same way every time is a REAL change — somebody made
|
|
860
|
+
// something deterministic, or hard-coded what used to vary — and subtracting the old
|
|
861
|
+
// build's wobble is exactly what would hide it. Where both builds wobble at a path, the
|
|
862
|
+
// candidate's own wobble already covers it, so nothing is lost by leaving this alone.
|
|
810
863
|
|
|
811
864
|
/** @type {Difference[]} */
|
|
812
865
|
const real = [];
|
|
@@ -838,13 +891,21 @@ export function subtractWobble(differences, wobble, opts = {}) {
|
|
|
838
891
|
});
|
|
839
892
|
}
|
|
840
893
|
|
|
841
|
-
|
|
894
|
+
const storm = wobbleStorm(wobble);
|
|
895
|
+
/** @type {WobbleSubtraction} */
|
|
896
|
+
const out = {
|
|
842
897
|
real,
|
|
843
898
|
noise,
|
|
844
899
|
newlyUnstable,
|
|
845
900
|
couldTellNewlyUnstable: couldTell,
|
|
846
901
|
note: subtractionNote(wobble, couldTell, real.length, noise.length, newlyUnstable.length),
|
|
847
902
|
};
|
|
903
|
+
if (storm.stormy) {
|
|
904
|
+
out.couldNotTell = true;
|
|
905
|
+
out.couldNotTellWhy = storm.why;
|
|
906
|
+
out.note = `${storm.why} ${out.note}`;
|
|
907
|
+
}
|
|
908
|
+
return out;
|
|
848
909
|
}
|
|
849
910
|
|
|
850
911
|
/**
|
package/src/v2/remote.js
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
import { spawn } from 'node:child_process';
|
|
40
40
|
import { StaysFixedError } from '../core/errors.js';
|
|
41
|
-
import { joinPath, notCovered, observation, sizeBucket, timeBucket, trimForStorage } from './adapters/contract.js';
|
|
41
|
+
import { howLongItTook, joinPath, notCovered, observation, sizeBucket, timeBucket, trimForStorage } from './adapters/contract.js';
|
|
42
42
|
|
|
43
43
|
/** @typedef {import('./types.js').Observation} Observation */
|
|
44
44
|
/** @typedef {import('./types.js').Journey} Journey */
|
|
@@ -629,13 +629,12 @@ export function remoteRunner(opts) {
|
|
|
629
629
|
journey: journey.name,
|
|
630
630
|
surface,
|
|
631
631
|
}));
|
|
632
|
-
seen.push(
|
|
632
|
+
seen.push(howLongItTook({
|
|
633
633
|
channel: 'counters',
|
|
634
634
|
path: joinPath('remote', host, journey.name, String(index), 'took'),
|
|
635
|
-
|
|
636
|
-
|
|
635
|
+
ms: result.ms,
|
|
636
|
+
what: `On ${host}, "${label}"`,
|
|
637
637
|
journey: journey.name,
|
|
638
|
-
surface,
|
|
639
638
|
}));
|
|
640
639
|
} catch (error) {
|
|
641
640
|
// The link went. Everything from here on is unchecked, and it says so.
|
package/src/v2/run.js
CHANGED
|
@@ -24,6 +24,7 @@ import { makeEvents } from '../core/events.js';
|
|
|
24
24
|
import { StaysFixedError, messageOf } from '../core/errors.js';
|
|
25
25
|
import {
|
|
26
26
|
diffCaptures,
|
|
27
|
+
findDuplicatePaths,
|
|
27
28
|
measureWobble,
|
|
28
29
|
mergeWobble,
|
|
29
30
|
unmeasuredWobble,
|
|
@@ -104,6 +105,9 @@ const VERSION = /** @type {{version?: string}} */ (require('../../package.json')
|
|
|
104
105
|
* @property {string} product One repo can build five products. This names one.
|
|
105
106
|
* @property {BuildFingerprint} candidate The build you just made.
|
|
106
107
|
* @property {Journey[]} journeys
|
|
108
|
+
* @property {CoverageGap[]} [gaps] Holes found before any journey ran — an adapter that
|
|
109
|
+
* fell over while listing what it would walk, a name
|
|
110
|
+
* that matched nothing. They belong in the coverage.
|
|
107
111
|
* @property {Walker} walk
|
|
108
112
|
* @property {string} cwd Project root — where the working diff is read.
|
|
109
113
|
* @property {(candidate: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} [bootReference]
|
|
@@ -178,7 +182,7 @@ export async function runCheck(opts) {
|
|
|
178
182
|
|
|
179
183
|
const journeys = (opts.journeys ?? []).filter((j) => !j.skip);
|
|
180
184
|
/** @type {CoverageGap[]} */
|
|
181
|
-
const gaps = [];
|
|
185
|
+
const gaps = [...(opts.gaps ?? [])];
|
|
182
186
|
for (const skipped of (opts.journeys ?? []).filter((j) => j.skip)) {
|
|
183
187
|
// A switched-off journey is missing coverage, never a pass. Anything else
|
|
184
188
|
// lets a product go quiet by having its checks turned off one at a time.
|
|
@@ -227,6 +231,11 @@ export async function runCheck(opts) {
|
|
|
227
231
|
const walked = new Map();
|
|
228
232
|
/** @type {Map<string, Capture>} */
|
|
229
233
|
const before = new Map();
|
|
234
|
+
// Counted rather than inferred. Both the live build and the stored record carry the SAME
|
|
235
|
+
// build id — they are two ways of looking at one build — so nothing about a capture in
|
|
236
|
+
// `before` says which of the two it came from, and the mode has to be recorded as it
|
|
237
|
+
// happens or not at all.
|
|
238
|
+
let liveWalks = 0;
|
|
230
239
|
/** @type {Wobble[]} */
|
|
231
240
|
const wobbles = [];
|
|
232
241
|
/** @type {Wobble[]} */
|
|
@@ -240,6 +249,7 @@ export async function runCheck(opts) {
|
|
|
240
249
|
say({ type: 'journey:start', at: events.elapsed(), journey: journey.name, message: `Walking ${journey.describe || journey.name}.` });
|
|
241
250
|
|
|
242
251
|
const a = await walkOnce(opts, journey, opts.candidate, 'a', 'candidate', undefined, events);
|
|
252
|
+
gaps.push(...duplicateGaps(a.observations, journey));
|
|
243
253
|
const b = await walkOnce(opts, journey, opts.candidate, 'b', 'candidate', undefined, events);
|
|
244
254
|
const wobble = measureWobble(a, b);
|
|
245
255
|
walked.set(journey.name, { a, b, wobble });
|
|
@@ -264,9 +274,34 @@ export async function runCheck(opts) {
|
|
|
264
274
|
// switched off, and nothing in the output would say so.
|
|
265
275
|
const wasA = await walkOnce(opts, journey, live.build, 'a', 'reference', live, events);
|
|
266
276
|
const wasB = await walkOnce(opts, journey, live.build, 'b', 'reference', live, events);
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
277
|
+
// The old build being ON this machine is not the same as the old build having been
|
|
278
|
+
// WALKED. When every observation it came back with is a hole, it was not walked, and
|
|
279
|
+
// treating that as the reference makes the whole product look newly invented: every
|
|
280
|
+
// address in the new build has nothing opposite it, so every one of them 'appeared'.
|
|
281
|
+
//
|
|
282
|
+
// Measured on Terminal Deck's Android app on 2026-08-30. `--paired` exports the old
|
|
283
|
+
// commit with `git archive`; an APK is a build output and is gitignored, so the export
|
|
284
|
+
// has no app in it; the adapter honestly reported one hole per journey; and the run
|
|
285
|
+
// came back with seventeen sealed escalations claiming the sign-in screen and every
|
|
286
|
+
// permission had appeared out of nowhere, with nothing anywhere saying the old build
|
|
287
|
+
// had never run. Falling back to the stored record here is weaker and says so, which
|
|
288
|
+
// is the whole difference between a weaker answer and a wrong one.
|
|
289
|
+
if (wasA.observations.some((o) => o.meta?.refused !== true)) {
|
|
290
|
+
before.set(journey.name, wasA);
|
|
291
|
+
referenceWobbles.push(measureWobble(wasA, wasB));
|
|
292
|
+
liveWalks += 1;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
gaps.push({
|
|
296
|
+
what: `The old build could not be walked for "${journey.describe || journey.name}", so this was not a paired comparison after all.`,
|
|
297
|
+
why:
|
|
298
|
+
`${nameOf(reference)} was put back on this machine, and then there was nothing there to run: ${
|
|
299
|
+
wasA.observations[0]?.meta?.describe ?? 'the adapter could not open it'
|
|
300
|
+
}. This usually means the product is BUILT rather than committed — an APK, a .app, a packaged desktop app — and a checkout of the old commit does not contain one.`,
|
|
301
|
+
unlockedBy:
|
|
302
|
+
'Build the old commit before the run, or point the settings at a kept copy of the old build\'s artifact. Until then this journey falls back to the record the old build left last time.',
|
|
303
|
+
surface: journey.surface,
|
|
304
|
+
});
|
|
270
305
|
}
|
|
271
306
|
const stored = await storedReference(opts.store, reference.id, journey.name);
|
|
272
307
|
if (!stored.capture) {
|
|
@@ -279,6 +314,21 @@ export async function runCheck(opts) {
|
|
|
279
314
|
continue;
|
|
280
315
|
}
|
|
281
316
|
before.set(journey.name, stored.capture);
|
|
317
|
+
// The rules stamp exists so a run can notice this, and until 2026-08-30 nothing ever
|
|
318
|
+
// read it. A stored capture normalised under one set of rules compared against a fresh
|
|
319
|
+
// one normalised under another produces differences that are about the RULES — either a
|
|
320
|
+
// wall of noise that reads like a regression, or, when the change was to add a rule,
|
|
321
|
+
// quiet where there should not be any. Either way the reader has to be told.
|
|
322
|
+
if (stored.capture.rules && a.rules && stored.capture.rules !== a.rules) {
|
|
323
|
+
gaps.push({
|
|
324
|
+
what: `"${journey.describe || journey.name}" is being compared across a change to the normalisation rules.`,
|
|
325
|
+
why:
|
|
326
|
+
`The stored record of the old build was tidied up by rule set ${stored.capture.rules} and this run used ${a.rules}. ` +
|
|
327
|
+
'Some of what you see may be the rules changing rather than the product, and a rule that was added since could be covering something up.',
|
|
328
|
+
unlockedBy: 'Run a paired check, which walks the old build live under today\'s rules, or ship again to cut a fresh reference.',
|
|
329
|
+
surface: journey.surface,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
282
332
|
if (stored.wobble) steadyInReference.push(...steadyPaths(stored.capture, stored.wobble));
|
|
283
333
|
else referenceWobbleMeasured = false;
|
|
284
334
|
}
|
|
@@ -337,14 +387,30 @@ export async function runCheck(opts) {
|
|
|
337
387
|
count: subtraction.real.length,
|
|
338
388
|
message: subtraction.note,
|
|
339
389
|
});
|
|
390
|
+
// A wobble big enough to swallow the comparison is not a result. It is recorded as a hole
|
|
391
|
+
// here and it takes the verdict down at the bottom of this function, because the one thing
|
|
392
|
+
// that must never come out of it is a clean sentence resting on a subtraction that removed
|
|
393
|
+
// most of what was looked at.
|
|
394
|
+
if (subtraction.couldNotTell === true) {
|
|
395
|
+
gaps.push({
|
|
396
|
+
what: 'This run could not tell you anything, because the new build did not answer the same way twice.',
|
|
397
|
+
why: subtraction.couldNotTellWhy ?? 'Most of the addresses it was asked about were unsteady, so almost everything was dropped before it was compared.',
|
|
398
|
+
unlockedBy: 'Run it again when the machine is quiet. If it happens twice, look at what the product does differently on a second start.',
|
|
399
|
+
});
|
|
400
|
+
}
|
|
340
401
|
stop();
|
|
341
402
|
|
|
342
403
|
// 5 — expensive proof, only where it is owed. Everything the live old build
|
|
343
404
|
// does too is dropped silently and counted. That silence is the point: it is
|
|
344
405
|
// what keeps this list short enough to read every word of.
|
|
345
406
|
let survivors = subtraction.real;
|
|
346
|
-
|
|
347
|
-
|
|
407
|
+
// Booting the old build is not the same as having walked it. When every live walk came
|
|
408
|
+
// back holes-only — a built artifact that no checkout of the old commit contains — the
|
|
409
|
+
// run fell back to the stored record above, and calling that a paired run would be the
|
|
410
|
+
// report's single most misleading sentence. See the gap pushed in the walk loop.
|
|
411
|
+
const walkedLive = liveWalks > 0;
|
|
412
|
+
const mode = /** @type {'paired'|'stored-record'} */ (walkedLive ? 'paired' : 'stored-record');
|
|
413
|
+
let provedLive = walkedLive;
|
|
348
414
|
// How many suspicions the old build turned out to have as well. Naming this
|
|
349
415
|
// number is what makes the short list believable: it says how much work the
|
|
350
416
|
// expensive half did rather than leaving the reader to assume it did none.
|
|
@@ -403,7 +469,7 @@ export async function runCheck(opts) {
|
|
|
403
469
|
if (warning) gaps.push(...warningGaps(mode, provedLive));
|
|
404
470
|
|
|
405
471
|
return finish(opts, {
|
|
406
|
-
ok: ranked.findings.length === 0 && subtraction.newlyUnstable.length === 0,
|
|
472
|
+
ok: ranked.findings.length === 0 && subtraction.newlyUnstable.length === 0 && subtraction.couldNotTell !== true,
|
|
407
473
|
mode,
|
|
408
474
|
modeWarning: warning,
|
|
409
475
|
reference,
|
|
@@ -412,7 +478,9 @@ export async function runCheck(opts) {
|
|
|
412
478
|
noise: subtraction.noise.length,
|
|
413
479
|
newlyUnstable: subtraction.newlyUnstable,
|
|
414
480
|
coverage: foldCoverage(walked, journeys, gaps),
|
|
415
|
-
summary:
|
|
481
|
+
summary:
|
|
482
|
+
(subtraction.couldNotTell === true ? `NO ANSWER FROM THIS RUN. ${subtraction.couldNotTellWhy} ` : '') +
|
|
483
|
+
summarise(ranked.findings, subtraction, wobble, warning, ranked.notes, reference, provedLive, dropped),
|
|
416
484
|
startedAt,
|
|
417
485
|
started,
|
|
418
486
|
events,
|
|
@@ -506,6 +574,33 @@ async function storedReference(store, buildId, journey) {
|
|
|
506
574
|
}
|
|
507
575
|
}
|
|
508
576
|
|
|
577
|
+
/**
|
|
578
|
+
* Two facts written down at one address, with two different answers.
|
|
579
|
+
*
|
|
580
|
+
* Every index in this engine keeps the FIRST observation at a path and ignores the rest, so
|
|
581
|
+
* the second fact has no address of its own: it is never compared with anything, and a door
|
|
582
|
+
* that broke behind it is invisible while the run still says "nothing that already worked has
|
|
583
|
+
* changed". The detector for this existed from the first day of v2 and until 2026-08-30
|
|
584
|
+
* nothing ever called it, which is why it is a named hole now rather than a comment.
|
|
585
|
+
*
|
|
586
|
+
* Identical repeats are not reported. Two log lines that tidy down to the same address AND the
|
|
587
|
+
* same value hide nothing, and reporting those would bury the ones that do.
|
|
588
|
+
*
|
|
589
|
+
* @param {Observation[]} observations
|
|
590
|
+
* @param {Journey} journey
|
|
591
|
+
* @returns {CoverageGap[]}
|
|
592
|
+
*/
|
|
593
|
+
export function duplicateGaps(observations, journey) {
|
|
594
|
+
return findDuplicatePaths(observations).map((clash) => ({
|
|
595
|
+
what: `Two different answers were written down at the same address, ${clash.path}, while walking "${journey.describe || journey.name}".`,
|
|
596
|
+
why:
|
|
597
|
+
`Only the first is kept, so ${clash.values.slice(1).map((v) => JSON.stringify(v)).join(' and ')} ` +
|
|
598
|
+
`${clash.values.length > 2 ? 'were' : 'was'} never compared against anything at all. Whatever produced that address is giving one name to more than one thing.`,
|
|
599
|
+
unlockedBy: 'The adapter that made that address has to give those two things two different names.',
|
|
600
|
+
surface: journey.surface,
|
|
601
|
+
}));
|
|
602
|
+
}
|
|
603
|
+
|
|
509
604
|
/**
|
|
510
605
|
* @param {CheckRun} opts
|
|
511
606
|
* @param {BuildFingerprint} reference
|
|
@@ -569,7 +664,28 @@ async function walkOnce(opts, journey, build, run, which, live, events) {
|
|
|
569
664
|
export function proveAgainstLive(suspicions, live, now) {
|
|
570
665
|
/** @type {Map<string, Map<string, Observation>>} */
|
|
571
666
|
const liveIndex = new Map();
|
|
572
|
-
for (const [name, capture] of live)
|
|
667
|
+
for (const [name, capture] of live) {
|
|
668
|
+
// A capture that came back with nothing it could actually observe is NOT a walk of the
|
|
669
|
+
// old build, and treating it as one is the worst mistake this function can make: every
|
|
670
|
+
// stored before-value is thrown away, every difference is relabelled 'appeared' with no
|
|
671
|
+
// before-value at all, and the whole lot is stamped proven, which the report then reads
|
|
672
|
+
// out as "re-checked against the old build booted live, so none of it is drift".
|
|
673
|
+
//
|
|
674
|
+
// Measured on Terminal Deck's Android app on 2026-08-30. The old build is exported with
|
|
675
|
+
// `git archive`, an APK is a build output and is gitignored, so the exported checkout has
|
|
676
|
+
// no APK in it, prepare gives up, and the adapter correctly returns one uncovered
|
|
677
|
+
// observation saying so. A control that went from greyed out to usable — the exact
|
|
678
|
+
// regression the run was meant to catch — was reported as a control that had appeared out
|
|
679
|
+
// of nowhere, with `false` never mentioned. Any platform whose artifact is built rather
|
|
680
|
+
// than committed hits this, not only phones.
|
|
681
|
+
// `covered` is the ADAPTER's word for this and it does not survive onto an Observation:
|
|
682
|
+
// `observation()` in adapters/contract.js turns `covered: false` into `meta.refused`.
|
|
683
|
+
// Filtering on `o.covered` therefore matched everything and did nothing at all — the
|
|
684
|
+
// fix above was written correctly and then read the wrong field.
|
|
685
|
+
const walked = capture.observations.filter((o) => o.meta?.refused !== true);
|
|
686
|
+
if (walked.length === 0) continue;
|
|
687
|
+
liveIndex.set(name, indexByPath(walked));
|
|
688
|
+
}
|
|
573
689
|
/** @type {Map<string, Map<string, Observation>>} */
|
|
574
690
|
const nowIndex = new Map();
|
|
575
691
|
for (const [name, pair] of now) nowIndex.set(name, indexByPath(pair.a.observations));
|
package/src/v2/sealed.js
CHANGED
|
@@ -205,8 +205,14 @@ const SHORTEST_GUARD_NAME = 6;
|
|
|
205
205
|
/** How much of one value is read. A whole HTTP body would drown the match in noise. */
|
|
206
206
|
const VALUE_CHARS = 400;
|
|
207
207
|
|
|
208
|
-
|
|
209
|
-
|
|
208
|
+
// EVERY difference of a finding is read, and there is deliberately no ceiling on that.
|
|
209
|
+
// Until 2026-08-30 this file read the first eighty and stopped, which meant a cluster of
|
|
210
|
+
// three hundred addresses could hold the word `refund` at address two hundred and be
|
|
211
|
+
// classified ordinary — waivable by an agent, never seen by a person. A cap here is not a
|
|
212
|
+
// performance decision, it is a hole in the one gate that cannot have one. The cost is a
|
|
213
|
+
// substring search over text already in memory: the values are trimmed to VALUE_CHARS
|
|
214
|
+
// before they are searched, so reading them all costs a constant multiple of the finding
|
|
215
|
+
// itself, which the caller is already holding.
|
|
210
216
|
|
|
211
217
|
// ---------------------------------------------------------------------------
|
|
212
218
|
// The answer
|
|
@@ -340,7 +346,6 @@ export function sayRefusal(verdict, finding) {
|
|
|
340
346
|
*
|
|
341
347
|
* @typedef {object} ReadFinding
|
|
342
348
|
* @property {{text: string, where: string}[]} pieces
|
|
343
|
-
* @property {string} all Every piece joined, for one fast test.
|
|
344
349
|
* @property {Set<Channel>} channels
|
|
345
350
|
* @property {Difference[]} differences
|
|
346
351
|
*/
|
|
@@ -370,7 +375,7 @@ function readFinding(finding) {
|
|
|
370
375
|
for (const file of finding.nearFiles ?? []) add(file, 'a source file this points at');
|
|
371
376
|
for (const p of finding.paths ?? []) add(p, p);
|
|
372
377
|
|
|
373
|
-
const differences =
|
|
378
|
+
const differences = finding.differences ?? [];
|
|
374
379
|
for (const d of differences) {
|
|
375
380
|
add(d.path, d.path);
|
|
376
381
|
add(d.describe, d.path);
|
|
@@ -386,8 +391,7 @@ function readFinding(finding) {
|
|
|
386
391
|
|
|
387
392
|
return {
|
|
388
393
|
pieces,
|
|
389
|
-
|
|
390
|
-
channels: new Set((finding.differences ?? []).map((d) => d.channel)),
|
|
394
|
+
channels: new Set(differences.map((d) => d.channel)),
|
|
391
395
|
differences,
|
|
392
396
|
};
|
|
393
397
|
}
|