@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.
Files changed (136) hide show
  1. package/build/prompts/test-maintenance/actionsInstructions.js +4 -2
  2. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +9 -0
  3. package/build/prompts/test-maintenance/drift-analysis-prompt.js +8 -7
  4. package/build/prompts/test-maintenance/driftAnalysisSections.js +4 -5
  5. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +11 -0
  6. package/build/prompts/test-maintenance/driftAnalysisShared.js +22 -6
  7. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +23 -31
  8. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +7 -1
  9. package/build/prompts/test-recommendation/diffExecutionPlan.js +20 -6
  10. package/build/prompts/test-recommendation/scopeAssessment.d.ts +10 -1
  11. package/build/prompts/test-recommendation/scopeAssessment.js +27 -2
  12. package/build/prompts/test-recommendation/scopeAssessment.test.js +35 -0
  13. package/build/prompts/test-recommendation/test-recommendation-prompt.js +5 -1
  14. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +61 -0
  15. package/build/prompts/testbot/testbot-prompts.js +7 -5
  16. package/build/prompts/testbot/testbot-prompts.test.js +1 -1
  17. package/build/services/AnalyticsService.js +37 -2
  18. package/build/services/TestDiscoveryService.d.ts +38 -4
  19. package/build/services/TestDiscoveryService.js +187 -16
  20. package/build/services/TestDiscoveryService.test.js +232 -10
  21. package/build/services/TestExecutionService.d.ts +12 -1
  22. package/build/services/TestExecutionService.js +20 -41
  23. package/build/services/TestGenerationService.d.ts +8 -0
  24. package/build/services/TestGenerationService.js +62 -17
  25. package/build/services/TestGenerationService.test.js +67 -1
  26. package/build/tools/auth/loginTool.js +1 -1
  27. package/build/tools/auth/logoutTool.js +1 -1
  28. package/build/tools/code-refactor/codeReuseTool.js +1 -1
  29. package/build/tools/code-refactor/modularizationTool.js +1 -1
  30. package/build/tools/executeSkyrampTestTool.js +30 -1
  31. package/build/tools/fixErrorTool.js +1 -1
  32. package/build/tools/one-click/oneClickTool.js +1 -1
  33. package/build/tools/test-management/actionsTool.js +76 -41
  34. package/build/tools/test-management/actionsTool.test.js +104 -38
  35. package/build/tools/test-management/analyzeChangesTool.js +102 -4
  36. package/build/tools/test-management/analyzeChangesTool.test.js +3 -1
  37. package/build/tools/test-management/analyzeTestHealthTool.js +9 -1
  38. package/build/tools/test-management/analyzeTestHealthTool.test.js +1 -1
  39. package/build/tools/trace/startTraceCollectionTool.js +1 -1
  40. package/build/tools/trace/stopTraceCollectionTool.js +1 -1
  41. package/build/tools/workspace/initScanWorkspaceTool.js +1 -1
  42. package/build/tools/workspace/initializeWorkspaceTool.js +1 -1
  43. package/build/tools/workspace/initializeWorkspaceTool.test.js +1 -1
  44. package/build/types/RepositoryAnalysis.d.ts +6 -6
  45. package/build/types/TestAnalysis.d.ts +13 -0
  46. package/build/utils/dartRouteExtractor.js +8 -30
  47. package/build/utils/docker.test.js +1 -1
  48. package/build/utils/fileWalk.d.ts +71 -0
  49. package/build/utils/fileWalk.js +79 -0
  50. package/build/utils/fileWalk.test.d.ts +1 -0
  51. package/build/utils/fileWalk.test.js +252 -0
  52. package/build/utils/frontendIntegration.js +20 -36
  53. package/build/utils/frontendSelectors.d.ts +34 -0
  54. package/build/utils/frontendSelectors.js +124 -0
  55. package/build/utils/frontendSelectors.test.d.ts +1 -0
  56. package/build/utils/frontendSelectors.test.js +118 -0
  57. package/build/utils/importerHop.d.ts +18 -0
  58. package/build/utils/importerHop.js +48 -3
  59. package/build/utils/importerHop.test.js +71 -1
  60. package/build/utils/pythonMountPrefixes.d.ts +25 -0
  61. package/build/utils/pythonMountPrefixes.js +347 -0
  62. package/build/utils/pythonMountPrefixes.test.d.ts +1 -0
  63. package/build/utils/pythonMountPrefixes.test.js +113 -0
  64. package/build/utils/repoScanner.js +30 -23
  65. package/build/utils/repoScanner.test.js +98 -0
  66. package/build/utils/routeParsers.d.ts +7 -1
  67. package/build/utils/routeParsers.js +32 -18
  68. package/build/utils/sourceRouteExtractor.js +17 -40
  69. package/build/utils/trace-parser.js +7 -19
  70. package/build/utils/versions.d.ts +3 -3
  71. package/build/utils/versions.js +1 -1
  72. package/build/utils/walkerCharacterization.test.d.ts +1 -0
  73. package/build/utils/walkerCharacterization.test.js +233 -0
  74. package/node_modules/playwright/lib/common/config.js +1 -1
  75. package/node_modules/playwright/lib/common/configLoader.js +1 -1
  76. package/node_modules/playwright/lib/common/fixtures.js +1 -1
  77. package/node_modules/playwright/lib/common/testType.js +1 -1
  78. package/node_modules/playwright/lib/index.js +2 -2
  79. package/node_modules/playwright/lib/isomorphic/testTree.js +1 -1
  80. package/node_modules/playwright/lib/matchers/expect.js +1 -1
  81. package/node_modules/playwright/lib/mcp/browser/tab.js +1 -1
  82. package/node_modules/playwright/lib/mcp/browser/tools/navigate.js +1 -1
  83. package/node_modules/playwright/lib/mcp/browser/tools/tracing.js +1 -1
  84. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +7 -0
  85. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -1
  86. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +3 -1
  87. package/node_modules/playwright/lib/reporters/base.js +2 -2
  88. package/node_modules/playwright/lib/reporters/list.js +1 -1
  89. package/node_modules/playwright/lib/reporters/teleEmitter.js +1 -1
  90. package/node_modules/playwright/lib/worker/testInfo.js +2 -2
  91. package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +23 -126
  92. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  93. package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
  94. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -1
  95. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-Cqp3cwEJ.js → codeMirrorModule-aszq5EdG.js} +1 -1
  96. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-CYf9adZh.js → defaultSettingsView-BxS7Jm4s.js} +94 -94
  97. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.d4gkVSou.js → index.D4JTTy4R.js} +1 -1
  98. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  99. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.u4_8VnCV.js → uiMode.DaRMQKOI.js} +1 -1
  100. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  101. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  102. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  103. package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
  104. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  105. package/node_modules/playwright/package.json +1 -1
  106. package/package.json +3 -3
  107. package/node_modules/playwright/._ThirdPartyNotices.txt +0 -0
  108. package/node_modules/playwright/._cli.js +0 -0
  109. package/node_modules/playwright/._index.d.ts +0 -0
  110. package/node_modules/playwright/._index.js +0 -0
  111. package/node_modules/playwright/._index.mjs +0 -0
  112. package/node_modules/playwright/._jsx-runtime.js +0 -0
  113. package/node_modules/playwright/._jsx-runtime.mjs +0 -0
  114. package/node_modules/playwright/._lib +0 -0
  115. package/node_modules/playwright/._node_modules +0 -0
  116. package/node_modules/playwright/._package.json +0 -0
  117. package/node_modules/playwright/._test.d.ts +0 -0
  118. package/node_modules/playwright/._test.js +0 -0
  119. package/node_modules/playwright/._test.mjs +0 -0
  120. package/node_modules/playwright/._types +0 -0
  121. package/node_modules/playwright/._vitest.config.ts +0 -0
  122. package/node_modules/playwright/node_modules/playwright-core/._.DS_Store +0 -0
  123. package/node_modules/playwright/node_modules/playwright-core/._.npmignore +0 -0
  124. package/node_modules/playwright/node_modules/playwright-core/._README.md +0 -0
  125. package/node_modules/playwright/node_modules/playwright-core/._ThirdPartyNotices.txt +0 -0
  126. package/node_modules/playwright/node_modules/playwright-core/._bin +0 -0
  127. package/node_modules/playwright/node_modules/playwright-core/._browsers.json +0 -0
  128. package/node_modules/playwright/node_modules/playwright-core/._bundles +0 -0
  129. package/node_modules/playwright/node_modules/playwright-core/._cli.js +0 -0
  130. package/node_modules/playwright/node_modules/playwright-core/._index.d.ts +0 -0
  131. package/node_modules/playwright/node_modules/playwright-core/._index.js +0 -0
  132. package/node_modules/playwright/node_modules/playwright-core/._index.mjs +0 -0
  133. package/node_modules/playwright/node_modules/playwright-core/._lib +0 -0
  134. package/node_modules/playwright/node_modules/playwright-core/._package.json +0 -0
  135. package/node_modules/playwright/node_modules/playwright-core/._src +0 -0
  136. 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 addedLines = diffHunk.split("\n").filter((line) => line.startsWith("+"));
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
+ });