staysfixed 0.10.0 → 0.11.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.
@@ -48,7 +48,7 @@
48
48
  import { asAddress } from './adapters/electron.js';
49
49
  import { readContract, readFileRoutes, readPackageCommands } from './adapters/source.js';
50
50
  import { familyOf, irreversibility, isRunnable } from './journeys/from-routes.js';
51
- import { joinPath, splitPath } from './observation.js';
51
+ import { CHANNELS, joinPath, splitPath } from './observation.js';
52
52
  import { listBuilds, listCaptures, loadCapture, referencePointer } from './store.js';
53
53
 
54
54
  /** @typedef {import('./types.js').Channel} Channel */
@@ -109,6 +109,10 @@ import { listBuilds, listCaptures, loadCapture, referencePointer } from './store
109
109
  * Doors a step knocked on where the running build
110
110
  * answered that they are not there. Knocking is not
111
111
  * walking, and these have proved nothing.
112
+ * @property {string[]} [notTried] Doors this journey's steps name, on a walk that
113
+ * never happened — the adapter refused it and wrote
114
+ * down only that. Nobody knocked, so nothing here
115
+ * is walked.
112
116
  * @property {string[]} [doorAddresses] Full door addresses, for steps that were specific
113
117
  * enough to build one. A route step knows its verb,
114
118
  * and GET /x and POST /x are two doors that share a
@@ -392,6 +396,14 @@ function asDoor(door) {
392
396
  * file: reading a door out of the source is how we know it exists, never evidence that
393
397
  * anybody opened it.
394
398
  *
399
+ * A refusal is dropped for the same reason. When an adapter cannot look at something it
400
+ * writes that down at the address it would have looked at, marked `refused` — a payment it
401
+ * would not make, a server that never started, a route with a parameter nobody supplied.
402
+ * Left in, the note saying "we did not look here" became the evidence that we did.
403
+ *
404
+ * Both still count in `byChannel`: they were written down, and the tallies say how much was
405
+ * written down. It is only the "somebody opened this door" set they are kept out of.
406
+ *
395
407
  * @param {Observation[]} observations
396
408
  * @returns {{paths: string[], byChannel: Partial<Record<Channel, number>>}}
397
409
  */
@@ -403,6 +415,7 @@ export function addressesTouched(observations) {
403
415
  for (const o of observations) {
404
416
  byChannel[o.channel] = (byChannel[o.channel] ?? 0) + 1;
405
417
  if (o.channel === 'contract') continue;
418
+ if (o.meta?.refused === true) continue;
406
419
  const parts = String(o.path).split('.');
407
420
  for (let i = 1; i <= parts.length; i++) paths.add(parts.slice(0, i).join('.'));
408
421
  }
@@ -435,6 +448,26 @@ export function walkFromCapture(capture, journey) {
435
448
  buildId: capture.build?.id,
436
449
  paths: touched.paths,
437
450
  };
451
+ // NOTHING WAS TRIED IS NOT A WALK.
452
+ //
453
+ // Every adapter has branches where it runs nothing and says so: the server never came up,
454
+ // the route has a `:id` nobody supplied a value for, the command spends money and there is
455
+ // nothing watching to stop it. Each one writes a single observation marked `refused` and
456
+ // returns. No `api.<door>.status` comes back, because no request went out.
457
+ //
458
+ // The status rule below reads a missing status as "it answered something we are happy
459
+ // with", so every one of those doors counted as walked. Measured 2026-08-31 with the HTTP
460
+ // adapter's own output: one route, one journey, the server never started, and the ledger
461
+ // came back doorsWalked 1 of 1 — full coverage of a product that had not been run — in the
462
+ // same report whose only line read "was not tried: It never started."
463
+ //
464
+ // A capture whose entire non-contract record is refusals is a walk that did nothing. Its
465
+ // steps opened nothing, and the doors they name are listed rather than dropped.
466
+ const seen = capture.observations ?? [];
467
+ const refusals = seen.filter((o) => o?.meta?.refused === true);
468
+ const anythingHappened = seen.some((o) => o?.channel !== 'contract' && o?.meta?.refused !== true);
469
+ const nothingWasTried = refusals.length > 0 && !anythingHappened;
470
+
438
471
  if (journey?.steps) {
439
472
  // Two lists, because a doorKey is kind and name only. That is right for an IPC channel or
440
473
  // an exported name, where the name IS the door; it is wrong for a route, where GET /basket
@@ -464,6 +497,8 @@ export function walkFromCapture(capture, journey) {
464
497
  const shut = [];
465
498
  /** @type {{door: string, status: number}[]} */
466
499
  const bounced = [];
500
+ /** @type {string[]} */
501
+ const untried = [];
467
502
  /** @param {any} s @returns {boolean} */
468
503
  const reallyWalked = (s) => {
469
504
  // The door is the ROUTE (`/reports`); the observation is addressed by method and route
@@ -474,6 +509,10 @@ export function walkFromCapture(capture, journey) {
474
509
  typeof s.method === 'string' && s.method ? `${s.method} ${s.door}` : null,
475
510
  String(s.door),
476
511
  ].filter(Boolean);
512
+ if (nothingWasTried) {
513
+ untried.push(String(keys[0] ?? s.door));
514
+ return false;
515
+ }
477
516
  const key = keys.find((k) => answered.has(/** @type {string} */ (k)));
478
517
  const code = key === undefined ? undefined : answered.get(/** @type {string} */ (key));
479
518
  // A redirect is real behaviour and it IS walked — but what you saw is the bounce, not the
@@ -492,6 +531,7 @@ export function walkFromCapture(capture, journey) {
492
531
  const byName = named.filter((s) => typeof s.doorDetail !== 'string' || s.doorDetail === '').filter(reallyWalked);
493
532
  if (shut.length > 0) walk.knockedShut = shut;
494
533
  if (bounced.length > 0) walk.onlyRedirected = bounced;
534
+ if (untried.length > 0) walk.notTried = untried;
495
535
  if (byName.length > 0) walk.doors = byName.map((s) => doorKey({ kind: String(s.kind), name: String(s.door) }));
496
536
  if (exact.length > 0) {
497
537
  walk.doorAddresses = exact.map((s) =>
@@ -561,6 +601,138 @@ function trimmedAddress(door) {
561
601
  return joinPath(['ipc', asAddress(door.name)]);
562
602
  }
563
603
 
604
+ // ---------------------------------------------------------------------------
605
+ // One order, every time
606
+ // ---------------------------------------------------------------------------
607
+
608
+ /**
609
+ * TWO IDENTICAL RUNS HAVE TO SAY THE SAME THING.
610
+ *
611
+ * This tool's whole method is running the same thing twice and subtracting what disagrees.
612
+ * A report that disagrees with itself between two identical runs is not untidy — it is the
613
+ * measurement contradicting the method, and a reader who spots it has no reason to believe
614
+ * the rest.
615
+ *
616
+ * It was doing exactly that. A capture id ends in three random bytes, deliberately, so two
617
+ * captures written inside the same second do not overwrite each other — and the store hands
618
+ * captures back in id order, so those random bytes decide which walk is read first. Measured
619
+ * 2026-08-31 on one unchanged product, three runs: help, the-code, the-code, help; then
620
+ * the-code, help, the-code, help; then help, the-code, the-code, help.
621
+ *
622
+ * Everything the ledger built in that order came out shuffled with it — which of two
623
+ * journeys got named as the evidence for a door, which six of eight shut doors got listed by
624
+ * name in the caveat, the order of the gaps, the key order of the tallies. So the walks are
625
+ * put in an order of the ledger's own before anything reads them, and that order is a
626
+ * function of what is IN each walk, never of when it arrived.
627
+ *
628
+ * @param {Walk[]} walks
629
+ * @returns {Walk[]}
630
+ */
631
+ function inWalkOrder(walks) {
632
+ return [...walks]
633
+ .map((walk) => ({ walk, key: walkOrderKey(walk) }))
634
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
635
+ .map(({ walk }) => walk);
636
+ }
637
+
638
+ /**
639
+ * The whole of one walk, squeezed into a single sortable string.
640
+ *
641
+ * Every field that can reach the report is in here, so two walks only ever tie when they
642
+ * would produce the same words either way round. The addresses are the exception: a walk on
643
+ * a big product carries tens of thousands of them, and holding all of that in a sort key
644
+ * costs more than the ordering is worth. How many there are, plus a fingerprint of them,
645
+ * tells two walks apart just as well and stays one short string.
646
+ *
647
+ * @param {Walk} walk
648
+ * @returns {string}
649
+ */
650
+ function walkOrderKey(walk) {
651
+ return [
652
+ walk.journey ?? '',
653
+ walk.at ?? '',
654
+ walk.buildId ?? '',
655
+ walk.source ?? '',
656
+ String((walk.paths ?? []).length),
657
+ digestOf(walk.paths ?? []),
658
+ (walk.doors ?? []).join('\u0001'),
659
+ (walk.doorAddresses ?? []).join('\u0001'),
660
+ (walk.knockedShut ?? []).map((d) => `${d.door}=${d.status}`).join('\u0001'),
661
+ (walk.onlyRedirected ?? []).map((d) => `${d.door}=${d.status}`).join('\u0001'),
662
+ (walk.notTried ?? []).join('\u0001'),
663
+ (walk.touchedFiles ?? []).join('\u0001'),
664
+ (walk.touchedFunctions ?? []).join('\u0001'),
665
+ String(walk.functionsNotListed ?? 0),
666
+ ].join('\u0000');
667
+ }
668
+
669
+ /**
670
+ * A short fingerprint of a list of strings. FNV-1a, written out rather than imported,
671
+ * because this file has no business opening a crypto library to decide a sort order.
672
+ *
673
+ * @param {string[]} items
674
+ * @returns {string}
675
+ */
676
+ function digestOf(items) {
677
+ let hash = 0x811c9dc5;
678
+ for (const item of items) {
679
+ for (let i = 0; i < item.length; i++) {
680
+ hash ^= item.charCodeAt(i);
681
+ hash = Math.imul(hash, 0x01000193) >>> 0;
682
+ }
683
+ hash ^= 0x0a;
684
+ hash = Math.imul(hash, 0x01000193) >>> 0;
685
+ }
686
+ return hash.toString(16).padStart(8, '0');
687
+ }
688
+
689
+ /**
690
+ * Sort a list of pairs by the first of the two. Used wherever a caveat names only the first
691
+ * few of something: which few get named has to be decided by their names.
692
+ *
693
+ * @param {[string, unknown]} a
694
+ * @param {[string, unknown]} b
695
+ * @returns {number}
696
+ */
697
+ function byFirst(a, b) {
698
+ return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
699
+ }
700
+
701
+ /**
702
+ * The channel tallies written out in one fixed order — the channel list's own, not the order
703
+ * the observations happened to arrive in. Same reason as {@link inWalkOrder}: this object is
704
+ * printed by `--json`, and JSON keeps the order keys were added in.
705
+ *
706
+ * @param {Partial<Record<Channel, number>>} byChannel
707
+ * @returns {Partial<Record<Channel, number>>}
708
+ */
709
+ function inChannelOrder(byChannel) {
710
+ /** @type {Record<string, number>} */
711
+ const out = {};
712
+ const held = /** @type {Record<string, number>} */ (byChannel);
713
+ for (const channel of CHANNELS) if (held[channel] !== undefined) out[channel] = held[channel];
714
+ // A channel this build of the tool has never heard of is still evidence somebody wrote
715
+ // down, and dropping it would make the report smaller than the run was.
716
+ for (const channel of Object.keys(held).sort()) if (out[channel] === undefined) out[channel] = held[channel];
717
+ return out;
718
+ }
719
+
720
+ /**
721
+ * Sort gaps into an order of their own. Only for gaps whose order carries no meaning — the
722
+ * ones read back out of stored captures, which arrive in whatever order the disk listed
723
+ * them. The gaps `toCoverage` builds are in rank order and are left exactly as they are.
724
+ *
725
+ * @param {CoverageGap[]} list
726
+ * @returns {CoverageGap[]}
727
+ */
728
+ function inGapOrder(list) {
729
+ return [...list].sort((a, b) => {
730
+ const left = `${a.what}\u0000${a.why}\u0000${a.unlockedBy ?? ''}`;
731
+ const right = `${b.what}\u0000${b.why}\u0000${b.unlockedBy ?? ''}`;
732
+ return left < right ? -1 : left > right ? 1 : 0;
733
+ });
734
+ }
735
+
564
736
  // ---------------------------------------------------------------------------
565
737
  // The ledger
566
738
  // ---------------------------------------------------------------------------
@@ -589,7 +761,7 @@ function trimmedAddress(door) {
589
761
  export function buildLedger(input) {
590
762
  /** @type {DoorEntry[]} */
591
763
  const entries = [];
592
- const walks = input.walks.map((walk) => ({ walk, paths: new Set(walk.paths) }));
764
+ const walks = inWalkOrder(input.walks).map((walk) => ({ walk, paths: new Set(walk.paths) }));
593
765
 
594
766
  /** @type {Record<string, KindTally>} */
595
767
  const byKind = {};
@@ -684,7 +856,9 @@ export function buildLedger(input) {
684
856
  const shutDoors = new Map();
685
857
  for (const { walk } of walks) for (const d of walk.knockedShut ?? []) shutDoors.set(d.door, d.status);
686
858
  if (shutDoors.size > 0) {
687
- const listed = [...shutDoors.entries()].slice(0, 6).map(([door, code]) => `${door} answered ${code}`).join(', ');
859
+ // Sorted, so which six get named is decided by their names and not by which capture the
860
+ // disk happened to hand back first. See `inWalkOrder` for why that was ever in doubt.
861
+ const listed = [...shutDoors.entries()].sort(byFirst).slice(0, 6).map(([door, code]) => `${door} answered ${code}`).join(', ');
688
862
  caveats.push(
689
863
  `${shutDoors.size} ${shutDoors.size === 1 ? 'door the code declares was' : 'doors the code declares were'} knocked on and answered as not being there (${listed}${shutDoors.size > 6 ? ', and more' : ''}). Knocking is not walking: nothing has been proved about ${shutDoors.size === 1 ? 'it' : 'them'}, and the source and the build that ran disagree about whether ${shutDoors.size === 1 ? 'it exists' : 'they exist'}.`,
690
864
  );
@@ -695,7 +869,20 @@ export function buildLedger(input) {
695
869
  if (bouncedDoors.size > 0) {
696
870
  const all = bouncedDoors.size >= Math.max(1, opened);
697
871
  caveats.push(
698
- `${bouncedDoors.size} ${bouncedDoors.size === 1 ? 'door' : 'doors'} answered with a redirect rather than with ${bouncedDoors.size === 1 ? 'a page' : 'pages'} — ${[...bouncedDoors.entries()].slice(0, 5).map(([door, code]) => `${door} answered ${code}`).join(', ')}${bouncedDoors.size > 5 ? ', and more' : ''}. What was seen is the bounce, not what is behind it.${all ? ' EVERY door that answered did this, which is what a sign-in wall looks like from out here: this run has not been inside the product at all.' : ''}`,
872
+ `${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
+ );
874
+ }
875
+ // A door whose journey was refused before anything ran. Nobody knocked on it, so it is not
876
+ // shut and it is not bounced — it is untouched, and the only wrong answer is to leave it
877
+ // out. This says the number out loud so nobody has to notice a door that quietly stopped
878
+ // being counted as walked.
879
+ /** @type {Set<string>} */
880
+ const untriedDoors = new Set();
881
+ for (const { walk } of walks) for (const door of walk.notTried ?? []) untriedDoors.add(door);
882
+ if (untriedDoors.size > 0) {
883
+ const listed = [...untriedDoors].sort().slice(0, 6).join(', ');
884
+ 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.`,
699
886
  );
700
887
  }
701
888
  if (input.doors.length === 0) {
@@ -719,8 +906,11 @@ export function buildLedger(input) {
719
906
  entries,
720
907
  byKind,
721
908
  journeys: new Set(walks.map(({ walk }) => walk.journey)).size,
722
- byJourneySource,
723
- byChannel: input.byChannel ?? {},
909
+ // Both of these are printed by `--json`, and JSON keeps the order keys were added in.
910
+ // Added in the order the walks arrived, they came out shuffled between two identical
911
+ // runs — see `inWalkOrder`. Written out in an order of their own, they do not.
912
+ byJourneySource: Object.fromEntries(Object.entries(byJourneySource).sort(byFirst)),
913
+ byChannel: inChannelOrder(input.byChannel ?? {}),
724
914
  captures: input.captures ?? walks.length,
725
915
  builds: input.builds ?? 0,
726
916
  caveats,
@@ -883,7 +1073,11 @@ export async function ledger(store, product, opts = {}) {
883
1073
  captures,
884
1074
  builds: wanted.length,
885
1075
  caveats,
886
- gaps: dedupeGaps(holes),
1076
+ // These holes were collected build by build and capture by capture, in the order the
1077
+ // store listed them — which is the order the random end of a capture id put them in.
1078
+ // Nothing about one unreadable record makes it more urgent than another, so they go in
1079
+ // an order of their own and two identical runs list them the same way round.
1080
+ gaps: inGapOrder(dedupeGaps(holes)),
887
1081
  });
888
1082
  }
889
1083
 
@@ -975,7 +1169,13 @@ export function gaps(led, opts = {}) {
975
1169
  });
976
1170
  }
977
1171
 
978
- const ranked = jobs.sort((a, b) => (b.rank === a.rank ? b.doors - a.doors : b.rank - a.rank));
1172
+ // Whole families tie here routinely ten folders of four unopened exports each score the
1173
+ // same and hold the same number of doors — and a tie used to be settled by whichever came
1174
+ // out of the map first. The family's own name settles it instead, so the list this file
1175
+ // hands back is the same list every time and the cut at the end falls in the same place.
1176
+ const ranked = jobs.sort(
1177
+ (a, b) => b.rank - a.rank || b.doors - a.doors || (a.group < b.group ? -1 : a.group > b.group ? 1 : 0),
1178
+ );
979
1179
  if (ranked.length <= worst) return ranked;
980
1180
 
981
1181
  // The cut is real and it used to be invisible. `toCoverage` asks for eight jobs; a product
package/src/v2/detect.js CHANGED
@@ -140,6 +140,24 @@ const PLATFORM_FOLDERS = [
140
140
  /** How many files the artifact and test sweeps will look at before giving up and saying so. */
141
141
  const MOST_FILES = 20_000;
142
142
 
143
+ /**
144
+ * The folders a project's own code normally lives in. The same list the source reader falls
145
+ * back to, kept here so this file can say WHICH folders it asked for instead of leaving the
146
+ * reader to guess — see {@link whereTheCodeIs} for why that mattered.
147
+ */
148
+ const USUAL_SOURCE_FOLDERS = ['src', 'lib', 'app', 'bin', 'server', 'pages', 'api', 'electron', 'main', 'packages'];
149
+
150
+ /**
151
+ * Root-level files that are how a project is BUILT rather than what it ships. A packaging
152
+ * config sitting beside `src/` is no reason to read the whole repository; a `server.js`
153
+ * sitting beside `src/` is every reason.
154
+ */
155
+ const ROOT_TOOLING_FILES = new Set([
156
+ 'gulpfile.js', 'gulpfile.mjs', 'gulpfile.cjs', 'gruntfile.js', 'karma.conf.js',
157
+ 'protractor.conf.js', 'gatsby-config.js', 'gatsby-node.js', 'gatsby-browser.js',
158
+ 'gatsby-ssr.js', 'webpack.mix.js',
159
+ ]);
160
+
143
161
  /**
144
162
  * Folders that are never a product of their own: either the contract channel already read
145
163
  * them as part of the root product, or they hold work about the project rather than the
@@ -278,7 +296,11 @@ export async function detectProject(options = {}) {
278
296
  // The source read, once, for two answers — how many doors there are, and what the routes
279
297
  // are called. Reading Terminal Deck's 1,416 files twice because two functions each wanted
280
298
  // their own copy cost a second and a half of the two this whole detection takes.
281
- const reading = readCode ? await readTheSource(root) : { doors: notRead(), routes: [], channels: [], envNames: [] };
299
+ // The folders are named here rather than left to the reader's own default, because the
300
+ // default reads `src/` and its cousins and NOTHING at the top level — see
301
+ // {@link whereTheCodeIs} for the server that went unread because of it.
302
+ const firstFolders = whereTheCodeIs(listing);
303
+ const reading = readCode ? await readTheSource(root, firstFolders) : { doors: notRead(), routes: [], channels: [], envNames: [] };
282
304
  const doors = reading.doors;
283
305
  const routes = reading.routes;
284
306
  const channels = reading.channels;
@@ -323,7 +345,7 @@ export async function detectProject(options = {}) {
323
345
  root, where: place.root, listing: local, pkg: place.pkg,
324
346
  // Doors and pages were read from the root, so they only describe the root. A
325
347
  // sub-package gets credited with them only when it IS the root.
326
- doors: place.root === '.' ? doors : notRead(),
348
+ doors: place.root === '.' ? theRootsOwnDoors(doors, routes, members) : notRead(),
327
349
  pages: place.root === '.' ? pages : [],
328
350
  containers: place.root === '.' ? containers : { dockerfile: null, compose: null },
329
351
  scripts: place.pkg?.scripts ?? {},
@@ -354,6 +376,12 @@ export async function detectProject(options = {}) {
354
376
  for (const folder of listing.dirs) {
355
377
  if (SKIP_DIRS.has(folder) || folder.startsWith('.') || claimed.has(folder)) continue;
356
378
  if (members.some((m) => m.root === folder)) continue;
379
+ // Nor is a folder whose whole contents are already accounted for one level down. A
380
+ // monorepo's `apps/` is a shelf: every product in it was found, named and is being
381
+ // checked, and this warning would say the opposite in the plainest words on the page —
382
+ // "nothing in it is being checked" — about the two products directly above it.
383
+ const alreadyFound = (/** @type {string} */ p) => p.startsWith(`${folder}/`);
384
+ if (merged.some((p) => alreadyFound(p.where)) || members.some((m) => alreadyFound(m.root))) continue;
357
385
  // The root product's own source is not an unclaimed folder. These are the folders the
358
386
  // contract channel already read, plus the ones that are never a product on their own.
359
387
  if (ALREADY_COVERED.has(folder)) continue;
@@ -387,7 +415,7 @@ export async function detectProject(options = {}) {
387
415
  languages: await languagesIn(root),
388
416
  tests,
389
417
  scripts: scriptsOf(pkg?.scripts ?? {}),
390
- ...(await theSourceAgain({ root, readCode, merged, listing, first: { doors, routes, channels, envNames } })),
418
+ ...(await theSourceAgain({ root, readCode, merged, listing, firstFolders, first: { doors, routes, channels, envNames } })),
391
419
  bulk: await measureBulk(root),
392
420
  pages,
393
421
  containers,
@@ -953,6 +981,94 @@ async function findMembers(root, globs, listing) {
953
981
  return members;
954
982
  }
955
983
 
984
+ /**
985
+ * The doors that are the ROOT'S, once the ones belonging to sub-packages are handed back.
986
+ *
987
+ * The source is read once, from the top, which is what keeps this fast — but the routes it
988
+ * comes back with are the whole repository's, and the root is then judged on them. In a
989
+ * workspaces monorepo that made the root itself "the server", off the strength of routes
990
+ * written in `packages/api`: a shelf holding two packages, reported as a third product that
991
+ * ships nothing. `packages/api` was already in the list, correctly, one line above it.
992
+ *
993
+ * Only a clean sweep counts. The moment ONE route was read outside every member, the root has
994
+ * routes of its own and keeps the full count — losing a real server is far worse than listing
995
+ * a doubtful one, so the doubt goes that way. The route list is capped at 200 names, so this
996
+ * is a sample rather than a census on a repository with more than that; a root with routes of
997
+ * its own would have to contribute none of the first 200 to be missed, and its framework
998
+ * dependency or its own `server.js` says it is a server anyway.
999
+ *
1000
+ * @param {ProjectShape['doors']} doors
1001
+ * @param {ProjectShape['routes']} routes
1002
+ * @param {{name: string, root: string}[]} members
1003
+ * @returns {ProjectShape['doors']}
1004
+ */
1005
+ function theRootsOwnDoors(doors, routes, members) {
1006
+ if (members.length === 0 || routes.length === 0 || doors.route === 0) return doors;
1007
+ const slashed = (/** @type {string} */ p) => p.split(path.sep).join('/');
1008
+ const theirs = members.map((m) => slashed(m.root));
1009
+ const inAMember = (/** @type {string} */ file) =>
1010
+ theirs.some((their) => slashed(file).startsWith(`${their}/`));
1011
+ if (!routes.every((one) => inAMember(one.file))) return doors;
1012
+ return { ...doors, route: 0 };
1013
+ }
1014
+
1015
+ /**
1016
+ * Does the project keep code of its own at the top level, outside every folder below it?
1017
+ *
1018
+ * A repository whose whole server is one `server.js` beside `package.json` is completely
1019
+ * normal, and it is the shape this file used to go blind on the moment somebody added a
1020
+ * `src/` folder for something else.
1021
+ *
1022
+ * Only files that are the PRODUCT count. A build config, a declaration file and a test all
1023
+ * sit at the top level of nearly every repository, and treating any of them as "the project
1024
+ * keeps code up here" would make this true everywhere and so worth nothing.
1025
+ *
1026
+ * @param {{files: string[], dirs: string[]}} listing
1027
+ * @returns {boolean}
1028
+ */
1029
+ function rootHoldsItsOwnCode(listing) {
1030
+ return listing.files.some((name) => {
1031
+ if (!/\.[cm]?[jt]sx?$/.test(name)) return false;
1032
+ if (name.startsWith('.')) return false;
1033
+ if (/\.d\.[cm]?ts$/.test(name)) return false; // a declaration describes, it opens nothing
1034
+ if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(name)) return false;
1035
+ if (/\.(config|conf)\.[cm]?[jt]sx?$/.test(name)) return false;
1036
+ return !ROOT_TOOLING_FILES.has(name.toLowerCase());
1037
+ });
1038
+ }
1039
+
1040
+ /**
1041
+ * Which folders to hand the source reader.
1042
+ *
1043
+ * THE FAILURE THIS EXISTS TO STOP, and it is the worst kind this tool has. The reader is
1044
+ * pointed at a list of folders, and it reads THOSE and nothing else. So a project with its
1045
+ * server in `server.js` at the top level and a `src/` folder holding anything at all had its
1046
+ * server never opened: four routes read as zero, and every later question about them answered
1047
+ * "nothing that worked has changed". A deleted route and a route that started returning 500
1048
+ * both came back clean, exit 0. Measured on a four-route express server: 4 routes with no
1049
+ * `src/` folder, 0 routes the moment an unrelated `src/` folder existed beside it.
1050
+ *
1051
+ * The reader can only be aimed at folders, never at single files, and the top-level files sit
1052
+ * outside every folder there is. So when the project keeps code up there, the answer is the
1053
+ * whole project — which is exactly what the reader already does for a project that has no
1054
+ * `src/` at all. The two cases now behave the same way instead of one of them going silent.
1055
+ *
1056
+ * The cost of that is a wider read: an `examples/` folder gets opened too, and a route written
1057
+ * in an example is counted. That is the right direction to be wrong in. An extra route makes a
1058
+ * check ask for an address that answers 404 both times, which changes nothing and alarms
1059
+ * nobody; a MISSING route makes the tool say "nothing that worked has changed" about a product
1060
+ * whose orders endpoint has gone. Aiming the reader at single files would fix both, and that
1061
+ * lives in `collectFiles` in the source adapter rather than here.
1062
+ *
1063
+ * @param {{files: string[], dirs: string[]}} listing
1064
+ * @returns {string[]} Folders to read. Empty means the reader falls back to the whole
1065
+ * project, which is its own long-standing behaviour.
1066
+ */
1067
+ function whereTheCodeIs(listing) {
1068
+ if (rootHoldsItsOwnCode(listing)) return ['.'];
1069
+ return USUAL_SOURCE_FOLDERS.filter((name) => listing.dirs.includes(name));
1070
+ }
1071
+
956
1072
  /**
957
1073
  * Every door in the source, counted AND named, using the same reader the contract channel
958
1074
  * uses so the number here and the number in a check can never disagree.
@@ -1951,6 +2067,40 @@ async function foreignServerIn(dir, sources, language) {
1951
2067
  return { yes: false, file: null, framework: null, readsPort: false };
1952
2068
  }
1953
2069
 
2070
+ /**
2071
+ * Does this file belong to a package sitting INSIDE the folder being looked at?
2072
+ *
2073
+ * THE FAILURE THIS EXISTS TO STOP. Both server readings below open files three folders deep,
2074
+ * which is right for a product folder and wrong for a shelf. In a workspaces monorepo it made
2075
+ * `packages/api/src/server.js` count as evidence about `packages/` itself, and a folder that
2076
+ * ships nothing at all was announced as "the server in packages/" with 0.8 confidence — a
2077
+ * product that does not exist, sitting in the list beside four that do. The same file had
2078
+ * already been read correctly one folder down, where it actually lives.
2079
+ *
2080
+ * A `package.json` on the way down is the line. Everything below it is that package's, and
2081
+ * that package is looked at in its own right.
2082
+ *
2083
+ * @param {string} dir The folder being asked about.
2084
+ * @param {string} rel A file inside it, relative to it.
2085
+ * @param {Map<string, boolean>} seen Answers already worked out, so one walk costs one look.
2086
+ * @returns {boolean}
2087
+ */
2088
+ function insideAnotherPackage(dir, rel, seen) {
2089
+ const parts = rel.split(path.sep);
2090
+ parts.pop(); // the filename itself is never a folder
2091
+ let sofar = '';
2092
+ for (const part of parts) {
2093
+ sofar = sofar ? path.join(sofar, part) : part;
2094
+ let itsOwn = seen.get(sofar);
2095
+ if (itsOwn === undefined) {
2096
+ itsOwn = fs.existsSync(path.join(dir, sofar, 'package.json'));
2097
+ seen.set(sofar, itsOwn);
2098
+ }
2099
+ if (itsOwn) return true;
2100
+ }
2101
+ return false;
2102
+ }
2103
+
1954
2104
  /**
1955
2105
  * Does a folder hold a server somebody wrote by hand, on node's own http module?
1956
2106
  *
@@ -1969,8 +2119,13 @@ async function foreignServerIn(dir, sources, language) {
1969
2119
  */
1970
2120
  async function handWrittenServerIn(dir) {
1971
2121
  const { files } = await readSome(dir, { most: 80, depth: 3 });
2122
+ /** @type {Map<string, boolean>} */
2123
+ const packagesInside = new Map();
1972
2124
  for (const one of files) {
1973
2125
  const where = one.rel.split(path.sep).join('/');
2126
+ // Somebody else's package, read on the way past. It is a product in its own right and is
2127
+ // found as one; borrowing its server for the folder above invents a second product.
2128
+ if (insideAnotherPackage(dir, one.rel, packagesInside)) continue;
1974
2129
  // A server standing in a fixtures folder is a prop for somebody's test, not the product.
1975
2130
  // This tool's own repository has one, and without this line it reported ITSELF as a
1976
2131
  // server — which is exactly the kind of confident wrong answer that gets a tool switched
@@ -2007,8 +2162,13 @@ async function handWrittenServerIn(dir) {
2007
2162
  */
2008
2163
  async function looksLikeAServer(dir) {
2009
2164
  const { files } = await readSome(dir, { most: 60, depth: 3 });
2165
+ /** @type {Map<string, boolean>} */
2166
+ const packagesInside = new Map();
2010
2167
  for (const one of files) {
2011
2168
  if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(one.rel)) continue;
2169
+ // The same line as above: a socket opened inside a package of its own says nothing about
2170
+ // the folder that package happens to sit in. `apps/` is not a server because `apps/api` is.
2171
+ if (insideAnotherPackage(dir, one.rel, packagesInside)) continue;
2012
2172
  const listens = /\.listen\s*\(|createServer\s*\(|Deno\.serve\s*\(|Bun\.serve\s*\(|serve\s*\(\s*\{[^}]*port/.test(one.text);
2013
2173
  if (!listens) continue;
2014
2174
  const readsPort = /process\.env\.PORT|Deno\.env\.get\(\s*['"]PORT|env\.PORT/.test(one.text);
@@ -2291,7 +2451,13 @@ function startCommandFor(input) {
2291
2451
  // build. It takes the port as a flag, so nothing has to be downloaded to serve the files.
2292
2452
  if (build && script('preview') && (has('vite') || has('astro') || has('@sveltejs/kit'))) {
2293
2453
  return {
2294
- command: `${build} && ${script('preview')} -- --port $PORT --strictPort`,
2454
+ // `--host 127.0.0.1` is not decoration. Measured on 2026-08-31 on an app scaffolded a
2455
+ // minute earlier with `npm create vite@latest -- --template react-ts`: Vite ignores both
2456
+ // the PORT and the HOST it is handed in the environment and binds the NAME `localhost`,
2457
+ // which macOS resolves to the IPv6 loopback — so the site came up on `[::1]` and nothing
2458
+ // whatever was listening on `127.0.0.1`. Naming the address makes the command land where
2459
+ // the settings say it will, instead of wherever name resolution happens to put it.
2460
+ command: `${build} && ${script('preview')} -- --port $PORT --strictPort --host 127.0.0.1`,
2295
2461
  kind: 'build-and-serve',
2296
2462
  why: 'It is built, and then the build is served by the tool that made it. That is what ships — a dev server serves unbundled source with a live-reload connection in every page, which is a second thing moving under the comparison.',
2297
2463
  };
@@ -2505,10 +2671,15 @@ function inGigabytes(bytes) {
2505
2671
  * @returns {Promise<string[]>}
2506
2672
  */
2507
2673
  async function proposeSourceFolders(root, products, listing) {
2674
+ // Code at the top level belongs to no folder, and the reader only takes folders. So the
2675
+ // settings this writes have to say "the whole project" rather than name a folder that would
2676
+ // leave the project's own `server.js` unopened for good — the same silence
2677
+ // {@link whereTheCodeIs} exists to stop, except written down and kept.
2678
+ if (rootHoldsItsOwnCode(listing)) return ['.'];
2679
+
2508
2680
  /** @type {Set<string>} */
2509
2681
  const folders = new Set();
2510
- const usual = ['src', 'lib', 'app', 'bin', 'server', 'pages', 'api', 'electron', 'main', 'packages'];
2511
- for (const name of usual) if (listing.dirs.includes(name)) folders.add(name);
2682
+ for (const name of USUAL_SOURCE_FOLDERS) if (listing.dirs.includes(name)) folders.add(name);
2512
2683
 
2513
2684
  for (const product of products) {
2514
2685
  if (product.where === '.' || product.where === '') continue;
@@ -2772,14 +2943,16 @@ function plainly(items) {
2772
2943
  * @param {boolean} input.readCode
2773
2944
  * @param {Product[]} input.merged
2774
2945
  * @param {{files: string[], dirs: string[]}} input.listing
2946
+ * @param {string[]} input.firstFolders The folders the first read was actually given. Worked
2947
+ * out again from the usual list, this said "src was read" about a run that had in fact been
2948
+ * pointed at the whole project, and the whole project then got read a second time for nothing.
2775
2949
  * @param {{doors: ProjectShape['doors'], routes: ProjectShape['routes'], channels: ProjectShape['channels'], envNames: ProjectShape['envNames']}} input.first
2776
2950
  * @returns {Promise<{doors: ProjectShape['doors'], routes: ProjectShape['routes'], channels: ProjectShape['channels'], envNames: ProjectShape['envNames'], sourceFolders: string[]}>}
2777
2951
  */
2778
2952
  async function theSourceAgain(input) {
2779
- const { root, readCode, merged, listing, first } = input;
2953
+ const { root, readCode, merged, listing, firstFolders, first } = input;
2780
2954
  const sourceFolders = await proposeSourceFolders(root, merged, listing);
2781
- const usual = ['src', 'lib', 'app', 'bin', 'server', 'pages', 'api', 'electron', 'main', 'packages'];
2782
- const alreadyRead = new Set(usual.filter((name) => listing.dirs.includes(name)));
2955
+ const alreadyRead = new Set(firstFolders);
2783
2956
  const missed = sourceFolders.filter((folder) => !alreadyRead.has(folder));
2784
2957
  if (!readCode || missed.length === 0) return { ...first, sourceFolders };
2785
2958