@skyramp/mcp 0.3.8 → 0.4.0-rc.1

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 (229) hide show
  1. package/build/commands/commandLibrary.d.ts +1 -1
  2. package/build/commands/commandLibrary.js +3 -3
  3. package/build/commands/recommendTestsAndExecuteCommand.d.ts +1 -1
  4. package/build/commands/recommendTestsAndExecuteCommand.js +35 -20
  5. package/build/commands/testThisEndpointCommand.js +35 -19
  6. package/build/index.js +9 -3
  7. package/build/playwright/blueprintDigest.d.ts +15 -0
  8. package/build/playwright/blueprintDigest.js +152 -0
  9. package/build/playwright/blueprintDigestStore.d.ts +31 -0
  10. package/build/playwright/blueprintDigestStore.js +117 -0
  11. package/build/playwright/registerPlaywrightTools.js +60 -12
  12. package/build/playwright/traceRecordingPrompt.js +8 -7
  13. package/build/prompts/enhance-assertions/sharedAssertionRules.js +9 -8
  14. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -2
  15. package/build/prompts/promptAssets.d.ts +20 -0
  16. package/build/prompts/promptAssets.js +55 -0
  17. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +19 -5
  18. package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
  19. package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
  20. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +0 -10
  21. package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -11
  22. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +8 -4
  23. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +5 -22
  24. package/build/prompts/test-recommendation/diffExecutionPlan.js +37 -465
  25. package/build/prompts/test-recommendation/recommendationSections.d.ts +7 -17
  26. package/build/prompts/test-recommendation/recommendationSections.js +67 -309
  27. package/build/prompts/test-recommendation/recommendationShared.d.ts +19 -47
  28. package/build/prompts/test-recommendation/recommendationShared.js +49 -155
  29. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.d.ts +0 -5
  30. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +10 -153
  31. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -29
  32. package/build/prompts/test-recommendation/test-recommendation-prompt.js +32 -457
  33. package/build/prompts/testbot/planDeclarations.d.ts +6 -0
  34. package/build/prompts/testbot/planDeclarations.js +9 -0
  35. package/build/prompts/testbot/testbot-prompts.d.ts +8 -0
  36. package/build/prompts/testbot/testbot-prompts.js +256 -381
  37. package/build/recommendation/answers.d.ts +35 -0
  38. package/build/recommendation/answers.js +96 -0
  39. package/build/recommendation/registerPlan.d.ts +49 -0
  40. package/build/recommendation/registerPlan.js +117 -0
  41. package/build/recommendation/runVerifiers.d.ts +10 -0
  42. package/build/recommendation/runVerifiers.js +49 -0
  43. package/build/recommendation/subjectStep.d.ts +42 -0
  44. package/build/recommendation/subjectStep.js +86 -0
  45. package/build/recommendation/types.d.ts +163 -0
  46. package/build/recommendation/types.js +20 -0
  47. package/build/recommendation/verifierContracts.d.ts +382 -0
  48. package/build/recommendation/verifierContracts.js +263 -0
  49. package/build/recommendation/verifiers/changedFile.d.ts +2 -0
  50. package/build/recommendation/verifiers/changedFile.js +82 -0
  51. package/build/recommendation/verifiers/citedPath.d.ts +12 -0
  52. package/build/recommendation/verifiers/citedPath.js +35 -0
  53. package/build/recommendation/verifiers/coverage.d.ts +7 -0
  54. package/build/recommendation/verifiers/coverage.js +617 -0
  55. package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +11 -0
  56. package/build/recommendation/verifiers/deliveredMatchesPlan.js +33 -0
  57. package/build/recommendation/verifiers/endpointGrounded.d.ts +17 -0
  58. package/build/recommendation/verifiers/endpointGrounded.js +128 -0
  59. package/build/recommendation/verifiers/existingCoverage.d.ts +6 -0
  60. package/build/recommendation/verifiers/existingCoverage.js +51 -0
  61. package/build/recommendation/verifiers/expectedOutcome.d.ts +31 -0
  62. package/build/recommendation/verifiers/expectedOutcome.js +105 -0
  63. package/build/recommendation/verifiers/removedElementGuarded.d.ts +2 -0
  64. package/build/recommendation/verifiers/removedElementGuarded.js +57 -0
  65. package/build/recommendation/verifiers/reportedCategory.d.ts +26 -0
  66. package/build/recommendation/verifiers/reportedCategory.js +84 -0
  67. package/build/recommendation/verifiers/screenRoute.d.ts +10 -0
  68. package/build/recommendation/verifiers/screenRoute.js +118 -0
  69. package/build/recommendation/verifiers/statedDifference.d.ts +6 -0
  70. package/build/recommendation/verifiers/statedDifference.js +140 -0
  71. package/build/recommendation/verifiers/uiElementGrounded.d.ts +7 -0
  72. package/build/recommendation/verifiers/uiElementGrounded.js +318 -0
  73. package/build/resources/analysisResources.js +1 -114
  74. package/build/resources/testbotResource.js +23 -13
  75. package/build/services/ModularizationService.js +2 -1
  76. package/build/services/TestDiscoveryService.d.ts +3 -72
  77. package/build/services/TestDiscoveryService.js +10 -303
  78. package/build/services/containerEnv.d.ts +1 -1
  79. package/build/services/containerEnv.js +12 -0
  80. package/build/skills/fixTestImportErrorsSkill.d.ts +13 -0
  81. package/build/skills/fixTestImportErrorsSkill.js +20 -0
  82. package/build/toolNames.d.ts +1 -0
  83. package/build/toolNames.js +1 -0
  84. package/build/tools/code-refactor/enhanceAssertionsTool.js +3 -3
  85. package/build/tools/code-refactor/modularizationTool.js +2 -1
  86. package/build/tools/executeSkyrampTestTool.d.ts +80 -0
  87. package/build/tools/executeSkyrampTestTool.js +246 -19
  88. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +6 -0
  89. package/build/tools/generate-tests/generateContractRestTool.js +3 -3
  90. package/build/tools/generate-tests/planGuard.d.ts +2 -2
  91. package/build/tools/generate-tests/planGuard.js +78 -18
  92. package/build/tools/one-click/oneClickTool.d.ts +0 -1
  93. package/build/tools/one-click/oneClickTool.js +0 -5
  94. package/build/tools/submitReportTool.d.ts +48 -42
  95. package/build/tools/submitReportTool.js +576 -193
  96. package/build/tools/test-management/actionsTool.js +72 -4
  97. package/build/tools/test-management/analyzeChangesTool.d.ts +144 -48
  98. package/build/tools/test-management/analyzeChangesTool.js +212 -1219
  99. package/build/tools/test-management/analyzeTestHealthTool.js +13 -24
  100. package/build/tools/test-management/index.d.ts +1 -0
  101. package/build/tools/test-management/index.js +1 -0
  102. package/build/tools/test-management/registerTestPlanTool.d.ts +795 -172
  103. package/build/tools/test-management/registerTestPlanTool.js +609 -542
  104. package/build/tools/test-management/resolveScreenTool.d.ts +75 -0
  105. package/build/tools/test-management/resolveScreenTool.js +289 -0
  106. package/build/types/BlueprintDigest.d.ts +34 -0
  107. package/build/types/BlueprintDigest.js +1 -0
  108. package/build/types/RepositoryAnalysis.d.ts +20 -1559
  109. package/build/types/RepositoryAnalysis.js +2 -58
  110. package/build/types/StepMethod.d.ts +40 -0
  111. package/build/types/StepMethod.js +77 -0
  112. package/build/types/TestAnalysis.d.ts +12 -0
  113. package/build/types/TestExecution.d.ts +4 -0
  114. package/build/types/TestRecommendation.d.ts +24 -24
  115. package/build/types/TestRecommendation.js +91 -89
  116. package/build/types/TestbotPromptOptions.d.ts +0 -4
  117. package/build/types/TestbotReport.d.ts +64 -2
  118. package/build/utils/AnalysisStateManager.d.ts +79 -113
  119. package/build/utils/AnalysisStateManager.js +147 -57
  120. package/build/utils/assertion-verify/api-shared-lints.js +1 -1
  121. package/build/utils/assertion-verify/metrics.js +85 -36
  122. package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
  123. package/build/utils/assertion-verify/ui-lints.js +32 -0
  124. package/build/utils/branchDiff.d.ts +63 -31
  125. package/build/utils/branchDiff.js +242 -94
  126. package/build/utils/containedPath.d.ts +18 -0
  127. package/build/utils/containedPath.js +73 -0
  128. package/build/utils/dartRouteExtractor.d.ts +18 -34
  129. package/build/utils/dartRouteExtractor.js +101 -173
  130. package/build/utils/featureFlags.d.ts +12 -0
  131. package/build/utils/featureFlags.js +14 -0
  132. package/build/utils/frontendSelectors.d.ts +48 -27
  133. package/build/utils/frontendSelectors.js +241 -80
  134. package/build/utils/pathMatching.d.ts +2 -4
  135. package/build/utils/pathMatching.js +2 -4
  136. package/build/utils/planMatchKeys.d.ts +38 -47
  137. package/build/utils/planMatchKeys.js +143 -81
  138. package/build/utils/rebaselineSnapshots.d.ts +24 -0
  139. package/build/utils/rebaselineSnapshots.js +65 -0
  140. package/build/utils/removedUiElements.d.ts +22 -0
  141. package/build/utils/removedUiElements.js +106 -0
  142. package/build/utils/reportVerification.d.ts +2 -6
  143. package/build/utils/reportVerification.js +61 -2
  144. package/build/utils/screenRoutes.d.ts +66 -0
  145. package/build/utils/screenRoutes.js +727 -0
  146. package/build/utils/sourceRouteExtractor.js +320 -112
  147. package/build/utils/testFileClassification.d.ts +11 -2
  148. package/build/utils/testFileClassification.js +44 -2
  149. package/build/utils/testFixtures.d.ts +5 -0
  150. package/build/utils/testFixtures.js +13 -0
  151. package/build/utils/utils.d.ts +0 -1
  152. package/build/utils/utils.js +0 -11
  153. package/build/utils/versions.d.ts +3 -3
  154. package/build/utils/versions.js +1 -1
  155. package/build/workspace/workspace.d.ts +12 -12
  156. package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
  157. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
  158. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
  159. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
  160. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
  161. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
  162. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
  163. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
  164. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
  165. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
  166. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  167. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  168. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  169. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
  170. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
  171. package/node_modules/playwright/package.json +1 -1
  172. package/package.json +4 -3
  173. package/plugin/.claude-plugin/plugin.json +8 -0
  174. package/plugin/plugin.json +6 -0
  175. package/plugin/prompts/declaring-a-plan.md +20 -0
  176. package/plugin/prompts/generate-tests/context-fetching.md +4 -0
  177. package/plugin/prompts/generate-tests/execution-plan.md +63 -0
  178. package/plugin/prompts/generate-tests/generation.md +108 -0
  179. package/plugin/prompts/generate-tests/path-parameters.md +1 -0
  180. package/plugin/prompts/generate-tests/reasoning-protocol.md +17 -0
  181. package/plugin/prompts/generate-tests/tool-workflow-variants.md +61 -0
  182. package/plugin/prompts/generate-tests/tool-workflows.md +65 -0
  183. package/plugin/prompts/plan-tests.md +42 -0
  184. package/plugin/prompts/testbot-task1.md +82 -0
  185. package/plugin/skills/fix-test-import-errors/SKILL.md +98 -0
  186. package/build/prompts/test-recommendation/analysisOutputPrompt.d.ts +0 -84
  187. package/build/prompts/test-recommendation/analysisOutputPrompt.js +0 -369
  188. package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +0 -7
  189. package/build/prompts/test-recommendation/fullRepoCatalog.js +0 -283
  190. package/build/prompts/test-recommendation/scopeAssessment.d.ts +0 -81
  191. package/build/prompts/test-recommendation/scopeAssessment.js +0 -359
  192. package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +0 -7
  193. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +0 -105
  194. package/build/recommendation/budgeters/fixedNBudgeter.d.ts +0 -7
  195. package/build/recommendation/budgeters/fixedNBudgeter.js +0 -11
  196. package/build/recommendation/budgeters/shared.d.ts +0 -32
  197. package/build/recommendation/budgeters/shared.js +0 -246
  198. package/build/recommendation/discriminators.d.ts +0 -37
  199. package/build/recommendation/discriminators.js +0 -379
  200. package/build/recommendation/diversity.d.ts +0 -47
  201. package/build/recommendation/diversity.js +0 -101
  202. package/build/recommendation/planRanker.d.ts +0 -65
  203. package/build/recommendation/planRanker.js +0 -83
  204. package/build/recommendation/testFixtures.d.ts +0 -25
  205. package/build/recommendation/testFixtures.js +0 -45
  206. package/build/types/FrontendIntegration.d.ts +0 -28
  207. package/build/types/FrontendIntegration.js +0 -22
  208. package/build/types/Recommendation.d.ts +0 -146
  209. package/build/types/Recommendation.js +0 -74
  210. package/build/utils/changedRoutes.d.ts +0 -29
  211. package/build/utils/changedRoutes.js +0 -87
  212. package/build/utils/frontendIntegration.d.ts +0 -9
  213. package/build/utils/frontendIntegration.js +0 -243
  214. package/build/utils/importerHop.d.ts +0 -135
  215. package/build/utils/importerHop.js +0 -489
  216. package/build/utils/pathAffinityClassification.d.ts +0 -49
  217. package/build/utils/pathAffinityClassification.js +0 -180
  218. package/build/utils/pythonMountPrefixes.d.ts +0 -25
  219. package/build/utils/pythonMountPrefixes.js +0 -347
  220. package/build/utils/repoScanner.d.ts +0 -34
  221. package/build/utils/repoScanner.js +0 -300
  222. package/build/utils/routeParsers.d.ts +0 -95
  223. package/build/utils/routeParsers.js +0 -951
  224. package/build/utils/scenarioDrafting.d.ts +0 -92
  225. package/build/utils/scenarioDrafting.js +0 -951
  226. package/build/utils/subjectEndpoints.d.ts +0 -19
  227. package/build/utils/subjectEndpoints.js +0 -98
  228. package/build/utils/uiPageEnumerator.d.ts +0 -172
  229. package/build/utils/uiPageEnumerator.js +0 -474
@@ -1,951 +0,0 @@
1
- import * as fs from "fs";
2
- import * as os from "os";
3
- import * as path from "path";
4
- import { execFileSync } from "child_process";
5
- import { joinRoutePrefix, resolvePythonMountPrefixes } from "./pythonMountPrefixes.js";
6
- import { logger } from "./logger.js";
7
- export function nextjsFileToApiPath(filePath) {
8
- const pagesMatch = filePath.match(/(?:^|\/)pages\/(api\/.+)\.[jt]sx?$/);
9
- if (pagesMatch) {
10
- let route = "/" + pagesMatch[1];
11
- route = route.replace(/\/index$/, "");
12
- route = route
13
- .replace(/\[\.\.\.(\w+)\]/g, "{$1}")
14
- .replace(/\[(\w+)\]/g, "{$1}");
15
- return route;
16
- }
17
- const appMatch = filePath.match(/(?:^|\/)app\/(api\/.+)\/route\.[jt]sx?$/);
18
- if (appMatch) {
19
- let route = "/" + appMatch[1];
20
- route = route
21
- .replace(/\[\.\.\.(\w+)\]/g, "{$1}")
22
- .replace(/\[(\w+)\]/g, "{$1}");
23
- return route;
24
- }
25
- return null;
26
- }
27
- // UI component file extensions are unambiguously frontend — never route definitions.
28
- const UI_COMPONENT_EXT = /\.(jsx|tsx|vue|svelte)$/i;
29
- // Frontend-file extensions for UI-change detection (broader than UI_COMPONENT_EXT —
30
- // includes template/markup files that backend-only PRs won't touch). Used by
31
- // testbot-prompts.ts to gate the UI pre-scan and capture-act-capture sections,
32
- // and anywhere else that needs to ask "does this PR touch the frontend?".
33
- // Single source of truth for that question.
34
- export const UI_FILE_EXTENSIONS = ['tsx', 'jsx', 'vue', 'svelte', 'html', 'xml'];
35
- // Git pathspec form of the UI-file filter — usable directly with `git diff`'s
36
- // trailing pathspec args, no external tool needed. Avoids the "agent shell
37
- // has grep aliased to rg" failure mode where `grep -E` becomes `rg -E`
38
- // (which means `--encoding`, not extended regex). Each entry is `*.ext`;
39
- // pass them as `-- '*.tsx' '*.jsx' ...` to git diff.
40
- export const UI_FILE_GIT_PATHSPEC = UI_FILE_EXTENSIONS.map(ext => `'*.${ext}'`).join(' ');
41
- // Pipe-joined form of the UI extensions for use inside an extended regex
42
- // (e.g. `grep -E '\\.(tsx|jsx|vue|svelte|html|xml) '`). Mirrors
43
- // UI_FILE_GIT_PATHSPEC but for grep over a pre-computed diff file.
44
- export const UI_FILE_EXTENSIONS_PIPED = UI_FILE_EXTENSIONS.join('|');
45
- export function parseRouteLine(line, sourceFile) {
46
- const stripped = line.replace(/^[+-]\s*/, "").trim();
47
- const isComponent = UI_COMPONENT_EXT.test(sourceFile);
48
- const decoratorMatch = stripped.match(/@(?:\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*["']([^"'?#]*)/i);
49
- if (decoratorMatch) {
50
- return {
51
- method: decoratorMatch[1].toUpperCase(),
52
- path: decoratorMatch[2],
53
- sourceFile,
54
- };
55
- }
56
- const flaskMatch = stripped.match(/@\w+\.route\s*\(\s*["']([^"'?#]+)["'][^)]*methods\s*=\s*\[["'](\w+)["']/i);
57
- if (flaskMatch) {
58
- return {
59
- method: flaskMatch[2].toUpperCase(),
60
- path: flaskMatch[1],
61
- sourceFile,
62
- };
63
- }
64
- const expressMatch = stripped.match(/(?:router|app)\.(get|post|put|patch|delete)\s*\(\s*["']([^"'?#]+)/i);
65
- if (expressMatch && !isComponent) {
66
- return {
67
- method: expressMatch[1].toUpperCase(),
68
- path: expressMatch[2],
69
- sourceFile,
70
- };
71
- }
72
- const springMatch = stripped.match(/@(Get|Post|Put|Patch|Delete)Mapping\s*(?:\(\s*(?:(?:value|path)\s*=\s*)?["']([^"'?#]+)["'])?/i);
73
- if (springMatch && springMatch[2]) {
74
- return {
75
- method: springMatch[1].toUpperCase(),
76
- path: springMatch[2],
77
- sourceFile,
78
- };
79
- }
80
- // @RequestMapping at method level — without explicit method = RequestMethod.X it matches
81
- // all HTTP verbs, so emit "MULTI" as the sentinel rather than guessing GET.
82
- const requestMappingMatch = stripped.match(/@RequestMapping\s*\(\s*(?:(?:value|path)\s*=\s*)?["']([^"'?#]+)["']/i);
83
- if (requestMappingMatch) {
84
- const methodMatches = [...stripped.matchAll(/RequestMethod\.([A-Z]+)/gi)];
85
- const method = methodMatches.length === 1 ? methodMatches[0][1].toUpperCase() : "MULTI";
86
- return { method, path: requestMappingMatch[1], sourceFile };
87
- }
88
- const nestjsMatch = stripped.match(/@(Get|Post|Put|Patch|Delete)\s*\(\s*["']([^"'?#]+)["']/i);
89
- if (nestjsMatch && !isComponent) {
90
- return {
91
- method: nestjsMatch[1].toUpperCase(),
92
- path: nestjsMatch[2],
93
- sourceFile,
94
- };
95
- }
96
- const nextjsMethodMatch = stripped.match(/req\.method\s*===?\s*["'](GET|POST|PUT|PATCH|DELETE)["']/i);
97
- if (nextjsMethodMatch) {
98
- const apiPath = nextjsFileToApiPath(sourceFile);
99
- if (apiPath) {
100
- return {
101
- method: nextjsMethodMatch[1].toUpperCase(),
102
- path: apiPath,
103
- sourceFile,
104
- };
105
- }
106
- }
107
- // Next.js guard clause: `if (req.method !== "POST")` means the handler IS for POST
108
- const nextjsGuardMatch = stripped.match(/req\.method\s*!==?\s*["'](GET|POST|PUT|PATCH|DELETE)["']/i);
109
- if (nextjsGuardMatch) {
110
- const apiPath = nextjsFileToApiPath(sourceFile);
111
- if (apiPath) {
112
- return {
113
- method: nextjsGuardMatch[1].toUpperCase(),
114
- path: apiPath,
115
- sourceFile,
116
- };
117
- }
118
- }
119
- const appRouterExportMatch = stripped.match(/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/i);
120
- if (appRouterExportMatch) {
121
- const apiPath = nextjsFileToApiPath(sourceFile);
122
- if (apiPath) {
123
- return {
124
- method: appRouterExportMatch[1].toUpperCase(),
125
- path: apiPath,
126
- sourceFile,
127
- };
128
- }
129
- }
130
- // Go (Gin, Echo, Chi): <ident>.GET/POST/...("path", handler)
131
- //
132
- // An all-caps method token (Gin/Echo convention: `g.GET(...)`, `r.POST(...)`)
133
- // is unambiguously a route registration — nobody writes `query.GET(...)` — so
134
- // it's accepted even without a leading slash (real Gin routes like
135
- // `g.GET("ping", ...)` omit it). A CamelCase token (Chi's `r.Get`/`r.Post`)
136
- // shares call syntax with a plain getter — `query.Get("term")`,
137
- // `Header.Get("Accept-Version")` — so it only counts as a route when the
138
- // captured path starts with "/", which every Chi route does and a getter's
139
- // argument never does.
140
- const ginUpperMatch = stripped.match(/\w+\.(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(\s*["']([^"'?#]+)/);
141
- if (ginUpperMatch && sourceFile.endsWith(".go")) {
142
- return { method: ginUpperMatch[1], path: ginUpperMatch[2], sourceFile };
143
- }
144
- const ginCamelMatch = stripped.match(/\w+\.(Get|Post|Put|Patch|Delete|Head|Options)\s*\(\s*["'](\/[^"'?#]*)/);
145
- if (ginCamelMatch && sourceFile.endsWith(".go")) {
146
- return {
147
- method: ginCamelMatch[1].toUpperCase(),
148
- path: ginCamelMatch[2],
149
- sourceFile,
150
- };
151
- }
152
- // Go stdlib: http.HandleFunc/Handle
153
- const goHandleMatch = stripped.match(/(?:HandleFunc|Handle)\s*\(\s*["']([^"'?#]+)/);
154
- if (goHandleMatch && sourceFile.endsWith(".go")) {
155
- return { method: "MULTI", path: goHandleMatch[1], sourceFile };
156
- }
157
- // Ruby (Rails) and Elixir (Phoenix): verb "/path", ...
158
- // Rails: get "/users", to: "users#index"
159
- // Phoenix: get "/users", UserController, :index
160
- const railsMatch = stripped.match(/^(get|post|put|patch|delete)\s+["']([^"'?#]+)/i);
161
- if (railsMatch && (sourceFile.endsWith(".rb") ||
162
- sourceFile.endsWith(".ex") ||
163
- sourceFile.endsWith(".exs") ||
164
- /routes/i.test(sourceFile))) {
165
- return { method: railsMatch[1].toUpperCase(), path: railsMatch[2], sourceFile };
166
- }
167
- // PHP — Laravel
168
- const laravelMatch = stripped.match(/Route::(get|post|put|patch|delete|options)\s*\(\s*["']([^"'?#]+)/i);
169
- if (laravelMatch) {
170
- return { method: laravelMatch[1].toUpperCase(), path: laravelMatch[2], sourceFile };
171
- }
172
- // Rust — Actix
173
- const actixAttrMatch = stripped.match(/#\[(get|post|put|patch|delete)\s*\(\s*["']([^"'?#]+)/i);
174
- if (actixAttrMatch) {
175
- return { method: actixAttrMatch[1].toUpperCase(), path: actixAttrMatch[2], sourceFile };
176
- }
177
- // ASP.NET
178
- const aspnetMatch = stripped.match(/\[Http(Get|Post|Put|Patch|Delete)\s*(?:\(\s*["']([^"'?#]+)["'])?/i);
179
- if (aspnetMatch) {
180
- return {
181
- method: aspnetMatch[1].toUpperCase(),
182
- path: aspnetMatch[2] || "/",
183
- sourceFile,
184
- };
185
- }
186
- // Hapi.js
187
- const hapiMatch = stripped.match(/method:\s*["'](GET|POST|PUT|PATCH|DELETE)["'][^}]*path:\s*["']([^"'?#]+)/i);
188
- if (hapiMatch && !isComponent) {
189
- return { method: hapiMatch[1].toUpperCase(), path: hapiMatch[2], sourceFile };
190
- }
191
- return null;
192
- }
193
- /**
194
- * Scan all route definitions in a single source file, combining prefix declarations
195
- * with method-level annotations. Two orthogonal state machines run in parallel:
196
- *
197
- * 1. Class-prefix (Spring, NestJS, ASP.NET, Kotlin):
198
- * @Controller/@RestController/@RequestMapping (class level) + class declaration →
199
- * activates classPrefix, applied to all @Get/@Post/@GetMapping etc. below.
200
- *
201
- * 2. Router-variable prefix (FastAPI, Flask Blueprint):
202
- * varName = APIRouter(prefix=...) or Blueprint(url_prefix=...) — handles both
203
- * single-line and multi-line declarations — maps the variable name to its prefix.
204
- * Applied when @varName.method(...) is seen.
205
- *
206
- * Falls back to pure parseRouteLine for all other frameworks (Express inline, Gin,
207
- * Rails, Laravel, Actix, Hapi, Go stdlib, etc.), which remain unaffected.
208
- *
209
- * `mountPrefixes` optionally supplies, per router variable, the **ancestor**
210
- * prefix that router inherits from cross-file `include_router(...)` mount points
211
- * (see `resolvePythonMountPrefixes`). It is composed *ahead* of the router's own
212
- * in-file prefix, so a `router = APIRouter(prefix="/recipes")` mounted under an
213
- * aggregator's `APIRouter(prefix="/api")` yields `/api/recipes`, not `/recipes`.
214
- */
215
- export function parseFileEndpoints(content, sourceFile, mountPrefixes) {
216
- const results = [];
217
- let classPrefix = ""; // active prefix for the current class body
218
- let pendingPrefix = ""; // set by class-level annotation; activated on class declaration
219
- // FastAPI APIRouter / Flask Blueprint: variable name → prefix
220
- const routerPrefixes = new Map();
221
- // Effective prefix for a router var = its cross-file ancestor mount prefix
222
- // composed with its own in-file APIRouter(prefix=…). Returns undefined when
223
- // the var has neither, so callers leave the path untouched.
224
- const effectivePrefix = (varName) => {
225
- const own = routerPrefixes.get(varName);
226
- const mount = mountPrefixes?.get(varName);
227
- if (own === undefined && mount === undefined)
228
- return undefined;
229
- return joinRoutePrefix(mount ?? "", own ?? "");
230
- };
231
- let pendingRouterVar = ""; // variable name being accumulated in a multi-line declaration
232
- let pendingPythonDecorator = null;
233
- let pendingExpressMethod = ""; // HTTP method from a multi-line Express route (e.g. "POST" from `router.post(\n`)
234
- let pendingExpressLine = 0; // line where the multi-line Express route started
235
- // Fastify: server.route({ method: "GET", url: "/:id", ... }) — method may be a
236
- // single string or an array of strings (one route handling multiple verbs).
237
- // Object bodies are always multi-line in practice (Prettier-formatted), so this
238
- // accumulates method/url across lines until both are found (then emits) or the
239
- // call closes / runs long without resolving (then abandons, fail-open).
240
- // `depth` is the brace depth relative to the route-options object: fields are
241
- // only matched on lines that START at depth 1 (directly inside the options
242
- // object), so a coincidental "url"/"method" key inside a nested config/schema
243
- // body is never mistaken for the route's own — and depth returning to 0 marks
244
- // the true end of the call regardless of how many nested blocks preceded it.
245
- let pendingFastifyRoute = null;
246
- const FASTIFY_PENDING_LINE_BUDGET = 200;
247
- let lineNo = 0; // 1-based; incremented for every line (including skipped) to keep numbering accurate
248
- for (const line of content.split("\n")) {
249
- lineNo++;
250
- const trimmed = line.trim();
251
- if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("*"))
252
- continue;
253
- // ── Class-prefix state machine (Java/Kotlin/TS/C#) ───────────────────────
254
- // Class declaration → activate any pending prefix (or clear if none was set)
255
- if (/^(?:(?:public|private|protected|abstract|final|sealed|static|override)\s+)*(?:export\s+)?(?:abstract\s+)?class\s+\w+/.test(trimmed)) {
256
- classPrefix = pendingPrefix;
257
- pendingPrefix = "";
258
- continue;
259
- }
260
- // @Controller / @RestController → marks a class-level context (Spring, NestJS)
261
- if (/@(?:Controller|RestController)\b/.test(trimmed)) {
262
- const m = trimmed.match(/@(?:Controller|RestController)\s*\(\s*["']([^"'?#]+)["']/i);
263
- if (m)
264
- pendingPrefix = m[1]; // explicit path → update pending
265
- // No path arg → leave pendingPrefix unchanged (may have been set by @RequestMapping)
266
- continue;
267
- }
268
- // @RequestMapping without method= → class-level prefix declaration (Spring/Kotlin)
269
- if (/@RequestMapping\b/.test(trimmed) && !/method\s*=/.test(trimmed)) {
270
- const m = trimmed.match(/@RequestMapping\s*\(\s*(?:(?:value|path)\s*=\s*)?["']([^"'?#]+)["']/i);
271
- pendingPrefix = m ? m[1] : "";
272
- continue;
273
- }
274
- // ASP.NET [Route("prefix")] at start of line → class-level prefix
275
- if (/^\[Route\s*\(/.test(trimmed)) {
276
- const m = trimmed.match(/\[Route\s*\(\s*["']([^"'?#]+)["']/i);
277
- pendingPrefix = m ? m[1] : "";
278
- continue;
279
- }
280
- // ── Router-variable prefix state machine (Python FastAPI / Flask) ─────────
281
- if (!trimmed.startsWith("@")) {
282
- // Inside a multi-line APIRouter()/Blueprint() call — collect prefix= / url_prefix=
283
- if (pendingRouterVar) {
284
- const prefM = trimmed.match(/\burl_prefix\s*=\s*["']([^"'?#]+)["']/)
285
- || trimmed.match(/(?<![a-zA-Z_])prefix\s*=\s*["']([^"'?#]+)["']/);
286
- if (prefM) {
287
- routerPrefixes.set(pendingRouterVar, prefM[1]);
288
- pendingRouterVar = "";
289
- }
290
- else if (trimmed.startsWith(")")) {
291
- pendingRouterVar = ""; // closed without a prefix arg
292
- }
293
- continue;
294
- }
295
- // Single-line: varName = APIRouter(prefix="/path") or APIRouter(tags=..., prefix="/path").
296
- // `\w*APIRouter` also matches custom subclasses (e.g. Mealie's UserAPIRouter).
297
- const singleM = trimmed.match(/^(\w+)\s*=\s*\w*APIRouter\s*\([^)]*prefix\s*=\s*["']([^"'?#]+)["']/) ||
298
- trimmed.match(/^(\w+)\s*=\s*Blueprint\s*\([^)]*url_prefix\s*=\s*["']([^"'?#]+)["']/);
299
- if (singleM) {
300
- routerPrefixes.set(singleM[1], singleM[2]);
301
- continue;
302
- }
303
- // Multi-line open: varName = APIRouter( or Blueprint( without closing ) on this line
304
- if (/^(\w+)\s*=\s*(?:\w*APIRouter|Blueprint)\s*\(/.test(trimmed) && !trimmed.includes(")")) {
305
- const openM = trimmed.match(/^(\w+)/);
306
- if (openM)
307
- pendingRouterVar = openM[1];
308
- continue;
309
- }
310
- }
311
- if (pendingPythonDecorator) {
312
- // FastAPI decorators commonly split the route path onto the next line:
313
- // @router.delete(
314
- // "/{id}",
315
- // ...
316
- // )
317
- const pathMatch = trimmed.match(/^(?:path\s*=\s*)?["']([^"'?#]*)/) ||
318
- trimmed.match(/^value\s*=\s*["']([^"'?#]*)/);
319
- if (pathMatch) {
320
- const eff = effectivePrefix(pendingPythonDecorator.routerVar);
321
- const composedPath = eff !== undefined
322
- ? joinRoutePrefix(eff, pathMatch[1])
323
- : pathMatch[1];
324
- results.push({
325
- method: pendingPythonDecorator.method,
326
- path: composedPath,
327
- sourceFile,
328
- line: pendingPythonDecorator.startLine,
329
- });
330
- pendingPythonDecorator = null;
331
- continue;
332
- }
333
- if (/^[\]\)\}]+[;,)]*\s*(?:#.*)?$/.test(trimmed) ||
334
- trimmed.startsWith("@") ||
335
- /^async\s+def\b|^def\b/.test(trimmed)) {
336
- pendingPythonDecorator = null;
337
- }
338
- else {
339
- continue;
340
- }
341
- }
342
- // ── Fastify route state machine ───────────────────────────────────────────
343
- // Handles patterns like:
344
- // server.route({
345
- // config: { ... }, // nested blocks may precede or follow
346
- // schema: { ... }, // method/url — brace depth tracks them
347
- // method: "GET", // or: method: ["GET", "POST"],
348
- // url: "/:secretName",
349
- // handler: ...
350
- // });
351
- // method/url may appear in either order and on their own lines. Fields are
352
- // matched only on lines starting at brace depth 1 (directly inside the
353
- // options object) so a coincidental "url"/"method" key inside a nested
354
- // config/schema body can't cause a false match, and the pending state is
355
- // abandoned only when depth returns to 0 (the call actually closed).
356
- if (!pendingFastifyRoute && /\.route\s*\(\s*\{/.test(trimmed)) {
357
- pendingFastifyRoute = { method: null, url: null, startLine: lineNo, depth: 0 };
358
- }
359
- if (pendingFastifyRoute) {
360
- // The opener line starts at depth 0 (its own `{` raises it to 1), so
361
- // inline fields on the opener are matchable too.
362
- const matchable = pendingFastifyRoute.depth <= 1;
363
- if (matchable) {
364
- if (pendingFastifyRoute.method === null) {
365
- const arrMatch = trimmed.match(/\bmethod\s*:\s*\[([^\]]*)\]/i);
366
- const strMatch = trimmed.match(/\bmethod\s*:\s*["']([A-Za-z]+)["']/i);
367
- if (arrMatch) {
368
- pendingFastifyRoute.method = [...arrMatch[1].matchAll(/["']([A-Za-z]+)["']/g)]
369
- .map((m) => m[1].toUpperCase());
370
- }
371
- else if (strMatch) {
372
- pendingFastifyRoute.method = [strMatch[1].toUpperCase()];
373
- }
374
- }
375
- if (pendingFastifyRoute.url === null) {
376
- const urlMatch = trimmed.match(/\burl\s*:\s*["']([^"'?#]*)["']/i);
377
- if (urlMatch)
378
- pendingFastifyRoute.url = urlMatch[1];
379
- }
380
- if (pendingFastifyRoute.method && pendingFastifyRoute.url !== null && !UI_COMPONENT_EXT.test(sourceFile)) {
381
- for (const method of pendingFastifyRoute.method) {
382
- results.push({
383
- method,
384
- path: pendingFastifyRoute.url,
385
- sourceFile,
386
- line: pendingFastifyRoute.startLine,
387
- });
388
- }
389
- pendingFastifyRoute = null;
390
- continue;
391
- }
392
- }
393
- // Update brace depth from this line, ignoring braces inside string
394
- // literals (a url like "/items/{id}" or a schema description containing
395
- // "{" must not skew the count). Abandon when the options object closes
396
- // (depth back to 0) without both fields resolving, or if the call has
397
- // run implausibly long — fail-open, never throws.
398
- const withoutStrings = trimmed.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, "");
399
- pendingFastifyRoute.depth +=
400
- (withoutStrings.match(/\{/g)?.length ?? 0) - (withoutStrings.match(/\}/g)?.length ?? 0);
401
- if (pendingFastifyRoute.depth <= 0 ||
402
- lineNo - pendingFastifyRoute.startLine > FASTIFY_PENDING_LINE_BUDGET) {
403
- pendingFastifyRoute = null;
404
- }
405
- continue;
406
- }
407
- // ── Multi-line Express route state machine ────────────────────────────────
408
- // Handles patterns like:
409
- // router.post(
410
- // '/:collection',
411
- // middleware,
412
- // handler
413
- // );
414
- if (pendingExpressMethod) {
415
- // Look for the first path-like string literal on this line
416
- const pathMatch = trimmed.match(/["'](\/[^"'?#]*|:[^"'?#]+)["']/);
417
- if (pathMatch) {
418
- const isComponent = UI_COMPONENT_EXT.test(sourceFile);
419
- if (!isComponent) {
420
- results.push({
421
- method: pendingExpressMethod,
422
- path: pathMatch[1],
423
- sourceFile,
424
- line: pendingExpressLine,
425
- });
426
- }
427
- pendingExpressMethod = "";
428
- continue;
429
- }
430
- // If we hit a closing delimiter-only line or another route call, abandon the pending state
431
- if (/^[\]\)\}]+[;,)]*\s*(?:\/\/.*)?$/.test(trimmed)) {
432
- pendingExpressMethod = "";
433
- continue;
434
- }
435
- if (/(?:router|app)\.(get|post|put|patch|delete)\s*\(/i.test(trimmed)) {
436
- // New route call — reset and fall through to parse this line normally
437
- pendingExpressMethod = "";
438
- }
439
- else {
440
- continue; // Skip middleware/handler lines
441
- }
442
- }
443
- // ── Non-annotation line → clear any dangling pendingPrefix ───────────────
444
- // Prevents method-level @RequestMapping (no method=) from being misidentified
445
- if (pendingPrefix && !trimmed.startsWith("@") && !trimmed.startsWith("[")) {
446
- pendingPrefix = "";
447
- }
448
- // ── Parse method-level annotation ─────────────────────────────────────────
449
- let ep = parseRouteLine(trimmed, sourceFile);
450
- // NestJS decorator with no path arg → applies to the controller's root path
451
- if (!ep) {
452
- const nestjsNoPath = trimmed.match(/@(Get|Post|Put|Patch|Delete)\s*\(\s*\)/i);
453
- if (nestjsNoPath)
454
- ep = { method: nestjsNoPath[1].toUpperCase(), path: "", sourceFile };
455
- }
456
- if (!ep) {
457
- // Detect multi-line FastAPI/Flask-style decorators with the path on a later line.
458
- const pythonDecoratorMultiLine = trimmed.match(/@(\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*$/i);
459
- if (pythonDecoratorMultiLine) {
460
- pendingPythonDecorator = {
461
- routerVar: pythonDecoratorMultiLine[1],
462
- method: pythonDecoratorMultiLine[2].toUpperCase(),
463
- startLine: lineNo,
464
- };
465
- continue;
466
- }
467
- // Detect multi-line Express route: method call with no path string on same line
468
- // e.g. "router.post(" or "app.get(\n"
469
- const expressMultiLine = trimmed.match(/(?:router|app)\.(get|post|put|patch|delete)\s*\(\s*$/i);
470
- if (expressMultiLine && !UI_COMPONENT_EXT.test(sourceFile)) {
471
- pendingExpressMethod = expressMultiLine[1].toUpperCase();
472
- pendingExpressLine = lineNo;
473
- }
474
- continue;
475
- }
476
- // ── Apply prefix ──────────────────────────────────────────────────────────
477
- if (classPrefix) {
478
- // Class-based prefix (Spring, NestJS, ASP.NET)
479
- const prefix = classPrefix.replace(/\/$/, "");
480
- const suffix = ep.path.replace(/^\//, "");
481
- if (!suffix) {
482
- ep.path = prefix || "/";
483
- }
484
- else if (!ep.path.startsWith(prefix)) {
485
- ep.path = `${prefix}/${suffix}`;
486
- }
487
- }
488
- else if (routerPrefixes.size > 0 || (mountPrefixes && mountPrefixes.size > 0)) {
489
- // Router-variable prefix (FastAPI, Flask Blueprint) — own prefix composed
490
- // with any cross-file ancestor mount prefix.
491
- const varM = trimmed.match(/@(\w+)\.(get|post|put|patch|delete|head|options)\s*\(/i)
492
- || trimmed.match(/@(\w+)\.route\s*\(/i);
493
- if (varM) {
494
- const eff = effectivePrefix(varM[1]);
495
- if (eff !== undefined)
496
- ep.path = joinRoutePrefix(eff, ep.path);
497
- }
498
- }
499
- ep.line = lineNo;
500
- results.push(ep);
501
- }
502
- // Normalize to a leading-slash path here (single responsibility) so callers
503
- // don't each re-normalize. Empty paths (e.g. NestJS no-path decorators) are
504
- // left as-is.
505
- return results.map((ep) => ({
506
- ...ep,
507
- path: ep.path && !ep.path.startsWith("/") ? `/${ep.path}` : ep.path,
508
- }));
509
- }
510
- /**
511
- * One path segment that names a parameter rather than a resource, in any route
512
- * syntax the scanners read: OpenAPI "{id}", Express/NestJS ":id", and Next.js
513
- * "[id]" / "[...id]" / "[[...id]]" (the double-bracket form needs its own
514
- * alternative — it contains an inner "]", which the single-bracket form's
515
- * [^\]]+ can never span).
516
- */
517
- // The colon form may carry an Express/Fastify constraint or modifier —
518
- // `:id(\d+)`, `:id?`, `:id*`, `:id+`. Those are still parameters, and a
519
- // resource name is never one, so the predicate has to accept them or
520
- // extractResourceFromPath returns the whole segment as the resource.
521
- const PARAM_SEGMENT_RE = /^\{[^}]+\}$|^:[A-Za-z_]\w*(?:\([^)]*\))?[?*+]?$|^\[\[[^\]]+\]\]$|^\[[^\]]+\]$/;
522
- export function isParamSegment(segment) {
523
- return PARAM_SEGMENT_RE.test(segment);
524
- }
525
- /**
526
- * One path segment that is an id VALUE rather than a name: a UUID, digits only,
527
- * or a long hex run (a Mongo ObjectId is 24). A caller writes a literal id in a
528
- * path where the route declares a parameter, and an id can never be a resource
529
- * name, so resource derivation must skip it exactly as it skips a parameter.
530
- * SKYR-4214: an accepted UUID produced the key
531
- * "DELETE::a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11::integration", which matches
532
- * nothing, so the candidate escaped dedup entirely.
533
- *
534
- * The 16-character floor on the hex rule keeps real resource names safe: a name
535
- * built only from the letters a-f and digits, 16 characters or longer, is not a
536
- * word.
537
- */
538
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
539
- export function isOpaqueIdSegment(segment) {
540
- // The sign belongs to the value: an edge-case step writes `/orders/-1` to
541
- // probe a negative id, and without the sign that segment became the resource
542
- // name, so the key could never match real order coverage.
543
- return UUID_RE.test(segment) || /^-?\d+$/.test(segment) || /^[0-9a-f]{16,}$/i.test(segment);
544
- }
545
- /** A segment that names neither a resource nor a sub-resource. */
546
- function isNonResourceSegment(segment) {
547
- return isParamSegment(segment) || isOpaqueIdSegment(segment);
548
- }
549
- export const SKIP_PATH_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
550
- /**
551
- * True for version/prefix path segments — "v1".."v99" and the literal "api".
552
- * `SKIP_PATH_SEGMENTS` enumerates only v1-v3, so resource-token derivation uses
553
- * this predicate as well; without it any endpoint versioned beyond v3 would
554
- * derive a nonsense token ("v4") as its resource (SKYR-3857).
555
- */
556
- export function isVersionLikeSegment(segment) {
557
- const lower = segment.toLowerCase();
558
- return /^v\d+$/.test(lower) || lower === "api";
559
- }
560
- /**
561
- * Extract the primary resource key from an endpoint path for use as a dedup key.
562
- *
563
- * For top-level resources (e.g. "/api/v1/orders/{id}") returns the resource name ("orders").
564
- * For sub-resources (e.g. "/orders/{id}/items") returns "orders_items" — including the
565
- * parent prevents "/orders/{id}/items" and "/products/{id}/items" from colliding on "items".
566
- */
567
- export function extractResourceFromPath(endpointPath) {
568
- const segments = endpointPath.split("/").filter(Boolean);
569
- const meaningful = segments.filter((s) => !isNonResourceSegment(s) && !SKIP_PATH_SEGMENTS.has(s));
570
- if (meaningful.length === 0)
571
- return "unknown";
572
- // Sub-resource pattern: /…/<parent>/<id>/<child>
573
- // Detected when the last raw segment names a resource and the one before it
574
- // does not — an id in any spelling, not only "{param}".
575
- const last = segments[segments.length - 1];
576
- const secondLast = segments.length >= 2 ? segments[segments.length - 2] : "";
577
- if (!isNonResourceSegment(last) && isNonResourceSegment(secondLast) && meaningful.length >= 2) {
578
- return `${meaningful[meaningful.length - 2]}_${meaningful[meaningful.length - 1]}`;
579
- }
580
- return meaningful[meaningful.length - 1];
581
- }
582
- const GENERIC_ROUTE_BASENAMES = new Set(["api", "app", "handler", "handlers", "index", "route", "routes", "router", "server"]);
583
- function normalizeSourcePath(filePath) {
584
- return filePath.replace(/\\/g, "/");
585
- }
586
- function staticPathSegments(urlPath) {
587
- return urlPath.split("/").filter(s => s && !/^[:{[]/.test(s)).map(s => s.toLowerCase());
588
- }
589
- function endpointSourceResource(ep) {
590
- const sourceSegments = ep.sourceFile.split(/[\\/]/).filter(Boolean);
591
- const filename = sourceSegments.at(-1)?.replace(/\.[^.]+$/, "") ?? "";
592
- const normalizedFilename = filename.replace(/^(?:old|new)[-_]/i, "").toLowerCase();
593
- const isDynamic = /^\[.*\]$/.test(normalizedFilename) || /^\{.*\}$/.test(normalizedFilename);
594
- if (!isDynamic && !GENERIC_ROUTE_BASENAMES.has(normalizedFilename) && normalizedFilename) {
595
- return normalizedFilename;
596
- }
597
- else if (GENERIC_ROUTE_BASENAMES.has(normalizedFilename)) {
598
- // Generic filename: walk up the directory tree for a more specific ancestor name.
599
- const parentSegments = sourceSegments.slice(0, -1).reverse();
600
- const contextSegment = parentSegments
601
- .map(s => s.replace(/\.[^.]+$/, "").replace(/^(?:old|new)[-_]/i, "").toLowerCase())
602
- .find(s => s && !GENERIC_ROUTE_BASENAMES.has(s) && !/^\[.*\]$/.test(s) && !/^\{.*\}$/.test(s));
603
- if (contextSegment)
604
- return `${contextSegment}/${normalizedFilename}`;
605
- }
606
- // Fall back to the last static segment of the endpoint's URL path.
607
- const segments = staticPathSegments(ep.path);
608
- return segments.at(-1) ?? normalizedFilename;
609
- }
610
- function endpointMovedKey(path, resource, method) {
611
- return method ? `${path}::${resource}::${method}` : `${path}::${resource}`;
612
- }
613
- // Route-file heuristics: keep base-branch I/O bounded to files that plausibly
614
- // declare endpoints. Mirrors the previous deleted-file recovery filter.
615
- const ROUTE_FILE_PATTERN = /route|controller|endpoint|handler|view|urls|api|router|service|gateway|resolver|\bserver\b/i;
616
- const ROUTE_FILE_BASENAME_PATTERN = /\bapp\b|\bmain\b/i;
617
- const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
618
- export function isRouteLikeFile(filePath) {
619
- const base = normalizeSourcePath(filePath).split("/").pop() ?? "";
620
- return SOURCE_EXTS.test(filePath) &&
621
- (ROUTE_FILE_PATTERN.test(filePath) || ROUTE_FILE_BASENAME_PATTERN.test(base));
622
- }
623
- /**
624
- * Files that may contain endpoints removed in this branch: every changed file
625
- * except newly-created ones. Both whole-file deletions AND line-edits that drop a
626
- * route from a surviving file belong here — newly-created files cannot contain a
627
- * route that existed in the base branch.
628
- */
629
- export function selectRemovalCandidateFiles(diffData) {
630
- const newFileSet = new Set(diffData.newFiles.map(normalizeSourcePath));
631
- return diffData.changedFiles.filter((f) => !newFileSet.has(normalizeSourcePath(f)));
632
- }
633
- /**
634
- * Recover endpoints as they existed in the base branch, for the given candidate
635
- * files. `fetchBaseContent` returns a file's base-branch content (e.g. via
636
- * `git show base:<file>`); files that throw are skipped. Returns every endpoint
637
- * found in the base versions — callers pass the result to
638
- * `classifyEndpointsByChangedFiles`, whose moved/still-present filter drops any
639
- * endpoint that survives in the current catalog, leaving only genuine removals.
640
- */
641
- /**
642
- * Materialize just the `.py` files of a git ref into a fresh temp directory
643
- * via `git archive` (no full checkout/worktree) — lets `resolvePythonMountPrefixes`
644
- * run against the BASE tree unmodified. Best-effort: returns null on any
645
- * failure (shallow clone missing the ref, git/tar unavailable, etc.) so the
646
- * caller can fall back rather than abort removal recovery.
647
- */
648
- function materializePythonFilesFromRef(repositoryPath, ref) {
649
- let tmpDir;
650
- try {
651
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-base-py-"));
652
- }
653
- catch {
654
- return null;
655
- }
656
- const archivePath = path.join(tmpDir, "archive.tar");
657
- try {
658
- execFileSync("git", ["-C", repositoryPath, "archive", "--format=tar", "-o", archivePath, ref, "--", "*.py"], {
659
- stdio: ["ignore", "ignore", "ignore"],
660
- });
661
- execFileSync("tar", ["-xf", archivePath, "-C", tmpDir], { stdio: ["ignore", "ignore", "ignore"] });
662
- fs.rmSync(archivePath, { force: true });
663
- return tmpDir;
664
- }
665
- catch (err) {
666
- logger.warning("Failed to materialize base-branch Python files for mount-prefix resolution — falling back to current working tree", {
667
- ref,
668
- error: err instanceof Error ? err.message : String(err),
669
- });
670
- try {
671
- fs.rmSync(tmpDir, { recursive: true, force: true });
672
- }
673
- catch {
674
- // best-effort cleanup
675
- }
676
- return null;
677
- }
678
- }
679
- export async function recoverRemovedEndpointsFromBase(candidateFiles, fetchBaseContent, repositoryPath, baseBranch) {
680
- const routeFiles = candidateFiles.filter(isRouteLikeFile);
681
- const endpointMap = new Map();
682
- // Cross-file FastAPI mount-prefix resolution, mirroring the main scan
683
- // (repoScanner.ts). Without it, a route file whose prefix is applied
684
- // externally (app.include_router(router, prefix=...)) yields unmounted
685
- // paths here that can never match the fully-mounted current-catalog
686
- // paths in classifyEndpointsByChangedFiles's "still exists" check — every
687
- // endpoint in that file gets misreported as removed, not just the one
688
- // actually deleted (SKYR-4026).
689
- //
690
- // Resolved from the BASE tree when possible (via a `.py`-only git-archive
691
- // extraction), not the current working tree — if a PR also renames the
692
- // mount prefix itself (not just the recovered route), head-state prefixes
693
- // would rewrite the recovered base endpoint onto the NEW prefix, which
694
- // could mask a real removal or misattribute the path. Falls back to the
695
- // current working tree if base-tree materialization isn't possible (e.g.
696
- // shallow clone, git/tar unavailable) — still strictly better than no
697
- // mount-prefix resolution at all.
698
- let mountPrefixesByFile;
699
- if (repositoryPath && routeFiles.some((f) => f.endsWith(".py"))) {
700
- const baseTreeDir = baseBranch ? materializePythonFilesFromRef(repositoryPath, baseBranch) : null;
701
- try {
702
- mountPrefixesByFile = resolvePythonMountPrefixes(baseTreeDir ?? repositoryPath);
703
- }
704
- finally {
705
- if (baseTreeDir) {
706
- try {
707
- fs.rmSync(baseTreeDir, { recursive: true, force: true });
708
- }
709
- catch {
710
- // best-effort cleanup
711
- }
712
- }
713
- }
714
- }
715
- for (const file of routeFiles) {
716
- let content;
717
- try {
718
- content = await fetchBaseContent(file);
719
- }
720
- catch (err) {
721
- // Expected for files that don't exist in the base tree: a path renamed in
722
- // this branch (the new name isn't in base), or a sparse/shallow checkout
723
- // where `git show base:<file>` can't resolve. We skip (best-effort
724
- // recovery — a single unreadable file shouldn't abort removal detection)
725
- // but warn so it's visible if recovery silently under-reports.
726
- logger.warning("Failed to fetch base-branch content for removal recovery", {
727
- file,
728
- error: err instanceof Error ? err.message : String(err),
729
- });
730
- continue;
731
- }
732
- for (const ep of parseFileEndpoints(content, file, mountPrefixesByFile?.get(file))) {
733
- // parseFileEndpoints already normalizes paths to a leading slash.
734
- const key = `${file}::${ep.path}`;
735
- const existing = endpointMap.get(key);
736
- if (existing) {
737
- existing.methods.add(ep.method);
738
- }
739
- else {
740
- endpointMap.set(key, {
741
- path: ep.path,
742
- methods: new Set([ep.method]),
743
- sourceFile: file,
744
- });
745
- }
746
- }
747
- }
748
- return [...endpointMap.values()].map((d) => ({
749
- path: d.path,
750
- methods: [...d.methods],
751
- sourceFile: d.sourceFile,
752
- }));
753
- }
754
- /** Strip git's `a/` / `b/` diff-path prefix and normalize separators. */
755
- export function normalizeDiffPath(spec) {
756
- let p = spec.replace(/\\/g, "/");
757
- if (p.startsWith("a/") || p.startsWith("b/"))
758
- p = p.slice(2);
759
- return p;
760
- }
761
- /**
762
- * Parse a unified diff into the set of new-side (post-change) line numbers that
763
- * were touched, keyed by normalized file path.
764
- *
765
- * Context and added (`+`) lines advance the new-side cursor; added lines are
766
- * recorded. Removed (`-`) lines don't advance the cursor but record the current
767
- * new-side position, so deletion-only hunks still map to a location (otherwise a
768
- * pure deletion would scope to nothing and fall back to whole-file matching).
769
- * Feeds hunk-aware scoping in `classifyEndpointsByChangedFiles` so a
770
- * change can be attributed to the specific route whose declaration owns those
771
- * lines — instead of flagging every route in a touched file (the failure mode
772
- * on centralized-routing SUTs that register the whole API in one hub file).
773
- */
774
- export function parseChangedLinesByFile(diffContent) {
775
- const result = new Map();
776
- if (!diffContent)
777
- return result;
778
- let currentFile = null;
779
- let newCursor = 0;
780
- // Walk the diff line-by-line without materializing a full array of every line
781
- // (`split("\n")`), which would transiently double the memory of an already
782
- // in-memory (and potentially large) diff. Only one line is held at a time.
783
- const len = diffContent.length;
784
- let pos = 0;
785
- while (pos <= len) {
786
- let nl = diffContent.indexOf("\n", pos);
787
- if (nl === -1)
788
- nl = len;
789
- const raw = diffContent.slice(pos, nl);
790
- pos = nl + 1;
791
- // New-file header: "+++ b/path" (checked before the generic "+" branch).
792
- if (raw.startsWith("+++ ")) {
793
- const spec = raw.slice(4).trim().split("\t")[0];
794
- currentFile = spec === "/dev/null" ? null : normalizeDiffPath(spec);
795
- continue;
796
- }
797
- if (raw.startsWith("--- ") || raw.startsWith("diff --git") || raw.startsWith("index ")) {
798
- continue;
799
- }
800
- const hunk = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
801
- if (hunk) {
802
- newCursor = parseInt(hunk[1], 10);
803
- continue;
804
- }
805
- if (currentFile === null)
806
- continue;
807
- if (raw.startsWith("+")) {
808
- const set = result.get(currentFile) ?? new Set();
809
- set.add(newCursor);
810
- result.set(currentFile, set);
811
- newCursor++;
812
- }
813
- else if (raw.startsWith("-")) {
814
- // Removed line — present only on the old side, so the new-side cursor does
815
- // not advance. Record the current new-side position so deletion-only hunks
816
- // (removing a route line or handler code) still scope to the owning route
817
- // instead of falling back to whole-file matching.
818
- const set = result.get(currentFile) ?? new Set();
819
- set.add(newCursor);
820
- result.set(currentFile, set);
821
- }
822
- else if (raw.startsWith("\\")) {
823
- // "" — not a real line.
824
- }
825
- else {
826
- // Context line (leading space, or a blank final line).
827
- newCursor++;
828
- }
829
- }
830
- return result;
831
- }
832
- /**
833
- * Scope a file's endpoints to the ones actually touched by the diff, using an
834
- * ownership-range model: sorted by declaration line, each endpoint owns
835
- * [line_i, line_{i+1}) (the last owns to EOF). An endpoint is "changed" iff a
836
- * changed line falls in its range — so a route-line edit maps to that one route
837
- * and a handler-body edit maps to its enclosing route.
838
- *
839
- * Falls back to the full set (file-level behavior) whenever scoping isn't safe:
840
- * no changed lines for the file, fewer than two endpoints, any endpoint missing
841
- * line info, or no changed line mapping to any route (e.g. an import edit above
842
- * the first route). Fallback keeps behavior identical to pre-scoping when
843
- * `diffContent` is empty.
844
- */
845
- function scopeEndpointsToHunks(eps, changedLines) {
846
- if (!changedLines || changedLines.size === 0)
847
- return eps;
848
- if (eps.length <= 1)
849
- return eps;
850
- if (!eps.every((e) => typeof e.line === "number"))
851
- return eps;
852
- const sorted = [...eps].sort((a, b) => a.line - b.line);
853
- const matched = new Set();
854
- for (let i = 0; i < sorted.length; i++) {
855
- const start = sorted[i].line;
856
- const end = i + 1 < sorted.length ? sorted[i + 1].line : Infinity;
857
- for (const ln of changedLines) {
858
- if (ln >= start && ln < end) {
859
- matched.add(sorted[i]);
860
- break;
861
- }
862
- }
863
- }
864
- if (matched.size === 0)
865
- return eps;
866
- return eps.filter((e) => matched.has(e));
867
- }
868
- export function classifyEndpointsByChangedFiles(diffData, scannedEndpoints, deletedFileEndpoints) {
869
- const newFileSet = new Set(diffData.newFiles.map(normalizeSourcePath));
870
- const deletedFileSet = new Set(diffData.deletedFiles.map(normalizeSourcePath));
871
- const changedLinesByFile = parseChangedLinesByFile(diffData.diffContent);
872
- const bySourceFile = new Map();
873
- for (const ep of scannedEndpoints) {
874
- const sourceKey = normalizeSourcePath(ep.sourceFile);
875
- const existing = bySourceFile.get(sourceKey);
876
- if (existing)
877
- existing.push(ep);
878
- else
879
- bySourceFile.set(sourceKey, [ep]);
880
- }
881
- const changedEndpoints = [];
882
- const newEndpoints = [];
883
- const unmatchedFiles = [];
884
- for (const file of diffData.changedFiles) {
885
- const fileKey = normalizeSourcePath(file);
886
- if (deletedFileSet.has(fileKey))
887
- continue;
888
- const eps = bySourceFile.get(fileKey);
889
- if (!eps || eps.length === 0) {
890
- unmatchedFiles.push(file);
891
- continue;
892
- }
893
- if (newFileSet.has(fileKey)) {
894
- newEndpoints.push(...eps);
895
- }
896
- else {
897
- // Scope to the routes the diff actually touched — critical on hub files
898
- // that register the whole API, where file-level matching would flag every
899
- // route as changed. Falls back to `eps` when scoping isn't safe.
900
- changedEndpoints.push(...scopeEndpointsToHunks(eps, changedLinesByFile.get(fileKey)));
901
- }
902
- }
903
- // Removed endpoints: from deleted files, recovered from the base branch.
904
- // Filter out endpoints that still exist in the current catalog as moved/refactored.
905
- // Same router-relative paths can exist in sibling files, so only treat them as moved
906
- // when the source resource also matches.
907
- const currentMethodKeys = new Set();
908
- const currentPathResourceKeys = new Set();
909
- const currentMultiKeys = new Set();
910
- for (const current of scannedEndpoints) {
911
- const currentResource = endpointSourceResource(current);
912
- const pathResourceKey = endpointMovedKey(current.path, currentResource);
913
- currentPathResourceKeys.add(pathResourceKey);
914
- for (const method of current.methods) {
915
- if (method === "MULTI")
916
- currentMultiKeys.add(pathResourceKey);
917
- currentMethodKeys.add(endpointMovedKey(current.path, currentResource, method));
918
- }
919
- }
920
- function isMovedEndpoint(deleted, method) {
921
- const deletedResource = endpointSourceResource(deleted);
922
- const pathResourceKey = endpointMovedKey(deleted.path, deletedResource);
923
- if (method === "MULTI")
924
- return currentPathResourceKeys.has(pathResourceKey);
925
- return currentMultiKeys.has(pathResourceKey) ||
926
- currentMethodKeys.has(endpointMovedKey(deleted.path, deletedResource, method));
927
- }
928
- const removedEndpoints = (deletedFileEndpoints ?? [])
929
- .map((ep) => ({
930
- ...ep,
931
- methods: ep.methods.filter((m) => !isMovedEndpoint(ep, m)),
932
- }))
933
- .filter((ep) => ep.methods.length > 0);
934
- // Affected services: same heuristic as before
935
- const servicePattern = /(?:services?|modules?|apps?)\/([a-z0-9_-]+)/i;
936
- const affectedServices = [
937
- ...new Set(diffData.changedFiles
938
- .map((f) => f.match(servicePattern)?.[1])
939
- .filter((s) => !!s)),
940
- ];
941
- return {
942
- changedEndpoints,
943
- newEndpoints,
944
- removedEndpoints,
945
- unmatchedFiles,
946
- changedFiles: diffData.changedFiles,
947
- currentBranch: diffData.currentBranch,
948
- baseBranch: diffData.baseBranch,
949
- affectedServices,
950
- };
951
- }