gitnexus 1.6.6-rc.81 → 1.6.6-rc.82
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/dist/core/group/extractors/http-patterns/python.js +345 -6
- package/dist/core/group/extractors/http-patterns/types.d.ts +33 -1
- package/dist/core/group/extractors/http-route-extractor.js +39 -10
- package/dist/core/ingestion/parsing-processor.d.ts +4 -0
- package/dist/core/ingestion/parsing-processor.js +15 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +152 -0
- package/dist/core/ingestion/pipeline-phases/routes.js +2 -2
- package/dist/core/ingestion/route-extractors/fastapi-router-bindings.d.ts +134 -0
- package/dist/core/ingestion/route-extractors/fastapi-router-bindings.js +208 -0
- package/dist/core/ingestion/workers/parse-worker.d.ts +26 -0
- package/dist/core/ingestion/workers/parse-worker.js +36 -0
- package/package.json +1 -1
|
@@ -19,9 +19,13 @@ const FASTAPI_VERBS = {
|
|
|
19
19
|
delete: 'DELETE',
|
|
20
20
|
patch: 'PATCH',
|
|
21
21
|
};
|
|
22
|
-
// ─── Provider: FastAPI @app
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
// ─── Provider: FastAPI @app.<verb> / @router.<verb> ──────────────────
|
|
23
|
+
// Two separate patterns so we can tag detections by decorator object.
|
|
24
|
+
// Only `@router.*` detections participate in `include_router(prefix=)`
|
|
25
|
+
// path-prefix joining (see `PythonRepoContext` + `joinPrefix`); `@app.*`
|
|
26
|
+
// routes already carry their final path verbatim.
|
|
27
|
+
const FASTAPI_APP_PATTERNS = compilePatterns({
|
|
28
|
+
name: 'python-fastapi-app',
|
|
25
29
|
language: Python,
|
|
26
30
|
patterns: [
|
|
27
31
|
{
|
|
@@ -37,6 +41,133 @@ const FASTAPI_PATTERNS = compilePatterns({
|
|
|
37
41
|
},
|
|
38
42
|
],
|
|
39
43
|
});
|
|
44
|
+
const FASTAPI_ROUTER_PATTERNS = compilePatterns({
|
|
45
|
+
name: 'python-fastapi-router',
|
|
46
|
+
language: Python,
|
|
47
|
+
patterns: [
|
|
48
|
+
{
|
|
49
|
+
meta: {},
|
|
50
|
+
query: `
|
|
51
|
+
(decorator
|
|
52
|
+
(call
|
|
53
|
+
function: (attribute
|
|
54
|
+
object: (identifier) @obj (#eq? @obj "router")
|
|
55
|
+
attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$"))
|
|
56
|
+
arguments: (argument_list . (string) @path)))
|
|
57
|
+
`,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
// ─── include_router(<router_obj>, prefix='/x') across the repo ────────
|
|
62
|
+
// Two shapes are common:
|
|
63
|
+
// app.include_router(assistant.router, prefix='/ai')
|
|
64
|
+
// app.include_router(my_router, prefix='/ai')
|
|
65
|
+
// The first names the originating module via `<module>.router`; the second
|
|
66
|
+
// references a name imported into the host file. We capture both.
|
|
67
|
+
const INCLUDE_ROUTER_ATTR_PATTERNS = compilePatterns({
|
|
68
|
+
name: 'python-fastapi-include-router-attr',
|
|
69
|
+
language: Python,
|
|
70
|
+
patterns: [
|
|
71
|
+
{
|
|
72
|
+
meta: {},
|
|
73
|
+
// Match any `<host>.include_router(<module>.router, ..., prefix='/x')`
|
|
74
|
+
// call. We deliberately do NOT pin `<host>` to the literal name `app`
|
|
75
|
+
// — production code routinely uses `api`, `application`, `asgi_app`,
|
|
76
|
+
// etc. The shape (`include_router` invoked with a router argument and
|
|
77
|
+
// a `prefix=` keyword) is specific enough on its own; restricting the
|
|
78
|
+
// host produces false negatives without removing meaningful false
|
|
79
|
+
// positives.
|
|
80
|
+
query: `
|
|
81
|
+
(call
|
|
82
|
+
function: (attribute
|
|
83
|
+
attribute: (identifier) @incl (#eq? @incl "include_router"))
|
|
84
|
+
arguments: (argument_list
|
|
85
|
+
(attribute
|
|
86
|
+
object: (identifier) @router_module
|
|
87
|
+
attribute: (identifier) @router_attr (#eq? @router_attr "router"))
|
|
88
|
+
(keyword_argument
|
|
89
|
+
name: (identifier) @kw (#eq? @kw "prefix")
|
|
90
|
+
value: (string) @prefix)))
|
|
91
|
+
`,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
const INCLUDE_ROUTER_NAME_PATTERNS = compilePatterns({
|
|
96
|
+
name: 'python-fastapi-include-router-name',
|
|
97
|
+
language: Python,
|
|
98
|
+
patterns: [
|
|
99
|
+
{
|
|
100
|
+
meta: {},
|
|
101
|
+
// Same `<host>` rationale as INCLUDE_ROUTER_ATTR_PATTERNS — see above.
|
|
102
|
+
query: `
|
|
103
|
+
(call
|
|
104
|
+
function: (attribute
|
|
105
|
+
attribute: (identifier) @incl (#eq? @incl "include_router"))
|
|
106
|
+
arguments: (argument_list
|
|
107
|
+
(identifier) @router_name
|
|
108
|
+
(keyword_argument
|
|
109
|
+
name: (identifier) @kw (#eq? @kw "prefix")
|
|
110
|
+
value: (string) @prefix)))
|
|
111
|
+
`,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
// `from .api.assistant import router` style — used together with
|
|
116
|
+
// INCLUDE_ROUTER_NAME so we can map a local name back to its module
|
|
117
|
+
// path, then back to the file the router was declared in.
|
|
118
|
+
const FROM_IMPORT_ROUTER_PATTERNS = compilePatterns({
|
|
119
|
+
name: 'python-fastapi-from-import-router',
|
|
120
|
+
language: Python,
|
|
121
|
+
patterns: [
|
|
122
|
+
{
|
|
123
|
+
meta: {},
|
|
124
|
+
query: `
|
|
125
|
+
(import_from_statement
|
|
126
|
+
module_name: (_) @module
|
|
127
|
+
name: (dotted_name (identifier) @imported (#eq? @imported "router")))
|
|
128
|
+
`,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
meta: {},
|
|
132
|
+
query: `
|
|
133
|
+
(import_from_statement
|
|
134
|
+
module_name: (_) @module
|
|
135
|
+
name: (aliased_import
|
|
136
|
+
name: (dotted_name (identifier) @imported (#eq? @imported "router"))
|
|
137
|
+
alias: (identifier) @alias))
|
|
138
|
+
`,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
// `from api import users` / `from api import users as u` — module-level
|
|
143
|
+
// imports where the imported name is itself the module that owns
|
|
144
|
+
// `<name>.router`. Lets Shape A (`<host>.include_router(<name>.router, …)`)
|
|
145
|
+
// look up the full package path of `<name>` and pin the prefix onto the
|
|
146
|
+
// exact file (`api/users.py`) rather than every file basenamed `users.py`.
|
|
147
|
+
const FROM_IMPORT_MODULE_PATTERNS = compilePatterns({
|
|
148
|
+
name: 'python-fastapi-from-import-module',
|
|
149
|
+
language: Python,
|
|
150
|
+
patterns: [
|
|
151
|
+
{
|
|
152
|
+
meta: {},
|
|
153
|
+
query: `
|
|
154
|
+
(import_from_statement
|
|
155
|
+
module_name: (_) @module
|
|
156
|
+
name: (dotted_name (identifier) @imported))
|
|
157
|
+
`,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
meta: {},
|
|
161
|
+
query: `
|
|
162
|
+
(import_from_statement
|
|
163
|
+
module_name: (_) @module
|
|
164
|
+
name: (aliased_import
|
|
165
|
+
name: (dotted_name (identifier) @imported)
|
|
166
|
+
alias: (identifier) @alias))
|
|
167
|
+
`,
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
});
|
|
40
171
|
// ─── Consumer: requests.get/post/... ──────────────────────────────────
|
|
41
172
|
const REQUESTS_VERB_PATTERNS = compilePatterns({
|
|
42
173
|
name: 'python-requests-verb',
|
|
@@ -405,14 +536,183 @@ const HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS = compilePatterns({
|
|
|
405
536
|
},
|
|
406
537
|
],
|
|
407
538
|
});
|
|
539
|
+
/** Strip `.py` and return the bare basename (e.g. `api/users.py` → `users`). */
|
|
540
|
+
function fileShortKey(rel) {
|
|
541
|
+
const slash = rel.lastIndexOf('/');
|
|
542
|
+
const file = slash >= 0 ? rel.slice(slash + 1) : rel;
|
|
543
|
+
return file.endsWith('.py') ? file.slice(0, -3) : file;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Long key for a `.py` file: parent directory + stem, joined with `/`.
|
|
547
|
+
* Files at the repo root return the empty string (no parent), in which
|
|
548
|
+
* case callers should fall back to the short key.
|
|
549
|
+
*/
|
|
550
|
+
function fileLongKey(rel) {
|
|
551
|
+
const noExt = rel.endsWith('.py') ? rel.slice(0, -3) : rel;
|
|
552
|
+
const lastSlash = noExt.lastIndexOf('/');
|
|
553
|
+
if (lastSlash < 0)
|
|
554
|
+
return '';
|
|
555
|
+
const beforeLast = noExt.slice(0, lastSlash);
|
|
556
|
+
const stem = noExt.slice(lastSlash + 1);
|
|
557
|
+
const prevSlash = beforeLast.lastIndexOf('/');
|
|
558
|
+
const parent = prevSlash >= 0 ? beforeLast.slice(prevSlash + 1) : beforeLast;
|
|
559
|
+
return `${parent}/${stem}`;
|
|
560
|
+
}
|
|
561
|
+
/** Last `.`-separated segment of a (possibly relative) module path. */
|
|
562
|
+
function lastSegmentOfDotted(text) {
|
|
563
|
+
const stripped = text.replace(/^\.+/, '');
|
|
564
|
+
if (!stripped)
|
|
565
|
+
return '';
|
|
566
|
+
const dot = stripped.lastIndexOf('.');
|
|
567
|
+
return dot >= 0 ? stripped.slice(dot + 1) : stripped;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Last two `.`-separated segments of a (possibly relative) module path
|
|
571
|
+
* joined with `/`, e.g. `api.users` → `api/users`. Single-segment paths
|
|
572
|
+
* and pure-dot inputs return the empty string; callers should fall back
|
|
573
|
+
* to the short key in that case.
|
|
574
|
+
*/
|
|
575
|
+
function lastTwoSegmentsAsLongKey(text) {
|
|
576
|
+
const stripped = text.replace(/^\.+/, '');
|
|
577
|
+
if (!stripped)
|
|
578
|
+
return '';
|
|
579
|
+
const last = stripped.lastIndexOf('.');
|
|
580
|
+
if (last <= 0)
|
|
581
|
+
return '';
|
|
582
|
+
const beforeLast = stripped.slice(0, last);
|
|
583
|
+
const stem = stripped.slice(last + 1);
|
|
584
|
+
const prev = beforeLast.lastIndexOf('.');
|
|
585
|
+
const parent = prev >= 0 ? beforeLast.slice(prev + 1) : beforeLast;
|
|
586
|
+
return `${parent}/${stem}`;
|
|
587
|
+
}
|
|
588
|
+
function recordPrefix(target, key, prefix) {
|
|
589
|
+
const set = target.get(key) ?? new Set();
|
|
590
|
+
set.add(prefix);
|
|
591
|
+
target.set(key, set);
|
|
592
|
+
}
|
|
593
|
+
function buildPythonRepoContext(files, parser, readFile, parseSource) {
|
|
594
|
+
const prefixesByLongKey = new Map();
|
|
595
|
+
const prefixesByShortKey = new Map();
|
|
596
|
+
// Pre-pass over .py files. We deliberately run this even on files
|
|
597
|
+
// that don't contain `include_router` — the cost of an extra parse
|
|
598
|
+
// is bounded by the file count, and detecting `include_router`
|
|
599
|
+
// beforehand would require its own grep/scan.
|
|
600
|
+
for (const rel of files) {
|
|
601
|
+
if (!rel.endsWith('.py'))
|
|
602
|
+
continue;
|
|
603
|
+
const src = readFile(rel);
|
|
604
|
+
if (!src)
|
|
605
|
+
continue;
|
|
606
|
+
if (!src.includes('include_router'))
|
|
607
|
+
continue;
|
|
608
|
+
parser.setLanguage(Python);
|
|
609
|
+
const tree = parseSource(parser, src);
|
|
610
|
+
if (!tree)
|
|
611
|
+
continue;
|
|
612
|
+
const localNameToModule = new Map();
|
|
613
|
+
for (const m of runCompiledPatterns(FROM_IMPORT_ROUTER_PATTERNS, tree)) {
|
|
614
|
+
const moduleNode = m.captures.module;
|
|
615
|
+
const aliasNode = m.captures.alias;
|
|
616
|
+
const importedNode = m.captures.imported;
|
|
617
|
+
if (!moduleNode || !importedNode)
|
|
618
|
+
continue;
|
|
619
|
+
const localName = aliasNode?.text ?? importedNode.text;
|
|
620
|
+
const moduleShort = lastSegmentOfDotted(moduleNode.text);
|
|
621
|
+
if (!moduleShort)
|
|
622
|
+
continue;
|
|
623
|
+
const moduleLong = lastTwoSegmentsAsLongKey(moduleNode.text);
|
|
624
|
+
localNameToModule.set(localName, { moduleShort, moduleLong });
|
|
625
|
+
}
|
|
626
|
+
// Module-alias map: name imported from a multi-segment package →
|
|
627
|
+
// long key. Lets Shape A look up the precise file for `<name>.router`
|
|
628
|
+
// even when `<name>` collides with another package's basename.
|
|
629
|
+
const localNameToModuleAlias = new Map();
|
|
630
|
+
for (const m of runCompiledPatterns(FROM_IMPORT_MODULE_PATTERNS, tree)) {
|
|
631
|
+
const moduleNode = m.captures.module;
|
|
632
|
+
const importedNode = m.captures.imported;
|
|
633
|
+
const aliasNode = m.captures.alias;
|
|
634
|
+
if (!moduleNode || !importedNode)
|
|
635
|
+
continue;
|
|
636
|
+
// Skip the `router` shape — already handled by FROM_IMPORT_ROUTER_PATTERNS
|
|
637
|
+
// above and stored under its router-aware semantics.
|
|
638
|
+
if (importedNode.text === 'router')
|
|
639
|
+
continue;
|
|
640
|
+
const moduleLong = lastTwoSegmentsAsLongKey(`${moduleNode.text}.${importedNode.text}`);
|
|
641
|
+
if (!moduleLong)
|
|
642
|
+
continue;
|
|
643
|
+
const localName = aliasNode?.text ?? importedNode.text;
|
|
644
|
+
localNameToModuleAlias.set(localName, moduleLong);
|
|
645
|
+
}
|
|
646
|
+
// Shape A: `<host>.include_router(<module>.router, prefix='/x')`.
|
|
647
|
+
// The call site gives us only a short module name. We promote to a
|
|
648
|
+
// long key when the same file imports `<module>` via either
|
|
649
|
+
// `from <pkg> import <module>` (recorded in `localNameToModuleAlias`
|
|
650
|
+
// — the typical pattern) or, less commonly, a router-aware import
|
|
651
|
+
// statement. Only fall back to the basename short key when neither
|
|
652
|
+
// alias is available.
|
|
653
|
+
for (const m of runCompiledPatterns(INCLUDE_ROUTER_ATTR_PATTERNS, tree)) {
|
|
654
|
+
const modNode = m.captures.router_module;
|
|
655
|
+
const prefixNode = m.captures.prefix;
|
|
656
|
+
if (!modNode || !prefixNode)
|
|
657
|
+
continue;
|
|
658
|
+
const prefix = unquoteLiteral(prefixNode.text);
|
|
659
|
+
if (prefix === null)
|
|
660
|
+
continue;
|
|
661
|
+
const moduleShort = modNode.text;
|
|
662
|
+
const aliasLong = localNameToModuleAlias.get(moduleShort);
|
|
663
|
+
const sameFileImport = localNameToModule.get(moduleShort);
|
|
664
|
+
const longKey = aliasLong ?? sameFileImport?.moduleLong;
|
|
665
|
+
if (longKey) {
|
|
666
|
+
recordPrefix(prefixesByLongKey, longKey, prefix);
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
recordPrefix(prefixesByShortKey, moduleShort, prefix);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Shape B: `<host>.include_router(my_router, prefix='/x')` — resolve
|
|
673
|
+
// `my_router` via the import map built above. Whenever the import
|
|
674
|
+
// statement supplied a multi-segment module path the long key is
|
|
675
|
+
// recorded, eliminating cross-package collisions.
|
|
676
|
+
for (const m of runCompiledPatterns(INCLUDE_ROUTER_NAME_PATTERNS, tree)) {
|
|
677
|
+
const nameNode = m.captures.router_name;
|
|
678
|
+
const prefixNode = m.captures.prefix;
|
|
679
|
+
if (!nameNode || !prefixNode)
|
|
680
|
+
continue;
|
|
681
|
+
const localImp = localNameToModule.get(nameNode.text);
|
|
682
|
+
if (!localImp)
|
|
683
|
+
continue;
|
|
684
|
+
const prefix = unquoteLiteral(prefixNode.text);
|
|
685
|
+
if (prefix === null)
|
|
686
|
+
continue;
|
|
687
|
+
if (localImp.moduleLong) {
|
|
688
|
+
recordPrefix(prefixesByLongKey, localImp.moduleLong, prefix);
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
recordPrefix(prefixesByShortKey, localImp.moduleShort, prefix);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return { prefixesByLongKey, prefixesByShortKey };
|
|
696
|
+
}
|
|
697
|
+
function joinPrefix(prefix, route) {
|
|
698
|
+
// Mirror FastAPI's path joining: trim trailing slash off prefix,
|
|
699
|
+
// ensure exactly one leading slash on the result.
|
|
700
|
+
const p = prefix.replace(/\/+$/, '');
|
|
701
|
+
const r = route.startsWith('/') ? route : `/${route}`;
|
|
702
|
+
return `${p}${r}`;
|
|
703
|
+
}
|
|
408
704
|
export const PYTHON_HTTP_PLUGIN = {
|
|
409
705
|
name: 'python-http',
|
|
410
706
|
language: Python,
|
|
411
|
-
|
|
707
|
+
prepareRepo({ files, parser, readFile, parseSource }) {
|
|
708
|
+
return buildPythonRepoContext(files, parser, readFile, parseSource);
|
|
709
|
+
},
|
|
710
|
+
scan(tree, repoContext, fileRel) {
|
|
412
711
|
const out = [];
|
|
413
712
|
const httpxAsyncClients = collectHttpxAsyncClients(tree);
|
|
414
|
-
|
|
415
|
-
|
|
713
|
+
const ctx = repoContext;
|
|
714
|
+
// Providers: FastAPI @app.<verb>("/path") — already absolute path.
|
|
715
|
+
for (const match of runCompiledPatterns(FASTAPI_APP_PATTERNS, tree)) {
|
|
416
716
|
const methodNode = match.captures.method;
|
|
417
717
|
const pathNode = match.captures.path;
|
|
418
718
|
if (!methodNode || !pathNode)
|
|
@@ -432,6 +732,45 @@ export const PYTHON_HTTP_PLUGIN = {
|
|
|
432
732
|
confidence: 0.8,
|
|
433
733
|
});
|
|
434
734
|
}
|
|
735
|
+
// Providers: FastAPI @router.<verb>("/path") — must be joined
|
|
736
|
+
// with the prefix(es) declared at the include_router site. When
|
|
737
|
+
// no prefix is found we still emit the unprefixed path so this
|
|
738
|
+
// change is strictly additive vs. the prior @app-only behaviour;
|
|
739
|
+
// when the same router is mounted under multiple prefixes we emit
|
|
740
|
+
// one detection per prefix.
|
|
741
|
+
for (const match of runCompiledPatterns(FASTAPI_ROUTER_PATTERNS, tree)) {
|
|
742
|
+
const methodNode = match.captures.method;
|
|
743
|
+
const pathNode = match.captures.path;
|
|
744
|
+
if (!methodNode || !pathNode)
|
|
745
|
+
continue;
|
|
746
|
+
const httpMethod = FASTAPI_VERBS[methodNode.text];
|
|
747
|
+
if (!httpMethod)
|
|
748
|
+
continue;
|
|
749
|
+
const rawPath = unquoteLiteral(pathNode.text);
|
|
750
|
+
if (rawPath === null)
|
|
751
|
+
continue;
|
|
752
|
+
// Long key first (precise, package-aware), short key as fallback.
|
|
753
|
+
// Mirrors the ingestion-side resolution in parse-impl.ts so the
|
|
754
|
+
// graph nodes and group contracts agree on which prefix applies.
|
|
755
|
+
const longKey = fileRel ? fileLongKey(fileRel) : '';
|
|
756
|
+
const longPrefixes = longKey ? ctx?.prefixesByLongKey.get(longKey) : undefined;
|
|
757
|
+
const shortKey = fileRel ? fileShortKey(fileRel) : '';
|
|
758
|
+
const shortPrefixes = longPrefixes || !shortKey ? undefined : ctx?.prefixesByShortKey.get(shortKey);
|
|
759
|
+
const prefixSet = longPrefixes ?? shortPrefixes;
|
|
760
|
+
const paths = prefixSet && prefixSet.size > 0
|
|
761
|
+
? [...prefixSet].map((p) => joinPrefix(p, rawPath))
|
|
762
|
+
: [rawPath];
|
|
763
|
+
for (const p of paths) {
|
|
764
|
+
out.push({
|
|
765
|
+
role: 'provider',
|
|
766
|
+
framework: 'fastapi',
|
|
767
|
+
method: httpMethod,
|
|
768
|
+
path: p,
|
|
769
|
+
name: null,
|
|
770
|
+
confidence: 0.8,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
435
774
|
// Consumers: requests.<verb>
|
|
436
775
|
for (const match of runCompiledPatterns(REQUESTS_VERB_PATTERNS, tree)) {
|
|
437
776
|
const methodNode = match.captures.method;
|
|
@@ -47,15 +47,47 @@ export interface HttpDetection {
|
|
|
47
47
|
* `LanguagePatterns.language` in `tree-sitter-scanner.ts` — the
|
|
48
48
|
* grammar modules export different shapes.
|
|
49
49
|
*/
|
|
50
|
+
/**
|
|
51
|
+
* Per-repo state a plugin can build during a `prepareRepo` pass before
|
|
52
|
+
* any per-file `scan` is invoked. The orchestrator threads this opaque
|
|
53
|
+
* value back into each `scan` call so plugins can resolve cross-file
|
|
54
|
+
* facts (e.g. FastAPI `app.include_router(prefix=...)` mappings live
|
|
55
|
+
* in `main.py` but apply to handlers declared in `api/*.py`).
|
|
56
|
+
*
|
|
57
|
+
* Plugins that have no cross-file state can omit `prepareRepo` and
|
|
58
|
+
* receive `undefined`.
|
|
59
|
+
*/
|
|
60
|
+
export type RepoContext = unknown;
|
|
50
61
|
export interface HttpLanguagePlugin {
|
|
51
62
|
/** Human-readable plugin name for diagnostics. */
|
|
52
63
|
name: string;
|
|
53
64
|
/** tree-sitter grammar object (passed to the shared parser). */
|
|
54
65
|
language: unknown;
|
|
66
|
+
/**
|
|
67
|
+
* Optional pre-pass: walk the relevant files in the repo and produce
|
|
68
|
+
* an opaque context that `scan` can use to resolve cross-file facts.
|
|
69
|
+
* Implementations must not throw — return undefined on any error so
|
|
70
|
+
* the orchestrator falls back to context-less scanning.
|
|
71
|
+
*/
|
|
72
|
+
prepareRepo?(args: {
|
|
73
|
+
repoPath: string;
|
|
74
|
+
files: string[];
|
|
75
|
+
parser: Parser;
|
|
76
|
+
readFile: (rel: string) => string | null;
|
|
77
|
+
parseSource: (parser: Parser, src: string) => Parser.Tree | null;
|
|
78
|
+
}): RepoContext | undefined;
|
|
55
79
|
/**
|
|
56
80
|
* Scan a parsed tree and return zero or more HTTP detections. Plugins
|
|
57
81
|
* must not throw — they should swallow per-match errors so a single
|
|
58
82
|
* malformed construct does not abort the whole file.
|
|
83
|
+
*
|
|
84
|
+
* `repoContext` is whatever the plugin's `prepareRepo` produced (or
|
|
85
|
+
* `undefined` if there is no `prepareRepo`).
|
|
86
|
+
*
|
|
87
|
+
* `fileRel` is the repo-relative path of the file being scanned;
|
|
88
|
+
* plugins that resolve cross-file facts (e.g. FastAPI router prefix
|
|
89
|
+
* joining) need it to key into `repoContext`. Optional so existing
|
|
90
|
+
* single-file plugins can keep their unary `scan(tree)` shape.
|
|
59
91
|
*/
|
|
60
|
-
scan(tree: Parser.Tree): HttpDetection[];
|
|
92
|
+
scan(tree: Parser.Tree, repoContext?: RepoContext, fileRel?: string): HttpDetection[];
|
|
61
93
|
}
|
|
@@ -141,7 +141,35 @@ export class HttpRouteExtractor {
|
|
|
141
141
|
// both graph-assisted enrichment and source-scan emission.
|
|
142
142
|
const parser = new Parser();
|
|
143
143
|
const cachedDetections = new Map();
|
|
144
|
-
|
|
144
|
+
// Per-plugin cross-file context (e.g. Python's FastAPI router →
|
|
145
|
+
// include_router(prefix=...) map). Built lazily on first
|
|
146
|
+
// `getDetections` call for a file the plugin handles, scoped to the
|
|
147
|
+
// file list returned by `getScannedFiles`. Stored by plugin name so
|
|
148
|
+
// a repo with multiple languages keeps each plugin's context
|
|
149
|
+
// independent.
|
|
150
|
+
const repoContextByPlugin = new Map();
|
|
151
|
+
const ensureRepoContext = async (plugin) => {
|
|
152
|
+
if (!plugin || typeof plugin.prepareRepo !== 'function')
|
|
153
|
+
return undefined;
|
|
154
|
+
if (repoContextByPlugin.has(plugin.name))
|
|
155
|
+
return repoContextByPlugin.get(plugin.name);
|
|
156
|
+
try {
|
|
157
|
+
const ctx = plugin.prepareRepo({
|
|
158
|
+
repoPath,
|
|
159
|
+
files: await getScannedFiles(),
|
|
160
|
+
parser,
|
|
161
|
+
readFile: (rel) => readSafe(repoPath, rel),
|
|
162
|
+
parseSource: (p, src) => parseSourceSafe(p, src),
|
|
163
|
+
});
|
|
164
|
+
repoContextByPlugin.set(plugin.name, ctx);
|
|
165
|
+
return ctx;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
repoContextByPlugin.set(plugin.name, undefined);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const getDetections = async (rel) => {
|
|
145
173
|
const cached = cachedDetections.get(rel);
|
|
146
174
|
if (cached)
|
|
147
175
|
return cached;
|
|
@@ -150,6 +178,7 @@ export class HttpRouteExtractor {
|
|
|
150
178
|
cachedDetections.set(rel, []);
|
|
151
179
|
return [];
|
|
152
180
|
}
|
|
181
|
+
const repoContext = await ensureRepoContext(plugin);
|
|
153
182
|
const content = readSafe(repoPath, rel);
|
|
154
183
|
if (!content) {
|
|
155
184
|
cachedDetections.set(rel, []);
|
|
@@ -158,7 +187,7 @@ export class HttpRouteExtractor {
|
|
|
158
187
|
try {
|
|
159
188
|
parser.setLanguage(plugin.language);
|
|
160
189
|
const tree = parseSourceSafe(parser, content);
|
|
161
|
-
const detections = plugin.scan(tree);
|
|
190
|
+
const detections = plugin.scan(tree, repoContext, rel);
|
|
162
191
|
cachedDetections.set(rel, detections);
|
|
163
192
|
return detections;
|
|
164
193
|
}
|
|
@@ -179,9 +208,9 @@ export class HttpRouteExtractor {
|
|
|
179
208
|
const graphProviders = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : [];
|
|
180
209
|
// Source scan always runs to capture routes in languages/files not covered
|
|
181
210
|
// by graph edges; the glob and per-file parse results are cached above.
|
|
182
|
-
const providers = this.mergeGraphAndSourceContracts(graphProviders, this.extractProvidersSourceScan(await getScannedFiles(), getDetections));
|
|
211
|
+
const providers = this.mergeGraphAndSourceContracts(graphProviders, await this.extractProvidersSourceScan(await getScannedFiles(), getDetections));
|
|
183
212
|
const graphConsumers = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : [];
|
|
184
|
-
const consumers = this.mergeGraphAndSourceContracts(graphConsumers, this.extractConsumersSourceScan(await getScannedFiles(), getDetections));
|
|
213
|
+
const consumers = this.mergeGraphAndSourceContracts(graphConsumers, await this.extractConsumersSourceScan(await getScannedFiles(), getDetections));
|
|
185
214
|
return [...providers, ...consumers];
|
|
186
215
|
}
|
|
187
216
|
async scanFiles(repoPath) {
|
|
@@ -219,7 +248,7 @@ export class HttpRouteExtractor {
|
|
|
219
248
|
// helpers — tree-sitter gives both pieces of information
|
|
220
249
|
// structurally. Always run the lookup: even when method is set by
|
|
221
250
|
// `methodFromRouteReason`, we still need the handler name.
|
|
222
|
-
const detections = filePath ? getDetections(filePath) : [];
|
|
251
|
+
const detections = filePath ? await getDetections(filePath) : [];
|
|
223
252
|
const providerDetections = detections.filter((d) => d.role === 'provider');
|
|
224
253
|
let handlerName = null;
|
|
225
254
|
const normalizedRoute = normalizeHttpPath(routePath);
|
|
@@ -293,10 +322,10 @@ export class HttpRouteExtractor {
|
|
|
293
322
|
return out;
|
|
294
323
|
}
|
|
295
324
|
// ─── Source-scan providers ─────────────────────────────────────────
|
|
296
|
-
extractProvidersSourceScan(files, getDetections) {
|
|
325
|
+
async extractProvidersSourceScan(files, getDetections) {
|
|
297
326
|
const out = [];
|
|
298
327
|
for (const rel of files) {
|
|
299
|
-
const detections = getDetections(rel);
|
|
328
|
+
const detections = await getDetections(rel);
|
|
300
329
|
for (const d of detections) {
|
|
301
330
|
if (d.role !== 'provider')
|
|
302
331
|
continue;
|
|
@@ -338,7 +367,7 @@ export class HttpRouteExtractor {
|
|
|
338
367
|
let method = 'GET';
|
|
339
368
|
// Prefer the plugin's detected method if we can find a matching
|
|
340
369
|
// fetch/axios call in the same file.
|
|
341
|
-
const detections = filePath ? getDetections(filePath) : [];
|
|
370
|
+
const detections = filePath ? await getDetections(filePath) : [];
|
|
342
371
|
// Symmetric to the provider path: if multiple consumer calls in
|
|
343
372
|
// the same file share the same normalized path (e.g. a GET
|
|
344
373
|
// fetch AND a POST fetch to `/api/orders`), `.find()` silently
|
|
@@ -388,10 +417,10 @@ export class HttpRouteExtractor {
|
|
|
388
417
|
return out;
|
|
389
418
|
}
|
|
390
419
|
// ─── Source-scan consumers ─────────────────────────────────────────
|
|
391
|
-
extractConsumersSourceScan(files, getDetections) {
|
|
420
|
+
async extractConsumersSourceScan(files, getDetections) {
|
|
392
421
|
const out = [];
|
|
393
422
|
for (const rel of files) {
|
|
394
|
-
const detections = getDetections(rel);
|
|
423
|
+
const detections = await getDetections(rel);
|
|
395
424
|
for (const d of detections) {
|
|
396
425
|
if (d.role !== 'consumer')
|
|
397
426
|
continue;
|
|
@@ -4,6 +4,7 @@ import { ASTCache } from './ast-cache.js';
|
|
|
4
4
|
import type { ParsedFile } from '../../_shared/index.js';
|
|
5
5
|
import { WorkerPool } from './workers/worker-pool.js';
|
|
6
6
|
import type { ParseWorkerResult, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, ExtractedToolDef, FileConstructorBindings, FileScopeBindings, ExtractedORMQuery, FetchWrapperDef } from './workers/parse-worker.js';
|
|
7
|
+
import type { ExtractedRouterImport, ExtractedRouterInclude, ExtractedRouterModuleAlias } from './route-extractors/fastapi-router-bindings.js';
|
|
7
8
|
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
|
|
8
9
|
export interface WorkerExtractedData {
|
|
9
10
|
imports: ExtractedImport[];
|
|
@@ -14,6 +15,9 @@ export interface WorkerExtractedData {
|
|
|
14
15
|
fetchCalls: ExtractedFetchCall[];
|
|
15
16
|
fetchWrapperDefs: FetchWrapperDef[];
|
|
16
17
|
decoratorRoutes: ExtractedDecoratorRoute[];
|
|
18
|
+
routerIncludes: ExtractedRouterInclude[];
|
|
19
|
+
routerImports: ExtractedRouterImport[];
|
|
20
|
+
routerModuleAliases: ExtractedRouterModuleAlias[];
|
|
17
21
|
toolDefs: ExtractedToolDef[];
|
|
18
22
|
ormQueries: ExtractedORMQuery[];
|
|
19
23
|
constructorBindings: FileConstructorBindings[];
|
|
@@ -37,6 +37,9 @@ export const mergeChunkResults = (graph, symbolTable, chunkResults) => {
|
|
|
37
37
|
const allFetchCalls = [];
|
|
38
38
|
const allFetchWrapperDefs = [];
|
|
39
39
|
const allDecoratorRoutes = [];
|
|
40
|
+
const allRouterIncludes = [];
|
|
41
|
+
const allRouterImports = [];
|
|
42
|
+
const allRouterModuleAliases = [];
|
|
40
43
|
const allToolDefs = [];
|
|
41
44
|
const allORMQueries = [];
|
|
42
45
|
const allConstructorBindings = [];
|
|
@@ -82,6 +85,12 @@ export const mergeChunkResults = (graph, symbolTable, chunkResults) => {
|
|
|
82
85
|
allFetchWrapperDefs.push(item);
|
|
83
86
|
for (const item of result.decoratorRoutes)
|
|
84
87
|
allDecoratorRoutes.push(item);
|
|
88
|
+
for (const item of result.routerIncludes ?? [])
|
|
89
|
+
allRouterIncludes.push(item);
|
|
90
|
+
for (const item of result.routerImports ?? [])
|
|
91
|
+
allRouterImports.push(item);
|
|
92
|
+
for (const item of result.routerModuleAliases ?? [])
|
|
93
|
+
allRouterModuleAliases.push(item);
|
|
85
94
|
for (const item of result.toolDefs)
|
|
86
95
|
allToolDefs.push(item);
|
|
87
96
|
if (result.ormQueries)
|
|
@@ -105,6 +114,9 @@ export const mergeChunkResults = (graph, symbolTable, chunkResults) => {
|
|
|
105
114
|
fetchCalls: allFetchCalls,
|
|
106
115
|
fetchWrapperDefs: allFetchWrapperDefs,
|
|
107
116
|
decoratorRoutes: allDecoratorRoutes,
|
|
117
|
+
routerIncludes: allRouterIncludes,
|
|
118
|
+
routerImports: allRouterImports,
|
|
119
|
+
routerModuleAliases: allRouterModuleAliases,
|
|
108
120
|
toolDefs: allToolDefs,
|
|
109
121
|
ormQueries: allORMQueries,
|
|
110
122
|
constructorBindings: allConstructorBindings,
|
|
@@ -138,6 +150,9 @@ outRawResults) => {
|
|
|
138
150
|
fetchCalls: [],
|
|
139
151
|
fetchWrapperDefs: [],
|
|
140
152
|
decoratorRoutes: [],
|
|
153
|
+
routerIncludes: [],
|
|
154
|
+
routerImports: [],
|
|
155
|
+
routerModuleAliases: [],
|
|
141
156
|
toolDefs: [],
|
|
142
157
|
ormQueries: [],
|
|
143
158
|
constructorBindings: [],
|
|
@@ -237,6 +237,9 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
237
237
|
const allFetchWrapperDefs = [];
|
|
238
238
|
const allExtractedRoutes = [];
|
|
239
239
|
const allDecoratorRoutes = [];
|
|
240
|
+
const allRouterIncludes = [];
|
|
241
|
+
const allRouterImports = [];
|
|
242
|
+
const allRouterModuleAliases = [];
|
|
240
243
|
const allToolDefs = [];
|
|
241
244
|
const allORMQueries = [];
|
|
242
245
|
const deferredWorkerCalls = [];
|
|
@@ -543,6 +546,18 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
543
546
|
for (const item of chunkWorkerData.decoratorRoutes)
|
|
544
547
|
allDecoratorRoutes.push(item);
|
|
545
548
|
}
|
|
549
|
+
if (chunkWorkerData.routerIncludes?.length) {
|
|
550
|
+
for (const item of chunkWorkerData.routerIncludes)
|
|
551
|
+
allRouterIncludes.push(item);
|
|
552
|
+
}
|
|
553
|
+
if (chunkWorkerData.routerImports?.length) {
|
|
554
|
+
for (const item of chunkWorkerData.routerImports)
|
|
555
|
+
allRouterImports.push(item);
|
|
556
|
+
}
|
|
557
|
+
if (chunkWorkerData.routerModuleAliases?.length) {
|
|
558
|
+
for (const item of chunkWorkerData.routerModuleAliases)
|
|
559
|
+
allRouterModuleAliases.push(item);
|
|
560
|
+
}
|
|
546
561
|
if (chunkWorkerData.toolDefs?.length) {
|
|
547
562
|
for (const item of chunkWorkerData.toolDefs)
|
|
548
563
|
allToolDefs.push(item);
|
|
@@ -878,6 +893,143 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
878
893
|
importCtx.resolveCache.clear();
|
|
879
894
|
importCtx.index = EMPTY_INDEX;
|
|
880
895
|
importCtx.normalizedFileList = [];
|
|
896
|
+
// FastAPI router-prefix resolution (cross-file).
|
|
897
|
+
//
|
|
898
|
+
// Workers emit two kinds of records per Python file:
|
|
899
|
+
// • `routerIncludes` — every `app.include_router(<routerExpr>, prefix='/x')`
|
|
900
|
+
// site, where `routerExpr` is either `<module>.router` (Shape A) or a
|
|
901
|
+
// bare local name (Shape B).
|
|
902
|
+
// • `routerImports` — every `from <module> import router [as <alias>]`,
|
|
903
|
+
// mapping a local name to a module key (the basename of the source
|
|
904
|
+
// module). These let us resolve Shape-B router includes back to the
|
|
905
|
+
// module that defines the router.
|
|
906
|
+
//
|
|
907
|
+
// We build `module-basename → Set<prefix>` and then walk
|
|
908
|
+
// `allDecoratorRoutes`: any decorator route emitted from a `router.<verb>`
|
|
909
|
+
// decorator inherits its file-basename's prefix. When a router is mounted
|
|
910
|
+
// under multiple prefixes we duplicate the route entry, mirroring FastAPI's
|
|
911
|
+
// runtime behaviour.
|
|
912
|
+
if (allRouterIncludes.length > 0 && allDecoratorRoutes.length > 0) {
|
|
913
|
+
const importsByFile = new Map();
|
|
914
|
+
for (const imp of allRouterImports) {
|
|
915
|
+
let m = importsByFile.get(imp.filePath);
|
|
916
|
+
if (!m) {
|
|
917
|
+
m = new Map();
|
|
918
|
+
importsByFile.set(imp.filePath, m);
|
|
919
|
+
}
|
|
920
|
+
m.set(imp.localName, {
|
|
921
|
+
moduleKey: imp.moduleKey,
|
|
922
|
+
moduleKeyLong: imp.moduleKeyLong,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
// Module-alias map keyed by file: `localName` (the imported module
|
|
926
|
+
// identifier in this file) → long key. Shape-A receivers like
|
|
927
|
+
// `users.router` are matched against this map; the long key, when
|
|
928
|
+
// present, scopes the prefix to the precise source file.
|
|
929
|
+
const moduleAliasesByFile = new Map();
|
|
930
|
+
for (const alias of allRouterModuleAliases) {
|
|
931
|
+
let m = moduleAliasesByFile.get(alias.filePath);
|
|
932
|
+
if (!m) {
|
|
933
|
+
m = new Map();
|
|
934
|
+
moduleAliasesByFile.set(alias.filePath, m);
|
|
935
|
+
}
|
|
936
|
+
m.set(alias.localName, alias.moduleKeyLong);
|
|
937
|
+
}
|
|
938
|
+
// Two parallel maps: long-key (precise) and short-key (basename
|
|
939
|
+
// fallback). Long-key entries are preferred when the file's own long
|
|
940
|
+
// key matches; short-key entries match any file with that basename and
|
|
941
|
+
// remain the fallback when no long key is known (e.g. Shape A includes
|
|
942
|
+
// without a corresponding import statement).
|
|
943
|
+
const prefixesByLongKey = new Map();
|
|
944
|
+
const prefixesByShortKey = new Map();
|
|
945
|
+
const recordPrefix = (target, key, prefix) => {
|
|
946
|
+
let set = target.get(key);
|
|
947
|
+
if (!set) {
|
|
948
|
+
set = new Set();
|
|
949
|
+
target.set(key, set);
|
|
950
|
+
}
|
|
951
|
+
set.add(prefix);
|
|
952
|
+
};
|
|
953
|
+
for (const inc of allRouterIncludes) {
|
|
954
|
+
// Shape A: `<module>.router`. The worker emits `routerExpr` already
|
|
955
|
+
// including `.router`, so split it back. We only know a short module
|
|
956
|
+
// key here — the call site doesn't carry the dotted package path. If
|
|
957
|
+
// the same file imports `<module>` via `from <pkg> import <module>`
|
|
958
|
+
// (recorded in `allRouterModuleAliases`) we promote to a long key.
|
|
959
|
+
const dotIdx = inc.routerExpr.indexOf('.router');
|
|
960
|
+
if (dotIdx > 0) {
|
|
961
|
+
const moduleShort = inc.routerExpr.slice(0, dotIdx);
|
|
962
|
+
const aliasLong = moduleAliasesByFile.get(inc.filePath)?.get(moduleShort);
|
|
963
|
+
if (aliasLong) {
|
|
964
|
+
recordPrefix(prefixesByLongKey, aliasLong, inc.prefix);
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
recordPrefix(prefixesByShortKey, moduleShort, inc.prefix);
|
|
968
|
+
}
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
// Shape B: bare local name. Resolve through this file's imports. The
|
|
972
|
+
// import line gives us a long key whenever the module path was multi-
|
|
973
|
+
// segment, so cross-package collisions are eliminated for Shape B.
|
|
974
|
+
const localImp = importsByFile.get(inc.filePath)?.get(inc.routerExpr);
|
|
975
|
+
if (!localImp)
|
|
976
|
+
continue;
|
|
977
|
+
if (localImp.moduleKeyLong) {
|
|
978
|
+
recordPrefix(prefixesByLongKey, localImp.moduleKeyLong, inc.prefix);
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
recordPrefix(prefixesByShortKey, localImp.moduleKey, inc.prefix);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (prefixesByLongKey.size > 0 || prefixesByShortKey.size > 0) {
|
|
985
|
+
const fileLongKey = (rel) => {
|
|
986
|
+
// Strip `.py`, then take the last two path segments. `api/users.py`
|
|
987
|
+
// → `api/users`. Files at the repo root return the empty string,
|
|
988
|
+
// which can never match a long-key entry (those always include a
|
|
989
|
+
// parent directory) and so fall through to the short-key lookup.
|
|
990
|
+
const noExt = rel.endsWith('.py') ? rel.slice(0, -3) : rel;
|
|
991
|
+
const lastSlash = noExt.lastIndexOf('/');
|
|
992
|
+
if (lastSlash < 0)
|
|
993
|
+
return '';
|
|
994
|
+
const beforeLast = noExt.slice(0, lastSlash);
|
|
995
|
+
const stem = noExt.slice(lastSlash + 1);
|
|
996
|
+
const prevSlash = beforeLast.lastIndexOf('/');
|
|
997
|
+
const parent = prevSlash >= 0 ? beforeLast.slice(prevSlash + 1) : beforeLast;
|
|
998
|
+
return `${parent}/${stem}`;
|
|
999
|
+
};
|
|
1000
|
+
const fileShortKey = (rel) => {
|
|
1001
|
+
const slash = rel.lastIndexOf('/');
|
|
1002
|
+
const file = slash >= 0 ? rel.slice(slash + 1) : rel;
|
|
1003
|
+
return file.endsWith('.py') ? file.slice(0, -3) : file;
|
|
1004
|
+
};
|
|
1005
|
+
const expanded = [];
|
|
1006
|
+
for (const dr of allDecoratorRoutes) {
|
|
1007
|
+
if (dr.decoratorReceiver !== 'router' || !dr.filePath.endsWith('.py')) {
|
|
1008
|
+
expanded.push(dr);
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
// Long-key lookup first; only fall back to the short key when no
|
|
1012
|
+
// long-key prefix targets this file. This avoids prefix leakage
|
|
1013
|
+
// between e.g. `api/users.py` and `admin/users.py`.
|
|
1014
|
+
const longKey = fileLongKey(dr.filePath);
|
|
1015
|
+
const longPrefixes = longKey ? prefixesByLongKey.get(longKey) : undefined;
|
|
1016
|
+
const shortPrefixes = longPrefixes
|
|
1017
|
+
? undefined
|
|
1018
|
+
: prefixesByShortKey.get(fileShortKey(dr.filePath));
|
|
1019
|
+
const prefixes = longPrefixes ?? shortPrefixes;
|
|
1020
|
+
if (!prefixes || prefixes.size === 0) {
|
|
1021
|
+
expanded.push(dr);
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
for (const prefix of prefixes) {
|
|
1025
|
+
expanded.push({ ...dr, prefix });
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
allDecoratorRoutes.length = 0;
|
|
1029
|
+
for (const dr of expanded)
|
|
1030
|
+
allDecoratorRoutes.push(dr);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
881
1033
|
return {
|
|
882
1034
|
exportedTypeMap,
|
|
883
1035
|
allFetchCalls,
|
|
@@ -150,7 +150,6 @@ export const routesPhase = {
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
-
const ensureSlash = (path) => (path.startsWith('/') ? path : '/' + path);
|
|
154
153
|
let duplicateRoutes = 0;
|
|
155
154
|
const namedRouteRegistry = new Map();
|
|
156
155
|
const addRoute = (url, entry) => {
|
|
@@ -173,7 +172,8 @@ export const routesPhase = {
|
|
|
173
172
|
}
|
|
174
173
|
}
|
|
175
174
|
for (const dr of allDecoratorRoutes) {
|
|
176
|
-
|
|
175
|
+
const url = normalizeExtractedRoutePath(dr.routePath, dr.prefix ?? null);
|
|
176
|
+
addRoute(url, {
|
|
177
177
|
filePath: dr.filePath,
|
|
178
178
|
source: `decorator-${dr.decoratorName}`,
|
|
179
179
|
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FastAPI router-prefix detection — pure functions, no worker thread.
|
|
3
|
+
*
|
|
4
|
+
* NOT A WORKER. This module exports plain synchronous functions; it
|
|
5
|
+
* does not import `worker_threads`, does not call `parentPort`, and
|
|
6
|
+
* is not a new worker entry point. It lives next to the other route
|
|
7
|
+
* extractors (expo, nextjs, php, laravel) for that reason.
|
|
8
|
+
*
|
|
9
|
+
* The implementation was historically inlined in `workers/parse-worker.ts`,
|
|
10
|
+
* but parse-worker.ts is itself the worker entry point and cannot be
|
|
11
|
+
* loaded from the main thread (see the same constraint used by
|
|
12
|
+
* `test/unit/call-attribution-issue-1166.test.ts`). Splitting the pure
|
|
13
|
+
* extraction here lets unit tests import the function directly without
|
|
14
|
+
* booting a worker, satisfying DoD §2.7.
|
|
15
|
+
*
|
|
16
|
+
* Worker phase is per-file, so the heavy cross-file resolution lives in
|
|
17
|
+
* `pipeline-phases/parse-impl.ts`. Here we only extract two raw record
|
|
18
|
+
* kinds and let the pipeline aggregate them across files:
|
|
19
|
+
*
|
|
20
|
+
* • {@link ExtractedRouterInclude} — every
|
|
21
|
+
* `<host>.include_router(<routerExpr>, prefix='/x')` site, where
|
|
22
|
+
* `<routerExpr>` is either `<module>.router` (Shape A) or a bare
|
|
23
|
+
* local name (Shape B). `<host>` is intentionally unconstrained:
|
|
24
|
+
* production code uses `app`, `api`, `application`, `asgi_app`,
|
|
25
|
+
* etc., and the call shape (`include_router` invoked with a
|
|
26
|
+
* `prefix=` keyword) is specific enough on its own.
|
|
27
|
+
*
|
|
28
|
+
* • {@link ExtractedRouterImport} — every
|
|
29
|
+
* `from <module> import router [as <alias>]`, captured for both
|
|
30
|
+
* absolute and relative module paths (`from .calls import …`).
|
|
31
|
+
* parse-impl uses the imports to resolve Shape-B local names back
|
|
32
|
+
* to the file that declares the router.
|
|
33
|
+
*
|
|
34
|
+
* Module keying is two-tiered to avoid prefix bleed between same-named
|
|
35
|
+
* files in different packages (e.g. `api/users.py` vs `admin/users.py`):
|
|
36
|
+
*
|
|
37
|
+
* • short key — basename without `.py` (`users`)
|
|
38
|
+
* • long key — `<parent-dir>/<basename>` (`api/users`)
|
|
39
|
+
*
|
|
40
|
+
* Imports always carry the short key and, when the module path was
|
|
41
|
+
* multi-segment, also the long key. parse-impl matches against the
|
|
42
|
+
* long key first and falls back to the short key, so cross-package
|
|
43
|
+
* collisions are eliminated for Shape B and minimised for Shape A.
|
|
44
|
+
*
|
|
45
|
+
* The functions in this module are pure (no Worker / parentPort
|
|
46
|
+
* dependency) so they can be unit-tested directly without booting a
|
|
47
|
+
* worker thread.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* One `<host>.include_router(<routerExpr>, prefix='/x')` site.
|
|
51
|
+
*
|
|
52
|
+
* `routerExpr` is the raw text of the first argument — either
|
|
53
|
+
* `<module>.router` (Shape A) or a bare local name (Shape B).
|
|
54
|
+
* parse-impl resolves Shape B against {@link ExtractedRouterImport}
|
|
55
|
+
* records emitted by the same file.
|
|
56
|
+
*/
|
|
57
|
+
export interface ExtractedRouterInclude {
|
|
58
|
+
filePath: string;
|
|
59
|
+
routerExpr: string;
|
|
60
|
+
prefix: string;
|
|
61
|
+
lineNumber: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* One `from <module> import router [as <alias>]` discovered in a
|
|
65
|
+
* Python file.
|
|
66
|
+
*
|
|
67
|
+
* `moduleKey` is the short key (last `.`-segment of the module path,
|
|
68
|
+
* e.g. `api.users` → `users`). `moduleKeyLong` is the long key (last
|
|
69
|
+
* two segments joined with `/`, e.g. `api/users`); it is the empty
|
|
70
|
+
* string / undefined when the import is single-segment (e.g.
|
|
71
|
+
* `from users import router`) or pure-dots (e.g. `from . import
|
|
72
|
+
* router`). The long key, when present, gives parse-impl a precise
|
|
73
|
+
* way to bind a Shape-B `include_router` call to exactly one Python
|
|
74
|
+
* file even when other packages contain a same-named module.
|
|
75
|
+
*/
|
|
76
|
+
export interface ExtractedRouterImport {
|
|
77
|
+
filePath: string;
|
|
78
|
+
localName: string;
|
|
79
|
+
moduleKey: string;
|
|
80
|
+
moduleKeyLong?: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* One `from <package> import <module>` discovered in a Python file
|
|
84
|
+
* where `<module>` is later used as a Shape-A include receiver
|
|
85
|
+
* (`<host>.include_router(<module>.router, prefix='/x')`). Without
|
|
86
|
+
* this record parse-impl would have to fall back to the short key
|
|
87
|
+
* `<module>`, which collides between e.g. `api/users.py` and
|
|
88
|
+
* `admin/users.py`. The record carries the long key
|
|
89
|
+
* (`<package>/<module>`) so parse-impl can pin the prefix onto the
|
|
90
|
+
* exact source file.
|
|
91
|
+
*
|
|
92
|
+
* Only emitted when the import path was multi-segment (a single
|
|
93
|
+
* `from users import users` would yield no long key). All fields
|
|
94
|
+
* carry the same module-key semantics as
|
|
95
|
+
* {@link ExtractedRouterImport}.
|
|
96
|
+
*/
|
|
97
|
+
export interface ExtractedRouterModuleAlias {
|
|
98
|
+
filePath: string;
|
|
99
|
+
/** Local name in the importing file (== imported name or its alias). */
|
|
100
|
+
localName: string;
|
|
101
|
+
/** Long key (`<parent>/<stem>`) — non-empty for every emitted record. */
|
|
102
|
+
moduleKeyLong: string;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Last `.`-separated segment of a (possibly relative) Python module
|
|
106
|
+
* path. Strips any leading dots first so `from .api.assistant import
|
|
107
|
+
* …` and `from api.assistant import …` both yield `assistant`.
|
|
108
|
+
* Pure-dot inputs (`.`, `..`) have no segment and return the empty
|
|
109
|
+
* string; callers should skip empty results.
|
|
110
|
+
*/
|
|
111
|
+
export declare function lastDottedSegment(text: string): string;
|
|
112
|
+
/**
|
|
113
|
+
* Last two `.`-separated segments of a (possibly relative) module
|
|
114
|
+
* path joined with `/`, e.g. `api.users` → `api/users`. Mirrors the
|
|
115
|
+
* long-key shape used for files (`api/users.py` → `api/users`).
|
|
116
|
+
* Returns the empty string when no parent segment is available
|
|
117
|
+
* (single-segment imports or pure dots); callers should fall back
|
|
118
|
+
* to the short key in that case.
|
|
119
|
+
*/
|
|
120
|
+
export declare function lastTwoSegmentsAsPath(text: string): string;
|
|
121
|
+
/**
|
|
122
|
+
* Scan a single Python file's source text for FastAPI router
|
|
123
|
+
* `include_router` sites and `from <module> import router` imports,
|
|
124
|
+
* appending raw records to the supplied collectors.
|
|
125
|
+
*
|
|
126
|
+
* `outModuleAliases` is optional: when supplied, every multi-segment
|
|
127
|
+
* `from <pkg> import <name>` (other than `router` itself) is recorded
|
|
128
|
+
* as a module alias so parse-impl can pin Shape-A
|
|
129
|
+
* `<name>.include_router(...)` calls onto the exact module file. When
|
|
130
|
+
* omitted, the function preserves the pre-existing behaviour and
|
|
131
|
+
* skips the alias collection — this keeps the function signature
|
|
132
|
+
* back-compat with older callers (and the parse-cache replay path).
|
|
133
|
+
*/
|
|
134
|
+
export declare function extractFastAPIRouterBindings(filePath: string, content: string, outIncludes: ExtractedRouterInclude[], outImports: ExtractedRouterImport[], outModuleAliases?: ExtractedRouterModuleAlias[]): void;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FastAPI router-prefix detection — pure functions, no worker thread.
|
|
3
|
+
*
|
|
4
|
+
* NOT A WORKER. This module exports plain synchronous functions; it
|
|
5
|
+
* does not import `worker_threads`, does not call `parentPort`, and
|
|
6
|
+
* is not a new worker entry point. It lives next to the other route
|
|
7
|
+
* extractors (expo, nextjs, php, laravel) for that reason.
|
|
8
|
+
*
|
|
9
|
+
* The implementation was historically inlined in `workers/parse-worker.ts`,
|
|
10
|
+
* but parse-worker.ts is itself the worker entry point and cannot be
|
|
11
|
+
* loaded from the main thread (see the same constraint used by
|
|
12
|
+
* `test/unit/call-attribution-issue-1166.test.ts`). Splitting the pure
|
|
13
|
+
* extraction here lets unit tests import the function directly without
|
|
14
|
+
* booting a worker, satisfying DoD §2.7.
|
|
15
|
+
*
|
|
16
|
+
* Worker phase is per-file, so the heavy cross-file resolution lives in
|
|
17
|
+
* `pipeline-phases/parse-impl.ts`. Here we only extract two raw record
|
|
18
|
+
* kinds and let the pipeline aggregate them across files:
|
|
19
|
+
*
|
|
20
|
+
* • {@link ExtractedRouterInclude} — every
|
|
21
|
+
* `<host>.include_router(<routerExpr>, prefix='/x')` site, where
|
|
22
|
+
* `<routerExpr>` is either `<module>.router` (Shape A) or a bare
|
|
23
|
+
* local name (Shape B). `<host>` is intentionally unconstrained:
|
|
24
|
+
* production code uses `app`, `api`, `application`, `asgi_app`,
|
|
25
|
+
* etc., and the call shape (`include_router` invoked with a
|
|
26
|
+
* `prefix=` keyword) is specific enough on its own.
|
|
27
|
+
*
|
|
28
|
+
* • {@link ExtractedRouterImport} — every
|
|
29
|
+
* `from <module> import router [as <alias>]`, captured for both
|
|
30
|
+
* absolute and relative module paths (`from .calls import …`).
|
|
31
|
+
* parse-impl uses the imports to resolve Shape-B local names back
|
|
32
|
+
* to the file that declares the router.
|
|
33
|
+
*
|
|
34
|
+
* Module keying is two-tiered to avoid prefix bleed between same-named
|
|
35
|
+
* files in different packages (e.g. `api/users.py` vs `admin/users.py`):
|
|
36
|
+
*
|
|
37
|
+
* • short key — basename without `.py` (`users`)
|
|
38
|
+
* • long key — `<parent-dir>/<basename>` (`api/users`)
|
|
39
|
+
*
|
|
40
|
+
* Imports always carry the short key and, when the module path was
|
|
41
|
+
* multi-segment, also the long key. parse-impl matches against the
|
|
42
|
+
* long key first and falls back to the short key, so cross-package
|
|
43
|
+
* collisions are eliminated for Shape B and minimised for Shape A.
|
|
44
|
+
*
|
|
45
|
+
* The functions in this module are pure (no Worker / parentPort
|
|
46
|
+
* dependency) so they can be unit-tested directly without booting a
|
|
47
|
+
* worker thread.
|
|
48
|
+
*/
|
|
49
|
+
// `<host>.include_router(<module>.router, ..., prefix='/x')` (Shape A).
|
|
50
|
+
// `<host>` is left unrestricted — common production names include
|
|
51
|
+
// `app`, `api`, `application`, `asgi_app`. Pinning to the literal
|
|
52
|
+
// `app` would silently drop these.
|
|
53
|
+
const INCLUDE_ROUTER_ATTR_RE = /\b(?:[A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_][\w]*)\.router\b[^)]*?\bprefix\s*=\s*(['"])([^'"]*)\2/g;
|
|
54
|
+
// `<host>.include_router(<local_name>, ..., prefix='/x')` (Shape B).
|
|
55
|
+
const INCLUDE_ROUTER_NAME_RE = /\b(?:[A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_][\w]*)\b[^)]*?\bprefix\s*=\s*(['"])([^'"]*)\2/g;
|
|
56
|
+
// Module path: a sequence of dots (`.`, `..`, `...`) for "current
|
|
57
|
+
// package" imports, OR an optional leading-dot prefix followed by a
|
|
58
|
+
// dotted identifier (`api.users`, `.api.users`, `..siblings.users`).
|
|
59
|
+
// The latter is the common case and the only one we can map back to
|
|
60
|
+
// a module stem.
|
|
61
|
+
const FROM_IMPORT_ROUTER_RE = /^\s*from\s+(\.+|\.*[A-Za-z_][\w.]*)\s+import\s+([^#\n]+)/gm;
|
|
62
|
+
/**
|
|
63
|
+
* Last `.`-separated segment of a (possibly relative) Python module
|
|
64
|
+
* path. Strips any leading dots first so `from .api.assistant import
|
|
65
|
+
* …` and `from api.assistant import …` both yield `assistant`.
|
|
66
|
+
* Pure-dot inputs (`.`, `..`) have no segment and return the empty
|
|
67
|
+
* string; callers should skip empty results.
|
|
68
|
+
*/
|
|
69
|
+
export function lastDottedSegment(text) {
|
|
70
|
+
const stripped = text.replace(/^\.+/, '');
|
|
71
|
+
if (!stripped)
|
|
72
|
+
return '';
|
|
73
|
+
const dot = stripped.lastIndexOf('.');
|
|
74
|
+
return dot >= 0 ? stripped.slice(dot + 1) : stripped;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Last two `.`-separated segments of a (possibly relative) module
|
|
78
|
+
* path joined with `/`, e.g. `api.users` → `api/users`. Mirrors the
|
|
79
|
+
* long-key shape used for files (`api/users.py` → `api/users`).
|
|
80
|
+
* Returns the empty string when no parent segment is available
|
|
81
|
+
* (single-segment imports or pure dots); callers should fall back
|
|
82
|
+
* to the short key in that case.
|
|
83
|
+
*/
|
|
84
|
+
export function lastTwoSegmentsAsPath(text) {
|
|
85
|
+
const stripped = text.replace(/^\.+/, '');
|
|
86
|
+
if (!stripped)
|
|
87
|
+
return '';
|
|
88
|
+
const last = stripped.lastIndexOf('.');
|
|
89
|
+
if (last <= 0)
|
|
90
|
+
return '';
|
|
91
|
+
const beforeLast = stripped.slice(0, last);
|
|
92
|
+
const stem = stripped.slice(last + 1);
|
|
93
|
+
const prev = beforeLast.lastIndexOf('.');
|
|
94
|
+
const parent = prev >= 0 ? beforeLast.slice(prev + 1) : beforeLast;
|
|
95
|
+
return `${parent}/${stem}`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Scan a single Python file's source text for FastAPI router
|
|
99
|
+
* `include_router` sites and `from <module> import router` imports,
|
|
100
|
+
* appending raw records to the supplied collectors.
|
|
101
|
+
*
|
|
102
|
+
* `outModuleAliases` is optional: when supplied, every multi-segment
|
|
103
|
+
* `from <pkg> import <name>` (other than `router` itself) is recorded
|
|
104
|
+
* as a module alias so parse-impl can pin Shape-A
|
|
105
|
+
* `<name>.include_router(...)` calls onto the exact module file. When
|
|
106
|
+
* omitted, the function preserves the pre-existing behaviour and
|
|
107
|
+
* skips the alias collection — this keeps the function signature
|
|
108
|
+
* back-compat with older callers (and the parse-cache replay path).
|
|
109
|
+
*/
|
|
110
|
+
export function extractFastAPIRouterBindings(filePath, content, outIncludes, outImports, outModuleAliases) {
|
|
111
|
+
if (!content.includes('include_router') && !content.includes('router'))
|
|
112
|
+
return;
|
|
113
|
+
// `from <module> import router [as <alias>]`. We capture every name
|
|
114
|
+
// in the import list. `router` (with or without an `as` alias) maps
|
|
115
|
+
// to outImports; every other name lands in outModuleAliases when a
|
|
116
|
+
// long key is available, so Shape-A `<name>.router` includes can be
|
|
117
|
+
// pinned to the exact module file.
|
|
118
|
+
if (content.includes(' import ')) {
|
|
119
|
+
FROM_IMPORT_ROUTER_RE.lastIndex = 0;
|
|
120
|
+
let m;
|
|
121
|
+
while ((m = FROM_IMPORT_ROUTER_RE.exec(content)) !== null) {
|
|
122
|
+
const moduleText = m[1];
|
|
123
|
+
const importList = m[2];
|
|
124
|
+
const moduleShort = lastDottedSegment(moduleText);
|
|
125
|
+
if (!moduleShort)
|
|
126
|
+
continue;
|
|
127
|
+
// Long key for the imported MODULE itself (used by router
|
|
128
|
+
// imports — `from api.users import router` sets
|
|
129
|
+
// `moduleKeyLong = api/users`).
|
|
130
|
+
const moduleLong = lastTwoSegmentsAsPath(moduleText);
|
|
131
|
+
// Strip surrounding parens / trailing whitespace; split on
|
|
132
|
+
// commas. (Multiline import groups already have their newlines
|
|
133
|
+
// present in the captured list.)
|
|
134
|
+
const cleaned = importList.replace(/[()]/g, '').trim();
|
|
135
|
+
for (const rawPart of cleaned.split(',')) {
|
|
136
|
+
const part = rawPart.trim();
|
|
137
|
+
if (!part)
|
|
138
|
+
continue;
|
|
139
|
+
// `router` or `router as foo` → ExtractedRouterImport.
|
|
140
|
+
const routerAlias = /^router(?:\s+as\s+([A-Za-z_]\w*))?$/.exec(part);
|
|
141
|
+
if (routerAlias) {
|
|
142
|
+
const localName = routerAlias[1] ?? 'router';
|
|
143
|
+
outImports.push({
|
|
144
|
+
filePath,
|
|
145
|
+
localName,
|
|
146
|
+
moduleKey: moduleShort,
|
|
147
|
+
...(moduleLong ? { moduleKeyLong: moduleLong } : {}),
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
// Any other `<name>` or `<name> as <alias>` — recorded as a
|
|
152
|
+
// module alias so parse-impl can pin Shape-A includes. The
|
|
153
|
+
// long key here is computed against the IMPORTED MODULE PATH
|
|
154
|
+
// (`<moduleText>.<name>`), not the package path that `<name>`
|
|
155
|
+
// was imported FROM. `from api import users` therefore yields
|
|
156
|
+
// `api/users`, the same long key as the file it points at.
|
|
157
|
+
if (!outModuleAliases)
|
|
158
|
+
continue;
|
|
159
|
+
const otherAlias = /^([A-Za-z_]\w*)(?:\s+as\s+([A-Za-z_]\w*))?$/.exec(part);
|
|
160
|
+
if (!otherAlias)
|
|
161
|
+
continue;
|
|
162
|
+
const importedName = otherAlias[1];
|
|
163
|
+
const localName = otherAlias[2] ?? importedName;
|
|
164
|
+
const aliasLong = lastTwoSegmentsAsPath(`${moduleText}.${importedName}`);
|
|
165
|
+
if (!aliasLong)
|
|
166
|
+
continue;
|
|
167
|
+
outModuleAliases.push({
|
|
168
|
+
filePath,
|
|
169
|
+
localName,
|
|
170
|
+
moduleKeyLong: aliasLong,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!content.includes('include_router'))
|
|
176
|
+
return;
|
|
177
|
+
// Shape A: `<host>.include_router(<module>.router, prefix='/x')`.
|
|
178
|
+
INCLUDE_ROUTER_ATTR_RE.lastIndex = 0;
|
|
179
|
+
let m;
|
|
180
|
+
while ((m = INCLUDE_ROUTER_ATTR_RE.exec(content)) !== null) {
|
|
181
|
+
outIncludes.push({
|
|
182
|
+
filePath,
|
|
183
|
+
routerExpr: `${m[1]}.router`,
|
|
184
|
+
prefix: m[3],
|
|
185
|
+
lineNumber: content.substring(0, m.index).split('\n').length,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
// Shape B: `<host>.include_router(my_router, prefix='/x')`.
|
|
189
|
+
// Resolution to a module key happens in parse-impl using
|
|
190
|
+
// outImports from the same file.
|
|
191
|
+
INCLUDE_ROUTER_NAME_RE.lastIndex = 0;
|
|
192
|
+
while ((m = INCLUDE_ROUTER_NAME_RE.exec(content)) !== null) {
|
|
193
|
+
// Skip cases that already matched Shape A — INCLUDE_ROUTER_NAME_RE
|
|
194
|
+
// is intentionally permissive and would re-capture `<mod>.router`
|
|
195
|
+
// as the bare name `mod`. Discriminate by re-checking the
|
|
196
|
+
// immediate source around the captured argument position.
|
|
197
|
+
const argStart = m.index + m[0].indexOf(m[1]);
|
|
198
|
+
const dotProbe = content.slice(argStart + m[1].length, argStart + m[1].length + 8);
|
|
199
|
+
if (/^\s*\.\s*router/.test(dotProbe))
|
|
200
|
+
continue;
|
|
201
|
+
outIncludes.push({
|
|
202
|
+
filePath,
|
|
203
|
+
routerExpr: m[1],
|
|
204
|
+
prefix: m[3],
|
|
205
|
+
lineNumber: content.substring(0, m.index).split('\n').length,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SupportedLanguages } from '../../../_shared/index.js';
|
|
2
2
|
import type { ExtractedHeritage } from '../model/heritage-map.js';
|
|
3
|
+
import type { ExtractedRouterInclude, ExtractedRouterImport, ExtractedRouterModuleAlias } from '../route-extractors/fastapi-router-bindings.js';
|
|
3
4
|
import { type MixedChainStep } from '../utils/call-analysis.js';
|
|
4
5
|
import type { ConstructorBinding } from '../type-env.js';
|
|
5
6
|
import type { NamedBinding } from '../named-bindings/types.js';
|
|
@@ -111,6 +112,19 @@ export interface ExtractedDecoratorRoute {
|
|
|
111
112
|
httpMethod: string;
|
|
112
113
|
decoratorName: string;
|
|
113
114
|
lineNumber: number;
|
|
115
|
+
/**
|
|
116
|
+
* Decorator receiver identifier (e.g. `router` for `@router.get(...)`,
|
|
117
|
+
* `app` for `@app.get(...)`). Used by parse-impl to decide which routes
|
|
118
|
+
* participate in `include_router(prefix=...)` joining.
|
|
119
|
+
*/
|
|
120
|
+
decoratorReceiver?: string;
|
|
121
|
+
/**
|
|
122
|
+
* FastAPI `app.include_router(prefix='/x')` prefix that applies to
|
|
123
|
+
* this route. Filled by parse-impl after cross-file aggregation; the
|
|
124
|
+
* routes phase joins it via `normalizeExtractedRoutePath`. `null` /
|
|
125
|
+
* absent ⇒ no prefix applies.
|
|
126
|
+
*/
|
|
127
|
+
prefix?: string | null;
|
|
114
128
|
}
|
|
115
129
|
export interface ExtractedToolDef {
|
|
116
130
|
filePath: string;
|
|
@@ -172,6 +186,18 @@ export interface ParseWorkerResult {
|
|
|
172
186
|
fetchCalls: ExtractedFetchCall[];
|
|
173
187
|
fetchWrapperDefs: FetchWrapperDef[];
|
|
174
188
|
decoratorRoutes: ExtractedDecoratorRoute[];
|
|
189
|
+
routerIncludes: ExtractedRouterInclude[];
|
|
190
|
+
routerImports: ExtractedRouterImport[];
|
|
191
|
+
/**
|
|
192
|
+
* Optional. `from <pkg> import <module>` records from Python files
|
|
193
|
+
* where `<module>` is later used as a Shape-A include receiver
|
|
194
|
+
* (`<host>.include_router(<module>.router, prefix='/x')`). parse-impl
|
|
195
|
+
* uses these to promote Shape-A short-key entries to long keys, so
|
|
196
|
+
* same-named modules in different packages don't share prefixes.
|
|
197
|
+
* Optional for cache backward compatibility (older cache entries
|
|
198
|
+
* predate the field; consumers must guard with `if (… ?? [])`).
|
|
199
|
+
*/
|
|
200
|
+
routerModuleAliases?: ExtractedRouterModuleAlias[];
|
|
175
201
|
toolDefs: ExtractedToolDef[];
|
|
176
202
|
ormQueries: ExtractedORMQuery[];
|
|
177
203
|
constructorBindings: FileConstructorBindings[];
|
|
@@ -434,6 +434,9 @@ const processBatch = (files, onProgress) => {
|
|
|
434
434
|
fetchCalls: [],
|
|
435
435
|
fetchWrapperDefs: [],
|
|
436
436
|
decoratorRoutes: [],
|
|
437
|
+
routerIncludes: [],
|
|
438
|
+
routerImports: [],
|
|
439
|
+
routerModuleAliases: [],
|
|
437
440
|
toolDefs: [],
|
|
438
441
|
ormQueries: [],
|
|
439
442
|
constructorBindings: [],
|
|
@@ -649,6 +652,16 @@ export function extractORMQueries(filePath, content, out) {
|
|
|
649
652
|
}
|
|
650
653
|
}
|
|
651
654
|
}
|
|
655
|
+
// ============================================================================
|
|
656
|
+
// FastAPI router prefix detection (Python)
|
|
657
|
+
// ============================================================================
|
|
658
|
+
//
|
|
659
|
+
// The extraction lives in `../route-extractors/fastapi-router-bindings`
|
|
660
|
+
// (a pure-function module — NOT a worker, no `worker_threads`, no
|
|
661
|
+
// `parentPort`). It's imported here only so the worker entry can call it
|
|
662
|
+
// per file; this module does not re-export it. Downstream consumers
|
|
663
|
+
// import the function and its types directly from `route-extractors/`.
|
|
664
|
+
import { extractFastAPIRouterBindings } from '../route-extractors/fastapi-router-bindings.js';
|
|
652
665
|
const processFileGroup = (files, language, queryString, result, onFileProcessed) => {
|
|
653
666
|
let query;
|
|
654
667
|
try {
|
|
@@ -849,6 +862,7 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
|
|
|
849
862
|
if (captureMap['decorator'] && captureMap['decorator.name']) {
|
|
850
863
|
const decoratorName = captureMap['decorator.name'].text;
|
|
851
864
|
const decoratorArg = captureMap['decorator.arg']?.text;
|
|
865
|
+
const decoratorReceiver = captureMap['decorator.receiver']?.text;
|
|
852
866
|
const decoratorNode = captureMap['decorator'];
|
|
853
867
|
// Store by the decorator's end line — the definition follows immediately after
|
|
854
868
|
fileDecorators.set(decoratorNode.endPosition.row, {
|
|
@@ -867,6 +881,7 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
|
|
|
867
881
|
httpMethod,
|
|
868
882
|
decoratorName,
|
|
869
883
|
lineNumber: decoratorNode.startPosition.row + lineOffset,
|
|
884
|
+
...(decoratorReceiver ? { decoratorReceiver } : {}),
|
|
870
885
|
});
|
|
871
886
|
}
|
|
872
887
|
// MCP/RPC tool detection: @mcp.tool(), @app.tool(), @server.tool()
|
|
@@ -1546,6 +1561,13 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
|
|
|
1546
1561
|
}
|
|
1547
1562
|
// Extract ORM queries (Prisma, Supabase)
|
|
1548
1563
|
extractORMQueries(file.path, parseContent, result.ormQueries);
|
|
1564
|
+
// Extract FastAPI include_router(prefix=...) and `from <mod> import router`
|
|
1565
|
+
// sites. parse-impl aggregates these into a per-module prefix map and
|
|
1566
|
+
// injects the resolved prefix onto each ExtractedDecoratorRoute that
|
|
1567
|
+
// came from a `@router.<verb>` decorator. Python-only.
|
|
1568
|
+
if (language === SupportedLanguages.Python) {
|
|
1569
|
+
extractFastAPIRouterBindings(file.path, parseContent, result.routerIncludes, result.routerImports, (result.routerModuleAliases ??= []));
|
|
1570
|
+
}
|
|
1549
1571
|
// Vue: emit CALLS edges for components used in <template>
|
|
1550
1572
|
if (language === SupportedLanguages.Vue) {
|
|
1551
1573
|
const templateComponents = extractTemplateComponents(file.content);
|
|
@@ -1576,6 +1598,9 @@ let accumulated = {
|
|
|
1576
1598
|
fetchCalls: [],
|
|
1577
1599
|
fetchWrapperDefs: [],
|
|
1578
1600
|
decoratorRoutes: [],
|
|
1601
|
+
routerIncludes: [],
|
|
1602
|
+
routerImports: [],
|
|
1603
|
+
routerModuleAliases: [],
|
|
1579
1604
|
toolDefs: [],
|
|
1580
1605
|
ormQueries: [],
|
|
1581
1606
|
constructorBindings: [],
|
|
@@ -1604,6 +1629,14 @@ const mergeResult = (target, src) => {
|
|
|
1604
1629
|
appendAll(target.fetchCalls, src.fetchCalls);
|
|
1605
1630
|
appendAll(target.fetchWrapperDefs, src.fetchWrapperDefs);
|
|
1606
1631
|
appendAll(target.decoratorRoutes, src.decoratorRoutes);
|
|
1632
|
+
if (src.routerIncludes)
|
|
1633
|
+
appendAll(target.routerIncludes, src.routerIncludes);
|
|
1634
|
+
if (src.routerImports)
|
|
1635
|
+
appendAll(target.routerImports, src.routerImports);
|
|
1636
|
+
if (src.routerModuleAliases) {
|
|
1637
|
+
target.routerModuleAliases ??= [];
|
|
1638
|
+
appendAll(target.routerModuleAliases, src.routerModuleAliases);
|
|
1639
|
+
}
|
|
1607
1640
|
appendAll(target.toolDefs, src.toolDefs);
|
|
1608
1641
|
appendAll(target.ormQueries, src.ormQueries);
|
|
1609
1642
|
appendAll(target.constructorBindings, src.constructorBindings);
|
|
@@ -1687,6 +1720,9 @@ parentPort.on('message', (msg) => {
|
|
|
1687
1720
|
fetchCalls: [],
|
|
1688
1721
|
fetchWrapperDefs: [],
|
|
1689
1722
|
decoratorRoutes: [],
|
|
1723
|
+
routerIncludes: [],
|
|
1724
|
+
routerImports: [],
|
|
1725
|
+
routerModuleAliases: [],
|
|
1690
1726
|
toolDefs: [],
|
|
1691
1727
|
ormQueries: [],
|
|
1692
1728
|
constructorBindings: [],
|
package/package.json
CHANGED