@skyramp/mcp 0.3.8 → 0.4.0-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.
Files changed (229) hide show
  1. package/build/commands/commandLibrary.d.ts +1 -1
  2. package/build/commands/commandLibrary.js +3 -3
  3. package/build/commands/recommendTestsAndExecuteCommand.d.ts +1 -1
  4. package/build/commands/recommendTestsAndExecuteCommand.js +35 -20
  5. package/build/commands/testThisEndpointCommand.js +35 -19
  6. package/build/index.js +9 -3
  7. package/build/playwright/blueprintDigest.d.ts +15 -0
  8. package/build/playwright/blueprintDigest.js +152 -0
  9. package/build/playwright/blueprintDigestStore.d.ts +31 -0
  10. package/build/playwright/blueprintDigestStore.js +117 -0
  11. package/build/playwright/registerPlaywrightTools.js +60 -12
  12. package/build/playwright/traceRecordingPrompt.js +8 -7
  13. package/build/prompts/enhance-assertions/sharedAssertionRules.js +9 -8
  14. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -2
  15. package/build/prompts/promptAssets.d.ts +20 -0
  16. package/build/prompts/promptAssets.js +55 -0
  17. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +19 -5
  18. package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
  19. package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
  20. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +0 -10
  21. package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -11
  22. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +8 -4
  23. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +5 -22
  24. package/build/prompts/test-recommendation/diffExecutionPlan.js +37 -465
  25. package/build/prompts/test-recommendation/recommendationSections.d.ts +7 -17
  26. package/build/prompts/test-recommendation/recommendationSections.js +67 -309
  27. package/build/prompts/test-recommendation/recommendationShared.d.ts +19 -47
  28. package/build/prompts/test-recommendation/recommendationShared.js +49 -155
  29. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.d.ts +0 -5
  30. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +10 -153
  31. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -29
  32. package/build/prompts/test-recommendation/test-recommendation-prompt.js +32 -457
  33. package/build/prompts/testbot/planDeclarations.d.ts +6 -0
  34. package/build/prompts/testbot/planDeclarations.js +9 -0
  35. package/build/prompts/testbot/testbot-prompts.d.ts +8 -0
  36. package/build/prompts/testbot/testbot-prompts.js +256 -381
  37. package/build/recommendation/answers.d.ts +35 -0
  38. package/build/recommendation/answers.js +96 -0
  39. package/build/recommendation/registerPlan.d.ts +49 -0
  40. package/build/recommendation/registerPlan.js +117 -0
  41. package/build/recommendation/runVerifiers.d.ts +10 -0
  42. package/build/recommendation/runVerifiers.js +49 -0
  43. package/build/recommendation/subjectStep.d.ts +42 -0
  44. package/build/recommendation/subjectStep.js +86 -0
  45. package/build/recommendation/types.d.ts +163 -0
  46. package/build/recommendation/types.js +20 -0
  47. package/build/recommendation/verifierContracts.d.ts +382 -0
  48. package/build/recommendation/verifierContracts.js +263 -0
  49. package/build/recommendation/verifiers/changedFile.d.ts +2 -0
  50. package/build/recommendation/verifiers/changedFile.js +82 -0
  51. package/build/recommendation/verifiers/citedPath.d.ts +12 -0
  52. package/build/recommendation/verifiers/citedPath.js +35 -0
  53. package/build/recommendation/verifiers/coverage.d.ts +7 -0
  54. package/build/recommendation/verifiers/coverage.js +617 -0
  55. package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +11 -0
  56. package/build/recommendation/verifiers/deliveredMatchesPlan.js +33 -0
  57. package/build/recommendation/verifiers/endpointGrounded.d.ts +17 -0
  58. package/build/recommendation/verifiers/endpointGrounded.js +128 -0
  59. package/build/recommendation/verifiers/existingCoverage.d.ts +6 -0
  60. package/build/recommendation/verifiers/existingCoverage.js +51 -0
  61. package/build/recommendation/verifiers/expectedOutcome.d.ts +31 -0
  62. package/build/recommendation/verifiers/expectedOutcome.js +105 -0
  63. package/build/recommendation/verifiers/removedElementGuarded.d.ts +2 -0
  64. package/build/recommendation/verifiers/removedElementGuarded.js +57 -0
  65. package/build/recommendation/verifiers/reportedCategory.d.ts +26 -0
  66. package/build/recommendation/verifiers/reportedCategory.js +84 -0
  67. package/build/recommendation/verifiers/screenRoute.d.ts +10 -0
  68. package/build/recommendation/verifiers/screenRoute.js +118 -0
  69. package/build/recommendation/verifiers/statedDifference.d.ts +6 -0
  70. package/build/recommendation/verifiers/statedDifference.js +140 -0
  71. package/build/recommendation/verifiers/uiElementGrounded.d.ts +7 -0
  72. package/build/recommendation/verifiers/uiElementGrounded.js +318 -0
  73. package/build/resources/analysisResources.js +1 -114
  74. package/build/resources/testbotResource.js +23 -13
  75. package/build/services/ModularizationService.js +2 -1
  76. package/build/services/TestDiscoveryService.d.ts +3 -72
  77. package/build/services/TestDiscoveryService.js +10 -303
  78. package/build/services/containerEnv.d.ts +1 -1
  79. package/build/services/containerEnv.js +12 -0
  80. package/build/skills/fixTestImportErrorsSkill.d.ts +13 -0
  81. package/build/skills/fixTestImportErrorsSkill.js +20 -0
  82. package/build/toolNames.d.ts +1 -0
  83. package/build/toolNames.js +1 -0
  84. package/build/tools/code-refactor/enhanceAssertionsTool.js +3 -3
  85. package/build/tools/code-refactor/modularizationTool.js +2 -1
  86. package/build/tools/executeSkyrampTestTool.d.ts +80 -0
  87. package/build/tools/executeSkyrampTestTool.js +246 -19
  88. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +6 -0
  89. package/build/tools/generate-tests/generateContractRestTool.js +3 -3
  90. package/build/tools/generate-tests/planGuard.d.ts +2 -2
  91. package/build/tools/generate-tests/planGuard.js +78 -18
  92. package/build/tools/one-click/oneClickTool.d.ts +0 -1
  93. package/build/tools/one-click/oneClickTool.js +0 -5
  94. package/build/tools/submitReportTool.d.ts +48 -42
  95. package/build/tools/submitReportTool.js +576 -193
  96. package/build/tools/test-management/actionsTool.js +72 -4
  97. package/build/tools/test-management/analyzeChangesTool.d.ts +144 -48
  98. package/build/tools/test-management/analyzeChangesTool.js +212 -1219
  99. package/build/tools/test-management/analyzeTestHealthTool.js +13 -24
  100. package/build/tools/test-management/index.d.ts +1 -0
  101. package/build/tools/test-management/index.js +1 -0
  102. package/build/tools/test-management/registerTestPlanTool.d.ts +795 -172
  103. package/build/tools/test-management/registerTestPlanTool.js +609 -542
  104. package/build/tools/test-management/resolveScreenTool.d.ts +75 -0
  105. package/build/tools/test-management/resolveScreenTool.js +289 -0
  106. package/build/types/BlueprintDigest.d.ts +34 -0
  107. package/build/types/BlueprintDigest.js +1 -0
  108. package/build/types/RepositoryAnalysis.d.ts +20 -1559
  109. package/build/types/RepositoryAnalysis.js +2 -58
  110. package/build/types/StepMethod.d.ts +40 -0
  111. package/build/types/StepMethod.js +77 -0
  112. package/build/types/TestAnalysis.d.ts +12 -0
  113. package/build/types/TestExecution.d.ts +4 -0
  114. package/build/types/TestRecommendation.d.ts +24 -24
  115. package/build/types/TestRecommendation.js +91 -89
  116. package/build/types/TestbotPromptOptions.d.ts +0 -4
  117. package/build/types/TestbotReport.d.ts +64 -2
  118. package/build/utils/AnalysisStateManager.d.ts +79 -113
  119. package/build/utils/AnalysisStateManager.js +147 -57
  120. package/build/utils/assertion-verify/api-shared-lints.js +1 -1
  121. package/build/utils/assertion-verify/metrics.js +85 -36
  122. package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
  123. package/build/utils/assertion-verify/ui-lints.js +32 -0
  124. package/build/utils/branchDiff.d.ts +63 -31
  125. package/build/utils/branchDiff.js +242 -94
  126. package/build/utils/containedPath.d.ts +18 -0
  127. package/build/utils/containedPath.js +73 -0
  128. package/build/utils/dartRouteExtractor.d.ts +18 -34
  129. package/build/utils/dartRouteExtractor.js +101 -173
  130. package/build/utils/featureFlags.d.ts +12 -0
  131. package/build/utils/featureFlags.js +14 -0
  132. package/build/utils/frontendSelectors.d.ts +48 -27
  133. package/build/utils/frontendSelectors.js +241 -80
  134. package/build/utils/pathMatching.d.ts +2 -4
  135. package/build/utils/pathMatching.js +2 -4
  136. package/build/utils/planMatchKeys.d.ts +38 -47
  137. package/build/utils/planMatchKeys.js +143 -81
  138. package/build/utils/rebaselineSnapshots.d.ts +24 -0
  139. package/build/utils/rebaselineSnapshots.js +65 -0
  140. package/build/utils/removedUiElements.d.ts +22 -0
  141. package/build/utils/removedUiElements.js +106 -0
  142. package/build/utils/reportVerification.d.ts +2 -6
  143. package/build/utils/reportVerification.js +61 -2
  144. package/build/utils/screenRoutes.d.ts +66 -0
  145. package/build/utils/screenRoutes.js +727 -0
  146. package/build/utils/sourceRouteExtractor.js +320 -112
  147. package/build/utils/testFileClassification.d.ts +11 -2
  148. package/build/utils/testFileClassification.js +44 -2
  149. package/build/utils/testFixtures.d.ts +5 -0
  150. package/build/utils/testFixtures.js +13 -0
  151. package/build/utils/utils.d.ts +0 -1
  152. package/build/utils/utils.js +0 -11
  153. package/build/utils/versions.d.ts +3 -3
  154. package/build/utils/versions.js +1 -1
  155. package/build/workspace/workspace.d.ts +12 -12
  156. package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
  157. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
  158. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
  159. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
  160. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
  161. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
  162. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
  163. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
  164. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
  165. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
  166. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  167. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  168. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  169. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
  170. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
  171. package/node_modules/playwright/package.json +1 -1
  172. package/package.json +4 -3
  173. package/plugin/.claude-plugin/plugin.json +8 -0
  174. package/plugin/plugin.json +6 -0
  175. package/plugin/prompts/declaring-a-plan.md +20 -0
  176. package/plugin/prompts/generate-tests/context-fetching.md +4 -0
  177. package/plugin/prompts/generate-tests/execution-plan.md +63 -0
  178. package/plugin/prompts/generate-tests/generation.md +108 -0
  179. package/plugin/prompts/generate-tests/path-parameters.md +1 -0
  180. package/plugin/prompts/generate-tests/reasoning-protocol.md +17 -0
  181. package/plugin/prompts/generate-tests/tool-workflow-variants.md +61 -0
  182. package/plugin/prompts/generate-tests/tool-workflows.md +65 -0
  183. package/plugin/prompts/plan-tests.md +42 -0
  184. package/plugin/prompts/testbot-task1.md +82 -0
  185. package/plugin/skills/fix-test-import-errors/SKILL.md +98 -0
  186. package/build/prompts/test-recommendation/analysisOutputPrompt.d.ts +0 -84
  187. package/build/prompts/test-recommendation/analysisOutputPrompt.js +0 -369
  188. package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +0 -7
  189. package/build/prompts/test-recommendation/fullRepoCatalog.js +0 -283
  190. package/build/prompts/test-recommendation/scopeAssessment.d.ts +0 -81
  191. package/build/prompts/test-recommendation/scopeAssessment.js +0 -359
  192. package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +0 -7
  193. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +0 -105
  194. package/build/recommendation/budgeters/fixedNBudgeter.d.ts +0 -7
  195. package/build/recommendation/budgeters/fixedNBudgeter.js +0 -11
  196. package/build/recommendation/budgeters/shared.d.ts +0 -32
  197. package/build/recommendation/budgeters/shared.js +0 -246
  198. package/build/recommendation/discriminators.d.ts +0 -37
  199. package/build/recommendation/discriminators.js +0 -379
  200. package/build/recommendation/diversity.d.ts +0 -47
  201. package/build/recommendation/diversity.js +0 -101
  202. package/build/recommendation/planRanker.d.ts +0 -65
  203. package/build/recommendation/planRanker.js +0 -83
  204. package/build/recommendation/testFixtures.d.ts +0 -25
  205. package/build/recommendation/testFixtures.js +0 -45
  206. package/build/types/FrontendIntegration.d.ts +0 -28
  207. package/build/types/FrontendIntegration.js +0 -22
  208. package/build/types/Recommendation.d.ts +0 -146
  209. package/build/types/Recommendation.js +0 -74
  210. package/build/utils/changedRoutes.d.ts +0 -29
  211. package/build/utils/changedRoutes.js +0 -87
  212. package/build/utils/frontendIntegration.d.ts +0 -9
  213. package/build/utils/frontendIntegration.js +0 -243
  214. package/build/utils/importerHop.d.ts +0 -135
  215. package/build/utils/importerHop.js +0 -489
  216. package/build/utils/pathAffinityClassification.d.ts +0 -49
  217. package/build/utils/pathAffinityClassification.js +0 -180
  218. package/build/utils/pythonMountPrefixes.d.ts +0 -25
  219. package/build/utils/pythonMountPrefixes.js +0 -347
  220. package/build/utils/repoScanner.d.ts +0 -34
  221. package/build/utils/repoScanner.js +0 -300
  222. package/build/utils/routeParsers.d.ts +0 -95
  223. package/build/utils/routeParsers.js +0 -951
  224. package/build/utils/scenarioDrafting.d.ts +0 -92
  225. package/build/utils/scenarioDrafting.js +0 -951
  226. package/build/utils/subjectEndpoints.d.ts +0 -19
  227. package/build/utils/subjectEndpoints.js +0 -98
  228. package/build/utils/uiPageEnumerator.d.ts +0 -172
  229. package/build/utils/uiPageEnumerator.js +0 -474
@@ -1,42 +1,23 @@
1
1
  import { z } from "zod";
2
2
  import * as crypto from "crypto";
3
3
  import * as fs from "fs";
4
- import * as os from "os";
5
4
  import * as path from "path";
6
5
  import yaml from "js-yaml";
7
- import { simpleGit } from "simple-git";
8
6
  import { logger } from "../../utils/logger.js";
9
- import { dualChannelResult } from "../../utils/utils.js";
10
7
  import { parseWorkspaceAuthType, getDefaultAuthHeader, WorkspaceAuthType, readWorkspaceConfigRaw } from "../../utils/workspaceAuth.js";
11
8
  import { AnalyticsService } from "../../services/AnalyticsService.js";
12
9
  import { makeProgressReporter } from "../../utils/progress.js";
13
10
  import { StateManager, registerSession, storeSessionData, rememberTestsRepoDir, getActiveRunStatePath, setActiveRunStatePath, } from "../../utils/AnalysisStateManager.js";
14
- import { buildRecommendationPrompt, computeScoredCandidates } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
15
- import { hasFlutterSdkDep, isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
16
- import { buildExternalCoverageSet } from "../../prompts/test-recommendation/recommendationShared.js";
17
- import { resolveSubjectEndpoints } from "../../utils/subjectEndpoints.js";
18
- import { collectChangedRouteLines } from "../../utils/changedRoutes.js";
19
- import { CandidateSource, computeCandidateId } from "../../types/Recommendation.js";
20
- import { selectPlan } from "../../recommendation/planRanker.js";
21
- import { buildApprovedPlanItem } from "../../utils/planMatchKeys.js";
22
- import { enumerateCandidateUiPages, MAX_CANDIDATE_PAGES } from "../../utils/uiPageEnumerator.js";
23
- import { checkFrontendFileIntegration } from "../../utils/frontendIntegration.js";
24
- import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
11
+ import { isTestFile } from "../../utils/testFileClassification.js";
12
+ import { isPlanOnlyMode } from "../../utils/planOnlyMode.js";
25
13
  import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
26
- import { isDiscoveredTestFile } from "../../utils/testFileClassification.js";
27
- import { ScenarioSource, AnalysisScope } from "../../types/RepositoryAnalysis.js";
28
- import { computeBranchDiff } from "../../utils/branchDiff.js";
29
- import { classifyEndpointsByChangedFiles, selectRemovalCandidateFiles, recoverRemovedEndpointsFromBase, } from "../../utils/routeParsers.js";
30
- import { classifyUnmatchedFilesByPathAffinity } from "../../utils/pathAffinityClassification.js";
31
- import { scanAllRepoEndpoints, scanRelatedEndpoints, grepRouterMountingContext, findCandidateRouteFiles, } from "../../utils/repoScanner.js";
14
+ import { AnalysisScope } from "../../types/RepositoryAnalysis.js";
15
+ import { computeBranchDiff, changedFileNames, } from "../../utils/branchDiff.js";
32
16
  import { detectProjectMetadata } from "../../utils/projectMetadata.js";
33
- import { draftScenariosFromEndpoints, draftResponseShapeScenarios, capScenarios } from "../../utils/scenarioDrafting.js";
34
- import { mapUnmatchedFilesToImporterEndpoints, buildHopResponseShapeInputs, deriveResourceTokensForFile, deriveResourceToken, extractChangedSymbols, sliceDiffByFile } from "../../utils/importerHop.js";
35
- import { extractChangedSelectors, MAX_CHANGED_SELECTORS } from "../../utils/frontendSelectors.js";
36
- import { buildAnalysisOutputText } from "../../prompts/test-recommendation/analysisOutputPrompt.js";
17
+ import { diffBlocksByPath } from "../../utils/branchDiff.js";
18
+ import { collectRemovedUiElements } from "../../utils/removedUiElements.js";
37
19
  import { parseTraceFile, discoverTraceFiles, discoverPlaywrightZips, } from "../../utils/trace-parser.js";
38
20
  import { TestSource } from "../../types/TestAnalysis.js";
39
- import { parsePRComments } from "../../utils/pr-comment-parser.js";
40
21
  /** Exported for testing: maps a parsed trace result to a TraceFile. */
41
22
  export function buildTraceFileEntry(tracePath, result) {
42
23
  return {
@@ -47,169 +28,6 @@ export function buildTraceFileEntry(tracePath, result) {
47
28
  };
48
29
  }
49
30
  const TOOL_NAME = "skyramp_analyze_changes";
50
- 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;
51
- export function isSecurityRelevantDiff(diffContent) {
52
- return SECURITY_RELEVANT_DIFF_PATTERN.test(diffContent);
53
- }
54
- // Must match testbot/src/constants.ts BOT_EMAIL
55
- const BOT_EMAIL = "test-bot@skyramp.dev";
56
- /**
57
- * Get files added by the last bot commit that still exist on disk.
58
- * Fallback for when PR comment file names don't match actual files
59
- * (e.g. after a force push that rewrote history).
60
- */
61
- async function getBotCommittedFiles(repoPath) {
62
- try {
63
- const git = simpleGit(repoPath);
64
- const botShaRaw = await git.raw([
65
- "log", "--author=" + BOT_EMAIL, "-1", "--format=%H",
66
- ]);
67
- const lastBotSha = botShaRaw.trim();
68
- if (!lastBotSha)
69
- return [];
70
- const diffOutput = await git.raw([
71
- "diff-tree", "--no-commit-id", "--name-only", "-r", "--diff-filter=A", lastBotSha,
72
- ]);
73
- return diffOutput
74
- .trim()
75
- .split("\n")
76
- .filter(Boolean)
77
- .filter(f => {
78
- const abs = path.join(repoPath, f);
79
- return fs.existsSync(abs);
80
- });
81
- }
82
- catch {
83
- return [];
84
- }
85
- }
86
- /**
87
- * Get files changed by user commits (excluding bot commits) since the last bot commit.
88
- * Focuses on user intent — what did the developer push after the bot ran?
89
- * Returns null if no bot commit exists (first run) or on error.
90
- *
91
- * Uses git.raw() throughout because simple-git's .log() parser cannot
92
- * reliably handle custom --format flags. Filters out bot-authored commits
93
- * client-side because git's --not flag applies to revision specs, not --author.
94
- */
95
- async function getUserChangedFiles(repoPath) {
96
- try {
97
- const git = simpleGit(repoPath);
98
- // Find the last bot commit — use git.raw() because simple-git's .log()
99
- // parser cannot handle a custom --format flag reliably.
100
- const botShaRaw = await git.raw([
101
- "log", "--author=" + BOT_EMAIL, "-1", "--format=%H",
102
- ]);
103
- const lastBotSha = botShaRaw.trim();
104
- if (!lastBotSha)
105
- return null;
106
- // Get all commits in the range, then filter out bot-authored ones client-side.
107
- // git's --not flag applies to revision specs, not --author.
108
- const rangeLog = await git.raw([
109
- "log", "--format=%H %ae", `${lastBotSha}..HEAD`,
110
- ]);
111
- const userShas = rangeLog
112
- .trim()
113
- .split("\n")
114
- .filter(Boolean)
115
- .filter(line => !line.endsWith(BOT_EMAIL))
116
- .map(line => line.split(" ")[0]);
117
- if (userShas.length === 0)
118
- return [];
119
- // Union all files touched by user commits
120
- const fileSet = new Set();
121
- for (const sha of userShas) {
122
- const filesRaw = await git.raw([
123
- "diff-tree", "--no-commit-id", "--name-only", "-r", sha,
124
- ]);
125
- filesRaw.trim().split("\n").filter(Boolean).forEach(f => fileSet.add(f));
126
- }
127
- return Array.from(fileSet);
128
- }
129
- catch {
130
- return null;
131
- }
132
- }
133
- const NON_APP_PATTERNS = [
134
- // CI/CD
135
- /^\.github\//,
136
- /^\.circleci\//,
137
- /^\.gitlab-ci/,
138
- /^\.travis\.yml$/,
139
- /^Jenkinsfile/,
140
- /^\.buildkite\//,
141
- /^\.drone\.yml$/,
142
- // Docs
143
- /\.md$/i,
144
- /\.mdx$/i,
145
- /\.rst$/i,
146
- /\.txt$/i,
147
- /^docs\//i,
148
- /^documentation\//i,
149
- /^CHANGELOG/i,
150
- /^CONTRIBUTING/i,
151
- /^README/i,
152
- /^LICENSE/i,
153
- // Lock files & dependency manifests (not the manifest itself, just locks)
154
- /package-lock\.json$/,
155
- /yarn\.lock$/,
156
- /pnpm-lock\.yaml$/,
157
- /poetry\.lock$/,
158
- /Pipfile\.lock$/,
159
- /Gemfile\.lock$/,
160
- /composer\.lock$/,
161
- /go\.sum$/,
162
- /Cargo\.lock$/,
163
- // Config / infra (non-code)
164
- /^\.env/,
165
- /\.eslintrc/,
166
- /\.prettierrc/,
167
- /\.editorconfig$/,
168
- /^\.vscode\//,
169
- /^\.idea\//,
170
- /docker-compose.*\.ya?ml$/i,
171
- /^Dockerfile/i,
172
- /^\.dockerignore$/,
173
- /^\.gitignore$/,
174
- /^\.gitattributes$/,
175
- /tsconfig.*\.json$/,
176
- /jest\.config\./,
177
- /babel\.config\./,
178
- /^renovate\.json$/,
179
- /^\.pre-commit-config/,
180
- ];
181
- // ── filterEndpointsBySpec ──────────────────────────────────────────────────
182
- // Pure helper extracted so unit tests can exercise the filtering + merge logic
183
- // without spinning up the full analyzeChanges handler.
184
- export function filterEndpointsBySpec(scannedEndpoints, specPaths, specPathItems, diffChangedPaths) {
185
- if (!specPaths || specPaths.size === 0)
186
- return scannedEndpoints;
187
- const filtered = scannedEndpoints.filter(ep => {
188
- const normalized = ep.path.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, m => `{${m.slice(1)}}`);
189
- if (specPaths.has(ep.path) || specPaths.has(normalized))
190
- return true;
191
- if (diffChangedPaths.has(ep.path))
192
- return true;
193
- return false;
194
- });
195
- const scannedPathSet = new Set(filtered.map(ep => ep.path));
196
- // Also track normalized (:param → {param}) forms so Express-style scanned paths
197
- // (e.g. /api/v1/users/:id) don't produce a duplicate when the spec uses /api/v1/users/{id}.
198
- const scannedNormalizedSet = new Set(filtered.map(ep => ep.path.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, m => `{${m.slice(1)}}`)));
199
- const HTTP_VERBS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
200
- for (const specPath of specPaths) {
201
- if (!scannedPathSet.has(specPath) && !scannedNormalizedSet.has(specPath)) {
202
- const pathObj = specPathItems[specPath];
203
- const specMethods = Object.keys(pathObj ?? {})
204
- .filter(k => HTTP_VERBS.has(k))
205
- .map(m => m.toUpperCase());
206
- if (specMethods.length > 0) {
207
- filtered.push({ path: specPath, methods: specMethods, sourceFile: "" });
208
- }
209
- }
210
- }
211
- return filtered;
212
- }
213
31
  const GRAPHQL_EXT = /\.(graphql|gql)$/i;
214
32
  const GRAPHQL_SCHEMA_CONTENT_PATTERN = /^\s*(type\s+(Query|Mutation|Subscription)\s*\{|schema\s*\{|extend\s+type|directive\s+@)/m;
215
33
  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;
@@ -239,39 +57,7 @@ export async function isGraphQLFile(filePath, repositoryPath) {
239
57
  return false;
240
58
  }
241
59
  }
242
- function isGraphQLEndpointPath(endpointPath) {
243
- return /(?:^|\/)graphql(?:\/|$)/i.test(endpointPath);
244
- }
245
- async function isUnsupportedGraphQLEndpoint(endpoint, repositoryPath, checkGraphQLFile = (filePath) => isGraphQLFile(filePath, repositoryPath)) {
246
- return isGraphQLEndpointPath(endpoint.path) ||
247
- await checkGraphQLFile(endpoint.sourceFile);
248
- }
249
- export async function filterUnsupportedGraphQLEndpoints(endpoints, repositoryPath) {
250
- const graphqlFileCache = new Map();
251
- const checkGraphQLFile = (filePath) => {
252
- if (!filePath)
253
- return Promise.resolve(false);
254
- const cached = graphqlFileCache.get(filePath);
255
- if (cached)
256
- return cached;
257
- const result = isGraphQLFile(filePath, repositoryPath);
258
- graphqlFileCache.set(filePath, result);
259
- return result;
260
- };
261
- const filtered = [];
262
- for (const endpoint of endpoints) {
263
- if (!(await isUnsupportedGraphQLEndpoint(endpoint, repositoryPath, checkGraphQLFile))) {
264
- filtered.push(endpoint);
265
- }
266
- }
267
- return filtered;
268
- }
269
- function isNonApplicationFile(filePath) {
270
- return NON_APP_PATTERNS.some((p) => p.test(filePath));
271
- }
272
- const ROUTE_FILE_PATTERN = /route|controller|endpoint|handler|view|urls|api|router|service|gateway|resolver|\bserver\b/i;
273
- const ROUTE_FILE_BASENAME_PATTERN = /\bapp\b|\bmain\b/i;
274
- const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
60
+ // ── Seed and fixture data ───────────────────────────────────────────────────
275
61
  export const analyzeChangesInputSchema = {
276
62
  repositoryPath: z
277
63
  .string()
@@ -289,25 +75,21 @@ export const analyzeChangesInputSchema = {
289
75
  .string()
290
76
  .optional()
291
77
  .describe("Directory containing existing tests (auto-detected if omitted)"),
292
- topN: z
293
- .number()
294
- .optional()
295
- .default(MAX_RECOMMENDATIONS)
296
- .describe(`Number of ranked test recommendations to generate. Defaults to ${MAX_RECOMMENDATIONS}.`),
297
- maxGenerate: z
298
- .number()
299
- .int()
300
- .min(0)
301
- .optional()
302
- .describe(`Number of tests to generate and execute. Defaults to ${MAX_TESTS_TO_GENERATE} (diff mode) or all recommendations (full repo).`),
78
+ // The legacy count arguments are declared nowhere on purpose. zod strips an
79
+ // undeclared key, so a caller that still sends one keeps a working call while
80
+ // the name never appears in the schema the agent lists.
303
81
  prNumber: z
304
82
  .number()
305
83
  .optional()
306
- .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."),
84
+ .describe("GitHub PR number. Accepted and applied nowhere: nothing reads previous Testbot comments on the pull request any more. Pass it if the caller supplies it."),
307
85
  repository: z
308
86
  .string()
309
87
  .optional()
310
88
  .describe("The owner/repo this call analyzes (e.g. 'letsramp/api-insight'). In a multi-repo run, ALWAYS set it — for the primary AND every related repo. All calls in one Testbot run share ONE state file automatically (you do NOT pass a file path); each repo's analysis is stored as its own section within it. The primary's analysis is kept at the root and tagged with its owner/repo; related repos are stored as keyed sections. Omit only for a single-repo run."),
89
+ primaryRepository: z
90
+ .string()
91
+ .optional()
92
+ .describe("The owner/repo of the run's PRIMARY repository. In a multi-repo run set it on EVERY skyramp_analyze_changes call, for the primary and each related repo, so the state file's root is the primary whichever call comes first. Omit in a single-repo run."),
311
93
  testsRepoDir: z
312
94
  .string()
313
95
  .refine((v) => path.isAbsolute(v), { message: "testsRepoDir must be an absolute path" })
@@ -319,103 +101,59 @@ export const analyzeChangesInputSchema = {
319
101
  .default(false)
320
102
  .describe("When true, diffs the base ref against the working tree (captures uncommitted and unstaged changes). Use for local-dev workflows. Defaults to false (CI mode — committed changes only)."),
321
103
  };
322
- // ── UI blueprint-capture instructions ──
323
- // Moved here from the former skyramp_ui_analyze_changes pre-flight tool. These
324
- // strings + buildCaptureInstructions are appended to the tool output so the
325
- // agent captures browser_blueprints on the candidate UI pages after this call.
326
- // The captures stay in its tool-result history and serve as element vocabulary
327
- // for UI recommendation reasoning (enforced at submit time by the Blueprint
328
- // Citation Invariant in the testbot prompt).
329
- export const NO_UI_INSTRUCTIONS = `No UI changes detected — no blueprint capture needed.`;
330
- export const NO_RESOLVABLE_URLS_INSTRUCTIONS = `Frontend changes detected but no candidate URLs could be resolved (no route files matched the changed files or their importers, and no frontend baseUrl to fall back to). UI recommendations will be source-grounded only.`;
331
- export function buildCaptureInstructions(pages) {
332
- const pathOnly = pages.some((p) => p.baseUrlResolved === false);
333
- const pagesYaml = pages
334
- .map((p, i) => ` ${i + 1}. ${p.url} (sourcedFrom: ${p.sourcedFrom.join(", ") || "(none)"}${p.via ? `, via: ${p.via.join(", ")}` : ""}, strategy: ${p.strategy})`)
335
- .join("\n");
336
- const capNote = pages.length >= MAX_CANDIDATE_PAGES
337
- ? `\n(The list is capped at ${MAX_CANDIDATE_PAGES} pages — direct route matches first, then the pages that render the most changed files.)`
338
- : "";
339
- // The server could not resolve a frontend baseUrl (no frontend service with
340
- // `api.baseUrl`, no SKYRAMP_TEST_BASE_URL). The paths are still exact; the
341
- // agent supplies the host.
342
- const baseUrlStep = pathOnly
343
- ? `
344
- **The entries above are URL paths, not full URLs** — the workspace declares no frontend service with \`api.baseUrl\`. Determine the frontend base URL once before capturing — the testbot workflow's \`targetReadyCheckCommand\` or the dev-server port in \`package.json\` scripts usually names it — then prefix every path with it. Log an \`issuesFound\` info entry recommending that \`api.baseUrl\` be set on the frontend service in \`.skyramp/workspace.yml\`.
345
- `
346
- : "";
347
- return `Frontend changes detected. **Before writing any UI recommendation \`reasoning\`, capture blueprints on the candidate UI pages below.** Those captures stay in your tool-result history and serve as element vocabulary — the recommendation catalog further down gives you the authoring rules; you bring the observed elements.
348
-
349
- **Candidate ${pathOnly ? "URL paths" : "URLs"}:**
350
- ${pagesYaml}${capNote}
351
- ${baseUrlStep}
352
- **For each candidate URL:**
353
- - \`browser_navigate\` to the URL
354
- - \`browser_blueprint\` to capture the page
355
-
356
- You don't need to thread the blueprints back into a tool call — they're in your context once captured.
357
-
358
- If a candidate URL 404s or redirects unexpectedly, navigate from the frontend base URL and explore (admin apps mount routes under base prefixes the source extraction can't see). If the page rendered but lacks the changed feature (gated UI: modal, dropdown, accordion), do NOT iterate further during this step — UI recs will fall back to source-grounded prose for those, and the agent's later trace recording (Task 2) will navigate into the gate via capture-act-capture.
359
-
360
- If \`browser_blueprint\` fails on every candidate URL (app unreachable, all 404s), proceed and log an \`issuesFound\` info entry. Recommendations will be source-grounded; non-UI work is unaffected.`;
361
- }
362
- /**
363
- * Instruction block for changed frontend files the server determined have no
364
- * production importer (SKYR-3855) — dead/orphaned components. Used both to
365
- * replace the capture instructions entirely (every changed frontend file is
366
- * unintegrated) and to append a scoped note when only some files are
367
- * (`buildCaptureInstructions` still runs for the rest in that case).
368
- */
369
- export function buildUnintegratedInstructions(results) {
370
- const listing = results
371
- .filter((r) => !r.integrated)
372
- .map((r) => {
373
- const token = path.basename(r.file).replace(/\.[^.]+$/, "");
374
- return ` - \`${r.file}\` (checked for a production import of \`${token}\` — none found)`;
104
+ /** SKYR-4182: structuredContent needs a declared outputSchema, and the schema
105
+ * has to accept the early exit as well as a full analysis — hence the optional
106
+ * fields. */
107
+ export const analyzeChangesOutputSchema = {
108
+ sessionId: z.string().optional(),
109
+ stateFile: z.string().optional(),
110
+ repositoryPath: z.string(),
111
+ repositoryName: z.string(),
112
+ repository: z.string().optional(),
113
+ relatedRepositories: z.array(z.string()).optional(),
114
+ analysisScope: z.string(),
115
+ planOnly: z.boolean(),
116
+ currentBranch: z.string().optional(),
117
+ baseBranch: z.string().optional(),
118
+ changedFiles: z.array(z.object({ path: z.string(), deleted: z.boolean() })),
119
+ existingTestFiles: z
120
+ .object({
121
+ skyramp: z.array(z.string()),
122
+ external: z.array(z.string()),
123
+ relevantExternal: z.array(z.string()),
375
124
  })
376
- .join("\n");
377
- return `**Server-side integration check found no production importer for the following changed component(s) — do not treat this as unproven:**
378
- ${listing}
379
-
380
- These components have no DOM presence in the running app: no route, page, or other production file imports or renders them. Handle them as follows:
381
- - **Never record traces or generate UI tests against a substitute surface** (a different page or component that happens to be visible). A test on an unrelated surface validates nothing about this PR.
382
- - **DO write the change-targeting test directly from code analysis** — you know the component's structure, selectors, and expected behavior from the diff. Target the component's own elements/attributes. The test is EXPECTED TO FAIL because the component never mounts; that failing test documents the integration gap and is more valuable than prose. Mark its report entry accordingly (e.g. "expected failure: component has no production importer").
383
- - Log an \`issuesFound\` entry (high severity) naming the unintegrated file(s) above and stating that the changed code is unreachable in the running app.
384
- - Backend/other work in this PR is unaffected — proceed with it normally.`;
385
- }
386
- /**
387
- * Assemble the tool result. See `dualChannelResult` for why the payload is
388
- * carried on both result channels.
389
- */
390
- export function buildAnalyzeChangesResult(parts) {
391
- const executionPlan = `\`\`\`json\n${parts.structuredSummary}\n\`\`\`\n\n## UI Blueprint Capture — do this BEFORE writing UI recommendation reasoning\n${parts.uiInstructions}\n\n${parts.outputText}\n\n---\n\n## Pre-built Test Catalog — Fill in placeholders from source code, then display verbatim\n⚠️ Do NOT reformat, rename sections, or generate a new catalog. Replace \`<…from source>\` values, then show this output exactly as-is, grouped by test type.\n\n${parts.recommendationPrompt}`;
392
- return dualChannelResult({ executionPlan });
393
- }
125
+ .optional(),
126
+ workspace: z
127
+ .object({
128
+ baseUrl: z.string(),
129
+ authMethod: z.string(),
130
+ authHeader: z.string().optional(),
131
+ openApiSpecPath: z.string().optional(),
132
+ openApiSpecLoaded: z.boolean(),
133
+ })
134
+ .optional(),
135
+ uiContext: z
136
+ .object({
137
+ removedElements: z
138
+ .array(z.object({ attribute: z.string(), value: z.string(), file: z.string() }))
139
+ .optional(),
140
+ })
141
+ .optional(),
142
+ nextStep: z.string().optional(),
143
+ };
394
144
  /**
395
- * Coverage is discovered from the working tree, so a test file the PR itself adds looks
396
- * identical to pre-existing coverage. This is what the budgeter reads to decide whether
397
- * an empty plan is a dedup artifact or the author's own coverage.
398
- *
399
- * No diff means no answer, so it reports `true` and the reserve stays shut. `diffData` is
400
- * absent in full-repo scope — where fullRepoCatalog deliberately drops externally covered
401
- * scenarios, and promoting them back would contradict the prompt the agent reads — and
402
- * after a branch-diff failure, where nothing is known about the changed files at all.
403
- *
404
- * Classification comes from discovery, not from `isTestFile`: the flag has to agree with
405
- * whatever built the coverage keys it guards.
406
- *
407
- * `userChangedFiles` is the bot-filtered list. The bot commits its generated tests onto
408
- * the branch it analyzed, and those filenames are exactly what the discovery patterns
409
- * exist to match — so reading the raw base..HEAD list shut the reserve on every re-run
410
- * of a branch: run 1 generates the tests, run 2 sees them. When the filter cannot answer
411
- * (no bot commit yet, or a git failure) the raw list stands. That is safe in the same
412
- * direction as the `!diffData` case: the raw list can only hold MORE files, so the
413
- * fallback shuts the reserve rather than opening it.
145
+ * Assemble the tool result. The payload is carried on BOTH channels because
146
+ * each agent CLI reads a different one and none falls back: Claude Code takes
147
+ * `structuredContent`, Cursor takes `content[]`. `content[]` must be the
148
+ * literal JSON serialization of `structuredContent` — that exact form is what
149
+ * GitHub Copilot CLI de-duplicates on.
414
150
  */
415
- export function computeDiffChangesTestFiles(diffData, userChangedFiles) {
416
- if (!diffData)
417
- return true;
418
- return (userChangedFiles ?? diffData.changedFiles).some(isDiscoveredTestFile);
151
+ export function buildAnalyzeChangesResult(data) {
152
+ const structuredContent = JSON.parse(JSON.stringify(data));
153
+ return {
154
+ structuredContent,
155
+ content: [{ type: "text", text: JSON.stringify(structuredContent) }],
156
+ };
419
157
  }
420
158
  export function registerAnalyzeChangesTool(server) {
421
159
  server.registerTool(TOOL_NAME, {
@@ -423,17 +161,12 @@ export function registerAnalyzeChangesTool(server) {
423
161
  readOnlyHint: false, // writes a state file to disk
424
162
  destructiveHint: false,
425
163
  idempotentHint: false,
426
- openWorldHint: true, // may fetch PR comments from GitHub
164
+ openWorldHint: true, // reads an OpenAPI spec by URL when one is configured
427
165
  },
428
- description: `Scan repository API endpoints and discover existing tests — first step of the unified Test Health Analysis Flow. Returns a stateFile path and ranked test recommendations. Pass stateFile to skyramp_analyze_test_health and skyramp_actions.`,
166
+ description: `Read the repository and the current change, and discover the existing tests — first step of the unified Test Health Analysis Flow. Returns DATA only, no instructions: a stateFile path, the branch and its base, the changed files with their status, the existing test files, the workspace base URL and auth, the OpenAPI spec path, the repository's seed and fixture data files, and the identifying attributes the change removed from surviving files. It proposes no tests and it tells you nothing about the diff — read the diff and decide what the change warrants. Pass stateFile to skyramp_register_test_plan (required before you write any test), skyramp_analyze_test_health and skyramp_actions.`,
429
167
  // SKYR-4182: the payload is returned via structuredContent, which requires a
430
- // declared outputSchema. This also keeps the result out of the single-line
431
- // `.json` spill shape that Read cannot paginate — see buildAnalyzeChangesResult.
432
- outputSchema: {
433
- executionPlan: z
434
- .string()
435
- .describe("Analysis summary, UI blueprint capture instructions, and the ranked Execution Plan. Read and follow this field."),
436
- },
168
+ // declared outputSchema.
169
+ outputSchema: analyzeChangesOutputSchema,
437
170
  inputSchema: analyzeChangesInputSchema,
438
171
  }, async (params, extra) => {
439
172
  let errorResult;
@@ -464,177 +197,24 @@ export function registerAnalyzeChangesTool(server) {
464
197
  logger.warning("Failed to obtain branch diff, continuing without diff", { error: msg });
465
198
  }
466
199
  }
467
- // ── Early return: all changed files are non-application ──
468
- // Use user-only commits since last bot run (if a bot commit exists) so that
469
- // bot-committed test files don't count as application changes.
470
- if (analysisScope === AnalysisScope.CurrentBranchDiff && diffData && diffData.changedFiles.length > 0) {
471
- const userFiles = await getUserChangedFiles(params.repositoryPath);
472
- const filesToCheck = userFiles ?? diffData.changedFiles;
473
- const appFiles = filesToCheck.filter(f => !isNonApplicationFile(f));
474
- if (filesToCheck.length > 0 && appFiles.length === 0) {
475
- logger.info("All user-changed files are non-application — skipping analysis", {
476
- changedFiles: filesToCheck,
477
- });
478
- return dualChannelResult({
479
- executionPlan: `All ${filesToCheck.length} changed file(s) are non-application (CI/CD, docs, lock files, config). No test analysis needed for this diff.\n\nChanged files: ${filesToCheck.join(", ")}`,
480
- });
481
- }
482
- }
483
- // ── Step 2: Scan endpoints ──
484
- let scannedEndpoints = [];
485
- let rawRelatedEndpointCount;
486
- if (analysisScope !== AnalysisScope.CurrentBranchDiff) {
487
- await sendProgress(25, 100, "Scanning all repository endpoints...");
488
- try {
489
- scannedEndpoints = scanAllRepoEndpoints(params.repositoryPath);
490
- logger.info("Pre-scanned repo endpoints", {
491
- count: scannedEndpoints.length,
492
- });
493
- }
494
- catch (err) {
495
- logger.warning("Endpoint pre-scan failed", {
496
- error: err instanceof Error ? err.message : String(err),
497
- });
498
- }
499
- }
500
- else if (diffData) {
501
- await sendProgress(25, 100, "Scanning related endpoints from diff...");
502
- try {
503
- scannedEndpoints = scanRelatedEndpoints(params.repositoryPath, diffData.changedFiles);
504
- rawRelatedEndpointCount = scannedEndpoints.length;
505
- logger.info("Scanned related endpoints", {
506
- count: scannedEndpoints.length,
507
- });
508
- }
509
- catch (err) {
510
- rawRelatedEndpointCount = 0;
511
- logger.warning("Related endpoint scan failed", {
512
- error: err instanceof Error ? err.message : String(err),
513
- });
514
- }
515
- const beforeGraphQLFilter = scannedEndpoints.length;
516
- scannedEndpoints = await filterUnsupportedGraphQLEndpoints(scannedEndpoints, params.repositoryPath);
517
- if (scannedEndpoints.length !== beforeGraphQLFilter) {
518
- logger.info("Filtered unsupported GraphQL endpoints from related scan", {
519
- before: beforeGraphQLFilter,
520
- after: scannedEndpoints.length,
521
- });
522
- }
523
- // No fallback to scanAllRepoEndpoints in PR mode.
524
- // If the scanner found 0 related endpoints, the PR likely touches
525
- // non-route code (services, models, schemas, client SDK). Flooding
526
- // all repo endpoints into the prompt causes the LLM to test
527
- // irrelevant neighboring endpoints instead of focusing on the diff.
528
- }
529
- await sendProgress(40, 100, "Classifying changed endpoints...");
530
- // ── Step 2.5: Classify endpoints by changed files ──
531
- // Cross-reference changedFiles against scannedEndpoints[].sourceFile to
532
- // identify new/modified/removed endpoints — replaces fragile regex parsing
533
- // of diff hunks. Scanned endpoints always have full paths and concrete
534
- // HTTP methods, eliminating the need for path resolution and MULTI sentinels.
535
- let classifiedEndpoints;
536
- // Importer-hop mapping (SKYR-3855): unmatched non-route backend files
537
- // (DTOs, services, models) hopped one import edge to the endpoints that
538
- // reference them. Kept separate from classifiedEndpoints.changedEndpoints
539
- // (which it also feeds into, below) so Step 9 can draft response-shape
540
- // scenarios specifically for these endpoints.
541
- let hopResult = { endpoints: [], byFile: {} };
200
+ // The changed-file listing the result carries. Read from
201
+ // `--name-status`, which is the same listing skyramp_register_test_plan
202
+ // verifies a candidate's `changedFile` against — one source, so the two
203
+ // cannot disagree about what the change touched.
204
+ let changedNames = [];
542
205
  if (diffData) {
543
- // Recover endpoints that existed in the base branch from every candidate
544
- // file (deleted files AND modified files that may have dropped a route via
545
- // line-edit). classifyEndpointsByChangedFiles filters out any that still
546
- // exist in the current catalog, leaving only genuine removals.
547
- const removalCandidateFiles = selectRemovalCandidateFiles(diffData);
548
- const git = simpleGit(params.repositoryPath);
549
- const recoveredBaseEndpoints = removalCandidateFiles.length > 0
550
- ? await recoverRemovedEndpointsFromBase(removalCandidateFiles, (file) => git.show([`${diffData.baseBranch}:${file}`]), params.repositoryPath, diffData.baseBranch)
551
- : [];
552
- classifiedEndpoints = classifyEndpointsByChangedFiles(diffData, scannedEndpoints, recoveredBaseEndpoints);
553
- classifiedEndpoints = {
554
- ...classifiedEndpoints,
555
- // changed/new endpoints come from scannedEndpoints, which is already
556
- // GraphQL-filtered in branch-diff mode. Removed endpoints are recovered
557
- // from the base branch separately and still need unsupported-protocol filtering.
558
- removedEndpoints: await filterUnsupportedGraphQLEndpoints(classifiedEndpoints.removedEndpoints, params.repositoryPath),
559
- };
560
- logger.info("Classified endpoints from changed files", {
561
- changed: classifiedEndpoints.changedEndpoints.length,
562
- new: classifiedEndpoints.newEndpoints.length,
563
- removed: classifiedEndpoints.removedEndpoints.length,
564
- unmatched: classifiedEndpoints.unmatchedFiles.length,
565
- });
566
- // ── Importer-hop classification (SKYR-3855) ──
567
- // A changed DTO/service/model file declares no route of its own, so it
568
- // never matches classifyEndpointsByChangedFiles' exact sourceFile check
569
- // and lands in unmatchedFiles. Hop one import edge: find already-scanned
570
- // endpoints whose source imports the unmatched file AND shares its REST
571
- // resource, then fold them into changedEndpoints so diffContext.modifiedEndpoints,
572
- // novelty classification, and drift analysis all light up. Never fatal —
573
- // any failure just leaves classification exactly as it was.
574
- if (classifiedEndpoints.unmatchedFiles.length > 0) {
575
- try {
576
- hopResult = mapUnmatchedFilesToImporterEndpoints(params.repositoryPath, classifiedEndpoints.unmatchedFiles, scannedEndpoints, [...classifiedEndpoints.changedEndpoints, ...classifiedEndpoints.newEndpoints], diffData.diffContent);
577
- // `byFile` can be non-empty even when `endpoints` is empty — the hop
578
- // still found a valid mapping, but every candidate was already present
579
- // in changedEndpoints/newEndpoints (e.g. the controller and its DTO both
580
- // changed in the same PR) and got deduped out of `endpoints`. The
581
- // response-shape drafting step (below) re-derives its endpoint list
582
- // from `scannedEndpoints` via `buildHopResponseShapeInputs`, keyed off
583
- // `byFile`, so it isn't affected by that dedup — only the
584
- // `changedEndpoints` merge here needs the (deduped) `endpoints` array.
585
- if (Object.keys(hopResult.byFile).length > 0) {
586
- logger.info("Importer-hop mapped unmatched files to endpoints", {
587
- byFile: hopResult.byFile,
588
- });
589
- if (hopResult.endpoints.length > 0) {
590
- classifiedEndpoints = {
591
- ...classifiedEndpoints,
592
- changedEndpoints: [...classifiedEndpoints.changedEndpoints, ...hopResult.endpoints],
593
- };
594
- }
595
- }
596
- }
597
- catch (err) {
598
- logger.warning("Importer-hop classification failed — continuing without it", {
599
- error: err instanceof Error ? err.message : String(err),
600
- });
601
- hopResult = { endpoints: [], byFile: {} };
602
- }
206
+ try {
207
+ changedNames = changedFileNames(await computeBranchDiff(params.repositoryPath, params.baseBranch, false, true));
603
208
  }
604
- }
605
- // ── Early return: GraphQL-only diff — REST testing not supported ──
606
- // Uses (userFiles ?? diffData.changedFiles) to match the existing non-app
607
- // early-return pattern — bot-committed .graphql files must not trigger this.
608
- if (analysisScope === AnalysisScope.CurrentBranchDiff &&
609
- classifiedEndpoints &&
610
- classifiedEndpoints.changedEndpoints.length === 0 &&
611
- classifiedEndpoints.newEndpoints.length === 0 &&
612
- classifiedEndpoints.removedEndpoints.length === 0 &&
613
- diffData) {
614
- const userFiles = await getUserChangedFiles(params.repositoryPath);
615
- const filesToCheck = userFiles ?? diffData.changedFiles;
616
- // Exclude non-application files (docs, CI, configs) before the graphql check.
617
- // Non-app files are neutral — a README-only diff must NOT fire this early return.
618
- const appFilesToCheck = filesToCheck.filter(f => !isNonApplicationFile(f));
619
- const allGraphQL = appFilesToCheck.length > 0 &&
620
- (await Promise.all(appFilesToCheck.map(f => isGraphQLFile(f, params.repositoryPath)))).every(Boolean);
621
- if (allGraphQL) {
622
- logger.info("GraphQL-only diff detected — REST testing not supported", {
623
- changedFiles: diffData.changedFiles,
209
+ catch (error) {
210
+ logger.warning("Failed to list changed file names, continuing without them", {
211
+ error: error instanceof Error ? error.message : String(error),
624
212
  });
625
- return dualChannelResult({ executionPlan: [
626
- "**GraphQL-only diff detected.**",
627
- "",
628
- "The changed files appear to be GraphQL schema, artifact, or endpoint implementation files.",
629
- "Skyramp currently supports REST API testing only — GraphQL introspection,",
630
- "query validation, and type-name grounding are not yet supported.",
631
- "",
632
- "No test recommendations can be generated for this diff.",
633
- "",
634
- `Changed files: ${diffData.changedFiles.join(", ")}`,
635
- ].join("\n") });
636
213
  }
637
214
  }
215
+ // No endpoint scan, in either mode. A full-repo run used to hand the
216
+ // agent a catalog built by framework route parsers; it reads the
217
+ // repository and enumerates what it serves itself.
638
218
  await sendProgress(50, 100, "Discovering existing tests...");
639
219
  // ── Step 3: Discover existing tests ──
640
220
  // Always scan from repositoryPath so tests in any subdirectory (e.g.
@@ -645,142 +225,31 @@ export function registerAnalyzeChangesTool(server) {
645
225
  // testsRepoDir is a cross-repo path override — honour it when set.
646
226
  // Otherwise always scan the full repo root.
647
227
  const testDir = params.testsRepoDir ?? undefined;
648
- const routerMountContext = grepRouterMountingContext(params.repositoryPath);
649
- // Read contents once — used for token extraction and LLM prompt injection.
650
- const ROUTER_INLINE_LIMIT = 4096;
651
- const routerMountContextContents = routerMountContext.map((f) => {
652
- try {
653
- const abs = path.isAbsolute(f) ? f : path.join(params.repositoryPath, f);
654
- if (fs.statSync(abs).size > ROUTER_INLINE_LIMIT * 10)
655
- return ""; // skip very large files
656
- return fs.readFileSync(abs, "utf-8");
657
- }
658
- catch {
659
- return "";
660
- }
661
- });
662
- // Size-capped subset for LLM prompt injection.
663
- const ROUTER_INLINE_MAX_FILES = 3;
664
- const routerFileContents = routerMountContext
665
- .slice(0, ROUTER_INLINE_MAX_FILES)
666
- .flatMap((f, i) => {
667
- const content = routerMountContextContents[i] ?? "";
668
- if (!content || content.length > ROUTER_INLINE_LIMIT)
669
- return [];
670
- return [{ file: f, content: content.trimEnd() }];
671
- });
672
- // PR mode → [] excludes external tests from pre-loading (agent discovers via grep).
673
- // No diff context → undefined triggers full-repo mode in TestDiscoveryService.
674
- // SKYR-3924: a diff that changed NO endpoints but did change schema/model/DTO files
675
- // yields no endpoint signal, so external tests would be skipped entirely and the
676
- // breaking existing tests never enter the maintenance scope. Derive resource tokens
677
- // from the changed files via the shared importer-hop derivation and use them, so
678
- // discoverTests partitions and PRE-LOADS the relevant existing tests.
679
- // Per-file diff slices — lazily computed once and shared by the changedSymbols
680
- // (SKYR-3924) and changedSelectors (UI promotion pass 3) derivations below, so a diff
681
- // that triggers both isn't sliced twice.
228
+ // No route hints. The server used to grep for router entry points, list
229
+ // candidate route files and inline the first few router files' contents.
230
+ // All three guessed at a surface the agent reads for itself.
231
+ // Per-file diff slices — lazily computed once and shared by the
232
+ // changedSelectors derivation and the removed-element collection below,
233
+ // so a diff that reaches both is not sliced twice.
682
234
  let _diffByFile = null;
683
- const getDiffByFile = () => (_diffByFile ??= diffData?.diffContent ? sliceDiffByFile(diffData.diffContent) : new Map());
684
- let changedResources = classifiedEndpoints ? [] : undefined;
685
- let changedSymbols;
686
- if (classifiedEndpoints &&
687
- classifiedEndpoints.changedEndpoints.length === 0 &&
688
- classifiedEndpoints.newEndpoints.length === 0 &&
689
- classifiedEndpoints.removedEndpoints.length === 0 &&
690
- diffData?.diffContent) {
691
- // Only changed application-source files carry a resource signal — skip test
692
- // files, configs, workflows, and docs (they'd contribute filename noise like
693
- // "workspace" or "skyramp-testbot").
694
- const SOURCE_EXT = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
695
- const files = (classifiedEndpoints.changedFiles ?? diffData.changedFiles ?? [])
696
- .filter((f) => SOURCE_EXT.test(f) && !isTestFile(f));
697
- // A schema/DTO/model file is named for its module, not the REST resource — its
698
- // class names are the real signal (DeploymentCreate → "deployment", not the
699
- // "actions" filename). For those files prefer the symbol-derived tokens and drop
700
- // the generic filename token; otherwise use all tokens.
701
- const SCHEMA_FILE = /\/(schemas?|models?|dtos?|entities|serializers?|types)\/|\.(dto|schema|model|entity|serializer)\.[^/]+$/i;
702
- // Per-file diff sections (with @@ headers) so each file's tokens derive
703
- // only from its own hunk — passing the full multi-file diff would mix
704
- // symbols across files. Falls back to the full diff if a path doesn't
705
- // match (no worse than the prior behavior). SKYR-3924.
706
- const diffByFile = getDiffByFile();
707
- const derived = new Set();
708
- const symbols = new Set();
709
- for (const f of files) {
710
- const fileDiff = diffByFile.get(f) ?? diffData.diffContent;
711
- const all = deriveResourceTokensForFile(f, fileDiff);
712
- const filenameToken = deriveResourceToken(f);
713
- const symbolTokens = all.filter((t) => t !== filenameToken);
714
- // deriveResourceTokensForFile already drops generic tokens (via importerHop's
715
- // GENERIC_RESOURCE_TOKENS / GENERIC_SYMBOL_TOKENS), so no extra filter is needed here.
716
- const chosen = SCHEMA_FILE.test(f) && symbolTokens.length > 0 ? symbolTokens : all;
717
- for (const t of chosen)
718
- derived.add(t);
719
- // Raw changed symbol names, derived from THIS file's diff slice only (not the full
720
- // multi-file diff) so an unrelated changed file — a test or doc — can't inject its
721
- // symbols. Uses the per-file slice from diffByFile.get(f).
722
- for (const s of extractChangedSymbols(diffByFile.get(f) ?? ""))
723
- symbols.add(s);
724
- }
725
- if (derived.size > 0) {
726
- changedResources = [...derived];
727
- // Raw changed symbol names (e.g. "DeploymentCreate") for a content grep against
728
- // test bodies — catches tests that reference the changed type by name but whose
729
- // path/URL doesn't token-match the resource (e.g. a schema unit test).
730
- changedSymbols = [...symbols];
731
- logger.info("Schema-only diff: derived changedResources from changed files (SKYR-3924)", {
732
- changedResources,
733
- changedSymbols,
734
- });
735
- }
736
- }
737
- // Compute changed frontend files once — reused for both discoverTests (component
738
- // test promotion) and uiContext below (page enumeration). Uses classifiedEndpoints
739
- // changedFiles so it reflects the same diff scope as the rest of the analysis.
740
- const flutterSdkDep = hasFlutterSdkDep(params.repositoryPath);
741
- const changedFrontendFiles = (classifiedEndpoints?.changedFiles ?? diffData?.changedFiles ?? [])
742
- .filter(f => isFrontendFile(f, { hasFlutterSdkDep: flutterSdkDep }) && !isTestFile(f));
743
- // Selector literals (data-testid values, CSS class tokens) added/removed in the
744
- // frontend diff — drives discovery pass 3, which promotes page objects/specs that
745
- // couple to a changed component by selector rather than by filename or import
746
- // (see TestDiscoveryService Step 2). Derived from each changed frontend file's own
747
- // diff slice so an unrelated file can't inject selectors.
748
- let changedSelectors;
749
- if (changedFrontendFiles.length > 0 && diffData?.diffContent) {
750
- const diffByFile = getDiffByFile();
751
- // Total cap across all changed files (each file is separately capped inside
752
- // extractChangedSelectors). Bounds pass 3's O(externalFiles × selectors) scan so a
753
- // large frontend PR can't produce a pathologically long selector set.
754
- const MAX_TOTAL_SELECTORS = 200;
755
- const selectors = new Set();
756
- outer: for (const f of changedFrontendFiles) {
757
- const fileSelectors = extractChangedSelectors(diffByFile.get(f) ?? "");
758
- if (fileSelectors.length >= MAX_CHANGED_SELECTORS) {
759
- logger.warning(`changedSelectors: file '${f}' hit the per-file cap (${MAX_CHANGED_SELECTORS}); its selector extraction may be truncated`);
760
- }
761
- for (const s of fileSelectors) {
762
- selectors.add(s);
763
- if (selectors.size >= MAX_TOTAL_SELECTORS) {
764
- logger.warning(`changedSelectors: hit the total cap (${MAX_TOTAL_SELECTORS}) across ${changedFrontendFiles.length} changed frontend file(s); remaining selectors ignored for UI promotion pass 3`);
765
- break outer;
766
- }
767
- }
768
- }
769
- if (selectors.size > 0) {
770
- changedSelectors = [...selectors];
771
- logger.info("Derived changedSelectors from frontend diff (UI promotion pass 3)", {
772
- count: changedSelectors.length,
773
- });
774
- }
775
- }
235
+ const getDiffByFile = () => (_diffByFile ??= diffData?.diffContent ? diffBlocksByPath(diffData.diffContent) : new Map());
236
+ // No resource tokens are derived from the changed files. Deriving them
237
+ // guessed which existing tests were relevant from filenames and symbol
238
+ // names; `TestDiscoveryService` now takes the external tests uncut, up to
239
+ // its full-repo cap, and the agent reads them and decides.
240
+ // No frontend/backend classification of the changed files. It read a
241
+ // path's extension and directory to decide what kind of file it was, and
242
+ // that verdict gated whether the agent did any UI work at all. The agent
243
+ // reads the diff.
776
244
  let existingTests = [];
777
245
  let discoveredRelevantExternalPaths = [];
246
+ let testDiscoveryFailed = false;
778
247
  try {
779
248
  const testDiscoveryService = new TestDiscoveryService();
780
249
  // Run-scoped: only the primary call carries testsRepoDir, so never
781
250
  // let a related repo's call clear it (SKYR-4204).
782
251
  rememberTestsRepoDir(params.testsRepoDir);
783
- const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath, { changedResources, changedSymbols, preciseResources: (changedSymbols?.length ?? 0) > 0, changedFrontendFiles, changedSelectors });
252
+ const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath);
784
253
  existingTests = discoveryResult.tests.map((test) => ({
785
254
  testFile: test.testFile,
786
255
  testType: test.testType,
@@ -799,6 +268,7 @@ export function registerAnalyzeChangesTool(server) {
799
268
  });
800
269
  }
801
270
  catch (err) {
271
+ testDiscoveryFailed = true;
802
272
  logger.warning("Test discovery failed, continuing with empty list", {
803
273
  error: err instanceof Error ? err.message : String(err),
804
274
  });
@@ -885,81 +355,6 @@ export function registerAnalyzeChangesTool(server) {
885
355
  });
886
356
  }
887
357
  }
888
- // ── Step 4c: Filter scanned endpoints against spec ──
889
- // Sequencing: scannedEndpoints is populated at Step 2 (~line 400),
890
- // but wsSchemaPath and specPaths aren't known until Step 4b (above).
891
- // All of 1b, 5, 10a, and 8 must run here — after specPaths is assigned
892
- // and before Step 7 (~line 604) builds skeletonEndpoints.
893
- if (specPaths && specPaths.size > 0) {
894
- // Build a set of paths that the current diff explicitly changed — these
895
- // are preserved even if missing from spec (spec may lag the code on new PRs).
896
- // removedEndpoints intentionally excluded: deleted paths won't appear in
897
- // scannedEndpoints (they no longer exist in code), so there is nothing to
898
- // preserve — including them would only produce spurious spec-lag warnings.
899
- const diffChangedPaths = new Set([
900
- ...(classifiedEndpoints?.changedEndpoints ?? []).map(ep => ep.path),
901
- ...(classifiedEndpoints?.newEndpoints ?? []).map(ep => ep.path),
902
- ]);
903
- const beforeCount = scannedEndpoints.length;
904
- // ── Steps 4c + 4c-merge: filter against spec, merge spec-only paths ──
905
- scannedEndpoints = filterEndpointsBySpec(scannedEndpoints, specPaths, spec.paths ?? {}, diffChangedPaths);
906
- logger.info("Filtered scanned endpoints against OpenAPI spec", {
907
- before: beforeCount, after: scannedEndpoints.length,
908
- delta: scannedEndpoints.length - beforeCount, // positive = net added (spec merge), negative = net removed
909
- });
910
- // Warn when diff-changed endpoints were missing from spec — indicates spec lag
911
- const specLagPaths = [...diffChangedPaths].filter(p => !specPaths.has(p) &&
912
- !specPaths.has(p.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, m => `{${m.slice(1)}}`)));
913
- if (specLagPaths.length > 0) {
914
- logger.warning("Spec may be lagging code — diff-changed paths missing from spec (kept in catalog)", {
915
- paths: specLagPaths, schemaPath: wsSchemaPath,
916
- });
917
- }
918
- }
919
- // ── Step 4d: Filter unsupported protocol endpoints (GraphQL) ──
920
- // Must run AFTER spec-merge above — specs may include /graphql and the
921
- // merge step would re-add it if this ran earlier.
922
- {
923
- const beforeUnsupported = scannedEndpoints.length;
924
- scannedEndpoints = await filterUnsupportedGraphQLEndpoints(scannedEndpoints, params.repositoryPath);
925
- if (scannedEndpoints.length < beforeUnsupported) {
926
- logger.info("Filtered unsupported protocol endpoints (GraphQL)", {
927
- removed: beforeUnsupported - scannedEndpoints.length,
928
- });
929
- }
930
- }
931
- // ── Step 4e: Path-affinity classification fallback (SKYR-3857) ──
932
- // classifyEndpointsByChangedFiles (Step 2.5, above) matched changed files
933
- // to endpoints by exact sourceFile equality against the catalog as it stood
934
- // BEFORE the spec merge (Step 4c) — deliberately: this fallback needs the
935
- // final, spec-merged, GraphQL-filtered scannedEndpoints to have anything to
936
- // match against for a repo whose catalog is entirely spec-derived (every
937
- // entry sourceFile: ""), which is exactly the case it exists to cover. It
938
- // still runs well before diffContext/hasApiChanges (built much later, from
939
- // classifiedEndpoints) are derived, so the merged classification below is
940
- // visible everywhere that matters.
941
- if (classifiedEndpoints?.unmatchedFiles?.length) {
942
- const fallback = classifyUnmatchedFilesByPathAffinity(classifiedEndpoints.unmatchedFiles, scannedEndpoints,
943
- // changed+new are the dedup list (sourceFile::path keys — prevents
944
- // double-adding). removedEndpoints is passed separately: its entries
945
- // are keyed by the DELETED file's path so dedup can never match them;
946
- // the fallback excludes them from its candidates by path+method so a
947
- // stale spec-merged entry can't resurrect a removed endpoint as
948
- // "modified".
949
- [...classifiedEndpoints.changedEndpoints, ...classifiedEndpoints.newEndpoints], undefined, diffData?.diffContent, classifiedEndpoints.removedEndpoints);
950
- if (fallback.endpoints.length > 0) {
951
- const mappedFiles = new Set(fallback.mappedFiles);
952
- classifiedEndpoints = {
953
- ...classifiedEndpoints,
954
- changedEndpoints: [...classifiedEndpoints.changedEndpoints, ...fallback.endpoints],
955
- unmatchedFiles: classifiedEndpoints.unmatchedFiles.filter((f) => !mappedFiles.has(f)),
956
- };
957
- logger.info("Path-affinity fallback recovered endpoint classification", {
958
- files: mappedFiles.size,
959
- endpoints: fallback.endpoints.length,
960
- });
961
- }
962
- }
963
358
  // ── Step 5: Detect project metadata ──
964
359
  const projectMeta = detectProjectMetadata(params.repositoryPath);
965
360
  // ── Step 6: Trace files ──
@@ -979,164 +374,9 @@ export function registerAnalyzeChangesTool(server) {
979
374
  });
980
375
  }
981
376
  }
982
- // ── Step 7: Build skeleton endpoints ──
983
- const skeletonResponse = (method) => method === "POST"
984
- ? { statusCode: 201, description: "Created" }
985
- : method === "DELETE"
986
- ? { statusCode: 204, description: "No Content" }
987
- : { statusCode: 200, description: "OK" };
988
- const skeletonEndpoints = scannedEndpoints.map((ep) => ({
989
- path: ep.path,
990
- resourceGroup: ep.path.split("/").filter(Boolean).pop() || "unknown",
991
- pathParams: (ep.path.match(/\{(\w+)\}/g) || []).map((p) => ({
992
- name: p.slice(1, -1),
993
- type: "string",
994
- required: true,
995
- })),
996
- methods: ep.methods.map((m) => ({
997
- method: m,
998
- description: "",
999
- queryParams: [],
1000
- authRequired: true,
1001
- sourceFile: ep.sourceFile,
1002
- interactions: [
1003
- {
1004
- description: `${m} ${ep.path}`,
1005
- type: "success",
1006
- request: {},
1007
- response: skeletonResponse(m),
1008
- },
1009
- ],
1010
- })),
1011
- }));
1012
- // ── Step 8: Merge trace interactions ──
1013
- if (traceResult && traceResult.entries.length > 0) {
1014
- for (const entry of traceResult.entries) {
1015
- let rawPath = entry.path;
1016
- try {
1017
- rawPath = new URL(rawPath).pathname;
1018
- }
1019
- catch {
1020
- /* already a path */
1021
- }
1022
- const normalizedPath = rawPath
1023
- .replace(/\/[0-9a-f-]{20,}/gi, "/{id}")
1024
- .replace(/\/\d+/g, "/{id}");
1025
- const existing = skeletonEndpoints.find((ep) => {
1026
- const epNorm = ep.path.replace(/\{\w+\}/g, "/{id}");
1027
- return epNorm === normalizedPath;
1028
- });
1029
- if (existing) {
1030
- const methodObj = existing.methods.find((m) => m.method === entry.method);
1031
- if (methodObj) {
1032
- const alreadyHasStatus = methodObj.interactions.some((i) => i.response.statusCode === entry.statusCode);
1033
- if (!alreadyHasStatus) {
1034
- const traceRequest = {};
1035
- if (entry.requestBody)
1036
- traceRequest.body = entry.requestBody;
1037
- if (entry.queryParams)
1038
- traceRequest.queryParams = entry.queryParams;
1039
- methodObj.interactions.push({
1040
- description: `${entry.method} ${entry.path} \u2192 ${entry.statusCode} (from trace)`,
1041
- type: "success",
1042
- request: Object.keys(traceRequest).length > 0 ? traceRequest : {},
1043
- response: {
1044
- statusCode: entry.statusCode,
1045
- description: `Observed in trace (${traceResult.format})`,
1046
- ...(entry.responseBody
1047
- ? { body: entry.responseBody }
1048
- : {}),
1049
- },
1050
- });
1051
- }
1052
- }
1053
- }
1054
- }
1055
- }
1056
- // ── Step 9: Draft scenarios ──
1057
- // Only new endpoints are passed as "diff-direct" — draftDiffDirectScenarios
1058
- // generates success-oriented scenarios (200/201/204) which are wrong for
1059
- // removed endpoints. Removal coverage (verify-404) is handled by the LLM
1060
- // from the diffContext.removedEndpoints signal in the recommendation prompt.
1061
- // Classified endpoints have full paths and concrete methods (no MULTI sentinels).
1062
- const newEndpointsForDrafting = classifiedEndpoints?.newEndpoints.flatMap((ep) => ep.methods.map((m) => ({
1063
- method: m,
1064
- path: ep.path,
1065
- sourceFile: ep.sourceFile,
1066
- }))) ?? [];
1067
- const changedEndpointsForSecurityExpansion = classifiedEndpoints?.changedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
1068
- method: m,
1069
- path: ep.path,
1070
- sourceFile: ep.sourceFile,
1071
- }))) ?? [];
1072
- const securityRelevantDiff = Boolean(diffData?.diffContent && isSecurityRelevantDiff(diffData.diffContent));
1073
- // Full-repo mode: no diff context, so seed scenario drafting from the entire
1074
- // skeletonEndpoints catalog. We gate on analysisScope (not just array length)
1075
- // to avoid drafting catalog-wide scenarios for PR-mode diffs that happened to
1076
- // add zero new endpoints (only changed or removed existing ones).
1077
- const fullRepoMode = analysisScope !== AnalysisScope.CurrentBranchDiff;
1078
- const scenarioDraftSeed = newEndpointsForDrafting.length > 0
1079
- ? newEndpointsForDrafting
1080
- : fullRepoMode
1081
- ? skeletonEndpoints.flatMap(ep => ep.methods.map(m => ({
1082
- method: typeof m === "string" ? m : m.method,
1083
- path: ep.path,
1084
- sourceFile: m.sourceFile ?? "",
1085
- })))
1086
- : [];
1087
- const removedEndpointsForDrafting = classifiedEndpoints?.removedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
1088
- method: m,
1089
- path: ep.path,
1090
- sourceFile: ep.sourceFile,
1091
- }))) ?? [];
1092
- const codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints, scenarioDraftSeed, wsAuthType, {
1093
- changedEndpoints: changedEndpointsForSecurityExpansion,
1094
- securityRelevantDiff,
1095
- }, removedEndpointsForDrafting);
1096
- // Response-shape scenarios for importer-hop-mapped endpoints (SKYR-3855):
1097
- // deterministic coverage for backend changes (DTOs/services) that touch no
1098
- // route directly, so the ranked plan mentions the actual change instead of
1099
- // falling back to generic structural tests on unrelated resources.
1100
- // See `buildHopResponseShapeInputs` for why this is keyed off `byFile`
1101
- // rather than `hopResult.endpoints`.
1102
- const hopResponseShapeInputs = buildHopResponseShapeInputs(hopResult, scannedEndpoints, diffData?.diffContent);
1103
- const responseShapeScenarios = hopResponseShapeInputs.endpoints.length > 0
1104
- ? draftResponseShapeScenarios(hopResponseShapeInputs.endpoints, hopResult.byFile, hopResponseShapeInputs.addedFieldsByFile)
1105
- : [];
1106
- // `codeInferredScenarios` is already capped internally by
1107
- // draftScenariosFromEndpoints, but `responseShapeScenarios` is appended
1108
- // afterward — re-cap the combined list so the global MAX_TOTAL_SCENARIOS
1109
- // bound (prompt size/cost, ranking input) still holds.
1110
- let allDraftedScenarios = capScenarios([...codeInferredScenarios, ...responseShapeScenarios]);
1111
- if (traceResult && traceResult.userFlows.length > 0) {
1112
- const traceScenarios = traceResult.userFlows
1113
- .slice(0, 5)
1114
- .map((flow, idx) => ({
1115
- scenarioName: `trace-flow-${idx + 1}`,
1116
- description: `User flow from trace: ${flow.entries.map((e) => `${e.method} ${e.path}`).join(" \u2192 ")}`,
1117
- category: "workflow",
1118
- priority: "high",
1119
- steps: flow.entries.map((e, stepIdx) => ({
1120
- order: stepIdx + 1,
1121
- method: e.method,
1122
- path: e.path,
1123
- description: `${e.method} ${e.path} \u2192 ${e.statusCode}`,
1124
- interactionType: e.statusCode < 400
1125
- ? "success"
1126
- : "error",
1127
- requestBody: e.requestBody,
1128
- responseBody: e.responseBody,
1129
- expectedStatusCode: e.statusCode,
1130
- })),
1131
- chainingKeys: [],
1132
- requiresAuth: true,
1133
- estimatedComplexity: flow.entries.length > 3
1134
- ? "complex"
1135
- : "moderate",
1136
- source: ScenarioSource.Trace,
1137
- }));
1138
- allDraftedScenarios = capScenarios([...traceScenarios, ...codeInferredScenarios, ...responseShapeScenarios]);
1139
- }
377
+ // Nothing is drafted here. The server proposed scenarios from the
378
+ // catalog and from trace flows; the agent drafts every candidate and
379
+ // registers it through the plan tool.
1140
380
  await sendProgress(80, 100, "Building unified state...");
1141
381
  // ── Step 10: Build full RepositoryAnalysis for ranked recommendations ──
1142
382
  const sessionId = crypto.randomUUID();
@@ -1201,91 +441,23 @@ export function registerAnalyzeChangesTool(server) {
1201
441
  // Relativize against repositoryPath to avoid leaking absolute machine paths
1202
442
  // into the prompt, state file, and PR reports.
1203
443
  const relevantExternalTestPaths = discoveredRelevantExternalPaths.map(p => path.relative(params.repositoryPath, p));
1204
- // Build the full RepositoryAnalysis object — same structure as analyzeRepositoryTool
1205
- // so buildRecommendationPrompt can reason over enriched endpoint + scenario data
1206
- // Build diffContext from classifiedEndpoints — ScannedEndpoint already
1207
- // has full paths and concrete methods, so no grouping/MULTI handling needed.
1208
- const diffContext = classifiedEndpoints ? {
1209
- currentBranch: classifiedEndpoints.currentBranch,
1210
- baseBranch: classifiedEndpoints.baseBranch,
1211
- changedFiles: classifiedEndpoints.changedFiles,
1212
- newEndpoints: classifiedEndpoints.newEndpoints.map((ep) => ({
1213
- path: ep.path,
1214
- methods: ep.methods.map((m) => ({
1215
- method: m,
1216
- sourceFile: ep.sourceFile,
1217
- interactionCount: 0,
1218
- })),
1219
- })),
1220
- modifiedEndpoints: classifiedEndpoints.changedEndpoints.map((ep) => ({
1221
- path: ep.path,
1222
- methods: ep.methods.map((m) => ({
1223
- method: m,
1224
- sourceFile: ep.sourceFile,
1225
- changeType: "modified",
1226
- })),
1227
- })),
1228
- removedEndpoints: classifiedEndpoints.removedEndpoints.map((ep) => ({
1229
- path: ep.path,
1230
- methods: ep.methods.map((m) => ({
1231
- method: m,
1232
- sourceFile: ep.sourceFile,
1233
- changeType: "removed",
1234
- })),
1235
- })),
1236
- affectedServices: classifiedEndpoints.affectedServices,
1237
- summary: "",
444
+ // Build the full RepositoryAnalysis object — same structure as
445
+ // analyzeRepositoryTool. It is stored in the in-process session store for
446
+ // the MCP resources and registerRecommendTestsPrompt; the tool result
447
+ // does not carry it.
448
+ // The branch diff itself — which branch, against which base, which files.
449
+ // No endpoint lists: the scan that produced them ran a route parser over
450
+ // the diff, and the agent reads the diff directly instead.
451
+ // No `summary`: the server used to write a sentence about the diff here
452
+ // and it has written an empty string since. Nothing reads the field.
453
+ const diffContext = diffData ? {
454
+ currentBranch: diffData.currentBranch,
455
+ baseBranch: diffData.baseBranch,
456
+ changedFiles: diffData.changedFiles,
1238
457
  } : undefined;
1239
- // ── Route discovery context for LLM grounding and state persistence ──
1240
- // fullAnalysis lives only in inMemorySessionStore (for MCP resources
1241
- // and registerRecommendTestsPrompt). The disk state carries only the
1242
- // slim fields that downstream tools (health, execute, actions) need.
1243
- // routerMountContext and candidateRouteFiles are computed here so they
1244
- // can be persisted to the state file for downstream tools (health, drift).
1245
- // Without them, analyzeTestHealth would work only off the static catalog
1246
- // which has wrong paths for nested resources and unsupported frameworks.
1247
- const routeLikeUnmatchedFiles = [];
1248
- for (const file of classifiedEndpoints?.unmatchedFiles ?? []) {
1249
- const routeLike = SOURCE_EXTS.test(file) &&
1250
- (ROUTE_FILE_PATTERN.test(file) || ROUTE_FILE_BASENAME_PATTERN.test(path.basename(file)));
1251
- if (routeLike && !(await isGraphQLFile(file, params.repositoryPath))) {
1252
- routeLikeUnmatchedFiles.push(file);
1253
- }
1254
- }
1255
- const shouldIncludeCandidateRouteFiles = analysisScope !== AnalysisScope.CurrentBranchDiff ||
1256
- rawRelatedEndpointCount === 0 ||
1257
- scannedEndpoints.length === 0 ||
1258
- routeLikeUnmatchedFiles.length > 0;
1259
- let candidateRouteFiles;
1260
- if (shouldIncludeCandidateRouteFiles) {
1261
- candidateRouteFiles = [];
1262
- for (const file of findCandidateRouteFiles(params.repositoryPath)) {
1263
- if (!(await isGraphQLFile(file, params.repositoryPath))) {
1264
- candidateRouteFiles.push(file);
1265
- }
1266
- }
1267
- }
1268
- // Write the full diff to a temp file before building state so the path
1269
- // can be persisted and read by analyzeTestHealthTool for per-line detection.
1270
- let diffFilePath;
1271
- if (diffData?.diffContent) {
1272
- diffFilePath = path.join(os.tmpdir(), `skyramp-diff-${sessionId}.diff`);
1273
- await fs.promises.writeFile(diffFilePath, diffData.diffContent, { encoding: "utf-8", mode: 0o600 });
1274
- }
1275
- const routeDiscovery = {
1276
- candidateFiles: [
1277
- ...(diffData?.changedFiles ?? []),
1278
- ...(candidateRouteFiles ?? []),
1279
- ].filter((file, index, files) => files.indexOf(file) === index),
1280
- staticHints: scannedEndpoints.map((ep) => ({
1281
- path: ep.path,
1282
- methods: ep.methods,
1283
- sourceFile: ep.sourceFile,
1284
- })),
1285
- openApiPaths: specPaths ? [...specPaths] : [],
1286
- routerMountContext,
1287
- ...(diffFilePath ? { diffFilePath } : {}),
1288
- };
458
+ // No `routeDiscovery`. Its three parts — a candidate route-file list, a
459
+ // router entry-point grep and the OpenAPI path list — were the server's
460
+ // guess at the repository's surface. Nothing downstream read them.
1289
461
  const fullAnalysis = {
1290
462
  metadata: {
1291
463
  repositoryName: path.basename(params.repositoryPath),
@@ -1310,7 +482,6 @@ export function registerAnalyzeChangesTool(server) {
1310
482
  userFlows: [],
1311
483
  dataFlows: [],
1312
484
  integrationPatterns: [],
1313
- draftedScenarios: allDraftedScenarios,
1314
485
  },
1315
486
  artifacts: {
1316
487
  openApiSpecs: wsSchemaPath ? [{ path: wsSchemaPath, version: "from-workspace-config", endpointCount: 0, baseUrl: wsBaseUrl, authType: wsAuthMethod }] : [],
@@ -1318,11 +489,7 @@ export function registerAnalyzeChangesTool(server) {
1318
489
  traceFiles: traceResult ? [buildTraceFileEntry(traceFiles[0], traceResult)] : [],
1319
490
  notFound: [],
1320
491
  },
1321
- apiEndpoints: {
1322
- totalCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
1323
- baseUrl: wsBaseUrl,
1324
- endpoints: skeletonEndpoints,
1325
- },
492
+ workspace: { baseUrl: wsBaseUrl },
1326
493
  authentication: {
1327
494
  method: wsAuthMethod,
1328
495
  configLocation: wsAuthHeader ? ".skyramp/workspace.yml" : "",
@@ -1342,7 +509,6 @@ export function registerAnalyzeChangesTool(server) {
1342
509
  hasCoverageReports: false,
1343
510
  relevantExternalTestPaths,
1344
511
  },
1345
- routeDiscovery,
1346
512
  ...(diffContext ? { branchDiffContext: diffContext } : {}),
1347
513
  };
1348
514
  // Store RecommendationState in memory so it's compatible with skyramp_recommend_tests if needed
@@ -1352,175 +518,73 @@ export function registerAnalyzeChangesTool(server) {
1352
518
  analysis: fullAnalysis,
1353
519
  };
1354
520
  storeSessionData(sessionId, recommendationState);
1355
- // SKYR-3879 Path B: size-capped raw diff text, persisted (not just the
1356
- // file path above) so skyramp_register_test_plan can verify a
1357
- // discriminator's changedCodeAnchor occurs verbatim in the diff without
1358
- // an extra file read of its own.
1359
- const DIFF_TEXT_CAP = 200_000;
1360
- const diffText = diffData?.diffContent ? diffData.diffContent.slice(0, DIFF_TEXT_CAP) : undefined;
1361
- // Compute UI context from the diff's changed files using the shared
1362
- // `isFrontendFile` classifier. Persisting this in the stateFile lets
1363
- // skyramp_analyze_test_health and the recommendation prompt consume the
1364
- // same classification without re-deriving it. Absent on backend-only PRs.
1365
- //
1366
- // candidateUiPages is enumerated programmatically via the strategy
1367
- // ladder in uiPageEnumerator (framework route grep + import graph,
1368
- // source-grounded routes, root fallback). The agent uses these to capture
1369
- // browser_blueprints — see uiInstructions below, which this tool returns
1370
- // so the agent captures element vocabulary for UI rec reasoning.
521
+ // UI context. Nothing here classifies a file as frontend any more, so it
522
+ // is built for every run that has a diff rather than only for one the
523
+ // server judged to have touched the frontend.
1371
524
  const uiContext = await (async () => {
1372
- // changedFrontendFiles computed above (before discoverTests) — reuse here.
1373
- if (changedFrontendFiles.length === 0)
525
+ if (!diffData)
1374
526
  return undefined;
1375
- // SKYR-3855: deterministic production-importer check, computed here so
1376
- // downstream consumers (testbot prompt) can skip UI generation for
1377
- // unintegrated components on a server fact instead of a mid-run grep.
1378
- // Computed first: the enumerator resolves changed non-route files to
1379
- // the pages that import them.
1380
- const frontendFileIntegration = checkFrontendFileIntegration(params.repositoryPath, changedFrontendFiles);
1381
- const candidateUiPages = await enumerateCandidateUiPages(params.repositoryPath, changedFrontendFiles, frontendFileIntegration);
1382
- return {
1383
- changedFrontendFiles,
1384
- candidateUiPages,
1385
- frontendFileIntegration,
1386
- };
527
+ // SKYR-4305: identifying `data-*` attributes the diff removed from a
528
+ // file that survives it — grounding for the removal test the removal
529
+ // check requires, since a removed element appears in no blueprint.
530
+ // Purely additive: a failure here must never cost the analyze call.
531
+ let removedElements = [];
532
+ if (diffData?.diffContent) {
533
+ try {
534
+ removedElements = collectRemovedUiElements({
535
+ // Every changed file that is not a test. A removed `data-testid`
536
+ // is found by reading the diff, not by the file's extension.
537
+ changedFrontendFiles: (diffData.changedFiles ?? []).filter((f) => !isTestFile(f)),
538
+ deletedFiles: diffData.deletedFiles ?? [],
539
+ diffByFile: getDiffByFile(),
540
+ existingTestFiles: [
541
+ ...existingTests.map((t) => t.testFile),
542
+ ...discoveredRelevantExternalPaths,
543
+ ],
544
+ discoveryFailed: testDiscoveryFailed,
545
+ });
546
+ }
547
+ catch (err) {
548
+ logger.warning("removedUiElements: collection failed, continuing without removal-guard grounding", {
549
+ error: err instanceof Error ? err.message : String(err),
550
+ });
551
+ removedElements = [];
552
+ }
553
+ }
554
+ if (removedElements.length > 0) {
555
+ logger.info("Removed UI elements detected (removal-guard grounding)", {
556
+ count: removedElements.length,
557
+ removed: removedElements.map((e) => `${e.attribute}="${e.value}"`),
558
+ });
559
+ }
560
+ return removedElements.length > 0 ? { removedElements } : {};
1387
561
  })();
1388
- // Derive the UI blueprint-capture instructions the agent acts on after
1389
- // this call. Mirrors the branching of the former skyramp_ui_analyze_changes
1390
- // pre-flight tool, now folded in here so the agent makes one analysis call
1391
- // instead of two. The captures stay in the agent's tool-result history and
1392
- // serve as element vocabulary when it writes UI recommendation reasoning.
1393
- //
1394
- // SKYR-3855: when the integration check finds no production importer for
1395
- // some/all changed frontend files, that overrides/augments the normal
1396
- // capture flow — see buildUnintegratedInstructions.
1397
- const unintegratedFrontendFiles = (uiContext?.frontendFileIntegration ?? []).filter((r) => !r.integrated);
1398
- const allFrontendFilesUnintegrated = !!uiContext &&
1399
- uiContext.changedFrontendFiles.length > 0 &&
1400
- unintegratedFrontendFiles.length === uiContext.changedFrontendFiles.length;
1401
- const uiInstructions = !uiContext || uiContext.changedFrontendFiles.length === 0
1402
- ? NO_UI_INSTRUCTIONS
1403
- : allFrontendFilesUnintegrated
1404
- ? buildUnintegratedInstructions(unintegratedFrontendFiles)
1405
- : (uiContext.candidateUiPages.length === 0
1406
- ? NO_RESOLVABLE_URLS_INSTRUCTIONS
1407
- : buildCaptureInstructions(uiContext.candidateUiPages)) +
1408
- (unintegratedFrontendFiles.length > 0
1409
- ? `\n\n${buildUnintegratedInstructions(unintegratedFrontendFiles)}`
1410
- : "");
1411
- // ── SKYR-3879 Path B: budget context + pre-seeded approved plan ──
1412
- // Computed BEFORE the state write (not after, alongside the
1413
- // recommendation prompt) so both this pre-seed and buildRecommendationPrompt
1414
- // derive their GENERATE/ADDITIONAL split from the exact same numbers —
1415
- // computeScoredCandidates is the single source of truth for both.
1416
- const topN = params.topN ?? MAX_RECOMMENDATIONS;
1417
- const scoredResult = computeScoredCandidates(fullAnalysis, analysisScope, topN, params.maxGenerate);
1418
- // Resolve each scenario's subject endpoints once, here — this is the only
1419
- // point that has the scenarios, the diff, and runs before BOTH consumers
1420
- // (selectPlan below, and the two prompt renderers). `scored.scenario` is
1421
- // the SAME object as the entry in `allDraftedScenarios` (assigned by
1422
- // reference into businessContext.draftedScenarios, then into
1423
- // repositoryAnalysis.scenarios below), so this mutation also fills what
1424
- // gets persisted. Filling later would leave one consumer keying off a
1425
- // different subject (SKYR-4214).
1426
- const changedRoutesForSubjects = collectChangedRouteLines(diffText ?? "");
1427
- for (const scored of scoredResult.scored) {
1428
- scored.scenario.subjectEndpoints = resolveSubjectEndpoints(scored.scenario, {
1429
- changedRoutes: changedRoutesForSubjects,
1430
- });
1431
- }
1432
- const externalCoverage = buildExternalCoverageSet(testLocationsByType);
1433
- const diffChangesTestFiles = computeDiffChangesTestFiles(diffData, await getUserChangedFiles(params.repositoryPath));
1434
- const planBudgetContext = {
1435
- maxGenerate: scoredResult.maxGen,
1436
- maxTotal: topN,
1437
- isUIOnlyPR: scoredResult.isUIOnlyPR,
1438
- hasFrontendChanges: scoredResult.hasFrontendChanges,
1439
- externalCoverageKeys: [...externalCoverage],
1440
- diffChangesTestFiles,
1441
- };
1442
- let approvedPlan;
1443
- if (scoredResult.scored.length > 0) {
1444
- const budgetCtx = {
1445
- maxGenerate: planBudgetContext.maxGenerate,
1446
- maxTotal: planBudgetContext.maxTotal,
1447
- isUIOnlyPR: planBudgetContext.isUIOnlyPR,
1448
- hasFrontendChanges: planBudgetContext.hasFrontendChanges,
1449
- externalCoverage,
1450
- diffChangesTestFiles,
1451
- };
1452
- const serverCandidates = scoredResult.scored.map(({ scenario, priority, novelty }) => ({
1453
- scenario,
1454
- priority,
1455
- novelty,
1456
- source: CandidateSource.SERVER,
1457
- candidateId: computeCandidateId(scenario),
1458
- }));
1459
- const plan = selectPlan(serverCandidates, { ...budgetCtx, diffText });
1460
- approvedPlan = {
1461
- planId: crypto.randomUUID(),
1462
- createdAt: new Date().toISOString(),
1463
- generate: plan.generate.map(buildApprovedPlanItem),
1464
- additional: plan.additional.map(buildApprovedPlanItem),
1465
- demotions: [],
1466
- dropped: plan.dropped,
1467
- };
1468
- }
562
+ // The server proposes no tests. It returns the analysis; the agent
563
+ // drafts its own plan from it and registers that through
564
+ // skyramp_register_test_plan.
1469
565
  const unifiedState = {
1470
566
  existingTests,
1471
- newEndpoints: newEndpointsForDrafting,
1472
567
  analysisScope,
1473
568
  ...(uiContext ? { uiContext } : {}),
1474
- ...(diffText ? { diffText } : {}),
1475
- planBudgetContext,
1476
- ...(approvedPlan ? { approvedPlan } : {}),
569
+ // Only what another tool or prompt reads back. The workspace base URL,
570
+ // the spec path, the auth method, the project metadata, the relevant
571
+ // external test paths and the changed-file names were all written here
572
+ // for v1 stages that no longer exist; the tool result carries the ones
573
+ // the agent still needs.
1477
574
  repositoryAnalysis: {
1478
- skeletonEndpoints,
1479
- projectMeta,
1480
- wsBaseUrl,
575
+ // The IDE recommendation prompt reads these four.
1481
576
  wsAuthHeader,
1482
577
  wsAuthType,
1483
578
  wsAuthScheme,
1484
- wsSchemaPath,
1485
- wsAuthMethod,
1486
- specFetchSucceeded,
1487
- scenarios: allDraftedScenarios,
1488
- diffFilePath,
1489
579
  testLocations: testLocationsByType,
1490
- diff: classifiedEndpoints
1491
- ? {
1492
- currentBranch: classifiedEndpoints.currentBranch,
1493
- baseBranch: classifiedEndpoints.baseBranch,
1494
- changedFiles: classifiedEndpoints.changedFiles,
1495
- newEndpoints: classifiedEndpoints.newEndpoints.map((ep) => ({
1496
- path: ep.path,
1497
- methods: ep.methods.map((m) => ({
1498
- method: m,
1499
- sourceFile: ep.sourceFile,
1500
- })),
1501
- })),
1502
- modifiedEndpoints: classifiedEndpoints.changedEndpoints.map((ep) => ({
1503
- path: ep.path,
1504
- methods: ep.methods.map((m) => ({
1505
- method: m,
1506
- sourceFile: ep.sourceFile,
1507
- })),
1508
- })),
1509
- removedEndpoints: classifiedEndpoints.removedEndpoints.map((ep) => ({
1510
- path: ep.path,
1511
- methods: ep.methods.map((m) => ({
1512
- method: m,
1513
- sourceFile: ep.sourceFile,
1514
- })),
1515
- })),
1516
- affectedServices: classifiedEndpoints.affectedServices,
1517
- }
580
+ // NAMES only, never content: the state file's readers ask which files
581
+ // the change touched, and the agent reads the diff itself.
582
+ // skyramp_register_test_plan still recomputes the listing at verify time
583
+ // from `baseBranch`, so the two cannot drift apart.
584
+ diff: diffData
585
+ ? { baseBranch: diffData.baseBranch, changedFiles: changedNames.map((file) => file.path) }
1518
586
  : undefined,
1519
587
  sessionId,
1520
- routerMountContext,
1521
- candidateRouteFiles,
1522
- routeDiscovery,
1523
- relevantExternalTestPaths,
1524
588
  },
1525
589
  };
1526
590
  // Clean up old state files (>24 hours) before creating new one.
@@ -1547,6 +611,7 @@ export function registerAnalyzeChangesTool(server) {
1547
611
  // same run-scoped stateFile.
1548
612
  await stateManager.writeRepoData(unifiedState, {
1549
613
  repo: params.repository,
614
+ primaryRepo: params.primaryRepository,
1550
615
  repositoryPath: params.repositoryPath,
1551
616
  step: "analyze_changes",
1552
617
  });
@@ -1561,121 +626,49 @@ export function registerAnalyzeChangesTool(server) {
1561
626
  catch {
1562
627
  // Client may not support resource list notifications
1563
628
  }
1564
- await sendProgress(90, 100, "Generating ranked recommendations...");
1565
- // ── Step 12: Generate ranked recommendations inline ──
1566
- // topN computed earlier (SKYR-3879 Path B pre-seed, before the state write).
1567
- // ── Step 13: Fetch PR comment history for deduplication ──
1568
- let prContext;
1569
- if (params.prNumber) {
1570
- try {
1571
- // Derive repo owner/name from git remote
1572
- const { execFileSync } = await import("child_process");
1573
- const remoteUrl = execFileSync("git", ["-C", params.repositoryPath, "remote", "get-url", "origin"], { encoding: "utf-8", timeout: 5_000 }).trim();
1574
- // Parse owner/repo from https or ssh remote URLs
1575
- const match = remoteUrl.match(/[:/]([^/]+)\/([^/.]+?)(\.git)?$/);
1576
- if (match) {
1577
- prContext = await parsePRComments(match[1], match[2], params.prNumber);
1578
- logger.info("Fetched PR comment history", {
1579
- prNumber: params.prNumber,
1580
- previousRecommendations: prContext.previousRecommendations.length,
1581
- implementedFiles: prContext.implementedTestFiles.length,
1582
- });
1583
- }
1584
- }
1585
- catch (err) {
1586
- logger.warning("Failed to fetch PR comment history — continuing without it", {
1587
- error: err instanceof Error ? err.message : String(err),
1588
- });
1589
- }
1590
- // Fallback: if comment-based implementedTestFiles is empty (names didn't
1591
- // match disk, e.g. after a force push), recover from the actual bot commit.
1592
- if (prContext && prContext.implementedTestFiles.length === 0 && analysisScope === AnalysisScope.CurrentBranchDiff) {
1593
- const botFiles = await getBotCommittedFiles(params.repositoryPath);
1594
- if (botFiles.length > 0) {
1595
- logger.info("Recovered implementedTestFiles from bot commit (comment names did not match disk)", {
1596
- files: botFiles,
1597
- });
1598
- prContext.implementedTestFiles = botFiles;
1599
- }
1600
- }
1601
- }
1602
- if (uiContext && uiContext.changedFrontendFiles.length > 0) {
1603
- logger.info("Frontend changes detected — UI rec grounding relies on the agent's own browser_blueprint history", { candidateUiPages: uiContext.candidateUiPages.map((p) => p.url) });
1604
- }
1605
- const recommendationPrompt = buildRecommendationPrompt(fullAnalysis, analysisScope, topN, prContext, wsAuthHeader, wsAuthType, wsAuthScheme, params.maxGenerate, sessionId);
629
+ // No pull-request comment history. `prNumber` is still accepted, and
630
+ // nothing reads it: skipping a test because an earlier comment mentioned
631
+ // it dropped tests the current diff still needed.
1606
632
  await sendProgress(100, 100, "Analysis complete.");
1607
- const stateSize = await stateManager.getSizeFormatted();
1608
- const structuredSummary = JSON.stringify({
1609
- sessionId,
1610
- stateFile,
1611
- summary: {
1612
- repositoryName: path.basename(params.repositoryPath),
1613
- projectType: projectMeta.projectType,
1614
- primaryFramework: projectMeta.primaryFramework,
1615
- existingTestCount: existingTests.length,
1616
- newEndpointCount: classifiedEndpoints?.newEndpoints.length ?? 0,
1617
- modifiedEndpointCount: classifiedEndpoints?.changedEndpoints.length ?? 0,
1618
- removedEndpointCount: classifiedEndpoints?.removedEndpoints.length ?? 0,
1619
- endpointCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
1620
- newEndpoints: (classifiedEndpoints?.newEndpoints ?? []).flatMap((ep) => ep.methods.map((m) => `${m} ${ep.path}`)),
1621
- modifiedEndpoints: (classifiedEndpoints?.changedEndpoints ?? []).flatMap((ep) => ep.methods.map((m) => `${m} ${ep.path}`)),
1622
- },
1623
- // Surface uiContext inline so the testbot prompt can iterate
1624
- // candidateUiPages and inspect changedFrontendFiles without
1625
- // re-reading the stateFile. Absent on backend-only PRs.
1626
- ...(uiContext ? { uiContext } : {}),
1627
- stateFileSize: stateSize,
1628
- nextStep: "Call skyramp_analyze_test_health with stateFile to run drift analysis and health scoring",
1629
- }, null, 2);
1630
- // Build a DiffSummary for buildAnalysisOutputText from classified endpoints.
1631
- const parsedDiffShim = classifiedEndpoints
1632
- ? {
1633
- currentBranch: classifiedEndpoints.currentBranch,
1634
- baseBranch: classifiedEndpoints.baseBranch,
1635
- changedFiles: classifiedEndpoints.changedFiles,
1636
- diffStat: diffData?.diffStat ?? "",
1637
- newEndpoints: classifiedEndpoints.newEndpoints.flatMap((ep) => ep.methods.map((m) => ({
1638
- method: m,
1639
- path: ep.path,
1640
- sourceFile: ep.sourceFile,
1641
- }))),
1642
- modifiedEndpoints: classifiedEndpoints.changedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
1643
- method: m,
1644
- path: ep.path,
1645
- sourceFile: ep.sourceFile,
1646
- }))),
1647
- removedEndpoints: classifiedEndpoints.removedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
1648
- method: m,
1649
- path: ep.path,
1650
- sourceFile: ep.sourceFile,
1651
- }))),
1652
- affectedServices: classifiedEndpoints.affectedServices,
1653
- }
1654
- : undefined;
1655
- const outputText = buildAnalysisOutputText({
633
+ const relatedRepositories = await stateManager.listRelatedRepos();
634
+ // Existing test files, split by who owns them and relativized so no
635
+ // absolute machine path reaches the agent.
636
+ const skyrampTestFiles = [];
637
+ const externalTestFiles = [];
638
+ for (const test of existingTests) {
639
+ const target = test.source === TestSource.External ? externalTestFiles : skyrampTestFiles;
640
+ target.push(displayPath(test.testFile));
641
+ }
642
+ return buildAnalyzeChangesResult({
1656
643
  sessionId,
1657
644
  stateFile,
1658
645
  repositoryPath: params.repositoryPath,
646
+ repositoryName: path.basename(params.repositoryPath),
647
+ ...(params.repository ? { repository: params.repository } : {}),
648
+ ...(relatedRepositories.length ? { relatedRepositories } : {}),
1659
649
  analysisScope,
1660
- parsedDiff: parsedDiffShim,
1661
- diffFilePath,
1662
- candidateRouteFiles,
1663
- scannedEndpoints,
1664
- wsBaseUrl,
1665
- wsAuthHeader: wsAuthHeader ?? "",
1666
- wsAuthType: wsAuthType ?? "",
1667
- wsSchemaPath,
1668
- specFetchSucceeded,
1669
- routerMountContext,
1670
- routerFileContents,
1671
- unmatchedFiles: classifiedEndpoints?.unmatchedFiles,
1672
- nextTool: "skyramp_analyze_test_health",
1673
- });
1674
- return buildAnalyzeChangesResult({
1675
- structuredSummary,
1676
- uiInstructions,
1677
- outputText,
1678
- recommendationPrompt,
650
+ planOnly: isPlanOnlyMode(),
651
+ ...(diffData
652
+ ? {
653
+ currentBranch: diffData.currentBranch,
654
+ baseBranch: diffData.baseBranch,
655
+ }
656
+ : {}),
657
+ changedFiles: changedNames,
658
+ existingTestFiles: {
659
+ skyramp: skyrampTestFiles,
660
+ external: externalTestFiles,
661
+ relevantExternal: relevantExternalTestPaths,
662
+ },
663
+ workspace: {
664
+ baseUrl: wsBaseUrl,
665
+ authMethod: wsAuthMethod,
666
+ ...(wsAuthHeader ? { authHeader: wsAuthHeader } : {}),
667
+ ...(wsSchemaPath ? { openApiSpecPath: wsSchemaPath } : {}),
668
+ openApiSpecLoaded: specFetchSucceeded,
669
+ },
670
+ ...(uiContext?.removedElements?.length ? { uiContext } : {}),
671
+ nextStep: "skyramp_analyze_test_health, with this stateFile",
1679
672
  });
1680
673
  }
1681
674
  catch (error) {