@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
@@ -0,0 +1,617 @@
1
+ import { allChangedFiles } from "../types.js";
2
+ import { LOCKFILE, NOT_A_ROUTE_FILE, NOT_SOURCE_DIR } from "./endpointGrounded.js";
3
+ import { isTestPath } from "../../utils/testFileClassification.js";
4
+ import { normalizeCitedPath } from "./citedPath.js";
5
+ import { COVERAGE_CONTRACT, fillPlaceholders } from "../verifierContracts.js";
6
+ import { isUIPlannedTest, stateRouteKey, subjectStep } from "../subjectStep.js";
7
+ /** Whether a path can be product code at all, by the deny list route grounding
8
+ * already applies to a cited file. Nothing else is excluded: a file's kind is
9
+ * not this check's judgment to make. */
10
+ function couldBeProductCode(file) {
11
+ const path = file.trim();
12
+ if (!path)
13
+ return false;
14
+ return !(isTestPath(path) || NOT_SOURCE_DIR.test(path) || LOCKFILE.test(path) || NOT_A_ROUTE_FILE.test(path));
15
+ }
16
+ /** Every changed file some planned test cites, in normalised spelling. THREE fields,
17
+ * all name-level: `changedFile`, `routes[].file` and `screenEvidence.file`
18
+ * — citing a changed file through any of them is being about that file. Coverage
19
+ * by citation reads names alone, where an anchor search needed the diff text. */
20
+ function citedFiles(plannedTests) {
21
+ const cited = new Set();
22
+ const add = (value) => {
23
+ const path = normalizeCitedPath(value);
24
+ if (path)
25
+ cited.add(path);
26
+ };
27
+ for (const plannedTest of plannedTests) {
28
+ add(plannedTest?.declarations?.changedFile);
29
+ add(plannedTest?.declarations?.screenEvidence?.file);
30
+ // The plan is read off disk, so this is an array only on the validated
31
+ // path. A throw here would reach the agent as `:crashed`, hiding the real
32
+ // objection instead of raising it.
33
+ const evidence = plannedTest?.declarations?.routes;
34
+ if (Array.isArray(evidence))
35
+ for (const entry of evidence)
36
+ add(entry?.file);
37
+ }
38
+ return cited;
39
+ }
40
+ /** The behaviour ids some planned test cites, trimmed. */
41
+ function citedBehaviors(plannedTests) {
42
+ const cited = new Set();
43
+ for (const plannedTest of plannedTests) {
44
+ const declared = plannedTest?.declarations?.changes;
45
+ if (!Array.isArray(declared))
46
+ continue;
47
+ for (const id of declared) {
48
+ const trimmed = String(id ?? "").trim();
49
+ if (trimmed)
50
+ cited.add(trimmed);
51
+ }
52
+ }
53
+ return cited;
54
+ }
55
+ /** Every declared behaviour needs a planned test citing it, or an answer. Coverage per
56
+ * file is the basic minimum — one shape test on a changed file satisfies it while
57
+ * the behaviour that file promises goes untested. This half is the target.
58
+ *
59
+ * Checked whether or not the run has a diff: the agent declared these itself, so
60
+ * there is always something to hold it to. */
61
+ function claimObjections(registration) {
62
+ const changes = Array.isArray(registration.changes) ? registration.changes : [];
63
+ const plannedTests = registration.plannedTests ?? [];
64
+ const objections = [];
65
+ if (changes.length === 0) {
66
+ return [
67
+ {
68
+ objectionId: "coverage:noChanges",
69
+ verifier: "coverage",
70
+ message: COVERAGE_CONTRACT.objections.noChanges.message,
71
+ evidence: "the registered plan declares an empty `changes` list.",
72
+ suggestion: COVERAGE_CONTRACT.objections.noChanges.suggestion,
73
+ },
74
+ ];
75
+ }
76
+ const declared = new Set();
77
+ for (const change of changes) {
78
+ const id = String(change?.id ?? "").trim();
79
+ if (id)
80
+ declared.add(id);
81
+ }
82
+ const cited = citedBehaviors(plannedTests);
83
+ const seen = new Set();
84
+ for (const change of changes) {
85
+ const id = String(change?.id ?? "").trim();
86
+ if (!id || seen.has(id))
87
+ continue;
88
+ seen.add(id);
89
+ if (cited.has(id))
90
+ continue;
91
+ objections.push({
92
+ objectionId: `coverage:change:${id}`,
93
+ verifier: "coverage",
94
+ message: COVERAGE_CONTRACT.objections.change.message,
95
+ evidence: `"${String(change?.text ?? "").trim()}" (${String(change?.source ?? "").trim()}): no planned test cites behaviour ${id}.`,
96
+ suggestion: COVERAGE_CONTRACT.objections.change.suggestion,
97
+ });
98
+ }
99
+ // One objection per planned test, not per id: a planned test with three unknown ids
100
+ // has one mistake to fix, and three objections would need three answers.
101
+ for (const plannedTest of plannedTests) {
102
+ const claimIds = plannedTest?.declarations?.changes;
103
+ if (!Array.isArray(claimIds))
104
+ continue;
105
+ const unknown = [...new Set(claimIds.map((id) => String(id ?? "").trim()))].filter((id) => id && !declared.has(id));
106
+ if (unknown.length === 0)
107
+ continue;
108
+ objections.push({
109
+ objectionId: `coverage:unknownChange:${plannedTest.plannedTestId}`,
110
+ verifier: "coverage",
111
+ plannedTestId: plannedTest.plannedTestId,
112
+ message: COVERAGE_CONTRACT.objections.unknownChange.message,
113
+ evidence: `${plannedTest.plannedTestId} cites ${unknown.map((id) => `"${id}"`).join(", ")}; this plan declares ${[...declared].map((id) => `"${id}"`).join(", ") || "no claims"}.`,
114
+ suggestion: COVERAGE_CONTRACT.objections.unknownChange.suggestion,
115
+ });
116
+ }
117
+ return objections;
118
+ }
119
+ // ── The state surface ───────────────────────────────────────────────────────
120
+ /** The methods that write. A GET reads what is already there, so what the record
121
+ * held before the call decides nothing; these four run new code against a value
122
+ * the change did not write, which is where a latent bug sits. */
123
+ const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
124
+ /** Whether a planned test names the state its starting record is in. Presence is all
125
+ * that is checked: no two repositories describe a record's state the same way, and
126
+ * the test puts the record in that state itself rather than reading it from a file. */
127
+ function declaresStartState(plannedTest) {
128
+ return String(plannedTest?.declarations?.startState ?? "").trim().length > 0;
129
+ }
130
+ /** The route a planned test's test mutates, by SHAPE — `stateRouteKey`, so the id a
131
+ * step happened to write does not split one route in two. UI planned tests are OUT:
132
+ * their subject is a screen, and their endpoints are attributions the agent wrote
133
+ * rather than what the test asserts. */
134
+ function mutatedRoute(plannedTest) {
135
+ if (isUIPlannedTest(plannedTest))
136
+ return undefined;
137
+ const step = subjectStep(plannedTest);
138
+ if (!MUTATING_METHODS.has(String(step?.method ?? "").trim().toUpperCase()))
139
+ return undefined;
140
+ return stateRouteKey(step);
141
+ }
142
+ /** The routes some planned test already tests from a non-default state. A UI planned test
143
+ * COUNTS here where it does not above: a browser test that drives the mutation
144
+ * against such a record is the same evidence as an API one. */
145
+ function routesTestedFromNonDefaultState(plannedTests) {
146
+ const covered = new Set();
147
+ for (const plannedTest of plannedTests) {
148
+ if (!declaresStartState(plannedTest))
149
+ continue;
150
+ // Both sides of the comparison read the same key, or a covered route would
151
+ // never match the route that owes the test.
152
+ const route = stateRouteKey(subjectStep(plannedTest));
153
+ if (route)
154
+ covered.add(route);
155
+ }
156
+ return covered;
157
+ }
158
+ /** Every mutated route needs one test that starts from a record in a state a
159
+ * freshly created record is not in. Read off the PLAN alone, like the behaviour
160
+ * half — the agent declared the mutation itself, so a run with no diff has
161
+ * something to hold it to. */
162
+ function stateTestObjections(registration) {
163
+ const plannedTests = registration.plannedTests ?? [];
164
+ const covered = routesTestedFromNonDefaultState(plannedTests);
165
+ const objections = [];
166
+ const seen = new Set();
167
+ for (const plannedTest of plannedTests) {
168
+ const route = mutatedRoute(plannedTest);
169
+ if (!route || covered.has(route) || seen.has(route))
170
+ continue;
171
+ seen.add(route);
172
+ objections.push({
173
+ // Keyed on the ROUTE, not the planned test: the plan owes one state test per
174
+ // route, whichever planned test delivers it.
175
+ objectionId: `coverage:stateTest:${route}`,
176
+ verifier: "coverage",
177
+ message: fillPlaceholders(COVERAGE_CONTRACT.objections.stateTest.message, { route }),
178
+ evidence: `${plannedTest.plannedTestId} calls ${route} as its subject step; no planned test declaring startState has a subject step on it.`,
179
+ suggestion: COVERAGE_CONTRACT.objections.stateTest.suggestion,
180
+ });
181
+ }
182
+ return objections;
183
+ }
184
+ // ── The declared cases ──────────────────────────────────────────────────────
185
+ /** One number, from a value a step sends or a case declares. A parameter is as
186
+ * often a string as a number, so `"100"` and `100` are one value. Anything else
187
+ * is no number at all: `Number("")` is 0, which would read as a value that was
188
+ * sent. */
189
+ function asNumber(value) {
190
+ if (typeof value === "number")
191
+ return Number.isFinite(value) ? value : undefined;
192
+ if (typeof value !== "string")
193
+ return undefined;
194
+ const text = value.trim();
195
+ if (!text)
196
+ return undefined;
197
+ const parsed = Number(text);
198
+ return Number.isFinite(parsed) ? parsed : undefined;
199
+ }
200
+ /** Whether a value a step sends is the value a case declares. Numbers when both
201
+ * sides read as numbers, text otherwise, so a query string's `"100"` matches a
202
+ * declared `100` and `"asc"` matches `"asc"`. A declared `null` matches only a
203
+ * value written as null. */
204
+ function sameValue(sent, declared) {
205
+ // Only an `absent` case has no value, and that case never reaches here.
206
+ if (declared === undefined)
207
+ return false;
208
+ if (declared === null)
209
+ return sent === null;
210
+ const sentNumber = asNumber(sent);
211
+ const declaredNumber = asNumber(declared);
212
+ if (sentNumber !== undefined && declaredNumber !== undefined)
213
+ return sentNumber === declaredNumber;
214
+ return String(sent) === String(declared);
215
+ }
216
+ /** Every value of one parameter a step sends, wherever it carries it: the query
217
+ * string written into the path, the `queryParams` object, or the top level of the
218
+ * request body. Nothing reads deeper into a body — a constrained input is a field
219
+ * of the request, not a field nested inside one. */
220
+ function valuesSent(step, param) {
221
+ const found = [];
222
+ const query = String(step?.path ?? "").split("?").slice(1).join("?");
223
+ if (query)
224
+ found.push(...new URLSearchParams(query).getAll(param));
225
+ for (const carrier of [step?.queryParams, step?.requestBody]) {
226
+ if (carrier && typeof carrier === "object" && !Array.isArray(carrier)) {
227
+ const fields = carrier;
228
+ if (param in fields)
229
+ found.push(fields[param]);
230
+ }
231
+ }
232
+ return found;
233
+ }
234
+ /** The parameter name that names the request's verb rather than one of its inputs. */
235
+ const METHOD_PARAM = "method";
236
+ /** A path segment written as a placeholder: `{id}`, `:id` or `[id]`. */
237
+ const PLACEHOLDER_SEGMENT = /^(?:\{(.+)\}|:(.+)|\[(.+)\])$/;
238
+ /** A step's path segments, with any query string dropped. */
239
+ function pathSegments(step) {
240
+ return String(step?.path ?? "").split("?")[0].trim().split("/");
241
+ }
242
+ /** The parameter a placeholder segment names, or undefined for a literal one. */
243
+ function placeholderName(segment) {
244
+ const match = PLACEHOLDER_SEGMENT.exec(segment.trim());
245
+ if (!match)
246
+ return undefined;
247
+ return (match[1] ?? match[2] ?? match[3]).trim() || undefined;
248
+ }
249
+ /** Where `param` sits in a path, according to the steps that write it as a
250
+ * placeholder. Only the plan says this: no route template is inferred from a
251
+ * concrete path, so a plan whose steps are all concrete yields no slot and its
252
+ * path cases read as not sent. */
253
+ function pathSlots(steps, param) {
254
+ const slots = [];
255
+ for (const step of steps) {
256
+ const segments = pathSegments(step);
257
+ segments.forEach((segment, index) => {
258
+ if (placeholderName(segment) === param)
259
+ slots.push({ segments, index });
260
+ });
261
+ }
262
+ return slots;
263
+ }
264
+ /** Every name one parameter wears in a route. A case names the input — `id` —
265
+ * where the step writes the route's own placeholder — `{collectionId}`. An accept
266
+ * case whose value is that placeholder is the plan saying the two are the same
267
+ * parameter, so its inner name counts as a name for `param` as well. The plan is
268
+ * still the only source: the names come from what it declares, never from a
269
+ * concrete path. */
270
+ function pathSlotNames(cases, param) {
271
+ const names = new Set([param]);
272
+ for (const declared of cases ?? []) {
273
+ if (String(declared?.param ?? "").trim() !== param)
274
+ continue;
275
+ if (declared?.expect !== "accept" || declared?.absent === true)
276
+ continue;
277
+ if (!isPlaceholderValue(declared?.value))
278
+ continue;
279
+ const inner = String(declared.value).trim().slice(1, -1).trim();
280
+ if (inner)
281
+ names.add(inner);
282
+ }
283
+ return [...names];
284
+ }
285
+ /** The one slot a route with a single placeholder has. A step that writes exactly
286
+ * one placeholder says where the record it names sits, whatever that placeholder
287
+ * is called, so a case on a parameter whose name ends in `id` reads that segment
288
+ * without the plan spelling out the alias. A step with no placeholder still says
289
+ * nothing — no template is read from a concrete path — and a step with two says
290
+ * nothing about which of them is the id. */
291
+ function soleSlots(steps) {
292
+ const slots = [];
293
+ for (const step of steps) {
294
+ const segments = pathSegments(step);
295
+ const placeholders = segments.reduce((at, segment, index) => (placeholderName(segment) ? [...at, index] : at), []);
296
+ if (placeholders.length === 1)
297
+ slots.push({ segments, index: placeholders[0] });
298
+ }
299
+ return slots;
300
+ }
301
+ /** Where `param` sits in a path, under any of the names it wears. */
302
+ function pathSlotsFor(steps, cases, param) {
303
+ const named = pathSlotNames(cases, param).flatMap((name) => pathSlots(steps, name));
304
+ return ID_PARAM.test(param) ? [...named, ...soleSlots(steps)] : named;
305
+ }
306
+ /** Whether a step carries `value` in the segment `param` occupies. Every other
307
+ * segment has to be the template's own, so the same value at the same depth of a
308
+ * different route does not count as sent. */
309
+ function sendsInPath(step, slot, value) {
310
+ const segments = pathSegments(step);
311
+ if (segments.length !== slot.segments.length)
312
+ return false;
313
+ for (let at = 0; at < segments.length; at++) {
314
+ if (at !== slot.index && segments[at] !== slot.segments[at])
315
+ return false;
316
+ }
317
+ return sameValue(segments[slot.index], value);
318
+ }
319
+ /** Whether a step's verb is the declared value. A rejected method is the input. */
320
+ function sendsAsMethod(step, value) {
321
+ const method = String(step?.method ?? "").trim();
322
+ if (!method)
323
+ return false;
324
+ return method.toUpperCase() === String(value).trim().toUpperCase();
325
+ }
326
+ /** Whether a step states that it leaves one parameter out. STATED, not inferred:
327
+ * a step that does not carry a parameter is not distinguishable from a step the
328
+ * parameter has nothing to do with. */
329
+ function omittedBy(step, param) {
330
+ const omits = step?.omits;
331
+ if (!Array.isArray(omits))
332
+ return false;
333
+ return omits.some((named) => String(named ?? "").trim() === param);
334
+ }
335
+ /** One declared case as the objection spells it. */
336
+ function renderCase(declared) {
337
+ const param = String(declared.param).trim();
338
+ const sent = declared.absent === true ? "absent" : `=${JSON.stringify(declared.value)}`;
339
+ return `${param}${declared.absent === true ? " " : ""}${sent} (${declared.expect})`;
340
+ }
341
+ /** Whether a planned test cites one change by id. */
342
+ function citesChange(plannedTest, id) {
343
+ const declared = plannedTest?.declarations?.changes;
344
+ if (!Array.isArray(declared))
345
+ return false;
346
+ return declared.some((cited) => String(cited ?? "").trim() === id);
347
+ }
348
+ /** Every declared case needs a step of a citing test that sends it. The agent says
349
+ * what proves the change; the server reads whether the plan sends it. Nothing here
350
+ * knows a kind of input, so a constraint nobody anticipated is checked the same
351
+ * way as a range.
352
+ *
353
+ * Four runs on one fixture declared a 1-100 range on `take` and sent the upper
354
+ * end in one of the four, while a report claimed both ends. A value the plan
355
+ * never sends is the gap.
356
+ *
357
+ * An `absent` case reads the step's `omits`: a step that simply does not carry a
358
+ * parameter says nothing, so the omission has to be stated to be checked.
359
+ *
360
+ * A case names a query parameter, a body field, a path segment, or the verb. Run
361
+ * 34078857176 declared five cases the plan did send — two ids in a path segment,
362
+ * two rejected methods — and drew an objection for each, because only the query
363
+ * string and the body were read. */
364
+ function caseObjections(registration) {
365
+ const changes = Array.isArray(registration.changes) ? registration.changes : [];
366
+ const plannedTests = registration.plannedTests ?? [];
367
+ const objections = [];
368
+ const seen = new Set();
369
+ for (const change of changes) {
370
+ const id = String(change?.id ?? "").trim();
371
+ const cases = change?.cases;
372
+ if (!id || !Array.isArray(cases) || cases.length === 0 || seen.has(id))
373
+ continue;
374
+ // A change nothing cites already draws `coverage:change:<id>`. A second
375
+ // objection about the same gap would need a second answer for one fix.
376
+ const citing = plannedTests.filter((plannedTest) => citesChange(plannedTest, id));
377
+ if (citing.length === 0)
378
+ continue;
379
+ seen.add(id);
380
+ const citingSteps = citing.flatMap((plannedTest) => {
381
+ const steps = plannedTest?.scenario?.steps;
382
+ return Array.isArray(steps) ? steps : [];
383
+ });
384
+ const missing = cases.filter((declared) => {
385
+ const param = String(declared?.param ?? "").trim();
386
+ if (!param)
387
+ return false;
388
+ if (declared.absent === true)
389
+ return !citingSteps.some((step) => omittedBy(step, param));
390
+ const slots = pathSlotsFor(citingSteps, cases, param);
391
+ const isMethod = param.toLowerCase() === METHOD_PARAM;
392
+ return !citingSteps.some((step) => valuesSent(step, param).some((sent) => sameValue(sent, declared.value)) ||
393
+ (isMethod && sendsAsMethod(step, declared.value)) ||
394
+ slots.some((slot) => sendsInPath(step, slot, declared.value)));
395
+ });
396
+ // One objection per case. A single objection listing three cases took one
397
+ // answer that addressed two of them and closed: run 34167252679 answered
398
+ // patch-highlight-guards for id="abc" and id=999999, and the third case, a
399
+ // second user's id no step ever sent, went with it. The id carries the
400
+ // rendered case, so it is the same string on the next registration whatever
401
+ // order the cases arrive in, and each one needs its own answer.
402
+ for (const declared of missing) {
403
+ const rendered = renderCase(declared);
404
+ objections.push({
405
+ objectionId: `coverage:cases:${id}:${rendered}`,
406
+ verifier: "coverage",
407
+ message: fillPlaceholders(COVERAGE_CONTRACT.objections.cases.message, { missing: rendered }),
408
+ evidence: `${citing.map((plannedTest) => plannedTest.plannedTestId).join(", ")} cite ${id}; no step of theirs sends ${rendered}.`,
409
+ suggestion: COVERAGE_CONTRACT.objections.cases.suggestion,
410
+ });
411
+ }
412
+ }
413
+ return objections;
414
+ }
415
+ /** A value written as a placeholder: the record an earlier step created. A step
416
+ * sends the same spelling, so `{memberUserId}` in a case is that record and a
417
+ * literal is a value the plan chose. */
418
+ const PLACEHOLDER_VALUE = /^\{[A-Za-z][A-Za-z0-9]*\}$/;
419
+ /** A parameter that names a record by id, by its own name. */
420
+ const ID_PARAM = /id$/i;
421
+ function isPlaceholderValue(value) {
422
+ return typeof value === "string" && PLACEHOLDER_VALUE.test(value.trim());
423
+ }
424
+ /** Every parameter some change rejects with a value, anywhere in the plan. A
425
+ * rejection belongs to whichever change lists it, so one change's reject case
426
+ * answers another's accept on the same parameter. An `absent` case is out: it
427
+ * rejects a missing parameter, not a record that is not there. */
428
+ function paramsWithValuedReject(changes) {
429
+ const rejected = new Set();
430
+ for (const change of changes) {
431
+ const cases = change?.cases;
432
+ if (!Array.isArray(cases))
433
+ continue;
434
+ for (const declared of cases) {
435
+ if (declared?.expect !== "reject" || declared?.absent === true || declared?.value === undefined)
436
+ continue;
437
+ const param = String(declared?.param ?? "").trim();
438
+ if (param)
439
+ rejected.add(param);
440
+ }
441
+ }
442
+ return rejected;
443
+ }
444
+ /** A route that takes a record by id can be given one that is not there, or one
445
+ * that belongs to another parent, and answer as if it were. `DELETE
446
+ * /collections/:id/members?userId=N` answered 200 "Member removed." for a user
447
+ * that was never a member; six runs on that fixture planned the accept and the
448
+ * absent-parameter rejection, and none planned the userId that is not a member.
449
+ *
450
+ * Read off the PLAN alone — no diff, no route table. An accept whose value is a
451
+ * placeholder names a record an earlier step created, and the parameter is an id
452
+ * either by its name or because a citing test sends it as a path segment. */
453
+ function rejectObjections(registration) {
454
+ const changes = Array.isArray(registration.changes) ? registration.changes : [];
455
+ const plannedTests = registration.plannedTests ?? [];
456
+ const rejected = paramsWithValuedReject(changes);
457
+ const objections = [];
458
+ const seen = new Set();
459
+ for (const change of changes) {
460
+ const id = String(change?.id ?? "").trim();
461
+ const cases = change?.cases;
462
+ if (!id || !Array.isArray(cases) || cases.length === 0 || seen.has(id))
463
+ continue;
464
+ // A change nothing cites already draws `coverage:change:<id>`, as above.
465
+ const citing = plannedTests.filter((plannedTest) => citesChange(plannedTest, id));
466
+ if (citing.length === 0)
467
+ continue;
468
+ seen.add(id);
469
+ const citingSteps = citing.flatMap((plannedTest) => {
470
+ const steps = plannedTest?.scenario?.steps;
471
+ return Array.isArray(steps) ? steps : [];
472
+ });
473
+ const unsatisfied = cases.find((declared) => {
474
+ const param = String(declared?.param ?? "").trim();
475
+ if (!param || param.toLowerCase() === METHOD_PARAM)
476
+ return false;
477
+ if (declared?.expect !== "accept" || declared?.absent === true)
478
+ return false;
479
+ if (!isPlaceholderValue(declared?.value))
480
+ return false;
481
+ if (!ID_PARAM.test(param) && pathSlotsFor(citingSteps, cases, param).length === 0)
482
+ return false;
483
+ return !rejected.has(param);
484
+ });
485
+ if (!unsatisfied)
486
+ continue;
487
+ const param = String(unsatisfied.param).trim();
488
+ const value = String(unsatisfied.value);
489
+ objections.push({
490
+ objectionId: `coverage:reject:${id}`,
491
+ verifier: "coverage",
492
+ message: fillPlaceholders(COVERAGE_CONTRACT.objections.reject.message, { param, value }),
493
+ evidence: `${id} declares ${param}=${value} (accept); no change in this plan declares a reject case on ${param} with a value.`,
494
+ suggestion: fillPlaceholders(COVERAGE_CONTRACT.objections.reject.suggestion, { param }),
495
+ });
496
+ }
497
+ return objections;
498
+ }
499
+ // ── The surface a change is on ──────────────────────────────────────────────
500
+ /** The test types that reach each surface. An `e2e` test drives the page AND the
501
+ * requests behind it, so it counts for both. */
502
+ const TEST_TYPES_FOR = {
503
+ api: { types: new Set(["contract", "integration", "e2e"]), kind: "contract, integration or e2e", name: "API" },
504
+ page: { types: new Set(["ui", "e2e"]), kind: "ui or e2e", name: "page" },
505
+ };
506
+ /** Every surface a change states, without repeats and without a name nothing maps. */
507
+ function declaredSurfaces(change) {
508
+ const named = change?.surfaces;
509
+ if (!Array.isArray(named))
510
+ return [];
511
+ return [...new Set(named.map((surface) => String(surface ?? "").trim()))].filter((surface) => surface in TEST_TYPES_FOR);
512
+ }
513
+ /** A change on a page needs a test that opens the page, and a change on the API
514
+ * needs one that calls it. Run 34065056830 cited a page change from an API test
515
+ * and nothing objected: no test in that plan clicked the filter the change added.
516
+ *
517
+ * Read off the PLAN alone, like the other two halves. */
518
+ function surfaceObjections(registration) {
519
+ const changes = Array.isArray(registration.changes) ? registration.changes : [];
520
+ const plannedTests = registration.plannedTests ?? [];
521
+ const objections = [];
522
+ const seen = new Set();
523
+ for (const change of changes) {
524
+ const id = String(change?.id ?? "").trim();
525
+ if (!id)
526
+ continue;
527
+ // A change nothing cites already draws `coverage:change:<id>`. A second
528
+ // objection about the same gap would need a second answer for one fix.
529
+ const citing = plannedTests.filter((plannedTest) => citesChange(plannedTest, id));
530
+ if (citing.length === 0)
531
+ continue;
532
+ for (const surface of declaredSurfaces(change)) {
533
+ const { types, kind, name } = TEST_TYPES_FOR[surface];
534
+ const objectionId = `coverage:surface:${id}:${surface}`;
535
+ if (seen.has(objectionId))
536
+ continue;
537
+ if (citing.some((plannedTest) => types.has(String(plannedTest?.scenario?.testType ?? "").trim().toLowerCase())))
538
+ continue;
539
+ seen.add(objectionId);
540
+ objections.push({
541
+ objectionId,
542
+ verifier: "coverage",
543
+ message: fillPlaceholders(COVERAGE_CONTRACT.objections.surface.message, { kind, surface: name }),
544
+ evidence: `${citing.map((plannedTest) => plannedTest.plannedTestId).join(", ")} cite ${id}; none of them is a ${kind} test.`,
545
+ suggestion: COVERAGE_CONTRACT.objections.surface.suggestion,
546
+ });
547
+ }
548
+ }
549
+ return objections;
550
+ }
551
+ /** One repository's changed files, and the name to attribute uncovered ones to.
552
+ * Keying the objection id on the path alone let two repositories that each change
553
+ * `src/App.tsx` share one id. A single-repository run names no repository in its
554
+ * ids, which is the id it produced before there was a second one. */
555
+ function repositoryPartitions(ctx) {
556
+ const named = Object.entries(ctx.changedFiles ?? {});
557
+ if (named.length < 2)
558
+ return [{ changedFiles: named[0]?.[1] ?? [] }];
559
+ return named.map(([repository, changedFiles]) => ({ repository, changedFiles: changedFiles ?? [] }));
560
+ }
561
+ /** The planned tests that count as covering one repository's change: those naming it,
562
+ * plus those naming no repository. ABSENT IS A WILDCARD, as `checkEveryDeliveredTestRan`
563
+ * and `collectExecutionOutcomes` read it — silence is nothing to disagree with. */
564
+ function candidatesFor(plannedTests, repository) {
565
+ if (!repository)
566
+ return plannedTests;
567
+ return plannedTests.filter((plannedTest) => {
568
+ const named = String(plannedTest?.repository ?? "").trim();
569
+ return named === "" || named === repository;
570
+ });
571
+ }
572
+ /** The completeness check, over three surfaces: every declared behaviour, every
573
+ * mutated route, and every changed file needs a test in the plan. More is
574
+ * welcome, fewer is the objection. ONE RULE for the file half, no classification
575
+ * — every changed file is a surface unless it cannot be product code, including a
576
+ * removed one. A FLOOR, never a cap. Only the file half needs a diff. */
577
+ export const coverage = {
578
+ name: "coverage",
579
+ run(registration, ctx) {
580
+ const objections = [
581
+ ...claimObjections(registration),
582
+ ...stateTestObjections(registration),
583
+ ...caseObjections(registration),
584
+ ...rejectObjections(registration),
585
+ ...surfaceObjections(registration),
586
+ ];
587
+ if (allChangedFiles(ctx).length === 0)
588
+ return objections;
589
+ const all = registration.plannedTests ?? [];
590
+ for (const { repository, changedFiles } of repositoryPartitions(ctx)) {
591
+ const cited = citedFiles(candidatesFor(all, repository));
592
+ const seen = new Set();
593
+ for (const entry of changedFiles) {
594
+ // Compared in the normalised spelling the citations are reduced to. The
595
+ // objection quotes the path as `--name-status` gave it, which for a
596
+ // non-ASCII name is the unquoted form, not the escaped one the diff shows.
597
+ const file = entry?.path ?? "";
598
+ const path = normalizeCitedPath(file);
599
+ if (!path || !couldBeProductCode(path) || seen.has(path))
600
+ continue;
601
+ seen.add(path);
602
+ if (cited.has(path))
603
+ continue;
604
+ objections.push({
605
+ objectionId: repository
606
+ ? `coverage:${repository}/${file}`
607
+ : `coverage:${file}`,
608
+ verifier: "coverage",
609
+ message: COVERAGE_CONTRACT.objections.file.message,
610
+ evidence: `${repository ? `${repository} ` : ""}${file}${entry?.deleted ? " (removed by this change)" : ""}: no planned test cites it in changedFile, routes or screenEvidence.`,
611
+ suggestion: COVERAGE_CONTRACT.objections.file.suggestion,
612
+ });
613
+ }
614
+ }
615
+ return objections;
616
+ },
617
+ };
@@ -0,0 +1,11 @@
1
+ import { Objection } from "../types.js";
2
+ import { Plan } from "../registerPlan.js";
3
+ /** One test as it appears in the submitted report. */
4
+ export interface DeliveredTest {
5
+ plannedTestId: string;
6
+ }
7
+ /** Verifier 5, post-execution half. Both directions on one pass: a report entry
8
+ * that was in no plan, and a planned planned test that never shipped. The two lists
9
+ * join on `plannedTestId` exactly — never a name, an endpoint or a similarity
10
+ * score. Objections go to the report: the tests already exist. */
11
+ export declare function checkDeliveredMatchesPlan(plan: Plan, delivered: DeliveredTest[]): Objection[];
@@ -0,0 +1,33 @@
1
+ /** Verifier 5, post-execution half. Both directions on one pass: a report entry
2
+ * that was in no plan, and a planned planned test that never shipped. The two lists
3
+ * join on `plannedTestId` exactly — never a name, an endpoint or a similarity
4
+ * score. Objections go to the report: the tests already exist. */
5
+ export function checkDeliveredMatchesPlan(plan, delivered) {
6
+ const planned = new Set(plan.plannedTests.map((c) => c.plannedTestId));
7
+ const shipped = new Set(delivered.map((d) => d.plannedTestId));
8
+ const objections = [];
9
+ for (const id of shipped) {
10
+ if (planned.has(id))
11
+ continue;
12
+ objections.push({
13
+ objectionId: `deliveredMatchesPlan:${id}`,
14
+ verifier: "deliveredMatchesPlan",
15
+ message: "This test is in the report but was not in the plan. Every test written must be declared first.",
16
+ evidence: `delivered but unplanned: ${id}`,
17
+ suggestion: "Declare it in the plan and register it again, or take it out of the report.",
18
+ });
19
+ }
20
+ for (const id of planned) {
21
+ if (shipped.has(id))
22
+ continue;
23
+ objections.push({
24
+ objectionId: `deliveredMatchesPlan:${id}`,
25
+ verifier: "deliveredMatchesPlan",
26
+ plannedTestId: id,
27
+ message: "This test was in the plan and is not in the report. Say why it did not ship.",
28
+ evidence: `planned but not delivered: ${id}`,
29
+ suggestion: "Add the test to the report, or record in the report why it was dropped.",
30
+ });
31
+ }
32
+ return objections;
33
+ }