@skyramp/mcp 0.3.5 → 0.3.6-rc.2.ac20

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 (164) hide show
  1. package/build/adapters/jestAdapter.js +3 -0
  2. package/build/adapters/mochaAdapter.js +2 -0
  3. package/build/adapters/playwrightAdapter.js +3 -0
  4. package/build/adapters/pytestAdapter.js +12 -0
  5. package/build/playwright/registerPlaywrightTools.js +92 -30
  6. package/build/playwright/traceRecordingPrompt.d.ts +6 -0
  7. package/build/playwright/traceRecordingPrompt.js +6 -2
  8. package/build/prompts/code-reuse.d.ts +1 -2
  9. package/build/prompts/code-reuse.js +182 -77
  10. package/build/prompts/modularization/integration-test-modularization.d.ts +2 -0
  11. package/build/prompts/modularization/integration-test-modularization.js +83 -41
  12. package/build/prompts/modularization/render.d.ts +18 -0
  13. package/build/prompts/modularization/render.js +12 -0
  14. package/build/prompts/modularization/ui-test-modularization.d.ts +3 -1
  15. package/build/prompts/modularization/ui-test-modularization.js +89 -47
  16. package/build/prompts/pom-aware-code-reuse.js +3 -1
  17. package/build/prompts/shared-helper-policy.d.ts +57 -0
  18. package/build/prompts/shared-helper-policy.js +135 -0
  19. package/build/prompts/test-recommendation/diffExecutionPlan.js +62 -56
  20. package/build/prompts/test-recommendation/fullRepoCatalog.js +19 -8
  21. package/build/prompts/test-recommendation/recommendationShared.d.ts +28 -6
  22. package/build/prompts/test-recommendation/recommendationShared.js +90 -16
  23. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +22 -0
  24. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -2
  25. package/build/prompts/test-recommendation/test-recommendation-prompt.js +3 -3
  26. package/build/prompts/testbot/testbot-prompts.js +81 -35
  27. package/build/recommendation/budgeters/shared.js +105 -27
  28. package/build/recommendation/discriminators.js +13 -2
  29. package/build/recommendation/planRanker.d.ts +6 -6
  30. package/build/recommendation/planRanker.js +6 -61
  31. package/build/services/AnalyticsService.d.ts +7 -0
  32. package/build/services/AnalyticsService.js +7 -1
  33. package/build/services/ModularizationService.js +1 -3
  34. package/build/services/TestDiscoveryService.d.ts +0 -2
  35. package/build/services/TestDiscoveryService.js +2 -37
  36. package/build/services/TestGenerationService.d.ts +16 -0
  37. package/build/services/TestGenerationService.js +86 -10
  38. package/build/services/containerEnv.js +13 -12
  39. package/build/tools/code-refactor/codeReuseTool.js +279 -93
  40. package/build/tools/code-refactor/enhance-state.d.ts +49 -0
  41. package/build/tools/code-refactor/enhance-state.js +109 -0
  42. package/build/tools/code-refactor/enhanceAssertionsTool.js +34 -1
  43. package/build/tools/code-refactor/modularizationTool.js +9 -2
  44. package/build/tools/code-refactor/reuse-outcome.d.ts +23 -1
  45. package/build/tools/code-refactor/reuse-outcome.js +14 -4
  46. package/build/tools/code-refactor/reuse-state.d.ts +127 -5
  47. package/build/tools/code-refactor/reuse-state.js +628 -16
  48. package/build/tools/code-refactor/utils-verify-gates.d.ts +26 -0
  49. package/build/tools/code-refactor/utils-verify-gates.js +100 -0
  50. package/build/tools/code-refactor/verify-gates.d.ts +2 -1
  51. package/build/tools/code-refactor/verify-gates.js +90 -25
  52. package/build/tools/executeSkyrampTestTool.d.ts +19 -0
  53. package/build/tools/executeSkyrampTestTool.js +158 -8
  54. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +2 -2
  55. package/build/tools/generate-tests/generateE2ERestTool.js +16 -0
  56. package/build/tools/generate-tests/generateUIRestTool.d.ts +1 -0
  57. package/build/tools/generate-tests/generateUIRestTool.js +22 -0
  58. package/build/tools/generate-tests/scenarioLint.d.ts +2 -0
  59. package/build/tools/generate-tests/scenarioLint.js +127 -19
  60. package/build/tools/generate-tests/trace-reuse-guard.d.ts +20 -0
  61. package/build/tools/generate-tests/trace-reuse-guard.js +93 -0
  62. package/build/tools/runExistingTestsTool.d.ts +34 -2
  63. package/build/tools/runExistingTestsTool.js +104 -4
  64. package/build/tools/submitReportTool.d.ts +38 -38
  65. package/build/tools/submitReportTool.js +537 -120
  66. package/build/tools/test-management/analyzeChangesTool.d.ts +24 -1
  67. package/build/tools/test-management/analyzeChangesTool.js +71 -10
  68. package/build/tools/test-management/analyzeTestHealthTool.js +7 -7
  69. package/build/tools/test-management/registerTestPlanTool.d.ts +203 -0
  70. package/build/tools/test-management/registerTestPlanTool.js +70 -12
  71. package/build/types/ExternalTestExecution.d.ts +67 -1
  72. package/build/types/Recommendation.d.ts +34 -5
  73. package/build/types/RepositoryAnalysis.d.ts +133 -114
  74. package/build/types/RepositoryAnalysis.js +1 -1
  75. package/build/types/ReuseOutcome.d.ts +102 -6
  76. package/build/types/ReuseOutcome.js +16 -2
  77. package/build/types/TestRecommendation.js +21 -3
  78. package/build/types/TestTypes.js +14 -8
  79. package/build/types/TestbotReport.d.ts +10 -1
  80. package/build/types/index.d.ts +2 -2
  81. package/build/types/index.js +1 -1
  82. package/build/utils/AnalysisStateManager.d.ts +57 -1
  83. package/build/utils/AnalysisStateManager.js +54 -5
  84. package/build/utils/branchDiff.d.ts +10 -0
  85. package/build/utils/branchDiff.js +28 -0
  86. package/build/utils/changedRoutes.d.ts +29 -0
  87. package/build/utils/changedRoutes.js +87 -0
  88. package/build/utils/featureFlags.d.ts +21 -0
  89. package/build/utils/featureFlags.js +23 -0
  90. package/build/utils/frontendIntegration.js +34 -4
  91. package/build/utils/importerHop.d.ts +2 -8
  92. package/build/utils/importerHop.js +15 -53
  93. package/build/utils/pathMatching.d.ts +38 -0
  94. package/build/utils/pathMatching.js +71 -0
  95. package/build/utils/pathSignatures.d.ts +22 -0
  96. package/build/utils/pathSignatures.js +57 -0
  97. package/build/utils/planMatchKeys.d.ts +16 -3
  98. package/build/utils/planMatchKeys.js +26 -10
  99. package/build/utils/pluralization.d.ts +10 -0
  100. package/build/utils/pluralization.js +18 -0
  101. package/build/utils/pom-catalog-parse.d.ts +52 -0
  102. package/build/utils/pom-catalog-parse.js +141 -0
  103. package/build/utils/pom-scope/selector-extractor.d.ts +12 -0
  104. package/build/utils/pom-scope/selector-extractor.js +34 -8
  105. package/build/utils/pom-verify/verify.d.ts +6 -5
  106. package/build/utils/pom-verify/verify.js +8 -6
  107. package/build/utils/reportVerification.d.ts +64 -4
  108. package/build/utils/reportVerification.js +228 -3
  109. package/build/utils/reuseRouting.d.ts +3 -0
  110. package/build/utils/reuseRouting.js +50 -0
  111. package/build/utils/routeParsers.d.ts +2 -0
  112. package/build/utils/routeParsers.js +65 -8
  113. package/build/utils/scenarioDrafting.d.ts +1 -1
  114. package/build/utils/scenarioDrafting.js +57 -45
  115. package/build/utils/subjectEndpoints.d.ts +19 -0
  116. package/build/utils/subjectEndpoints.js +98 -0
  117. package/build/utils/testFileClassification.d.ts +11 -0
  118. package/build/utils/testFileClassification.js +47 -0
  119. package/build/utils/uiPageEnumerator.d.ts +45 -19
  120. package/build/utils/uiPageEnumerator.js +95 -51
  121. package/build/utils/utils-verify/allow.d.ts +16 -0
  122. package/build/utils/utils-verify/allow.js +68 -0
  123. package/build/utils/utils-verify/call-sites.d.ts +34 -0
  124. package/build/utils/utils-verify/call-sites.js +154 -0
  125. package/build/utils/utils-verify/index.d.ts +7 -0
  126. package/build/utils/utils-verify/index.js +7 -0
  127. package/build/utils/utils-verify/language-spec.d.ts +91 -0
  128. package/build/utils/utils-verify/language-spec.js +210 -0
  129. package/build/utils/utils-verify/locate.d.ts +39 -0
  130. package/build/utils/utils-verify/locate.js +199 -0
  131. package/build/utils/utils-verify/parse.d.ts +34 -0
  132. package/build/utils/utils-verify/parse.js +177 -0
  133. package/build/utils/utils-verify/stage.d.ts +24 -0
  134. package/build/utils/utils-verify/stage.js +107 -0
  135. package/build/utils/utils-verify/verify.d.ts +63 -0
  136. package/build/utils/utils-verify/verify.js +168 -0
  137. package/build/utils/utils.d.ts +3 -1
  138. package/build/utils/utils.js +3 -1
  139. package/build/workspace/workspace.d.ts +32 -32
  140. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +9 -5
  141. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +16 -0
  142. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -0
  143. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +115 -14
  144. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +13 -1
  145. package/node_modules/playwright/node_modules/playwright-core/.DS_Store +0 -0
  146. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +2 -0
  147. package/node_modules/playwright/node_modules/playwright-core/lib/vite/htmlReport/index.html +27 -253
  148. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/{codeMirrorModule-DtudTj_v.js → codeMirrorModule-DJMC4zNo.js} +1 -1
  149. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BW82eAUI.js +196 -0
  150. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/index.html +1 -1
  151. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/{codeMirrorModule-FNMuBzX1.js → codeMirrorModule-CZfp96qZ.js} +1 -1
  152. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-gpLo02E0.js +809 -0
  153. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.Bq1r1URj.js +2 -0
  154. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +2 -2
  155. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.VEfqi1qN.js +5 -0
  156. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +2 -2
  157. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  158. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  159. package/node_modules/playwright/package.json +1 -1
  160. package/package.json +2 -2
  161. package/node_modules/playwright/node_modules/playwright-core/lib/vite/recorder/assets/index-BpDwp16L.js +0 -422
  162. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Co9upU5h.js +0 -1035
  163. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.DXNIQ_dx.js +0 -2
  164. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CIKB3XSv.js +0 -5
@@ -3,24 +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";
6
+ import { TEST_CATEGORIES, externalCategory, } from "../types/TestRecommendation.js";
7
7
  import { TestType, HttpMethod } from "../types/TestTypes.js";
8
8
  import { DriftAction } from "../types/TestAnalysis.js";
9
9
  import { TestExecutionStatus } from "../types/TestExecution.js";
10
10
  import { IssueFoundCategory } from "../types/TestbotReport.js";
11
- import { StateManager, runArtifactDir, getTestsRepoDir } from "../utils/AnalysisStateManager.js";
11
+ import { StateManager, runArtifactDir, getTestsRepoDir, } from "../utils/AnalysisStateManager.js";
12
12
  import { toolError, testFileMatches } from "../utils/utils.js";
13
13
  import { matchesApprovedPlan } from "../utils/planMatchKeys.js";
14
14
  import { isTestbotEnabled } from "../utils/featureFlags.js";
15
- import { findUnbackedClaims, listChangedFiles, listChangedFilesAcross } from "../utils/reportVerification.js";
15
+ import { findInvalidSourceCitations, findUnchangedFileClaims, listChangedFiles, listChangedFilesAcross, } from "../utils/reportVerification.js";
16
16
  import { getReportLanguage, isEnforcedReportLanguage, findLanguageViolations, findLanguageNearMisses, reportLanguageDisplayName, } from "../utils/reportLanguage.js";
17
- import { rederiveReuseOutcome } from "./code-refactor/reuse-state.js";
17
+ import { rederiveReuseOutcome, reuseChainSkipped, } from "./code-refactor/reuse-state.js";
18
18
  // SKYR-3879 Path B: which testTypes the register-plan checkpoint gates. Mirrors
19
19
  // the generation tools actually wired to planGuard (batch-scenario/integration,
20
20
  // contract) — UI and E2E are on a separate blueprint-grounded pipeline and are
21
21
  // NOT gated at generation time (see planGuard.ts wiring), so they are excluded
22
22
  // here too rather than surprising the agent with a report-time-only gate.
23
- const PLAN_GATED_TEST_TYPES = new Set([TestType.CONTRACT, TestType.INTEGRATION]);
23
+ const PLAN_GATED_TEST_TYPES = new Set([
24
+ TestType.CONTRACT,
25
+ TestType.INTEGRATION,
26
+ ]);
24
27
  /**
25
28
  * Filename of the report, written beside the state file. SKYR-4147: the report path is
26
29
  * derived here rather than accepted as a parameter. The caller builds the state file and
@@ -46,7 +49,9 @@ function planNameCandidates(testId, testType) {
46
49
  if (!id)
47
50
  return [];
48
51
  const prefix = `${testType}-`;
49
- return id.toLowerCase().startsWith(prefix) ? [id, id.slice(prefix.length)] : [id];
52
+ return id.toLowerCase().startsWith(prefix)
53
+ ? [id, id.slice(prefix.length)]
54
+ : [id];
50
55
  }
51
56
  /**
52
57
  * Split an `endpoint` field into one {method, path} per endpoint it names.
@@ -55,14 +60,20 @@ function planNameCandidates(testId, testType) {
55
60
  * unmatchable path, so such an entry could match nothing (SKYR-4123).
56
61
  */
57
62
  function parseEndpointField(endpoint) {
58
- const parts = (endpoint ?? "").split(",").map((p) => p.trim()).filter(Boolean);
63
+ const parts = (endpoint ?? "")
64
+ .split(",")
65
+ .map((p) => p.trim())
66
+ .filter(Boolean);
59
67
  if (parts.length === 0)
60
68
  return [{}];
61
69
  return parts.map((part) => {
62
70
  const spaceIdx = part.indexOf(" ");
63
71
  if (spaceIdx <= 0)
64
72
  return { path: part };
65
- return { method: part.slice(0, spaceIdx), path: part.slice(spaceIdx + 1).trim() };
73
+ return {
74
+ method: part.slice(0, spaceIdx),
75
+ path: part.slice(spaceIdx + 1).trim(),
76
+ };
66
77
  });
67
78
  }
68
79
  // Drift actions that actually modify a test file. VERIFY and IGNORE are
@@ -73,6 +84,96 @@ const MAINTENANCE_CHANGE_ACTIONS = new Set([
73
84
  DriftAction.Regenerate,
74
85
  DriftAction.Delete,
75
86
  ]);
87
+ const EXTERNAL_TO_EXECUTION_STATUS = {
88
+ pass: TestExecutionStatus.Pass,
89
+ fail: TestExecutionStatus.Fail,
90
+ error: TestExecutionStatus.Error,
91
+ };
92
+ /** Does this run's file outcome describe `knownPath`?
93
+ *
94
+ * `absoluteFile` is the framework's own path resolved against the root it reported,
95
+ * so it names one file and is compared exactly. An outcome without one is refused:
96
+ * a relative spelling is a suffix, and a suffix cannot separate
97
+ * `packages/a/tests/x.spec.ts` from `packages/b/tests/x.spec.ts`. Guessing there and
98
+ * guarding the guess is what this deliberately does not do — a missing status is a
99
+ * row that reads Unknown, which is what it reads today; a wrong one is a false claim
100
+ * about someone's tests.
101
+ *
102
+ * The cost is real and accepted: a suite running inside a container reports the
103
+ * container's root, which cannot equal the host path discovery recorded, so those
104
+ * repos get no status until the run records a root the report can line up. */
105
+ function outcomeMatches(outcome, knownPath) {
106
+ return (typeof outcome.absoluteFile === "string" &&
107
+ path.resolve(outcome.absoluteFile) === path.resolve(knownPath));
108
+ }
109
+ /** One line describing what the suite saw, for a row whose status came from the run
110
+ * rather than from an execution the agent drafted a summary for. Without it the
111
+ * rendered cell is a bare `Error` or `Pass` with nothing behind it. */
112
+ function describeOutcome(o) {
113
+ // Defended like the record reads around it: a malformed outcome must not throw
114
+ // out of the handler and take the whole report submission with it.
115
+ const c = o.counts ?? { pass: 0, fail: 0, error: 0, skipped: 0 };
116
+ const parts = [
117
+ c.pass ? `${c.pass} passed` : "",
118
+ c.fail ? `${c.fail} failed` : "",
119
+ c.error ? `${c.error} errored` : "",
120
+ c.skipped ? `${c.skipped} skipped` : "",
121
+ ].filter(Boolean);
122
+ const head = `${parts.join(", ") || "no tests reported"} in the repo's own suite`;
123
+ // Adapter messages carry stack traces, so collapse whitespace before truncating:
124
+ // an embedded newline would break the maintenance row this lands in.
125
+ const first = (o.errors ?? [])[0]?.replace(/\s+/g, " ").trim();
126
+ return first ? `${head} — ${first.slice(0, 200)}` : head;
127
+ }
128
+ function externalStatusesFor(records, knownPath) {
129
+ // LATEST per suite, then WORST across suites. Both halves matter: a suite re-run
130
+ // after another edit supersedes its own earlier verdict, so an obsolete failure
131
+ // must not outlive the fix that repaired it; but two different suites can each
132
+ // own the same file, and a pass in one must not mask a failure in the other.
133
+ const RANK = { pass: 0, fail: 1, error: 2 };
134
+ const statusIn = (mode) => {
135
+ const latestPerSuite = new Map();
136
+ for (const record of records ?? []) {
137
+ if (record.mode !== mode || record.skipped || !record.environmentHealthy)
138
+ continue;
139
+ // A suite that stopped on --max-failures left tests unrun and does not say
140
+ // which, so no file it touched can stand as covered. The adapter reports this;
141
+ // it is not a guess. (The tool forces --max-failures=0 for confirm, so this is
142
+ // reachable on a verify re-run under the repo's own cap.)
143
+ if (record.diagnostics?.maxFailuresBail)
144
+ continue;
145
+ const hit = (record.files ?? []).find((o) => outcomeMatches(o, knownPath));
146
+ if (!hit)
147
+ continue;
148
+ const key = record.suite ?? "";
149
+ const prior = latestPerSuite.get(key);
150
+ // `ranAt` is an ISO timestamp, so a lexical compare is chronological. The
151
+ // newest run wins BEFORE it is judged: a later partial re-run supersedes an
152
+ // earlier complete one, and must leave the row unknown rather than let the
153
+ // stale complete outcome stand in for it.
154
+ if (!prior || record.ranAt >= prior.at)
155
+ latestPerSuite.set(key, { at: record.ranAt, outcome: hit });
156
+ }
157
+ let worst;
158
+ for (const { outcome } of latestPerSuite.values()) {
159
+ // `skipped` means every test in the file was skipped — it was not exercised,
160
+ // and TestExecutionStatus.Skipped means a deliberate decision not to run it.
161
+ if (!outcome.complete || outcome.status === "skipped")
162
+ continue;
163
+ if (!worst || RANK[outcome.status] > RANK[worst.status])
164
+ worst = outcome;
165
+ }
166
+ return worst;
167
+ };
168
+ const before = statusIn("confirm");
169
+ const after = statusIn("verify");
170
+ return {
171
+ before: before && EXTERNAL_TO_EXECUTION_STATUS[before.status],
172
+ after: after && EXTERNAL_TO_EXECUTION_STATUS[after.status],
173
+ beforeDetail: before && describeOutcome(before),
174
+ afterDetail: after && describeOutcome(after),
175
+ };
176
+ }
76
177
  const TOOL_NAME = "skyramp_submit_report";
77
178
  const DEFAULT_COMMIT_MESSAGE = "Added recommendations by Skyramp Testbot.";
78
179
  // Per-repo attribution. In a multi-repo run, every report item carries the
@@ -89,23 +190,33 @@ const repositoryField = z
89
190
  * (downstream consumers treat absence as "the primary repo"). */
90
191
  function normalizeRepository(item) {
91
192
  const trimmed = item.repository?.trim();
92
- return trimmed ? { ...item, repository: trimmed } : { ...item, repository: undefined };
193
+ return trimmed
194
+ ? { ...item, repository: trimmed }
195
+ : { ...item, repository: undefined };
93
196
  }
94
197
  // videoPath is deliberately absent from this input contract: it is attached server-side
95
198
  // from the run's execution records (see attachVideoPath), and zod strips any the model
96
199
  // supplies anyway. SKYR-4156 is what happens when the agent owns that field instead.
97
200
  const testResultSchema = z.object({
98
- testType: z.nativeEnum(TestType).describe("Type of test. Do not include priority or other metadata in this field."),
99
- endpoint: z.string().describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
201
+ testType: z
202
+ .nativeEnum(TestType)
203
+ .describe("Type of test. Do not include priority or other metadata in this field."),
204
+ endpoint: z
205
+ .string()
206
+ .describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
100
207
  status: z.enum(["Pass", "Fail", "Skipped"]).describe("Test execution result"),
101
- details: z.string().describe("One sentence — no embedded newlines, no markdown. e.g. '10.8s, products_contract_test.py' or 'failed: <one-line error summary>, products_contract_test.py'"),
208
+ details: z
209
+ .string()
210
+ .describe("One sentence — no embedded newlines, no markdown. e.g. '10.8s, products_contract_test.py' or 'failed: <one-line error summary>, products_contract_test.py'"),
102
211
  // Required for every row: each one reports a specific test file the agent ran, so it
103
212
  // can always name it. It is what identifies the row server-side — `endpoint` cannot,
104
213
  // since several tests routinely exercise one endpoint — and for ui/e2e it is what
105
214
  // attaches the recorded video (SKYR-4156). Not included in the report itself.
106
215
  testFilePath: z
107
216
  .string()
108
- .refine((p) => path.isAbsolute(p), { message: "testFilePath must be an absolute path" })
217
+ .refine((p) => path.isAbsolute(p), {
218
+ message: "testFilePath must be an absolute path",
219
+ })
109
220
  .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."),
110
221
  repository: repositoryField,
111
222
  });
@@ -117,21 +228,47 @@ const testResultSchema = z.object({
117
228
  // parsing. `reasoning` is free-form prose constrained by the Blueprint
118
229
  // Citation Invariant — every element cited must appear in `targetElements`.
119
230
  // See testbot prompt step 4 for the full populate-and-render rules.
231
+ /**
232
+ * Accept an omitted key as an explicit `null`.
233
+ *
234
+ * SKYR-4208. The blueprint capture no longer carries a key whose value is null,
235
+ * so an element the agent lifts verbatim simply has no `testId`, `stableId` or
236
+ * `contextText`. Consumers of the report still expect all three keys, so the
237
+ * missing one is filled in here rather than asked for in the prompt.
238
+ */
239
+ function nullWhenAbsent(inner) {
240
+ return inner.nullish().transform((v) => v ?? null);
241
+ }
120
242
  export const targetElementSchema = z.object({
121
- role: z.string().describe("ARIA role (e.g. 'button', 'heading', 'textbox', 'link'). Lifted verbatim from the captured blueprint element's `role` field."),
122
- accessibleName: z.string().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."),
123
- testId: z.string().nullable().describe("data-testid attribute value (preferred locator handle), or null when the element has no data-testid. Lifted from the blueprint element's `testId`."),
124
- stableId: z.string().nullable().describe("Unique HTML id attribute value (fallback locator when no testId), or null. Lifted from the blueprint element's `stableId`."),
125
- contextText: z.array(z.string()).nullable().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."),
126
- mutability: z.enum(["mutable", "immutable", "unknown"]).optional().describe("Whether the element's content/state is expected to change between captures. 'mutable' elements are behavioral-test targets; 'immutable' are smoke-test targets. Copied from the blueprint element's `mutability`."),
127
- widgetType: z.enum(["native", "custom", "unknown"]).optional().describe("'native' = HTML built-ins (button, input). 'custom' or 'unknown' = non-standard composites; fall back to snapshot-driven trial clicks for interaction. Copied from the blueprint element's `widgetType`."),
243
+ role: z
244
+ .string()
245
+ .describe("ARIA role (e.g. 'button', 'heading', 'textbox', 'link'). Lifted verbatim from the captured blueprint element's `role` field."),
246
+ accessibleName: z
247
+ .string()
248
+ .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."),
249
+ 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`."),
250
+ stableId: nullWhenAbsent(z.string()).describe("Unique HTML id attribute value (fallback locator when no testId), or null. Lifted from the blueprint element's `stableId`."),
251
+ 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."),
252
+ mutability: z
253
+ .enum(["mutable", "immutable", "unknown"])
254
+ .optional()
255
+ .describe("Whether the element's content/state is expected to change between captures. 'mutable' elements are behavioral-test targets; 'immutable' are smoke-test targets. Copied from the blueprint element's `mutability`."),
256
+ widgetType: z
257
+ .enum(["native", "custom", "unknown"])
258
+ .optional()
259
+ .describe("'native' = HTML built-ins (button, input). 'custom' or 'unknown' = non-standard composites; fall back to snapshot-driven trial clicks for interaction. Copied from the blueprint element's `widgetType`."),
128
260
  });
129
261
  // Page metadata for a UI recommendation. Lifted from the BlueprintCapture
130
262
  // the agent used to populate `targetElements`. Codegen reads `url` to drive
131
263
  // page.goto(); the verifier uses `pageHash` to detect stale captures.
132
264
  export const pageContextSchema = z.object({
133
- url: z.string().describe("URL of the page where the test runs. Lifted from BlueprintCapture.url."),
134
- pageHash: z.string().optional().describe("Opaque hash of the captured page state (BlueprintCapture.pageHash). Lets the verifier confirm the recommendation was grounded in a still-current capture."),
265
+ url: z
266
+ .string()
267
+ .describe("URL of the page where the test runs. Lifted from BlueprintCapture.url."),
268
+ pageHash: z
269
+ .string()
270
+ .optional()
271
+ .describe("Opaque hash of the captured page state (BlueprintCapture.pageHash). Lets the verifier confirm the recommendation was grounded in a still-current capture."),
135
272
  });
136
273
  /**
137
274
  * SKYR-4193: LLMs habitually emit every key a schema declares, using a
@@ -165,21 +302,53 @@ function stripNullGroundingFields(val) {
165
302
  // interface that adds an `implemented: boolean` field. Both describe the same
166
303
  // concept (a test recommendation) — the only difference is whether it was
167
304
  // generated in this run or left for later. Tracked per Archit's review comment.
168
- export const newTestSchema = z.preprocess(stripNullGroundingFields, z.object({
169
- testId: z.string().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."),
170
- testType: z.nativeEnum(TestType).describe("Type of test created. Do not include priority or other metadata in this field."),
171
- category: z.preprocess((val) => externalCategory(val), z.enum(TEST_CATEGORIES)).describe("Test category — critical categories (security_boundary, business_rule, data_integrity, breaking_change) get generation priority over workflow"),
172
- endpoint: z.string().describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
305
+ export const newTestSchema = z.preprocess(stripNullGroundingFields, z
306
+ .object({
307
+ testId: z
308
+ .string()
309
+ .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."),
310
+ testType: z
311
+ .nativeEnum(TestType)
312
+ .describe("Type of test created. Do not include priority or other metadata in this field."),
313
+ category: z
314
+ .preprocess((val) => externalCategory(val), z.enum(TEST_CATEGORIES))
315
+ .describe("Test category — critical categories (security_boundary, business_rule, data_integrity, breaking_change) get generation priority over workflow"),
316
+ endpoint: z
317
+ .string()
318
+ .describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
173
319
  fileName: z.string().describe("Name of the generated test file"),
174
- description: z.string().trim().min(1).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."),
175
- scenarioFile: z.string().optional().describe("Path to the scenario JSON file if one was generated (e.g. 'tests/scenario_collections-links.json')"),
176
- traceFile: z.string().optional().describe("Path to the backend trace file if used or created"),
177
- frontendTrace: z.string().optional().describe("Path to the Playwright/UI trace file if used or created"),
178
- reasoning: z.string().describe("Why this test was created: what production risk it mitigates, what code pattern it targets, or what coverage gap it fills"),
320
+ description: z
321
+ .string()
322
+ .trim()
323
+ .min(1)
324
+ .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."),
325
+ scenarioFile: z
326
+ .string()
327
+ .optional()
328
+ .describe("Path to the scenario JSON file if one was generated (e.g. 'tests/scenario_collections-links.json')"),
329
+ traceFile: z
330
+ .string()
331
+ .optional()
332
+ .describe("Path to the backend trace file if used or created"),
333
+ frontendTrace: z
334
+ .string()
335
+ .optional()
336
+ .describe("Path to the Playwright/UI trace file if used or created"),
337
+ reasoning: z
338
+ .string()
339
+ .describe("Why this test was created: what production risk it mitigates, what code pattern it targets, or what coverage gap it fills"),
179
340
  repository: repositoryField,
180
- targetElements: z.array(targetElementSchema).min(1).nullable().optional().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."),
181
- pageContext: pageContextSchema.optional().describe("UI tests only: page metadata for the test. Lifted from the BlueprintCapture used during grounding."),
182
- }).superRefine((rec, ctx) => {
341
+ targetElements: z
342
+ .array(targetElementSchema)
343
+ .min(1)
344
+ .nullable()
345
+ .optional()
346
+ .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."),
347
+ pageContext: pageContextSchema
348
+ .optional()
349
+ .describe("UI tests only: page metadata for the test. Lifted from the BlueprintCapture used during grounding."),
350
+ })
351
+ .superRefine((rec, ctx) => {
183
352
  // targetElements / pageContext are UI-only.
184
353
  if (rec.testType !== TestType.UI) {
185
354
  for (const field of ["targetElements", "pageContext"]) {
@@ -210,7 +379,8 @@ export const newTestSchema = z.preprocess(stripNullGroundingFields, z.object({
210
379
  });
211
380
  }
212
381
  // pageContext is required when targetElements is an array (grounded), forbidden when null.
213
- if (Array.isArray(rec.targetElements) && rec.pageContext === undefined) {
382
+ if (Array.isArray(rec.targetElements) &&
383
+ rec.pageContext === undefined) {
214
384
  ctx.addIssue({
215
385
  code: z.ZodIssueCode.custom,
216
386
  path: ["pageContext"],
@@ -233,7 +403,8 @@ export const newTestSchema = z.preprocess(stripNullGroundingFields, z.object({
233
403
  message: "reasoning must contain '[no-blueprint-data]' when targetElements is null.",
234
404
  });
235
405
  }
236
- if (rec.description && !rec.description.includes("[no-blueprint-data]")) {
406
+ if (rec.description &&
407
+ !rec.description.includes("[no-blueprint-data]")) {
237
408
  ctx.addIssue({
238
409
  code: z.ZodIssueCode.custom,
239
410
  path: ["description"],
@@ -243,8 +414,18 @@ export const newTestSchema = z.preprocess(stripNullGroundingFields, z.object({
243
414
  }
244
415
  }
245
416
  }));
246
- const issueFoundSchema = z.object({
247
- description: z.string().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."),
417
+ /** A citation string the submit-time check verifies and the report then ships.
418
+ * Normalized HERE, once, so the value the verifier resolved is byte-identical to
419
+ * the value written: the check trimmed its input, so a padded " src/x.py " used to
420
+ * validate against the real path and ship the unusable padded one. A
421
+ * blank/whitespace-only value means "no citation", the same rule `repository` uses. */
422
+ const citationString = z.preprocess((v) => (typeof v === "string" ? v.trim() || undefined : v), z.string().optional());
423
+ const issueFoundSchema = z
424
+ .object({
425
+ description: z
426
+ .string()
427
+ .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. " +
428
+ "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."),
248
429
  severity: z
249
430
  .enum(["critical", "high", "medium", "low"])
250
431
  .optional()
@@ -256,36 +437,130 @@ const issueFoundSchema = z.object({
256
437
  .describe("Issue classification. bug = a product/code defect, e.g. found by a test or in the diff. " +
257
438
  "lint = a linter or formatter finding (eslint, flake8, prettier). " +
258
439
  "type = a type-check failure (tsc, mypy). " +
259
- "config = environment or tooling misconfiguration (wrong workspace auth type, missing env var, setup command failure). " +
440
+ "config = environment or tooling misconfiguration (wrong workspace auth type, missing env var, setup command failure), " +
441
+ "and also every Skyramp tool or environment failure — a generation or execution tool error, an unreachable app, a missing credential, a failed capture. " +
260
442
  "The report renders lint/type/config entries in a separate 'Configuration Errors' section so product bugs stay prominent under 'Issues Found'."),
261
443
  repository: repositoryField,
444
+ 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'). " +
445
+ "REQUIRED when category is 'bug'. " +
446
+ "Cite only a file you actually opened this run: this tool rejects the report if the path does not exist in the repository."),
447
+ sourceSymbol: citationString.describe("The function, method, or identifier inside sourceFile whose code is missing or wrong (e.g. 'delete_product'). " +
448
+ "Optional — a config file or template has no symbol to name. Set it whenever the file has one. " +
449
+ "This tool rejects the report if the text does not appear in the cited file."),
450
+ sourceLine: z
451
+ .number()
452
+ .int()
453
+ .positive()
454
+ .optional()
455
+ .describe("1-based line number in sourceFile. Optional and advisory. " +
456
+ "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. " +
457
+ "The tool does not check that the line still holds the cited code, because line numbers drift."),
458
+ })
459
+ .superRefine((issue, ctx) => {
460
+ // A `bug` entry asserts that product code is wrong, so it has to name the code:
461
+ // without a file the entry is prose a reviewer cannot act on, and nothing
462
+ // downstream can place it in the diff. Only the file is required — a config file
463
+ // or a template legitimately has no symbol to name, and line numbers are
464
+ // advisory. The other categories are tooling findings that often have no single
465
+ // source location (an unreachable app, a missing env var), so the requirement is
466
+ // scoped to `bug` alone.
467
+ //
468
+ // The exit when the code cannot be named is NOT to invent a citation: a failure
469
+ // the agent cannot localize is already reported by its `testResults` row, and
470
+ // a tool or environment failure belongs under `config`.
471
+ if (issue.category !== IssueFoundCategory.Bug)
472
+ return;
473
+ if (issue.sourceFile !== undefined)
474
+ return;
475
+ ctx.addIssue({
476
+ code: z.ZodIssueCode.custom,
477
+ path: ["sourceFile"],
478
+ message: `sourceFile is required when category is 'bug' — a bug entry must name the code it is about. ` +
479
+ `Open the file, then set sourceFile (path relative to the repository root). Also set sourceSymbol (the function or identifier inside it) when the file has one, and sourceLine (the line of the offending statement) when you have it — both are optional. ` +
480
+ `If you cannot point at the code: a test failure is already reported by its testResults entry, and a tool or environment failure belongs under category 'config' — do not guess a citation.`,
481
+ });
262
482
  });
263
483
  const scenarioStepSchema = z.object({
264
- method: z.nativeEnum(HttpMethod).optional().describe("HTTP method. Required for API steps, omit for UI/E2E actions."),
265
- path: z.string().optional().describe("Endpoint or page path (e.g. '/api/v1/products' or '/products'). Required for API steps, omit for UI actions."),
266
- description: z.string().describe("What this step does, e.g. 'Create a product' or 'Click checkout button and verify confirmation'"),
267
- expectedStatusCode: z.number().optional().describe("Expected HTTP status code, e.g. 200, 201, 404"),
268
- requestBody: z.record(z.any()).optional().describe("Example request body with realistic field values"),
269
- responseBody: z.record(z.any()).optional().describe("Key response fields to verify, e.g. { id: 'number', name: 'string', in_stock: 'boolean?' }"),
484
+ method: z
485
+ .nativeEnum(HttpMethod)
486
+ .optional()
487
+ .describe("HTTP method. Required for API steps, omit for UI/E2E actions."),
488
+ path: z
489
+ .string()
490
+ .optional()
491
+ .describe("Endpoint or page path (e.g. '/api/v1/products' or '/products'). Required for API steps, omit for UI actions."),
492
+ description: z
493
+ .string()
494
+ .describe("What this step does, e.g. 'Create a product' or 'Click checkout button and verify confirmation'"),
495
+ expectedStatusCode: z
496
+ .number()
497
+ .optional()
498
+ .describe("Expected HTTP status code, e.g. 200, 201, 404"),
499
+ requestBody: z
500
+ .record(z.any())
501
+ .optional()
502
+ .describe("Example request body with realistic field values"),
503
+ responseBody: z
504
+ .union([z.record(z.any()), z.array(z.any())])
505
+ .optional()
506
+ .describe("Key response fields to verify, e.g. { id: 'number', name: 'string', in_stock: 'boolean?' }. An array for a collection/list endpoint that returns a JSON array."),
270
507
  });
271
- export const additionalRecommendationSchema = z.preprocess(stripNullGroundingFields, z.object({
272
- testId: z.string().describe("Human-readable kebab-case identifier, e.g. 'integration-products-orders-workflow' or 'e2e-checkout-flow'. Format: '<testType>-<scenario-slug>'. Must be unique within the report."),
273
- testType: z.nativeEnum(TestType).describe("Type of test. Do not include priority or other metadata in this field."),
274
- category: z.preprocess((val) => externalCategory(val), z.enum(TEST_CATEGORIES)).describe("Test category — critical categories get generation priority over workflow"),
275
- primaryEndpoint: z.string().optional().describe("The focal endpoint this test targets, e.g. 'PATCH /api/v1/orders/{order_id}'. Required for single-step contract tests. For multi-step integration or E2E scenarios, omit — the steps array is the authoritative source of all endpoints involved."),
276
- scenarioName: z.string().optional().describe("Proposed scenario name for future generation, e.g. 'products-orders-workflow'. No file exists yet — this is a suggestion only. Omit if not applicable."),
508
+ export const additionalRecommendationSchema = z.preprocess(stripNullGroundingFields, z
509
+ .object({
510
+ testId: z
511
+ .string()
512
+ .describe("Human-readable kebab-case identifier, e.g. 'integration-products-orders-workflow' or 'e2e-checkout-flow'. Format: '<testType>-<scenario-slug>'. Must be unique within the report."),
513
+ testType: z
514
+ .nativeEnum(TestType)
515
+ .describe("Type of test. Do not include priority or other metadata in this field."),
516
+ category: z
517
+ .preprocess((val) => externalCategory(val), z.enum(TEST_CATEGORIES))
518
+ .describe("Test category — critical categories get generation priority over workflow"),
519
+ primaryEndpoint: z
520
+ .string()
521
+ .optional()
522
+ .describe("The focal endpoint this test targets, e.g. 'PATCH /api/v1/orders/{order_id}'. Required for single-step contract tests. For multi-step integration or E2E scenarios, omit — the steps array is the authoritative source of all endpoints involved."),
523
+ scenarioName: z
524
+ .string()
525
+ .optional()
526
+ .describe("Proposed scenario name for future generation, e.g. 'products-orders-workflow'. No file exists yet — this is a suggestion only. Omit if not applicable."),
277
527
  // TODO: replace text with max(3) and check for regression
278
- steps: z.array(scenarioStepSchema).describe("Ordered sequence of API/UI steps in this test scenario (at most 3). Each step must include method and path so the endpoints are explicit. Omit requestBody and responseBody from steps."),
279
- description: z.string().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."),
280
- priority: z.preprocess((val) => (typeof val === "string" ? val.toLowerCase() : val), z.enum(["high", "medium", "low"])).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."),
281
- openApiSpec: z.string().optional().describe("Path to OpenAPI/Swagger spec file if available, e.g. 'openapi.yaml'"),
282
- backendTrace: z.string().optional().describe("Path to backend trace file if available, e.g. 'tests/skyramp-traces.json'. Used by integration and E2E tests."),
283
- frontendTrace: z.string().optional().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."),
284
- reasoning: z.string().describe("Why this test is recommended: the specific production risk, business rule, or security boundary it would validate"),
528
+ steps: z
529
+ .array(scenarioStepSchema)
530
+ .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."),
531
+ description: z
532
+ .string()
533
+ .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."),
534
+ priority: z
535
+ .preprocess((val) => (typeof val === "string" ? val.toLowerCase() : val), z.enum(["high", "medium", "low"]))
536
+ .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."),
537
+ openApiSpec: z
538
+ .string()
539
+ .optional()
540
+ .describe("Path to OpenAPI/Swagger spec file if available, e.g. 'openapi.yaml'"),
541
+ backendTrace: z
542
+ .string()
543
+ .optional()
544
+ .describe("Path to backend trace file if available, e.g. 'tests/skyramp-traces.json'. Used by integration and E2E tests."),
545
+ frontendTrace: z
546
+ .string()
547
+ .optional()
548
+ .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."),
549
+ reasoning: z
550
+ .string()
551
+ .describe("Why this test is recommended: the specific production risk, business rule, or security boundary it would validate"),
285
552
  repository: repositoryField,
286
- targetElements: z.array(targetElementSchema).min(1).nullable().optional().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."),
287
- pageContext: pageContextSchema.optional().describe("UI tests only: page metadata for the test. Lifted from the BlueprintCapture used during grounding."),
288
- }).superRefine((rec, ctx) => {
553
+ targetElements: z
554
+ .array(targetElementSchema)
555
+ .min(1)
556
+ .nullable()
557
+ .optional()
558
+ .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."),
559
+ pageContext: pageContextSchema
560
+ .optional()
561
+ .describe("UI tests only: page metadata for the test. Lifted from the BlueprintCapture used during grounding."),
562
+ })
563
+ .superRefine((rec, ctx) => {
289
564
  if (rec.testType === TestType.CONTRACT && !rec.primaryEndpoint) {
290
565
  ctx.addIssue({
291
566
  code: z.ZodIssueCode.custom,
@@ -313,7 +588,8 @@ export const additionalRecommendationSchema = z.preprocess(stripNullGroundingFie
313
588
  message: "targetElements is required for testType: 'ui'. Use null when blueprint capture failed (and add '[no-blueprint-data]' to both description and reasoning).",
314
589
  });
315
590
  }
316
- if (Array.isArray(rec.targetElements) && rec.pageContext === undefined) {
591
+ if (Array.isArray(rec.targetElements) &&
592
+ rec.pageContext === undefined) {
317
593
  ctx.addIssue({
318
594
  code: z.ZodIssueCode.custom,
319
595
  path: ["pageContext"],
@@ -348,23 +624,33 @@ export const additionalRecommendationSchema = z.preprocess(stripNullGroundingFie
348
624
  // TODO(multi-repo maintenance): no `repository` field yet — see readData() TODO below.
349
625
  const testMaintenanceSchema = z.object({
350
626
  testType: z.nativeEnum(TestType).describe("Type of test."),
351
- endpoint: z.string().describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
627
+ endpoint: z
628
+ .string()
629
+ .describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
352
630
  testFilePath: z
353
631
  .string()
354
- .refine((p) => path.isAbsolute(p), { message: "testFilePath must be an absolute path" })
632
+ .refine((p) => path.isAbsolute(p), {
633
+ message: "testFilePath must be an absolute path",
634
+ })
355
635
  .describe("Absolute path of the test file that was maintained, e.g. '/repo/tests/products_smoke_test.py' — the same path you passed to skyramp_execute_test's testFile param. Consumers should basename this for display."),
356
- action: z.nativeEnum(DriftAction).describe("The drift action assigned to this test during maintenance triage."),
636
+ action: z
637
+ .nativeEnum(DriftAction)
638
+ .describe("The drift action assigned to this test during maintenance triage."),
357
639
  description: z.string().describe("What was changed and why"),
358
- beforeDetails: z.string().describe("One line only — no embedded newlines, no raw HTTP headers or JSON blobs. " +
640
+ beforeDetails: z
641
+ .string()
642
+ .describe("One line only — no embedded newlines, no raw HTTP headers or JSON blobs. " +
359
643
  "For passing runs: count and timing, e.g. '4 passed in 15.09s'. " +
360
644
  "For failing runs: failure name and one-line root cause, e.g. " +
361
645
  "'FAILED test_foo — assert 403 got 200, auth middleware not enforced'. " +
362
- "Empty string for VERIFY/IGNORE entries where no before-execution was run."),
363
- afterDetails: z.string().describe("One line only — no embedded newlines, no raw HTTP headers or JSON blobs. " +
646
+ "Pass an empty string when you ran nothing yourself — the server then fills this from the repo suite's own run — and for VERIFY/IGNORE entries where nothing ran at all. Omit the whole entry, never this field."),
647
+ afterDetails: z
648
+ .string()
649
+ .describe("One line only — no embedded newlines, no raw HTTP headers or JSON blobs. " +
364
650
  "For passing runs: count and timing, e.g. '5 passed in 10.96s'. " +
365
651
  "For failing runs: failure name and one-line root cause, e.g. " +
366
652
  "'FAILED test_foo — check_schema fails, order_id=1 has discount from prior PATCH test'. " +
367
- "Empty string for VERIFY/IGNORE/DELETE entries where no after-execution was run."),
653
+ "Pass an empty string when you ran nothing yourself — the server then fills this from the repo suite's own run — and for VERIFY/IGNORE/DELETE entries where nothing ran at all. Omit the whole entry, never this field."),
368
654
  // Server-populated from stateFile execution records — never supplied by the LLM.
369
655
  beforeStatus: z.nativeEnum(TestExecutionStatus),
370
656
  afterStatus: z.nativeEnum(TestExecutionStatus),
@@ -405,7 +691,9 @@ function computeReportMetrics(params) {
405
691
  const recommendations = params.additionalRecommendations ?? [];
406
692
  const countBy = (items, pred) => items.filter(pred).length;
407
693
  const changedMaintenance = (params.testMaintenance ?? []).filter(isMaintenanceChange);
408
- const maintenanceRecovered = countBy(changedMaintenance, (m) => (m.beforeStatus === TestExecutionStatus.Fail || m.beforeStatus === TestExecutionStatus.Error) && m.afterStatus === TestExecutionStatus.Pass);
694
+ const maintenanceRecovered = countBy(changedMaintenance, (m) => (m.beforeStatus === TestExecutionStatus.Fail ||
695
+ m.beforeStatus === TestExecutionStatus.Error) &&
696
+ m.afterStatus === TestExecutionStatus.Pass);
409
697
  return {
410
698
  testsGenerated: String(params.newTestsCreated.length),
411
699
  testsMaintained: String(changedMaintenance.length),
@@ -437,17 +725,35 @@ function computeReportMetrics(params) {
437
725
  * state: the execution fix-up can restore `<testFile>.raw.bak` over it afterwards
438
726
  * by plain `cp`, which this tool never sees. See rederiveReuseOutcome.
439
727
  *
440
- * Non-UI tests, tests the reuse tool never ran for, and specs whose outcome cannot
441
- * be re-derived all get nothing — which consumers already treat as "no reuse
442
- * summary". */
443
- async function attachReuseOutcome(test, outcomes) {
444
- if (test.testType !== TestType.UI || !outcomes)
728
+ * Tests the reuse tool never ran for and specs whose outcome cannot be re-derived
729
+ * get nothing — which consumers already treat as "no reuse summary" — with one
730
+ * exception: a test whose generation handed off a reuse step that was never taken
731
+ * gets `chainSkipped: true` (SKYR-4220), the one claim that is exactly about the
732
+ * reuse tool NOT having run. Applies to every test type: UI tests carry the POM
733
+ * fields, API tests the shared-helper ones. */
734
+ async function attachReuseOutcome(test, outcomes, handOffs) {
735
+ // POM records describe browser specs; a basename collision with an API test's
736
+ // fileName must not attach them there. A utils-path record is attachable anywhere.
737
+ const record = outcomes?.[path.basename(test.fileName)];
738
+ // A utils record carries the test type its verify ran under: a mismatch is a
739
+ // basename collision with another test, not this row's outcome.
740
+ const typeMatches = !record?.utils?.testType || record.utils.testType === test.testType;
741
+ const found = record && typeMatches && (test.testType === TestType.UI || record.utils)
742
+ ? record
743
+ : undefined;
744
+ const derived = found ? await rederiveReuseOutcome(found) : undefined;
745
+ // The RAW record: a colliding record of any kind means reuse ran for this basename,
746
+ // and the chain claim must not be made on the filtered view.
747
+ const chainSkipped = await reuseChainSkipped(test.fileName, test.testType, record, handOffs);
748
+ const reuse = derived || chainSkipped
749
+ ? { ...(derived ?? {}), ...(chainSkipped ? { chainSkipped } : {}) }
750
+ : undefined;
751
+ if (!reuse)
445
752
  return test;
446
- const found = outcomes[path.basename(test.fileName)];
447
- if (!found)
448
- return test;
449
- const reuse = await rederiveReuseOutcome(found);
450
- return reuse ? { ...test, reuse } : test;
753
+ // A record that re-derives to nothing (e.g. a utils path that wrote no file) must
754
+ // not attach an empty object — consumers treat presence as "reuse ran".
755
+ const hasContent = Object.values(reuse).some((v) => v !== undefined);
756
+ return hasContent ? { ...test, reuse } : test;
451
757
  }
452
758
  /**
453
759
  * Attach the video recorded for this execution, matched by the row's testFilePath
@@ -461,7 +767,10 @@ async function attachReuseOutcome(test, outcomes) {
461
767
  * consumers already treat as "no recording for this test".
462
768
  */
463
769
  function attachVideoPath(row, videos) {
464
- return { ...row, videoPath: videos?.[path.basename(row.testFilePath)]?.videoPath };
770
+ return {
771
+ ...row,
772
+ videoPath: videos?.[path.basename(row.testFilePath)]?.videoPath,
773
+ };
465
774
  }
466
775
  function deduplicateById(items) {
467
776
  const seen = new Set();
@@ -518,8 +827,8 @@ export function registerSubmitReportTool(server) {
518
827
  .optional()
519
828
  .default([])
520
829
  .describe("Actionable follow-ups for the PR author. Each entry must be a single-line string (no embedded newlines). " +
521
- "Include a next step for every critical/high severity issue in issuesFound. No next steps for low-severity issues. " +
522
- "Also include a next step for every external test (Playwright, Cypress, RTL) assigned REGENERATE or DELETE in testMaintenance — the developer must act on these manually since they cannot be auto-applied (e.g. 'Regenerate frontend/tests/cart_pom.spec.ts — CartLine structure changed, all selectors need re-recording' or 'Delete frontend/tests/homepage.spec.ts — /cart route removed'). " +
830
+ "Do NOT add a next step that repeats an entry in issuesFound. The report renders every issue with its description and source location, and the consumer also posts an inline review comment on the cited line, so a repeated next step shows the same sentence to the author three times. Add a next step for an issue only when it names an action the issue text does not already state. " +
831
+ "Include a next step for every external test (Playwright, Cypress, RTL) assigned REGENERATE or DELETE in testMaintenance — the developer must act on these manually since they cannot be auto-applied (e.g. 'Regenerate frontend/tests/cart_pom.spec.ts — CartLine structure changed, all selectors need re-recording' or 'Delete frontend/tests/homepage.spec.ts — /cart route removed'). " +
523
832
  "If multiple tests fail with 404 or connection refused: suggest checking targetSetupCommand/targetReadyCheckCommand. " +
524
833
  "If 401/403 on auth endpoints: suggest authTokenCommand. " +
525
834
  "When referencing code, use file name and relevant code pattern — no line numbers unless certain."),
@@ -590,31 +899,62 @@ export function registerSubmitReportTool(server) {
590
899
  { path: "businessCaseAnalysis", text: params.businessCaseAnalysis },
591
900
  ];
592
901
  params.newTestsCreated.forEach((t, i) => {
593
- textFields.push({ path: `newTestsCreated[${i}].description`, text: t.description });
594
- textFields.push({ path: `newTestsCreated[${i}].reasoning`, text: t.reasoning });
902
+ textFields.push({
903
+ path: `newTestsCreated[${i}].description`,
904
+ text: t.description,
905
+ });
906
+ textFields.push({
907
+ path: `newTestsCreated[${i}].reasoning`,
908
+ text: t.reasoning,
909
+ });
595
910
  });
596
911
  (params.additionalRecommendations ?? []).forEach((r, i) => {
597
- textFields.push({ path: `additionalRecommendations[${i}].description`, text: r.description });
598
- textFields.push({ path: `additionalRecommendations[${i}].reasoning`, text: r.reasoning });
912
+ textFields.push({
913
+ path: `additionalRecommendations[${i}].description`,
914
+ text: r.description,
915
+ });
916
+ textFields.push({
917
+ path: `additionalRecommendations[${i}].reasoning`,
918
+ text: r.reasoning,
919
+ });
599
920
  r.steps.forEach((s, j) => {
600
- textFields.push({ path: `additionalRecommendations[${i}].steps[${j}].description`, text: s.description });
921
+ textFields.push({
922
+ path: `additionalRecommendations[${i}].steps[${j}].description`,
923
+ text: s.description,
924
+ });
601
925
  });
602
926
  });
603
927
  params.testResults.forEach((t, i) => {
604
- textFields.push({ path: `testResults[${i}].details`, text: t.details });
928
+ textFields.push({
929
+ path: `testResults[${i}].details`,
930
+ text: t.details,
931
+ });
605
932
  });
606
933
  params.issuesFound.forEach((f, i) => {
607
- textFields.push({ path: `issuesFound[${i}].description`, text: f.description });
934
+ textFields.push({
935
+ path: `issuesFound[${i}].description`,
936
+ text: f.description,
937
+ });
608
938
  });
609
939
  (params.nextSteps ?? []).forEach((s, i) => {
610
940
  textFields.push({ path: `nextSteps[${i}]`, text: s });
611
941
  });
612
942
  (params.testMaintenanceDetails ?? []).forEach((d, i) => {
613
- textFields.push({ path: `testMaintenanceDetails[${i}].beforeDetails`, text: d.beforeDetails });
614
- textFields.push({ path: `testMaintenanceDetails[${i}].afterDetails`, text: d.afterDetails });
943
+ textFields.push({
944
+ path: `testMaintenanceDetails[${i}].beforeDetails`,
945
+ text: d.beforeDetails,
946
+ });
947
+ textFields.push({
948
+ path: `testMaintenanceDetails[${i}].afterDetails`,
949
+ text: d.afterDetails,
950
+ });
615
951
  });
616
- if (params.commitMessage && params.commitMessage !== DEFAULT_COMMIT_MESSAGE) {
617
- textFields.push({ path: "commitMessage", text: params.commitMessage });
952
+ if (params.commitMessage &&
953
+ params.commitMessage !== DEFAULT_COMMIT_MESSAGE) {
954
+ textFields.push({
955
+ path: "commitMessage",
956
+ text: params.commitMessage,
957
+ });
618
958
  }
619
959
  const languageViolations = findLanguageViolations(textFields, reportLanguage);
620
960
  if (languageViolations.length > 0) {
@@ -637,7 +977,9 @@ export function registerSubmitReportTool(server) {
637
977
  }
638
978
  }
639
979
  const dedupedNewTests = deduplicateById([...params.newTestsCreated]);
640
- const dedupedRecommendations = deduplicateById([...(params.additionalRecommendations ?? [])]);
980
+ const dedupedRecommendations = deduplicateById([
981
+ ...(params.additionalRecommendations ?? []),
982
+ ]);
641
983
  const stateManager = StateManager.fromStatePath(params.stateFile);
642
984
  let stateData;
643
985
  try {
@@ -658,7 +1000,8 @@ export function registerSubmitReportTool(server) {
658
1000
  // than silently reporting an empty testMaintenance section indistinguishable from a
659
1001
  // real "nothing to do". With zero existing tests there was nothing to lose, so
660
1002
  // undefined is safe there — no round-trip through skyramp_actions required.
661
- if (stateData.maintenanceVerdicts === undefined && (stateData.existingTests?.length ?? 0) > 0) {
1003
+ if (stateData.maintenanceVerdicts === undefined &&
1004
+ (stateData.existingTests?.length ?? 0) > 0) {
662
1005
  errorResult = toolError("stateFile has existingTests but no maintenanceVerdicts — skyramp_actions was not called for this run. " +
663
1006
  "Call skyramp_actions (with recommendations: [] if no existing tests needed action) before skyramp_submit_report.");
664
1007
  return errorResult;
@@ -682,19 +1025,24 @@ export function registerSubmitReportTool(server) {
682
1025
  return false;
683
1026
  const endpoints = parseEndpointField(t.endpoint);
684
1027
  const names = planNameCandidates(t.testId, t.testType);
685
- return !(names.length > 0 ? names : [undefined]).some((scenarioName) => endpoints.some(({ method, path }) => matchesApprovedPlan(approvedPlan, { scenarioName, testType: t.testType, method, path })));
1028
+ return !(names.length > 0 ? names : [undefined]).some((scenarioName) => endpoints.some(({ method, path }) => matchesApprovedPlan(approvedPlan, {
1029
+ scenarioName,
1030
+ testType: t.testType,
1031
+ method,
1032
+ path,
1033
+ })));
686
1034
  });
687
1035
  if (unapproved.length > 0) {
688
1036
  const approvedList = approvedPlan.generate.length > 0
689
- // Show each item's endpoint keys: without them a rejection that turns on
690
- // the endpoint reads as self-contradicting, because the entry's own name
691
- // is printed in this same list (SKYR-4123).
692
- ? approvedPlan.generate
693
- .map((item) => {
694
- const eps = item.matchKeys.filter((k) => k.startsWith("ep:"));
695
- return `[${item.testType}] ${item.scenarioName}${eps.length > 0 ? ` covering ${eps.join(", ")}` : ""}`;
696
- })
697
- .join("; ")
1037
+ ? // Show each item's endpoint keys: without them a rejection that turns on
1038
+ // the endpoint reads as self-contradicting, because the entry's own name
1039
+ // is printed in this same list (SKYR-4123).
1040
+ approvedPlan.generate
1041
+ .map((item) => {
1042
+ const eps = item.matchKeys.filter((k) => k.startsWith("ep:"));
1043
+ return `[${item.testType}] ${item.scenarioName}${eps.length > 0 ? ` covering ${eps.join(", ")}` : ""}`;
1044
+ })
1045
+ .join("; ")
698
1046
  : "(none)";
699
1047
  errorResult = toolError(`${unapproved.length} newTestsCreated entr${unapproved.length === 1 ? "y" : "ies"} not in the approved plan from ` +
700
1048
  `skyramp_register_test_plan (plan ${approvedPlan.planId}) — neither its GENERATE list nor its ADDITIONAL backfill pool: ` +
@@ -745,21 +1093,62 @@ export function registerSubmitReportTool(server) {
745
1093
  const recorded = stateData.existingTests?.find((t) => testFileMatches(t.testFile, m.testFilePath));
746
1094
  const detail = params.testMaintenanceDetails?.find((d) => d.testFilePath === m.testFilePath);
747
1095
  const displayName = path.basename(m.testFilePath);
748
- const defaultBeforeStatus = MAINTENANCE_CHANGE_ACTIONS.has(m.action) ? TestExecutionStatus.Unknown : TestExecutionStatus.Skipped;
749
- const defaultAfterStatus = m.action === DriftAction.Delete ? TestExecutionStatus.Skipped
750
- : MAINTENANCE_CHANGE_ACTIONS.has(m.action) ? TestExecutionStatus.Unknown : TestExecutionStatus.Skipped;
751
- const beforeStatus = recorded?.executionBefore?.status ?? defaultBeforeStatus;
752
- const afterStatus = recorded?.executionAfter?.status ?? defaultAfterStatus;
1096
+ const defaultBeforeStatus = MAINTENANCE_CHANGE_ACTIONS.has(m.action)
1097
+ ? TestExecutionStatus.Unknown
1098
+ : TestExecutionStatus.Skipped;
1099
+ const defaultAfterStatus = m.action === DriftAction.Delete
1100
+ ? TestExecutionStatus.Skipped
1101
+ : MAINTENANCE_CHANGE_ACTIONS.has(m.action)
1102
+ ? TestExecutionStatus.Unknown
1103
+ : TestExecutionStatus.Skipped;
1104
+ // A suite run answers only for a row whose default is Unknown. VERIFY and
1105
+ // IGNORE are Skipped by decision, not for want of a result, and testbot
1106
+ // drops both from the rendered table.
1107
+ const external = externalStatusesFor(stateData.externalTestResults, m.testFilePath);
1108
+ const beforeStatus = recorded?.executionBefore?.status ??
1109
+ (defaultBeforeStatus === TestExecutionStatus.Unknown
1110
+ ? external.before
1111
+ : undefined) ??
1112
+ defaultBeforeStatus;
1113
+ const afterStatus = recorded?.executionAfter?.status ??
1114
+ (defaultAfterStatus === TestExecutionStatus.Unknown
1115
+ ? external.after
1116
+ : undefined) ??
1117
+ defaultAfterStatus;
753
1118
  // Trim before checking — a whitespace-only string is semantically blank and
754
1119
  // must not satisfy the "drafted a summary" requirement.
755
- const beforeDetails = detail?.beforeDetails?.trim() ?? "";
756
- const afterDetails = detail?.afterDetails?.trim() ?? "";
1120
+ // The agent's drafted summary wins; otherwise a status that came from
1121
+ // the suite run carries the run's own line, so the rendered cell is
1122
+ // never a bare verdict with nothing behind it.
1123
+ //
1124
+ // Only when there is NO recorded execution. A file the agent ran itself
1125
+ // owes a drafted summary, and filling it here would satisfy the check
1126
+ // below on the agent's behalf whenever the two statuses happened to
1127
+ // agree — silently dropping the requirement.
1128
+ //
1129
+ // And only where the STATUS came from the suite too. A VERIFY or DELETE
1130
+ // row keeps its Skipped default, so a line saying "1 passed in the repo's
1131
+ // own suite" beside it contradicts the very cell it annotates.
1132
+ const beforeFromSuite = defaultBeforeStatus === TestExecutionStatus.Unknown &&
1133
+ !recorded?.executionBefore;
1134
+ const afterFromSuite = defaultAfterStatus === TestExecutionStatus.Unknown &&
1135
+ !recorded?.executionAfter;
1136
+ const beforeDetails = detail?.beforeDetails?.trim() ||
1137
+ (beforeFromSuite ? (external.beforeDetail ?? "") : "");
1138
+ const afterDetails = detail?.afterDetails?.trim() ||
1139
+ (afterFromSuite ? (external.afterDetail ?? "") : "");
757
1140
  if (recorded?.executionBefore && !beforeDetails)
758
1141
  missingDetails.push(`${displayName} (beforeDetails)`);
759
1142
  if (recorded?.executionAfter && !afterDetails)
760
1143
  missingDetails.push(`${displayName} (afterDetails)`);
761
1144
  logger.info(`${displayName}: before=${beforeStatus} after=${afterStatus}`);
762
- return { ...m, beforeDetails, afterDetails, beforeStatus, afterStatus };
1145
+ return {
1146
+ ...m,
1147
+ beforeDetails,
1148
+ afterDetails,
1149
+ beforeStatus,
1150
+ afterStatus,
1151
+ };
763
1152
  });
764
1153
  }
765
1154
  if (missingDetails.length > 0) {
@@ -796,7 +1185,7 @@ export function registerSubmitReportTool(server) {
796
1185
  ...Object.values(fullState?.relatedRepos ?? {}).map((section) => section.repositoryPath),
797
1186
  ])),
798
1187
  ];
799
- const unbacked = findUnbackedClaims({
1188
+ const unbacked = findUnchangedFileClaims({
800
1189
  repoRoot,
801
1190
  changedFiles,
802
1191
  newTests: dedupedNewTests,
@@ -815,7 +1204,10 @@ export function registerSubmitReportTool(server) {
815
1204
  // SKYR-4129. Do NOT offer re-running skyramp_actions to restate the verdict:
816
1205
  // this branch only fires when the edit is genuinely absent, so rewriting the
817
1206
  // record to match that would be the tamper path, not the fix.
818
- const changedList = changedFiles.slice(0, 20).map((f) => ` - ${f}`).join("\n");
1207
+ const changedList = changedFiles
1208
+ .slice(0, 20)
1209
+ .map((f) => ` - ${f}`)
1210
+ .join("\n");
819
1211
  errorResult = toolError(`${unbacked.length} report claim(s) are not backed by any change in the working tree — ` +
820
1212
  `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` +
821
1213
  `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` +
@@ -832,12 +1224,36 @@ export function registerSubmitReportTool(server) {
832
1224
  }
833
1225
  }
834
1226
  }
1227
+ // Validated against the tool INPUT only, never the state file (a state-file
1228
+ // guard here previously caused a rejection loop). Only entries carrying a
1229
+ // citation are checked, so an ordinary run pays no extra cost.
1230
+ // `repository` is normalized BEFORE the check so the attribution the check
1231
+ // reconciles is the one the report ships, and so the stamp below survives
1232
+ // into the written file — this array is what the report is built from.
1233
+ const issuesFound = params.issuesFound.map(normalizeRepository);
1234
+ if (issuesFound.some((issue) => issue.sourceFile?.trim())) {
1235
+ const citations = await findInvalidSourceCitations({
1236
+ checkouts: await stateManager.listRepoCheckouts(),
1237
+ issues: issuesFound,
1238
+ });
1239
+ if (citations.invalid.length > 0) {
1240
+ errorResult = toolError(citations.invalid.join("\n"));
1241
+ return errorResult;
1242
+ }
1243
+ // The checkout the citation resolved in owns the finding, so the report
1244
+ // states it. Left to the consumer, an unattributed related-repo path is
1245
+ // resolved against the primary checkout and links the wrong repo's file.
1246
+ citations.repository.forEach((repo, i) => {
1247
+ if (repo)
1248
+ issuesFound[i] = { ...issuesFound[i], repository: repo };
1249
+ });
1250
+ }
835
1251
  // Strip generation-artifact fields from newTestsCreated before writing.
836
1252
  // scenarioFile, traceFile, frontendTrace are internal paths used during
837
1253
  // generation — downstream scoring scripts don't expect them and fail if
838
1254
  // they encounter these string fields while traversing the object.
839
1255
  // Also normalize each item's `repository` (blank → undefined).
840
- const sanitizedNewTests = await Promise.all(dedupedNewTests.map(({ scenarioFile: _sf, traceFile: _tf, frontendTrace: _ft, ...rest }) => attachReuseOutcome(normalizeRepository(rest), stateData.reuseOutcomes)));
1256
+ const sanitizedNewTests = await Promise.all(dedupedNewTests.map(({ scenarioFile: _sf, traceFile: _tf, frontendTrace: _ft, ...rest }) => attachReuseOutcome(normalizeRepository(rest), stateData.reuseOutcomes, stateData.reuseHandOffs)));
841
1257
  const report = {
842
1258
  businessCaseAnalysis: params.businessCaseAnalysis,
843
1259
  newTestsCreated: sanitizedNewTests,
@@ -858,9 +1274,10 @@ export function registerSubmitReportTool(server) {
858
1274
  const { testFilePath: _tfp, ...wire } = attachVideoPath(normalizeRepository(row), stateData.executionVideos);
859
1275
  return wire;
860
1276
  }),
861
- issuesFound: params.issuesFound.map(normalizeRepository),
1277
+ issuesFound,
862
1278
  nextSteps: params.nextSteps ?? [],
863
- commitMessage: (params.commitMessage ?? "").replace(/[\r\n]+/g, " ").trim() || DEFAULT_COMMIT_MESSAGE,
1279
+ commitMessage: (params.commitMessage ?? "").replace(/[\r\n]+/g, " ").trim() ||
1280
+ DEFAULT_COMMIT_MESSAGE,
864
1281
  };
865
1282
  const reportJson = JSON.stringify(report, null, 2);
866
1283
  // Beside the state file, which was read successfully above — so this directory is