@skyramp/mcp 0.3.8 → 0.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/build/commands/commandLibrary.d.ts +1 -1
  2. package/build/commands/commandLibrary.js +3 -3
  3. package/build/commands/recommendTestsAndExecuteCommand.d.ts +1 -1
  4. package/build/commands/recommendTestsAndExecuteCommand.js +35 -20
  5. package/build/commands/testThisEndpointCommand.js +35 -19
  6. package/build/index.js +9 -3
  7. package/build/playwright/blueprintDigest.d.ts +15 -0
  8. package/build/playwright/blueprintDigest.js +152 -0
  9. package/build/playwright/blueprintDigestStore.d.ts +31 -0
  10. package/build/playwright/blueprintDigestStore.js +117 -0
  11. package/build/playwright/registerPlaywrightTools.js +60 -12
  12. package/build/playwright/traceRecordingPrompt.js +8 -7
  13. package/build/prompts/enhance-assertions/sharedAssertionRules.js +9 -8
  14. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -2
  15. package/build/prompts/promptAssets.d.ts +20 -0
  16. package/build/prompts/promptAssets.js +55 -0
  17. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +19 -5
  18. package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
  19. package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
  20. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +0 -10
  21. package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -11
  22. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +8 -4
  23. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +5 -22
  24. package/build/prompts/test-recommendation/diffExecutionPlan.js +37 -465
  25. package/build/prompts/test-recommendation/recommendationSections.d.ts +7 -17
  26. package/build/prompts/test-recommendation/recommendationSections.js +67 -309
  27. package/build/prompts/test-recommendation/recommendationShared.d.ts +19 -47
  28. package/build/prompts/test-recommendation/recommendationShared.js +49 -155
  29. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.d.ts +0 -5
  30. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +10 -153
  31. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -29
  32. package/build/prompts/test-recommendation/test-recommendation-prompt.js +32 -457
  33. package/build/prompts/testbot/planDeclarations.d.ts +6 -0
  34. package/build/prompts/testbot/planDeclarations.js +9 -0
  35. package/build/prompts/testbot/testbot-prompts.d.ts +8 -0
  36. package/build/prompts/testbot/testbot-prompts.js +256 -381
  37. package/build/recommendation/answers.d.ts +35 -0
  38. package/build/recommendation/answers.js +96 -0
  39. package/build/recommendation/registerPlan.d.ts +49 -0
  40. package/build/recommendation/registerPlan.js +117 -0
  41. package/build/recommendation/runVerifiers.d.ts +10 -0
  42. package/build/recommendation/runVerifiers.js +49 -0
  43. package/build/recommendation/subjectStep.d.ts +42 -0
  44. package/build/recommendation/subjectStep.js +86 -0
  45. package/build/recommendation/types.d.ts +163 -0
  46. package/build/recommendation/types.js +20 -0
  47. package/build/recommendation/verifierContracts.d.ts +382 -0
  48. package/build/recommendation/verifierContracts.js +263 -0
  49. package/build/recommendation/verifiers/changedFile.d.ts +2 -0
  50. package/build/recommendation/verifiers/changedFile.js +82 -0
  51. package/build/recommendation/verifiers/citedPath.d.ts +12 -0
  52. package/build/recommendation/verifiers/citedPath.js +35 -0
  53. package/build/recommendation/verifiers/coverage.d.ts +7 -0
  54. package/build/recommendation/verifiers/coverage.js +617 -0
  55. package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +11 -0
  56. package/build/recommendation/verifiers/deliveredMatchesPlan.js +33 -0
  57. package/build/recommendation/verifiers/endpointGrounded.d.ts +17 -0
  58. package/build/recommendation/verifiers/endpointGrounded.js +128 -0
  59. package/build/recommendation/verifiers/existingCoverage.d.ts +6 -0
  60. package/build/recommendation/verifiers/existingCoverage.js +51 -0
  61. package/build/recommendation/verifiers/expectedOutcome.d.ts +31 -0
  62. package/build/recommendation/verifiers/expectedOutcome.js +105 -0
  63. package/build/recommendation/verifiers/removedElementGuarded.d.ts +2 -0
  64. package/build/recommendation/verifiers/removedElementGuarded.js +57 -0
  65. package/build/recommendation/verifiers/reportedCategory.d.ts +26 -0
  66. package/build/recommendation/verifiers/reportedCategory.js +84 -0
  67. package/build/recommendation/verifiers/screenRoute.d.ts +10 -0
  68. package/build/recommendation/verifiers/screenRoute.js +118 -0
  69. package/build/recommendation/verifiers/statedDifference.d.ts +6 -0
  70. package/build/recommendation/verifiers/statedDifference.js +140 -0
  71. package/build/recommendation/verifiers/uiElementGrounded.d.ts +7 -0
  72. package/build/recommendation/verifiers/uiElementGrounded.js +318 -0
  73. package/build/resources/analysisResources.js +1 -114
  74. package/build/resources/testbotResource.js +23 -13
  75. package/build/services/ModularizationService.js +2 -1
  76. package/build/services/TestDiscoveryService.d.ts +3 -72
  77. package/build/services/TestDiscoveryService.js +10 -303
  78. package/build/services/containerEnv.d.ts +1 -1
  79. package/build/services/containerEnv.js +12 -0
  80. package/build/skills/fixTestImportErrorsSkill.d.ts +13 -0
  81. package/build/skills/fixTestImportErrorsSkill.js +20 -0
  82. package/build/toolNames.d.ts +1 -0
  83. package/build/toolNames.js +1 -0
  84. package/build/tools/code-refactor/enhanceAssertionsTool.js +3 -3
  85. package/build/tools/code-refactor/modularizationTool.js +2 -1
  86. package/build/tools/executeSkyrampTestTool.d.ts +80 -0
  87. package/build/tools/executeSkyrampTestTool.js +246 -19
  88. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +6 -0
  89. package/build/tools/generate-tests/generateContractRestTool.js +3 -3
  90. package/build/tools/generate-tests/planGuard.d.ts +2 -2
  91. package/build/tools/generate-tests/planGuard.js +78 -18
  92. package/build/tools/one-click/oneClickTool.d.ts +0 -1
  93. package/build/tools/one-click/oneClickTool.js +0 -5
  94. package/build/tools/submitReportTool.d.ts +48 -42
  95. package/build/tools/submitReportTool.js +576 -193
  96. package/build/tools/test-management/actionsTool.js +72 -4
  97. package/build/tools/test-management/analyzeChangesTool.d.ts +144 -48
  98. package/build/tools/test-management/analyzeChangesTool.js +212 -1219
  99. package/build/tools/test-management/analyzeTestHealthTool.js +13 -24
  100. package/build/tools/test-management/index.d.ts +1 -0
  101. package/build/tools/test-management/index.js +1 -0
  102. package/build/tools/test-management/registerTestPlanTool.d.ts +795 -172
  103. package/build/tools/test-management/registerTestPlanTool.js +609 -542
  104. package/build/tools/test-management/resolveScreenTool.d.ts +75 -0
  105. package/build/tools/test-management/resolveScreenTool.js +289 -0
  106. package/build/types/BlueprintDigest.d.ts +34 -0
  107. package/build/types/BlueprintDigest.js +1 -0
  108. package/build/types/RepositoryAnalysis.d.ts +20 -1559
  109. package/build/types/RepositoryAnalysis.js +2 -58
  110. package/build/types/StepMethod.d.ts +40 -0
  111. package/build/types/StepMethod.js +77 -0
  112. package/build/types/TestAnalysis.d.ts +12 -0
  113. package/build/types/TestExecution.d.ts +4 -0
  114. package/build/types/TestRecommendation.d.ts +24 -24
  115. package/build/types/TestRecommendation.js +91 -89
  116. package/build/types/TestbotPromptOptions.d.ts +0 -4
  117. package/build/types/TestbotReport.d.ts +64 -2
  118. package/build/utils/AnalysisStateManager.d.ts +79 -113
  119. package/build/utils/AnalysisStateManager.js +147 -57
  120. package/build/utils/assertion-verify/api-shared-lints.js +1 -1
  121. package/build/utils/assertion-verify/metrics.js +85 -36
  122. package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
  123. package/build/utils/assertion-verify/ui-lints.js +32 -0
  124. package/build/utils/branchDiff.d.ts +63 -31
  125. package/build/utils/branchDiff.js +242 -94
  126. package/build/utils/containedPath.d.ts +18 -0
  127. package/build/utils/containedPath.js +73 -0
  128. package/build/utils/dartRouteExtractor.d.ts +18 -34
  129. package/build/utils/dartRouteExtractor.js +101 -173
  130. package/build/utils/featureFlags.d.ts +12 -0
  131. package/build/utils/featureFlags.js +14 -0
  132. package/build/utils/frontendSelectors.d.ts +48 -27
  133. package/build/utils/frontendSelectors.js +241 -80
  134. package/build/utils/pathMatching.d.ts +2 -4
  135. package/build/utils/pathMatching.js +2 -4
  136. package/build/utils/planMatchKeys.d.ts +38 -47
  137. package/build/utils/planMatchKeys.js +143 -81
  138. package/build/utils/rebaselineSnapshots.d.ts +24 -0
  139. package/build/utils/rebaselineSnapshots.js +65 -0
  140. package/build/utils/removedUiElements.d.ts +22 -0
  141. package/build/utils/removedUiElements.js +106 -0
  142. package/build/utils/reportVerification.d.ts +2 -6
  143. package/build/utils/reportVerification.js +61 -2
  144. package/build/utils/screenRoutes.d.ts +66 -0
  145. package/build/utils/screenRoutes.js +727 -0
  146. package/build/utils/sourceRouteExtractor.js +320 -112
  147. package/build/utils/testFileClassification.d.ts +11 -2
  148. package/build/utils/testFileClassification.js +44 -2
  149. package/build/utils/testFixtures.d.ts +5 -0
  150. package/build/utils/testFixtures.js +13 -0
  151. package/build/utils/utils.d.ts +0 -1
  152. package/build/utils/utils.js +0 -11
  153. package/build/utils/versions.d.ts +3 -3
  154. package/build/utils/versions.js +1 -1
  155. package/build/workspace/workspace.d.ts +12 -12
  156. package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
  157. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
  158. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
  159. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
  160. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
  161. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
  162. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
  163. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
  164. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
  165. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
  166. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  167. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  168. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  169. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
  170. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
  171. package/node_modules/playwright/package.json +1 -1
  172. package/package.json +4 -3
  173. package/plugin/.claude-plugin/plugin.json +8 -0
  174. package/plugin/plugin.json +6 -0
  175. package/plugin/prompts/declaring-a-plan.md +20 -0
  176. package/plugin/prompts/generate-tests/context-fetching.md +4 -0
  177. package/plugin/prompts/generate-tests/execution-plan.md +63 -0
  178. package/plugin/prompts/generate-tests/generation.md +108 -0
  179. package/plugin/prompts/generate-tests/path-parameters.md +1 -0
  180. package/plugin/prompts/generate-tests/reasoning-protocol.md +17 -0
  181. package/plugin/prompts/generate-tests/tool-workflow-variants.md +61 -0
  182. package/plugin/prompts/generate-tests/tool-workflows.md +65 -0
  183. package/plugin/prompts/plan-tests.md +42 -0
  184. package/plugin/prompts/testbot-task1.md +82 -0
  185. package/plugin/skills/fix-test-import-errors/SKILL.md +98 -0
  186. package/build/prompts/test-recommendation/analysisOutputPrompt.d.ts +0 -84
  187. package/build/prompts/test-recommendation/analysisOutputPrompt.js +0 -369
  188. package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +0 -7
  189. package/build/prompts/test-recommendation/fullRepoCatalog.js +0 -283
  190. package/build/prompts/test-recommendation/scopeAssessment.d.ts +0 -81
  191. package/build/prompts/test-recommendation/scopeAssessment.js +0 -359
  192. package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +0 -7
  193. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +0 -105
  194. package/build/recommendation/budgeters/fixedNBudgeter.d.ts +0 -7
  195. package/build/recommendation/budgeters/fixedNBudgeter.js +0 -11
  196. package/build/recommendation/budgeters/shared.d.ts +0 -32
  197. package/build/recommendation/budgeters/shared.js +0 -246
  198. package/build/recommendation/discriminators.d.ts +0 -37
  199. package/build/recommendation/discriminators.js +0 -379
  200. package/build/recommendation/diversity.d.ts +0 -47
  201. package/build/recommendation/diversity.js +0 -101
  202. package/build/recommendation/planRanker.d.ts +0 -65
  203. package/build/recommendation/planRanker.js +0 -83
  204. package/build/recommendation/testFixtures.d.ts +0 -25
  205. package/build/recommendation/testFixtures.js +0 -45
  206. package/build/types/FrontendIntegration.d.ts +0 -28
  207. package/build/types/FrontendIntegration.js +0 -22
  208. package/build/types/Recommendation.d.ts +0 -146
  209. package/build/types/Recommendation.js +0 -74
  210. package/build/utils/changedRoutes.d.ts +0 -29
  211. package/build/utils/changedRoutes.js +0 -87
  212. package/build/utils/frontendIntegration.d.ts +0 -9
  213. package/build/utils/frontendIntegration.js +0 -243
  214. package/build/utils/importerHop.d.ts +0 -135
  215. package/build/utils/importerHop.js +0 -489
  216. package/build/utils/pathAffinityClassification.d.ts +0 -49
  217. package/build/utils/pathAffinityClassification.js +0 -180
  218. package/build/utils/pythonMountPrefixes.d.ts +0 -25
  219. package/build/utils/pythonMountPrefixes.js +0 -347
  220. package/build/utils/repoScanner.d.ts +0 -34
  221. package/build/utils/repoScanner.js +0 -300
  222. package/build/utils/routeParsers.d.ts +0 -95
  223. package/build/utils/routeParsers.js +0 -951
  224. package/build/utils/scenarioDrafting.d.ts +0 -92
  225. package/build/utils/scenarioDrafting.js +0 -951
  226. package/build/utils/subjectEndpoints.d.ts +0 -19
  227. package/build/utils/subjectEndpoints.js +0 -98
  228. package/build/utils/uiPageEnumerator.d.ts +0 -172
  229. package/build/utils/uiPageEnumerator.js +0 -474
@@ -3,26 +3,27 @@ import { logger } from "../utils/logger.js";
3
3
  import * as fs from "fs/promises";
4
4
  import * as path from "path";
5
5
  import { AnalyticsService } from "../services/AnalyticsService.js";
6
- import { TEST_CATEGORIES, externalCategory, } from "../types/TestRecommendation.js";
7
- import { TestType, HttpMethod } from "../types/TestTypes.js";
6
+ import { SCENARIO_CATEGORIES, categoryExpectsRed, } from "../types/TestRecommendation.js";
7
+ import { TestType } from "../types/TestTypes.js";
8
+ import { STEP_METHOD_DESCRIPTION, stepMethodSchema } from "../types/StepMethod.js";
8
9
  import { DriftAction } from "../types/TestAnalysis.js";
9
10
  import { TestExecutionStatus } from "../types/TestExecution.js";
10
11
  import { IssueFoundCategory } from "../types/TestbotReport.js";
11
- import { StateManager, runArtifactDir, getTestsRepoDir, } from "../utils/AnalysisStateManager.js";
12
+ import { StateManager, runArtifactDir, getTestsRepoDir, resolveRunStatePath, } from "../utils/AnalysisStateManager.js";
12
13
  import { toolError, testFileMatches } from "../utils/utils.js";
13
- import { matchesApprovedPlan } from "../utils/planMatchKeys.js";
14
- import { isTestbotEnabled } from "../utils/featureFlags.js";
14
+ import { isTestbotEnabled, } from "../utils/featureFlags.js";
15
+ import { answerFor, unknownAnswerObjections } from "../recommendation/answers.js";
16
+ import { checkDeliveredMatchesPlan } from "../recommendation/verifiers/deliveredMatchesPlan.js";
17
+ import { checkReportedCategoryMatchesPlan, checkRequirementConflictReported, } from "../recommendation/verifiers/reportedCategory.js";
18
+ import { checkExpectedOutcomeAfterExecution, } from "../recommendation/verifiers/expectedOutcome.js";
15
19
  import { findInvalidSourceCitations, findUnchangedFileClaims, listChangedFiles, listChangedFilesAcross, listChangedFilesAbs, } from "../utils/reportVerification.js";
16
20
  import { isPlanOnlyMode } from "../utils/planOnlyMode.js";
17
21
  import { getReportLanguage, isEnforcedReportLanguage, findLanguageViolations, findLanguageNearMisses, reportLanguageDisplayName, } from "../utils/reportLanguage.js";
18
22
  import { canonicalTestPath, findAssertionRecordByFileName, rederiveAssertionOutcome, } from "./code-refactor/assertion-state.js";
19
23
  import { rederiveReuseOutcome, reuseChainSkipped, samePath, } from "./code-refactor/reuse-state.js";
20
24
  import { retrofitGate, } from "./code-refactor/retrofit-state.js";
21
- // SKYR-3879 Path B: which testTypes the register-plan checkpoint gates. Mirrors
22
- // the generation tools actually wired to planGuard (batch-scenario/integration,
23
- // contract) — UI and E2E are on a separate blueprint-grounded pipeline and are
24
- // NOT gated at generation time (see planGuard.ts wiring), so they are excluded
25
- // here too rather than surprising the agent with a report-time-only gate.
25
+ // Mirrors the tools wired to planGuard: UI and E2E are not gated at generation
26
+ // time, so gating them here would be a report-time-only surprise.
26
27
  const PLAN_GATED_TEST_TYPES = new Set([
27
28
  TestType.CONTRACT,
28
29
  TestType.INTEGRATION,
@@ -41,44 +42,6 @@ const PLAN_GATED_TEST_TYPES = new Set([
41
42
  * `RUN_STATE_FILE_NAME` / testbot's `STATE_FILE_NAME`.
42
43
  */
43
44
  export const REPORT_FILE_NAME = "testbot-result.txt";
44
- /**
45
- * The names to match a report entry against the approved plan's `name:` keys.
46
- * `testId` is documented as `<testType>-<scenario-slug>`, so the plan's bare
47
- * scenarioName only matches once that prefix is dropped — but agents also submit
48
- * the bare name (both forms appear in SKYR-4123's runs), so try each.
49
- */
50
- function planNameCandidates(testId, testType) {
51
- const id = (testId ?? "").trim();
52
- if (!id)
53
- return [];
54
- const prefix = `${testType}-`;
55
- return id.toLowerCase().startsWith(prefix)
56
- ? [id, id.slice(prefix.length)]
57
- : [id];
58
- }
59
- /**
60
- * Split an `endpoint` field into one {method, path} per endpoint it names.
61
- * A multi-step test writes them comma-separated ("POST /orders, DELETE
62
- * /orders/{id}"); read as a single endpoint the whole string becomes one
63
- * unmatchable path, so such an entry could match nothing (SKYR-4123).
64
- */
65
- function parseEndpointField(endpoint) {
66
- const parts = (endpoint ?? "")
67
- .split(",")
68
- .map((p) => p.trim())
69
- .filter(Boolean);
70
- if (parts.length === 0)
71
- return [{}];
72
- return parts.map((part) => {
73
- const spaceIdx = part.indexOf(" ");
74
- if (spaceIdx <= 0)
75
- return { path: part };
76
- return {
77
- method: part.slice(0, spaceIdx),
78
- path: part.slice(spaceIdx + 1).trim(),
79
- };
80
- });
81
- }
82
45
  // Drift actions that actually modify a test file. VERIFY and IGNORE are
83
46
  // no-ops (the test was assessed but left unchanged), so they must not count
84
47
  // toward "tests maintained" telemetry.
@@ -87,17 +50,20 @@ const MAINTENANCE_CHANGE_ACTIONS = new Set([
87
50
  DriftAction.Regenerate,
88
51
  DriftAction.Delete,
89
52
  ]);
53
+ /** Objection ids for an untested declared change, `coverage:change:<id>`. The
54
+ * change table reads the agent's answer back off them. */
55
+ const CHANGE_OBJECTION_PREFIX = "coverage:change:";
90
56
  const TOOL_NAME = "skyramp_submit_report";
91
57
  const DEFAULT_COMMIT_MESSAGE = "Added recommendations by Skyramp Testbot.";
92
58
  // Per-repo attribution. In a multi-repo run, every report item carries the
93
- // owner/repo it pertains to (the primary's owner/repo for primary findings, or a
94
- // related repo's owner/repo for findings from that repo's diff). Whitespace-only
59
+ // owner/repo it pertains to (the primary's owner/repo for primary objections, or a
60
+ // related repo's owner/repo for objections from that repo's diff). Whitespace-only
95
61
  // values are normalized to undefined so a blank string is never treated as a
96
62
  // distinct repo. Single-repo runs may omit it.
97
63
  const repositoryField = z
98
64
  .string()
99
65
  .optional()
100
- .describe("The 'owner/repo' this item pertains to (e.g. 'letsramp/api-insight'). In a multi-repo run, ALWAYS set it — the primary's owner/repo for primary-repo findings, or a related repo's owner/repo for findings derived from that repository's diff. May be omitted only in single-repo runs.");
66
+ .describe("The 'owner/repo' this item pertains to (e.g. 'letsramp/api-insight'). In a multi-repo run, ALWAYS set it — the primary's owner/repo for primary-repo objections, or a related repo's owner/repo for objections derived from that repository's diff. May be omitted only in single-repo runs.");
101
67
  /** Normalize a `repository` attribution: trim, and map a blank/whitespace-only
102
68
  * value to undefined so it is never serialized as a distinct empty repo
103
69
  * (downstream consumers treat absence as "the primary repo"). */
@@ -116,7 +82,7 @@ const testResultSchema = z.object({
116
82
  .describe("Type of test. Do not include priority or other metadata in this field."),
117
83
  endpoint: z
118
84
  .string()
119
- .describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
85
+ .describe("For contract and integration tests, the HTTP verb and path, e.g. 'GET /api/v1/products'. For ui and e2e tests, the page path the test opens, e.g. '/notifications'."),
120
86
  status: z.enum(["Pass", "Fail", "Skipped"]).describe("Test execution result"),
121
87
  details: z
122
88
  .string()
@@ -131,15 +97,23 @@ const testResultSchema = z.object({
131
97
  message: "testFilePath must be an absolute path",
132
98
  })
133
99
  .describe("Absolute path of the test file this result is for — the same path you passed to skyramp_execute_test's testFile param. Consumers basename it for display."),
100
+ reachedBehavior: z
101
+ .boolean()
102
+ .optional()
103
+ .describe("For a failing test only. `true` when the request that carries the behaviour under test was sent and answered by the code under test, and the assertion that failed is about that behaviour. " +
104
+ "`false` when the failure came earlier: a 404, 401, 403 or 5xx on a route the change adds; an expired token or a sign-in page; a control or setup step that failed; a generator or SDK error. " +
105
+ "If your own `details` text says the request was not sent or an earlier step tripped, this is `false`. Omit only if you cannot tell."),
134
106
  repository: repositoryField,
135
107
  });
136
108
  // Structured grounding for UI recommendations. Lifted verbatim from a
137
109
  // `browser_blueprint` element captured during this run. Source of truth for
138
110
  // codegen (prefers testId > stableId > role+name for locator selection),
139
- // the verifier (cross-checks each field against captured blueprints), and
111
+ // the verifier (matches on `testId`, `stableId`, accessible name, or role plus
112
+ // a repeating row's `contextText` — it reads no other field here), and
140
113
  // any future tooling that wants reliable grounding data without prose
141
114
  // parsing. `reasoning` is free-form prose constrained by the Blueprint
142
- // Citation Invariant — every element cited must appear in `targetElements`.
115
+ // Citation Invariant — every element cited SHOULD appear in `targetElements`.
116
+ // Prose only: nothing checks it, and it is on the list of unchecked rules.
143
117
  // See testbot prompt step 4 for the full populate-and-render rules.
144
118
  /**
145
119
  * Accept an omitted key as an explicit `null`.
@@ -158,7 +132,7 @@ export const targetElementSchema = z.object({
158
132
  .describe("ARIA role (e.g. 'button', 'heading', 'textbox', 'link'). Lifted verbatim from the captured blueprint element's `role` field."),
159
133
  accessibleName: z
160
134
  .string()
161
- .describe("Computed accessible name (e.g. 'Save changes', 'Order Details'). Lifted verbatim from the captured blueprint element's `accessibleName` field. Must match the bolded name in `reasoning` character-for-character."),
135
+ .describe("Computed accessible name (e.g. 'Save changes', 'Order Details'). Lifted verbatim from the captured blueprint element's `accessibleName` field. In a REPORT entry, match the bolded name in `reasoning` character-for-character. A plan planned test has no `reasoning` field, so that half does not apply there."),
162
136
  testId: nullWhenAbsent(z.string()).describe("data-testid attribute value (preferred locator handle), or null when the element has no data-testid. Lifted from the blueprint element's `testId`."),
163
137
  stableId: nullWhenAbsent(z.string()).describe("Unique HTML id attribute value (fallback locator when no testId), or null. Lifted from the blueprint element's `stableId`."),
164
138
  contextText: nullWhenAbsent(z.array(z.string())).describe("Disambiguating row text for elements inside repeating sections (table rows, list items): the row's surrounding non-interactive text. Lifted from the blueprint repeatingElement's items[].contextText. null for non-repeating elements."),
@@ -181,21 +155,13 @@ export const pageContextSchema = z.object({
181
155
  pageHash: z
182
156
  .string()
183
157
  .optional()
184
- .describe("Opaque hash of the captured page state (BlueprintCapture.pageHash). Lets the verifier confirm the recommendation was grounded in a still-current capture."),
158
+ .describe("Opaque hash of the captured page state (BlueprintCapture.pageHash). Recorded for a reader; NOT checked — the element check pools every capture of the page rather than narrowing by hash, so a stale one costs nothing. The tool that verifies elements says the same."),
185
159
  });
186
160
  /**
187
- * SKYR-4193: LLMs habitually emit every key a schema declares, using a
188
- * literal null for "not applicable". A null is stripped (normalized to
189
- * omitted) before validation wherever null carries no meaning, instead of
190
- * costing the agent a rejection/rewrite round-trip (run 32182635458 lost
191
- * ~2m40s to exactly this):
192
- * - `pageContext: null` is always stripped — the field is optional but never
193
- * nullable, so null is never a valid value, UI or not.
194
- * - `targetElements: null` is stripped only on non-UI entries — on UI tests
195
- * null is load-bearing ("blueprint capture failed", requires the
196
- * '[no-blueprint-data]' markers) and must reach the refinements intact.
197
- * Non-null values on non-UI entries still reach the refinements and are
198
- * still rejected.
161
+ * A null that carries no meaning is stripped to omitted before validation.
162
+ * `pageContext: null` always: the field is optional, never nullable.
163
+ * `targetElements: null` on non-UI entries only — on a UI entry the null MEANS
164
+ * capture failed, so it must reach the refinements intact.
199
165
  */
200
166
  function stripNullGroundingFields(val) {
201
167
  if (!val || typeof val !== "object" || Array.isArray(val))
@@ -220,21 +186,27 @@ export const newTestSchema = z.preprocess(stripNullGroundingFields, z
220
186
  testId: z
221
187
  .string()
222
188
  .describe("Human-readable kebab-case identifier, e.g. 'contract-get-products' or 'integration-users-orders-workflow'. Format: '<testType>-<method>-<resource>' for single-endpoint tests or '<testType>-<scenario-slug>' for multi-step tests. Must be unique within the report."),
189
+ plannedTestId: z
190
+ .string()
191
+ .optional()
192
+ .describe("The `plannedTestId` of the plan entry this test was approved under. Copy it from the plan you submitted to skyramp_register_test_plan — never invent one. " +
193
+ "Required on EVERY entry, UI included. That is deliberately wider than the approved-plan generation gate, which stays contract and integration only: " +
194
+ "this id is the exact key the report is joined back to the plan on, and an entry without one falls back to guessing from the test name and endpoint."),
223
195
  testType: z
224
196
  .nativeEnum(TestType)
225
197
  .describe("Type of test created. Do not include priority or other metadata in this field."),
226
198
  category: z
227
- .preprocess((val) => externalCategory(val), z.enum(TEST_CATEGORIES))
228
- .describe("Test category — critical categories (security_boundary, business_rule, data_integrity, breaking_change) get generation priority over workflow"),
199
+ .enum(SCENARIO_CATEGORIES)
200
+ .describe("Test category. Write the same category the plan declared."),
229
201
  endpoint: z
230
202
  .string()
231
- .describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
203
+ .describe("For contract and integration tests, the HTTP verb and path, e.g. 'GET /api/v1/products'. For ui and e2e tests, the page path the test opens, e.g. '/notifications'."),
232
204
  fileName: z.string().describe("Name of the generated test file"),
233
205
  description: z
234
206
  .string()
235
207
  .trim()
236
208
  .min(1)
237
- .describe("What the test does — the steps and assertions, not the bugs it finds. e.g. 'Creates a collection, adds a link, then verifies the link exists'. Do NOT describe expected failures or bugs here — those belong in issuesFound."),
209
+ .describe("What the test does — the steps and assertions, not the bugs it finds. e.g. 'Creates a collection, adds a link, then verifies the link exists'. Do NOT describe expected failures or bugs here — those belong in issuesFound. Name any UI element the way a person would, e.g. the disabled 'Mark all as read' button — never a builder term such as blueprint or DOM analyzer, and never identifier syntax such as role=button or accessibleName=X."),
238
210
  scenarioFile: z
239
211
  .string()
240
212
  .optional()
@@ -249,19 +221,30 @@ export const newTestSchema = z.preprocess(stripNullGroundingFields, z
249
221
  .describe("Path to the Playwright/UI trace file if used or created"),
250
222
  reasoning: z
251
223
  .string()
252
- .describe("Why this test was created: what production risk it mitigates, what code pattern it targets, or what coverage gap it fills"),
224
+ .describe("Why this test was created: what production risk it mitigates, what code pattern it targets, or what coverage gap it fills. Name any UI element the way a person would, e.g. the disabled 'Mark all as read' button — never a builder term such as blueprint or DOM analyzer, and never identifier syntax such as role=button or accessibleName=X."),
253
225
  repository: repositoryField,
254
226
  targetElements: z
255
227
  .array(targetElementSchema)
256
228
  .min(1)
257
229
  .nullable()
258
230
  .optional()
259
- .describe("UI tests only: structured grounding for one or more elements the test targets. Most tests target a single element (array length 1); render-state and multi-step UI tests target several (array length 2+). Each entry must be lifted verbatim from a captured blueprint element. Set to null when blueprint capture failed (also requires '[no-blueprint-data]' marker in BOTH description and reasoning). See Blueprint Citation Invariant in testbot prompt."),
231
+ .describe("UI tests only: structured grounding for one or more elements the test targets. Most tests target a single element (array length 1); render-state and multi-step UI tests target several (array length 2+). Each entry must be lifted verbatim from a captured blueprint element. Set to null when blueprint capture failed — the null IS the signal, so put no marker in description or reasoning. See Blueprint Citation Invariant in testbot prompt."),
260
232
  pageContext: pageContextSchema
261
233
  .optional()
262
234
  .describe("UI tests only: page metadata for the test. Lifted from the BlueprintCapture used during grounding."),
263
235
  })
264
236
  .superRefine((rec, ctx) => {
237
+ // The join to the stored plan is this id and nothing else. Required for
238
+ // EVERY testType: the generation gate covers only contract and integration,
239
+ // and UI is where a delivery drifts furthest from the plan. Declared
240
+ // optional above and required here so the message says what to do.
241
+ if ((rec.plannedTestId ?? "").trim().length === 0) {
242
+ ctx.addIssue({
243
+ code: z.ZodIssueCode.custom,
244
+ path: ["plannedTestId"],
245
+ message: "plannedTestId is required on every newTestsCreated entry. Use the plannedTestId of the plan entry this test was approved under, from skyramp_register_test_plan.",
246
+ });
247
+ }
265
248
  // targetElements / pageContext are UI-only.
266
249
  if (rec.testType !== TestType.UI) {
267
250
  for (const field of ["targetElements", "pageContext"]) {
@@ -288,10 +271,12 @@ export const newTestSchema = z.preprocess(stripNullGroundingFields, z
288
271
  ctx.addIssue({
289
272
  code: z.ZodIssueCode.custom,
290
273
  path: ["targetElements"],
291
- message: "targetElements is required for testType: 'ui'. Use null when blueprint capture failed (and add '[no-blueprint-data]' to both description and reasoning).",
274
+ message: "targetElements is required for testType: 'ui'. Use null when blueprint capture failed; the null is the signal and needs no marker in description or reasoning.",
292
275
  });
293
276
  }
294
- // pageContext is required when targetElements is an array (grounded), forbidden when null.
277
+ // Required beside an array, and ALLOWED beside null: the plan schema asks a
278
+ // UI planned test with no HTTP step for `pageContext.url`, so refusing it here
279
+ // would make a plan-legal shape illegal in the report.
295
280
  if (Array.isArray(rec.targetElements) &&
296
281
  rec.pageContext === undefined) {
297
282
  ctx.addIssue({
@@ -300,31 +285,6 @@ export const newTestSchema = z.preprocess(stripNullGroundingFields, z
300
285
  message: "pageContext is required for testType: 'ui' when targetElements is an array.",
301
286
  });
302
287
  }
303
- if (rec.targetElements === null && rec.pageContext !== undefined) {
304
- ctx.addIssue({
305
- code: z.ZodIssueCode.custom,
306
- path: ["pageContext"],
307
- message: "pageContext must be omitted when targetElements is null (no blueprint data).",
308
- });
309
- }
310
- // When targetElements is null, both description and reasoning must carry the '[no-blueprint-data]' marker.
311
- if (rec.targetElements === null) {
312
- if (!rec.reasoning.includes("[no-blueprint-data]")) {
313
- ctx.addIssue({
314
- code: z.ZodIssueCode.custom,
315
- path: ["reasoning"],
316
- message: "reasoning must contain '[no-blueprint-data]' when targetElements is null.",
317
- });
318
- }
319
- if (rec.description &&
320
- !rec.description.includes("[no-blueprint-data]")) {
321
- ctx.addIssue({
322
- code: z.ZodIssueCode.custom,
323
- path: ["description"],
324
- message: "description must contain '[no-blueprint-data]' when targetElements is null.",
325
- });
326
- }
327
- }
328
288
  }
329
289
  }));
330
290
  /** A citation string the submit-time check verifies and the report then ships.
@@ -338,6 +298,7 @@ const issueFoundSchema = z
338
298
  description: z
339
299
  .string()
340
300
  .describe("One-line description. Do NOT prefix with the severity level — severity is a separate field. Include code logic bugs from the diff, test generation/execution failures, and environment misconfiguration. " +
301
+ "A bug that this change did not cause is still an issue: keep the test that found it, and say in this description that the bug predates the change. " +
341
302
  "When sourceFile or sourceSymbol is set, quote the offending line inside backticks in this description — the exact code, copied from the file, so review tools can find it."),
342
303
  severity: z
343
304
  .enum(["critical", "high", "medium", "low"])
@@ -345,18 +306,26 @@ const issueFoundSchema = z
345
306
  .describe("Issue severity. critical = feature broken/unusable (e.g. page doesn't load, data corruption). " +
346
307
  "high = incorrect behavior (e.g. wrong calculation, stale data returned). " +
347
308
  "medium = minor functional gap. low = cosmetic or informational. " +
348
- "Floor: a requirement conflict — the PR title/description (or a requirements file it references) states a requirement the implemented behavior contradicts — is 'high' or 'critical', never 'medium' or below."),
309
+ "CHECKED: a run that wrote a test planned as `requirement_conflict` — the PR title or description, " +
310
+ "or a requirements file it names, states a requirement the implemented behavior contradicts — must " +
311
+ "report an issue here at 'critical' or 'high'. A stated requirement the code does not meet is not a " +
312
+ "medium. The check raises an objection, never a rejection."),
349
313
  category: z
350
314
  .nativeEnum(IssueFoundCategory)
351
315
  .describe("Issue classification. bug = a product/code defect, e.g. found by a test or in the diff. " +
352
- "lint = a linter or formatter finding (eslint, flake8, prettier). " +
316
+ "lint = a linter or formatter objection (eslint, flake8, prettier). " +
353
317
  "type = a type-check failure (tsc, mypy). " +
354
318
  "config = environment or tooling misconfiguration (wrong workspace auth type, missing env var, setup command failure), " +
355
319
  "and also every Skyramp tool or environment failure — a generation or execution tool error, an unreachable app, a missing credential, a failed capture. " +
356
320
  "The report renders lint/type/config entries in a separate 'Configuration Errors' section so product bugs stay prominent under 'Issues Found'."),
357
321
  repository: repositoryField,
322
+ plannedTestId: z
323
+ .string()
324
+ .optional()
325
+ .describe("The `plannedTestId` of the planned test this issue is about, when it is about one. Set it on the issue you record for a `requirement_conflict` test so the conflict is tied to the test that asserts it; otherwise leave it out."),
358
326
  sourceFile: citationString.describe("Path of the application file whose code is missing or wrong, relative to the repository root (e.g. 'src/crud/products.py'). " +
359
327
  "REQUIRED when category is 'bug'. " +
328
+ "For code that is MISSING — an unmounted router, an unregistered route, an import never added — name the file where the line should be, not the file that defines what is unmounted. " +
360
329
  "Cite only a file you actually opened this run: this tool rejects the report if the path does not exist in the repository."),
361
330
  sourceSymbol: citationString.describe("The function, method, or identifier inside sourceFile whose code is missing or wrong (e.g. 'delete_product'). " +
362
331
  "Optional — a config file or template has no symbol to name. Set it whenever the file has one. " +
@@ -367,7 +336,7 @@ const issueFoundSchema = z
367
336
  .positive()
368
337
  .optional()
369
338
  .describe("1-based line number in sourceFile. Optional and advisory. " +
370
- "Give the line of the offending statement itself, not the line of the enclosing function's signature — review views place the finding on exactly this line. " +
339
+ "Give the line of the offending statement itself, not the line of the enclosing function's signature — review views place the objection on exactly this line. " +
371
340
  "The tool does not check that the line still holds the cited code, because line numbers drift."),
372
341
  })
373
342
  .superRefine((issue, ctx) => {
@@ -375,7 +344,7 @@ const issueFoundSchema = z
375
344
  // without a file the entry is prose a reviewer cannot act on, and nothing
376
345
  // downstream can place it in the diff. Only the file is required — a config file
377
346
  // or a template legitimately has no symbol to name, and line numbers are
378
- // advisory. The other categories are tooling findings that often have no single
347
+ // advisory. The other categories are tooling objections that often have no single
379
348
  // source location (an unreachable app, a missing env var), so the requirement is
380
349
  // scoped to `bug` alone.
381
350
  //
@@ -395,10 +364,9 @@ const issueFoundSchema = z
395
364
  });
396
365
  });
397
366
  const scenarioStepSchema = z.object({
398
- method: z
399
- .nativeEnum(HttpMethod)
367
+ method: stepMethodSchema
400
368
  .optional()
401
- .describe("HTTP method. Required for API steps, omit for UI/E2E actions."),
369
+ .describe(`${STEP_METHOD_DESCRIPTION} Omit it when the step does none of the three.`),
402
370
  path: z
403
371
  .string()
404
372
  .optional()
@@ -428,8 +396,8 @@ export const additionalRecommendationSchema = z.preprocess(stripNullGroundingFie
428
396
  .nativeEnum(TestType)
429
397
  .describe("Type of test. Do not include priority or other metadata in this field."),
430
398
  category: z
431
- .preprocess((val) => externalCategory(val), z.enum(TEST_CATEGORIES))
432
- .describe("Test category — critical categories get generation priority over workflow"),
399
+ .enum(SCENARIO_CATEGORIES)
400
+ .describe("Test category, e.g. business_rule, error_handling, security_boundary. Write the same category the plan declared."),
433
401
  primaryEndpoint: z
434
402
  .string()
435
403
  .optional()
@@ -441,10 +409,12 @@ export const additionalRecommendationSchema = z.preprocess(stripNullGroundingFie
441
409
  // TODO: replace text with max(3) and check for regression
442
410
  steps: z
443
411
  .array(scenarioStepSchema)
412
+ .optional()
413
+ .default([])
444
414
  .describe("Ordered sequence of API/UI steps in this test scenario (at most 3). Each API step must include method and path so the endpoints are explicit; a UI/E2E action step omits them. Include requestBody and responseBody only where they carry something a reader needs — the concrete values a claim rests on, or a list endpoint's array response; omit them otherwise."),
445
415
  description: z
446
416
  .string()
447
- .describe("Walkthrough of what the test does — the steps and assertions. For multi-step scenarios, list the endpoints involved. The 'why it is valuable' belongs in reasoning."),
417
+ .describe("Walkthrough of what the test does — the steps and assertions. For multi-step scenarios, list the endpoints involved. The 'why it is valuable' belongs in reasoning. Name any UI element the way a person would, e.g. the disabled 'Mark all as read' button — never a builder term such as blueprint or DOM analyzer, and never identifier syntax such as role=button or accessibleName=X."),
448
418
  priority: z
449
419
  .preprocess((val) => (typeof val === "string" ? val.toLowerCase() : val), z.enum(["high", "medium", "low"]))
450
420
  .describe("Priority level: high, medium, or low. First check diff relevance — does the test target an endpoint changed in this PR? HIGH: diff-relevant security/auth/error tests, cross-resource isolation for diff endpoints, CRUD lifecycle for NEW endpoints in the diff. MEDIUM: diff-relevant business-rule happy paths, multi-resource workflows involving diff endpoints, security/error tests for NON-diff endpoints. LOW: tests targeting only unchanged endpoints, trivially discoverable happy paths duplicating generated tests."),
@@ -462,14 +432,14 @@ export const additionalRecommendationSchema = z.preprocess(stripNullGroundingFie
462
432
  .describe("Path to Playwright/UI trace file if available, e.g. 'tests/skyramp-playwright.zip'. UI tests need this; E2E tests need both frontend and backend traces."),
463
433
  reasoning: z
464
434
  .string()
465
- .describe("Why this test is recommended: the specific production risk, business rule, or security boundary it would validate"),
435
+ .describe("Why this test is recommended: the specific production risk, business rule, or security boundary it would validate. Name any UI element the way a person would, e.g. the disabled 'Mark all as read' button — never a builder term such as blueprint or DOM analyzer, and never identifier syntax such as role=button or accessibleName=X."),
466
436
  repository: repositoryField,
467
437
  targetElements: z
468
438
  .array(targetElementSchema)
469
439
  .min(1)
470
440
  .nullable()
471
441
  .optional()
472
- .describe("UI tests only: structured grounding for one or more elements the test targets. Most tests target a single element (array length 1); render-state and multi-step UI tests target several (array length 2+). Each entry must be lifted verbatim from a captured blueprint element. Set to null when blueprint capture failed (also requires '[no-blueprint-data]' marker in BOTH description and reasoning). See Blueprint Citation Invariant in testbot prompt."),
442
+ .describe("UI tests only: structured grounding for one or more elements the test would target. Most recommendations target a single element (array length 1); render-state and multi-step UI tests target several (array length 2+). Each entry must be lifted verbatim from a captured blueprint element. Set to null when no capture covers the screen — this test was never written, so null needs no marker. See Blueprint Citation Invariant in testbot prompt."),
473
443
  pageContext: pageContextSchema
474
444
  .optional()
475
445
  .describe("UI tests only: page metadata for the test. Lifted from the BlueprintCapture used during grounding."),
@@ -499,7 +469,7 @@ export const additionalRecommendationSchema = z.preprocess(stripNullGroundingFie
499
469
  ctx.addIssue({
500
470
  code: z.ZodIssueCode.custom,
501
471
  path: ["targetElements"],
502
- message: "targetElements is required for testType: 'ui'. Use null when blueprint capture failed (and add '[no-blueprint-data]' to both description and reasoning).",
472
+ message: "targetElements is required for testType: 'ui'. Use null when no capture covers the screen.",
503
473
  });
504
474
  }
505
475
  if (Array.isArray(rec.targetElements) &&
@@ -510,29 +480,8 @@ export const additionalRecommendationSchema = z.preprocess(stripNullGroundingFie
510
480
  message: "pageContext is required for testType: 'ui' when targetElements is an array.",
511
481
  });
512
482
  }
513
- if (rec.targetElements === null && rec.pageContext !== undefined) {
514
- ctx.addIssue({
515
- code: z.ZodIssueCode.custom,
516
- path: ["pageContext"],
517
- message: "pageContext must be omitted when targetElements is null (no blueprint data).",
518
- });
519
- }
520
- if (rec.targetElements === null) {
521
- if (!rec.reasoning.includes("[no-blueprint-data]")) {
522
- ctx.addIssue({
523
- code: z.ZodIssueCode.custom,
524
- path: ["reasoning"],
525
- message: "reasoning must contain '[no-blueprint-data]' when targetElements is null.",
526
- });
527
- }
528
- if (!rec.description.includes("[no-blueprint-data]")) {
529
- ctx.addIssue({
530
- code: z.ZodIssueCode.custom,
531
- path: ["description"],
532
- message: "description must contain '[no-blueprint-data]' when targetElements is null.",
533
- });
534
- }
535
- }
483
+ // No marker is asked for here: a recommendation was never written, so
484
+ // `targetElements: null` is its normal state rather than a lost capture.
536
485
  }
537
486
  }));
538
487
  // TODO(multi-repo maintenance): no `repository` field yet — see readData() TODO below.
@@ -540,7 +489,7 @@ const testMaintenanceSchema = z.object({
540
489
  testType: z.nativeEnum(TestType).describe("Type of test."),
541
490
  endpoint: z
542
491
  .string()
543
- .describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
492
+ .describe("For contract and integration tests, the HTTP verb and path, e.g. 'GET /api/v1/products'. For ui and e2e tests, the page path the test opens, e.g. '/notifications'."),
544
493
  testFilePath: z
545
494
  .string()
546
495
  .refine((p) => path.isAbsolute(p), {
@@ -582,6 +531,12 @@ const testMaintenanceDetailSchema = testMaintenanceLLMSchema.pick({
582
531
  beforeDetails: true,
583
532
  afterDetails: true,
584
533
  });
534
+ /** The key an answer is matched by: the objection's id AND its message. The id
535
+ * alone is not unique — one verifier raises several different objections under
536
+ * it — and the message is what the reader judges the answer against. */
537
+ function answerKey(objection) {
538
+ return `${objection.objectionId}\n${objection.message}`;
539
+ }
585
540
  /**
586
541
  * Derive per-run analytics counts from a submitted report. These power the
587
542
  * alpha-launch dashboards (tests generated/maintained, suite growth, bugs vs
@@ -734,6 +689,145 @@ function deduplicateById(items) {
734
689
  }
735
690
  return result;
736
691
  }
692
+ /** Recommendation v2, post-execution half: the checks that need the tests to
693
+ * exist and to have run. Nothing here is a gate — the tests are already written,
694
+ * so an objection is a record in the report. */
695
+ /** One execution outcome per delivered test, joined by FILE and never by array
696
+ * position. Repository must match BEFORE the file comparison — `fileName` is a
697
+ * basename. A file can own several rows: failed if ANY failed, first failure
698
+ * carries the text; `Skipped` rows are evidence in neither direction. */
699
+ function collectExecutionOutcomes(delivered, results) {
700
+ // Absent names nothing to disagree with, so it matches any — the same rule
701
+ // `checkEveryDeliveredTestRan` uses.
702
+ const sameRepository = (a, b) => {
703
+ const left = (a ?? "").trim();
704
+ const right = (b ?? "").trim();
705
+ return left === "" || right === "" || left === right;
706
+ };
707
+ const outcomes = [];
708
+ for (const test of delivered) {
709
+ const plannedTestId = test.plannedTestId?.trim();
710
+ if (!plannedTestId)
711
+ continue;
712
+ const rows = results.filter((row) => row.status !== "Skipped" &&
713
+ sameRepository(row.repository, test.repository) &&
714
+ testFileMatches(row.testFilePath, test.fileName));
715
+ if (rows.length === 0)
716
+ continue;
717
+ const failed = rows.find((row) => row.status === "Fail");
718
+ outcomes.push({
719
+ plannedTestId,
720
+ passed: !failed,
721
+ ...(failed ? { failureDetail: failed.details } : {}),
722
+ // Off the failing row, since that is the row the statement is about.
723
+ ...(failed && typeof failed.reachedBehavior === "boolean"
724
+ ? { reachedBehavior: failed.reachedBehavior }
725
+ : {}),
726
+ });
727
+ }
728
+ return outcomes;
729
+ }
730
+ /** One check, crash contained: the plan is read off disk, so a malformed field can
731
+ * throw and a throw here would lose the whole report. The id prefix is a separate
732
+ * argument because expectedOutcome's plan-time half mints its own. */
733
+ function runPostExecutionCheck(name, objectionIdPrefix, check) {
734
+ try {
735
+ return check();
736
+ }
737
+ catch (error) {
738
+ const detail = error instanceof Error ? error.message : String(error);
739
+ logger.warning(`Post-execution check ${name} threw: ${detail}`);
740
+ return [
741
+ {
742
+ objectionId: `${objectionIdPrefix}:crashed`,
743
+ verifier: name,
744
+ message: `The ${name} check could not run against this report.`,
745
+ evidence: `${name} threw: ${detail}`,
746
+ suggestion: "The report was written and the other checks still ran. A field of the stored plan is probably not the type the schema describes — re-submit the plan with skyramp_register_test_plan.",
747
+ },
748
+ ];
749
+ }
750
+ }
751
+ // ── Report-time checks on what the customer will read ───────────────────────
752
+ /** One report entry per plan planned test. The expectedOutcome check keys results by
753
+ * `plannedTestId` and the LAST wins, so a second entry with one id silently chose
754
+ * which result answered for the planned test. The plan side keeps ids unique;
755
+ * nothing checked the reverse. */
756
+ function checkOneEntryPerCandidate(delivered) {
757
+ const byCandidate = new Map();
758
+ for (const test of delivered) {
759
+ const id = test.plannedTestId?.trim();
760
+ if (!id)
761
+ continue;
762
+ byCandidate.set(id, [...(byCandidate.get(id) ?? []), test.testId]);
763
+ }
764
+ return [...byCandidate]
765
+ .filter(([, testIds]) => testIds.length > 1)
766
+ .map(([plannedTestId, testIds]) => ({
767
+ objectionId: `oneEntryPerCandidate:${plannedTestId}`,
768
+ verifier: "oneEntryPerCandidate",
769
+ plannedTestId,
770
+ message: "Two report entries name one plan planned test. Only one of them can be the test that planned test asked for, and the checks that read the planned test's declaration cannot tell which.",
771
+ evidence: `planned test ${plannedTestId} claimed by: ${testIds.join(", ")}`,
772
+ suggestion: "Give each entry the plannedTestId of the planned test it really delivers, or plan the second test and use its own id.",
773
+ }));
774
+ }
775
+ /** Every delivered test has an execution result. The expectedOutcome check is silent for
776
+ * a planned test with no outcome, so a filename the results do not carry was the
777
+ * way past it. This is the check that says so. */
778
+ function checkEveryDeliveredTestRan(delivered, results) {
779
+ // A side that names no repository names none to disagree with, so it matches
780
+ // any. A single-repo run fills the field on one list and not the other often
781
+ // enough that requiring both to agree reported tests that had plainly run.
782
+ const sameRepository = (a, b) => {
783
+ const left = (a ?? "").trim();
784
+ const right = (b ?? "").trim();
785
+ return left === "" || right === "" || left === right;
786
+ };
787
+ return delivered
788
+ .filter((test) => !results.some((row) => sameRepository(row.repository, test.repository) &&
789
+ testFileMatches(row.testFilePath, test.fileName)))
790
+ .map((test) => {
791
+ // Say which half failed. "No row names that file" reads as a missing run
792
+ // when the file is there and only the repository differs.
793
+ const byName = results.filter((row) => testFileMatches(row.testFilePath, test.fileName));
794
+ const evidence = byName.length
795
+ ? `reported as ${test.fileName} for ${test.repository?.trim() || "no repository"}, and the ${byName.length === 1 ? "row" : "rows"} naming that file ${byName.length === 1 ? "is" : "are"} for ${byName.map((row) => row.repository?.trim() || "no repository").join(", ")}`
796
+ : `reported as ${test.fileName}, no testResults row names that file`;
797
+ return {
798
+ objectionId: `deliveredTestRan:${test.testId}`,
799
+ verifier: "deliveredTestRan",
800
+ message: "This test is in the report and in no execution result. Nothing says whether it passes, so the checks that read its outcome stayed silent about it.",
801
+ evidence,
802
+ suggestion: "Run the test and add its result to `testResults` under the same file name, or take the entry out of the report.",
803
+ };
804
+ });
805
+ }
806
+ /** The four types a plan can express. `registerTestPlanTool`'s `PLAN_TEST_TYPES`
807
+ * is the same list; the report accepts every `TestType`, so an entry outside
808
+ * these four can never name a plan planned test and must not be asked to. */
809
+ const PLANNABLE_TEST_TYPES = new Set([
810
+ TestType.CONTRACT,
811
+ TestType.INTEGRATION,
812
+ TestType.E2E,
813
+ TestType.UI,
814
+ ]);
815
+ function runPostExecutionChecks(plan, delivered, results, issues) {
816
+ const shipped = delivered
817
+ // A smoke, fuzz or load entry is filtered out rather than reported as
818
+ // unplanned: a plan cannot hold one, so the objection would name a mistake the
819
+ // agent has no way to fix.
820
+ .filter((test) => PLANNABLE_TEST_TYPES.has(test.testType))
821
+ .map((test) => test.plannedTestId?.trim())
822
+ .filter((id) => !!id)
823
+ .map((plannedTestId) => ({ plannedTestId }));
824
+ return [
825
+ ...runPostExecutionCheck("deliveredMatchesPlan", "deliveredMatchesPlan", () => checkDeliveredMatchesPlan(plan, shipped)),
826
+ ...runPostExecutionCheck("expectedOutcome", "expectedOutcome:executed", () => checkExpectedOutcomeAfterExecution(plan, collectExecutionOutcomes(delivered, results), issues)),
827
+ ...runPostExecutionCheck("reportedCategory", "reportedCategory", () => checkReportedCategoryMatchesPlan(plan, delivered)),
828
+ ...runPostExecutionCheck("requirementConflictReported", "requirementConflictReported", () => checkRequirementConflictReported(plan, delivered, issues)),
829
+ ];
830
+ }
737
831
  export function registerSubmitReportTool(server) {
738
832
  server.registerTool(TOOL_NAME, {
739
833
  annotations: {
@@ -742,8 +836,9 @@ export function registerSubmitReportTool(server) {
742
836
  idempotentHint: false,
743
837
  openWorldHint: false,
744
838
  },
745
- description: "Submit the final testbot report. Call this tool once after completing all test analysis, generation, and execution. " +
746
- "This is the ONLY way to submit the report — do NOT write the report to a file manually.",
839
+ description: "Submit the final testbot report. Call this tool once, after all test analysis, generation and execution. " +
840
+ "This is the ONLY way to submit the report — do NOT write the report to a file manually. " +
841
+ "If a check objects to what you reported, the objection names an id: fix what you can, and answer the rest through `answers` on a second call.",
747
842
  inputSchema: {
748
843
  businessCaseAnalysis: z
749
844
  .string()
@@ -752,11 +847,13 @@ export function registerSubmitReportTool(server) {
752
847
  newTestsCreated: z
753
848
  .array(newTestSchema)
754
849
  .describe("List of new tests created. Use empty array [] if none."),
850
+ // Optional and unchecked: this run has no concept of a recommendation it
851
+ // does not write. Kept so an older caller still works.
755
852
  additionalRecommendations: z
756
853
  .array(additionalRecommendationSchema)
757
854
  .optional()
758
855
  .default([])
759
- .describe("Recommended tests that were not generated (lower priority). Only include recommendations that add distinct coverage beyond generated tests — do not pad with variants testing the same endpoint and flow."),
856
+ .describe("Leave this empty or omit it. Every test this run recommends is a test it generates: a test worth writing goes in the plan you submit to skyramp_register_test_plan, and nothing caps how many tests a plan may hold."),
760
857
  testMaintenanceDetails: z
761
858
  .array(testMaintenanceDetailSchema)
762
859
  .optional()
@@ -784,13 +881,27 @@ export function registerSubmitReportTool(server) {
784
881
  .describe("Succinct commit message (if possible, under 72 chars) summarizing what Testbot did, " +
785
882
  "e.g. 'add contract tests for /products endpoint' or 'update smoke tests for order API changes'. " +
786
883
  "Used as both the git commit subject and the side PR title — the consumer applies truncation as needed."),
884
+ answers: z
885
+ .array(z
886
+ .object({
887
+ objectionId: z
888
+ .string()
889
+ .describe("The objection's id, exactly as this tool reported it after your last call. Answers are matched by the id AND the wording of the objection, never by position, so re-send the id as it was printed. An id this call raised no objection for closes nothing — a plan-time objection is answered through skyramp_register_test_plan instead."),
890
+ answer: z
891
+ .string()
892
+ .describe("Why the objection does not apply, or what you decided instead. A blank or whitespace-only answer closes nothing and the objection stays open."),
893
+ })
894
+ .strict())
895
+ .optional()
896
+ .default([])
897
+ .describe("One answer per check that objected to your previous call and that you do not intend to fix. An answered objection is published with your answer beside it and no longer counted open; an unanswered one is published on its own. Answers persist across calls, so an id answered once stays answered. There is no call limit."),
787
898
  stateFile: z
788
899
  .string()
789
- .describe("Path to the state file returned by skyramp_analyze_changes. Required — " +
790
- "contains execution results for maintenance scoring (existingTests), " +
791
- "discovered endpoints for coverage validation (newEndpoints), " +
792
- "UI page context for frontend test grounding (uiContext), and the " +
793
- "videos recorded for each executed browser test (executionVideos)."),
900
+ .optional()
901
+ .describe("Optional. A Testbot run finds its own state file, so leave this out. " +
902
+ "Give a path only to name a state file the run would not find by itself. " +
903
+ "The report is written beside it, and it holds the execution results, the " +
904
+ "page captures and the videos this report needs."),
794
905
  },
795
906
  _meta: {
796
907
  keywords: ["report", "summary", "testbot", "submit"],
@@ -802,6 +913,11 @@ export function registerSubmitReportTool(server) {
802
913
  // below when a report language is enforced, spread into the analytics
803
914
  // event in the finally. Empty (no properties) for non-enforced runs.
804
915
  const languageTelemetry = {};
916
+ // The run's own state file, unless the agent named one.
917
+ const stateFile = resolveRunStatePath(params.stateFile);
918
+ if (!stateFile) {
919
+ return toolError("This run has no state file. Call skyramp_analyze_changes first — it writes the state file this report goes beside.");
920
+ }
805
921
  // The report goes next to the state file, so in a Testbot run the state file has to
806
922
  // be in the run directory — that is where the action reads the report back from.
807
923
  // Two ways that breaks, both ending in a report nobody reads while this tool says
@@ -817,7 +933,7 @@ export function registerSubmitReportTool(server) {
817
933
  "from $RUNNER_TEMP/skyramp. Set RUNNER_TEMP for the run. Retrying or changing " +
818
934
  "the report will not help — this is an environment problem.");
819
935
  }
820
- const stateDir = path.dirname(params.stateFile);
936
+ const stateDir = path.dirname(stateFile);
821
937
  if (path.resolve(stateDir) !== path.resolve(runDir)) {
822
938
  return toolError(`Cannot write the report: stateFile is in ${stateDir}, but this run's directory ` +
823
939
  `is ${runDir}. The report is written next to the state file and the Testbot ` +
@@ -925,7 +1041,7 @@ export function registerSubmitReportTool(server) {
925
1041
  const dedupedRecommendations = deduplicateById([
926
1042
  ...(params.additionalRecommendations ?? []),
927
1043
  ]);
928
- const stateManager = StateManager.fromStatePath(params.stateFile);
1044
+ const stateManager = StateManager.fromStatePath(stateFile);
929
1045
  let stateData;
930
1046
  try {
931
1047
  // Only reads the primary repo — see the related-repo check below for why related
@@ -937,7 +1053,7 @@ export function registerSubmitReportTool(server) {
937
1053
  return errorResult;
938
1054
  }
939
1055
  if (!stateData) {
940
- errorResult = toolError(`stateFile not found or empty: ${params.stateFile}`);
1056
+ errorResult = toolError(`stateFile not found or empty: ${stateFile}`);
941
1057
  return errorResult;
942
1058
  }
943
1059
  // undefined + existing tests present means skyramp_actions was never called this
@@ -951,49 +1067,47 @@ export function registerSubmitReportTool(server) {
951
1067
  "Call skyramp_actions (with recommendations: [] if no existing tests needed action) before skyramp_submit_report.");
952
1068
  return errorResult;
953
1069
  }
954
- // SKYR-3879 Path B: every gated newTestsCreated entry (contract/integration —
955
- // the testTypes actually wired to planGuard) must be admissible under the
956
- // approved plan from skyramp_register_test_plan — the same predicate the
957
- // generation-tool gate uses (GENERATE, or ADDITIONAL backfill when GENERATE
958
- // is non-empty), so a generation the gate permitted is never rejected at
959
- // report time. Absent approvedPlan (register tool never ran / never
960
- // pre-seeded anything) → no check, back-compat.
1070
+ // Every gated entry names a plan planned test by `plannedTestId`, and NOT
1071
+ // conditionally on a plan existing: with none, every entry fails here, where
1072
+ // skipping the check would read exactly like a satisfied plan.
961
1073
  //
962
- // SKYR-4123: the name must be part of the query for that parity to hold.
963
- // Every generation tool passes scenarioName; this side did not, so the plan
964
- // item's `name:` key was dead here and a test the gate had already let
965
- // through was rejected while being listed as approved in the same message.
966
- if (stateData.approvedPlan) {
967
- const approvedPlan = stateData.approvedPlan;
1074
+ // The planned test must be of the entry's OWN test type, trimmed and lower-cased
1075
+ // because the plan's `testType` is free-form text; one stating no type is
1076
+ // accepted. EXEMPT: a plan whose `plannedTests` is not an array cannot be read,
1077
+ // and that is no evidence a test went unplanned. An ABSENT plan is not that.
1078
+ const planUnreadable = !!stateData.plan && !Array.isArray(stateData.plan.plannedTests);
1079
+ if (!planUnreadable) {
1080
+ const planCandidates = stateData.plan?.plannedTests ?? [];
1081
+ const plannedById = new Map();
1082
+ for (const plannedTest of planCandidates) {
1083
+ const id = plannedTest?.plannedTestId?.trim();
1084
+ // First wins: `buildRegistration` suffixes a repeat, so a shared id can
1085
+ // only come from a hand-edited plan.
1086
+ if (id && !plannedById.has(id))
1087
+ plannedById.set(id, plannedTest);
1088
+ }
968
1089
  const unapproved = dedupedNewTests.filter((t) => {
969
1090
  if (!PLAN_GATED_TEST_TYPES.has(t.testType))
970
1091
  return false;
971
- const endpoints = parseEndpointField(t.endpoint);
972
- const names = planNameCandidates(t.testId, t.testType);
973
- return !(names.length > 0 ? names : [undefined]).some((scenarioName) => endpoints.some(({ method, path }) => matchesApprovedPlan(approvedPlan, {
974
- scenarioName,
975
- testType: t.testType,
976
- method,
977
- path,
978
- })));
1092
+ const plannedTest = plannedById.get((t.plannedTestId ?? "").trim());
1093
+ if (!plannedTest)
1094
+ return true;
1095
+ const plannedType = String(plannedTest.scenario?.testType ?? "").trim().toLowerCase();
1096
+ return plannedType !== "" && plannedType !== t.testType;
979
1097
  });
980
1098
  if (unapproved.length > 0) {
981
- const approvedList = approvedPlan.generate.length > 0
982
- ? // Show each item's endpoint keys: without them a rejection that turns on
983
- // the endpoint reads as self-contradicting, because the entry's own name
984
- // is printed in this same list (SKYR-4123).
985
- approvedPlan.generate
986
- .map((item) => {
987
- const eps = item.matchKeys.filter((k) => k.startsWith("ep:"));
988
- return `[${item.testType}] ${item.scenarioName}${eps.length > 0 ? ` covering ${eps.join(", ")}` : ""}`;
989
- })
990
- .join("; ")
991
- : "(none)";
992
- errorResult = toolError(`${unapproved.length} newTestsCreated entr${unapproved.length === 1 ? "y" : "ies"} not in the approved plan from ` +
993
- `skyramp_register_test_plan (plan ${approvedPlan.planId}) — neither its GENERATE list nor its ADDITIONAL backfill pool: ` +
994
- `${unapproved.map((t) => `${t.testId} (${t.testType} ${t.endpoint})`).join(", ")}. ` +
995
- `Approved GENERATE items: ${approvedList}. Call skyramp_register_test_plan to register this test before reporting it, ` +
996
- `or remove it from newTestsCreated.`);
1099
+ const plannedList = plannedById.size > 0
1100
+ ? planCandidates
1101
+ .map((plannedTest) => `${plannedTest?.plannedTestId} (${plannedTest?.scenario?.testType ?? "?"} ${plannedTest?.scenario?.scenarioName ?? "?"})`)
1102
+ .join("; ")
1103
+ : "(none — no plan was submitted this run)";
1104
+ errorResult = toolError(`${unapproved.length} newTestsCreated entr${unapproved.length === 1 ? "y" : "ies"} name no planned test of its own ` +
1105
+ `testType in the plan submitted to skyramp_register_test_plan: ` +
1106
+ `${unapproved
1107
+ .map((t) => `${t.testId} (${t.testType}, plannedTestId: ${(t.plannedTestId ?? "").trim() || "missing"})`)
1108
+ .join(", ")}. ` +
1109
+ `Planned planned tests: ${plannedList}. Submit a plan that contains these tests with skyramp_register_test_plan, ` +
1110
+ `then resubmit this report with each entry's plannedTestId copied from it — or remove the entries from newTestsCreated.`);
997
1111
  return errorResult;
998
1112
  }
999
1113
  }
@@ -1016,12 +1130,20 @@ export function registerSubmitReportTool(server) {
1016
1130
  // action/testType/endpoint/description come entirely from stateData.maintenanceVerdicts
1017
1131
  // (written by skyramp_actions right after drift analysis) — the LLM never supplies
1018
1132
  // testMaintenance directly, so there's nothing to re-transcribe here.
1133
+ // `pomFile` rides along per row and is stripped from the wire format below,
1134
+ // like testFilePath. It cannot be keyed by spec path: a spec emits one UPDATE
1135
+ // verdict PER page object, so a spec-keyed map keeps only the last.
1019
1136
  const rawMaintenance = (stateData.maintenanceVerdicts ?? []).map((v) => ({
1020
1137
  testFilePath: v.testFilePath,
1138
+ pomFile: v.pomFile,
1021
1139
  testType: v.testType,
1022
1140
  endpoint: v.endpoint,
1023
1141
  action: v.action,
1024
- description: v.rationale,
1142
+ // A baseline refresh (SKYR-4298) leaves the spec untouched, so name the
1143
+ // replaced PNGs here — the reviewer's cue to open the image diff in the PR.
1144
+ description: v.rebaselineSnapshots?.length
1145
+ ? `${v.rationale} (visual baseline refreshed: ${v.rebaselineSnapshots.join(", ")})`
1146
+ : v.rationale,
1025
1147
  beforeDetails: "",
1026
1148
  afterDetails: "",
1027
1149
  }));
@@ -1135,7 +1257,7 @@ export function registerSubmitReportTool(server) {
1135
1257
  `Testbot will not report file work that hasn't actually been made. Do NOT make a token edit to the claimed file just to satisfy this check.\n` +
1136
1258
  `For a newTestsCreated claim: create the file, correct the claim's fileName to the file you actually created (see the changed files below), or remove the claim.\n` +
1137
1259
  `For a testMaintenance claim: make the edit on the named file. That row is derived from the maintenance triage, not from this tool's input, so it cannot be corrected by changing what you pass here.\n` +
1138
- `Unbacked claims:\n${unbacked.map((u) => ` - ${u}`).join("\n")}\n` +
1260
+ `Unbacked changes:\n${unbacked.map((u) => ` - ${u}`).join("\n")}\n` +
1139
1261
  `Files with actual working-tree changes:\n${changedList || " (none)"}`);
1140
1262
  return errorResult;
1141
1263
  }
@@ -1174,7 +1296,7 @@ export function registerSubmitReportTool(server) {
1174
1296
  errorResult = toolError(citations.invalid.join("\n"));
1175
1297
  return errorResult;
1176
1298
  }
1177
- // The checkout the citation resolved in owns the finding, so the report
1299
+ // The checkout the citation resolved in owns the objection, so the report
1178
1300
  // states it. Left to the consumer, an unattributed related-repo path is
1179
1301
  // resolved against the primary checkout and links the wrong repo's file.
1180
1302
  citations.repository.forEach((repo, i) => {
@@ -1187,7 +1309,7 @@ export function registerSubmitReportTool(server) {
1187
1309
  // generation — downstream scoring scripts don't expect them and fail if
1188
1310
  // they encounter these string fields while traversing the object.
1189
1311
  // Also normalize each item's `repository` (blank → undefined).
1190
- // Checkout roots let the assertion-record matcher verify a candidate
1312
+ // Checkout roots let the assertion-record matcher verify a planned test
1191
1313
  // record actually lives in the row's repo (basename collisions across
1192
1314
  // repos must not publish one spec's proof-of-work under another's name).
1193
1315
  const assertionCheckouts = await stateManager
@@ -1197,6 +1319,246 @@ export function registerSubmitReportTool(server) {
1197
1319
  const row = await attachReuseOutcome(normalizeRepository(rest), stateData.reuseOutcomes, stateData.reuseHandOffs, retrofitViews);
1198
1320
  return attachAssertionOutcome(row, stateData.assertionOutcomes ?? {}, assertionCheckouts);
1199
1321
  }));
1322
+ // The report enum has no `bug_caught` value, so which failure was on purpose
1323
+ // is derived from the plan rather than from agent prose. Read defensively:
1324
+ // the plan comes off disk.
1325
+ const failOnPurpose = new Set();
1326
+ for (const plannedTest of Array.isArray(stateData.plan?.plannedTests)
1327
+ ? stateData.plan.plannedTests
1328
+ : []) {
1329
+ const id = plannedTest?.plannedTestId?.trim();
1330
+ if (!id)
1331
+ continue;
1332
+ // Off the category table, not a literal comparison: `changedFile`'s
1333
+ // neighbour normalises case, space and separator, and the two must agree.
1334
+ if (categoryExpectsRed(plannedTest?.scenario?.category) ||
1335
+ plannedTest?.declarations?.expected?.outcome === "fail") {
1336
+ failOnPurpose.add(id);
1337
+ }
1338
+ }
1339
+ for (const test of sanitizedNewTests) {
1340
+ if (failOnPurpose.has((test.plannedTestId ?? "").trim())) {
1341
+ test.expectedToFail = true;
1342
+ }
1343
+ }
1344
+ // Report-time checks on what the customer will read. They need no plan, so a
1345
+ // run without one still gets them.
1346
+ const reportChecks = [
1347
+ ...runPostExecutionCheck("oneEntryPerCandidate", "oneEntryPerCandidate", () => checkOneEntryPerCandidate(dedupedNewTests)),
1348
+ // Not in the plan-only lane: that lane requires `testResults` to be empty,
1349
+ // so the check would flag every planned test.
1350
+ ...(isPlanOnlyMode()
1351
+ ? []
1352
+ : runPostExecutionCheck("deliveredTestRan", "deliveredTestRan", () => checkEveryDeliveredTestRan(dedupedNewTests, params.testResults))),
1353
+ ];
1354
+ // Run against an EMPTY plan when none was submitted, never skipped: the gate
1355
+ // above covers contract and integration only. An empty plan raises exactly
1356
+ // the "delivered but unplanned" half, which is the true statement.
1357
+ let objections;
1358
+ let changeTable;
1359
+ {
1360
+ const postExecution = runPostExecutionChecks(stateData.plan ?? { plannedTests: [], registrationNumber: 0, answers: [], openObjections: [], answeredObjections: [] }, dedupedNewTests, params.testResults, params.issuesFound ?? []);
1361
+ // Read defensively, like the crash-contained checks above: the plan comes
1362
+ // off disk, so its declared array type holds only on the validated path.
1363
+ const stillOpen = Array.isArray(stateData.plan?.openObjections)
1364
+ ? stateData.plan.openObjections
1365
+ : [];
1366
+ // An answered objection is published WITH its answer, beside the open ones:
1367
+ // any non-blank answer closes one, so publishing only the open would let a
1368
+ // single character erase the record. The reader judges the answer, not the
1369
+ // server.
1370
+ const answered = Array.isArray(stateData.plan?.answeredObjections)
1371
+ ? stateData.plan.answeredObjections
1372
+ : [];
1373
+ // Closed by a blocker the server could not check. Published with the blocker
1374
+ // it names, so a reader can count how much of this run's accountability
1375
+ // rests on a claim nothing verified.
1376
+ const unverified = Array.isArray(stateData.plan?.unverifiedCloses)
1377
+ ? stateData.plan.unverifiedCloses
1378
+ : [];
1379
+ // Post-execution objections are recomputed on every call, so one the agent
1380
+ // fixed is gone and one it did not is raised again. Recomputation loses the
1381
+ // ANSWER, which arrives on one call and is needed on every later one, so the
1382
+ // answered set is persisted below and merged with this call's answers.
1383
+ const acknowledged = params.answers ?? [];
1384
+ // The message is part of the key, not just the id: one id covers several
1385
+ // objections — `expectedOutcome:executed:<id>` is raised for a should-fail
1386
+ // test that passed, a should-pass one that failed, and a failure that never
1387
+ // reached its behaviour — so an id-only match would close the wrong one.
1388
+ const carried = (Array.isArray(stateData.reportObjections?.answeredObjections)
1389
+ ? stateData.reportObjections.answeredObjections
1390
+ : []).filter((entry) => typeof entry?.objection?.objectionId === "string" &&
1391
+ typeof entry?.answer === "string" &&
1392
+ entry.answer.trim().length > 0);
1393
+ const carriedByKey = new Map(carried.map((entry) => [answerKey(entry.objection), entry]));
1394
+ // What the LAST call raised. Without it there is nothing to notice a
1395
+ // fixed objection's absence against.
1396
+ const previouslyRaised = (Array.isArray(stateData.reportObjections?.raisedObjections)
1397
+ ? stateData.reportObjections.raisedObjections
1398
+ : []).filter((entry) => typeof entry?.objection?.objectionId === "string");
1399
+ // When an objection was first raised. Restamping it every call said an
1400
+ // objection answered on call 2 was raised on call 2, so a reader could not
1401
+ // tell how long it had stood (run 33984531266, sb19-home-dashboard). The
1402
+ // number comes off disk, so a non-number is treated as no record.
1403
+ const firstRaisedAt = new Map(previouslyRaised
1404
+ .filter((entry) => Number.isFinite(entry.submission))
1405
+ .map((entry) => [answerKey(entry.objection), entry.submission]));
1406
+ const previouslyResolved = (Array.isArray(stateData.reportObjections?.resolvedObjections)
1407
+ ? stateData.reportObjections.resolvedObjections
1408
+ : []).filter((entry) => typeof entry?.objection?.objectionId === "string" &&
1409
+ typeof entry?.resolvedBy === "string");
1410
+ const submissionNumber = (stateData.reportObjections?.submissionCount ?? 0) + 1;
1411
+ const raised = [...postExecution, ...reportChecks];
1412
+ // Answerable = raised by THIS call or by an earlier one. The agent fixes an
1413
+ // objection and answers it in the same call: fixing it stops the check
1414
+ // raising it, so matching against this call alone called the answer unknown
1415
+ // and objected to the agent for doing both (run 33946409570, sb19).
1416
+ const answerable = [...raised, ...previouslyRaised.map((entry) => entry.objection)];
1417
+ const reportStageObjections = [
1418
+ ...raised,
1419
+ ...unknownAnswerObjections(answerable, acknowledged, {
1420
+ scope: "this run raised",
1421
+ suggestion: "Answer only the ids this tool has listed on this run. A plan-time objection is answered through skyramp_register_test_plan's own answers, not here.",
1422
+ }),
1423
+ ];
1424
+ const raisedNow = new Set(reportStageObjections.map(answerKey));
1425
+ // A call with no execution result gives these checks nothing to judge, so
1426
+ // their silence is not evidence: only a call carrying results can resolve an
1427
+ // earlier objection.
1428
+ const judgedThisCall = (params.testResults ?? []).length > 0;
1429
+ // Resolved = raised earlier, absent from a call that could have raised it.
1430
+ // One that comes back drops out: publishing it as both says two things.
1431
+ const resolvedObjections = [
1432
+ ...previouslyResolved,
1433
+ ...(judgedThisCall
1434
+ ? previouslyRaised
1435
+ .filter((entry) => !raisedNow.has(answerKey(entry.objection)))
1436
+ .map((entry) => ({
1437
+ objection: entry.objection,
1438
+ resolvedBy: `submission ${submissionNumber}`,
1439
+ }))
1440
+ : []),
1441
+ ].filter((entry, index, all) => !raisedNow.has(answerKey(entry.objection)) &&
1442
+ all.findIndex((other) => answerKey(other.objection) === answerKey(entry.objection)) === index);
1443
+ const answeredReportObjections = [];
1444
+ const openReportObjections = [];
1445
+ for (const objection of reportStageObjections) {
1446
+ const answer = answerFor(objection, acknowledged) ?? carriedByKey.get(answerKey(objection))?.answer;
1447
+ if (answer)
1448
+ answeredReportObjections.push({ objection, answer });
1449
+ else
1450
+ openReportObjections.push(objection);
1451
+ }
1452
+ objections = [
1453
+ ...stillOpen.map((objection) => ({
1454
+ ...objection,
1455
+ stage: "plan",
1456
+ })),
1457
+ ...answered
1458
+ .filter((entry) => entry?.objection)
1459
+ .map((entry) => ({
1460
+ ...entry.objection,
1461
+ stage: "plan",
1462
+ answer: typeof entry.answer === "string" ? entry.answer : "",
1463
+ })),
1464
+ ...unverified
1465
+ .filter((entry) => entry?.objection)
1466
+ .map((entry) => ({
1467
+ ...entry.objection,
1468
+ stage: "plan",
1469
+ answer: typeof entry.answer === "string" ? entry.answer : "",
1470
+ unverifiedBlocker: typeof entry.blocker === "string" ? entry.blocker : "",
1471
+ })),
1472
+ ...openReportObjections.map((objection) => ({
1473
+ ...objection,
1474
+ stage: "post-execution",
1475
+ })),
1476
+ ...answeredReportObjections.map(({ objection, answer }) => ({
1477
+ ...objection,
1478
+ stage: "post-execution",
1479
+ answer,
1480
+ })),
1481
+ ...resolvedObjections.map(({ objection, resolvedBy }) => ({
1482
+ ...objection,
1483
+ stage: "post-execution",
1484
+ resolvedBy,
1485
+ })),
1486
+ ];
1487
+ // The change table: what the agent said the diff makes, and what it
1488
+ // delivered against each promise. Read defensively — the plan comes off
1489
+ // disk, so its declared array types hold only on the validated path.
1490
+ const declaredChanges = Array.isArray(stateData.plan?.changes)
1491
+ ? stateData.plan.changes
1492
+ : [];
1493
+ if (declaredChanges.length > 0) {
1494
+ const changesByCandidate = new Map();
1495
+ for (const plannedTest of Array.isArray(stateData.plan?.plannedTests)
1496
+ ? stateData.plan.plannedTests
1497
+ : []) {
1498
+ const id = plannedTest?.plannedTestId?.trim();
1499
+ const cited = plannedTest?.declarations?.changes;
1500
+ if (id && Array.isArray(cited)) {
1501
+ changesByCandidate.set(id, cited.map((entry) => String(entry ?? "").trim()));
1502
+ }
1503
+ }
1504
+ const answerByChange = new Map();
1505
+ for (const objection of objections) {
1506
+ if (!objection.objectionId.startsWith(CHANGE_OBJECTION_PREFIX))
1507
+ continue;
1508
+ const answer = typeof objection.answer === "string" ? objection.answer.trim() : "";
1509
+ if (answer)
1510
+ answerByChange.set(objection.objectionId.slice(CHANGE_OBJECTION_PREFIX.length), answer);
1511
+ }
1512
+ changeTable = declaredChanges.map((change) => {
1513
+ const id = String(change?.id ?? "").trim();
1514
+ const testedBy = dedupedNewTests
1515
+ .map((test) => (test.plannedTestId ?? "").trim())
1516
+ .filter((plannedTestId) => plannedTestId && (changesByCandidate.get(plannedTestId) ?? []).includes(id));
1517
+ const answer = answerByChange.get(id);
1518
+ return {
1519
+ id,
1520
+ text: String(change?.text ?? ""),
1521
+ source: String(change?.source ?? ""),
1522
+ testedBy,
1523
+ ...(answer ? { answer } : {}),
1524
+ };
1525
+ });
1526
+ }
1527
+ // An answer outlives both the call it was given on and the calls where
1528
+ // its objection does not fire, so the persisted set is this call's answers
1529
+ // plus every carried answer this call did not re-raise. The objections
1530
+ // are recomputed, so a stored copy would have no reader.
1531
+ const answeredNow = new Set(answeredReportObjections.map(({ objection }) => answerKey(objection)));
1532
+ // The report is the deliverable, so a failed state write must not cost
1533
+ // it; the answer is simply repeated on the next call.
1534
+ try {
1535
+ await stateManager.writeData({
1536
+ ...stateData,
1537
+ reportObjections: {
1538
+ answeredObjections: [
1539
+ ...answeredReportObjections,
1540
+ ...carried.filter((entry) => !answeredNow.has(answerKey(entry.objection))),
1541
+ ],
1542
+ // A call that judged nothing keeps the earlier entries it did not
1543
+ // re-raise: unresolved, merely unjudged.
1544
+ raisedObjections: [
1545
+ ...reportStageObjections.map((objection) => ({
1546
+ objection,
1547
+ submission: firstRaisedAt.get(answerKey(objection)) ?? submissionNumber,
1548
+ })),
1549
+ ...(judgedThisCall
1550
+ ? []
1551
+ : previouslyRaised.filter((entry) => !raisedNow.has(answerKey(entry.objection)))),
1552
+ ],
1553
+ resolvedObjections,
1554
+ submissionCount: submissionNumber,
1555
+ },
1556
+ });
1557
+ }
1558
+ catch (error) {
1559
+ logger.warning(`Could not persist report objections: ${error instanceof Error ? error.message : String(error)}`);
1560
+ }
1561
+ }
1200
1562
  const report = {
1201
1563
  businessCaseAnalysis: params.businessCaseAnalysis,
1202
1564
  newTestsCreated: sanitizedNewTests,
@@ -1206,7 +1568,10 @@ export function registerSubmitReportTool(server) {
1206
1568
  // TODO(multi-repo maintenance): map(normalizeRepository) once testMaintenanceSchema
1207
1569
  // has a repository field (see TODO above).
1208
1570
  testMaintenance: testMaintenance
1209
- ? await Promise.all(testMaintenance.map(async ({ testFilePath, ...row }) => {
1571
+ ? await Promise.all(testMaintenance.map(async ({ testFilePath, pomFile, ...row }) => {
1572
+ // `pomFile` is the file skyramp_actions told the agent to edit;
1573
+ // `testFilePath` stays the spec, which is what ran. Name both when
1574
+ // they differ, or a page-object edit is reported against the spec.
1210
1575
  // Maintenance rows still carry the absolute path here, so the
1211
1576
  // assertion summary uses an EXACT canonical-path lookup — no
1212
1577
  // basename ambiguity. This is what carries the maintenance
@@ -1220,6 +1585,10 @@ export function registerSubmitReportTool(server) {
1220
1585
  return {
1221
1586
  ...row,
1222
1587
  fileName: path.basename(testFilePath),
1588
+ ...(pomFile &&
1589
+ path.basename(pomFile) !== path.basename(testFilePath)
1590
+ ? { editedFileName: path.basename(pomFile) }
1591
+ : {}),
1223
1592
  ...(assertions ? { assertions } : {}),
1224
1593
  };
1225
1594
  }))
@@ -1234,13 +1603,15 @@ export function registerSubmitReportTool(server) {
1234
1603
  }),
1235
1604
  issuesFound,
1236
1605
  nextSteps: params.nextSteps ?? [],
1606
+ ...(changeTable ? { changes: changeTable } : {}),
1607
+ ...(objections ? { objections } : {}),
1237
1608
  commitMessage: (params.commitMessage ?? "").replace(/[\r\n]+/g, " ").trim() ||
1238
1609
  DEFAULT_COMMIT_MESSAGE,
1239
1610
  };
1240
1611
  const reportJson = JSON.stringify(report, null, 2);
1241
1612
  // Beside the state file, which was read successfully above — so this directory is
1242
1613
  // known to exist and no mkdir is needed. See REPORT_FILE_NAME.
1243
- const reportPath = path.join(path.dirname(params.stateFile), REPORT_FILE_NAME);
1614
+ const reportPath = path.join(path.dirname(stateFile), REPORT_FILE_NAME);
1244
1615
  logger.info("Submitting testbot report", {
1245
1616
  outputFile: reportPath,
1246
1617
  payloadBytes: reportJson.length,
@@ -1253,11 +1624,23 @@ export function registerSubmitReportTool(server) {
1253
1624
  outputFile: reportPath,
1254
1625
  elapsedMs: elapsed,
1255
1626
  });
1627
+ // Addressed to the AGENT in the second person, each naming a next move: the
1628
+ // report is already written when this returns and the tool can be called
1629
+ // again, so the instruction is actionable. An answered objection is
1630
+ // published but not listed back, which would read as a second demand.
1631
+ const open = objections?.filter((objection) => objection.stage === "post-execution" && objection.answer === undefined) ?? [];
1256
1632
  return {
1257
1633
  content: [
1258
1634
  {
1259
1635
  type: "text",
1260
- text: `Report submitted successfully to ${reportPath}`,
1636
+ text: `Report submitted successfully to ${reportPath}` +
1637
+ (open.length === 0
1638
+ ? ""
1639
+ : `\n\n${open.length} check${open.length === 1 ? "" : "s"} objected to what was reported. ` +
1640
+ `Every one is published in the report as it stands; fix what you can and call this tool again.\n` +
1641
+ open
1642
+ .map((objection) => `- ${objection.objectionId} — ${objection.message}\n evidence: ${objection.evidence}\n next: ${objection.suggestion}`)
1643
+ .join("\n")),
1261
1644
  },
1262
1645
  ],
1263
1646
  };