@skyramp/mcp 0.3.4 → 0.3.6-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 (159) hide show
  1. package/build/playwright/registerPlaywrightTools.js +92 -30
  2. package/build/playwright/traceRecordingPrompt.d.ts +6 -0
  3. package/build/playwright/traceRecordingPrompt.js +6 -2
  4. package/build/prompts/code-reuse.d.ts +1 -2
  5. package/build/prompts/code-reuse.js +182 -77
  6. package/build/prompts/modularization/integration-test-modularization.d.ts +2 -0
  7. package/build/prompts/modularization/integration-test-modularization.js +83 -41
  8. package/build/prompts/modularization/render.d.ts +18 -0
  9. package/build/prompts/modularization/render.js +12 -0
  10. package/build/prompts/modularization/ui-test-modularization.d.ts +3 -1
  11. package/build/prompts/modularization/ui-test-modularization.js +89 -47
  12. package/build/prompts/pom-aware-code-reuse.js +3 -1
  13. package/build/prompts/shared-helper-policy.d.ts +57 -0
  14. package/build/prompts/shared-helper-policy.js +135 -0
  15. package/build/prompts/test-recommendation/diffExecutionPlan.js +62 -56
  16. package/build/prompts/test-recommendation/fullRepoCatalog.js +19 -8
  17. package/build/prompts/test-recommendation/recommendationShared.d.ts +28 -6
  18. package/build/prompts/test-recommendation/recommendationShared.js +90 -16
  19. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +22 -0
  20. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -2
  21. package/build/prompts/test-recommendation/test-recommendation-prompt.js +3 -3
  22. package/build/prompts/testbot/testbot-prompts.js +88 -33
  23. package/build/recommendation/budgeters/shared.js +105 -27
  24. package/build/recommendation/discriminators.js +13 -2
  25. package/build/recommendation/planRanker.d.ts +6 -6
  26. package/build/recommendation/planRanker.js +6 -61
  27. package/build/services/AnalyticsService.d.ts +7 -0
  28. package/build/services/AnalyticsService.js +7 -1
  29. package/build/services/ModularizationService.js +1 -3
  30. package/build/services/TestDiscoveryService.d.ts +0 -2
  31. package/build/services/TestDiscoveryService.js +2 -37
  32. package/build/services/TestGenerationService.d.ts +16 -0
  33. package/build/services/TestGenerationService.js +86 -10
  34. package/build/services/containerEnv.js +13 -12
  35. package/build/tools/code-refactor/codeReuseTool.js +279 -93
  36. package/build/tools/code-refactor/enhance-state.d.ts +49 -0
  37. package/build/tools/code-refactor/enhance-state.js +109 -0
  38. package/build/tools/code-refactor/enhanceAssertionsTool.js +34 -1
  39. package/build/tools/code-refactor/modularizationTool.js +9 -2
  40. package/build/tools/code-refactor/reuse-outcome.d.ts +23 -1
  41. package/build/tools/code-refactor/reuse-outcome.js +14 -4
  42. package/build/tools/code-refactor/reuse-state.d.ts +127 -5
  43. package/build/tools/code-refactor/reuse-state.js +628 -16
  44. package/build/tools/code-refactor/utils-verify-gates.d.ts +26 -0
  45. package/build/tools/code-refactor/utils-verify-gates.js +100 -0
  46. package/build/tools/code-refactor/verify-gates.d.ts +2 -1
  47. package/build/tools/code-refactor/verify-gates.js +90 -25
  48. package/build/tools/executeSkyrampTestTool.d.ts +19 -0
  49. package/build/tools/executeSkyrampTestTool.js +158 -8
  50. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +2 -2
  51. package/build/tools/generate-tests/generateE2ERestTool.js +16 -0
  52. package/build/tools/generate-tests/generateUIRestTool.d.ts +1 -0
  53. package/build/tools/generate-tests/generateUIRestTool.js +22 -0
  54. package/build/tools/generate-tests/scenarioLint.d.ts +2 -0
  55. package/build/tools/generate-tests/scenarioLint.js +127 -19
  56. package/build/tools/generate-tests/trace-reuse-guard.d.ts +20 -0
  57. package/build/tools/generate-tests/trace-reuse-guard.js +93 -0
  58. package/build/tools/submitReportTool.d.ts +38 -38
  59. package/build/tools/submitReportTool.js +487 -104
  60. package/build/tools/test-management/analyzeChangesTool.d.ts +24 -1
  61. package/build/tools/test-management/analyzeChangesTool.js +75 -12
  62. package/build/tools/test-management/analyzeTestHealthTool.js +7 -7
  63. package/build/tools/test-management/registerTestPlanTool.d.ts +203 -0
  64. package/build/tools/test-management/registerTestPlanTool.js +149 -23
  65. package/build/types/Recommendation.d.ts +34 -5
  66. package/build/types/RepositoryAnalysis.d.ts +133 -114
  67. package/build/types/RepositoryAnalysis.js +1 -1
  68. package/build/types/ReuseOutcome.d.ts +102 -6
  69. package/build/types/ReuseOutcome.js +16 -2
  70. package/build/types/TestRecommendation.js +21 -3
  71. package/build/types/TestTypes.js +14 -8
  72. package/build/types/TestbotReport.d.ts +25 -3
  73. package/build/types/index.d.ts +2 -2
  74. package/build/types/index.js +1 -1
  75. package/build/utils/AnalysisStateManager.d.ts +69 -1
  76. package/build/utils/AnalysisStateManager.js +69 -5
  77. package/build/utils/branchDiff.d.ts +10 -0
  78. package/build/utils/branchDiff.js +28 -0
  79. package/build/utils/changedRoutes.d.ts +29 -0
  80. package/build/utils/changedRoutes.js +87 -0
  81. package/build/utils/featureFlags.d.ts +21 -0
  82. package/build/utils/featureFlags.js +23 -0
  83. package/build/utils/frontendIntegration.js +34 -4
  84. package/build/utils/importerHop.d.ts +2 -8
  85. package/build/utils/importerHop.js +15 -53
  86. package/build/utils/pathMatching.d.ts +38 -0
  87. package/build/utils/pathMatching.js +71 -0
  88. package/build/utils/pathSignatures.d.ts +22 -0
  89. package/build/utils/pathSignatures.js +57 -0
  90. package/build/utils/planMatchKeys.d.ts +16 -3
  91. package/build/utils/planMatchKeys.js +26 -10
  92. package/build/utils/pluralization.d.ts +10 -0
  93. package/build/utils/pluralization.js +18 -0
  94. package/build/utils/pom-catalog-parse.d.ts +52 -0
  95. package/build/utils/pom-catalog-parse.js +141 -0
  96. package/build/utils/pom-scope/selector-extractor.d.ts +12 -0
  97. package/build/utils/pom-scope/selector-extractor.js +34 -8
  98. package/build/utils/pom-verify/verify.d.ts +6 -5
  99. package/build/utils/pom-verify/verify.js +8 -6
  100. package/build/utils/reportLanguage.d.ts +43 -0
  101. package/build/utils/reportLanguage.js +125 -0
  102. package/build/utils/reportVerification.d.ts +74 -4
  103. package/build/utils/reportVerification.js +259 -3
  104. package/build/utils/reuseRouting.d.ts +3 -0
  105. package/build/utils/reuseRouting.js +50 -0
  106. package/build/utils/routeParsers.d.ts +2 -0
  107. package/build/utils/routeParsers.js +65 -8
  108. package/build/utils/scenarioDrafting.d.ts +1 -1
  109. package/build/utils/scenarioDrafting.js +57 -45
  110. package/build/utils/subjectEndpoints.d.ts +19 -0
  111. package/build/utils/subjectEndpoints.js +98 -0
  112. package/build/utils/testFileClassification.d.ts +11 -0
  113. package/build/utils/testFileClassification.js +47 -0
  114. package/build/utils/uiPageEnumerator.d.ts +45 -19
  115. package/build/utils/uiPageEnumerator.js +95 -51
  116. package/build/utils/utils-verify/allow.d.ts +16 -0
  117. package/build/utils/utils-verify/allow.js +68 -0
  118. package/build/utils/utils-verify/call-sites.d.ts +34 -0
  119. package/build/utils/utils-verify/call-sites.js +154 -0
  120. package/build/utils/utils-verify/index.d.ts +7 -0
  121. package/build/utils/utils-verify/index.js +7 -0
  122. package/build/utils/utils-verify/language-spec.d.ts +91 -0
  123. package/build/utils/utils-verify/language-spec.js +210 -0
  124. package/build/utils/utils-verify/locate.d.ts +39 -0
  125. package/build/utils/utils-verify/locate.js +199 -0
  126. package/build/utils/utils-verify/parse.d.ts +34 -0
  127. package/build/utils/utils-verify/parse.js +177 -0
  128. package/build/utils/utils-verify/stage.d.ts +24 -0
  129. package/build/utils/utils-verify/stage.js +107 -0
  130. package/build/utils/utils-verify/verify.d.ts +63 -0
  131. package/build/utils/utils-verify/verify.js +168 -0
  132. package/build/utils/utils.d.ts +3 -1
  133. package/build/utils/utils.js +3 -1
  134. package/build/workspace/workspace.d.ts +32 -32
  135. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +9 -5
  136. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +16 -0
  137. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -0
  138. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +115 -14
  139. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +13 -1
  140. package/node_modules/playwright/node_modules/playwright-core/.DS_Store +0 -0
  141. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -0
  142. package/node_modules/playwright/node_modules/playwright-core/lib/vite/htmlReport/index.html +27 -253
  143. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-DtudTj_v.js → codeMirrorModule-DJMC4zNo.js} +1 -1
  144. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BW82eAUI.js +196 -0
  145. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
  146. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-FNMuBzX1.js → codeMirrorModule-CZfp96qZ.js} +1 -1
  147. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-gpLo02E0.js +809 -0
  148. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.Bq1r1URj.js +2 -0
  149. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  150. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.VEfqi1qN.js +5 -0
  151. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  152. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  153. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  154. package/node_modules/playwright/package.json +1 -1
  155. package/package.json +2 -2
  156. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BpDwp16L.js +0 -422
  157. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Co9upU5h.js +0 -1035
  158. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.DXNIQ_dx.js +0 -2
  159. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CIKB3XSv.js +0 -5
@@ -10,6 +10,10 @@ import { SCENARIO_CATEGORIES, CATEGORY_PRIORITY, Novelty, PriorityTier } from ".
10
10
  import { HttpMethod, TestType } from "../../types/TestTypes.js";
11
11
  import { CandidateSource, computeCandidateId, scenarioMergeKey, DiscriminatorKind } from "../../types/Recommendation.js";
12
12
  import { selectPlan } from "../../recommendation/planRanker.js";
13
+ import { resolveSubjectEndpoints } from "../../utils/subjectEndpoints.js";
14
+ import { collectChangedRouteLines } from "../../utils/changedRoutes.js";
15
+ import { reservedUISlots } from "../../recommendation/budgeters/shared.js";
16
+ import { inferScenarioType } from "../../recommendation/diversity.js";
13
17
  import { validateDiscriminator } from "../../recommendation/discriminators.js";
14
18
  import { isAttackSurfaceSecurityBoundary } from "../../prompts/test-recommendation/recommendationShared.js";
15
19
  import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
@@ -47,7 +51,10 @@ const registerStepSchema = z.object({
47
51
  .record(z.any())
48
52
  .optional()
49
53
  .describe("Query parameters with concrete values. Also scanned for boundary_equality and negative_match verification."),
50
- responseBody: z.record(z.any()).optional().describe("Expected response body shape/values, when asserted."),
54
+ responseBody: z
55
+ .union([z.record(z.any()), z.array(z.any())])
56
+ .optional()
57
+ .describe("Expected response body shape/values, when asserted. An array for a collection/list endpoint that returns a JSON array."),
51
58
  expectedStatusCode: z.number().int().describe("HTTP status code this step asserts (e.g. 201, 400, 404)."),
52
59
  expectedResponseFields: z
53
60
  .array(z.string())
@@ -84,10 +91,12 @@ const registerTestPlanSchema = {
84
91
  .string()
85
92
  .refine((p) => path.isAbsolute(p), { message: "stateFile must be an absolute path" })
86
93
  .describe("Path to state file from skyramp_analyze_changes"),
87
- repository: z
88
- .string()
89
- .optional()
90
- .describe("The owner/repo whose analysis section to register against (e.g. 'letsramp/api-insight'). In a multi-repo run, ALWAYS set it. Omit only for a single-repo run."),
94
+ // No `repository` argument, deliberately (SKYR-4204 reopen). The approved
95
+ // plan is run-wide — one pooled registration covers every repository in the
96
+ // run — so unlike the per-repo tools (analyze_test_health, actions,
97
+ // execute_test) there is no section to select: the tool reads the root and
98
+ // derives run-wide facts (diff union, budget flags, scenario lookup) itself.
99
+ // A legacy caller still passing `repository` has it stripped harmlessly.
91
100
  candidates: z
92
101
  .array(registerCandidateSchema)
93
102
  .default([])
@@ -115,8 +124,8 @@ function toScenarioStep(step) {
115
124
  chainsFrom: step.chainsFrom,
116
125
  };
117
126
  }
118
- function toDraftedScenario(input) {
119
- return {
127
+ function toDraftedScenario(input, changedRoutes) {
128
+ const scenario = {
120
129
  scenarioName: input.scenarioName,
121
130
  description: input.description,
122
131
  category: input.category,
@@ -127,13 +136,20 @@ function toDraftedScenario(input) {
127
136
  estimatedComplexity: "moderate",
128
137
  testType: input.testType,
129
138
  };
139
+ // Resolved here, at the one boundary that has both the steps and the diff, so
140
+ // every later dedup stage reads one recorded value (SKYR-4214).
141
+ return { ...scenario, subjectEndpoints: resolveSubjectEndpoints(scenario, { changedRoutes }) };
130
142
  }
131
143
  /** Build the agent-submitted candidates. Discriminator claims are verified
132
144
  * later, by {@link applyDiscriminatorClaims}, once the merge has settled which
133
- * scenario each name resolves to. */
134
- function buildAgentCandidates(candidates) {
145
+ * scenario each name resolves to. Exported for the direct unit test in
146
+ * registerTestPlanTool.test.ts — a candidate that does not survive selection
147
+ * never reaches the persisted plan, so its subjectEndpoints fill is not
148
+ * observable through the tool's output, and testing here avoids coupling the
149
+ * fill to what selection happens to keep. */
150
+ export function buildAgentCandidates(candidates, changedRoutes) {
135
151
  return candidates.map((input) => {
136
- const scenario = toDraftedScenario(input);
152
+ const scenario = toDraftedScenario(input, changedRoutes);
137
153
  return {
138
154
  scenario,
139
155
  priority: derivePriorityTier(scenario),
@@ -226,6 +242,13 @@ function resolveBudgetContext(stateData, fullState) {
226
242
  isUIOnlyPR: pbc?.isUIOnlyPR ?? false,
227
243
  hasFrontendChanges: pbc?.hasFrontendChanges ?? false,
228
244
  externalCoverage: new Set(pbc?.externalCoverageKeys ?? []),
245
+ // `true` for the same reason `computeDiffChangesTestFiles` answers `true` with
246
+ // no diff: an unknown must shut the reserve, not open it. State written by an
247
+ // older MCP build carries no such field, and if that PR did add its own tests
248
+ // the reserve would recommend duplicates of them — the failure this flag
249
+ // exists to stop, reached by version skew alone. It is also the pre-reserve
250
+ // behaviour: covered candidates were dropped.
251
+ diffChangesTestFiles: pbc?.diffChangesTestFiles ?? true,
229
252
  };
230
253
  const relatedSections = Object.values(fullState?.relatedRepos ?? {}).map((section) => section.data);
231
254
  if (relatedSections.length === 0)
@@ -245,12 +268,23 @@ function resolveBudgetContext(stateData, fullState) {
245
268
  const hasFrontendChanges = sections.some((s) => s?.planBudgetContext?.hasFrontendChanges ?? false);
246
269
  const hasApiChanges = sections.some(sectionHasApiChanges);
247
270
  const rootPbc = fullState?.planBudgetContext;
271
+ // SKYR-4211: external-test coverage is also a per-section fact — a backend
272
+ // candidate already covered by an external test recorded in a RELATED repo's
273
+ // section must dedup the same as one covered in the primary. Union the keys
274
+ // across all sections, like the flags above.
275
+ const externalCoverage = new Set(sections.flatMap((s) => s?.planBudgetContext?.externalCoverageKeys ?? []));
248
276
  return {
249
277
  maxGenerate: rootPbc?.maxGenerate ?? base.maxGenerate,
250
278
  maxTotal: rootPbc?.maxTotal ?? base.maxTotal,
251
279
  isUIOnlyPR: hasFrontendChanges && !hasApiChanges,
252
280
  hasFrontendChanges,
253
- externalCoverage: base.externalCoverage,
281
+ externalCoverage,
282
+ // Unioned across sections like the flags above: a pull request that adds
283
+ // its own tests in ANY repo of the run must shut the reserve, or that repo's
284
+ // own coverage becomes grounds to recommend duplicates of it. A section with
285
+ // no budget context is an unknown, and an unknown shuts the reserve — the
286
+ // same direction `computeDiffChangesTestFiles` takes with no diff.
287
+ diffChangesTestFiles: sections.some((s) => s?.planBudgetContext?.diffChangesTestFiles ?? true),
254
288
  };
255
289
  }
256
290
  function describeGenerationCall(item) {
@@ -306,7 +340,7 @@ function renderGenerationDirective(plan) {
306
340
  (nonUICount > 0 ? ` Generate the ${tests(nonUICount)} of other types as well.` : ""),
307
341
  ];
308
342
  }
309
- function renderPlanText(plan) {
343
+ function renderPlanText(plan, dropped) {
310
344
  const lines = [];
311
345
  lines.push(`## Approved Test Plan (${plan.planId})`);
312
346
  lines.push("");
@@ -329,10 +363,41 @@ function renderPlanText(plan) {
329
363
  plan.additional.forEach((item, i) => lines.push(renderPlanItem(item, plan.generate.length + i + 1)));
330
364
  }
331
365
  if (plan.demotions.length > 0) {
366
+ // A demotion (failed discriminator claim) and a drop (removed by the
367
+ // budgeter for an unrelated reason — coverage or budget) are independent:
368
+ // a candidate can suffer both. Split so the report never claims a
369
+ // dropped candidate is "still registered".
370
+ const dropReasonByCandidateId = new Map(dropped.map((d) => [d.candidateId, d.reason]));
371
+ const registeredDemotions = plan.demotions.filter((d) => !dropReasonByCandidateId.has(d.candidateId));
372
+ const removedDemotions = plan.demotions.filter((d) => dropReasonByCandidateId.has(d.candidateId));
373
+ if (registeredDemotions.length > 0) {
374
+ lines.push("");
375
+ lines.push(`### Demotions (${registeredDemotions.length}) — discriminator claims that did NOT verify (candidate still registered, claim dropped)`);
376
+ for (const demotion of registeredDemotions) {
377
+ lines.push(`- ${demotion.candidateId}: ${demotion.reason}`);
378
+ }
379
+ }
380
+ if (removedDemotions.length > 0) {
381
+ lines.push("");
382
+ lines.push(`### Demoted AND removed (${removedDemotions.length}) — the claim failed to verify AND the candidate left the plan. It is NOT registered.`);
383
+ for (const demotion of removedDemotions) {
384
+ lines.push(`- ${demotion.candidateId}: claim failed — ${demotion.reason}; removed — ${dropReasonByCandidateId.get(demotion.candidateId)}`);
385
+ }
386
+ }
387
+ }
388
+ // Every other removal — budget or coverage, with no failed claim attached.
389
+ // This section is independent of `demotions`: the two channels are unrelated,
390
+ // and the motivating case has drops and NO demotions. Nesting it inside the
391
+ // demotion block returned empty GENERATE/ADDITIONAL lists with no candidate id
392
+ // and no reason, which is the state item 5 exists to make visible. Measured on
393
+ // eval run 32586610615, fixture repo15-P14: 10 drops, 0 demotions.
394
+ const demotedIds = new Set(plan.demotions.map((d) => d.candidateId));
395
+ const removedWithoutDemotion = dropped.filter((d) => !demotedIds.has(d.candidateId));
396
+ if (removedWithoutDemotion.length > 0) {
332
397
  lines.push("");
333
- lines.push(`### Demotions (${plan.demotions.length}) — discriminator claims that did NOT verify (candidate still registered, claim dropped)`);
334
- for (const demotion of plan.demotions) {
335
- lines.push(`- ${demotion.candidateId}: ${demotion.reason}`);
398
+ lines.push(`### Removed (${removedWithoutDemotion.length}) — candidates the selection stage dropped. They are NOT registered.`);
399
+ for (const drop of removedWithoutDemotion) {
400
+ lines.push(`- ${drop.candidateId}: ${drop.reason}`);
336
401
  }
337
402
  }
338
403
  lines.push("");
@@ -359,12 +424,16 @@ export function registerRegisterTestPlanTool(server) {
359
424
  }, async (params) => {
360
425
  let errorResult;
361
426
  try {
362
- logger.info(`Registering test plan${params.repository ? ` for repository ${params.repository}` : ""}`, {
427
+ logger.info("Registering run-wide test plan", {
363
428
  candidateCount: params.candidates?.length ?? 0,
364
429
  });
365
430
  const stateManager = StateManager.fromStatePath(params.stateFile);
366
- const stateData = await stateManager.readRepoData(params.repository);
367
- const repositoryPath = (await stateManager.getRepoRepositoryPath(params.repository)) || "";
431
+ // The plan is run-wide, so register always reads the ROOT (primary)
432
+ // section — there is no section selector (routing by one is what let a
433
+ // related-repo registration run with that section's context —
434
+ // SKYR-4204 reopen). Run-wide facts (diff union, budget flags,
435
+ // scenario lookup) come from fullState below.
436
+ const stateData = await stateManager.readData();
368
437
  if (!stateData) {
369
438
  errorResult = toolError(`State file is empty or invalid: ${params.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
370
439
  return errorResult;
@@ -388,9 +457,20 @@ export function registerRegisterTestPlanTool(server) {
388
457
  ];
389
458
  const diffText = diffSections.map((s) => s.diff).filter(Boolean).join("\n");
390
459
  const reposWithoutDiff = diffSections.filter((s) => !s.diff).map((s) => s.repo);
391
- const allScenarios = stateData.repositoryAnalysis?.scenarios ?? [];
392
- const agentCandidates = buildAgentCandidates(params.candidates ?? []);
393
- const serverCandidates = recoverServerCandidates(stateData.approvedPlan, allScenarios);
460
+ // Scenario lookup for server-candidate recovery spans ALL sections —
461
+ // the primary's drafted scenarios must resolve even when `repository`
462
+ // targets a related repo (SKYR-4204 reopen: the same single-section
463
+ // shape as the diff union above).
464
+ const allScenarios = [
465
+ ...(fullState?.repositoryAnalysis?.scenarios ?? []),
466
+ ...Object.values(fullState?.relatedRepos ?? {}).flatMap((section) => section.data?.repositoryAnalysis?.scenarios ?? []),
467
+ ];
468
+ const changedRoutes = collectChangedRouteLines(diffText);
469
+ const agentCandidates = buildAgentCandidates(params.candidates ?? [], changedRoutes);
470
+ // The approved plan is run-wide and persists at the ROOT (see the
471
+ // persist step below), so prior-plan recovery reads the root first;
472
+ // the section fallback covers state written by older builds.
473
+ const serverCandidates = recoverServerCandidates(fullState?.approvedPlan ?? stateData.approvedPlan, allScenarios);
394
474
  // Merge by scenario-name identity, NOT the full content-hashed candidateId:
395
475
  // the server (analyze_changes) and the agent frequently draft their own
396
476
  // independent steps[] for "the same" scenario (identical scenarioName),
@@ -433,7 +513,44 @@ export function registerRegisterTestPlanTool(server) {
433
513
  "Submit your complete candidate list (every test you would generate or recommend) in `candidates` — an empty plan would block all generation.");
434
514
  return errorResult;
435
515
  }
516
+ // Review (mcp#774): enforce ONE run-wide plan at the tool boundary. A
517
+ // re-registration whose pool shares no scenario with the active plan
518
+ // is the per-repo-partial signature (the demoshop rig's second,
519
+ // backend-only call) — accepting it would silently REPLACE the run's
520
+ // plan with a subset and drop every earlier agent candidate. Genuine
521
+ // full-list updates overlap the active plan and replace it as before.
522
+ // The overlap test spans GENERATE ∪ ADDITIONAL: with maxGenerate=0 the
523
+ // active plan's GENERATE is empty while ADDITIONAL carries every
524
+ // candidate, and a disjoint partial could otherwise still replace it
525
+ // silently (Copilot review on #774).
526
+ const priorItems = [
527
+ ...(fullState?.approvedPlan?.generate ?? []),
528
+ ...(fullState?.approvedPlan?.additional ?? []),
529
+ ];
530
+ if (priorItems.length > 0) {
531
+ const poolKeys = new Set(allCandidates.map(mergeKey));
532
+ const overlaps = priorItems.some((item) => poolKeys.has(scenarioMergeKey(item.scenarioName) || item.candidateId));
533
+ if (!overlaps) {
534
+ errorResult = toolError(`A run-wide plan is already active (${fullState.approvedPlan.planId}: ` +
535
+ `${priorItems.map((i) => i.scenarioName).join(", ")}) and this submission shares none of its ` +
536
+ "scenarios. skyramp_register_test_plan REPLACES the whole run's plan — resubmit ONE pooled candidate " +
537
+ "list for the entire run (your previous candidates plus these changes), not a per-repo subset.");
538
+ return errorResult;
539
+ }
540
+ }
436
541
  const budgetContext = resolveBudgetContext(stateData, fullState);
542
+ // SKYR-4204 reopen: a reserved UI slot is only fillable from the
543
+ // candidate pool. A pool with no UI candidate on a run with frontend
544
+ // changes would silently plan zero UI tests (runs 32419025266,
545
+ // 32431042133, 32431106864) — reject so the agent self-corrects in one
546
+ // step, exactly like the empty-candidates rejection above.
547
+ if (reservedUISlots(budgetContext) > 0 &&
548
+ !allCandidates.some((c) => inferScenarioType(c.scenario) === TestType.UI)) {
549
+ errorResult = toolError("This run has frontend changes, so the plan reserves a UI slot — but no submitted or recovered " +
550
+ "candidate has testType 'ui'. Resubmit your full candidate list including at least one UI candidate " +
551
+ "for the changed frontend (see uiContext.changedFrontendFiles).");
552
+ return errorResult;
553
+ }
437
554
  const result = selectPlan(allCandidates, { ...budgetContext, demotions, diffText });
438
555
  const approvedPlan = {
439
556
  planId: crypto.randomUUID(),
@@ -441,16 +558,25 @@ export function registerRegisterTestPlanTool(server) {
441
558
  generate: result.generate.map(buildApprovedPlanItem),
442
559
  additional: result.additional.map(buildApprovedPlanItem),
443
560
  demotions: result.demotions,
561
+ dropped: result.dropped,
444
562
  };
563
+ // The approved plan is the ONE run-wide authority (the prompt mandates
564
+ // a single pooled registration on multi-repo runs), so it persists at
565
+ // the state-file ROOT regardless of `repository`. Per-section plans
566
+ // blinded every root-reading consumer — planGuard let generation run
567
+ // unguarded, submitReportTool's reconciliation was inert, and the eval
568
+ // extractor saw "no plan" (SKYR-4204 reopen). writeData preserves the
569
+ // relatedRepos sections and the root metadata.
445
570
  try {
446
- await stateManager.writeRepoData({ ...stateData, approvedPlan }, { repo: params.repository, repositoryPath, step: TOOL_NAME });
571
+ const { metadata: _meta, relatedRepos: _sections, ...rootData } = fullState ?? {};
572
+ await stateManager.writeData({ ...rootData, approvedPlan }, { step: TOOL_NAME });
447
573
  }
448
574
  catch (error) {
449
575
  errorResult = toolError(`Failed to persist approved test plan to state file: ${error.message}. Retry this call.`);
450
576
  return errorResult;
451
577
  }
452
578
  return {
453
- content: [{ type: "text", text: renderPlanText(approvedPlan) }],
579
+ content: [{ type: "text", text: renderPlanText(approvedPlan, result.dropped) }],
454
580
  };
455
581
  }
456
582
  catch (error) {
@@ -1,4 +1,4 @@
1
- import { DraftedScenario } from "../types/RepositoryAnalysis.js";
1
+ import { DraftedScenario, SubjectEndpoint } from "../types/RepositoryAnalysis.js";
2
2
  import { Novelty, PriorityTier } from "../types/TestRecommendation.js";
3
3
  /**
4
4
  * The structural bug-shape a candidate claims to probe. Verified against the
@@ -53,21 +53,42 @@ export interface BudgetContext {
53
53
  hasFrontendChanges: boolean;
54
54
  /** Method-aware coverage keys of external tests; matching candidates are dropped. */
55
55
  externalCoverage: Set<string>;
56
+ /**
57
+ * Whether the PR's own diff changes any test file. External coverage is
58
+ * discovered from the working tree, so a test the PR itself adds is
59
+ * indistinguishable from pre-existing coverage; when this is true the
60
+ * covered-candidate reserve stays shut rather than forcing duplicates of a
61
+ * test the author already wrote.
62
+ */
63
+ diffChangesTestFiles: boolean;
56
64
  }
57
65
  /** A demoted claim: which candidate, and the human-readable reason returned to
58
- * the agent so it can fix the claim (never a silent drop). */
66
+ * the agent so it can fix the claim (never a silent drop of the claim itself).
67
+ * A demotion records that a declared discriminator claim failed to verify.
68
+ * It does not say whether the candidate is still in the plan. Budgeting
69
+ * decides that separately. A candidate can be both demoted and dropped. */
59
70
  export interface Demotion {
60
71
  candidateId: string;
61
72
  reason: string;
62
73
  }
74
+ /** A candidate the selection stage removed, and why. A demotion annotates a
75
+ * claim. A drop removes a candidate from `generate` and `additional`. The
76
+ * two are independent. The same candidate can carry both. */
77
+ export interface Dropped {
78
+ candidateId: string;
79
+ reason: string;
80
+ }
63
81
  /** Result of budgeting: the GENERATE set, the ADDITIONAL set, how many UI
64
- * placeholder slots the render layer should add (mixed/UI-only PRs), and the
65
- * demoted-claim channel (the missing rejection surface for register-plan). */
82
+ * placeholder slots the render layer should add (mixed/UI-only PRs), the
83
+ * demoted-claim channel (the missing rejection surface for register-plan),
84
+ * and the dropped-candidate channel (SKYR-4214: every candidate the
85
+ * selection stage removed outright, with a human-actionable reason). */
66
86
  export interface SelectionResult {
67
87
  generate: Candidate[];
68
88
  additional: Candidate[];
69
89
  reservedUISlots: number;
70
90
  demotions: Demotion[];
91
+ dropped: Dropped[];
71
92
  }
72
93
  /** A pluggable budgeting algorithm. Deterministic and pure. */
73
94
  export interface Budgeter {
@@ -105,7 +126,14 @@ export declare function scenarioMergeKey(scenarioName: string | undefined): stri
105
126
  export declare function computeCandidateId(scenario: DraftedScenario): string;
106
127
  /** One approved plan item as persisted in `UnifiedAnalysisState.approvedPlan`.
107
128
  * Deliberately slim (no steps/requestBody/etc.) — `matchKeys` already carries
108
- * everything a downstream matcher needs. */
129
+ * everything a downstream matcher needs.
130
+ *
131
+ * `subjectEndpoints` is the one exception. `matchKeys` holds one key per
132
+ * step and is deliberately broad, so a generated test can be matched back
133
+ * to its plan item. It does not say which step is the subject. Dedup
134
+ * judges the subject, not the whole step list, so the plan item must
135
+ * record it too. See `DraftedScenario.subjectEndpoints` for the
136
+ * absent-vs-empty contract this field carries over unchanged. */
109
137
  export interface ApprovedPlanItem {
110
138
  candidateId: string;
111
139
  scenarioName: string;
@@ -113,5 +141,6 @@ export interface ApprovedPlanItem {
113
141
  category: string;
114
142
  source: CandidateSource;
115
143
  verifiedDiscriminator?: string;
144
+ subjectEndpoints?: SubjectEndpoint[];
116
145
  matchKeys: string[];
117
146
  }