@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.
Files changed (35) hide show
  1. package/build/index.js +32 -5
  2. package/build/prompts/test-recommendation/analysisOutputPrompt.js +98 -0
  3. package/build/prompts/test-recommendation/recommendationSections.js +226 -0
  4. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +71 -0
  5. package/build/prompts/test-recommendation/test-recommendation-prompt.js +166 -104
  6. package/build/prompts/testGenerationPrompt.js +2 -3
  7. package/build/prompts/testbot/testbot-prompts.js +96 -93
  8. package/build/resources/analysisResources.js +254 -0
  9. package/build/services/ScenarioGenerationService.js +70 -26
  10. package/build/tools/generate-tests/generateIntegrationRestTool.js +54 -1
  11. package/build/tools/generate-tests/generateScenarioRestTool.js +8 -5
  12. package/build/tools/submitReportTool.js +28 -0
  13. package/build/tools/test-maintenance/stateCleanupTool.js +8 -0
  14. package/build/tools/test-recommendation/analyzeRepositoryTool.js +349 -217
  15. package/build/tools/test-recommendation/recommendTestsTool.js +163 -159
  16. package/build/tools/workspace/initializeWorkspaceTool.js +1 -1
  17. package/build/types/RepositoryAnalysis.js +99 -12
  18. package/build/utils/AnalysisStateManager.js +40 -23
  19. package/build/utils/branchDiff.js +47 -0
  20. package/build/utils/pr-comment-parser.js +124 -0
  21. package/build/utils/projectMetadata.js +188 -0
  22. package/build/utils/projectMetadata.test.js +81 -0
  23. package/build/utils/repoScanner.js +378 -0
  24. package/build/utils/routeParsers.js +213 -0
  25. package/build/utils/routeParsers.test.js +87 -0
  26. package/build/utils/scenarioDrafting.js +119 -0
  27. package/build/utils/scenarioDrafting.test.js +66 -0
  28. package/build/utils/trace-parser.js +166 -0
  29. package/build/utils/workspaceAuth.js +16 -0
  30. package/package.json +1 -1
  31. package/build/prompts/test-recommendation/repository-analysis-prompt.js +0 -326
  32. package/build/prompts/test-recommendation/test-mapping-prompt.js +0 -266
  33. package/build/tools/test-recommendation/mapTestsTool.js +0 -243
  34. package/build/types/TestMapping.js +0 -173
  35. package/build/utils/scoring-engine.js +0 -380
@@ -1,174 +1,18 @@
1
1
  import { z } from "zod";
2
- import { simpleGit } from "simple-git";
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 { StateManager, } from "../../utils/AnalysisStateManager.js";
7
- const MAX_DIFF_LENGTH = 50_000;
8
- /**
9
- * Parse the list of changed files from a unified diff string by scanning
10
- * for `diff --git a/... b/<file>` header lines.
11
- */
12
- function parseChangedFilesFromDiff(diffContent) {
13
- const files = [];
14
- for (const line of diffContent.split("\n")) {
15
- const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
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 structured data that can be used by skyramp_map_tests to calculate test priorities.
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
- // Parse the diff content inside the tool — never embed raw diff in the response.
118
+ await sendProgress(30, 100, "Parsing endpoints from diff...");
266
119
  const parsedDiff = diffData ? parseEndpointsFromDiff(diffData) : undefined;
267
- const analysisPrompt = getRepositoryAnalysisPrompt(params.repositoryPath, analysisScope, parsedDiff);
268
- const stateManager = new StateManager("recommendation");
269
- const stateFilePath = stateManager.getStatePath();
270
- logger.info("Created state file for analysis", {
271
- stateFile: stateFilePath,
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
- // Compact diff section built entirely from pre-parsed data — no raw content
274
- const diffSection = parsedDiff
275
- ? `
276
- ## Branch Diff Context
277
- **Branch**: \`${parsedDiff.currentBranch}\` → base: \`${parsedDiff.baseBranch}\`
278
- **Changed Files** (${parsedDiff.changedFiles.length}): ${parsedDiff.changedFiles.join(", ")}
279
- **New Endpoints** (${parsedDiff.newEndpoints.length}): ${parsedDiff.newEndpoints.map((e) => `${e.method} ${e.path}`).join(", ") || "none detected"}
280
- **Modified Endpoints** (${parsedDiff.modifiedEndpoints.length}): ${parsedDiff.modifiedEndpoints.map((e) => `${e.method} ${e.path}`).join(", ") || "none detected"}
281
- **Affected Services**: ${parsedDiff.affectedServices.join(", ") || "none detected"}
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
  };