staysfixed 0.9.1 → 0.10.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 +57 -0
- package/package.json +1 -1
- package/src/v2/check.js +207 -10
- package/src/v2/coverage.js +87 -2
- package/src/v2/doctor.js +86 -1
- package/src/v2/init.js +14 -0
- package/src/v2/normalise.js +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,63 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
Nothing yet.
|
|
10
10
|
|
|
11
|
+
## [0.10.0] — 2026-08-31
|
|
12
|
+
|
|
13
|
+
The night the four surfaces nobody had pointed it at were pointed at it, and the biggest
|
|
14
|
+
remaining lie in the coverage ledger was closed.
|
|
15
|
+
|
|
16
|
+
### Fixed — the ledger said things had been checked that had not
|
|
17
|
+
|
|
18
|
+
- **Knocking counted as walking.** A door counted as walked because a step said it knocked on
|
|
19
|
+
it, whatever came back. So a route the source declares and the running build answers 404 to
|
|
20
|
+
was counted as covered — and behind a sign-in wall, where every request is bounced to
|
|
21
|
+
/login, every door in the product counted as walked and the run came back clean. What
|
|
22
|
+
answered was on the record all along. **404, 405, 410 and 501** now leave the door shut and
|
|
23
|
+
say that the source and the build disagree about whether it exists. **A redirect** is walked
|
|
24
|
+
— the bounce is real behaviour — but named as a bounce, and when every door that answered
|
|
25
|
+
did it, the run says so: that is what a sign-in wall looks like from outside. **A 500 is
|
|
26
|
+
still walked**, deliberately: the route exists and it broke, which is what a check is for.
|
|
27
|
+
- **A product that never started was reported as twelve regressions.** A server that will not
|
|
28
|
+
boot produces a difference at every address it used to answer at, and not one of them said
|
|
29
|
+
the server had not started. The verdict leads with the cause now.
|
|
30
|
+
- **A build older than its source passed as "nothing that worked has changed".** This tool
|
|
31
|
+
runs your product and never builds it, so `node dist/server.js` runs yesterday's build
|
|
32
|
+
against today's code and compares it against a reference cut from the same stale output. It
|
|
33
|
+
says so now. Following `npm run start` down into package.json is what made it fire at all.
|
|
34
|
+
|
|
35
|
+
### Added — the headline feature actually runs
|
|
36
|
+
|
|
37
|
+
- **Guards run on `staysfixed check`**, and over MCP, whenever the settings give an address to
|
|
38
|
+
open. A guard that fails becomes a finding **sealed by name**, which no agent may wave
|
|
39
|
+
through. Where there is no address to open it still says so, and names the one line that
|
|
40
|
+
would change it.
|
|
41
|
+
|
|
42
|
+
### Fixed — it asked for what it had already been given
|
|
43
|
+
|
|
44
|
+
- **`doctor` asked every adapter with an EMPTY config** unless the settings were JSON — and
|
|
45
|
+
`init` writes JavaScript. So on almost every project it asked for the very thing the
|
|
46
|
+
settings already named, and one sentence managed to say an app was present and missing at
|
|
47
|
+
once. It reads the few values it needs out of the text now, scoped per block, without
|
|
48
|
+
running anybody's code.
|
|
49
|
+
- **`ios.app` was invisible**: the lookup only ever searched for `xcworkspace`, while this
|
|
50
|
+
tool's own advice says to name the built app under `ios.app`.
|
|
51
|
+
|
|
52
|
+
### Fixed — noise that buried the news
|
|
53
|
+
|
|
54
|
+
- **A bundler renames its output whenever the source changes**, so one edit produced four
|
|
55
|
+
extra findings, all of them chunk filenames, with the real change underneath. Normalised
|
|
56
|
+
inside a bundler's own asset paths only — a standalone content hash still changes and still
|
|
57
|
+
shows.
|
|
58
|
+
- **On a monorepo `init` announced five products and wrote settings for one.** What is
|
|
59
|
+
announced and what is written now have to agree.
|
|
60
|
+
|
|
61
|
+
### Still wrong, and written down rather than hidden
|
|
62
|
+
|
|
63
|
+
- `init` still names a container folder (`packages/`) as a product on a workspaces monorepo.
|
|
64
|
+
- An Electron check deadlocked once with no output and could not be reproduced.
|
|
65
|
+
- The Windows surface is reachable and signed in but its screen is locked, so nothing can be
|
|
66
|
+
driven there yet.
|
|
67
|
+
|
|
11
68
|
## [0.9.1] — 2026-08-30
|
|
12
69
|
|
|
13
70
|
Eleven more, from the same hunt that produced 0.9.0 — the findings it had already paid for.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staysfixed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers — as a CLI and as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/v2/check.js
CHANGED
|
@@ -326,16 +326,38 @@ export async function check(options = {}) {
|
|
|
326
326
|
// the word "guard" exactly zero times. A tool built to catch silent breakage must not do
|
|
327
327
|
// nothing silently.
|
|
328
328
|
const named = await guardNames(project.root);
|
|
329
|
-
if (named.length > 0
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
329
|
+
if (named.length > 0) {
|
|
330
|
+
const walked = await walkTheGuards(project.root, options);
|
|
331
|
+
if (walked.ran) {
|
|
332
|
+
// A guard that failed is a bug somebody already had, coming back. It is sealed by
|
|
333
|
+
// name — one of the five classes no agent may wave through, whatever the reason.
|
|
334
|
+
for (const bad of walked.failed) {
|
|
335
|
+
verdict.findings = [
|
|
336
|
+
...(verdict.findings ?? []),
|
|
337
|
+
/** @type {any} */ ({
|
|
338
|
+
id: `guard-${sha256(bad.name).slice(0, 6)}`,
|
|
339
|
+
title: `A bug that was already fixed is back: ${bad.name}`,
|
|
340
|
+
why: bad.message ?? 'The guard written for it does not hold any more.',
|
|
341
|
+
class: 'named guard',
|
|
342
|
+
guard: bad.name,
|
|
343
|
+
differences: [],
|
|
344
|
+
rank: 0,
|
|
345
|
+
count: 1,
|
|
346
|
+
}),
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
verdict.summary = `${walked.said} ${verdict.summary}`;
|
|
350
|
+
if (walked.failed.length > 0) verdict.ok = false;
|
|
351
|
+
} else if (verdict.coverage) {
|
|
352
|
+
verdict.coverage.gaps = [
|
|
353
|
+
...(verdict.coverage.gaps ?? []),
|
|
354
|
+
{
|
|
355
|
+
what: `${named.length} guard${named.length === 1 ? '' : 's'} written against bugs that already happened once`,
|
|
356
|
+
why: `They are sealed by name, so nothing touching one can be waved through quietly — but they were not RUN on this check: ${walked.why}`,
|
|
357
|
+
unlockedBy: 'Give the settings an address to open — `url` beside `start` in the `web` block, or `electron.binary` — and every check walks them from then on.',
|
|
358
|
+
},
|
|
359
|
+
];
|
|
360
|
+
}
|
|
339
361
|
}
|
|
340
362
|
|
|
341
363
|
/** @type {CheckOutcome} */
|
|
@@ -471,6 +493,21 @@ async function settle(verdict, store, product, guards) {
|
|
|
471
493
|
: `NOTHING WAS ACTUALLY COMPARED. Every journey was walked on the build you have, and not one of them had anything on record from the build you were happy with, so there was nothing to hold them against. This is not a pass and not a failure — it is no answer. ${verdict.summary}`;
|
|
472
494
|
}
|
|
473
495
|
|
|
496
|
+
// THE CAUSE FIRST, when the cause is that the product never answered.
|
|
497
|
+
//
|
|
498
|
+
// A server that will not start produces a difference at every address it used to answer
|
|
499
|
+
// at — the content type gone, the body gone, "answered at all" arriving. Measured
|
|
500
|
+
// 2026-08-31 on a product whose start command throws: twelve findings, not one of them
|
|
501
|
+
// saying the server had not started, and a person reads "12 things behave differently"
|
|
502
|
+
// and goes looking for a regression in code that is fine. The symptoms are real and they
|
|
503
|
+
// belong in the list; they are just not the news.
|
|
504
|
+
const silent = didNotAnswer(verdict);
|
|
505
|
+
if (silent.length > 0) {
|
|
506
|
+
verdict.summary =
|
|
507
|
+
`THE PRODUCT DID NOT ANSWER. ${silent.length} ${silent.length === 1 ? 'way in was' : 'ways in were'} not tried at all — ${silent.slice(0, 3).join('; ')}${silent.length > 3 ? '; and more' : ''}. Most of what follows is that one fact wearing different clothes, not ${silent.length === 1 ? 'a separate change' : 'separate changes'}: fix the start and check again before reading any of it as a regression. ` +
|
|
508
|
+
verdict.summary;
|
|
509
|
+
}
|
|
510
|
+
|
|
474
511
|
// And what was NOT looked at, in the same breath as the good news, on every run
|
|
475
512
|
// including the clean ones. A green verdict on a product with three hundred doors
|
|
476
513
|
// nobody has ever opened is true and it is not what it looks like, and the only place
|
|
@@ -659,6 +696,45 @@ function comparedNothing(verdict) {
|
|
|
659
696
|
return nothingToCompare >= walked ? 'no stored record' : null;
|
|
660
697
|
}
|
|
661
698
|
|
|
699
|
+
/**
|
|
700
|
+
* Walk the guards, if this project can be opened.
|
|
701
|
+
*
|
|
702
|
+
* Guards are the headline of this whole product — one plain-English rule per bug somebody
|
|
703
|
+
* already had — and the default command never ran them. It said so, which was better than
|
|
704
|
+
* pretending, but saying it is not doing it. Version 1 knows how to drive an app and run
|
|
705
|
+
* them; what was missing was anybody calling it from here.
|
|
706
|
+
*
|
|
707
|
+
* It needs an address it can open. Where the settings only say how to START the product,
|
|
708
|
+
* that is version 2's job and this path cannot do it — so it says so instead, and names the
|
|
709
|
+
* one line that would change it.
|
|
710
|
+
*
|
|
711
|
+
* @param {string} root
|
|
712
|
+
* @param {any} options
|
|
713
|
+
* @returns {Promise<{ran: boolean, why: string, said: string, failed: {name: string, message?: string}[]}>}
|
|
714
|
+
*/
|
|
715
|
+
async function walkTheGuards(root, options) {
|
|
716
|
+
try {
|
|
717
|
+
const { loadProject } = await import('../core/config.js');
|
|
718
|
+
const { runCheck } = await import('../run.js');
|
|
719
|
+
const project = await loadProject({ cwd: root, configFile: options?.configFile });
|
|
720
|
+
const run = await runCheck(project, { guardsOnly: true, writeReport: false, quiet: true, signal: options?.signal });
|
|
721
|
+
const guards = run.guards ?? [];
|
|
722
|
+
const failed = guards.filter((g) => g.status === 'failed').map((g) => ({ name: g.name, message: g.message }));
|
|
723
|
+
const held = guards.length - failed.length;
|
|
724
|
+
return {
|
|
725
|
+
ran: true,
|
|
726
|
+
why: '',
|
|
727
|
+
said:
|
|
728
|
+
failed.length === 0
|
|
729
|
+
? `All ${guards.length} guard${guards.length === 1 ? '' : 's'} still hold.`
|
|
730
|
+
: `${failed.length} of ${guards.length} guards failed — ${failed.length === 1 ? 'a bug' : 'bugs'} that ${failed.length === 1 ? 'was' : 'were'} already fixed ${failed.length === 1 ? 'is' : 'are'} back, and no agent may wave ${failed.length === 1 ? 'it' : 'them'} through. ${held} still hold.`,
|
|
731
|
+
failed,
|
|
732
|
+
};
|
|
733
|
+
} catch (e) {
|
|
734
|
+
return { ran: false, why: messageOf(e), said: '', failed: [] };
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
662
738
|
/**
|
|
663
739
|
* The guards this project has, by name.
|
|
664
740
|
*
|
|
@@ -681,6 +757,125 @@ export async function guardNames(root) {
|
|
|
681
757
|
}
|
|
682
758
|
}
|
|
683
759
|
|
|
760
|
+
/**
|
|
761
|
+
* Is the thing that ran older than the code it was built from?
|
|
762
|
+
*
|
|
763
|
+
* This tool never builds anything — deliberately, because building somebody's project is not
|
|
764
|
+
* its business. But a project whose start command runs `dist/server.js` and whose source
|
|
765
|
+
* lives in `src/` will happily run YESTERDAY's build against today's source, compare it
|
|
766
|
+
* against a reference cut from the same stale output, and answer "Nothing that worked has
|
|
767
|
+
* changed" — about code it has never once executed. Measured 2026-08-31.
|
|
768
|
+
*
|
|
769
|
+
* It cannot be fixed by building; it can be SAID, which is all a coverage gap has to do.
|
|
770
|
+
*
|
|
771
|
+
* @param {string} root
|
|
772
|
+
* @param {any} config
|
|
773
|
+
* @returns {Promise<CoverageGap|null>}
|
|
774
|
+
*/
|
|
775
|
+
async function builtBeforeItsSource(root, config) {
|
|
776
|
+
try {
|
|
777
|
+
let pkg = {};
|
|
778
|
+
try {
|
|
779
|
+
pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
|
|
780
|
+
} catch {
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
const scripts = /** @type {any} */ (pkg).scripts ?? {};
|
|
784
|
+
// `npm run start` says nothing about where the product lives; the answer is one level
|
|
785
|
+
// down, in the script it runs. Following that indirection is the difference between this
|
|
786
|
+
// check firing and never firing, because `init` writes exactly `npm run start`.
|
|
787
|
+
const through = (/** @type {string} */ line) => {
|
|
788
|
+
const run = /(?:npm run|yarn|pnpm run|pnpm)\s+([\w:-]+)/.exec(line);
|
|
789
|
+
const named = run ? scripts[run[1]] : line.includes('npm start') ? scripts.start : null;
|
|
790
|
+
return `${line} ${typeof named === 'string' ? named : ''}`;
|
|
791
|
+
};
|
|
792
|
+
const starts = [
|
|
793
|
+
...(config?.process?.commands ?? []).map((/** @type {any} */ c) => String(c?.run ?? '')),
|
|
794
|
+
String(config?.http?.start ?? ''),
|
|
795
|
+
String(config?.web?.start ?? ''),
|
|
796
|
+
].map(through).join(' ');
|
|
797
|
+
const named = /\b(dist|build|out|lib)\b/.exec(starts);
|
|
798
|
+
const builds = typeof scripts.build === 'string';
|
|
799
|
+
if (!named || !builds) return null;
|
|
800
|
+
|
|
801
|
+
const outDir = path.join(root, named[1]);
|
|
802
|
+
const srcDir = path.join(root, 'src');
|
|
803
|
+
const [built, source] = await Promise.all([newestUnder(outDir), newestUnder(srcDir)]);
|
|
804
|
+
if (built === 0 || source === 0 || source <= built) return null;
|
|
805
|
+
|
|
806
|
+
const behind = Math.round((source - built) / 1000);
|
|
807
|
+
/** @param {number} n @param {string} unit */
|
|
808
|
+
const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'}`;
|
|
809
|
+
const howLong =
|
|
810
|
+
behind > 86400 ? plural(Math.round(behind / 86400), 'day') : behind > 3600 ? plural(Math.round(behind / 3600), 'hour') : plural(Math.max(1, Math.round(behind / 60)), 'minute');
|
|
811
|
+
return {
|
|
812
|
+
what: `What ran is older than the code it was built from — \`${named[1]}/\` is ${howLong} behind \`src/\`.`,
|
|
813
|
+
why: `This tool runs your product, it never builds it. So the build in \`${named[1]}/\` is what was walked, and your newer source was not executed at all. A clean result here says nothing whatever about the code you have just written — and the reference it was compared against was cut from the same stale output.`,
|
|
814
|
+
unlockedBy: 'Run your build before the check — `npm run build && npx staysfixed check` — or put the build into the start command in your settings.',
|
|
815
|
+
};
|
|
816
|
+
} catch {
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* The newest modification time anywhere under a folder, or 0 if there is nothing there.
|
|
823
|
+
* @param {string} dir
|
|
824
|
+
* @returns {Promise<number>}
|
|
825
|
+
*/
|
|
826
|
+
async function newestUnder(dir) {
|
|
827
|
+
let newest = 0;
|
|
828
|
+
/** @param {string} at @param {number} depth */
|
|
829
|
+
const walk = async (at, depth) => {
|
|
830
|
+
if (depth > 6) return;
|
|
831
|
+
let entries = [];
|
|
832
|
+
try {
|
|
833
|
+
entries = await fsp.readdir(at, { withFileTypes: true });
|
|
834
|
+
} catch {
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
for (const entry of entries) {
|
|
838
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
839
|
+
const full = path.join(at, entry.name);
|
|
840
|
+
if (entry.isDirectory()) await walk(full, depth + 1);
|
|
841
|
+
else {
|
|
842
|
+
try {
|
|
843
|
+
const at2 = (await fsp.stat(full)).mtimeMs;
|
|
844
|
+
if (at2 > newest) newest = at2;
|
|
845
|
+
} catch {
|
|
846
|
+
// gone between the listing and the question
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
await walk(dir, 0);
|
|
852
|
+
return newest;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* The ways into the product that were never tried, and why.
|
|
857
|
+
*
|
|
858
|
+
* A build that would not start does not produce one finding — it produces one at every
|
|
859
|
+
* address it used to answer at, and the real news is nowhere in the list.
|
|
860
|
+
*
|
|
861
|
+
* @param {CheckOutcome} verdict
|
|
862
|
+
* @returns {string[]}
|
|
863
|
+
*/
|
|
864
|
+
function didNotAnswer(verdict) {
|
|
865
|
+
/** @type {Set<string>} */
|
|
866
|
+
const said = new Set();
|
|
867
|
+
for (const finding of verdict.findings ?? []) {
|
|
868
|
+
for (const d of finding.differences ?? []) {
|
|
869
|
+
const path = String(d.path ?? '');
|
|
870
|
+
if (!path.endsWith('answered at all')) continue;
|
|
871
|
+
const name = path.split('.').slice(1, -1).join('.') || path;
|
|
872
|
+
const why = typeof d.candidate === 'string' ? d.candidate : typeof d.describe === 'string' ? d.describe : '';
|
|
873
|
+
said.add(why ? `${name} (${String(why).slice(0, 90)})` : name);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return [...said];
|
|
877
|
+
}
|
|
878
|
+
|
|
684
879
|
/**
|
|
685
880
|
* Copies left behind by runs that never finished.
|
|
686
881
|
*
|
|
@@ -1591,6 +1786,8 @@ async function openProject(options) {
|
|
|
1591
1786
|
unlockedBy: `Fix package.json, or put the name you want in your settings file as product: '<name>'. Until then every comparison starts from nothing.`,
|
|
1592
1787
|
});
|
|
1593
1788
|
}
|
|
1789
|
+
const stale = await builtBeforeItsSource(root, config);
|
|
1790
|
+
if (stale) gaps.push(stale);
|
|
1594
1791
|
if (storeTrouble.length > 0) {
|
|
1595
1792
|
gaps.push({
|
|
1596
1793
|
what: 'This run was NOT written down, so the next check has nothing from today to compare against.',
|
package/src/v2/coverage.js
CHANGED
|
@@ -101,6 +101,14 @@ import { listBuilds, listCaptures, loadCapture, referencePointer } from './store
|
|
|
101
101
|
* @property {string[]} [doors] Door keys the journey's steps name, for steps that
|
|
102
102
|
* named a door with nothing to tell apart from
|
|
103
103
|
* another of the same name. See doorKey.
|
|
104
|
+
* @property {{door: string, status: number}[]} [onlyRedirected]
|
|
105
|
+
* Doors that answered with a redirect. Walked — the
|
|
106
|
+
* bounce is real behaviour — but what is behind them
|
|
107
|
+
* was never seen.
|
|
108
|
+
* @property {{door: string, status: number}[]} [knockedShut]
|
|
109
|
+
* Doors a step knocked on where the running build
|
|
110
|
+
* answered that they are not there. Knocking is not
|
|
111
|
+
* walking, and these have proved nothing.
|
|
104
112
|
* @property {string[]} [doorAddresses] Full door addresses, for steps that were specific
|
|
105
113
|
* enough to build one. A route step knows its verb,
|
|
106
114
|
* and GET /x and POST /x are two doors that share a
|
|
@@ -401,6 +409,15 @@ export function addressesTouched(observations) {
|
|
|
401
409
|
return { paths: [...paths], byChannel };
|
|
402
410
|
}
|
|
403
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Answers that mean the door is not there in the build that ran.
|
|
414
|
+
*
|
|
415
|
+
* A 500 is deliberately NOT here: the route exists and it broke, which is a real difference
|
|
416
|
+
* and exactly what a check is for. These four are the codes that say the thing the source
|
|
417
|
+
* declares was never reachable, so nothing has been proved about it either way.
|
|
418
|
+
*/
|
|
419
|
+
const NOTHING_THERE = new Set([404, 405, 410, 501]);
|
|
420
|
+
|
|
404
421
|
/**
|
|
405
422
|
* A walk, built from one stored capture and, when it is to hand, the journey behind it.
|
|
406
423
|
*
|
|
@@ -426,8 +443,55 @@ export function walkFromCapture(capture, journey) {
|
|
|
426
443
|
// would report POST as walked too, which is the coverage ledger lying in the one direction
|
|
427
444
|
// it must never lie in.
|
|
428
445
|
const named = journey.steps.filter((s) => typeof s.door === 'string' && typeof s.kind === 'string');
|
|
429
|
-
|
|
430
|
-
|
|
446
|
+
|
|
447
|
+
// KNOCKING IS NOT WALKING.
|
|
448
|
+
//
|
|
449
|
+
// A door was counted as walked because a STEP said it knocked on it — whatever came back.
|
|
450
|
+
// So a route the source declares and the running build answers 404 counted as covered; and
|
|
451
|
+
// behind a login wall, where every request is bounced to /login, every door in the product
|
|
452
|
+
// counted as walked and the run came back clean. That is the coverage ledger lying in the
|
|
453
|
+
// one direction this file says it must never lie in.
|
|
454
|
+
//
|
|
455
|
+
// What answered is on the record: the http adapter writes `api.<door>.status`. A 404, 405,
|
|
456
|
+
// 410 or 501 means the thing the code declares is not there in the build that ran, so
|
|
457
|
+
// nothing was proved about it and it stays shut.
|
|
458
|
+
const answered = new Map();
|
|
459
|
+
for (const ob of capture.observations ?? []) {
|
|
460
|
+
const found = /^api\.(.+)\.status$/.exec(String(ob?.path ?? ''));
|
|
461
|
+
if (found) answered.set(found[1], Number(ob.value));
|
|
462
|
+
}
|
|
463
|
+
/** @type {{door: string, status: number}[]} */
|
|
464
|
+
const shut = [];
|
|
465
|
+
/** @type {{door: string, status: number}[]} */
|
|
466
|
+
const bounced = [];
|
|
467
|
+
/** @param {any} s @returns {boolean} */
|
|
468
|
+
const reallyWalked = (s) => {
|
|
469
|
+
// The door is the ROUTE (`/reports`); the observation is addressed by method and route
|
|
470
|
+
// together (`api.GET /reports.status`), because GET and POST on one path are two doors.
|
|
471
|
+
// Try the composite first and the bare name after, so both shapes of step are covered.
|
|
472
|
+
const keys = [
|
|
473
|
+
typeof s.doorDetail === 'string' && s.doorDetail ? `${s.doorDetail} ${s.door}` : null,
|
|
474
|
+
typeof s.method === 'string' && s.method ? `${s.method} ${s.door}` : null,
|
|
475
|
+
String(s.door),
|
|
476
|
+
].filter(Boolean);
|
|
477
|
+
const key = keys.find((k) => answered.has(/** @type {string} */ (k)));
|
|
478
|
+
const code = key === undefined ? undefined : answered.get(/** @type {string} */ (key));
|
|
479
|
+
// A redirect is real behaviour and it IS walked — but what you saw is the bounce, not the
|
|
480
|
+
// thing behind it. Behind a login wall every door answers 302 to /login, and the run
|
|
481
|
+
// then reports full coverage of a product it never got into.
|
|
482
|
+
if (typeof code === 'number' && code >= 300 && code < 400) {
|
|
483
|
+
bounced.push({ door: String(key), status: code });
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
if (typeof code !== 'number' || !NOTHING_THERE.has(code)) return true;
|
|
487
|
+
shut.push({ door: String(key), status: code });
|
|
488
|
+
return false;
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const exact = named.filter((s) => typeof s.doorDetail === 'string' && s.doorDetail !== '').filter(reallyWalked);
|
|
492
|
+
const byName = named.filter((s) => typeof s.doorDetail !== 'string' || s.doorDetail === '').filter(reallyWalked);
|
|
493
|
+
if (shut.length > 0) walk.knockedShut = shut;
|
|
494
|
+
if (bounced.length > 0) walk.onlyRedirected = bounced;
|
|
431
495
|
if (byName.length > 0) walk.doors = byName.map((s) => doorKey({ kind: String(s.kind), name: String(s.door) }));
|
|
432
496
|
if (exact.length > 0) {
|
|
433
497
|
walk.doorAddresses = exact.map((s) =>
|
|
@@ -613,6 +677,27 @@ export function buildLedger(input) {
|
|
|
613
677
|
`${cutFunctions} functions that really did run were cut from the coverage lists to keep them readable, so up to that many of the doors counted as never opened were in fact opened. This ledger undercounts, and it undercounts by no more than ${cutFunctions}.`,
|
|
614
678
|
);
|
|
615
679
|
}
|
|
680
|
+
// Named, never silently dropped. A route the code declares and the build answers 404 to is
|
|
681
|
+
// not a covered route and it is not an absent one either — it is a disagreement between the
|
|
682
|
+
// source and the thing that ran, and that is worth more than most differences.
|
|
683
|
+
/** @type {Map<string, number>} */
|
|
684
|
+
const shutDoors = new Map();
|
|
685
|
+
for (const { walk } of walks) for (const d of walk.knockedShut ?? []) shutDoors.set(d.door, d.status);
|
|
686
|
+
if (shutDoors.size > 0) {
|
|
687
|
+
const listed = [...shutDoors.entries()].slice(0, 6).map(([door, code]) => `${door} answered ${code}`).join(', ');
|
|
688
|
+
caveats.push(
|
|
689
|
+
`${shutDoors.size} ${shutDoors.size === 1 ? 'door the code declares was' : 'doors the code declares were'} knocked on and answered as not being there (${listed}${shutDoors.size > 6 ? ', and more' : ''}). Knocking is not walking: nothing has been proved about ${shutDoors.size === 1 ? 'it' : 'them'}, and the source and the build that ran disagree about whether ${shutDoors.size === 1 ? 'it exists' : 'they exist'}.`,
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
/** @type {Map<string, number>} */
|
|
693
|
+
const bouncedDoors = new Map();
|
|
694
|
+
for (const { walk } of walks) for (const d of walk.onlyRedirected ?? []) bouncedDoors.set(d.door, d.status);
|
|
695
|
+
if (bouncedDoors.size > 0) {
|
|
696
|
+
const all = bouncedDoors.size >= Math.max(1, opened);
|
|
697
|
+
caveats.push(
|
|
698
|
+
`${bouncedDoors.size} ${bouncedDoors.size === 1 ? 'door' : 'doors'} answered with a redirect rather than with ${bouncedDoors.size === 1 ? 'a page' : 'pages'} — ${[...bouncedDoors.entries()].slice(0, 5).map(([door, code]) => `${door} answered ${code}`).join(', ')}${bouncedDoors.size > 5 ? ', and more' : ''}. What was seen is the bounce, not what is behind it.${all ? ' EVERY door that answered did this, which is what a sign-in wall looks like from out here: this run has not been inside the product at all.' : ''}`,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
616
701
|
if (input.doors.length === 0) {
|
|
617
702
|
caveats.push('No doors are known at all, so this ledger cannot say what is uncovered — which is not the same as there being nothing uncovered.');
|
|
618
703
|
}
|
package/src/v2/doctor.js
CHANGED
|
@@ -900,6 +900,18 @@ async function phoneApps(root, configFile) {
|
|
|
900
900
|
return found ? { where: found[1], how: `your settings name it under ${key}` } : null;
|
|
901
901
|
};
|
|
902
902
|
|
|
903
|
+
/**
|
|
904
|
+
* A named key whose value has to look right, so one settings word cannot be mistaken for
|
|
905
|
+
* another block's.
|
|
906
|
+
* @param {string} key
|
|
907
|
+
* @param {(value: string) => boolean} looksRight
|
|
908
|
+
* @returns {FoundApp|null}
|
|
909
|
+
*/
|
|
910
|
+
const namedPath = (key, looksRight) => {
|
|
911
|
+
const found = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(settings);
|
|
912
|
+
return found && looksRight(found[1]) ? { where: found[1], how: `your settings name it under ${key}` } : null;
|
|
913
|
+
};
|
|
914
|
+
|
|
903
915
|
/**
|
|
904
916
|
* @param {string[]} folders
|
|
905
917
|
* @param {(name: string) => boolean} wanted
|
|
@@ -933,6 +945,14 @@ async function phoneApps(root, configFile) {
|
|
|
933
945
|
: null);
|
|
934
946
|
|
|
935
947
|
const ios =
|
|
948
|
+
// `ios.app` FIRST, because that is the key the adapter reads and the key this very file
|
|
949
|
+
// tells people to write: "name the built .app in your settings under ios.app". It then
|
|
950
|
+
// looked only for `xcworkspace`, so a settings file naming a real built app was invisible
|
|
951
|
+
// and doctor answered "no iPhone app was found in this project, and the settings do not
|
|
952
|
+
// name one" about a project whose settings named one. Measured 2026-08-31 against a real
|
|
953
|
+
// TerminalDeck.app. The value has to end in `.app` so a bare `app:` belonging to some
|
|
954
|
+
// other block can never be mistaken for this one.
|
|
955
|
+
namedPath('app', (v) => v.endsWith('.app')) ??
|
|
936
956
|
named('xcworkspace') ??
|
|
937
957
|
built(['dist', 'out', 'build', 'release'], (name) => name.endsWith('.app')) ??
|
|
938
958
|
(there(path.join('ios', 'Podfile')) || readdirSafe(path.join(root, 'ios')).some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace'))
|
|
@@ -1065,6 +1085,16 @@ async function askTheAdapters(root) {
|
|
|
1065
1085
|
// Read as text and parsed only when it is JSON. Doctor never runs a person's code to
|
|
1066
1086
|
// answer a question about their machine, and a settings file may be JavaScript.
|
|
1067
1087
|
if (file && file.endsWith('.json')) config = JSON.parse(readFileSync(file, 'utf8'));
|
|
1088
|
+
// But "not JSON" was being treated as "says nothing", and `init` writes JavaScript — so
|
|
1089
|
+
// for almost every project every adapter was asked what it needs while being handed an
|
|
1090
|
+
// EMPTY config. It then asked for the very thing the settings already named: a project
|
|
1091
|
+
// whose settings pointed at a real built TerminalDeck.app was told, in one sentence,
|
|
1092
|
+
// "the app is here. What is missing is a built iPhone app to check". Measured 2026-08-31.
|
|
1093
|
+
//
|
|
1094
|
+
// The few values the adapters need to answer honestly are read out of the TEXT instead,
|
|
1095
|
+
// scoped to their own block so one block's `app` can never be read as another's. Still no
|
|
1096
|
+
// code is run, which was the whole point of the rule.
|
|
1097
|
+
else if (file) config = { ...config, ...settingsFromText(readFileSync(file, 'utf8')) };
|
|
1068
1098
|
} catch {
|
|
1069
1099
|
config = {};
|
|
1070
1100
|
}
|
|
@@ -1384,6 +1414,54 @@ export function readHostProbe(name, alive, look) {
|
|
|
1384
1414
|
return report;
|
|
1385
1415
|
}
|
|
1386
1416
|
|
|
1417
|
+
/**
|
|
1418
|
+
* The handful of settings an adapter needs to say what it is missing, read out of a
|
|
1419
|
+
* JavaScript settings file WITHOUT running it.
|
|
1420
|
+
*
|
|
1421
|
+
* Scoped per block on purpose: `app` means one thing under `ios` and nothing under `web`, and
|
|
1422
|
+
* a flat search would hand the wrong path to the wrong adapter.
|
|
1423
|
+
*
|
|
1424
|
+
* @param {string} text
|
|
1425
|
+
* @returns {Record<string, any>}
|
|
1426
|
+
*/
|
|
1427
|
+
function settingsFromText(text) {
|
|
1428
|
+
const clean = withoutComments(text);
|
|
1429
|
+
/** @type {Record<string, any>} */
|
|
1430
|
+
const out = {};
|
|
1431
|
+
/** @type {Record<string, string[]>} */
|
|
1432
|
+
const wanted = {
|
|
1433
|
+
ios: ['app'],
|
|
1434
|
+
android: ['apk', 'package'],
|
|
1435
|
+
electron: ['binary'],
|
|
1436
|
+
web: ['url', 'start'],
|
|
1437
|
+
http: ['start', 'url'],
|
|
1438
|
+
};
|
|
1439
|
+
for (const [block, keys] of Object.entries(wanted)) {
|
|
1440
|
+
const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(clean);
|
|
1441
|
+
if (!at) continue;
|
|
1442
|
+
// The block's own text: from its brace to the matching one, counted rather than guessed,
|
|
1443
|
+
// so a nested object cannot end the block early.
|
|
1444
|
+
let depth = 0;
|
|
1445
|
+
let end = at.index + at[0].length;
|
|
1446
|
+
for (; end < clean.length; end += 1) {
|
|
1447
|
+
if (clean[end] === '{') depth += 1;
|
|
1448
|
+
else if (clean[end] === '}') {
|
|
1449
|
+
if (depth === 0) break;
|
|
1450
|
+
depth -= 1;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
const inside = clean.slice(at.index + at[0].length, end);
|
|
1454
|
+
/** @type {Record<string, string>} */
|
|
1455
|
+
const found = {};
|
|
1456
|
+
for (const key of keys) {
|
|
1457
|
+
const hit = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(inside);
|
|
1458
|
+
if (hit) found[key] = hit[1];
|
|
1459
|
+
}
|
|
1460
|
+
if (Object.keys(found).length > 0) out[block] = found;
|
|
1461
|
+
}
|
|
1462
|
+
return out;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1387
1465
|
/**
|
|
1388
1466
|
* Host names out of ~/.ssh/config. Patterns are skipped: `Host *` is a rule, not
|
|
1389
1467
|
* a machine, and dialling it would be meaningless.
|
|
@@ -1704,7 +1782,14 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1704
1782
|
? 'Cannot run here: no usable iOS runtime was found, so there is no simulator to boot the app on.'
|
|
1705
1783
|
: iosReady
|
|
1706
1784
|
? `Covered on the simulator, against the stored record. It boots ${phones.ios.where} on a simulator of its own and reads what each control on the screen is and does. A real iPhone in your hand cannot be compared side by side and never will be — two builds cannot exist on it at once.`
|
|
1707
|
-
:
|
|
1785
|
+
: // "and the app is here" was said because a PATH was found, while the adapter was
|
|
1786
|
+
// still asking for a built app — so one sentence claimed the app was present and
|
|
1787
|
+
// missing at once. Measured 2026-08-31 on a settings file pointing at a
|
|
1788
|
+
// TerminalDeck.app that turned out to be an empty folder left by an old build.
|
|
1789
|
+
// A path is not a bundle, and only the adapter can tell the difference.
|
|
1790
|
+
iosWants.some((n) => /built iPhone app/i.test(String(n.what)))
|
|
1791
|
+
? `The simulator is here and the settings name an app, but there is no built app bundle at that path — an empty folder from an old build looks exactly like this. What is missing is ${plainList(iosWants.map((n) => n.what))}.`
|
|
1792
|
+
: `The simulator is here and the app is here. What is missing is ${plainList(iosWants.map((n) => n.what))}, so a clean result would cover less than it looks like.`,
|
|
1708
1793
|
canCheck: iosReady || iosPartly ? [...withoutADriver, 'meaning', 'pixels'] : [],
|
|
1709
1794
|
cannotCheck: iosReady || iosPartly ? [] : CHANNELS.map((c) => c.id),
|
|
1710
1795
|
needs:
|
package/src/v2/init.js
CHANGED
|
@@ -1807,7 +1807,21 @@ export async function run(ctx) {
|
|
|
1807
1807
|
}
|
|
1808
1808
|
|
|
1809
1809
|
blank();
|
|
1810
|
+
// What was ANNOUNCED and what was WRITTEN have to agree. On a workspaces monorepo this
|
|
1811
|
+
// said "can be checked here now" about four things — including a folder that is only a
|
|
1812
|
+
// container and holds no product at all — and then wrote one settings file describing one
|
|
1813
|
+
// product. Measured 2026-08-31. Everything above is what was FOUND; this says what is
|
|
1814
|
+
// actually covered, and how to cover the rest.
|
|
1815
|
+
const others = (result.plan?.readiness ?? [])
|
|
1816
|
+
.map((/** @type {any} */ r) => String(r.product ?? ''))
|
|
1817
|
+
.filter((/** @type {string} */ n, /** @type {number} */ i, /** @type {string[]} */ all) => n !== '' && all.indexOf(n) === i);
|
|
1810
1818
|
if (result.written.length > 0) ok(`Written: ${result.written.map((f) => shortPath(f)).join(', ')}`);
|
|
1819
|
+
if (others.length > 1) {
|
|
1820
|
+
warn(
|
|
1821
|
+
`Those settings describe ONE product. ${others.length} were found here (${others.join(', ')}), and the others are not covered by this file. ` +
|
|
1822
|
+
'Run `staysfixed init` from inside each of the others to give it its own settings, and check it from there.',
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1811
1825
|
if (result.kept.length > 0) warn(`Left exactly as it was: ${result.kept.map((f) => shortPath(f)).join(', ')}`);
|
|
1812
1826
|
for (const problem of result.problems) fail(problem);
|
|
1813
1827
|
|
package/src/v2/normalise.js
CHANGED
|
@@ -114,6 +114,17 @@ export const DEFAULT_RULES = [
|
|
|
114
114
|
pattern: '\\b[0-9a-fA-F]{16,31}\\b',
|
|
115
115
|
with: '<hex>',
|
|
116
116
|
},
|
|
117
|
+
{
|
|
118
|
+
id: 'asset.bundled',
|
|
119
|
+
kind: 'replace',
|
|
120
|
+
what: 'The hash in a bundler\'s own asset filename — /_next/static/chunks/main-9f2c1a.js, /assets/index-4b8e21.css, app.7d3f9a1c.js.',
|
|
121
|
+
why:
|
|
122
|
+
'A bundler renames its output whenever the source changes, so editing one line renames several files. Measured on a Next.js app: one source edit produced four extra findings, all of them chunk filenames, and the change a person actually made was underneath them. The rename is not news — the code change is, and that is reported on its own.',
|
|
123
|
+
wouldHide:
|
|
124
|
+
'A deliberate change to an asset filename, which nobody makes by hand. Deliberately narrow: the hash is only taken inside a path that a bundler owns, so a content hash anywhere else still changes and still shows — see id.hex, which leaves 32-and-longer hex alone for exactly that reason.',
|
|
125
|
+
pattern: '(/_next/static/[^"\'\\s]*?|/assets/[^"\'\\s]*?|\\b[\\w.-]+)[.-][0-9a-fA-F]{6,}(\\.(?:js|mjs|css|map))',
|
|
126
|
+
with: '$1.<asset>$2',
|
|
127
|
+
},
|
|
117
128
|
{
|
|
118
129
|
id: 'id.pid',
|
|
119
130
|
kind: 'replace',
|