@skyramp/mcp 0.2.0 → 0.2.1-rc.1

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.
@@ -1,6 +1,5 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { simpleGit } from "simple-git";
4
3
  import { logger } from "../utils/logger.js";
5
4
  import { TestSource } from "../types/TestAnalysis.js";
6
5
  import fg from "fast-glob";
@@ -54,11 +53,8 @@ export class TestDiscoveryService {
54
53
  /[\\/]__tests__[\\/]/,
55
54
  /[\\/]spec[\\/]/,
56
55
  ];
57
- // Cache git client and repo status per repository
58
- gitClientCache = new Map();
59
- isGitRepoCache = new Map();
60
56
  /**
61
- * Discover all tests in a repository — both Skyramp-generated and external (user-written).
57
+ * Discover all tests under testDir — both Skyramp-generated and external (user-written).
62
58
  * Uses fast-glob for cross-platform file scanning, then classifies discovered files
63
59
  * as Skyramp-generated tests, external tests, or not-a-test during processing.
64
60
  *
@@ -68,19 +64,17 @@ export class TestDiscoveryService {
68
64
  * rather than flooding context with irrelevant files.
69
65
  * - `undefined` (full-repo mode, no diff): cap at MAX_EXTERNAL_FULL_REPO.
70
66
  */
71
- async discoverTests(repositoryPath, options = {}) {
72
- logger.info(`Starting test discovery in: ${repositoryPath}`);
73
- if (!fs.existsSync(repositoryPath)) {
74
- throw new Error(`Repository path does not exist: ${repositoryPath}`);
67
+ async discoverTests(testDir, options = {}) {
68
+ logger.info(`Starting test discovery in: ${testDir}`);
69
+ if (!fs.existsSync(testDir)) {
70
+ throw new Error(`Test directory does not exist: ${testDir}`);
75
71
  }
76
- const stats = fs.statSync(repositoryPath);
72
+ const stats = fs.statSync(testDir);
77
73
  if (!stats.isDirectory()) {
78
- throw new Error(`Path is not a directory: ${repositoryPath}`);
74
+ throw new Error(`Path is not a directory: ${testDir}`);
79
75
  }
80
- // Initialize git client cache for this repository
81
- await this.initializeGitClient(repositoryPath);
82
- // File classification: skyramp vs external vs not-a-test (carries content forward)
83
- const classified = this.classifyTestFiles(repositoryPath);
76
+ // File classification: skyramp vs external vs not-a-test (carries content forward).
77
+ const classified = this.classifyTestFiles(testDir);
84
78
  logger.info(`Found ${classified.skyramp.length} Skyramp test files, ${classified.external.length} external test files`);
85
79
  // Process Skyramp tests (content already cached from classification)
86
80
  const skyrampTests = await this.processFilesInBatches(classified.skyramp, false, classified.contentCache);
@@ -139,9 +133,6 @@ export class TestDiscoveryService {
139
133
  }));
140
134
  const externalTests = [...relevantExternalTests, ...otherExternalTests];
141
135
  logger.info(`Discovered ${skyrampTests.length} Skyramp tests, ${externalTests.length} external tests`);
142
- // Clean up caches to free memory
143
- this.gitClientCache.clear();
144
- this.isGitRepoCache.clear();
145
136
  return {
146
137
  tests: [...skyrampTests, ...externalTests],
147
138
  // Expose the relevant file paths so callers can build read instructions for the LLM.
@@ -186,27 +177,6 @@ export class TestDiscoveryService {
186
177
  }
187
178
  return { relevant, other };
188
179
  }
189
- /**
190
- * Initialize git client and check if repository is a git repo
191
- */
192
- async initializeGitClient(repositoryPath) {
193
- try {
194
- const git = simpleGit(repositoryPath);
195
- this.gitClientCache.set(repositoryPath, git);
196
- const isRepo = await git.checkIsRepo();
197
- this.isGitRepoCache.set(repositoryPath, isRepo);
198
- if (isRepo) {
199
- logger.debug(`Git repository detected at: ${repositoryPath}`);
200
- }
201
- else {
202
- logger.debug(`Not a git repository: ${repositoryPath}`);
203
- }
204
- }
205
- catch (error) {
206
- logger.debug(`Could not initialize git client: ${error.message}`);
207
- this.isGitRepoCache.set(repositoryPath, false);
208
- }
209
- }
210
180
  /**
211
181
  * Process test files in parallel batches with concurrency control
212
182
  * @param isExternal When true, uses external test metadata extraction
@@ -2,7 +2,6 @@ import { z } from "zod";
2
2
  import { logger } from "../../utils/logger.js";
3
3
  import { StateManager, } from "../../utils/AnalysisStateManager.js";
4
4
  import { TestSource, DriftAction, RecommendationPriority, EstimatedWork } from "../../types/TestAnalysis.js";
5
- import { TestType } from "../../types/TestTypes.js";
6
5
  import * as fs from "fs";
7
6
  import * as path from "path";
8
7
  import { AnalyticsService } from "../../services/AnalyticsService.js";
@@ -44,22 +43,6 @@ export function computeRenamedTestFile(testFile, renames) {
44
43
  }
45
44
  return newFilePath;
46
45
  }
47
- /**
48
- * Select test types to generate based on HTTP method.
49
- */
50
- function selectTestTypesForEndpoint(method) {
51
- switch (method.toUpperCase()) {
52
- case "POST":
53
- case "PUT":
54
- case "PATCH":
55
- return [TestType.INTEGRATION, TestType.CONTRACT];
56
- case "DELETE":
57
- return [TestType.INTEGRATION, TestType.SMOKE];
58
- case "GET":
59
- default:
60
- return [TestType.CONTRACT, TestType.SMOKE];
61
- }
62
- }
63
46
  const recommendationSchema = z.object({
64
47
  testFile: z
65
48
  .string()
@@ -117,20 +100,17 @@ export function registerActionsTool(server) {
117
100
  idempotentHint: false,
118
101
  openWorldHint: true,
119
102
  },
120
- description: `Execute test maintenance and generation actions — final step of the unified Test Health Analysis Flow.
103
+ description: `Execute test maintenance actions — final step of the unified Test Health Analysis Flow.
121
104
 
122
105
  **PREREQUISITE:** Call \`skyramp_analyze_changes\` (produces the stateFile), then \`skyramp_analyze_test_health\` (runs the drift assessment). This tool reads the stateFile from \`skyramp_analyze_changes\`.
123
106
 
124
- Call this tool after completing the drift assessment. It executes maintenance actions automatically from the stateFile — no user confirmation required.
125
-
126
107
  **EXECUTING ACTIONS:**
127
- - UPDATE: Tests with drift emits targeted per-file edit instructions driven by updateInstructions and renamedEndpoints
128
- - REGENERATE: Emits file-level summary; follow up by calling the appropriate generation tool (e.g. skyramp_integration_test_generation) with the same filename to overwrite
129
- - VERIFY: Emits file-level summary for human review — no automated edits
130
- - ADD: Auto-generates tests for new endpoints via LLM instructions
108
+ - UPDATE: Reads each test file and emits targeted per-file edit instructions driven by \`updateInstructions\` and \`renamedEndpoints\`
109
+ - REGENERATE: Reads the existing file for context (endpoint, auth, test type), then instructs the LLM to call the appropriate generation tool (e.g. \`skyramp_integration_test_generation\`) with \`outputDir\` + \`output\` matching the existing file to overwrite it
110
+ - IGNORE / VERIFY / DELETE: Passed through and summarised — no file reads, no automated edits
131
111
 
132
112
  **OUTPUT:**
133
- Comprehensive report with executed actions, summary, and instructions for ADD recommendations
113
+ Per-file instructions for UPDATE and REGENERATE actions, plus a structured \`LLM_INSTRUCTIONS\` block for automated execution.
134
114
  `,
135
115
  inputSchema: actionsSchema,
136
116
  }, async (args) => {
@@ -146,17 +126,22 @@ Comprehensive report with executed actions, summary, and instructions for ADD re
146
126
  errorResult = toolError(`State file is empty or invalid: ${args.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
147
127
  return errorResult;
148
128
  }
149
- // External tests must not be candidates for UPDATE/REGENERATE/DELETE actions.
150
- // Default source to Skyramp for backwards compat with state files created before the source field existed.
151
- const testAnalysisResults = (stateData.existingTests || []).filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External);
152
- const newEndpoints = stateData.newEndpoints || [];
153
129
  // Resolve repo root for path normalization and security checks.
154
130
  const repoRoot = repositoryPath ? path.resolve(repositoryPath) : "";
155
- // Set of non-external (Skyramp-generated) test file paths the only files
156
- // that may receive UPDATE/REGENERATE/DELETE actions. Using the allowlist rather
157
- // than a blocklist catches both external tests AND hallucinated paths the LLM
158
- // may supply that are not present in the scanned catalog at all.
159
- const skyrampTestFiles = new Set(testAnalysisResults.map((t) => t.testFile));
131
+ // Relevant external (user-written) tests: UPDATE is permitted; REGENERATE/DELETE
132
+ // are report-only (the LLM may recommend them but this tool will not apply them).
133
+ // Paths are stored relative to repositoryPath in the state file re-absolutize.
134
+ const relevantExternalPaths = new Set((stateData.repositoryAnalysis?.relevantExternalTestPaths ?? []).map((p) => path.isAbsolute(p) ? p : path.resolve(repoRoot, p)));
135
+ // Allowlist: Skyramp-generated tests + relevant external tests.
136
+ // Using an allowlist (not a blocklist) catches hallucinated paths the LLM
137
+ // may supply that are not in the scanned catalog at all.
138
+ const testAnalysisResults = (stateData.existingTests || []);
139
+ const skyrampTestFiles = new Set(testAnalysisResults
140
+ .filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External)
141
+ .map((t) => t.testFile));
142
+ const externalTestFiles = new Set(testAnalysisResults
143
+ .filter((t) => (t.source ?? TestSource.Skyramp) === TestSource.External)
144
+ .map((t) => t.testFile));
160
145
  // ── Build recommendations from LLM-supplied drift assessment ──
161
146
  // The LLM performs the drift assessment in context after skyramp_analyze_test_health
162
147
  // and passes results here directly — analyzeTestHealthTool never writes assessment
@@ -165,23 +150,55 @@ Comprehensive report with executed actions, summary, and instructions for ADD re
165
150
  (args.recommendations ?? []).forEach((rec) => {
166
151
  // Schema requires absolute paths; resolve any relative paths defensively
167
152
  // against repoRoot in case the LLM sends a relative path despite the schema.
168
- const resolvedFile = path.isAbsolute(rec.testFile)
153
+ // Normalize via path.resolve to collapse any `..` segments before the
154
+ // traversal guard — otherwise "/repo/../etc/passwd" would pass startsWith.
155
+ const rawFile = path.isAbsolute(rec.testFile)
169
156
  ? rec.testFile
170
157
  : repoRoot
171
158
  ? path.resolve(repoRoot, rec.testFile)
172
159
  : rec.testFile;
160
+ const resolvedFile = path.resolve(rawFile);
173
161
  // Reject files outside the repo root (path-traversal guard).
174
- if (repoRoot && !resolvedFile.startsWith(repoRoot + path.sep) && resolvedFile !== repoRoot) {
162
+ // Exception: files already in the scanned test catalog (externalTestFiles / skyrampTestFiles)
163
+ // may legitimately live in a separate testsRepoDir outside repositoryPath — catalog
164
+ // membership is a sufficient provenance check for those paths.
165
+ const isInCatalog = skyrampTestFiles.has(resolvedFile) || skyrampTestFiles.has(rec.testFile)
166
+ || externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile);
167
+ if (repoRoot && !isInCatalog && !resolvedFile.startsWith(repoRoot + path.sep) && resolvedFile !== repoRoot) {
175
168
  logger.warning(`Skipping recommendation for path outside repo root: ${rec.testFile}`);
176
169
  return;
177
170
  }
178
- // Guard: only Skyramp-generated tests may receive UPDATE/REGENERATE/DELETE.
179
- // Using an allowlist (skyrampTestFiles) rather than a blocklist catches both
180
- // external tests and hallucinated paths the LLM may supply that are not in
181
- // the scanned catalog. IGNORE/VERIFY are informational and pass through.
171
+ // Guard: only files present in the scanned test catalog may receive any
172
+ // recommendation. Hallucinated paths (not in either set) are rejected for
173
+ // all actions, including VERIFY and IGNORE, to keep the report consistent
174
+ // with what was actually discovered.
175
+ const isSkyramp = skyrampTestFiles.has(resolvedFile) || skyrampTestFiles.has(rec.testFile);
176
+ const isRelevantExternal = (externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile)) &&
177
+ (relevantExternalPaths.has(resolvedFile) || relevantExternalPaths.has(rec.testFile));
178
+ const isInAnyKnownCatalog = isSkyramp || isRelevantExternal
179
+ || externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile);
180
+ if (!isInAnyKnownCatalog) {
181
+ logger.warning(`Skipping ${rec.action} for unknown test (not in scanned catalog): ${rec.testFile}`);
182
+ return;
183
+ }
182
184
  const isActionable = [DriftAction.Update, DriftAction.Regenerate, DriftAction.Delete].includes(rec.action);
183
- if (isActionable && !skyrampTestFiles.has(resolvedFile) && !skyrampTestFiles.has(rec.testFile)) {
184
- logger.warning(`Skipping ${rec.action} for non-Skyramp or unknown test: ${rec.testFile}`);
185
+ if (isActionable && !isSkyramp && !isRelevantExternal) {
186
+ logger.warning(`Skipping ${rec.action} for irrelevant external test: ${rec.testFile}`);
187
+ return;
188
+ }
189
+ // REGENERATE and DELETE on external tests are report-only — convert to VERIFY so
190
+ // the finding surfaces to the developer without touching the file.
191
+ if (isRelevantExternal && !isSkyramp &&
192
+ (rec.action === DriftAction.Regenerate || rec.action === DriftAction.Delete)) {
193
+ recommendations.push({
194
+ testFile: resolvedFile,
195
+ action: DriftAction.Verify,
196
+ priority: rec.priority ?? RecommendationPriority.Medium,
197
+ rationale: `[external test — needs manual review] ${rec.rationale ?? ""}`.trimEnd(),
198
+ estimatedWork: rec.estimatedWork ?? EstimatedWork.Small,
199
+ updateInstructions: "",
200
+ renamedEndpoints: [],
201
+ });
185
202
  return;
186
203
  }
187
204
  recommendations.push({
@@ -194,7 +211,7 @@ Comprehensive report with executed actions, summary, and instructions for ADD re
194
211
  renamedEndpoints: rec.renamedEndpoints ?? [],
195
212
  });
196
213
  });
197
- // ── Process UPDATE recommendations ──
214
+ // ── Process UPDATE and REGENERATE recommendations ──
198
215
  // Deduplicate by testFile — keep the highest-priority entry when the LLM
199
216
  // repeats a file. Priority order: high > medium > low.
200
217
  const priorityRank = {
@@ -202,28 +219,44 @@ Comprehensive report with executed actions, summary, and instructions for ADD re
202
219
  [RecommendationPriority.Medium]: 1,
203
220
  [RecommendationPriority.Low]: 0,
204
221
  };
222
+ // Build per-file winner maps. REGENERATE beats UPDATE for the same file —
223
+ // if the LLM emits both, keep REGENERATE (higher severity) and drop UPDATE.
205
224
  const updateByFile = new Map();
225
+ const regenerateByFile = new Map();
206
226
  for (const rec of recommendations) {
207
- if (rec.action !== DriftAction.Update)
208
- continue;
209
- const existing = updateByFile.get(rec.testFile);
210
- if (!existing || priorityRank[rec.priority] > priorityRank[existing.priority]) {
211
- updateByFile.set(rec.testFile, rec);
227
+ if (rec.action === DriftAction.Regenerate) {
228
+ const existing = regenerateByFile.get(rec.testFile);
229
+ if (!existing || priorityRank[rec.priority] > priorityRank[existing.priority]) {
230
+ regenerateByFile.set(rec.testFile, rec);
231
+ }
232
+ }
233
+ else if (rec.action === DriftAction.Update) {
234
+ // Only add to updateByFile if no REGENERATE exists for this file.
235
+ if (!regenerateByFile.has(rec.testFile)) {
236
+ const existing = updateByFile.get(rec.testFile);
237
+ if (!existing || priorityRank[rec.priority] > priorityRank[existing.priority]) {
238
+ updateByFile.set(rec.testFile, rec);
239
+ }
240
+ }
212
241
  }
213
242
  }
243
+ // Second pass: drop any UPDATE entries for files that ended up with REGENERATE
244
+ // (handles ordering where UPDATE was inserted before REGENERATE was seen).
245
+ for (const file of regenerateByFile.keys()) {
246
+ updateByFile.delete(file);
247
+ }
214
248
  const updateRecommendations = Array.from(updateByFile.values());
249
+ const regenerateRecommendations = Array.from(regenerateByFile.values());
215
250
  const fileInstructions = [];
216
251
  const testFilesToUpdate = [];
217
252
  const testFileContentMap = new Map();
253
+ // ── UPDATE: read file, emit targeted edit instructions ──
218
254
  for (const rec of updateRecommendations) {
219
255
  if (!rec.testFile) {
220
256
  logger.warning("Recommendation missing testFile", rec);
221
257
  continue;
222
258
  }
223
259
  testFilesToUpdate.push(rec.testFile);
224
- const testData = testAnalysisResults.find((t) => t.testFile === rec.testFile);
225
- const driftData = testData?.drift;
226
- const driftChanges = driftData?.changes || [];
227
260
  let testFileContent = "";
228
261
  try {
229
262
  testFileContent = fs.readFileSync(rec.testFile, "utf-8");
@@ -236,9 +269,6 @@ Comprehensive report with executed actions, summary, and instructions for ADD re
236
269
  const renames = rec.renamedEndpoints || [];
237
270
  const isRenameUpdate = renames.length > 0;
238
271
  let instruction = `\n### ${rec.testFile}\n\n`;
239
- instruction += `**Priority:** ${rec.priority} | `;
240
- instruction += `**Estimated Effort:** ${rec.estimatedWork || EstimatedWork.Small}\n\n`;
241
- instruction += `**Why Update Needed:** ${rec.rationale}\n\n`;
242
272
  if (isRenameUpdate) {
243
273
  instruction += `**Endpoint Rename Detected — Path Substitution Required:**\n\n`;
244
274
  instruction += `| Old Path | New Path | Method |\n`;
@@ -264,79 +294,41 @@ Comprehensive report with executed actions, summary, and instructions for ADD re
264
294
  instruction += `Preserve all existing test logic — only add or adjust what is described above.\n\n`;
265
295
  }
266
296
  else if (!isRenameUpdate) {
267
- instruction += `**Action:** Update this test file per the rationale above. `;
297
+ const fallbackRationale = rec.rationale ?? "";
298
+ if (fallbackRationale) {
299
+ instruction += `**Why:** ${fallbackRationale}\n\n`;
300
+ }
301
+ instruction += `**Action:** Update this test file based on the rationale above. `;
268
302
  instruction += `Match the assertion style already used in the file. `;
269
303
  instruction += `Preserve all existing test logic — only add or adjust the minimum required assertions.\n\n`;
270
304
  }
271
- if (driftData) {
272
- instruction += `**Analysis:**\n`;
273
- instruction += `- Changes Detected: ${driftData.changes?.length || 0}\n`;
274
- instruction += `- Affected Files: ${driftData.affectedFiles.files?.length || 0}\n\n`;
275
- }
276
- if (driftChanges.length > 0) {
277
- instruction += `**Changes Detected:**\n`;
278
- driftChanges.forEach((change) => {
279
- instruction += `**${change.type}** (Severity: ${change.severity}): ${change.description}\n`;
280
- if (change.details) {
281
- instruction += ` └─ ${change.details}\n`;
282
- }
283
- if (change.file) {
284
- instruction += ` └─ In: \`${change.file}\`\n`;
285
- }
286
- });
287
- instruction += `\n`;
288
- }
289
- // File content is provided in LLM_INSTRUCTIONS.update_context.current_content — omit here to avoid duplication.
290
305
  fileInstructions.push(instruction);
291
306
  }
292
- // ── Build ADD section for new endpoints ──
293
- const wsBaseUrl = stateData.repositoryAnalysis?.wsBaseUrl || "";
294
- const wsSchemaPath = stateData.repositoryAnalysis?.wsSchemaPath || "";
295
- const primaryLanguage = stateData.repositoryAnalysis?.projectMeta?.primaryLanguage ||
296
- "python";
297
- const primaryFramework = stateData.repositoryAnalysis?.projectMeta?.primaryFramework ||
298
- "pytest";
299
- // Determine output directory from workspace config or repo path
300
- const outputDir = repositoryPath
301
- ? path.join(repositoryPath, "tests", "skyramp")
302
- : "./tests/skyramp";
303
- const addSummaryLines = [];
304
- const llmToolCalls = [];
305
- for (const ep of newEndpoints) {
306
- const testTypes = selectTestTypesForEndpoint(ep.method);
307
- const endpointURL = wsBaseUrl
308
- ? wsBaseUrl.replace(/\/$/, "") + ep.path
309
- : ep.path;
310
- addSummaryLines.push(`- ${ep.method} ${ep.path} → ${testTypes.join(", ")} tests`);
311
- for (const testType of testTypes) {
312
- let toolName = "";
313
- switch (testType) {
314
- case TestType.CONTRACT:
315
- toolName = "skyramp_contract_test_generation";
316
- break;
317
- case TestType.INTEGRATION:
318
- toolName = "skyramp_integration_test_generation";
319
- break;
320
- case TestType.SMOKE:
321
- toolName = "skyramp_smoke_test_generation";
322
- break;
323
- default:
324
- toolName = "skyramp_contract_test_generation";
325
- }
326
- llmToolCalls.push({
327
- tool: toolName,
328
- params: {
329
- endpointURL,
330
- method: ep.method,
331
- language: primaryLanguage,
332
- framework: primaryFramework,
333
- outputDir,
334
- ...(wsSchemaPath ? { apiSchema: wsSchemaPath } : {}),
335
- },
336
- endpoint: `${ep.method} ${ep.path}`,
337
- testType: testType,
338
- });
307
+ // ── REGENERATE: read file for context, emit overwrite instructions ──
308
+ const regenerateInstructions = [];
309
+ const testFilesToRegenerate = [];
310
+ const regenerateContentMap = new Map();
311
+ for (const rec of regenerateRecommendations) {
312
+ if (!rec.testFile) {
313
+ logger.warning("Recommendation missing testFile", rec);
314
+ continue;
315
+ }
316
+ testFilesToRegenerate.push(rec.testFile);
317
+ let existingContent = "";
318
+ try {
319
+ existingContent = fs.readFileSync(rec.testFile, "utf-8");
320
+ regenerateContentMap.set(rec.testFile, existingContent);
339
321
  }
322
+ catch (error) {
323
+ logger.warning(`Could not read file for REGENERATE context ${rec.testFile}: ${error.message}`);
324
+ }
325
+ let instruction = `\n### ${rec.testFile}\n\n`;
326
+ instruction += `**Action: REGENERATE** — the response shape changed too drastically for targeted edits.\n\n`;
327
+ if (rec.updateInstructions) {
328
+ instruction += `**What changed:**\n\n${rec.updateInstructions}\n\n`;
329
+ }
330
+ instruction += `Call the appropriate generation tool (e.g. \`skyramp_integration_test_generation\`, \`skyramp_contract_test_generation\`) with \`outputDir: "${path.dirname(rec.testFile)}"\` and \`output: "${path.basename(rec.testFile)}"\` to overwrite this file from scratch. Use the existing file content in \`LLM_INSTRUCTIONS.regenerate_context\` for context on the endpoint, auth pattern, and test structure — replicate the test type and language.\n\n`;
331
+ regenerateInstructions.push(instruction);
340
332
  }
341
333
  // ── Build response text ──
342
334
  let responseText = `# Test Actions Report\n\n`;
@@ -348,37 +340,26 @@ Comprehensive report with executed actions, summary, and instructions for ADD re
348
340
  responseText += `\n---\n`;
349
341
  responseText += fileInstructions.join("\n---\n");
350
342
  }
351
- if (newEndpoints.length > 0) {
352
- responseText += `\n## New Endpoint Tests to Generate (${newEndpoints.length} endpoints)\n\n`;
353
- addSummaryLines.forEach((line) => {
354
- responseText += `${line}\n`;
343
+ if (regenerateRecommendations.length > 0) {
344
+ responseText += `\n## Tests Requiring Regeneration (${regenerateRecommendations.length})\n\n`;
345
+ testFilesToRegenerate.forEach((file, idx) => {
346
+ responseText += `${idx + 1}. \`${file}\`\n`;
355
347
  });
356
- responseText += `\nThe following tests will be generated automatically.\n`;
357
- }
358
- if (updateRecommendations.length === 0 && newEndpoints.length === 0) {
359
- const otherRecs = recommendations.filter((rec) => rec.action !== DriftAction.Update);
360
- if (otherRecs.length > 0) {
361
- responseText += `## Recommendations (${otherRecs.length})\n\n`;
362
- otherRecs.forEach((rec) => {
363
- responseText += `- **${rec.testFile}** — Action: ${rec.action}, Priority: ${rec.priority}\n`;
364
- responseText += ` ${rec.rationale}\n`;
365
- });
366
- }
367
- else {
368
- responseText += `No action required. All existing tests appear healthy.\n`;
369
- }
348
+ responseText += `\n---\n`;
349
+ responseText += regenerateInstructions.join("\n---\n");
370
350
  }
371
- responseText += `\n\n## Next Steps\n\n`;
372
- responseText += `The AI assistant will:\n`;
373
- let stepNumber = 1;
374
- if (updateRecommendations.length > 0) {
375
- responseText += `${stepNumber++}. Review the changes and issues for each test\n`;
376
- responseText += `${stepNumber++}. Update test files to fix compatibility issues\n`;
377
- responseText += `${stepNumber++}. Preserve original test logic and structure\n`;
378
- responseText += `${stepNumber++}. Show you the changes made\n`;
351
+ const otherRecs = recommendations.filter((rec) => rec.action !== DriftAction.Update && rec.action !== DriftAction.Regenerate);
352
+ if (otherRecs.length > 0) {
353
+ responseText += `\n## Other Findings (${otherRecs.length})\n\n`;
354
+ otherRecs.forEach((rec) => {
355
+ responseText += `- **${rec.testFile}** Action: ${rec.action}, Priority: ${rec.priority}`;
356
+ if (rec.rationale)
357
+ responseText += ` — ${rec.rationale}`;
358
+ responseText += `\n`;
359
+ });
379
360
  }
380
- if (newEndpoints.length > 0) {
381
- responseText += `${stepNumber++}. Generate new tests for new endpoints\n`;
361
+ else if (updateRecommendations.length === 0 && regenerateRecommendations.length === 0) {
362
+ responseText += `No action required. All existing tests appear healthy.\n`;
382
363
  }
383
364
  responseText += `\n**This tool is currently in Early Preview stage. Please verify the results.**\n`;
384
365
  // ── Build LLM instructions for UPDATE ──
@@ -397,6 +378,8 @@ Comprehensive report with executed actions, summary, and instructions for ADD re
397
378
  auto_proceed: true,
398
379
  files_to_update: testFilesToUpdate,
399
380
  update_count: updateRecommendations.length,
381
+ files_to_regenerate: testFilesToRegenerate,
382
+ regenerate_count: regenerateRecommendations.length,
400
383
  };
401
384
  if (uniqueRenames.length > 0) {
402
385
  llmInstructionsObj.endpoint_renames = uniqueRenames;
@@ -428,6 +411,22 @@ Comprehensive report with executed actions, summary, and instructions for ADD re
428
411
  llmInstructionsObj.update_strategy =
429
412
  "For each file in update_context, apply the changes described in context to the provided current_content. Write the result using the Edit tool. Do NOT re-read the file first. Match the assertion style already used in the file. Preserve all existing test logic. After applying all edits, call skyramp_enhance_assertions with each updated file path to strengthen the assertions.";
430
413
  }
414
+ // REGENERATE context: existing file content gives the generation tool the
415
+ // endpoint URL, auth pattern, test type, and language to replicate.
416
+ const regenerateContextFiles = [];
417
+ for (const rec of regenerateRecommendations) {
418
+ const existing_content = regenerateContentMap.get(rec.testFile);
419
+ regenerateContextFiles.push({
420
+ file: rec.testFile,
421
+ rationale: rec.updateInstructions || rec.rationale,
422
+ ...(existing_content !== undefined && { existing_content }),
423
+ });
424
+ }
425
+ if (regenerateContextFiles.length > 0) {
426
+ llmInstructionsObj.regenerate_context = regenerateContextFiles;
427
+ llmInstructionsObj.regenerate_strategy =
428
+ "For each file in regenerate_context, call the appropriate generation tool (skyramp_integration_test_generation or skyramp_contract_test_generation) with outputDir set to the file's directory and output set to the filename. Use existing_content to determine the test type, endpoint, auth pattern, and language. The generation tool will overwrite the file. Do NOT use skyramp_ui_test_generation here — UI test regeneration requires a recorded trace (playwrightInput) and must be handled separately.";
429
+ }
431
430
  const llmInstructions = `<!-- LLM_INSTRUCTIONS:\n${JSON.stringify(llmInstructionsObj, null, 2)}\n-->\n`;
432
431
  const contentBlocks = [
433
432
  {
@@ -445,23 +444,6 @@ Comprehensive report with executed actions, summary, and instructions for ADD re
445
444
  },
446
445
  },
447
446
  ];
448
- // ── Build ADD instructions for new endpoints ──
449
- if (newEndpoints.length > 0 && llmToolCalls.length > 0) {
450
- const addInstructionsObj = {
451
- workflow: "add_tests_for_new_endpoints",
452
- auto_proceed: true,
453
- instruction: "Call each tool in tool_calls immediately and in order. Do NOT ask for confirmation. If endpointURL is a bare path, read .skyramp/workspace.yml for baseUrl and prepend it.",
454
- tool_calls: llmToolCalls,
455
- };
456
- const addInstructions = `<!-- LLM_INSTRUCTIONS:\n${JSON.stringify(addInstructionsObj, null, 2)}\n-->\n`;
457
- contentBlocks.push({
458
- type: "text",
459
- text: addInstructions,
460
- annotations: {
461
- audience: ["assistant"],
462
- },
463
- });
464
- }
465
447
  return {
466
448
  content: contentBlocks,
467
449
  };
@@ -538,6 +538,14 @@ Combines API endpoint scanning, branch diff computation, and test discovery into
538
538
  }
539
539
  await sendProgress(50, 100, "Discovering existing tests...");
540
540
  // ── Step 3: Discover existing tests ──
541
+ // Resolve testDir to scope the file scan — prefer explicit testsRepoDir param,
542
+ // then workspace.yml testDirectory. repositoryPath remains the repo root for
543
+ // git operations; testDir only limits which files are classified as tests.
544
+ const wsConfigEarly = await readWorkspaceConfigRaw(params.repositoryPath);
545
+ const wsTestDir = wsConfigEarly?.services?.[0]?.testDirectory;
546
+ const testDir = params.testsRepoDir
547
+ ?? (params.testDirectory ? path.resolve(params.repositoryPath, params.testDirectory) : undefined)
548
+ ?? (wsTestDir ? path.resolve(params.repositoryPath, wsTestDir) : undefined);
541
549
  // Compute changedResources from classified endpoints for test discovery filtering.
542
550
  // undefined → full-repo mode (no diff context)
543
551
  // [] → PR mode, no endpoints found → skip external tests
@@ -577,8 +585,7 @@ Combines API endpoint scanning, branch diff computation, and test discovery into
577
585
  try {
578
586
  const testDiscoveryService = new TestDiscoveryService();
579
587
  setTestsRepoDir(params.testsRepoDir);
580
- const testScanPath = params.testsRepoDir ?? params.repositoryPath;
581
- const discoveryResult = await testDiscoveryService.discoverTests(testScanPath, { changedResources });
588
+ const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath, { changedResources });
582
589
  existingTests = discoveryResult.tests.map((test) => ({
583
590
  testFile: test.testFile,
584
591
  testType: test.testType,
@@ -1182,6 +1189,7 @@ Combines API endpoint scanning, branch diff computation, and test discovery into
1182
1189
  sessionId,
1183
1190
  routerMountContext,
1184
1191
  candidateRouteFiles,
1192
+ relevantExternalTestPaths,
1185
1193
  },
1186
1194
  };
1187
1195
  // Clean up old state files (>24 hours) before creating new one
@@ -15,16 +15,17 @@ export function registerAnalyzeTestHealthTool(server) {
15
15
  idempotentHint: true,
16
16
  openWorldHint: false,
17
17
  },
18
- description: `Generate drift and health assessment instructions for existing tests — second step of the unified Test Health Analysis Flow.
18
+ description: `Generate drift assessment instructions for existing tests — second step of the unified Test Health Analysis Flow.
19
19
 
20
20
  **PREREQUISITE:** Call \`skyramp_analyze_changes\` first to get a stateFile.
21
21
 
22
- This tool reads existing tests, the branch diff, and scanned endpoints from the stateFile,
23
- then returns a structured prompt for the LLM to assess each test for drift and health.
22
+ Returns a structured prompt for the LLM to assess each existing test against the branch diff and assign one of: UPDATE / REGENERATE / VERIFY / DELETE / IGNORE.
24
23
 
25
- The LLM follows the returned prompt to assign drift details and actions (UPDATE / REGENERATE / VERIFY / DELETE / IGNORE) for each test, then calls \`skyramp_actions\`.
24
+ Includes both Skyramp-generated tests and user-written (external) tests that are relevant to the PR's changed endpoints. For external tests, UPDATE is applied automatically; REGENERATE and DELETE are surfaced as report-only findings for the developer.
26
25
 
27
- (Optional) Execute tests using \`skyramp_execute_test\` with \`stateFile\` parameter before \`skyramp_actions\` to validate tests live.`,
26
+ The LLM follows the returned prompt (Action Decision Tree pre-scan Endpoint Existence Response Shape → Additive Fields → Auth/AuthZ → Behavioral Contract → Assign Action → Update Execution Rules), then calls \`skyramp_actions\` with its \`recommendations[]\`.
27
+
28
+ (Optional) Execute tests using \`skyramp_execute_test\` with \`stateFile\` before \`skyramp_actions\` to validate live.`,
28
29
  inputSchema: {
29
30
  stateFile: z
30
31
  .string()
@@ -45,14 +46,24 @@ The LLM follows the returned prompt to assign drift details and actions (UPDATE
45
46
  if (!stateData) {
46
47
  return toolError(`State file is empty or invalid: ${args.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
47
48
  }
48
- // Only Skyramp tests are candidates for drift analysis and maintenance actions.
49
- // External (user-written) tests are used only for recommendation deduplication.
50
- // Default source to Skyramp for backwards compat with state files created before the source field existed.
51
- const existingTests = (stateData.existingTests || []).filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External);
52
- logger.info(`Loaded ${existingTests.length} existing Skyramp tests from state file (excluded external)`);
53
49
  if (!repositoryPath || typeof repositoryPath !== "string") {
54
50
  return toolError(`repositoryPath not found in state file metadata. The state file was likely created by an older version — re-run skyramp_analyze_changes to regenerate it.`);
55
51
  }
52
+ // Skyramp tests: full drift analysis + all actions permitted.
53
+ // Relevant external tests (user-written, relevant to this PR's endpoints): drift analysis
54
+ // + UPDATE only — REGENERATE and DELETE are report-only (enforced in skyramp_actions).
55
+ // Other external tests: excluded entirely (deduplication only, not analysed).
56
+ // relevantExternalTestPaths are stored relative to repositoryPath in the state file.
57
+ // Re-absolutize here so has() comparisons against t.testFile (absolute) work correctly.
58
+ const relevantExternalPaths = new Set((stateData.repositoryAnalysis?.relevantExternalTestPaths ?? []).map((p) => path.isAbsolute(p) ? p : path.resolve(repositoryPath, p)));
59
+ const existingTests = (stateData.existingTests || []).filter((t) => {
60
+ if ((t.source ?? TestSource.Skyramp) !== TestSource.External)
61
+ return true;
62
+ return relevantExternalPaths.has(t.testFile);
63
+ });
64
+ const skyrampCount = existingTests.filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External).length;
65
+ const externalCount = existingTests.length - skyrampCount;
66
+ logger.info(`Loaded ${skyrampCount} Skyramp + ${externalCount} relevant external tests from state file`);
56
67
  const absoluteRepoPath = path.resolve(repositoryPath);
57
68
  const scannedEndpoints = stateData.repositoryAnalysis?.skeletonEndpoints || [];
58
69
  const routerMountContext = stateData.repositoryAnalysis?.routerMountContext;
@@ -76,6 +87,7 @@ The LLM follows the returned prompt to assign drift details and actions (UPDATE
76
87
  routerMountContext,
77
88
  candidateRouteFiles,
78
89
  diffFilePath,
90
+ relevantExternalTestPaths: [...relevantExternalPaths],
79
91
  });
80
92
  return {
81
93
  structuredContent: { prompt: promptText },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.1-rc.1",
4
4
  "main": "build/index.js",
5
5
  "exports": {
6
6
  ".": "./build/index.js",