@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.
- package/build/commands/commandLibrary.d.ts +1 -1
- package/build/commands/commandLibrary.js +3 -3
- package/build/commands/recommendTestsAndExecuteCommand.d.ts +1 -1
- package/build/commands/recommendTestsAndExecuteCommand.js +35 -20
- package/build/commands/testThisEndpointCommand.js +35 -19
- package/build/index.js +9 -3
- package/build/playwright/blueprintDigest.d.ts +15 -0
- package/build/playwright/blueprintDigest.js +152 -0
- package/build/playwright/blueprintDigestStore.d.ts +31 -0
- package/build/playwright/blueprintDigestStore.js +117 -0
- package/build/playwright/registerPlaywrightTools.js +60 -12
- package/build/playwright/traceRecordingPrompt.js +8 -7
- package/build/prompts/enhance-assertions/sharedAssertionRules.js +9 -8
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -2
- package/build/prompts/promptAssets.d.ts +20 -0
- package/build/prompts/promptAssets.js +55 -0
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +19 -5
- package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
- package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +0 -10
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -11
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +8 -4
- package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +5 -22
- package/build/prompts/test-recommendation/diffExecutionPlan.js +37 -465
- package/build/prompts/test-recommendation/recommendationSections.d.ts +7 -17
- package/build/prompts/test-recommendation/recommendationSections.js +67 -309
- package/build/prompts/test-recommendation/recommendationShared.d.ts +19 -47
- package/build/prompts/test-recommendation/recommendationShared.js +49 -155
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.d.ts +0 -5
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +10 -153
- package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -29
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +32 -457
- package/build/prompts/testbot/planDeclarations.d.ts +6 -0
- package/build/prompts/testbot/planDeclarations.js +9 -0
- package/build/prompts/testbot/testbot-prompts.d.ts +8 -0
- package/build/prompts/testbot/testbot-prompts.js +256 -381
- package/build/recommendation/answers.d.ts +35 -0
- package/build/recommendation/answers.js +96 -0
- package/build/recommendation/registerPlan.d.ts +49 -0
- package/build/recommendation/registerPlan.js +117 -0
- package/build/recommendation/runVerifiers.d.ts +10 -0
- package/build/recommendation/runVerifiers.js +49 -0
- package/build/recommendation/subjectStep.d.ts +42 -0
- package/build/recommendation/subjectStep.js +86 -0
- package/build/recommendation/types.d.ts +163 -0
- package/build/recommendation/types.js +20 -0
- package/build/recommendation/verifierContracts.d.ts +382 -0
- package/build/recommendation/verifierContracts.js +263 -0
- package/build/recommendation/verifiers/changedFile.d.ts +2 -0
- package/build/recommendation/verifiers/changedFile.js +82 -0
- package/build/recommendation/verifiers/citedPath.d.ts +12 -0
- package/build/recommendation/verifiers/citedPath.js +35 -0
- package/build/recommendation/verifiers/coverage.d.ts +7 -0
- package/build/recommendation/verifiers/coverage.js +617 -0
- package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +11 -0
- package/build/recommendation/verifiers/deliveredMatchesPlan.js +33 -0
- package/build/recommendation/verifiers/endpointGrounded.d.ts +17 -0
- package/build/recommendation/verifiers/endpointGrounded.js +128 -0
- package/build/recommendation/verifiers/existingCoverage.d.ts +6 -0
- package/build/recommendation/verifiers/existingCoverage.js +51 -0
- package/build/recommendation/verifiers/expectedOutcome.d.ts +31 -0
- package/build/recommendation/verifiers/expectedOutcome.js +105 -0
- package/build/recommendation/verifiers/removedElementGuarded.d.ts +2 -0
- package/build/recommendation/verifiers/removedElementGuarded.js +57 -0
- package/build/recommendation/verifiers/reportedCategory.d.ts +26 -0
- package/build/recommendation/verifiers/reportedCategory.js +84 -0
- package/build/recommendation/verifiers/screenRoute.d.ts +10 -0
- package/build/recommendation/verifiers/screenRoute.js +118 -0
- package/build/recommendation/verifiers/statedDifference.d.ts +6 -0
- package/build/recommendation/verifiers/statedDifference.js +140 -0
- package/build/recommendation/verifiers/uiElementGrounded.d.ts +7 -0
- package/build/recommendation/verifiers/uiElementGrounded.js +318 -0
- package/build/resources/analysisResources.js +1 -114
- package/build/resources/testbotResource.js +23 -13
- package/build/services/ModularizationService.js +2 -1
- package/build/services/TestDiscoveryService.d.ts +3 -72
- package/build/services/TestDiscoveryService.js +10 -303
- package/build/services/containerEnv.d.ts +1 -1
- package/build/services/containerEnv.js +12 -0
- package/build/skills/fixTestImportErrorsSkill.d.ts +13 -0
- package/build/skills/fixTestImportErrorsSkill.js +20 -0
- package/build/toolNames.d.ts +1 -0
- package/build/toolNames.js +1 -0
- package/build/tools/code-refactor/enhanceAssertionsTool.js +3 -3
- package/build/tools/code-refactor/modularizationTool.js +2 -1
- package/build/tools/executeSkyrampTestTool.d.ts +80 -0
- package/build/tools/executeSkyrampTestTool.js +246 -19
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +6 -0
- package/build/tools/generate-tests/generateContractRestTool.js +3 -3
- package/build/tools/generate-tests/planGuard.d.ts +2 -2
- package/build/tools/generate-tests/planGuard.js +78 -18
- package/build/tools/one-click/oneClickTool.d.ts +0 -1
- package/build/tools/one-click/oneClickTool.js +0 -5
- package/build/tools/submitReportTool.d.ts +48 -42
- package/build/tools/submitReportTool.js +576 -193
- package/build/tools/test-management/actionsTool.js +72 -4
- package/build/tools/test-management/analyzeChangesTool.d.ts +144 -48
- package/build/tools/test-management/analyzeChangesTool.js +212 -1219
- package/build/tools/test-management/analyzeTestHealthTool.js +13 -24
- package/build/tools/test-management/index.d.ts +1 -0
- package/build/tools/test-management/index.js +1 -0
- package/build/tools/test-management/registerTestPlanTool.d.ts +795 -172
- package/build/tools/test-management/registerTestPlanTool.js +609 -542
- package/build/tools/test-management/resolveScreenTool.d.ts +75 -0
- package/build/tools/test-management/resolveScreenTool.js +289 -0
- package/build/types/BlueprintDigest.d.ts +34 -0
- package/build/types/BlueprintDigest.js +1 -0
- package/build/types/RepositoryAnalysis.d.ts +20 -1559
- package/build/types/RepositoryAnalysis.js +2 -58
- package/build/types/StepMethod.d.ts +40 -0
- package/build/types/StepMethod.js +77 -0
- package/build/types/TestAnalysis.d.ts +12 -0
- package/build/types/TestExecution.d.ts +4 -0
- package/build/types/TestRecommendation.d.ts +24 -24
- package/build/types/TestRecommendation.js +91 -89
- package/build/types/TestbotPromptOptions.d.ts +0 -4
- package/build/types/TestbotReport.d.ts +64 -2
- package/build/utils/AnalysisStateManager.d.ts +79 -113
- package/build/utils/AnalysisStateManager.js +147 -57
- package/build/utils/assertion-verify/api-shared-lints.js +1 -1
- package/build/utils/assertion-verify/metrics.js +85 -36
- package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
- package/build/utils/assertion-verify/ui-lints.js +32 -0
- package/build/utils/branchDiff.d.ts +63 -31
- package/build/utils/branchDiff.js +242 -94
- package/build/utils/containedPath.d.ts +18 -0
- package/build/utils/containedPath.js +73 -0
- package/build/utils/dartRouteExtractor.d.ts +18 -34
- package/build/utils/dartRouteExtractor.js +101 -173
- package/build/utils/featureFlags.d.ts +12 -0
- package/build/utils/featureFlags.js +14 -0
- package/build/utils/frontendSelectors.d.ts +48 -27
- package/build/utils/frontendSelectors.js +241 -80
- package/build/utils/pathMatching.d.ts +2 -4
- package/build/utils/pathMatching.js +2 -4
- package/build/utils/planMatchKeys.d.ts +38 -47
- package/build/utils/planMatchKeys.js +143 -81
- package/build/utils/rebaselineSnapshots.d.ts +24 -0
- package/build/utils/rebaselineSnapshots.js +65 -0
- package/build/utils/removedUiElements.d.ts +22 -0
- package/build/utils/removedUiElements.js +106 -0
- package/build/utils/reportVerification.d.ts +2 -6
- package/build/utils/reportVerification.js +61 -2
- package/build/utils/screenRoutes.d.ts +66 -0
- package/build/utils/screenRoutes.js +727 -0
- package/build/utils/sourceRouteExtractor.js +320 -112
- package/build/utils/testFileClassification.d.ts +11 -2
- package/build/utils/testFileClassification.js +44 -2
- package/build/utils/testFixtures.d.ts +5 -0
- package/build/utils/testFixtures.js +13 -0
- package/build/utils/utils.d.ts +0 -1
- package/build/utils/utils.js +0 -11
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/workspace/workspace.d.ts +12 -12
- package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
- package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
- package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
- package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
- package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +4 -3
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/plugin.json +6 -0
- package/plugin/prompts/declaring-a-plan.md +20 -0
- package/plugin/prompts/generate-tests/context-fetching.md +4 -0
- package/plugin/prompts/generate-tests/execution-plan.md +63 -0
- package/plugin/prompts/generate-tests/generation.md +108 -0
- package/plugin/prompts/generate-tests/path-parameters.md +1 -0
- package/plugin/prompts/generate-tests/reasoning-protocol.md +17 -0
- package/plugin/prompts/generate-tests/tool-workflow-variants.md +61 -0
- package/plugin/prompts/generate-tests/tool-workflows.md +65 -0
- package/plugin/prompts/plan-tests.md +42 -0
- package/plugin/prompts/testbot-task1.md +82 -0
- package/plugin/skills/fix-test-import-errors/SKILL.md +98 -0
- package/build/prompts/test-recommendation/analysisOutputPrompt.d.ts +0 -84
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +0 -369
- package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +0 -7
- package/build/prompts/test-recommendation/fullRepoCatalog.js +0 -283
- package/build/prompts/test-recommendation/scopeAssessment.d.ts +0 -81
- package/build/prompts/test-recommendation/scopeAssessment.js +0 -359
- package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +0 -7
- package/build/recommendation/budgeters/diversityBalancedBudgeter.js +0 -105
- package/build/recommendation/budgeters/fixedNBudgeter.d.ts +0 -7
- package/build/recommendation/budgeters/fixedNBudgeter.js +0 -11
- package/build/recommendation/budgeters/shared.d.ts +0 -32
- package/build/recommendation/budgeters/shared.js +0 -246
- package/build/recommendation/discriminators.d.ts +0 -37
- package/build/recommendation/discriminators.js +0 -379
- package/build/recommendation/diversity.d.ts +0 -47
- package/build/recommendation/diversity.js +0 -101
- package/build/recommendation/planRanker.d.ts +0 -65
- package/build/recommendation/planRanker.js +0 -83
- package/build/recommendation/testFixtures.d.ts +0 -25
- package/build/recommendation/testFixtures.js +0 -45
- package/build/types/FrontendIntegration.d.ts +0 -28
- package/build/types/FrontendIntegration.js +0 -22
- package/build/types/Recommendation.d.ts +0 -146
- package/build/types/Recommendation.js +0 -74
- package/build/utils/changedRoutes.d.ts +0 -29
- package/build/utils/changedRoutes.js +0 -87
- package/build/utils/frontendIntegration.d.ts +0 -9
- package/build/utils/frontendIntegration.js +0 -243
- package/build/utils/importerHop.d.ts +0 -135
- package/build/utils/importerHop.js +0 -489
- package/build/utils/pathAffinityClassification.d.ts +0 -49
- package/build/utils/pathAffinityClassification.js +0 -180
- package/build/utils/pythonMountPrefixes.d.ts +0 -25
- package/build/utils/pythonMountPrefixes.js +0 -347
- package/build/utils/repoScanner.d.ts +0 -34
- package/build/utils/repoScanner.js +0 -300
- package/build/utils/routeParsers.d.ts +0 -95
- package/build/utils/routeParsers.js +0 -951
- package/build/utils/scenarioDrafting.d.ts +0 -92
- package/build/utils/scenarioDrafting.js +0 -951
- package/build/utils/subjectEndpoints.d.ts +0 -19
- package/build/utils/subjectEndpoints.js +0 -98
- package/build/utils/uiPageEnumerator.d.ts +0 -172
- 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
|
-
}
|