staysfixed 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 { state: 'opened', how: `"${walk.journey}" read it off the module's exported surface.` };
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
- ? `Nothing has ever opened it, so a break behind this ${KIND_ONE[door.kind] ?? 'door'} would not be seen.`
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 whose journey was refused before anything ran. Nobody knocked on it, so it is not
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'} was refused before anything ran the thing it needed did not start, or a value it needed was never supplied — and the reason is on the record beside it. ${untriedDoors.size === 1 ? 'It is' : 'They are'} counted here as never opened, because nothing knocked.`,
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
- doors = opts.doors.map(doorFact);
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
- const reading = await readContract({ root: opts.root });
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
- doors = reading.doors.map(doorFact);
1048
- caveats.push(`The code was read as this ledger was drawn up: ${reading.report.filesRead} files, ${reading.doors.length} doors, and nothing was run.`);
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
- if (entry.state === 'opened') family.opened++;
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",