@skyramp/mcp 0.4.0 → 0.4.1-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 (33) hide show
  1. package/build/prompts/enhance-assertions/sharedAssertionRules.js +20 -4
  2. package/build/prompts/test-recommendation/test-recommendation-prompt.js +4 -5
  3. package/build/prompts/testbot/testbot-prompts.js +10 -8
  4. package/build/recommendation/answers.d.ts +11 -7
  5. package/build/recommendation/answers.js +14 -10
  6. package/build/recommendation/pullRequestText.d.ts +18 -0
  7. package/build/recommendation/pullRequestText.js +31 -0
  8. package/build/recommendation/registerPlan.d.ts +5 -1
  9. package/build/recommendation/registerPlan.js +3 -1
  10. package/build/recommendation/runVerifiers.js +6 -0
  11. package/build/recommendation/types.d.ts +35 -0
  12. package/build/recommendation/verifierContracts.d.ts +94 -11
  13. package/build/recommendation/verifierContracts.js +129 -27
  14. package/build/recommendation/verifiers/defects.d.ts +9 -0
  15. package/build/recommendation/verifiers/defects.js +117 -0
  16. package/build/recommendation/verifiers/expectedValueSourced.d.ts +14 -0
  17. package/build/recommendation/verifiers/expectedValueSourced.js +149 -0
  18. package/build/recommendation/verifiers/issueTraceability.d.ts +52 -0
  19. package/build/recommendation/verifiers/issueTraceability.js +197 -0
  20. package/build/recommendation/verifiers/requirementSourced.d.ts +2 -0
  21. package/build/recommendation/verifiers/requirementSourced.js +168 -0
  22. package/build/tools/submitReportTool.js +19 -3
  23. package/build/tools/test-management/registerTestPlanTool.d.ts +31 -17
  24. package/build/tools/test-management/registerTestPlanTool.js +79 -5
  25. package/build/types/TestbotReport.d.ts +7 -3
  26. package/build/utils/assertion-verify/api-shared-lints.js +70 -0
  27. package/package.json +1 -1
  28. package/plugin/prompts/generate-tests/execution-plan.md +2 -2
  29. package/plugin/prompts/plan-tests.md +18 -9
  30. package/plugin/prompts/testbot-task1.md +3 -9
  31. package/build/prompts/testbot/planDeclarations.d.ts +0 -6
  32. package/build/prompts/testbot/planDeclarations.js +0 -9
  33. 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,7 +83,6 @@ 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.",
@@ -98,7 +93,6 @@ export const EXISTING_COVERAGE_CONTRACT = {
98
93
  };
99
94
  export const EXPECTED_OUTCOME_CONTRACT = {
100
95
  id: "expectedOutcome",
101
- declarationFields: ["category", "declarations.expected.outcome", "declarations.expected.why"],
102
96
  objections: {
103
97
  // `{category}` is the planned test's own `category` value, quoted back to it so
104
98
  // the agent recognises the value it sent.
@@ -119,16 +113,6 @@ export const EXPECTED_OUTCOME_CONTRACT = {
119
113
  };
120
114
  export const UI_ELEMENT_GROUNDED_CONTRACT = {
121
115
  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
116
  objections: {
133
117
  nogrounding: {
134
118
  message: "This UI test names no `elements.items`, so nothing says the elements it targets are on the page.",
@@ -154,16 +138,6 @@ export const UI_ELEMENT_GROUNDED_CONTRACT = {
154
138
  };
155
139
  export const COVERAGE_CONTRACT = {
156
140
  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
141
  objections: {
168
142
  change: {
169
143
  message: "This declared change has no test in the plan.",
@@ -225,7 +199,6 @@ export const COVERAGE_CONTRACT = {
225
199
  };
226
200
  export const REMOVED_ELEMENT_GUARDED_CONTRACT = {
227
201
  id: "removedElementGuarded",
228
- declarationFields: ["declarations.elements.items", "declarations.asserts"],
229
202
  objections: {
230
203
  // v1 resolved a removed element to a page in the server, and fell back to the
231
204
  // root pages when the import graph found none, so that an element whose page
@@ -246,6 +219,135 @@ export const REMOVED_ELEMENT_GUARDED_CONTRACT = {
246
219
  },
247
220
  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
221
  };
222
+ export const EXPECTED_VALUE_SOURCED_CONTRACT = {
223
+ id: "expectedValueSourced",
224
+ objections: {
225
+ // The whole point of the check. A value read off the running app is the one
226
+ // source that cannot disagree with a defect, so a test built from it passes
227
+ // on the broken code and fails once the code is fixed.
228
+ sourcedFromCode: {
229
+ 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.",
230
+ 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.",
231
+ },
232
+ missingSource: {
233
+ message: "This case states an expected value and does not say where the value came from.",
234
+ suggestion: "Set `expectedFrom` to `pr-title`, `pr-description`, `spec:<path>`, `convention:<file:line>` or `code`.",
235
+ },
236
+ unreadableSource: {
237
+ message: "This case names a file as the source of its expected value, and that file is not in the checkout.",
238
+ 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.",
239
+ },
240
+ // `convention:` is trusted for one reason: the pull request did not write the rule,
241
+ // so the rule can still disagree with the pull request. Cited inside the diff, the
242
+ // same change decides the behaviour and decides what counts as correct.
243
+ //
244
+ // Says only that, and not that the value came from the code under test: a large
245
+ // diff carries changed files that have nothing to do with this case, and the check
246
+ // cannot tell those from the ones it tests. Answerable for that reason.
247
+ changedConvention: {
248
+ 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.",
249
+ 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.",
250
+ },
251
+ // The server rendered the prompt, so it holds the words the run was given. It
252
+ // checks the RULE and not the value: "over 60 is rejected" states no 61, and run
253
+ // 34414643135 objected to five cases whose values that rule decides.
254
+ quoteMissing: {
255
+ message: "This case reads its value from the pull request, and its change does not quote the sentence that states the rule.",
256
+ suggestion: "Put the sentence from the title or description on the change as `quote`, as written there. Derive this case's value from that rule; the value itself need not appear in the pull request.",
257
+ },
258
+ // A test that expects to fail asserts what the code does not return today, so
259
+ // some source outside the code states that value. Run 34406282133 declared such
260
+ // tests and put `expectedValue` on no case at all.
261
+ failWithoutValue: {
262
+ message: "This test expects to fail, and no case on the changes it cites states the value it must assert.",
263
+ 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 no source states the value, answer with what the test asserts instead and where that comes from.",
264
+ },
265
+ },
266
+ suggestion: "Where does the correct value for this case come from?",
267
+ };
268
+ export const REQUIREMENT_SOURCED_CONTRACT = {
269
+ id: "requirementSourced",
270
+ objections: {
271
+ // `{change}` is the change id. Nothing on the server parses a pull request, so
272
+ // this field is the only record of where the change was read. An unrecognised
273
+ // spelling lands here: one that passed would be a check a typo switches off.
274
+ unsourced: {
275
+ message: "Change `{change}` does not name a source the plan accepts: `pr-title`, `pr-description`, `spec:<path>` or `diff`.",
276
+ 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`.",
277
+ },
278
+ // `{source}` is the source text as the plan wrote it. A path that no file
279
+ // answers is a citation nobody can check.
280
+ unreadable: {
281
+ message: "Change `{change}` cites `{source}`, and the checkout holds no file at that path.",
282
+ suggestion: "Cite the requirements file by the path the checkout holds, or name the source you actually read this change in.",
283
+ },
284
+ // A conflict is a disagreement between a requirement and the code. A change
285
+ // read off the diff is the code, so it has no second side. The check reads the
286
+ // sources the cited changes name, and says only that, not where they came from.
287
+ fromDiff: {
288
+ message: "This test reports a requirement conflict, and none of the changes it cites was read from the pull request or a requirements file.",
289
+ 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.",
290
+ },
291
+ // The server rendered the prompt, so it holds the title and description the run
292
+ // was given. A requirements file the pull request never names is a file the agent
293
+ // found by looking, which is reading the repository, not reading a requirement.
294
+ notNamed: {
295
+ message: "Change `{change}` cites `{source}`, and the pull request names no such file.",
296
+ 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`.",
297
+ },
298
+ // Says only what was read: this pull request arrived with no words in it.
299
+ emptyPullRequest: {
300
+ message: "Change `{change}` says it was read from the pull request, and the pull request has no title or description.",
301
+ suggestion: "Set `source` to `spec:<path>` for the requirements file that states this change, or to `diff` if only the code states it.",
302
+ },
303
+ // The quote is what makes the claim checkable: without it the server has the
304
+ // pull request's words and no way to know which of them the change rests on.
305
+ notQuoted: {
306
+ message: "This test reports a requirement conflict, and change `{change}` quotes words the pull request does not carry.",
307
+ 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`.",
308
+ },
309
+ unquoted: {
310
+ message: "This test reports a requirement conflict, and change `{change}` does not quote the requirement.",
311
+ suggestion: "Set `quote` on that change to the sentence in the title or description that states the requirement.",
312
+ },
313
+ // Nothing else objects to this: the coverage check reads a change and asks which
314
+ // test proves it, so a test that cites nothing is a test it never reaches.
315
+ noChange: {
316
+ message: "This test reports a requirement conflict and cites no change, so nothing says where the requirement came from.",
317
+ suggestion: "Name in `changes` the change that states the requirement, or report this test as something other than a requirement conflict.",
318
+ },
319
+ },
320
+ 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?",
321
+ };
322
+ export const DEFECTS_CONTRACT = {
323
+ id: "defects",
324
+ objections: {
325
+ // Run 34283539284 reported six bugs with no test behind them: the review
326
+ // happened at report time, after the plan was frozen. A plan that lists no
327
+ // defect is where the review is asked for, before the plan.
328
+ none: {
329
+ message: "The plan declares no defects, so nothing says the code review happened before the plan.",
330
+ 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.",
331
+ },
332
+ untested: {
333
+ message: "This declared defect has no test in the plan.",
334
+ 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.",
335
+ },
336
+ expectedPass: {
337
+ message: "Every test that cites this defect expects to pass, and a test that passes today proves nothing about a defect.",
338
+ 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.",
339
+ },
340
+ unknownId: {
341
+ message: "A planned test cites a defect id that this plan does not declare.",
342
+ 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.",
343
+ },
344
+ conflictWithoutDefect: {
345
+ message: "This test reports a requirement conflict and cites no defect.",
346
+ 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.",
347
+ },
348
+ },
349
+ suggestion: "Which planned test proves each defect the review found, and for one nothing proves, why does it need no test?",
350
+ };
249
351
  /** Fills a contract sentence's `{placeholder}` spans from the run's or the
250
352
  * planned test's own values. A placeholder with no value is left as written rather
251
353
  * than blanked, so a missed key shows up as `{knob}` instead of reading as a gap
@@ -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
+ };
@@ -0,0 +1,14 @@
1
+ import { Verifier } from "../types.js";
2
+ /** Verifier 10. A case that states what the response must carry says where that
3
+ * value came from, and the value did not come from the code under test.
4
+ *
5
+ * This is the check for the failure that costs the most and looks like success: a
6
+ * test whose expected value was copied out of the running app passes on the broken
7
+ * code and fails the moment the code is fixed, so it defends the defect instead of
8
+ * catching it. The plan is the only place the value can be decided, because by
9
+ * generation time the response is the only thing to hand.
10
+ *
11
+ * Silent on a case with no `expectedValue`: asserting the shape of a value nobody
12
+ * states is a legitimate position, and the objection would fire on every
13
+ * rejection case in every plan. */
14
+ export declare const expectedValueSourced: Verifier;
@@ -0,0 +1,149 @@
1
+ import { allChangedFiles } from "../types.js";
2
+ import { changedPathSet, normalizeCitedPath } from "./citedPath.js";
3
+ import { EXPECTED_VALUE_SOURCED_CONTRACT as CONTRACT } from "../verifierContracts.js";
4
+ import { appearsIn, searchable } from "../pullRequestText.js";
5
+ function readSource(raw) {
6
+ const source = typeof raw === "string" ? raw.trim() : "";
7
+ if (source.length === 0)
8
+ return { kind: "none", file: "" };
9
+ if (/^code$/i.test(source))
10
+ return { kind: "code", file: "" };
11
+ if (/^(pr-title|pr-description)$/i.test(source))
12
+ return { kind: "pullRequest", file: "" };
13
+ const named = /^(spec|convention):(.*)$/i.exec(source);
14
+ if (!named)
15
+ return { kind: "none", file: "" };
16
+ const kind = named[1].toLowerCase();
17
+ const cited = named[2].split(/[#\u00a7]/)[0].replace(/(?::\d+)+(?:-\d+)?$/, "").trim();
18
+ // `spec:` with nothing after it names no file, so there is nothing to check and
19
+ // nothing to trust either. A path that IS written and still normalises away is a
20
+ // named source, and it is named wrongly.
21
+ if (cited.length === 0)
22
+ return { kind: "none", file: "" };
23
+ const file = normalizeCitedPath(cited);
24
+ return file ? { kind, file } : { kind: "unresolvable", file: "" };
25
+ }
26
+ /** One case's address, for the evidence line. A case has no id of its own, so it
27
+ * is named the way the agent wrote it. */
28
+ function caseLabel(change, entry) {
29
+ return `${String(change?.id ?? "?")}:${String(entry?.param ?? "?")}`;
30
+ }
31
+ /** Verifier 10. A case that states what the response must carry says where that
32
+ * value came from, and the value did not come from the code under test.
33
+ *
34
+ * This is the check for the failure that costs the most and looks like success: a
35
+ * test whose expected value was copied out of the running app passes on the broken
36
+ * code and fails the moment the code is fixed, so it defends the defect instead of
37
+ * catching it. The plan is the only place the value can be decided, because by
38
+ * generation time the response is the only thing to hand.
39
+ *
40
+ * Silent on a case with no `expectedValue`: asserting the shape of a value nobody
41
+ * states is a legitimate position, and the objection would fire on every
42
+ * rejection case in every plan. */
43
+ export const expectedValueSourced = {
44
+ name: "expectedValueSourced",
45
+ run(registration, ctx) {
46
+ const objections = [];
47
+ // The plan comes off disk, so both arrays hold their declared type only on the
48
+ // validated path. A throw here reaches the agent as `:crashed` and hides
49
+ // whatever real objection this pass would have raised.
50
+ // A `convention:` is trusted BECAUSE the pull request did not change it. Comparing
51
+ // against the run's own changed files is the only way to hold it to that.
52
+ const changed = changedPathSet(allChangedFiles(ctx), true);
53
+ const readable = typeof ctx?.citedFileExists === "function" ? ctx.citedFileExists : undefined;
54
+ const prText = searchable(ctx?.pullRequest ?? { title: "", description: "" });
55
+ const changes = Array.isArray(registration.changes) ? registration.changes : [];
56
+ for (const change of changes) {
57
+ const changeId = String(change?.id ?? "?");
58
+ const cases = Array.isArray(change?.cases) ? change.cases : [];
59
+ cases.forEach((entry, index) => {
60
+ // `undefined` is "this case states no expected value"; `null` is a stated
61
+ // expectation that the field comes back null, which needs a source like
62
+ // any other value.
63
+ if (!entry || entry.expectedValue === undefined)
64
+ return;
65
+ const label = caseLabel(change, entry);
66
+ const where = `${label} (source "${String(entry.expectedFrom ?? "")}")`;
67
+ const source = readSource(entry.expectedFrom);
68
+ // One objection per CASE. A single objection listing three cases took one
69
+ // answer that addressed two of them and closed, and the id an answer is
70
+ // carried forward by must therefore name the case as well as the change.
71
+ const param = String(entry?.param ?? "").trim() || `case-${index + 1}`;
72
+ const raise = (kind, objection, evidence) => objections.push({
73
+ objectionId: `expectedValueSourced:${kind}:${changeId}:${param}`,
74
+ verifier: "expectedValueSourced",
75
+ message: objection.message,
76
+ evidence,
77
+ suggestion: objection.suggestion,
78
+ });
79
+ if (source.kind === "none") {
80
+ raise("unsourced", CONTRACT.objections.missingSource, `expected value with no usable \`expectedFrom\`: ${where}`);
81
+ }
82
+ else if (source.kind === "code") {
83
+ raise("code", CONTRACT.objections.sourcedFromCode, `expected value read from the code under test: ${label} (expected ${JSON.stringify(entry.expectedValue)})`);
84
+ }
85
+ else if (source.kind === "unresolvable") {
86
+ // A path no repository file can answer, so the citation opens nothing.
87
+ raise("unreadable", CONTRACT.objections.unreadableSource, `source file not in the checkout: ${where}`);
88
+ }
89
+ else if (source.kind === "pullRequest") {
90
+ // The RULE is what the pull request states; the value is derived from it.
91
+ // "over 60 is rejected" states no 61, so looking for the value itself
92
+ // objected to five correct cases in run 34414643135.
93
+ const quote = String(change?.quote ?? "").trim();
94
+ if (quote.length === 0) {
95
+ raise("noQuote", CONTRACT.objections.quoteMissing, `${label} reads its value from the pull request; change ${changeId} states no \`quote\``);
96
+ }
97
+ else if (!appearsIn(prText, quote)) {
98
+ raise("noQuote", CONTRACT.objections.quoteMissing, `${label} reads its value from the pull request; change ${changeId} quotes \`${quote}\`, which is in neither the title nor the description`);
99
+ }
100
+ }
101
+ else if (!source.file) {
102
+ // A kind that names no file and is not the pull request has nothing to check.
103
+ }
104
+ else if (source.kind === "convention" && changed.has(source.file)) {
105
+ // Asked BEFORE readability: a convention cited in a file the pull request
106
+ // DELETED is not a spelling problem, it is the rule this check exists for.
107
+ raise("changedConvention", CONTRACT.objections.changedConvention, `convention cited in a file this pull request changed: ${where}`);
108
+ }
109
+ else if (readable && !readable(source.file)) {
110
+ // A named source nothing opens is a source in name only.
111
+ raise("unreadable", CONTRACT.objections.unreadableSource, `source file not in the checkout: ${where}`);
112
+ }
113
+ });
114
+ }
115
+ // A test that expects to fail asserts what the code does not return today, so a
116
+ // source outside the code has to state that value, and a case has to carry it.
117
+ const plannedTests = Array.isArray(registration?.plannedTests) ? registration.plannedTests : [];
118
+ for (const plannedTest of plannedTests) {
119
+ if (plannedTest?.declarations?.expected?.outcome !== "fail")
120
+ continue;
121
+ // A test that calls nothing sends no case, so it has no case to state a value
122
+ // on. That is the plan-only lane, whose ui tests declare a page and no steps.
123
+ if (!Array.isArray(plannedTest?.scenario?.steps) || plannedTest.scenario.steps.length === 0)
124
+ continue;
125
+ const citedIds = (Array.isArray(plannedTest?.declarations?.changes) ? plannedTest.declarations.changes : [])
126
+ .map((changeId) => (typeof changeId === "string" ? changeId.trim() : ""))
127
+ .filter((changeId) => changeId.length > 0);
128
+ // An id no change carries is the coverage check's finding, not this one.
129
+ const cited = changes.filter((change) => citedIds.includes(String(change?.id ?? "").trim()));
130
+ if (cited.length === 0)
131
+ continue;
132
+ const statesAValue = cited.some((change) => (Array.isArray(change?.cases) ? change.cases : []).some((entry) => entry && entry.expectedValue !== undefined));
133
+ if (statesAValue)
134
+ continue;
135
+ const plannedTestId = String(plannedTest?.plannedTestId ?? "").trim();
136
+ objections.push({
137
+ objectionId: `expectedValueSourced:noValue:${plannedTestId || "plan"}`,
138
+ verifier: "expectedValueSourced",
139
+ ...(plannedTestId ? { plannedTestId } : {}),
140
+ message: CONTRACT.objections.failWithoutValue.message,
141
+ evidence: `planned test ${plannedTestId || "(unnamed)"} expects to fail and cites ${cited
142
+ .map((change) => String(change?.id ?? "?"))
143
+ .join(", ")}; no case on those changes states an \`expectedValue\``,
144
+ suggestion: CONTRACT.objections.failWithoutValue.suggestion,
145
+ });
146
+ }
147
+ return objections;
148
+ },
149
+ };
@@ -0,0 +1,52 @@
1
+ import { Objection } from "../types.js";
2
+ import { Plan } from "../registerPlan.js";
3
+ /** One `issuesFound` entry, as much of it as these checks read. Every field is
4
+ * optional: the entries come off a submitted report, and only the schema-validated
5
+ * path guarantees a shape. */
6
+ export interface ReportedIssueForTraceability {
7
+ description?: string;
8
+ category?: string;
9
+ sourceFile?: string;
10
+ plannedTestId?: string;
11
+ defectId?: string;
12
+ }
13
+ /** One delivered test, as much of it as these checks read. */
14
+ export interface DeliveredForTraceability {
15
+ plannedTestId?: string;
16
+ }
17
+ /** Which lane the report comes from. Passed by the caller, not read off the
18
+ * process, so the check stays a pure function of its inputs. */
19
+ export interface TraceabilityLane {
20
+ /** The plan-only lane delivers nothing: `newTestsCreated` DECLARES the plan.
21
+ * A planned test is as far as a trace can go there. */
22
+ planOnly: boolean;
23
+ }
24
+ /** Report-time. Every `category: bug` issue names the test that proves it, by
25
+ * `plannedTestId` or through a `defectId`, or it draws an objection the agent
26
+ * answers. Run 34283539284 reported six bugs with no test behind any of them and
27
+ * nothing said so; the one test near the headline bug asserted a value that
28
+ * passes against the broken output.
29
+ *
30
+ * A test proves a bug when the run DELIVERED it and the plan declares it as one
31
+ * that expects to fail. Both halves matter. A planned test the run never wrote is
32
+ * a promise, not proof, and the plan-time objections already hold the agent to
33
+ * those — except in the plan-only lane, which delivers nothing by design, so
34
+ * there a planned test is as far as a trace can go. A test the plan declares
35
+ * green is the run-34283539284 case itself: it passes against the broken output,
36
+ * so it says nothing about the bug. The plan is the only place a delivered test's
37
+ * expected outcome is written down, so a delivered test that joins to no planned
38
+ * test carries no such declaration and does not count either.
39
+ *
40
+ * A `defectId` counts the same way, and only for an id the plan's own `defects`
41
+ * list declares: `declarations.defects` is free text until it is joined to that
42
+ * list, so an unjoined id would let a report invent a defect and trace every bug
43
+ * through it.
44
+ *
45
+ * A declared `defectId` also counts when the agent answered `defects:untested:<id>`
46
+ * at plan time (or closed it with a blocker). The plan-time verifier asked why no
47
+ * test proves that defect and the agent said; asking again here is the same
48
+ * question twice, and the answer is already published with the plan. */
49
+ export declare function checkIssueTraceability(plan: Plan, delivered: DeliveredForTraceability[], issues: ReportedIssueForTraceability[], lane?: TraceabilityLane): Objection[];
50
+ /** Report-time. Every defect the plan declared is an `issuesFound` entry that
51
+ * names it by `defectId`. The review found it; the report must show it. */
52
+ export declare function checkDefectsReported(plan: Plan, issues: ReportedIssueForTraceability[]): Objection[];