staysfixed 0.11.1 → 0.13.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 +108 -2
- package/README.md +77 -19
- package/docs/design-v2.md +8 -7
- package/docs/getting-started.md +5 -3
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +43 -10
- package/docs/mcp.md +6 -4
- package/docs/settings.md +11 -2
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/android.js +220 -11
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/extension.js +1988 -0
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/ios-driver.js +95 -12
- package/src/v2/adapters/ios.js +220 -10
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/linux-driver.js +1028 -0
- package/src/v2/adapters/linux.js +1324 -0
- package/src/v2/adapters/macos-driver.js +913 -0
- package/src/v2/adapters/macos.js +1374 -0
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +145 -25
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +465 -47
- package/src/v2/cli.js +21 -1
- package/src/v2/coverage.js +556 -19
- package/src/v2/detect.js +742 -42
- package/src/v2/doctor.js +125 -18
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +574 -23
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/journeys/index.js +3 -3
- package/src/v2/journeys/record-session.js +839 -0
- package/src/v2/journeys/record.js +12 -0
- package/src/v2/mcp/tools.js +193 -27
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
- package/src/v2/types.js +1 -1
- package/src/v2/watch/events.js +6 -0
|
@@ -9,6 +9,27 @@
|
|
|
9
9
|
* Everything below is arranged around that: one device, one build at a time, the whole
|
|
10
10
|
* machine put back in between.
|
|
11
11
|
*
|
|
12
|
+
* IS THAT GOOD ENOUGH FOR A PAIRED RUN? YES, AND IT HAS NOW BEEN MEASURED.
|
|
13
|
+
*
|
|
14
|
+
* Until 2026-08-31 this file said a paired run was not offered here, because nobody had proven
|
|
15
|
+
* that two emulator snapshots come back the same. That was a guess. It was measured that day,
|
|
16
|
+
* on an Apple Silicon Mac, against Terminal Deck's own Android app (dev.terminaldeck.apk.debug
|
|
17
|
+
* 0.13.0) on the `sfx_a33` virtual device — Android 13, Google APIs, arm64, no Play Store.
|
|
18
|
+
*
|
|
19
|
+
* One build was walked TEN times, and the device was put back from its own snapshot between
|
|
20
|
+
* every one of them, which is exactly what happens between the two builds of a paired run.
|
|
21
|
+
* Five pairs, 309 addresses in each walk. 301 of those 309 agreed in every single pair. The
|
|
22
|
+
* eight that did not were one thing seen at eight checkpoints: the identity code this app
|
|
23
|
+
* makes fresh for itself every time it is installed. A control run with the snapshot restore
|
|
24
|
+
* switched OFF produced the same eight and no others — so the restore contributed exactly
|
|
25
|
+
* zero disagreements, and the eight belong to the app, not to the emulator.
|
|
26
|
+
*
|
|
27
|
+
* Eight addresses out of 309 that move on their own is ordinary wobble, and the engine
|
|
28
|
+
* already measures and subtracts it: the new build is always walked twice and anything that
|
|
29
|
+
* cannot answer the same way twice is taken out of the comparison before a single difference
|
|
30
|
+
* is reported. So a paired Android run is offered, and what actually limits it is not the
|
|
31
|
+
* device at all — it is getting hold of the OLD build's package. See `prepare`.
|
|
32
|
+
*
|
|
12
33
|
* What is read, and in which channel:
|
|
13
34
|
*
|
|
14
35
|
* CONTRACT Everything the APK declares, read out of the file without installing it and
|
|
@@ -517,6 +538,93 @@ export function screenObservations(input) {
|
|
|
517
538
|
/** Keyed by build id, emptied on teardown. Never shared between two builds. */
|
|
518
539
|
const open = new Map();
|
|
519
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Which file each half of a comparison was walked from, remembered across the whole run.
|
|
543
|
+
*
|
|
544
|
+
* It has to live out here rather than inside a prepared build, because the engine prepares
|
|
545
|
+
* ONE build, walks ONE journey against it and throws it away again before the next: nothing
|
|
546
|
+
* that lives on a prepared build survives long enough to notice that the old build and the
|
|
547
|
+
* new build were the same file.
|
|
548
|
+
*
|
|
549
|
+
* And that is the thing worth noticing. `android.apk` in the settings is very often an
|
|
550
|
+
* absolute path, and an absolute path does not move when the old commit is checked out
|
|
551
|
+
* somewhere else — so both halves of a paired run read the SAME package, find no differences
|
|
552
|
+
* at all, and the run says "nothing that worked has changed" about a comparison that never
|
|
553
|
+
* happened. That is the one failure this tool exists to prevent, arriving through the front
|
|
554
|
+
* door. Anything found here is reported as a hole on every journey the reference walked; see
|
|
555
|
+
* `sameFileAsTheOtherHalf` below.
|
|
556
|
+
*
|
|
557
|
+
* Keyed by role — 'candidate' or 'reference' — and emptied by `teardown`.
|
|
558
|
+
*
|
|
559
|
+
* @type {Map<string, string>}
|
|
560
|
+
*/
|
|
561
|
+
const walkedFrom = new Map();
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The build ids that turned out to be the other half of the comparison, and the sentence
|
|
565
|
+
* that says so.
|
|
566
|
+
*
|
|
567
|
+
* Separate from `open` above because `open` only holds builds that reached a device, and the
|
|
568
|
+
* dangerous case is the one that does NOT: on a machine with no emulator this adapter still
|
|
569
|
+
* reads everything the package declares, and one package compared against itself declares
|
|
570
|
+
* exactly the same things. That is a clean, confident, meaningless all-clear, and it has to
|
|
571
|
+
* carry the warning like every other journey does.
|
|
572
|
+
*
|
|
573
|
+
* @type {Map<string, string>}
|
|
574
|
+
*/
|
|
575
|
+
const sameFileFor = new Map();
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Which file this really is: the path with the links and the `..`s taken out.
|
|
579
|
+
*
|
|
580
|
+
* A symlink, a `./` and a `..` must not be able to make one file look like two, because one
|
|
581
|
+
* file looking like two is the whole failure being guarded against here.
|
|
582
|
+
*
|
|
583
|
+
* @param {string} file
|
|
584
|
+
* @returns {Promise<string>}
|
|
585
|
+
*/
|
|
586
|
+
async function artifactIdentity(file) {
|
|
587
|
+
try {
|
|
588
|
+
return await fsp.realpath(file);
|
|
589
|
+
} catch {
|
|
590
|
+
// A path that will not resolve is still worth remembering exactly as it was typed. The
|
|
591
|
+
// question below is whether the two halves agree, not whether the file is there.
|
|
592
|
+
return file;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Did this half of the comparison read the same package as the other half?
|
|
598
|
+
*
|
|
599
|
+
* Returns the sentence to put in front of a reader, or null when the two halves really are
|
|
600
|
+
* two different files.
|
|
601
|
+
*
|
|
602
|
+
* WHAT IT DOES NOT CATCH, said here rather than left to be discovered: two different paths
|
|
603
|
+
* holding the same build. Somebody whose release script copies today's package to
|
|
604
|
+
* `builds/latest.apk` and points `reference` at it is comparing one build against itself with
|
|
605
|
+
* two names, and nothing here notices. That was left alone on purpose — two different files
|
|
606
|
+
* are usually two builds somebody produced on purpose, and refusing them on a guess would
|
|
607
|
+
* block real comparisons to prevent an unusual one.
|
|
608
|
+
*
|
|
609
|
+
* @param {'reference'|'candidate'} role
|
|
610
|
+
* @param {string} mine
|
|
611
|
+
* @returns {string|null}
|
|
612
|
+
*/
|
|
613
|
+
function sameFileAsTheOtherHalf(role, mine) {
|
|
614
|
+
// Only ever asked about the OLD build's half, and that is not squeamishness — it is the
|
|
615
|
+
// only answer that stays the same from one journey to the next. The engine prepares a
|
|
616
|
+
// build, walks ONE journey against it and throws it away, and it always does the new build
|
|
617
|
+
// first: new-run-a, new-run-b, old-run-a, old-run-b, then the same four again for the next
|
|
618
|
+
// journey. So from the second journey onwards the new build's half would find the previous
|
|
619
|
+
// journey's old half sitting in the map and flag itself as well — the warning would be
|
|
620
|
+
// absent on the first journey and doubled on every one after it, which reads like a bug in
|
|
621
|
+
// the tool rather than a fact about the run.
|
|
622
|
+
if (role !== 'reference') return null;
|
|
623
|
+
const other = walkedFrom.get('candidate');
|
|
624
|
+
if (!other || other !== mine) return null;
|
|
625
|
+
return `Both halves of this comparison were walked from the same file: ${mine}. Nothing in it is older or newer than anything else in it, so no difference between the two builds could possibly show up.`;
|
|
626
|
+
}
|
|
627
|
+
|
|
520
628
|
/**
|
|
521
629
|
* Read the size out of a PNG without decoding it.
|
|
522
630
|
*
|
|
@@ -532,6 +640,25 @@ export function pngSize(png) {
|
|
|
532
640
|
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
|
533
641
|
}
|
|
534
642
|
|
|
643
|
+
/**
|
|
644
|
+
* Where a kept copy of the OLD build's package lives, if the settings name one.
|
|
645
|
+
*
|
|
646
|
+
* Two spellings, because both read naturally and neither is worth an argument:
|
|
647
|
+
* `{"reference": "builds/0.14.0.apk"}` and `{"reference": {"apk": "builds/0.14.0.apk"}}`.
|
|
648
|
+
* A relative path is resolved against whatever `findApk` is given, which for the reference
|
|
649
|
+
* half is the checkout of the old commit — so a project that DOES commit its package can
|
|
650
|
+
* simply leave this out and everything still works.
|
|
651
|
+
*
|
|
652
|
+
* @param {Record<string, any>} config
|
|
653
|
+
* @returns {string|undefined}
|
|
654
|
+
*/
|
|
655
|
+
export function referenceArtifact(config) {
|
|
656
|
+
const said = config?.reference;
|
|
657
|
+
if (typeof said === 'string' && said.trim() !== '') return said;
|
|
658
|
+
if (said && typeof said === 'object' && typeof said.apk === 'string' && said.apk.trim() !== '') return said.apk;
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
|
|
535
662
|
/**
|
|
536
663
|
* Choose how the device gets put back between builds, and say what that costs.
|
|
537
664
|
*
|
|
@@ -657,6 +784,7 @@ export const androidAdapter = defineAdapter({
|
|
|
657
784
|
...notes,
|
|
658
785
|
'What is compared is what the screen MEANS — the roles, names and states a screen reader would read — and every control is found by what it is, never by where it sits. Moving something, restyling it, or wrapping it in another layout reports nothing.',
|
|
659
786
|
'The two builds are never installed at once. Android allows one app of a given name on a device, so each build is installed, walked and removed, and the whole device is put back in between.',
|
|
787
|
+
'Putting the device back really does put it back, and that is measured rather than assumed. On 2026-08-31 one build was walked ten times on an emulator with a snapshot restore between every walk: 301 of 309 addresses agreed in all five pairs, and the eight that did not were the app\'s own freshly-made identity code — a control run with no restore at all produced exactly the same eight. So a paired run is offered here. What it needs from you is a copy of the OLD build\'s package, because an APK is a build output and a checkout of the old commit does not contain one: name it with {"reference": "path/to/the-old.apk"} under "android" in the settings.',
|
|
660
788
|
'Nothing is allowed off this machine. Every call the app makes is written down at a proxy and stopped there — which also means what is inside an encrypted request is never seen, and is reported as unchecked rather than as fine.',
|
|
661
789
|
],
|
|
662
790
|
};
|
|
@@ -690,14 +818,51 @@ export const androidAdapter = defineAdapter({
|
|
|
690
818
|
const base = path.join(ctx.scratchDir, `android-${build.id.slice(0, 12).replace(/[^A-Za-z0-9_-]/g, '-')}`);
|
|
691
819
|
await fsp.mkdir(base, { recursive: true });
|
|
692
820
|
|
|
821
|
+
// Worked out a few lines below, and captured here so that even a build which could not
|
|
822
|
+
// be got ready still says it. A reference half that failed AND was the same file as the
|
|
823
|
+
// candidate is two separate pieces of bad news, and the second one is the one that would
|
|
824
|
+
// otherwise be lost — the run would report "could not be prepared", somebody would fix
|
|
825
|
+
// that, and the comparison would come back green for the wrong reason.
|
|
826
|
+
/** @type {string|null} */
|
|
827
|
+
let sameFile = null;
|
|
828
|
+
|
|
693
829
|
/** @param {string} why */
|
|
694
830
|
const notReady = (why) => ({
|
|
695
|
-
build,
|
|
696
|
-
|
|
831
|
+
build,
|
|
832
|
+
root: base,
|
|
833
|
+
ready: false,
|
|
834
|
+
why: sameFile ? `${why} ${sameFile}` : why,
|
|
835
|
+
...(build.role === 'reference' ? { facts: { paired: sameFile === null } } : {}),
|
|
836
|
+
dispose: async () => {
|
|
837
|
+
sameFileFor.delete(build.id);
|
|
838
|
+
await fsp.rm(base, { recursive: true, force: true });
|
|
839
|
+
},
|
|
697
840
|
});
|
|
698
841
|
|
|
699
|
-
|
|
700
|
-
|
|
842
|
+
// WHICH package this half of the comparison walks.
|
|
843
|
+
//
|
|
844
|
+
// For the build you have, that is whatever the settings point at. For the build you were
|
|
845
|
+
// happy with it is different, and the difference is the whole of paired mode on a phone:
|
|
846
|
+
// the engine hands over a checkout of the old commit, and an APK is a BUILD OUTPUT that
|
|
847
|
+
// nobody commits, so a checkout of the old commit contains no app at all. `android.reference`
|
|
848
|
+
// is where a kept copy of the old build's package goes, and it is looked at first for the
|
|
849
|
+
// reference half and never for the candidate.
|
|
850
|
+
const wanted = build.role === 'reference' ? (referenceArtifact(config) ?? config.apk) : config.apk;
|
|
851
|
+
const found = await findApk(build.artifact ? path.dirname(build.artifact) : build.root, { ...config, apk: build.artifact ?? wanted });
|
|
852
|
+
if (!found.path) {
|
|
853
|
+
return notReady(
|
|
854
|
+
`There is no APK for ${build.label}: ${found.why}.` +
|
|
855
|
+
(build.role === 'reference'
|
|
856
|
+
? ' A paired run walks the OLD build here, and an APK is a build output that a repository does not commit — so a checkout of the old commit has no app in it. Keep a copy of each release\'s package and point at it with {"reference": "path/to/the-old.apk"} under "android" in the settings, and this becomes a real comparison. Without it this journey falls back to the record the old build left the last time it ran, which is weaker and says so.'
|
|
857
|
+
: ''),
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Two halves, one file. Worked out here, said on every journey — see `run`.
|
|
862
|
+
const mine = await artifactIdentity(found.path);
|
|
863
|
+
sameFile = sameFileAsTheOtherHalf(/** @type {'reference'|'candidate'} */ (build.role), mine);
|
|
864
|
+
walkedFrom.set(build.role, mine);
|
|
865
|
+
if (sameFile) sameFileFor.set(build.id, sameFile);
|
|
701
866
|
const apk = await readApk(found.path);
|
|
702
867
|
if (!apk.ok) return notReady(`The APK for ${build.label} could not be read: ${apk.why}`);
|
|
703
868
|
|
|
@@ -708,8 +873,16 @@ export const androidAdapter = defineAdapter({
|
|
|
708
873
|
return {
|
|
709
874
|
build, root: base, ready: true,
|
|
710
875
|
why: `adb is not on this machine, so ${build.label} cannot be installed or opened. What it declares — its version, its permissions and all ${apk.components.length} of its components — is still read straight out of the file, and every journey that needs a device is reported as unchecked rather than passed.`,
|
|
711
|
-
facts: {
|
|
712
|
-
|
|
876
|
+
facts: {
|
|
877
|
+
apk: found.path,
|
|
878
|
+
pkg: apk.pkg,
|
|
879
|
+
deviceless: true,
|
|
880
|
+
...(build.role === 'reference' ? { paired: sameFile === null } : {}),
|
|
881
|
+
},
|
|
882
|
+
dispose: async () => {
|
|
883
|
+
sameFileFor.delete(build.id);
|
|
884
|
+
await fsp.rm(base, { recursive: true, force: true });
|
|
885
|
+
},
|
|
713
886
|
};
|
|
714
887
|
}
|
|
715
888
|
|
|
@@ -791,7 +964,7 @@ export const androidAdapter = defineAdapter({
|
|
|
791
964
|
build,
|
|
792
965
|
root: base,
|
|
793
966
|
ready: true,
|
|
794
|
-
why: `${build.label} is installed on ${serial} (${apk.pkg} ${apk.versionName ?? ''} build ${apk.versionCode ?? '?'}). The clock, the time zone, the text size and every animation are pinned, the first launch has been used up and thrown away, and between builds the device is put back ${reset === 'snapshot' ? 'completely, from a snapshot taken before anything was installed' : 'by removing the app — which does not undo anything it changed elsewhere on the device'}.${caveats.length > 0 ? ` Worth knowing: ${caveats.join('; ')}.` : ''}`,
|
|
967
|
+
why: `${build.label} is installed on ${serial} (${apk.pkg} ${apk.versionName ?? ''} build ${apk.versionCode ?? '?'}). The clock, the time zone, the text size and every animation are pinned, the first launch has been used up and thrown away, and between builds the device is put back ${reset === 'snapshot' ? 'completely, from a snapshot taken before anything was installed' : 'by removing the app — which does not undo anything it changed elsewhere on the device'}.${sameFile ? ` ${sameFile}` : ''}${caveats.length > 0 ? ` Worth knowing: ${caveats.join('; ')}.` : ''}`,
|
|
795
968
|
facts: {
|
|
796
969
|
serial: /** @type {string} */ (serial),
|
|
797
970
|
pkg: apk.pkg,
|
|
@@ -800,9 +973,13 @@ export const androidAdapter = defineAdapter({
|
|
|
800
973
|
rooted: device.rooted === true,
|
|
801
974
|
firstLaunchMs: warmMs,
|
|
802
975
|
ownsDevice,
|
|
976
|
+
// Only ever set on the OLD build's half, because that is the half the question is
|
|
977
|
+
// about: was there really a second build here, or did both halves read one file.
|
|
978
|
+
...(build.role === 'reference' ? { paired: sameFile === null } : {}),
|
|
803
979
|
},
|
|
804
980
|
dispose: async () => {
|
|
805
981
|
open.delete(build.id);
|
|
982
|
+
sameFileFor.delete(build.id);
|
|
806
983
|
try {
|
|
807
984
|
await resetDevice(session);
|
|
808
985
|
} catch {
|
|
@@ -828,18 +1005,44 @@ export const androidAdapter = defineAdapter({
|
|
|
828
1005
|
const out = [];
|
|
829
1006
|
const session = open.get(prepared.build.id);
|
|
830
1007
|
|
|
1008
|
+
// FIRST, BEFORE ANY JOURNEY INCLUDING THE ONE THAT NEEDS NO DEVICE.
|
|
1009
|
+
//
|
|
1010
|
+
// Said on every journey rather than once at the start, which is the same rule the web
|
|
1011
|
+
// adapter follows for an app read at a fixed address. A run that compared one file
|
|
1012
|
+
// against itself finds no differences, and "no differences" is the sentence this whole
|
|
1013
|
+
// tool is believed for.
|
|
1014
|
+
//
|
|
1015
|
+
// It has to come before the contract journey below rather than after it, and that
|
|
1016
|
+
// ordering is the whole point. The contract journey needs no emulator at all — it reads
|
|
1017
|
+
// the package's own manifest — so on a machine with no device it is the ONLY thing that
|
|
1018
|
+
// runs, and it is exactly where one file compared against itself produces a complete,
|
|
1019
|
+
// confident, meaningless all-clear. This check first went in after that early return, on
|
|
1020
|
+
// 2026-08-31, and the deviceless case was still wide open; it was caught by running it
|
|
1021
|
+
// against a real emulator and reading the answer rather than the code.
|
|
1022
|
+
const sameFile = sameFileFor.get(prepared.build.id);
|
|
1023
|
+
if (sameFile) {
|
|
1024
|
+
out.push(notCovered({
|
|
1025
|
+
channel: 'meaning',
|
|
1026
|
+
path: joinPath('screen', journey.name, 'which build this was'),
|
|
1027
|
+
reason: 'not supported here',
|
|
1028
|
+
says:
|
|
1029
|
+
`${sameFile} An APK is a build output, so a checkout of the old commit does not contain one and the settings' own path was used for both halves. ` +
|
|
1030
|
+
'Keep a copy of the package you shipped and name it with {"reference": "path/to/the-old.apk"} under "android" in the settings, and this becomes a real comparison.',
|
|
1031
|
+
}));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
831
1034
|
// The one journey that needs nothing but the file.
|
|
832
1035
|
if (journey.name === DECLARED) {
|
|
833
1036
|
const apkPath = String(prepared.facts?.apk ?? session?.apkPath ?? '');
|
|
834
1037
|
const apk = session?.apk ?? (apkPath ? await readApk(apkPath) : null);
|
|
835
1038
|
if (!apk?.ok) {
|
|
836
|
-
return [notCovered({ channel: 'contract', path: ['manifest', 'read'], reason: 'missing tool', says: 'the app file could not be read, so nothing it declares was checked' })];
|
|
1039
|
+
return [...out, notCovered({ channel: 'contract', path: ['manifest', 'read'], reason: 'missing tool', says: 'the app file could not be read, so nothing it declares was checked' })];
|
|
837
1040
|
}
|
|
838
|
-
return declaredObservations(apk, apkPath);
|
|
1041
|
+
return [...out, ...declaredObservations(apk, apkPath)];
|
|
839
1042
|
}
|
|
840
1043
|
|
|
841
1044
|
if (!session) {
|
|
842
|
-
return [notCovered({
|
|
1045
|
+
return [...out, notCovered({
|
|
843
1046
|
channel: 'meaning',
|
|
844
1047
|
path: joinPath('screen', journey.name, 'walked'),
|
|
845
1048
|
reason: 'missing tool',
|
|
@@ -850,8 +1053,9 @@ export const androidAdapter = defineAdapter({
|
|
|
850
1053
|
const { device, apk } = session;
|
|
851
1054
|
const started = Date.now();
|
|
852
1055
|
|
|
1056
|
+
|
|
853
1057
|
if (journey.irreversible && ctx.allowIrreversible !== true) {
|
|
854
|
-
return [notCovered({
|
|
1058
|
+
return [...out, notCovered({
|
|
855
1059
|
channel: 'effects',
|
|
856
1060
|
path: joinPath('screen', journey.name, 'walked'),
|
|
857
1061
|
reason: 'irreversible',
|
|
@@ -1238,6 +1442,11 @@ export const androidAdapter = defineAdapter({
|
|
|
1238
1442
|
}
|
|
1239
1443
|
open.delete(id);
|
|
1240
1444
|
}
|
|
1445
|
+
// One run's memory of which file each half was walked from. It must not survive into the
|
|
1446
|
+
// next run in the same process — the MCP server and the watch panel both call check()
|
|
1447
|
+
// more than once — or a second run would report the first run's packages as its own.
|
|
1448
|
+
walkedFrom.clear();
|
|
1449
|
+
sameFileFor.clear();
|
|
1241
1450
|
},
|
|
1242
1451
|
});
|
|
1243
1452
|
|
package/src/v2/adapters/child.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
|
|
22
22
|
import { spawn } from 'node:child_process';
|
|
23
23
|
|
|
24
|
+
import { stopTree, OWN_PROCESS_GROUP } from '../../core/stop-tree.js';
|
|
25
|
+
|
|
24
26
|
/**
|
|
25
27
|
* Start the product, in a group of its own.
|
|
26
28
|
*
|
|
@@ -34,9 +36,10 @@ export function spawnServer(command, opts) {
|
|
|
34
36
|
cwd: opts.cwd,
|
|
35
37
|
env: opts.env,
|
|
36
38
|
stdio: opts.stdio ?? ['ignore', 'pipe', 'pipe'],
|
|
37
|
-
// The whole point.
|
|
38
|
-
//
|
|
39
|
-
|
|
39
|
+
// The whole point. Windows has no process groups of this kind, and `detached` there means
|
|
40
|
+
// a console window of its own instead — so Windows is left alone at spawn time and gets
|
|
41
|
+
// its whole tree stopped by `stopTree` below, which walks the children itself.
|
|
42
|
+
detached: OWN_PROCESS_GROUP,
|
|
40
43
|
});
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -52,21 +55,16 @@ export async function stopServer(child, opts = {}) {
|
|
|
52
55
|
const pid = child.pid;
|
|
53
56
|
const graceMs = opts.graceMs ?? 500;
|
|
54
57
|
|
|
55
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* @param {'SIGTERM'|'SIGKILL'} signal
|
|
60
|
+
*/
|
|
56
61
|
const tellTheGroup = (signal) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// No group, or already gone. Ask the one process we definitely know about.
|
|
64
|
-
try {
|
|
65
|
-
child.kill(signal);
|
|
66
|
-
} catch {
|
|
67
|
-
// Already gone, which is the outcome wanted.
|
|
68
|
-
}
|
|
69
|
-
}
|
|
62
|
+
// The group on Linux and a Mac, the tree of children on Windows. Killing only the shell
|
|
63
|
+
// on Windows left the server running and holding the folder it was started in: measured
|
|
64
|
+
// on a real Windows 11 machine on 2026-08-31, where the whole of `waiting.test.js` failed
|
|
65
|
+
// on being unable to delete its own scratch folder afterwards, because the servers it had
|
|
66
|
+
// asked to stop were all still there.
|
|
67
|
+
stopTree(pid, signal, { child });
|
|
70
68
|
};
|
|
71
69
|
|
|
72
70
|
if (child.exitCode === null && child.signalCode === null) {
|
|
@@ -378,6 +378,10 @@ export function compareJson(a, b) {
|
|
|
378
378
|
* @param {string} spec.says
|
|
379
379
|
* @param {boolean} [spec.covered] False means we did not really look. See `reason`.
|
|
380
380
|
* @param {NotCoveredReason} [spec.reason]
|
|
381
|
+
* @param {string} [spec.detail] What the thing itself said, in its own words, when it
|
|
382
|
+
* said anything. Goes onto the refusal's reason, which
|
|
383
|
+
* is where a person reads why a hole is a hole. Never
|
|
384
|
+
* compared — see `whatItSaid` for why that matters.
|
|
381
385
|
* @param {{file?: string, line?: number, url?: string}} [spec.where]
|
|
382
386
|
* @param {string} [spec.evidence]
|
|
383
387
|
* @param {string} [spec.journey]
|
|
@@ -414,7 +418,16 @@ export function observation(spec) {
|
|
|
414
418
|
// — and never this flag. Written down on 2026-08-31 after the refusal lane found the two
|
|
415
419
|
// meanings sharing one field.
|
|
416
420
|
meta.refused = true;
|
|
417
|
-
|
|
421
|
+
// The reason word stays first and stays in the fixed vocabulary, because the ledger counts
|
|
422
|
+
// these and a free-text reason cannot be counted. What the thing ITSELF said goes on the
|
|
423
|
+
// end. That position is deliberate: `staysfixed coverage` prints this line with a 400
|
|
424
|
+
// character budget and the sentence above it with 160, so this is the place where the real
|
|
425
|
+
// error — the `ModuleNotFoundError`, the `SyntaxError` — reliably survives being trimmed
|
|
426
|
+
// and reaches the person who has to go and fix it. Added 2026-08-31, after three broken
|
|
427
|
+
// products were checked and none of their owners was ever told what was wrong.
|
|
428
|
+
meta.refusedWhy = `${NOT_COVERED_MEANING[spec.reason ?? 'refused']} (${spec.reason ?? 'refused'})${
|
|
429
|
+
spec.detail ? ` — and this is what it said for itself: ${spec.detail}` : ''
|
|
430
|
+
}`;
|
|
418
431
|
}
|
|
419
432
|
return makeObservation(path, spec.channel, stableValue(spec.value), meta);
|
|
420
433
|
}
|
|
@@ -431,6 +444,7 @@ export function observation(spec) {
|
|
|
431
444
|
* @param {string|(string|number)[]} spec.path
|
|
432
445
|
* @param {NotCoveredReason} spec.reason
|
|
433
446
|
* @param {string} spec.says What we would have looked at, and why we did not.
|
|
447
|
+
* @param {string} [spec.detail] What the thing itself said about it, in its own words.
|
|
434
448
|
* @param {{file?: string, line?: number, url?: string}} [spec.where]
|
|
435
449
|
* @returns {Observation}
|
|
436
450
|
*/
|
|
@@ -438,10 +452,15 @@ export function notCovered(spec) {
|
|
|
438
452
|
return observation({
|
|
439
453
|
channel: spec.channel,
|
|
440
454
|
path: spec.path,
|
|
455
|
+
// The VALUE stays the fixed sentence and never carries what the thing said. Two builds
|
|
456
|
+
// that fall over with two different messages would otherwise differ at this address, and
|
|
457
|
+
// the report would call a crash a change in the product. The words go in `says` and in the
|
|
458
|
+
// refusal's reason, neither of which is ever compared.
|
|
441
459
|
value: `not checked — ${NOT_COVERED_MEANING[spec.reason]}`,
|
|
442
460
|
says: spec.says,
|
|
443
461
|
covered: false,
|
|
444
462
|
reason: spec.reason,
|
|
463
|
+
detail: spec.detail,
|
|
445
464
|
where: spec.where,
|
|
446
465
|
});
|
|
447
466
|
}
|
|
@@ -610,6 +629,108 @@ export function undoOurFootprint(text, footprint) {
|
|
|
610
629
|
return out;
|
|
611
630
|
}
|
|
612
631
|
|
|
632
|
+
/**
|
|
633
|
+
* What a program said about itself, cut down to the part a person can act on.
|
|
634
|
+
*
|
|
635
|
+
* WHY THIS EXISTS. Measured on 2026-08-31, against three deliberately broken products: a Node
|
|
636
|
+
* server whose source has a syntax error, a Python command importing a module that is not
|
|
637
|
+
* installed, and a Node command importing a package that is not installed. All three were
|
|
638
|
+
* correctly refused — no false all-clear, and that half worked. But a grep of the whole reply,
|
|
639
|
+
* `--verbose` included, found no mention of `SyntaxError`, `ModuleNotFoundError` or
|
|
640
|
+
* `ERR_MODULE_NOT_FOUND` anywhere in it. Every owner was told "the thing being observed fell
|
|
641
|
+
* over before it could be read" and then had to go and find the reason themselves. Each
|
|
642
|
+
* product had printed the reason, in one line, on its own standard error, and this tool threw
|
|
643
|
+
* it away. Handing somebody a sentence they can act on is the whole design; this is the
|
|
644
|
+
* function that keeps the product's own sentence instead of a paraphrase of it.
|
|
645
|
+
*
|
|
646
|
+
* NOTHING IS INVENTED HERE. What comes out is the program's own words. What is dropped is only
|
|
647
|
+
* what carries nothing for the person reading: blank lines, the `at ...` frames of a stack
|
|
648
|
+
* trace, the caret lines that underline a column of a terminal that is not this one, "... 4
|
|
649
|
+
* more" frame counts, and Node's own version footer. What is left over the budget is dropped
|
|
650
|
+
* from the FRONT, because every runtime in use here — Node, Python, a shell — puts the
|
|
651
|
+
* sentence that says what went wrong at the END of what it printed.
|
|
652
|
+
*
|
|
653
|
+
* This is evidence, not noise, and it belongs in the reply. It is never compared: it goes into
|
|
654
|
+
* the sentence and into the refusal's reason, both of which live in `meta`, so a crash that
|
|
655
|
+
* words itself differently on two machines can never register as a difference in the product.
|
|
656
|
+
*
|
|
657
|
+
* @param {string} text Whatever the thing printed. Run our own footprint out of it
|
|
658
|
+
* first, or a scratch path ends up quoted at a person.
|
|
659
|
+
* @param {object} [opts]
|
|
660
|
+
* @param {number} [opts.mostLines] How many lines of it to keep. Default 6.
|
|
661
|
+
* @param {number} [opts.mostChars] How many characters of it to keep. Default 300.
|
|
662
|
+
* @returns {string} One line, or '' when it said nothing worth repeating.
|
|
663
|
+
*/
|
|
664
|
+
export function whatItSaid(text, opts = {}) {
|
|
665
|
+
const mostLines = opts.mostLines ?? 6;
|
|
666
|
+
const mostChars = opts.mostChars ?? 300;
|
|
667
|
+
const lines = String(text ?? '')
|
|
668
|
+
// Colour codes are how a program makes an error red in a terminal. Printed into a
|
|
669
|
+
// sentence they are unreadable rubbish, and they are not part of what it said.
|
|
670
|
+
.replace(/\u001b\[[0-9;]*m/g, '')
|
|
671
|
+
.split(/\r?\n/)
|
|
672
|
+
.map((line) => line.trim())
|
|
673
|
+
.filter((line) => line !== '' && !NOT_WORTH_REPEATING.some((noise) => noise.test(line)));
|
|
674
|
+
if (lines.length === 0) return '';
|
|
675
|
+
|
|
676
|
+
// ONE LINE MEANS THE ONE THAT NAMES THE TROUBLE, not simply the last one.
|
|
677
|
+
//
|
|
678
|
+
// "The last line" was the first thing tried and it is wrong often enough to matter: measured
|
|
679
|
+
// 2026-08-31 on a Node command importing a package that is not installed, Node printed the
|
|
680
|
+
// whole error object after the message, so the last line was `}` and the headline read "It
|
|
681
|
+
// said: }." — which is worse than saying nothing. So the line that looks like a runtime
|
|
682
|
+
// naming a fault is preferred, and the last line is what happens when none does. Preferred,
|
|
683
|
+
// never required: nothing is dropped by this, it only decides which single line gets quoted
|
|
684
|
+
// where there is room for one, and the fuller quote sits directly underneath it.
|
|
685
|
+
if (mostLines === 1) {
|
|
686
|
+
const named = [...lines].reverse().find((line) => NAMES_THE_TROUBLE.test(line)) ?? lines[lines.length - 1];
|
|
687
|
+
return named.length > mostChars ? `${named.slice(0, mostChars - 3)}...` : named;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** @type {string[]} */
|
|
691
|
+
const kept = [];
|
|
692
|
+
let used = 0;
|
|
693
|
+
for (let i = lines.length - 1; i >= 0 && kept.length < mostLines; i--) {
|
|
694
|
+
const line = lines[i];
|
|
695
|
+
if (kept.length > 0 && used + line.length + 3 > mostChars) break;
|
|
696
|
+
kept.unshift(line);
|
|
697
|
+
used += line.length + 3;
|
|
698
|
+
}
|
|
699
|
+
// One line longer than the whole budget is still the line that says what went wrong, so it
|
|
700
|
+
// is kept and cut rather than dropped for being too long.
|
|
701
|
+
if (kept.length === 1 && kept[0].length > mostChars) kept[0] = `${kept[0].slice(0, mostChars - 3)}...`;
|
|
702
|
+
const left = lines.length - kept.length;
|
|
703
|
+
// Said out loud rather than trimmed silently: a reader who is told six lines were left out
|
|
704
|
+
// knows to open the evidence file, and a reader who is not told assumes they have all of it.
|
|
705
|
+
return `${left > 0 ? `(${left} earlier ${left === 1 ? 'line' : 'lines'} left out) ` : ''}${kept.join(' / ')}`;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* A line that reads like a runtime saying what went wrong.
|
|
710
|
+
*
|
|
711
|
+
* The three shapes measured on 2026-08-31, one from each broken product: `SyntaxError:
|
|
712
|
+
* Unexpected end of input`, `ModuleNotFoundError: No module named 'tabulate'`, and `Error
|
|
713
|
+
* [ERR_MODULE_NOT_FOUND]: Cannot find package 'chalk' imported from ...`. The second pattern is
|
|
714
|
+
* for the tools that write their trouble in lower case instead — a compiler, git, a shell.
|
|
715
|
+
*/
|
|
716
|
+
const NAMES_THE_TROUBLE =
|
|
717
|
+
/^[A-Za-z_$][A-Za-z0-9_$.]*(?:Error|Exception|Warning)(?:\s*\[[^\]]*\])?\s*:|^(?:fatal|error|panic|Traceback)\b/i;
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Lines that are in the way of the sentence rather than part of it.
|
|
721
|
+
*
|
|
722
|
+
* Deliberately short. Every pattern here has to be something that cannot possibly BE the
|
|
723
|
+
* reason a product fell over — a stack frame under the message, a caret underlining a column,
|
|
724
|
+
* a version footer. Anything else stays, because a rule that guesses which of a program's own
|
|
725
|
+
* lines matter is a rule that will one day drop the only line that did.
|
|
726
|
+
*/
|
|
727
|
+
const NOT_WORTH_REPEATING = [
|
|
728
|
+
/^at\s+\S/, // a stack frame under the message that already said it
|
|
729
|
+
/^[\^~]+$/, // the caret line underlining a column in somebody else's terminal
|
|
730
|
+
/^\.\.\.\s*\d+\s+more$/, // "... 4 more", the frames a runtime left out itself
|
|
731
|
+
/^Node\.js v[\d.]+$/, // Node's version footer, printed under every uncaught throw
|
|
732
|
+
];
|
|
733
|
+
|
|
613
734
|
/**
|
|
614
735
|
* Keep a piece of text at a size worth storing.
|
|
615
736
|
*
|