staysfixed 0.12.0 → 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 +32 -2
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +10 -0
- 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/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/isolate.js +169 -14
- 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 +136 -24
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +332 -34
- package/src/v2/cli.js +19 -1
- package/src/v2/coverage.js +555 -18
- package/src/v2/detect.js +737 -40
- package/src/v2/doctor.js +3 -3
- package/src/v2/escalate.js +57 -11
- package/src/v2/init.js +562 -21
- 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/mcp/tools.js +185 -12
- 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/selfcheck.js
CHANGED
|
@@ -6,16 +6,20 @@
|
|
|
6
6
|
* as the evidence that it can still go red, and there is no other way to get
|
|
7
7
|
* that evidence: you cannot test a difference engine by reading it.
|
|
8
8
|
*
|
|
9
|
-
* So this builds
|
|
10
|
-
* commit and an uncommitted change on top - which is exactly the shape
|
|
11
|
-
* thing an agent points this tool at - runs the engine over each, and
|
|
12
|
-
* loudly if a break gets through.
|
|
9
|
+
* So this builds a tiny product per case, each as a real repository with a
|
|
10
|
+
* working commit and an uncommitted change on top - which is exactly the shape
|
|
11
|
+
* of the thing an agent points this tool at - runs the engine over each, and
|
|
12
|
+
* fails loudly if a break gets through. A few of them go further and ship,
|
|
13
|
+
* because what "working" MEANS is only ever written down by shipping, and the
|
|
14
|
+
* cases about that record cannot be set up any other way.
|
|
13
15
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* a
|
|
16
|
+
* Most are breaks that MUST be caught. The rest are the other half of the same
|
|
17
|
+
* promise, and they matter just as much: pairs that must produce NO findings at
|
|
18
|
+
* all. A tool that cries wolf gets switched off, and a tool that is switched off
|
|
19
|
+
* catches nothing, so a false alarm fails this run exactly the way a miss does.
|
|
20
|
+
* No count is given here on purpose - it was wrong within a fortnight of being
|
|
21
|
+
* written, and a comment that has to be edited every time a case is added is a
|
|
22
|
+
* comment that ends up lying. The list below is the count.
|
|
19
23
|
*
|
|
20
24
|
* staysfixed check --selfcheck
|
|
21
25
|
* node src/v2/selfcheck.js --only rounded --keep
|
|
@@ -37,6 +41,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
37
41
|
// ever looked in different places, the corpus would be proving something other
|
|
38
42
|
// than what an agent actually runs, which is worse than having no corpus at all.
|
|
39
43
|
import { loadEngine } from './mcp/tools.js';
|
|
44
|
+
// Shipping is the one thing in this tool that decides what "working" means, and three of the
|
|
45
|
+
// cases below are about exactly that decision, so they have to make it for real. This is the
|
|
46
|
+
// same function `staysfixed ship` calls; going round it — writing a reference pointer by
|
|
47
|
+
// hand — would prove the engine works against a record no person could ever produce.
|
|
48
|
+
import { onShip } from './ship.js';
|
|
40
49
|
|
|
41
50
|
const run = promisify(execFile);
|
|
42
51
|
|
|
@@ -71,6 +80,17 @@ const run = promisify(execFile);
|
|
|
71
80
|
* @property {Record<string, unknown>[]} [journeys]
|
|
72
81
|
* A journeys file of its own, for a case about what happens when a journey cannot be
|
|
73
82
|
* walked. Left out, every case gets the one-step "run it" journey.
|
|
83
|
+
* @property {'the commit that works'|'whatever was shipped'} [compareWith]
|
|
84
|
+
* Which build this case holds the product against. Every case but the ones about what
|
|
85
|
+
* "working" MEANS uses the default and names the commit, because that is the shortest way
|
|
86
|
+
* to a fixture with two builds in it. A case about the record itself cannot: the record is
|
|
87
|
+
* only written by shipping, so those cases ship in `prepare` and then ask the engine the
|
|
88
|
+
* way a person does — by asking nothing, and letting it read what the project calls working.
|
|
89
|
+
* @property {boolean} [paired]
|
|
90
|
+
* Whether to boot the old build and walk it live. True unless a case says otherwise, because
|
|
91
|
+
* it is the strongest comparison there is. A case about a reference that CANNOT honestly be
|
|
92
|
+
* booted has to say false: asking for a paired run against one is refused outright, which is
|
|
93
|
+
* the right answer to give a person and a useless one to write a corpus case against.
|
|
74
94
|
* @property {(dir: string, working: string) => Promise<{ready: boolean, why: string}>} [prepare]
|
|
75
95
|
* Bend the machine around the product before the engine runs — take away permission to
|
|
76
96
|
* write, for instance. Answering `ready: false` means this machine cannot be made to do
|
|
@@ -573,8 +593,271 @@ export const CASES = [
|
|
|
573
593
|
'report.js': `console.log('${broken ? 'three' : 'two'} orders');\n`,
|
|
574
594
|
}),
|
|
575
595
|
},
|
|
596
|
+
|
|
597
|
+
{
|
|
598
|
+
name: 'a product that never started is not a product that has not changed',
|
|
599
|
+
breaks:
|
|
600
|
+
'Nothing runs at all. This product throws on its first line, prints nothing and stops with a failure code, and it does it on BOTH builds — which is the ordinary shape of a project somebody is halfway through fixing. What the walk then wrote down was a stack trace and an exit code: two facts, both true, both about the crash rather than about the product. Two builds that crash the same way agree at every one of those addresses, and agreement is the silence this tool reads as "nothing changed". Measured on 2026-08-31 it came back "Nothing that worked has changed. 7 addresses checked" about a product that had done nothing whatsoever, `ship` blessed it as the standard, and fixing the product the next day produced four findings nobody had caused — every real value differing from the stored crash.',
|
|
601
|
+
expect: 'no answer',
|
|
602
|
+
// The word that has to be in the closing paragraph. A run may perfectly well come back
|
|
603
|
+
// with no findings here; what it may never do is present that as a comparison. Saying
|
|
604
|
+
// "refusal" is saying the addresses hold no answer on one side or the other, which is the
|
|
605
|
+
// whole difference between "I looked and found nothing" and "there was nothing to look at".
|
|
606
|
+
summaryMustSay: [/refusal/i],
|
|
607
|
+
build: (broken) => ({
|
|
608
|
+
// Its own package.json, without the `bin` entry every other fixture carries. That entry
|
|
609
|
+
// is a door, the source reader finds it without running anything, and two addresses that
|
|
610
|
+
// compare perfectly well are enough to stop this being a run where NOTHING was compared —
|
|
611
|
+
// which is the exact shape this case is about. The product here does nothing at all, and
|
|
612
|
+
// the fixture has to be a product that offers nothing else either.
|
|
613
|
+
'package.json': JSON.stringify({ name: 'widget', version: '1.0.0', type: 'module' }, null, 2) + '\n',
|
|
614
|
+
'cli.js': [
|
|
615
|
+
"import { readFileSync } from 'node:fs';",
|
|
616
|
+
'// The first line, and it throws: the settings file this needs is not there. Nothing',
|
|
617
|
+
'// below it ever runs, on either build.',
|
|
618
|
+
"const settings = JSON.parse(readFileSync('settings.json', 'utf8'));",
|
|
619
|
+
broken ? "console.log('orders: could not be loaded');" : 'console.log(`orders: ${settings.orders}`);',
|
|
620
|
+
'',
|
|
621
|
+
].join('\n'),
|
|
622
|
+
}),
|
|
623
|
+
},
|
|
624
|
+
|
|
625
|
+
{
|
|
626
|
+
name: 'coverage this build took away is never handed back as a pass',
|
|
627
|
+
breaks:
|
|
628
|
+
'Two journeys. One of them is untouched and agrees perfectly; the other stops answering, because the program behind it now throws on its first line. So there are no findings — there is nothing at those addresses to differ from — and the honest verdict is that this run is not a pass: an address that used to be watched and cannot be now is coverage this change took away. The engine works that out correctly and then handed it to the bookkeeping, and until 2026-08-31 the bookkeeping ASSIGNED the verdict rather than narrowing it: one line, `ok = no findings and no new wobble`, which threw away every not-a-pass already decided. Accounting may take a pass away. It may never hand one back.',
|
|
629
|
+
expect: 'no answer',
|
|
630
|
+
summaryMustSay: [/coverage this build has taken away/i],
|
|
631
|
+
journeys: [
|
|
632
|
+
{
|
|
633
|
+
name: 'run-it',
|
|
634
|
+
describe: 'Run the main program.',
|
|
635
|
+
source: 'code',
|
|
636
|
+
surface: 'cli',
|
|
637
|
+
steps: [{ act: 'run', run: 'node cli.js', note: 'the part of the product that still works' }],
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
name: 'run-the-report',
|
|
641
|
+
describe: 'Run the report.',
|
|
642
|
+
source: 'code',
|
|
643
|
+
surface: 'cli',
|
|
644
|
+
steps: [{ act: 'run', run: 'node report.js', note: 'the part that stops answering' }],
|
|
645
|
+
},
|
|
646
|
+
],
|
|
647
|
+
build: (broken) => ({
|
|
648
|
+
'package.json': PKG,
|
|
649
|
+
'cli.js': "console.log('two orders');\n",
|
|
650
|
+
'report.js': broken
|
|
651
|
+
? [
|
|
652
|
+
"import { readFileSync } from 'node:fs';",
|
|
653
|
+
"const rows = JSON.parse(readFileSync('nowhere.json', 'utf8'));",
|
|
654
|
+
'console.log(`report rows ${rows.length}`);',
|
|
655
|
+
'',
|
|
656
|
+
].join('\n')
|
|
657
|
+
: "console.log('report rows 3');\n",
|
|
658
|
+
}),
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
{
|
|
662
|
+
name: 'a standard cut from a tree git does not have is never walked out of git',
|
|
663
|
+
breaks:
|
|
664
|
+
'The product shipped from a working tree with uncommitted changes in it, which is how most things actually ship. The record is filed under a fingerprint of that TREE — an id like `work-76ac0155c8b9`, deliberately not the commit\'s — because the files that were checked are not the files git has. Then somebody threw the uncommitted work away, so the product is the old commit again and a field has gone from what it prints. That is a real regression against what shipped, and the record on disk holds the value it is missing. Until 2026-08-31 a run would export the COMMIT, boot it, call it the old build, see it missing the same field, and drop the finding as something the old build does too. A clean answer, produced by walking a build nobody ever shipped.',
|
|
665
|
+
expect: 'a finding',
|
|
666
|
+
mustSay: [/email/i],
|
|
667
|
+
compareWith: 'whatever was shipped',
|
|
668
|
+
// Not paired, and that is the case rather than a limitation of it. Asking for a paired run
|
|
669
|
+
// against a reference cut from a tree git does not have is refused in one sentence, which
|
|
670
|
+
// is the right answer and not one a corpus can learn anything from. The damage was always
|
|
671
|
+
// done on the ordinary run: the old build gets booted anyway, to prove the differences that
|
|
672
|
+
// survived, and that is the road this walks.
|
|
673
|
+
paired: false,
|
|
674
|
+
prepare: shipFromATreeWithUncommittedWork,
|
|
675
|
+
build: (broken) => ({ 'package.json': PKG, 'cli.js': theReply(broken) }),
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
{
|
|
679
|
+
name: 'with nothing edited, nothing can be a change',
|
|
680
|
+
breaks:
|
|
681
|
+
'Nothing at all, and there is nothing on the other side of the comparison either: this is the build already on record as working, run again. Measured 2026-08-31 on a stock Next.js app with two pages and a link between them — its own prefetch is started by the browser and cancelled when the page is torn down, so it lands in about four runs in five. Ten checks of that untouched tree, minutes apart, gave three reports of a difference, one report that "the change made something non-deterministic", and six clean ones. Identical bytes, four different answers, and the one thing that was certainly true — that nothing had been edited — was the thing the report denied. A race is no use in a corpus that has to mean the same thing every time it runs, so the fixture stands in for it with something that behaves the same way and never flickers: a local file nobody commits, holding the name of the machine this was set up on. The working tree has it and a checkout of the commit does not, so the address answers one way here and another way there while the build is byte for byte the same build.',
|
|
682
|
+
expect: 'nothing',
|
|
683
|
+
// Both sentences, and each carries half of it. The first is the headline saying what this
|
|
684
|
+
// run really was; the second proves an address DID move and was filed as the build
|
|
685
|
+
// disagreeing with itself rather than dropped. A case that went quiet because nothing
|
|
686
|
+
// happened would pass the first and fail the second, which is the point of having it.
|
|
687
|
+
summaryMustSay: [/compared with itself/i, /answered differently across two runs of one build/i],
|
|
688
|
+
build: () => ({
|
|
689
|
+
'package.json': PKG,
|
|
690
|
+
// Under `out/`, which the corpus gitignores, so it is in the working tree and not in
|
|
691
|
+
// the commit — exactly like the .env or the machine-local settings file every real
|
|
692
|
+
// project has one of.
|
|
693
|
+
'out/machine.txt': 'lab-7\n',
|
|
694
|
+
'cli.js': [
|
|
695
|
+
"import fs from 'node:fs';",
|
|
696
|
+
'// A product that says which machine it was set up on, out of a file nobody commits.',
|
|
697
|
+
"const machine = fs.existsSync('out/machine.txt') ? fs.readFileSync('out/machine.txt', 'utf8').trim() : 'unknown';",
|
|
698
|
+
'console.log(`machine ${machine}`);',
|
|
699
|
+
"console.log('total 10.005');",
|
|
700
|
+
'',
|
|
701
|
+
].join('\n'),
|
|
702
|
+
}),
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
{
|
|
706
|
+
name: 'what working means moves only when somebody ships',
|
|
707
|
+
breaks:
|
|
708
|
+
'Nothing, and the product prints exactly what it printed on the day it shipped. What moved in between is the RECORD: somebody put the shipped build back on another machine and ran an ordinary check, which stored fresh captures of it. The store keeps every capture a build ever produced and the reader took the newest, so that run quietly became the definition of working — and the next check compared today against a stranger\'s afternoon rather than against what was blessed. Only `ship` decides what working means; a record that drifts on its own is that one rule leaking. Measured and fixed 2026-08-31, and the two captures blessed at ship time are written down beside the cut for exactly this reason.',
|
|
709
|
+
expect: 'nothing',
|
|
710
|
+
// Proof that it was quiet for the right reason. Silence over a comparison that never
|
|
711
|
+
// happened is the failure this whole corpus exists to make impossible, so the closing
|
|
712
|
+
// sentence has to name a count of addresses that really were put side by side.
|
|
713
|
+
summaryMustSay: [/addresses checked/i],
|
|
714
|
+
compareWith: 'whatever was shipped',
|
|
715
|
+
paired: false,
|
|
716
|
+
prepare: letTheRecordDriftAfterShipping,
|
|
717
|
+
build: () => ({
|
|
718
|
+
'package.json': PKG,
|
|
719
|
+
'cli.js': [
|
|
720
|
+
"import fs from 'node:fs';",
|
|
721
|
+
'// The machine name lives in a file nobody commits, so it can differ from one run of',
|
|
722
|
+
'// this very build to the next without the build itself changing at all.',
|
|
723
|
+
"const machine = fs.existsSync('out/machine.txt') ? fs.readFileSync('out/machine.txt', 'utf8').trim() : 'unknown';",
|
|
724
|
+
'console.log(`machine ${machine}`);',
|
|
725
|
+
"console.log('two orders');",
|
|
726
|
+
'',
|
|
727
|
+
].join('\n'),
|
|
728
|
+
}),
|
|
729
|
+
},
|
|
576
730
|
];
|
|
577
731
|
|
|
732
|
+
/**
|
|
733
|
+
* The reply this product prints, with and without the field that was shipped.
|
|
734
|
+
*
|
|
735
|
+
* One function because two cases need the same two versions of it: the build that is put in
|
|
736
|
+
* the commit, and the build that is really shipped on top of it.
|
|
737
|
+
*
|
|
738
|
+
* @param {boolean} withEmail
|
|
739
|
+
* @returns {string}
|
|
740
|
+
*/
|
|
741
|
+
function theReply(withEmail) {
|
|
742
|
+
return [
|
|
743
|
+
'const person = {',
|
|
744
|
+
' id: 7,',
|
|
745
|
+
" name: 'Ada',",
|
|
746
|
+
withEmail ? " email: 'ada@example.com'," : null,
|
|
747
|
+
" city: 'London',",
|
|
748
|
+
'};',
|
|
749
|
+
'console.log(JSON.stringify(person));',
|
|
750
|
+
'',
|
|
751
|
+
]
|
|
752
|
+
.filter((line) => line !== null)
|
|
753
|
+
.join('\n');
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Ship the working tree, uncommitted changes and all, and then throw the uncommitted work away.
|
|
758
|
+
*
|
|
759
|
+
* The state this leaves behind is an ordinary Tuesday: a release cut from what was actually on
|
|
760
|
+
* disk, and a tree that has since gone back to the commit. What makes it worth a case is that
|
|
761
|
+
* the record and the commit are two different builds, and everything downstream believed
|
|
762
|
+
* otherwise — so the value the record holds gets walked against a build that never had it.
|
|
763
|
+
*
|
|
764
|
+
* `ready: false` where the machine will not perform it. A refused cut is not a failed case: it
|
|
765
|
+
* means this fixture never reached the state the case is about, and calling that a pass would
|
|
766
|
+
* be the corpus telling the kind of lie it exists to catch.
|
|
767
|
+
*
|
|
768
|
+
* @param {string} dir
|
|
769
|
+
* @returns {Promise<{ready: boolean, why: string}>}
|
|
770
|
+
*/
|
|
771
|
+
async function shipFromATreeWithUncommittedWork(dir) {
|
|
772
|
+
const engine = await loadEngine();
|
|
773
|
+
if (!engine.parts.check) return { ready: false, why: 'the difference engine is not in this build' };
|
|
774
|
+
const journeys = path.join(dir, 'journeys.json');
|
|
775
|
+
|
|
776
|
+
// Proved, not assumed. The whole case rests on the tree being one git does not have, and a
|
|
777
|
+
// fixture that came out clean would ship a plain commit and test nothing at all.
|
|
778
|
+
const changed = await git(dir, ['status', '--porcelain']);
|
|
779
|
+
if (changed.trim() === '') return { ready: false, why: 'the working tree came out clean, so there was no uncommitted change to ship from' };
|
|
780
|
+
|
|
781
|
+
// One walk, so the store has a record of what this build does. Nothing is compared: there is
|
|
782
|
+
// no reference yet, which is exactly where a project starts.
|
|
783
|
+
try {
|
|
784
|
+
await engine.parts.check({ cwd: dir, configFile: undefined, against: undefined, paired: false, journeys, only: [] });
|
|
785
|
+
} catch (e) {
|
|
786
|
+
return { ready: false, why: `the first walk did not work: ${why(e)}` };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** @type {any} */
|
|
790
|
+
let shipped;
|
|
791
|
+
try {
|
|
792
|
+
// No product name is passed, deliberately. A person runs `staysfixed ship` and nothing
|
|
793
|
+
// else, so the name has to be worked out the same way `check` works it out — and the day
|
|
794
|
+
// those two readers disagreed, a project shipped and checked under different names for its
|
|
795
|
+
// whole life and never compared anything. Letting it derive here keeps that in the corpus.
|
|
796
|
+
shipped = await onShip({ root: dir, note: 'shipped from the working tree' });
|
|
797
|
+
} catch (e) {
|
|
798
|
+
return { ready: false, why: `shipping did not work: ${why(e)}` };
|
|
799
|
+
}
|
|
800
|
+
if (shipped?.cut !== true) return { ready: false, why: `nothing was made the standard, so there is no record to compare against: ${shipped?.summary ?? 'no reason given'}` };
|
|
801
|
+
|
|
802
|
+
// And now somebody throws the uncommitted work away — a discarded stash, a `git checkout .`,
|
|
803
|
+
// an agent tidying up. The product is the commit again, and a field that was in what shipped
|
|
804
|
+
// has gone with it.
|
|
805
|
+
await writeAll(dir, { 'cli.js': theReply(false) });
|
|
806
|
+
return { ready: true, why: '' };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Ship, then let somebody else run an ordinary check on the very same build.
|
|
811
|
+
*
|
|
812
|
+
* That second run stores fresh captures of the shipped build, and it is the whole case: the
|
|
813
|
+
* store keeps every capture a build ever produced, and the moment the reader takes the newest
|
|
814
|
+
* of them, a stranger's afternoon has quietly replaced what "working" means. Nobody shipped.
|
|
815
|
+
*
|
|
816
|
+
* The last two lines matter as much as the rest. The machine name is put back so the product
|
|
817
|
+
* really is doing what it did on the day it shipped — the case is about the RECORD moving, not
|
|
818
|
+
* the product — and a note file is added so this build is not the reference build itself, which
|
|
819
|
+
* would make the run silent for a completely different and correct reason.
|
|
820
|
+
*
|
|
821
|
+
* @param {string} dir
|
|
822
|
+
* @returns {Promise<{ready: boolean, why: string}>}
|
|
823
|
+
*/
|
|
824
|
+
async function letTheRecordDriftAfterShipping(dir) {
|
|
825
|
+
const engine = await loadEngine();
|
|
826
|
+
if (!engine.parts.check) return { ready: false, why: 'the difference engine is not in this build' };
|
|
827
|
+
const journeys = path.join(dir, 'journeys.json');
|
|
828
|
+
/** @param {string} machine */
|
|
829
|
+
const setUpOn = async (machine) => writeAll(dir, { 'out/machine.txt': `${machine}\n` });
|
|
830
|
+
|
|
831
|
+
await setUpOn('lab-7');
|
|
832
|
+
try {
|
|
833
|
+
await engine.parts.check({ cwd: dir, configFile: undefined, against: undefined, paired: false, journeys, only: [] });
|
|
834
|
+
} catch (e) {
|
|
835
|
+
return { ready: false, why: `the walk before shipping did not work: ${why(e)}` };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** @type {any} */
|
|
839
|
+
let shipped;
|
|
840
|
+
try {
|
|
841
|
+
shipped = await onShip({ root: dir, note: 'the build that works' });
|
|
842
|
+
} catch (e) {
|
|
843
|
+
return { ready: false, why: `shipping did not work: ${why(e)}` };
|
|
844
|
+
}
|
|
845
|
+
if (shipped?.cut !== true) return { ready: false, why: `nothing was made the standard, so there is no record to drift: ${shipped?.summary ?? 'no reason given'}` };
|
|
846
|
+
|
|
847
|
+
// Somebody else, on another machine, checks the same build out and runs a check. Nothing
|
|
848
|
+
// about that is unusual and nothing about it is shipping.
|
|
849
|
+
await setUpOn('lab-9');
|
|
850
|
+
try {
|
|
851
|
+
await engine.parts.check({ cwd: dir, configFile: undefined, against: undefined, paired: false, journeys, only: [] });
|
|
852
|
+
} catch (e) {
|
|
853
|
+
return { ready: false, why: `the second walk of the shipped build did not work: ${why(e)}` };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
await setUpOn('lab-7');
|
|
857
|
+
await fsp.writeFile(path.join(dir, 'NOTES.md'), 'A note about the product. Nothing runs it.\n');
|
|
858
|
+
return { ready: true, why: '' };
|
|
859
|
+
}
|
|
860
|
+
|
|
578
861
|
/**
|
|
579
862
|
* Take away the store's permission to be written to, and say honestly when this machine
|
|
580
863
|
* will not let that happen.
|
|
@@ -836,8 +1119,11 @@ async function runOne(check, workDir, c, attempt) {
|
|
|
836
1119
|
result = await check({
|
|
837
1120
|
cwd: dir,
|
|
838
1121
|
configFile: undefined,
|
|
839
|
-
|
|
840
|
-
|
|
1122
|
+
// Naming the commit is what gives most cases two builds without anybody shipping. A
|
|
1123
|
+
// case about what the RECORD says is working must not name one: it has to be answered
|
|
1124
|
+
// out of the store, which is the only place a person's `staysfixed ship` writes to.
|
|
1125
|
+
against: c.compareWith === 'whatever was shipped' ? undefined : working,
|
|
1126
|
+
paired: c.paired !== false,
|
|
841
1127
|
journeys: path.join(dir, 'journeys.json'),
|
|
842
1128
|
only: [],
|
|
843
1129
|
});
|
package/src/v2/store.js
CHANGED
|
@@ -33,6 +33,7 @@ import fs from 'node:fs';
|
|
|
33
33
|
import fsp from 'node:fs/promises';
|
|
34
34
|
import path from 'node:path';
|
|
35
35
|
import crypto from 'node:crypto';
|
|
36
|
+
import { pathToFileURL } from 'node:url';
|
|
36
37
|
import { safeName, findConfigFile } from '../core/paths.js';
|
|
37
38
|
import { StaysFixedError } from '../core/errors.js';
|
|
38
39
|
import { sortObservations } from './observation.js';
|
|
@@ -145,7 +146,21 @@ async function productInSettings(configFile) {
|
|
|
145
146
|
const parsed = JSON.parse(await fsp.readFile(configFile, 'utf8'));
|
|
146
147
|
return typeof parsed?.product === 'string' && parsed.product ? parsed.product : null;
|
|
147
148
|
}
|
|
148
|
-
|
|
149
|
+
// `pathToFileURL`, rather than gluing `file://` to the front of a path.
|
|
150
|
+
//
|
|
151
|
+
// Measured on a real Windows 11 machine on 2026-08-31, and the answer was not the one
|
|
152
|
+
// expected: `file://C:\Users\me\staysfixed.config.mjs` DOES load, because the URL rules
|
|
153
|
+
// treat a drive letter sitting where the host should be as part of the path and turn the
|
|
154
|
+
// backslashes round. Even a folder with a space in it survives that. So this was not the
|
|
155
|
+
// Windows bug it looked like.
|
|
156
|
+
//
|
|
157
|
+
// It is still wrong, and it is changed for the one shape where the luck runs out: a
|
|
158
|
+
// project on a network share. `\\server\share\app` glued to `file://` becomes
|
|
159
|
+
// `file:////server/share/app`, which names no host and no file; `pathToFileURL` makes it
|
|
160
|
+
// `file://server/share/app`, which is the file. The catch below would have turned that
|
|
161
|
+
// into a silent "no product name in the settings", and the release would have been
|
|
162
|
+
// recorded under the wrong name rather than the one the person wrote down.
|
|
163
|
+
const module = await import(pathToFileURL(configFile).href);
|
|
149
164
|
const raw = module.default ?? module.config ?? module;
|
|
150
165
|
return typeof raw?.product === 'string' && raw.product ? raw.product : null;
|
|
151
166
|
} catch {
|