@skyramp/mcp 0.2.8 → 0.2.10-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/index.js +1 -8
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
- package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
- package/build/prompts/personas.d.ts +2 -2
- package/build/prompts/personas.js +2 -2
- package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +6 -1
- package/build/prompts/sut-setup/shared.d.ts +20 -0
- package/build/prompts/sut-setup/shared.js +67 -7
- package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +63 -3
- package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -25
- package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +20 -0
- package/build/prompts/test-maintenance/driftAnalysisShared.js +80 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +32 -0
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +212 -0
- package/build/prompts/test-recommendation/recommendationShared.js +1 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
- package/build/prompts/testbot/testbot-prompts.js +10 -9
- package/build/resources/testbotResource.js +1 -1
- package/build/services/TestDiscoveryService.d.ts +12 -10
- package/build/services/TestDiscoveryService.js +137 -83
- package/build/services/TestDiscoveryService.test.js +370 -15
- package/build/services/TestExecutionService.d.ts +1 -1
- package/build/services/TestExecutionService.js +7 -7
- package/build/services/TestExecutionService.test.js +4 -1
- package/build/tools/executeSkyrampTestTool.js +40 -71
- package/build/tools/submitReportTool.d.ts +25 -25
- package/build/tools/submitReportTool.js +123 -34
- package/build/tools/submitReportTool.test.js +413 -94
- package/build/tools/test-management/actionsTool.js +177 -77
- package/build/tools/test-management/actionsTool.test.d.ts +1 -0
- package/build/tools/test-management/actionsTool.test.js +297 -0
- package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
- package/build/tools/test-management/analyzeChangesTool.js +74 -169
- package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
- package/build/tools/test-management/analyzeTestHealthTool.js +63 -22
- package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
- package/build/tools/test-management/analyzeTestHealthTool.test.js +268 -0
- package/build/tools/workspace/initializeWorkspaceTool.js +37 -24
- package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
- package/build/types/FrontendIntegration.d.ts +25 -0
- package/build/types/FrontendIntegration.js +19 -0
- package/build/types/RepositoryAnalysis.d.ts +8 -8
- package/build/types/TestAnalysis.d.ts +18 -49
- package/build/types/TestAnalysis.js +0 -28
- package/build/types/TestTypes.d.ts +3 -1
- package/build/types/TestTypes.js +3 -1
- package/build/types/TestbotReport.d.ts +1 -1
- package/build/utils/AnalysisStateManager.d.ts +16 -22
- package/build/utils/docker.test.js +1 -1
- package/build/utils/featureFlags.d.ts +1 -1
- package/build/utils/featureFlags.js +1 -1
- package/build/utils/frontendIntegration.d.ts +9 -0
- package/build/utils/frontendIntegration.js +237 -0
- package/build/utils/frontendIntegration.test.d.ts +1 -0
- package/build/utils/frontendIntegration.test.js +229 -0
- package/build/utils/pr-comment-parser.d.ts +3 -3
- package/build/utils/pr-comment-parser.js +6 -6
- package/build/utils/pr-comment-parser.test.js +3 -3
- package/build/utils/repoScanner.d.ts +7 -0
- package/build/utils/repoScanner.js +14 -6
- package/build/utils/repoScanner.test.js +9 -9
- package/build/utils/routeParsers.d.ts +32 -0
- package/build/utils/routeParsers.js +205 -2
- package/build/utils/routeParsers.test.js +279 -29
- package/build/utils/sourceRouteExtractor.js +174 -5
- package/build/utils/sourceRouteExtractor.test.js +173 -0
- package/build/utils/utils.d.ts +11 -0
- package/build/utils/utils.js +19 -0
- package/build/utils/utils.test.js +23 -1
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/workspace/workspace.d.ts +21 -21
- package/build/workspace/workspace.js +7 -4
- package/build/workspace/workspace.test.js +65 -6
- package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
- package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
- package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
- package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.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/package.json +1 -1
- package/package.json +3 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PR Comment Parser — extracts
|
|
2
|
+
* PR Comment Parser — extracts Testbot history from GitHub PR comments.
|
|
3
3
|
*
|
|
4
|
-
* Parses previous Skyramp
|
|
4
|
+
* Parses previous Skyramp Testbot comments on a PR to build context
|
|
5
5
|
* about what has already been recommended, implemented, and tested.
|
|
6
6
|
* This prevents re-recommendations and enables contextual awareness
|
|
7
7
|
* across the PR lifecycle.
|
|
@@ -23,7 +23,7 @@ export interface PRTestContext {
|
|
|
23
23
|
}>;
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
26
|
-
* Fetch and parse PR comments to build
|
|
26
|
+
* Fetch and parse PR comments to build Testbot history context.
|
|
27
27
|
*
|
|
28
28
|
* Uses the `gh` CLI (available in CI and local dev environments with
|
|
29
29
|
* GitHub CLI installed). Falls back gracefully if `gh` is unavailable.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PR Comment Parser — extracts
|
|
2
|
+
* PR Comment Parser — extracts Testbot history from GitHub PR comments.
|
|
3
3
|
*
|
|
4
|
-
* Parses previous Skyramp
|
|
4
|
+
* Parses previous Skyramp Testbot comments on a PR to build context
|
|
5
5
|
* about what has already been recommended, implemented, and tested.
|
|
6
6
|
* This prevents re-recommendations and enables contextual awareness
|
|
7
7
|
* across the PR lifecycle.
|
|
@@ -16,7 +16,7 @@ const TESTBOT_MARKERS = [
|
|
|
16
16
|
const TEST_TYPE_PATTERN = /\b(Smoke|Contract|Integration|Fuzz|Load|E2E|UI)\b/gi;
|
|
17
17
|
const TEST_FILE_PATTERN = /[\w/.-]+(?:_test|_smoke|_contract|_fuzz|_integration|_load|_e2e|_ui)\.\w+/gi;
|
|
18
18
|
const STATUS_PATTERN = /\b(Pass|Fail|Skipped)\b/gi;
|
|
19
|
-
function
|
|
19
|
+
function isTestbotComment(comment) {
|
|
20
20
|
const hasMarker = TESTBOT_MARKERS.some((marker) => comment.body.includes(marker));
|
|
21
21
|
if (!hasMarker)
|
|
22
22
|
return false;
|
|
@@ -191,7 +191,7 @@ function extractImplementedFiles(body) {
|
|
|
191
191
|
return [...new Set(matches.map((m) => m[0]))];
|
|
192
192
|
}
|
|
193
193
|
/**
|
|
194
|
-
* Fetch and parse PR comments to build
|
|
194
|
+
* Fetch and parse PR comments to build Testbot history context.
|
|
195
195
|
*
|
|
196
196
|
* Uses the `gh` CLI (available in CI and local dev environments with
|
|
197
197
|
* GitHub CLI installed). Falls back gracefully if `gh` is unavailable.
|
|
@@ -228,11 +228,11 @@ export async function parsePRComments(repoOwner, repoName, prNumber, _token) {
|
|
|
228
228
|
logger.warning("Failed to parse PR comments JSON");
|
|
229
229
|
return empty;
|
|
230
230
|
}
|
|
231
|
-
const testBotComments = comments.filter((c) =>
|
|
231
|
+
const testBotComments = comments.filter((c) => isTestbotComment(c));
|
|
232
232
|
if (testBotComments.length === 0) {
|
|
233
233
|
return empty;
|
|
234
234
|
}
|
|
235
|
-
// Use only the latest
|
|
235
|
+
// Use only the latest Testbot comment — older comments represent stale
|
|
236
236
|
// state from previous bot runs. The latest comment has the most accurate
|
|
237
237
|
// picture of what was recommended, generated, and executed.
|
|
238
238
|
const latestComment = testBotComments[testBotComments.length - 1];
|
|
@@ -163,7 +163,7 @@ describe("parsePRComments — error handling", () => {
|
|
|
163
163
|
expect(ctx.implementedTestFiles).toEqual([]);
|
|
164
164
|
expect(ctx.executionResults).toEqual([]);
|
|
165
165
|
});
|
|
166
|
-
it("returns empty context when no
|
|
166
|
+
it("returns empty context when no Testbot comments exist", async () => {
|
|
167
167
|
mockedExecFileSync.mockReturnValue(ghResponse([
|
|
168
168
|
{ id: 1, body: "LGTM!", login: "reviewer" },
|
|
169
169
|
{ id: 2, body: "Please fix the typo", login: "reviewer" },
|
|
@@ -173,7 +173,7 @@ describe("parsePRComments — error handling", () => {
|
|
|
173
173
|
expect(ctx.implementedTestFiles).toEqual([]);
|
|
174
174
|
expect(ctx.executionResults).toEqual([]);
|
|
175
175
|
});
|
|
176
|
-
it("uses only the latest
|
|
176
|
+
it("uses only the latest Testbot comment when multiple exist", async () => {
|
|
177
177
|
const olderReport = progressWrapper(`### 📋 Business Case Analysis
|
|
178
178
|
Old report.
|
|
179
179
|
|
|
@@ -347,7 +347,7 @@ describe("edge cases", () => {
|
|
|
347
347
|
const count = ctx.implementedTestFiles.filter((f) => f === "test_items_integration.py").length;
|
|
348
348
|
expect(count).toBe(1);
|
|
349
349
|
});
|
|
350
|
-
it("handles empty
|
|
350
|
+
it("handles empty Testbot comment body", async () => {
|
|
351
351
|
mockedExecFileSync.mockReturnValue(ghResponse([{ id: 1, body: "Skyramp Testbot — empty run" }]));
|
|
352
352
|
const ctx = await parsePRComments("owner", "repo", 1);
|
|
353
353
|
expect(ctx.previousRecommendations).toEqual([]);
|
|
@@ -2,6 +2,13 @@ export interface ScannedEndpoint {
|
|
|
2
2
|
path: string;
|
|
3
3
|
methods: string[];
|
|
4
4
|
sourceFile: string;
|
|
5
|
+
/**
|
|
6
|
+
* 1-based line number of the route's declaration in `sourceFile`, when known.
|
|
7
|
+
* Used by hunk-aware diff scoping to attribute a changed line to the single
|
|
8
|
+
* route that owns it (rather than flagging every route in a touched file).
|
|
9
|
+
* Optional — absence disables scoping and falls back to file-level matching.
|
|
10
|
+
*/
|
|
11
|
+
line?: number;
|
|
5
12
|
}
|
|
6
13
|
/**
|
|
7
14
|
* Returns the relative paths of entry-point files that define the routing
|
|
@@ -101,16 +101,22 @@ export function grepRouterMountingContext(repositoryPath) {
|
|
|
101
101
|
}
|
|
102
102
|
return found;
|
|
103
103
|
}
|
|
104
|
-
function addEndpointToMap(endpointMap, apiPath, method, sourceFile, repositoryPath) {
|
|
104
|
+
function addEndpointToMap(endpointMap, apiPath, method, sourceFile, repositoryPath, line) {
|
|
105
105
|
const relative = sourceFile.startsWith(repositoryPath)
|
|
106
106
|
? sourceFile.slice(repositoryPath.length + 1) : sourceFile;
|
|
107
107
|
const normalizedPath = apiPath.startsWith("/") ? apiPath : `/${apiPath}`;
|
|
108
108
|
const key = `${relative}::${normalizedPath}`;
|
|
109
109
|
const existing = endpointMap.get(key);
|
|
110
|
-
if (existing)
|
|
110
|
+
if (existing) {
|
|
111
111
|
existing.methods.add(method);
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
// Keep the earliest declaration line so ownership-range scoping starts there.
|
|
113
|
+
if (typeof line === "number" && (existing.line === undefined || line < existing.line)) {
|
|
114
|
+
existing.line = line;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
endpointMap.set(key, { path: normalizedPath, methods: new Set([method]), sourceFile: relative, line });
|
|
119
|
+
}
|
|
114
120
|
}
|
|
115
121
|
function scanNextjsFile(file, repositoryPath, endpointMap) {
|
|
116
122
|
const relative = file.startsWith(repositoryPath)
|
|
@@ -239,13 +245,14 @@ export function scanAllRepoEndpoints(repositoryPath) {
|
|
|
239
245
|
if (content === null)
|
|
240
246
|
continue;
|
|
241
247
|
for (const ep of parseFileEndpoints(content, relative)) {
|
|
242
|
-
addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath);
|
|
248
|
+
addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath, ep.line);
|
|
243
249
|
}
|
|
244
250
|
}
|
|
245
251
|
return Array.from(endpointMap.values()).map((data) => ({
|
|
246
252
|
path: data.path,
|
|
247
253
|
methods: Array.from(data.methods),
|
|
248
254
|
sourceFile: data.sourceFile,
|
|
255
|
+
...(data.line !== undefined ? { line: data.line } : {}),
|
|
249
256
|
}));
|
|
250
257
|
}
|
|
251
258
|
export function scanRelatedEndpoints(repositoryPath, changedFiles) {
|
|
@@ -273,7 +280,7 @@ export function scanRelatedEndpoints(repositoryPath, changedFiles) {
|
|
|
273
280
|
if (fileContent === null)
|
|
274
281
|
continue;
|
|
275
282
|
for (const ep of parseFileEndpoints(fileContent, relative)) {
|
|
276
|
-
addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath);
|
|
283
|
+
addEndpointToMap(endpointMap, ep.path, ep.method, file, repositoryPath, ep.line);
|
|
277
284
|
}
|
|
278
285
|
}
|
|
279
286
|
}
|
|
@@ -281,5 +288,6 @@ export function scanRelatedEndpoints(repositoryPath, changedFiles) {
|
|
|
281
288
|
path: data.path,
|
|
282
289
|
methods: Array.from(data.methods),
|
|
283
290
|
sourceFile: data.sourceFile,
|
|
291
|
+
...(data.line !== undefined ? { line: data.line } : {}),
|
|
284
292
|
}));
|
|
285
293
|
}
|
|
@@ -42,14 +42,14 @@ async def bulk_delete_deployments(): pass
|
|
|
42
42
|
const endpoints = scanAllRepoEndpoints(repo);
|
|
43
43
|
const relatedEndpoints = scanRelatedEndpoints(repo, ["src/prefect/server/api/flows.py"]);
|
|
44
44
|
expect(endpoints).toEqual(expect.arrayContaining([
|
|
45
|
-
{ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
46
|
-
{ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
47
|
-
{ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/deployments.py" },
|
|
48
|
-
{ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/deployments.py" },
|
|
45
|
+
expect.objectContaining({ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/flows.py" }),
|
|
46
|
+
expect.objectContaining({ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/flows.py" }),
|
|
47
|
+
expect.objectContaining({ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/deployments.py" }),
|
|
48
|
+
expect.objectContaining({ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/deployments.py" }),
|
|
49
49
|
]));
|
|
50
50
|
expect(relatedEndpoints).toEqual(expect.arrayContaining([
|
|
51
|
-
{ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
52
|
-
{ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
51
|
+
expect.objectContaining({ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/flows.py" }),
|
|
52
|
+
expect.objectContaining({ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/flows.py" }),
|
|
53
53
|
]));
|
|
54
54
|
const classified = classifyEndpointsByChangedFiles({
|
|
55
55
|
currentBranch: "feature/admin-key",
|
|
@@ -61,11 +61,11 @@ async def bulk_delete_deployments(): pass
|
|
|
61
61
|
deletedFiles: [],
|
|
62
62
|
}, endpoints);
|
|
63
63
|
expect(classified.changedEndpoints).toEqual(expect.arrayContaining([
|
|
64
|
-
{ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
65
|
-
{ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/flows.py" },
|
|
64
|
+
expect.objectContaining({ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/flows.py" }),
|
|
65
|
+
expect.objectContaining({ path: "/bulk_delete", methods: ["POST"], sourceFile: "src/prefect/server/api/flows.py" }),
|
|
66
66
|
]));
|
|
67
67
|
expect(classified.changedEndpoints).not.toEqual(expect.arrayContaining([
|
|
68
|
-
{ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/deployments.py" },
|
|
68
|
+
expect.objectContaining({ path: "/{id:uuid}", methods: ["DELETE"], sourceFile: "src/prefect/server/api/deployments.py" }),
|
|
69
69
|
]));
|
|
70
70
|
});
|
|
71
71
|
it("does not suppress removed router-relative endpoints with same-path siblings", () => {
|
|
@@ -4,6 +4,8 @@ export interface ParsedDiffEndpoint {
|
|
|
4
4
|
method: string;
|
|
5
5
|
path: string;
|
|
6
6
|
sourceFile: string;
|
|
7
|
+
/** 1-based line where the route declaration starts, when parsed from full file content. */
|
|
8
|
+
line?: number;
|
|
7
9
|
}
|
|
8
10
|
export declare function nextjsFileToApiPath(filePath: string): string | null;
|
|
9
11
|
export declare const UI_FILE_EXTENSIONS: readonly ["tsx", "jsx", "vue", "svelte", "html", "xml"];
|
|
@@ -36,6 +38,36 @@ export declare const SKIP_PATH_SEGMENTS: Set<string>;
|
|
|
36
38
|
* parent prevents "/orders/{id}/items" and "/products/{id}/items" from colliding on "items".
|
|
37
39
|
*/
|
|
38
40
|
export declare function extractResourceFromPath(endpointPath: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Files that may contain endpoints removed in this branch: every changed file
|
|
43
|
+
* except newly-created ones. Both whole-file deletions AND line-edits that drop a
|
|
44
|
+
* route from a surviving file belong here — newly-created files cannot contain a
|
|
45
|
+
* route that existed in the base branch.
|
|
46
|
+
*/
|
|
47
|
+
export declare function selectRemovalCandidateFiles(diffData: BranchDiffData): string[];
|
|
48
|
+
/**
|
|
49
|
+
* Recover endpoints as they existed in the base branch, for the given candidate
|
|
50
|
+
* files. `fetchBaseContent` returns a file's base-branch content (e.g. via
|
|
51
|
+
* `git show base:<file>`); files that throw are skipped. Returns every endpoint
|
|
52
|
+
* found in the base versions — callers pass the result to
|
|
53
|
+
* `classifyEndpointsByChangedFiles`, whose moved/still-present filter drops any
|
|
54
|
+
* endpoint that survives in the current catalog, leaving only genuine removals.
|
|
55
|
+
*/
|
|
56
|
+
export declare function recoverRemovedEndpointsFromBase(candidateFiles: string[], fetchBaseContent: (file: string) => Promise<string>): Promise<ScannedEndpoint[]>;
|
|
57
|
+
/**
|
|
58
|
+
* Parse a unified diff into the set of new-side (post-change) line numbers that
|
|
59
|
+
* were touched, keyed by normalized file path.
|
|
60
|
+
*
|
|
61
|
+
* Context and added (`+`) lines advance the new-side cursor; added lines are
|
|
62
|
+
* recorded. Removed (`-`) lines don't advance the cursor but record the current
|
|
63
|
+
* new-side position, so deletion-only hunks still map to a location (otherwise a
|
|
64
|
+
* pure deletion would scope to nothing and fall back to whole-file matching).
|
|
65
|
+
* Feeds hunk-aware scoping in `classifyEndpointsByChangedFiles` so a
|
|
66
|
+
* change can be attributed to the specific route whose declaration owns those
|
|
67
|
+
* lines — instead of flagging every route in a touched file (the failure mode
|
|
68
|
+
* on centralized-routing SUTs that register the whole API in one hub file).
|
|
69
|
+
*/
|
|
70
|
+
export declare function parseChangedLinesByFile(diffContent: string): Map<string, Set<number>>;
|
|
39
71
|
export interface ClassifiedEndpoints {
|
|
40
72
|
/** Endpoints in scanned catalog whose sourceFile is in changedFiles (not new/deleted). */
|
|
41
73
|
changedEndpoints: ScannedEndpoint[];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
1
2
|
export function nextjsFileToApiPath(filePath) {
|
|
2
3
|
const pagesMatch = filePath.match(/(?:^|\/)pages\/(api\/.+)\.[jt]sx?$/);
|
|
3
4
|
if (pagesMatch) {
|
|
@@ -192,7 +193,10 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
192
193
|
let pendingRouterVar = ""; // variable name being accumulated in a multi-line declaration
|
|
193
194
|
let pendingPythonDecorator = null;
|
|
194
195
|
let pendingExpressMethod = ""; // HTTP method from a multi-line Express route (e.g. "POST" from `router.post(\n`)
|
|
196
|
+
let pendingExpressLine = 0; // line where the multi-line Express route started
|
|
197
|
+
let lineNo = 0; // 1-based; incremented for every line (including skipped) to keep numbering accurate
|
|
195
198
|
for (const line of content.split("\n")) {
|
|
199
|
+
lineNo++;
|
|
196
200
|
const trimmed = line.trim();
|
|
197
201
|
if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("*"))
|
|
198
202
|
continue;
|
|
@@ -272,6 +276,7 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
272
276
|
method: pendingPythonDecorator.method,
|
|
273
277
|
path,
|
|
274
278
|
sourceFile,
|
|
279
|
+
line: pendingPythonDecorator.startLine,
|
|
275
280
|
});
|
|
276
281
|
pendingPythonDecorator = null;
|
|
277
282
|
continue;
|
|
@@ -302,6 +307,7 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
302
307
|
method: pendingExpressMethod,
|
|
303
308
|
path: pathMatch[1],
|
|
304
309
|
sourceFile,
|
|
310
|
+
line: pendingExpressLine,
|
|
305
311
|
});
|
|
306
312
|
}
|
|
307
313
|
pendingExpressMethod = "";
|
|
@@ -340,6 +346,7 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
340
346
|
pendingPythonDecorator = {
|
|
341
347
|
routerVar: pythonDecoratorMultiLine[1],
|
|
342
348
|
method: pythonDecoratorMultiLine[2].toUpperCase(),
|
|
349
|
+
startLine: lineNo,
|
|
343
350
|
};
|
|
344
351
|
continue;
|
|
345
352
|
}
|
|
@@ -348,6 +355,7 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
348
355
|
const expressMultiLine = trimmed.match(/(?:router|app)\.(get|post|put|patch|delete)\s*\(\s*$/i);
|
|
349
356
|
if (expressMultiLine && !UI_COMPONENT_EXT.test(sourceFile)) {
|
|
350
357
|
pendingExpressMethod = expressMultiLine[1].toUpperCase();
|
|
358
|
+
pendingExpressLine = lineNo;
|
|
351
359
|
}
|
|
352
360
|
continue;
|
|
353
361
|
}
|
|
@@ -376,9 +384,16 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
376
384
|
}
|
|
377
385
|
}
|
|
378
386
|
}
|
|
387
|
+
ep.line = lineNo;
|
|
379
388
|
results.push(ep);
|
|
380
389
|
}
|
|
381
|
-
|
|
390
|
+
// Normalize to a leading-slash path here (single responsibility) so callers
|
|
391
|
+
// don't each re-normalize. Empty paths (e.g. NestJS no-path decorators) are
|
|
392
|
+
// left as-is.
|
|
393
|
+
return results.map((ep) => ({
|
|
394
|
+
...ep,
|
|
395
|
+
path: ep.path && !ep.path.startsWith("/") ? `/${ep.path}` : ep.path,
|
|
396
|
+
}));
|
|
382
397
|
}
|
|
383
398
|
export const SKIP_PATH_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
|
|
384
399
|
/**
|
|
@@ -433,9 +448,194 @@ function endpointSourceResource(ep) {
|
|
|
433
448
|
function endpointMovedKey(path, resource, method) {
|
|
434
449
|
return method ? `${path}::${resource}::${method}` : `${path}::${resource}`;
|
|
435
450
|
}
|
|
451
|
+
// Route-file heuristics: keep base-branch I/O bounded to files that plausibly
|
|
452
|
+
// declare endpoints. Mirrors the previous deleted-file recovery filter.
|
|
453
|
+
const ROUTE_FILE_PATTERN = /route|controller|endpoint|handler|view|urls|api|router|service|gateway|resolver|\bserver\b/i;
|
|
454
|
+
const ROUTE_FILE_BASENAME_PATTERN = /\bapp\b|\bmain\b/i;
|
|
455
|
+
const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
|
|
456
|
+
function isRouteLikeFile(filePath) {
|
|
457
|
+
const base = normalizeSourcePath(filePath).split("/").pop() ?? "";
|
|
458
|
+
return SOURCE_EXTS.test(filePath) &&
|
|
459
|
+
(ROUTE_FILE_PATTERN.test(filePath) || ROUTE_FILE_BASENAME_PATTERN.test(base));
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Files that may contain endpoints removed in this branch: every changed file
|
|
463
|
+
* except newly-created ones. Both whole-file deletions AND line-edits that drop a
|
|
464
|
+
* route from a surviving file belong here — newly-created files cannot contain a
|
|
465
|
+
* route that existed in the base branch.
|
|
466
|
+
*/
|
|
467
|
+
export function selectRemovalCandidateFiles(diffData) {
|
|
468
|
+
const newFileSet = new Set(diffData.newFiles.map(normalizeSourcePath));
|
|
469
|
+
return diffData.changedFiles.filter((f) => !newFileSet.has(normalizeSourcePath(f)));
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Recover endpoints as they existed in the base branch, for the given candidate
|
|
473
|
+
* files. `fetchBaseContent` returns a file's base-branch content (e.g. via
|
|
474
|
+
* `git show base:<file>`); files that throw are skipped. Returns every endpoint
|
|
475
|
+
* found in the base versions — callers pass the result to
|
|
476
|
+
* `classifyEndpointsByChangedFiles`, whose moved/still-present filter drops any
|
|
477
|
+
* endpoint that survives in the current catalog, leaving only genuine removals.
|
|
478
|
+
*/
|
|
479
|
+
export async function recoverRemovedEndpointsFromBase(candidateFiles, fetchBaseContent) {
|
|
480
|
+
const routeFiles = candidateFiles.filter(isRouteLikeFile);
|
|
481
|
+
const endpointMap = new Map();
|
|
482
|
+
for (const file of routeFiles) {
|
|
483
|
+
let content;
|
|
484
|
+
try {
|
|
485
|
+
content = await fetchBaseContent(file);
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
// Expected for files that don't exist in the base tree: a path renamed in
|
|
489
|
+
// this branch (the new name isn't in base), or a sparse/shallow checkout
|
|
490
|
+
// where `git show base:<file>` can't resolve. We skip (best-effort
|
|
491
|
+
// recovery — a single unreadable file shouldn't abort removal detection)
|
|
492
|
+
// but warn so it's visible if recovery silently under-reports.
|
|
493
|
+
logger.warning("Failed to fetch base-branch content for removal recovery", {
|
|
494
|
+
file,
|
|
495
|
+
error: err instanceof Error ? err.message : String(err),
|
|
496
|
+
});
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
for (const ep of parseFileEndpoints(content, file)) {
|
|
500
|
+
// parseFileEndpoints already normalizes paths to a leading slash.
|
|
501
|
+
const key = `${file}::${ep.path}`;
|
|
502
|
+
const existing = endpointMap.get(key);
|
|
503
|
+
if (existing) {
|
|
504
|
+
existing.methods.add(ep.method);
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
endpointMap.set(key, {
|
|
508
|
+
path: ep.path,
|
|
509
|
+
methods: new Set([ep.method]),
|
|
510
|
+
sourceFile: file,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return [...endpointMap.values()].map((d) => ({
|
|
516
|
+
path: d.path,
|
|
517
|
+
methods: [...d.methods],
|
|
518
|
+
sourceFile: d.sourceFile,
|
|
519
|
+
}));
|
|
520
|
+
}
|
|
521
|
+
/** Strip git's `a/` / `b/` diff-path prefix and normalize separators. */
|
|
522
|
+
function normalizeDiffPath(spec) {
|
|
523
|
+
let p = spec.replace(/\\/g, "/");
|
|
524
|
+
if (p.startsWith("a/") || p.startsWith("b/"))
|
|
525
|
+
p = p.slice(2);
|
|
526
|
+
return p;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Parse a unified diff into the set of new-side (post-change) line numbers that
|
|
530
|
+
* were touched, keyed by normalized file path.
|
|
531
|
+
*
|
|
532
|
+
* Context and added (`+`) lines advance the new-side cursor; added lines are
|
|
533
|
+
* recorded. Removed (`-`) lines don't advance the cursor but record the current
|
|
534
|
+
* new-side position, so deletion-only hunks still map to a location (otherwise a
|
|
535
|
+
* pure deletion would scope to nothing and fall back to whole-file matching).
|
|
536
|
+
* Feeds hunk-aware scoping in `classifyEndpointsByChangedFiles` so a
|
|
537
|
+
* change can be attributed to the specific route whose declaration owns those
|
|
538
|
+
* lines — instead of flagging every route in a touched file (the failure mode
|
|
539
|
+
* on centralized-routing SUTs that register the whole API in one hub file).
|
|
540
|
+
*/
|
|
541
|
+
export function parseChangedLinesByFile(diffContent) {
|
|
542
|
+
const result = new Map();
|
|
543
|
+
if (!diffContent)
|
|
544
|
+
return result;
|
|
545
|
+
let currentFile = null;
|
|
546
|
+
let newCursor = 0;
|
|
547
|
+
// Walk the diff line-by-line without materializing a full array of every line
|
|
548
|
+
// (`split("\n")`), which would transiently double the memory of an already
|
|
549
|
+
// in-memory (and potentially large) diff. Only one line is held at a time.
|
|
550
|
+
const len = diffContent.length;
|
|
551
|
+
let pos = 0;
|
|
552
|
+
while (pos <= len) {
|
|
553
|
+
let nl = diffContent.indexOf("\n", pos);
|
|
554
|
+
if (nl === -1)
|
|
555
|
+
nl = len;
|
|
556
|
+
const raw = diffContent.slice(pos, nl);
|
|
557
|
+
pos = nl + 1;
|
|
558
|
+
// New-file header: "+++ b/path" (checked before the generic "+" branch).
|
|
559
|
+
if (raw.startsWith("+++ ")) {
|
|
560
|
+
const spec = raw.slice(4).trim().split("\t")[0];
|
|
561
|
+
currentFile = spec === "/dev/null" ? null : normalizeDiffPath(spec);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (raw.startsWith("--- ") || raw.startsWith("diff --git") || raw.startsWith("index ")) {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const hunk = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
568
|
+
if (hunk) {
|
|
569
|
+
newCursor = parseInt(hunk[1], 10);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (currentFile === null)
|
|
573
|
+
continue;
|
|
574
|
+
if (raw.startsWith("+")) {
|
|
575
|
+
const set = result.get(currentFile) ?? new Set();
|
|
576
|
+
set.add(newCursor);
|
|
577
|
+
result.set(currentFile, set);
|
|
578
|
+
newCursor++;
|
|
579
|
+
}
|
|
580
|
+
else if (raw.startsWith("-")) {
|
|
581
|
+
// Removed line — present only on the old side, so the new-side cursor does
|
|
582
|
+
// not advance. Record the current new-side position so deletion-only hunks
|
|
583
|
+
// (removing a route line or handler code) still scope to the owning route
|
|
584
|
+
// instead of falling back to whole-file matching.
|
|
585
|
+
const set = result.get(currentFile) ?? new Set();
|
|
586
|
+
set.add(newCursor);
|
|
587
|
+
result.set(currentFile, set);
|
|
588
|
+
}
|
|
589
|
+
else if (raw.startsWith("\\")) {
|
|
590
|
+
// "" — not a real line.
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
// Context line (leading space, or a blank final line).
|
|
594
|
+
newCursor++;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return result;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Scope a file's endpoints to the ones actually touched by the diff, using an
|
|
601
|
+
* ownership-range model: sorted by declaration line, each endpoint owns
|
|
602
|
+
* [line_i, line_{i+1}) (the last owns to EOF). An endpoint is "changed" iff a
|
|
603
|
+
* changed line falls in its range — so a route-line edit maps to that one route
|
|
604
|
+
* and a handler-body edit maps to its enclosing route.
|
|
605
|
+
*
|
|
606
|
+
* Falls back to the full set (file-level behavior) whenever scoping isn't safe:
|
|
607
|
+
* no changed lines for the file, fewer than two endpoints, any endpoint missing
|
|
608
|
+
* line info, or no changed line mapping to any route (e.g. an import edit above
|
|
609
|
+
* the first route). Fallback keeps behavior identical to pre-scoping when
|
|
610
|
+
* `diffContent` is empty.
|
|
611
|
+
*/
|
|
612
|
+
function scopeEndpointsToHunks(eps, changedLines) {
|
|
613
|
+
if (!changedLines || changedLines.size === 0)
|
|
614
|
+
return eps;
|
|
615
|
+
if (eps.length <= 1)
|
|
616
|
+
return eps;
|
|
617
|
+
if (!eps.every((e) => typeof e.line === "number"))
|
|
618
|
+
return eps;
|
|
619
|
+
const sorted = [...eps].sort((a, b) => a.line - b.line);
|
|
620
|
+
const matched = new Set();
|
|
621
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
622
|
+
const start = sorted[i].line;
|
|
623
|
+
const end = i + 1 < sorted.length ? sorted[i + 1].line : Infinity;
|
|
624
|
+
for (const ln of changedLines) {
|
|
625
|
+
if (ln >= start && ln < end) {
|
|
626
|
+
matched.add(sorted[i]);
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (matched.size === 0)
|
|
632
|
+
return eps;
|
|
633
|
+
return eps.filter((e) => matched.has(e));
|
|
634
|
+
}
|
|
436
635
|
export function classifyEndpointsByChangedFiles(diffData, scannedEndpoints, deletedFileEndpoints) {
|
|
437
636
|
const newFileSet = new Set(diffData.newFiles.map(normalizeSourcePath));
|
|
438
637
|
const deletedFileSet = new Set(diffData.deletedFiles.map(normalizeSourcePath));
|
|
638
|
+
const changedLinesByFile = parseChangedLinesByFile(diffData.diffContent);
|
|
439
639
|
const bySourceFile = new Map();
|
|
440
640
|
for (const ep of scannedEndpoints) {
|
|
441
641
|
const sourceKey = normalizeSourcePath(ep.sourceFile);
|
|
@@ -461,7 +661,10 @@ export function classifyEndpointsByChangedFiles(diffData, scannedEndpoints, dele
|
|
|
461
661
|
newEndpoints.push(...eps);
|
|
462
662
|
}
|
|
463
663
|
else {
|
|
464
|
-
|
|
664
|
+
// Scope to the routes the diff actually touched — critical on hub files
|
|
665
|
+
// that register the whole API, where file-level matching would flag every
|
|
666
|
+
// route as changed. Falls back to `eps` when scoping isn't safe.
|
|
667
|
+
changedEndpoints.push(...scopeEndpointsToHunks(eps, changedLinesByFile.get(fileKey)));
|
|
465
668
|
}
|
|
466
669
|
}
|
|
467
670
|
// Removed endpoints: from deleted files, recovered from the base branch.
|