staysfixed 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +429 -0
- package/README.md +193 -57
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +19 -6
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +564 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/status.js +45 -1
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +54 -3
- package/src/core/paths.js +15 -0
- package/src/guard/run.js +70 -3
- package/src/report/console.js +50 -6
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +18 -11
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +783 -71
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +134 -24
- package/src/v2/adapters/web.js +149 -18
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +66 -3
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +653 -69
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +65 -42
- package/src/v2/cluster.js +220 -14
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +353 -54
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +183 -66
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +90 -16
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +160 -24
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +55 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- package/src/v2/watch/panel.js +167 -17
package/src/v2/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,9 @@ 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';
|
|
40
|
+
import { whatWasNotChecked } from './check.js';
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* What comes back from a check. Everything that did not change never appears
|
|
@@ -102,35 +104,42 @@ const V1_OPTIONS = [
|
|
|
102
104
|
['--pictures', 'The version 1 picture check, unchanged.'],
|
|
103
105
|
['--guards', 'The version 1 guards, unchanged.'],
|
|
104
106
|
['--only <name>', 'Just this journey, screen or guard. Repeat it for several.'],
|
|
107
|
+
['--record', 'The version 1 run that records network fixtures.'],
|
|
108
|
+
['--report / --no-report', 'Write the version 1 HTML report. Version 1 checks only.'],
|
|
109
|
+
['--profile', 'Print where the time went. Version 1 checks only.'],
|
|
105
110
|
];
|
|
106
111
|
|
|
107
112
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
113
|
+
* Flags only version 1's check reads, by the name the parser knows them under.
|
|
114
|
+
*
|
|
115
|
+
* They are accepted on every `check` because the two halves share one spec — which is right,
|
|
116
|
+
* since `--pictures --profile` has to work. What was wrong is that typing one WITHOUT
|
|
117
|
+
* `--pictures` or `--guards` did nothing at all and said nothing at all: `staysfixed check
|
|
118
|
+
* --profile` ran a perfectly ordinary difference-engine check, printed no profile, and gave
|
|
119
|
+
* no hint that the flag had been ignored. A flag that is accepted and does nothing is the
|
|
120
|
+
* same lie as a flag that does not exist, and a slower one to find.
|
|
121
|
+
*/
|
|
122
|
+
const V1_ONLY_FLAGS = [
|
|
123
|
+
['profile', '--profile'],
|
|
124
|
+
['report', '--report'],
|
|
125
|
+
['record', '--record'],
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The commands version 2 contributes. `src/cli/index.js` merges these over version 1's,
|
|
130
|
+
* so `check` and `doctor` become the difference engine while everything version 1 did
|
|
131
|
+
* stays reachable behind `--pictures`, `--guards` and `--watch`.
|
|
116
132
|
*/
|
|
117
133
|
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.
|
|
121
|
-
//
|
|
122
|
-
// `staysfixed init` from src/v2/init.js is deliberately NOT merged in yet, and this is
|
|
123
|
-
// the same call as the MCP server: version 2's init is a BREAKING change. Version 1's
|
|
124
|
-
// init writes settings for any folder; version 2's reads the project first and writes
|
|
125
|
-
// nothing when it cannot tell what the project is, which is better and is not what the
|
|
126
|
-
// three tests in test/cli.test.js describe. Somebody installed this last week and has
|
|
127
|
-
// `staysfixed init` in a setup script. Adding one line here —
|
|
134
|
+
// Version 2's init replaces version 1's.
|
|
128
135
|
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
136
|
+
// It was held back on the grounds that somebody might have `staysfixed init` in a
|
|
137
|
+
// setup script and would get a different file. True, and not a reason: the old one
|
|
138
|
+
// writes settings for photographing screens, which is not what this tool does any
|
|
139
|
+
// more, so leaving it in place hands a new project the wrong shape and calls it done.
|
|
140
|
+
// The three tests in test/cli.test.js were rewritten in the same change to say what
|
|
141
|
+
// the new one actually does.
|
|
142
|
+
...INIT_COMMANDS,
|
|
134
143
|
...SHIP_COMMANDS,
|
|
135
144
|
|
|
136
145
|
check: {
|
|
@@ -159,16 +168,17 @@ export const V2_COMMANDS = {
|
|
|
159
168
|
|
|
160
169
|
doctor: {
|
|
161
170
|
summary: 'What this tool can and cannot check on this machine, and what would unlock more.',
|
|
162
|
-
usage: 'staysfixed doctor [--json] [--offline] [--fix]',
|
|
171
|
+
usage: 'staysfixed doctor [--json] [--machines] [--offline] [--fix]',
|
|
163
172
|
describe:
|
|
164
|
-
'Looks at this machine rather than at your project: what is installed, which other\nmachines it can already reach, what each of those lets it watch, and what exactly\nis in the way of the rest. It never asks you to set up something that already\nworks — everything it lists as missing failed a real check first.\n\n--json is the same answer as an object, and it is the first thing an agent\nshould call. --fix repairs the small things version 1 could repair.',
|
|
173
|
+
'Looks at this machine rather than at your project: what is installed, which other\nmachines it can already reach, what each of those lets it watch, and what exactly\nis in the way of the rest. It never asks you to set up something that already\nworks — everything it lists as missing failed a real check first.\n\n--json is the same answer as an object, and it is the first thing an agent\nshould call. --fix repairs the small things version 1 could repair.\n\nIt does not connect to the machines in your ssh config unless this project has a\ndesktop app that might need one, or you pass --machines. They are still listed.',
|
|
165
174
|
options: [
|
|
166
175
|
['--json', 'The whole answer as one JSON object. For agents.'],
|
|
176
|
+
['--machines', 'Also dial the machines in your ssh config, to find a runner for a desktop app.'],
|
|
167
177
|
['--offline', 'Do not dial any other machine. Faster, and reports no runners.'],
|
|
168
178
|
['--fix', 'Repair the small local things that can be repaired safely.'],
|
|
169
179
|
],
|
|
170
180
|
examples: ['staysfixed doctor', 'staysfixed doctor --json'],
|
|
171
|
-
spec: { booleans: ['json', 'offline', 'fix'] },
|
|
181
|
+
spec: { booleans: ['json', 'offline', 'fix', 'machines'] },
|
|
172
182
|
load: async () => ({ run: doctorRun }),
|
|
173
183
|
},
|
|
174
184
|
};
|
|
@@ -203,6 +213,16 @@ export async function run(ctx) {
|
|
|
203
213
|
return await v1.run(ctx);
|
|
204
214
|
}
|
|
205
215
|
|
|
216
|
+
// Say so when a flag was accepted and will do nothing.
|
|
217
|
+
//
|
|
218
|
+
// This goes to standard error, so it cannot corrupt --json, and it is a warning rather
|
|
219
|
+
// than a refusal: the person asked for a real check and they should still get one.
|
|
220
|
+
for (const [flag, written] of V1_ONLY_FLAGS) {
|
|
221
|
+
if (ctx.flags[flag] !== undefined) {
|
|
222
|
+
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.`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
206
226
|
// --json means the answer belongs to a machine. Every line meant for a person
|
|
207
227
|
// is switched off before anything else runs, rather than trusted not to
|
|
208
228
|
// print: one stray sentence on standard output and the JSON will not parse.
|
|
@@ -225,23 +245,26 @@ export async function run(ctx) {
|
|
|
225
245
|
// checks on the command line and then ships is told their build was "never
|
|
226
246
|
// checked", and the safeguard fires on the honest case instead of the careless
|
|
227
247
|
// one. The agent surface records its own; this is the command line's half.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
ok: verdict.ok,
|
|
235
|
-
blocked: /** @type {any} */ (verdict).blocked === true,
|
|
236
|
-
findings: verdict.findings.length,
|
|
237
|
-
by: 'staysfixed check',
|
|
238
|
-
});
|
|
239
|
-
} catch {
|
|
240
|
-
// Never let bookkeeping cost somebody the result they came for.
|
|
241
|
-
}
|
|
248
|
+
// NOT recorded again here. The engine writes this line itself, for every surface, inside
|
|
249
|
+
// `rememberCheck` — and with more in it than this ever had: what was waived, what was
|
|
250
|
+
// sealed, how much went unaccounted for. This block was added on the belief that only the
|
|
251
|
+
// agent surface recorded, so every command-line check wrote TWO near-identical rows two
|
|
252
|
+
// milliseconds apart. Measured 2026-08-30: eight rows for four checks, and `ship` reads
|
|
253
|
+
// this log to decide whether the last check was clean.
|
|
242
254
|
|
|
243
255
|
if (asJson) {
|
|
244
|
-
|
|
256
|
+
// The README promises these as fields of their own — "a number an agent can read"
|
|
257
|
+
// rather than a sentence it has to parse — and only the MCP reply had them. So a human
|
|
258
|
+
// asking for JSON on the command line got a strictly worse answer about what was NOT
|
|
259
|
+
// checked than an agent asking over MCP, about the very same run.
|
|
260
|
+
const coverage = verdict.coverage ?? null;
|
|
261
|
+
process.stdout.write(
|
|
262
|
+
JSON.stringify({
|
|
263
|
+
...verdict,
|
|
264
|
+
notChecked: whatWasNotChecked(coverage),
|
|
265
|
+
doorsNeverOpened: Math.max(0, (coverage?.doorsKnown ?? 0) - (coverage?.doorsWalked ?? 0)),
|
|
266
|
+
}) + '\n',
|
|
267
|
+
);
|
|
245
268
|
} else {
|
|
246
269
|
report(verdict);
|
|
247
270
|
}
|