@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.
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +87 -98
- package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +60 -92
- package/build/prompts/test-maintenance/driftAnalysisSections.js +197 -139
- package/build/prompts/testbot/testbot-prompts.js +7 -4
- package/build/prompts/testbot/testbot-prompts.test.js +22 -17
- package/build/services/TestDiscoveryService.js +9 -39
- package/build/tools/test-management/actionsTool.js +148 -166
- package/build/tools/test-management/analyzeChangesTool.js +10 -2
- package/build/tools/test-management/analyzeTestHealthTool.js +22 -10
- package/package.json +1 -1
|
@@ -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
|
|
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(
|
|
72
|
-
logger.info(`Starting test discovery in: ${
|
|
73
|
-
if (!fs.existsSync(
|
|
74
|
-
throw new Error(`
|
|
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(
|
|
72
|
+
const stats = fs.statSync(testDir);
|
|
77
73
|
if (!stats.isDirectory()) {
|
|
78
|
-
throw new Error(`Path is not a directory: ${
|
|
74
|
+
throw new Error(`Path is not a directory: ${testDir}`);
|
|
79
75
|
}
|
|
80
|
-
//
|
|
81
|
-
|
|
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
|
|
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:
|
|
128
|
-
- REGENERATE:
|
|
129
|
-
- VERIFY
|
|
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
|
-
|
|
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
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
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 && !
|
|
184
|
-
logger.warning(`Skipping ${rec.action} for
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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 (
|
|
352
|
-
responseText += `\n##
|
|
353
|
-
|
|
354
|
-
responseText += `${
|
|
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 += `\
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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 (
|
|
381
|
-
responseText +=
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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 },
|