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.
- package/CHANGELOG.md +125 -0
- package/README.md +17 -5
- package/docs/getting-started.md +10 -0
- package/docs/how-v2-works.md +5 -2
- package/package.json +2 -2
- package/src/guard/api.js +107 -3
- package/src/guard/run.js +154 -20
- package/src/report/console.js +235 -17
- package/src/report/html.js +75 -19
- package/src/types.js +5 -0
- package/src/v2/adapters/android-driver.js +62 -12
- package/src/v2/adapters/contract.js +18 -4
- package/src/v2/adapters/electron.js +96 -14
- package/src/v2/adapters/http.js +264 -23
- package/src/v2/adapters/ios-driver.js +22 -4
- package/src/v2/adapters/ios.js +5 -2
- package/src/v2/adapters/isolate.js +78 -5
- package/src/v2/adapters/process.js +350 -92
- package/src/v2/adapters/web-driver.js +23 -1
- package/src/v2/adapters/web.js +42 -3
- package/src/v2/adapters/windows.js +32 -15
- package/src/v2/check.js +319 -9
- package/src/v2/cli.js +345 -3
- package/src/v2/cluster.js +112 -4
- package/src/v2/coverage.js +208 -8
- package/src/v2/detect.js +182 -9
- package/src/v2/doctor.js +168 -30
- package/src/v2/init.js +88 -10
- package/src/v2/mcp/server.js +4 -1
- package/src/v2/mcp/tools.js +291 -24
- package/src/v2/observation.js +57 -5
- package/src/v2/reference.js +133 -14
- package/src/v2/refusal.js +389 -0
- package/src/v2/remote.js +24 -3
- package/src/v2/run.js +306 -16
- package/src/v2/sealed.js +14 -2
- package/src/v2/ship.js +286 -22
- package/src/v2/store.js +101 -2
- package/src/v2/types.js +5 -0
- package/src/v2/waiver.js +9 -2
- package/src/watch/panel.js +12 -1
package/src/v2/coverage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
723
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|