@skyramp/mcp 0.2.9 → 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 (76) hide show
  1. package/build/index.js +1 -1
  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/dockerComposePrompt.js +3 -0
  7. package/build/prompts/sut-setup/shared.js +1 -3
  8. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +1 -1
  9. package/build/prompts/test-maintenance/drift-analysis-prompt.js +10 -5
  10. package/build/prompts/test-maintenance/driftAnalysisSections.js +1 -4
  11. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +7 -1
  12. package/build/prompts/test-maintenance/driftAnalysisShared.js +19 -1
  13. package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +0 -6
  14. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +0 -16
  15. package/build/prompts/test-recommendation/recommendationShared.js +1 -1
  16. package/build/prompts/testbot/testbot-prompts.js +8 -8
  17. package/build/resources/testbotResource.js +1 -1
  18. package/build/services/TestDiscoveryService.d.ts +0 -1
  19. package/build/services/TestDiscoveryService.js +13 -33
  20. package/build/services/TestDiscoveryService.test.js +136 -1
  21. package/build/services/TestExecutionService.d.ts +1 -1
  22. package/build/tools/executeSkyrampTestTool.js +40 -71
  23. package/build/tools/submitReportTool.d.ts +20 -20
  24. package/build/tools/submitReportTool.js +113 -31
  25. package/build/tools/submitReportTool.test.js +411 -94
  26. package/build/tools/test-management/actionsTool.js +135 -53
  27. package/build/tools/test-management/actionsTool.test.d.ts +1 -0
  28. package/build/tools/test-management/actionsTool.test.js +297 -0
  29. package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
  30. package/build/tools/test-management/analyzeChangesTool.js +63 -152
  31. package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
  32. package/build/tools/test-management/analyzeTestHealthTool.js +24 -157
  33. package/build/tools/test-management/analyzeTestHealthTool.test.js +15 -215
  34. package/build/tools/workspace/initializeWorkspaceTool.js +35 -15
  35. package/build/types/FrontendIntegration.d.ts +25 -0
  36. package/build/types/FrontendIntegration.js +19 -0
  37. package/build/types/RepositoryAnalysis.d.ts +8 -8
  38. package/build/types/TestAnalysis.d.ts +16 -50
  39. package/build/types/TestAnalysis.js +0 -28
  40. package/build/types/TestTypes.d.ts +3 -1
  41. package/build/types/TestTypes.js +3 -1
  42. package/build/utils/AnalysisStateManager.d.ts +13 -1
  43. package/build/utils/docker.test.js +1 -1
  44. package/build/utils/featureFlags.d.ts +1 -1
  45. package/build/utils/featureFlags.js +1 -1
  46. package/build/utils/frontendIntegration.d.ts +9 -0
  47. package/build/utils/frontendIntegration.js +237 -0
  48. package/build/utils/frontendIntegration.test.d.ts +1 -0
  49. package/build/utils/frontendIntegration.test.js +229 -0
  50. package/build/utils/pr-comment-parser.d.ts +3 -3
  51. package/build/utils/pr-comment-parser.js +6 -6
  52. package/build/utils/pr-comment-parser.test.js +3 -3
  53. package/build/utils/repoScanner.d.ts +7 -0
  54. package/build/utils/repoScanner.js +14 -6
  55. package/build/utils/repoScanner.test.js +9 -9
  56. package/build/utils/routeParsers.d.ts +32 -0
  57. package/build/utils/routeParsers.js +205 -2
  58. package/build/utils/routeParsers.test.js +279 -29
  59. package/build/utils/sourceRouteExtractor.js +174 -5
  60. package/build/utils/sourceRouteExtractor.test.js +173 -0
  61. package/build/utils/utils.d.ts +11 -0
  62. package/build/utils/utils.js +19 -0
  63. package/build/utils/utils.test.js +23 -1
  64. package/build/utils/versions.d.ts +3 -3
  65. package/build/utils/versions.js +1 -1
  66. package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
  67. package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
  68. package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
  69. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
  70. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
  71. package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
  72. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.js} +1 -1
  73. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  74. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  75. package/node_modules/playwright/package.json +1 -1
  76. package/package.json +3 -3
@@ -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.