@skyramp/mcp 0.3.0-rc.1 → 0.3.0
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/build/prompts/test-maintenance/actionsInstructions.js +4 -2
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +9 -0
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +8 -7
- package/build/prompts/test-maintenance/driftAnalysisSections.js +4 -5
- package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +11 -0
- package/build/prompts/test-maintenance/driftAnalysisShared.js +22 -6
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +23 -31
- package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +7 -1
- package/build/prompts/test-recommendation/diffExecutionPlan.js +20 -6
- package/build/prompts/test-recommendation/scopeAssessment.d.ts +10 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +27 -2
- package/build/prompts/test-recommendation/scopeAssessment.test.js +35 -0
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +5 -1
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +61 -0
- package/build/prompts/testbot/testbot-prompts.js +7 -5
- package/build/prompts/testbot/testbot-prompts.test.js +1 -1
- package/build/services/AnalyticsService.js +37 -2
- package/build/services/TestDiscoveryService.d.ts +38 -4
- package/build/services/TestDiscoveryService.js +187 -16
- package/build/services/TestDiscoveryService.test.js +232 -10
- package/build/services/TestExecutionService.d.ts +12 -1
- package/build/services/TestExecutionService.js +20 -41
- package/build/services/TestGenerationService.d.ts +8 -0
- package/build/services/TestGenerationService.js +62 -17
- package/build/services/TestGenerationService.test.js +67 -1
- package/build/tools/auth/loginTool.js +1 -1
- package/build/tools/auth/logoutTool.js +1 -1
- package/build/tools/code-refactor/codeReuseTool.js +1 -1
- package/build/tools/code-refactor/modularizationTool.js +1 -1
- package/build/tools/executeSkyrampTestTool.js +30 -1
- package/build/tools/fixErrorTool.js +1 -1
- package/build/tools/one-click/oneClickTool.js +1 -1
- package/build/tools/test-management/actionsTool.js +76 -41
- package/build/tools/test-management/actionsTool.test.js +104 -38
- package/build/tools/test-management/analyzeChangesTool.js +102 -4
- package/build/tools/test-management/analyzeChangesTool.test.js +3 -1
- package/build/tools/test-management/analyzeTestHealthTool.js +9 -1
- package/build/tools/test-management/analyzeTestHealthTool.test.js +1 -1
- package/build/tools/trace/startTraceCollectionTool.js +1 -1
- package/build/tools/trace/stopTraceCollectionTool.js +1 -1
- package/build/tools/workspace/initScanWorkspaceTool.js +1 -1
- package/build/tools/workspace/initializeWorkspaceTool.js +1 -1
- package/build/tools/workspace/initializeWorkspaceTool.test.js +1 -1
- package/build/types/RepositoryAnalysis.d.ts +6 -6
- package/build/types/TestAnalysis.d.ts +13 -0
- package/build/utils/dartRouteExtractor.js +8 -30
- package/build/utils/docker.test.js +1 -1
- package/build/utils/fileWalk.d.ts +71 -0
- package/build/utils/fileWalk.js +79 -0
- package/build/utils/fileWalk.test.d.ts +1 -0
- package/build/utils/fileWalk.test.js +252 -0
- package/build/utils/frontendIntegration.js +20 -36
- package/build/utils/frontendSelectors.d.ts +34 -0
- package/build/utils/frontendSelectors.js +124 -0
- package/build/utils/frontendSelectors.test.d.ts +1 -0
- package/build/utils/frontendSelectors.test.js +118 -0
- package/build/utils/importerHop.d.ts +18 -0
- package/build/utils/importerHop.js +48 -3
- package/build/utils/importerHop.test.js +71 -1
- package/build/utils/pythonMountPrefixes.d.ts +25 -0
- package/build/utils/pythonMountPrefixes.js +347 -0
- package/build/utils/pythonMountPrefixes.test.d.ts +1 -0
- package/build/utils/pythonMountPrefixes.test.js +113 -0
- package/build/utils/repoScanner.js +30 -23
- package/build/utils/repoScanner.test.js +98 -0
- package/build/utils/routeParsers.d.ts +7 -1
- package/build/utils/routeParsers.js +32 -18
- package/build/utils/sourceRouteExtractor.js +17 -40
- package/build/utils/trace-parser.js +7 -19
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/utils/walkerCharacterization.test.d.ts +1 -0
- package/build/utils/walkerCharacterization.test.js +233 -0
- package/node_modules/playwright/lib/common/config.js +1 -1
- package/node_modules/playwright/lib/common/configLoader.js +1 -1
- package/node_modules/playwright/lib/common/fixtures.js +1 -1
- package/node_modules/playwright/lib/common/testType.js +1 -1
- package/node_modules/playwright/lib/index.js +2 -2
- package/node_modules/playwright/lib/isomorphic/testTree.js +1 -1
- package/node_modules/playwright/lib/matchers/expect.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/tab.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/tools/navigate.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/tools/tracing.js +1 -1
- package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +7 -0
- package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -1
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +3 -1
- package/node_modules/playwright/lib/reporters/base.js +2 -2
- package/node_modules/playwright/lib/reporters/list.js +1 -1
- package/node_modules/playwright/lib/reporters/teleEmitter.js +1 -1
- package/node_modules/playwright/lib/worker/testInfo.js +2 -2
- package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +23 -126
- package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-Cqp3cwEJ.js → codeMirrorModule-aszq5EdG.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-CYf9adZh.js → defaultSettingsView-BxS7Jm4s.js} +94 -94
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.d4gkVSou.js → index.D4JTTy4R.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.u4_8VnCV.js → uiMode.DaRMQKOI.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +3 -3
- package/node_modules/playwright/._ThirdPartyNotices.txt +0 -0
- package/node_modules/playwright/._cli.js +0 -0
- package/node_modules/playwright/._index.d.ts +0 -0
- package/node_modules/playwright/._index.js +0 -0
- package/node_modules/playwright/._index.mjs +0 -0
- package/node_modules/playwright/._jsx-runtime.js +0 -0
- package/node_modules/playwright/._jsx-runtime.mjs +0 -0
- package/node_modules/playwright/._lib +0 -0
- package/node_modules/playwright/._node_modules +0 -0
- package/node_modules/playwright/._package.json +0 -0
- package/node_modules/playwright/._test.d.ts +0 -0
- package/node_modules/playwright/._test.js +0 -0
- package/node_modules/playwright/._test.mjs +0 -0
- package/node_modules/playwright/._types +0 -0
- package/node_modules/playwright/._vitest.config.ts +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._.DS_Store +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._.npmignore +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._README.md +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._ThirdPartyNotices.txt +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._bin +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._browsers.json +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._bundles +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._cli.js +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._index.d.ts +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._index.js +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._index.mjs +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._lib +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._package.json +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._src +0 -0
- package/node_modules/playwright/node_modules/playwright-core/._types +0 -0
|
@@ -216,6 +216,26 @@ export function deriveResourceToken(file) {
|
|
|
216
216
|
* `[deriveResourceToken(file)]` (or `[]` if that's generic/unknown) —
|
|
217
217
|
* identical to today's filename-only behavior.
|
|
218
218
|
*/
|
|
219
|
+
/**
|
|
220
|
+
* Declared symbol names changed in a unified diff — from added-line declarations
|
|
221
|
+
* AND from the hunk-header enclosing type (so a field-only edit to an existing class,
|
|
222
|
+
* e.g. `DeploymentCreate.work_pool_name` → required, still yields `DeploymentCreate`).
|
|
223
|
+
* Returns raw names, used both for resource-token derivation (via `tokensFromSymbol`)
|
|
224
|
+
* and for content-grep matching against test bodies (SKYR-3924).
|
|
225
|
+
*/
|
|
226
|
+
export function extractChangedSymbols(diffHunk) {
|
|
227
|
+
const lines = diffHunk.split("\n");
|
|
228
|
+
const symbols = new Set(extractDeclaredSymbolsFromAddedLines(lines.filter((line) => line.startsWith("+"))));
|
|
229
|
+
const HUNK_CTX_DECL = /^@@.*@@.*\b(?:class|struct|interface|type|enum)\s+([A-Za-z_]\w*)/;
|
|
230
|
+
for (const line of lines) {
|
|
231
|
+
if (!line.startsWith("@@"))
|
|
232
|
+
continue;
|
|
233
|
+
const m = HUNK_CTX_DECL.exec(line);
|
|
234
|
+
if (m)
|
|
235
|
+
symbols.add(m[1]);
|
|
236
|
+
}
|
|
237
|
+
return [...symbols];
|
|
238
|
+
}
|
|
219
239
|
export function deriveResourceTokensForFile(file, diffHunk) {
|
|
220
240
|
const tokens = [];
|
|
221
241
|
const seen = new Set();
|
|
@@ -230,9 +250,7 @@ export function deriveResourceTokensForFile(file, diffHunk) {
|
|
|
230
250
|
addToken(filenameToken);
|
|
231
251
|
}
|
|
232
252
|
if (diffHunk) {
|
|
233
|
-
const
|
|
234
|
-
const symbols = extractDeclaredSymbolsFromAddedLines(addedLines);
|
|
235
|
-
for (const symbol of symbols) {
|
|
253
|
+
for (const symbol of extractChangedSymbols(diffHunk)) {
|
|
236
254
|
for (const token of tokensFromSymbol(symbol))
|
|
237
255
|
addToken(token);
|
|
238
256
|
}
|
|
@@ -459,6 +477,33 @@ export function sliceAddedLinesByFile(diffContent) {
|
|
|
459
477
|
}
|
|
460
478
|
return sections;
|
|
461
479
|
}
|
|
480
|
+
/**
|
|
481
|
+
* Splits a unified diff into per-file raw sections — every line after a file's
|
|
482
|
+
* `diff --git` header up to the next one — keyed by the `b/` (post-change) path.
|
|
483
|
+
* Unlike `sliceAddedLinesByFile` (added lines only), this preserves the `@@`
|
|
484
|
+
* hunk headers, so `extractChangedSymbols`' enclosing-type extraction (which
|
|
485
|
+
* reads `@@ ... class Foo`) still works when a single file's section is passed
|
|
486
|
+
* to `deriveResourceTokensForFile`. Use this for per-file token derivation that
|
|
487
|
+
* must not mix symbols across files in a multi-file diff (SKYR-3924).
|
|
488
|
+
*/
|
|
489
|
+
export function sliceDiffByFile(diffContent) {
|
|
490
|
+
const sections = new Map();
|
|
491
|
+
let currentFile = null;
|
|
492
|
+
for (const line of diffContent.split("\n")) {
|
|
493
|
+
const headerMatch = /^diff --git a\/.+ b\/(.+)$/.exec(line);
|
|
494
|
+
if (headerMatch) {
|
|
495
|
+
currentFile = headerMatch[1];
|
|
496
|
+
sections.set(currentFile, []);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (currentFile)
|
|
500
|
+
sections.get(currentFile).push(line);
|
|
501
|
+
}
|
|
502
|
+
const joined = new Map();
|
|
503
|
+
for (const [file, lines] of sections)
|
|
504
|
+
joined.set(file, lines.join("\n"));
|
|
505
|
+
return joined;
|
|
506
|
+
}
|
|
462
507
|
/**
|
|
463
508
|
* Computes added-field names per file for the given files, from a full
|
|
464
509
|
* unified diff. Files with no added-line matches are omitted from the result
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as os from "os";
|
|
3
3
|
import * as path from "path";
|
|
4
|
-
import { mapUnmatchedFilesToImporterEndpoints, deriveResourceToken, deriveResourceTokensForFile, endpointResourceTokens, extractFieldNamesFromAddedLines, computeAddedFieldsByFile, buildHopResponseShapeInputs, } from "./importerHop.js";
|
|
4
|
+
import { mapUnmatchedFilesToImporterEndpoints, deriveResourceToken, deriveResourceTokensForFile, endpointResourceTokens, extractFieldNamesFromAddedLines, computeAddedFieldsByFile, extractChangedSymbols, sliceDiffByFile, buildHopResponseShapeInputs, } from "./importerHop.js";
|
|
5
5
|
/** Creates a plain (non-git) tmp dir with the given files — mapUnmatchedFilesToImporterEndpoints
|
|
6
6
|
* reads files directly off disk, no git grep involved. */
|
|
7
7
|
function makeFixtureDir(files) {
|
|
@@ -397,3 +397,73 @@ describe("computeAddedFieldsByFile", () => {
|
|
|
397
397
|
expect(computeAddedFieldsByFile("some diff", [])).toEqual({});
|
|
398
398
|
});
|
|
399
399
|
});
|
|
400
|
+
describe("extractChangedSymbols", () => {
|
|
401
|
+
it("extracts a class declared on an added line (python)", () => {
|
|
402
|
+
expect(extractChangedSymbols("+class DeploymentCreate(ActionBaseModel):")).toEqual([
|
|
403
|
+
"DeploymentCreate",
|
|
404
|
+
]);
|
|
405
|
+
});
|
|
406
|
+
it("extracts TS interface/type/enum/zod-schema declared on added lines", () => {
|
|
407
|
+
expect(extractChangedSymbols("+export interface UserDto {")).toEqual(["UserDto"]);
|
|
408
|
+
expect(extractChangedSymbols("+type FlowRunFilter = {")).toEqual(["FlowRunFilter"]);
|
|
409
|
+
expect(extractChangedSymbols("+export enum WorkPoolType {")).toEqual(["WorkPoolType"]);
|
|
410
|
+
expect(extractChangedSymbols("+export const UserSchema = z.object({")).toEqual(["UserSchema"]);
|
|
411
|
+
});
|
|
412
|
+
it("extracts the enclosing type from the @@ hunk header when only a field changed (SKYR-3924)", () => {
|
|
413
|
+
// A field-only edit yields no declaration on the added line — the class name
|
|
414
|
+
// survives only in the hunk-context header, which is exactly the schema/DTO case.
|
|
415
|
+
const hunk = [
|
|
416
|
+
"@@ -10,6 +10,7 @@ class DeploymentCreate(ActionBaseModel):",
|
|
417
|
+
" name: str",
|
|
418
|
+
"+ work_pool_name: str = Field(default=...)",
|
|
419
|
+
" flow_id: UUID",
|
|
420
|
+
].join("\n");
|
|
421
|
+
expect(extractChangedSymbols(hunk)).toEqual(["DeploymentCreate"]);
|
|
422
|
+
});
|
|
423
|
+
it("dedups a symbol that appears in both an added declaration and the hunk header", () => {
|
|
424
|
+
const hunk = [
|
|
425
|
+
"@@ -1,3 +1,4 @@ class DeploymentCreate(ActionBaseModel):",
|
|
426
|
+
"+class DeploymentCreate(ActionBaseModel):",
|
|
427
|
+
].join("\n");
|
|
428
|
+
expect(extractChangedSymbols(hunk)).toEqual(["DeploymentCreate"]);
|
|
429
|
+
});
|
|
430
|
+
it("returns an empty array when no type-level declaration is present", () => {
|
|
431
|
+
const hunk = ["@@ -1,2 +1,3 @@ def create_deployment():", "+ work_pool_name = None"].join("\n");
|
|
432
|
+
expect(extractChangedSymbols(hunk)).toEqual([]);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
describe("sliceDiffByFile", () => {
|
|
436
|
+
const multiFileDiff = [
|
|
437
|
+
"diff --git a/src/schemas/actions.py b/src/schemas/actions.py",
|
|
438
|
+
"--- a/src/schemas/actions.py",
|
|
439
|
+
"+++ b/src/schemas/actions.py",
|
|
440
|
+
"@@ -10,6 +10,7 @@ class DeploymentCreate(ActionBaseModel):",
|
|
441
|
+
" name: str",
|
|
442
|
+
"+ work_pool_name: str = Field(default=...)",
|
|
443
|
+
"diff --git a/src/schemas/core.py b/src/schemas/core.py",
|
|
444
|
+
"--- a/src/schemas/core.py",
|
|
445
|
+
"+++ b/src/schemas/core.py",
|
|
446
|
+
"@@ -1,2 +1,3 @@",
|
|
447
|
+
"+class WorkPool(ORMBaseModel):",
|
|
448
|
+
].join("\n");
|
|
449
|
+
it("keys sections by the b/ path and isolates each file's lines", () => {
|
|
450
|
+
const sections = sliceDiffByFile(multiFileDiff);
|
|
451
|
+
expect([...sections.keys()]).toEqual(["src/schemas/actions.py", "src/schemas/core.py"]);
|
|
452
|
+
expect(sections.get("src/schemas/actions.py")).toContain("work_pool_name");
|
|
453
|
+
expect(sections.get("src/schemas/actions.py")).not.toContain("WorkPool");
|
|
454
|
+
});
|
|
455
|
+
it("preserves @@ hunk headers within each file section", () => {
|
|
456
|
+
const sections = sliceDiffByFile(multiFileDiff);
|
|
457
|
+
expect(sections.get("src/schemas/actions.py")).toContain("@@ -10,6 +10,7 @@ class DeploymentCreate(ActionBaseModel):");
|
|
458
|
+
});
|
|
459
|
+
it("keeps a symbol declared in one file out of another file's derived symbols", () => {
|
|
460
|
+
// The mixing bug: symbols from core.py must not leak into actions.py's slice.
|
|
461
|
+
const sections = sliceDiffByFile(multiFileDiff);
|
|
462
|
+
expect(extractChangedSymbols(sections.get("src/schemas/actions.py"))).toEqual([
|
|
463
|
+
"DeploymentCreate",
|
|
464
|
+
]);
|
|
465
|
+
expect(extractChangedSymbols(sections.get("src/schemas/core.py"))).toEqual(["WorkPool"]);
|
|
466
|
+
// Whereas the full multi-file diff mixes both.
|
|
467
|
+
expect(extractChangedSymbols(multiFileDiff).sort()).toEqual(["DeploymentCreate", "WorkPool"]);
|
|
468
|
+
});
|
|
469
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Repo-relative POSIX path of a Python source file. */
|
|
2
|
+
export type RelFile = string;
|
|
3
|
+
/** A router variable name as written in source (e.g. "router"). */
|
|
4
|
+
export type RouterVar = string;
|
|
5
|
+
/** The ancestor mount prefix a router inherits from its include_router chain (e.g. "/api"). */
|
|
6
|
+
export type MountPrefix = string;
|
|
7
|
+
/**
|
|
8
|
+
* `sourceFile → (routerVar → ancestor mount prefix)`, produced by
|
|
9
|
+
* `resolvePythonMountPrefixes`. Only routers that inherit a non-empty ancestor
|
|
10
|
+
* prefix appear; everything else is absent (single-file parsing is unchanged).
|
|
11
|
+
*/
|
|
12
|
+
export type MountPrefixesByFile = Map<RelFile, Map<RouterVar, MountPrefix>>;
|
|
13
|
+
/**
|
|
14
|
+
* Join a prefix and a child path segment with exactly one slash, tolerating
|
|
15
|
+
* leading/trailing slashes on either side. Empty child → the prefix unchanged;
|
|
16
|
+
* empty prefix → the child made absolute.
|
|
17
|
+
*/
|
|
18
|
+
export declare function joinRoutePrefix(prefix: string, child: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Build `sourceFile → (routerVar → ancestorMountPrefix)` for every FastAPI
|
|
21
|
+
* router reachable through an `include_router` chain. Only non-empty ancestor
|
|
22
|
+
* prefixes are returned, so files whose routers sit at the root are absent and
|
|
23
|
+
* fall back to unchanged single-file parsing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolvePythonMountPrefixes(repositoryPath: string): MountPrefixesByFile;
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { walkSourceFiles } from "./fileWalk.js";
|
|
4
|
+
import { logger } from "./logger.js";
|
|
5
|
+
/**
|
|
6
|
+
* Cross-file FastAPI mount-prefix resolution.
|
|
7
|
+
*
|
|
8
|
+
* FastAPI composes a route's full URL from prefixes applied at three levels:
|
|
9
|
+
* 1. the router's own `APIRouter(prefix=…)` (captured per-file by
|
|
10
|
+
* `parseFileEndpoints`), and
|
|
11
|
+
* 2. every `include_router(child, prefix=…)` / `app.include_router(child, …)`
|
|
12
|
+
* mount point the router passes through — which routinely live in *other*
|
|
13
|
+
* files (an `app.py` / `routes/__init__.py` aggregator).
|
|
14
|
+
*
|
|
15
|
+
* The single-file route parser can only see (1), so a leaf route declared as
|
|
16
|
+
* `@router.get("")` on a `router = APIRouter(prefix="/recipes")` that an
|
|
17
|
+
* aggregator mounts under `APIRouter(prefix="/api")` was emitted as `/recipes`
|
|
18
|
+
* instead of `/api/recipes` — generated tests then hit the un-prefixed URL and
|
|
19
|
+
* 405. (Seen on Mealie's endpoint catalog.)
|
|
20
|
+
*
|
|
21
|
+
* This module does a repo-level pre-pass: it parses every Python file for router
|
|
22
|
+
* declarations, `include_router` mount edges, and the intra-repo imports that
|
|
23
|
+
* connect them, then walks the mount graph to compute, for each router, the
|
|
24
|
+
* **ancestor** prefix it inherits from its mount chain (NOT including the
|
|
25
|
+
* router's own prefix, which `parseFileEndpoints` still applies). The result is
|
|
26
|
+
* keyed by `sourceFile → routerVarName → ancestorPrefix`.
|
|
27
|
+
*
|
|
28
|
+
* Best-effort by design (mirrors the rest of the extractor): an import that
|
|
29
|
+
* can't be resolved simply leaves that router as a graph root with an empty
|
|
30
|
+
* ancestor prefix — i.e. it falls back to today's single-file behavior rather
|
|
31
|
+
* than emitting a wrong path.
|
|
32
|
+
*/
|
|
33
|
+
/** Skip these directory names when walking for Python sources. */
|
|
34
|
+
const SKIP_DIRS = new Set([
|
|
35
|
+
"node_modules", ".git", "dist", "build", "out",
|
|
36
|
+
"venv", ".venv", "env", ".env", "__pycache__", ".tox",
|
|
37
|
+
"site-packages", ".mypy_cache", ".pytest_cache", "coverage",
|
|
38
|
+
]);
|
|
39
|
+
/** Cap on Python files parsed — a backstop against pathological repos. */
|
|
40
|
+
const MAX_PY_FILES = 5000;
|
|
41
|
+
/** Guard against cycles / runaway mount chains. */
|
|
42
|
+
const MAX_MOUNT_DEPTH = 32;
|
|
43
|
+
function nodeId(relFile, varName) {
|
|
44
|
+
return `${relFile}\n${varName}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Join a prefix and a child path segment with exactly one slash, tolerating
|
|
48
|
+
* leading/trailing slashes on either side. Empty child → the prefix unchanged;
|
|
49
|
+
* empty prefix → the child made absolute.
|
|
50
|
+
*/
|
|
51
|
+
export function joinRoutePrefix(prefix, child) {
|
|
52
|
+
const left = prefix.replace(/\/+$/, "");
|
|
53
|
+
const right = child.replace(/^\/+/, "");
|
|
54
|
+
if (!right)
|
|
55
|
+
return left;
|
|
56
|
+
if (!left)
|
|
57
|
+
return "/" + right;
|
|
58
|
+
return `${left}/${right}`;
|
|
59
|
+
}
|
|
60
|
+
function normalizeRel(p) {
|
|
61
|
+
return p.replace(/\\/g, "/");
|
|
62
|
+
}
|
|
63
|
+
/** Recursively collect `.py` files, returning repo-relative (posix) paths. */
|
|
64
|
+
function collectPyFiles(repositoryPath) {
|
|
65
|
+
const abs = walkSourceFiles(repositoryPath, {
|
|
66
|
+
extensions: [".py"],
|
|
67
|
+
skipDirs: SKIP_DIRS,
|
|
68
|
+
limit: MAX_PY_FILES,
|
|
69
|
+
});
|
|
70
|
+
if (abs.length >= MAX_PY_FILES) {
|
|
71
|
+
logger.warning("resolvePythonMountPrefixes: hit MAX_PY_FILES cap — some Python files were not scanned, so cross-file mount prefixes may be under-resolved on this repo", { cap: MAX_PY_FILES });
|
|
72
|
+
}
|
|
73
|
+
return abs.map((full) => normalizeRel(full.startsWith(repositoryPath) ? full.slice(repositoryPath.length + 1) : full));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Merge physical lines into logical lines, joining any line whose parentheses
|
|
77
|
+
* are left open (multi-line `APIRouter(`, `include_router(`, and
|
|
78
|
+
* `from x import ( … )`). String contents are not specially handled — a stray
|
|
79
|
+
* paren inside a string literal is a rare edge that only degrades to a missed
|
|
80
|
+
* prefix (fail-safe).
|
|
81
|
+
*/
|
|
82
|
+
function toLogicalLines(content) {
|
|
83
|
+
const logical = [];
|
|
84
|
+
let buf = "";
|
|
85
|
+
let depth = 0;
|
|
86
|
+
for (const raw of content.split("\n")) {
|
|
87
|
+
const line = raw.replace(/#.*$/, ""); // drop trailing comments
|
|
88
|
+
buf = buf ? `${buf} ${line.trim()}` : line;
|
|
89
|
+
for (const ch of line) {
|
|
90
|
+
if (ch === "(" || ch === "[" || ch === "{")
|
|
91
|
+
depth++;
|
|
92
|
+
else if (ch === ")" || ch === "]" || ch === "}")
|
|
93
|
+
depth = Math.max(0, depth - 1);
|
|
94
|
+
}
|
|
95
|
+
if (depth === 0) {
|
|
96
|
+
logical.push(buf);
|
|
97
|
+
buf = "";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (buf)
|
|
101
|
+
logical.push(buf);
|
|
102
|
+
return logical;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Resolve an intra-repo Python module reference to a source file in `fileSet`.
|
|
106
|
+
* `dots` is the leading-dot count (0 = absolute), `modulePath` the dotted
|
|
107
|
+
* remainder (e.g. `mealie.routes` or `recipe`). Returns the repo-relative file
|
|
108
|
+
* path or null when it can't be resolved to a known file.
|
|
109
|
+
*/
|
|
110
|
+
function resolveModuleFile(fromRel, dots, modulePath, fileSet) {
|
|
111
|
+
const segments = modulePath ? modulePath.split(".").filter(Boolean) : [];
|
|
112
|
+
const candidates = [];
|
|
113
|
+
if (dots > 0) {
|
|
114
|
+
// Relative import: walk up `dots` directories from the current file's dir.
|
|
115
|
+
let base = path.posix.dirname(fromRel);
|
|
116
|
+
for (let i = 1; i < dots; i++)
|
|
117
|
+
base = path.posix.dirname(base);
|
|
118
|
+
const joined = [base === "." ? "" : base, ...segments].filter(Boolean).join("/");
|
|
119
|
+
candidates.push(`${joined}.py`, `${joined}/__init__.py`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// Absolute import: resolve from repo root, but also suffix-match so `src/`
|
|
123
|
+
// (or other package-root) layouts resolve too.
|
|
124
|
+
const joined = segments.join("/");
|
|
125
|
+
candidates.push(`${joined}.py`, `${joined}/__init__.py`);
|
|
126
|
+
}
|
|
127
|
+
for (const c of candidates) {
|
|
128
|
+
if (fileSet.has(c))
|
|
129
|
+
return c;
|
|
130
|
+
}
|
|
131
|
+
// Suffix fallback for absolute imports under a package root (e.g. src/).
|
|
132
|
+
if (dots === 0) {
|
|
133
|
+
const joined = segments.join("/");
|
|
134
|
+
const suffixes = [`/${joined}.py`, `/${joined}/__init__.py`];
|
|
135
|
+
for (const f of fileSet) {
|
|
136
|
+
if (suffixes.some((s) => f.endsWith(s)))
|
|
137
|
+
return f;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
const ROUTER_DECL_RE = /^(\w+)\s*=\s*\w*APIRouter\s*\(/;
|
|
143
|
+
const PREFIX_KW_RE = /(?:^|[,(\s])prefix\s*=\s*["']([^"']*)["']/;
|
|
144
|
+
const CONST_RE = /^(\w+)\s*=\s*["']([^"']*)["']\s*$/;
|
|
145
|
+
const IMPORT_FROM_RE = /^from\s+(\.*)([\w.]*)\s+import\s+(.+)$/;
|
|
146
|
+
const IMPORT_RE = /^import\s+([\w.]+)(?:\s+as\s+(\w+))?/;
|
|
147
|
+
const INCLUDE_ROUTER_RE = /(\w+)\.include_router\s*\(\s*([\w.]+)\s*(?:,(.*))?\)/;
|
|
148
|
+
function parseImportedNames(blob) {
|
|
149
|
+
return blob
|
|
150
|
+
.replace(/[()]/g, "")
|
|
151
|
+
.split(",")
|
|
152
|
+
.map((s) => s.trim())
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
.map((tok) => {
|
|
155
|
+
const m = tok.match(/^(\w+)(?:\s+as\s+(\w+))?$/);
|
|
156
|
+
if (!m)
|
|
157
|
+
return null;
|
|
158
|
+
return { name: m[1], alias: m[2] ?? m[1] };
|
|
159
|
+
})
|
|
160
|
+
.filter((x) => x !== null);
|
|
161
|
+
}
|
|
162
|
+
function collectFileFacts(relFile, content, fileSet) {
|
|
163
|
+
const facts = {
|
|
164
|
+
ownPrefixes: new Map(),
|
|
165
|
+
stringConsts: new Map(),
|
|
166
|
+
moduleAlias: new Map(),
|
|
167
|
+
symbolAlias: new Map(),
|
|
168
|
+
mounts: [],
|
|
169
|
+
};
|
|
170
|
+
for (const line of toLogicalLines(content)) {
|
|
171
|
+
const trimmed = line.trim();
|
|
172
|
+
if (!trimmed)
|
|
173
|
+
continue;
|
|
174
|
+
// Router declaration + its own prefix.
|
|
175
|
+
const decl = trimmed.match(ROUTER_DECL_RE);
|
|
176
|
+
if (decl) {
|
|
177
|
+
const pm = trimmed.match(PREFIX_KW_RE);
|
|
178
|
+
facts.ownPrefixes.set(decl[1], pm ? pm[1] : "");
|
|
179
|
+
// fall through: a declaration line has no include_router / import.
|
|
180
|
+
}
|
|
181
|
+
// Module-level string const (for prefix=CONST resolution).
|
|
182
|
+
const cm = trimmed.match(CONST_RE);
|
|
183
|
+
if (cm)
|
|
184
|
+
facts.stringConsts.set(cm[1], cm[2]);
|
|
185
|
+
// `from <module> import <names>`
|
|
186
|
+
const fromM = trimmed.match(IMPORT_FROM_RE);
|
|
187
|
+
if (fromM) {
|
|
188
|
+
const dots = fromM[1].length;
|
|
189
|
+
const modulePath = fromM[2];
|
|
190
|
+
const names = parseImportedNames(fromM[3]);
|
|
191
|
+
if (modulePath) {
|
|
192
|
+
// Symbols imported FROM a specific module (e.g. `from .x import router`).
|
|
193
|
+
const modFile = resolveModuleFile(relFile, dots, modulePath, fileSet);
|
|
194
|
+
if (modFile) {
|
|
195
|
+
for (const { name, alias } of names) {
|
|
196
|
+
facts.symbolAlias.set(alias, { file: modFile, varName: name });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else if (dots > 0) {
|
|
201
|
+
// `from . import a, b` — each name is a submodule.
|
|
202
|
+
for (const { name, alias } of names) {
|
|
203
|
+
const modFile = resolveModuleFile(relFile, dots, name, fileSet);
|
|
204
|
+
if (modFile)
|
|
205
|
+
facts.moduleAlias.set(alias, modFile);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
// `import a.b.c as d`
|
|
211
|
+
const impM = trimmed.match(IMPORT_RE);
|
|
212
|
+
if (impM) {
|
|
213
|
+
const modFile = resolveModuleFile(relFile, 0, impM[1], fileSet);
|
|
214
|
+
if (modFile) {
|
|
215
|
+
const alias = impM[2] ?? impM[1].split(".")[0];
|
|
216
|
+
facts.moduleAlias.set(alias, modFile);
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// include_router mount edges (a line may contain several).
|
|
221
|
+
let m;
|
|
222
|
+
const re = new RegExp(INCLUDE_ROUTER_RE, "g");
|
|
223
|
+
while ((m = re.exec(trimmed)) !== null) {
|
|
224
|
+
const prefixExpr = extractMountPrefixExpr(m[3] ?? "");
|
|
225
|
+
facts.mounts.push({ parentVar: m[1], arg: m[2], prefixExpr });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return facts;
|
|
229
|
+
}
|
|
230
|
+
/** Pull the `prefix=` argument (literal or bare identifier) out of the call tail. */
|
|
231
|
+
function extractMountPrefixExpr(argsTail) {
|
|
232
|
+
const lit = argsTail.match(/prefix\s*=\s*["']([^"']*)["']/);
|
|
233
|
+
if (lit)
|
|
234
|
+
return JSON.stringify(lit[1]); // quote to mark it a literal
|
|
235
|
+
const ident = argsTail.match(/prefix\s*=\s*(\w+)/);
|
|
236
|
+
if (ident)
|
|
237
|
+
return ident[1]; // bare identifier — resolve against stringConsts
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
/** Resolve an `include_router` first-argument expression to a router NodeId. */
|
|
241
|
+
function resolveMountArg(arg, relFile, facts) {
|
|
242
|
+
const parts = arg.split(".");
|
|
243
|
+
if (parts.length === 1) {
|
|
244
|
+
// Bare name: an imported symbol, else a router declared in this file.
|
|
245
|
+
const sym = facts.symbolAlias.get(parts[0]);
|
|
246
|
+
if (sym)
|
|
247
|
+
return nodeId(sym.file, sym.varName);
|
|
248
|
+
return nodeId(relFile, parts[0]);
|
|
249
|
+
}
|
|
250
|
+
// `mod.router` — module alias + attribute.
|
|
251
|
+
const modFile = facts.moduleAlias.get(parts[0]);
|
|
252
|
+
if (modFile)
|
|
253
|
+
return nodeId(modFile, parts[parts.length - 1]);
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Build `sourceFile → (routerVar → ancestorMountPrefix)` for every FastAPI
|
|
258
|
+
* router reachable through an `include_router` chain. Only non-empty ancestor
|
|
259
|
+
* prefixes are returned, so files whose routers sit at the root are absent and
|
|
260
|
+
* fall back to unchanged single-file parsing.
|
|
261
|
+
*/
|
|
262
|
+
export function resolvePythonMountPrefixes(repositoryPath) {
|
|
263
|
+
const pyFiles = collectPyFiles(repositoryPath);
|
|
264
|
+
const result = new Map();
|
|
265
|
+
if (pyFiles.length === 0)
|
|
266
|
+
return result;
|
|
267
|
+
const fileSet = new Set(pyFiles);
|
|
268
|
+
const factsByFile = new Map();
|
|
269
|
+
const ownPrefixByNode = new Map();
|
|
270
|
+
const edges = [];
|
|
271
|
+
for (const rel of pyFiles) {
|
|
272
|
+
let content;
|
|
273
|
+
try {
|
|
274
|
+
content = fs.readFileSync(path.join(repositoryPath, rel), "utf-8");
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// Cheap gate: only parse files that actually wire routers together or
|
|
280
|
+
// declare one — avoids tokenizing the whole repo's Python.
|
|
281
|
+
if (!/include_router|APIRouter/.test(content))
|
|
282
|
+
continue;
|
|
283
|
+
const facts = collectFileFacts(rel, content, fileSet);
|
|
284
|
+
factsByFile.set(rel, facts);
|
|
285
|
+
for (const [varName, prefix] of facts.ownPrefixes) {
|
|
286
|
+
ownPrefixByNode.set(nodeId(rel, varName), prefix);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Second pass: resolve mount edges now that every file's facts (imports,
|
|
290
|
+
// consts, router vars) are known.
|
|
291
|
+
for (const [rel, facts] of factsByFile) {
|
|
292
|
+
for (const mount of facts.mounts) {
|
|
293
|
+
const child = resolveMountArg(mount.arg, rel, facts);
|
|
294
|
+
if (!child)
|
|
295
|
+
continue;
|
|
296
|
+
let mountPrefix = "";
|
|
297
|
+
if (mount.prefixExpr !== null) {
|
|
298
|
+
if (mount.prefixExpr.startsWith('"')) {
|
|
299
|
+
mountPrefix = JSON.parse(mount.prefixExpr);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
mountPrefix = facts.stringConsts.get(mount.prefixExpr) ?? "";
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
edges.push({ parent: nodeId(rel, mount.parentVar), child, mountPrefix });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// First incoming edge wins (a router mounted in two places is rare; pick one
|
|
309
|
+
// deterministically rather than guessing a "primary" mount).
|
|
310
|
+
const incoming = new Map();
|
|
311
|
+
for (const e of edges) {
|
|
312
|
+
if (!incoming.has(e.child))
|
|
313
|
+
incoming.set(e.child, e);
|
|
314
|
+
}
|
|
315
|
+
const memo = new Map();
|
|
316
|
+
function ancestorPrefix(node, depth) {
|
|
317
|
+
if (depth > MAX_MOUNT_DEPTH)
|
|
318
|
+
return "";
|
|
319
|
+
const cached = memo.get(node);
|
|
320
|
+
if (cached !== undefined)
|
|
321
|
+
return cached;
|
|
322
|
+
memo.set(node, ""); // cycle guard: treat as root until resolved
|
|
323
|
+
const inc = incoming.get(node);
|
|
324
|
+
if (!inc)
|
|
325
|
+
return ""; // root — no inherited prefix
|
|
326
|
+
const parentAncestor = ancestorPrefix(inc.parent, depth + 1);
|
|
327
|
+
const parentOwn = ownPrefixByNode.get(inc.parent) ?? "";
|
|
328
|
+
const full = joinRoutePrefix(joinRoutePrefix(parentAncestor, parentOwn), inc.mountPrefix);
|
|
329
|
+
memo.set(node, full);
|
|
330
|
+
return full;
|
|
331
|
+
}
|
|
332
|
+
for (const node of ownPrefixByNode.keys()) {
|
|
333
|
+
const prefix = ancestorPrefix(node, 0);
|
|
334
|
+
if (!prefix)
|
|
335
|
+
continue; // root router — leave to single-file parsing
|
|
336
|
+
const sep = node.indexOf("\n");
|
|
337
|
+
const relFile = node.slice(0, sep);
|
|
338
|
+
const varName = node.slice(sep + 1);
|
|
339
|
+
let perFile = result.get(relFile);
|
|
340
|
+
if (!perFile) {
|
|
341
|
+
perFile = new Map();
|
|
342
|
+
result.set(relFile, perFile);
|
|
343
|
+
}
|
|
344
|
+
perFile.set(varName, prefix);
|
|
345
|
+
}
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { joinRoutePrefix, resolvePythonMountPrefixes } from "./pythonMountPrefixes.js";
|
|
5
|
+
describe("joinRoutePrefix", () => {
|
|
6
|
+
it("composes with exactly one slash regardless of surrounding slashes", () => {
|
|
7
|
+
expect(joinRoutePrefix("/api", "/recipes")).toBe("/api/recipes");
|
|
8
|
+
expect(joinRoutePrefix("/api/", "recipes")).toBe("/api/recipes");
|
|
9
|
+
expect(joinRoutePrefix("/api", "recipes/")).toBe("/api/recipes/");
|
|
10
|
+
});
|
|
11
|
+
it("returns the prefix unchanged for an empty child", () => {
|
|
12
|
+
expect(joinRoutePrefix("/api", "")).toBe("/api");
|
|
13
|
+
});
|
|
14
|
+
it("makes the child absolute for an empty prefix", () => {
|
|
15
|
+
expect(joinRoutePrefix("", "/recipes")).toBe("/recipes");
|
|
16
|
+
expect(joinRoutePrefix("", "recipes")).toBe("/recipes");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
describe("resolvePythonMountPrefixes", () => {
|
|
20
|
+
const tempRepos = [];
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const repo of tempRepos.splice(0)) {
|
|
23
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
function makeRepo(files) {
|
|
27
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), "py-mount-"));
|
|
28
|
+
tempRepos.push(repo);
|
|
29
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
30
|
+
const full = path.join(repo, rel);
|
|
31
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
32
|
+
fs.writeFileSync(full, content);
|
|
33
|
+
}
|
|
34
|
+
return repo;
|
|
35
|
+
}
|
|
36
|
+
it("resolves a relative-import mount chain to the ancestor prefix", () => {
|
|
37
|
+
const repo = makeRepo({
|
|
38
|
+
"app/__init__.py": `
|
|
39
|
+
from fastapi import APIRouter
|
|
40
|
+
from . import users
|
|
41
|
+
|
|
42
|
+
router = APIRouter(prefix="/api")
|
|
43
|
+
router.include_router(users.router)
|
|
44
|
+
`,
|
|
45
|
+
"app/users.py": `
|
|
46
|
+
from fastapi import APIRouter
|
|
47
|
+
router = APIRouter(prefix="/users")
|
|
48
|
+
@router.get("")
|
|
49
|
+
def list_users(): pass
|
|
50
|
+
`,
|
|
51
|
+
});
|
|
52
|
+
const result = resolvePythonMountPrefixes(repo);
|
|
53
|
+
// users.router inherits "/api" (aggregator own prefix); its own "/users" is
|
|
54
|
+
// applied later by parseFileEndpoints, so it is NOT part of the ancestor.
|
|
55
|
+
expect(result.get("app/users.py")?.get("router")).toBe("/api");
|
|
56
|
+
// The root aggregator has no ancestor prefix, so it's omitted entirely.
|
|
57
|
+
expect(result.get("app/__init__.py")).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
it("resolves a mount prefix given as a module-level string constant", () => {
|
|
60
|
+
const repo = makeRepo({
|
|
61
|
+
"svc/__init__.py": `
|
|
62
|
+
from fastapi import APIRouter
|
|
63
|
+
from . import billing
|
|
64
|
+
|
|
65
|
+
BILLING = "/billing"
|
|
66
|
+
router = APIRouter()
|
|
67
|
+
router.include_router(billing.router, prefix=BILLING)
|
|
68
|
+
`,
|
|
69
|
+
"svc/billing.py": `
|
|
70
|
+
from fastapi import APIRouter
|
|
71
|
+
router = APIRouter()
|
|
72
|
+
@router.get("/invoices")
|
|
73
|
+
def invoices(): pass
|
|
74
|
+
`,
|
|
75
|
+
});
|
|
76
|
+
const result = resolvePythonMountPrefixes(repo);
|
|
77
|
+
expect(result.get("svc/billing.py")?.get("router")).toBe("/billing");
|
|
78
|
+
});
|
|
79
|
+
it("fails safe (no ancestor prefix) when the mounted module can't be resolved", () => {
|
|
80
|
+
const repo = makeRepo({
|
|
81
|
+
"app/main.py": `
|
|
82
|
+
from fastapi import FastAPI
|
|
83
|
+
from some_external_pkg import router as ext_router
|
|
84
|
+
|
|
85
|
+
app = FastAPI()
|
|
86
|
+
app.include_router(ext_router, prefix="/api")
|
|
87
|
+
`,
|
|
88
|
+
});
|
|
89
|
+
// The external module isn't in the repo, so the edge is dropped and nothing
|
|
90
|
+
// is returned — the scanner then behaves exactly as it does today.
|
|
91
|
+
const result = resolvePythonMountPrefixes(repo);
|
|
92
|
+
expect(result.size).toBe(0);
|
|
93
|
+
});
|
|
94
|
+
it("does not loop forever on a mutually-recursive mount cycle", () => {
|
|
95
|
+
const repo = makeRepo({
|
|
96
|
+
"a.py": `
|
|
97
|
+
from fastapi import APIRouter
|
|
98
|
+
from b import router as b_router
|
|
99
|
+
router = APIRouter(prefix="/a")
|
|
100
|
+
router.include_router(b_router)
|
|
101
|
+
`,
|
|
102
|
+
"b.py": `
|
|
103
|
+
from fastapi import APIRouter
|
|
104
|
+
from a import router as a_router
|
|
105
|
+
router = APIRouter(prefix="/b")
|
|
106
|
+
router.include_router(a_router)
|
|
107
|
+
`,
|
|
108
|
+
});
|
|
109
|
+
// Should terminate; the exact prefixes are unimportant, only that it returns.
|
|
110
|
+
const result = resolvePythonMountPrefixes(repo);
|
|
111
|
+
expect(result).toBeInstanceOf(Map);
|
|
112
|
+
});
|
|
113
|
+
});
|