@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.
Files changed (88) hide show
  1. package/build/index.js +1 -8
  2. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
  3. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
  4. package/build/prompts/personas.d.ts +2 -2
  5. package/build/prompts/personas.js +2 -2
  6. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
  7. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +6 -1
  8. package/build/prompts/sut-setup/shared.d.ts +20 -0
  9. package/build/prompts/sut-setup/shared.js +67 -7
  10. package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
  11. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
  12. package/build/prompts/test-maintenance/drift-analysis-prompt.js +63 -3
  13. package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -25
  14. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +20 -0
  15. package/build/prompts/test-maintenance/driftAnalysisShared.js +80 -0
  16. package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +32 -0
  17. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +212 -0
  18. package/build/prompts/test-recommendation/recommendationShared.js +1 -1
  19. package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
  20. package/build/prompts/testbot/testbot-prompts.js +10 -9
  21. package/build/resources/testbotResource.js +1 -1
  22. package/build/services/TestDiscoveryService.d.ts +12 -10
  23. package/build/services/TestDiscoveryService.js +137 -83
  24. package/build/services/TestDiscoveryService.test.js +370 -15
  25. package/build/services/TestExecutionService.d.ts +1 -1
  26. package/build/services/TestExecutionService.js +7 -7
  27. package/build/services/TestExecutionService.test.js +4 -1
  28. package/build/tools/executeSkyrampTestTool.js +40 -71
  29. package/build/tools/submitReportTool.d.ts +25 -25
  30. package/build/tools/submitReportTool.js +123 -34
  31. package/build/tools/submitReportTool.test.js +413 -94
  32. package/build/tools/test-management/actionsTool.js +177 -77
  33. package/build/tools/test-management/actionsTool.test.d.ts +1 -0
  34. package/build/tools/test-management/actionsTool.test.js +297 -0
  35. package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
  36. package/build/tools/test-management/analyzeChangesTool.js +74 -169
  37. package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
  38. package/build/tools/test-management/analyzeTestHealthTool.js +63 -22
  39. package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
  40. package/build/tools/test-management/analyzeTestHealthTool.test.js +268 -0
  41. package/build/tools/workspace/initializeWorkspaceTool.js +37 -24
  42. package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
  43. package/build/types/FrontendIntegration.d.ts +25 -0
  44. package/build/types/FrontendIntegration.js +19 -0
  45. package/build/types/RepositoryAnalysis.d.ts +8 -8
  46. package/build/types/TestAnalysis.d.ts +18 -49
  47. package/build/types/TestAnalysis.js +0 -28
  48. package/build/types/TestTypes.d.ts +3 -1
  49. package/build/types/TestTypes.js +3 -1
  50. package/build/types/TestbotReport.d.ts +1 -1
  51. package/build/utils/AnalysisStateManager.d.ts +16 -22
  52. package/build/utils/docker.test.js +1 -1
  53. package/build/utils/featureFlags.d.ts +1 -1
  54. package/build/utils/featureFlags.js +1 -1
  55. package/build/utils/frontendIntegration.d.ts +9 -0
  56. package/build/utils/frontendIntegration.js +237 -0
  57. package/build/utils/frontendIntegration.test.d.ts +1 -0
  58. package/build/utils/frontendIntegration.test.js +229 -0
  59. package/build/utils/pr-comment-parser.d.ts +3 -3
  60. package/build/utils/pr-comment-parser.js +6 -6
  61. package/build/utils/pr-comment-parser.test.js +3 -3
  62. package/build/utils/repoScanner.d.ts +7 -0
  63. package/build/utils/repoScanner.js +14 -6
  64. package/build/utils/repoScanner.test.js +9 -9
  65. package/build/utils/routeParsers.d.ts +32 -0
  66. package/build/utils/routeParsers.js +205 -2
  67. package/build/utils/routeParsers.test.js +279 -29
  68. package/build/utils/sourceRouteExtractor.js +174 -5
  69. package/build/utils/sourceRouteExtractor.test.js +173 -0
  70. package/build/utils/utils.d.ts +11 -0
  71. package/build/utils/utils.js +19 -0
  72. package/build/utils/utils.test.js +23 -1
  73. package/build/utils/versions.d.ts +3 -3
  74. package/build/utils/versions.js +1 -1
  75. package/build/workspace/workspace.d.ts +21 -21
  76. package/build/workspace/workspace.js +7 -4
  77. package/build/workspace/workspace.test.js +65 -6
  78. package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
  79. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  80. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
  81. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
  82. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
  83. package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
  84. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
  85. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  86. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  87. package/node_modules/playwright/package.json +1 -1
  88. package/package.json +3 -3
@@ -1,7 +1,7 @@
1
1
  /**
2
- * PR Comment Parser — extracts TestBot history from GitHub PR comments.
2
+ * PR Comment Parser — extracts Testbot history from GitHub PR comments.
3
3
  *
4
- * Parses previous Skyramp TestBot comments on a PR to build context
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 TestBot history context.
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 TestBot history from GitHub PR comments.
2
+ * PR Comment Parser — extracts Testbot history from GitHub PR comments.
3
3
  *
4
- * Parses previous Skyramp TestBot comments on a PR to build context
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 isTestBotComment(comment) {
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 TestBot history context.
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) => isTestBotComment(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 TestBot comment — older comments represent stale
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 TestBot comments exist", async () => {
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 TestBot comment when multiple exist", async () => {
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 TestBot comment body", async () => {
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
- else
113
- endpointMap.set(key, { path: normalizedPath, methods: new Set([method]), sourceFile: relative });
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
- return results;
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
- changedEndpoints.push(...eps);
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.