staysfixed 0.7.2 → 0.9.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 (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  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 +564 -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/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. 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
- }