@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
|
@@ -9,6 +9,7 @@ describe("stepSchema — requestBody validation", () => {
|
|
|
9
9
|
const result = stepSchema.safeParse({
|
|
10
10
|
method: "POST",
|
|
11
11
|
path: "/api/v1/products",
|
|
12
|
+
statusCode: 201,
|
|
12
13
|
requestBody: JSON.stringify({ name: "Widget-123", price: 9.99 }),
|
|
13
14
|
});
|
|
14
15
|
expect(result.success).toBe(true);
|
|
@@ -17,6 +18,7 @@ describe("stepSchema — requestBody validation", () => {
|
|
|
17
18
|
const result = stepSchema.safeParse({
|
|
18
19
|
method: "POST",
|
|
19
20
|
path: "/api/v1/products",
|
|
21
|
+
statusCode: 201,
|
|
20
22
|
requestBody: "{}",
|
|
21
23
|
});
|
|
22
24
|
expect(result.success).toBe(false);
|
|
@@ -28,6 +30,7 @@ describe("stepSchema — requestBody validation", () => {
|
|
|
28
30
|
const result = stepSchema.safeParse({
|
|
29
31
|
method: "PATCH",
|
|
30
32
|
path: "/api/v1/orders/1",
|
|
33
|
+
statusCode: 200,
|
|
31
34
|
requestBody: "{}",
|
|
32
35
|
});
|
|
33
36
|
expect(result.success).toBe(false);
|
|
@@ -37,6 +40,7 @@ describe("stepSchema — requestBody validation", () => {
|
|
|
37
40
|
const result = stepSchema.safeParse({
|
|
38
41
|
method: "PUT",
|
|
39
42
|
path: "/api/v1/products/1",
|
|
43
|
+
statusCode: 200,
|
|
40
44
|
requestBody: "{}",
|
|
41
45
|
});
|
|
42
46
|
expect(result.success).toBe(false);
|
|
@@ -49,6 +53,7 @@ describe("stepSchema — requestBody validation", () => {
|
|
|
49
53
|
const result = stepSchema.safeParse({
|
|
50
54
|
method: "POST",
|
|
51
55
|
path: "/api/v1/products",
|
|
56
|
+
statusCode: 201,
|
|
52
57
|
});
|
|
53
58
|
expect(result.success).toBe(true);
|
|
54
59
|
});
|
|
@@ -57,6 +62,7 @@ describe("stepSchema — requestBody validation", () => {
|
|
|
57
62
|
const result = stepSchema.safeParse({
|
|
58
63
|
method: "POST",
|
|
59
64
|
path: "/api/v1/products",
|
|
65
|
+
statusCode: 201,
|
|
60
66
|
requestBody: "null",
|
|
61
67
|
});
|
|
62
68
|
expect(result.success).toBe(true); // null is not an empty object — not rejected
|
|
@@ -65,6 +71,7 @@ describe("stepSchema — requestBody validation", () => {
|
|
|
65
71
|
const result = stepSchema.safeParse({
|
|
66
72
|
method: "GET",
|
|
67
73
|
path: "/api/v1/products/1",
|
|
74
|
+
statusCode: 200,
|
|
68
75
|
});
|
|
69
76
|
expect(result.success).toBe(true);
|
|
70
77
|
});
|
|
@@ -72,6 +79,7 @@ describe("stepSchema — requestBody validation", () => {
|
|
|
72
79
|
const result = stepSchema.safeParse({
|
|
73
80
|
method: "DELETE",
|
|
74
81
|
path: "/api/v1/products/1",
|
|
82
|
+
statusCode: 204,
|
|
75
83
|
});
|
|
76
84
|
expect(result.success).toBe(true);
|
|
77
85
|
});
|
|
@@ -81,6 +89,7 @@ describe("stepSchema — requestBody validation", () => {
|
|
|
81
89
|
const result = stepSchema.safeParse({
|
|
82
90
|
method: "GET",
|
|
83
91
|
path: "/api/v1/products",
|
|
92
|
+
statusCode: 200,
|
|
84
93
|
requestBody: "{}",
|
|
85
94
|
});
|
|
86
95
|
expect(result.success).toBe(true);
|
|
@@ -120,8 +120,9 @@ export function registerSubmitReportTool(server) {
|
|
|
120
120
|
.string()
|
|
121
121
|
.optional()
|
|
122
122
|
.default(DEFAULT_COMMIT_MESSAGE)
|
|
123
|
-
.describe("Succinct commit message (under 72 chars) summarizing what
|
|
124
|
-
"e.g. 'add contract tests for /products endpoint' or 'update smoke tests for order API changes'"
|
|
123
|
+
.describe("Succinct commit message (if possible, under 72 chars) summarizing what Testbot did, " +
|
|
124
|
+
"e.g. 'add contract tests for /products endpoint' or 'update smoke tests for order API changes'. " +
|
|
125
|
+
"Used as both the git commit subject and the side PR title — the consumer applies truncation as needed."),
|
|
125
126
|
},
|
|
126
127
|
_meta: {
|
|
127
128
|
keywords: ["report", "summary", "testbot", "submit"],
|
|
@@ -161,7 +162,7 @@ export function registerSubmitReportTool(server) {
|
|
|
161
162
|
testResults: params.testResults,
|
|
162
163
|
issuesFound: params.issuesFound,
|
|
163
164
|
nextSteps: params.nextSteps ?? [],
|
|
164
|
-
commitMessage: (params.commitMessage ?? "").replace(/[\r\n]+/g, " ").trim()
|
|
165
|
+
commitMessage: (params.commitMessage ?? "").replace(/[\r\n]+/g, " ").trim() || DEFAULT_COMMIT_MESSAGE,
|
|
165
166
|
}, null, 2);
|
|
166
167
|
logger.info("Submitting testbot report", {
|
|
167
168
|
outputFile: params.summaryOutputFile,
|
|
@@ -128,7 +128,7 @@ describe("registerSubmitReportTool", () => {
|
|
|
128
128
|
const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
|
|
129
129
|
expect(written.commitMessage).toBe("Added recommendations by Skyramp Testbot.");
|
|
130
130
|
});
|
|
131
|
-
it("should sanitize commitMessage (newlines,
|
|
131
|
+
it("should sanitize commitMessage (collapse newlines, trim whitespace)", async () => {
|
|
132
132
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
|
|
133
133
|
tmpDirs.push(tmpDir);
|
|
134
134
|
const outputFile = path.join(tmpDir, "report.json");
|
|
@@ -139,7 +139,21 @@ describe("registerSubmitReportTool", () => {
|
|
|
139
139
|
expect(result.isError).toBeUndefined();
|
|
140
140
|
const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
|
|
141
141
|
expect(written.commitMessage).toBe("line one line two line three");
|
|
142
|
-
|
|
142
|
+
// No length cap — truncation is the consumer's responsibility (SKYR-3757).
|
|
143
|
+
});
|
|
144
|
+
it("should preserve commitMessage longer than 72 chars without truncation", async () => {
|
|
145
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
|
|
146
|
+
tmpDirs.push(tmpDir);
|
|
147
|
+
const outputFile = path.join(tmpDir, "report.json");
|
|
148
|
+
const longMessage = "add contract and integration tests for the new PATCH /orders/{order_id} endpoint including discount recalculation and line-item edits";
|
|
149
|
+
const result = await handler({
|
|
150
|
+
...sampleReportParams(outputFile),
|
|
151
|
+
commitMessage: longMessage,
|
|
152
|
+
});
|
|
153
|
+
expect(result.isError).toBeUndefined();
|
|
154
|
+
const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
|
|
155
|
+
expect(written.commitMessage).toBe(longMessage);
|
|
156
|
+
expect(written.commitMessage.length).toBeGreaterThan(72);
|
|
143
157
|
});
|
|
144
158
|
it("should use default commitMessage when provided as empty string", async () => {
|
|
145
159
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import * as crypto from "crypto";
|
|
3
3
|
import * as fs from "fs";
|
|
4
|
+
import * as os from "os";
|
|
4
5
|
import * as path from "path";
|
|
5
6
|
import { simpleGit } from "simple-git";
|
|
6
7
|
import { logger } from "../../utils/logger.js";
|
|
@@ -12,7 +13,7 @@ import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-r
|
|
|
12
13
|
import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
|
|
13
14
|
import { ScenarioSource, AnalysisScope } from "../../types/RepositoryAnalysis.js";
|
|
14
15
|
import { computeBranchDiff } from "../../utils/branchDiff.js";
|
|
15
|
-
import {
|
|
16
|
+
import { parseFileEndpoints, extractResourceFromPath, classifyEndpointsByChangedFiles, } from "../../utils/routeParsers.js";
|
|
16
17
|
import { scanAllRepoEndpoints, scanRelatedEndpoints, grepRouterMountingContext, findCandidateRouteFiles, } from "../../utils/repoScanner.js";
|
|
17
18
|
import { detectProjectMetadata } from "../../utils/projectMetadata.js";
|
|
18
19
|
import { draftScenariosFromEndpoints } from "../../utils/scenarioDrafting.js";
|
|
@@ -151,6 +152,53 @@ const NON_APP_PATTERNS = [
|
|
|
151
152
|
function isNonApplicationFile(filePath) {
|
|
152
153
|
return NON_APP_PATTERNS.some((p) => p.test(filePath));
|
|
153
154
|
}
|
|
155
|
+
const ROUTE_FILE_PATTERN = /route|controller|endpoint|handler|view|urls|api|router/i;
|
|
156
|
+
const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
|
|
157
|
+
/**
|
|
158
|
+
* Recover endpoints from files deleted in this branch by reading their
|
|
159
|
+
* content from the base branch via `git show`. Only checks files whose
|
|
160
|
+
* name matches route-file heuristics to keep I/O bounded.
|
|
161
|
+
*/
|
|
162
|
+
async function recoverDeletedFileEndpoints(repositoryPath, baseBranch, deletedFiles) {
|
|
163
|
+
const candidates = deletedFiles.filter((f) => SOURCE_EXTS.test(f) && ROUTE_FILE_PATTERN.test(f));
|
|
164
|
+
if (candidates.length === 0)
|
|
165
|
+
return [];
|
|
166
|
+
const git = simpleGit(repositoryPath);
|
|
167
|
+
const results = [];
|
|
168
|
+
const endpointMap = new Map();
|
|
169
|
+
for (const file of candidates) {
|
|
170
|
+
try {
|
|
171
|
+
const content = await git.show([`${baseBranch}:${file}`]);
|
|
172
|
+
for (const ep of parseFileEndpoints(content, file)) {
|
|
173
|
+
const existing = endpointMap.get(ep.path);
|
|
174
|
+
if (existing) {
|
|
175
|
+
existing.methods.add(ep.method);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
endpointMap.set(ep.path, {
|
|
179
|
+
methods: new Set([ep.method]),
|
|
180
|
+
sourceFile: file,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
logger.debug("Failed to recover endpoints from deleted file", {
|
|
187
|
+
file,
|
|
188
|
+
baseBranch,
|
|
189
|
+
error: err instanceof Error ? err.message : String(err),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const [apiPath, data] of endpointMap) {
|
|
194
|
+
results.push({
|
|
195
|
+
path: apiPath,
|
|
196
|
+
methods: Array.from(data.methods),
|
|
197
|
+
sourceFile: data.sourceFile,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return results;
|
|
201
|
+
}
|
|
154
202
|
export const analyzeChangesInputSchema = {
|
|
155
203
|
repositoryPath: z
|
|
156
204
|
.string()
|
|
@@ -267,17 +315,10 @@ to produce a unified state file for the test health workflow.
|
|
|
267
315
|
};
|
|
268
316
|
}
|
|
269
317
|
}
|
|
270
|
-
await sendProgress(25, 100, "Parsing endpoints from diff...");
|
|
271
|
-
const parsedDiff = diffData
|
|
272
|
-
? parseEndpointsFromDiff(diffData)
|
|
273
|
-
: undefined;
|
|
274
|
-
const newEndpoints = parsedDiff
|
|
275
|
-
? parsedDiff.newEndpoints
|
|
276
|
-
: [];
|
|
277
318
|
// ── Step 2: Scan endpoints ──
|
|
278
319
|
let scannedEndpoints = [];
|
|
279
320
|
if (analysisScope !== AnalysisScope.CurrentBranchDiff) {
|
|
280
|
-
await sendProgress(
|
|
321
|
+
await sendProgress(25, 100, "Scanning all repository endpoints...");
|
|
281
322
|
try {
|
|
282
323
|
scannedEndpoints = scanAllRepoEndpoints(params.repositoryPath);
|
|
283
324
|
logger.info("Pre-scanned repo endpoints", {
|
|
@@ -291,7 +332,7 @@ to produce a unified state file for the test health workflow.
|
|
|
291
332
|
}
|
|
292
333
|
}
|
|
293
334
|
else if (diffData) {
|
|
294
|
-
await sendProgress(
|
|
335
|
+
await sendProgress(25, 100, "Scanning related endpoints from diff...");
|
|
295
336
|
try {
|
|
296
337
|
scannedEndpoints = scanRelatedEndpoints(params.repositoryPath, diffData.changedFiles);
|
|
297
338
|
logger.info("Scanned related endpoints", {
|
|
@@ -303,32 +344,68 @@ to produce a unified state file for the test health workflow.
|
|
|
303
344
|
error: err instanceof Error ? err.message : String(err),
|
|
304
345
|
});
|
|
305
346
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
347
|
+
// No fallback to scanAllRepoEndpoints in PR mode.
|
|
348
|
+
// If the scanner found 0 related endpoints, the PR likely touches
|
|
349
|
+
// non-route code (services, models, schemas, client SDK). Flooding
|
|
350
|
+
// all repo endpoints into the prompt causes the LLM to test
|
|
351
|
+
// irrelevant neighboring endpoints instead of focusing on the diff.
|
|
352
|
+
}
|
|
353
|
+
await sendProgress(40, 100, "Classifying changed endpoints...");
|
|
354
|
+
// ── Step 2.5: Classify endpoints by changed files ──
|
|
355
|
+
// Cross-reference changedFiles against scannedEndpoints[].sourceFile to
|
|
356
|
+
// identify new/modified/removed endpoints — replaces fragile regex parsing
|
|
357
|
+
// of diff hunks. Scanned endpoints always have full paths and concrete
|
|
358
|
+
// HTTP methods, eliminating the need for path resolution and MULTI sentinels.
|
|
359
|
+
let classifiedEndpoints;
|
|
360
|
+
if (diffData) {
|
|
361
|
+
// Recover endpoints from deleted files by reading base-branch content
|
|
362
|
+
const deletedFileEndpoints = diffData.deletedFiles.length > 0
|
|
363
|
+
? await recoverDeletedFileEndpoints(params.repositoryPath, diffData.baseBranch, diffData.deletedFiles)
|
|
364
|
+
: [];
|
|
365
|
+
classifiedEndpoints = classifyEndpointsByChangedFiles(diffData, scannedEndpoints, deletedFileEndpoints);
|
|
366
|
+
logger.info("Classified endpoints from changed files", {
|
|
367
|
+
changed: classifiedEndpoints.changedEndpoints.length,
|
|
368
|
+
new: classifiedEndpoints.newEndpoints.length,
|
|
369
|
+
removed: classifiedEndpoints.removedEndpoints.length,
|
|
370
|
+
unmatched: classifiedEndpoints.unmatchedFiles.length,
|
|
371
|
+
});
|
|
319
372
|
}
|
|
320
373
|
await sendProgress(50, 100, "Discovering existing tests...");
|
|
321
374
|
// ── Step 3: Discover existing tests ──
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
375
|
+
// Compute changedResources from classified endpoints for test discovery filtering.
|
|
376
|
+
// undefined → full-repo mode (no diff context)
|
|
377
|
+
// [] → PR mode, no endpoints found → skip external tests
|
|
378
|
+
// [...names] → PR mode with resolved resource names → filter external by relevance
|
|
379
|
+
let changedResources;
|
|
380
|
+
if (!classifiedEndpoints) {
|
|
381
|
+
changedResources = undefined;
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
const allClassified = [
|
|
385
|
+
...classifiedEndpoints.changedEndpoints,
|
|
386
|
+
...classifiedEndpoints.newEndpoints,
|
|
387
|
+
...classifiedEndpoints.removedEndpoints,
|
|
388
|
+
];
|
|
389
|
+
if (allClassified.length > 0) {
|
|
390
|
+
// Scanned endpoints always have full paths — extractResourceFromPath
|
|
391
|
+
// never returns "unknown" for properly resolved paths.
|
|
392
|
+
const resolved = allClassified
|
|
393
|
+
.map((ep) => extractResourceFromPath(ep.path))
|
|
394
|
+
.filter((r, i, arr) => r !== "unknown" && arr.indexOf(r) === i);
|
|
395
|
+
changedResources = resolved.length > 0 ? resolved : ["unknown"];
|
|
396
|
+
}
|
|
397
|
+
else if (classifiedEndpoints.unmatchedFiles.length > 0) {
|
|
398
|
+
// Changed files don't map to any endpoint (e.g. schema, model, or
|
|
399
|
+
// migration changes near route files). Use ["unknown"] so external
|
|
400
|
+
// tests get name-only entries — enough for the LLM to infer coverage
|
|
401
|
+
// from filenames without flooding context with full extraction of
|
|
402
|
+
// hundreds of irrelevant test files.
|
|
403
|
+
changedResources = ["unknown"];
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
changedResources = [];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
332
409
|
let existingTests = [];
|
|
333
410
|
let discoveredRelevantExternalPaths = [];
|
|
334
411
|
try {
|
|
@@ -427,71 +504,30 @@ to produce a unified state file for the test health workflow.
|
|
|
427
504
|
: method === "DELETE"
|
|
428
505
|
? { statusCode: 204, description: "No Content" }
|
|
429
506
|
: { statusCode: 200, description: "OK" };
|
|
430
|
-
const skeletonEndpoints = scannedEndpoints.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
{
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}))
|
|
455
|
-
: parsedDiff
|
|
456
|
-
? (() => {
|
|
457
|
-
const grouped = new Map();
|
|
458
|
-
for (const ep of [
|
|
459
|
-
...parsedDiff.newEndpoints,
|
|
460
|
-
...parsedDiff.modifiedEndpoints,
|
|
461
|
-
]) {
|
|
462
|
-
let entry = grouped.get(ep.path);
|
|
463
|
-
if (!entry) {
|
|
464
|
-
entry = {
|
|
465
|
-
path: ep.path,
|
|
466
|
-
resourceGroup: ep.path.split("/").filter(Boolean).pop() || "unknown",
|
|
467
|
-
pathParams: (ep.path.match(/\{(\w+)\}/g) || []).map((p) => ({
|
|
468
|
-
name: p.slice(1, -1),
|
|
469
|
-
type: "string",
|
|
470
|
-
required: true,
|
|
471
|
-
})),
|
|
472
|
-
methods: [],
|
|
473
|
-
};
|
|
474
|
-
grouped.set(ep.path, entry);
|
|
475
|
-
}
|
|
476
|
-
entry.methods.push({
|
|
477
|
-
method: ep.method,
|
|
478
|
-
description: "",
|
|
479
|
-
queryParams: [],
|
|
480
|
-
authRequired: true,
|
|
481
|
-
sourceFile: ep.sourceFile,
|
|
482
|
-
interactions: [
|
|
483
|
-
{
|
|
484
|
-
description: `${ep.method} ${ep.path}`,
|
|
485
|
-
type: "success",
|
|
486
|
-
request: {},
|
|
487
|
-
response: skeletonResponse(ep.method),
|
|
488
|
-
},
|
|
489
|
-
],
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
return Array.from(grouped.values());
|
|
493
|
-
})()
|
|
494
|
-
: [];
|
|
507
|
+
const skeletonEndpoints = scannedEndpoints.map((ep) => ({
|
|
508
|
+
path: ep.path,
|
|
509
|
+
resourceGroup: ep.path.split("/").filter(Boolean).pop() || "unknown",
|
|
510
|
+
pathParams: (ep.path.match(/\{(\w+)\}/g) || []).map((p) => ({
|
|
511
|
+
name: p.slice(1, -1),
|
|
512
|
+
type: "string",
|
|
513
|
+
required: true,
|
|
514
|
+
})),
|
|
515
|
+
methods: ep.methods.map((m) => ({
|
|
516
|
+
method: m,
|
|
517
|
+
description: "",
|
|
518
|
+
queryParams: [],
|
|
519
|
+
authRequired: true,
|
|
520
|
+
sourceFile: ep.sourceFile,
|
|
521
|
+
interactions: [
|
|
522
|
+
{
|
|
523
|
+
description: `${m} ${ep.path}`,
|
|
524
|
+
type: "success",
|
|
525
|
+
request: {},
|
|
526
|
+
response: skeletonResponse(m),
|
|
527
|
+
},
|
|
528
|
+
],
|
|
529
|
+
})),
|
|
530
|
+
}));
|
|
495
531
|
// ── Step 8: Merge trace interactions ──
|
|
496
532
|
if (traceResult && traceResult.entries.length > 0) {
|
|
497
533
|
for (const entry of traceResult.entries) {
|
|
@@ -536,16 +572,23 @@ to produce a unified state file for the test health workflow.
|
|
|
536
572
|
}
|
|
537
573
|
}
|
|
538
574
|
}
|
|
539
|
-
// ── Step 8.5: Resolve diff-parsed endpoint paths ──
|
|
540
|
-
// The diff parser extracts route-decorator-relative paths (e.g. "/{order_id}")
|
|
541
|
-
// because the router prefix is usually outside the diff hunk. Match against
|
|
542
|
-
// the authoritative scanned endpoints to recover the full API path.
|
|
543
|
-
if (parsedDiff && skeletonEndpoints.length > 0) {
|
|
544
|
-
resolveEndpointPaths(parsedDiff.newEndpoints, skeletonEndpoints);
|
|
545
|
-
resolveEndpointPaths(parsedDiff.modifiedEndpoints, skeletonEndpoints);
|
|
546
|
-
}
|
|
547
575
|
// ── Step 9: Draft scenarios ──
|
|
548
|
-
|
|
576
|
+
// New and changed endpoints get minimal intent-marker scenarios (contract + integration
|
|
577
|
+
// for mutating methods) via draftMinimalScenarios. The LLM enriches these with
|
|
578
|
+
// prerequisite steps and error paths during source-code analysis. Removed endpoints
|
|
579
|
+
// are handled by the LLM from the diffContext.removedEndpoints signal.
|
|
580
|
+
// Classified endpoints have full paths and concrete methods (no MULTI sentinels).
|
|
581
|
+
const newEndpointsForDrafting = classifiedEndpoints?.newEndpoints.flatMap((ep) => ep.methods.map((m) => ({
|
|
582
|
+
method: m,
|
|
583
|
+
path: ep.path,
|
|
584
|
+
sourceFile: ep.sourceFile,
|
|
585
|
+
}))) ?? [];
|
|
586
|
+
const changedEndpointsForDrafting = classifiedEndpoints?.changedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
|
|
587
|
+
method: m,
|
|
588
|
+
path: ep.path,
|
|
589
|
+
sourceFile: ep.sourceFile,
|
|
590
|
+
}))) ?? [];
|
|
591
|
+
const codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints, newEndpointsForDrafting, changedEndpointsForDrafting);
|
|
549
592
|
let allDraftedScenarios = codeInferredScenarios;
|
|
550
593
|
if (traceResult && traceResult.userFlows.length > 0) {
|
|
551
594
|
const traceScenarios = traceResult.userFlows
|
|
@@ -642,35 +685,37 @@ to produce a unified state file for the test health workflow.
|
|
|
642
685
|
const relevantExternalTestPaths = discoveredRelevantExternalPaths.map(p => path.relative(params.repositoryPath, p));
|
|
643
686
|
// Build the full RepositoryAnalysis object — same structure as analyzeRepositoryTool
|
|
644
687
|
// so buildRecommendationPrompt can reason over enriched endpoint + scenario data
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
688
|
+
// Build diffContext from classifiedEndpoints — ScannedEndpoint already
|
|
689
|
+
// has full paths and concrete methods, so no grouping/MULTI handling needed.
|
|
690
|
+
const diffContext = classifiedEndpoints ? {
|
|
691
|
+
currentBranch: classifiedEndpoints.currentBranch,
|
|
692
|
+
baseBranch: classifiedEndpoints.baseBranch,
|
|
693
|
+
changedFiles: classifiedEndpoints.changedFiles,
|
|
694
|
+
newEndpoints: classifiedEndpoints.newEndpoints.map((ep) => ({
|
|
695
|
+
path: ep.path,
|
|
696
|
+
methods: ep.methods.map((m) => ({
|
|
697
|
+
method: m,
|
|
698
|
+
sourceFile: ep.sourceFile,
|
|
699
|
+
interactionCount: 0,
|
|
700
|
+
})),
|
|
701
|
+
})),
|
|
702
|
+
modifiedEndpoints: classifiedEndpoints.changedEndpoints.map((ep) => ({
|
|
703
|
+
path: ep.path,
|
|
704
|
+
methods: ep.methods.map((m) => ({
|
|
705
|
+
method: m,
|
|
706
|
+
sourceFile: ep.sourceFile,
|
|
707
|
+
changeType: "modified",
|
|
708
|
+
})),
|
|
709
|
+
})),
|
|
710
|
+
removedEndpoints: classifiedEndpoints.removedEndpoints.map((ep) => ({
|
|
711
|
+
path: ep.path,
|
|
712
|
+
methods: ep.methods.map((m) => ({
|
|
713
|
+
method: m,
|
|
714
|
+
sourceFile: ep.sourceFile,
|
|
715
|
+
changeType: "removed",
|
|
716
|
+
})),
|
|
717
|
+
})),
|
|
718
|
+
affectedServices: classifiedEndpoints.affectedServices,
|
|
674
719
|
summary: "",
|
|
675
720
|
} : undefined;
|
|
676
721
|
const fullAnalysis = {
|
|
@@ -752,7 +797,7 @@ to produce a unified state file for the test health workflow.
|
|
|
752
797
|
: undefined;
|
|
753
798
|
const unifiedState = {
|
|
754
799
|
existingTests,
|
|
755
|
-
newEndpoints,
|
|
800
|
+
newEndpoints: newEndpointsForDrafting,
|
|
756
801
|
analysisScope,
|
|
757
802
|
repositoryAnalysis: {
|
|
758
803
|
skeletonEndpoints,
|
|
@@ -764,7 +809,35 @@ to produce a unified state file for the test health workflow.
|
|
|
764
809
|
wsSchemaPath,
|
|
765
810
|
wsAuthMethod,
|
|
766
811
|
scenarios: allDraftedScenarios,
|
|
767
|
-
diff:
|
|
812
|
+
diff: classifiedEndpoints
|
|
813
|
+
? {
|
|
814
|
+
currentBranch: classifiedEndpoints.currentBranch,
|
|
815
|
+
baseBranch: classifiedEndpoints.baseBranch,
|
|
816
|
+
changedFiles: classifiedEndpoints.changedFiles,
|
|
817
|
+
newEndpoints: classifiedEndpoints.newEndpoints.map((ep) => ({
|
|
818
|
+
path: ep.path,
|
|
819
|
+
methods: ep.methods.map((m) => ({
|
|
820
|
+
method: m,
|
|
821
|
+
sourceFile: ep.sourceFile,
|
|
822
|
+
})),
|
|
823
|
+
})),
|
|
824
|
+
modifiedEndpoints: classifiedEndpoints.changedEndpoints.map((ep) => ({
|
|
825
|
+
path: ep.path,
|
|
826
|
+
methods: ep.methods.map((m) => ({
|
|
827
|
+
method: m,
|
|
828
|
+
sourceFile: ep.sourceFile,
|
|
829
|
+
})),
|
|
830
|
+
})),
|
|
831
|
+
removedEndpoints: classifiedEndpoints.removedEndpoints.map((ep) => ({
|
|
832
|
+
path: ep.path,
|
|
833
|
+
methods: ep.methods.map((m) => ({
|
|
834
|
+
method: m,
|
|
835
|
+
sourceFile: ep.sourceFile,
|
|
836
|
+
})),
|
|
837
|
+
})),
|
|
838
|
+
affectedServices: classifiedEndpoints.affectedServices,
|
|
839
|
+
}
|
|
840
|
+
: undefined,
|
|
768
841
|
sessionId,
|
|
769
842
|
routerMountContext,
|
|
770
843
|
candidateRouteFiles,
|
|
@@ -780,6 +853,24 @@ to produce a unified state file for the test health workflow.
|
|
|
780
853
|
catch (error) {
|
|
781
854
|
logger.warning(`Failed to cleanup old state files: ${error.message}`);
|
|
782
855
|
}
|
|
856
|
+
// Clean up old diff temp files (>24 hours) from previous invocations
|
|
857
|
+
try {
|
|
858
|
+
const tmpDir = os.tmpdir();
|
|
859
|
+
const entries = await fs.promises.readdir(tmpDir);
|
|
860
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
861
|
+
for (const entry of entries) {
|
|
862
|
+
if (!entry.startsWith("skyramp-diff-") || !entry.endsWith(".diff"))
|
|
863
|
+
continue;
|
|
864
|
+
const fullPath = path.join(tmpDir, entry);
|
|
865
|
+
const stat = await fs.promises.stat(fullPath).catch(() => null);
|
|
866
|
+
if (stat && stat.mtimeMs < cutoff) {
|
|
867
|
+
await fs.promises.unlink(fullPath).catch(() => { });
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
// Non-critical — temp cleanup failure should not block analysis
|
|
873
|
+
}
|
|
783
874
|
const stateManager = new StateManager("analysis", sessionId, undefined, params.stateOutputFile);
|
|
784
875
|
await stateManager.writeData(unifiedState, {
|
|
785
876
|
repositoryPath: params.repositoryPath,
|
|
@@ -842,18 +933,51 @@ to produce a unified state file for the test health workflow.
|
|
|
842
933
|
projectType: projectMeta.projectType,
|
|
843
934
|
primaryFramework: projectMeta.primaryFramework,
|
|
844
935
|
existingTestCount: existingTests.length,
|
|
845
|
-
newEndpointCount: newEndpoints.length,
|
|
936
|
+
newEndpointCount: classifiedEndpoints?.newEndpoints.length ?? 0,
|
|
846
937
|
endpointCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
|
|
847
938
|
},
|
|
848
939
|
stateFileSize: stateSize,
|
|
849
940
|
nextStep: "Call skyramp_analyze_test_health with stateFile to run drift analysis and health scoring",
|
|
850
941
|
}, null, 2);
|
|
942
|
+
// Build a DiffSummary for buildAnalysisOutputText from classified endpoints.
|
|
943
|
+
const parsedDiffShim = classifiedEndpoints
|
|
944
|
+
? {
|
|
945
|
+
currentBranch: classifiedEndpoints.currentBranch,
|
|
946
|
+
baseBranch: classifiedEndpoints.baseBranch,
|
|
947
|
+
changedFiles: classifiedEndpoints.changedFiles,
|
|
948
|
+
diffStat: diffData?.diffStat ?? "",
|
|
949
|
+
newEndpoints: classifiedEndpoints.newEndpoints.flatMap((ep) => ep.methods.map((m) => ({
|
|
950
|
+
method: m,
|
|
951
|
+
path: ep.path,
|
|
952
|
+
sourceFile: ep.sourceFile,
|
|
953
|
+
}))),
|
|
954
|
+
modifiedEndpoints: classifiedEndpoints.changedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
|
|
955
|
+
method: m,
|
|
956
|
+
path: ep.path,
|
|
957
|
+
sourceFile: ep.sourceFile,
|
|
958
|
+
}))),
|
|
959
|
+
removedEndpoints: classifiedEndpoints.removedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
|
|
960
|
+
method: m,
|
|
961
|
+
path: ep.path,
|
|
962
|
+
sourceFile: ep.sourceFile,
|
|
963
|
+
}))),
|
|
964
|
+
affectedServices: classifiedEndpoints.affectedServices,
|
|
965
|
+
}
|
|
966
|
+
: undefined;
|
|
967
|
+
// Write the full diff to a temp file so the LLM can read it on demand
|
|
968
|
+
// rather than embedding potentially large content inline in the prompt.
|
|
969
|
+
let diffFilePath;
|
|
970
|
+
if (diffData?.diffContent) {
|
|
971
|
+
diffFilePath = path.join(os.tmpdir(), `skyramp-diff-${sessionId}.diff`);
|
|
972
|
+
await fs.promises.writeFile(diffFilePath, diffData.diffContent, { encoding: "utf-8", mode: 0o600 });
|
|
973
|
+
}
|
|
851
974
|
const outputText = buildAnalysisOutputText({
|
|
852
975
|
sessionId,
|
|
853
976
|
stateFile,
|
|
854
977
|
repositoryPath: params.repositoryPath,
|
|
855
978
|
analysisScope,
|
|
856
|
-
parsedDiff,
|
|
979
|
+
parsedDiff: parsedDiffShim,
|
|
980
|
+
diffFilePath,
|
|
857
981
|
diffContent: diffData?.diffContent,
|
|
858
982
|
candidateRouteFiles,
|
|
859
983
|
scannedEndpoints,
|
|
@@ -21,7 +21,9 @@ jest.mock("../../utils/branchDiff.js", () => ({
|
|
|
21
21
|
computeBranchDiff: jest.fn(),
|
|
22
22
|
}));
|
|
23
23
|
jest.mock("../../utils/routeParsers.js", () => ({
|
|
24
|
-
|
|
24
|
+
classifyEndpointsByChangedFiles: jest.fn(),
|
|
25
|
+
extractResourceFromPath: jest.fn(),
|
|
26
|
+
parseFileEndpoints: jest.fn(),
|
|
25
27
|
}));
|
|
26
28
|
jest.mock("../../utils/repoScanner.js", () => ({
|
|
27
29
|
scanAllRepoEndpoints: jest.fn(),
|