@skyramp/mcp 0.1.2 → 0.1.4

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 (28) hide show
  1. package/build/prompts/test-maintenance/driftAnalysisSections.js +2 -2
  2. package/build/prompts/test-recommendation/analysisOutputPrompt.js +26 -21
  3. package/build/prompts/test-recommendation/recommendationSections.js +42 -10
  4. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +2 -5
  5. package/build/prompts/test-recommendation/test-recommendation-prompt.js +114 -157
  6. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +250 -18
  7. package/build/prompts/testbot/testbot-prompts.js +17 -9
  8. package/build/services/ScenarioGenerationService.js +2 -1
  9. package/build/services/TestDiscoveryService.js +22 -7
  10. package/build/services/TestDiscoveryService.test.js +44 -0
  11. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +3 -4
  12. package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +9 -0
  13. package/build/tools/submitReportTool.js +4 -3
  14. package/build/tools/submitReportTool.test.js +16 -2
  15. package/build/tools/test-management/analyzeChangesTool.js +264 -140
  16. package/build/tools/test-management/analyzeChangesTool.test.js +3 -1
  17. package/build/tools/test-management/analyzeTestHealthTool.js +5 -0
  18. package/build/types/RepositoryAnalysis.js +8 -0
  19. package/build/types/TestRecommendation.js +2 -0
  20. package/build/utils/branchDiff.js +24 -8
  21. package/build/utils/featureFlags.js +25 -0
  22. package/build/utils/httpDefaults.js +12 -0
  23. package/build/utils/repoScanner.js +16 -2
  24. package/build/utils/routeParsers.js +79 -79
  25. package/build/utils/routeParsers.test.js +192 -66
  26. package/build/utils/scenarioDrafting.js +116 -497
  27. package/build/utils/scenarioDrafting.test.js +260 -480
  28. package/package.json +1 -1
@@ -94,6 +94,11 @@ exist only in the LLM's reasoning context and are acted on by \`skyramp_actions\
94
94
  .map((e) => e.methods ? e.methods.map((m) => `${m.method} ${e.path}`).join(", ") : `${e.method} ${e.path}`)
95
95
  .join(", ")}`);
96
96
  }
97
+ if (diff.removedEndpoints?.length > 0) {
98
+ lines.push(`**Removed Endpoints** (${diff.removedEndpoints.length}): ${diff.removedEndpoints
99
+ .map((e) => e.methods ? e.methods.map((m) => `${m.method} ${e.path}`).join(", ") : `${e.method} ${e.path}`)
100
+ .join(", ")}`);
101
+ }
97
102
  parsedDiffText = lines.join("\n");
98
103
  }
99
104
  const scannedEndpoints = stateData.repositoryAnalysis?.skeletonEndpoints || [];
@@ -128,6 +128,14 @@ export const branchDiffContextSchema = z.object({
128
128
  changeType: z.enum(["added", "modified", "removed"]),
129
129
  })),
130
130
  })),
131
+ removedEndpoints: z.array(z.object({
132
+ path: z.string(),
133
+ methods: z.array(z.object({
134
+ method: z.string(),
135
+ sourceFile: z.string(),
136
+ changeType: z.literal("removed"),
137
+ })),
138
+ })).optional(),
131
139
  affectedServices: z.array(z.string()),
132
140
  summary: z.string().optional(),
133
141
  });
@@ -24,6 +24,8 @@ const CATEGORIES = [
24
24
  export const SCENARIO_CATEGORIES = [...INTERNAL_CATEGORIES, ...CATEGORIES];
25
25
  /** Categories valid for tool submissions (excludes internal-only categories). */
26
26
  export const TEST_CATEGORIES = CATEGORIES;
27
+ /** Numeric ordering for priority tiers (higher = more important). */
28
+ export const PRIORITY_TIER_ORDER = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
27
29
  /** Priority assignment for each category. */
28
30
  export const CATEGORY_PRIORITY = {
29
31
  new_endpoint: "CRITICAL",
@@ -1,10 +1,30 @@
1
1
  import { simpleGit } from "simple-git";
2
2
  import { logger } from "./logger.js";
3
- const MAX_DIFF_LENGTH = 50_000;
4
3
  /**
5
4
  * Try a git diff against the given ref. Returns undefined if the ref doesn't exist
6
5
  * or the diff fails, so the caller can try the next candidate.
7
6
  */
7
+ /** Parse diff headers to find newly created and deleted files. */
8
+ function parseNewAndDeletedFiles(rawDiff) {
9
+ const newFiles = [];
10
+ const deletedFiles = [];
11
+ const lines = rawDiff.split("\n");
12
+ let currentFile = "";
13
+ for (const line of lines) {
14
+ const fileMatch = line.match(/^diff --git a\/.+ b\/(.+)$/);
15
+ if (fileMatch) {
16
+ currentFile = fileMatch[1];
17
+ continue;
18
+ }
19
+ if (line === "--- /dev/null" && currentFile) {
20
+ newFiles.push(currentFile);
21
+ }
22
+ else if (line === "+++ /dev/null" && currentFile) {
23
+ deletedFiles.push(currentFile);
24
+ }
25
+ }
26
+ return { newFiles, deletedFiles };
27
+ }
8
28
  async function tryDiff(git, ref) {
9
29
  try {
10
30
  const changedFilesRaw = await git.diff([`${ref}...HEAD`, "--name-only"]);
@@ -13,13 +33,9 @@ async function tryDiff(git, ref) {
13
33
  .map((f) => f.trim())
14
34
  .filter((f) => f.length > 0);
15
35
  const diffStat = await git.diff([`${ref}...HEAD`, "--stat"]);
16
- let diffContent = await git.diff([`${ref}...HEAD`]);
17
- if (diffContent.length > MAX_DIFF_LENGTH) {
18
- diffContent =
19
- diffContent.substring(0, MAX_DIFF_LENGTH) +
20
- `\n\n... [diff truncated at ${MAX_DIFF_LENGTH} chars, ${changedFiles.length} files total] ...`;
21
- }
22
- return { changedFiles, diffContent, diffStat };
36
+ const fullDiff = await git.diff([`${ref}...HEAD`]);
37
+ const { newFiles, deletedFiles } = parseNewAndDeletedFiles(fullDiff);
38
+ return { changedFiles, diffContent: fullDiff, diffStat, newFiles, deletedFiles };
23
39
  }
24
40
  catch (err) {
25
41
  logger.debug(`tryDiff against ${ref} failed`, { error: err instanceof Error ? err.message : String(err) });
@@ -32,3 +32,28 @@
32
32
  export function isContractConsumerModeEnabled() {
33
33
  return process.env.SKYRAMP_FEATURE_CONTRACT_CONSUMER_MODE === "1";
34
34
  }
35
+ export function isTestbotMode() {
36
+ return process.env.SKYRAMP_FEATURE_TESTBOT === "1";
37
+ }
38
+ /**
39
+ * Returns the prompt phrasing for where to find service details.
40
+ *
41
+ * - Testbot mode: references the `<services>` XML block injected at the top of the prompt.
42
+ * - Normal MCP mode: references `.skyramp/workspace.yml`.
43
+ */
44
+ export function resolveServiceDetailsRef() {
45
+ if (isTestbotMode()) {
46
+ return {
47
+ testDirRef: "the `<output_dir>` from the `<services>` block",
48
+ frontendTestDirRef: "the **frontend** service's `<output_dir>` from the `<services>` block",
49
+ baseUrlRef: "the `<base_url>` from the `<services>` block",
50
+ authSourceRef: "the `<services>` block",
51
+ };
52
+ }
53
+ return {
54
+ testDirRef: "the `testDirectory` from `.skyramp/workspace.yml`",
55
+ frontendTestDirRef: "the **frontend** service's `testDirectory` from `.skyramp/workspace.yml`",
56
+ baseUrlRef: "the `api.baseUrl` from `.skyramp/workspace.yml`",
57
+ authSourceRef: "`.skyramp/workspace.yml`",
58
+ };
59
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared HTTP method defaults used across scenario drafting and test generation.
3
+ */
4
+ /** Returns the conventional success status code for a given HTTP method. */
5
+ export function inferExpectedStatus(method) {
6
+ const m = method.toUpperCase();
7
+ if (m === "POST")
8
+ return 201;
9
+ if (m === "DELETE")
10
+ return 204;
11
+ return 200;
12
+ }
@@ -1,13 +1,15 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { nextjsFileToApiPath, parseFileEndpoints, } from "./routeParsers.js";
4
+ import { logger } from "./logger.js";
4
5
  function globRecursive(dir, extensions) {
5
6
  const results = [];
6
7
  let entries;
7
8
  try {
8
9
  entries = fs.readdirSync(dir, { withFileTypes: true });
9
10
  }
10
- catch {
11
+ catch (err) {
12
+ logger.debug("globRecursive: skipping directory", { dir, error: err instanceof Error ? err.message : String(err) });
11
13
  return results;
12
14
  }
13
15
  for (const entry of entries) {
@@ -201,7 +203,19 @@ export function findCandidateRouteFiles(repositoryPath) {
201
203
  }
202
204
  }
203
205
  }
204
- return [...byName, ...byContent].slice(0, MAX_CANDIDATE_FILES);
206
+ const combined = [...byName, ...byContent];
207
+ const contentPassSkipped = byName.length >= MAX_CANDIDATE_FILES;
208
+ const result = combined.slice(0, MAX_CANDIDATE_FILES);
209
+ if (combined.length > MAX_CANDIDATE_FILES) {
210
+ logger.warning("findCandidateRouteFiles: hit MAX_CANDIDATE_FILES cap — some route files may be excluded from LLM analysis", {
211
+ cap: MAX_CANDIDATE_FILES,
212
+ byName: byName.length,
213
+ byContent: byContent.length,
214
+ contentPassSkipped,
215
+ totalFound: combined.length,
216
+ });
217
+ }
218
+ return result;
205
219
  }
206
220
  export function scanAllRepoEndpoints(repositoryPath) {
207
221
  const endpointMap = new Map();
@@ -82,6 +82,18 @@ export function parseRouteLine(line, sourceFile) {
82
82
  };
83
83
  }
84
84
  }
85
+ // Next.js guard clause: `if (req.method !== "POST")` means the handler IS for POST
86
+ const nextjsGuardMatch = stripped.match(/req\.method\s*!==?\s*["'](GET|POST|PUT|PATCH|DELETE)["']/i);
87
+ if (nextjsGuardMatch) {
88
+ const apiPath = nextjsFileToApiPath(sourceFile);
89
+ if (apiPath) {
90
+ return {
91
+ method: nextjsGuardMatch[1].toUpperCase(),
92
+ path: apiPath,
93
+ sourceFile,
94
+ };
95
+ }
96
+ }
85
97
  const appRouterExportMatch = stripped.match(/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/i);
86
98
  if (appRouterExportMatch) {
87
99
  const apiPath = nextjsFileToApiPath(sourceFile);
@@ -267,46 +279,75 @@ export function parseFileEndpoints(content, sourceFile) {
267
279
  }
268
280
  return results;
269
281
  }
270
- export function parseEndpointsFromDiff(diffData) {
271
- const lines = diffData.diffContent.split("\n");
272
- const addedRoutes = [];
273
- const removedKeys = new Set();
274
- let currentFile = "";
275
- for (const line of lines) {
276
- const fileMatch = line.match(/^diff --git a\/.+ b\/(.+)$/);
277
- if (fileMatch) {
278
- currentFile = fileMatch[1];
279
- continue;
280
- }
281
- if (line.startsWith("+") && !line.startsWith("+++")) {
282
- const ep = parseRouteLine(line, currentFile);
283
- if (ep)
284
- addedRoutes.push(ep);
285
- }
286
- else if (line.startsWith("-") && !line.startsWith("---")) {
287
- const ep = parseRouteLine(line, currentFile);
288
- if (ep)
289
- removedKeys.add(`${ep.method} ${ep.path}`);
290
- }
282
+ export const SKIP_PATH_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
283
+ /** Extract the primary resource name from an endpoint path (e.g. "/api/v1/orders/{id}" → "orders"). */
284
+ export function extractResourceFromPath(endpointPath) {
285
+ const segments = endpointPath.split("/").filter(Boolean);
286
+ const meaningful = segments.filter((s) => !s.startsWith("{") && !SKIP_PATH_SEGMENTS.has(s));
287
+ return meaningful[meaningful.length - 1] || "unknown";
288
+ }
289
+ /**
290
+ * Classify endpoints by cross-referencing `changedFiles` against `scannedEndpoints[].sourceFile`.
291
+ *
292
+ * This replaces `parseEndpointsFromDiff` — instead of regex-parsing diff hunks for route
293
+ * annotations (fragile for 15+ frameworks), it uses the already-scanned endpoint catalog
294
+ * which has full, resolved paths and concrete HTTP methods.
295
+ *
296
+ * For deleted files, the caller must supply endpoints recovered from the base branch
297
+ * (via `parseFileEndpoints` on `git show base:<file>`) in `deletedFileEndpoints`.
298
+ */
299
+ export function classifyEndpointsByChangedFiles(diffData, scannedEndpoints, deletedFileEndpoints) {
300
+ const newFileSet = new Set(diffData.newFiles);
301
+ const deletedFileSet = new Set(diffData.deletedFiles);
302
+ // Build sourceFile → ScannedEndpoint[] map from the post-change catalog
303
+ const bySourceFile = new Map();
304
+ for (const ep of scannedEndpoints) {
305
+ const existing = bySourceFile.get(ep.sourceFile);
306
+ if (existing)
307
+ existing.push(ep);
308
+ else
309
+ bySourceFile.set(ep.sourceFile, [ep]);
291
310
  }
292
- const addedRouteKeys = new Set(addedRoutes.map((r) => r.path));
311
+ const changedEndpoints = [];
312
+ const newEndpoints = [];
313
+ const unmatchedFiles = [];
293
314
  for (const file of diffData.changedFiles) {
294
- const apiPath = nextjsFileToApiPath(file);
295
- if (apiPath && !addedRouteKeys.has(apiPath)) {
296
- addedRoutes.push({ method: "MULTI", path: apiPath, sourceFile: file });
297
- addedRouteKeys.add(apiPath);
315
+ // Deleted files won't appear in scanned catalog — handled separately below
316
+ if (deletedFileSet.has(file))
317
+ continue;
318
+ const eps = bySourceFile.get(file);
319
+ if (!eps || eps.length === 0) {
320
+ unmatchedFiles.push(file);
321
+ continue;
298
322
  }
299
- }
300
- const newEndpoints = [];
301
- const modifiedEndpoints = [];
302
- for (const ep of addedRoutes) {
303
- if (removedKeys.has(`${ep.method} ${ep.path}`)) {
304
- modifiedEndpoints.push(ep);
323
+ if (newFileSet.has(file)) {
324
+ newEndpoints.push(...eps);
305
325
  }
306
326
  else {
307
- newEndpoints.push(ep);
327
+ changedEndpoints.push(...eps);
308
328
  }
309
329
  }
330
+ // Removed endpoints: from deleted files, recovered from the base branch.
331
+ // Filter out any endpoint that still exists in the current scanned catalog
332
+ // (i.e. was moved/refactored to another file, not truly deleted).
333
+ // Paths with a MULTI sentinel in the catalog are treated as present for all methods
334
+ // (Next.js catch-all handlers, Java @RequestMapping without explicit method, etc.).
335
+ const currentEndpointKeys = new Set(scannedEndpoints.flatMap((ep) => ep.methods.map((m) => `${m} ${ep.path}`)));
336
+ const multiPaths = new Set(scannedEndpoints.filter((ep) => ep.methods.includes("MULTI")).map((ep) => ep.path));
337
+ const currentPaths = new Set(scannedEndpoints.map((ep) => ep.path));
338
+ const removedEndpoints = (deletedFileEndpoints ?? [])
339
+ .map((ep) => ({
340
+ ...ep,
341
+ methods: ep.methods.filter((m) => {
342
+ // A deleted MULTI means the file was a catch-all (e.g. Next.js default export).
343
+ // If the path exists in the current catalog with any method, it was moved — not removed.
344
+ if (m === "MULTI")
345
+ return !currentPaths.has(ep.path);
346
+ return !currentEndpointKeys.has(`${m} ${ep.path}`) && !multiPaths.has(ep.path);
347
+ }),
348
+ }))
349
+ .filter((ep) => ep.methods.length > 0);
350
+ // Affected services: same heuristic as before
310
351
  const servicePattern = /(?:services?|modules?|apps?)\/([a-z0-9_-]+)/i;
311
352
  const affectedServices = [
312
353
  ...new Set(diffData.changedFiles
@@ -314,54 +355,13 @@ export function parseEndpointsFromDiff(diffData) {
314
355
  .filter((s) => !!s)),
315
356
  ];
316
357
  return {
358
+ changedEndpoints,
359
+ newEndpoints,
360
+ removedEndpoints,
361
+ unmatchedFiles,
362
+ changedFiles: diffData.changedFiles,
317
363
  currentBranch: diffData.currentBranch,
318
364
  baseBranch: diffData.baseBranch,
319
- changedFiles: diffData.changedFiles,
320
- diffStat: diffData.diffStat,
321
- newEndpoints,
322
- modifiedEndpoints,
323
365
  affectedServices,
324
366
  };
325
367
  }
326
- export const SKIP_PATH_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
327
- /** Extract the primary resource name from an endpoint path (e.g. "/api/v1/orders/{id}" → "orders"). */
328
- export function extractResourceFromPath(endpointPath) {
329
- const segments = endpointPath.split("/").filter(Boolean);
330
- const meaningful = segments.filter(s => !s.startsWith("{") && !SKIP_PATH_SEGMENTS.has(s));
331
- return meaningful[meaningful.length - 1] || "unknown";
332
- }
333
- /**
334
- * Resolve incomplete diff-parsed endpoint paths against the authoritative
335
- * scanned endpoint catalog. Route decorators in diffs often contain only the
336
- * handler-relative fragment (e.g. "/{order_id}") because the router prefix
337
- * (e.g. APIRouter(prefix="/api/v1/orders")) is outside the diff hunk.
338
- *
339
- * For each diff endpoint whose path doesn't match any known endpoint exactly,
340
- * find the scanned endpoint whose full path ends with the diff path and shares
341
- * the same HTTP method. Mutates the input array in place.
342
- */
343
- export function resolveEndpointPaths(diffEndpoints, knownEndpoints) {
344
- if (diffEndpoints.length === 0 || knownEndpoints.length === 0)
345
- return;
346
- for (const ep of diffEndpoints) {
347
- const alreadyFull = knownEndpoints.some(s => s.path === ep.path);
348
- if (alreadyFull)
349
- continue;
350
- const candidates = knownEndpoints.filter(s => s.path.endsWith(ep.path) &&
351
- s.path !== ep.path &&
352
- s.methods.some(m => m.method === ep.method));
353
- if (candidates.length === 1) {
354
- ep.path = candidates[0].path;
355
- }
356
- else if (candidates.length > 1) {
357
- const byFile = candidates.filter(s => s.methods.some(m => m.method === ep.method &&
358
- m.sourceFile != null &&
359
- (m.sourceFile === ep.sourceFile ||
360
- m.sourceFile.endsWith(ep.sourceFile) ||
361
- ep.sourceFile.endsWith(m.sourceFile))));
362
- if (byFile.length === 1) {
363
- ep.path = byFile[0].path;
364
- }
365
- }
366
- }
367
- }
@@ -1,5 +1,5 @@
1
1
  // @ts-ignore
2
- import { nextjsFileToApiPath, parseRouteLine, parseFileEndpoints, resolveEndpointPaths, extractResourceFromPath } from "./routeParsers.js";
2
+ import { nextjsFileToApiPath, parseRouteLine, parseFileEndpoints, extractResourceFromPath, classifyEndpointsByChangedFiles } from "./routeParsers.js";
3
3
  describe("nextjsFileToApiPath", () => {
4
4
  it("converts pages/api route to API path", () => {
5
5
  expect(nextjsFileToApiPath("pages/api/users/index.ts")).toBe("/api/users");
@@ -47,6 +47,18 @@ describe("parseRouteLine", () => {
47
47
  expect(result).not.toBeNull();
48
48
  expect(result.method).toBe("GET");
49
49
  });
50
+ it("parses Next.js guard clause (req.method !== 'POST')", () => {
51
+ const result = parseRouteLine(' if (req.method !== "POST") {', "pages/api/v1/collections/[id]/transfer.ts");
52
+ expect(result).not.toBeNull();
53
+ expect(result.method).toBe("POST");
54
+ expect(result.path).toBe("/api/v1/collections/{id}/transfer");
55
+ });
56
+ it("parses Next.js guard clause with != operator", () => {
57
+ const result = parseRouteLine(" if (req.method != 'DELETE') {", "pages/api/v1/items/[id].ts");
58
+ expect(result).not.toBeNull();
59
+ expect(result.method).toBe("DELETE");
60
+ expect(result.path).toBe("/api/v1/items/{id}");
61
+ });
50
62
  it("parses Gin route", () => {
51
63
  const result = parseRouteLine('r.GET("/users", getUsers)', "main.go");
52
64
  expect(result).toEqual({ method: "GET", path: "/users", sourceFile: "main.go" });
@@ -246,71 +258,6 @@ async def get_product(): pass
246
258
  expect(eps).toContainEqual({ method: "GET", path: "/products/{product_id}", sourceFile: "routers/product.py" });
247
259
  });
248
260
  });
249
- describe("resolveEndpointPaths", () => {
250
- const knownEndpoints = [
251
- { path: "/api/v1/orders", methods: [{ method: "POST", sourceFile: "routers/order.py" }, { method: "GET", sourceFile: "routers/order.py" }] },
252
- { path: "/api/v1/orders/{order_id}", methods: [{ method: "GET", sourceFile: "routers/order.py" }, { method: "PUT", sourceFile: "routers/order.py" }, { method: "DELETE", sourceFile: "routers/order.py" }] },
253
- { path: "/api/v1/products", methods: [{ method: "POST", sourceFile: "routers/product.py" }, { method: "GET", sourceFile: "routers/product.py" }] },
254
- { path: "/api/v1/products/{product_id}", methods: [{ method: "GET", sourceFile: "routers/product.py" }, { method: "PUT", sourceFile: "routers/product.py" }, { method: "DELETE", sourceFile: "routers/product.py" }] },
255
- ];
256
- it("resolves router-relative path to full API path", () => {
257
- const eps = [{ method: "PUT", path: "/{order_id}", sourceFile: "routers/order.py" }];
258
- resolveEndpointPaths(eps, knownEndpoints);
259
- expect(eps[0].path).toBe("/api/v1/orders/{order_id}");
260
- });
261
- it("leaves already-full paths unchanged", () => {
262
- const eps = [{ method: "POST", path: "/api/v1/orders", sourceFile: "routers/order.py" }];
263
- resolveEndpointPaths(eps, knownEndpoints);
264
- expect(eps[0].path).toBe("/api/v1/orders");
265
- });
266
- it("resolves multiple endpoints in one call", () => {
267
- const eps = [
268
- { method: "PUT", path: "/{order_id}", sourceFile: "routers/order.py" },
269
- { method: "DELETE", path: "/{order_id}", sourceFile: "routers/order.py" },
270
- ];
271
- resolveEndpointPaths(eps, knownEndpoints);
272
- expect(eps[0].path).toBe("/api/v1/orders/{order_id}");
273
- expect(eps[1].path).toBe("/api/v1/orders/{order_id}");
274
- });
275
- it("disambiguates by sourceFile when suffix matches multiple endpoints", () => {
276
- const ambiguousKnown = [
277
- { path: "/api/v1/orders/{id}", methods: [{ method: "PUT", sourceFile: "routers/order.py" }] },
278
- { path: "/api/v1/products/{id}", methods: [{ method: "PUT", sourceFile: "routers/product.py" }] },
279
- ];
280
- const eps = [{ method: "PUT", path: "/{id}", sourceFile: "routers/order.py" }];
281
- resolveEndpointPaths(eps, ambiguousKnown);
282
- expect(eps[0].path).toBe("/api/v1/orders/{id}");
283
- });
284
- it("does not resolve when no match found", () => {
285
- const eps = [{ method: "PATCH", path: "/{id}", sourceFile: "routers/unknown.py" }];
286
- resolveEndpointPaths(eps, knownEndpoints);
287
- expect(eps[0].path).toBe("/{id}");
288
- });
289
- it("does not resolve when ambiguous and sourceFile does not disambiguate", () => {
290
- const ambiguousKnown = [
291
- { path: "/api/v1/orders/{id}", methods: [{ method: "PUT" }] },
292
- { path: "/api/v1/products/{id}", methods: [{ method: "PUT" }] },
293
- ];
294
- const eps = [{ method: "PUT", path: "/{id}", sourceFile: "shared.py" }];
295
- resolveEndpointPaths(eps, ambiguousKnown);
296
- expect(eps[0].path).toBe("/{id}");
297
- });
298
- it("handles empty arrays gracefully", () => {
299
- const eps = [];
300
- resolveEndpointPaths(eps, knownEndpoints);
301
- expect(eps).toEqual([]);
302
- const eps2 = [{ method: "PUT", path: "/{id}", sourceFile: "x.py" }];
303
- resolveEndpointPaths(eps2, []);
304
- expect(eps2[0].path).toBe("/{id}");
305
- });
306
- it("resolves collection-level relative paths (e.g. empty string becomes base)", () => {
307
- const eps = [{ method: "POST", path: "", sourceFile: "routers/order.py" }];
308
- resolveEndpointPaths(eps, knownEndpoints);
309
- // Empty string — endsWith("") is always true, so multiple matches.
310
- // sourceFile should disambiguate to orders.
311
- expect(eps[0].path).toBe("/api/v1/orders");
312
- });
313
- });
314
261
  describe("extractResourceFromPath", () => {
315
262
  it("extracts resource from simple path", () => {
316
263
  expect(extractResourceFromPath("/orders")).toBe("orders");
@@ -339,3 +286,182 @@ describe("extractResourceFromPath", () => {
339
286
  expect(extractResourceFromPath("api/v1/orders")).toBe("orders");
340
287
  });
341
288
  });
289
+ describe("classifyEndpointsByChangedFiles", () => {
290
+ function makeDiffDataForClassify(opts) {
291
+ return {
292
+ currentBranch: "feature/test",
293
+ baseBranch: "origin/main",
294
+ changedFiles: opts.changedFiles,
295
+ diffContent: "",
296
+ diffStat: "",
297
+ newFiles: opts.newFiles ?? [],
298
+ deletedFiles: opts.deletedFiles ?? [],
299
+ };
300
+ }
301
+ const scannedEndpoints = [
302
+ { path: "/api/v1/orders/{order_id}", methods: ["GET", "PATCH"], sourceFile: "src/routes/orders.py" },
303
+ { path: "/api/v1/products/{product_id}", methods: ["GET", "POST"], sourceFile: "src/routes/products.py" },
304
+ { path: "/api/v1/users", methods: ["GET"], sourceFile: "src/routes/users.py" },
305
+ ];
306
+ it("classifies modified endpoints from changed files", () => {
307
+ const diff = makeDiffDataForClassify({ changedFiles: ["src/routes/orders.py"] });
308
+ const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
309
+ expect(result.changedEndpoints).toHaveLength(1);
310
+ expect(result.changedEndpoints[0].path).toBe("/api/v1/orders/{order_id}");
311
+ expect(result.newEndpoints).toHaveLength(0);
312
+ expect(result.removedEndpoints).toHaveLength(0);
313
+ });
314
+ it("classifies new endpoints from newly-created files", () => {
315
+ const diff = makeDiffDataForClassify({ changedFiles: ["src/routes/products.py"], newFiles: ["src/routes/products.py"] });
316
+ const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
317
+ expect(result.newEndpoints).toHaveLength(1);
318
+ expect(result.newEndpoints[0].path).toBe("/api/v1/products/{product_id}");
319
+ expect(result.changedEndpoints).toHaveLength(0);
320
+ });
321
+ it("puts files without endpoints into unmatchedFiles", () => {
322
+ const diff = makeDiffDataForClassify({ changedFiles: ["src/models/order.py", "src/routes/orders.py"] });
323
+ const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
324
+ expect(result.unmatchedFiles).toEqual(["src/models/order.py"]);
325
+ expect(result.changedEndpoints).toHaveLength(1);
326
+ });
327
+ it("skips deleted files from changed classification", () => {
328
+ const deletedEndpoints = [{ path: "/api/v1/legacy", methods: ["GET"], sourceFile: "src/routes/legacy.py" }];
329
+ const diff = makeDiffDataForClassify({ changedFiles: ["src/routes/legacy.py", "src/routes/orders.py"], deletedFiles: ["src/routes/legacy.py"] });
330
+ const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints, deletedEndpoints);
331
+ expect(result.removedEndpoints).toHaveLength(1);
332
+ expect(result.removedEndpoints[0].path).toBe("/api/v1/legacy");
333
+ expect(result.changedEndpoints).toHaveLength(1);
334
+ expect(result.changedEndpoints[0].path).toBe("/api/v1/orders/{order_id}");
335
+ });
336
+ it("handles multiple endpoints in the same file", () => {
337
+ const diff = makeDiffDataForClassify({ changedFiles: ["src/routes/orders.py"] });
338
+ const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
339
+ expect(result.changedEndpoints).toHaveLength(1);
340
+ expect(result.changedEndpoints[0].methods).toEqual(["GET", "PATCH"]);
341
+ });
342
+ it("returns empty results when no changed files match endpoints", () => {
343
+ const diff = makeDiffDataForClassify({ changedFiles: ["README.md", "package.json"] });
344
+ const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
345
+ expect(result.changedEndpoints).toHaveLength(0);
346
+ expect(result.newEndpoints).toHaveLength(0);
347
+ expect(result.removedEndpoints).toHaveLength(0);
348
+ expect(result.unmatchedFiles).toEqual(["README.md", "package.json"]);
349
+ });
350
+ it("computes affectedServices from changedFiles", () => {
351
+ const diff = makeDiffDataForClassify({ changedFiles: ["services/orders/routes.py", "services/users/models.py"] });
352
+ const result = classifyEndpointsByChangedFiles(diff, []);
353
+ expect(result.affectedServices).toContain("orders");
354
+ expect(result.affectedServices).toContain("users");
355
+ });
356
+ it("propagates branch metadata from diffData", () => {
357
+ const diff = makeDiffDataForClassify({ changedFiles: [] });
358
+ const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
359
+ expect(result.currentBranch).toBe("feature/test");
360
+ expect(result.baseBranch).toBe("origin/main");
361
+ expect(result.changedFiles).toEqual([]);
362
+ });
363
+ it("handles empty scannedEndpoints (all files unmatched)", () => {
364
+ const diff = makeDiffDataForClassify({ changedFiles: ["src/routes/orders.py"] });
365
+ const result = classifyEndpointsByChangedFiles(diff, []);
366
+ expect(result.changedEndpoints).toHaveLength(0);
367
+ expect(result.newEndpoints).toHaveLength(0);
368
+ expect(result.removedEndpoints).toHaveLength(0);
369
+ expect(result.unmatchedFiles).toEqual(["src/routes/orders.py"]);
370
+ });
371
+ it("does not double-count a file that is both new and has endpoints", () => {
372
+ const diff = makeDiffDataForClassify({
373
+ changedFiles: ["src/routes/products.py", "src/routes/orders.py"],
374
+ newFiles: ["src/routes/products.py"],
375
+ });
376
+ const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
377
+ // products.py is new → newEndpoints; orders.py is changed → changedEndpoints
378
+ expect(result.newEndpoints).toHaveLength(1);
379
+ expect(result.newEndpoints[0].sourceFile).toBe("src/routes/products.py");
380
+ expect(result.changedEndpoints).toHaveLength(1);
381
+ expect(result.changedEndpoints[0].sourceFile).toBe("src/routes/orders.py");
382
+ });
383
+ it("includes deleted-file endpoints in removedEndpoints when not in current catalog", () => {
384
+ const deleted = [
385
+ { path: "/api/v1/removed", methods: ["GET", "POST"], sourceFile: "src/routes/removed.py" },
386
+ ];
387
+ const diff = makeDiffDataForClassify({
388
+ changedFiles: ["src/routes/removed.py"],
389
+ deletedFiles: ["src/routes/removed.py"],
390
+ });
391
+ const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints, deleted);
392
+ expect(result.removedEndpoints).toHaveLength(1);
393
+ expect(result.removedEndpoints[0].path).toBe("/api/v1/removed");
394
+ expect(result.removedEndpoints[0].methods).toEqual(["GET", "POST"]);
395
+ // The deleted file should NOT appear in changedEndpoints or unmatchedFiles
396
+ expect(result.changedEndpoints.every(ep => ep.sourceFile !== "src/routes/removed.py")).toBe(true);
397
+ expect(result.unmatchedFiles).not.toContain("src/routes/removed.py");
398
+ });
399
+ it("filters moved endpoints from removedEndpoints when they exist in current catalog", () => {
400
+ // Endpoint was in a deleted file but the same METHOD+path exists in scanned catalog
401
+ // (moved to another file) — should NOT appear in removedEndpoints
402
+ const deleted = [
403
+ { path: "/api/v1/orders/{order_id}", methods: ["GET", "DELETE"], sourceFile: "src/routes/old-orders.py" },
404
+ ];
405
+ const diff = makeDiffDataForClassify({
406
+ changedFiles: ["src/routes/old-orders.py"],
407
+ deletedFiles: ["src/routes/old-orders.py"],
408
+ });
409
+ // scannedEndpoints has GET /api/v1/orders/{order_id} (from orders.py) → moved, filter out
410
+ // DELETE /api/v1/orders/{order_id} is NOT in scannedEndpoints → truly removed, keep
411
+ const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints, deleted);
412
+ expect(result.removedEndpoints).toHaveLength(1);
413
+ expect(result.removedEndpoints[0].path).toBe("/api/v1/orders/{order_id}");
414
+ expect(result.removedEndpoints[0].methods).toEqual(["DELETE"]);
415
+ });
416
+ it("filters out entirely moved endpoints (all methods exist in catalog)", () => {
417
+ const deleted = [
418
+ { path: "/api/v1/orders/{order_id}", methods: ["GET", "PATCH"], sourceFile: "src/routes/old-orders.py" },
419
+ ];
420
+ const diff = makeDiffDataForClassify({
421
+ changedFiles: ["src/routes/old-orders.py"],
422
+ deletedFiles: ["src/routes/old-orders.py"],
423
+ });
424
+ // Both GET and PATCH for /api/v1/orders/{order_id} exist in scannedEndpoints → fully filtered
425
+ const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints, deleted);
426
+ expect(result.removedEndpoints).toHaveLength(0);
427
+ });
428
+ it("treats MULTI sentinel in current catalog as wildcard when filtering moved endpoints", () => {
429
+ // Deleted file had explicit GET+POST methods, but current catalog has MULTI for the same path
430
+ // (e.g. moved to a Next.js catch-all handler) — should be fully filtered out
431
+ const catalogWithMulti = [
432
+ ...scannedEndpoints,
433
+ { path: "/api/v1/legacy", methods: ["MULTI"], sourceFile: "pages/api/legacy.ts" },
434
+ ];
435
+ const deleted = [
436
+ { path: "/api/v1/legacy", methods: ["GET", "POST"], sourceFile: "src/routes/legacy.py" },
437
+ ];
438
+ const diff = makeDiffDataForClassify({
439
+ changedFiles: ["src/routes/legacy.py"],
440
+ deletedFiles: ["src/routes/legacy.py"],
441
+ });
442
+ const result = classifyEndpointsByChangedFiles(diff, catalogWithMulti, deleted);
443
+ expect(result.removedEndpoints).toHaveLength(0);
444
+ });
445
+ it("treats MULTI sentinel in deleted endpoints as wildcard when path exists in catalog", () => {
446
+ // Deleted file was a Next.js catch-all (MULTI), but path now exists with concrete methods
447
+ // in the current catalog — should be filtered out as moved, not flagged as removed
448
+ const deleted = [
449
+ { path: "/api/v1/orders/{order_id}", methods: ["MULTI"], sourceFile: "pages/api/orders/[order_id].ts" },
450
+ ];
451
+ const diff = makeDiffDataForClassify({
452
+ changedFiles: ["pages/api/orders/[order_id].ts"],
453
+ deletedFiles: ["pages/api/orders/[order_id].ts"],
454
+ });
455
+ // scannedEndpoints already has GET+PATCH for /api/v1/orders/{order_id}
456
+ const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints, deleted);
457
+ expect(result.removedEndpoints).toHaveLength(0);
458
+ });
459
+ it("defaults removedEndpoints to empty when deletedFileEndpoints is omitted", () => {
460
+ const diff = makeDiffDataForClassify({
461
+ changedFiles: ["src/routes/legacy.py"],
462
+ deletedFiles: ["src/routes/legacy.py"],
463
+ });
464
+ const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
465
+ expect(result.removedEndpoints).toEqual([]);
466
+ });
467
+ });