@skyramp/mcp 0.1.8 → 0.2.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/build/index.js +4 -2
  2. package/build/playwright/registerPlaywrightTools.js +12 -0
  3. package/build/playwright/traceRecordingPrompt.js +15 -0
  4. package/build/prompts/code-reuse.js +106 -7
  5. package/build/prompts/pom-aware-code-reuse.js +106 -7
  6. package/build/prompts/startTraceCollectionPrompts.js +37 -15
  7. package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -31
  8. package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +40 -1
  9. package/build/prompts/test-maintenance/driftAnalysisSections.js +90 -86
  10. package/build/prompts/test-recommendation/analysisOutputPrompt.js +286 -163
  11. package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -45
  12. package/build/prompts/test-recommendation/diffExecutionPlan.js +246 -117
  13. package/build/prompts/test-recommendation/promptPlan.js +290 -0
  14. package/build/prompts/test-recommendation/promptPlan.test.js +336 -0
  15. package/build/prompts/test-recommendation/recommendationSections.js +4 -3
  16. package/build/prompts/test-recommendation/recommendationShared.js +23 -1
  17. package/build/prompts/test-recommendation/scopeAssessment.js +65 -14
  18. package/build/prompts/test-recommendation/scopeAssessment.test.js +93 -2
  19. package/build/prompts/test-recommendation/test-recommendation-prompt.js +36 -12
  20. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +316 -1
  21. package/build/prompts/testbot/testbot-prompts.js +73 -13
  22. package/build/prompts/testbot/testbot-prompts.test.js +114 -1
  23. package/build/resources/testbotResource.js +1 -1
  24. package/build/services/ScenarioGenerationService.integration.test.js +158 -0
  25. package/build/services/ScenarioGenerationService.js +47 -4
  26. package/build/services/ScenarioGenerationService.test.js +158 -22
  27. package/build/services/TestExecutionService.js +73 -15
  28. package/build/services/TestExecutionService.test.js +105 -0
  29. package/build/services/TestGenerationService.js +11 -1
  30. package/build/tools/executeSkyrampTestTool.js +1 -10
  31. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +16 -4
  32. package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
  33. package/build/tools/generate-tests/generateUIRestTool.js +2 -0
  34. package/build/tools/test-management/actionsTool.js +152 -63
  35. package/build/tools/test-management/analyzeChangesTool.js +178 -64
  36. package/build/tools/test-management/analyzeChangesTool.test.js +103 -16
  37. package/build/tools/test-management/analyzeTestHealthTool.js +30 -81
  38. package/build/tools/test-management/index.js +1 -0
  39. package/build/tools/test-management/uiAnalyzeChangesTool.js +149 -0
  40. package/build/tools/test-management/uiAnalyzeChangesTool.test.js +100 -0
  41. package/build/tools/trace/resolveSaveStoragePath.js +16 -0
  42. package/build/tools/trace/resolveSaveStoragePath.test.js +17 -0
  43. package/build/tools/trace/resolveSessionPaths.js +39 -0
  44. package/build/tools/trace/resolveSessionPaths.test.js +103 -0
  45. package/build/tools/trace/sessionState.js +14 -0
  46. package/build/tools/trace/sessionState.test.js +17 -0
  47. package/build/tools/trace/startTraceCollectionTool.js +84 -14
  48. package/build/tools/trace/stopTraceCollectionTool.js +9 -2
  49. package/build/types/TestAnalysis.js +50 -0
  50. package/build/types/TestRecommendation.js +6 -58
  51. package/build/types/TestTypes.js +1 -1
  52. package/build/utils/AnalysisStateManager.js +22 -11
  53. package/build/utils/branchDiff.js +11 -2
  54. package/build/utils/docker.test.js +1 -1
  55. package/build/utils/gitStaging.js +52 -3
  56. package/build/utils/gitStaging.test.js +19 -1
  57. package/build/utils/repoScanner.js +18 -10
  58. package/build/utils/repoScanner.test.js +92 -0
  59. package/build/utils/routeParsers.js +180 -25
  60. package/build/utils/routeParsers.test.js +180 -1
  61. package/build/utils/scenarioDrafting.js +220 -17
  62. package/build/utils/scenarioDrafting.test.js +182 -9
  63. package/build/utils/sourceRouteExtractor.js +806 -0
  64. package/build/utils/sourceRouteExtractor.test.js +565 -0
  65. package/build/utils/uiPageEnumerator.js +319 -0
  66. package/build/utils/uiPageEnumerator.test.js +422 -0
  67. package/build/utils/utils.js +27 -0
  68. package/build/utils/versions.js +1 -1
  69. package/build/utils/workspaceAuth.js +33 -4
  70. package/node_modules/playwright/ThirdPartyNotices.txt +6 -6
  71. package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
  72. package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1210 -0
  73. package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
  74. package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
  75. package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
  76. package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +254 -0
  77. package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +304 -0
  78. package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
  79. package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
  80. package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
  81. package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
  82. package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
  83. package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
  84. package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
  85. package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
  86. package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
  87. package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
  88. package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
  89. package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -0
  90. package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.js +150 -0
  91. package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.test.js +470 -0
  92. package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.js +169 -0
  93. package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
  94. package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
  95. package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
  96. package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
  97. package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
  98. package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
  99. package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
  100. package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
  101. package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
  102. package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
  103. package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
  104. package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
  105. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +146 -0
  106. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +140 -0
  107. package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
  108. package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
  109. package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
  110. package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
  111. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
  112. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
  113. package/node_modules/playwright/package.json +1 -1
  114. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
  115. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
  116. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
  117. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
  118. package/package.json +3 -3
  119. package/build/services/TestHealthService.js +0 -694
  120. package/build/services/TestHealthService.test.js +0 -241
  121. package/build/types/TestDriftAnalysis.js +0 -1
  122. package/build/types/TestHealth.js +0 -4
@@ -8,8 +8,10 @@ import { simpleGit } from "simple-git";
8
8
  import { logger } from "../../utils/logger.js";
9
9
  import { parseWorkspaceAuthType, getDefaultAuthHeader, WorkspaceAuthType, readWorkspaceConfigRaw } from "../../utils/workspaceAuth.js";
10
10
  import { AnalyticsService } from "../../services/AnalyticsService.js";
11
- import { StateManager, registerSession, storeSessionData, } from "../../utils/AnalysisStateManager.js";
11
+ import { StateManager, registerSession, storeSessionData, setTestsRepoDir, } from "../../utils/AnalysisStateManager.js";
12
12
  import { buildRecommendationPrompt } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
13
+ import { isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
14
+ import { enumerateCandidateUiPages } from "../../utils/uiPageEnumerator.js";
13
15
  import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
14
16
  import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
15
17
  import { ScenarioSource, AnalysisScope } from "../../types/RepositoryAnalysis.js";
@@ -32,6 +34,10 @@ export function buildTraceFileEntry(tracePath, result) {
32
34
  };
33
35
  }
34
36
  const TOOL_NAME = "skyramp_analyze_changes";
37
+ const SECURITY_RELEVANT_DIFF_PATTERN = /\b(?:auth|authorization|permission|permissions|admin[-_\s]?key|x-admin-key|role|roles|rbac|owner|ownership|guard|auth[-_\s]?middleware|permission[-_\s]?middleware|require[-_\s]?(?:auth|admin|role)|authorize|authorized|authenticated|require_admin_key|destructive)\b/i;
38
+ export function isSecurityRelevantDiff(diffContent) {
39
+ return SECURITY_RELEVANT_DIFF_PATTERN.test(diffContent);
40
+ }
35
41
  // Must match testbot/src/constants.ts BOT_EMAIL
36
42
  const BOT_EMAIL = "test-bot@skyramp.dev";
37
43
  /**
@@ -192,23 +198,66 @@ export function filterEndpointsBySpec(scannedEndpoints, specPaths, specPathItems
192
198
  return filtered;
193
199
  }
194
200
  const GRAPHQL_EXT = /\.(graphql|gql)$/i;
195
- const GRAPHQL_CONTENT_PATTERN = /^\s*(type\s+(Query|Mutation|Subscription)\s*\{|schema\s*\{|extend\s+type|directive\s+@)/m;
201
+ const GRAPHQL_SCHEMA_CONTENT_PATTERN = /^\s*(type\s+(Query|Mutation|Subscription)\s*\{|schema\s*\{|extend\s+type|directive\s+@)/m;
202
+ const GRAPHQL_IMPLEMENTATION_CONTENT_PATTERN = /(?:@Resolver\b|from\s+["'](?:@nestjs\/graphql|apollo-server|graphql-yoga|type-graphql)["']|\b(?:ApolloServer|GraphQLObjectType|GraphQLSchema|makeExecutableSchema|buildSchema)\b|(?:\btypeDefs\b[\s\S]{0,200}\bresolvers\b|\bresolvers\b[\s\S]{0,200}\btypeDefs\b))/m;
203
+ /** Path-based detection for non-code GraphQL artifacts.
204
+ * Matches "graphql" as a directory segment for files without a code extension
205
+ * (e.g. services/graphql/schema), but NOT filenames that merely contain
206
+ * "graphql" (e.g. graphql-test-route.ts). */
207
+ const GRAPHQL_DIR_PATTERN = /(?:^|[/\\])graphql(?:[/\\]|$)/i;
208
+ /** Code file extensions — these should use content detection, not directory heuristic,
209
+ * because .ts/.js files in a graphql/ directory are often resolvers or route-adjacent
210
+ * code and should not trigger the GraphQL-only early return by path alone. */
211
+ const CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|java|kt|rs|cs)$/i;
196
212
  export async function isGraphQLFile(filePath, repositoryPath) {
197
213
  if (GRAPHQL_EXT.test(filePath))
198
214
  return true;
215
+ // Only use directory heuristic for non-code files (e.g. extensionless files).
216
+ // Code files in a graphql/ dir may be REST-reachable resolver glue; use content detection instead.
217
+ if (GRAPHQL_DIR_PATTERN.test(filePath) && !CODE_EXT.test(filePath))
218
+ return true;
199
219
  try {
200
220
  const absPath = path.join(repositoryPath, filePath);
201
221
  const fileContent = await fs.promises.readFile(absPath, "utf-8");
202
- return GRAPHQL_CONTENT_PATTERN.test(fileContent);
222
+ return GRAPHQL_SCHEMA_CONTENT_PATTERN.test(fileContent) ||
223
+ GRAPHQL_IMPLEMENTATION_CONTENT_PATTERN.test(fileContent);
203
224
  }
204
225
  catch {
205
226
  return false;
206
227
  }
207
228
  }
229
+ function isGraphQLEndpointPath(endpointPath) {
230
+ return /(?:^|\/)graphql(?:\/|$)/i.test(endpointPath);
231
+ }
232
+ async function isUnsupportedGraphQLEndpoint(endpoint, repositoryPath, checkGraphQLFile = (filePath) => isGraphQLFile(filePath, repositoryPath)) {
233
+ return isGraphQLEndpointPath(endpoint.path) ||
234
+ await checkGraphQLFile(endpoint.sourceFile);
235
+ }
236
+ export async function filterUnsupportedGraphQLEndpoints(endpoints, repositoryPath) {
237
+ const graphqlFileCache = new Map();
238
+ const checkGraphQLFile = (filePath) => {
239
+ if (!filePath)
240
+ return Promise.resolve(false);
241
+ const cached = graphqlFileCache.get(filePath);
242
+ if (cached)
243
+ return cached;
244
+ const result = isGraphQLFile(filePath, repositoryPath);
245
+ graphqlFileCache.set(filePath, result);
246
+ return result;
247
+ };
248
+ const filtered = [];
249
+ for (const endpoint of endpoints) {
250
+ if (!(await isUnsupportedGraphQLEndpoint(endpoint, repositoryPath, checkGraphQLFile))) {
251
+ filtered.push(endpoint);
252
+ }
253
+ }
254
+ return filtered;
255
+ }
208
256
  function isNonApplicationFile(filePath) {
209
257
  return NON_APP_PATTERNS.some((p) => p.test(filePath));
210
258
  }
211
- const ROUTE_FILE_PATTERN = /route|controller|endpoint|handler|view|urls|api|router/i;
259
+ const ROUTE_FILE_PATTERN = /route|controller|endpoint|handler|view|urls|api|router|service|gateway|resolver|\bserver\b/i;
260
+ const ROUTE_FILE_BASENAME_PATTERN = /\bapp\b|\bmain\b/i;
212
261
  const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
213
262
  /**
214
263
  * Recover endpoints from files deleted in this branch by reading their
@@ -216,7 +265,7 @@ const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
216
265
  * name matches route-file heuristics to keep I/O bounded.
217
266
  */
218
267
  async function recoverDeletedFileEndpoints(repositoryPath, baseBranch, deletedFiles) {
219
- const candidates = deletedFiles.filter((f) => SOURCE_EXTS.test(f) && ROUTE_FILE_PATTERN.test(f));
268
+ const candidates = deletedFiles.filter((f) => SOURCE_EXTS.test(f) && (ROUTE_FILE_PATTERN.test(f) || ROUTE_FILE_BASENAME_PATTERN.test(path.basename(f))));
220
269
  if (candidates.length === 0)
221
270
  return [];
222
271
  const git = simpleGit(repositoryPath);
@@ -226,12 +275,15 @@ async function recoverDeletedFileEndpoints(repositoryPath, baseBranch, deletedFi
226
275
  try {
227
276
  const content = await git.show([`${baseBranch}:${file}`]);
228
277
  for (const ep of parseFileEndpoints(content, file)) {
229
- const existing = endpointMap.get(ep.path);
278
+ const normalizedPath = ep.path.startsWith("/") ? ep.path : `/${ep.path}`;
279
+ const key = `${file}::${normalizedPath}`;
280
+ const existing = endpointMap.get(key);
230
281
  if (existing) {
231
282
  existing.methods.add(ep.method);
232
283
  }
233
284
  else {
234
- endpointMap.set(ep.path, {
285
+ endpointMap.set(key, {
286
+ path: normalizedPath,
235
287
  methods: new Set([ep.method]),
236
288
  sourceFile: file,
237
289
  });
@@ -246,9 +298,9 @@ async function recoverDeletedFileEndpoints(repositoryPath, baseBranch, deletedFi
246
298
  });
247
299
  }
248
300
  }
249
- for (const [apiPath, data] of endpointMap) {
301
+ for (const data of endpointMap.values()) {
250
302
  results.push({
251
- path: apiPath,
303
+ path: data.path,
252
304
  methods: Array.from(data.methods),
253
305
  sourceFile: data.sourceFile,
254
306
  });
@@ -263,7 +315,7 @@ export const analyzeChangesInputSchema = {
263
315
  .enum(["full_repo", "branch_diff"])
264
316
  .default("branch_diff")
265
317
  .optional()
266
- .describe("Analysis scope: 'full_repo' scans entire repo, 'branch_diff' focuses on current branch changes"),
318
+ .describe("Analysis scope. 'full_repo': scans all API endpoints in the repository. 'branch_diff': scans only API endpoints affected by current branch changes — faster for CI use. Default: 'branch_diff'."),
267
319
  baseBranch: z
268
320
  .string()
269
321
  .optional()
@@ -286,29 +338,31 @@ export const analyzeChangesInputSchema = {
286
338
  prNumber: z
287
339
  .number()
288
340
  .optional()
289
- .describe("GitHub PR number. When provided, fetches previous TestBot comments for recommendation deduplication across commits."),
341
+ .describe("GitHub PR number. When provided, fetches previous TestBot comments on this PR and skips re-recommending tests already suggested in earlier commits — reduces duplicate recommendations across multiple pushes to the same PR."),
290
342
  stateOutputFile: z
291
343
  .string()
292
344
  .refine((v) => path.isAbsolute(v), { message: "stateOutputFile must be an absolute path" })
293
345
  .optional()
294
346
  .describe("Absolute path where the state file should be written. When provided, overrides the default auto-generated temp path so the caller can locate it without log parsing."),
347
+ testsRepoDir: z
348
+ .string()
349
+ .refine((v) => path.isAbsolute(v), { message: "testsRepoDir must be an absolute path" })
350
+ .optional()
351
+ .describe("Absolute path to a separate test repository clone. When set, existing test discovery scans this directory instead of repositoryPath. Used in cross-repo test delivery mode where tests live in a separate repo."),
295
352
  };
296
353
  export function registerAnalyzeChangesTool(server) {
297
354
  server.registerTool(TOOL_NAME, {
298
- description: `Analyze repository changes and discover existing tests — first step of the unified Test Health Analysis Flow.
299
-
300
- This tool combines repository analysis (endpoint scanning, branch diff) with test discovery
301
- to produce a unified state file for the test health workflow.
355
+ annotations: {
356
+ readOnlyHint: false, // writes a state file to disk
357
+ destructiveHint: false,
358
+ idempotentHint: false,
359
+ openWorldHint: true, // may fetch PR comments from GitHub
360
+ },
361
+ description: `Scan repository API endpoints and discover existing tests — first step of the unified Test Health Analysis Flow.
302
362
 
303
- **Workflow:**
304
- 1. Call \`skyramp_analyze_changes\` → discovers existing tests, scans endpoints, computes branch diff → returns a stateFile
305
- 2. Call \`skyramp_analyze_test_health\` with stateFile → drift analysis + health scoring
306
- 3. (Optional) Execute tests using \`skyramp_execute_test\` with \`stateFile\` param for each test file → validates test status live and writes results back for execution-aware health scoring
307
- 4. Call \`skyramp_actions\` with stateFile → execute UPDATE/REGENERATE/ADD recommendations (with execution-aware prioritization if step 3 ran)
363
+ Combines API endpoint scanning, branch diff computation, and test discovery into a single state file consumed by \`skyramp_analyze_test_health\` and \`skyramp_actions\`.
308
364
 
309
- **Output:** stateFile path + LLM instructions for enrichment and calling skyramp_analyze_test_health
310
-
311
- **Recommendation path:** The response also includes inline ranked test recommendations and source-code enrichment instructions. Follow the enrichment steps (read handler + schema files), draft enrichedScenarios, then call \`skyramp_recommend_tests\` with stateFile and enrichedScenarios for richer, field-accurate recommendations.`,
365
+ **Output:** stateFile path + ranked test recommendations + enrichment instructions for calling \`skyramp_recommend_tests\`.`,
312
366
  // TODO: Define outputSchema here instead of embedding structured output format in the
313
367
  // description string — per Archit's review comment. outputSchema reduces token usage
314
368
  // by letting the MCP client understand the response shape structurally rather than
@@ -373,6 +427,7 @@ to produce a unified state file for the test health workflow.
373
427
  }
374
428
  // ── Step 2: Scan endpoints ──
375
429
  let scannedEndpoints = [];
430
+ let rawRelatedEndpointCount;
376
431
  if (analysisScope !== AnalysisScope.CurrentBranchDiff) {
377
432
  await sendProgress(25, 100, "Scanning all repository endpoints...");
378
433
  try {
@@ -391,15 +446,25 @@ to produce a unified state file for the test health workflow.
391
446
  await sendProgress(25, 100, "Scanning related endpoints from diff...");
392
447
  try {
393
448
  scannedEndpoints = scanRelatedEndpoints(params.repositoryPath, diffData.changedFiles);
449
+ rawRelatedEndpointCount = scannedEndpoints.length;
394
450
  logger.info("Scanned related endpoints", {
395
451
  count: scannedEndpoints.length,
396
452
  });
397
453
  }
398
454
  catch (err) {
455
+ rawRelatedEndpointCount = 0;
399
456
  logger.warning("Related endpoint scan failed", {
400
457
  error: err instanceof Error ? err.message : String(err),
401
458
  });
402
459
  }
460
+ const beforeGraphQLFilter = scannedEndpoints.length;
461
+ scannedEndpoints = await filterUnsupportedGraphQLEndpoints(scannedEndpoints, params.repositoryPath);
462
+ if (scannedEndpoints.length !== beforeGraphQLFilter) {
463
+ logger.info("Filtered unsupported GraphQL endpoints from related scan", {
464
+ before: beforeGraphQLFilter,
465
+ after: scannedEndpoints.length,
466
+ });
467
+ }
403
468
  // No fallback to scanAllRepoEndpoints in PR mode.
404
469
  // If the scanner found 0 related endpoints, the PR likely touches
405
470
  // non-route code (services, models, schemas, client SDK). Flooding
@@ -419,6 +484,13 @@ to produce a unified state file for the test health workflow.
419
484
  ? await recoverDeletedFileEndpoints(params.repositoryPath, diffData.baseBranch, diffData.deletedFiles)
420
485
  : [];
421
486
  classifiedEndpoints = classifyEndpointsByChangedFiles(diffData, scannedEndpoints, deletedFileEndpoints);
487
+ classifiedEndpoints = {
488
+ ...classifiedEndpoints,
489
+ // changed/new endpoints come from scannedEndpoints, which is already
490
+ // GraphQL-filtered in branch-diff mode. Removed endpoints are recovered
491
+ // from the base branch separately and still need unsupported-protocol filtering.
492
+ removedEndpoints: await filterUnsupportedGraphQLEndpoints(classifiedEndpoints.removedEndpoints, params.repositoryPath),
493
+ };
422
494
  logger.info("Classified endpoints from changed files", {
423
495
  changed: classifiedEndpoints.changedEndpoints.length,
424
496
  new: classifiedEndpoints.newEndpoints.length,
@@ -452,7 +524,7 @@ to produce a unified state file for the test health workflow.
452
524
  text: [
453
525
  "**GraphQL-only diff detected.**",
454
526
  "",
455
- "The changed files appear to be GraphQL schema or resolver definitions.",
527
+ "The changed files appear to be GraphQL schema, artifact, or endpoint implementation files.",
456
528
  "Skyramp currently supports REST API testing only — GraphQL introspection,",
457
529
  "query validation, and type-name grounding are not yet supported.",
458
530
  "",
@@ -504,7 +576,9 @@ to produce a unified state file for the test health workflow.
504
576
  let discoveredRelevantExternalPaths = [];
505
577
  try {
506
578
  const testDiscoveryService = new TestDiscoveryService();
507
- const discoveryResult = await testDiscoveryService.discoverTests(params.repositoryPath, { changedResources });
579
+ setTestsRepoDir(params.testsRepoDir);
580
+ const testScanPath = params.testsRepoDir ?? params.repositoryPath;
581
+ const discoveryResult = await testDiscoveryService.discoverTests(testScanPath, { changedResources });
508
582
  existingTests = discoveryResult.tests.map((test) => ({
509
583
  testFile: test.testFile,
510
584
  testType: test.testType,
@@ -642,15 +716,11 @@ to produce a unified state file for the test health workflow.
642
716
  }
643
717
  }
644
718
  // ── Step 4d: Filter unsupported protocol endpoints (GraphQL) ──
645
- // Must run AFTER spec-merge above — Directus spec includes /graphql and
646
- // the merge step would re-add it if this ran earlier.
647
- // Use segment check to catch /api/graphql, /v1/graphql, etc.
719
+ // Must run AFTER spec-merge above — specs may include /graphql and the
720
+ // merge step would re-add it if this ran earlier.
648
721
  {
649
722
  const beforeUnsupported = scannedEndpoints.length;
650
- scannedEndpoints = scannedEndpoints.filter(ep => {
651
- const normalized = ep.path.replace(/\/+$/, "").toLowerCase();
652
- return !normalized.split("/").some(seg => seg === "graphql");
653
- });
723
+ scannedEndpoints = await filterUnsupportedGraphQLEndpoints(scannedEndpoints, params.repositoryPath);
654
724
  if (scannedEndpoints.length < beforeUnsupported) {
655
725
  logger.info("Filtered unsupported protocol endpoints (GraphQL)", {
656
726
  removed: beforeUnsupported - scannedEndpoints.length,
@@ -761,6 +831,12 @@ to produce a unified state file for the test health workflow.
761
831
  path: ep.path,
762
832
  sourceFile: ep.sourceFile,
763
833
  }))) ?? [];
834
+ const changedEndpointsForSecurityExpansion = classifiedEndpoints?.changedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
835
+ method: m,
836
+ path: ep.path,
837
+ sourceFile: ep.sourceFile,
838
+ }))) ?? [];
839
+ const securityRelevantDiff = Boolean(diffData?.diffContent && isSecurityRelevantDiff(diffData.diffContent));
764
840
  // Full-repo mode: no diff context, so seed scenario drafting from the entire
765
841
  // skeletonEndpoints catalog. We gate on analysisScope (not just array length)
766
842
  // to avoid drafting catalog-wide scenarios for PR-mode diffs that happened to
@@ -775,7 +851,10 @@ to produce a unified state file for the test health workflow.
775
851
  sourceFile: m.sourceFile ?? "",
776
852
  })))
777
853
  : [];
778
- const codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints, scenarioDraftSeed);
854
+ const codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints, scenarioDraftSeed, wsAuthType, {
855
+ changedEndpoints: changedEndpointsForSecurityExpansion,
856
+ securityRelevantDiff,
857
+ });
779
858
  let allDraftedScenarios = codeInferredScenarios;
780
859
  if (traceResult && traceResult.userFlows.length > 0) {
781
860
  const traceScenarios = traceResult.userFlows
@@ -979,9 +1058,34 @@ to produce a unified state file for the test health workflow.
979
1058
  // Without them, analyzeTestHealth would work only off the static catalog
980
1059
  // which has wrong paths for nested resources and unsupported frameworks.
981
1060
  const routerMountContext = grepRouterMountingContext(params.repositoryPath);
982
- const candidateRouteFiles = analysisScope !== AnalysisScope.CurrentBranchDiff
983
- ? findCandidateRouteFiles(params.repositoryPath)
984
- : undefined;
1061
+ const routeLikeUnmatchedFiles = [];
1062
+ for (const file of classifiedEndpoints?.unmatchedFiles ?? []) {
1063
+ const routeLike = SOURCE_EXTS.test(file) &&
1064
+ (ROUTE_FILE_PATTERN.test(file) || ROUTE_FILE_BASENAME_PATTERN.test(path.basename(file)));
1065
+ if (routeLike && !(await isGraphQLFile(file, params.repositoryPath))) {
1066
+ routeLikeUnmatchedFiles.push(file);
1067
+ }
1068
+ }
1069
+ const shouldIncludeCandidateRouteFiles = analysisScope !== AnalysisScope.CurrentBranchDiff ||
1070
+ rawRelatedEndpointCount === 0 ||
1071
+ scannedEndpoints.length === 0 ||
1072
+ routeLikeUnmatchedFiles.length > 0;
1073
+ let candidateRouteFiles;
1074
+ if (shouldIncludeCandidateRouteFiles) {
1075
+ candidateRouteFiles = [];
1076
+ for (const file of findCandidateRouteFiles(params.repositoryPath)) {
1077
+ if (!(await isGraphQLFile(file, params.repositoryPath))) {
1078
+ candidateRouteFiles.push(file);
1079
+ }
1080
+ }
1081
+ }
1082
+ // Write the full diff to a temp file before building state so the path
1083
+ // can be persisted and read by analyzeTestHealthTool for per-line detection.
1084
+ let diffFilePath;
1085
+ if (diffData?.diffContent) {
1086
+ diffFilePath = path.join(os.tmpdir(), `skyramp-diff-${sessionId}.diff`);
1087
+ await fs.promises.writeFile(diffFilePath, diffData.diffContent, { encoding: "utf-8", mode: 0o600 });
1088
+ }
985
1089
  // Read router mount files server-side (size-capped) so the LLM has them
986
1090
  // inline and doesn't need an extra read step when no spec is available.
987
1091
  const ROUTER_INLINE_LIMIT = 4096; // bytes — skip files larger than ~4 KB
@@ -1001,10 +1105,38 @@ to produce a unified state file for the test health workflow.
1001
1105
  return [];
1002
1106
  }
1003
1107
  });
1108
+ // Compute UI context from the diff's changed files using the shared
1109
+ // `isFrontendFile` classifier. Persisting this in the stateFile lets
1110
+ // skyramp_analyze_test_health and the recommendation prompt consume the
1111
+ // same classification without re-deriving it. Absent on backend-only PRs.
1112
+ //
1113
+ // candidateUiPages is enumerated programmatically via the strategy
1114
+ // ladder in uiPageEnumerator (framework route grep, source-grounded
1115
+ // routes, root fallback). The same enumeration powers the
1116
+ // skyramp_ui_analyze_changes pre-flight tool that the testbot prompt
1117
+ // calls before this tool, so both code paths see the same candidates.
1118
+ const uiContext = await (async () => {
1119
+ const changedFiles = classifiedEndpoints?.changedFiles ?? [];
1120
+ if (changedFiles.length === 0)
1121
+ return undefined;
1122
+ // Filter to frontend source files only — exclude test files, which
1123
+ // pass isFrontendFile (any .ts under a frontend directory matches
1124
+ // the tier-3 rule) but aren't UI source we'd want to ground page
1125
+ // enumeration in.
1126
+ const frontendFiles = changedFiles.filter((f) => isFrontendFile(f) && !isTestFile(f));
1127
+ if (frontendFiles.length === 0)
1128
+ return undefined;
1129
+ const candidateUiPages = await enumerateCandidateUiPages(params.repositoryPath, frontendFiles);
1130
+ return {
1131
+ changedFrontendFiles: frontendFiles,
1132
+ candidateUiPages,
1133
+ };
1134
+ })();
1004
1135
  const unifiedState = {
1005
1136
  existingTests,
1006
1137
  newEndpoints: newEndpointsForDrafting,
1007
1138
  analysisScope,
1139
+ ...(uiContext ? { uiContext } : {}),
1008
1140
  repositoryAnalysis: {
1009
1141
  skeletonEndpoints,
1010
1142
  projectMeta,
@@ -1016,6 +1148,7 @@ to produce a unified state file for the test health workflow.
1016
1148
  wsAuthMethod,
1017
1149
  specFetchSucceeded,
1018
1150
  scenarios: allDraftedScenarios,
1151
+ diffFilePath,
1019
1152
  testLocations: testLocationsByType,
1020
1153
  diff: classifiedEndpoints
1021
1154
  ? {
@@ -1056,29 +1189,11 @@ to produce a unified state file for the test health workflow.
1056
1189
  ? path.dirname(path.resolve(params.stateOutputFile))
1057
1190
  : undefined;
1058
1191
  try {
1059
- await StateManager.cleanupOldStateFiles(24, stateDir);
1192
+ await StateManager.cleanupOldFiles(24, stateDir);
1060
1193
  }
1061
1194
  catch (error) {
1062
1195
  logger.warning(`Failed to cleanup old state files: ${error.message}`);
1063
1196
  }
1064
- // Clean up old diff temp files (>24 hours) from previous invocations
1065
- try {
1066
- const tmpDir = os.tmpdir();
1067
- const entries = await fs.promises.readdir(tmpDir);
1068
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
1069
- for (const entry of entries) {
1070
- if (!entry.startsWith("skyramp-diff-") || !entry.endsWith(".diff"))
1071
- continue;
1072
- const fullPath = path.join(tmpDir, entry);
1073
- const stat = await fs.promises.stat(fullPath).catch(() => null);
1074
- if (stat && stat.mtimeMs < cutoff) {
1075
- await fs.promises.unlink(fullPath).catch(() => { });
1076
- }
1077
- }
1078
- }
1079
- catch {
1080
- // Non-critical — temp cleanup failure should not block analysis
1081
- }
1082
1197
  const stateManager = new StateManager("analysis", sessionId, undefined, params.stateOutputFile);
1083
1198
  await stateManager.writeData(unifiedState, {
1084
1199
  repositoryPath: params.repositoryPath,
@@ -1130,7 +1245,10 @@ to produce a unified state file for the test health workflow.
1130
1245
  }
1131
1246
  }
1132
1247
  }
1133
- const recommendationPrompt = buildRecommendationPrompt(fullAnalysis, analysisScope, topN, prContext, wsAuthHeader, wsAuthType, wsAuthScheme, params.maxGenerate);
1248
+ if (uiContext && uiContext.changedFrontendFiles.length > 0) {
1249
+ logger.info("Frontend changes detected — UI rec grounding relies on the agent's own browser_blueprint history", { candidateUiPages: uiContext.candidateUiPages.map((p) => p.url) });
1250
+ }
1251
+ const recommendationPrompt = buildRecommendationPrompt(fullAnalysis, analysisScope, topN, prContext, wsAuthHeader, wsAuthType, wsAuthScheme, params.maxGenerate, sessionId);
1134
1252
  await sendProgress(100, 100, "Analysis complete.");
1135
1253
  const stateSize = await stateManager.getSizeFormatted();
1136
1254
  const structuredSummary = JSON.stringify({
@@ -1144,6 +1262,10 @@ to produce a unified state file for the test health workflow.
1144
1262
  newEndpointCount: classifiedEndpoints?.newEndpoints.length ?? 0,
1145
1263
  endpointCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
1146
1264
  },
1265
+ // Surface uiContext inline so the testbot prompt can iterate
1266
+ // candidateUiPages and inspect changedFrontendFiles without
1267
+ // re-reading the stateFile. Absent on backend-only PRs.
1268
+ ...(uiContext ? { uiContext } : {}),
1147
1269
  stateFileSize: stateSize,
1148
1270
  nextStep: "Call skyramp_analyze_test_health with stateFile to run drift analysis and health scoring",
1149
1271
  }, null, 2);
@@ -1172,13 +1294,6 @@ to produce a unified state file for the test health workflow.
1172
1294
  affectedServices: classifiedEndpoints.affectedServices,
1173
1295
  }
1174
1296
  : undefined;
1175
- // Write the full diff to a temp file so the LLM can read it on demand
1176
- // rather than embedding potentially large content inline in the prompt.
1177
- let diffFilePath;
1178
- if (diffData?.diffContent) {
1179
- diffFilePath = path.join(os.tmpdir(), `skyramp-diff-${sessionId}.diff`);
1180
- await fs.promises.writeFile(diffFilePath, diffData.diffContent, { encoding: "utf-8", mode: 0o600 });
1181
- }
1182
1297
  const outputText = buildAnalysisOutputText({
1183
1298
  sessionId,
1184
1299
  stateFile,
@@ -1186,7 +1301,6 @@ to produce a unified state file for the test health workflow.
1186
1301
  analysisScope,
1187
1302
  parsedDiff: parsedDiffShim,
1188
1303
  diffFilePath,
1189
- diffContent: diffData?.diffContent,
1190
1304
  candidateRouteFiles,
1191
1305
  scannedEndpoints,
1192
1306
  wsBaseUrl,
@@ -64,8 +64,20 @@ jest.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({
64
64
  jest.mock("@modelcontextprotocol/sdk/types.js", () => ({}));
65
65
  jest.mock("@modelcontextprotocol/sdk/shared/protocol.js", () => ({}));
66
66
  import { z } from "zod";
67
- import { analyzeChangesInputSchema } from "./analyzeChangesTool.js";
67
+ import { analyzeChangesInputSchema, isSecurityRelevantDiff } from "./analyzeChangesTool.js";
68
68
  const schema = z.object(analyzeChangesInputSchema);
69
+ describe("isSecurityRelevantDiff", () => {
70
+ it("matches auth-specific middleware and admin-key signals", () => {
71
+ expect(isSecurityRelevantDiff("const authMiddleware = requireAuth();")).toBe(true);
72
+ expect(isSecurityRelevantDiff("require_admin_key(request)")).toBe(true);
73
+ expect(isSecurityRelevantDiff("headers['x-admin-key']")).toBe(true);
74
+ expect(isSecurityRelevantDiff("require-role for destructive delete")).toBe(true);
75
+ });
76
+ it("does not match generic middleware-only refactors", () => {
77
+ expect(isSecurityRelevantDiff("refactor logging middleware order")).toBe(false);
78
+ expect(isSecurityRelevantDiff("move compression middleware to server setup")).toBe(false);
79
+ });
80
+ });
69
81
  describe("analyzeChangesInputSchema — stateOutputFile validation", () => {
70
82
  it("accepts a valid absolute path", () => {
71
83
  const result = schema.safeParse({
@@ -88,31 +100,37 @@ describe("analyzeChangesInputSchema — stateOutputFile validation", () => {
88
100
  expect(result.success).toBe(true);
89
101
  });
90
102
  });
91
- describe("automatic state file cleanup", () => {
103
+ describe("automatic old files cleanup", () => {
92
104
  let StateManager;
93
- let cleanupOldStateFilesSpy;
105
+ let cleanupOldFilesSpy;
94
106
  beforeEach(() => {
95
107
  jest.clearAllMocks();
96
108
  StateManager = require("../../utils/AnalysisStateManager.js").StateManager;
97
- cleanupOldStateFilesSpy = jest.fn().mockResolvedValue(0);
98
- StateManager.cleanupOldStateFiles = cleanupOldStateFilesSpy;
109
+ cleanupOldFilesSpy = jest.fn().mockResolvedValue(0);
110
+ StateManager.cleanupOldFiles = cleanupOldFilesSpy;
99
111
  });
100
- it("calls cleanupOldStateFiles with default temp dir when no stateOutputFile provided", async () => {
112
+ it("calls cleanupOldFiles with default temp dir when no stateOutputFile provided", async () => {
101
113
  // This test verifies the cleanup call is made without stateDir when using default location
102
- expect(StateManager.cleanupOldStateFiles).toBeDefined();
103
- await StateManager.cleanupOldStateFiles(24, undefined);
104
- expect(cleanupOldStateFilesSpy).toHaveBeenCalledWith(24, undefined);
114
+ expect(StateManager.cleanupOldFiles).toBeDefined();
115
+ await StateManager.cleanupOldFiles(24, undefined);
116
+ expect(cleanupOldFilesSpy).toHaveBeenCalledWith(24, undefined);
105
117
  });
106
- it("calls cleanupOldStateFiles with custom dir when stateOutputFile is provided", async () => {
118
+ it("calls cleanupOldFiles with custom dir when stateOutputFile is provided", async () => {
107
119
  // This test verifies the cleanup call is made with the directory of stateOutputFile
108
120
  const customPath = "/custom/dir";
109
- await StateManager.cleanupOldStateFiles(24, customPath);
110
- expect(cleanupOldStateFilesSpy).toHaveBeenCalledWith(24, customPath);
121
+ await StateManager.cleanupOldFiles(24, customPath);
122
+ expect(cleanupOldFilesSpy).toHaveBeenCalledWith(24, customPath);
123
+ });
124
+ it("calls cleanupOldFiles with empty stateTypes to restrict to diff files only", async () => {
125
+ // analyzeTestHealthTool passes [] so state files (skyramp-analysis-*, skyramp-recommendation-*)
126
+ // are never deleted — the caller still needs args.stateFile for skyramp_actions.
127
+ await StateManager.cleanupOldFiles(24, undefined, []);
128
+ expect(cleanupOldFilesSpy).toHaveBeenCalledWith(24, undefined, []);
111
129
  });
112
130
  it("continues execution if cleanup fails", async () => {
113
131
  // Cleanup failures should not crash the analyze flow
114
- cleanupOldStateFilesSpy.mockRejectedValue(new Error("permission denied"));
115
- await expect(StateManager.cleanupOldStateFiles(24).catch(() => {
132
+ cleanupOldFilesSpy.mockRejectedValue(new Error("permission denied"));
133
+ await expect(StateManager.cleanupOldFiles(24).catch(() => {
116
134
  // In the real code, this is caught and logged as a warning
117
135
  return Promise.resolve();
118
136
  })).resolves.toBeUndefined();
@@ -206,7 +224,7 @@ describe("filterEndpointsBySpec", () => {
206
224
  // ─────────────────────────────────────────────────────────────────────────────
207
225
  // isGraphQLFile — unit tests
208
226
  // ─────────────────────────────────────────────────────────────────────────────
209
- import { isGraphQLFile } from "./analyzeChangesTool.js";
227
+ import { filterUnsupportedGraphQLEndpoints, isGraphQLFile } from "./analyzeChangesTool.js";
210
228
  import * as os from "os";
211
229
  import * as path from "path";
212
230
  import * as fsSync from "fs";
@@ -220,6 +238,10 @@ describe("isGraphQLFile", () => {
220
238
  const result = await isGraphQLFile("types.gql", "/any/repo");
221
239
  expect(result).toBe(true);
222
240
  });
241
+ it("returns true for a top-level graphql directory non-code artifact", async () => {
242
+ const result = await isGraphQLFile("graphql/schema", "/any/repo");
243
+ expect(result).toBe(true);
244
+ });
223
245
  it("returns true for a file containing a GraphQL type Query block", async () => {
224
246
  const file = path.join(tmpDir, "graphql-test-query.ts");
225
247
  fsSync.writeFileSync(file, 'type Query {\n hello: String\n}\n');
@@ -232,17 +254,55 @@ describe("isGraphQLFile", () => {
232
254
  const result = await isGraphQLFile(path.basename(file), tmpDir);
233
255
  expect(result).toBe(true);
234
256
  });
257
+ it("returns true for a GraphQL resolver implementation file", async () => {
258
+ const file = path.join(tmpDir, "users.resolver.ts");
259
+ fsSync.writeFileSync(file, 'import { Resolver, Query } from "@nestjs/graphql";\n@Resolver()\nclass UsersResolver { @Query() users() { return []; } }\n');
260
+ const result = await isGraphQLFile(path.basename(file), tmpDir);
261
+ expect(result).toBe(true);
262
+ });
263
+ it("returns false for a Nest REST controller using @Query params", async () => {
264
+ const file = path.join(tmpDir, "users.controller.ts");
265
+ fsSync.writeFileSync(file, 'import { Controller, Get, Query } from "@nestjs/common";\n@Controller("users")\nclass UsersController { @Get() list(@Query("page") page: string) { return page; } }\n');
266
+ const result = await isGraphQLFile(path.basename(file), tmpDir);
267
+ expect(result).toBe(false);
268
+ });
235
269
  it("returns false for a regular TypeScript route file", async () => {
236
270
  const file = path.join(tmpDir, "graphql-test-route.ts");
237
271
  fsSync.writeFileSync(file, 'import express from "express";\nrouter.get("/users", handler);\n');
238
272
  const result = await isGraphQLFile(path.basename(file), tmpDir);
239
273
  expect(result).toBe(false);
240
274
  });
275
+ it("returns false for frontend Apollo gql query templates", async () => {
276
+ const file = path.join(tmpDir, "usersQuery.tsx");
277
+ fsSync.writeFileSync(file, 'import { gql } from "@apollo/client";\nexport const USERS_QUERY = gql`query Users { users { id } }`;\n');
278
+ const result = await isGraphQLFile(path.basename(file), tmpDir);
279
+ expect(result).toBe(false);
280
+ });
241
281
  it("returns false (does not throw) when the file does not exist", async () => {
242
282
  const result = await isGraphQLFile("nonexistent-file.ts", tmpDir);
243
283
  expect(result).toBe(false);
244
284
  });
245
285
  });
286
+ describe("filterUnsupportedGraphQLEndpoints", () => {
287
+ it("memoizes GraphQL source-file checks within one filter pass", async () => {
288
+ const endpoints = [
289
+ { path: "/users", methods: ["GET"], sourceFile: "src/routes/users.ts" },
290
+ { path: "/users/{id}", methods: ["GET"], sourceFile: "src/routes/users.ts" },
291
+ { path: "/orders", methods: ["GET"], sourceFile: "src/routes/orders.ts" },
292
+ { path: "/graphql", methods: ["POST"], sourceFile: "src/routes/graphql.ts" },
293
+ { path: "/openapi-only", methods: ["GET"], sourceFile: "" },
294
+ ];
295
+ const readSpy = jest.spyOn(fsModule.promises, "readFile").mockResolvedValue('router.get("/users", handler)');
296
+ const result = await filterUnsupportedGraphQLEndpoints(endpoints, "/repo");
297
+ expect(result.map((endpoint) => endpoint.path)).toEqual(["/users", "/users/{id}", "/orders", "/openapi-only"]);
298
+ expect(readSpy).toHaveBeenCalledTimes(2);
299
+ expect(readSpy.mock.calls.map(([file]) => String(file))).toEqual([
300
+ path.join("/repo", "src/routes/users.ts"),
301
+ path.join("/repo", "src/routes/orders.ts"),
302
+ ]);
303
+ readSpy.mockRestore();
304
+ });
305
+ });
246
306
  // ─────────────────────────────────────────────────────────────────────────────
247
307
  // analyzeChangesTool handler — GraphQL-only early return (handler-level)
248
308
  // ─────────────────────────────────────────────────────────────────────────────
@@ -296,6 +356,32 @@ describe("analyzeChangesTool handler — GraphQL-only early return", () => {
296
356
  expect(result.content[0].text).toContain("schema.graphql");
297
357
  expect(result.content[0].text).toContain("REST API testing");
298
358
  });
359
+ it("returns the GraphQL-only message when only GraphQL resolver endpoints are scanned", async () => {
360
+ computeBranchDiff.mockResolvedValue({
361
+ currentBranch: "feature",
362
+ baseBranch: "main",
363
+ changedFiles: ["src/graphql/users.resolver.ts"],
364
+ deletedFiles: [],
365
+ diffContent: "",
366
+ });
367
+ scanRelatedEndpoints.mockReturnValue([
368
+ { path: "/users", methods: ["GET"], sourceFile: "src/graphql/users.resolver.ts" },
369
+ ]);
370
+ classifyEndpointsByChangedFiles.mockReturnValue({
371
+ changedEndpoints: [],
372
+ newEndpoints: [],
373
+ removedEndpoints: [],
374
+ unmatchedFiles: [],
375
+ affectedServices: [],
376
+ });
377
+ const readSpy = jest.spyOn(fsModule.promises, "readFile").mockResolvedValue('import { Resolver, Query } from "@nestjs/graphql";\n@Resolver()\nclass UsersResolver { @Query() users() { return []; } }');
378
+ const handler = captureAnalyzeHandler();
379
+ const result = await handler(baseParams);
380
+ expect(classifyEndpointsByChangedFiles).toHaveBeenCalledWith(expect.anything(), [], []);
381
+ expect(result.content[0].text).toContain("GraphQL-only diff detected");
382
+ expect(result.content[0].text).toContain("endpoint implementation");
383
+ readSpy.mockRestore();
384
+ });
299
385
  it("does NOT early-return when .graphql files are mixed with REST route files", async () => {
300
386
  computeBranchDiff.mockResolvedValue({
301
387
  currentBranch: "feature",
@@ -305,11 +391,12 @@ describe("analyzeChangesTool handler — GraphQL-only early return", () => {
305
391
  diffContent: "",
306
392
  });
307
393
  // users.ts is not a GraphQL file — isGraphQLFile will read it and return false
308
- jest.spyOn(fsModule.promises, "readFile").mockResolvedValue('router.get("/users", handler)');
394
+ const readSpy = jest.spyOn(fsModule.promises, "readFile").mockResolvedValue('router.get("/users", handler)');
309
395
  const handler = captureAnalyzeHandler();
310
396
  const result = await handler(baseParams);
311
397
  // Should not early-return with GraphQL message
312
398
  expect(result.content[0].text).not.toContain("GraphQL-only diff detected");
399
+ readSpy.mockRestore();
313
400
  });
314
401
  it("does NOT early-return when scope is full_repo (only fires for PR diffs)", async () => {
315
402
  const { scanAllRepoEndpoints } = require("../../utils/repoScanner.js");