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.
@@ -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 (index.has(normalized))
105
- return [normalized];
106
- if (normalized.startsWith('http::*::')) {
107
- const pathPart = normalized.substring('http::*::'.length);
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
- for (const key of index.keys()) {
110
- if (key.startsWith('http::') && key.endsWith(`::${pathPart}`)) {
111
- matches.push(key);
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 (e.g., Laravel routes.php).
194
- * When true, the worker extracts routes via the language's route extraction logic.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.9-rc.3",
3
+ "version": "1.6.9-rc.4",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",