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.
@@ -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 nine tiny products, each as a real repository with a working
10
- * commit and an uncommitted change on top - which is exactly the shape of the
11
- * thing an agent points this tool at - runs the engine over each, and fails
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
- * Six of the nine are breaks that MUST be caught. Three are the other half of
15
- * the same promise, and they matter just as much: pairs that must produce NO
16
- * findings at all. A tool that cries wolf gets switched off, and a tool that is
17
- * switched off catches nothing, so a false alarm fails this run exactly the way
18
- * a miss does.
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
- against: working,
840
- paired: true,
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
- const module = await import(`file://${configFile}`);
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 {