@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.
- package/build/prompts/test-maintenance/driftAnalysisSections.js +2 -2
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +26 -21
- package/build/prompts/test-recommendation/recommendationSections.js +42 -10
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +2 -5
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +114 -157
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +250 -18
- package/build/prompts/testbot/testbot-prompts.js +17 -9
- package/build/services/ScenarioGenerationService.js +2 -1
- package/build/services/TestDiscoveryService.js +22 -7
- package/build/services/TestDiscoveryService.test.js +44 -0
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +3 -4
- package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +9 -0
- package/build/tools/submitReportTool.js +4 -3
- package/build/tools/submitReportTool.test.js +16 -2
- package/build/tools/test-management/analyzeChangesTool.js +264 -140
- package/build/tools/test-management/analyzeChangesTool.test.js +3 -1
- package/build/tools/test-management/analyzeTestHealthTool.js +5 -0
- package/build/types/RepositoryAnalysis.js +8 -0
- package/build/types/TestRecommendation.js +2 -0
- package/build/utils/branchDiff.js +24 -8
- package/build/utils/featureFlags.js +25 -0
- package/build/utils/httpDefaults.js +12 -0
- package/build/utils/repoScanner.js +16 -2
- package/build/utils/routeParsers.js +79 -79
- package/build/utils/routeParsers.test.js +192 -66
- package/build/utils/scenarioDrafting.js +116 -497
- package/build/utils/scenarioDrafting.test.js +260 -480
- 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
311
|
+
const changedEndpoints = [];
|
|
312
|
+
const newEndpoints = [];
|
|
313
|
+
const unmatchedFiles = [];
|
|
293
314
|
for (const file of diffData.changedFiles) {
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
});
|