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/ci.js
CHANGED
|
@@ -415,7 +415,12 @@ export async function referenceForCI(opts = {}) {
|
|
|
415
415
|
}
|
|
416
416
|
|
|
417
417
|
// ---- released ----------------------------------------------------------
|
|
418
|
-
|
|
418
|
+
// Anything the store could not read travels with the answer and is said out loud, whether
|
|
419
|
+
// the answer was found or not. A run that says "this project has nothing on record as
|
|
420
|
+
// working" while a build folder sat there unreadable is stating a fact it does not have.
|
|
421
|
+
const releasedLook = await releasedCommit(cwd, opts.product);
|
|
422
|
+
const released = releasedLook.found;
|
|
423
|
+
const storeTrouble = releasedLook.trouble;
|
|
419
424
|
if (released) {
|
|
420
425
|
const found = await resolveCommit(cwd, released.sha);
|
|
421
426
|
const sha = notThisBuild(found);
|
|
@@ -443,11 +448,12 @@ export async function referenceForCI(opts = {}) {
|
|
|
443
448
|
'The fork point of this branch could not be worked out, so this was compared against the last shipped build instead. Anything else that was merged since that release will show up here as well, even though this branch did not do it.';
|
|
444
449
|
result.unlockedBy = deepen;
|
|
445
450
|
}
|
|
451
|
+
if (storeTrouble) result.caveat = result.caveat ? `${result.caveat} ${storeTrouble}` : storeTrouble;
|
|
446
452
|
return result;
|
|
447
453
|
}
|
|
448
|
-
else missed('released', `The project's reference points at ${short(released.sha)}, and that commit is not in this checkout.`, shallow ? deepen : 'Make sure the commit that was shipped is still in this repository.');
|
|
454
|
+
else missed('released', withTrouble(`The project's reference points at ${short(released.sha)}, and that commit is not in this checkout.`, storeTrouble), shallow ? deepen : 'Make sure the commit that was shipped is still in this repository.');
|
|
449
455
|
} else {
|
|
450
|
-
missed('released', 'This project has no build on record as working. Only its owner can set one, by shipping.');
|
|
456
|
+
missed('released', withTrouble('This project has no build on record as working. Only its owner can set one, by shipping.', storeTrouble));
|
|
451
457
|
}
|
|
452
458
|
|
|
453
459
|
// ---- last tag ----------------------------------------------------------
|
|
@@ -491,7 +497,8 @@ export async function referenceForCI(opts = {}) {
|
|
|
491
497
|
missed('previous-commit', 'There is no earlier commit in this checkout.', shallow ? deepen : undefined);
|
|
492
498
|
|
|
493
499
|
// ---- stored record -----------------------------------------------------
|
|
494
|
-
const
|
|
500
|
+
const storedLook = await storedRecord(cwd, opts.product);
|
|
501
|
+
const stored = storedLook.found;
|
|
495
502
|
if (stored) {
|
|
496
503
|
considered.push({ mode: 'stored-record', available: true, why: `There are stored observations for ${stored.buildId}.` });
|
|
497
504
|
const same = stored.machine === 'same';
|
|
@@ -513,9 +520,14 @@ export async function referenceForCI(opts = {}) {
|
|
|
513
520
|
machine: stored.machine,
|
|
514
521
|
};
|
|
515
522
|
}
|
|
516
|
-
missed('stored-record', 'There are no stored observations in this checkout either.', 'Commit the .staysfixed folder, or restore it from a cache written by a job on your main branch.');
|
|
523
|
+
missed('stored-record', withTrouble('There are no stored observations in this checkout either.', storedLook.trouble), 'Commit the .staysfixed folder, or restore it from a cache written by a job on your main branch.');
|
|
517
524
|
|
|
518
525
|
// ---- nothing -----------------------------------------------------------
|
|
526
|
+
// The last line of this file is the one that says a whole checkout holds nothing to
|
|
527
|
+
// compare against, and it is read as final. It must not be said over the top of a store
|
|
528
|
+
// that was sitting right there with records nobody could open, so whatever the two looks
|
|
529
|
+
// in the store could not see is repeated here where a person will actually meet it.
|
|
530
|
+
const blindSpots = [storeTrouble, storedLook.trouble].filter((t) => t !== '');
|
|
519
531
|
return {
|
|
520
532
|
mode: 'none',
|
|
521
533
|
against: null,
|
|
@@ -523,13 +535,27 @@ export async function referenceForCI(opts = {}) {
|
|
|
523
535
|
strength: 'none',
|
|
524
536
|
how: 'Nothing was found to compare against.',
|
|
525
537
|
why: 'No named commit, no pull request base, no reference, no tag, no earlier commit and no stored record. There is nothing in this checkout that says what this product used to do.',
|
|
526
|
-
caveat:
|
|
527
|
-
|
|
538
|
+
caveat:
|
|
539
|
+
blindSpots.length === 0
|
|
540
|
+
? 'This run proves nothing about your product either way. It is not a pass.'
|
|
541
|
+
: `This run proves nothing about your product either way. It is not a pass. AND IT COULD NOT SEE EVERYTHING THERE IS: ${[...new Set(blindSpots)].join(' ')}`,
|
|
542
|
+
unlockedBy: blindSpots.length === 0 ? deepen : `${deepen} And repair or delete the build records named above, so the next run can read them.`,
|
|
528
543
|
considered,
|
|
529
544
|
shallow,
|
|
530
545
|
};
|
|
531
546
|
}
|
|
532
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Add what could not be seen to a sentence about what was not found.
|
|
550
|
+
*
|
|
551
|
+
* @param {string} said
|
|
552
|
+
* @param {string} trouble
|
|
553
|
+
* @returns {string}
|
|
554
|
+
*/
|
|
555
|
+
function withTrouble(said, trouble) {
|
|
556
|
+
return trouble ? `${said} ${trouble}` : said;
|
|
557
|
+
}
|
|
558
|
+
|
|
533
559
|
/**
|
|
534
560
|
* The commit this branch forked from.
|
|
535
561
|
*
|
|
@@ -585,6 +611,19 @@ async function findMergeBase(cwd, ci) {
|
|
|
585
611
|
return null;
|
|
586
612
|
}
|
|
587
613
|
|
|
614
|
+
/**
|
|
615
|
+
* What a look in the store found, and what it could not see while looking.
|
|
616
|
+
*
|
|
617
|
+
* `trouble` is empty almost always. It carries a sentence when a build folder had to be
|
|
618
|
+
* skipped, or when the store would not open at all — and that sentence has to travel,
|
|
619
|
+
* because both of those turn "this project has nothing on record as working" into a claim
|
|
620
|
+
* the code is in no position to make. The build being asked for may well be one of the ones
|
|
621
|
+
* that could not be read.
|
|
622
|
+
*
|
|
623
|
+
* @template T
|
|
624
|
+
* @typedef {{found: T|null, trouble: string}} LookedInTheStore
|
|
625
|
+
*/
|
|
626
|
+
|
|
588
627
|
/**
|
|
589
628
|
* The commit this project's own reference points at — the build somebody shipped.
|
|
590
629
|
*
|
|
@@ -592,23 +631,35 @@ async function findMergeBase(cwd, ci) {
|
|
|
592
631
|
*
|
|
593
632
|
* @param {string} cwd
|
|
594
633
|
* @param {string} [product]
|
|
595
|
-
* @returns {Promise<{sha: string, note: string}
|
|
634
|
+
* @returns {Promise<LookedInTheStore<{sha: string, note: string}>>}
|
|
596
635
|
*/
|
|
597
636
|
async function releasedCommit(cwd, product) {
|
|
637
|
+
/** @type {string[]} */
|
|
638
|
+
const skipped = [];
|
|
598
639
|
try {
|
|
599
640
|
const store = openStore({ root: cwd });
|
|
600
641
|
const name = product ?? (await productName(cwd));
|
|
601
|
-
if (!name) return null;
|
|
642
|
+
if (!name) return { found: null, trouble: '' };
|
|
602
643
|
const pointer = await referencePointer(store, name);
|
|
603
|
-
if (!pointer) return null;
|
|
604
|
-
|
|
644
|
+
if (!pointer) return { found: null, trouble: '' };
|
|
645
|
+
// Until 2026-08-30 nothing was passed here, so a build folder that could not be read was
|
|
646
|
+
// simply not in the list — and "not in the list" is how this file spells "never existed".
|
|
647
|
+
// The build the reference pointer names is exactly the one most likely to have been
|
|
648
|
+
// written to most recently, and so exactly the one most likely to be the damaged folder.
|
|
649
|
+
const builds = await listBuilds(store, { product: name, onProblem: (m) => skipped.push(m) });
|
|
605
650
|
const hit = builds.find((b) => b.fingerprint.id === pointer.buildId);
|
|
606
651
|
const sha = hit?.fingerprint.gitSha ?? shaFromBuildId(pointer.buildId);
|
|
607
|
-
if (!sha) return null;
|
|
608
|
-
return {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
652
|
+
if (!sha) return { found: null, trouble: troubleFrom(skipped, '') };
|
|
653
|
+
return {
|
|
654
|
+
found: { sha, note: hit?.fingerprint.version ? `version ${hit.fingerprint.version}` : '' },
|
|
655
|
+
// Named even on the way to a good answer. A reference found without the record that
|
|
656
|
+
// was supposed to describe it is a weaker answer than one found with it.
|
|
657
|
+
trouble: hit ? troubleFrom(skipped, '') : troubleFrom(skipped, `The record of build ${pointer.buildId} itself was not among them.`),
|
|
658
|
+
};
|
|
659
|
+
} catch (e) {
|
|
660
|
+
// A store that will not open is a reason to try the next mode, never a reason to fail —
|
|
661
|
+
// but it is never a reason to then say this project has nothing on record either.
|
|
662
|
+
return { found: null, trouble: troubleFrom(skipped, `The store here could not be read at all: ${messageOf(e)}`) };
|
|
612
663
|
}
|
|
613
664
|
}
|
|
614
665
|
|
|
@@ -617,26 +668,47 @@ async function releasedCommit(cwd, product) {
|
|
|
617
668
|
*
|
|
618
669
|
* @param {string} cwd
|
|
619
670
|
* @param {string} [product]
|
|
620
|
-
* @returns {Promise<{buildId: string, machine: 'same'|'different'|'unknown'}
|
|
671
|
+
* @returns {Promise<LookedInTheStore<{buildId: string, machine: 'same'|'different'|'unknown'}>>}
|
|
621
672
|
*/
|
|
622
673
|
async function storedRecord(cwd, product) {
|
|
674
|
+
/** @type {string[]} */
|
|
675
|
+
const skipped = [];
|
|
623
676
|
try {
|
|
624
677
|
const store = openStore({ root: cwd });
|
|
625
678
|
const name = product ?? (await productName(cwd));
|
|
626
|
-
if (!name) return null;
|
|
679
|
+
if (!name) return { found: null, trouble: '' };
|
|
627
680
|
const pointer = await referencePointer(store, name);
|
|
628
|
-
const builds = await listBuilds(store, { product: name });
|
|
629
|
-
if (builds.length === 0) return null;
|
|
681
|
+
const builds = await listBuilds(store, { product: name, onProblem: (m) => skipped.push(m) });
|
|
682
|
+
if (builds.length === 0) return { found: null, trouble: troubleFrom(skipped, '') };
|
|
630
683
|
const hit = (pointer && builds.find((b) => b.fingerprint.id === pointer.buildId)) || builds[0];
|
|
631
684
|
const here = `${process.platform}-${process.arch}`;
|
|
632
685
|
/** @type {'same'|'different'|'unknown'} */
|
|
633
686
|
const machine = !hit.fingerprint.platform ? 'unknown' : hit.fingerprint.platform === here ? 'same' : 'different';
|
|
634
|
-
return { buildId: hit.fingerprint.id, machine };
|
|
635
|
-
} catch {
|
|
636
|
-
return null;
|
|
687
|
+
return { found: { buildId: hit.fingerprint.id, machine }, trouble: troubleFrom(skipped, '') };
|
|
688
|
+
} catch (e) {
|
|
689
|
+
return { found: null, trouble: troubleFrom(skipped, `The store here could not be read at all: ${messageOf(e)}`) };
|
|
637
690
|
}
|
|
638
691
|
}
|
|
639
692
|
|
|
693
|
+
/**
|
|
694
|
+
* One sentence about what could not be seen, or nothing at all when everything could.
|
|
695
|
+
*
|
|
696
|
+
* @param {string[]} skipped
|
|
697
|
+
* @param {string} extra
|
|
698
|
+
* @returns {string}
|
|
699
|
+
*/
|
|
700
|
+
function troubleFrom(skipped, extra) {
|
|
701
|
+
/** @type {string[]} */
|
|
702
|
+
const parts = [];
|
|
703
|
+
if (skipped.length > 0) {
|
|
704
|
+
parts.push(
|
|
705
|
+
`${skipped.length} stored build ${skipped.length === 1 ? 'record was' : 'records were'} skipped because ${skipped.length === 1 ? 'it' : 'they'} could not be read, so what is on record here may be more than this run could see: ${skipped.join(' ')}`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
if (extra) parts.push(extra);
|
|
709
|
+
return parts.join(' ');
|
|
710
|
+
}
|
|
711
|
+
|
|
640
712
|
// ---------------------------------------------------------------------------
|
|
641
713
|
// The report
|
|
642
714
|
// ---------------------------------------------------------------------------
|
|
@@ -816,23 +888,31 @@ function findingLines(f) {
|
|
|
816
888
|
/**
|
|
817
889
|
* Append the report to the job summary, when the build server has one.
|
|
818
890
|
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
891
|
+
* TWO ANSWERS THAT USED TO BE ONE NULL. "This server has no summary page" is the normal
|
|
892
|
+
* state on GitLab and CircleCI and needs saying to nobody. "This server HAS one and the
|
|
893
|
+
* report did not reach it" is a report that has vanished — the person opens the job, sees
|
|
894
|
+
* the tab they expect, finds nothing under it, and concludes the check did not run. Both
|
|
895
|
+
* came back as null, so nothing anywhere could tell them apart or mention the second.
|
|
896
|
+
*
|
|
897
|
+
* The exit code is untouched either way. Losing the page must never cost the half that
|
|
898
|
+
* actually stops the merge.
|
|
821
899
|
*
|
|
822
900
|
* @param {CIReport} report
|
|
823
901
|
* @param {CIEnvironment} [env]
|
|
824
|
-
* @returns {Promise<string|null>}
|
|
902
|
+
* @returns {Promise<{file: string|null, why: string}>} `why` is empty unless there was a
|
|
903
|
+
* page to write to and writing failed.
|
|
825
904
|
*/
|
|
826
905
|
export async function writeJobSummary(report, env) {
|
|
827
906
|
const where = (env ?? detectCI()).summaryFile;
|
|
828
|
-
if (!where) return null;
|
|
907
|
+
if (!where) return { file: null, why: '' };
|
|
829
908
|
try {
|
|
830
909
|
await fsp.appendFile(where, `${report.markdown}\n`);
|
|
831
|
-
return where;
|
|
832
|
-
} catch {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
910
|
+
return { file: where, why: '' };
|
|
911
|
+
} catch (e) {
|
|
912
|
+
return {
|
|
913
|
+
file: null,
|
|
914
|
+
why: `The report could not be written to this job's summary page (${where}): ${messageOf(e)}. The answer below is the whole of it.`,
|
|
915
|
+
};
|
|
836
916
|
}
|
|
837
917
|
}
|
|
838
918
|
|
|
@@ -931,12 +1011,27 @@ export async function runCI(opts = {}) {
|
|
|
931
1011
|
|
|
932
1012
|
/** @type {string|null} */
|
|
933
1013
|
let evidence = null;
|
|
1014
|
+
/** @type {string[]} */
|
|
1015
|
+
const lost = [];
|
|
934
1016
|
try {
|
|
935
1017
|
evidence = await saveEvidence({ cwd, dir: opts.evidenceDir, verdict, reference, report, env: where });
|
|
936
|
-
} catch {
|
|
937
|
-
// Losing the attachment must never change the answer
|
|
1018
|
+
} catch (e) {
|
|
1019
|
+
// Losing the attachment must never change the answer — and it must never be lost in
|
|
1020
|
+
// silence either, because the report above says in so many words that the rest of the
|
|
1021
|
+
// gaps are "in the evidence attached to this run". A run that swallowed this printed
|
|
1022
|
+
// that sentence pointing at a folder that was never written.
|
|
1023
|
+
lost.push(`THE EVIDENCE FOR THIS RUN WAS NOT SAVED: ${messageOf(e)} Anything the report says is in the attached evidence is not there, so what you can read above is all there is.`);
|
|
1024
|
+
}
|
|
1025
|
+
const summaryPage = await writeJobSummary(report, where);
|
|
1026
|
+
if (summaryPage.why) lost.push(summaryPage.why);
|
|
1027
|
+
|
|
1028
|
+
// Onto the report itself, both shapes of it, rather than only into a log line. The
|
|
1029
|
+
// markdown is what a person opens later and the text is what the job log keeps.
|
|
1030
|
+
if (lost.length > 0) {
|
|
1031
|
+
const said = lost.join(' ');
|
|
1032
|
+
report.markdown = `${report.markdown}\n\n> **${said}**\n`;
|
|
1033
|
+
report.text = `${report.text}\n${said}\n`;
|
|
938
1034
|
}
|
|
939
|
-
await writeJobSummary(report, where);
|
|
940
1035
|
|
|
941
1036
|
if (opts.quiet !== true) process.stdout.write(`${report.text}\n`);
|
|
942
1037
|
return { exitCode: report.exitCode, report, reference, verdict, evidence };
|
package/src/v2/cli.js
CHANGED
|
@@ -35,7 +35,8 @@ import { escalationBlock, escalationsFor, productFor, writeEscalations } from '.
|
|
|
35
35
|
// to mean two different things depending on which check you ran. src/cli/index.js imports
|
|
36
36
|
// this file in turn; that circle is safe because nothing here touches it while either
|
|
37
37
|
// module is still being evaluated.
|
|
38
|
-
import { watchFlags } from '../cli/
|
|
38
|
+
import { watchFlags } from '../cli/watch-flags.js';
|
|
39
|
+
import { INIT_COMMANDS } from './init.js';
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* What comes back from a check. Everything that did not change never appears
|
|
@@ -102,35 +103,42 @@ const V1_OPTIONS = [
|
|
|
102
103
|
['--pictures', 'The version 1 picture check, unchanged.'],
|
|
103
104
|
['--guards', 'The version 1 guards, unchanged.'],
|
|
104
105
|
['--only <name>', 'Just this journey, screen or guard. Repeat it for several.'],
|
|
106
|
+
['--record', 'The version 1 run that records network fixtures.'],
|
|
107
|
+
['--report / --no-report', 'Write the version 1 HTML report. Version 1 checks only.'],
|
|
108
|
+
['--profile', 'Print where the time went. Version 1 checks only.'],
|
|
105
109
|
];
|
|
106
110
|
|
|
107
111
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
112
|
+
* Flags only version 1's check reads, by the name the parser knows them under.
|
|
113
|
+
*
|
|
114
|
+
* They are accepted on every `check` because the two halves share one spec — which is right,
|
|
115
|
+
* since `--pictures --profile` has to work. What was wrong is that typing one WITHOUT
|
|
116
|
+
* `--pictures` or `--guards` did nothing at all and said nothing at all: `staysfixed check
|
|
117
|
+
* --profile` ran a perfectly ordinary difference-engine check, printed no profile, and gave
|
|
118
|
+
* no hint that the flag had been ignored. A flag that is accepted and does nothing is the
|
|
119
|
+
* same lie as a flag that does not exist, and a slower one to find.
|
|
120
|
+
*/
|
|
121
|
+
const V1_ONLY_FLAGS = [
|
|
122
|
+
['profile', '--profile'],
|
|
123
|
+
['report', '--report'],
|
|
124
|
+
['record', '--record'],
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The commands version 2 contributes. `src/cli/index.js` merges these over version 1's,
|
|
129
|
+
* so `check` and `doctor` become the difference engine while everything version 1 did
|
|
130
|
+
* stays reachable behind `--pictures`, `--guards` and `--watch`.
|
|
116
131
|
*/
|
|
117
132
|
export const V2_COMMANDS = {
|
|
118
|
-
//
|
|
119
|
-
// version 1 command table so that wiring version 2 into the front door stays the one
|
|
120
|
-
// import it has always been.
|
|
133
|
+
// Version 2's init replaces version 1's.
|
|
121
134
|
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// three tests in test/cli.test.js
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
// import { INIT_COMMANDS } from './init.js'; ...INIT_COMMANDS,
|
|
130
|
-
//
|
|
131
|
-
// switches it over, and those three tests have to be rewritten in the same change to
|
|
132
|
-
// say what the new one does. That is a decision, not an oversight, and it belongs in a
|
|
133
|
-
// change of its own rather than arriving as a side effect of wiring up the phones.
|
|
135
|
+
// It was held back on the grounds that somebody might have `staysfixed init` in a
|
|
136
|
+
// setup script and would get a different file. True, and not a reason: the old one
|
|
137
|
+
// writes settings for photographing screens, which is not what this tool does any
|
|
138
|
+
// more, so leaving it in place hands a new project the wrong shape and calls it done.
|
|
139
|
+
// The three tests in test/cli.test.js were rewritten in the same change to say what
|
|
140
|
+
// the new one actually does.
|
|
141
|
+
...INIT_COMMANDS,
|
|
134
142
|
...SHIP_COMMANDS,
|
|
135
143
|
|
|
136
144
|
check: {
|
|
@@ -203,6 +211,16 @@ export async function run(ctx) {
|
|
|
203
211
|
return await v1.run(ctx);
|
|
204
212
|
}
|
|
205
213
|
|
|
214
|
+
// Say so when a flag was accepted and will do nothing.
|
|
215
|
+
//
|
|
216
|
+
// This goes to standard error, so it cannot corrupt --json, and it is a warning rather
|
|
217
|
+
// than a refusal: the person asked for a real check and they should still get one.
|
|
218
|
+
for (const [flag, written] of V1_ONLY_FLAGS) {
|
|
219
|
+
if (ctx.flags[flag] !== undefined) {
|
|
220
|
+
warn(`${written} only applies to the version 1 check. This run is the difference engine, so it was ignored — add --pictures or --guards if that is what you wanted.`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
206
224
|
// --json means the answer belongs to a machine. Every line meant for a person
|
|
207
225
|
// is switched off before anything else runs, rather than trusted not to
|
|
208
226
|
// print: one stray sentence on standard output and the JSON will not parse.
|
package/src/v2/cluster.js
CHANGED
|
@@ -27,14 +27,6 @@ import { splitPath, sameValue } from './observation.js';
|
|
|
27
27
|
/** @typedef {import('./types.js').Channel} Channel */
|
|
28
28
|
/** @typedef {import('./types.js').ObservedValue} ObservedValue */
|
|
29
29
|
|
|
30
|
-
/** How much of a cluster a finding carries with it. Enough to orient, not enough to bury. */
|
|
31
|
-
const KEEP_NEAR_FILES = 5;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* How many of a cluster's addresses travel with the finding. `count` always says how
|
|
35
|
-
* many there really are, so a long cluster cannot hide its size behind a short list.
|
|
36
|
-
*/
|
|
37
|
-
const KEEP_PATHS = 20;
|
|
38
30
|
|
|
39
31
|
/**
|
|
40
32
|
* How each channel introduces itself. This is the first half of every sentence
|
|
@@ -55,8 +47,25 @@ const CHANNEL_WORDS = {
|
|
|
55
47
|
/**
|
|
56
48
|
* Last segments too vague to identify anything alone. When an address ends in
|
|
57
49
|
* one of these, the segment before it comes along for the ride.
|
|
50
|
+
*
|
|
51
|
+
* WHAT PUTS A WORD IN HERE. Every address ends either in something the PRODUCT
|
|
52
|
+
* named — an exported function, a route, a control, a field — or in a word this
|
|
53
|
+
* tool wrote to say what was being asked about it. The first kind identifies
|
|
54
|
+
* something on its own. The second kind is the same word on every journey, every
|
|
55
|
+
* door and every screen in the product, so a sentence built out of it names
|
|
56
|
+
* nothing: `"declared" is gone` was a real title, about a route that had been
|
|
57
|
+
* renamed, and the route was sitting one segment to the left the whole time.
|
|
58
|
+
* That sentence is not only read by an agent — it goes verbatim into the block a
|
|
59
|
+
* person reads in the closing summary, and the owner of this tool is not a coder.
|
|
60
|
+
*
|
|
61
|
+
* So this holds the words the tool writes, checked against the addresses the
|
|
62
|
+
* adapters actually produce rather than guessed at. `smartLeaf names the half a
|
|
63
|
+
* reader can act on` in the tests sweeps one real address of every shape through
|
|
64
|
+
* here; a new adapter that ends an address in a shared word belongs in that list
|
|
65
|
+
* and in this set on the same day.
|
|
58
66
|
*/
|
|
59
67
|
const VAGUE = new Set([
|
|
68
|
+
// What was asked about a thing: the thing itself is one segment to the left.
|
|
60
69
|
'value',
|
|
61
70
|
'text',
|
|
62
71
|
'name',
|
|
@@ -64,14 +73,45 @@ const VAGUE = new Set([
|
|
|
64
73
|
'label',
|
|
65
74
|
'title',
|
|
66
75
|
'count',
|
|
76
|
+
'size',
|
|
77
|
+
'reason',
|
|
67
78
|
'enabled',
|
|
68
79
|
'visible',
|
|
69
80
|
'status',
|
|
70
81
|
'type',
|
|
71
82
|
'id',
|
|
72
83
|
'body',
|
|
84
|
+
'shape',
|
|
73
85
|
'result',
|
|
74
86
|
'exit',
|
|
87
|
+
// Doors, which all end the same way whatever the door is: a route, a command,
|
|
88
|
+
// an IPC channel, a named control on a phone.
|
|
89
|
+
'declared',
|
|
90
|
+
'registered',
|
|
91
|
+
'reached',
|
|
92
|
+
// What happened when we asked, on every surface there is.
|
|
93
|
+
'asked',
|
|
94
|
+
'answered',
|
|
95
|
+
'answered at all',
|
|
96
|
+
'ran at all',
|
|
97
|
+
'opened at all',
|
|
98
|
+
'walked',
|
|
99
|
+
'read',
|
|
100
|
+
'typed',
|
|
101
|
+
'pressed',
|
|
102
|
+
'done',
|
|
103
|
+
'started',
|
|
104
|
+
'finished',
|
|
105
|
+
'refused',
|
|
106
|
+
'settled',
|
|
107
|
+
'held still',
|
|
108
|
+
'looks like',
|
|
109
|
+
'written',
|
|
110
|
+
'still running',
|
|
111
|
+
'stdout',
|
|
112
|
+
'stderr',
|
|
113
|
+
'controls',
|
|
114
|
+
'picture',
|
|
75
115
|
]);
|
|
76
116
|
|
|
77
117
|
/**
|
|
@@ -194,7 +234,15 @@ export function journeysOf(finding) {
|
|
|
194
234
|
*/
|
|
195
235
|
function buildFinding(signature, members, rename, sources) {
|
|
196
236
|
const head = members[0];
|
|
197
|
-
|
|
237
|
+
// EVERY source file this finding touches, not the first five. The short list was written
|
|
238
|
+
// as "enough to orient, not enough to bury", and the things reading it are not orienting.
|
|
239
|
+
// `sealed.js` searches these names for the words nobody may wave through, so a finding
|
|
240
|
+
// whose sixth file was src/billing/refund.js was classified ordinary and became waivable;
|
|
241
|
+
// `intent.js` matches them against what the agent declared it was changing, and `cause.js`
|
|
242
|
+
// uses them to work out which edit caused what. A cap on the input to the one gate that
|
|
243
|
+
// cannot have a ceiling is the same bug that was closed for differences. Everything that
|
|
244
|
+
// DISPLAYS this list already cuts it itself and says "and N more" when it does.
|
|
245
|
+
const nearFiles = unique(members.map((m) => sources[m.path]));
|
|
198
246
|
const evidence = members.find((m) => typeof m.evidence === 'string' && m.evidence.length > 0)?.evidence;
|
|
199
247
|
// Half the differences in a rename are the "vanished" side, so the count of
|
|
200
248
|
// places is the count of pairs, not of rows.
|
|
@@ -225,7 +273,16 @@ function buildFinding(signature, members, rename, sources) {
|
|
|
225
273
|
// everything downstream — the MCP reply lists them, and the self-check corpus
|
|
226
274
|
// matches its patterns against them — so they are filled in here rather than
|
|
227
275
|
// left for each reader to dig out of `differences` in its own way.
|
|
228
|
-
|
|
276
|
+
// EVERY address, not the first twenty. The short list was there to keep a finding small,
|
|
277
|
+
// and it was buying nothing: the finding already carries `differences`, which holds the
|
|
278
|
+
// same addresses AND both values at each of them, so cutting this list saved a fraction
|
|
279
|
+
// of what was being stored anyway. What it cost was real. A waiver is pinned partly to
|
|
280
|
+
// this list, so two three-hundred-address findings that agreed about their first twenty
|
|
281
|
+
// pinned to the same thing; and the reply an agent reads prints the length of this list
|
|
282
|
+
// under the heading "every address that moved", which was a count of twenty about a
|
|
283
|
+
// finding with three hundred. Readers that want a short list still cut it themselves,
|
|
284
|
+
// and every one of them says "and N more" when it does.
|
|
285
|
+
paths: members.map((m) => m.path),
|
|
229
286
|
sample: head,
|
|
230
287
|
};
|
|
231
288
|
if (nearFiles.length > 0) finding.nearFiles = nearFiles;
|
|
@@ -259,8 +316,23 @@ export function describe(d, count, rename, identical = true) {
|
|
|
259
316
|
if (rename) return `${where}, "${rename.from}" is now called "${rename.to}".${spread}`;
|
|
260
317
|
|
|
261
318
|
switch (d.kind) {
|
|
262
|
-
case 'changed':
|
|
263
|
-
|
|
319
|
+
case 'changed': {
|
|
320
|
+
const now = describeValue(d.candidate);
|
|
321
|
+
const was = describeValue(d.reference);
|
|
322
|
+
if (now !== was) return `${where}, "${name}" is now ${now} where it was ${was}.${spread}`;
|
|
323
|
+
// BOTH SIDES CAME OUT IN THE SAME WORDS, so this sentence would say nothing changed
|
|
324
|
+
// while sitting on top of a difference. It is what happens whenever the two values
|
|
325
|
+
// are summarised by their SHAPE and the shape held still: an invoice line that went
|
|
326
|
+
// from "£49.99" to "49.99 GBP" read "is now a set of details (one field: line) where
|
|
327
|
+
// it was a set of details (one field: line)" — twice the same words, on the tool's own
|
|
328
|
+
// flagship example, in the paragraph a person reads rather than an agent. So the
|
|
329
|
+
// summary is put down and the thing that actually moved is named instead.
|
|
330
|
+
const moved = whatMoved(d.reference, d.candidate);
|
|
331
|
+
if (!moved) return `${where}, "${name}" changed, and both versions of it read the same at this length.${spread}`;
|
|
332
|
+
return moved.what === ''
|
|
333
|
+
? `${where}, "${name}" now reads ${moved.now} where it read ${moved.was}.${spread}`
|
|
334
|
+
: `${where}, "${name}" now has "${moved.what}" reading ${moved.now} where it read ${moved.was}.${spread}`;
|
|
335
|
+
}
|
|
264
336
|
case 'appeared':
|
|
265
337
|
return `${where}, "${name}" is there now and was not before. It says ${describeValue(d.candidate)}.${spread}`;
|
|
266
338
|
case 'vanished':
|
|
@@ -354,7 +426,86 @@ export function describeValue(value) {
|
|
|
354
426
|
if (Array.isArray(value)) return `a list of ${value.length}`;
|
|
355
427
|
const keys = Object.keys(/** @type {object} */ (value));
|
|
356
428
|
if (keys.length === 0) return 'an empty set of details';
|
|
357
|
-
|
|
429
|
+
// The field names are quoted and counted. Bare, they run into the sentence around them and
|
|
430
|
+
// stop looking like names at all: a shape whose fields are the words "a list of" and "each
|
|
431
|
+
// one" came out as "a set of details (a list of, each one)", which is not a thing anybody
|
|
432
|
+
// can picture. Quoted, it reads as what it is.
|
|
433
|
+
const shown = keys.slice(0, 4).map((k) => JSON.stringify(k)).join(', ');
|
|
434
|
+
return `a set of details (${keys.length === 1 ? 'one field' : `${keys.length} fields`}: ${shown}${keys.length > 4 ? ', and more' : ''})`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The smallest thing that actually moved between two values.
|
|
439
|
+
*
|
|
440
|
+
* Only reached when a summary of the two whole values comes out identical, which is exactly
|
|
441
|
+
* when a summary is the wrong thing to print. It walks in until it finds the one field, or
|
|
442
|
+
* the one stretch of text, that is not the same, and hands back that piece with a name for
|
|
443
|
+
* it. `sameValue` is the tool's one comparison, so what counts as "not the same" here is
|
|
444
|
+
* what counts as a difference everywhere else.
|
|
445
|
+
*
|
|
446
|
+
* @param {ObservedValue|undefined} reference
|
|
447
|
+
* @param {ObservedValue|undefined} candidate
|
|
448
|
+
* @param {string[]} [trail]
|
|
449
|
+
* @returns {{what: string, was: string, now: string}|null}
|
|
450
|
+
*/
|
|
451
|
+
function whatMoved(reference, candidate, trail = []) {
|
|
452
|
+
if (isSetOfDetails(reference) && isSetOfDetails(candidate)) {
|
|
453
|
+
for (const key of [...new Set([...Object.keys(reference), ...Object.keys(candidate)])].sort()) {
|
|
454
|
+
const a = /** @type {Record<string, any>} */ (reference)[key];
|
|
455
|
+
const b = /** @type {Record<string, any>} */ (candidate)[key];
|
|
456
|
+
if (sameValue(a, b)) continue;
|
|
457
|
+
return whatMoved(a, b, [...trail, key]);
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
if (Array.isArray(reference) && Array.isArray(candidate)) {
|
|
462
|
+
for (let i = 0; i < Math.max(reference.length, candidate.length); i += 1) {
|
|
463
|
+
if (sameValue(reference[i], candidate[i])) continue;
|
|
464
|
+
return whatMoved(reference[i], candidate[i], [...trail, `number ${i + 1}`]);
|
|
465
|
+
}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
const what = trail.join(' / ');
|
|
469
|
+
if (typeof reference === 'string' && typeof candidate === 'string') {
|
|
470
|
+
// Two long strings summarise to their first sixty-odd characters, so if they agree that
|
|
471
|
+
// far they read the same however differently they end. A window round the first place
|
|
472
|
+
// they part company says what neither summary can.
|
|
473
|
+
const spot = firstDifference(reference, candidate);
|
|
474
|
+
return { what, was: JSON.stringify(spot.was), now: JSON.stringify(spot.now) };
|
|
475
|
+
}
|
|
476
|
+
const was = describeValue(reference);
|
|
477
|
+
const now = describeValue(candidate);
|
|
478
|
+
return was === now ? null : { what, was, now };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* A window round the first character two pieces of text stop agreeing at, with enough either
|
|
483
|
+
* side to recognise the place.
|
|
484
|
+
*
|
|
485
|
+
* @param {string} a
|
|
486
|
+
* @param {string} b
|
|
487
|
+
* @returns {{was: string, now: string}}
|
|
488
|
+
*/
|
|
489
|
+
function firstDifference(a, b) {
|
|
490
|
+
// Short enough to read whole, so read it whole. A window round the difference is only
|
|
491
|
+
// worth its ellipses when there is genuinely too much text to print.
|
|
492
|
+
if (a.length <= 70 && b.length <= 70) return { was: a, now: b };
|
|
493
|
+
let at = 0;
|
|
494
|
+
while (at < a.length && at < b.length && a[at] === b[at]) at += 1;
|
|
495
|
+
const from = Math.max(0, at - 20);
|
|
496
|
+
/** @param {string} text */
|
|
497
|
+
const window = (text) => `${from > 0 ? '…' : ''}${text.slice(from, at + 40)}${at + 40 < text.length ? '…' : ''}`;
|
|
498
|
+
return { was: window(a), now: window(b) };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* A value made of named fields, as opposed to a list, a number, or a piece of text.
|
|
503
|
+
*
|
|
504
|
+
* @param {unknown} value
|
|
505
|
+
* @returns {value is Record<string, unknown>}
|
|
506
|
+
*/
|
|
507
|
+
function isSetOfDetails(value) {
|
|
508
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
358
509
|
}
|
|
359
510
|
|
|
360
511
|
/**
|