gitnexus 1.6.6-rc.80 → 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/languages/cpp/adl.d.ts +14 -21
- package/dist/core/ingestion/languages/cpp/adl.js +116 -43
- package/dist/core/ingestion/languages/cpp/captures.js +30 -0
- 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/scope-extractor.js +1 -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;
|
|
@@ -24,22 +24,18 @@
|
|
|
24
24
|
* V2 additionally walks class ancestors (via MRO), so base-class enclosing
|
|
25
25
|
* namespaces also contribute associated namespaces.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* set,
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* workspace lookup (only contributed when a Function/Method named `worker` exists
|
|
40
|
-
* in `utils`). For unqualified refs the workspace is searched for any Function
|
|
41
|
-
* def with that simple name. Locally-declared function-pointer variables
|
|
42
|
-
* (e.g. `void (*g)()`) and function parameters are excluded from this path.
|
|
27
|
+
* Function-reference arguments follow ISO C++ `[basic.lookup.argdep]`:
|
|
28
|
+
* associated entities come from the parameter types and return type of each
|
|
29
|
+
* referenced function in the overload set, not from the function's enclosing
|
|
30
|
+
* namespace. For `void worker()`, the associated set is empty. For
|
|
31
|
+
* `void worker(api::Token)` or `api::Token make_token()`, `api` is associated
|
|
32
|
+
* through `Token`.
|
|
33
|
+
*
|
|
34
|
+
* For qualified refs (e.g. `utils::worker`) the workspace lookup is restricted
|
|
35
|
+
* to functions/methods named `worker` in `utils`; for unqualified refs the
|
|
36
|
+
* workspace is searched for matching functions/methods by simple name. Locally
|
|
37
|
+
* declared function-pointer variables and function parameters are excluded
|
|
38
|
+
* from this path.
|
|
43
39
|
*
|
|
44
40
|
* ADL candidates are merged with ordinary unqualified-lookup candidates
|
|
45
41
|
* in the free-call fallback before overload narrowing.
|
|
@@ -94,11 +90,8 @@ export interface CppAdlArgInfo {
|
|
|
94
90
|
/** When set, the arg is a potential free-function reference (not a locally-
|
|
95
91
|
* declared function-pointer variable or function parameter). Contains the
|
|
96
92
|
* identifier text as written in source (e.g. `"utils::worker"` or
|
|
97
|
-
* `"worker"`).
|
|
98
|
-
*
|
|
99
|
-
* lookup confirms a Function/Method with that simple name exists in the
|
|
100
|
-
* namespace before contributing; for unqualified refs every namespace
|
|
101
|
-
* containing a matching Function/Method def is contributed. */
|
|
93
|
+
* `"worker"`). Resolution contributes associated namespaces from each
|
|
94
|
+
* referenced Function/Method def's parameter and return types. */
|
|
102
95
|
readonly functionRefText?: string;
|
|
103
96
|
}
|
|
104
97
|
/** Record per-call-site argument info. Called once per call site from
|