gitnexus 1.6.9-rc.3 → 1.6.9-rc.4
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 +221 -1
- package/dist/core/group/matching.js +23 -7
- package/dist/core/ingestion/language-provider.d.ts +23 -2
- package/dist/core/ingestion/languages/python.js +9 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +19 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +125 -1
- package/dist/core/ingestion/pipeline-phases/routes.js +5 -0
- package/dist/core/ingestion/route-extractors/django-root-discovery.d.ts +25 -0
- package/dist/core/ingestion/route-extractors/django-root-discovery.js +217 -0
- package/dist/core/ingestion/route-extractors/django.d.ts +4 -0
- package/dist/core/ingestion/route-extractors/django.js +425 -0
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-s
|
|
|
3
3
|
/**
|
|
4
4
|
* Python HTTP plugin. Handles:
|
|
5
5
|
* - FastAPI `@app.get("/path")` provider decorators
|
|
6
|
+
* - Django `path("route/", view)` provider calls
|
|
6
7
|
* - `requests.get/post/...("url")` consumer calls
|
|
7
8
|
* - Generic `requests.request("METHOD", "url")` consumer calls
|
|
8
9
|
* - `httpx.AsyncClient` instances calling `.get/.post/...("url")`, including
|
|
@@ -41,6 +42,13 @@ const FASTAPI_APP_PATTERNS = compilePatterns({
|
|
|
41
42
|
},
|
|
42
43
|
],
|
|
43
44
|
});
|
|
45
|
+
// NOTE: Django providers are NOT extracted by this per-file source scan.
|
|
46
|
+
// A standalone scan of `path()`/`re_path()` calls cannot tell a route from an
|
|
47
|
+
// `include()` mount point, nor compose the include() prefix across files, so it
|
|
48
|
+
// emitted bogus fragments (e.g. `/api` for a mount and `/items` un-prefixed
|
|
49
|
+
// instead of the real `/api/items`). Django provider contracts come from the
|
|
50
|
+
// graph Route nodes, which the ingestion route extractor builds with the
|
|
51
|
+
// includes already composed.
|
|
44
52
|
const FASTAPI_ROUTER_PATTERNS = compilePatterns({
|
|
45
53
|
name: 'python-fastapi-router',
|
|
46
54
|
language: Python,
|
|
@@ -168,7 +176,7 @@ const FROM_IMPORT_MODULE_PATTERNS = compilePatterns({
|
|
|
168
176
|
},
|
|
169
177
|
],
|
|
170
178
|
});
|
|
171
|
-
// ─── Consumer: requests.get/post/...
|
|
179
|
+
// ─── Consumer: requests.get/post/...("literal") ──────────────────────
|
|
172
180
|
const REQUESTS_VERB_PATTERNS = compilePatterns({
|
|
173
181
|
name: 'python-requests-verb',
|
|
174
182
|
language: Python,
|
|
@@ -185,6 +193,26 @@ const REQUESTS_VERB_PATTERNS = compilePatterns({
|
|
|
185
193
|
},
|
|
186
194
|
],
|
|
187
195
|
});
|
|
196
|
+
// ─── Consumer: requests.get/post/...(url=VALUE) keyword ──────────────
|
|
197
|
+
const REQUESTS_KEYWORD_URL_PATTERNS = compilePatterns({
|
|
198
|
+
name: 'python-requests-keyword-url',
|
|
199
|
+
language: Python,
|
|
200
|
+
patterns: [
|
|
201
|
+
{
|
|
202
|
+
meta: {},
|
|
203
|
+
query: `
|
|
204
|
+
(call
|
|
205
|
+
function: (attribute
|
|
206
|
+
object: (identifier) @obj (#eq? @obj "requests")
|
|
207
|
+
attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$"))
|
|
208
|
+
arguments: (argument_list
|
|
209
|
+
(keyword_argument
|
|
210
|
+
name: (identifier) @kw (#eq? @kw "url")
|
|
211
|
+
value: (string) @path)))
|
|
212
|
+
`,
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
});
|
|
188
216
|
// ─── Consumer: requests.request("METHOD", "url") ─────────────────────
|
|
189
217
|
const REQUESTS_GENERIC_PATTERNS = compilePatterns({
|
|
190
218
|
name: 'python-requests-generic',
|
|
@@ -202,6 +230,97 @@ const REQUESTS_GENERIC_PATTERNS = compilePatterns({
|
|
|
202
230
|
},
|
|
203
231
|
],
|
|
204
232
|
});
|
|
233
|
+
// ─── Consumer: wrapper classes with uri= or url= keyword argument ──────
|
|
234
|
+
// Common pattern: wrapper classes like RequestFetch that accept URL via
|
|
235
|
+
// named argument instead of positional argument:
|
|
236
|
+
// obj.fetch(uri="api/v1/camera/info/")
|
|
237
|
+
// obj.get(url="api/v1/camera/info/")
|
|
238
|
+
// obj.post(uri="api/v1/config/update/")
|
|
239
|
+
const WRAPPER_URI_PATTERNS = compilePatterns({
|
|
240
|
+
name: 'python-http-wrapper-uri',
|
|
241
|
+
language: Python,
|
|
242
|
+
patterns: [
|
|
243
|
+
{
|
|
244
|
+
meta: {},
|
|
245
|
+
// Match any method call where keyword argument is `uri` or `url`
|
|
246
|
+
query: `
|
|
247
|
+
(call
|
|
248
|
+
function: (attribute
|
|
249
|
+
object: (_) @client
|
|
250
|
+
attribute: (identifier) @method)
|
|
251
|
+
arguments: (argument_list
|
|
252
|
+
(keyword_argument
|
|
253
|
+
name: (identifier) @kw (#match? @kw "^(uri|url)$")
|
|
254
|
+
value: (string) @path)))
|
|
255
|
+
`,
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
});
|
|
259
|
+
// Map wrapper method names to HTTP verbs
|
|
260
|
+
const WRAPPER_METHOD_TO_HTTP = {
|
|
261
|
+
get: 'GET',
|
|
262
|
+
post: 'POST',
|
|
263
|
+
put: 'PUT',
|
|
264
|
+
delete: 'DELETE',
|
|
265
|
+
patch: 'PATCH',
|
|
266
|
+
fetch: 'GET',
|
|
267
|
+
request: 'GET',
|
|
268
|
+
};
|
|
269
|
+
// ─── Variable-to-string propagation patterns ─────────────────────────
|
|
270
|
+
// Many repos assign URL paths to local variables then pass them as
|
|
271
|
+
// keyword arguments: uri = "api/v1/endpoint/"; obj.fetch(uri=uri, body)
|
|
272
|
+
// These patterns + buildLocalStringMap resolve the variable → literal chain.
|
|
273
|
+
// Track local string constants: uri = "api/v1/endpoint/"
|
|
274
|
+
const LOCAL_STRING_ASSIGNMENTS = compilePatterns({
|
|
275
|
+
name: 'python-local-string-assign',
|
|
276
|
+
language: Python,
|
|
277
|
+
patterns: [
|
|
278
|
+
{
|
|
279
|
+
meta: {},
|
|
280
|
+
query: `
|
|
281
|
+
(assignment
|
|
282
|
+
left: (identifier) @var_name
|
|
283
|
+
right: (string) @var_value)
|
|
284
|
+
`,
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
// Match method calls where uri=/url= value is a variable that was previously
|
|
289
|
+
// assigned a string literal
|
|
290
|
+
const WRAPPER_URI_VAR_PATTERNS = compilePatterns({
|
|
291
|
+
name: 'python-http-wrapper-uri-var',
|
|
292
|
+
language: Python,
|
|
293
|
+
patterns: [
|
|
294
|
+
{
|
|
295
|
+
meta: {},
|
|
296
|
+
query: `
|
|
297
|
+
(call
|
|
298
|
+
function: (attribute
|
|
299
|
+
object: (_) @client
|
|
300
|
+
attribute: (identifier) @method)
|
|
301
|
+
arguments: (argument_list
|
|
302
|
+
(keyword_argument
|
|
303
|
+
name: (identifier) @kw (#match? @kw "^(uri|url)$")
|
|
304
|
+
value: (identifier) @path_var)))
|
|
305
|
+
`,
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
});
|
|
309
|
+
// Pre-scan: collect local string assignments (uri = "api/v1/endpoint/")
|
|
310
|
+
function buildLocalStringMap(tree) {
|
|
311
|
+
const map = new Map();
|
|
312
|
+
for (const match of runCompiledPatterns(LOCAL_STRING_ASSIGNMENTS, tree)) {
|
|
313
|
+
const varNode = match.captures.var_name;
|
|
314
|
+
const valNode = match.captures.var_value;
|
|
315
|
+
if (!varNode || !valNode)
|
|
316
|
+
continue;
|
|
317
|
+
const val = unquoteLiteral(valNode.text);
|
|
318
|
+
if (val === null)
|
|
319
|
+
continue;
|
|
320
|
+
map.set(varNode.text, val);
|
|
321
|
+
}
|
|
322
|
+
return map;
|
|
323
|
+
}
|
|
205
324
|
// ─── Consumer: httpx.AsyncClient assignments ────────────────────────
|
|
206
325
|
// Module-scope clients are only matched
|
|
207
326
|
// at module scope; calls inside functions require a function/class-local tracked
|
|
@@ -734,6 +853,9 @@ export const PYTHON_HTTP_PLUGIN = {
|
|
|
734
853
|
confidence: 0.8,
|
|
735
854
|
});
|
|
736
855
|
}
|
|
856
|
+
// Django providers come from the graph Route nodes (includes composed by
|
|
857
|
+
// the ingestion route extractor), not a per-file source scan — see the note
|
|
858
|
+
// at the top of this file.
|
|
737
859
|
// Providers: FastAPI @router.<verb>("/path") — must be joined
|
|
738
860
|
// with the prefix(es) declared at the include_router site. When
|
|
739
861
|
// no prefix is found we still emit the unprefixed path so this
|
|
@@ -791,6 +913,24 @@ export const PYTHON_HTTP_PLUGIN = {
|
|
|
791
913
|
confidence: 0.7,
|
|
792
914
|
});
|
|
793
915
|
}
|
|
916
|
+
// Consumers: requests.<verb>(url="literal") keyword
|
|
917
|
+
for (const match of runCompiledPatterns(REQUESTS_KEYWORD_URL_PATTERNS, tree)) {
|
|
918
|
+
const methodNode = match.captures.method;
|
|
919
|
+
const pathNode = match.captures.path;
|
|
920
|
+
if (!methodNode || !pathNode)
|
|
921
|
+
continue;
|
|
922
|
+
const path = unquoteLiteral(pathNode.text);
|
|
923
|
+
if (path === null)
|
|
924
|
+
continue;
|
|
925
|
+
out.push({
|
|
926
|
+
role: 'consumer',
|
|
927
|
+
framework: 'python-requests',
|
|
928
|
+
method: methodNode.text.toUpperCase(),
|
|
929
|
+
path,
|
|
930
|
+
name: null,
|
|
931
|
+
confidence: 0.7,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
794
934
|
// Consumers: requests.request("METHOD", "url")
|
|
795
935
|
for (const match of runCompiledPatterns(REQUESTS_GENERIC_PATTERNS, tree)) {
|
|
796
936
|
const methodNode = match.captures.http_method;
|
|
@@ -853,6 +993,86 @@ export const PYTHON_HTTP_PLUGIN = {
|
|
|
853
993
|
confidence: 0.7,
|
|
854
994
|
});
|
|
855
995
|
}
|
|
996
|
+
// Consumers: wrapper classes with uri= or url= keyword argument
|
|
997
|
+
// obj.fetch(uri="api/v1/camera/info/")
|
|
998
|
+
// obj.post(url="api/v1/config/update/")
|
|
999
|
+
const seenUriDetections = new Set(); // node byte ranges, to avoid duplicates
|
|
1000
|
+
for (const match of runCompiledPatterns(WRAPPER_URI_PATTERNS, tree)) {
|
|
1001
|
+
const methodNode = match.captures.method;
|
|
1002
|
+
const pathNode = match.captures.path;
|
|
1003
|
+
if (!methodNode || !pathNode)
|
|
1004
|
+
continue;
|
|
1005
|
+
const path = unquoteLiteral(pathNode.text);
|
|
1006
|
+
if (path === null)
|
|
1007
|
+
continue;
|
|
1008
|
+
// Deduplicate: the two pattern branches can match the same call. Key on
|
|
1009
|
+
// node byte offsets, not line arithmetic (lineNum*1000+row can collide in
|
|
1010
|
+
// files over 1000 lines, and miss a real dup when a node straddles a line).
|
|
1011
|
+
const dedupKey = `${pathNode.startIndex}:${methodNode.startIndex}`;
|
|
1012
|
+
if (seenUriDetections.has(dedupKey))
|
|
1013
|
+
continue;
|
|
1014
|
+
seenUriDetections.add(dedupKey);
|
|
1015
|
+
const methodName = methodNode.text.toLowerCase();
|
|
1016
|
+
// Map wrapper method name to HTTP verb (fetch, request → GET)
|
|
1017
|
+
const httpMethod = WRAPPER_METHOD_TO_HTTP[methodName] ?? 'GET';
|
|
1018
|
+
out.push({
|
|
1019
|
+
role: 'consumer',
|
|
1020
|
+
framework: 'python-http-wrapper',
|
|
1021
|
+
method: httpMethod,
|
|
1022
|
+
path,
|
|
1023
|
+
name: null,
|
|
1024
|
+
confidence: 0.65,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
// Variable propagation: uri = "api/v1/endpoint/"; obj.fetch(uri=uri)
|
|
1028
|
+
// Many repos assign URL paths to local vars then pass as keyword args.
|
|
1029
|
+
const localStrings = buildLocalStringMap(tree);
|
|
1030
|
+
const seenVarDetections = new Set();
|
|
1031
|
+
for (const match of runCompiledPatterns(WRAPPER_URI_VAR_PATTERNS, tree)) {
|
|
1032
|
+
const methodNode = match.captures.method;
|
|
1033
|
+
const pathVarNode = match.captures.path_var;
|
|
1034
|
+
if (!methodNode || !pathVarNode)
|
|
1035
|
+
continue;
|
|
1036
|
+
const dedupKey = `${pathVarNode.startPosition.row}:${methodNode.startPosition.row}`;
|
|
1037
|
+
if (seenVarDetections.has(dedupKey))
|
|
1038
|
+
continue;
|
|
1039
|
+
seenVarDetections.add(dedupKey);
|
|
1040
|
+
const resolved = localStrings.get(pathVarNode.text);
|
|
1041
|
+
if (!resolved)
|
|
1042
|
+
continue;
|
|
1043
|
+
const normalized = normalizeConsumerPath(resolved);
|
|
1044
|
+
if (normalized === '/')
|
|
1045
|
+
continue;
|
|
1046
|
+
const httpMethod = WRAPPER_METHOD_TO_HTTP[methodNode.text.toLowerCase()] ?? 'GET';
|
|
1047
|
+
out.push({
|
|
1048
|
+
role: 'consumer',
|
|
1049
|
+
framework: 'python-http-wrapper',
|
|
1050
|
+
method: httpMethod,
|
|
1051
|
+
path: normalized,
|
|
1052
|
+
name: null,
|
|
1053
|
+
confidence: 0.6,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
856
1056
|
return out;
|
|
857
1057
|
},
|
|
858
1058
|
};
|
|
1059
|
+
/** Normalize consumer path: strip host, template literals, numeric segments → {param} */
|
|
1060
|
+
function normalizeConsumerPath(url) {
|
|
1061
|
+
let s = url.replace(/\$\{[^}]+\}/g, '{param}').trim();
|
|
1062
|
+
if (/^https?:\/\//i.test(s)) {
|
|
1063
|
+
try {
|
|
1064
|
+
s = new URL(s).pathname;
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
s = s.replace(/^https?:\/\/[^/]+/i, '');
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (!s.startsWith('/'))
|
|
1071
|
+
s = '/' + s;
|
|
1072
|
+
const segments = s
|
|
1073
|
+
.split('/')
|
|
1074
|
+
.filter(Boolean)
|
|
1075
|
+
.map((seg) => (/^\d+$/.test(seg) ? '{param}' : seg));
|
|
1076
|
+
s = '/' + segments.join('/');
|
|
1077
|
+
return s.replace(/\/+$/, '') || '/';
|
|
1078
|
+
}
|
|
@@ -101,18 +101,34 @@ export function normalizeContractId(id) {
|
|
|
101
101
|
}
|
|
102
102
|
function findMatchingKeys(contractId, index) {
|
|
103
103
|
const normalized = normalizeContractId(contractId);
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
104
|
+
if (normalized.startsWith('http::')) {
|
|
105
|
+
const rest = normalized.substring('http::'.length);
|
|
106
|
+
const sepIdx = rest.indexOf('::');
|
|
107
|
+
const method = sepIdx >= 0 ? rest.substring(0, sepIdx) : '';
|
|
108
|
+
const pathPart = sepIdx >= 0 ? rest.substring(sepIdx + 2) : rest;
|
|
108
109
|
const matches = [];
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
if (method === '*') {
|
|
111
|
+
// Wildcard consumer: match a provider of any method on this path.
|
|
112
|
+
for (const key of index.keys()) {
|
|
113
|
+
if (key.startsWith('http::') && key.endsWith(`::${pathPart}`)) {
|
|
114
|
+
matches.push(key);
|
|
115
|
+
}
|
|
112
116
|
}
|
|
117
|
+
return matches;
|
|
113
118
|
}
|
|
119
|
+
// Specific consumer: match an exact-method provider OR a method-agnostic
|
|
120
|
+
// (`*`) provider on the same path — symmetric to the wildcard-consumer case,
|
|
121
|
+
// so a `POST /x` consumer still matches a method-agnostic (e.g. Django)
|
|
122
|
+
// provider for `/x`.
|
|
123
|
+
if (index.has(normalized))
|
|
124
|
+
matches.push(normalized);
|
|
125
|
+
const wildcardKey = `http::*::${pathPart}`;
|
|
126
|
+
if (index.has(wildcardKey))
|
|
127
|
+
matches.push(wildcardKey);
|
|
114
128
|
return matches;
|
|
115
129
|
}
|
|
130
|
+
if (index.has(normalized))
|
|
131
|
+
return [normalized];
|
|
116
132
|
if (normalized.startsWith('thrift::')) {
|
|
117
133
|
const rest = normalized.substring('thrift::'.length);
|
|
118
134
|
const slashIdx = rest.indexOf('/');
|
|
@@ -21,6 +21,7 @@ import type { ImportResolverFn } from './import-resolvers/types.js';
|
|
|
21
21
|
import type { SyntaxNode } from './utils/ast-helpers.js';
|
|
22
22
|
import type { CfgVisitor } from './cfg/types.js';
|
|
23
23
|
import type { NodeLabel } from '../../_shared/index.js';
|
|
24
|
+
import type { ExtractedRoute } from './route-extractors/laravel.js';
|
|
24
25
|
import type Parser from 'tree-sitter';
|
|
25
26
|
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
|
26
27
|
/** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
|
|
@@ -190,10 +191,30 @@ interface LanguageProviderConfig {
|
|
|
190
191
|
* property arrays, relation method descriptions).
|
|
191
192
|
* Default: undefined (no description extraction). */
|
|
192
193
|
readonly descriptionExtractor?: (nodeLabel: NodeLabel, nodeName: string, captureMap: CaptureMap) => string | undefined;
|
|
193
|
-
/** Detect if a file contains framework route definitions
|
|
194
|
-
* When true, the worker extracts
|
|
194
|
+
/** Detect if a file contains single-file framework route definitions
|
|
195
|
+
* (e.g., Laravel `routes/*.php`). When true, the parse worker extracts
|
|
196
|
+
* routes from that file in isolation via the worker's route logic.
|
|
195
197
|
* Default: undefined (no route files). */
|
|
196
198
|
readonly isRouteFile?: (filePath: string) => boolean;
|
|
199
|
+
/** Discover the root route file(s) for a whole-repo, cross-file routing
|
|
200
|
+
* framework (e.g. Django: manage.py → settings → ROOT_URLCONF → root urls.py).
|
|
201
|
+
* Runs once on the main thread after all files are scanned. `reader` resolves
|
|
202
|
+
* arbitrary repo-relative paths (in-memory map, then disk) so discovery never
|
|
203
|
+
* depends on which parse chunk a file landed in. Returns one repo-relative
|
|
204
|
+
* path per discoverable project (empty when the framework is absent) — a
|
|
205
|
+
* monorepo with several projects yields each project's root.
|
|
206
|
+
* Pairs with `extractRoutes`; languages with this hook are skipped by the
|
|
207
|
+
* worker's single-file `isRouteFile` path. */
|
|
208
|
+
readonly discoverRootRouteFiles?: (files: Array<{
|
|
209
|
+
path: string;
|
|
210
|
+
content?: string;
|
|
211
|
+
}>, contentMap?: Map<string, string>, reader?: (relativePath: string) => string | null) => string[];
|
|
212
|
+
/** Extract routes from a root route file, following cross-file includes via
|
|
213
|
+
* `reader`. Runs on the main thread (never in the worker, which has no
|
|
214
|
+
* filesystem access). `parser` is a tree-sitter parser preloaded with this
|
|
215
|
+
* language's grammar, available for re-parsing included files.
|
|
216
|
+
* Default: undefined (no route extraction). */
|
|
217
|
+
readonly extractRoutes?: (tree: Parser.Tree, filePath: string, reader: (relativePath: string) => string | null, parser?: Parser | null) => ExtractedRoute[];
|
|
197
218
|
/**
|
|
198
219
|
* Extract decorator-style route annotations from a parsed file.
|
|
199
220
|
*
|
|
@@ -26,6 +26,8 @@ import { createCallExtractor } from '../call-extractors/generic.js';
|
|
|
26
26
|
import { pythonCallConfig } from '../call-extractors/configs/python.js';
|
|
27
27
|
import { createPythonCfgVisitor } from '../cfg/visitors/python.js';
|
|
28
28
|
import { emitPythonScopeCaptures, pythonFunctionDefinitionLabel, interpretPythonImport, interpretPythonTypeBinding, pythonArityCompatibility, pythonBindingScopeFor, pythonImportOwningScope, pythonMergeBindings, pythonReceiverBinding, resolvePythonImportTarget, } from './python/index.js';
|
|
29
|
+
import { extractDjangoRoutes } from '../route-extractors/django.js';
|
|
30
|
+
import { discoverDjangoRootUrls } from '../route-extractors/django-root-discovery.js';
|
|
29
31
|
const BUILT_INS = new Set([
|
|
30
32
|
'print',
|
|
31
33
|
'len',
|
|
@@ -110,6 +112,13 @@ export const pythonProvider = defineLanguage({
|
|
|
110
112
|
classExtractor: createClassExtractor(pythonClassConfig),
|
|
111
113
|
descriptionExtractor: pythonDescriptionExtractor,
|
|
112
114
|
builtInNames: BUILT_INS,
|
|
115
|
+
// Django routing is whole-repo and cross-file (manage.py → settings →
|
|
116
|
+
// ROOT_URLCONF → root urls.py, then include()s across files), so it runs as
|
|
117
|
+
// a main-thread pass (see parse-impl's cross-file route extraction) rather
|
|
118
|
+
// than the worker's single-file `isRouteFile` path. `reader` lets discovery
|
|
119
|
+
// and extraction resolve any repo-relative file regardless of parse chunking.
|
|
120
|
+
discoverRootRouteFiles: (files, contentMap, reader) => discoverDjangoRootUrls(files, contentMap, reader),
|
|
121
|
+
extractRoutes: (tree, filePath, reader, parser) => parser ? extractDjangoRoutes(tree, filePath, parser, reader) : [],
|
|
113
122
|
labelOverride: pythonFunctionDefinitionLabel,
|
|
114
123
|
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
|
|
115
124
|
// Python is the first migration. See ./python/index.ts for the
|
|
@@ -26,6 +26,25 @@ type ScannedFile = {
|
|
|
26
26
|
size: number;
|
|
27
27
|
};
|
|
28
28
|
type ProgressFn = (progress: PipelineProgress) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Whole-repo, cross-file route extraction (main thread).
|
|
31
|
+
*
|
|
32
|
+
* Some frameworks define their route table from a single root file that pulls
|
|
33
|
+
* in other files across the repo — e.g. Django follows
|
|
34
|
+
* `manage.py → DJANGO_SETTINGS_MODULE → ROOT_URLCONF → root urls.py`, then walks
|
|
35
|
+
* `include()` chains across many files. Unlike single-file route files (Laravel
|
|
36
|
+
* `routes/*.php`), which the parse worker extracts in isolation, these need a
|
|
37
|
+
* whole-repo view and on-demand cross-file reads — neither of which the
|
|
38
|
+
* filesystem-free worker can provide, and which a per-chunk worker view gets
|
|
39
|
+
* wrong whenever the root file and its includes land in different chunks.
|
|
40
|
+
*
|
|
41
|
+
* So it runs here, once, after every file is scanned — mirroring the FastAPI
|
|
42
|
+
* router-include join further below. The pass is language-agnostic: any
|
|
43
|
+
* {@link LanguageProvider} exposing both `discoverRootRouteFile` and
|
|
44
|
+
* `extractRoutes` participates (today only Python/Django). For repos without
|
|
45
|
+
* such a framework the cost is a path scan plus one `manage.py`-style miss.
|
|
46
|
+
*/
|
|
47
|
+
export declare function extractCrossFileRoutes(allPaths: string[], repoPath: string): Promise<ExtractedRoute[]>;
|
|
29
48
|
/**
|
|
30
49
|
* Chunked parse + resolve loop.
|
|
31
50
|
*
|
|
@@ -23,7 +23,9 @@ import { processRoutesFromExtracted, buildExportedTypeMapFromGraph, } from '../c
|
|
|
23
23
|
import { createSemanticModel } from '../model/index.js';
|
|
24
24
|
import { getLanguageFromFilename, } from '../../../_shared/index.js';
|
|
25
25
|
import { readFileContents } from '../filesystem-walker.js';
|
|
26
|
-
import { isLanguageAvailable, isGrammarRuntimeSkipped } from '../../tree-sitter/parser-loader.js';
|
|
26
|
+
import { isLanguageAvailable, isGrammarRuntimeSkipped, createParserForLanguage, } from '../../tree-sitter/parser-loader.js';
|
|
27
|
+
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
|
|
28
|
+
import { getProvider, providers } from '../languages/index.js';
|
|
27
29
|
import { createWorkerPool, workerPoolDisabledByEnv, resolveAutoPoolSize, WorkerPoolInitializationError, WorkerPoolDisabledError, } from '../workers/worker-pool.js';
|
|
28
30
|
import fs from 'node:fs';
|
|
29
31
|
import path from 'node:path';
|
|
@@ -85,6 +87,116 @@ function resolveChunkByteBudget(options, effectivePoolSize = 1) {
|
|
|
85
87
|
// single-worker (tiny-repo) run keeps the original 2 MB invalidation floor.
|
|
86
88
|
return Math.max(DEFAULT_CHUNK_BYTE_BUDGET, effectivePoolSize * CHUNK_BYTES_PER_WORKER);
|
|
87
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Whole-repo, cross-file route extraction (main thread).
|
|
92
|
+
*
|
|
93
|
+
* Some frameworks define their route table from a single root file that pulls
|
|
94
|
+
* in other files across the repo — e.g. Django follows
|
|
95
|
+
* `manage.py → DJANGO_SETTINGS_MODULE → ROOT_URLCONF → root urls.py`, then walks
|
|
96
|
+
* `include()` chains across many files. Unlike single-file route files (Laravel
|
|
97
|
+
* `routes/*.php`), which the parse worker extracts in isolation, these need a
|
|
98
|
+
* whole-repo view and on-demand cross-file reads — neither of which the
|
|
99
|
+
* filesystem-free worker can provide, and which a per-chunk worker view gets
|
|
100
|
+
* wrong whenever the root file and its includes land in different chunks.
|
|
101
|
+
*
|
|
102
|
+
* So it runs here, once, after every file is scanned — mirroring the FastAPI
|
|
103
|
+
* router-include join further below. The pass is language-agnostic: any
|
|
104
|
+
* {@link LanguageProvider} exposing both `discoverRootRouteFile` and
|
|
105
|
+
* `extractRoutes` participates (today only Python/Django). For repos without
|
|
106
|
+
* such a framework the cost is a path scan plus one `manage.py`-style miss.
|
|
107
|
+
*/
|
|
108
|
+
export async function extractCrossFileRoutes(allPaths, repoPath) {
|
|
109
|
+
const out = [];
|
|
110
|
+
// Languages whose provider implements the cross-file route hooks. Route
|
|
111
|
+
// results are intentionally NOT persisted across analyze runs, so a repo
|
|
112
|
+
// using such a framework (e.g. Django) re-derives its routes on every run;
|
|
113
|
+
// a repo without one does effectively nothing here. Cross-run route caching
|
|
114
|
+
// is a deliberate follow-up — see #1836.
|
|
115
|
+
const routeCapableLangs = new Set();
|
|
116
|
+
for (const provider of Object.values(providers)) {
|
|
117
|
+
if (provider.discoverRootRouteFiles && provider.extractRoutes) {
|
|
118
|
+
routeCapableLangs.add(provider.id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (routeCapableLangs.size === 0)
|
|
122
|
+
return out;
|
|
123
|
+
// Bucket only the paths whose language can contribute routes, so a non-
|
|
124
|
+
// framework repo never pays to bucket the languages it doesn't use here.
|
|
125
|
+
const pathsByLang = new Map();
|
|
126
|
+
for (const p of allPaths) {
|
|
127
|
+
const lang = getLanguageFromFilename(p);
|
|
128
|
+
if (!lang || !routeCapableLangs.has(lang))
|
|
129
|
+
continue;
|
|
130
|
+
let bucket = pathsByLang.get(lang);
|
|
131
|
+
if (!bucket) {
|
|
132
|
+
bucket = [];
|
|
133
|
+
pathsByLang.set(lang, bucket);
|
|
134
|
+
}
|
|
135
|
+
bucket.push(p);
|
|
136
|
+
}
|
|
137
|
+
for (const [lang, langPaths] of pathsByLang) {
|
|
138
|
+
if (!isLanguageAvailable(lang))
|
|
139
|
+
continue;
|
|
140
|
+
const provider = getProvider(lang);
|
|
141
|
+
if (!provider.discoverRootRouteFiles || !provider.extractRoutes)
|
|
142
|
+
continue;
|
|
143
|
+
// Disk-backed reader keyed on repo-relative paths. Discovery and the
|
|
144
|
+
// include() walk read through this; nothing is pre-loaded, so a repo that
|
|
145
|
+
// lacks the framework pays only the reads its own discovery probes trigger.
|
|
146
|
+
const readCache = new Map();
|
|
147
|
+
const reader = (relativePath) => {
|
|
148
|
+
const cached = readCache.get(relativePath);
|
|
149
|
+
if (cached !== undefined)
|
|
150
|
+
return cached;
|
|
151
|
+
let content = null;
|
|
152
|
+
try {
|
|
153
|
+
content = fs.readFileSync(path.join(repoPath, relativePath), 'utf-8');
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
content = null;
|
|
157
|
+
}
|
|
158
|
+
readCache.set(relativePath, content);
|
|
159
|
+
return content;
|
|
160
|
+
};
|
|
161
|
+
// One root route file per discoverable project (a monorepo can have several).
|
|
162
|
+
const rootPaths = provider.discoverRootRouteFiles(langPaths.map((p) => ({ path: p })), undefined, reader);
|
|
163
|
+
if (rootPaths.length === 0)
|
|
164
|
+
continue;
|
|
165
|
+
// One parser per language — the grammar is language-scoped, so it is reused
|
|
166
|
+
// for every project root and every include() re-parse.
|
|
167
|
+
let parser;
|
|
168
|
+
try {
|
|
169
|
+
parser = await createParserForLanguage(lang, rootPaths[0]);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
continue; // grammar unavailable — skip the language, mirrors worker safety net
|
|
173
|
+
}
|
|
174
|
+
for (const rootPath of rootPaths) {
|
|
175
|
+
const rootContent = reader(rootPath);
|
|
176
|
+
if (rootContent === null)
|
|
177
|
+
continue; // skip this root only, not the language
|
|
178
|
+
let rootTree;
|
|
179
|
+
try {
|
|
180
|
+
rootTree = parseSourceSafe(parser, rootContent);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
logger.warn(`Skipping unparseable root route file: ${rootPath}`);
|
|
184
|
+
continue; // skip this root only
|
|
185
|
+
}
|
|
186
|
+
// Isolate a misbehaving provider: a throw here must not abort the whole
|
|
187
|
+
// analyze (mirrors the worker's per-file isolation). Skip this root, warn.
|
|
188
|
+
try {
|
|
189
|
+
const routes = provider.extractRoutes(rootTree, rootPath, reader, parser);
|
|
190
|
+
for (const r of routes)
|
|
191
|
+
out.push(r);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
logger.warn({ err }, `Cross-file route extraction failed for ${rootPath}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
88
200
|
/**
|
|
89
201
|
* Handle a worker-pool startup failure by FAILING FAST with the captured cause
|
|
90
202
|
* (#1741). The pool self-heals *transient* worker crashes on its own — a
|
|
@@ -734,6 +846,18 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
734
846
|
exportedTypeMap.set(fp, exports);
|
|
735
847
|
logHeapProbe('post-buildExportedTypeMapFromGraph');
|
|
736
848
|
}
|
|
849
|
+
// Whole-repo, cross-file route extraction (e.g. Django) runs on the main
|
|
850
|
+
// thread — the worker has no filesystem access and can't follow `include()`
|
|
851
|
+
// chains across files. Merge its routes in before `processRoutesFromExtracted`
|
|
852
|
+
// and the routes phase consume `allExtractedRoutes`.
|
|
853
|
+
const crossFileRoutes = await extractCrossFileRoutes(allPaths, repoPath);
|
|
854
|
+
if (crossFileRoutes.length > 0) {
|
|
855
|
+
for (const r of crossFileRoutes)
|
|
856
|
+
allExtractedRoutes.push(r);
|
|
857
|
+
if (deferredProfile) {
|
|
858
|
+
logDeferredProfile(`cross-file routes: +${crossFileRoutes.length}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
737
861
|
if (allExtractedRoutes.length > 0) {
|
|
738
862
|
const tRoutes = startTimer(deferredProfile);
|
|
739
863
|
await processRoutesFromExtracted(graph, allExtractedRoutes, model, (current, total) => {
|
|
@@ -121,6 +121,11 @@ export function normalizeRouteMethod(raw) {
|
|
|
121
121
|
if (typeof raw !== 'string')
|
|
122
122
|
return undefined;
|
|
123
123
|
const verb = raw.trim().toUpperCase();
|
|
124
|
+
// '*' marks a method-agnostic route (e.g. a Django function view handles any
|
|
125
|
+
// verb). Preserve it so the contract layer emits a wildcard provider that
|
|
126
|
+
// matches consumers of any method, instead of silently narrowing to GET.
|
|
127
|
+
if (verb === '*')
|
|
128
|
+
return '*';
|
|
124
129
|
return VALID_HTTP_METHODS.has(verb) ? verb : undefined;
|
|
125
130
|
}
|
|
126
131
|
export const routesPhase = {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { DjangoFileReader } from './django.js';
|
|
2
|
+
/**
|
|
3
|
+
* Given a dotted Python module path, produce possible file paths.
|
|
4
|
+
* e.g. `cmrMngt.settings` → `['cmrMngt/settings.py', 'cmrMngt/settings/__init__.py']`
|
|
5
|
+
*/
|
|
6
|
+
export declare function djangoModuleToFilePaths(modulePath: string): string[];
|
|
7
|
+
/**
|
|
8
|
+
* Discover the Django root URL file(s) by following, for EVERY `manage.py` in
|
|
9
|
+
* the file set:
|
|
10
|
+
* manage.py → DJANGO_SETTINGS_MODULE → settings → ROOT_URLCONF → urls.py
|
|
11
|
+
*
|
|
12
|
+
* Returns one root urls path per discoverable Django project, so a monorepo
|
|
13
|
+
* with several `manage.py` files (e.g. `serviceA/manage.py`, `serviceB/manage.py`)
|
|
14
|
+
* yields every project's routes rather than only the first.
|
|
15
|
+
*
|
|
16
|
+
* @param files Array of file paths (content optional — when absent, `reader`
|
|
17
|
+
* resolves it on demand).
|
|
18
|
+
* @param contentMap Optional pre-built map of file path → content.
|
|
19
|
+
* @param reader Optional disk-backed reader for files not present in the map.
|
|
20
|
+
* @returns De-duplicated relative paths to each project's root URL file (empty if none).
|
|
21
|
+
*/
|
|
22
|
+
export declare function discoverDjangoRootUrls(files: Array<{
|
|
23
|
+
path: string;
|
|
24
|
+
content?: string;
|
|
25
|
+
}>, contentMap?: Map<string, string>, reader?: DjangoFileReader): string[];
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Given a `manage.py` file content, extract the Django settings module.
|
|
3
|
+
* e.g. `os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cmrMngt.settings')`
|
|
4
|
+
* returns `'cmrMngt.settings'`
|
|
5
|
+
*/
|
|
6
|
+
function extractDjangoSettingsModule(manageContent) {
|
|
7
|
+
const m = manageContent.match(/DJANGO_SETTINGS_MODULE\s*['"]?[,= ]\s*['"]([^'"]+)['"]/);
|
|
8
|
+
return m ? m[1] : null;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Given a dotted Python module path, produce possible file paths.
|
|
12
|
+
* e.g. `cmrMngt.settings` → `['cmrMngt/settings.py', 'cmrMngt/settings/__init__.py']`
|
|
13
|
+
*/
|
|
14
|
+
export function djangoModuleToFilePaths(modulePath) {
|
|
15
|
+
const base = modulePath.replace(/\./g, '/');
|
|
16
|
+
return [`${base}.py`, `${base}/__init__.py`];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Read a file, trying first the in-memory content map, then the optional
|
|
20
|
+
* reader (typically a disk-backed reader on the main thread). The map keeps
|
|
21
|
+
* already-loaded content cheap; the reader lets discovery reach files that
|
|
22
|
+
* were never pre-loaded — critical because the relevant files (manage.py,
|
|
23
|
+
* settings, the root urls.py) can be scattered across parse chunks.
|
|
24
|
+
*/
|
|
25
|
+
function tryReadFile(relativePath, contentMap, reader) {
|
|
26
|
+
return contentMap.get(relativePath) ?? reader?.(relativePath) ?? null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Extract a module-level string assignment value from Python source.
|
|
30
|
+
* e.g. `content` contains `ROOT_URLCONF = 'cmrMngt.urls'`
|
|
31
|
+
* returns `'cmrMngt.urls'`
|
|
32
|
+
*/
|
|
33
|
+
function extractPythonStringAssignment(content, varName) {
|
|
34
|
+
const regex = new RegExp(`^${varName}\\s*=\\s*['"]([^'"]+)['"]`, 'm');
|
|
35
|
+
const m = content.match(regex);
|
|
36
|
+
return m ? m[1] : null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Extract `from <module> import *` statements from Python source.
|
|
40
|
+
* e.g. `from .settings_base import *` → `settings_base`
|
|
41
|
+
* `from cmrMngt.settings_base import *` → `cmrMngt.settings_base`
|
|
42
|
+
*/
|
|
43
|
+
function extractStarImports(content) {
|
|
44
|
+
const modules = [];
|
|
45
|
+
const regex = /^from\s+(\.?[\w.]+)\s+import\s+\*/gm;
|
|
46
|
+
let m;
|
|
47
|
+
while ((m = regex.exec(content)) !== null) {
|
|
48
|
+
// Relative (leading-dot) and absolute module names are both pushed verbatim;
|
|
49
|
+
// the caller resolves relative ones against the current module path.
|
|
50
|
+
modules.push(m[1]);
|
|
51
|
+
}
|
|
52
|
+
return modules;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve a relative Python import path.
|
|
56
|
+
* `from .settings_base import *` in `cmrMngt/settings.py`
|
|
57
|
+
* → `cmrMngt/settings_base.py`
|
|
58
|
+
*/
|
|
59
|
+
function resolveRelativeImport(currentModulePath, importPath) {
|
|
60
|
+
if (!importPath.startsWith('.'))
|
|
61
|
+
return null;
|
|
62
|
+
const currentDir = currentModulePath.includes('/')
|
|
63
|
+
? currentModulePath.substring(0, currentModulePath.lastIndexOf('/'))
|
|
64
|
+
: '';
|
|
65
|
+
let relPath = importPath;
|
|
66
|
+
let dir = currentDir;
|
|
67
|
+
while (relPath.startsWith('.')) {
|
|
68
|
+
if (relPath.startsWith('..')) {
|
|
69
|
+
dir = dir.includes('/') ? dir.substring(0, dir.lastIndexOf('/')) : '';
|
|
70
|
+
relPath = relPath.substring(2);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
relPath = relPath.substring(1);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return dir ? `${dir}/${relPath}` : relPath;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the root URL file for a SINGLE Django project rooted at `managePyPath`.
|
|
81
|
+
*
|
|
82
|
+
* Module paths in `manage.py`/`settings.py` are written relative to the
|
|
83
|
+
* project directory (the one containing `manage.py`), NOT the repo root — so a
|
|
84
|
+
* project under `backend/` declares `myproj.settings`, with the file living at
|
|
85
|
+
* `backend/myproj/settings.py`. We therefore resolve every candidate against
|
|
86
|
+
* the project directory first and the repo root second. `resolvedSettingsPath`
|
|
87
|
+
* is kept project-dir-aware so the downstream star-import and urls-dir
|
|
88
|
+
* resolution anchor correctly.
|
|
89
|
+
*/
|
|
90
|
+
function resolveDjangoProjectRoot(managePyPath, manageContent, map, reader) {
|
|
91
|
+
const projectDir = managePyPath.includes('/')
|
|
92
|
+
? managePyPath.substring(0, managePyPath.lastIndexOf('/'))
|
|
93
|
+
: '';
|
|
94
|
+
// Try the project dir first (the common subdir case), then the repo root.
|
|
95
|
+
const bases = projectDir ? [`${projectDir}/`, ''] : [''];
|
|
96
|
+
const settingsModule = extractDjangoSettingsModule(manageContent);
|
|
97
|
+
if (!settingsModule)
|
|
98
|
+
return null;
|
|
99
|
+
const settingsSlash = settingsModule.replace(/\./g, '/');
|
|
100
|
+
// Find the settings file, recording which base it resolved under so relative
|
|
101
|
+
// imports and the urls-dir fallback stay anchored to the right directory.
|
|
102
|
+
let settingsContent = null;
|
|
103
|
+
let resolvedSettingsPath = null;
|
|
104
|
+
for (const base of bases) {
|
|
105
|
+
for (const sp of djangoModuleToFilePaths(settingsModule)) {
|
|
106
|
+
const c = tryReadFile(base + sp, map, reader);
|
|
107
|
+
if (c !== null) {
|
|
108
|
+
settingsContent = c;
|
|
109
|
+
resolvedSettingsPath = base + settingsSlash;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (settingsContent)
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
if (!settingsContent || resolvedSettingsPath === null)
|
|
117
|
+
return null;
|
|
118
|
+
// Check ROOT_URLCONF in the main settings and any base settings (star imports)
|
|
119
|
+
let rootUrlConf = extractPythonStringAssignment(settingsContent, 'ROOT_URLCONF');
|
|
120
|
+
if (!rootUrlConf) {
|
|
121
|
+
// Check star-imported base settings
|
|
122
|
+
const starImports = extractStarImports(settingsContent);
|
|
123
|
+
for (const imp of starImports) {
|
|
124
|
+
let baseModule = null;
|
|
125
|
+
if (imp.startsWith('.')) {
|
|
126
|
+
const resolved = resolveRelativeImport(resolvedSettingsPath, imp);
|
|
127
|
+
if (resolved)
|
|
128
|
+
baseModule = resolved;
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
baseModule = imp;
|
|
132
|
+
}
|
|
133
|
+
if (!baseModule)
|
|
134
|
+
continue;
|
|
135
|
+
// `baseModule` is always a slash-path here: a relative import is resolved
|
|
136
|
+
// by `resolveRelativeImport` (which never returns a leading-dot path), and
|
|
137
|
+
// an absolute import is the bare module name. So there is no remaining
|
|
138
|
+
// dot-prefixed case to handle.
|
|
139
|
+
const basePaths = [];
|
|
140
|
+
const baseSlash = baseModule.replace(/\./g, '/');
|
|
141
|
+
// A relative import (`imp` started with `.`) is already anchored under
|
|
142
|
+
// the project dir via `resolvedSettingsPath`; an absolute module name
|
|
143
|
+
// may live under the project dir OR the repo root.
|
|
144
|
+
const candidateBases = imp.startsWith('.') ? [''] : bases;
|
|
145
|
+
for (const cb of candidateBases) {
|
|
146
|
+
basePaths.push(`${cb}${baseSlash}.py`);
|
|
147
|
+
basePaths.push(`${cb}${baseSlash}/__init__.py`);
|
|
148
|
+
}
|
|
149
|
+
for (const bp of basePaths) {
|
|
150
|
+
const bc = tryReadFile(bp, map, reader);
|
|
151
|
+
if (bc) {
|
|
152
|
+
rootUrlConf = extractPythonStringAssignment(bc, 'ROOT_URLCONF');
|
|
153
|
+
if (rootUrlConf)
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (rootUrlConf)
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!rootUrlConf)
|
|
162
|
+
return null;
|
|
163
|
+
// Convert ROOT_URLCONF module path to a file path, trying project dir then root.
|
|
164
|
+
const urlPaths = djangoModuleToFilePaths(rootUrlConf);
|
|
165
|
+
for (const base of bases) {
|
|
166
|
+
for (const up of urlPaths) {
|
|
167
|
+
if (tryReadFile(base + up, map, reader) !== null)
|
|
168
|
+
return base + up;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Also try relative to the settings module's directory (project-dir-aware).
|
|
172
|
+
if (resolvedSettingsPath.includes('/')) {
|
|
173
|
+
const settingsDir = resolvedSettingsPath.substring(0, resolvedSettingsPath.lastIndexOf('/') + 1);
|
|
174
|
+
for (const up of urlPaths) {
|
|
175
|
+
const tryPath = settingsDir + up;
|
|
176
|
+
if (tryReadFile(tryPath, map, reader) !== null)
|
|
177
|
+
return tryPath;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Discover the Django root URL file(s) by following, for EVERY `manage.py` in
|
|
184
|
+
* the file set:
|
|
185
|
+
* manage.py → DJANGO_SETTINGS_MODULE → settings → ROOT_URLCONF → urls.py
|
|
186
|
+
*
|
|
187
|
+
* Returns one root urls path per discoverable Django project, so a monorepo
|
|
188
|
+
* with several `manage.py` files (e.g. `serviceA/manage.py`, `serviceB/manage.py`)
|
|
189
|
+
* yields every project's routes rather than only the first.
|
|
190
|
+
*
|
|
191
|
+
* @param files Array of file paths (content optional — when absent, `reader`
|
|
192
|
+
* resolves it on demand).
|
|
193
|
+
* @param contentMap Optional pre-built map of file path → content.
|
|
194
|
+
* @param reader Optional disk-backed reader for files not present in the map.
|
|
195
|
+
* @returns De-duplicated relative paths to each project's root URL file (empty if none).
|
|
196
|
+
*/
|
|
197
|
+
export function discoverDjangoRootUrls(files, contentMap, reader) {
|
|
198
|
+
const map = contentMap ?? new Map();
|
|
199
|
+
for (const f of files)
|
|
200
|
+
if (f.content != null)
|
|
201
|
+
map.set(f.path, f.content);
|
|
202
|
+
const roots = [];
|
|
203
|
+
const seen = new Set();
|
|
204
|
+
for (const f of files) {
|
|
205
|
+
if (f.path !== 'manage.py' && !f.path.endsWith('/manage.py'))
|
|
206
|
+
continue;
|
|
207
|
+
const manageContent = f.content ?? tryReadFile(f.path, map, reader);
|
|
208
|
+
if (!manageContent)
|
|
209
|
+
continue;
|
|
210
|
+
const root = resolveDjangoProjectRoot(f.path, manageContent, map, reader);
|
|
211
|
+
if (root !== null && !seen.has(root)) {
|
|
212
|
+
seen.add(root);
|
|
213
|
+
roots.push(root);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return roots;
|
|
217
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type Parser from 'tree-sitter';
|
|
2
|
+
import type { ExtractedRoute } from './laravel.js';
|
|
3
|
+
export type DjangoFileReader = (relativePath: string) => string | null;
|
|
4
|
+
export declare function extractDjangoRoutes(tree: Parser.Tree, filePath: string, parser: Parser, readFile?: DjangoFileReader | null, _visited?: Set<string>): ExtractedRoute[];
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
|
|
2
|
+
import { extractStringContent } from '../utils/ast-helpers.js';
|
|
3
|
+
import { logger } from '../../logger.js';
|
|
4
|
+
const DJANGO_ROUTE_FUNCTIONS = new Set(['path', 're_path', 'url']);
|
|
5
|
+
const DJANGO_INCLUDE_FUNCTION = 'include';
|
|
6
|
+
const MAX_INCLUDE_DEPTH = 8;
|
|
7
|
+
// Wrapper calls whose first list/tuple argument is itself a urlpatterns list
|
|
8
|
+
// (e.g. DRF's `format_suffix_patterns([...])`, `i18n_patterns(...)`,
|
|
9
|
+
// `staticfiles_urlpatterns()`). We descend into their list-typed arguments.
|
|
10
|
+
const URLPATTERNS_WRAPPER_FUNCTIONS = new Set([
|
|
11
|
+
'format_suffix_patterns',
|
|
12
|
+
'staticfiles_urlpatterns',
|
|
13
|
+
'i18n_patterns',
|
|
14
|
+
]);
|
|
15
|
+
function modulePathToFilePath(modulePath) {
|
|
16
|
+
return modulePath.replace(/\./g, '/');
|
|
17
|
+
}
|
|
18
|
+
function extractStringArg(argsNode) {
|
|
19
|
+
if (!argsNode)
|
|
20
|
+
return null;
|
|
21
|
+
for (const child of argsNode.children ?? []) {
|
|
22
|
+
if (child.type === '(' || child.type === ')' || child.type === ',')
|
|
23
|
+
continue;
|
|
24
|
+
if (child.type === 'string') {
|
|
25
|
+
return extractStringContent(child);
|
|
26
|
+
}
|
|
27
|
+
if (child.type === 'binary_operator') {
|
|
28
|
+
let concat = '';
|
|
29
|
+
for (const part of child.children ?? []) {
|
|
30
|
+
if (part.type === 'string') {
|
|
31
|
+
const s = extractStringContent(part);
|
|
32
|
+
if (s !== null)
|
|
33
|
+
concat += s;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (concat)
|
|
37
|
+
return concat;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
function extractViewTarget(argsNode) {
|
|
43
|
+
if (!argsNode)
|
|
44
|
+
return { viewName: null, viewCall: null };
|
|
45
|
+
const positionalArgs = [];
|
|
46
|
+
for (const child of argsNode.children ?? []) {
|
|
47
|
+
if (child.type === '(' || child.type === ')' || child.type === ',')
|
|
48
|
+
continue;
|
|
49
|
+
positionalArgs.push(child);
|
|
50
|
+
}
|
|
51
|
+
const viewNode = positionalArgs[1];
|
|
52
|
+
if (!viewNode)
|
|
53
|
+
return { viewName: null, viewCall: null };
|
|
54
|
+
if (viewNode.type === 'attribute')
|
|
55
|
+
return { viewName: viewNode.text, viewCall: null };
|
|
56
|
+
if (viewNode.type === 'call')
|
|
57
|
+
return { viewName: null, viewCall: viewNode.text };
|
|
58
|
+
if (viewNode.type === 'identifier')
|
|
59
|
+
return { viewName: viewNode.text, viewCall: null };
|
|
60
|
+
if (viewNode.type === 'string')
|
|
61
|
+
return { viewName: extractStringContent(viewNode), viewCall: null };
|
|
62
|
+
return { viewName: null, viewCall: null };
|
|
63
|
+
}
|
|
64
|
+
function inferHttpMethod(viewName) {
|
|
65
|
+
if (!viewName)
|
|
66
|
+
return '*';
|
|
67
|
+
const lower = viewName.toLowerCase();
|
|
68
|
+
const m = lower.match(/\.(get|post|put|patch|delete|head|options)(_|$)/);
|
|
69
|
+
if (m) {
|
|
70
|
+
return m[1].toUpperCase();
|
|
71
|
+
}
|
|
72
|
+
return '*';
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Collect the list/tuple container node(s) that hold route entries from a
|
|
76
|
+
* `urlpatterns` right-hand side. Handles the common non-literal shapes:
|
|
77
|
+
* - `urlpatterns = [...]` → the list
|
|
78
|
+
* - `urlpatterns = (...)` → the tuple
|
|
79
|
+
* - `urlpatterns = a + b` → both operands (concatenation)
|
|
80
|
+
* - `urlpatterns = wrapper([...])` → the wrapper's list argument
|
|
81
|
+
* Inherently-dynamic shapes (`router.urls`, comprehensions, bare names) yield
|
|
82
|
+
* nothing — they cannot be resolved statically.
|
|
83
|
+
*/
|
|
84
|
+
function collectUrlpatternContainers(node, out) {
|
|
85
|
+
switch (node.type) {
|
|
86
|
+
case 'list':
|
|
87
|
+
case 'tuple':
|
|
88
|
+
out.push(node);
|
|
89
|
+
return;
|
|
90
|
+
case 'binary_operator':
|
|
91
|
+
// `a + b` concatenation — descend into both operands.
|
|
92
|
+
for (const child of node.children ?? [])
|
|
93
|
+
collectUrlpatternContainers(child, out);
|
|
94
|
+
return;
|
|
95
|
+
case 'call': {
|
|
96
|
+
const fn = getCallFuncName(node);
|
|
97
|
+
if (fn && URLPATTERNS_WRAPPER_FUNCTIONS.has(fn)) {
|
|
98
|
+
const args = node.childForFieldName?.('arguments') ?? null;
|
|
99
|
+
for (const child of args?.children ?? [])
|
|
100
|
+
collectUrlpatternContainers(child, out);
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
default:
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function findUrlpatternsLists(rootNode) {
|
|
109
|
+
const assignmentNodes = [];
|
|
110
|
+
_collectAssignments(rootNode, assignmentNodes);
|
|
111
|
+
const lists = [];
|
|
112
|
+
for (const node of assignmentNodes) {
|
|
113
|
+
const left = node.childForFieldName?.('left') ?? node.children?.[0] ?? null;
|
|
114
|
+
if (left?.type === 'identifier' && left.text === 'urlpatterns') {
|
|
115
|
+
const right = node.childForFieldName?.('right') ?? node.children?.[2] ?? null;
|
|
116
|
+
if (!right)
|
|
117
|
+
continue;
|
|
118
|
+
const before = lists.length;
|
|
119
|
+
collectUrlpatternContainers(right, lists);
|
|
120
|
+
if (lists.length === before && (right.type === 'attribute' || right.type === 'identifier')) {
|
|
121
|
+
// Dynamically-built urlpatterns (e.g. DRF `router.urls`) can't be
|
|
122
|
+
// resolved statically — surface it so the silent-zero case is visible.
|
|
123
|
+
logger.debug(`Django: skipping non-static urlpatterns (${right.type}: ${right.text})`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return lists;
|
|
128
|
+
}
|
|
129
|
+
function _collectAssignments(node, out) {
|
|
130
|
+
if (node.type === 'assignment' || node.type === 'augmented_assignment') {
|
|
131
|
+
out.push(node);
|
|
132
|
+
}
|
|
133
|
+
for (const child of node.children ?? []) {
|
|
134
|
+
_collectAssignments(child, out);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function emitDjangoRoute(callNode, filePath, ctx) {
|
|
138
|
+
const argsNode = callNode.childForFieldName?.('arguments') ?? null;
|
|
139
|
+
const routePath = extractStringArg(argsNode);
|
|
140
|
+
const { viewName, viewCall } = extractViewTarget(argsNode);
|
|
141
|
+
const httpMethod = inferHttpMethod(viewName);
|
|
142
|
+
let routeName = null;
|
|
143
|
+
if (argsNode) {
|
|
144
|
+
for (let i = 0; i < argsNode.children.length; i++) {
|
|
145
|
+
const child = argsNode.children[i];
|
|
146
|
+
if (child.type === 'keyword_argument' && child.childForFieldName?.('name')?.text === 'name') {
|
|
147
|
+
const valueNode = child.childForFieldName?.('value');
|
|
148
|
+
if (valueNode?.type === 'string') {
|
|
149
|
+
routeName = extractStringContent(valueNode);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
filePath,
|
|
156
|
+
httpMethod,
|
|
157
|
+
routePath,
|
|
158
|
+
routeName,
|
|
159
|
+
controllerName: viewName ?? viewCall,
|
|
160
|
+
methodName: null,
|
|
161
|
+
middleware: [],
|
|
162
|
+
prefix: ctx.prefix,
|
|
163
|
+
lineNumber: callNode.startPosition.row,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function getIncludeModulePath(callNode) {
|
|
167
|
+
const funcName = callNode.childForFieldName?.('function')?.text ??
|
|
168
|
+
callNode.children?.find((c) => c.type === 'identifier')?.text;
|
|
169
|
+
if (funcName !== DJANGO_INCLUDE_FUNCTION)
|
|
170
|
+
return null;
|
|
171
|
+
const argsNode = callNode.childForFieldName?.('arguments');
|
|
172
|
+
if (!argsNode)
|
|
173
|
+
return null;
|
|
174
|
+
const modulePath = extractStringArg(argsNode);
|
|
175
|
+
if (modulePath)
|
|
176
|
+
return modulePath;
|
|
177
|
+
for (const child of argsNode.children ?? []) {
|
|
178
|
+
if (child.type === '(' || child.type === ')' || child.type === ',')
|
|
179
|
+
continue;
|
|
180
|
+
if (child.type === 'tuple' || child.type === 'parenthesized_expression') {
|
|
181
|
+
for (const inner of child.children ?? []) {
|
|
182
|
+
if (inner.type === '(' || inner.type === ')' || inner.type === ',')
|
|
183
|
+
continue;
|
|
184
|
+
if (inner.type === 'string')
|
|
185
|
+
return extractStringContent(inner);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
function makePrefix(parentPrefix, childPrefix) {
|
|
192
|
+
if (!childPrefix)
|
|
193
|
+
return parentPrefix;
|
|
194
|
+
if (!parentPrefix)
|
|
195
|
+
return childPrefix;
|
|
196
|
+
return `${parentPrefix}/${childPrefix}`.replace(/\/+/g, '/');
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Recursion-guard key. A urlconf file is walked once *per accumulated prefix*,
|
|
200
|
+
* so a urlconf `include()`d under two different prefixes (a "diamond" — e.g.
|
|
201
|
+
* the same app mounted at `/v1/` and `/v2/`) emits routes for both mounts,
|
|
202
|
+
* while a genuine cycle (same file + same prefix) still terminates. `null` and
|
|
203
|
+
* `''` collapse to the same key so a no-prefix re-entry is treated as a cycle.
|
|
204
|
+
*/
|
|
205
|
+
function includeVisitKey(filePath, prefix) {
|
|
206
|
+
return `${filePath}\u0000${prefix ?? ''}`;
|
|
207
|
+
}
|
|
208
|
+
function getCallFuncName(node) {
|
|
209
|
+
return (node.childForFieldName?.('function')?.text ??
|
|
210
|
+
node.children?.find((c) => c.type === 'identifier')?.text ??
|
|
211
|
+
null);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Find the Django project root for `startFilePath` — the nearest ancestor
|
|
215
|
+
* directory that contains a `manage.py`. Django resolves `include('app.urls')`
|
|
216
|
+
* as an absolute import from this directory (it is the entry on `sys.path`), so
|
|
217
|
+
* knowing it lets us resolve includes unambiguously even when an unrelated
|
|
218
|
+
* `app/urls.py` exists at the repo root (the monorepo wrong-app hazard).
|
|
219
|
+
* Returns the project-root directory (possibly `''` for a repo-root project),
|
|
220
|
+
* or `null` when no `manage.py` ancestor is readable.
|
|
221
|
+
*/
|
|
222
|
+
function findDjangoProjectRoot(startFilePath, readFile) {
|
|
223
|
+
if (!readFile)
|
|
224
|
+
return null;
|
|
225
|
+
let dir = startFilePath.includes('/')
|
|
226
|
+
? startFilePath.substring(0, startFilePath.lastIndexOf('/'))
|
|
227
|
+
: '';
|
|
228
|
+
for (;;) {
|
|
229
|
+
const candidate = dir ? `${dir}/manage.py` : 'manage.py';
|
|
230
|
+
if (readFile(candidate) !== null)
|
|
231
|
+
return dir;
|
|
232
|
+
if (!dir)
|
|
233
|
+
return null;
|
|
234
|
+
const sep = dir.lastIndexOf('/');
|
|
235
|
+
dir = sep < 0 ? '' : dir.substring(0, sep);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Given a Django dotted module path like `app.submodule.urls`,
|
|
240
|
+
* try multiple path resolution strategies to find the file on disk.
|
|
241
|
+
*
|
|
242
|
+
* Strategies tried in order:
|
|
243
|
+
* 0. Anchored at the Django project root (manage.py dir) — the authoritative
|
|
244
|
+
* resolution for absolute module paths, when the project root is known
|
|
245
|
+
* 1. Direct dot-to-slash: `module/path.py` and `module/path/__init__.py`
|
|
246
|
+
* 2. Relative to the current file's directory
|
|
247
|
+
* 3. Walk up the directory tree from the current file, trying each ancestor
|
|
248
|
+
*/
|
|
249
|
+
function resolveIncludedFile(modulePath, currentFilePath, readFile, projectRoot) {
|
|
250
|
+
const basePath = modulePathToFilePath(modulePath);
|
|
251
|
+
const candidates = [];
|
|
252
|
+
// Strategy 0: anchored at the project root (sys.path entry). Tried first so
|
|
253
|
+
// `include('app.urls')` from backend/ resolves to backend/app/urls.py rather
|
|
254
|
+
// than a same-named app at the repo root.
|
|
255
|
+
if (projectRoot !== null) {
|
|
256
|
+
const anchored = projectRoot ? `${projectRoot}/${basePath}` : basePath;
|
|
257
|
+
candidates.push(anchored + '.py');
|
|
258
|
+
candidates.push(anchored + '/__init__.py');
|
|
259
|
+
}
|
|
260
|
+
// Strategy 1: direct path (app/urls.py, app/urls/__init__.py)
|
|
261
|
+
candidates.push(basePath + '.py');
|
|
262
|
+
candidates.push(basePath + '/__init__.py');
|
|
263
|
+
// Strategy 2: relative to current file's directory
|
|
264
|
+
if (currentFilePath.includes('/')) {
|
|
265
|
+
const dir = currentFilePath.substring(0, currentFilePath.lastIndexOf('/') + 1);
|
|
266
|
+
candidates.push(dir + basePath + '.py');
|
|
267
|
+
candidates.push(dir + basePath + '/__init__.py');
|
|
268
|
+
}
|
|
269
|
+
// Strategy 3: walk up from current file, trying each ancestor
|
|
270
|
+
let parentDir = currentFilePath.includes('/')
|
|
271
|
+
? currentFilePath.substring(0, currentFilePath.lastIndexOf('/'))
|
|
272
|
+
: '';
|
|
273
|
+
while (parentDir.length > 0) {
|
|
274
|
+
const prefix = parentDir + '/';
|
|
275
|
+
candidates.push(prefix + basePath + '.py');
|
|
276
|
+
candidates.push(prefix + basePath + '/__init__.py');
|
|
277
|
+
const nextSep = parentDir.lastIndexOf('/');
|
|
278
|
+
if (nextSep < 0)
|
|
279
|
+
break;
|
|
280
|
+
parentDir = parentDir.substring(0, nextSep);
|
|
281
|
+
}
|
|
282
|
+
// Strategy 4: bare path with just the last segment (e.g. 'urls.py' from 'app.urls')
|
|
283
|
+
const segments = basePath.split('/');
|
|
284
|
+
if (segments.length > 1) {
|
|
285
|
+
const lastSegment = segments[segments.length - 1];
|
|
286
|
+
candidates.push(lastSegment + '.py');
|
|
287
|
+
candidates.push(lastSegment + '/__init__.py');
|
|
288
|
+
}
|
|
289
|
+
for (const candidate of candidates) {
|
|
290
|
+
const content = readFile(candidate);
|
|
291
|
+
if (content !== null)
|
|
292
|
+
return { filePath: candidate, content };
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
export function extractDjangoRoutes(tree, filePath, parser, readFile, _visited) {
|
|
297
|
+
const routeSet = _visited ?? new Set();
|
|
298
|
+
const entryKey = includeVisitKey(filePath, null);
|
|
299
|
+
if (routeSet.has(entryKey))
|
|
300
|
+
return [];
|
|
301
|
+
routeSet.add(entryKey);
|
|
302
|
+
// Resolve the project root once (constant across the whole walk) so absolute
|
|
303
|
+
// include() module paths anchor correctly even in a monorepo.
|
|
304
|
+
const projectRoot = findDjangoProjectRoot(filePath, readFile);
|
|
305
|
+
const listNodes = findUrlpatternsLists(tree.rootNode);
|
|
306
|
+
if (listNodes.length === 0)
|
|
307
|
+
return [];
|
|
308
|
+
const routes = [];
|
|
309
|
+
const walkStack = [];
|
|
310
|
+
for (const listNode of listNodes) {
|
|
311
|
+
walkStack.push({
|
|
312
|
+
node: listNode,
|
|
313
|
+
routeCtx: { prefix: null },
|
|
314
|
+
currentFilePath: filePath,
|
|
315
|
+
depth: 0,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
while (walkStack.length > 0) {
|
|
319
|
+
const { node, routeCtx, currentFilePath, depth } = walkStack.pop();
|
|
320
|
+
if (node.type === 'list') {
|
|
321
|
+
const children = node.children ?? [];
|
|
322
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
323
|
+
const child = children[i];
|
|
324
|
+
if (child.type === '[' || child.type === ']' || child.type === ',')
|
|
325
|
+
continue;
|
|
326
|
+
walkStack.push({ node: child, routeCtx, currentFilePath, depth });
|
|
327
|
+
}
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (node.type === 'call') {
|
|
331
|
+
const funcName = getCallFuncName(node);
|
|
332
|
+
if (!funcName) {
|
|
333
|
+
for (const child of node.children ?? []) {
|
|
334
|
+
if (child.type === 'call' || child.type === 'list') {
|
|
335
|
+
walkStack.push({ node: child, routeCtx, currentFilePath, depth });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (DJANGO_ROUTE_FUNCTIONS.has(funcName)) {
|
|
341
|
+
const argsNode = node.childForFieldName?.('arguments') ?? null;
|
|
342
|
+
let hasIncludeChild = false;
|
|
343
|
+
if (argsNode) {
|
|
344
|
+
for (const child of argsNode.children ?? []) {
|
|
345
|
+
if (child.type === 'call' && getCallFuncName(child) === DJANGO_INCLUDE_FUNCTION) {
|
|
346
|
+
hasIncludeChild = true;
|
|
347
|
+
const modulePath = getIncludeModulePath(child);
|
|
348
|
+
if (modulePath && readFile && depth < MAX_INCLUDE_DEPTH) {
|
|
349
|
+
const resolved = resolveIncludedFile(modulePath, currentFilePath, readFile, projectRoot);
|
|
350
|
+
// Key the guard on (file, accumulated prefix) so the same
|
|
351
|
+
// urlconf mounted under another prefix elsewhere is still walked.
|
|
352
|
+
const childPrefix = makePrefix(routeCtx.prefix, extractStringArg(argsNode));
|
|
353
|
+
if (resolved && !routeSet.has(includeVisitKey(resolved.filePath, childPrefix))) {
|
|
354
|
+
routeSet.add(includeVisitKey(resolved.filePath, childPrefix));
|
|
355
|
+
let childTree;
|
|
356
|
+
try {
|
|
357
|
+
childTree = parseSourceSafe(parser, resolved.content);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const childLists = findUrlpatternsLists(childTree.rootNode);
|
|
363
|
+
for (const childList of childLists) {
|
|
364
|
+
walkStack.push({
|
|
365
|
+
node: childList,
|
|
366
|
+
routeCtx: { prefix: childPrefix },
|
|
367
|
+
currentFilePath: resolved.filePath,
|
|
368
|
+
depth: depth + 1,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (!hasIncludeChild) {
|
|
377
|
+
routes.push(emitDjangoRoute(node, currentFilePath, routeCtx));
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (funcName === DJANGO_INCLUDE_FUNCTION && readFile && depth < MAX_INCLUDE_DEPTH) {
|
|
382
|
+
const modulePath = getIncludeModulePath(node);
|
|
383
|
+
if (modulePath) {
|
|
384
|
+
const resolved = resolveIncludedFile(modulePath, currentFilePath, readFile, projectRoot);
|
|
385
|
+
// Bare include() inherits the current prefix; key the guard on it so a
|
|
386
|
+
// shared urlconf reached under two prefixes is walked once per prefix.
|
|
387
|
+
if (resolved && !routeSet.has(includeVisitKey(resolved.filePath, routeCtx.prefix))) {
|
|
388
|
+
routeSet.add(includeVisitKey(resolved.filePath, routeCtx.prefix));
|
|
389
|
+
let childTree;
|
|
390
|
+
try {
|
|
391
|
+
childTree = parseSourceSafe(parser, resolved.content);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const childLists = findUrlpatternsLists(childTree.rootNode);
|
|
397
|
+
for (const childList of childLists) {
|
|
398
|
+
walkStack.push({
|
|
399
|
+
node: childList,
|
|
400
|
+
routeCtx,
|
|
401
|
+
currentFilePath: resolved.filePath,
|
|
402
|
+
depth: depth + 1,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
for (const child of node.children ?? []) {
|
|
410
|
+
if (child.type === 'call' || child.type === 'list') {
|
|
411
|
+
walkStack.push({ node: child, routeCtx, currentFilePath, depth });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
for (const child of node.children ?? []) {
|
|
417
|
+
if (child.type === '(' || child.type === ')' || child.type === ',')
|
|
418
|
+
continue;
|
|
419
|
+
if (child.type === 'call' || child.type === 'list') {
|
|
420
|
+
walkStack.push({ node: child, routeCtx, currentFilePath, depth });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return routes;
|
|
425
|
+
}
|
package/package.json
CHANGED