staysfixed 0.12.0 → 0.14.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 +85 -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 +372 -35
- 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 +91 -12
- package/src/v2/init.js +577 -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/watch/dialogs.js +410 -0
- package/src/v2/watch/panel.js +27 -1
package/src/v2/coverage.js
CHANGED
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
* watched at the call and refused at the effect, permanently and on purpose.
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
|
+
import fsp from 'node:fs/promises';
|
|
49
|
+
import path from 'node:path';
|
|
50
|
+
|
|
51
|
+
import { findConfigFile, rootForConfig } from '../core/paths.js';
|
|
48
52
|
import { asAddress } from './adapters/electron.js';
|
|
49
53
|
import { readContract, readFileRoutes, readPackageCommands } from './adapters/source.js';
|
|
50
54
|
import { familyOf, irreversibility, isRunnable } from './journeys/from-routes.js';
|
|
@@ -118,6 +122,13 @@ import { listBuilds, listCaptures, loadCapture, referencePointer } from './store
|
|
|
118
122
|
* and GET /x and POST /x are two doors that share a
|
|
119
123
|
* doorKey — so a step that knows which one it knocked
|
|
120
124
|
* on lands here instead, and the other stays shut.
|
|
125
|
+
* @property {{door: string, at: string}[]} [sampledAt]
|
|
126
|
+
* Doors whose address has a changing part in it,
|
|
127
|
+
* and the ONE address the walk actually asked for
|
|
128
|
+
* after a sample value was put in the gap. Opening
|
|
129
|
+
* `/blog/[slug]` at `/blog/hello-world` is one page
|
|
130
|
+
* opened, never the family covered, and this is the
|
|
131
|
+
* only record of which of the two happened.
|
|
121
132
|
* @property {string[]} [touchedFiles]
|
|
122
133
|
* @property {string[]} [touchedFunctions] 'file:name', from the suite's own coverage.
|
|
123
134
|
* @property {number} [functionsNotListed] Functions that ran and were cut from the list to
|
|
@@ -138,6 +149,10 @@ import { listBuilds, listCaptures, loadCapture, referencePointer } from './store
|
|
|
138
149
|
* @property {string[]} journeys Journeys that opened it.
|
|
139
150
|
* @property {string|null} lastWalkedAt ISO, or null.
|
|
140
151
|
* @property {boolean} walkable False when nothing here could ever open it.
|
|
152
|
+
* @property {boolean} [sampled] Opened at ONE address of a family, because its own
|
|
153
|
+
* address has a changing part and one sample value
|
|
154
|
+
* was supplied. One page opened, not a family covered.
|
|
155
|
+
* @property {string} [openedAt] That one address, when `sampled` is true.
|
|
141
156
|
* @property {string} [whyNot] Why not, in plain English. Set when walkable is false.
|
|
142
157
|
* @property {boolean} [irreversible] Opening it for real cannot be undone. Watched at
|
|
143
158
|
* the call, refused at the effect, forever.
|
|
@@ -157,6 +172,9 @@ import { listBuilds, listCaptures, loadCapture, referencePointer } from './store
|
|
|
157
172
|
* the ledger only had totals to work from.
|
|
158
173
|
* @property {number} doors Doors the code reader knows about.
|
|
159
174
|
* @property {number} opened
|
|
175
|
+
* @property {number} sampled Of `opened`, the ones opened at a single address of
|
|
176
|
+
* a family. Counted apart because one value is not
|
|
177
|
+
* coverage of everything behind a changing address.
|
|
160
178
|
* @property {number} reached Code ran; the door itself was never addressed.
|
|
161
179
|
* @property {number} never
|
|
162
180
|
* @property {number} unwalkable Of `never`, the ones nothing here could ever open.
|
|
@@ -326,6 +344,128 @@ export function doorsFromObservations(observations) {
|
|
|
326
344
|
return [...found.values()];
|
|
327
345
|
}
|
|
328
346
|
|
|
347
|
+
/**
|
|
348
|
+
* The parts of a door's name that are a placeholder rather than a real address.
|
|
349
|
+
*
|
|
350
|
+
* Every page framework spells this differently and they all mean the same thing: this
|
|
351
|
+
* address cannot be asked for until somebody says what goes in the gap. Next.js and
|
|
352
|
+
* SvelteKit and Astro write `[slug]`, Next also writes `[...slug]` and `[[...slug]]`, Nuxt
|
|
353
|
+
* and Express write `:slug`, Remix writes `$slug`, and a few write `{slug}`.
|
|
354
|
+
*/
|
|
355
|
+
const A_PLACEHOLDER = /\[\[?\.{0,3}([^\]]+)\]?\]|:([A-Za-z0-9_]+)|\$([A-Za-z0-9_]+)|\{([^}]+)\}/g;
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The placeholders in a door's name, in the order they appear, or an empty list.
|
|
359
|
+
*
|
|
360
|
+
* WHY THE LEDGER CARES. A page at `/blog/[slug]` is a door like any other, and it is the one
|
|
361
|
+
* kind that cannot be opened by asking for it: without a value there is no address to
|
|
362
|
+
* request. Left with the ledger's ordinary sentence it reads "Nothing has ever opened it",
|
|
363
|
+
* which is true and tells the owner nothing about what to do — and a job nobody knows how to
|
|
364
|
+
* start is a job that stays on the list for ever. Named, it becomes one line of settings.
|
|
365
|
+
*
|
|
366
|
+
* Said here, in the ledger, rather than only in the adapter that would have walked it. An
|
|
367
|
+
* adapter only ever reports the doors it FOUND: a framework this tool cannot read pages out
|
|
368
|
+
* of contributes no journey at all, so its refusal never happens and nothing downstream ever
|
|
369
|
+
* hears about it. The door itself still reaches the ledger from the code reader, and this is
|
|
370
|
+
* the last place the reason can still be attached. Written 2026-08-31.
|
|
371
|
+
*
|
|
372
|
+
* @param {DoorFact} door
|
|
373
|
+
* @returns {string[]}
|
|
374
|
+
*/
|
|
375
|
+
export function placeholdersIn(door) {
|
|
376
|
+
if (door.kind !== 'route') return [];
|
|
377
|
+
/** @type {string[]} */
|
|
378
|
+
const found = [];
|
|
379
|
+
for (const match of String(door.name).matchAll(A_PLACEHOLDER)) {
|
|
380
|
+
const name = match[1] ?? match[2] ?? match[3] ?? match[4];
|
|
381
|
+
if (name) found.push(name.replace(/^\.{3}/, ''));
|
|
382
|
+
}
|
|
383
|
+
return found;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Files that hold markup and styling rather than a program somebody can run.
|
|
388
|
+
*
|
|
389
|
+
* Used for one job: telling a command-line flag apart from a CSS custom property. Nobody
|
|
390
|
+
* declares the flags of a command-line program inside a React component or a stylesheet, and
|
|
391
|
+
* everybody writes `--px` and `--gap` in exactly those files.
|
|
392
|
+
*/
|
|
393
|
+
const A_COMPONENT_FILE = new Set([
|
|
394
|
+
'.tsx', '.jsx', '.vue', '.svelte', '.astro', '.css', '.scss', '.sass', '.less', '.styl',
|
|
395
|
+
]);
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Is this "command" really a CSS custom property, and so not a door at all?
|
|
399
|
+
*
|
|
400
|
+
* WHAT WAS MEASURED, 2026-08-31, on a Next.js website. The code reader turns every string
|
|
401
|
+
* that looks like `--something` into a command-line flag, because in a command-line tool
|
|
402
|
+
* that is what it is. On a website it is a style value: `style={{ '--px': '1.5rem' }}`.
|
|
403
|
+
* Eleven of them were read out of five component files on the reproduction, and every one
|
|
404
|
+
* landed in the ledger's door count. The total then said the product had more ways into it
|
|
405
|
+
* than it has, and a total that counts things that are not doors is not a total of doors.
|
|
406
|
+
*
|
|
407
|
+
* The test is the FILE, not the name, and that is deliberate: `--dry-run` and `--px` look
|
|
408
|
+
* exactly alike, so guessing from the name would start dropping real flags. A component or a
|
|
409
|
+
* stylesheet is a place where a program's flags are never declared, and that is provable
|
|
410
|
+
* without guessing. Anything in a `.ts` or `.js` file is kept, because there the two really
|
|
411
|
+
* cannot be told apart from here — and keeping a thing that is not a door costs a line of
|
|
412
|
+
* noise, while dropping a door that is real is the silence this whole file exists to prevent.
|
|
413
|
+
*
|
|
414
|
+
* The real fix is one line further up, in the code reader, which can see that the string is
|
|
415
|
+
* an object KEY with a style value beside it rather than an argument. That lives in
|
|
416
|
+
* `src/v2/adapters/source.js` and is written up in this lane's report.
|
|
417
|
+
*
|
|
418
|
+
* @param {DoorFact} door
|
|
419
|
+
* @returns {boolean}
|
|
420
|
+
*/
|
|
421
|
+
export function isAStyleValue(door) {
|
|
422
|
+
if (door.kind !== 'command') return false;
|
|
423
|
+
if (!/^--/.test(String(door.name))) return false;
|
|
424
|
+
const where = String(door.file ?? '');
|
|
425
|
+
const dot = where.lastIndexOf('.');
|
|
426
|
+
return dot > 0 && A_COMPONENT_FILE.has(where.slice(dot).toLowerCase());
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The door list with everything that is not a door taken out, and every door counted once.
|
|
431
|
+
*
|
|
432
|
+
* THE DENOMINATOR IS THE PROMISE. Every reassuring number this tool prints is a fraction of
|
|
433
|
+
* this list, so anything wrong in here is wrong in all of them. Two things were wrong on
|
|
434
|
+
* 2026-08-31, both of them making the list longer than the product is:
|
|
435
|
+
*
|
|
436
|
+
* - style values counted as commands, which {@link isAStyleValue} explains;
|
|
437
|
+
* - the same door counted once per file that mentions it. A setting read in nine files is
|
|
438
|
+
* one setting; a flag written in four is one flag. The contract channel has folded those
|
|
439
|
+
* since it was written, and the ledger — reading the same source with the same reader —
|
|
440
|
+
* did not, so the two halves of one tool disagreed about how many doors a project has.
|
|
441
|
+
*
|
|
442
|
+
* Folding is by ADDRESS rather than by name, because the address is what carries the parts
|
|
443
|
+
* that make two doors different: `GET /basket` and `POST /basket` keep their own entries, and
|
|
444
|
+
* `parse` exported from two files stays two doors. Only genuinely identical doors fold.
|
|
445
|
+
*
|
|
446
|
+
* @param {DoorFact[]} doors
|
|
447
|
+
* @returns {{doors: DoorFact[], styleValues: DoorFact[], folded: number}}
|
|
448
|
+
*/
|
|
449
|
+
export function justTheDoors(doors) {
|
|
450
|
+
/** @type {DoorFact[]} */
|
|
451
|
+
const styleValues = [];
|
|
452
|
+
/** @type {Map<string, DoorFact>} */
|
|
453
|
+
const kept = new Map();
|
|
454
|
+
let folded = 0;
|
|
455
|
+
for (const door of doors) {
|
|
456
|
+
if (isAStyleValue(door)) {
|
|
457
|
+
styleValues.push(door);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (kept.has(door.address)) {
|
|
461
|
+
folded++;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
kept.set(door.address, door);
|
|
465
|
+
}
|
|
466
|
+
return { doors: [...kept.values()], styleValues, folded };
|
|
467
|
+
}
|
|
468
|
+
|
|
329
469
|
/**
|
|
330
470
|
* Could anything here ever open this door, and if not, why not?
|
|
331
471
|
*
|
|
@@ -436,9 +576,14 @@ const NOTHING_THERE = new Set([404, 405, 410, 501]);
|
|
|
436
576
|
*
|
|
437
577
|
* @param {Capture} capture
|
|
438
578
|
* @param {JourneyWithTouch} [journey]
|
|
579
|
+
* @param {Map<string, {url: string, needs: string[]}>} [pages]
|
|
580
|
+
* The page addresses read out of the folder names, keyed by the file each was read from.
|
|
581
|
+
* A page journey names no door on its step — see {@link pageWalked} — so this is what lets
|
|
582
|
+
* a walked page be matched to the page door in the list. Left out, pages read as unopened,
|
|
583
|
+
* which is the safe direction and not the true one.
|
|
439
584
|
* @returns {Walk}
|
|
440
585
|
*/
|
|
441
|
-
export function walkFromCapture(capture, journey) {
|
|
586
|
+
export function walkFromCapture(capture, journey, pages) {
|
|
442
587
|
const touched = addressesTouched(capture.observations);
|
|
443
588
|
/** @type {Walk} */
|
|
444
589
|
const walk = {
|
|
@@ -538,6 +683,48 @@ export function walkFromCapture(capture, journey) {
|
|
|
538
683
|
doorAddress({ kind: String(s.kind), name: String(s.door), detail: String(s.doorDetail), file: typeof s.doorFile === 'string' ? s.doorFile : undefined }),
|
|
539
684
|
);
|
|
540
685
|
}
|
|
686
|
+
|
|
687
|
+
// ONE VALUE IS ONE ADDRESS, NOT A FAMILY.
|
|
688
|
+
//
|
|
689
|
+
// A step on `/blog/[slug]` carries the address it really asked for — `/blog/hello-world`,
|
|
690
|
+
// built out of the one sample value the settings supply. The door it names is the whole
|
|
691
|
+
// family. Until 2026-08-31 the two were the same thing here, so a site with five blog
|
|
692
|
+
// posts and six product pages behind two changing addresses reported both families
|
|
693
|
+
// covered on the strength of two pages, and the eleven that were never opened were never
|
|
694
|
+
// mentioned anywhere. Written down, the ledger can say which of the two really happened.
|
|
695
|
+
for (const step of [...exact, ...byName]) {
|
|
696
|
+
const at = oneAddressAsked(step);
|
|
697
|
+
if (at === null) continue;
|
|
698
|
+
(walk.sampledAt ??= []).push({
|
|
699
|
+
door: typeof step.doorDetail === 'string' && step.doorDetail !== ''
|
|
700
|
+
? doorAddress({ kind: String(step.kind), name: String(step.door), detail: String(step.doorDetail) })
|
|
701
|
+
: doorKey({ kind: String(step.kind), name: String(step.door) }),
|
|
702
|
+
at,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// A PAGE IS A DOOR AND IT HAS NO NAME ON ITS STEP.
|
|
708
|
+
//
|
|
709
|
+
// The route adapter writes `door` on every step it makes, so a walked route finds its way
|
|
710
|
+
// to the door list. The page adapter does not: its step says `open /blog/hello-world` and
|
|
711
|
+
// nothing else, so every page on every website read as never opened — on runs that had just
|
|
712
|
+
// opened them. Matched here by the FILE the page was read out of, which is the same file
|
|
713
|
+
// the page list carries, so the two cannot drift apart the way matching on a printed
|
|
714
|
+
// sentence would. The proper fix is the adapter naming the door, and it is in this lane's
|
|
715
|
+
// report; this makes the ledger right in the meantime.
|
|
716
|
+
const page = pageWalked(journey, pages);
|
|
717
|
+
if (page) {
|
|
718
|
+
const address = doorAddress({ kind: 'route', name: page.url, detail: 'GET' });
|
|
719
|
+
if (nothingWasTried || page.unfilled) {
|
|
720
|
+
// The page adapter refuses a page whose address still has a gap in it, and it refuses
|
|
721
|
+
// one it could not open at all. Both write a single refusal and nothing else, and both
|
|
722
|
+
// must read here as a door nobody knocked on — never as one that was walked.
|
|
723
|
+
(walk.notTried ??= []).push(`GET ${page.url}`);
|
|
724
|
+
} else {
|
|
725
|
+
(walk.doorAddresses ??= []).push(address);
|
|
726
|
+
if (page.at !== null) (walk.sampledAt ??= []).push({ door: address, at: page.at });
|
|
727
|
+
}
|
|
541
728
|
}
|
|
542
729
|
if (journey?.touched?.files) walk.touchedFiles = journey.touched.files;
|
|
543
730
|
if (journey?.touched?.functions) walk.touchedFunctions = journey.touched.functions;
|
|
@@ -545,6 +732,52 @@ export function walkFromCapture(capture, journey) {
|
|
|
545
732
|
return walk;
|
|
546
733
|
}
|
|
547
734
|
|
|
735
|
+
/**
|
|
736
|
+
* The one address a step really asked for, when the door it names is a whole family.
|
|
737
|
+
*
|
|
738
|
+
* Null when the door has no changing part in it, when nothing filled the gap in, or when the
|
|
739
|
+
* step never said which address it used — in every one of those the step opened the door it
|
|
740
|
+
* named and there is nothing extra to say about it.
|
|
741
|
+
*
|
|
742
|
+
* @param {any} step
|
|
743
|
+
* @returns {string|null}
|
|
744
|
+
*/
|
|
745
|
+
function oneAddressAsked(step) {
|
|
746
|
+
const name = String(step?.door ?? '');
|
|
747
|
+
if (step?.kind !== 'route') return null; // only an address has a changing part in it
|
|
748
|
+
if (placeholdersIn({ kind: 'route', name, address: '' }).length === 0) return null;
|
|
749
|
+
if (Array.isArray(step?.unfilled) && step.unfilled.length > 0) return null; // nothing was asked for
|
|
750
|
+
const asked = typeof step?.url === 'string' ? step.url : typeof step?.goto === 'string' ? step.goto : '';
|
|
751
|
+
return asked === '' || asked === name ? null : asked;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* The page a journey walked, if it walked one.
|
|
756
|
+
*
|
|
757
|
+
* A page journey is recognised by the file it was read out of, never by its name or by the
|
|
758
|
+
* words in its step. Both of those are sentences an adapter writes for a person to read, and
|
|
759
|
+
* matching on a sentence is how a rename turns coverage silently into zero. The file comes
|
|
760
|
+
* from the page list, and the journey's `from` is set to that same value by the adapter that
|
|
761
|
+
* made it, so a match here means the same page and cannot mean anything else.
|
|
762
|
+
*
|
|
763
|
+
* @param {JourneyWithTouch} [journey]
|
|
764
|
+
* @param {Map<string, {url: string, needs: string[]}>} [pages]
|
|
765
|
+
* @returns {{url: string, at: string|null, unfilled: boolean}|null}
|
|
766
|
+
*/
|
|
767
|
+
function pageWalked(journey, pages) {
|
|
768
|
+
if (!pages || !journey || typeof journey.from !== 'string') return null;
|
|
769
|
+
const page = pages.get(journey.from);
|
|
770
|
+
if (!page) return null;
|
|
771
|
+
const step = (journey.steps ?? []).find((s) => /** @type {any} */ (s)?.act === 'open');
|
|
772
|
+
const asked = typeof (/** @type {any} */ (step)?.goto) === 'string' ? String(/** @type {any} */ (step).goto) : null;
|
|
773
|
+
const unfilled = Array.isArray(/** @type {any} */ (step)?.unfilled) && /** @type {any} */ (step).unfilled.length > 0;
|
|
774
|
+
return {
|
|
775
|
+
url: page.url,
|
|
776
|
+
at: page.needs.length > 0 && asked !== null && asked !== page.url ? asked : null,
|
|
777
|
+
unfilled,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
548
781
|
/**
|
|
549
782
|
* Kinds where an observation sharing a door's address really is that door.
|
|
550
783
|
*
|
|
@@ -562,18 +795,53 @@ const ADDRESS_RULE = new Set(['ipc', 'route', 'export', 'env']);
|
|
|
562
795
|
* @param {DoorFact} door
|
|
563
796
|
* @param {Walk} walk
|
|
564
797
|
* @param {Set<string>} paths walk.paths, as a set.
|
|
565
|
-
* @returns {{state: 'opened'|'reached', how: string}|null}
|
|
798
|
+
* @returns {{state: 'opened'|'reached', how: string, at?: string}|null}
|
|
566
799
|
*/
|
|
567
800
|
export function whatTheWalkDid(door, walk, paths) {
|
|
568
801
|
if (walk.doorAddresses?.includes(door.address) || walk.doors?.includes(doorKey(door))) {
|
|
802
|
+
const at = (walk.sampledAt ?? []).find((s) => s.door === door.address || s.door === doorKey(door));
|
|
803
|
+
if (at) {
|
|
804
|
+
// Said in the door's own sentence rather than only in a caveat at the bottom, because
|
|
805
|
+
// this is the line somebody reads when they ask what was proved about this page.
|
|
806
|
+
const gaps = placeholdersIn(door);
|
|
807
|
+
return {
|
|
808
|
+
state: 'opened',
|
|
809
|
+
at: at.at,
|
|
810
|
+
how:
|
|
811
|
+
`"${walk.journey}" opened it at one address, ${at.at}. The address has a changing part in it, so what was ` +
|
|
812
|
+
`walked is that one ${KIND_ONE[door.kind] ?? 'door'} and not the family behind it: every other value of ` +
|
|
813
|
+
`${gaps.map((g) => `"${g}"`).join(' and ')} is unwalked, and a break behind one of those would not be seen.`,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
569
816
|
return { state: 'opened', how: `"${walk.journey}" has a step that knocks on it directly.` };
|
|
570
817
|
}
|
|
571
818
|
if (ADDRESS_RULE.has(door.kind)) {
|
|
572
819
|
if (paths.has(door.address) || paths.has(trimmedAddress(door))) {
|
|
573
820
|
return { state: 'opened', how: `"${walk.journey}" saw the product answer at its own address.` };
|
|
574
821
|
}
|
|
822
|
+
// READING THE SIGN ON A DOOR IS NOT WALKING THROUGH IT.
|
|
823
|
+
//
|
|
824
|
+
// This branch used to answer 'opened', and that single word was the worst false all-clear
|
|
825
|
+
// this tool has ever produced. The import journey imports a module and writes down what it
|
|
826
|
+
// exports — "slug: a function taking 1 argument". Nothing calls it. So an exported name
|
|
827
|
+
// counted as covered on the strength of its own label, `init` and `doctor` both told the
|
|
828
|
+
// owner that libraries were covered "in full", and a check said so on every run.
|
|
829
|
+
//
|
|
830
|
+
// Measured 2026-08-31 on a four-line library: the separator inside `slug` was changed from
|
|
831
|
+
// "-" to "_", so every web address the product makes came out different, and
|
|
832
|
+
// `isReserved('admin')` went from true to false. Both names still existed and both still
|
|
833
|
+
// took one argument, so every address agreed and the run answered "Nothing that worked has
|
|
834
|
+
// changed" and exited 0.
|
|
835
|
+
//
|
|
836
|
+
// It is 'reached' now, which is what it always was: the code is there, something looked at
|
|
837
|
+
// it, and nothing exercised it. An export that was really CALLED is opened by the branch
|
|
838
|
+
// above this one — the answers journey writes each call at this same address — or by the
|
|
839
|
+
// test suite's own coverage below. This one is the label, and it says so.
|
|
575
840
|
if (door.kind === 'export' && paths.has(joinPath(['export', walk.journey, door.name]))) {
|
|
576
|
-
return {
|
|
841
|
+
return {
|
|
842
|
+
state: 'reached',
|
|
843
|
+
how: `"${walk.journey}" imported the module and read this name off it. That its name and its shape are still there is compared; nothing called it, so a version of it that returns a different answer would look identical.`,
|
|
844
|
+
};
|
|
577
845
|
}
|
|
578
846
|
}
|
|
579
847
|
if (door.kind === 'export' && door.file && walk.touchedFunctions?.includes(`${door.file}:${door.name}`)) {
|
|
@@ -657,6 +925,7 @@ function walkOrderKey(walk) {
|
|
|
657
925
|
digestOf(walk.paths ?? []),
|
|
658
926
|
(walk.doors ?? []).join('\u0001'),
|
|
659
927
|
(walk.doorAddresses ?? []).join('\u0001'),
|
|
928
|
+
(walk.sampledAt ?? []).map((d) => `${d.door}=${d.at}`).join('\u0001'),
|
|
660
929
|
(walk.knockedShut ?? []).map((d) => `${d.door}=${d.status}`).join('\u0001'),
|
|
661
930
|
(walk.onlyRedirected ?? []).map((d) => `${d.door}=${d.status}`).join('\u0001'),
|
|
662
931
|
(walk.notTried ?? []).join('\u0001'),
|
|
@@ -750,6 +1019,30 @@ function inGapOrder(list) {
|
|
|
750
1019
|
* @property {string} [at]
|
|
751
1020
|
*/
|
|
752
1021
|
|
|
1022
|
+
/**
|
|
1023
|
+
* What to say about a door nothing has opened, when opening it is possible.
|
|
1024
|
+
*
|
|
1025
|
+
* One sentence, and it has to be the sentence that names the next move. "Nothing has ever
|
|
1026
|
+
* opened it" is where every unopened door used to land, including a page at `/blog/[slug]`
|
|
1027
|
+
* that nothing CAN open until somebody supplies a slug — so the one door with a concrete,
|
|
1028
|
+
* one-line fix read exactly like the ones with no fix at all.
|
|
1029
|
+
*
|
|
1030
|
+
* @param {DoorFact} door
|
|
1031
|
+
* @returns {string}
|
|
1032
|
+
*/
|
|
1033
|
+
function whyItIsStillShut(door) {
|
|
1034
|
+
const gaps = placeholdersIn(door);
|
|
1035
|
+
if (gaps.length > 0) {
|
|
1036
|
+
return (
|
|
1037
|
+
`Nothing has ever opened it, and nothing can until somebody says what ${gaps.map((g) => `"${g}"`).join(' and ')} ` +
|
|
1038
|
+
`should be — the address has a gap in it, so there is nothing to ask for. Put a real value under "http.samples" ` +
|
|
1039
|
+
`(or "web.samples" for a page) in the settings and this ${KIND_ONE[door.kind] ?? 'door'} starts being checked. ` +
|
|
1040
|
+
`Until then a break behind it would not be seen.`
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
return `Nothing has ever opened it, so a break behind this ${KIND_ONE[door.kind] ?? 'door'} would not be seen.`;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
753
1046
|
/**
|
|
754
1047
|
* Draw up the ledger. Pure: hand it doors and walks, get the answer — no disk, and no clock
|
|
755
1048
|
* beyond the one stamp saying when. Everything that touches a store lives in {@link ledger},
|
|
@@ -768,6 +1061,7 @@ export function buildLedger(input) {
|
|
|
768
1061
|
/** @type {Record<string, number>} */
|
|
769
1062
|
const byJourneySource = {};
|
|
770
1063
|
let opened = 0;
|
|
1064
|
+
let sampled = 0;
|
|
771
1065
|
let reached = 0;
|
|
772
1066
|
let never = 0;
|
|
773
1067
|
let unwalkable = 0;
|
|
@@ -775,11 +1069,11 @@ export function buildLedger(input) {
|
|
|
775
1069
|
|
|
776
1070
|
for (const door of input.doors) {
|
|
777
1071
|
const can = walkability(door);
|
|
778
|
-
/** @type {{journey: string, at?: string, how: string, state: 'opened'|'reached'}[]} */
|
|
1072
|
+
/** @type {{journey: string, at?: string, how: string, state: 'opened'|'reached', only?: string}[]} */
|
|
779
1073
|
const hits = [];
|
|
780
1074
|
for (const { walk, paths } of walks) {
|
|
781
1075
|
const did = whatTheWalkDid(door, walk, paths);
|
|
782
|
-
if (did) hits.push({ journey: walk.journey, at: walk.at, how: did.how, state: did.state });
|
|
1076
|
+
if (did) hits.push({ journey: walk.journey, at: walk.at, how: did.how, state: did.state, only: did.at });
|
|
783
1077
|
}
|
|
784
1078
|
const openedBy = hits.filter((h) => h.state === 'opened');
|
|
785
1079
|
const reachedBy = hits.filter((h) => h.state === 'reached');
|
|
@@ -800,7 +1094,7 @@ export function buildLedger(input) {
|
|
|
800
1094
|
how: best
|
|
801
1095
|
? best.how
|
|
802
1096
|
: can.walkable
|
|
803
|
-
?
|
|
1097
|
+
? whyItIsStillShut(door)
|
|
804
1098
|
: /** @type {string} */ (can.whyNot),
|
|
805
1099
|
journeys: [...new Set(evidence.map((h) => h.journey))],
|
|
806
1100
|
lastWalkedAt: stamps.length > 0 ? /** @type {string} */ (stamps[stamps.length - 1]) : null,
|
|
@@ -808,8 +1102,17 @@ export function buildLedger(input) {
|
|
|
808
1102
|
group: family.group,
|
|
809
1103
|
groupLabel: family.label,
|
|
810
1104
|
};
|
|
1105
|
+
// Opened at ONE address of a family only counts as that, and only when NOTHING opened it
|
|
1106
|
+
// properly. A door walked once with a sample value and once for real is covered; a door
|
|
1107
|
+
// walked only with a sample value is one address of an unknown number, and the difference
|
|
1108
|
+
// has to survive all the way to the report or the number goes back to flattering itself.
|
|
1109
|
+
const onlySampled = state === 'opened' && openedBy.length > 0 && openedBy.every((h) => typeof h.only === 'string');
|
|
811
1110
|
if (!can.walkable) entry.whyNot = can.whyNot;
|
|
812
1111
|
if (can.irreversible) entry.irreversible = true;
|
|
1112
|
+
if (onlySampled) {
|
|
1113
|
+
entry.sampled = true;
|
|
1114
|
+
entry.openedAt = /** @type {string} */ (openedBy[0].only);
|
|
1115
|
+
}
|
|
813
1116
|
if (door.file) entry.file = door.file;
|
|
814
1117
|
if (door.line !== undefined) entry.line = door.line;
|
|
815
1118
|
entries.push(entry);
|
|
@@ -817,6 +1120,7 @@ export function buildLedger(input) {
|
|
|
817
1120
|
const tally = byKind[door.kind] ?? { doors: 0, opened: 0, reached: 0, never: 0 };
|
|
818
1121
|
byKind[door.kind] = tally;
|
|
819
1122
|
tally.doors++;
|
|
1123
|
+
if (onlySampled) sampled++;
|
|
820
1124
|
if (state === 'opened') { opened++; tally.opened++; }
|
|
821
1125
|
else if (state === 'reached') { reached++; tally.reached++; }
|
|
822
1126
|
else { never++; tally.never++; }
|
|
@@ -849,6 +1153,29 @@ export function buildLedger(input) {
|
|
|
849
1153
|
`${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}.`,
|
|
850
1154
|
);
|
|
851
1155
|
}
|
|
1156
|
+
// ONE VALUE IS NOT A FAMILY, SAID OUT LOUD.
|
|
1157
|
+
//
|
|
1158
|
+
// A page at `/blog/[slug]` opened at `/blog/hello-world` used to be indistinguishable, in
|
|
1159
|
+
// every number this file produces, from a door with a fixed address that was walked. On a
|
|
1160
|
+
// real Next.js site on 2026-08-31 that meant two families of eleven pages between them read
|
|
1161
|
+
// as covered on the strength of two, and nothing anywhere said so. This is the sentence
|
|
1162
|
+
// that says so, and it is deliberately in the caveats rather than only on the door: the
|
|
1163
|
+
// caveats are what the check prints, and the door entry is what somebody has to go and ask
|
|
1164
|
+
// for.
|
|
1165
|
+
const sampledDoors = entries.filter((e) => e.sampled === true);
|
|
1166
|
+
if (sampledDoors.length > 0) {
|
|
1167
|
+
const listed = sampledDoors
|
|
1168
|
+
.slice(0, 5)
|
|
1169
|
+
.map((e) => `${e.name} was opened only at ${e.openedAt}`)
|
|
1170
|
+
.join(', ');
|
|
1171
|
+
caveats.push(
|
|
1172
|
+
`${sampledDoors.length} ${sampledDoors.length === 1 ? 'door has' : 'doors have'} a changing part in the address and ` +
|
|
1173
|
+
`${sampledDoors.length === 1 ? 'was' : 'were'} opened at ONE address each (${listed}${sampledDoors.length > 5 ? ', and more' : ''}). ` +
|
|
1174
|
+
`That is ${sampledDoors.length === 1 ? 'one page' : 'one page each'} opened, never the family behind it: how many addresses are really there is not ` +
|
|
1175
|
+
`something this tool can know, and every one of them other than the ${sampledDoors.length === 1 ? 'address' : 'addresses'} named here is unwalked. ` +
|
|
1176
|
+
`Put more values under "http.samples" (or "web.samples" for a page) in the settings and more of the family starts being checked.`,
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
852
1179
|
// Named, never silently dropped. A route the code declares and the build answers 404 to is
|
|
853
1180
|
// not a covered route and it is not an absent one either — it is a disagreement between the
|
|
854
1181
|
// source and the thing that ran, and that is worth more than most differences.
|
|
@@ -872,7 +1199,11 @@ export function buildLedger(input) {
|
|
|
872
1199
|
`${bouncedDoors.size} ${bouncedDoors.size === 1 ? 'door' : 'doors'} answered with a redirect rather than with ${bouncedDoors.size === 1 ? 'a page' : 'pages'} — ${[...bouncedDoors.entries()].sort(byFirst).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.' : ''}`,
|
|
873
1200
|
);
|
|
874
1201
|
}
|
|
875
|
-
// A door
|
|
1202
|
+
// A door nothing got through. Either its journey never ran, or it ran and the route turned
|
|
1203
|
+
// the request away — a POST answered 400 because this tool sent no body is not a route that
|
|
1204
|
+
// works, and since 2026-08-31 it is recorded as a door found and not opened rather than as
|
|
1205
|
+
// the route's behaviour. The old wording said "refused before anything ran", which is now
|
|
1206
|
+
// wrong for half the cases it covers. Nobody knocked through, so it is not
|
|
876
1207
|
// shut and it is not bounced — it is untouched, and the only wrong answer is to leave it
|
|
877
1208
|
// out. This says the number out loud so nobody has to notice a door that quietly stopped
|
|
878
1209
|
// being counted as walked.
|
|
@@ -882,7 +1213,21 @@ export function buildLedger(input) {
|
|
|
882
1213
|
if (untriedDoors.size > 0) {
|
|
883
1214
|
const listed = [...untriedDoors].sort().slice(0, 6).join(', ');
|
|
884
1215
|
caveats.push(
|
|
885
|
-
`${untriedDoors.size} ${untriedDoors.size === 1 ? 'door was' : 'doors were'} never tried at all (${listed}${untriedDoors.size > 6 ? ', and more' : ''}). The journey that would have opened ${untriedDoors.size === 1 ? 'it' : 'them'}
|
|
1216
|
+
`${untriedDoors.size} ${untriedDoors.size === 1 ? 'door was' : 'doors were'} never tried at all (${listed}${untriedDoors.size > 6 ? ', and more' : ''}). The journey that would have opened ${untriedDoors.size === 1 ? 'it' : 'them'} either never ran, or ran and could not get past what the route asked for — and the reason is on the record beside it. ${untriedDoors.size === 1 ? 'It is' : 'They are'} counted here as never opened, because nothing knocked.`,
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
// WHAT AN EXPORTED NAME BEING "REACHED" REALLY MEANS, said in the report rather than left
|
|
1220
|
+
// for somebody to infer from a word. An export lands here when something imported the module
|
|
1221
|
+
// and read the name off it, or ran the file it lives in, and nothing ever called it. The
|
|
1222
|
+
// count is small and the consequence is not: on a library, the names are the packaging and
|
|
1223
|
+
// the answers are the product, and this is the line that says the product was not compared.
|
|
1224
|
+
const labelOnly = entries.filter((e) => e.kind === 'export' && e.state === 'reached');
|
|
1225
|
+
if (labelOnly.length > 0) {
|
|
1226
|
+
const listed = labelOnly.slice(0, 6).map((e) => e.name).join(', ');
|
|
1227
|
+
caveats.push(
|
|
1228
|
+
`${labelOnly.length} exported ${labelOnly.length === 1 ? 'name was' : 'names were'} read but never called (${listed}${labelOnly.length > 6 ? ', and more' : ''}). ` +
|
|
1229
|
+
`What is compared about ${labelOnly.length === 1 ? 'it' : 'them'} is that the name is still there and still takes the same number of arguments. ` +
|
|
1230
|
+
`A version that returns a DIFFERENT ANSWER would look identical, so a clean run says nothing about what ${labelOnly.length === 1 ? 'it does' : 'they do'}.`,
|
|
886
1231
|
);
|
|
887
1232
|
}
|
|
888
1233
|
if (input.doors.length === 0) {
|
|
@@ -898,6 +1243,7 @@ export function buildLedger(input) {
|
|
|
898
1243
|
knows: 'per door',
|
|
899
1244
|
doors: input.doors.length,
|
|
900
1245
|
opened,
|
|
1246
|
+
sampled,
|
|
901
1247
|
reached,
|
|
902
1248
|
never,
|
|
903
1249
|
unwalkable,
|
|
@@ -922,6 +1268,10 @@ export function buildLedger(input) {
|
|
|
922
1268
|
* @typedef {object} LedgerOptions
|
|
923
1269
|
* @property {Door[]} [doors] The code reader's own output. The best answer there is.
|
|
924
1270
|
* @property {string} [root] Read the code now to get the doors. Reads, runs nothing.
|
|
1271
|
+
* @property {string[]} [folders] Which folders of `root` hold this project's code. Left
|
|
1272
|
+
* out, the settings file at `root` is asked — which is
|
|
1273
|
+
* the same answer the run itself uses, and the whole
|
|
1274
|
+
* point: one answer to "what is in this project".
|
|
925
1275
|
* @property {JourneyWithTouch[]} [journeys]
|
|
926
1276
|
* The journeys behind the captures. With these, a door
|
|
927
1277
|
* is matched by the step that knocks on it, which is
|
|
@@ -934,6 +1284,118 @@ export function buildLedger(input) {
|
|
|
934
1284
|
* @property {(message: string) => void} [log]
|
|
935
1285
|
*/
|
|
936
1286
|
|
|
1287
|
+
/**
|
|
1288
|
+
* Which folders of this project hold its code — the settings' answer, not this file's guess.
|
|
1289
|
+
*
|
|
1290
|
+
* The settings are read here rather than handed in, and that is deliberate. The one caller
|
|
1291
|
+
* that draws up a ledger during a check had the settings in its hand and did not pass them,
|
|
1292
|
+
* which is how the ledger came to be measuring a third of a website. A default that has to be
|
|
1293
|
+
* remembered is a default that will be forgotten again, so the answer is fetched from the one
|
|
1294
|
+
* place that holds it.
|
|
1295
|
+
*
|
|
1296
|
+
* When the settings cannot be read at all, the reader's own list of usual folders is used and
|
|
1297
|
+
* the ledger SAYS SO. Reading fewer folders than the product has is the failure this whole
|
|
1298
|
+
* lane is about, and it must never happen again without a sentence beside it.
|
|
1299
|
+
*
|
|
1300
|
+
* @param {string} root
|
|
1301
|
+
* @param {string[]} [given] Folders the caller already knows. Believed without a second read.
|
|
1302
|
+
* @returns {Promise<{folders: string[]|undefined, why: string}>}
|
|
1303
|
+
*/
|
|
1304
|
+
export async function sourceFoldersFor(root, given) {
|
|
1305
|
+
if (Array.isArray(given) && given.length > 0) return { folders: given, why: '' };
|
|
1306
|
+
// The settings file search walks UPWARDS, so a project with none of its own can be handed
|
|
1307
|
+
// its parent's. The folder names in that file are relative to the parent, so reading them
|
|
1308
|
+
// here would point the reader at folders that are not in this project at all — which is the
|
|
1309
|
+
// same failure this function exists to fix, wearing a different hat. Only this project's own
|
|
1310
|
+
// settings count.
|
|
1311
|
+
const found = findConfigFile(root);
|
|
1312
|
+
const file = found !== null && path.resolve(rootForConfig(found)) === path.resolve(root) ? found : null;
|
|
1313
|
+
if (!file) {
|
|
1314
|
+
return {
|
|
1315
|
+
folders: undefined,
|
|
1316
|
+
why:
|
|
1317
|
+
'There is no Stays Fixed settings file here, so the code was read from the folders the reader guesses at — ' +
|
|
1318
|
+
'src, lib, app and the rest. A project that keeps code anywhere else has doors that are not in this ledger at all. ' +
|
|
1319
|
+
'Run `staysfixed init` and the folders are named once and read the same way every time.',
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
try {
|
|
1323
|
+
/** @type {Record<string, any>} */
|
|
1324
|
+
let settings;
|
|
1325
|
+
if (file.endsWith('.json')) settings = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
1326
|
+
else {
|
|
1327
|
+
const loaded = await import(`file://${path.resolve(file)}`);
|
|
1328
|
+
settings = loaded.default ?? loaded.config ?? loaded;
|
|
1329
|
+
}
|
|
1330
|
+
const folders = settings?.source?.folders;
|
|
1331
|
+
if (Array.isArray(folders) && folders.length > 0) return { folders: folders.map(String), why: '' };
|
|
1332
|
+
return { folders: undefined, why: '' };
|
|
1333
|
+
} catch (e) {
|
|
1334
|
+
return {
|
|
1335
|
+
folders: undefined,
|
|
1336
|
+
why:
|
|
1337
|
+
`The settings at ${path.basename(file)} could not be read (${e instanceof Error ? e.message : String(e)}), so the code was read from ` +
|
|
1338
|
+
'the folders the reader guesses at rather than the ones this project names. Any door outside those folders is missing from this ledger.',
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* The page addresses this website has, keyed by the file each was read out of.
|
|
1345
|
+
*
|
|
1346
|
+
* Every failure is a hole rather than an exception: a ledger that cannot list the pages is
|
|
1347
|
+
* worse than one that can, and it is far better than no ledger at all. Empty for anything
|
|
1348
|
+
* that is not a website, which is the right answer there.
|
|
1349
|
+
*
|
|
1350
|
+
* @param {string} root
|
|
1351
|
+
* @param {CoverageGap[]} holes
|
|
1352
|
+
* @returns {Promise<Map<string, {url: string, needs: string[]}>>}
|
|
1353
|
+
*/
|
|
1354
|
+
async function readThePages(root, holes) {
|
|
1355
|
+
/** @type {Map<string, {url: string, needs: string[]}>} */
|
|
1356
|
+
const found = new Map();
|
|
1357
|
+
try {
|
|
1358
|
+
const { readPageRoutes } = await import('./adapters/web.js');
|
|
1359
|
+
for (const page of await readPageRoutes(root)) found.set(page.file, { url: page.url, needs: page.needs });
|
|
1360
|
+
} catch (e) {
|
|
1361
|
+
holes.push({
|
|
1362
|
+
what: `The pages of this project could not be listed (${e instanceof Error ? e.message : String(e)}).`,
|
|
1363
|
+
why: 'A page is a way into a website, so every page is missing from the door count in this ledger and nothing here says anything about any of them.',
|
|
1364
|
+
unlockedBy: 'Run `staysfixed doctor` — it says what this copy of the tool can and cannot read.',
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
return found;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Say what was taken OUT of the door list, and why, every time anything was.
|
|
1372
|
+
*
|
|
1373
|
+
* A denominator that quietly shrinks is as bad as one that quietly grows, so nothing is ever
|
|
1374
|
+
* dropped in silence. Both of these make the count smaller, and both make it more true.
|
|
1375
|
+
*
|
|
1376
|
+
* @param {string[]} caveats
|
|
1377
|
+
* @param {{styleValues: DoorFact[], folded: number}} only
|
|
1378
|
+
* @returns {void}
|
|
1379
|
+
*/
|
|
1380
|
+
function sayWhatWasNotADoor(caveats, only) {
|
|
1381
|
+
if (only.styleValues.length > 0) {
|
|
1382
|
+
const names = [...new Set(only.styleValues.map((d) => d.name))].sort().slice(0, 5).join(', ');
|
|
1383
|
+
caveats.push(
|
|
1384
|
+
`${only.styleValues.length} ${only.styleValues.length === 1 ? 'name that looks like a command-line flag was' : 'names that look like command-line flags were'} ` +
|
|
1385
|
+
`left out of the door count (${names}${only.styleValues.length > 5 ? ', and more' : ''}): ${only.styleValues.length === 1 ? 'it is' : 'they are'} written in a ` +
|
|
1386
|
+
`component or a stylesheet, where a name of that shape is a CSS custom property and not a way into the product. ` +
|
|
1387
|
+
`Counting ${only.styleValues.length === 1 ? 'it' : 'them'} made this product look bigger than it is, and every fraction of that total wrong.`,
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
if (only.folded > 0) {
|
|
1391
|
+
caveats.push(
|
|
1392
|
+
`${only.folded} repeated ${only.folded === 1 ? 'mention was' : 'mentions were'} counted once rather than once per file. ` +
|
|
1393
|
+
'A setting read in nine files is one setting and a flag written in four is one flag — the same folding the contract channel has always done, ' +
|
|
1394
|
+
'so the two halves of this tool now agree about how many doors this project has.',
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
937
1399
|
/**
|
|
938
1400
|
* Everything this tool has ever managed to walk of one product, door by door.
|
|
939
1401
|
*
|
|
@@ -979,6 +1441,12 @@ export async function ledger(store, product, opts = {}) {
|
|
|
979
1441
|
const byName = new Map();
|
|
980
1442
|
for (const journey of opts.journeys ?? []) byName.set(journey.name, journey);
|
|
981
1443
|
|
|
1444
|
+
// The pages, read before anything else needs them, because both halves of this function
|
|
1445
|
+
// want the same list: the walks want it to match a walked page to the page it walked, and
|
|
1446
|
+
// the door list wants it because a page IS a door and until 2026-08-31 not one of them was
|
|
1447
|
+
// in the count. Read once, so the two halves cannot answer differently.
|
|
1448
|
+
const pages = opts.root ? await readThePages(opts.root, holes) : new Map();
|
|
1449
|
+
|
|
982
1450
|
/** @type {Walk[]} */
|
|
983
1451
|
const walks = [];
|
|
984
1452
|
/** @type {Observation[]} */
|
|
@@ -1022,7 +1490,7 @@ export async function ledger(store, product, opts = {}) {
|
|
|
1022
1490
|
const key = /** @type {Channel} */ (channel);
|
|
1023
1491
|
byChannel[key] = (byChannel[key] ?? 0) + n;
|
|
1024
1492
|
}
|
|
1025
|
-
walks.push(walkFromCapture(capture, byName.get(capture.journey)));
|
|
1493
|
+
walks.push(walkFromCapture(capture, byName.get(capture.journey), pages));
|
|
1026
1494
|
for (const gap of capture.coverage?.gaps ?? []) holes.push(gap);
|
|
1027
1495
|
}
|
|
1028
1496
|
}
|
|
@@ -1030,10 +1498,29 @@ export async function ledger(store, product, opts = {}) {
|
|
|
1030
1498
|
/** @type {DoorFact[]} */
|
|
1031
1499
|
let doors;
|
|
1032
1500
|
if (opts.doors) {
|
|
1033
|
-
|
|
1501
|
+
// The same filter as the other two branches, and for the same reason: what counts as a
|
|
1502
|
+
// door has to be one answer, whoever produced the list. A caller handing in the reader's
|
|
1503
|
+
// raw output hands in its style values and its repeats along with it.
|
|
1504
|
+
const only = justTheDoors(opts.doors.map(doorFact));
|
|
1505
|
+
doors = only.doors;
|
|
1034
1506
|
caveats.push('The doors were handed in by the code reader as this ledger was drawn up, so it knows about doors added since the last run.');
|
|
1507
|
+
sayWhatWasNotADoor(caveats, only);
|
|
1035
1508
|
} else if (opts.root) {
|
|
1036
|
-
|
|
1509
|
+
// THE SOURCE THE LEDGER READS AND THE SOURCE THE RUN READS ARE ONE SOURCE.
|
|
1510
|
+
//
|
|
1511
|
+
// This call used to be `readContract({ root })` and nothing else, so it read the code
|
|
1512
|
+
// reader's OWN list of usual folders — src, lib, app, bin and the rest — while the run it
|
|
1513
|
+
// is reporting on read the folders the settings name. Measured 2026-08-31 on a Next.js
|
|
1514
|
+
// website whose settings say `source: { folders: ['.'] }`: the ledger read 8 of the
|
|
1515
|
+
// project's 20 source files and 25 of its doors, and then printed "25 of the 25 doors
|
|
1516
|
+
// this product has have never been walked" — a total drawn from a third of the product,
|
|
1517
|
+
// presented as the whole of it. Every fraction underneath was a fraction of the wrong
|
|
1518
|
+
// thing.
|
|
1519
|
+
//
|
|
1520
|
+
// So the folders are settled once, from the same settings file the run reads, and both
|
|
1521
|
+
// halves of the tool now answer the same question the same way.
|
|
1522
|
+
const asked = await sourceFoldersFor(opts.root, opts.folders);
|
|
1523
|
+
const reading = await readContract({ root: opts.root, folders: asked.folders });
|
|
1037
1524
|
const fileRoutes = await readFileRoutes(opts.root);
|
|
1038
1525
|
reading.doors.push(...fileRoutes.doors);
|
|
1039
1526
|
reading.doors.push(...(await readPackageCommands(opts.root)));
|
|
@@ -1044,13 +1531,29 @@ export async function ledger(store, product, opts = {}) {
|
|
|
1044
1531
|
unlockedBy: 'Make that folder readable by whoever runs the check.',
|
|
1045
1532
|
});
|
|
1046
1533
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1534
|
+
// A page is a way into a website, so a page is a door. The code reader cannot see one —
|
|
1535
|
+
// it reads calls, and a Next.js page is a folder name — so the page list is added here
|
|
1536
|
+
// from the same reader the page adapter walks with.
|
|
1537
|
+
const pageDoors = [...pages.entries()].map(([file, page]) => ({
|
|
1538
|
+
kind: /** @type {const} */ ('route'), name: page.url, detail: 'GET', file, line: 1,
|
|
1539
|
+
inTest: false, named: true, via: 'the folder it lives in',
|
|
1540
|
+
}));
|
|
1541
|
+
const only = justTheDoors([...reading.doors, ...pageDoors].map(doorFact));
|
|
1542
|
+
doors = only.doors;
|
|
1543
|
+
caveats.push(
|
|
1544
|
+
`The code was read as this ledger was drawn up: ${reading.report.filesRead} files in ${(asked.folders ?? ['the folders it looks in by default']).join(', ')}, ` +
|
|
1545
|
+
`${doors.length} doors, and nothing was run.`,
|
|
1546
|
+
);
|
|
1547
|
+
if (asked.why !== '') caveats.push(asked.why);
|
|
1548
|
+
sayWhatWasNotADoor(caveats, only);
|
|
1049
1549
|
} else {
|
|
1050
1550
|
doors = doorsFromObservations(contractSeen);
|
|
1051
1551
|
caveats.push(
|
|
1052
1552
|
'The door list came from what previous runs wrote down, not from the code as it stands now, so a door added since the last run is not in this ledger at all. Pass `root` and it reads the source instead.',
|
|
1053
1553
|
);
|
|
1554
|
+
const only = justTheDoors(doors);
|
|
1555
|
+
doors = only.doors;
|
|
1556
|
+
sayWhatWasNotADoor(caveats, only);
|
|
1054
1557
|
}
|
|
1055
1558
|
if (!opts.includeTests) {
|
|
1056
1559
|
const before = doors.length;
|
|
@@ -1114,7 +1617,7 @@ export function gaps(led, opts = {}) {
|
|
|
1114
1617
|
const worst = opts.worst ?? 12;
|
|
1115
1618
|
const minDoors = opts.minDoors ?? 1;
|
|
1116
1619
|
|
|
1117
|
-
/** @type {Map<string, {label: string, kind: string, never: DoorEntry[], opened: number, reached: number, files: Map<string, number>}>} */
|
|
1620
|
+
/** @type {Map<string, {label: string, kind: string, never: DoorEntry[], opened: number, sampled: number, reached: number, files: Map<string, number>}>} */
|
|
1118
1621
|
const families = new Map();
|
|
1119
1622
|
for (const entry of led.entries) {
|
|
1120
1623
|
if (!opts.includeUnwalkable && !entry.walkable) continue;
|
|
@@ -1124,10 +1627,16 @@ export function gaps(led, opts = {}) {
|
|
|
1124
1627
|
kind: entry.kind,
|
|
1125
1628
|
never: /** @type {DoorEntry[]} */ ([]),
|
|
1126
1629
|
opened: 0,
|
|
1630
|
+
sampled: 0,
|
|
1127
1631
|
reached: 0,
|
|
1128
1632
|
files: /** @type {Map<string, number>} */ (new Map()),
|
|
1129
1633
|
};
|
|
1130
|
-
|
|
1634
|
+
// A door opened at one address of a family is counted apart from one that was really
|
|
1635
|
+
// opened. It used to be counted as opened, so a family of pages behind a changing address
|
|
1636
|
+
// read as covered the moment a single sample value existed, and the job of covering the
|
|
1637
|
+
// rest never appeared on the queue at all.
|
|
1638
|
+
if (entry.sampled === true) family.sampled++;
|
|
1639
|
+
else if (entry.state === 'opened') family.opened++;
|
|
1131
1640
|
else if (entry.state === 'reached') family.reached++;
|
|
1132
1641
|
else family.never.push(entry);
|
|
1133
1642
|
if (entry.file) family.files.set(entry.file, (family.files.get(entry.file) ?? 0) + 1);
|
|
@@ -1142,8 +1651,11 @@ export function gaps(led, opts = {}) {
|
|
|
1142
1651
|
const jobs = [];
|
|
1143
1652
|
for (const [group, family] of families) {
|
|
1144
1653
|
if (family.never.length < minDoors) continue;
|
|
1145
|
-
const total = family.never.length + family.opened + family.reached;
|
|
1654
|
+
const total = family.never.length + family.opened + family.sampled + family.reached;
|
|
1146
1655
|
const allDark = family.opened === 0;
|
|
1656
|
+
const sampledHere = family.sampled > 0
|
|
1657
|
+
? ` ${family.sampled} more ${family.sampled === 1 ? 'was' : 'were'} opened at a single address each, which is one page apiece rather than the family behind it.`
|
|
1658
|
+
: '';
|
|
1147
1659
|
const weight = KIND_WEIGHT[family.kind] ?? 3;
|
|
1148
1660
|
// Size counts, but under a square root, so one family of four hundred cannot bury twenty
|
|
1149
1661
|
// families of ten that between them cover far more of the product.
|
|
@@ -1158,8 +1670,8 @@ export function gaps(led, opts = {}) {
|
|
|
1158
1670
|
family.reached > 0
|
|
1159
1671
|
? ` ${family.reached} of them sit in code the tests do run, so the break would be right beside a path that looks covered.`
|
|
1160
1672
|
: ''
|
|
1161
|
-
}`
|
|
1162
|
-
: `${family.opened} of them are covered and ${family.never.length} are not, so a clean run here means less than it looks like it does
|
|
1673
|
+
}${sampledHere}`
|
|
1674
|
+
: `${family.opened} of them are covered and ${family.never.length} are not, so a clean run here means less than it looks like it does.${sampledHere}`,
|
|
1163
1675
|
howTo: howToCover(family.kind, family.never, harvested),
|
|
1164
1676
|
doors: family.never.length,
|
|
1165
1677
|
openedHere: family.opened,
|
|
@@ -1244,6 +1756,31 @@ function howToCover(kind, never, harvested = false) {
|
|
|
1244
1756
|
export function toCoverage(led, opts = {}) {
|
|
1245
1757
|
/** @type {CoverageGap[]} */
|
|
1246
1758
|
const out = [...led.gaps];
|
|
1759
|
+
// A door opened at one address of a family is its own hole, and it is the one hole the
|
|
1760
|
+
// reader is most likely to read as coverage: the run really did open that page, so nothing
|
|
1761
|
+
// in the report looks wrong. It goes at the FRONT of the list, because the summary sentence
|
|
1762
|
+
// names only the first hole it finds by way of an example, and this is the one somebody
|
|
1763
|
+
// would otherwise never think to ask about.
|
|
1764
|
+
if (led.sampled > 0) {
|
|
1765
|
+
const which = led.entries.filter((e) => e.sampled === true).slice(0, 4);
|
|
1766
|
+
out.unshift({
|
|
1767
|
+
what: led.sampled === 1
|
|
1768
|
+
? '1 address with a changing part in it was opened at one value only.'
|
|
1769
|
+
: `${led.sampled} addresses with a changing part in them were opened at one value each.`,
|
|
1770
|
+
why:
|
|
1771
|
+
`${which.map((e) => `${e.name} was opened at ${e.openedAt}`).join(', ')}${led.sampled > which.length ? ', and more' : ''}. ` +
|
|
1772
|
+
`That is ${led.sampled === 1 ? 'one page' : 'one page each'} opened and not the family behind it — how many addresses are really there is not something ` +
|
|
1773
|
+
'this tool can know from the outside, and a break at any of the others would not be seen.',
|
|
1774
|
+
unlockedBy: 'Put more values under "http.samples", or "web.samples" for a page, in the settings — one per value worth checking.',
|
|
1775
|
+
channel: 'contract',
|
|
1776
|
+
// No `doors` count on purpose, and it is not an oversight. A gap carrying a door count
|
|
1777
|
+
// is folded into the "N of the M doors were never walked" sentence and then dropped
|
|
1778
|
+
// from the list of other things that were not looked at — so a count here would delete
|
|
1779
|
+
// this line from the only sentence a person reads. These doors WERE walked, at one
|
|
1780
|
+
// address each, so they belong in the other half of that sentence rather than in the
|
|
1781
|
+
// door arithmetic.
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1247
1784
|
if (led.doors > 0 && led.never > 0) {
|
|
1248
1785
|
out.push({
|
|
1249
1786
|
// A product with one door read "1 of this product's 1 doors have never been opened",
|