@skyramp/mcp 0.3.0-rc.1 → 0.3.0

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 (136) hide show
  1. package/build/prompts/test-maintenance/actionsInstructions.js +4 -2
  2. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +9 -0
  3. package/build/prompts/test-maintenance/drift-analysis-prompt.js +8 -7
  4. package/build/prompts/test-maintenance/driftAnalysisSections.js +4 -5
  5. package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +11 -0
  6. package/build/prompts/test-maintenance/driftAnalysisShared.js +22 -6
  7. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +23 -31
  8. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +7 -1
  9. package/build/prompts/test-recommendation/diffExecutionPlan.js +20 -6
  10. package/build/prompts/test-recommendation/scopeAssessment.d.ts +10 -1
  11. package/build/prompts/test-recommendation/scopeAssessment.js +27 -2
  12. package/build/prompts/test-recommendation/scopeAssessment.test.js +35 -0
  13. package/build/prompts/test-recommendation/test-recommendation-prompt.js +5 -1
  14. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +61 -0
  15. package/build/prompts/testbot/testbot-prompts.js +7 -5
  16. package/build/prompts/testbot/testbot-prompts.test.js +1 -1
  17. package/build/services/AnalyticsService.js +37 -2
  18. package/build/services/TestDiscoveryService.d.ts +38 -4
  19. package/build/services/TestDiscoveryService.js +187 -16
  20. package/build/services/TestDiscoveryService.test.js +232 -10
  21. package/build/services/TestExecutionService.d.ts +12 -1
  22. package/build/services/TestExecutionService.js +20 -41
  23. package/build/services/TestGenerationService.d.ts +8 -0
  24. package/build/services/TestGenerationService.js +62 -17
  25. package/build/services/TestGenerationService.test.js +67 -1
  26. package/build/tools/auth/loginTool.js +1 -1
  27. package/build/tools/auth/logoutTool.js +1 -1
  28. package/build/tools/code-refactor/codeReuseTool.js +1 -1
  29. package/build/tools/code-refactor/modularizationTool.js +1 -1
  30. package/build/tools/executeSkyrampTestTool.js +30 -1
  31. package/build/tools/fixErrorTool.js +1 -1
  32. package/build/tools/one-click/oneClickTool.js +1 -1
  33. package/build/tools/test-management/actionsTool.js +76 -41
  34. package/build/tools/test-management/actionsTool.test.js +104 -38
  35. package/build/tools/test-management/analyzeChangesTool.js +102 -4
  36. package/build/tools/test-management/analyzeChangesTool.test.js +3 -1
  37. package/build/tools/test-management/analyzeTestHealthTool.js +9 -1
  38. package/build/tools/test-management/analyzeTestHealthTool.test.js +1 -1
  39. package/build/tools/trace/startTraceCollectionTool.js +1 -1
  40. package/build/tools/trace/stopTraceCollectionTool.js +1 -1
  41. package/build/tools/workspace/initScanWorkspaceTool.js +1 -1
  42. package/build/tools/workspace/initializeWorkspaceTool.js +1 -1
  43. package/build/tools/workspace/initializeWorkspaceTool.test.js +1 -1
  44. package/build/types/RepositoryAnalysis.d.ts +6 -6
  45. package/build/types/TestAnalysis.d.ts +13 -0
  46. package/build/utils/dartRouteExtractor.js +8 -30
  47. package/build/utils/docker.test.js +1 -1
  48. package/build/utils/fileWalk.d.ts +71 -0
  49. package/build/utils/fileWalk.js +79 -0
  50. package/build/utils/fileWalk.test.d.ts +1 -0
  51. package/build/utils/fileWalk.test.js +252 -0
  52. package/build/utils/frontendIntegration.js +20 -36
  53. package/build/utils/frontendSelectors.d.ts +34 -0
  54. package/build/utils/frontendSelectors.js +124 -0
  55. package/build/utils/frontendSelectors.test.d.ts +1 -0
  56. package/build/utils/frontendSelectors.test.js +118 -0
  57. package/build/utils/importerHop.d.ts +18 -0
  58. package/build/utils/importerHop.js +48 -3
  59. package/build/utils/importerHop.test.js +71 -1
  60. package/build/utils/pythonMountPrefixes.d.ts +25 -0
  61. package/build/utils/pythonMountPrefixes.js +347 -0
  62. package/build/utils/pythonMountPrefixes.test.d.ts +1 -0
  63. package/build/utils/pythonMountPrefixes.test.js +113 -0
  64. package/build/utils/repoScanner.js +30 -23
  65. package/build/utils/repoScanner.test.js +98 -0
  66. package/build/utils/routeParsers.d.ts +7 -1
  67. package/build/utils/routeParsers.js +32 -18
  68. package/build/utils/sourceRouteExtractor.js +17 -40
  69. package/build/utils/trace-parser.js +7 -19
  70. package/build/utils/versions.d.ts +3 -3
  71. package/build/utils/versions.js +1 -1
  72. package/build/utils/walkerCharacterization.test.d.ts +1 -0
  73. package/build/utils/walkerCharacterization.test.js +233 -0
  74. package/node_modules/playwright/lib/common/config.js +1 -1
  75. package/node_modules/playwright/lib/common/configLoader.js +1 -1
  76. package/node_modules/playwright/lib/common/fixtures.js +1 -1
  77. package/node_modules/playwright/lib/common/testType.js +1 -1
  78. package/node_modules/playwright/lib/index.js +2 -2
  79. package/node_modules/playwright/lib/isomorphic/testTree.js +1 -1
  80. package/node_modules/playwright/lib/matchers/expect.js +1 -1
  81. package/node_modules/playwright/lib/mcp/browser/tab.js +1 -1
  82. package/node_modules/playwright/lib/mcp/browser/tools/navigate.js +1 -1
  83. package/node_modules/playwright/lib/mcp/browser/tools/tracing.js +1 -1
  84. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +7 -0
  85. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -1
  86. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +3 -1
  87. package/node_modules/playwright/lib/reporters/base.js +2 -2
  88. package/node_modules/playwright/lib/reporters/list.js +1 -1
  89. package/node_modules/playwright/lib/reporters/teleEmitter.js +1 -1
  90. package/node_modules/playwright/lib/worker/testInfo.js +2 -2
  91. package/node_modules/playwright/node_modules/playwright-core/ThirdPartyNotices.txt +23 -126
  92. package/node_modules/playwright/node_modules/playwright-core/lib/generated/injectedScriptSource.js +1 -1
  93. package/node_modules/playwright/node_modules/playwright-core/lib/generated/pollingRecorderSource.js +1 -1
  94. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -1
  95. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-Cqp3cwEJ.js → codeMirrorModule-aszq5EdG.js} +1 -1
  96. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{defaultSettingsView-CYf9adZh.js → defaultSettingsView-BxS7Jm4s.js} +94 -94
  97. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.d4gkVSou.js → index.D4JTTy4R.js} +1 -1
  98. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  99. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{uiMode.u4_8VnCV.js → uiMode.DaRMQKOI.js} +1 -1
  100. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  101. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  102. package/node_modules/playwright/node_modules/playwright-core/src/generated/injectedScriptSource.ts +1 -1
  103. package/node_modules/playwright/node_modules/playwright-core/src/generated/pollingRecorderSource.ts +1 -1
  104. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  105. package/node_modules/playwright/package.json +1 -1
  106. package/package.json +3 -3
  107. package/node_modules/playwright/._ThirdPartyNotices.txt +0 -0
  108. package/node_modules/playwright/._cli.js +0 -0
  109. package/node_modules/playwright/._index.d.ts +0 -0
  110. package/node_modules/playwright/._index.js +0 -0
  111. package/node_modules/playwright/._index.mjs +0 -0
  112. package/node_modules/playwright/._jsx-runtime.js +0 -0
  113. package/node_modules/playwright/._jsx-runtime.mjs +0 -0
  114. package/node_modules/playwright/._lib +0 -0
  115. package/node_modules/playwright/._node_modules +0 -0
  116. package/node_modules/playwright/._package.json +0 -0
  117. package/node_modules/playwright/._test.d.ts +0 -0
  118. package/node_modules/playwright/._test.js +0 -0
  119. package/node_modules/playwright/._test.mjs +0 -0
  120. package/node_modules/playwright/._types +0 -0
  121. package/node_modules/playwright/._vitest.config.ts +0 -0
  122. package/node_modules/playwright/node_modules/playwright-core/._.DS_Store +0 -0
  123. package/node_modules/playwright/node_modules/playwright-core/._.npmignore +0 -0
  124. package/node_modules/playwright/node_modules/playwright-core/._README.md +0 -0
  125. package/node_modules/playwright/node_modules/playwright-core/._ThirdPartyNotices.txt +0 -0
  126. package/node_modules/playwright/node_modules/playwright-core/._bin +0 -0
  127. package/node_modules/playwright/node_modules/playwright-core/._browsers.json +0 -0
  128. package/node_modules/playwright/node_modules/playwright-core/._bundles +0 -0
  129. package/node_modules/playwright/node_modules/playwright-core/._cli.js +0 -0
  130. package/node_modules/playwright/node_modules/playwright-core/._index.d.ts +0 -0
  131. package/node_modules/playwright/node_modules/playwright-core/._index.js +0 -0
  132. package/node_modules/playwright/node_modules/playwright-core/._index.mjs +0 -0
  133. package/node_modules/playwright/node_modules/playwright-core/._lib +0 -0
  134. package/node_modules/playwright/node_modules/playwright-core/._package.json +0 -0
  135. package/node_modules/playwright/node_modules/playwright-core/._src +0 -0
  136. package/node_modules/playwright/node_modules/playwright-core/._types +0 -0
@@ -50,7 +50,7 @@ Logout from Skyramp platform to end your authenticated session.`,
50
50
  const recordParams = {
51
51
  prompt: params.prompt,
52
52
  };
53
- AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, recordParams);
53
+ AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, recordParams).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
54
54
  }
55
55
  });
56
56
  }
@@ -102,7 +102,7 @@ export function registerCodeReuseTool(server) {
102
102
  testFile: params.testFile,
103
103
  language: params.language,
104
104
  framework: params.framework,
105
- });
105
+ }).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
106
106
  }
107
107
  });
108
108
  }
@@ -120,7 +120,7 @@ After modularization, if errors remain, call skyramp_fix_errors.
120
120
  testFile: params.testFile,
121
121
  language: params.language ?? "",
122
122
  testType: params.testType ?? "",
123
- });
123
+ }).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
124
124
  }
125
125
  });
126
126
  }
@@ -8,6 +8,7 @@ import { TestExecutionStatus } from "../types/TestExecution.js";
8
8
  import { getWorkspaceBaseUrl } from "../utils/workspaceAuth.js";
9
9
  import { ProgrammingLanguage, TestType } from "../types/TestTypes.js";
10
10
  import { StateManager } from "../utils/AnalysisStateManager.js";
11
+ import { TestSource } from "../types/TestAnalysis.js";
11
12
  import { logger } from "../utils/logger.js";
12
13
  import { toolError } from "../utils/utils.js";
13
14
  const TOOL_NAME = "skyramp_execute_test";
@@ -77,6 +78,34 @@ export function registerExecuteSkyrampTestTool(server) {
77
78
  errorResult = toolError(`testFile must be an absolute path, got: ${params.testFile}`);
78
79
  return errorResult;
79
80
  }
81
+ // Deterministic external-test guard (SKYR-3924): this tool runs Skyramp-generated
82
+ // tests in the executor and cannot run a repo's native (user-written) suite, so a
83
+ // run on an external test only errors (e.g. pytest import/collection failure).
84
+ // If the stateFile records this test as external, skip execution — the prompt asks
85
+ // the agent to exclude them, and this enforces it. Best-effort and conservative:
86
+ // only skip on a positive external match; any state-read issue falls through to
87
+ // normal execution, and a not-found test (e.g. a newly generated one) is not skipped.
88
+ if (params.stateFile) {
89
+ try {
90
+ const stateManager = StateManager.fromStatePath(params.stateFile);
91
+ const stateData = await stateManager.readRepoData(params.repository);
92
+ const entry = stateData?.existingTests?.find((t) => t.testFile === params.testFile);
93
+ if (entry?.source === TestSource.External) {
94
+ logger.info(`Skipping execution of external test ${params.testFile} — native suites are not run by ${TOOL_NAME}`);
95
+ return {
96
+ content: [
97
+ {
98
+ type: "text",
99
+ text: `Skipped execution: ${params.testFile} is marked external in the state file. ${TOOL_NAME} runs Skyramp-generated tests only; external (native) suites are not executed here.`,
100
+ },
101
+ ],
102
+ };
103
+ }
104
+ }
105
+ catch (err) {
106
+ logger.warning(`External-test guard could not read stateFile (${err.message}); proceeding with execution`);
107
+ }
108
+ }
80
109
  // Send initial progress
81
110
  await sendProgress(0, 100, "Starting test execution...");
82
111
  // Always resolve workspace config for dockerNetwork (host networking)
@@ -187,7 +216,7 @@ If this failed with a 401 status code, ask the user for the authentication token
187
216
  workspacePath: params.workspacePath,
188
217
  language: params.language,
189
218
  testType: params.testType,
190
- });
219
+ }).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
191
220
  }
192
221
  });
193
222
  }
@@ -64,7 +64,7 @@ export function registerFixErrorTool(server) {
64
64
  AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {
65
65
  prompt: params.prompt,
66
66
  testFile: params.testFile,
67
- });
67
+ }).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
68
68
  }
69
69
  });
70
70
  }
@@ -184,7 +184,7 @@ export function registerOneClickTool(server) {
184
184
  finally {
185
185
  AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {
186
186
  workflow: params.workflow,
187
- });
187
+ }).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
188
188
  }
189
189
  });
190
190
  }
@@ -6,7 +6,8 @@ import { TestType } from "../../types/TestTypes.js";
6
6
  import * as fs from "fs";
7
7
  import * as path from "path";
8
8
  import { AnalyticsService } from "../../services/AnalyticsService.js";
9
- import { toolError, testFileMatches } from "../../utils/utils.js";
9
+ import { toolError } from "../../utils/utils.js";
10
+ import { isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
10
11
  import { buildRenameStrategy, buildFileRenameStrategy, buildUpdateStrategy, buildRegenerateStrategy, buildDeleteStrategy, buildUpdateFileInstruction, buildRegenerateFileInstruction, } from "../../prompts/test-maintenance/actionsInstructions.js";
11
12
  /**
12
13
  * Compute a suggested new filename when an endpoint is renamed.
@@ -58,9 +59,9 @@ const recommendationSchema = z.object({
58
59
  action: z
59
60
  .nativeEnum(DriftAction)
60
61
  .refine((a) => a !== DriftAction.Ignore, {
61
- message: "Use ignoredTestFiles for IGNORE tests; omit IGNORE entries from recommendations.",
62
+ message: "Omit IGNORE entries entirely a discovered test you don't recommend is automatically treated as needing no action.",
62
63
  })
63
- .describe("Drift action assigned by the LLM health assessment. Never IGNORE — omit the recommendation and use ignoredTestFiles instead."),
64
+ .describe("The maintenance action for this test (UPDATE, REGENERATE, DELETE, or VERIFY). Do not send IGNORE — omit no-action tests from `recommendations` entirely; anything you leave out is recorded as needing no action."),
64
65
  testType: z
65
66
  .nativeEnum(TestType)
66
67
  .describe("Type of this test — carried through to the maintenance report."),
@@ -89,6 +90,18 @@ const recommendationSchema = z.object({
89
90
  "auth changes, new request params, removed fields, or other drift-related changes. " +
90
91
  "Example: 'Added stock_count: int (ge=0, default=0) to ProductBase. " +
91
92
  "Test hits GET /products — assert stock_count is present and non-negative.'"),
93
+ editSites: z
94
+ .array(z.object({
95
+ line: z.number().int().min(1).describe("1-based line number of the edit site in the current file."),
96
+ anchor: z
97
+ .string()
98
+ .min(1)
99
+ .describe("A short verbatim snippet from that line (copied exactly from the file you read) so the edit can be located without re-scanning."),
100
+ change: z.string().optional().describe("What to change at this site (e.g. 'add work_pool_name to this payload')."),
101
+ }))
102
+ .optional()
103
+ .describe("For a large UPDATE file, the concrete per-site edits you already located while reading it — one entry per place that must change, with a verbatim anchor. " +
104
+ "Surfaced to the apply step as a checklist so it edits each site directly instead of re-grepping/re-paging the file. Provide this whenever an UPDATE touches more than a couple of sites."),
92
105
  renamedEndpoints: z
93
106
  .array(z.object({
94
107
  oldPath: z.string().describe("Previous endpoint path"),
@@ -109,17 +122,9 @@ const actionsSchema = {
109
122
  .describe("The owner/repo whose analysis section to act on (e.g. 'letsramp/api-insight'). In a multi-repo run, ALWAYS set it — the primary's owner/repo for the primary repo's tests, or a related repo's owner/repo for that repo's section — within the run-scoped stateFile. Omit only for a single-repo run."),
110
123
  recommendations: z
111
124
  .array(recommendationSchema)
112
- .optional()
113
- .describe("Drift recommendations from skyramp_analyze_test_health. Omit IGNORE entries. Omitting this field results in no maintenance actions."),
114
- ignoredTestFiles: z
115
- .array(z.string().refine((p) => path.isAbsolute(p), {
116
- message: "ignoredTestFiles entries must be absolute paths — use the exact path reported by skyramp_analyze_changes or skyramp_analyze_test_health. A bare filename is ambiguous when multiple discovered tests share that basename.",
117
- }))
118
- .optional()
119
125
  .default([])
120
- .describe("Absolute path of every existing test you assessed during drift analysis and decided needs no action use the exact path reported by skyramp_analyze_changes or skyramp_analyze_test_health. " +
121
- "Every test discovered by skyramp_analyze_changes must appear in either `recommendations` or here " +
122
- "otherwise this call fails, naming the test(s) you didn't account for."),
126
+ .describe("Drift recommendations from skyramp_analyze_test_health include only tests that need an action (UPDATE, REGENERATE, DELETE, or VERIFY). " +
127
+ "Any discovered test you don't list here is automatically treated as needing no action, so never add IGNORE entries or enumerate the no-action tests yourself. Defaults to an empty array."),
123
128
  };
124
129
  const TOOL_NAME = "skyramp_actions";
125
130
  export function registerActionsTool(server) {
@@ -130,7 +135,7 @@ export function registerActionsTool(server) {
130
135
  idempotentHint: false,
131
136
  openWorldHint: true,
132
137
  },
133
- description: `Execute test maintenance actions — final step of the unified Test Health Analysis Flow. Call this once per run even when no tests need action — it is the only point where every assessed existing test is reconciled (via \`recommendations\` + \`ignoredTestFiles\`) before the run moves on.`,
138
+ description: `Execute test maintenance actions — final step of the unified Test Health Analysis Flow. Call this once per run even when no tests need action (pass \`recommendations: []\`) — it is where the maintenance verdicts are persisted before the run moves on. Supply only the tests that need an action; any discovered test you omit from \`recommendations\` is treated as needing no action and recorded automatically, so you never enumerate the no-action tests yourself.`,
134
139
  inputSchema: actionsSchema,
135
140
  }, async (args) => {
136
141
  let errorResult;
@@ -195,10 +200,26 @@ export function registerActionsTool(server) {
195
200
  // Catalog membership is the safety boundary — prevents editing non-test files.
196
201
  const isRelevantExternal = (externalTestFiles.has(resolvedFile) || externalTestFiles.has(rec.testFilePath));
197
202
  const isInAnyKnownCatalog = isSkyramp || isRelevantExternal;
198
- if (!isInAnyKnownCatalog) {
199
- logger.warning(`Skipping ${rec.action} for unknown test (not in scanned catalog): ${rec.testFilePath}`);
203
+ // Discovery is relevance-scoped, not exhaustive — it can miss a real test file the
204
+ // agent correctly identifies from the diff (e.g. an e2e Playwright spec/POM under a
205
+ // dir discovery didn't rank relevant). Rejecting those as "hallucinated" silently
206
+ // drops correct maintenance verdicts, leaving report.testMaintenance empty even
207
+ // though the edit was applied (SKYR — D2 bucket B1). Accept an uncataloged
208
+ // recommendation when its testFilePath is a REAL file on disk, inside the repo root,
209
+ // whose name matches a test-file pattern. The path-traversal guard above already
210
+ // bounds it to repoRoot; isTestFile bounds it to test files (never app source), so
211
+ // this preserves the anti-hallucination guarantee (an invented path fails existsSync).
212
+ const isRealUncatalogedTest = !isInAnyKnownCatalog &&
213
+ isTestFile(resolvedFile) &&
214
+ (!repoRoot || resolvedFile.startsWith(repoRoot + path.sep) || resolvedFile === repoRoot) &&
215
+ fs.existsSync(resolvedFile);
216
+ if (!isInAnyKnownCatalog && !isRealUncatalogedTest) {
217
+ logger.warning(`Skipping ${rec.action} for unknown test (not in scanned catalog, not a real test file): ${rec.testFilePath}`);
200
218
  return;
201
219
  }
220
+ if (isRealUncatalogedTest) {
221
+ logger.info(`Accepting uncataloged but real test file discovery missed: ${rec.testFilePath}`);
222
+ }
202
223
  const isActionable = [DriftAction.Update, DriftAction.Regenerate, DriftAction.Delete].includes(rec.action);
203
224
  // No write access to a related repo's checkout — convert any edit-requiring
204
225
  // action to VERIFY so the finding still surfaces without attempting a write that
@@ -217,21 +238,27 @@ export function registerActionsTool(server) {
217
238
  });
218
239
  return;
219
240
  }
220
- if (isActionable && !isSkyramp && !isRelevantExternal) {
241
+ if (isActionable && !isSkyramp && !isRelevantExternal && !isRealUncatalogedTest) {
221
242
  logger.warning(`Skipping ${rec.action} for irrelevant external test: ${rec.testFilePath}`);
222
243
  return;
223
244
  }
224
- // REGENERATE and DELETE on external tests are report-only convert to VERIFY so
225
- // the finding surfaces to the developer without touching the file.
226
- if (isRelevantExternal && !isSkyramp &&
245
+ // A real-but-uncataloged test is treated as external (user-authored) for policy.
246
+ const isExternalTarget = isRelevantExternal || isRealUncatalogedTest;
247
+ // REGENERATE and DELETE on external tests are report-only — the file is never
248
+ // touched, but the verdict keeps its REAL action (not flattened to VERIFY) so the
249
+ // report renders it as a deletion/regeneration RECOMMENDATION the developer applies
250
+ // manually. `reportOnly: true` makes the apply loops below skip it (B2 — a flattened
251
+ // VERIFY read to the D2 judge as "uncertain", hiding a correct DELETE call).
252
+ if (isExternalTarget && !isSkyramp &&
227
253
  (rec.action === DriftAction.Regenerate || rec.action === DriftAction.Delete)) {
228
254
  recommendations.push({
229
255
  testFilePath: resolvedFile,
230
- action: DriftAction.Verify,
256
+ action: rec.action,
257
+ reportOnly: true,
231
258
  testType: rec.testType,
232
259
  endpoint: rec.endpoint,
233
260
  priority: rec.priority ?? RecommendationPriority.Medium,
234
- rationale: `[external test — needs manual review] ${rec.rationale}`,
261
+ rationale: `[external test — report-only ${rec.action}, apply manually] ${rec.rationale}`,
235
262
  estimatedWork: rec.estimatedWork ?? EstimatedWork.Small,
236
263
  updateInstructions: "",
237
264
  renamedEndpoints: [],
@@ -261,32 +288,31 @@ export function registerActionsTool(server) {
261
288
  estimatedWork: rec.estimatedWork ?? EstimatedWork.Small,
262
289
  updateInstructions: rec.updateInstructions ?? "",
263
290
  renamedEndpoints: rec.renamedEndpoints ?? [],
291
+ ...(rec.editSites?.length ? { editSites: rec.editSites } : {}),
264
292
  });
265
293
  });
266
- // Use the validated `recommendations` array (built above), not the raw
267
- // `args.recommendations` a hallucinated/rejected path (not in the catalog, or
268
- // outside the repo root) never made it into `recommendations`, so it must not
269
- // count as "accounted for" here either. Counting the raw input would let a typo'd
270
- // testFilePath mask a real file that never received a valid recommendation.
271
- const accountedForNames = [
272
- ...recommendations.map((r) => r.testFilePath),
273
- ...(args.ignoredTestFiles ?? []),
274
- ];
275
- const unaccountedFor = testAnalysisResults
294
+ // Derive the no-action set here instead of asking the LLM to enumerate it.
295
+ // The caller supplies only the tests that need an action (`recommendations`); every
296
+ // other discovered test is, by definition, no-action. The tool already holds the full
297
+ // catalog (`testAnalysisResults`), so the ignored set is just the complement computed
298
+ // in O(catalog) with zero LLM tokens. The previous design required the LLM to echo back
299
+ // an absolute path for every discovered test and hard-rejected otherwise (SKYR-3906);
300
+ // on a 1,000+ test monorepo the LLM cannot round-trip that list reliably, so it never
301
+ // converged and the run timed out (SKYR-3938). The report is built solely from
302
+ // `maintenanceVerdicts` (the validated recommendations), persisted below at
303
+ // decision-time that persistence is what prevents a decided-upon entry from silently
304
+ // vanishing at report time; the ignored set is a complete, accurate record for free.
305
+ const recommendedPaths = new Set(recommendations.map((r) => r.testFilePath));
306
+ const ignoredTestFiles = testAnalysisResults
276
307
  .map((t) => t.testFile)
277
- .filter((testFile) => !accountedForNames.some((name) => testFileMatches(testFile, name)));
278
- if (unaccountedFor.length > 0) {
279
- errorResult = toolError(`${unaccountedFor.length} existing test(s) assessed by skyramp_analyze_test_health are not accounted for: ${unaccountedFor.join(", ")}. ` +
280
- "Add a recommendation for each one that needs UPDATE/REGENERATE/DELETE/VERIFY, or add its path to ignoredTestFiles if it needs no action.");
281
- return errorResult;
282
- }
308
+ .filter((testFile) => !recommendedPaths.has(testFile));
283
309
  // So skyramp_submit_report can read this back instead of re-asking the LLM.
284
310
  // Must fail loud — edits below apply regardless, so a swallowed failure here
285
311
  // would let edits ship with no testMaintenance in the final report.
286
312
  try {
287
313
  await stateManager.writeRepoData({
288
314
  ...stateData,
289
- ignoredTestFiles: args.ignoredTestFiles ?? [],
315
+ ignoredTestFiles,
290
316
  maintenanceVerdicts: recommendations.map((r) => ({
291
317
  testFilePath: r.testFilePath,
292
318
  action: r.action,
@@ -317,6 +343,10 @@ export function registerActionsTool(server) {
317
343
  const regenerateByFile = new Map();
318
344
  const deleteByFile = new Map();
319
345
  for (const rec of recommendations) {
346
+ // Report-only verdicts (external DELETE/REGENERATE) are recorded for the report
347
+ // but never applied — keep them out of every apply map.
348
+ if (rec.reportOnly)
349
+ continue;
320
350
  if (rec.action === DriftAction.Delete) {
321
351
  const existing = deleteByFile.get(rec.testFilePath);
322
352
  if (!existing || priorityRank[rec.priority] > priorityRank[existing.priority]) {
@@ -494,7 +524,12 @@ export function registerActionsTool(server) {
494
524
  .map((rec) => {
495
525
  const editTarget = rec.pomFile ?? rec.testFilePath;
496
526
  const current_content = testFileContentMap.get(editTarget);
497
- return { file: editTarget, context: rec.updateInstructions, ...(current_content !== undefined && { current_content }) };
527
+ return {
528
+ file: editTarget,
529
+ context: rec.updateInstructions,
530
+ ...(rec.editSites?.length ? { edit_sites: rec.editSites } : {}),
531
+ ...(current_content !== undefined && { current_content }),
532
+ };
498
533
  });
499
534
  if (updateContext.length > 0) {
500
535
  llmInstructionsObj.update_context = updateContext;
@@ -549,7 +584,7 @@ export function registerActionsTool(server) {
549
584
  }
550
585
  AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {
551
586
  repositoryPath,
552
- });
587
+ }).catch((err) => { logger.warning("Analytics event failed", { error: String(err) }); });
553
588
  }
554
589
  });
555
590
  }
@@ -51,7 +51,7 @@ async function writeStateFile(dir, existingTests, repositoryAnalysis) {
51
51
  }), "utf-8");
52
52
  return stateFile;
53
53
  }
54
- describe("registerActionsTool — completeness check (SKYR-3906)", () => {
54
+ describe("registerActionsTool — verdict reconciliation & persistence (SKYR-3906, SKYR-3938)", () => {
55
55
  let handler;
56
56
  let tmpDirs = [];
57
57
  beforeAll(() => {
@@ -85,32 +85,25 @@ describe("registerActionsTool — completeness check (SKYR-3906)", () => {
85
85
  expect(result.content).toBeDefined();
86
86
  expect(result.isError).toBeUndefined();
87
87
  });
88
- it("succeeds when every existing test is covered by ignoredTestFiles", async () => {
88
+ it("derives the ignored set as the catalog complement of recommendations — no LLM enumeration (SKYR-3938)", async () => {
89
+ // The caller supplies only actionable recommendations; every other discovered test is
90
+ // recorded as ignored automatically. The LLM payload is O(recommendations) — independent of
91
+ // catalog size — which is the fix for the large-repo timeout where the LLM had to echo back
92
+ // every path. (Server-side derivation is still O(catalog): filter the catalog by a
93
+ // recommendations Set.)
89
94
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
90
95
  tmpDirs.push(tmpDir);
91
96
  const testFile = path.join(tmpDir, "unrelated_test.py");
92
97
  await fs.writeFile(testFile, "# test");
93
98
  const stateFile = await writeStateFile(tmpDir, [{ testFile, source: TestSource.Skyramp }]);
94
- const result = await handler({
95
- stateFile,
96
- ignoredTestFiles: [testFile],
97
- });
99
+ // No recommendations at all — the sole discovered test is recorded as ignored.
100
+ const result = await handler({ stateFile, recommendations: [] });
98
101
  expect(result.isError).toBeUndefined();
102
+ const written = JSON.parse(await fs.readFile(stateFile, "utf-8"));
103
+ expect(written.ignoredTestFiles).toEqual([testFile]);
104
+ expect(written.maintenanceVerdicts).toEqual([]);
99
105
  });
100
- it("rejects a bare basename in ignoredTestFiles at the schema level — ambiguous across files sharing that name", async () => {
101
- const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
102
- tmpDirs.push(tmpDir);
103
- const testFile = path.join(tmpDir, "unrelated_test.py");
104
- await fs.writeFile(testFile, "# test");
105
- const stateFile = await writeStateFile(tmpDir, [{ testFile, source: TestSource.Skyramp }]);
106
- const result = await handler({
107
- stateFile,
108
- ignoredTestFiles: ["unrelated_test.py"],
109
- });
110
- expect(result.isError).toBe(true);
111
- expect(result.content[0].text).toContain("must be absolute paths");
112
- });
113
- it("rejects a recommendation with action: IGNORE at the schema level — must use ignoredTestFiles", async () => {
106
+ it("rejects a recommendation with action: IGNORE at the schema level — must simply be omitted", async () => {
114
107
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
115
108
  tmpDirs.push(tmpDir);
116
109
  const testFile = path.join(tmpDir, "unrelated_test.py");
@@ -119,12 +112,16 @@ describe("registerActionsTool — completeness check (SKYR-3906)", () => {
119
112
  const result = await handler({
120
113
  stateFile,
121
114
  recommendations: [{ testFilePath: testFile, action: DriftAction.Ignore, rationale: "No change needed", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" }],
122
- // ignoredTestFiles intentionally omitted — IGNORE must not silently persist via recommendations.
115
+ // IGNORE must not silently persist via recommendations — it must simply be omitted.
123
116
  });
124
117
  expect(result.isError).toBe(true);
125
- expect(result.content[0].text).toContain("Use ignoredTestFiles for IGNORE tests");
118
+ expect(result.content[0].text).toContain("Omit IGNORE entries entirely");
126
119
  });
127
- it("fails with a descriptive error naming the file missing from both recommendations and ignoredTestFiles", async () => {
120
+ it("treats an existing test omitted from recommendations as needing no action no completeness gate (SKYR-3938)", async () => {
121
+ // The prior hard-reject forced the LLM to echo back every discovered test, which is
122
+ // infeasible on large repos (1,000+ tests) and caused the run to spin until CI timeout.
123
+ // An omitted test is now silently no-action: the call succeeds, only the covered file
124
+ // lands in the verdicts, and the dropped file is recorded in the derived ignored set.
128
125
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
129
126
  tmpDirs.push(tmpDir);
130
127
  const coveredFile = path.join(tmpDir, "covered_test.py");
@@ -138,13 +135,16 @@ describe("registerActionsTool — completeness check (SKYR-3906)", () => {
138
135
  const result = await handler({
139
136
  stateFile,
140
137
  recommendations: [{ testFilePath: coveredFile, action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" }],
141
- // droppedFile intentionally absent from both recommendations and ignoredTestFiles
138
+ // droppedFile intentionally absent from recommendations
142
139
  });
143
- expect(result.isError).toBe(true);
144
- expect(result.content[0].text).toContain("secret_test.py");
145
- expect(result.content[0].text).toContain("not accounted for");
140
+ expect(result.isError).toBeUndefined();
141
+ const written = JSON.parse(await fs.readFile(stateFile, "utf-8"));
142
+ expect(written.maintenanceVerdicts).toEqual([
143
+ { testFilePath: coveredFile, action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" },
144
+ ]);
145
+ expect(written.ignoredTestFiles).toEqual([droppedFile]);
146
146
  });
147
- it("persists a reconciled maintenanceVerdicts + ignoredTestFiles record to the state file", async () => {
147
+ it("persists maintenanceVerdicts + a derived ignoredTestFiles record to the state file", async () => {
148
148
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
149
149
  tmpDirs.push(tmpDir);
150
150
  const updatedFile = path.join(tmpDir, "orders_contract_test.py");
@@ -155,10 +155,10 @@ describe("registerActionsTool — completeness check (SKYR-3906)", () => {
155
155
  { testFile: updatedFile, source: TestSource.Skyramp },
156
156
  { testFile: ignoredFile, source: TestSource.Skyramp },
157
157
  ]);
158
+ // Only the actionable test is supplied; ignoredFile is derived as the catalog complement.
158
159
  await handler({
159
160
  stateFile,
160
161
  recommendations: [{ testFilePath: updatedFile, action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" }],
161
- ignoredTestFiles: [ignoredFile],
162
162
  });
163
163
  const written = JSON.parse(await fs.readFile(stateFile, "utf-8"));
164
164
  expect(written.ignoredTestFiles).toEqual([ignoredFile]);
@@ -168,6 +168,67 @@ describe("registerActionsTool — completeness check (SKYR-3906)", () => {
168
168
  // Original existingTests must survive the write-back untouched.
169
169
  expect(written.existingTests).toHaveLength(2);
170
170
  });
171
+ it("accepts a real test file discovery missed (uncataloged) instead of dropping it (B1)", async () => {
172
+ // Discovery is relevance-scoped, so a real e2e spec/POM the agent correctly identifies
173
+ // can be absent from the catalog. Before B1 its verdict was dropped as "hallucinated",
174
+ // leaving report.testMaintenance empty even though the edit was applied.
175
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
176
+ tmpDirs.push(tmpDir);
177
+ const cataloged = path.join(tmpDir, "orders_contract_test.py");
178
+ await fs.writeFile(cataloged, "# test");
179
+ const e2eDir = path.join(tmpDir, "e2e", "specs");
180
+ await fs.mkdir(e2eDir, { recursive: true });
181
+ const uncataloged = path.join(e2eDir, "drafts.spec.ts"); // real on disk, NOT in existingTests
182
+ await fs.writeFile(uncataloged, "import { test } from '@playwright/test';\ntest('x', async () => {});");
183
+ const stateFile = await writeStateFile(tmpDir, [{ testFile: cataloged, source: TestSource.Skyramp }]);
184
+ const result = await handler({
185
+ stateFile,
186
+ recommendations: [
187
+ { testFilePath: uncataloged, action: DriftAction.Update, rationale: "testid moved, re-scope locator", testType: TestType.E2E, endpoint: "/drafts", updateInstructions: "re-scope the badge locator" },
188
+ ],
189
+ });
190
+ expect(result.isError).toBeUndefined();
191
+ const written = JSON.parse(await fs.readFile(stateFile, "utf-8"));
192
+ expect(written.maintenanceVerdicts.map((v) => v.testFilePath)).toContain(uncataloged);
193
+ });
194
+ it("still rejects a recommendation for a non-existent (hallucinated) test path (B1 boundary)", async () => {
195
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
196
+ tmpDirs.push(tmpDir);
197
+ const cataloged = path.join(tmpDir, "orders_contract_test.py");
198
+ await fs.writeFile(cataloged, "# test");
199
+ const stateFile = await writeStateFile(tmpDir, [{ testFile: cataloged, source: TestSource.Skyramp }]);
200
+ const ghost = path.join(tmpDir, "e2e", "does_not_exist.spec.ts"); // matches test pattern but NOT on disk
201
+ const result = await handler({
202
+ stateFile,
203
+ recommendations: [
204
+ { testFilePath: ghost, action: DriftAction.Update, rationale: "hallucinated", testType: TestType.E2E, endpoint: "/x", updateInstructions: "y" },
205
+ ],
206
+ });
207
+ expect(result.isError).toBeUndefined();
208
+ const written = JSON.parse(await fs.readFile(stateFile, "utf-8"));
209
+ expect(written.maintenanceVerdicts.map((v) => v.testFilePath)).not.toContain(ghost);
210
+ });
211
+ it("records external DELETE as a report-only verdict (real action, file untouched) — no VERIFY flattening (B2)", async () => {
212
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
213
+ tmpDirs.push(tmpDir);
214
+ const extTest = path.join(tmpDir, "whats_new.spec.ts");
215
+ await fs.writeFile(extTest, "import { test } from '@playwright/test';\ntest('whats new', async () => {});");
216
+ const stateFile = await writeStateFile(tmpDir, [{ testFile: extTest, source: TestSource.External }]);
217
+ const result = await handler({
218
+ stateFile,
219
+ recommendations: [
220
+ { testFilePath: extTest, action: DriftAction.Delete, rationale: "What's New feature removed in this PR", testType: TestType.E2E, endpoint: "/whats-new" },
221
+ ],
222
+ });
223
+ expect(result.isError).toBeUndefined();
224
+ const written = JSON.parse(await fs.readFile(stateFile, "utf-8"));
225
+ const verdict = written.maintenanceVerdicts.find((v) => v.testFilePath === extTest);
226
+ expect(verdict).toBeDefined();
227
+ // The verdict keeps the real DELETE action — NOT flattened to VERIFY (the D2-judge bug).
228
+ expect(verdict.action).toBe(DriftAction.Delete);
229
+ // Report-only: the external test file must still exist on disk.
230
+ await expect(fs.access(extTest)).resolves.toBeUndefined();
231
+ });
171
232
  it("fails loud when persisting maintenanceVerdicts to state fails, instead of silently applying edits anyway", async () => {
172
233
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
173
234
  tmpDirs.push(tmpDir);
@@ -270,9 +331,8 @@ describe("registerActionsTool — completeness check (SKYR-3906)", () => {
270
331
  });
271
332
  // ── Multi-repo (`repository` param) ─────────────────────────────────────────
272
333
  // A run-scoped state file holds the primary repo at the root and each related
273
- // repo under `relatedRepos[<owner/repo>]`. `args.repository` must scope both
274
- // the completeness check and the maintenanceVerdicts/ignoredTestFiles write-back
275
- // to the matching section only.
334
+ // repo under `relatedRepos[<owner/repo>]`. `args.repository` must scope the
335
+ // maintenanceVerdicts/ignoredTestFiles write-back to the matching section only.
276
336
  async function writeMultiRepoStateFile(stateFile, primary, related) {
277
337
  const now = "2026-01-01T00:00:00.000Z";
278
338
  await fs.writeFile(stateFile, JSON.stringify({
@@ -295,7 +355,7 @@ describe("registerActionsTool — completeness check (SKYR-3906)", () => {
295
355
  },
296
356
  }), "utf-8");
297
357
  }
298
- it("scopes the completeness check and write-back to a related (non-primary) repo section, leaving the primary section untouched", async () => {
358
+ it("scopes the write-back to a related (non-primary) repo section, leaving the primary section untouched", async () => {
299
359
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
300
360
  tmpDirs.push(tmpDir);
301
361
  const primaryDir = path.join(tmpDir, "primary");
@@ -344,7 +404,7 @@ describe("registerActionsTool — completeness check (SKYR-3906)", () => {
344
404
  { testFilePath: secondaryTestFile, action: DriftAction.Verify, rationale: "[related repo — no write access, needs manual review] Endpoint removed", testType: TestType.CONTRACT, endpoint: "DELETE /api/v1/reset" },
345
405
  ]);
346
406
  });
347
- it("fails the completeness check for a related repo's missing test, unaffected by the primary repo's own state", async () => {
407
+ it("scopes the write-back to the related repo section, writing an empty verdict record when nothing needs action (SKYR-3938)", async () => {
348
408
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
349
409
  tmpDirs.push(tmpDir);
350
410
  const primaryDir = path.join(tmpDir, "primary");
@@ -357,14 +417,20 @@ describe("registerActionsTool — completeness check (SKYR-3906)", () => {
357
417
  await fs.writeFile(secondaryTestFile, "# test");
358
418
  const stateFile = path.join(tmpDir, "analyze-changes-state.json");
359
419
  await writeMultiRepoStateFile(stateFile, { repositoryPath: primaryDir, existingTests: [{ testFile: primaryTestFile, source: TestSource.Skyramp }] }, { repo: "org/secondary", repositoryPath: secondaryDir, existingTests: [{ testFile: secondaryTestFile, source: TestSource.Skyramp }] });
360
- // Primary repo's own test is fully accounted for, but that must not satisfy
361
- // the related repo's completeness check.
420
+ // With no completeness gate, a related-repo test the LLM left unlisted is simply
421
+ // no-action: the call succeeds and writes an empty maintenanceVerdicts to the related
422
+ // section only, leaving the primary section untouched.
362
423
  const result = await handler({
363
424
  stateFile,
364
425
  repository: "org/secondary",
365
426
  recommendations: [],
366
427
  });
367
- expect(result.isError).toBe(true);
368
- expect(result.content[0].text).toContain("secret_test.py");
428
+ expect(result.isError).toBeUndefined();
429
+ const written = JSON.parse(await fs.readFile(stateFile, "utf-8"));
430
+ expect(written.relatedRepos["org/secondary"].data.maintenanceVerdicts).toEqual([]);
431
+ // The related section's lone test is recorded as ignored (derived complement), scoped
432
+ // to that section — the primary section stays untouched.
433
+ expect(written.relatedRepos["org/secondary"].data.ignoredTestFiles).toEqual([secondaryTestFile]);
434
+ expect(written.maintenanceVerdicts).toBeUndefined();
369
435
  });
370
436
  });