@skyramp/mcp 0.4.0 → 0.4.1-rc.2

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 (83) hide show
  1. package/build/prompts/enhance-assertions/contractProviderAssertionsPrompt.js +2 -1
  2. package/build/prompts/enhance-assertions/integrationAssertionsPrompt.js +2 -1
  3. package/build/prompts/enhance-assertions/sharedAssertionRules.d.ts +1 -1
  4. package/build/prompts/enhance-assertions/sharedAssertionRules.js +57 -22
  5. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +17 -9
  6. package/build/prompts/test-recommendation/diffExecutionPlan.js +0 -2
  7. package/build/prompts/test-recommendation/test-recommendation-prompt.js +11 -6
  8. package/build/prompts/testbot/testbot-prompts.js +19 -12
  9. package/build/recommendation/answers.d.ts +11 -7
  10. package/build/recommendation/answers.js +14 -10
  11. package/build/recommendation/pullRequestText.d.ts +18 -0
  12. package/build/recommendation/pullRequestText.js +31 -0
  13. package/build/recommendation/registerPlan.d.ts +9 -1
  14. package/build/recommendation/registerPlan.js +8 -1
  15. package/build/recommendation/runVerifiers.js +6 -0
  16. package/build/recommendation/types.d.ts +58 -0
  17. package/build/recommendation/verifierContracts.d.ts +108 -13
  18. package/build/recommendation/verifierContracts.js +147 -29
  19. package/build/recommendation/verifiers/coverage.d.ts +10 -0
  20. package/build/recommendation/verifiers/coverage.js +144 -22
  21. package/build/recommendation/verifiers/defects.d.ts +9 -0
  22. package/build/recommendation/verifiers/defects.js +117 -0
  23. package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +22 -0
  24. package/build/recommendation/verifiers/deliveredMatchesPlan.js +43 -0
  25. package/build/recommendation/verifiers/existingCoverage.js +53 -0
  26. package/build/recommendation/verifiers/expectedValueSourced.d.ts +14 -0
  27. package/build/recommendation/verifiers/expectedValueSourced.js +246 -0
  28. package/build/recommendation/verifiers/issueTraceability.d.ts +52 -0
  29. package/build/recommendation/verifiers/issueTraceability.js +197 -0
  30. package/build/recommendation/verifiers/requirementSourced.d.ts +2 -0
  31. package/build/recommendation/verifiers/requirementSourced.js +168 -0
  32. package/build/services/TestGenerationService.js +3 -1
  33. package/build/tools/code-refactor/codeReuseTool.js +1 -1
  34. package/build/tools/code-refactor/reuse-outcome.d.ts +1 -1
  35. package/build/tools/code-refactor/reuse-state.d.ts +85 -7
  36. package/build/tools/code-refactor/reuse-state.js +239 -34
  37. package/build/tools/code-refactor/utils-verify-gates.d.ts +5 -0
  38. package/build/tools/code-refactor/utils-verify-gates.js +103 -11
  39. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +2 -1
  40. package/build/tools/submitReportTool.js +276 -39
  41. package/build/tools/test-management/actionsTool.js +5 -0
  42. package/build/tools/test-management/analyzeChangesTool.d.ts +53 -0
  43. package/build/tools/test-management/analyzeChangesTool.js +55 -2
  44. package/build/tools/test-management/registerTestPlanTool.d.ts +32 -17
  45. package/build/tools/test-management/registerTestPlanTool.js +105 -16
  46. package/build/types/ReuseOutcome.d.ts +73 -7
  47. package/build/types/TestAnalysis.d.ts +6 -0
  48. package/build/types/TestbotReport.d.ts +22 -4
  49. package/build/utils/AnalysisStateManager.d.ts +7 -1
  50. package/build/utils/AnalysisStateManager.js +5 -1
  51. package/build/utils/assertion-verify/api-shared-lints.js +118 -11
  52. package/build/utils/assertion-verify/format.js +2 -2
  53. package/build/utils/assertion-verify/helper-imports.d.ts +7 -0
  54. package/build/utils/assertion-verify/helper-imports.js +119 -27
  55. package/build/utils/assertion-verify/lint-types.d.ts +31 -2
  56. package/build/utils/assertion-verify/lint-types.js +66 -0
  57. package/build/utils/assertion-verify/metrics.d.ts +13 -0
  58. package/build/utils/assertion-verify/metrics.js +16 -0
  59. package/build/utils/assertion-verify/verify.d.ts +11 -6
  60. package/build/utils/assertion-verify/verify.js +56 -15
  61. package/build/utils/canonicalJson.d.ts +11 -0
  62. package/build/utils/canonicalJson.js +17 -0
  63. package/build/utils/utils-verify/action-key.d.ts +27 -0
  64. package/build/utils/utils-verify/action-key.js +292 -0
  65. package/build/utils/utils-verify/allow.d.ts +8 -1
  66. package/build/utils/utils-verify/allow.js +14 -1
  67. package/build/utils/utils-verify/call-sites.d.ts +76 -8
  68. package/build/utils/utils-verify/call-sites.js +256 -70
  69. package/build/utils/utils-verify/language-spec.d.ts +3 -2
  70. package/build/utils/utils-verify/parse.d.ts +22 -3
  71. package/build/utils/utils-verify/parse.js +123 -52
  72. package/build/utils/utils-verify/verify.d.ts +33 -3
  73. package/build/utils/utils-verify/verify.js +126 -12
  74. package/build/utils/workspaceAuth.d.ts +59 -19
  75. package/build/utils/workspaceAuth.js +228 -31
  76. package/package.json +1 -1
  77. package/plugin/prompts/generate-tests/execution-plan.md +3 -3
  78. package/plugin/prompts/generate-tests/generation.md +1 -0
  79. package/plugin/prompts/plan-tests.md +41 -15
  80. package/plugin/prompts/testbot-task1.md +3 -9
  81. package/build/prompts/testbot/planDeclarations.d.ts +0 -6
  82. package/build/prompts/testbot/planDeclarations.js +0 -9
  83. package/plugin/prompts/declaring-a-plan.md +0 -20
@@ -11,7 +11,6 @@
11
11
  // evidence stays computed in the verifier.
12
12
  export const CHANGED_FILE_CONTRACT = {
13
13
  id: "changedFile",
14
- declarationFields: ["declarations.changedFile", "declarations.screenEvidence.file"],
15
14
  objections: {
16
15
  notStated: {
17
16
  message: "This test does not state which changed file it targets.",
@@ -34,7 +33,6 @@ export const CHANGED_FILE_CONTRACT = {
34
33
  };
35
34
  export const SCREEN_ROUTE_CONTRACT = {
36
35
  id: "screenRoute",
37
- declarationFields: ["declarations.elements.pageUrl", "declarations.screenEvidence.file", "declarations.changedFile"],
38
36
  objections: {
39
37
  mismatch: {
40
38
  message: "The page this UI test opens is not a page that renders the changed file it is about.",
@@ -49,7 +47,6 @@ export const SCREEN_ROUTE_CONTRACT = {
49
47
  };
50
48
  export const ENDPOINT_GROUNDED_CONTRACT = {
51
49
  id: "endpointGrounded",
52
- declarationFields: ["steps[]", "testType", "declarations.routes"],
53
50
  objections: {
54
51
  uncited: {
55
52
  message: "A call this test makes cites no file that declares it.",
@@ -68,7 +65,6 @@ export const ENDPOINT_GROUNDED_CONTRACT = {
68
65
  };
69
66
  export const STATED_DIFFERENCE_CONTRACT = {
70
67
  id: "statedDifference",
71
- declarationFields: ["steps[]", "testType", "scenarioName", "declarations.differsFrom"],
72
68
  objections: {
73
69
  unexplainedPair: {
74
70
  message: "Another planned test in this plan tests the same endpoint and neither says how they differ.",
@@ -87,18 +83,24 @@ export const STATED_DIFFERENCE_CONTRACT = {
87
83
  };
88
84
  export const EXISTING_COVERAGE_CONTRACT = {
89
85
  id: "existingCoverage",
90
- declarationFields: ["declarations.existingTests"],
91
86
  objections: {
92
87
  citedTestMissing: {
93
88
  message: "This planned test names an existing test file that is not in the checkout.",
94
89
  suggestion: "Name the file repository-relative, spelled as the analysis lists it. Drop the entry if it names a test you did not read — naming none is an answer too.",
95
90
  },
91
+ maintainedTestMissing: {
92
+ message: "`maintains` names an existing test file that is not in the checkout.",
93
+ suggestion: "Name the file repository-relative, spelled as the analysis lists it. A maintenance entry is what makes its changes count as covered, so it has to name a file this run can edit.",
94
+ },
95
+ maintainedUnknownChange: {
96
+ message: "A `maintains` entry cites a change id that this plan does not declare.",
97
+ suggestion: "Spell the change's `id` exactly as your own `changes` list gives it — the match is exact apart from surrounding space. Declare the change if it is missing.",
98
+ },
96
99
  },
97
- suggestion: "Which existing tests did you read on this change, and how does this planned test differ from them?",
100
+ suggestion: "Which existing tests did you read on this change, how does this planned test differ from them, and which changes does the maintenance you are doing cover?",
98
101
  };
99
102
  export const EXPECTED_OUTCOME_CONTRACT = {
100
103
  id: "expectedOutcome",
101
- declarationFields: ["category", "declarations.expected.outcome", "declarations.expected.why"],
102
104
  objections: {
103
105
  // `{category}` is the planned test's own `category` value, quoted back to it so
104
106
  // the agent recognises the value it sent.
@@ -119,16 +121,6 @@ export const EXPECTED_OUTCOME_CONTRACT = {
119
121
  };
120
122
  export const UI_ELEMENT_GROUNDED_CONTRACT = {
121
123
  id: "uiElementGrounded",
122
- declarationFields: [
123
- "testType",
124
- "steps[]",
125
- "declarations.elements.items",
126
- "declarations.elements.pageUrl",
127
- "declarations.asserts",
128
- "scenarioName",
129
- "description",
130
- "declarations.expected.why",
131
- ],
132
124
  objections: {
133
125
  nogrounding: {
134
126
  message: "This UI test names no `elements.items`, so nothing says the elements it targets are on the page.",
@@ -154,20 +146,10 @@ export const UI_ELEMENT_GROUNDED_CONTRACT = {
154
146
  };
155
147
  export const COVERAGE_CONTRACT = {
156
148
  id: "coverage",
157
- declarationFields: [
158
- "changes",
159
- "declarations.changes",
160
- "declarations.changedFile",
161
- "declarations.routes",
162
- "declarations.screenEvidence.file",
163
- "declarations.startState",
164
- "changes[].cases",
165
- "changes[].surfaces",
166
- ],
167
149
  objections: {
168
150
  change: {
169
151
  message: "This declared change has no test in the plan.",
170
- suggestion: "Plan a test that exercises it. If this run cannot write one, answer it and set `blocker` to what stopped the run — a service that is not running, a paired branch that no longer exists, the one credential the run holds. An answer with no blocker leaves this open: a change nothing prevented a test from reaching takes the test. Coverage per file is the basic minimum; coverage per change is what the plan is judged by, so a file covered by a shape test still leaves the change it makes untested.",
152
+ suggestion: "Plan a test that exercises it. If an existing test already covers it and this run edits that test rather than writing a new one, declare it in `maintains` with the file, what your edit changes, and this change's id — maintenance is coverage, and a duplicate spec beside the one you edited is the mistake. If this run cannot write one, answer it and set `blocker` to what stopped the run — a service that is not running, a paired branch that no longer exists, the one credential the run holds. An answer with no blocker leaves this open: a change nothing prevented a test from reaching takes the test. Coverage per file is the basic minimum; coverage per change is what the plan is judged by, so a file covered by a shape test still leaves the change it makes untested.",
171
153
  },
172
154
  noChanges: {
173
155
  message: "The plan declares no changes, so nothing says what the pull request must make different.",
@@ -225,7 +207,6 @@ export const COVERAGE_CONTRACT = {
225
207
  };
226
208
  export const REMOVED_ELEMENT_GUARDED_CONTRACT = {
227
209
  id: "removedElementGuarded",
228
- declarationFields: ["declarations.elements.items", "declarations.asserts"],
229
210
  objections: {
230
211
  // v1 resolved a removed element to a page in the server, and fell back to the
231
212
  // root pages when the import graph found none, so that an element whose page
@@ -246,6 +227,143 @@ export const REMOVED_ELEMENT_GUARDED_CONTRACT = {
246
227
  },
247
228
  suggestion: "Does every element this diff removes have a planned test that asserts it is gone, and does no test claim a renamed identifier is gone?",
248
229
  };
230
+ export const EXPECTED_VALUE_SOURCED_CONTRACT = {
231
+ id: "expectedValueSourced",
232
+ objections: {
233
+ // The whole point of the check. A value read off the running app is the one
234
+ // source that cannot disagree with a defect, so a test built from it passes
235
+ // on the broken code and fails once the code is fixed.
236
+ sourcedFromCode: {
237
+ message: "This case expects a value it read from the code as it stands, so a defect in that code becomes what the test demands.",
238
+ suggestion: "Read the value from the pull request title or description, from a requirements file the description names, or from a convention the application already follows elsewhere, and name that source. If none of them states it, leave `expectedValue` out and assert the shape instead.",
239
+ },
240
+ missingSource: {
241
+ message: "This case states an expected value and does not say where the value came from.",
242
+ suggestion: "Set `expectedFrom` to `pr-title`, `pr-description`, `spec:<path>`, `convention:<file:line>` or `code`.",
243
+ },
244
+ unreadableSource: {
245
+ message: "This case names a file as the source of its expected value, and that file is not in the checkout.",
246
+ suggestion: "Give the path as the repository spells it, from the repository root. If the file is not there, the value has no source: read it from the pull request title or description instead, or leave `expectedValue` out and assert the shape.",
247
+ },
248
+ // `convention:` is trusted for one reason: the pull request did not write the rule,
249
+ // so the rule can still disagree with the pull request. Cited inside the diff, the
250
+ // same change decides the behaviour and decides what counts as correct.
251
+ //
252
+ // Says only that, and not that the value came from the code under test: a large
253
+ // diff carries changed files that have nothing to do with this case, and the check
254
+ // cannot tell those from the ones it tests. Answerable for that reason.
255
+ changedConvention: {
256
+ message: "This case reads its expected value from a line this pull request writes, so the same change decides both the behaviour and what counts as correct.",
257
+ suggestion: "Cite a file the pull request leaves alone, or read the value from the description or a requirements file. If the cited file is changed but has nothing to do with this case, say so as the answer.",
258
+ },
259
+ // The server rendered the prompt, so it holds the words the run was given. It
260
+ // checks the RULE and not the value: "over 60 is rejected" states no 61, and run
261
+ // 34414643135 objected to five cases whose values that rule decides.
262
+ quoteMissing: {
263
+ message: "This case reads its value from the pull request, and its change does not quote the sentence that states the rule.",
264
+ suggestion: "Put the sentence from the title or description on the change as `quote`, as written there. If that sentence states the rule and not the number, put the rule in `derived` and leave `expectedValue` out.",
265
+ },
266
+ // The other half of the same reading. Run 34434835959 declared
267
+ // `expectedValue: 159.998` from `pr-description` on a change whose quote states
268
+ // how a percentage discount works and writes no number; the agent computed 10%
269
+ // of a subtotal it had seen and attributed the result to the description.
270
+ valueNotQuoted: {
271
+ message: "This case reads its value from the pull request, and the pull request does not state this value.",
272
+ suggestion: "Quote the sentence that states it, or, if the pull request states a rule, put the rule in `derived` and leave `expectedValue` out; the test computes the value from what it sent.",
273
+ },
274
+ // A test that expects to fail asserts what the code does not return today, so
275
+ // some source outside the code states that value. Run 34406282133 declared such
276
+ // tests and put `expectedValue` on no case at all.
277
+ failWithoutValue: {
278
+ message: "This test expects to fail, and no case on the changes it cites states the value it must assert.",
279
+ suggestion: "Put the value the code does not return today on the case this test sends, as `expectedValue`, and name where you read it. If the source states a rule rather than a number, put the rule in `derived` instead. If no source states either, answer with what the test asserts instead and where that comes from.",
280
+ },
281
+ },
282
+ suggestion: "Where does the correct value for this case come from?",
283
+ };
284
+ export const REQUIREMENT_SOURCED_CONTRACT = {
285
+ id: "requirementSourced",
286
+ objections: {
287
+ // `{change}` is the change id. Nothing on the server parses a pull request, so
288
+ // this field is the only record of where the change was read. An unrecognised
289
+ // spelling lands here: one that passed would be a check a typo switches off.
290
+ unsourced: {
291
+ message: "Change `{change}` does not name a source the plan accepts: `pr-title`, `pr-description`, `spec:<path>` or `diff`.",
292
+ suggestion: "Set `source` on this change to `pr-title` or `pr-description`, to `spec:<path>` for the requirements file that states it, or to `diff`.",
293
+ },
294
+ // `{source}` is the source text as the plan wrote it. A path that no file
295
+ // answers is a citation nobody can check.
296
+ unreadable: {
297
+ message: "Change `{change}` cites `{source}`, and the checkout holds no file at that path.",
298
+ suggestion: "Cite the requirements file by the path the checkout holds, or name the source you actually read this change in.",
299
+ },
300
+ // A conflict is a disagreement between a requirement and the code. A change
301
+ // read off the diff is the code, so it has no second side. The check reads the
302
+ // sources the cited changes name, and says only that, not where they came from.
303
+ fromDiff: {
304
+ message: "This test reports a requirement conflict, and none of the changes it cites was read from the pull request or a requirements file.",
305
+ suggestion: "Cite a change you read in the pull request or in a requirements file, or report this test as something other than a requirement conflict.",
306
+ },
307
+ // The server rendered the prompt, so it holds the title and description the run
308
+ // was given. A requirements file the pull request never names is a file the agent
309
+ // found by looking, which is reading the repository, not reading a requirement.
310
+ notNamed: {
311
+ message: "Change `{change}` cites `{source}`, and the pull request names no such file.",
312
+ suggestion: "Cite a file the title or description names. If neither names it, you read the change in the repository, so set `source` to `diff`.",
313
+ },
314
+ // Says only what was read: this pull request arrived with no words in it.
315
+ emptyPullRequest: {
316
+ message: "Change `{change}` says it was read from the pull request, and the pull request has no title or description.",
317
+ suggestion: "Set `source` to `spec:<path>` for the requirements file that states this change, or to `diff` if only the code states it.",
318
+ },
319
+ // The quote is what makes the claim checkable: without it the server has the
320
+ // pull request's words and no way to know which of them the change rests on.
321
+ notQuoted: {
322
+ message: "This test reports a requirement conflict, and change `{change}` quotes words the pull request does not carry.",
323
+ suggestion: "Quote the requirement as the title or description spells it, word for word. If you cannot find those words there, the change came from somewhere else: say where in `source`.",
324
+ },
325
+ unquoted: {
326
+ message: "This test reports a requirement conflict, and change `{change}` does not quote the requirement.",
327
+ suggestion: "Set `quote` on that change to the sentence in the title or description that states the requirement.",
328
+ },
329
+ // Nothing else objects to this: the coverage check reads a change and asks which
330
+ // test proves it, so a test that cites nothing is a test it never reaches.
331
+ noChange: {
332
+ message: "This test reports a requirement conflict and cites no change, so nothing says where the requirement came from.",
333
+ suggestion: "Name in `changes` the change that states the requirement, or report this test as something other than a requirement conflict.",
334
+ },
335
+ },
336
+ suggestion: "Does every change name where you read it, and does every requirement-conflict test cite a change that came from the pull request or a requirements file?",
337
+ };
338
+ export const DEFECTS_CONTRACT = {
339
+ id: "defects",
340
+ objections: {
341
+ // Run 34283539284 reported six bugs with no test behind them: the review
342
+ // happened at report time, after the plan was frozen. A plan that lists no
343
+ // defect is where the review is asked for, before the plan.
344
+ none: {
345
+ message: "The plan declares no defects, so nothing says the code review happened before the plan.",
346
+ suggestion: "Review the code that serves each change — the handler, the functions it calls to read or write data, the component and what it calls — and list each defect in `defects`. If the review found none, answer in one line what you read.",
347
+ },
348
+ untested: {
349
+ message: "This declared defect has no test in the plan.",
350
+ suggestion: "Add a planned test that cites the defect in `defects` and sets `expected.outcome` to `fail`. A defect in a file this pull request touches gets the test whether or not this pull request introduced it. If something stopped this run from writing the test, answer it and set `blocker` to what stopped it.",
351
+ },
352
+ expectedPass: {
353
+ message: "Every test that cites this defect expects to pass, and a test that passes today proves nothing about a defect.",
354
+ suggestion: "Assert what the fixed code returns, set `expected.outcome` to `fail` with `why`, or answer in one line why a passing test is the proof.",
355
+ },
356
+ unknownId: {
357
+ message: "A planned test cites a defect id that this plan does not declare.",
358
+ suggestion: "Spell the defect's `id` exactly as your own `defects` list gives it — the match is exact apart from surrounding space. Declare the defect if it is missing.",
359
+ },
360
+ conflictWithoutDefect: {
361
+ message: "This test reports a requirement conflict and cites no defect.",
362
+ suggestion: "List the conflict in `defects`: the file and line that contradict the requirement, and a description that quotes the requirement. Cite that id from this test.",
363
+ },
364
+ },
365
+ suggestion: "Which planned test proves each defect the review found, and for one nothing proves, why does it need no test?",
366
+ };
249
367
  /** Fills a contract sentence's `{placeholder}` spans from the run's or the
250
368
  * planned test's own values. A placeholder with no value is left as written rather
251
369
  * than blanked, so a missed key shows up as `{knob}` instead of reading as a gap
@@ -1,4 +1,14 @@
1
1
  import { Verifier } from "../types.js";
2
+ /** One declared case as the objection spells it.
3
+ *
4
+ * Exported: `expectedValueSourced` keys its objections on the same rendering, so
5
+ * two cases on one parameter are two objections there too. */
6
+ export declare function renderCase(declared: {
7
+ param: string;
8
+ value?: unknown;
9
+ absent?: true;
10
+ expect: string;
11
+ }): string;
2
12
  /** The completeness check, over three surfaces: every declared behaviour, every
3
13
  * mutated route, and every changed file needs a test in the plan. More is
4
14
  * welcome, fewer is the objection. ONE RULE for the file half, no classification
@@ -1,5 +1,6 @@
1
1
  import { allChangedFiles } from "../types.js";
2
- import { LOCKFILE, NOT_A_ROUTE_FILE, NOT_SOURCE_DIR } from "./endpointGrounded.js";
2
+ import { canonicalJson } from "../../utils/canonicalJson.js";
3
+ import { escapesRepo, LOCKFILE, NOT_A_ROUTE_FILE, NOT_SOURCE_DIR } from "./endpointGrounded.js";
3
4
  import { isTestPath } from "../../utils/testFileClassification.js";
4
5
  import { normalizeCitedPath } from "./citedPath.js";
5
6
  import { COVERAGE_CONTRACT, fillPlaceholders } from "../verifierContracts.js";
@@ -37,9 +38,31 @@ function citedFiles(plannedTests) {
37
38
  }
38
39
  return cited;
39
40
  }
40
- /** The behaviour ids some planned test cites, trimmed. */
41
- function citedBehaviors(plannedTests) {
41
+ /** The behaviour ids the plan cites, trimmed — from its planned tests AND from the
42
+ * existing tests it maintains. A change covered by a spec this run edits is tested;
43
+ * counting only planned tests made `coverage:change:` unanswerable for a correct
44
+ * maintenance decision, since that objection closes with a `blocker` alone and
45
+ * nothing stopped the run.
46
+ *
47
+ * A maintenance entry credits its ids only when the file it names RESOLVES in the
48
+ * checkout. `existingCoverage` objects about an unresolvable one, but that objection
49
+ * takes prose, so crediting first would have handed `coverage:change:` — the one
50
+ * class a sentence cannot close, and the reason `BLOCKER_ONLY_PREFIX` exists — a
51
+ * route out through a file name nobody can open. */
52
+ function citedBehaviors(plannedTests, maintains = [], fileResolves = () => false) {
42
53
  const cited = new Set();
54
+ for (const entry of maintains) {
55
+ const ids = entry?.changes;
56
+ if (!Array.isArray(ids))
57
+ continue;
58
+ if (!fileResolves(String(entry?.file ?? "").trim()))
59
+ continue;
60
+ for (const id of ids) {
61
+ const trimmed = String(id ?? "").trim();
62
+ if (trimmed)
63
+ cited.add(trimmed);
64
+ }
65
+ }
43
66
  for (const plannedTest of plannedTests) {
44
67
  const declared = plannedTest?.declarations?.changes;
45
68
  if (!Array.isArray(declared))
@@ -58,7 +81,7 @@ function citedBehaviors(plannedTests) {
58
81
  *
59
82
  * Checked whether or not the run has a diff: the agent declared these itself, so
60
83
  * there is always something to hold it to. */
61
- function claimObjections(registration) {
84
+ function claimObjections(registration, ctx) {
62
85
  const changes = Array.isArray(registration.changes) ? registration.changes : [];
63
86
  const plannedTests = registration.plannedTests ?? [];
64
87
  const objections = [];
@@ -79,7 +102,8 @@ function claimObjections(registration) {
79
102
  if (id)
80
103
  declared.add(id);
81
104
  }
82
- const cited = citedBehaviors(plannedTests);
105
+ const maintains = Array.isArray(registration.maintains) ? registration.maintains : [];
106
+ const cited = citedBehaviors(plannedTests, maintains, (file) => !!file && !escapesRepo(file) && ctx.citedFileExists(file));
83
107
  const seen = new Set();
84
108
  for (const change of changes) {
85
109
  const id = String(change?.id ?? "").trim();
@@ -207,28 +231,100 @@ function sameValue(sent, declared) {
207
231
  return false;
208
232
  if (declared === null)
209
233
  return sent === null;
234
+ // A list or an object compares WHOLE, and only against another one. `String` made
235
+ // every structure a scalar: `String(["alpha","beta"])` is `"alpha,beta"`, so a
236
+ // case declaring that string was answered by a list no entry of which sends it,
237
+ // and `String([])` is `""`, so an empty list answered `tags=""`. An entry of the
238
+ // list is still a scalar of its own and still answers a scalar case.
239
+ const sentIsStructure = sent !== null && typeof sent === "object";
240
+ const declaredIsStructure = typeof declared === "object";
241
+ if (sentIsStructure || declaredIsStructure) {
242
+ // Both sides are non-null objects by the guard above, so neither call can be
243
+ // the `undefined` the helper returns for a value JSON renders as nothing.
244
+ return sentIsStructure && declaredIsStructure && canonicalJson(sent) === canonicalJson(declared);
245
+ }
210
246
  const sentNumber = asNumber(sent);
211
247
  const declaredNumber = asNumber(declared);
212
248
  if (sentNumber !== undefined && declaredNumber !== undefined)
213
249
  return sentNumber === declaredNumber;
214
250
  return String(sent) === String(declared);
215
251
  }
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. */
252
+ /** The path a case's `param` names, as segments. Numeric segments drop: a plan
253
+ * writes `items.0` for "the line items", not for the first one only. */
254
+ function fieldPath(param) {
255
+ const segments = param
256
+ .split(/[.[\]]/)
257
+ .map((segment) => segment.trim())
258
+ .filter((segment) => segment.length > 0 && !/^\d+$/.test(segment));
259
+ return segments.length > 0 ? segments : [param];
260
+ }
261
+ /** Every value at `rest`, below a key that already matched the path's first
262
+ * segment. Only arrays are transparent between segments, so `billing.postcode` is
263
+ * not sent by `billing.shipping.postcode`. A matched array yields the array AND
264
+ * its entries, because `String(["beta"]) === "beta"` matched a one-item list only.
265
+ *
266
+ * `open` holds the objects on the way down to this one, so a payload that points
267
+ * back at itself ends rather than recurses. It is the way down and not every
268
+ * object seen, because one object reached twice by two different routes is two
269
+ * values the payload really sends. */
270
+ function valuesAt(value, rest, open) {
271
+ if (rest.length === 0)
272
+ return [value, ...(Array.isArray(value) ? value : [])];
273
+ if (value === null || typeof value !== "object" || open.has(value))
274
+ return [];
275
+ open.add(value);
276
+ const found = Array.isArray(value)
277
+ ? value.flatMap((entry) => valuesAt(entry, rest, open))
278
+ : (() => {
279
+ const [head, ...next] = rest;
280
+ const fields = value;
281
+ return head in fields ? valuesAt(fields[head], next, open) : [];
282
+ })();
283
+ open.delete(value);
284
+ return found;
285
+ }
286
+ /** Every value carried at `path`, anywhere inside one payload. Only the FIRST
287
+ * segment is found at any depth, which is what makes `quantity` find
288
+ * `items.0.quantity`; the rest of the path is matched by `valuesAt`. */
289
+ function valuesUnder(payload, path, open = new Set()) {
290
+ // A body that points back at itself ends the walk; how deeply a real body nests
291
+ // is the payload's business, and a depth cap here read a valid one as sending
292
+ // nothing.
293
+ if (payload === null || typeof payload !== "object" || open.has(payload))
294
+ return [];
295
+ open.add(payload);
296
+ const found = [];
297
+ if (Array.isArray(payload)) {
298
+ for (const entry of payload)
299
+ found.push(...valuesUnder(entry, path, open));
300
+ }
301
+ else {
302
+ for (const [key, value] of Object.entries(payload)) {
303
+ if (key === path[0])
304
+ found.push(...valuesAt(value, path.slice(1), open));
305
+ // The same head, deeper: this is the any-depth half of the search.
306
+ found.push(...valuesUnder(value, path, open));
307
+ }
308
+ }
309
+ open.delete(payload);
310
+ return found;
311
+ }
312
+ /** Every value of one parameter a step sends: the query string, the `queryParams`
313
+ * object, or anywhere inside the request body. */
220
314
  function valuesSent(step, param) {
221
315
  const found = [];
316
+ // A query parameter is flat and its own name can hold a dot, so the query answers
317
+ // the parameter as written. Only the body is read as a path.
222
318
  const query = String(step?.path ?? "").split("?").slice(1).join("?");
223
319
  if (query)
224
320
  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
- }
321
+ const queryParams = step?.queryParams;
322
+ if (queryParams && typeof queryParams === "object" && !Array.isArray(queryParams)) {
323
+ const fields = queryParams;
324
+ if (param in fields)
325
+ found.push(fields[param]);
231
326
  }
327
+ found.push(...valuesUnder(step?.requestBody, fieldPath(param)));
232
328
  return found;
233
329
  }
234
330
  /** The parameter name that names the request's verb rather than one of its inputs. */
@@ -316,6 +412,15 @@ function sendsInPath(step, slot, value) {
316
412
  }
317
413
  return sameValue(segments[slot.index], value);
318
414
  }
415
+ /** Whether a step writes the declared value as one of its own path segments. The
416
+ * fallback for a plan that spells every route concretely, so no slot exists. Only
417
+ * used when the parameter has no slot at all: where the plan does say where the
418
+ * parameter sits, the slot rule stands. */
419
+ function sendsAsPathSegment(step, value) {
420
+ if (value === undefined || value === null || String(value).trim().length === 0)
421
+ return false;
422
+ return pathSegments(step).some((segment) => sameValue(segment.trim(), value));
423
+ }
319
424
  /** Whether a step's verb is the declared value. A rejected method is the input. */
320
425
  function sendsAsMethod(step, value) {
321
426
  const method = String(step?.method ?? "").trim();
@@ -332,8 +437,11 @@ function omittedBy(step, param) {
332
437
  return false;
333
438
  return omits.some((named) => String(named ?? "").trim() === param);
334
439
  }
335
- /** One declared case as the objection spells it. */
336
- function renderCase(declared) {
440
+ /** One declared case as the objection spells it.
441
+ *
442
+ * Exported: `expectedValueSourced` keys its objections on the same rendering, so
443
+ * two cases on one parameter are two objections there too. */
444
+ export function renderCase(declared) {
337
445
  const param = String(declared.param).trim();
338
446
  const sent = declared.absent === true ? "absent" : `=${JSON.stringify(declared.value)}`;
339
447
  return `${param}${declared.absent === true ? " " : ""}${sent} (${declared.expect})`;
@@ -381,6 +489,7 @@ function caseObjections(registration) {
381
489
  const steps = plannedTest?.scenario?.steps;
382
490
  return Array.isArray(steps) ? steps : [];
383
491
  });
492
+ const writesAnyPlaceholder = citingSteps.some((step) => pathSegments(step).some((segment) => placeholderName(segment) !== undefined));
384
493
  const missing = cases.filter((declared) => {
385
494
  const param = String(declared?.param ?? "").trim();
386
495
  if (!param)
@@ -389,9 +498,14 @@ function caseObjections(registration) {
389
498
  return !citingSteps.some((step) => omittedBy(step, param));
390
499
  const slots = pathSlotsFor(citingSteps, cases, param);
391
500
  const isMethod = param.toLowerCase() === METHOD_PARAM;
501
+ // Only for a plan that wrote NO template at all, and only for a parameter that
502
+ // names a record. Wider than that, the URL answered everything: a `quantity=5`
503
+ // case passed on `GET /api/v1/orders/5`.
504
+ const byValueInPath = slots.length === 0 && !writesAnyPlaceholder && ID_PARAM.test(param) && !isMethod;
392
505
  return !citingSteps.some((step) => valuesSent(step, param).some((sent) => sameValue(sent, declared.value)) ||
393
506
  (isMethod && sendsAsMethod(step, declared.value)) ||
394
- slots.some((slot) => sendsInPath(step, slot, declared.value)));
507
+ slots.some((slot) => sendsInPath(step, slot, declared.value)) ||
508
+ (byValueInPath && sendsAsPathSegment(step, declared.value)));
395
509
  });
396
510
  // One objection per case. A single objection listing three cases took one
397
511
  // answer that addressed two of them and closed: run 34167252679 answered
@@ -405,7 +519,13 @@ function caseObjections(registration) {
405
519
  objectionId: `coverage:cases:${id}:${rendered}`,
406
520
  verifier: "coverage",
407
521
  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}.`,
522
+ evidence: `${citing.map((plannedTest) => plannedTest.plannedTestId).join(", ")} cite ${id}; no step of theirs sends ${rendered}.` +
523
+ // Which spelling WOULD have matched, or the agent re-sends the same plan.
524
+ // Id parameters only: it asked for `{quantity}` in a route otherwise.
525
+ (ID_PARAM.test(String(declared?.param ?? "").trim()) &&
526
+ citingSteps.some((step) => sendsAsPathSegment(step, declared.value))
527
+ ? ` A step's path carries that value, in a segment this plan's own placeholders do not name \`${String(declared?.param ?? "").trim()}\`; write the route as a template, with \`{${String(declared?.param ?? "").trim()}}\` where it sits.`
528
+ : ""),
409
529
  suggestion: COVERAGE_CONTRACT.objections.cases.suggestion,
410
530
  });
411
531
  }
@@ -416,8 +536,10 @@ function caseObjections(registration) {
416
536
  * sends the same spelling, so `{memberUserId}` in a case is that record and a
417
537
  * literal is a value the plan chose. */
418
538
  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;
539
+ /** A parameter that names a record by id, by its own name: `id` itself, `id` after
540
+ * a separator, or `Id`/`ID` starting a word. `/id$/i` also read `paid`, `valid` and
541
+ * `fluid` as record ids, which let `valid=5` take its value from `/orders/5`. */
542
+ const ID_PARAM = /(?:^|[_\-.])[Ii][Dd]$|[a-z0-9](?:Id|ID)$/;
421
543
  function isPlaceholderValue(value) {
422
544
  return typeof value === "string" && PLACEHOLDER_VALUE.test(value.trim());
423
545
  }
@@ -578,7 +700,7 @@ export const coverage = {
578
700
  name: "coverage",
579
701
  run(registration, ctx) {
580
702
  const objections = [
581
- ...claimObjections(registration),
703
+ ...claimObjections(registration, ctx),
582
704
  ...stateTestObjections(registration),
583
705
  ...caseObjections(registration),
584
706
  ...rejectObjections(registration),
@@ -0,0 +1,9 @@
1
+ import { Verifier } from "../types.js";
2
+ /** Every declared defect needs one planned test that cites it and expects to fail,
3
+ * or an answer. Read off the PLAN alone, like the behaviour half of coverage: the
4
+ * agent declared the defects itself, so there is always something to hold it to.
5
+ *
6
+ * The one objection about the list as a whole, `defects:none`, is what asks for
7
+ * the review before the plan: without it the review's only output was
8
+ * `issuesFound`, which exists at report time, after every test is generated. */
9
+ export declare const defects: Verifier;
@@ -0,0 +1,117 @@
1
+ import { normalizeCategory } from "../../types/TestRecommendation.js";
2
+ import { DEFECTS_CONTRACT } from "../verifierContracts.js";
3
+ /** A defect id as the plan spells it: trimmed, and exact otherwise. */
4
+ function idKey(raw) {
5
+ return typeof raw === "string" ? raw.trim() : "";
6
+ }
7
+ /** The defect ids one planned test cites, trimmed and without repeats. Read
8
+ * defensively: the plan comes off disk, so the array type holds only on the
9
+ * validated path, and a throw here would hide every other objection. */
10
+ function citedDefects(plannedTest) {
11
+ const declared = plannedTest?.declarations?.defects;
12
+ if (!Array.isArray(declared))
13
+ return [];
14
+ return [...new Set(declared.map(idKey))].filter(Boolean);
15
+ }
16
+ /** Whether a planned test expects to fail. Undeclared reads as not failing: a test
17
+ * that says nothing about its outcome proves nothing about a defect either. */
18
+ function expectsToFail(plannedTest) {
19
+ return plannedTest?.declarations?.expected?.outcome === "fail";
20
+ }
21
+ /** Where the defect sits, as the objection quotes it. */
22
+ function locate(defect) {
23
+ const file = String(defect?.file ?? "").trim() || "no file";
24
+ return typeof defect?.line === "number" ? `${file}:${defect.line}` : file;
25
+ }
26
+ /** Every declared defect needs one planned test that cites it and expects to fail,
27
+ * or an answer. Read off the PLAN alone, like the behaviour half of coverage: the
28
+ * agent declared the defects itself, so there is always something to hold it to.
29
+ *
30
+ * The one objection about the list as a whole, `defects:none`, is what asks for
31
+ * the review before the plan: without it the review's only output was
32
+ * `issuesFound`, which exists at report time, after every test is generated. */
33
+ export const defects = {
34
+ name: "defects",
35
+ run(registration, _ctx) {
36
+ const declared = (Array.isArray(registration.defects) ? registration.defects : []).filter((defect) => idKey(defect?.id).length > 0);
37
+ const plannedTests = registration.plannedTests ?? [];
38
+ const objections = [];
39
+ // An empty list is its own objection and NOT an early return: a plan that
40
+ // declares nothing can still cite `D1` from a planned test, and that citation
41
+ // is a second mistake with a second answer. Returning here left it unraised,
42
+ // so one answer to `defects:none` closed the whole thing.
43
+ if (declared.length === 0) {
44
+ objections.push({
45
+ objectionId: "defects:none",
46
+ verifier: "defects",
47
+ message: DEFECTS_CONTRACT.objections.none.message,
48
+ evidence: "the registered plan declares an empty `defects` list.",
49
+ suggestion: DEFECTS_CONTRACT.objections.none.suggestion,
50
+ });
51
+ }
52
+ const known = new Set(declared.map((defect) => idKey(defect.id)));
53
+ const seen = new Set();
54
+ for (const defect of declared) {
55
+ const id = idKey(defect.id);
56
+ if (seen.has(id))
57
+ continue;
58
+ seen.add(id);
59
+ const citing = plannedTests.filter((plannedTest) => citedDefects(plannedTest).includes(id));
60
+ if (citing.length === 0) {
61
+ objections.push({
62
+ // Keyed on the defect's id, not its position: the agent reorders the
63
+ // list between registrations and the answer has to follow the defect.
64
+ objectionId: `defects:untested:${id}`,
65
+ verifier: "defects",
66
+ message: DEFECTS_CONTRACT.objections.untested.message,
67
+ evidence: `${id} (${locate(defect)}): "${String(defect.description ?? "").trim()}"; no planned test cites it in \`defects\`.`,
68
+ suggestion: DEFECTS_CONTRACT.objections.untested.suggestion,
69
+ });
70
+ continue;
71
+ }
72
+ if (citing.some(expectsToFail))
73
+ continue;
74
+ objections.push({
75
+ objectionId: `defects:expectedPass:${id}`,
76
+ verifier: "defects",
77
+ message: DEFECTS_CONTRACT.objections.expectedPass.message,
78
+ evidence: `${citing.map((plannedTest) => plannedTest.plannedTestId).join(", ")} cite ${id}; none declares \`expected.outcome: fail\`.`,
79
+ suggestion: DEFECTS_CONTRACT.objections.expectedPass.suggestion,
80
+ });
81
+ }
82
+ // One objection per (planned test, unknown id): each is its own misspelling
83
+ // and owes its own answer.
84
+ for (const plannedTest of plannedTests) {
85
+ for (const id of citedDefects(plannedTest)) {
86
+ if (known.has(id))
87
+ continue;
88
+ objections.push({
89
+ objectionId: `defects:unknownId:${plannedTest.plannedTestId}:${id}`,
90
+ verifier: "defects",
91
+ plannedTestId: plannedTest.plannedTestId,
92
+ message: DEFECTS_CONTRACT.objections.unknownId.message,
93
+ evidence: `${plannedTest.plannedTestId} cites "${id}"; this plan declares ${known.size > 0 ? [...known].map((declaredId) => `"${declaredId}"`).join(", ") : "no defects"}.`,
94
+ suggestion: DEFECTS_CONTRACT.objections.unknownId.suggestion,
95
+ });
96
+ }
97
+ }
98
+ // A requirement the code does not meet is a defect, and that defect's `file` is
99
+ // the code that contradicts the requirement.
100
+ for (const plannedTest of plannedTests) {
101
+ if (normalizeCategory(plannedTest?.scenario?.category) !== "requirement_conflict")
102
+ continue;
103
+ if (citedDefects(plannedTest).length > 0)
104
+ continue;
105
+ const plannedTestId = idKey(plannedTest?.plannedTestId) || "plan";
106
+ objections.push({
107
+ objectionId: `defects:conflictWithoutDefect:${plannedTestId}`,
108
+ verifier: "defects",
109
+ plannedTestId: plannedTest?.plannedTestId,
110
+ message: DEFECTS_CONTRACT.objections.conflictWithoutDefect.message,
111
+ evidence: `${plannedTestId} is planned as \`requirement_conflict\` and cites no defect in \`defects\`.`,
112
+ suggestion: DEFECTS_CONTRACT.objections.conflictWithoutDefect.suggestion,
113
+ });
114
+ }
115
+ return objections;
116
+ },
117
+ };