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/types.js
CHANGED
|
@@ -152,7 +152,6 @@
|
|
|
152
152
|
* @property {string|null} [branch]
|
|
153
153
|
* @property {boolean} [dirty] Working tree had uncommitted changes.
|
|
154
154
|
* @property {string} [artifact] Path to the built thing. NOT stored here — see store.js.
|
|
155
|
-
* @property {string} [artifactSha256]
|
|
156
155
|
* @property {string} [builtAt] ISO timestamp.
|
|
157
156
|
* @property {string} [platform] e.g. 'darwin-arm64'. Comparing across platforms warns.
|
|
158
157
|
* @property {string} [tool] Stays Fixed version that captured it.
|
|
@@ -183,7 +182,12 @@
|
|
|
183
182
|
* @property {Coverage} [coverage] What this capture did NOT manage to look at.
|
|
184
183
|
* @property {boolean} [complete] False when the file was read back torn — see store.js.
|
|
185
184
|
* @property {string} [note]
|
|
186
|
-
* @property {string} [rules]
|
|
185
|
+
* @property {string} [rules] Fingerprint of what the normalisation rules DO, if any
|
|
186
|
+
* were applied. Scope is stamped separately — see rulesScope.
|
|
187
|
+
* @property {Record<string, string[]>} [rulesScope]
|
|
188
|
+
* Where each scoped rule applied, by rule id. Absent on
|
|
189
|
+
* captures written before this was stamped, which is a real
|
|
190
|
+
* state and says "cannot be compared" rather than "nothing".
|
|
187
191
|
*/
|
|
188
192
|
|
|
189
193
|
// ---------------------------------------------------------------------------
|
|
@@ -438,6 +442,12 @@ export {};
|
|
|
438
442
|
* '$.items.3.name'. Used by sort, round and drop.
|
|
439
443
|
* @property {boolean} [off] Shipped, documented, and not switched on.
|
|
440
444
|
* @property {string} [whyOff] Why it is not on by default.
|
|
445
|
+
* @property {boolean} [machine] This rule's pattern is a fact about THIS machine — where
|
|
446
|
+
* the project is checked out, where home is, where the temp
|
|
447
|
+
* folder went today — rather than a decision about what to
|
|
448
|
+
* tidy. Two machines running the same rule write the same
|
|
449
|
+
* placeholder, so the rule is the same rule and its pattern
|
|
450
|
+
* must not reach the fingerprint. See rulesFingerprint.
|
|
441
451
|
*/
|
|
442
452
|
|
|
443
453
|
/**
|
package/src/v2/waiver.js
CHANGED
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
* different difference and is reported.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import fsp from 'node:fs/promises';
|
|
40
39
|
import path from 'node:path';
|
|
41
40
|
import crypto from 'node:crypto';
|
|
42
41
|
|
|
@@ -48,9 +47,10 @@ import {
|
|
|
48
47
|
readIntent,
|
|
49
48
|
readIntentById,
|
|
50
49
|
referenceStamp,
|
|
50
|
+
fingerprintTree,
|
|
51
|
+
treeMovedSince,
|
|
51
52
|
readJsonFile,
|
|
52
53
|
writeJsonAtomic,
|
|
53
|
-
shortDigest,
|
|
54
54
|
} from './intent.js';
|
|
55
55
|
|
|
56
56
|
/**
|
|
@@ -71,10 +71,15 @@ import {
|
|
|
71
71
|
*/
|
|
72
72
|
export const WAIVER_BUDGET = 5;
|
|
73
73
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
/**
|
|
75
|
+
* How many dead waivers are kept per product, so a summary can still say what expired.
|
|
76
|
+
*
|
|
77
|
+
* LIVE ONES ARE NEVER PRUNED, which is what matters: the budget, the gates and whether a
|
|
78
|
+
* difference is covered are all worked out from those alone and none of them can be affected
|
|
79
|
+
* by this number. What it can affect is the sentence "three waivers expired when you
|
|
80
|
+
* shipped", which counts the dead ones still on disk — so after fifty of them, spread over
|
|
81
|
+
* ten ships or more, that number is the recent history rather than the whole of it.
|
|
82
|
+
*/
|
|
78
83
|
const KEEP_EXPIRED = 50;
|
|
79
84
|
|
|
80
85
|
// ---------------------------------------------------------------------------
|
|
@@ -94,12 +99,22 @@ const KEEP_EXPIRED = 50;
|
|
|
94
99
|
* @property {string} fingerprint Pins the exact difference, values included.
|
|
95
100
|
* @property {string} finding The finding's own id, when it had one.
|
|
96
101
|
* @property {string} summary The finding's title, kept so this reads without a check.
|
|
97
|
-
* @property {string[]} paths The addresses involved,
|
|
102
|
+
* @property {string[]} paths The first few addresses involved, for somebody reading
|
|
103
|
+
* this back later. Nothing is decided from them: what the
|
|
104
|
+
* waiver actually covers is `fingerprint`, which takes in
|
|
105
|
+
* every difference with no ceiling at all.
|
|
98
106
|
* @property {FindingClass} class What the engine called it. Always ordinary — see gate 1.
|
|
99
107
|
* @property {string} why The agent's reason, in its own words.
|
|
100
108
|
* @property {string} intentId
|
|
101
109
|
* @property {string} intentSummary Copied, so a pruned intent does not orphan the waiver.
|
|
102
110
|
* @property {string} ordering What was known about when the intent was sealed.
|
|
111
|
+
* @property {{moved: boolean, knowable: boolean, say: string}} codeSince
|
|
112
|
+
* Whether the code moved between sealing that intent and
|
|
113
|
+
* writing this. Not a gate — a moved tree is what an
|
|
114
|
+
* intent sealed BEFORE the work is supposed to look like.
|
|
115
|
+
* It is recorded because the alternative is that nobody
|
|
116
|
+
* reading this waiver a month later can tell whether the
|
|
117
|
+
* intent describes the build that was actually checked.
|
|
103
118
|
* @property {IntentCoverage} coverage How well it matched what was declared, and how sure.
|
|
104
119
|
* @property {string} at ISO. Written here, never supplied.
|
|
105
120
|
* @property {string} [by]
|
|
@@ -284,6 +299,12 @@ export async function waive(store, what) {
|
|
|
284
299
|
};
|
|
285
300
|
}
|
|
286
301
|
|
|
302
|
+
// The last of the three things intent.js says its tree fingerprint makes checkable. The
|
|
303
|
+
// other two are gates above; this one cannot be, because both answers are legitimate — an
|
|
304
|
+
// intent sealed before the work SHOULD see a moved tree, and one sealed after it should
|
|
305
|
+
// not. So it is written down rather than judged, and a person reading the waiver decides.
|
|
306
|
+
const codeSince = treeMovedSince(intent, await fingerprintTree(store.root));
|
|
307
|
+
|
|
287
308
|
/** @type {Waiver} */
|
|
288
309
|
const waiver = {
|
|
289
310
|
id: `waiver-${crypto.randomBytes(5).toString('hex')}`,
|
|
@@ -297,6 +318,7 @@ export async function waive(store, what) {
|
|
|
297
318
|
intentId: intent.id,
|
|
298
319
|
intentSummary: intent.summary,
|
|
299
320
|
ordering: intent.ordering,
|
|
321
|
+
codeSince,
|
|
300
322
|
coverage,
|
|
301
323
|
at: new Date().toISOString(),
|
|
302
324
|
reference: stamp,
|
|
@@ -318,6 +340,7 @@ export async function waive(store, what) {
|
|
|
318
340
|
`Recorded as intended: ${waiver.summary}`,
|
|
319
341
|
`Your reason, kept: ${why}`,
|
|
320
342
|
`Matched against what you sealed: ${coverage.why} (${coverage.confidence} match)`,
|
|
343
|
+
codeSince.say,
|
|
321
344
|
'',
|
|
322
345
|
`${left} of your ${WAIVER_BUDGET} waivers left before the next ship. This one is pinned to the exact values that differ and to the reference in force now: if either moves, it stops covering anything.`,
|
|
323
346
|
'This is not approval. Nothing becomes the new normal until a build ships. Say in what you report back that you waived this, and why.',
|
|
@@ -371,45 +394,6 @@ export function waiverFor(waivers, finding) {
|
|
|
371
394
|
return waivers.find((w) => w.fingerprint === fingerprint) ?? null;
|
|
372
395
|
}
|
|
373
396
|
|
|
374
|
-
/**
|
|
375
|
-
* What the closing summary needs: how many were waived, how many are left, what expired, and one
|
|
376
|
-
* sentence saying so.
|
|
377
|
-
*
|
|
378
|
-
* Waivers must be visible, not quiet. This is the function that makes them so, and a summary
|
|
379
|
-
* that does not use it is hiding something an agent decided on its own.
|
|
380
|
-
*
|
|
381
|
-
* @param {Store} store
|
|
382
|
-
* @param {string} product
|
|
383
|
-
* @returns {Promise<{budget: number, spent: number, left: number, active: Waiver[], expired: number, reference: string, line: string}>}
|
|
384
|
-
*/
|
|
385
|
-
export async function countWaivers(store, product) {
|
|
386
|
-
const stamp = await referenceStamp(store, product);
|
|
387
|
-
const all = await allWaivers(store, product);
|
|
388
|
-
const active = all.filter((w) => isLive(w, stamp));
|
|
389
|
-
const expired = all.length - active.length;
|
|
390
|
-
const left = Math.max(0, WAIVER_BUDGET - active.length);
|
|
391
|
-
|
|
392
|
-
const line =
|
|
393
|
-
active.length === 0
|
|
394
|
-
? `Nothing was waived${expired > 0 ? `, and ${expired} older waiver${expired === 1 ? '' : 's'} died when the reference last moved` : ''}.`
|
|
395
|
-
: `${active.length} difference${active.length === 1 ? ' was' : 's were'} recorded as intended, not approved: ${active
|
|
396
|
-
.map((w) => trim(w.summary, 90))
|
|
397
|
-
.join('; ')}. ${left} of the ${WAIVER_BUDGET} allowed before a person has to look ${left === 1 ? 'is' : 'are'} left.`;
|
|
398
|
-
|
|
399
|
-
return { budget: WAIVER_BUDGET, spent: active.length, left, active, expired, reference: stamp, line };
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Forget a product's waivers. Housekeeping, and the way a test starts clean.
|
|
404
|
-
*
|
|
405
|
-
* @param {Store} store
|
|
406
|
-
* @param {string} product
|
|
407
|
-
* @returns {Promise<void>}
|
|
408
|
-
*/
|
|
409
|
-
export async function forgetWaivers(store, product) {
|
|
410
|
-
await fsp.rm(waiversFile(store, product), { force: true });
|
|
411
|
-
}
|
|
412
|
-
|
|
413
397
|
/**
|
|
414
398
|
* What a waiver is pinned to.
|
|
415
399
|
*
|
|
@@ -418,20 +402,46 @@ export async function forgetWaivers(store, product) {
|
|
|
418
402
|
* which errs towards a person looking at something they have already seen rather than towards a
|
|
419
403
|
* new break hiding behind an old excuse. That is the right way round.
|
|
420
404
|
*
|
|
405
|
+
* EVERY difference, and there is deliberately no ceiling on that. Until 2026-08-30 this took
|
|
406
|
+
* the first forty and stopped, and the sentence above was simply false: a waiver written about
|
|
407
|
+
* a three-hundred-address finding went on covering it after a value past the fortieth turned
|
|
408
|
+
* into something else. The addresses were all still there, the title still read the same, the
|
|
409
|
+
* cluster was still one finding — so the pin matched, the difference was filed as intended, and
|
|
410
|
+
* nobody was ever shown the one row that had actually broken. That is the whole failure this
|
|
411
|
+
* file exists to prevent, arriving through the file itself.
|
|
412
|
+
*
|
|
413
|
+
* The cost of having no ceiling is a hash over text the caller is already holding in memory,
|
|
414
|
+
* which is nothing next to being wrong. The tuples are sorted first so that two runs which
|
|
415
|
+
* found the same differences in a different order still pin to the same thing; the number of
|
|
416
|
+
* them goes in as well, so a cluster that merely GREW cannot match a waiver written about the
|
|
417
|
+
* smaller one.
|
|
418
|
+
*
|
|
421
419
|
* @param {Finding} finding
|
|
422
420
|
* @returns {string}
|
|
423
421
|
*/
|
|
424
422
|
export function fingerprintFinding(finding) {
|
|
425
|
-
const differences =
|
|
426
|
-
|
|
427
|
-
|
|
423
|
+
const differences = finding.differences ?? [];
|
|
424
|
+
const hash = crypto.createHash('sha256');
|
|
425
|
+
/** @param {unknown} part */
|
|
426
|
+
const eat = (part) => {
|
|
427
|
+
// JSON escapes every newline inside a value, so a newline is a separator nothing in the
|
|
428
|
+
// text can forge — two different findings cannot run together into one identical digest.
|
|
429
|
+
hash.update(`${JSON.stringify(part) ?? 'null'}\n`);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
eat(finding.title ?? '');
|
|
433
|
+
eat([...(finding.paths ?? [])].sort());
|
|
434
|
+
eat(differences.length);
|
|
435
|
+
const rows = differences.map((d) => JSON.stringify([d.path, d.kind, face(d.reference), face(d.candidate)]));
|
|
436
|
+
rows.sort();
|
|
437
|
+
for (const row of rows) hash.update(`${row}\n`);
|
|
438
|
+
|
|
428
439
|
// A finding with no differences attached, which some callers pass, still has to be pinnable,
|
|
429
440
|
// so the sample and the paths stand in for them.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
return shortDigest([finding.title ?? '', [...(finding.paths ?? [])].sort(), differences, fallback]);
|
|
441
|
+
if (differences.length === 0) {
|
|
442
|
+
eat([finding.sample?.path ?? '', finding.sample?.kind ?? '', face(finding.sample?.reference), face(finding.sample?.candidate)]);
|
|
443
|
+
}
|
|
444
|
+
return hash.digest('hex').slice(0, 16);
|
|
435
445
|
}
|
|
436
446
|
|
|
437
447
|
// ---------------------------------------------------------------------------
|
package/src/v2/watch/events.js
CHANGED
|
@@ -134,6 +134,12 @@ export const SOURCE_WORDS = Object.freeze({
|
|
|
134
134
|
* @property {number} [total] How many journeys there are.
|
|
135
135
|
* @property {number} [count] Whatever this event is counting.
|
|
136
136
|
* @property {number} [watched] Addresses watched so far, across every journey.
|
|
137
|
+
* @property {number} [steady] Only on 'wobble'. Addresses answered the same way
|
|
138
|
+
* twice, as the engine measured them.
|
|
139
|
+
* @property {boolean} [measured] Only on 'wobble'. False when no wobble was taken.
|
|
140
|
+
* @property {string[]} [findingIds] Only on 'check:done', and only when the whole
|
|
141
|
+
* verdict was to hand: every finding that survived,
|
|
142
|
+
* so a window can drop one that has been waived.
|
|
137
143
|
* @property {number} [durationMs]
|
|
138
144
|
* @property {PanelReference} [reference] Only on 'reference'.
|
|
139
145
|
* @property {PanelWobble} [wobble] Only on 'wobble'.
|
|
@@ -235,6 +241,9 @@ export const SOURCE_WORDS = Object.freeze({
|
|
|
235
241
|
* @property {string} summary
|
|
236
242
|
* @property {number} findings
|
|
237
243
|
* @property {number} sealed How many need a person.
|
|
244
|
+
* @property {number} newlyUnstable Addresses that were steady before this change and
|
|
245
|
+
* are not now. A run can be `ok: false` on these
|
|
246
|
+
* alone, with no findings at all.
|
|
238
247
|
* @property {number} differencesReal
|
|
239
248
|
* @property {number} differencesNoise
|
|
240
249
|
* @property {number} durationMs
|
|
@@ -291,220 +300,30 @@ export const SOURCE_WORDS = Object.freeze({
|
|
|
291
300
|
*/
|
|
292
301
|
|
|
293
302
|
// ---------------------------------------------------------------------------
|
|
294
|
-
//
|
|
303
|
+
// WHY THERE IS NO say*() FAMILY HERE ANY MORE (removed 2026-08-30)
|
|
295
304
|
// ---------------------------------------------------------------------------
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
* How many milliseconds in, according to the stream itself when it can say.
|
|
319
|
-
* @param {EventSink|undefined|null} events
|
|
320
|
-
* @returns {number}
|
|
321
|
-
*/
|
|
322
|
-
function now(events) {
|
|
323
|
-
try {
|
|
324
|
-
return typeof events?.elapsed === 'function' ? events.elapsed() : 0;
|
|
325
|
-
} catch {
|
|
326
|
-
return 0;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* The plan, said out loud, for a window that opened after the check started.
|
|
332
|
-
*
|
|
333
|
-
* @param {EventSink|undefined|null} events
|
|
334
|
-
* @param {PanelPlanShape} plan
|
|
335
|
-
* @returns {void}
|
|
336
|
-
*/
|
|
337
|
-
export function sayPlan(events, plan) {
|
|
338
|
-
say(events, { type: 'plan', at: now(events), plan });
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Which build this is being measured against — and, when it is the weaker kind, that it is.
|
|
343
|
-
*
|
|
344
|
-
* @param {EventSink|undefined|null} events
|
|
345
|
-
* @param {PanelReference} reference
|
|
346
|
-
* @returns {void}
|
|
347
|
-
*/
|
|
348
|
-
export function sayReference(events, reference) {
|
|
349
|
-
say(events, {
|
|
350
|
-
type: 'reference',
|
|
351
|
-
at: now(events),
|
|
352
|
-
reference,
|
|
353
|
-
message: reference.weak
|
|
354
|
-
? `${reference.warning || 'This is a weaker check than usual.'} ${reference.how}`.trim()
|
|
355
|
-
: `Measured against ${reference.name}. ${reference.how}`.trim(),
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* A journey starting, on a named surface.
|
|
361
|
-
*
|
|
362
|
-
* The surface is the new part. One repository builds a website, a desktop app and a phone app,
|
|
363
|
-
* and a window that only says "walking checkout" leaves a person guessing which of the three
|
|
364
|
-
* they are watching.
|
|
365
|
-
*
|
|
366
|
-
* @param {EventSink|undefined|null} events
|
|
367
|
-
* @param {object} what
|
|
368
|
-
* @param {string} what.journey
|
|
369
|
-
* @param {Surface} [what.surface]
|
|
370
|
-
* @param {string} [what.describe]
|
|
371
|
-
* @param {string} [what.source]
|
|
372
|
-
* @param {string} [what.run] 'a', 'b' or 'single'.
|
|
373
|
-
* @param {number} [what.index]
|
|
374
|
-
* @param {number} [what.total]
|
|
375
|
-
* @returns {void}
|
|
376
|
-
*/
|
|
377
|
-
export function sayJourneyStart(events, what) {
|
|
378
|
-
// "Website: buying one item with a saved card." The surface leads, because on a repository
|
|
379
|
-
// that builds five products the surface is the thing a person is trying to work out.
|
|
380
|
-
const where = what.surface ? `${surfaceWord(what.surface)}: ` : '';
|
|
381
|
-
const doing = what.describe || `walking ${what.journey}`;
|
|
382
|
-
say(events, {
|
|
383
|
-
type: 'journey:start',
|
|
384
|
-
at: now(events),
|
|
385
|
-
journey: what.journey,
|
|
386
|
-
describe: what.describe,
|
|
387
|
-
surface: what.surface,
|
|
388
|
-
surfaceWord: what.surface ? surfaceWord(what.surface) : undefined,
|
|
389
|
-
source: what.source,
|
|
390
|
-
run: what.run,
|
|
391
|
-
index: what.index,
|
|
392
|
-
total: what.total,
|
|
393
|
-
message: `${where}${doing}.`,
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* The address count rising while a journey is still walking.
|
|
399
|
-
*
|
|
400
|
-
* This is the number that makes the window worth having open: proof that something is
|
|
401
|
-
* happening, on a run where nothing is going to be wrong and there will be nothing to show.
|
|
402
|
-
*
|
|
403
|
-
* @param {EventSink|undefined|null} events
|
|
404
|
-
* @param {string} journey
|
|
405
|
-
* @param {number} count Addresses this journey has watched so far.
|
|
406
|
-
* @returns {void}
|
|
407
|
-
*/
|
|
408
|
-
export function sayAddresses(events, journey, count) {
|
|
409
|
-
say(events, { type: 'journey:addresses', at: now(events), journey, count });
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* A journey finished.
|
|
414
|
-
* @param {EventSink|undefined|null} events
|
|
415
|
-
* @param {object} what
|
|
416
|
-
* @param {string} what.journey
|
|
417
|
-
* @param {number} what.count Addresses watched.
|
|
418
|
-
* @param {number} [what.unstable] Of those, how many would not sit still.
|
|
419
|
-
* @param {number} [what.durationMs]
|
|
420
|
-
* @param {Surface} [what.surface]
|
|
421
|
-
* @param {string} [what.message]
|
|
422
|
-
* @returns {void}
|
|
423
|
-
*/
|
|
424
|
-
export function sayJourneyDone(events, what) {
|
|
425
|
-
const unstable = Number(what.unstable ?? 0);
|
|
426
|
-
say(events, {
|
|
427
|
-
type: 'journey:done',
|
|
428
|
-
at: now(events),
|
|
429
|
-
journey: what.journey,
|
|
430
|
-
surface: what.surface,
|
|
431
|
-
surfaceWord: what.surface ? surfaceWord(what.surface) : undefined,
|
|
432
|
-
count: what.count,
|
|
433
|
-
durationMs: what.durationMs,
|
|
434
|
-
message:
|
|
435
|
-
what.message ||
|
|
436
|
-
`${plural(what.count, 'address', 'addresses')} watched` +
|
|
437
|
-
(unstable > 0 ? `, ${unstable} of which this build cannot answer the same way twice.` : '.'),
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* The wobble, measured.
|
|
443
|
-
*
|
|
444
|
-
* Nobody else's tool has this number, so it does not get buried. Everything that would not sit
|
|
445
|
-
* still between two runs of the SAME build was not caused by the change, and is subtracted
|
|
446
|
-
* arithmetically rather than allowed for by a tolerance somebody guessed.
|
|
447
|
-
*
|
|
448
|
-
* @param {EventSink|undefined|null} events
|
|
449
|
-
* @param {PanelWobble} wobble
|
|
450
|
-
* @returns {void}
|
|
451
|
-
*/
|
|
452
|
-
export function sayWobble(events, wobble) {
|
|
453
|
-
const note = wobble.note || wobbleSentence(wobble);
|
|
454
|
-
say(events, { type: 'wobble', at: now(events), wobble: { ...wobble, note }, count: wobble.unstable, message: note });
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* One finding, the moment it is formed.
|
|
459
|
-
*
|
|
460
|
-
* The finding goes on the stream whole. Cutting it down is the mapper's job and only the
|
|
461
|
-
* mapper's job — two places trimming the same shape is how a window ends up drawing a
|
|
462
|
-
* finding that has already had its findings taken out of it.
|
|
463
|
-
*
|
|
464
|
-
* @param {EventSink|undefined|null} events
|
|
465
|
-
* @param {Finding} finding
|
|
466
|
-
* @returns {void}
|
|
467
|
-
*/
|
|
468
|
-
export function sayFinding(events, finding) {
|
|
469
|
-
say(events, { type: 'finding', at: now(events), finding, message: finding?.title });
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* The coverage, folded — which is mostly the list of what was NOT looked at.
|
|
474
|
-
* @param {EventSink|undefined|null} events
|
|
475
|
-
* @param {Coverage} coverage
|
|
476
|
-
* @returns {void}
|
|
477
|
-
*/
|
|
478
|
-
export function sayCoverage(events, coverage) {
|
|
479
|
-
const gaps = Array.isArray(coverage?.gaps) ? coverage.gaps.length : 0;
|
|
480
|
-
say(events, {
|
|
481
|
-
type: 'coverage',
|
|
482
|
-
at: now(events),
|
|
483
|
-
coverage,
|
|
484
|
-
count: gaps,
|
|
485
|
-
message: coverageSentence(trimCoverage(coverage)),
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/**
|
|
490
|
-
* A plain note, for anything that does not have a shape of its own.
|
|
491
|
-
* @param {EventSink|undefined|null} events
|
|
492
|
-
* @param {string} message
|
|
493
|
-
* @returns {void}
|
|
494
|
-
*/
|
|
495
|
-
export function sayNote(events, message) {
|
|
496
|
-
say(events, { type: 'note', at: now(events), message });
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* The end.
|
|
501
|
-
* @param {EventSink|undefined|null} events
|
|
502
|
-
* @param {Verdict} verdict
|
|
503
|
-
* @returns {void}
|
|
504
|
-
*/
|
|
505
|
-
export function sayCheckDone(events, verdict) {
|
|
506
|
-
say(events, { type: 'check:done', at: now(events), verdict, durationMs: verdict?.durationMs, message: verdict?.summary });
|
|
507
|
-
}
|
|
305
|
+
//
|
|
306
|
+
// This file used to export a second way of talking to a window: sayPlan, sayReference,
|
|
307
|
+
// sayJourneyStart, sayAddresses, sayJourneyDone, sayWobble, sayFinding, sayCoverage,
|
|
308
|
+
// sayNote and sayCheckDone, each one wrapping an emit. Nothing anywhere ever called a
|
|
309
|
+
// single one of them. The engine emits its own plain CheckEvents and `makeMapper` below
|
|
310
|
+
// translates them, so the tool carried two vocabularies for one window and only one of
|
|
311
|
+
// them was wired.
|
|
312
|
+
//
|
|
313
|
+
// They were deleted rather than adopted, and the reason is the paragraph at the top of
|
|
314
|
+
// watch/index.js: the ENGINE works everything out and the PANEL only draws. Every say*()
|
|
315
|
+
// would have had run.js reach for the window's vocabulary at the moment it is meant to be
|
|
316
|
+
// running a difference machine — and it would only have covered the one stream that
|
|
317
|
+
// remembered to call them, where the mapper covers ANY stream, v1's events included. Dead
|
|
318
|
+
// code in a tool whose job is telling the truth about a product is its own small lie: it
|
|
319
|
+
// reads like a supported road and is a road nobody has ever driven down.
|
|
320
|
+
//
|
|
321
|
+
// What was NOT deleted is the vocabulary the window really needs. CLASS_WORDS and
|
|
322
|
+
// SURFACE_NOTES are now embedded in the page by panel.js, exactly the way SURFACE_WORDS
|
|
323
|
+
// and SOURCE_WORDS already were. CLASS_WORDS had to be: the panel was keeping a second
|
|
324
|
+
// hand-written copy of the same map, and the two had already drifted — the panel said "a
|
|
325
|
+
// bug already reported once" where this file says "a bug you already reported", and the
|
|
326
|
+
// panel had no word for 'ordinary' at all.
|
|
508
327
|
|
|
509
328
|
// ---------------------------------------------------------------------------
|
|
510
329
|
// Trimming — what crosses into the window, and what stays out
|
|
@@ -661,6 +480,16 @@ export function trimVerdict(verdict) {
|
|
|
661
480
|
typeof verdict?.sealed === 'number'
|
|
662
481
|
? Number(verdict.sealed)
|
|
663
482
|
: list.filter((/** @type {any} */ f) => f?.sealed || isSealedClass(f?.class)).length;
|
|
483
|
+
// Addresses that used to be steady and are not any more. A run can have NO findings and
|
|
484
|
+
// still not be a pass because of these, and without the number the window cannot tell that
|
|
485
|
+
// apart from a run that compared nothing — the two look identical from `ok: false` and a
|
|
486
|
+
// finding count of nought, and they need opposite sentences.
|
|
487
|
+
const newlyUnstable =
|
|
488
|
+
typeof verdict?.newlyUnstable === 'number'
|
|
489
|
+
? verdict.newlyUnstable
|
|
490
|
+
: Array.isArray(verdict?.newlyUnstable)
|
|
491
|
+
? verdict.newlyUnstable.length
|
|
492
|
+
: 0;
|
|
664
493
|
return {
|
|
665
494
|
ok: Boolean(verdict?.ok),
|
|
666
495
|
mode: verdict?.mode ?? 'stored-record',
|
|
@@ -668,6 +497,7 @@ export function trimVerdict(verdict) {
|
|
|
668
497
|
summary: String(verdict?.summary ?? ''),
|
|
669
498
|
findings: counted,
|
|
670
499
|
sealed,
|
|
500
|
+
newlyUnstable,
|
|
671
501
|
differencesReal: Number(verdict?.differencesReal ?? 0) || 0,
|
|
672
502
|
differencesNoise: Number(verdict?.differencesNoise ?? 0) || 0,
|
|
673
503
|
durationMs: Number(verdict?.durationMs ?? 0) || 0,
|
|
@@ -913,10 +743,17 @@ export function makeMapper(plan = {}) {
|
|
|
913
743
|
|
|
914
744
|
case 'wobble': {
|
|
915
745
|
announcedWobble = true;
|
|
746
|
+
// What the ENGINE measured, wherever it said it. `steady` used to be guessed here as
|
|
747
|
+
// "everything watched so far, minus the unstable ones", and those are two different
|
|
748
|
+
// populations: `watched` counts what the adapters wrote down, while the wobble is
|
|
749
|
+
// measured over addresses. The guess therefore claimed addresses had answered the
|
|
750
|
+
// same way twice that the build had never been asked at. It is kept only as a
|
|
751
|
+
// fallback, for a stream that says nothing about steadiness at all.
|
|
752
|
+
const said = numberOr(event.steady);
|
|
916
753
|
const wobble = /** @type {PanelWobble|undefined} */ (event.wobble) ?? {
|
|
917
|
-
measured:
|
|
754
|
+
measured: event.measured !== false,
|
|
918
755
|
unstable: Number(event.count) || 0,
|
|
919
|
-
steady: Math.max(0, watched - (Number(event.count) || 0)),
|
|
756
|
+
steady: said ?? Math.max(0, watched - (Number(event.count) || 0)),
|
|
920
757
|
newlyUnstable: 0,
|
|
921
758
|
};
|
|
922
759
|
return [{ type: 'wobble', at, message: message ?? wobbleSentence(wobble), wobble, count: wobble.unstable }];
|
|
@@ -993,6 +830,14 @@ export function makeMapper(plan = {}) {
|
|
|
993
830
|
type: 'check:done',
|
|
994
831
|
at,
|
|
995
832
|
verdict: trimVerdict(verdict),
|
|
833
|
+
// WHICH findings, not just how many. A finding only ever ARRIVES at a window; there
|
|
834
|
+
// was no way to take one away again, and findings are taken away — the engine's
|
|
835
|
+
// verdict carries every difference it found, and the gates in check.js then remove
|
|
836
|
+
// the ones an agent has recorded as intended and hand back the settled list. Both
|
|
837
|
+
// verdicts reach the window, in that order, so a waived finding stayed drawn beside
|
|
838
|
+
// a terminal that had already stopped reporting it. Naming the survivors is what
|
|
839
|
+
// lets the window agree: anything drawn that is not on this list is gone.
|
|
840
|
+
findingIds: findings.map((/** @type {any} */ f) => String(f?.id ?? '')).filter(Boolean),
|
|
996
841
|
durationMs: verdict.durationMs,
|
|
997
842
|
message: message ?? verdict.summary,
|
|
998
843
|
});
|
package/src/v2/watch/focus.js
CHANGED
|
@@ -131,19 +131,29 @@ export async function bringForward(name) {
|
|
|
131
131
|
* - When one of ours is in front and you have chosen nothing yet, it is left alone. That
|
|
132
132
|
* first appearance is the point: it is how you see what is happening.
|
|
133
133
|
*
|
|
134
|
-
*
|
|
134
|
+
* `look` and `putBack` exist so this loop can be exercised without a screen. They default to
|
|
135
|
+
* the two functions above and nothing in the tool passes them; a test does, because the
|
|
136
|
+
* bookkeeping — what counts as yours, when the screen is given back, how many times it
|
|
137
|
+
* happened — is the part that has to be right, and it is unreachable behind two calls to
|
|
138
|
+
* `osascript` that answer differently on every machine and not at all on most of them.
|
|
139
|
+
*
|
|
140
|
+
* @param {{claims?: string[], everyMs?: number, graceMs?: number, look?: () => Promise<string|null>, putBack?: (name: string) => Promise<boolean>}} [opts]
|
|
135
141
|
* @returns {ScreenGuard}
|
|
136
142
|
*/
|
|
137
143
|
export function guardTheScreen(opts = {}) {
|
|
138
144
|
const everyMs = opts.everyMs ?? LOOK_EVERY_MS;
|
|
139
145
|
const graceMs = opts.graceMs ?? GRACE_MS;
|
|
146
|
+
const whoIsInFront = opts.look ?? frontmostApp;
|
|
147
|
+
const putBack = opts.putBack ?? bringForward;
|
|
140
148
|
|
|
141
149
|
/** @type {Set<string>} everything the tool opened */
|
|
142
150
|
const ours = new Set(opts.claims ?? []);
|
|
143
151
|
/** @type {string|null} the last application the person chose for themselves */
|
|
144
152
|
let yours = null;
|
|
145
153
|
let handedBack = 0;
|
|
146
|
-
|
|
154
|
+
// Nothing to guard where there is no window server — unless a caller supplied its own way
|
|
155
|
+
// of looking, which means it is being driven deliberately rather than left to the machine.
|
|
156
|
+
let stopped = process.platform !== 'darwin' && !opts.look;
|
|
147
157
|
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
148
158
|
let timer = null;
|
|
149
159
|
const startedAt = Date.now();
|
|
@@ -166,14 +176,14 @@ export function guardTheScreen(opts = {}) {
|
|
|
166
176
|
|
|
167
177
|
const look = async () => {
|
|
168
178
|
if (stopped) return;
|
|
169
|
-
const front = await
|
|
179
|
+
const front = await whoIsInFront();
|
|
170
180
|
if (front) {
|
|
171
181
|
if (!isOurs(front)) {
|
|
172
182
|
// The person chose this. It is now what "yours" means.
|
|
173
183
|
yours = front;
|
|
174
184
|
} else if (yours && Date.now() - startedAt > graceMs) {
|
|
175
185
|
// Something of ours is in front, and there is somewhere to put you back.
|
|
176
|
-
const ok = await
|
|
186
|
+
const ok = await putBack(yours);
|
|
177
187
|
if (ok) {
|
|
178
188
|
handedBack += 1;
|
|
179
189
|
detail(`the screen was taken by ${front}; gave it back to ${yours}`);
|