@skyramp/mcp 0.0.59 → 0.0.60-rc.2
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/index.js +32 -5
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +98 -0
- package/build/prompts/test-recommendation/recommendationSections.js +226 -0
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +71 -0
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +166 -104
- package/build/prompts/testGenerationPrompt.js +2 -3
- package/build/prompts/testbot/testbot-prompts.js +96 -93
- package/build/resources/analysisResources.js +254 -0
- package/build/services/ScenarioGenerationService.js +70 -26
- package/build/tools/generate-tests/generateIntegrationRestTool.js +54 -1
- package/build/tools/generate-tests/generateScenarioRestTool.js +8 -5
- package/build/tools/submitReportTool.js +28 -0
- package/build/tools/test-maintenance/stateCleanupTool.js +8 -0
- package/build/tools/test-recommendation/analyzeRepositoryTool.js +349 -217
- package/build/tools/test-recommendation/recommendTestsTool.js +163 -159
- package/build/tools/workspace/initializeWorkspaceTool.js +1 -1
- package/build/types/RepositoryAnalysis.js +99 -12
- package/build/utils/AnalysisStateManager.js +40 -23
- package/build/utils/branchDiff.js +47 -0
- package/build/utils/pr-comment-parser.js +124 -0
- package/build/utils/projectMetadata.js +188 -0
- package/build/utils/projectMetadata.test.js +81 -0
- package/build/utils/repoScanner.js +378 -0
- package/build/utils/routeParsers.js +213 -0
- package/build/utils/routeParsers.test.js +87 -0
- package/build/utils/scenarioDrafting.js +119 -0
- package/build/utils/scenarioDrafting.test.js +66 -0
- package/build/utils/trace-parser.js +166 -0
- package/build/utils/workspaceAuth.js +16 -0
- package/package.json +1 -1
- package/build/prompts/test-recommendation/repository-analysis-prompt.js +0 -326
- package/build/prompts/test-recommendation/test-mapping-prompt.js +0 -266
- package/build/tools/test-recommendation/mapTestsTool.js +0 -243
- package/build/types/TestMapping.js +0 -173
- package/build/utils/scoring-engine.js +0 -380
|
@@ -1,174 +1,18 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import
|
|
2
|
+
import * as crypto from "crypto";
|
|
3
|
+
import * as path from "path";
|
|
3
4
|
import { logger } from "../../utils/logger.js";
|
|
4
|
-
import { getRepositoryAnalysisPrompt } from "../../prompts/test-recommendation/repository-analysis-prompt.js";
|
|
5
5
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (match) {
|
|
17
|
-
files.push(match[1]);
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
return files;
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Extract an API endpoint definition from a single diff line.
|
|
24
|
-
* Handles FastAPI, Flask, Express, and Spring patterns.
|
|
25
|
-
*/
|
|
26
|
-
function parseRouteLine(line, sourceFile) {
|
|
27
|
-
const stripped = line.replace(/^[+-]\s*/, "").trim();
|
|
28
|
-
// FastAPI / Starlette: @router.get("/path") or @app.post("/path")
|
|
29
|
-
const decoratorMatch = stripped.match(/@(?:\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*["']([^"'?#]+)/i);
|
|
30
|
-
if (decoratorMatch) {
|
|
31
|
-
return {
|
|
32
|
-
method: decoratorMatch[1].toUpperCase(),
|
|
33
|
-
path: decoratorMatch[2],
|
|
34
|
-
sourceFile,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
// Flask: @app.route("/path", methods=["GET"])
|
|
38
|
-
const flaskMatch = stripped.match(/@\w+\.route\s*\(\s*["']([^"'?#]+)["'][^)]*methods\s*=\s*\[["'](\w+)["']/i);
|
|
39
|
-
if (flaskMatch) {
|
|
40
|
-
return {
|
|
41
|
-
method: flaskMatch[2].toUpperCase(),
|
|
42
|
-
path: flaskMatch[1],
|
|
43
|
-
sourceFile,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
// Express: router.get('/path', ...) or app.post('/path', ...)
|
|
47
|
-
const expressMatch = stripped.match(/(?:router|app)\.(get|post|put|patch|delete)\s*\(\s*["']([^"'?#]+)/i);
|
|
48
|
-
if (expressMatch) {
|
|
49
|
-
return {
|
|
50
|
-
method: expressMatch[1].toUpperCase(),
|
|
51
|
-
path: expressMatch[2],
|
|
52
|
-
sourceFile,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
// Spring: @GetMapping("/path") or @PostMapping("/path")
|
|
56
|
-
const springMatch = stripped.match(/@(Get|Post|Put|Patch|Delete)Mapping\s*(?:\(\s*(?:value\s*=\s*)?["']([^"'?#]+)["'])?/i);
|
|
57
|
-
// Only capture Spring endpoints when an explicit path is provided.
|
|
58
|
-
// When no path argument is given, Spring uses the class-level @RequestMapping path,
|
|
59
|
-
// which we do not track here.
|
|
60
|
-
if (springMatch && springMatch[2]) {
|
|
61
|
-
return {
|
|
62
|
-
method: springMatch[1].toUpperCase(),
|
|
63
|
-
path: springMatch[2],
|
|
64
|
-
sourceFile,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Parse a unified diff and extract new/modified API endpoints.
|
|
71
|
-
* Processes diffContent entirely inside the tool — never embedded in the response.
|
|
72
|
-
*/
|
|
73
|
-
function parseEndpointsFromDiff(diffData) {
|
|
74
|
-
const lines = diffData.diffContent.split("\n");
|
|
75
|
-
const addedRoutes = [];
|
|
76
|
-
const removedKeys = new Set();
|
|
77
|
-
let currentFile = "";
|
|
78
|
-
for (const line of lines) {
|
|
79
|
-
const fileMatch = line.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
80
|
-
if (fileMatch) {
|
|
81
|
-
currentFile = fileMatch[1];
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
85
|
-
const ep = parseRouteLine(line, currentFile);
|
|
86
|
-
if (ep)
|
|
87
|
-
addedRoutes.push(ep);
|
|
88
|
-
}
|
|
89
|
-
else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
90
|
-
const ep = parseRouteLine(line, currentFile);
|
|
91
|
-
if (ep)
|
|
92
|
-
removedKeys.add(`${ep.method} ${ep.path}`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
const newEndpoints = [];
|
|
96
|
-
const modifiedEndpoints = [];
|
|
97
|
-
for (const ep of addedRoutes) {
|
|
98
|
-
if (removedKeys.has(`${ep.method} ${ep.path}`)) {
|
|
99
|
-
modifiedEndpoints.push(ep);
|
|
100
|
-
}
|
|
101
|
-
else {
|
|
102
|
-
newEndpoints.push(ep);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
// Infer services from changed file paths (e.g. src/services/order-service.ts)
|
|
106
|
-
const servicePattern = /(?:services?|modules?|apps?)\/([a-z0-9_-]+)/i;
|
|
107
|
-
const affectedServices = [
|
|
108
|
-
...new Set(diffData.changedFiles
|
|
109
|
-
.map((f) => f.match(servicePattern)?.[1])
|
|
110
|
-
.filter((s) => !!s)),
|
|
111
|
-
];
|
|
112
|
-
return {
|
|
113
|
-
currentBranch: diffData.currentBranch,
|
|
114
|
-
baseBranch: diffData.baseBranch,
|
|
115
|
-
changedFiles: diffData.changedFiles,
|
|
116
|
-
diffStat: diffData.diffStat,
|
|
117
|
-
newEndpoints,
|
|
118
|
-
modifiedEndpoints,
|
|
119
|
-
affectedServices,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
async function computeBranchDiff(repositoryPath, providedBaseBranch) {
|
|
123
|
-
const git = simpleGit(repositoryPath);
|
|
124
|
-
const isRepo = await git.checkIsRepo();
|
|
125
|
-
if (!isRepo) {
|
|
126
|
-
throw new Error(`"${repositoryPath}" is not a Git repository.`);
|
|
127
|
-
}
|
|
128
|
-
const branchInfo = await git.branch();
|
|
129
|
-
const currentBranch = branchInfo.current || "HEAD";
|
|
130
|
-
let baseBranch;
|
|
131
|
-
if (providedBaseBranch) {
|
|
132
|
-
// Use the PR's base branch when explicitly provided (e.g. from testbot)
|
|
133
|
-
baseBranch = `origin/${providedBaseBranch}`;
|
|
134
|
-
}
|
|
135
|
-
else {
|
|
136
|
-
// Fall back to auto-detecting origin/main or origin/master
|
|
137
|
-
baseBranch = "origin/main";
|
|
138
|
-
try {
|
|
139
|
-
const remoteBranches = await git.branch(["-r"]);
|
|
140
|
-
if (remoteBranches.all.some((b) => b.endsWith("/main"))) {
|
|
141
|
-
baseBranch = "origin/main";
|
|
142
|
-
}
|
|
143
|
-
else if (remoteBranches.all.some((b) => b.endsWith("/master"))) {
|
|
144
|
-
baseBranch = "origin/master";
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
logger.debug("Could not determine remote default branch, falling back to origin/main");
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
const changedFilesRaw = await git.diff([
|
|
152
|
-
`${baseBranch}...HEAD`,
|
|
153
|
-
"--name-only",
|
|
154
|
-
]);
|
|
155
|
-
const changedFiles = changedFilesRaw
|
|
156
|
-
.split("\n")
|
|
157
|
-
.map((f) => f.trim())
|
|
158
|
-
.filter((f) => f.length > 0);
|
|
159
|
-
const diffStat = await git.diff([`${baseBranch}...HEAD`, "--stat"]);
|
|
160
|
-
let diffContent = await git.diff([`${baseBranch}...HEAD`]);
|
|
161
|
-
if (diffContent.length > MAX_DIFF_LENGTH) {
|
|
162
|
-
diffContent =
|
|
163
|
-
diffContent.substring(0, MAX_DIFF_LENGTH) +
|
|
164
|
-
`\n\n... [diff truncated at ${MAX_DIFF_LENGTH} chars, ${changedFiles.length} files total] ...`;
|
|
165
|
-
}
|
|
166
|
-
return { currentBranch, baseBranch, changedFiles, diffContent, diffStat };
|
|
167
|
-
}
|
|
168
|
-
/**
|
|
169
|
-
* Analyze Repository Tool
|
|
170
|
-
* MCP tool for comprehensive repository analysis
|
|
171
|
-
*/
|
|
6
|
+
import { registerSession, storeSessionData, } from "../../utils/AnalysisStateManager.js";
|
|
7
|
+
import { WorkspaceConfigManager } from "@skyramp/skyramp";
|
|
8
|
+
import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
|
|
9
|
+
import { computeBranchDiff } from "../../utils/branchDiff.js";
|
|
10
|
+
import { parseEndpointsFromDiff } from "../../utils/routeParsers.js";
|
|
11
|
+
import { scanAllRepoEndpoints, scanRelatedEndpoints, grepRouterMountingContext } from "../../utils/repoScanner.js";
|
|
12
|
+
import { detectProjectMetadata } from "../../utils/projectMetadata.js";
|
|
13
|
+
import { draftScenariosFromEndpoints } from "../../utils/scenarioDrafting.js";
|
|
14
|
+
import { buildAnalysisOutputText } from "../../prompts/test-recommendation/analysisOutputPrompt.js";
|
|
15
|
+
import { parseTraceFile, discoverTraceFiles } from "../../utils/trace-parser.js";
|
|
172
16
|
const analyzeRepositorySchema = z.object({
|
|
173
17
|
repositoryPath: z
|
|
174
18
|
.string()
|
|
@@ -181,10 +25,6 @@ const analyzeRepositorySchema = z.object({
|
|
|
181
25
|
.enum(["full_repo", "current_branch_diff"])
|
|
182
26
|
.default("full_repo")
|
|
183
27
|
.describe("Scope of analysis. 'full_repo' analyzes the entire repository. 'current_branch_diff' analyzes only the changes in the current branch compared to the default branch (e.g., main/master), useful for PR-scoped test recommendations."),
|
|
184
|
-
focusAreas: z
|
|
185
|
-
.array(z.string())
|
|
186
|
-
.optional()
|
|
187
|
-
.describe("Optional: Specific areas to focus on (e.g., ['api', 'frontend', 'infrastructure'])"),
|
|
188
28
|
baseBranch: z
|
|
189
29
|
.string()
|
|
190
30
|
.optional()
|
|
@@ -219,7 +59,8 @@ This tool performs comprehensive repository analysis including:
|
|
|
219
59
|
- Infrastructure configuration (Docker, Kubernetes, CI/CD)
|
|
220
60
|
- Existing test coverage assessment
|
|
221
61
|
|
|
222
|
-
The analysis provides
|
|
62
|
+
The analysis provides enriched endpoint data (interactions, headers, cookies) and
|
|
63
|
+
drafted scenarios that can be used by skyramp_recommend_tests for LLM-powered recommendations.
|
|
223
64
|
|
|
224
65
|
When \`analysisScope\` is set to \`"current_branch_diff"\`, the analysis focuses specifically on the code changes in the current branch — identifying new/modified endpoints, changed services, and affected areas — rather than scanning the entire repository. This is ideal for PR-scoped test recommendations.
|
|
225
66
|
|
|
@@ -237,8 +78,18 @@ Example usage:
|
|
|
237
78
|
|
|
238
79
|
Output: Detailed RepositoryAnalysis JSON object with all repository characteristics.`,
|
|
239
80
|
inputSchema: analyzeRepositorySchema.shape,
|
|
240
|
-
}, async (params) => {
|
|
81
|
+
}, async (params, extra) => {
|
|
241
82
|
let errorResult;
|
|
83
|
+
const sendProgress = async (progress, total, message) => {
|
|
84
|
+
const progressToken = extra._meta?.progressToken;
|
|
85
|
+
if (progressToken !== undefined) {
|
|
86
|
+
const notification = {
|
|
87
|
+
method: "notifications/progress",
|
|
88
|
+
params: { progressToken, progress, total, message },
|
|
89
|
+
};
|
|
90
|
+
await extra.sendNotification(notification);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
242
93
|
try {
|
|
243
94
|
const analysisScope = params.analysisScope || "full_repo";
|
|
244
95
|
logger.info("Analyze repository tool invoked", {
|
|
@@ -246,8 +97,10 @@ Output: Detailed RepositoryAnalysis JSON object with all repository characterist
|
|
|
246
97
|
scanDepth: params.scanDepth,
|
|
247
98
|
analysisScope,
|
|
248
99
|
});
|
|
100
|
+
await sendProgress(0, 100, "Starting repository analysis...");
|
|
249
101
|
let diffData;
|
|
250
102
|
if (analysisScope === "current_branch_diff") {
|
|
103
|
+
await sendProgress(10, 100, "Computing branch diff...");
|
|
251
104
|
try {
|
|
252
105
|
diffData = await computeBranchDiff(params.repositoryPath, params.baseBranch);
|
|
253
106
|
logger.info("Branch diff computed via git", {
|
|
@@ -262,53 +115,332 @@ Output: Detailed RepositoryAnalysis JSON object with all repository characterist
|
|
|
262
115
|
throw new Error(`Failed to obtain branch diff: ${msg}`);
|
|
263
116
|
}
|
|
264
117
|
}
|
|
265
|
-
|
|
118
|
+
await sendProgress(30, 100, "Parsing endpoints from diff...");
|
|
266
119
|
const parsedDiff = diffData ? parseEndpointsFromDiff(diffData) : undefined;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
120
|
+
// In PR mode, skip the expensive full-repo scan — the LLM will
|
|
121
|
+
// discover relevant endpoints from the diff and handler code.
|
|
122
|
+
// In full-repo mode, scan to give the LLM a head start.
|
|
123
|
+
let scannedEndpoints = [];
|
|
124
|
+
if (analysisScope !== "current_branch_diff") {
|
|
125
|
+
await sendProgress(40, 100, "Scanning repository for all endpoints...");
|
|
126
|
+
try {
|
|
127
|
+
scannedEndpoints = scanAllRepoEndpoints(params.repositoryPath);
|
|
128
|
+
logger.info("Pre-scanned repo endpoints", { count: scannedEndpoints.length });
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
logger.warning("Endpoint pre-scan failed, LLM will discover endpoints", {
|
|
132
|
+
error: err instanceof Error ? err.message : String(err),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else if (diffData) {
|
|
137
|
+
await sendProgress(40, 100, "Scanning related endpoints from diff...");
|
|
138
|
+
try {
|
|
139
|
+
scannedEndpoints = scanRelatedEndpoints(params.repositoryPath, diffData.changedFiles);
|
|
140
|
+
logger.info("Scanned related endpoints for PR scope", { count: scannedEndpoints.length });
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
logger.warning("Related endpoint scan failed", {
|
|
144
|
+
error: err instanceof Error ? err.message : String(err),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (scannedEndpoints.length === 0) {
|
|
148
|
+
await sendProgress(45, 100, "No related endpoints found, scanning full repo...");
|
|
149
|
+
try {
|
|
150
|
+
scannedEndpoints = scanAllRepoEndpoints(params.repositoryPath);
|
|
151
|
+
logger.info("Fallback: scanned all repo endpoints", { count: scannedEndpoints.length });
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
logger.warning("Full repo endpoint scan also failed", {
|
|
155
|
+
error: err instanceof Error ? err.message : String(err),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
await sendProgress(50, 100, "Building analysis session...");
|
|
161
|
+
// Read workspace config for baseUrl and auth info
|
|
162
|
+
let wsBaseUrl = "";
|
|
163
|
+
let wsAuthHeader = "";
|
|
164
|
+
let wsSchemaPath = "";
|
|
165
|
+
let wsAuthMethod = "none";
|
|
166
|
+
try {
|
|
167
|
+
const wsMgr = new WorkspaceConfigManager(params.repositoryPath);
|
|
168
|
+
if (await wsMgr.exists()) {
|
|
169
|
+
const wsConfig = await wsMgr.read();
|
|
170
|
+
const svc = wsConfig.services?.[0];
|
|
171
|
+
if (svc?.api?.baseUrl)
|
|
172
|
+
wsBaseUrl = svc.api.baseUrl;
|
|
173
|
+
if (svc?.api?.authHeader)
|
|
174
|
+
wsAuthHeader = svc.api.authHeader;
|
|
175
|
+
if (svc?.api?.schemaPath)
|
|
176
|
+
wsSchemaPath = svc.api.schemaPath;
|
|
177
|
+
if (wsAuthHeader) {
|
|
178
|
+
wsAuthMethod = /cookie|session/i.test(wsAuthHeader) ? "session" : "bearer";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// workspace config not available
|
|
184
|
+
}
|
|
185
|
+
// Auto-detect project metadata from files
|
|
186
|
+
const projectMeta = detectProjectMetadata(params.repositoryPath);
|
|
187
|
+
const sessionId = crypto.randomUUID();
|
|
188
|
+
// Build skeleton endpoints — from full scan or diff-only
|
|
189
|
+
const skeletonEndpoints = scannedEndpoints.length > 0
|
|
190
|
+
? scannedEndpoints.map((ep) => ({
|
|
191
|
+
path: ep.path,
|
|
192
|
+
resourceGroup: ep.path.split("/").filter(Boolean).pop() || "unknown",
|
|
193
|
+
pathParams: (ep.path.match(/\{(\w+)\}/g) || []).map((p) => ({
|
|
194
|
+
name: p.slice(1, -1),
|
|
195
|
+
type: "string",
|
|
196
|
+
required: true,
|
|
197
|
+
})),
|
|
198
|
+
methods: ep.methods.map((m) => ({
|
|
199
|
+
method: m,
|
|
200
|
+
description: "",
|
|
201
|
+
queryParams: [],
|
|
202
|
+
authRequired: true,
|
|
203
|
+
sourceFile: ep.sourceFile,
|
|
204
|
+
interactions: [{
|
|
205
|
+
description: `${m} ${ep.path}`,
|
|
206
|
+
type: "success",
|
|
207
|
+
request: {},
|
|
208
|
+
response: { statusCode: 200, description: "OK" },
|
|
209
|
+
}],
|
|
210
|
+
})),
|
|
211
|
+
}))
|
|
212
|
+
: (parsedDiff
|
|
213
|
+
? [...parsedDiff.newEndpoints, ...parsedDiff.modifiedEndpoints].map((ep) => ({
|
|
214
|
+
path: ep.path,
|
|
215
|
+
resourceGroup: ep.path.split("/").filter(Boolean).pop() || "unknown",
|
|
216
|
+
pathParams: (ep.path.match(/\{(\w+)\}/g) || []).map((p) => ({
|
|
217
|
+
name: p.slice(1, -1),
|
|
218
|
+
type: "string",
|
|
219
|
+
required: true,
|
|
220
|
+
})),
|
|
221
|
+
methods: [{
|
|
222
|
+
method: ep.method,
|
|
223
|
+
description: "",
|
|
224
|
+
queryParams: [],
|
|
225
|
+
authRequired: true,
|
|
226
|
+
sourceFile: ep.sourceFile,
|
|
227
|
+
interactions: [{
|
|
228
|
+
description: `${ep.method} ${ep.path}`,
|
|
229
|
+
type: "success",
|
|
230
|
+
request: {},
|
|
231
|
+
response: { statusCode: 200, description: "OK" },
|
|
232
|
+
}],
|
|
233
|
+
}],
|
|
234
|
+
}))
|
|
235
|
+
: []);
|
|
236
|
+
const diffContext = parsedDiff ? {
|
|
237
|
+
currentBranch: parsedDiff.currentBranch,
|
|
238
|
+
baseBranch: parsedDiff.baseBranch,
|
|
239
|
+
changedFiles: parsedDiff.changedFiles,
|
|
240
|
+
newEndpoints: parsedDiff.newEndpoints.map((ep) => ({
|
|
241
|
+
path: ep.path,
|
|
242
|
+
methods: [{ method: ep.method, sourceFile: ep.sourceFile, interactionCount: 0 }],
|
|
243
|
+
})),
|
|
244
|
+
modifiedEndpoints: parsedDiff.modifiedEndpoints.map((ep) => ({
|
|
245
|
+
path: ep.path,
|
|
246
|
+
methods: [{ method: ep.method, sourceFile: ep.sourceFile, changeType: "modified" }],
|
|
247
|
+
})),
|
|
248
|
+
affectedServices: parsedDiff.affectedServices,
|
|
249
|
+
summary: "",
|
|
250
|
+
} : undefined;
|
|
251
|
+
// Run TestDiscoveryService to find existing Skyramp-generated tests
|
|
252
|
+
let discoveredTests = {
|
|
253
|
+
frameworks: [], coverage: { unit: 0, integration: 0, e2e: 0, ui: 0, load: 0, contract: 0, smoke: 0 }, testLocations: {}, hasCoverageReports: false,
|
|
254
|
+
};
|
|
255
|
+
try {
|
|
256
|
+
const discoveryService = new TestDiscoveryService();
|
|
257
|
+
const discoveryResult = await discoveryService.discoverTests(params.repositoryPath);
|
|
258
|
+
const byType = new Map();
|
|
259
|
+
for (const test of discoveryResult.tests) {
|
|
260
|
+
const type = test.testType || "unknown";
|
|
261
|
+
if (!byType.has(type))
|
|
262
|
+
byType.set(type, []);
|
|
263
|
+
byType.get(type).push(test.testFile);
|
|
264
|
+
if (test.framework && !discoveredTests.frameworks.includes(test.framework)) {
|
|
265
|
+
discoveredTests.frameworks.push(test.framework);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const [type, files] of byType) {
|
|
269
|
+
discoveredTests.testLocations[type] = files.join(", ");
|
|
270
|
+
}
|
|
271
|
+
logger.info("Test discovery completed", { totalFiles: discoveryResult.tests.length, types: Object.fromEntries(Array.from(byType.entries()).map(([k, v]) => [k, v.length])) });
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
logger.warning("TestDiscoveryService failed, existingTests will be empty", { error: err instanceof Error ? err.message : String(err) });
|
|
275
|
+
}
|
|
276
|
+
// I1/I2: Discover and parse trace files for enrichment
|
|
277
|
+
let traceResult;
|
|
278
|
+
const traceFiles = discoverTraceFiles(params.repositoryPath);
|
|
279
|
+
if (traceFiles.length > 0) {
|
|
280
|
+
try {
|
|
281
|
+
traceResult = await parseTraceFile(traceFiles[0]);
|
|
282
|
+
logger.info("Parsed trace file", {
|
|
283
|
+
file: traceFiles[0],
|
|
284
|
+
format: traceResult.format,
|
|
285
|
+
entries: traceResult.entries.length,
|
|
286
|
+
flows: traceResult.userFlows.length,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
logger.warning("Trace parsing failed", { error: err instanceof Error ? err.message : String(err) });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// I4: Merge trace-derived interactions into skeleton endpoints
|
|
294
|
+
if (traceResult && traceResult.entries.length > 0) {
|
|
295
|
+
for (const entry of traceResult.entries) {
|
|
296
|
+
const normalizedPath = entry.path.replace(/\/[0-9a-f-]{20,}/gi, "/{id}").replace(/\/\d+/g, "/{id}");
|
|
297
|
+
const existing = skeletonEndpoints.find(ep => {
|
|
298
|
+
const epNorm = ep.path.replace(/\{\w+\}/g, "/{id}");
|
|
299
|
+
return epNorm === normalizedPath;
|
|
300
|
+
});
|
|
301
|
+
if (existing) {
|
|
302
|
+
const methodObj = existing.methods.find(m => m.method === entry.method);
|
|
303
|
+
if (methodObj) {
|
|
304
|
+
const alreadyHasStatus = methodObj.interactions.some(i => i.response.statusCode === entry.statusCode);
|
|
305
|
+
if (!alreadyHasStatus) {
|
|
306
|
+
methodObj.interactions.push({
|
|
307
|
+
description: `${entry.method} ${entry.path} \u2192 ${entry.statusCode} (from trace)`,
|
|
308
|
+
type: "success",
|
|
309
|
+
request: entry.requestBody ? { body: entry.requestBody } : {},
|
|
310
|
+
response: {
|
|
311
|
+
statusCode: entry.statusCode,
|
|
312
|
+
description: `Observed in trace (${traceResult.format})`,
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Combine trace-derived + code-inferred scenarios
|
|
321
|
+
const codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints);
|
|
322
|
+
let allDraftedScenarios = codeInferredScenarios;
|
|
323
|
+
if (traceResult && traceResult.userFlows.length > 0) {
|
|
324
|
+
const traceScenarios = traceResult.userFlows.slice(0, 5).map((flow, idx) => ({
|
|
325
|
+
scenarioName: `trace-flow-${idx + 1}`,
|
|
326
|
+
description: `User flow from trace: ${flow.entries.map(e => `${e.method} ${e.path}`).join(" \u2192 ")}`,
|
|
327
|
+
category: "workflow",
|
|
328
|
+
priority: "high",
|
|
329
|
+
steps: flow.entries.map((e, stepIdx) => ({
|
|
330
|
+
order: stepIdx + 1,
|
|
331
|
+
method: e.method,
|
|
332
|
+
path: e.path,
|
|
333
|
+
description: `${e.method} ${e.path} \u2192 ${e.statusCode}`,
|
|
334
|
+
interactionType: e.statusCode < 400 ? "success" : "error",
|
|
335
|
+
requestBody: e.requestBody,
|
|
336
|
+
expectedStatusCode: e.statusCode,
|
|
337
|
+
})),
|
|
338
|
+
chainingKeys: [],
|
|
339
|
+
requiresAuth: true,
|
|
340
|
+
estimatedComplexity: flow.entries.length > 3 ? "complex" : "moderate",
|
|
341
|
+
source: "trace",
|
|
342
|
+
}));
|
|
343
|
+
allDraftedScenarios = [...traceScenarios, ...codeInferredScenarios];
|
|
344
|
+
}
|
|
345
|
+
const skeletonState = {
|
|
346
|
+
repositoryPath: params.repositoryPath,
|
|
347
|
+
analysisScope,
|
|
348
|
+
analysis: {
|
|
349
|
+
metadata: {
|
|
350
|
+
repositoryName: path.basename(params.repositoryPath),
|
|
351
|
+
analysisDate: new Date().toISOString(),
|
|
352
|
+
scanDepth: params.scanDepth || "full",
|
|
353
|
+
analysisScope,
|
|
354
|
+
},
|
|
355
|
+
projectClassification: {
|
|
356
|
+
projectType: projectMeta.projectType,
|
|
357
|
+
primaryLanguage: projectMeta.primaryLanguage,
|
|
358
|
+
primaryFramework: projectMeta.primaryFramework,
|
|
359
|
+
deploymentPattern: projectMeta.deploymentPattern,
|
|
360
|
+
},
|
|
361
|
+
technologyStack: { languages: projectMeta.languages, frameworks: projectMeta.frameworks, runtime: projectMeta.runtime, keyDependencies: [] },
|
|
362
|
+
businessContext: {
|
|
363
|
+
mainPurpose: "",
|
|
364
|
+
userFlows: [],
|
|
365
|
+
dataFlows: [],
|
|
366
|
+
integrationPatterns: [],
|
|
367
|
+
draftedScenarios: allDraftedScenarios,
|
|
368
|
+
},
|
|
369
|
+
artifacts: {
|
|
370
|
+
openApiSpecs: wsSchemaPath ? [{
|
|
371
|
+
path: wsSchemaPath,
|
|
372
|
+
version: "from-workspace-config",
|
|
373
|
+
endpointCount: 0,
|
|
374
|
+
baseUrl: wsBaseUrl,
|
|
375
|
+
authType: wsAuthMethod,
|
|
376
|
+
}] : [],
|
|
377
|
+
playwrightRecordings: [], traceFiles: [], notFound: [],
|
|
378
|
+
},
|
|
379
|
+
apiEndpoints: {
|
|
380
|
+
totalCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
|
|
381
|
+
baseUrl: wsBaseUrl,
|
|
382
|
+
endpoints: skeletonEndpoints,
|
|
383
|
+
},
|
|
384
|
+
authentication: {
|
|
385
|
+
method: wsAuthMethod,
|
|
386
|
+
configLocation: wsAuthHeader ? ".skyramp/workspace.yml" : "",
|
|
387
|
+
envVarsRequired: [],
|
|
388
|
+
setupExample: wsAuthHeader ? `${wsAuthHeader}: <token>` : "",
|
|
389
|
+
},
|
|
390
|
+
infrastructure: { isContainerized: projectMeta.isContainerized, hasDockerCompose: projectMeta.hasDockerCompose, hasKubernetes: false, hasCiCd: projectMeta.hasCiCd },
|
|
391
|
+
existingTests: discoveredTests,
|
|
392
|
+
...(diffContext ? { branchDiffContext: diffContext } : {}),
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
storeSessionData(sessionId, skeletonState);
|
|
396
|
+
registerSession(sessionId, `memory://${sessionId}`);
|
|
397
|
+
logger.info("Stored analysis skeleton in process memory", {
|
|
398
|
+
sessionId,
|
|
399
|
+
endpointCount: skeletonEndpoints.length,
|
|
272
400
|
});
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
401
|
+
// F2: Notify clients that new MCP resources are available
|
|
402
|
+
try {
|
|
403
|
+
await server.server.sendResourceListChanged();
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
// Client may not support resource list notifications
|
|
407
|
+
}
|
|
408
|
+
await sendProgress(70, 100, "Preparing LLM instructions...");
|
|
409
|
+
const routerMountContext = grepRouterMountingContext(params.repositoryPath);
|
|
410
|
+
await sendProgress(100, 100, "Analysis session ready.");
|
|
411
|
+
const outputText = buildAnalysisOutputText({
|
|
412
|
+
sessionId,
|
|
413
|
+
repositoryPath: params.repositoryPath,
|
|
414
|
+
analysisScope,
|
|
415
|
+
parsedDiff,
|
|
416
|
+
scannedEndpoints,
|
|
417
|
+
wsBaseUrl,
|
|
418
|
+
wsAuthHeader,
|
|
419
|
+
wsSchemaPath,
|
|
420
|
+
routerMountContext,
|
|
421
|
+
});
|
|
422
|
+
// B2: Structured JSON summary prepended to output
|
|
423
|
+
const structuredSummary = JSON.stringify({
|
|
424
|
+
sessionId,
|
|
425
|
+
summary: {
|
|
426
|
+
repositoryName: path.basename(params.repositoryPath),
|
|
427
|
+
projectType: projectMeta.projectType,
|
|
428
|
+
primaryFramework: projectMeta.primaryFramework,
|
|
429
|
+
endpointCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
|
|
430
|
+
scenarioCount: skeletonState.analysis.businessContext.draftedScenarios.length,
|
|
431
|
+
interactionCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.reduce((a2, m) => a2 + m.interactions.length, 0), 0),
|
|
432
|
+
},
|
|
433
|
+
resources: {
|
|
434
|
+
summary: `skyramp://analysis/${sessionId}/summary`,
|
|
435
|
+
endpoints: `skyramp://analysis/${sessionId}/endpoints`,
|
|
436
|
+
scenarios: `skyramp://analysis/${sessionId}/scenarios`,
|
|
437
|
+
diff: analysisScope === "current_branch_diff" ? `skyramp://analysis/${sessionId}/diff` : undefined,
|
|
438
|
+
},
|
|
439
|
+
nextStep: "Call skyramp_recommend_tests with sessionId, or read resources for details",
|
|
440
|
+
}, null, 2);
|
|
284
441
|
return {
|
|
285
442
|
content: [
|
|
286
|
-
{
|
|
287
|
-
type: "text",
|
|
288
|
-
text: `# Repository Analysis Request
|
|
289
|
-
|
|
290
|
-
Please analyze the repository at: \`${params.repositoryPath}\`
|
|
291
|
-
**Analysis Scope**: \`${analysisScope}\`
|
|
292
|
-
|
|
293
|
-
${params.focusAreas ? `Focus on: ${params.focusAreas.join(", ")}\n` : ""}
|
|
294
|
-
${diffSection}
|
|
295
|
-
Use the following tools to gather information:
|
|
296
|
-
- \`codebase_search\` - to understand code patterns and structure
|
|
297
|
-
- \`grep\` - to find specific patterns (route definitions, dependencies, etc.)
|
|
298
|
-
- \`glob_file_search\` - to find files by pattern (OpenAPI specs, config files, trace files, etc.)
|
|
299
|
-
- \`read_file\` - to read specific files (package.json, requirements.txt, etc.)
|
|
300
|
-
- \`list_dir\` - to explore directory structure
|
|
301
|
-
|
|
302
|
-
${analysisPrompt}
|
|
303
|
-
|
|
304
|
-
After gathering all information:
|
|
305
|
-
1. Construct the RepositoryAnalysis JSON${analysisScope === "current_branch_diff" ? " (include branchDiffContext)" : ""}
|
|
306
|
-
2. Save the complete JSON to this file: \`${stateFilePath}\`
|
|
307
|
-
3. Then call \`skyramp_map_tests\` with:
|
|
308
|
-
- stateFile: \`${stateFilePath}\`
|
|
309
|
-
- analysisScope: \`${analysisScope}\`
|
|
310
|
-
Do NOT pass the JSON inline as analysisReport — use stateFile to avoid serialization issues with large JSON.`,
|
|
311
|
-
},
|
|
443
|
+
{ type: "text", text: `\`\`\`json\n${structuredSummary}\n\`\`\`\n\n${outputText}` },
|
|
312
444
|
],
|
|
313
445
|
isError: false,
|
|
314
446
|
};
|