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/remote.js
CHANGED
|
@@ -57,9 +57,30 @@ import { howLongItTook, joinPath, notCovered, observation, sizeBucket, timeBucke
|
|
|
57
57
|
*/
|
|
58
58
|
export const SENTINEL = '#SF#';
|
|
59
59
|
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* The kinds of far side this file knows how to start.
|
|
62
|
+
*
|
|
63
|
+
* Checked at runtime rather than only in the types, because the types are not there when it
|
|
64
|
+
* matters. `farSideCommand` branches on 'windows' and treats everything else as posix, so a
|
|
65
|
+
* kind spelled 'win' used to be handed the Node bootstrap and sent to a Windows box, where it
|
|
66
|
+
* failed several seconds later as "node: not found" — a message about the far machine for a
|
|
67
|
+
* mistake made on this one.
|
|
68
|
+
*/
|
|
61
69
|
export const RUNNER_KINDS = /** @type {const} */ (['posix', 'windows']);
|
|
62
70
|
|
|
71
|
+
/**
|
|
72
|
+
* @param {unknown} kind
|
|
73
|
+
* @returns {RunnerKind}
|
|
74
|
+
*/
|
|
75
|
+
function checkKind(kind) {
|
|
76
|
+
if (!(/** @type {readonly unknown[]} */ (RUNNER_KINDS).includes(kind))) {
|
|
77
|
+
throw new StaysFixedError(`There is no far side called "${String(kind)}".`, {
|
|
78
|
+
hint: `The kinds this file can start are: ${RUNNER_KINDS.join(', ')}.`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return /** @type {RunnerKind} */ (kind);
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
/** @typedef {'posix'|'windows'} RunnerKind */
|
|
64
85
|
|
|
65
86
|
/**
|
|
@@ -191,7 +212,7 @@ export function nodeBootstrap() {
|
|
|
191
212
|
* @returns {string}
|
|
192
213
|
*/
|
|
193
214
|
export function farSideCommand(kind, opts = {}) {
|
|
194
|
-
if (kind === 'windows') {
|
|
215
|
+
if (checkKind(kind) === 'windows') {
|
|
195
216
|
const encoded = encodePowerShell(powerShellBootstrap());
|
|
196
217
|
if (opts.psPath) return `exec "${opts.psPath}" -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
|
|
197
218
|
const candidates = POWERSHELL_PATHS.map((p) => `"${p}"`).join(' ');
|
|
@@ -302,6 +323,12 @@ export function makeFrames() {
|
|
|
302
323
|
* Written as text rather than shipped as a file because it must never be installed. It exists
|
|
303
324
|
* in the memory of one `node -e` for the length of one run.
|
|
304
325
|
*
|
|
326
|
+
* `read` says how long the file really was and whether it cut it. It used to hand back the
|
|
327
|
+
* first 64K with nothing to say it had stopped there, and two builds of a product whose file
|
|
328
|
+
* differs only after byte 65536 would then be compared, byte for byte, on two identical
|
|
329
|
+
* halves — and reported as unchanged. A cap that cannot be seen from the outside is not a cap,
|
|
330
|
+
* it is a wrong answer.
|
|
331
|
+
*
|
|
305
332
|
* @returns {string}
|
|
306
333
|
*/
|
|
307
334
|
export function posixAgentScript() {
|
|
@@ -331,7 +358,11 @@ const ops = {
|
|
|
331
358
|
done({ ok: true, found });
|
|
332
359
|
},
|
|
333
360
|
read: (req, done) => {
|
|
334
|
-
try {
|
|
361
|
+
try {
|
|
362
|
+
const all = fs.readFileSync(req.file, 'utf8');
|
|
363
|
+
const cap = req.limit || 65536;
|
|
364
|
+
done({ ok: true, text: all.slice(0, cap), length: all.length, truncated: all.length > cap });
|
|
365
|
+
}
|
|
335
366
|
catch (e) { done({ ok: false, error: String(e.message) }); }
|
|
336
367
|
},
|
|
337
368
|
sh: (req, done) => {
|
|
@@ -422,7 +453,7 @@ process.stdin.on('end', () => process.exit(0));
|
|
|
422
453
|
* @param {RemoteRunnerOptions} opts
|
|
423
454
|
*/
|
|
424
455
|
export function remoteRunner(opts) {
|
|
425
|
-
const kind = opts.kind ?? 'posix';
|
|
456
|
+
const kind = checkKind(opts.kind ?? 'posix');
|
|
426
457
|
const host = opts.host;
|
|
427
458
|
const say = opts.log ?? (() => {});
|
|
428
459
|
const surface = opts.surface;
|
|
@@ -740,7 +771,13 @@ export function describeFacts(f) {
|
|
|
740
771
|
/**
|
|
741
772
|
* @typedef {object} RemoteDescription
|
|
742
773
|
* @property {string} host
|
|
743
|
-
* @property {boolean} reachable
|
|
774
|
+
* @property {boolean} reachable A shell on that machine answers. NOT the same
|
|
775
|
+
* question as `runnerStarted`, and folding the two together was a real bug: a machine with
|
|
776
|
+
* ssh working and no Node on it came back as "could not be reached", and the fix offered
|
|
777
|
+
* was to go and check the ssh config that already works.
|
|
778
|
+
* @property {boolean} runnerStarted The small program this tool pushes down the
|
|
779
|
+
* connection actually ran there. False on a reachable machine means Node is missing or too
|
|
780
|
+
* old, and everything below is unknown for that reason rather than because nothing answered.
|
|
744
781
|
* @property {string} how Plain English: what answered, or why nothing did.
|
|
745
782
|
* @property {string|null} os
|
|
746
783
|
* @property {boolean} windows A real Windows desktop sits behind this host.
|
|
@@ -767,20 +804,35 @@ const TOOLS_WORTH_ASKING_ABOUT = ['node', 'git', 'adb', 'emulator', 'java', 'pyt
|
|
|
767
804
|
*
|
|
768
805
|
* It never throws. Somebody running doctor is already stuck.
|
|
769
806
|
*
|
|
807
|
+
* WHAT THE CALLER MAY ALREADY KNOW. Starting the far side needs Node on that machine, so a
|
|
808
|
+
* machine with a perfectly good shell and no Node used to come back from here as "it could
|
|
809
|
+
* not be reached" — and `missingOn` then told somebody to go and fix the ssh config that
|
|
810
|
+
* already works. A caller that has proved the shell answers with something cheaper (doctor
|
|
811
|
+
* dials every host with a plain `echo` before it gets here) says so with `answered`, and
|
|
812
|
+
* hands over whatever its own probe found, so a failure to start the runner is reported as
|
|
813
|
+
* the missing Node it actually is.
|
|
814
|
+
*
|
|
770
815
|
* @param {string} host
|
|
771
|
-
* @param {
|
|
816
|
+
* @param {object} [opts]
|
|
817
|
+
* @param {number} [opts.timeoutMs] How long one request may take. Default 20s.
|
|
818
|
+
* @param {number} [opts.windowsTimeoutMs] The Windows probe on its own, which pays for
|
|
819
|
+
* PowerShell's start-up and is therefore slower than everything else. Default 45s.
|
|
820
|
+
* @param {(m: string) => void} [opts.log]
|
|
821
|
+
* @param {boolean} [opts.answered] The caller has already proved a shell answers here.
|
|
822
|
+
* @param {string|null} [opts.powershell] A powershell.exe path the caller's own probe found.
|
|
772
823
|
* @returns {Promise<RemoteDescription>}
|
|
773
824
|
*/
|
|
774
825
|
export async function describeRemote(host, opts = {}) {
|
|
775
826
|
/** @type {RemoteDescription} */
|
|
776
827
|
const out = {
|
|
777
828
|
host,
|
|
778
|
-
reachable:
|
|
779
|
-
|
|
829
|
+
reachable: opts.answered === true,
|
|
830
|
+
runnerStarted: false,
|
|
831
|
+
how: opts.answered === true ? 'a shell on it answered when the caller dialled it' : 'it did not answer',
|
|
780
832
|
os: null,
|
|
781
|
-
windows:
|
|
833
|
+
windows: typeof opts.powershell === 'string' && opts.powershell !== '',
|
|
782
834
|
windowsVersion: null,
|
|
783
|
-
powershell: null,
|
|
835
|
+
powershell: opts.powershell ?? null,
|
|
784
836
|
desktopLoggedIn: null,
|
|
785
837
|
desktopLocked: null,
|
|
786
838
|
tools: {},
|
|
@@ -792,6 +844,7 @@ export async function describeRemote(host, opts = {}) {
|
|
|
792
844
|
try {
|
|
793
845
|
const facts = await runner.open();
|
|
794
846
|
out.reachable = true;
|
|
847
|
+
out.runnerStarted = true;
|
|
795
848
|
out.how = 'it answered over ssh with the key already in the config';
|
|
796
849
|
out.os = [facts.platform, facts.release].filter(Boolean).join(' ') || null;
|
|
797
850
|
|
|
@@ -799,10 +852,21 @@ export async function describeRemote(host, opts = {}) {
|
|
|
799
852
|
out.tools = /** @type {Record<string, string|null>} */ (found.found ?? {});
|
|
800
853
|
|
|
801
854
|
// The Windows question, asked of the filesystem rather than of $PATH. See POWERSHELL_PATHS.
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
855
|
+
// Skipped entirely when the caller's own probe already found the path: it is the same
|
|
856
|
+
// question and asking it twice only creates a second chance for the two to disagree.
|
|
857
|
+
let psPath = out.powershell;
|
|
858
|
+
if (!psPath) {
|
|
859
|
+
const test = await runner.shell(
|
|
860
|
+
POWERSHELL_PATHS.map((p) => `if [ -x "${p}" ]; then echo "${p}"; fi`).join('; ')
|
|
861
|
+
);
|
|
862
|
+
psPath = test.stdout.split('\n').map((l) => l.trim()).find((l) => l !== '') ?? null;
|
|
863
|
+
// A probe that was killed comes back with an empty answer, which is the same empty
|
|
864
|
+
// answer a machine with no Windows on it gives. Reported as "no Windows here", that
|
|
865
|
+
// loses somebody the Windows runner they already have, and nothing anywhere says why.
|
|
866
|
+
if (!psPath && test.killed) {
|
|
867
|
+
out.notes.push('Whether there is a Windows desktop behind this machine is unknown: the question timed out rather than came back "no".');
|
|
868
|
+
}
|
|
869
|
+
}
|
|
806
870
|
if (psPath) {
|
|
807
871
|
out.powershell = psPath;
|
|
808
872
|
out.windows = true;
|
|
@@ -819,7 +883,10 @@ export async function describeRemote(host, opts = {}) {
|
|
|
819
883
|
].join('; ');
|
|
820
884
|
const probe = await runner.shell(
|
|
821
885
|
`"${psPath}" -NoProfile -NonInteractive -EncodedCommand ${encodePowerShell(script)}`,
|
|
822
|
-
|
|
886
|
+
// Its own knob, because this one call costs PowerShell's start-up and everything else
|
|
887
|
+
// here costs a round trip. Folding it into `timeoutMs` would mean a caller that wants
|
|
888
|
+
// the rest to be quick has to allow forty-five seconds for it too.
|
|
889
|
+
{ timeoutMs: opts.windowsTimeoutMs ?? 45_000 }
|
|
823
890
|
);
|
|
824
891
|
const line = probe.stdout.split('\n').map((l) => l.trim()).filter(Boolean).pop() ?? '';
|
|
825
892
|
const [caption, version, explorers, logonui] = line.split('|');
|
|
@@ -828,12 +895,26 @@ export async function describeRemote(host, opts = {}) {
|
|
|
828
895
|
out.desktopLoggedIn = Number(explorers) > 0;
|
|
829
896
|
out.desktopLocked = Number(logonui) > 0;
|
|
830
897
|
} else {
|
|
831
|
-
|
|
898
|
+
// Which of the two it was matters to whoever reads it: a probe that ran out of time
|
|
899
|
+
// says nothing about the machine, and a probe that answered something unreadable says
|
|
900
|
+
// something is wrong with PowerShell there. Both used to arrive as one sentence.
|
|
901
|
+
out.notes.push(
|
|
902
|
+
probe.killed
|
|
903
|
+
? `PowerShell is there and the question about the desktop was still running after ${Math.round((opts.windowsTimeoutMs ?? 45_000) / 1000)} seconds, so how much of Windows is usable is unknown — not "not much".`
|
|
904
|
+
: 'PowerShell is there but did not answer a question about the desktop, so how much of Windows is usable is unknown.',
|
|
905
|
+
);
|
|
832
906
|
}
|
|
833
907
|
}
|
|
834
908
|
await runner.close();
|
|
835
909
|
} catch (error) {
|
|
836
|
-
|
|
910
|
+
const why = error instanceof RemoteLinkLost ? error.message : String(error);
|
|
911
|
+
// Two different failures, and they were being reported as one. A machine that never
|
|
912
|
+
// answered is an ssh problem. A machine that answers and could not start the runner is a
|
|
913
|
+
// Node problem, and saying "it could not be reached" about it sends somebody to fix a
|
|
914
|
+
// connection that is working — while the one thing that would help goes unmentioned.
|
|
915
|
+
out.how = out.reachable
|
|
916
|
+
? `a shell on it answers, but the small program this tool sends down the connection would not start there (${why})`
|
|
917
|
+
: error instanceof RemoteLinkLost ? why : `it could not be reached (${why})`;
|
|
837
918
|
try { await runner.close(); } catch { /* nothing to close */ }
|
|
838
919
|
}
|
|
839
920
|
|
|
@@ -868,8 +949,16 @@ export function missingOn(d) {
|
|
|
868
949
|
if (!d.tools.node) {
|
|
869
950
|
missing.push({
|
|
870
951
|
what: 'Node on that machine',
|
|
952
|
+
// Two ways to get here and they deserve different words. Either the runner started and
|
|
953
|
+
// `which node` came back empty, or the runner never started at all — in which case the
|
|
954
|
+
// tools list is empty because nothing could ask, and a Node that is present but too old
|
|
955
|
+
// looks exactly the same from here. Saying "install Node" at somebody who has Node 12
|
|
956
|
+
// and is not told the version matters is how a person spends an afternoon on the wrong
|
|
957
|
+
// thing.
|
|
871
958
|
unlocks: 'the general remote runner, which is how any platform there is walked',
|
|
872
|
-
howToGet:
|
|
959
|
+
howToGet: d.reachable && !d.runnerStarted
|
|
960
|
+
? `The program this tool sends down would not start there, which is Node missing or too old. Check it with: ssh ${d.host} 'node --version' — it has to be 22 or newer. Install or upgrade it with whatever that machine installs packages with, for example: ssh ${d.host} 'sudo apt-get install -y nodejs'`
|
|
961
|
+
: `ssh ${d.host} 'sudo apt-get install -y nodejs' — or whatever that machine installs packages with.`,
|
|
873
962
|
blocking: true,
|
|
874
963
|
});
|
|
875
964
|
}
|
|
@@ -901,6 +990,12 @@ export function notesOn(d) {
|
|
|
901
990
|
const notes = [];
|
|
902
991
|
if (!d.reachable) return notes;
|
|
903
992
|
notes.push('Nothing is installed on that machine. The program that does the watching is sent down the connection each run and dies with it.');
|
|
993
|
+
// Said out loud, because everything else in this description is empty for one reason and an
|
|
994
|
+
// empty list that does not say why reads as "there is nothing there". There may be plenty
|
|
995
|
+
// there; nothing could ask.
|
|
996
|
+
if (!d.runnerStarted) {
|
|
997
|
+
notes.push(`A shell on ${d.host} answers, but the program this tool sends down the connection did not start, so nothing below was actually asked of that machine — what is installed on it is unknown rather than absent.`);
|
|
998
|
+
}
|
|
904
999
|
if (d.windows) {
|
|
905
1000
|
notes.push(`Windows is reached through ${d.powershell}, called from the Linux side. That absolute path is used deliberately: powershell.exe is not on the path of a non-interactive ssh session even when the machine is configured to add it.`);
|
|
906
1001
|
notes.push('Windows shows one desktop, so two builds can never run there at the same time. Runs are one after the other, and that is a real weakening of the same-machine guarantee, not a detail.');
|
package/src/v2/run.js
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
wobbleStorm,
|
|
35
35
|
} from './observation.js';
|
|
36
36
|
import { ensureStore, saveBuild, saveCapture, latestCapture, referenceFor, listBuilds } from './store.js';
|
|
37
|
+
import { describeRuleChange } from './normalise.js';
|
|
37
38
|
import { clusterDifferences } from './cluster.js';
|
|
38
39
|
import { rankFindings } from './rank.js';
|
|
39
40
|
|
|
@@ -135,6 +136,10 @@ const VERSION = /** @type {{version?: string}} */ (require('../../package.json')
|
|
|
135
136
|
* @property {string} [journey]
|
|
136
137
|
* @property {string} [run]
|
|
137
138
|
* @property {number} [count]
|
|
139
|
+
* @property {number} [steady] Only on 'wobble'. Addresses this build answered the same
|
|
140
|
+
* way twice, counted rather than inferred by subtraction.
|
|
141
|
+
* @property {boolean} [measured] Only on 'wobble'. False when the wobble was never taken,
|
|
142
|
+
* which is not the same as a wobble of nothing.
|
|
138
143
|
* @property {number} [durationMs]
|
|
139
144
|
* @property {Verdict} [verdict]
|
|
140
145
|
*/
|
|
@@ -251,6 +256,11 @@ export async function runCheck(opts) {
|
|
|
251
256
|
/** @type {string[]} */
|
|
252
257
|
const steadyInReference = [];
|
|
253
258
|
let referenceWobbleMeasured = true;
|
|
259
|
+
// One fact about one pair of rule sets, gathered here and said once at the end.
|
|
260
|
+
/** @type {Set<string>} */
|
|
261
|
+
const rulesMoved = new Set();
|
|
262
|
+
/** @type {string[]} */
|
|
263
|
+
const rulesMovedOn = [];
|
|
254
264
|
|
|
255
265
|
for (const journey of journeys) {
|
|
256
266
|
stop();
|
|
@@ -294,12 +304,22 @@ export async function runCheck(opts) {
|
|
|
294
304
|
surface: journey.surface,
|
|
295
305
|
});
|
|
296
306
|
}
|
|
307
|
+
// ADDRESSES, not rows. This used to send `a.observations.length`, which counts every
|
|
308
|
+
// observation the adapter wrote down — and two observations at the SAME address are one
|
|
309
|
+
// address written down twice, not two addresses. Every other number on the page is
|
|
310
|
+
// counted by address: `foldCoverage` builds a Set, and the wobble arithmetic indexes by
|
|
311
|
+
// path. So a walk with two duplicate addresses put "18 addresses watched" in the header
|
|
312
|
+
// beside "14 addresses watched" in the coverage ledger and "18 answered the same way
|
|
313
|
+
// twice" for a build that only ever had 14 addresses to answer at. Three numbers about
|
|
314
|
+
// one run, disagreeing, on a page whose entire job is being believed. The duplicates
|
|
315
|
+
// themselves are not swallowed — `duplicateGaps` above reports each one.
|
|
316
|
+
const addresses = new Set(a.observations.map((o) => o.path)).size;
|
|
297
317
|
say({
|
|
298
318
|
type: 'journey:done',
|
|
299
319
|
at: events.elapsed(),
|
|
300
320
|
journey: journey.name,
|
|
301
|
-
count:
|
|
302
|
-
message: `${
|
|
321
|
+
count: addresses,
|
|
322
|
+
message: `${addresses} ${plural(addresses, 'thing', 'things')} looked at, ${wobble.unstable.length} of which this build cannot answer the same way twice.`,
|
|
303
323
|
});
|
|
304
324
|
|
|
305
325
|
if (!reference) continue;
|
|
@@ -337,9 +357,8 @@ export async function runCheck(opts) {
|
|
|
337
357
|
gaps.push({
|
|
338
358
|
what: `The old build could not be walked for "${journey.describe || journey.name}", so this was not a paired comparison after all.`,
|
|
339
359
|
why:
|
|
340
|
-
`${nameOf(reference)} was put back on this machine, and then there was nothing there to run: ${
|
|
341
|
-
|
|
342
|
-
}. 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.`,
|
|
360
|
+
`${nameOf(reference)} was put back on this machine, and then there was nothing there to run: ${whyNothingRan(wasA)} ` +
|
|
361
|
+
'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.',
|
|
343
362
|
unlockedBy:
|
|
344
363
|
'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.',
|
|
345
364
|
surface: journey.surface,
|
|
@@ -383,20 +402,43 @@ export async function runCheck(opts) {
|
|
|
383
402
|
// one normalised under another produces differences that are about the RULES — either a
|
|
384
403
|
// wall of noise that reads like a regression, or, when the change was to add a rule,
|
|
385
404
|
// quiet where there should not be any. Either way the reader has to be told.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
405
|
+
//
|
|
406
|
+
// WHAT changed, not two hashes. This used to print "v1-fcf4b8000217 versus
|
|
407
|
+
// v1-29141a9ec069" and nothing else, which tells an agent nothing it can act on, so it
|
|
408
|
+
// acts on nothing. `describeRuleChange` owns the judgement — a rule that rewrites
|
|
409
|
+
// something differently makes the whole comparison suspect; a rule that merely reaches
|
|
410
|
+
// one more address makes only that address suspect and names it; and a record older
|
|
411
|
+
// than the scope stamp says so rather than reporting every glob as new.
|
|
412
|
+
//
|
|
413
|
+
// ONCE PER RUN, not once per journey. It is one fact about one pair of rule sets, and
|
|
414
|
+
// it was being written out again for every journey — four identical paragraphs in the
|
|
415
|
+
// coverage list on a four-journey project, on every run, saying the same thing. The
|
|
416
|
+
// coverage list is the one section that must never be skimmed, and nothing teaches a
|
|
417
|
+
// reader to skim it faster than a block that is always there and always the same.
|
|
418
|
+
const ruleChange = describeRuleChange(
|
|
419
|
+
{ fingerprint: stored.capture.rules, scope: stored.capture.rulesScope },
|
|
420
|
+
{ fingerprint: a.rules, scope: a.rulesScope },
|
|
421
|
+
);
|
|
422
|
+
if (!ruleChange.same) {
|
|
423
|
+
rulesMoved.add(ruleChange.say);
|
|
424
|
+
rulesMovedOn.push(journey.describe || journey.name);
|
|
395
425
|
}
|
|
396
426
|
if (stored.wobble) steadyInReference.push(...steadyPaths(stored.capture, stored.wobble));
|
|
397
427
|
else referenceWobbleMeasured = false;
|
|
398
428
|
}
|
|
399
429
|
|
|
430
|
+
if (rulesMovedOn.length > 0) {
|
|
431
|
+
const which =
|
|
432
|
+
rulesMovedOn.length === 1
|
|
433
|
+
? `"${rulesMovedOn[0]}" is`
|
|
434
|
+
: `${rulesMovedOn.length} journeys are`;
|
|
435
|
+
gaps.push({
|
|
436
|
+
what: `${which} being compared across a change to the normalisation rules.`,
|
|
437
|
+
why: [...rulesMoved].join(' '),
|
|
438
|
+
unlockedBy: 'Run a paired check, which walks the old build live under today\'s rules, or ship again to cut a fresh reference.',
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
400
442
|
stop();
|
|
401
443
|
// Booting the old build is not the same as having walked it. When every live walk came
|
|
402
444
|
// back holes-only — a built artifact that no checkout of the old commit contains — the
|
|
@@ -405,10 +447,19 @@ export async function runCheck(opts) {
|
|
|
405
447
|
const walkedLive = liveWalks > 0;
|
|
406
448
|
const mode = /** @type {'paired'|'stored-record'} */ (walkedLive ? 'paired' : 'stored-record');
|
|
407
449
|
const wobble = wobbles.length > 0 ? mergeWobble(wobbles) : unmeasuredWobble(opts.candidate.id, '*');
|
|
450
|
+
// `steady` and `measured` are sent because they were MEASURED here, and nothing
|
|
451
|
+
// downstream can work them out. Anything drawing this event had to guess steady as
|
|
452
|
+
// "everything watched, minus the unstable ones" — a subtraction across two different
|
|
453
|
+
// populations, so it reported addresses as having answered the same way twice when the
|
|
454
|
+
// build had never been asked at them. And `measured: false` is the difference between a
|
|
455
|
+
// build that wobbled about nothing and a build whose wobble was never taken; two noughts
|
|
456
|
+
// cannot tell those apart, and only the first one is good news.
|
|
408
457
|
say({
|
|
409
458
|
type: 'wobble',
|
|
410
459
|
at: events.elapsed(),
|
|
411
460
|
count: wobble.unstable.length,
|
|
461
|
+
steady: wobble.steady,
|
|
462
|
+
measured: wobble.measured,
|
|
412
463
|
message:
|
|
413
464
|
wobble.unstable.length === 0
|
|
414
465
|
? 'This build gives the same answer twice, everywhere.'
|
|
@@ -599,6 +650,16 @@ export async function runCheck(opts) {
|
|
|
599
650
|
cwd: opts.cwd,
|
|
600
651
|
guards: opts.guards ?? [],
|
|
601
652
|
touches: touchMap(walked),
|
|
653
|
+
// Where "your change" starts.
|
|
654
|
+
//
|
|
655
|
+
// Without this, "the change" means the working tree and nothing else — so the moment
|
|
656
|
+
// an agent commits its work, which is exactly what an agent does at the end of a task,
|
|
657
|
+
// the distance measure goes blind, the ranking loses its ordering, and every finding
|
|
658
|
+
// carries the sentence "nothing in the working tree has changed, so there is no edit
|
|
659
|
+
// to measure this against" over a change that is perfectly well known. The reference
|
|
660
|
+
// is a shipped build, which is a commit; the diff from there to here is the change,
|
|
661
|
+
// whether it has been committed or not.
|
|
662
|
+
since: reference?.gitSha ?? undefined,
|
|
602
663
|
});
|
|
603
664
|
|
|
604
665
|
const warning = modeWarning(mode, provedLive, reference);
|
|
@@ -920,6 +981,34 @@ const PROVEN_LIVE_WARNING =
|
|
|
920
981
|
const NO_REFERENCE_WARNING =
|
|
921
982
|
'Until you ship once with the reference hook in place there is nothing to compare against, so this run proves nothing about what still works.';
|
|
922
983
|
|
|
984
|
+
/**
|
|
985
|
+
* Why the old build came back with nothing that could be walked.
|
|
986
|
+
*
|
|
987
|
+
* The adapter always says why. It says it in the observation's own sentence, which
|
|
988
|
+
* `observation()` in adapters/contract.js files under `meta.describe` — "there is no APK in
|
|
989
|
+
* the exported checkout", "the runtime this needs is not on this machine". Reading only
|
|
990
|
+
* `observations[0]` threw that away in the two cases that matter most: a capture that came
|
|
991
|
+
* back completely EMPTY has no observation nought to read, so the reader got the generic
|
|
992
|
+
* "the adapter could not open it" while the real reason sat in the capture's note; and a
|
|
993
|
+
* capture whose first observation happens to be one the adapter did cover names the wrong
|
|
994
|
+
* hole. So the first REFUSED observation is the one that answers the question, its reason
|
|
995
|
+
* category stands in when it has no sentence of its own, and the capture's note is read
|
|
996
|
+
* before anything generic is said.
|
|
997
|
+
*
|
|
998
|
+
* The sentence is also finished properly. The adapter's `says` is already a whole sentence,
|
|
999
|
+
* and the old template appended a full stop of its own — the same fault already fixed once
|
|
1000
|
+
* in check.js's explain reply, which was giving agents "...real money..".
|
|
1001
|
+
*
|
|
1002
|
+
* @param {Capture} was The walk of the old build that produced nothing usable.
|
|
1003
|
+
* @returns {string} One finished sentence.
|
|
1004
|
+
*/
|
|
1005
|
+
function whyNothingRan(was) {
|
|
1006
|
+
const refused = was?.observations?.find((o) => o.meta?.refused === true);
|
|
1007
|
+
const said = refused?.meta?.describe || refused?.meta?.refusedWhy || was?.note || 'the adapter could not open it';
|
|
1008
|
+
const text = String(said).trim();
|
|
1009
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
923
1012
|
/**
|
|
924
1013
|
* @param {'paired'|'stored-record'} mode
|
|
925
1014
|
* @param {boolean} provedLive
|
package/src/v2/sealed.js
CHANGED
|
@@ -295,26 +295,6 @@ export function classify(finding, opts = {}) {
|
|
|
295
295
|
};
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
/**
|
|
299
|
-
* The same answer as a plain class name, for callers that already work in `FindingClass`.
|
|
300
|
-
*
|
|
301
|
-
* @param {Finding} finding
|
|
302
|
-
* @param {ClassifyOptions} [opts]
|
|
303
|
-
* @returns {FindingClass}
|
|
304
|
-
*/
|
|
305
|
-
export function sealedClassOf(finding, opts = {}) {
|
|
306
|
-
return classify(finding, opts)?.class ?? 'ordinary';
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* @param {Finding} finding
|
|
311
|
-
* @param {ClassifyOptions} [opts]
|
|
312
|
-
* @returns {boolean}
|
|
313
|
-
*/
|
|
314
|
-
export function isSealed(finding, opts = {}) {
|
|
315
|
-
return classify(finding, opts) !== null;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
298
|
/**
|
|
319
299
|
* The refusal, written out for whoever reads it — an agent that has just been told no, or a
|
|
320
300
|
* person reading the closing summary.
|