@skyramp/mcp 0.2.8 → 0.2.9

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 (41) hide show
  1. package/build/index.js +0 -7
  2. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
  3. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +3 -1
  4. package/build/prompts/sut-setup/shared.d.ts +20 -0
  5. package/build/prompts/sut-setup/shared.js +69 -7
  6. package/build/prompts/test-maintenance/actionsInstructions.js +5 -1
  7. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +15 -2
  8. package/build/prompts/test-maintenance/drift-analysis-prompt.js +58 -3
  9. package/build/prompts/test-maintenance/driftAnalysisSections.js +7 -22
  10. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +14 -0
  11. package/build/prompts/test-maintenance/driftAnalysisShared.js +62 -0
  12. package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +38 -0
  13. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +228 -0
  14. package/build/prompts/test-recommendation/scopeAssessment.js +12 -2
  15. package/build/prompts/testbot/testbot-prompts.js +3 -2
  16. package/build/services/TestDiscoveryService.d.ts +12 -9
  17. package/build/services/TestDiscoveryService.js +125 -51
  18. package/build/services/TestDiscoveryService.test.js +235 -15
  19. package/build/services/TestExecutionService.d.ts +1 -1
  20. package/build/services/TestExecutionService.js +7 -7
  21. package/build/services/TestExecutionService.test.js +4 -1
  22. package/build/tools/submitReportTool.d.ts +5 -5
  23. package/build/tools/submitReportTool.js +11 -4
  24. package/build/tools/submitReportTool.test.js +2 -0
  25. package/build/tools/test-management/actionsTool.js +54 -36
  26. package/build/tools/test-management/analyzeChangesTool.js +11 -17
  27. package/build/tools/test-management/analyzeTestHealthTool.js +182 -8
  28. package/build/tools/test-management/analyzeTestHealthTool.test.d.ts +1 -0
  29. package/build/tools/test-management/analyzeTestHealthTool.test.js +468 -0
  30. package/build/tools/workspace/initializeWorkspaceTool.js +2 -9
  31. package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
  32. package/build/types/TestAnalysis.d.ts +3 -0
  33. package/build/types/TestbotReport.d.ts +1 -1
  34. package/build/utils/AnalysisStateManager.d.ts +3 -21
  35. package/build/utils/docker.test.js +1 -1
  36. package/build/utils/versions.d.ts +3 -3
  37. package/build/utils/versions.js +1 -1
  38. package/build/workspace/workspace.d.ts +21 -21
  39. package/build/workspace/workspace.js +7 -4
  40. package/build/workspace/workspace.test.js +65 -6
  41. package/package.json +2 -2
@@ -6,7 +6,6 @@ import * as fs from "fs";
6
6
  import * as path from "path";
7
7
  import { AnalyticsService } from "../../services/AnalyticsService.js";
8
8
  import { toolError } from "../../utils/utils.js";
9
- import { stageGeneratedPaths } from "../../utils/gitStaging.js";
10
9
  import { buildRenameStrategy, buildFileRenameStrategy, buildUpdateStrategy, buildRegenerateStrategy, buildUpdateFileInstruction, buildRegenerateFileInstruction, } from "../../prompts/test-maintenance/actionsInstructions.js";
11
10
  /**
12
11
  * Compute a suggested new filename when an endpoint is renamed.
@@ -49,7 +48,12 @@ const recommendationSchema = z.object({
49
48
  testFile: z
50
49
  .string()
51
50
  .refine((p) => path.isAbsolute(p), { message: "testFile must be an absolute path" })
52
- .describe("Absolute path to the test file — use the path as reported by skyramp_analyze_changes or skyramp_analyze_test_health"),
51
+ .describe("Absolute path to the test spec file — use the path as reported by skyramp_analyze_changes or skyramp_analyze_test_health. For POM-based tests, this is the spec file (shown in the report); set pomFile to the page object path to edit."),
52
+ pomFile: z
53
+ .string()
54
+ .optional()
55
+ .refine((p) => p === undefined || path.isAbsolute(p), { message: "pomFile must be an absolute path" })
56
+ .describe("Absolute path to the page object (POM) file to edit, when the spec delegates selectors to a page object. When set, skyramp_actions edits this file instead of testFile. testFile (the spec) is still used for catalog validation and the maintenance report."),
53
57
  action: z
54
58
  .nativeEnum(DriftAction)
55
59
  .describe("Drift action assigned by the LLM health assessment"),
@@ -95,7 +99,7 @@ const actionsSchema = {
95
99
  recommendations: z
96
100
  .array(recommendationSchema)
97
101
  .optional()
98
- .describe("LLM Drift assessment produced by skyramp_analyze_test_health one entry per test assessed, including IGNORE and VERIFY entries. Required for UPDATE instructions to be emitted; omitting results in no maintenance actions. For [external] tests: UPDATE edits are applied automatically; REGENERATE and DELETE are surfaced as report-only findings and never rewrite or delete a user-authored file."),
102
+ .describe("Drift recommendations from skyramp_analyze_test_health. Omit IGNORE entries. Omitting this field results in no maintenance actions."),
99
103
  };
100
104
  const TOOL_NAME = "skyramp_actions";
101
105
  export function registerActionsTool(server) {
@@ -123,10 +127,6 @@ export function registerActionsTool(server) {
123
127
  }
124
128
  // Resolve repo root for path normalization and security checks.
125
129
  const repoRoot = repositoryPath ? path.resolve(repositoryPath) : "";
126
- // Relevant external (user-written) tests: UPDATE is permitted; REGENERATE/DELETE
127
- // are report-only (the LLM may recommend them but this tool will not apply them).
128
- // Paths are stored relative to repositoryPath in the state file — re-absolutize.
129
- const relevantExternalPaths = new Set((stateData.repositoryAnalysis?.relevantExternalTestPaths ?? []).map((p) => path.isAbsolute(p) ? p : path.resolve(repoRoot, p)));
130
130
  // Allowlist: Skyramp-generated tests + relevant external tests.
131
131
  // Using an allowlist (not a blocklist) catches hallucinated paths the LLM
132
132
  // may supply that are not in the scanned catalog at all.
@@ -168,10 +168,9 @@ export function registerActionsTool(server) {
168
168
  // all actions, including VERIFY and IGNORE, to keep the report consistent
169
169
  // with what was actually discovered.
170
170
  const isSkyramp = skyrampTestFiles.has(resolvedFile) || skyrampTestFiles.has(rec.testFile);
171
- const isRelevantExternal = (externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile)) &&
172
- (relevantExternalPaths.has(resolvedFile) || relevantExternalPaths.has(rec.testFile));
173
- const isInAnyKnownCatalog = isSkyramp || isRelevantExternal
174
- || externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile);
171
+ // Catalog membership is the safety boundary — prevents editing non-test files.
172
+ const isRelevantExternal = (externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFile));
173
+ const isInAnyKnownCatalog = isSkyramp || isRelevantExternal;
175
174
  if (!isInAnyKnownCatalog) {
176
175
  logger.warning(`Skipping ${rec.action} for unknown test (not in scanned catalog): ${rec.testFile}`);
177
176
  return;
@@ -196,8 +195,21 @@ export function registerActionsTool(server) {
196
195
  });
197
196
  return;
198
197
  }
198
+ // Resolve and validate pomFile with the same path-traversal guard as testFile.
199
+ let resolvedPomFile;
200
+ if (rec.pomFile) {
201
+ const rawPom = path.isAbsolute(rec.pomFile)
202
+ ? rec.pomFile
203
+ : path.resolve(repoRoot ?? "", rec.pomFile);
204
+ resolvedPomFile = path.resolve(rawPom);
205
+ if (repoRoot && !resolvedPomFile.startsWith(repoRoot + path.sep) && resolvedPomFile !== repoRoot) {
206
+ logger.warning(`Skipping pomFile outside repo root: ${rec.pomFile}`);
207
+ resolvedPomFile = undefined;
208
+ }
209
+ }
199
210
  recommendations.push({
200
211
  testFile: resolvedFile,
212
+ pomFile: resolvedPomFile,
201
213
  action: rec.action,
202
214
  priority: rec.priority ?? RecommendationPriority.Medium,
203
215
  rationale: rec.rationale ?? "",
@@ -207,16 +219,19 @@ export function registerActionsTool(server) {
207
219
  });
208
220
  });
209
221
  // ── Process UPDATE and REGENERATE recommendations ──
210
- // Deduplicate by testFile keep the highest-priority entry when the LLM
211
- // repeats a file. Priority order: high > medium > low.
222
+ // UPDATE dedup key: `${testFile}::${pomFile ?? ''}` a spec may emit one
223
+ // UPDATE entry per page object (different pomFile values), so the same
224
+ // testFile can appear multiple times with different edit targets.
225
+ // REGENERATE dedup key: testFile only — REGENERATE covers the whole spec.
226
+ // Priority order: high > medium > low.
212
227
  const priorityRank = {
213
228
  [RecommendationPriority.High]: 2,
214
229
  [RecommendationPriority.Medium]: 1,
215
230
  [RecommendationPriority.Low]: 0,
216
231
  };
217
- // Build per-file winner maps. REGENERATE beats UPDATE for the same file
232
+ // Build per-file winner maps. REGENERATE beats UPDATE for the same spec
218
233
  // if the LLM emits both, keep REGENERATE (higher severity) and drop UPDATE.
219
- const updateByFile = new Map();
234
+ const updateByKey = new Map();
220
235
  const regenerateByFile = new Map();
221
236
  for (const rec of recommendations) {
222
237
  if (rec.action === DriftAction.Regenerate) {
@@ -226,21 +241,25 @@ export function registerActionsTool(server) {
226
241
  }
227
242
  }
228
243
  else if (rec.action === DriftAction.Update) {
229
- // Only add to updateByFile if no REGENERATE exists for this file.
244
+ // Only add if no REGENERATE exists for this spec.
230
245
  if (!regenerateByFile.has(rec.testFile)) {
231
- const existing = updateByFile.get(rec.testFile);
246
+ const key = `${rec.testFile}::${rec.pomFile ?? ""}`;
247
+ const existing = updateByKey.get(key);
232
248
  if (!existing || priorityRank[rec.priority] > priorityRank[existing.priority]) {
233
- updateByFile.set(rec.testFile, rec);
249
+ updateByKey.set(key, rec);
234
250
  }
235
251
  }
236
252
  }
237
253
  }
238
- // Second pass: drop any UPDATE entries for files that ended up with REGENERATE
254
+ // Second pass: drop any UPDATE entries for specs that ended up with REGENERATE
239
255
  // (handles ordering where UPDATE was inserted before REGENERATE was seen).
240
256
  for (const file of regenerateByFile.keys()) {
241
- updateByFile.delete(file);
257
+ for (const key of updateByKey.keys()) {
258
+ if (key.startsWith(`${file}::`))
259
+ updateByKey.delete(key);
260
+ }
242
261
  }
243
- const updateRecommendations = Array.from(updateByFile.values());
262
+ const updateRecommendations = Array.from(updateByKey.values());
244
263
  const regenerateRecommendations = Array.from(regenerateByFile.values());
245
264
  const fileInstructions = [];
246
265
  const testFilesToUpdate = [];
@@ -251,23 +270,30 @@ export function registerActionsTool(server) {
251
270
  logger.warning("Recommendation missing testFile", rec);
252
271
  continue;
253
272
  }
254
- testFilesToUpdate.push(rec.testFile);
273
+ // When a POM file is specified, edit the POM (selector source of truth)
274
+ // rather than the spec. testFile (the spec) remains in the report.
275
+ const editTarget = rec.pomFile ?? rec.testFile;
276
+ testFilesToUpdate.push(editTarget);
255
277
  try {
256
- testFileContentMap.set(rec.testFile, fs.readFileSync(rec.testFile, "utf-8"));
278
+ testFileContentMap.set(editTarget, fs.readFileSync(editTarget, "utf-8"));
257
279
  }
258
280
  catch (error) {
259
- logger.error(`Failed to read test file ${rec.testFile}: ${error.message}`);
281
+ logger.error(`Failed to read ${rec.pomFile ? "page object" : "test"} file ${editTarget}: ${error.message}`);
260
282
  continue;
261
283
  }
262
284
  const renames = rec.renamedEndpoints || [];
263
285
  const isRenameUpdate = renames.length > 0;
286
+ // Always derive the rename suggestion from the spec (testFile), not the
287
+ // POM (editTarget). fileRenames keys off rec.testFile so the spec is
288
+ // what gets renamed; computing from editTarget when pomFile is set
289
+ // would produce a name derived from the page-object path instead.
264
290
  const suggestedNewFile = isRenameUpdate
265
291
  ? computeRenamedTestFile(rec.testFile, renames) ?? undefined
266
292
  : undefined;
267
293
  if (suggestedNewFile)
268
294
  rec._suggestedNewFile = suggestedNewFile;
269
295
  fileInstructions.push(buildUpdateFileInstruction({
270
- testFile: rec.testFile,
296
+ testFile: editTarget,
271
297
  renames,
272
298
  suggestedNewFile,
273
299
  updateInstructions: rec.updateInstructions,
@@ -354,8 +380,9 @@ export function registerActionsTool(server) {
354
380
  const updateContext = updateRecommendations
355
381
  .filter((rec) => !!rec.updateInstructions)
356
382
  .map((rec) => {
357
- const current_content = testFileContentMap.get(rec.testFile);
358
- return { file: rec.testFile, context: rec.updateInstructions, ...(current_content !== undefined && { current_content }) };
383
+ const editTarget = rec.pomFile ?? rec.testFile;
384
+ const current_content = testFileContentMap.get(editTarget);
385
+ return { file: editTarget, context: rec.updateInstructions, ...(current_content !== undefined && { current_content }) };
359
386
  });
360
387
  if (updateContext.length > 0) {
361
388
  llmInstructionsObj.update_context = updateContext;
@@ -370,15 +397,6 @@ export function registerActionsTool(server) {
370
397
  });
371
398
  llmInstructionsObj.regenerate_strategy = buildRegenerateStrategy();
372
399
  }
373
- // Stage UPDATE and REGENERATE target paths so testbot's re-stage step
374
- // commits the agent's edits even when files are outside testDirectory.
375
- const stagedPaths = [
376
- ...updateRecommendations.map((r) => r.testFile),
377
- ...regenerateRecommendations.map((r) => r.testFile),
378
- ];
379
- for (const p of stagedPaths) {
380
- await stageGeneratedPaths(p).catch((e) => logger.warning(`Failed to stage maintained file ${p}: ${e.message}`));
381
- }
382
400
  const llmInstructions = JSON.stringify(llmInstructionsObj, null, 2);
383
401
  const contentBlocks = [
384
402
  {
@@ -690,12 +690,18 @@ export function registerAnalyzeChangesTool(server) {
690
690
  changedResources = fromChangedFiles.length > 0 ? fromChangedFiles : [];
691
691
  }
692
692
  }
693
+ // Compute changed frontend files once — reused for both discoverTests (component
694
+ // test promotion) and uiContext below (page enumeration). Uses classifiedEndpoints
695
+ // changedFiles so it reflects the same diff scope as the rest of the analysis.
696
+ const flutterSdkDep = hasFlutterSdkDep(params.repositoryPath);
697
+ const changedFrontendFiles = (classifiedEndpoints?.changedFiles ?? diffData?.changedFiles ?? [])
698
+ .filter(f => isFrontendFile(f, { hasFlutterSdkDep: flutterSdkDep }) && !isTestFile(f));
693
699
  let existingTests = [];
694
700
  let discoveredRelevantExternalPaths = [];
695
701
  try {
696
702
  const testDiscoveryService = new TestDiscoveryService();
697
703
  setTestsRepoDir(params.testsRepoDir);
698
- const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath, { changedResources });
704
+ const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath, { changedResources, hasFrontendChanges: changedFrontendFiles.length > 0 });
699
705
  existingTests = discoveryResult.tests.map((test) => ({
700
706
  testFile: test.testFile,
701
707
  testType: test.testType,
@@ -1218,24 +1224,12 @@ export function registerAnalyzeChangesTool(server) {
1218
1224
  // browser_blueprints — see uiInstructions below, which this tool returns
1219
1225
  // so the agent captures element vocabulary for UI rec reasoning.
1220
1226
  const uiContext = await (async () => {
1221
- const changedFiles = classifiedEndpoints?.changedFiles ?? [];
1222
- if (changedFiles.length === 0)
1227
+ // changedFrontendFiles computed above (before discoverTests) — reuse here.
1228
+ if (changedFrontendFiles.length === 0)
1223
1229
  return undefined;
1224
- // Filter to frontend source files only — exclude test files, which
1225
- // pass isFrontendFile (any .ts under a frontend directory matches
1226
- // the tier-3 rule) but aren't UI source we'd want to ground page
1227
- // enumeration in.
1228
- // Compute hasFlutterSdkDep once and pass it through so .dart files
1229
- // in a Flutter project are recognised as frontend. See Confluence
1230
- // "Flutter support in Testbot" — this is the budget-driving call site
1231
- // that must thread the flag so a Flutter PR enters the UI pipeline.
1232
- const flutterSdk = hasFlutterSdkDep(params.repositoryPath);
1233
- const frontendFiles = changedFiles.filter((f) => isFrontendFile(f, { hasFlutterSdkDep: flutterSdk }) && !isTestFile(f));
1234
- if (frontendFiles.length === 0)
1235
- return undefined;
1236
- const candidateUiPages = await enumerateCandidateUiPages(params.repositoryPath, frontendFiles);
1230
+ const candidateUiPages = await enumerateCandidateUiPages(params.repositoryPath, changedFrontendFiles);
1237
1231
  return {
1238
- changedFrontendFiles: frontendFiles,
1232
+ changedFrontendFiles,
1239
1233
  candidateUiPages,
1240
1234
  };
1241
1235
  })();
@@ -2,24 +2,148 @@ import { z } from "zod";
2
2
  import { logger } from "../../utils/logger.js";
3
3
  import { StateManager, } from "../../utils/AnalysisStateManager.js";
4
4
  import path from "path";
5
+ import * as fs from "fs/promises";
5
6
  import { AnalyticsService } from "../../services/AnalyticsService.js";
6
7
  import { buildDriftAnalysisPrompt } from "../../prompts/test-maintenance/drift-analysis-prompt.js";
7
8
  import { TestSource } from "../../types/TestAnalysis.js";
8
9
  import { toolError } from "../../utils/utils.js";
9
10
  const TOOL_NAME = "skyramp_analyze_test_health";
11
+ // UI test frameworks. "playwright-ui" and "cypress-ui" are the browser-interaction
12
+ // variants detected when the file uses the page fixture (not just the request fixture).
13
+ // Plain "playwright"/"cypress" without the -ui suffix = REST API usage — excluded.
14
+ const UI_COMPONENT_FRAMEWORKS = new Set([
15
+ "rtl", "react-testing-library",
16
+ "vue-test-utils", "angular-testbed", "svelte-testing-library",
17
+ "enzyme",
18
+ "flutter-widget", "flutter-test",
19
+ "xcuitest", "espresso", "appium",
20
+ "selenium",
21
+ "playwright-ui", "cypress-ui",
22
+ ]);
23
+ // No REST API test is ever written in TSX/JSX — extension alone is sufficient.
24
+ const UI_TEST_EXTENSIONS = /\.(test|spec)\.(tsx|jsx)$/;
25
+ // Snapshot files (.snap) are secondary artifacts of UI tests — send them through
26
+ // the UI drift path so stale snapshots are detected when frontend selectors change.
27
+ const UI_SNAPSHOT_EXT = /\.snap$/;
28
+ // Playwright/Cypress without the "-ui" suffix = REST API usage — must not be routed
29
+ // to UI drift even when detectExternalTestType() labels the file as "e2e".
30
+ const API_ONLY_FRAMEWORKS = new Set(["playwright", "cypress"]);
31
+ function isUiTest(t) {
32
+ const fw = t.framework?.toLowerCase() ?? "";
33
+ // testType==="e2e" can be set by detectExternalTestType() for any file under
34
+ // playwright/ or cypress/ dirs, including API-only specs. Exclude those.
35
+ if (t.testType === "e2e" && API_ONLY_FRAMEWORKS.has(fw))
36
+ return false;
37
+ // .snap files are secondary artifacts — only route them to UI drift when
38
+ // accompanied by a UI testType or framework signal. API test snapshots
39
+ // (e.g. __snapshots__/orders_smoke.test.ts.snap) must remain in the API path.
40
+ const isUiSnap = UI_SNAPSHOT_EXT.test(t.testFile) &&
41
+ (t.testType === "ui" || UI_COMPONENT_FRAMEWORKS.has(fw));
42
+ return t.testType === "ui" || t.testType === "e2e" ||
43
+ UI_COMPONENT_FRAMEWORKS.has(fw) ||
44
+ UI_TEST_EXTENSIONS.test(t.testFile) ||
45
+ isUiSnap;
46
+ }
47
+ // Grep exported symbols + diff UI signals across external tests to find tests that
48
+ // reference changed frontend files when relevance scoring returns nothing (UI-only PRs).
49
+ const SYMBOL_GREP_MAX_READS = 150;
50
+ const SYMBOL_GREP_MAX_RESULTS = 20;
51
+ const GENERIC_SLUGS = new Set([
52
+ "index", "app", "main", "utils", "util", "lib", "helpers", "helper",
53
+ "common", "shared", "base", "core", "types", "constants", "config",
54
+ "hooks", "store", "context", "layout", "page", "view", "component",
55
+ ]);
56
+ async function findUiTestsBySymbolGrep(changedFrontendFiles, allTests, repositoryPath, alreadyIncluded, diffFilePath) {
57
+ const symbols = new Set();
58
+ for (const file of changedFrontendFiles) {
59
+ const abs = path.isAbsolute(file) ? file : path.resolve(repositoryPath, file);
60
+ const content = await fs.readFile(abs, "utf-8").catch(() => "");
61
+ for (const m of content.matchAll(/export\s+(?:default\s+)?(?:function|class|const|let|var)\s+(\w{3,})/g)) {
62
+ symbols.add(m[1]);
63
+ }
64
+ for (const m of content.matchAll(/(?:name|__name):\s*['"](\w[\w-]{2,})['"]/g)) {
65
+ symbols.add(m[1]);
66
+ }
67
+ const slug = path.basename(file).replace(/\.\w+$/, "");
68
+ if (!GENERIC_SLUGS.has(slug.toLowerCase()) && (slug.includes("-") || slug.length > 6)) {
69
+ symbols.add(slug);
70
+ const pascal = slug.replace(/(^|[-_])(\w)/g, (_, _sep, c) => c.toUpperCase());
71
+ symbols.add(pascal);
72
+ }
73
+ }
74
+ // Also extract quoted string literals from diff +/- lines for changed frontend files.
75
+ // This catches tests referencing OLD values (renamed testid, aria-label, CSS class) that
76
+ // only appear in `-` diff lines, not in the current file content.
77
+ if (diffFilePath) {
78
+ const diffContent = await fs.readFile(diffFilePath, "utf-8").catch(() => "");
79
+ // Exact path markers prevent basename substring false-positives
80
+ // (e.g. "personal-calendar-view.test.tsx" matching "personal-calendar-view.tsx").
81
+ const changedFileMarkers = changedFrontendFiles.flatMap(f => {
82
+ const rel = f.replace(/\\/g, "/");
83
+ return [` a/${rel} `, ` b/${rel}`];
84
+ });
85
+ let inChangedFile = false;
86
+ for (const line of diffContent.split("\n")) {
87
+ if (line.startsWith("diff --git ")) {
88
+ inChangedFile = changedFileMarkers.length > 0 &&
89
+ changedFileMarkers.some(marker => line.includes(marker));
90
+ }
91
+ if (!inChangedFile)
92
+ continue;
93
+ if (!line.startsWith("+") && !line.startsWith("-"))
94
+ continue;
95
+ // Min 10 chars skips generic values ("true","false","continue") that match
96
+ // hundreds of unrelated test files.
97
+ for (const m of line.matchAll(/["']([^"'\n]{10,60})["']/g)) {
98
+ symbols.add(m[1]);
99
+ }
100
+ }
101
+ }
102
+ if (symbols.size === 0)
103
+ return [];
104
+ const symArray = [...symbols];
105
+ logger.debug(`Symbol grep: ${symArray.length} symbol(s) — ${symArray.slice(0, 5).join(", ")}${symArray.length > 5 ? "..." : ""}`);
106
+ // Normalize paths — stateData.existingTests may contain relative paths in
107
+ // older state files. Mirror the re-absolutization done for relevantExternalPaths.
108
+ const candidates = allTests
109
+ .filter(t => (t.source ?? TestSource.Skyramp) === TestSource.External &&
110
+ isUiTest(t) &&
111
+ !alreadyIncluded.has(t.testFile))
112
+ .map(t => ({
113
+ ...t,
114
+ testFile: path.isAbsolute(t.testFile)
115
+ ? t.testFile
116
+ : path.resolve(repositoryPath, t.testFile),
117
+ }))
118
+ .slice(0, SYMBOL_GREP_MAX_READS);
119
+ // Read in batches to avoid EMFILE/ENFILE errors on large test suites.
120
+ const BATCH = 20;
121
+ const contents = [];
122
+ for (let i = 0; i < candidates.length; i += BATCH) {
123
+ const batch = await Promise.all(candidates.slice(i, i + BATCH).map(t => fs.readFile(t.testFile, "utf-8").catch(() => "")));
124
+ contents.push(...batch);
125
+ }
126
+ return candidates
127
+ .filter((_, i) => symArray.some(sym => contents[i].includes(sym)))
128
+ .slice(0, SYMBOL_GREP_MAX_RESULTS);
129
+ }
10
130
  export function registerAnalyzeTestHealthTool(server) {
11
131
  server.registerTool(TOOL_NAME, {
12
132
  annotations: {
13
- readOnlyHint: false, // deletes old state files via StateManager.cleanupOldFiles
14
- destructiveHint: false,
133
+ readOnlyHint: false,
134
+ destructiveHint: true, // deletes stale diff files via StateManager.cleanupOldFiles
15
135
  idempotentHint: true,
16
136
  openWorldHint: false,
17
137
  },
18
- description: `Generate drift assessment instructions for existing tests second step of the unified Test Health Analysis Flow.`,
138
+ description: `Assess existing tests for drift against the current branch diff and generate maintenance instructions (UPDATE, REGENERATE, DELETE, VERIFY, or IGNORE) for each test. Covers both API tests and UI tests (Playwright, Cypress, RTL, component tests). Requires a stateFile from skyramp_analyze_changes.`,
19
139
  inputSchema: {
20
140
  stateFile: z
21
141
  .string()
22
142
  .describe("Path to the analyze-changes-state.json file returned by skyramp_analyze_changes (the stateFile field in its output). Do NOT pass summaryOutputFile or testbot-result.txt here."),
143
+ blueprintCaptured: z
144
+ .boolean()
145
+ .optional()
146
+ .describe("If hasFrontendChanges is true in the skyramp_analyze_changes output: call browser_navigate to the primary route affected by the diff, then call browser_blueprint to capture the current DOM, then call this tool with blueprintCaptured: true. If hasFrontendChanges is false: call this tool directly and omit this parameter. Passing true activates blueprint-diff analysis for browser UI tests (Playwright, Cypress, Selenium). Component/widget tests always use source-only analysis regardless of this flag."),
23
147
  repository: z
24
148
  .string()
25
149
  .optional()
@@ -61,17 +185,67 @@ export function registerAnalyzeTestHealthTool(server) {
61
185
  const skyrampCount = existingTests.filter((t) => (t.source ?? TestSource.Skyramp) !== TestSource.External).length;
62
186
  const externalCount = existingTests.length - skyrampCount;
63
187
  logger.info(`Loaded ${skyrampCount} Skyramp + ${externalCount} relevant external tests from state file`);
64
- // Sweep stale diff files on this natural follow-up call so they don't accumulate.
65
- // Pass [] for stateTypes so only skyramp-diff-*.diff files are deleted — state files
66
- // (skyramp-analysis-*, skyramp-recommendation-*) must not be removed here because the
67
- // caller still needs args.stateFile to pass to skyramp_actions.
188
+ // Delete stale diff files only state files must remain for the caller's skyramp_actions call.
68
189
  try {
69
190
  await StateManager.cleanupOldFiles(24, undefined, []);
70
191
  }
71
192
  catch (error) {
72
193
  logger.warning(`Failed to cleanup old diff files: ${error.message}`);
73
194
  }
74
- const promptText = buildDriftAnalysisPrompt(stateManager.getStatePath(), existingTests.map((t) => ({ testFile: t.testFile, source: t.source })));
195
+ const uiTests = [];
196
+ const apiTests = [];
197
+ for (const t of existingTests) {
198
+ (isUiTest(t) ? uiTests : apiTests).push(t);
199
+ }
200
+ const uiContext = stateData.uiContext;
201
+ const hasUiFrontend = !!(uiContext?.changedFrontendFiles?.length);
202
+ // When frontend files changed, use the symbol grep to rank uiTests by relevance
203
+ // and cap the list to avoid token overflow in the drift prompt.
204
+ // All bulk-promoted UI tests are now in relevantExternalTestPaths and therefore
205
+ // in uiTests — the grep selects the most relevant subset rather than being the
206
+ // sole discovery path (which could silently drop tests that need UPDATE).
207
+ if (hasUiFrontend && uiTests.length >= SYMBOL_GREP_MAX_RESULTS) {
208
+ // Skyramp-generated UI tests always pass through — grep only ranks externals.
209
+ const skyrampUi = uiTests.filter(t => (t.source ?? TestSource.Skyramp) !== TestSource.External);
210
+ const externalUi = uiTests.filter(t => (t.source ?? TestSource.Skyramp) === TestSource.External);
211
+ const externalCap = Math.max(0, SYMBOL_GREP_MAX_RESULTS - skyrampUi.length);
212
+ // Grep the full discovered pool with empty alreadyIncluded so both:
213
+ // (a) externalUi members are ranked by relevance, and
214
+ // (b) tests not yet in uiTests (e.g. onboarding.e2e.ts missing from
215
+ // relevantExternalTestPaths) can still be found via diff signals.
216
+ // Previous bug: alreadyInUiTests excluded all candidates → grep returned [].
217
+ const grepped = await findUiTestsBySymbolGrep(uiContext.changedFrontendFiles, stateData.existingTests || [], repositoryPath, new Set(), stateData.repositoryAnalysis?.diffFilePath);
218
+ const rankedExternal = grepped.length > 0
219
+ ? grepped.slice(0, externalCap)
220
+ : externalUi.slice(0, externalCap); // fallback: discovery order
221
+ if (grepped.length > 0) {
222
+ logger.info(`Symbol grep ranked ${Math.min(grepped.length, externalCap)} external UI test(s) from ${stateData.existingTests?.length ?? 0} candidates`);
223
+ }
224
+ else {
225
+ logger.info(`Symbol grep found no matches; capping ${externalUi.length} external UI tests to ${externalCap}`);
226
+ }
227
+ uiTests.splice(0, uiTests.length, ...skyrampUi, ...rankedExternal);
228
+ }
229
+ else if (hasUiFrontend && uiTests.length === 0) {
230
+ // No UI tests found — grep the full discovered pool.
231
+ // Pass empty alreadyIncluded: we want to discover tests from stateData.existingTests,
232
+ // not exclude them. uiTests is empty so there's nothing to avoid re-adding.
233
+ const grepped = await findUiTestsBySymbolGrep(uiContext.changedFrontendFiles, stateData.existingTests || [], repositoryPath, new Set(), stateData.repositoryAnalysis?.diffFilePath);
234
+ if (grepped.length > 0) {
235
+ logger.info(`Symbol grep found ${grepped.length} UI test(s) referencing changed frontend files`);
236
+ uiTests.push(...grepped);
237
+ }
238
+ }
239
+ let uiDriftParams;
240
+ if (hasUiFrontend) {
241
+ logger.info(`Including UI drift analysis for ${uiTests.length} UI test(s), ${apiTests.length} API test(s)`);
242
+ uiDriftParams = {
243
+ tests: uiTests.map(t => ({ testFile: t.testFile, source: t.source })),
244
+ changedFrontendFiles: uiContext.changedFrontendFiles,
245
+ blueprintCaptured: args.blueprintCaptured,
246
+ };
247
+ }
248
+ const promptText = buildDriftAnalysisPrompt(stateManager.getStatePath(), apiTests.map((t) => ({ testFile: t.testFile, source: t.source })), uiDriftParams);
75
249
  return {
76
250
  structuredContent: { prompt: promptText },
77
251
  content: [{ type: "text", text: "Drift analysis prompt generated. Follow the prompt field to assess each test." }],