staysfixed 0.7.1 → 0.8.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/README.md +193 -55
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +18 -5
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +549 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/watch-flags.js +54 -0
  15. package/src/core/config.js +23 -3
  16. package/src/guard/run.js +49 -1
  17. package/src/report/console.js +15 -2
  18. package/src/v2/adapters/android-driver.js +6 -1
  19. package/src/v2/adapters/android.js +97 -2
  20. package/src/v2/adapters/contract.js +42 -5
  21. package/src/v2/adapters/electron.js +72 -6
  22. package/src/v2/adapters/http.js +11 -2
  23. package/src/v2/adapters/ios-driver.js +64 -14
  24. package/src/v2/adapters/ios.js +247 -25
  25. package/src/v2/adapters/process.js +728 -66
  26. package/src/v2/adapters/python.js +495 -0
  27. package/src/v2/adapters/source.js +373 -18
  28. package/src/v2/adapters/web-driver.js +94 -24
  29. package/src/v2/adapters/web.js +142 -9
  30. package/src/v2/adapters/windows.js +18 -1
  31. package/src/v2/browsers.js +9 -1
  32. package/src/v2/cause.js +61 -17
  33. package/src/v2/check.js +530 -66
  34. package/src/v2/ci.js +130 -35
  35. package/src/v2/cli.js +42 -24
  36. package/src/v2/cluster.js +164 -13
  37. package/src/v2/coverage.js +43 -176
  38. package/src/v2/detect.js +308 -60
  39. package/src/v2/doctor.js +345 -47
  40. package/src/v2/init.js +162 -61
  41. package/src/v2/intent.js +9 -23
  42. package/src/v2/journeys/from-suite.js +336 -30
  43. package/src/v2/journeys/index.js +99 -6
  44. package/src/v2/mcp/tools.js +10 -11
  45. package/src/v2/normalise.js +169 -23
  46. package/src/v2/observation.js +19 -33
  47. package/src/v2/rank.js +216 -23
  48. package/src/v2/reference.js +40 -10
  49. package/src/v2/remote.js +113 -18
  50. package/src/v2/run.js +103 -14
  51. package/src/v2/sealed.js +0 -20
  52. package/src/v2/selfcheck.js +190 -13
  53. package/src/v2/ship.js +29 -5
  54. package/src/v2/store.js +67 -1
  55. package/src/v2/types.js +12 -2
  56. package/src/v2/waiver.js +64 -54
  57. package/src/v2/watch/events.js +60 -215
  58. package/src/v2/watch/focus.js +14 -4
  59. package/src/v2/watch/panel.js +167 -17
@@ -31,6 +31,7 @@ import fsp from 'node:fs/promises';
31
31
  import path from 'node:path';
32
32
  import nodeModule from 'node:module';
33
33
  import { defineAdapter, joinPath, notCovered, observation } from './contract.js';
34
+ import { readPythonRoutes } from './python.js';
34
35
 
35
36
  // ---------------------------------------------------------------------------
36
37
  // The lexer
@@ -318,6 +319,19 @@ const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', '
318
319
  */
319
320
  const ROUTER_NAMES = new Set(['app', 'router', 'server', 'fastify', 'api', 'routes']);
320
321
 
322
+ /**
323
+ * The two names a hand-written request handler almost always gives its arguments. A server
324
+ * written straight on `node:http` registers nothing — there is no `app.get` to find — so the
325
+ * only thing that says "this function is where requests arrive" is the shape of its
326
+ * parameters. Both halves have to match before either name is believed, because a lone
327
+ * `req` could be anything and a function taking `(req, res)` could not.
328
+ */
329
+ const REQUEST_PARAM_NAMES = new Set(['req', 'request', 'incoming']);
330
+ const RESPONSE_PARAM_NAMES = new Set(['res', 'response', 'reply']);
331
+
332
+ /** What a route reads as when the code never checks which verb it is. */
333
+ const METHOD_UNKNOWN = 'ANY';
334
+
321
335
  /** @param {string} file */
322
336
  export function looksLikeATest(file) {
323
337
  const normalised = file.split(path.sep).join('/');
@@ -524,6 +538,13 @@ export function readFile(relFile, text) {
524
538
  }
525
539
  }
526
540
 
541
+ // A server written straight on node:http registers nothing. There is no `app.get` for the
542
+ // sweep above to find, so every route in it was invisible — and a repository whose entire
543
+ // HTTP surface is invisible was then reported as covered in full, which is the worst
544
+ // sentence this tool can say. In a hand-written server the routes live in the comparisons
545
+ // instead, and that is what this reads.
546
+ doors.push(...handWrittenRoutes(tokens, relFile, inTest, fromConstant));
547
+
527
548
  return { doors, constants: exportedConstants, recoveries, typesStripped };
528
549
  }
529
550
 
@@ -558,6 +579,342 @@ function isRouterFactory(tokens, at) {
558
579
  return tokens[at + 1]?.v === '.' && /^(Router|createServer)$/.test(tokens[at + 2]?.v ?? '');
559
580
  }
560
581
 
582
+ // ---------------------------------------------------------------------------
583
+ // Servers written by hand, with no framework under them
584
+ // ---------------------------------------------------------------------------
585
+
586
+ /**
587
+ * A path a hand-written server would really answer on.
588
+ *
589
+ * The comparison this came from is already strong evidence — the left-hand side had to be
590
+ * proven to hold the request's own address before we got here — so this only has to throw
591
+ * out the shapes no route ever has. It deliberately does NOT reject a path because it ends
592
+ * in something that looks like a file: `/favicon.ico` and `/api/data.json` are real routes,
593
+ * and a reader that quietly dropped them would be hiding doors to look tidy.
594
+ *
595
+ * @param {Token|undefined} token
596
+ * @returns {string|null}
597
+ */
598
+ function routePathIn(token) {
599
+ if (!token) return null;
600
+ if (token.t !== 'string' && token.t !== 'template') return null;
601
+ if (token.built) return null;
602
+ const value = token.v;
603
+ if (!value.startsWith('/')) return null;
604
+ if (value.length > 120) return null;
605
+ if (/[\\<>{}()\s]/.test(value)) return null;
606
+ return value;
607
+ }
608
+
609
+ /**
610
+ * The first parameter of the function written at `at`, when one is written there.
611
+ *
612
+ * Only a function literal counts. `createServer(handler)` names a function defined
613
+ * somewhere else, and guessing what that one calls its arguments would be inventing a fact —
614
+ * the `(req, res)` rule below is what covers that case, and it needs both names to agree
615
+ * before it believes either.
616
+ *
617
+ * @param {Token[]} tokens
618
+ * @param {number} at
619
+ * @returns {string|null}
620
+ */
621
+ function firstParameterAt(tokens, at) {
622
+ let i = at;
623
+ while (tokens[i]?.t === 'name' && (tokens[i].v === 'async' || tokens[i].v === 'function')) i++;
624
+ if (tokens[i]?.v === '*') i++;
625
+ // A named function expression: skip its name, but only when a parameter list follows.
626
+ if (tokens[i]?.t === 'name' && tokens[i + 1]?.v === '(' && tokens[at]?.v !== tokens[i]?.v) i++;
627
+ const first = tokens[i];
628
+ if (!first) return null;
629
+ if (first.t === 'name' && tokens[i + 1]?.v === '=>') return first.v;
630
+ if (first.v !== '(') return null;
631
+ const name = tokens[i + 1];
632
+ if (name?.t !== 'name') return null;
633
+ const close = matchBracket(tokens, i);
634
+ const arrow = tokens[close + 1]?.v === '=>';
635
+ const wasFunction = tokens[at]?.v === 'function' || tokens[at]?.v === 'async';
636
+ if (!arrow && !wasFunction) return null;
637
+ return name.v;
638
+ }
639
+
640
+ /**
641
+ * Every name in this file that holds an arriving request.
642
+ *
643
+ * Two ways in, and both need proof. A function handed straight to `createServer` is a
644
+ * request handler by definition, whatever it calls its arguments. Everything else has to be
645
+ * a function taking `(req, res)` — BOTH halves conventional — because a lone `req` could be
646
+ * anything at all, and a pair could not be.
647
+ *
648
+ * @param {Token[]} tokens
649
+ * @returns {Set<string>}
650
+ */
651
+ function requestNamesIn(tokens) {
652
+ /** @type {Set<string>} */
653
+ const names = new Set();
654
+
655
+ for (let i = 0; i < tokens.length; i++) {
656
+ const t = tokens[i];
657
+ if (t.t !== 'name') continue;
658
+ let callbackAt = -1;
659
+ if (t.v === 'createServer' && tokens[i + 1]?.v === '(') callbackAt = i + 2;
660
+ else if (
661
+ t.v === 'on' && tokens[i - 1]?.v === '.' && tokens[i + 1]?.v === '('
662
+ && tokens[i + 2]?.t === 'string' && tokens[i + 2].v === 'request' && tokens[i + 3]?.v === ','
663
+ ) callbackAt = i + 4;
664
+ if (callbackAt === -1) continue;
665
+ const found = firstParameterAt(tokens, callbackAt);
666
+ if (found) names.add(found);
667
+ }
668
+
669
+ for (let i = 0; i + 4 < tokens.length; i++) {
670
+ if (tokens[i].v !== '(') continue;
671
+ const [a, comma, b, close] = [tokens[i + 1], tokens[i + 2], tokens[i + 3], tokens[i + 4]];
672
+ if (a?.t !== 'name' || comma?.v !== ',' || b?.t !== 'name' || close?.v !== ')') continue;
673
+ if (REQUEST_PARAM_NAMES.has(a.v) && RESPONSE_PARAM_NAMES.has(b.v)) names.add(a.v);
674
+ }
675
+
676
+ return names;
677
+ }
678
+
679
+ /**
680
+ * The bracket closing `new URL(<the request address>, …)` when that is what sits at `at`.
681
+ * @param {Token[]} tokens
682
+ * @param {number} at
683
+ * @param {(i: number) => number} pathEnd
684
+ * @returns {number}
685
+ */
686
+ function urlBuiltFromRequest(tokens, at, pathEnd) {
687
+ if (tokens[at]?.v !== 'new' || tokens[at + 1]?.v !== 'URL' || tokens[at + 2]?.v !== '(') return -1;
688
+ if (pathEnd(at + 3) < 0) return -1;
689
+ return matchBracket(tokens, at + 2);
690
+ }
691
+
692
+ /**
693
+ * Every name in this file that holds the address being asked for, and every name that holds
694
+ * a parsed URL it was built from.
695
+ *
696
+ * Run to a standstill rather than once, because `const p = req.url` and
697
+ * `const q = p.split('?')[0]` is two hops and reading only the first hop loses the routes
698
+ * that hang off the second.
699
+ *
700
+ * @param {Token[]} tokens
701
+ * @param {Set<string>} requestNames
702
+ * @returns {{pathNames: Set<string>, urlNames: Set<string>}}
703
+ */
704
+ function pathNamesIn(tokens, requestNames) {
705
+ /** @type {Set<string>} */
706
+ const pathNames = new Set();
707
+ /** @type {Set<string>} */
708
+ const urlNames = new Set();
709
+ /** @param {number} i */
710
+ const pathEnd = (i) => pathExpressionEnd(tokens, i, requestNames, pathNames, urlNames);
711
+
712
+ for (let round = 0; round < 4; round++) {
713
+ const before = pathNames.size + urlNames.size;
714
+ for (let i = 0; i + 3 < tokens.length; i++) {
715
+ const t = tokens[i];
716
+ if (t.t !== 'name' || (t.v !== 'const' && t.v !== 'let' && t.v !== 'var')) continue;
717
+
718
+ // const { pathname } = new URL(req.url, …)
719
+ if (tokens[i + 1]?.v === '{') {
720
+ const close = matchBracket(tokens, i + 1);
721
+ if (tokens[close + 1]?.v !== '=') continue;
722
+ if (urlBuiltFromRequest(tokens, close + 2, pathEnd) < 0) continue;
723
+ for (let j = i + 2; j < close; j++) {
724
+ if (tokens[j]?.t !== 'name' || tokens[j].v !== 'pathname') continue;
725
+ const renamed = tokens[j + 1]?.v === ':' && tokens[j + 2]?.t === 'name' ? tokens[j + 2].v : null;
726
+ pathNames.add(renamed ?? 'pathname');
727
+ }
728
+ continue;
729
+ }
730
+
731
+ const target = tokens[i + 1];
732
+ if (target.t !== 'name' || tokens[i + 2]?.v !== '=') continue;
733
+ if (pathEnd(i + 3) > 0) { pathNames.add(target.v); continue; }
734
+ if (urlBuiltFromRequest(tokens, i + 3, pathEnd) > 0) urlNames.add(target.v);
735
+ }
736
+ if (pathNames.size + urlNames.size === before) break;
737
+ }
738
+
739
+ return { pathNames, urlNames };
740
+ }
741
+
742
+ /**
743
+ * Where the expression starting at `at` ends, when that expression is the address being
744
+ * asked for. Anything else answers -1.
745
+ *
746
+ * `.split('?')[0]` is allowed on the end because cutting the query string off is the one
747
+ * thing every hand-written server does to the address before comparing it. `.replace(…)` and
748
+ * friends are not, and that is deliberate: they change the value into something we can no
749
+ * longer claim is the route, and inventing a route is worse than missing one.
750
+ *
751
+ * @param {Token[]} tokens
752
+ * @param {number} at
753
+ * @param {Set<string>} requestNames
754
+ * @param {Set<string>} pathNames
755
+ * @param {Set<string>} urlNames
756
+ * @returns {number}
757
+ */
758
+ function pathExpressionEnd(tokens, at, requestNames, pathNames, urlNames) {
759
+ const t = tokens[at];
760
+ if (!t || t.t !== 'name') return -1;
761
+ /** @param {number} i */
762
+ const again = (i) => pathExpressionEnd(tokens, i, requestNames, pathNames, urlNames);
763
+
764
+ let end = -1;
765
+ const dotted = tokens[at + 1]?.v === '.' ? tokens[at + 2]?.v : null;
766
+ if (pathNames.has(t.v) && dotted !== 'pathname') end = at + 1;
767
+ else if (requestNames.has(t.v) && dotted === 'url') end = at + 3;
768
+ else if (urlNames.has(t.v) && dotted === 'pathname') end = at + 3;
769
+ else if (t.v === 'new') {
770
+ const close = urlBuiltFromRequest(tokens, at, again);
771
+ if (close > 0 && tokens[close + 1]?.v === '.' && tokens[close + 2]?.v === 'pathname') end = close + 3;
772
+ } else if (dotted === 'parse' && tokens[at + 3]?.v === '(') {
773
+ // The shape node's own `url` module has had since forever: url.parse(req.url).pathname.
774
+ const close = matchBracket(tokens, at + 3);
775
+ if (again(at + 4) > 0 && tokens[close + 1]?.v === '.' && tokens[close + 2]?.v === 'pathname') end = close + 3;
776
+ }
777
+ if (end < 0) return -1;
778
+
779
+ for (;;) {
780
+ if (tokens[end]?.v !== '.' || tokens[end + 1]?.v !== 'split' || tokens[end + 2]?.v !== '(') break;
781
+ const close = matchBracket(tokens, end + 2);
782
+ const separator = tokens[end + 3];
783
+ const cutsTheQuery = separator?.t === 'string' && (separator.v === '?' || separator.v === '#');
784
+ const takesTheFirst = tokens[close + 1]?.v === '[' && tokens[close + 2]?.v === '0' && tokens[close + 3]?.v === ']';
785
+ if (!cutsTheQuery || !takesTheFirst) break;
786
+ end = close + 4;
787
+ }
788
+ return end;
789
+ }
790
+
791
+ /**
792
+ * The verb this route is guarded by, when the same condition says.
793
+ *
794
+ * A hand-written server that never looks at `req.method` answers a route on every verb, and
795
+ * writing GET on it would be putting a fact in the report that is not in the code. Only what
796
+ * the condition itself says is believed; anything further out reads as unknown, which the
797
+ * rest of the tool already has a word for.
798
+ *
799
+ * @param {Token[]} tokens
800
+ * @param {number} at
801
+ * @param {Set<string>} requestNames
802
+ * @returns {string}
803
+ */
804
+ function methodGuarding(tokens, at, requestNames) {
805
+ let depth = 0;
806
+ let open = -1;
807
+ for (let j = at - 1, steps = 0; j >= 0 && steps < 400; j--, steps++) {
808
+ const t = tokens[j];
809
+ if (t.t !== 'punct') continue;
810
+ if (t.v === ')' || t.v === ']' || t.v === '}') { depth++; continue; }
811
+ if (t.v === '[' || t.v === '{') { if (depth === 0) return METHOD_UNKNOWN; depth--; continue; }
812
+ if (t.v === '(') { if (depth === 0) { open = j; break; } depth--; }
813
+ }
814
+ if (open < 0) return METHOD_UNKNOWN;
815
+ const keyword = tokens[open - 1];
816
+ if (keyword?.t !== 'name' || (keyword.v !== 'if' && keyword.v !== 'while')) return METHOD_UNKNOWN;
817
+
818
+ const close = matchBracket(tokens, open);
819
+ for (let j = open + 1; j + 3 < close; j++) {
820
+ if (tokens[j].t !== 'name' || !requestNames.has(tokens[j].v)) continue;
821
+ if (tokens[j + 1]?.v !== '.' || tokens[j + 2]?.v !== 'method') continue;
822
+ const operator = tokens[j + 3]?.v;
823
+ if (operator !== '===' && operator !== '==') continue;
824
+ const verb = tokens[j + 4];
825
+ if (verb?.t !== 'string' || !/^[a-z]+$/i.test(verb.v)) continue;
826
+ return verb.v.toUpperCase();
827
+ }
828
+ return METHOD_UNKNOWN;
829
+ }
830
+
831
+ /**
832
+ * Read the routes out of a server nobody used a framework to write.
833
+ *
834
+ * The whole thing is gated on there being a proven request handler in the file, so a project
835
+ * with no server in it walks straight back out. That gate is also what keeps the trap out:
836
+ * a string like '/etc/app/config.json' compared against an ordinary variable never reaches
837
+ * here, because the left-hand side was never shown to hold the request's own address.
838
+ *
839
+ * @param {Token[]} tokens
840
+ * @param {string} relFile
841
+ * @param {boolean} inTest
842
+ * @param {(raw: string) => {name: string|Pending, via: string}} fromConstant
843
+ * @returns {RawDoor[]}
844
+ */
845
+ function handWrittenRoutes(tokens, relFile, inTest, fromConstant) {
846
+ const requestNames = requestNamesIn(tokens);
847
+ if (requestNames.size === 0) return [];
848
+ const { pathNames, urlNames } = pathNamesIn(tokens, requestNames);
849
+
850
+ /** @type {RawDoor[]} */
851
+ const doors = [];
852
+ const via = 'a hand-written request handler';
853
+ /** @param {number} i */
854
+ const pathEnd = (i) => pathExpressionEnd(tokens, i, requestNames, pathNames, urlNames);
855
+
856
+ /** @param {Token} token @param {string} method @param {boolean} prefix */
857
+ const take = (token, method, prefix) => {
858
+ const value = routePathIn(token);
859
+ if (value === null) return;
860
+ // A prefix match is not one route, it is a family of them. Written as a changing part it
861
+ // joins the flow that already exists for `/users/:id` — the tool asks for a real value
862
+ // and, until it gets one, says out loud that nothing under here was ever looked at.
863
+ const name = prefix ? `${value.replace(/\/+$/, '')}/:rest` : value;
864
+ doors.push(door('route', name, method, relFile, token.line, inTest, true, via));
865
+ };
866
+
867
+ for (let i = 0; i < tokens.length; i++) {
868
+ // switch (pathname) { case '/a': … }
869
+ if (tokens[i].t === 'name' && tokens[i].v === 'switch' && tokens[i + 1]?.v === '(') {
870
+ const close = matchBracket(tokens, i + 1);
871
+ if (pathEnd(i + 2) !== close || tokens[close + 1]?.v !== '{') continue;
872
+ const endOfBody = matchBracket(tokens, close + 1);
873
+ for (let j = close + 2; j < endOfBody; j++) {
874
+ if (tokens[j].t !== 'name' || tokens[j].v !== 'case') continue;
875
+ const label = tokens[j + 1];
876
+ if (label?.t === 'name') {
877
+ const known = fromConstant(label.v);
878
+ if (typeof known.name === 'string' && known.name.startsWith('/')) {
879
+ doors.push(door('route', known.name, METHOD_UNKNOWN, relFile, label.line, inTest, true, known.via));
880
+ }
881
+ continue;
882
+ }
883
+ take(label, METHOD_UNKNOWN, false);
884
+ }
885
+ i = endOfBody;
886
+ continue;
887
+ }
888
+
889
+ // '/a' === req.url, which reads the same and is written often enough to matter.
890
+ const mirrored = routePathIn(tokens[i]);
891
+ if (mirrored !== null && (tokens[i + 1]?.v === '===' || tokens[i + 1]?.v === '==') && pathEnd(i + 2) > 0) {
892
+ take(tokens[i], methodGuarding(tokens, i, requestNames), false);
893
+ continue;
894
+ }
895
+
896
+ const end = pathEnd(i);
897
+ if (end < 0) continue;
898
+ const operator = tokens[end]?.v;
899
+ if (operator === '===' || operator === '==') {
900
+ const label = tokens[end + 1];
901
+ if (label?.t === 'name') {
902
+ const known = fromConstant(label.v);
903
+ if (typeof known.name === 'string' && known.name.startsWith('/')) {
904
+ doors.push(door('route', known.name, methodGuarding(tokens, i, requestNames), relFile, label.line, inTest, true, known.via));
905
+ }
906
+ } else {
907
+ take(label, methodGuarding(tokens, i, requestNames), false);
908
+ }
909
+ } else if (operator === '.' && tokens[end + 1]?.v === 'startsWith' && tokens[end + 2]?.v === '(') {
910
+ take(tokens[end + 3], methodGuarding(tokens, i, requestNames), true);
911
+ }
912
+ i = end - 1;
913
+ }
914
+
915
+ return doors;
916
+ }
917
+
561
918
  /**
562
919
  * Index of the bracket closing the one at `open`. Returns the end of the token list when
563
920
  * the file is unbalanced, which happens in a file the lexer had to recover inside.
@@ -929,10 +1286,17 @@ async function collectFiles(root, folders, maxFileBytes) {
929
1286
  // ---------------------------------------------------------------------------
930
1287
 
931
1288
  /**
1289
+ * Every route that is not written as a call in JavaScript.
1290
+ *
932
1291
  * Next.js puts its routes in folder names, so no amount of reading calls will find them.
933
1292
  * Both layouts are handled: an app folder, where a `route` file's exported method names are
934
1293
  * the verbs, and a pages/api folder, where the file itself is the route.
935
1294
  *
1295
+ * Python's routes are read here too, and this is the reason they are read HERE rather than
1296
+ * somewhere of their own: five places in this tool ask for routes, and four of them would
1297
+ * have had to be found and changed. A Flask app that had routes in one of them and none in
1298
+ * the others is a worse bug than no Python support at all.
1299
+ *
936
1300
  * A folder that cannot be opened takes every route behind it, so it is named rather than
937
1301
  * skipped. This is the same bug as the one fixed in the file walk on 2026-08-30 — a hole that
938
1302
  * looks exactly like a project with no routes in it — and it was still here in this function.
@@ -1014,6 +1378,10 @@ export async function readFileRoutes(root) {
1014
1378
  });
1015
1379
  }
1016
1380
 
1381
+ const python = await readPythonRoutes(root);
1382
+ doors.push(...python.doors);
1383
+ problems.push(...python.problems);
1384
+
1017
1385
  return { doors, problems };
1018
1386
  }
1019
1387
 
@@ -1183,16 +1551,15 @@ export function surfaceOf(project) {
1183
1551
  return 'library';
1184
1552
  }
1185
1553
 
1186
- /** @type {ContractReading|null} */
1187
- let lastReading = null;
1188
-
1189
1554
  /**
1190
1555
  * The static-contract adapter.
1191
1556
  *
1192
1557
  * It applies to every project, always, because every project has source. It is the one
1193
1558
  * adapter that costs nothing to run and can never break anything, so the engine runs it
1194
- * first and hands its result to the others the HTTP adapter learns its routes from here
1195
- * rather than by crawling a running server.
1559
+ * first. It does NOT hand its reading to the other adapters: the HTTP adapter calls
1560
+ * readContract() itself, which costs a second read and buys not caring what order the
1561
+ * adapters ran in. A shared cache was tried and taken out again, because an adapter that
1562
+ * silently needs another one to have gone first is the bug this tool exists to catch.
1196
1563
  */
1197
1564
  export const sourceAdapter = defineAdapter({
1198
1565
  name: 'source',
@@ -1271,22 +1638,10 @@ export const sourceAdapter = defineAdapter({
1271
1638
  for (const found of reading.doors) {
1272
1639
  reading.report.counts[found.kind] = (reading.report.counts[found.kind] ?? 0) + 1;
1273
1640
  }
1274
- lastReading = reading;
1275
1641
  return contractObservations(reading, journey.name);
1276
1642
  },
1277
1643
 
1278
1644
  async teardown() {
1279
- lastReading = null;
1645
+ // Nothing to tear down. Nothing was started and nothing was written.
1280
1646
  },
1281
1647
  });
1282
-
1283
- /**
1284
- * The last thing the source adapter read.
1285
- *
1286
- * The HTTP adapter uses this to find its routes without crawling. Anything that cannot
1287
- * guarantee it runs after the source adapter should call {@link readContract} itself rather
1288
- * than depend on run order.
1289
- */
1290
- export function lastContractReading() {
1291
- return lastReading;
1292
- }
@@ -67,43 +67,73 @@ import { globToRegExp } from '../../freeze/network.js';
67
67
  */
68
68
 
69
69
  /**
70
- * Find Playwright, in the two places it could honestly be.
70
+ * Find the browser library, in every place it could honestly be.
71
71
  *
72
- * Stays Fixed is installed INTO other people's projects, so "is Playwright here" has two
73
- * different answers: is it beside us, and is it in the project we were pointed at. Both are
74
- * tried, because a project that already drives its own tests with Playwright should not be
75
- * asked to install a second copy.
72
+ * ## Two packages, not one
76
73
  *
77
- * It is loaded with `import()` rather than named at the top of the file on purpose. A tool
78
- * that cannot start at all because an optional browser library is missing is a tool that
79
- * cannot tell you what is missing.
74
+ * `playwright` and `playwright-core` are the same driver. The difference is that the first
75
+ * downloads its own copy of Chromium when it installs about 150MB and the second
76
+ * downloads nothing and expects to be told where a browser already is. This tool depends on
77
+ * `playwright-core` and finds the browser itself, because it already knows how: `browsers.js`
78
+ * locates Chrome for Testing, Chrome, Edge or Chromium on the machine, and deliberately
79
+ * prefers one that is not the browser the person actually uses.
80
+ *
81
+ * Both names are tried, in both places, because a project that already drives its own tests
82
+ * with the full `playwright` should never be asked to install a second copy of the same thing.
83
+ *
84
+ * ## Why it is loaded like this
85
+ *
86
+ * With `import()` rather than named at the top of the file: a tool that cannot start at all
87
+ * because a browser library is missing is a tool that cannot tell you what is missing.
88
+ *
89
+ * ## What went wrong here before, so it does not happen twice
90
+ *
91
+ * `playwright` was removed from this package's dependencies on the grounds that nothing in
92
+ * `src/` imported it. Nothing does — this line does, and a search for a static import cannot
93
+ * see it. The result shipped: 0.7.2 told every agent that asked that web apps and sites could
94
+ * be checked "here and now", and then answered every website check with "Playwright is not
95
+ * installed, so no web page can be opened". A tool that is wrong about its own headline
96
+ * ability is worse than one that lacks it. `test/v2/web-driver.test.js` now holds the
97
+ * dependency in place by name.
80
98
  *
81
99
  * @param {object} [opts]
82
100
  * @param {string} [opts.projectRoot] The project being checked. Looked in second.
83
101
  * @returns {Promise<PlaywrightState>}
84
102
  */
85
103
  export async function loadPlaywright(opts = {}) {
86
- const install = 'npm install --save-dev playwright';
104
+ const install = 'npm install playwright-core';
87
105
  /** @type {any} */
88
106
  let mod = null;
89
107
  /** @type {string|undefined} */
90
108
  let version;
109
+ /** @type {string|null} */
110
+ let loadedName = null;
91
111
 
92
112
  /** @param {any} loaded */
93
113
  const unwrap = (loaded) => (loaded && loaded.chromium ? loaded : (loaded?.default ?? null));
94
114
 
95
- try {
96
- mod = unwrap(await import('playwright'));
97
- } catch {
98
- // Not beside us. Try the project we were pointed at.
115
+ // `playwright` first, because a project that has the full package has a browser downloaded
116
+ // with it, and using that is one less thing to go looking for.
117
+ for (const name of ['playwright', 'playwright-core']) {
118
+ if (mod) break;
119
+ try {
120
+ mod = unwrap(await import(name));
121
+ if (mod) loadedName = name;
122
+ } catch {
123
+ // Not beside us under this name. Try the next, then the project we were pointed at.
124
+ }
99
125
  }
100
126
 
101
127
  if (!mod && opts.projectRoot) {
102
- try {
103
- const require = createRequire(path.join(opts.projectRoot, 'package.json'));
104
- mod = unwrap(await import(require.resolve('playwright')));
105
- } catch {
106
- // Not there either. That is an answer, and it is reported as one.
128
+ for (const name of ['playwright', 'playwright-core']) {
129
+ if (mod) break;
130
+ try {
131
+ const require = createRequire(path.join(opts.projectRoot, 'package.json'));
132
+ mod = unwrap(await import(require.resolve(name)));
133
+ if (mod) loadedName = name;
134
+ } catch {
135
+ // Not there either. That is an answer, and it is reported as one.
136
+ }
107
137
  }
108
138
  }
109
139
 
@@ -111,14 +141,14 @@ export async function loadPlaywright(opts = {}) {
111
141
  return {
112
142
  ok: false,
113
143
  state: 'no package',
114
- why: 'Playwright is not installed, so no web page can be opened. Everything read out of the source still works; nothing that needs a browser does.',
144
+ why: 'The browser driver is not installed, so no web page can be opened. Everything read out of the source still works; nothing that needs a browser does.',
115
145
  howToGet: install,
116
146
  };
117
147
  }
118
148
 
119
149
  try {
120
150
  const require = createRequire(import.meta.url);
121
- version = String(require('playwright/package.json').version);
151
+ version = String(require(`${loadedName ?? 'playwright-core'}/package.json`).version);
122
152
  } catch {
123
153
  // A version we cannot read is not a reason to refuse to run.
124
154
  }
@@ -131,14 +161,40 @@ export async function loadPlaywright(opts = {}) {
131
161
  executable = undefined;
132
162
  }
133
163
 
134
- const there = Boolean(executable) && (await exists(/** @type {string} */ (executable)));
164
+ let there = Boolean(executable) && (await exists(/** @type {string} */ (executable)));
165
+
166
+ // The driver's own browser is not the only browser.
167
+ //
168
+ // `playwright-core` downloads nothing, so it always names a Chromium that is not there.
169
+ // That is not a failure — this tool already knows how to find a browser, and has a
170
+ // considered opinion about which one: `browsers.js` prefers Chrome for Testing over the
171
+ // browser the person actually uses, precisely so a check can never take over their
172
+ // windows, their profile or their sign-ins.
173
+ //
174
+ // So: ask it. Only when there is no browser on the machine at all is this a real "no".
175
+ /** @type {string|undefined} */
176
+ let borrowedFrom;
177
+ if (!there) {
178
+ try {
179
+ const { surveyBrowsers } = await import('../browsers.js');
180
+ const survey = await surveyBrowsers({ headless: true });
181
+ if (survey.chosen?.binary && (await exists(survey.chosen.binary))) {
182
+ executable = survey.chosen.binary;
183
+ borrowedFrom = survey.chosen.name;
184
+ there = true;
185
+ }
186
+ } catch {
187
+ // Nothing found, or the survey itself would not run. Reported as "no browser" below.
188
+ }
189
+ }
190
+
135
191
  if (!there) {
136
192
  return {
137
193
  ok: false,
138
194
  state: 'no browser',
139
195
  chromium: mod.chromium,
140
196
  version,
141
- why: `Playwright ${version ?? ''} is installed but its browser has not been downloaded, so no page can be opened yet. This is one command and nobody has to be asked.`.trim(),
197
+ why: `The browser driver ${version ?? ''} is installed, and there is no browser on this machine for it to open. This is one command and nobody has to be asked.`.trim(),
142
198
  howToGet: 'npx playwright install chromium',
143
199
  executable,
144
200
  };
@@ -150,7 +206,9 @@ export async function loadPlaywright(opts = {}) {
150
206
  chromium: mod.chromium,
151
207
  version,
152
208
  executable,
153
- why: `Playwright ${version ?? ''} is here and its Chromium is downloaded, so pages can be opened.`.trim(),
209
+ why: borrowedFrom
210
+ ? `The browser driver ${version ?? ''} is here and it will open ${borrowedFrom}, which is a separate application from the browser you use, so pages can be opened.`.trim()
211
+ : `The browser driver ${version ?? ''} is here and its Chromium is downloaded, so pages can be opened.`.trim(),
154
212
  };
155
213
  }
156
214
 
@@ -192,6 +250,7 @@ async function exists(file) {
192
250
  *
193
251
  * @param {object} opts
194
252
  * @param {any} opts.chromium
253
+ * @param {string} [opts.executable] Which browser to open. From `loadPlaywright`.
195
254
  * @param {string} opts.scratchDir
196
255
  * @param {{width: number, height: number, deviceScaleFactor?: number}} [opts.viewport]
197
256
  * @param {'light'|'dark'} [opts.colorScheme]
@@ -209,6 +268,15 @@ export async function openWindow(opts) {
209
268
  await fsp.mkdir(profileDir, { recursive: true });
210
269
 
211
270
  const context = await opts.chromium.launchPersistentContext(profileDir, {
271
+ // Which browser, said out loud rather than left to the driver's default.
272
+ //
273
+ // `playwright-core` has no browser of its own, so without this it looks for one that was
274
+ // never downloaded and the launch fails with a path nobody recognises. `loadPlaywright`
275
+ // has already decided which browser this machine should open — usually Chrome for
276
+ // Testing, deliberately not the browser the person uses — and this is where that decision
277
+ // is honoured. Left out when there is nothing to say, so the full `playwright` keeps
278
+ // using the Chromium it downloaded for itself.
279
+ ...(opts.executable ? { executablePath: opts.executable } : {}),
212
280
  headless: opts.headed !== true,
213
281
  viewport,
214
282
  deviceScaleFactor,
@@ -1243,7 +1311,9 @@ export function flattenAria(nodes) {
1243
1311
  // Two things with the same name in the same place have to be told apart somehow, and
1244
1312
  // counting is the only honest way left. The count is kept per place, so it cannot
1245
1313
  // spread: adding a row to one list never renumbers another.
1246
- const key = `${scope.join(' ')}${node.role}${node.name ?? ''}`;
1314
+ // The separator is written as the escape, never as the byte. A raw NUL makes grep and
1315
+ // file(1) treat this whole module as binary and skip it without saying so.
1316
+ const key = `${scope.join(' ')}\u0000${node.role}\u0000${node.name ?? ''}`;
1247
1317
  const nth = (seen.get(key) ?? 0) + 1;
1248
1318
  seen.set(key, nth);
1249
1319