gitnexus 1.6.13-rc.55 → 1.6.13-rc.57
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 +23 -4
- package/dist/core/ingestion/languages/python/builtin-descriptors.js +155 -11
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +14 -10
- package/dist/core/ingestion/route-extractors/fastapi-router-bindings.d.ts +19 -15
- package/dist/core/ingestion/route-extractors/fastapi-router-bindings.js +43 -21
- package/dist/core/ingestion/route-extractors/fastapi-router-prefixes.d.ts +21 -0
- package/dist/core/ingestion/route-extractors/fastapi-router-prefixes.js +153 -0
- package/dist/storage/parse-cache.js +7 -1
- package/package.json +1 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import Python from 'tree-sitter-python';
|
|
2
2
|
import { compilePatterns, runCompiledPatterns, unquoteLiteral, } from '../tree-sitter-scanner.js';
|
|
3
3
|
import { normalizeExtractedRoutePath } from '../../../ingestion/route-extractors/route-path.js';
|
|
4
|
+
import { extractFastAPIRouterBindings, } from '../../../ingestion/route-extractors/fastapi-router-bindings.js';
|
|
5
|
+
import { mergeMountPrefixes, resolveFastAPIRouterPrefixes, } from '../../../ingestion/route-extractors/fastapi-router-prefixes.js';
|
|
4
6
|
import { extractPythonModuleConstants, parseConstOperands, resolveOperands, } from '../../../ingestion/route-extractors/python-const-resolver.js';
|
|
5
7
|
/**
|
|
6
8
|
* Python HTTP plugin. Handles:
|
|
@@ -883,6 +885,10 @@ const NONLITERAL_ROUTE_DECORATOR_RE = /@\s*(?:app|router)\s*\.\s*(?:get|post|put
|
|
|
883
885
|
function buildPythonRepoContext(files, parser, readFile, parseSource) {
|
|
884
886
|
const prefixesByLongKey = new Map();
|
|
885
887
|
const prefixesByShortKey = new Map();
|
|
888
|
+
const routerIncludes = [];
|
|
889
|
+
const routerImports = [];
|
|
890
|
+
const routerModuleAliases = [];
|
|
891
|
+
const routerConstructorPrefixes = [];
|
|
886
892
|
// Single read pass (#2393): slurp every `.py` file's content ONCE. This used to
|
|
887
893
|
// be two passes — the include_router pre-pass below and the #2391 constant cost
|
|
888
894
|
// gate each re-read every `.py` file. The composed-route cost gate is computed
|
|
@@ -900,6 +906,15 @@ function buildPythonRepoContext(files, parser, readFile, parseSource) {
|
|
|
900
906
|
if (!hasComposedRoute && NONLITERAL_ROUTE_DECORATOR_RE.test(src))
|
|
901
907
|
hasComposedRoute = true;
|
|
902
908
|
}
|
|
909
|
+
for (const [rel, src] of pyContents) {
|
|
910
|
+
if (src.includes('include_router')) {
|
|
911
|
+
extractFastAPIRouterBindings(rel, src, routerIncludes, routerImports, routerModuleAliases, routerConstructorPrefixes);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
// Resolve against every repo path, empty files included, so module
|
|
915
|
+
// ambiguity matches ingestion (which passes all scanned paths).
|
|
916
|
+
const { prefixesByFile, resolvedIncludes } = resolveFastAPIRouterPrefixes(files, routerIncludes, routerImports, routerModuleAliases, routerConstructorPrefixes);
|
|
917
|
+
const resolvedIncludeKeys = new Set([...resolvedIncludes].map((inc) => JSON.stringify([inc.filePath, inc.routerExpr, inc.prefix])));
|
|
903
918
|
// Single PARSE pass (#2391): parse each `.py` at most once and feed BOTH the
|
|
904
919
|
// include_router prefix pre-pass and the composed-constant map below. This used
|
|
905
920
|
// to be two loops, so an include_router file in a composed repo was parsed
|
|
@@ -968,6 +983,8 @@ function buildPythonRepoContext(files, parser, readFile, parseSource) {
|
|
|
968
983
|
if (prefix === null)
|
|
969
984
|
continue;
|
|
970
985
|
const moduleShort = modNode.text;
|
|
986
|
+
if (resolvedIncludeKeys.has(JSON.stringify([rel, `${moduleShort}.router`, prefix])))
|
|
987
|
+
continue;
|
|
971
988
|
const aliasLong = localNameToModuleAlias.get(moduleShort);
|
|
972
989
|
const sameFileImport = localNameToModule.get(moduleShort);
|
|
973
990
|
const longKey = aliasLong ?? sameFileImport?.moduleLong;
|
|
@@ -993,6 +1010,8 @@ function buildPythonRepoContext(files, parser, readFile, parseSource) {
|
|
|
993
1010
|
const prefix = unquoteLiteral(prefixNode.text);
|
|
994
1011
|
if (prefix === null)
|
|
995
1012
|
continue;
|
|
1013
|
+
if (resolvedIncludeKeys.has(JSON.stringify([rel, nameNode.text, prefix])))
|
|
1014
|
+
continue;
|
|
996
1015
|
if (localImp.moduleLong) {
|
|
997
1016
|
recordPrefix(prefixesByLongKey, localImp.moduleLong, prefix);
|
|
998
1017
|
}
|
|
@@ -1013,6 +1032,7 @@ function buildPythonRepoContext(files, parser, readFile, parseSource) {
|
|
|
1013
1032
|
}
|
|
1014
1033
|
}
|
|
1015
1034
|
return {
|
|
1035
|
+
prefixesByFile,
|
|
1016
1036
|
prefixesByLongKey,
|
|
1017
1037
|
prefixesByShortKey,
|
|
1018
1038
|
constantsByFile,
|
|
@@ -1145,14 +1165,13 @@ export const PYTHON_HTTP_PLUGIN = {
|
|
|
1145
1165
|
// by the literal and the #2391 non-literal (resolved) router loops so both
|
|
1146
1166
|
// stack prefixes identically.
|
|
1147
1167
|
const emitRouterProvider = (httpMethod, rawPath, line) => {
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
// graph nodes and group contracts agree on which prefix applies.
|
|
1168
|
+
// Exact file matches plus the legacy long/short fallback. This mirrors
|
|
1169
|
+
// ingestion so graph Route nodes and contracts agree on mounted paths.
|
|
1151
1170
|
const longKey = fileRel ? fileLongKey(fileRel) : '';
|
|
1152
1171
|
const longPrefixes = longKey ? ctx?.prefixesByLongKey.get(longKey) : undefined;
|
|
1153
1172
|
const shortKey = fileRel ? fileShortKey(fileRel) : '';
|
|
1154
1173
|
const shortPrefixes = longPrefixes || !shortKey ? undefined : ctx?.prefixesByShortKey.get(shortKey);
|
|
1155
|
-
const prefixSet = longPrefixes ?? shortPrefixes;
|
|
1174
|
+
const prefixSet = mergeMountPrefixes(fileRel ? ctx?.prefixesByFile.get(fileRel.replace(/\\/g, '/')) : undefined, longPrefixes ?? shortPrefixes);
|
|
1156
1175
|
// Stack the same-file APIRouter(prefix=...) under any cross-file
|
|
1157
1176
|
// include_router prefix.
|
|
1158
1177
|
const localPath = constructorPrefix ? joinPrefix(constructorPrefix, rawPath) : rawPath;
|
|
@@ -80,6 +80,8 @@ function bindingOf(identifier) {
|
|
|
80
80
|
if (parent === null)
|
|
81
81
|
return null;
|
|
82
82
|
const shadow = { node: identifier, effect: 'shadow' };
|
|
83
|
+
if (inCasePattern(identifier))
|
|
84
|
+
return isCaseCapture(identifier) ? shadow : null;
|
|
83
85
|
switch (parent.type) {
|
|
84
86
|
case 'assignment':
|
|
85
87
|
case 'augmented_assignment':
|
|
@@ -117,11 +119,9 @@ function bindingOf(identifier) {
|
|
|
117
119
|
: null;
|
|
118
120
|
case 'dotted_name': {
|
|
119
121
|
const owner = parent.parent;
|
|
120
|
-
// `import a.b` binds `a
|
|
122
|
+
// `import a.b` binds `a`.
|
|
121
123
|
if (owner?.type === 'import_statement')
|
|
122
124
|
return parent.firstNamedChild?.id === node.id ? shadow : null;
|
|
123
|
-
if (owner?.type === 'case_pattern')
|
|
124
|
-
return parent.namedChildCount === 1 ? shadow : null;
|
|
125
125
|
if (owner?.type !== 'import_from_statement' || !isField(parent, 'name'))
|
|
126
126
|
return null;
|
|
127
127
|
return importsFromBuiltins(owner) ? { node: identifier, effect: 'builtin' } : shadow;
|
|
@@ -174,9 +174,145 @@ function descriptorBindings(node) {
|
|
|
174
174
|
const redirect = redirected.get(`${found.text}@${scopeOf(found)?.id}`) ?? null;
|
|
175
175
|
add(found.text, { ...binding, redirect });
|
|
176
176
|
}
|
|
177
|
+
addHelperCalls(tree.rootNode, bindings, redirected);
|
|
177
178
|
bindingsByTree.set(tree, bindings);
|
|
178
179
|
return bindings;
|
|
179
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* A bare module-level call to a same-file helper that always restores a
|
|
183
|
+
* descriptor name through `global` runs that restore at the call. Record it
|
|
184
|
+
* there as a module binding, so the call's position orders it.
|
|
185
|
+
*/
|
|
186
|
+
function addHelperCalls(root, bindings, redirected) {
|
|
187
|
+
// Helper function name -> its `def` and, per descriptor name, the restore
|
|
188
|
+
// its call performs.
|
|
189
|
+
const helpers = new Map();
|
|
190
|
+
for (const [name, list] of bindings) {
|
|
191
|
+
const byHelper = new Map();
|
|
192
|
+
for (const binding of list) {
|
|
193
|
+
if (binding.redirect !== 'global')
|
|
194
|
+
continue;
|
|
195
|
+
const fn = scopeOf(binding.node);
|
|
196
|
+
if (fn === null)
|
|
197
|
+
continue;
|
|
198
|
+
const entry = byHelper.get(fn.id);
|
|
199
|
+
if (entry === undefined)
|
|
200
|
+
byHelper.set(fn.id, { fn, globals: [binding] });
|
|
201
|
+
else
|
|
202
|
+
entry.globals.push(binding);
|
|
203
|
+
}
|
|
204
|
+
for (const { fn, globals } of byHelper.values()) {
|
|
205
|
+
const effect = restoreOnCall(fn, globals);
|
|
206
|
+
const helper = fn.childForFieldName('name')?.text;
|
|
207
|
+
if (effect === null || helper === undefined)
|
|
208
|
+
continue;
|
|
209
|
+
const entry = helpers.get(helper) ?? { fn, restores: new Map() };
|
|
210
|
+
entry.restores.set(name, effect);
|
|
211
|
+
helpers.set(helper, entry);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (helpers.size === 0)
|
|
215
|
+
return;
|
|
216
|
+
// A call only denotes the helper when its `def` is the name's one module
|
|
217
|
+
// binding. A local of the same name elsewhere cannot rebind it.
|
|
218
|
+
if (root.descendantsOfType('wildcard_import').length > 0)
|
|
219
|
+
return;
|
|
220
|
+
const bindingCounts = new Map();
|
|
221
|
+
for (const found of root.descendantsOfType('identifier')) {
|
|
222
|
+
if (!helpers.has(found.text) || bindingOf(found) === null)
|
|
223
|
+
continue;
|
|
224
|
+
const scope = scopeOf(found);
|
|
225
|
+
if (scope !== null && redirected.get(`${found.text}@${scope.id}`) !== 'global')
|
|
226
|
+
continue;
|
|
227
|
+
bindingCounts.set(found.text, (bindingCounts.get(found.text) ?? 0) + 1);
|
|
228
|
+
}
|
|
229
|
+
for (const [helper, count] of bindingCounts)
|
|
230
|
+
if (count > 1)
|
|
231
|
+
helpers.delete(helper);
|
|
232
|
+
const touched = new Set();
|
|
233
|
+
for (const statement of root.namedChildren) {
|
|
234
|
+
// Only a call that is the whole statement surely runs when reached.
|
|
235
|
+
if (statement.type !== 'expression_statement' || statement.namedChildCount !== 1)
|
|
236
|
+
continue;
|
|
237
|
+
const call = statement.firstNamedChild;
|
|
238
|
+
const callee = call?.type === 'call' ? call.childForFieldName('function') : null;
|
|
239
|
+
const helper = callee?.type === 'identifier' ? helpers.get(callee.text) : undefined;
|
|
240
|
+
// The call reaches the helper only once its `def` statement has run, and
|
|
241
|
+
// an argument-free call binds only when every parameter is optional.
|
|
242
|
+
if (helper === undefined || statement.startIndex < helper.fn.endIndex)
|
|
243
|
+
continue;
|
|
244
|
+
if ((call?.childForFieldName('arguments')?.namedChildCount ?? 0) > 0)
|
|
245
|
+
continue;
|
|
246
|
+
for (const [name, effect] of helper.restores) {
|
|
247
|
+
const list = bindings.get(name);
|
|
248
|
+
if (list === undefined)
|
|
249
|
+
continue;
|
|
250
|
+
list.push({ node: callee, effect, redirect: null });
|
|
251
|
+
touched.add(list);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
for (const list of touched)
|
|
255
|
+
list.sort((a, b) => a.node.startIndex - b.node.startIndex);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Does this name in a case pattern capture? Value patterns (`a.b`), class
|
|
259
|
+
* names and keyword keys are reads. Every other name is a capture.
|
|
260
|
+
*/
|
|
261
|
+
function isCaseCapture(identifier) {
|
|
262
|
+
const parent = identifier.parent;
|
|
263
|
+
if (parent?.type === 'keyword_pattern')
|
|
264
|
+
return parent.firstNamedChild?.id !== identifier.id;
|
|
265
|
+
if (parent?.type !== 'dotted_name')
|
|
266
|
+
return true;
|
|
267
|
+
if (parent.namedChildCount > 1)
|
|
268
|
+
return false;
|
|
269
|
+
return !(parent.parent?.type === 'class_pattern' && parent.parent.firstNamedChild?.id === parent.id);
|
|
270
|
+
}
|
|
271
|
+
/** Is `node` inside a `match` case pattern? */
|
|
272
|
+
function inCasePattern(node) {
|
|
273
|
+
for (let parent = node.parent; parent !== null; parent = parent.parent) {
|
|
274
|
+
if (parent.type === 'case_pattern')
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The restore a call to `fn` always performs on a module global, or `null`.
|
|
281
|
+
* `fn` must be a plain module-level `def` whose body runs on call (not a
|
|
282
|
+
* generator or coroutine), and every `global` binding of the name in it must
|
|
283
|
+
* be a restore in a simple statement that no earlier `return` can skip.
|
|
284
|
+
*/
|
|
285
|
+
function restoreOnCall(fn, globals) {
|
|
286
|
+
if (fn.type !== 'function_definition' || fn.parent?.type !== 'module')
|
|
287
|
+
return null;
|
|
288
|
+
if (fn.children.some((child) => child.type === 'async'))
|
|
289
|
+
return null;
|
|
290
|
+
const required = fn
|
|
291
|
+
.childForFieldName('parameters')
|
|
292
|
+
?.namedChildren.some((param) => param.type === 'identifier' ||
|
|
293
|
+
(param.type === 'typed_parameter' && param.firstNamedChild?.type === 'identifier'));
|
|
294
|
+
if (required === true)
|
|
295
|
+
return null;
|
|
296
|
+
const body = fn.childForFieldName('body');
|
|
297
|
+
// `globals` is in source order, so the last restore is the one that sticks.
|
|
298
|
+
const first = globals[0];
|
|
299
|
+
const last = globals[globals.length - 1];
|
|
300
|
+
if (body === null || first === undefined || last === undefined)
|
|
301
|
+
return null;
|
|
302
|
+
for (const binding of globals) {
|
|
303
|
+
const statement = statementIn(binding.node, body);
|
|
304
|
+
if (binding.effect === 'shadow' ||
|
|
305
|
+
statement === null ||
|
|
306
|
+
!SIMPLE_STATEMENTS.has(statement.type)) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const escapes = body
|
|
311
|
+
.descendantsOfType(['yield', 'return_statement'])
|
|
312
|
+
.some((node) => scopeOf(node)?.id === fn.id &&
|
|
313
|
+
(node.type === 'yield' || node.startIndex < first.node.startIndex));
|
|
314
|
+
return escapes ? null : last.effect;
|
|
315
|
+
}
|
|
180
316
|
/**
|
|
181
317
|
* The scope that owns names bound at `node`: a function or lambda body, a
|
|
182
318
|
* class body, a comprehension, or the module (`null`). A walrus target skips
|
|
@@ -207,6 +343,10 @@ function statementIn(node, body) {
|
|
|
207
343
|
current = current.parent;
|
|
208
344
|
return current.parent === null ? null : current;
|
|
209
345
|
}
|
|
346
|
+
/** Does `outer` span `node`? */
|
|
347
|
+
function contains(outer, node) {
|
|
348
|
+
return node.startIndex >= outer.startIndex && node.endIndex <= outer.endIndex;
|
|
349
|
+
}
|
|
210
350
|
/** Can `binding` run before `use` in the same scope, including an earlier
|
|
211
351
|
* iteration of an enclosing loop? */
|
|
212
352
|
function mayRunBefore(binding, use, scope) {
|
|
@@ -214,8 +354,7 @@ function mayRunBefore(binding, use, scope) {
|
|
|
214
354
|
return true;
|
|
215
355
|
for (let loop = use.parent; loop !== null && loop.id !== scope?.id; loop = loop.parent) {
|
|
216
356
|
if ((loop.type === 'for_statement' || loop.type === 'while_statement') &&
|
|
217
|
-
binding
|
|
218
|
-
binding.endIndex <= loop.endIndex) {
|
|
357
|
+
contains(loop, binding)) {
|
|
219
358
|
return true;
|
|
220
359
|
}
|
|
221
360
|
}
|
|
@@ -270,6 +409,13 @@ function lookupName(bindings, use) {
|
|
|
270
409
|
const owned = bindings.some((binding) => binding.redirect === null && scopeOf(binding.node)?.id === fn.id);
|
|
271
410
|
if (!owned)
|
|
272
411
|
continue;
|
|
412
|
+
// A nested function can rebind this cell whenever it is called; that
|
|
413
|
+
// order is not modelled, so assume it ran.
|
|
414
|
+
const rebound = bindings.some((binding) => binding.effect === 'shadow' &&
|
|
415
|
+
binding.redirect === 'nonlocal' &&
|
|
416
|
+
contains(fn, binding.node));
|
|
417
|
+
if (rebound)
|
|
418
|
+
return 'shadow';
|
|
273
419
|
// Any binding makes the name local to this function, so the class body
|
|
274
420
|
// reads that cell. An unbound cell raises NameError, not the builtin.
|
|
275
421
|
return namespaceState(bindings, fn, use) === 'builtin' ? 'builtin' : 'shadow';
|
|
@@ -287,16 +433,14 @@ function lookupName(bindings, use) {
|
|
|
287
433
|
// is called; that order is not modelled, so assume it ran.
|
|
288
434
|
if (binding.redirect === 'nonlocal') {
|
|
289
435
|
const outer = functions[functions.length - 1];
|
|
290
|
-
|
|
291
|
-
binding.node.startIndex >= outer.startIndex &&
|
|
292
|
-
binding.node.endIndex <= outer.endIndex;
|
|
293
|
-
if (inside)
|
|
436
|
+
if (outer !== undefined && contains(outer, binding.node))
|
|
294
437
|
return 'shadow';
|
|
295
438
|
}
|
|
296
439
|
if (binding.redirect === 'global') {
|
|
297
440
|
// The function can only be called once the top-level statement that
|
|
298
|
-
// defines it has run. Whether a call happens is unknown, so a
|
|
299
|
-
//
|
|
441
|
+
// defines it has run. Whether a call happens is unknown, so a rebinding
|
|
442
|
+
// one is assumed. A restore counts only at a proven call site (see
|
|
443
|
+
// `addHelperCalls`).
|
|
300
444
|
const top = statementIn(binding.node, binding.node.tree.rootNode);
|
|
301
445
|
if (deferred || (top !== null && mayRunBefore(top, use, null)))
|
|
302
446
|
return 'shadow';
|
|
@@ -30,6 +30,7 @@ import { classifyContentLanguages } from '../content-language-classification.js'
|
|
|
30
30
|
import { SCOPE_RESOLVERS } from '../scope-resolution/pipeline/registry.js';
|
|
31
31
|
import { DATA_ROUTE_TABLE_SOURCE } from '../route-extractors/data-route-table.js';
|
|
32
32
|
import { createWorkerPool, workerPoolDisabledByEnv, resolveAutoPoolSize, envWorkerPoolSize, resolveHostParallelism, WorkerPoolInitializationError, WorkerPoolDisabledError, } from '../workers/worker-pool.js';
|
|
33
|
+
import { mergeMountPrefixes, resolveFastAPIRouterPrefixes, } from '../route-extractors/fastapi-router-prefixes.js';
|
|
33
34
|
import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
|
|
34
35
|
import { resolveOperands } from '../route-extractors/python-const-resolver.js';
|
|
35
36
|
import { prepareRouteConstantsByProvider } from '../language-provider.js';
|
|
@@ -1326,11 +1327,9 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
1326
1327
|
}
|
|
1327
1328
|
m.set(alias.localName, alias.moduleKeyLong);
|
|
1328
1329
|
}
|
|
1329
|
-
//
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
// remain the fallback when no long key is known (e.g. Shape A includes
|
|
1333
|
-
// without a corresponding import statement).
|
|
1330
|
+
// Exact-file matches handle import-resolved mounts (including nested
|
|
1331
|
+
// routers). These long/short maps preserve the older fallback for mounts
|
|
1332
|
+
// whose import cannot be resolved to one file.
|
|
1334
1333
|
const prefixesByLongKey = new Map();
|
|
1335
1334
|
const prefixesByShortKey = new Map();
|
|
1336
1335
|
// Constructor prefixes are `router`-only (the apply gate below and the
|
|
@@ -1338,6 +1337,7 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
1338
1337
|
// flat file-key → prefix map suffices — mirrors the group layer's shape.
|
|
1339
1338
|
const constructorPrefixesByLongKey = new Map();
|
|
1340
1339
|
const constructorPrefixesByShortKey = new Map();
|
|
1340
|
+
const { prefixesByFile, resolvedIncludes } = resolveFastAPIRouterPrefixes(allPaths, allRouterIncludes, allRouterImports, allRouterModuleAliases, allRouterConstructorPrefixes);
|
|
1341
1341
|
const recordPrefix = (target, key, prefix) => {
|
|
1342
1342
|
let set = target.get(key);
|
|
1343
1343
|
if (!set) {
|
|
@@ -1347,6 +1347,10 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
1347
1347
|
set.add(prefix);
|
|
1348
1348
|
};
|
|
1349
1349
|
for (const inc of allRouterIncludes) {
|
|
1350
|
+
// Unprefixed includes only exist as propagation edges; recording `''`
|
|
1351
|
+
// here would shadow a real short-key prefix for the same module.
|
|
1352
|
+
if (resolvedIncludes.has(inc) || !inc.prefix)
|
|
1353
|
+
continue;
|
|
1350
1354
|
// Shape A: `<module>.router`. The worker emits `routerExpr` already
|
|
1351
1355
|
// including `.router`, so split it back. We only know a short module
|
|
1352
1356
|
// key here — the call site doesn't carry the dotted package path. If
|
|
@@ -1377,7 +1381,8 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
1377
1381
|
recordPrefix(prefixesByShortKey, localImp.moduleKey, inc.prefix);
|
|
1378
1382
|
}
|
|
1379
1383
|
}
|
|
1380
|
-
if (
|
|
1384
|
+
if (prefixesByFile.size > 0 ||
|
|
1385
|
+
prefixesByLongKey.size > 0 ||
|
|
1381
1386
|
prefixesByShortKey.size > 0 ||
|
|
1382
1387
|
allRouterConstructorPrefixes.length > 0) {
|
|
1383
1388
|
const fileLongKey = (rel) => {
|
|
@@ -1415,15 +1420,14 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
1415
1420
|
expanded.push(dr);
|
|
1416
1421
|
continue;
|
|
1417
1422
|
}
|
|
1418
|
-
//
|
|
1419
|
-
//
|
|
1420
|
-
// between e.g. `api/users.py` and `admin/users.py`.
|
|
1423
|
+
// Exact file matches plus the legacy long/short fallback for mounts
|
|
1424
|
+
// whose import could not be resolved.
|
|
1421
1425
|
const longKey = fileLongKey(dr.filePath);
|
|
1422
1426
|
const longPrefixes = longKey ? prefixesByLongKey.get(longKey) : undefined;
|
|
1423
1427
|
const shortPrefixes = longPrefixes
|
|
1424
1428
|
? undefined
|
|
1425
1429
|
: prefixesByShortKey.get(fileShortKey(dr.filePath));
|
|
1426
|
-
const prefixes = longPrefixes ?? shortPrefixes;
|
|
1430
|
+
const prefixes = mergeMountPrefixes(prefixesByFile.get(dr.filePath.replace(/\\/g, '/')), longPrefixes ?? shortPrefixes);
|
|
1427
1431
|
// Constructor prefixes are keyed like include_router prefixes:
|
|
1428
1432
|
// long-key entries are precise, while short-key entries are only
|
|
1429
1433
|
// valid for repo-root/single-segment files where `fileLongKey`
|
|
@@ -13,17 +13,19 @@
|
|
|
13
13
|
* extraction here lets unit tests import the function directly without
|
|
14
14
|
* booting a worker, satisfying DoD §2.7.
|
|
15
15
|
*
|
|
16
|
-
* Worker phase is per-file, so the
|
|
17
|
-
* `pipeline-phases/parse-impl.ts`. Here we
|
|
16
|
+
* Worker phase is per-file, so the cross-file resolution lives in
|
|
17
|
+
* `fastapi-router-prefixes.ts` and `pipeline-phases/parse-impl.ts`. Here we extract raw record
|
|
18
18
|
* kinds and let the pipeline aggregate them across files:
|
|
19
19
|
*
|
|
20
20
|
* • {@link ExtractedRouterInclude} — every
|
|
21
|
-
* `<host>.include_router(<routerExpr>, prefix='/x')`
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* `
|
|
21
|
+
* `<host>.include_router(<routerExpr>, prefix='/x')` mount, plus
|
|
22
|
+
* unprefixed `<host>.include_router(<routerExpr>[, <other kwargs>])`
|
|
23
|
+
* calls recorded with `prefix: ''`. `<routerExpr>` is either
|
|
24
|
+
* `<module>.router` (Shape A) or a bare local name (Shape B). The
|
|
25
|
+
* extractor leaves `<host>` unconstrained (production code uses `app`,
|
|
26
|
+
* `api`, `application`, `asgi_app`, …); unprefixed records are only
|
|
27
|
+
* propagation edges, and `fastapi-router-prefixes.ts` passes a parent
|
|
28
|
+
* prefix through them only when the host is `router`.
|
|
27
29
|
*
|
|
28
30
|
* • {@link ExtractedRouterImport} — every
|
|
29
31
|
* `from <module> import router [as <alias>]`, captured for both
|
|
@@ -37,25 +39,25 @@
|
|
|
37
39
|
* • short key — basename without `.py` (`users`)
|
|
38
40
|
* • long key — `<parent-dir>/<basename>` (`api/users`)
|
|
39
41
|
*
|
|
40
|
-
* Imports
|
|
41
|
-
*
|
|
42
|
-
* long key first and falls back to the short key, so cross-package
|
|
43
|
-
* collisions are eliminated for Shape B and minimised for Shape A.
|
|
42
|
+
* Imports retain their module path for exact-file resolution. The short and
|
|
43
|
+
* long keys remain for the conservative fallback when that resolution fails.
|
|
44
44
|
*
|
|
45
45
|
* The functions in this module are pure (no Worker / parentPort
|
|
46
46
|
* dependency) so they can be unit-tested directly without booting a
|
|
47
47
|
* worker thread.
|
|
48
48
|
*/
|
|
49
49
|
/**
|
|
50
|
-
* One `<host>.include_router(<routerExpr>, prefix='/x')` site
|
|
50
|
+
* One `<host>.include_router(<routerExpr>, prefix='/x')` site, or a
|
|
51
|
+
* simple child include without a local prefix.
|
|
51
52
|
*
|
|
52
53
|
* `routerExpr` is the raw text of the first argument — either
|
|
53
54
|
* `<module>.router` (Shape A) or a bare local name (Shape B).
|
|
54
|
-
*
|
|
55
|
-
*
|
|
55
|
+
* Cross-file resolution uses {@link ExtractedRouterImport} records
|
|
56
|
+
* emitted by the same file.
|
|
56
57
|
*/
|
|
57
58
|
export interface ExtractedRouterInclude {
|
|
58
59
|
filePath: string;
|
|
60
|
+
host: string;
|
|
59
61
|
routerExpr: string;
|
|
60
62
|
prefix: string;
|
|
61
63
|
lineNumber: number;
|
|
@@ -76,6 +78,7 @@ export interface ExtractedRouterInclude {
|
|
|
76
78
|
export interface ExtractedRouterImport {
|
|
77
79
|
filePath: string;
|
|
78
80
|
localName: string;
|
|
81
|
+
modulePath: string;
|
|
79
82
|
moduleKey: string;
|
|
80
83
|
moduleKeyLong?: string;
|
|
81
84
|
}
|
|
@@ -98,6 +101,7 @@ export interface ExtractedRouterModuleAlias {
|
|
|
98
101
|
filePath: string;
|
|
99
102
|
/** Local name in the importing file (== imported name or its alias). */
|
|
100
103
|
localName: string;
|
|
104
|
+
modulePath: string;
|
|
101
105
|
/** Long key (`<parent>/<stem>`) — non-empty for every emitted record. */
|
|
102
106
|
moduleKeyLong: string;
|
|
103
107
|
}
|
|
@@ -13,17 +13,19 @@
|
|
|
13
13
|
* extraction here lets unit tests import the function directly without
|
|
14
14
|
* booting a worker, satisfying DoD §2.7.
|
|
15
15
|
*
|
|
16
|
-
* Worker phase is per-file, so the
|
|
17
|
-
* `pipeline-phases/parse-impl.ts`. Here we
|
|
16
|
+
* Worker phase is per-file, so the cross-file resolution lives in
|
|
17
|
+
* `fastapi-router-prefixes.ts` and `pipeline-phases/parse-impl.ts`. Here we extract raw record
|
|
18
18
|
* kinds and let the pipeline aggregate them across files:
|
|
19
19
|
*
|
|
20
20
|
* • {@link ExtractedRouterInclude} — every
|
|
21
|
-
* `<host>.include_router(<routerExpr>, prefix='/x')`
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* `
|
|
21
|
+
* `<host>.include_router(<routerExpr>, prefix='/x')` mount, plus
|
|
22
|
+
* unprefixed `<host>.include_router(<routerExpr>[, <other kwargs>])`
|
|
23
|
+
* calls recorded with `prefix: ''`. `<routerExpr>` is either
|
|
24
|
+
* `<module>.router` (Shape A) or a bare local name (Shape B). The
|
|
25
|
+
* extractor leaves `<host>` unconstrained (production code uses `app`,
|
|
26
|
+
* `api`, `application`, `asgi_app`, …); unprefixed records are only
|
|
27
|
+
* propagation edges, and `fastapi-router-prefixes.ts` passes a parent
|
|
28
|
+
* prefix through them only when the host is `router`.
|
|
27
29
|
*
|
|
28
30
|
* • {@link ExtractedRouterImport} — every
|
|
29
31
|
* `from <module> import router [as <alias>]`, captured for both
|
|
@@ -37,10 +39,8 @@
|
|
|
37
39
|
* • short key — basename without `.py` (`users`)
|
|
38
40
|
* • long key — `<parent-dir>/<basename>` (`api/users`)
|
|
39
41
|
*
|
|
40
|
-
* Imports
|
|
41
|
-
*
|
|
42
|
-
* long key first and falls back to the short key, so cross-package
|
|
43
|
-
* collisions are eliminated for Shape B and minimised for Shape A.
|
|
42
|
+
* Imports retain their module path for exact-file resolution. The short and
|
|
43
|
+
* long keys remain for the conservative fallback when that resolution fails.
|
|
44
44
|
*
|
|
45
45
|
* The functions in this module are pure (no Worker / parentPort
|
|
46
46
|
* dependency) so they can be unit-tested directly without booting a
|
|
@@ -49,10 +49,18 @@
|
|
|
49
49
|
// `<host>.include_router(<module>.router, ..., prefix='/x')` (Shape A).
|
|
50
50
|
// `<host>` is left unrestricted — common production names include
|
|
51
51
|
// `app`, `api`, `application`, `asgi_app`. Pinning to the literal
|
|
52
|
-
// `app` would silently drop these.
|
|
53
|
-
|
|
52
|
+
// `app` would silently drop these. Arguments before `prefix=` may hold one
|
|
53
|
+
// level of nested calls (`dependencies=[Depends(auth)]`); the two
|
|
54
|
+
// alternatives start on disjoint characters, so matching stays linear.
|
|
55
|
+
const INCLUDE_ROUTER_ATTR_RE = /\b([A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_][\w]*)\.router\b(?:[^()]|\([^()]*\))*?\bprefix\s*=\s*(['"])([^'"]*)\3/g;
|
|
54
56
|
// `<host>.include_router(<local_name>, ..., prefix='/x')` (Shape B).
|
|
55
|
-
const INCLUDE_ROUTER_NAME_RE = /\b(
|
|
57
|
+
const INCLUDE_ROUTER_NAME_RE = /\b([A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_][\w]*)\b(?:[^()]|\([^()]*\))*?\bprefix\s*=\s*(['"])([^'"]*)\3/g;
|
|
58
|
+
// A child router may be included without a local prefix and inherit the
|
|
59
|
+
// prefix of its parent router when that parent is mounted elsewhere. Other
|
|
60
|
+
// keyword arguments (with one level of nested calls, like the prefixed
|
|
61
|
+
// patterns) and a trailing comma are allowed; a `prefix=` anywhere in the
|
|
62
|
+
// call declines the match so it never double-fires with those patterns.
|
|
63
|
+
const INCLUDE_ROUTER_UNPREFIXED_RE = /\b([A-Za-z_][\w.]*)\.include_router\s*\(\s*([A-Za-z_]\w*(?:\.router)?)\s*(?:,(?!(?:[^()]|\([^()]*\))*?\bprefix\s*=)(?:[^()]|\([^()]*\))*)?\)/g;
|
|
56
64
|
// Module path: a sequence of dots (`.`, `..`, `...`) for "current
|
|
57
65
|
// package" imports, OR an optional leading-dot prefix followed by a
|
|
58
66
|
// dotted identifier (`api.users`, `.api.users`, `..siblings.users`).
|
|
@@ -175,6 +183,7 @@ export function extractFastAPIRouterBindings(filePath, content, outIncludes, out
|
|
|
175
183
|
outImports.push({
|
|
176
184
|
filePath,
|
|
177
185
|
localName,
|
|
186
|
+
modulePath: moduleText,
|
|
178
187
|
moduleKey: moduleShort,
|
|
179
188
|
...(moduleLong ? { moduleKeyLong: moduleLong } : {}),
|
|
180
189
|
});
|
|
@@ -199,6 +208,7 @@ export function extractFastAPIRouterBindings(filePath, content, outIncludes, out
|
|
|
199
208
|
outModuleAliases.push({
|
|
200
209
|
filePath,
|
|
201
210
|
localName,
|
|
211
|
+
modulePath: `${moduleText}.${importedName}`,
|
|
202
212
|
moduleKeyLong: aliasLong,
|
|
203
213
|
});
|
|
204
214
|
}
|
|
@@ -231,8 +241,9 @@ export function extractFastAPIRouterBindings(filePath, content, outIncludes, out
|
|
|
231
241
|
while ((m = INCLUDE_ROUTER_ATTR_RE.exec(content)) !== null) {
|
|
232
242
|
outIncludes.push({
|
|
233
243
|
filePath,
|
|
234
|
-
|
|
235
|
-
|
|
244
|
+
host: m[1],
|
|
245
|
+
routerExpr: `${m[2]}.router`,
|
|
246
|
+
prefix: m[4],
|
|
236
247
|
lineNumber: content.substring(0, m.index).split('\n').length,
|
|
237
248
|
});
|
|
238
249
|
}
|
|
@@ -245,14 +256,25 @@ export function extractFastAPIRouterBindings(filePath, content, outIncludes, out
|
|
|
245
256
|
// is intentionally permissive and would re-capture `<mod>.router`
|
|
246
257
|
// as the bare name `mod`. Discriminate by re-checking the
|
|
247
258
|
// immediate source around the captured argument position.
|
|
248
|
-
const argStart = m.index + m[0].indexOf(m[
|
|
249
|
-
const dotProbe = content.slice(argStart + m[
|
|
259
|
+
const argStart = m.index + m[0].indexOf(m[2], m[0].indexOf('(') + 1);
|
|
260
|
+
const dotProbe = content.slice(argStart + m[2].length, argStart + m[2].length + 8);
|
|
250
261
|
if (/^\s*\.\s*router/.test(dotProbe))
|
|
251
262
|
continue;
|
|
252
263
|
outIncludes.push({
|
|
253
264
|
filePath,
|
|
254
|
-
|
|
255
|
-
|
|
265
|
+
host: m[1],
|
|
266
|
+
routerExpr: m[2],
|
|
267
|
+
prefix: m[4],
|
|
268
|
+
lineNumber: content.substring(0, m.index).split('\n').length,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
INCLUDE_ROUTER_UNPREFIXED_RE.lastIndex = 0;
|
|
272
|
+
while ((m = INCLUDE_ROUTER_UNPREFIXED_RE.exec(content)) !== null) {
|
|
273
|
+
outIncludes.push({
|
|
274
|
+
filePath,
|
|
275
|
+
host: m[1],
|
|
276
|
+
routerExpr: m[2],
|
|
277
|
+
prefix: '',
|
|
256
278
|
lineNumber: content.substring(0, m.index).split('\n').length,
|
|
257
279
|
});
|
|
258
280
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ExtractedRouterConstructorPrefix, ExtractedRouterImport, ExtractedRouterInclude, ExtractedRouterModuleAlias } from './fastapi-router-bindings.js';
|
|
2
|
+
export interface ResolvedFastAPIRouterPrefixes {
|
|
3
|
+
prefixesByFile: Map<string, Set<string>>;
|
|
4
|
+
resolvedIncludes: Set<ExtractedRouterInclude>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Prefixes that apply to one router file: exact import-resolved mounts plus
|
|
8
|
+
* the legacy long/short-key prefixes of mounts the resolver could not bind.
|
|
9
|
+
* Shared by ingestion and the group extractor so both surfaces agree.
|
|
10
|
+
*/
|
|
11
|
+
export declare function mergeMountPrefixes(exact: ReadonlySet<string> | undefined, legacy: ReadonlySet<string> | undefined): ReadonlySet<string> | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Carry mounted prefixes through exact, import-resolved router includes.
|
|
14
|
+
*
|
|
15
|
+
* Only a host literally named `router` passes its mounted prefix on to the
|
|
16
|
+
* routers it includes, and resolution is per file rather than per variable:
|
|
17
|
+
* `api_router = APIRouter(); api_router.include_router(x.router)` does not
|
|
18
|
+
* pass through, and a prefixed `api_router.include_router(...)` is treated
|
|
19
|
+
* as a root mount. Unprefixed includes from any other host are ignored.
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveFastAPIRouterPrefixes(files: Iterable<string>, includes: readonly ExtractedRouterInclude[], imports: readonly ExtractedRouterImport[], moduleAliases: readonly ExtractedRouterModuleAlias[], constructorPrefixes?: readonly ExtractedRouterConstructorPrefix[]): ResolvedFastAPIRouterPrefixes;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { normalizeExtractedRoutePath } from './route-path.js';
|
|
2
|
+
/** Resolve only imports that identify one Python file in this repository. */
|
|
3
|
+
function resolveModuleFile(importer, modulePath, files, absoluteModules) {
|
|
4
|
+
const leadingDots = /^\.+/.exec(modulePath)?.[0].length ?? 0;
|
|
5
|
+
const dotted = modulePath.slice(leadingDots);
|
|
6
|
+
if (!dotted)
|
|
7
|
+
return undefined;
|
|
8
|
+
const moduleSegments = dotted.split('.');
|
|
9
|
+
if (!moduleSegments.every((part) => /^[A-Za-z_]\w*$/.test(part)))
|
|
10
|
+
return undefined;
|
|
11
|
+
let stem;
|
|
12
|
+
if (leadingDots > 0) {
|
|
13
|
+
const directory = importer.replace(/\\/g, '/').split('/').slice(0, -1);
|
|
14
|
+
const parentLevels = leadingDots - 1;
|
|
15
|
+
if (parentLevels > directory.length)
|
|
16
|
+
return undefined;
|
|
17
|
+
stem = [...directory.slice(0, directory.length - parentLevels), ...moduleSegments].join('/');
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
stem = moduleSegments.join('/');
|
|
21
|
+
}
|
|
22
|
+
if (leadingDots === 0)
|
|
23
|
+
return absoluteModules.get(stem) ?? undefined;
|
|
24
|
+
const candidates = [`${stem}.py`, `${stem}/__init__.py`].filter((candidate) => files.has(candidate));
|
|
25
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Prefixes that apply to one router file: exact import-resolved mounts plus
|
|
29
|
+
* the legacy long/short-key prefixes of mounts the resolver could not bind.
|
|
30
|
+
* Shared by ingestion and the group extractor so both surfaces agree.
|
|
31
|
+
*/
|
|
32
|
+
export function mergeMountPrefixes(exact, legacy) {
|
|
33
|
+
if (!exact)
|
|
34
|
+
return legacy;
|
|
35
|
+
if (!legacy)
|
|
36
|
+
return exact;
|
|
37
|
+
return new Set([...exact, ...legacy]);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Carry mounted prefixes through exact, import-resolved router includes.
|
|
41
|
+
*
|
|
42
|
+
* Only a host literally named `router` passes its mounted prefix on to the
|
|
43
|
+
* routers it includes, and resolution is per file rather than per variable:
|
|
44
|
+
* `api_router = APIRouter(); api_router.include_router(x.router)` does not
|
|
45
|
+
* pass through, and a prefixed `api_router.include_router(...)` is treated
|
|
46
|
+
* as a root mount. Unprefixed includes from any other host are ignored.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveFastAPIRouterPrefixes(files, includes, imports, moduleAliases, constructorPrefixes = []) {
|
|
49
|
+
const fileSet = new Set([...files].map((file) => file.replace(/\\/g, '/')));
|
|
50
|
+
const constructorPrefixByFile = new Map(constructorPrefixes.map((ctor) => [ctor.filePath.replace(/\\/g, '/'), ctor.prefix]));
|
|
51
|
+
const absoluteModules = new Map();
|
|
52
|
+
for (const file of fileSet) {
|
|
53
|
+
if (!file.endsWith('.py'))
|
|
54
|
+
continue;
|
|
55
|
+
const stem = file.endsWith('/__init__.py')
|
|
56
|
+
? file.slice(0, -'/__init__.py'.length)
|
|
57
|
+
: file.slice(0, -'.py'.length);
|
|
58
|
+
const parts = stem.split('/');
|
|
59
|
+
for (let i = 0; i < parts.length; i++) {
|
|
60
|
+
const suffix = parts.slice(i).join('/');
|
|
61
|
+
const previous = absoluteModules.get(suffix);
|
|
62
|
+
absoluteModules.set(suffix, previous === undefined ? file : previous === file ? file : null);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const importsByFile = new Map();
|
|
66
|
+
for (const imp of [...imports, ...moduleAliases]) {
|
|
67
|
+
const modulePath = imp.modulePath;
|
|
68
|
+
if (!modulePath)
|
|
69
|
+
continue;
|
|
70
|
+
const file = imp.filePath.replace(/\\/g, '/');
|
|
71
|
+
const bindings = importsByFile.get(file) ?? new Map();
|
|
72
|
+
bindings.set(imp.localName, { modulePath });
|
|
73
|
+
importsByFile.set(file, bindings);
|
|
74
|
+
}
|
|
75
|
+
const prefixesByFile = new Map();
|
|
76
|
+
const resolvedIncludes = new Set();
|
|
77
|
+
const childIncludes = new Map();
|
|
78
|
+
const bareMounts = new Set();
|
|
79
|
+
for (const inc of includes) {
|
|
80
|
+
const source = inc.filePath.replace(/\\/g, '/');
|
|
81
|
+
const localName = inc.routerExpr.endsWith('.router')
|
|
82
|
+
? inc.routerExpr.slice(0, -'.router'.length)
|
|
83
|
+
: inc.routerExpr;
|
|
84
|
+
const modulePath = importsByFile.get(source)?.get(localName)?.modulePath;
|
|
85
|
+
if (!modulePath)
|
|
86
|
+
continue;
|
|
87
|
+
const target = resolveModuleFile(source, modulePath, fileSet, absoluteModules);
|
|
88
|
+
if (!target)
|
|
89
|
+
continue;
|
|
90
|
+
if (inc.host === 'router') {
|
|
91
|
+
const children = childIncludes.get(source) ?? [];
|
|
92
|
+
children.push({ target, prefix: inc.prefix, include: inc });
|
|
93
|
+
childIncludes.set(source, children);
|
|
94
|
+
}
|
|
95
|
+
else if (inc.prefix) {
|
|
96
|
+
const prefixes = prefixesByFile.get(target) ?? new Set();
|
|
97
|
+
prefixes.add(inc.prefix);
|
|
98
|
+
prefixesByFile.set(target, prefixes);
|
|
99
|
+
resolvedIncludes.add(inc);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
bareMounts.add(target);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// A router mounted without a prefix still seeds traversal (with an empty
|
|
106
|
+
// prefix) so its own `APIRouter(prefix=...)` reaches its children. Like its
|
|
107
|
+
// own routes, it only does so when no prefixed mount targets the file.
|
|
108
|
+
const roots = [
|
|
109
|
+
...[...prefixesByFile].flatMap(([file, prefixes]) => [...prefixes].map((prefix) => ({ file, prefix }))),
|
|
110
|
+
...[...bareMounts]
|
|
111
|
+
.filter((file) => !prefixesByFile.has(file))
|
|
112
|
+
.map((file) => ({ file, prefix: '' })),
|
|
113
|
+
];
|
|
114
|
+
// `expanded` memoizes (file, prefix) frames so diamond-shaped include graphs
|
|
115
|
+
// stay linear in distinct prefixes; the per-path `visited` set still stops
|
|
116
|
+
// cycles whose edges keep growing the prefix.
|
|
117
|
+
const expanded = new Set();
|
|
118
|
+
for (const { file, prefix } of roots) {
|
|
119
|
+
const stack = [{ file, prefix, visited: new Set([file]) }];
|
|
120
|
+
for (let current = stack.pop(); current; current = stack.pop()) {
|
|
121
|
+
const frameKey = `${current.file}\0${current.prefix}`;
|
|
122
|
+
if (expanded.has(frameKey))
|
|
123
|
+
continue;
|
|
124
|
+
expanded.add(frameKey);
|
|
125
|
+
// The parent's own `APIRouter(prefix=...)` sits between its mount
|
|
126
|
+
// prefix and the child include prefix.
|
|
127
|
+
const ctorPrefix = constructorPrefixByFile.get(current.file);
|
|
128
|
+
const parentPrefix = ctorPrefix
|
|
129
|
+
? normalizeExtractedRoutePath(ctorPrefix, current.prefix)
|
|
130
|
+
: current.prefix;
|
|
131
|
+
for (const edge of childIncludes.get(current.file) ?? []) {
|
|
132
|
+
if (current.visited.has(edge.target))
|
|
133
|
+
continue;
|
|
134
|
+
const normalized = normalizeExtractedRoutePath(edge.prefix, parentPrefix);
|
|
135
|
+
// An all-empty chain adds no prefix; record nothing so the child
|
|
136
|
+
// keeps its legacy fallback, but keep walking for deeper prefixes.
|
|
137
|
+
const joined = normalized === '/' ? '' : normalized;
|
|
138
|
+
if (joined) {
|
|
139
|
+
const targetPrefixes = prefixesByFile.get(edge.target) ?? new Set();
|
|
140
|
+
targetPrefixes.add(joined);
|
|
141
|
+
prefixesByFile.set(edge.target, targetPrefixes);
|
|
142
|
+
resolvedIncludes.add(edge.include);
|
|
143
|
+
}
|
|
144
|
+
stack.push({
|
|
145
|
+
file: edge.target,
|
|
146
|
+
prefix: joined,
|
|
147
|
+
visited: new Set([...current.visited, edge.target]),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { prefixesByFile, resolvedIncludes };
|
|
153
|
+
}
|
|
@@ -807,7 +807,13 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
|
|
|
807
807
|
// comments, honors rebinding of builtin descriptor names visible where the
|
|
808
808
|
// decorator is evaluated, and withholds subtype capacity from descriptor
|
|
809
809
|
// stacks. Warm v120 captures carry the old verdicts.
|
|
810
|
-
|
|
810
|
+
// v122 (#3414): Python decorator identity models restoring helper calls and
|
|
811
|
+
// treats match-pattern captures and nested nonlocal rebinds as shadowing.
|
|
812
|
+
// Warm v121 captures carry the old verdicts.
|
|
813
|
+
// v123 (#3408): FastAPI include records now carry `host`, router imports carry
|
|
814
|
+
// `modulePath`, and unprefixed child includes are emitted. Warm v122 records
|
|
815
|
+
// lack them, so nested router-prefix propagation would stay inert.
|
|
816
|
+
const SCHEMA_BUMP = 123;
|
|
811
817
|
const GITNEXUS_PKG_VERSION = (() => {
|
|
812
818
|
try {
|
|
813
819
|
// package.json sits at gitnexus/package.json — two levels up from
|
package/package.json
CHANGED