@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
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { logger } from "../../utils/logger.js";
3
3
  import { AnalyticsService } from "../../services/AnalyticsService.js";
4
4
  import { MAX_TESTS_TO_GENERATE, MAX_RECOMMENDATIONS, MAX_CRITICAL_TESTS, PATH_PARAM_UUID_GUIDANCE, AUTH_CONFLICT_ERROR_MSG, } from "../test-recommendation/recommendationSections.js";
5
+ import { TASK_ANALYZE_MAINTAIN, TASK_GENERATE, TASK_SUBMIT, taskRef } from "../test-recommendation/recommendationShared.js";
5
6
  import { getTraceRecordingPromptText } from "../../playwright/traceRecordingPrompt.js";
6
7
  import { isContractConsumerModeEnabled } from "../../utils/featureFlags.js";
7
8
  import { resolveServiceDetailsRef } from "../../utils/utils.js";
@@ -130,7 +131,7 @@ ${hasRelatedRepos ? `
130
131
 
131
132
  b. Write \`updateInstructions\` for each UPDATE or REGENERATE test before calling \`skyramp_actions\` — articulating the change first prevents file content from overriding diff-based reasoning.
132
133
 
133
- c. Call \`skyramp_execute_test\` with \`phase: "before"\` and \`stateFile\` for every test whose action is UPDATE, REGENERATE, or DELETE. Run them sequentially, not in parallel. This captures the pre-edit baseline — do not skip even if you expect the test to fail.
134
+ c. Call \`skyramp_execute_test\` with \`phase: "before"\` and \`stateFile\` for every UPDATE/REGENERATE/DELETE test. Exclude tests marked \`[external]\`. Run them sequentially, not in parallel. This captures the pre-edit baseline — do not skip even if you expect the test to fail.
134
135
 
135
136
  d. Call \`skyramp_actions\` with \`stateFile\` (from \`skyramp_analyze_changes\` output) and apply the edits it returns.
136
137
 
@@ -355,6 +356,7 @@ ${task1Section}
355
356
  ${userPrompt ? "Generate only the tests that the user requested from the Additional Recommendations. The rules below still apply." : "Drift-based maintenance (Task 1) is complete. This step only processes the GENERATE list. Exception: if a GENERATE item targets a resource with an existing `[skyramp]` contract test, UPDATE that test file (see covered-resource handling below) — a new test case added to an existing file counts toward the budget and is reported in `newTestsCreated`."}
356
357
 
357
358
  - **MANDATORY — use the pre-ranked GENERATE list as-is**: The Execution Plan's GENERATE section governs ADD actions. You MUST generate exactly those scenarios in the exact order listed. Do NOT substitute, rename, or replace a GENERATE item. If parameter grounding uncovers a distinct bug-catching scenario not already in the GENERATE or ADDITIONAL list, generate it after all planned GENERATE items are complete and report it in \`newTestsCreated\` — this is an additional test driven by source-code analysis and does not count against the GENERATE budget.${hasRelatedRepos ? `\n - **Multi-repo exception:** this run has related repositories, so the per-repo GENERATE lists are NOT final — they are candidates re-selected by the cross-repo round-robin described in Task 1's "Cross-repo test generation". Follow that pooled, type-distributed selection instead of any single repo's GENERATE list. (In single-repo runs the GENERATE list IS final — generate it exactly as-is.)` : ""}
359
+ - **Do not fabricate tests outside the GENERATE list.** New test files cover NEW observable surface only — a new endpoint, or a newly-integrated component/route not already covered by an existing test. Changes that only modify, delete, or add fields to an EXISTING covered endpoint or component are maintenance: handle them in ${taskRef(TASK_ANALYZE_MAINTAIN)} by UPDATE/DELETE of the existing test, never by creating a new spec. If the GENERATE list is empty (deletion-only, cosmetic, or modification-of-existing PRs with no new surface), create zero new tests and proceed to ${taskRef(TASK_SUBMIT)} — do not invent a new spec to have something to report.
358
360
  - Scenario JSON files are always new files — always generate them for new methods. Every generated scenario JSON must have a corresponding new integration test generated from it via \`skyramp_integration_test_generation\`.
359
361
  - Covered-resource handling (aligns with Execution Plan Step 0): When a GENERATE item targets a resource that already has an existing test file covering the same endpoint:
360
362
  - If the existing test source is \`[external]\`, skip the resource entirely — the external test already provides coverage. Do NOT UPDATE, REGENERATE, or DELETE external tests.
@@ -362,9 +364,9 @@ ${userPrompt ? "Generate only the tests that the user requested from the Additio
362
364
  - Contract tests: UPDATE the existing Skyramp test file (add the new method's test cases). A new test case is a new test even if the file already exists — report in \`newTestsCreated\` and count toward the budget.
363
365
  - Integration/scenario tests: Always generate as a new file via the scenario pipeline (\`skyramp_batch_scenario_test_generation\` → \`skyramp_integration_test_generation\`), even if an existing integration test covers the same resource. A new multi-step scenario (e.g. create → PATCH → verify recalculation) is a distinct test file. Report in \`newTestsCreated\` and count toward the budget.
364
366
  - UI tests: Always generate as a new file. Report in \`newTestsCreated\`.
365
- Keep advancing until you have created exactly ${maxGenerate} new test files OR exhausted all candidates.
367
+ Keep advancing until you have created exactly as many new test files as your committed Budget Plan's generate count (at most ${maxGenerate}) OR exhausted all candidates. If your Budget Plan is 0 total, ${taskRef(TASK_GENERATE)} produces zero tests.
366
368
  - Example: If enrichment reveals that sending \`discount_value\` without \`discount_type\` silently orphans the value (a concrete bug), complete all planned GENERATE items first, then generate this discovered scenario as an extra test and report it in \`newTestsCreated\`.
367
- - Total generated: Follow the "Budget: N generate" line in the Execution Plan. Process every GENERATE-tagged item in order. Backfill from ADDITIONAL candidates (highest-ranked first) until \`newTestsCreated\` reaches ${maxGenerate} or all candidates are exhausted.
369
+ - Total generated: your committed Budget Plan's generate count (from the Execution Plan's Scope Assessment, at most ${maxGenerate}) is the single source of truth for how many tests to create. Process every GENERATE-tagged item in order, then backfill from ADDITIONAL candidates (highest-ranked first) until \`newTestsCreated\` reaches that generate count or all candidates are exhausted. If your Budget Plan is 0 total (the Execution Plan's zero-classified default or cosmetic-only override applies), skip generation and backfilling entirely and proceed to ${taskRef(TASK_SUBMIT)}'s zero-test report path.
368
370
  - **UI test priority**: If the PR scope assessment shows any UI/E2E budget OR \`uiContext.changedFrontendFiles\` is non-empty (the deterministic server signal — populated for all supported frontend file types including \`.tsx\`/\`.jsx\`/\`.vue\`/\`.svelte\`/\`.dart\`), you MUST attempt to generate at least one UI test. Use \`browser_navigate\` to the app's base URL — if the app responds, record a trace and generate the test.
369
371
  **Flutter web apps:** Skyramp's Playwright tools automatically enable Flutter's accessibility semantics tree on every \`browser_navigate\` call — you do NOT need to manually click \`flt-semantics-placeholder\` or add any activation step to the trace. Do NOT log an \`issuesFound\` entry about Flutter canvas rendering or accessibility activation — this is handled transparently. **Do NOT skip test generation or abstain from recording based on what you see in the Flutter source code** (e.g. \`SemanticsBinding.ensureSemantics()\` commented out, \`IS_TESTING\` flag absent, or similar) — Skyramp enables accessibility from the browser side regardless of the app's Dart code. Proceed with \`browser_navigate\` and test recording as normal. **Start at the app's root URL** (e.g. \`{baseUrl}/\`) — do NOT \`browser_navigate\` straight to a deep sub-route (e.g. \`/authors\`, \`/orders/13\`). Flutter \`go_router\` SPAs route from the root: deep-linking on a cold page load often fails to render the expected screen (the route's widgets never mount, so the trace captures the wrong page). Load the root, let the app's own routing/auth-redirect render, then reach target screens by interaction. **After the initial login, navigate using in-app controls only** (tab buttons, links, back buttons) — do NOT call \`browser_navigate\` to a different URL after login. Flutter web apps are SPAs: a \`browser_navigate\` to a new URL after login triggers a full page reload which clears the auth session, causing redundant re-login cycles in the generated test. Use button clicks to reach target screens instead.
370
372
  **Skip only if one of these conditions is met:**
@@ -546,7 +548,7 @@ Do NOT use \`page.waitForTimeout()\` with fixed delays. Do NOT retry more than o
546
548
  4. **Wait**: Do NOT proceed to test execution until steps 1–3 are complete and the verification checklist in the \`skyramp_enhance_assertions\` tool result has been validated for EVERY generated test file.
547
549
  Do not make any changes other than the assertion enhancements described above. For example: do not modify auth headers, cookies, tokens, env vars, or imports that the generation tool already set correctly — those are correct by construction and changing them breaks auth or execution.
548
550
 
549
- **Final execution (mandatory):** Do NOT call \`skyramp_execute_test\` until ALL maintenance edits AND ALL new test generation/enhancement are complete. Run these calls sequentially, not in parallel.
551
+ **Final execution (mandatory):** Do NOT call \`skyramp_execute_test\` until ALL maintenance edits AND ALL new test generation/enhancement are complete. Run these calls sequentially, not in parallel. Exclude tests marked \`[external]\`.
550
552
  - Only report test results for files you actually ran.
551
553
  **Auth**: If \`skyramp_analyze_changes\` reports an auth token or \`SKYRAMP_TEST_TOKEN\` is set, pass it in **every** \`skyramp_execute_test\` call from the first attempt — do NOT wait for a 401/403 to discover auth is needed.
552
554
 
@@ -568,7 +570,7 @@ In these cases:
568
570
  - \`businessCaseAnalysis\` must be a one-sentence summary of what the PR actually does (do NOT leave it blank)
569
571
  - \`additionalRecommendations\` must be \`[]\` — do NOT recommend tests for a no-surface PR
570
572
 
571
- Otherwise: in \`newTestsCreated\`, you must have exactly ${maxGenerate} budget-counting new tests for the planned GENERATE items. Only new files (ADD) created for those planned GENERATE items count toward this ${maxGenerate} target — GENERATE items converted to UPDATE do not. You may also include at most one additional discovered-scenario file in \`newTestsCreated\` (the bug-catching test generated after all planned items); that extra test does **not** count against the ${maxGenerate} budget. If you have fewer than ${maxGenerate} budget-counting new tests, backfill from the remaining ADDITIONAL candidates before proceeding. Only proceed with fewer than ${maxGenerate} budget-counting new tests if all candidates failed after retry AND the fallback single-contract test also failed.
573
+ Otherwise (your Budget Plan is non-zero): in \`newTestsCreated\`, you must have exactly as many budget-counting new tests as your committed Budget Plan's generate count (at most ${maxGenerate}). Only new files (ADD) created for the planned GENERATE items count toward this target — GENERATE items converted to UPDATE do not. You may also include at most one additional discovered-scenario file in \`newTestsCreated\` (the bug-catching test generated after all planned items); that extra test does **not** count against the budget. If you have fewer budget-counting new tests than your generate count, backfill from the remaining ADDITIONAL candidates before proceeding. Only proceed with fewer if all candidates failed after retry AND the fallback single-contract test also failed.
572
574
 
573
575
  Call \`skyramp_submit_report\` with \`summaryOutputFile\`: "${summaryOutputFile}" and \`stateFile\` (from \`skyramp_analyze_changes\` output) — the stateFile is required for execution outcome tracking. Field names, types, and formats are defined in the tool's parameter schema — follow them exactly.
574
576
 
@@ -3,7 +3,7 @@ jest.unstable_mockModule("../../workspace/workspace.js", () => ({
3
3
  WorkspaceConfigManager: jest.fn(),
4
4
  }));
5
5
  jest.unstable_mockModule("../../services/AnalyticsService.js", () => ({
6
- AnalyticsService: { pushMCPToolEvent: jest.fn() },
6
+ AnalyticsService: { pushMCPToolEvent: jest.fn(() => Promise.resolve()) },
7
7
  }));
8
8
  jest.unstable_mockModule("../../playwright/traceRecordingPrompt.js", () => ({
9
9
  getTraceRecordingPromptText: () => "",
@@ -4,6 +4,41 @@ import * as path from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { getEntryPoint, getCIPlatform, getRepositoryInfo, } from "../utils/telemetry.js";
6
6
  import { logger } from "../utils/logger.js";
7
+ /**
8
+ * Timeout (ms) for the native FFI analytics call. 5 s is long enough for a
9
+ * single HTTPS POST to the telemetry endpoint (p99 < 1 s in normal conditions)
10
+ * but short enough that a hung DNS lookup or unresponsive backend won't stall
11
+ * the MCP tool response pipeline — the tool result must not be delayed by
12
+ * best-effort telemetry.
13
+ */
14
+ const ANALYTICS_TIMEOUT_MS = 5000;
15
+ /**
16
+ * Race a promise against a timeout. Resolves/rejects with whichever settles
17
+ * first; the losing branch is left to run (fire-and-forget) but cannot affect
18
+ * the caller.
19
+ */
20
+ function withTimeout(promise, ms, label) {
21
+ let timer;
22
+ const timeout = new Promise((_, reject) => {
23
+ timer = setTimeout(() => reject(new Error(`${label}: timed out after ${ms}ms`)), ms);
24
+ });
25
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
26
+ }
27
+ /**
28
+ * Safely invoke the native pushToolEvent FFI call. Wraps the call in a timeout
29
+ * so a hung Go network call cannot block forever, and catches any synchronous
30
+ * throw from the native binding (e.g. if the Go runtime panicked on a prior
31
+ * call and the shared library is in a bad state).
32
+ */
33
+ async function safePushToolEvent(entryPoint, toolName, errorMessage, params) {
34
+ try {
35
+ await withTimeout(pushToolEvent(entryPoint, toolName, errorMessage, params), ANALYTICS_TIMEOUT_MS, "pushToolEvent");
36
+ }
37
+ catch (error) {
38
+ logger.error("safePushToolEvent failed", { error: error instanceof Error ? error.message : String(error) });
39
+ // Swallow — analytics must never crash the server.
40
+ }
41
+ }
7
42
  export class AnalyticsService {
8
43
  static async pushTestGenerationToolEvent(toolName, result, params) {
9
44
  const analyticsResult = {};
@@ -33,7 +68,7 @@ export class AnalyticsService {
33
68
  // process.cwd() is set by the IDE and may not be the user's repo.
34
69
  const repoPath = params.repositoryPath || params.workspacePath;
35
70
  Object.assign(params, await getRepositoryInfo(repoPath));
36
- await pushToolEvent(getEntryPoint(), toolName, errorMessage, params);
71
+ await safePushToolEvent(getEntryPoint(), toolName, errorMessage, params);
37
72
  }
38
73
  catch (error) {
39
74
  logger.error("Error pushing MCP tool event", { error: error });
@@ -53,7 +88,7 @@ export class AnalyticsService {
53
88
  if (ciPlatform) {
54
89
  params.ciPlatform = ciPlatform;
55
90
  }
56
- await pushToolEvent(getEntryPoint(), "mcp_server_crash", errorMessage, params);
91
+ await safePushToolEvent(getEntryPoint(), "mcp_server_crash", errorMessage, params);
57
92
  }
58
93
  }
59
94
  /**
@@ -12,13 +12,32 @@ export interface TestDiscoveryOptions {
12
12
  * exist but resource names are unresolvable — most files score 0 (low-relevance)
13
13
  * and are excluded; only the few that happen to match the sentinel are kept.
14
14
  * - Empty array `[]`: PR mode with no endpoints from diff or scanner — API/integration
15
- * external tests are excluded entirely; UI tests may still be promoted via hasFrontendChanges.
15
+ * external tests are excluded entirely; UI tests may still be promoted via changedFrontendFiles.
16
16
  * - `undefined`: full-repo mode — external tests capped at MAX_EXTERNAL_FULL_REPO.
17
17
  */
18
18
  changedResources?: string[];
19
- /** True when the diff contains frontend files promotes UI test files for framework
20
- * detection regardless of changedResources. Orthogonal to API relevance scoring. */
21
- hasFrontendChanges?: boolean;
19
+ /** Raw changed symbol names (e.g. ["DeploymentCreate"]). Content-grep fallback so an
20
+ * external test that references a changed type by name — but whose path/URL matches no
21
+ * changed resource (e.g. a schema unit test) — is still surfaced (SKYR-3924). */
22
+ changedSymbols?: string[];
23
+ /** True when `changedResources` are precise (derived from specific changed schema/model
24
+ * symbols, not the `["unknown"]` sentinel or a broad set). Lets the URL content-match
25
+ * bucket skip the MAX_CONTENT_PROMOTED cap — a direct HTTP call to the changed endpoint
26
+ * is high-confidence, and the specific resource set naturally bounds the match count. */
27
+ preciseResources?: boolean;
28
+ /** Repo-relative paths of the changed frontend (non-test) files — their presence is the
29
+ * frontend-change signal that drives UI-test promotion (Step 2). When present, promotion
30
+ * is scoped to tests whose name/path/imports are relevant to these files — a frontend PR
31
+ * must not pull in the whole monorepo's UI test suite (SKYR-3941). When absent, Step 2 is
32
+ * skipped (no UI promotion). */
33
+ changedFrontendFiles?: string[];
34
+ /** Selector literals (data-testid values, CSS class tokens) added or removed by the diff.
35
+ * UI-test promotion pass 3 ("selector-edge"): a page object / spec couples to a changed
36
+ * component by SELECTOR, not by filename or import, so passes 1-2 miss it. Any external
37
+ * test-owned file (including page objects, which are not *.test/*.spec) whose body
38
+ * references one of these literals is promoted. Both added and removed values are passed
39
+ * so a page object still holding a renamed selector's OLD value is caught. */
40
+ changedSelectors?: string[];
22
41
  }
23
42
  export declare class TestDiscoveryService {
24
43
  private readonly EXCLUDED_DIRS;
@@ -47,7 +66,22 @@ export declare class TestDiscoveryService {
47
66
  * Example: "test_orders_api.py" vs ["orders"] → score 1.
48
67
  */
49
68
  private scoreRelevance;
69
+ /**
70
+ * Derive relevance tokens from the changed frontend files, for scoring UI tests in Step 2
71
+ * the same way scoreRelevance scores against changed API resources. For each file we emit:
72
+ * - the basename stem (e.g. "OrderSearch" → "ordersearch") — matches co-located tests like
73
+ * OrderSearch.test.tsx, the dominant convention;
74
+ * - its sub-tokens, split on delimiters AND camelCase boundaries ("OrderSearch" →
75
+ * "order","search") — matches hyphenated/underscored test names like order-search.spec.tsx;
76
+ * - the parent directory name — the signal when the basename is generic (orders/index.tsx).
77
+ * Generic tokens (index, components, utils, …) and tokens shorter than 3 chars are dropped so
78
+ * a shared dir or an index file doesn't match every UI test in the repo.
79
+ */
80
+ private deriveFrontendResourceTokens;
50
81
  private readonly MAX_CONTENT_PROMOTED;
82
+ private readonly MAX_UI_PROMOTED;
83
+ private readonly UI_IMPORT_MATCH_WEIGHT;
84
+ private readonly GENERIC_FRONTEND_TOKENS;
51
85
  /**
52
86
  * Partition external test files into relevant (score > 0) and low-relevance (score = 0).
53
87
  * Two-pass: filename token overlap first (primary); content-based endpoint path scan
@@ -3,6 +3,7 @@ import * as path from "path";
3
3
  import { logger } from "../utils/logger.js";
4
4
  import { TestSource } from "../types/TestAnalysis.js";
5
5
  import { TestType } from "../types/TestTypes.js";
6
+ import { buildPathSignatures } from "../utils/pathSignatures.js";
6
7
  import fg from "fast-glob";
7
8
  export class TestDiscoveryService {
8
9
  EXCLUDED_DIRS = [
@@ -104,7 +105,7 @@ export class TestDiscoveryService {
104
105
  // Full-repo mode (changedResources is undefined):
105
106
  // No diff context — all external files treated as potentially relevant.
106
107
  // Cap at MAX_EXTERNAL_FULL_REPO to avoid reading hundreds of files.
107
- const { changedResources, hasFrontendChanges } = options;
108
+ const { changedResources, changedSymbols, preciseResources, changedFrontendFiles, changedSelectors } = options;
108
109
  let relevantExternal;
109
110
  // Includes .e2e.ts — Playwright/Cypress tests that navigate pages. These get
110
111
  // testType="e2e" from path detection but are never named *.test.ts or *.spec.ts
@@ -117,7 +118,7 @@ export class TestDiscoveryService {
117
118
  // for tests that are misnamed relative to the endpoint they exercise.
118
119
  if (changedResources?.length) {
119
120
  ({ relevant: relevantExternal } =
120
- this.partitionByRelevance(classified.external, changedResources, classified.contentCache));
121
+ this.partitionByRelevance(classified.external, changedResources, classified.contentCache, changedSymbols, preciseResources));
121
122
  }
122
123
  else if (changedResources !== undefined) {
123
124
  relevantExternal = [];
@@ -125,10 +126,93 @@ export class TestDiscoveryService {
125
126
  else {
126
127
  relevantExternal = classified.external.slice(0, this.MAX_EXTERNAL_FULL_REPO);
127
128
  }
128
- // Step 2 — UI test promotion (driven by hasFrontendChanges, independent of Step 1).
129
- if (hasFrontendChanges) {
129
+ // Step 2 — UI test promotion, driven by the presence of changed frontend files and
130
+ // independent of Step 1. Scoped by relevance to those files, mirroring Step 1's API
131
+ // discipline: a frontend PR must not pull in the whole monorepo's UI test suite (SKYR-3941).
132
+ // Absent a changed-frontend-file list, Step 2 is skipped (no UI promotion).
133
+ if (changedFrontendFiles?.length) {
130
134
  const alreadyRelevant = new Set(relevantExternal);
131
- const promoted = classified.external.filter(f => UI_TEST_EXT.test(f) && !alreadyRelevant.has(f));
135
+ const uiCandidates = classified.external.filter(f => UI_TEST_EXT.test(f) && !alreadyRelevant.has(f));
136
+ // Pass 1 — filename/path relevance: tokens derived from the changed frontend files.
137
+ const feTokens = this.deriveFrontendResourceTokens(changedFrontendFiles);
138
+ // Pass 2 — import-edge relevance: module-path signatures of the changed files, matched
139
+ // against test bodies so a test that imports a changed component (from './PriceLabel')
140
+ // is caught even when its name shares no token — the recall case a filename-only filter
141
+ // drops (a checkout test that renders a changed child component). Reuses the same
142
+ // buildPathSignatures used by frontendIntegration/importerHop; this is Step 2's analog
143
+ // of Step 1's content-scan fallback. Skip generic basenames (index/app/…): their
144
+ // signature (`/index'`) would match every barrel import — those are already covered by
145
+ // pass 1's parent-directory token.
146
+ const importSignatures = changedFrontendFiles
147
+ .filter(f => {
148
+ const stem = path.basename(f).replace(/\.[^.]+$/, "").toLowerCase();
149
+ return stem.length >= 3 && !this.GENERIC_FRONTEND_TOKENS.has(stem);
150
+ })
151
+ .flatMap(f => buildPathSignatures(f));
152
+ let promoted;
153
+ if (feTokens.length === 0 && importSignatures.length === 0) {
154
+ // No relevance signal is derivable — every changed frontend file is a generic
155
+ // entrypoint (index.tsx / app.tsx / …) whose tokens are filtered out, and none
156
+ // yields an import signature. A root/entrypoint change is broadly impactful, so
157
+ // fall back to promoting all UI candidates rather than degenerate to zero — still
158
+ // bounded by MAX_UI_PROMOTED so it can't re-flood a large monorepo's suite.
159
+ promoted = uiCandidates.slice(0, this.MAX_UI_PROMOTED);
160
+ const dropped = uiCandidates.length - promoted.length;
161
+ logger.info(`UI test promotion: no relevance signal from generic-only frontend changes — capped promote-all fallback (${promoted.length}/${uiCandidates.length} promoted${dropped > 0 ? `, ${dropped} dropped by MAX_UI_PROMOTED` : ""})`);
162
+ }
163
+ else {
164
+ let scored = uiCandidates
165
+ .map(f => {
166
+ const nameScore = this.scoreRelevance(f, feTokens);
167
+ const content = classified.contentCache.get(f) ?? "";
168
+ const importsChanged = content.length > 0 && importSignatures.some(sig => content.includes(sig));
169
+ return { f, score: nameScore + (importsChanged ? this.UI_IMPORT_MATCH_WEIGHT : 0) };
170
+ })
171
+ .filter(x => x.score > 0)
172
+ .sort((a, b) => b.score - a.score)
173
+ .map(x => x.f);
174
+ if (scored.length > this.MAX_UI_PROMOTED) {
175
+ logger.info(`UI test promotion: ${scored.length} tests matched ${changedFrontendFiles.length} changed frontend file(s); capping to ${this.MAX_UI_PROMOTED} highest-scoring (SKYR-3941 backstop)`);
176
+ scored = scored.slice(0, this.MAX_UI_PROMOTED);
177
+ }
178
+ logger.info(`UI test promotion scoped to ${scored.length}/${uiCandidates.length} UI test(s) relevant to changed frontend files`);
179
+ promoted = scored;
180
+ }
181
+ // Pass 3 — selector-edge: a page object / spec couples to a changed component by
182
+ // SELECTOR (data-testid value or CSS class), not by filename or import, so passes
183
+ // 1-2 miss it. Crucially the candidate pool here is the FULL external set, not just
184
+ // UI_TEST_EXT: a page object like `table.component.ts` is classified external (via
185
+ // the page-objects/ dir pattern) with content cached, but is not a *.test/*.spec
186
+ // file, so it never enters `uiCandidates`. Promote any not-yet-relevant external
187
+ // file whose body references a changed selector literal (matched against both the
188
+ // added and removed diff values, so a page object still holding a renamed selector's
189
+ // OLD value is caught). Bounded by MAX_UI_PROMOTED.
190
+ // Combined cap: MAX_UI_PROMOTED is a single Step-2 ceiling, so pass 3 may only add up
191
+ // to what pass 1/2 left — otherwise the two independent caps could promote 2x and
192
+ // re-flood discovery (the exact failure SKYR-3941 guards against). Compute the headroom
193
+ // up front and skip the O(external × selectors) content scan entirely when pass 1/2
194
+ // already filled the budget.
195
+ const selectorHeadroom = Math.max(0, this.MAX_UI_PROMOTED - promoted.length);
196
+ if (changedSelectors?.length && selectorHeadroom > 0) {
197
+ const alreadyPromoted = new Set([...alreadyRelevant, ...promoted]);
198
+ const selectorMatched = classified.external
199
+ .filter((f) => {
200
+ if (alreadyPromoted.has(f))
201
+ return false;
202
+ const content = classified.contentCache.get(f) ?? "";
203
+ return content.length > 0 && changedSelectors.some((sel) => content.includes(sel));
204
+ })
205
+ // Sort for a deterministic cap — filesystem/glob traversal order is not stable
206
+ // across environments, so which files survive MAX_UI_PROMOTED must not depend on it.
207
+ .sort();
208
+ if (selectorMatched.length > 0) {
209
+ const capped = selectorMatched.slice(0, selectorHeadroom);
210
+ logger.info(`UI test promotion: ${selectorMatched.length} test/page-object file(s) reference a changed selector (pass 3, selector-edge)` +
211
+ (capped.length < selectorMatched.length ? `; added ${capped.length} (MAX_UI_PROMOTED headroom after pass 1/2)` : ""));
212
+ if (capped.length > 0)
213
+ promoted = [...promoted, ...capped];
214
+ }
215
+ }
132
216
  const promotedSet = new Set(promoted);
133
217
  // Promote snapshot/fixture siblings of scored AND newly promoted UI tests.
134
218
  // Include promoted files so siblings are found on UI-only PRs where
@@ -188,9 +272,59 @@ export class TestDiscoveryService {
188
272
  return parts.length > 1 && parts.every(p => p.length >= 3 && expanded.has(p));
189
273
  }).length;
190
274
  }
275
+ /**
276
+ * Derive relevance tokens from the changed frontend files, for scoring UI tests in Step 2
277
+ * the same way scoreRelevance scores against changed API resources. For each file we emit:
278
+ * - the basename stem (e.g. "OrderSearch" → "ordersearch") — matches co-located tests like
279
+ * OrderSearch.test.tsx, the dominant convention;
280
+ * - its sub-tokens, split on delimiters AND camelCase boundaries ("OrderSearch" →
281
+ * "order","search") — matches hyphenated/underscored test names like order-search.spec.tsx;
282
+ * - the parent directory name — the signal when the basename is generic (orders/index.tsx).
283
+ * Generic tokens (index, components, utils, …) and tokens shorter than 3 chars are dropped so
284
+ * a shared dir or an index file doesn't match every UI test in the repo.
285
+ */
286
+ deriveFrontendResourceTokens(changedFrontendFiles) {
287
+ const out = new Set();
288
+ const add = (t) => {
289
+ const v = t.toLowerCase();
290
+ if (v.length >= 3 && !this.GENERIC_FRONTEND_TOKENS.has(v))
291
+ out.add(v);
292
+ };
293
+ for (const f of changedFrontendFiles) {
294
+ const segs = f.replace(/\\/g, "/").split("/").filter(Boolean);
295
+ const base = segs[segs.length - 1] ?? "";
296
+ const stem = base.replace(/\.[^.]+$/, ""); // drop extension
297
+ add(stem); // whole stem (e.g. "ordersearch", "order-search")
298
+ for (const t of stem.split(/[-_.]+|(?<=[a-z0-9])(?=[A-Z])/))
299
+ add(t); // sub-tokens
300
+ const parent = segs[segs.length - 2];
301
+ if (parent && !parent.startsWith("__"))
302
+ add(parent);
303
+ }
304
+ return [...out];
305
+ }
191
306
  // Max additional tests promoted via content-based scoring on top of filename matches.
192
307
  // Content matches are lower-confidence than filename matches — cap to avoid token bloat.
193
308
  MAX_CONTENT_PROMOTED = 5;
309
+ // Backstop cap on scoped UI-test promotion (Step 2). The relevance filter against the
310
+ // changed frontend files does the real bounding; this only guards a pathological match
311
+ // (e.g. a change to a widely-shared component). Highest-scoring tests survive truncation.
312
+ MAX_UI_PROMOTED = 50;
313
+ // Weight added to a UI test's relevance score when it imports a changed frontend file by
314
+ // module path (import-edge match, Step 2 pass 2). Set high above any filename-token score
315
+ // so import matches — the strongest "this test exercises the changed code" signal —
316
+ // always survive the MAX_UI_PROMOTED cap ahead of name-only matches.
317
+ UI_IMPORT_MATCH_WEIGHT = 100;
318
+ // Generic path/name tokens that carry no feature signal — excluded when deriving relevance
319
+ // tokens from changed frontend files so a changed index.tsx or a shared "components" dir
320
+ // doesn't match every UI test in the repo.
321
+ GENERIC_FRONTEND_TOKENS = new Set([
322
+ "index", "app", "main", "root", "styles", "style", "css", "scss", "types", "type",
323
+ "constants", "utils", "util", "helpers", "helper", "components", "component", "pages",
324
+ "page", "routes", "route", "src", "common", "shared", "hooks", "hook", "lib", "context",
325
+ "providers", "provider", "store", "stores", "api", "test", "tests", "spec", "specs",
326
+ "mocks", "mock", "fixtures", "fixture", "assets", "config",
327
+ ]);
194
328
  /**
195
329
  * Partition external test files into relevant (score > 0) and low-relevance (score = 0).
196
330
  * Two-pass: filename token overlap first (primary); content-based endpoint path scan
@@ -198,8 +332,9 @@ export class TestDiscoveryService {
198
332
  * (e.g. test_checkout_flow.py testing /api/orders scores 0 by name but matches in content).
199
333
  * Content matches are capped at MAX_CONTENT_PROMOTED to bound token impact.
200
334
  */
201
- partitionByRelevance(files, changedResources, contentCache) {
335
+ partitionByRelevance(files, changedResources, contentCache, changedSymbols, preciseResources) {
202
336
  const nameMatched = [];
337
+ const symbolMatched = [];
203
338
  const contentCandidates = [];
204
339
  // Precompile content patterns once — avoids 20 resources × 150 files = 3K compilations.
205
340
  const contentPatterns = contentCache
@@ -212,22 +347,58 @@ export class TestDiscoveryService {
212
347
  return new RegExp(`["'\`][^"'\`]*/${escaped}`, "i");
213
348
  })
214
349
  : [];
350
+ // Exact, case-sensitive word-boundary match on a changed type name (e.g.
351
+ // `DeploymentCreate`). Catches an external test that references the changed schema/model
352
+ // by name but whose path/URL matches no changed resource — a schema unit test
353
+ // constructing it directly (SKYR-3924). High-confidence, so not subject to the content cap.
354
+ const symbolPatterns = contentCache
355
+ ? (changedSymbols ?? []).map(s => new RegExp(`\\b${s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`))
356
+ : [];
357
+ // Endpoint-call patterns: an HTTP-method call to a path containing the changed resource
358
+ // (e.g. `client.post("/deployments/")`). A direct call to the changed endpoint is
359
+ // high-confidence — distinct from a file that merely mentions the resource string.
360
+ const endpointCallPatterns = contentCache
361
+ ? changedResources.map(r => {
362
+ const escaped = r.toLowerCase()
363
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
364
+ .replace(/-|\//g, "[-/]");
365
+ return new RegExp(`\\b(?:get|post|put|patch|delete|request|fetch)\\b[^\\n]{0,60}["'\`][^"'\`]*/${escaped}`, "i");
366
+ })
367
+ : [];
368
+ const strongContent = [];
215
369
  for (const f of files) {
216
370
  if (this.scoreRelevance(f, changedResources) > 0) {
217
371
  nameMatched.push(f);
372
+ continue;
218
373
  }
219
- else if (contentPatterns.length > 0) {
220
- const content = contentCache.get(f) ?? "";
221
- if (content && contentPatterns.some(p => p.test(content))) {
222
- contentCandidates.push(f);
223
- }
374
+ const content = contentCache?.get(f) ?? "";
375
+ if (!content)
376
+ continue;
377
+ if (symbolPatterns.some(p => p.test(content))) {
378
+ symbolMatched.push(f);
379
+ }
380
+ else if (endpointCallPatterns.some(p => p.test(content))) {
381
+ strongContent.push(f); // direct call to the changed endpoint — high-confidence
382
+ }
383
+ else if (contentPatterns.some(p => p.test(content))) {
384
+ contentCandidates.push(f); // loose mention of the resource — low-confidence, capped
224
385
  }
225
386
  }
226
- const contentMatched = contentCandidates.slice(0, this.MAX_CONTENT_PROMOTED);
227
- if (contentMatched.length > 0) {
228
- logger.info(`Content-based relevance: promoting ${contentMatched.length} test(s) (capped at ${this.MAX_CONTENT_PROMOTED})`);
229
- }
230
- return { relevant: [...nameMatched, ...contentMatched] };
387
+ // Precise resource sets (schema-diff preload): keep high-confidence endpoint-call matches
388
+ // uncapped (bounded by the specific resource), still cap loose mentions. Otherwise cap all
389
+ // content matches together (original behavior).
390
+ const contentMatched = preciseResources
391
+ ? [...strongContent, ...contentCandidates.slice(0, this.MAX_CONTENT_PROMOTED)]
392
+ : [...strongContent, ...contentCandidates].slice(0, this.MAX_CONTENT_PROMOTED);
393
+ if (symbolMatched.length > 0 || contentMatched.length > 0) {
394
+ // Report what actually landed in contentMatched — the non-precise branch caps
395
+ // strong+loose together, so strongContent may be truncated too. Deriving both
396
+ // counts from contentMatched.length avoids a misleading negative "loose" count.
397
+ const strongIn = Math.min(strongContent.length, contentMatched.length);
398
+ const looseIn = contentMatched.length - strongIn;
399
+ logger.info(`Relevance: ${symbolMatched.length} symbol + ${strongIn} endpoint-call + ${looseIn} loose content`);
400
+ }
401
+ return { relevant: [...nameMatched, ...symbolMatched, ...contentMatched] };
231
402
  }
232
403
  /**
233
404
  * Process test files in parallel batches with concurrency control