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.
- package/CHANGELOG.md +429 -0
- package/README.md +193 -57
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +19 -6
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +564 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/status.js +45 -1
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +54 -3
- package/src/core/paths.js +15 -0
- package/src/guard/run.js +70 -3
- package/src/report/console.js +50 -6
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +18 -11
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +783 -71
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +134 -24
- package/src/v2/adapters/web.js +149 -18
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +66 -3
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +653 -69
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +65 -42
- package/src/v2/cluster.js +220 -14
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +353 -54
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +183 -66
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +90 -16
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +160 -24
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +55 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- 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
|
|
1195
|
-
*
|
|
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
|
-
|
|
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
|
-
}
|