@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
@@ -0,0 +1,197 @@
1
+ const NORMAL_LANE = { planOnly: false };
2
+ /** An id as the plan spells it: trimmed, and exact otherwise. */
3
+ function idKey(raw) {
4
+ return typeof raw === "string" ? raw.trim() : "";
5
+ }
6
+ /** The plan's defects that carry an id. Read defensively: the plan comes off disk,
7
+ * and a plan stored before this field existed has none. */
8
+ function declaredDefects(plan) {
9
+ const raw = plan?.defects;
10
+ if (!Array.isArray(raw))
11
+ return [];
12
+ return raw.filter((defect) => idKey(defect?.id).length > 0);
13
+ }
14
+ /** The defect ids one planned test cites. */
15
+ function citedDefects(plannedTest) {
16
+ const declared = plannedTest?.declarations?.defects;
17
+ return Array.isArray(declared) ? declared.map(idKey).filter(Boolean) : [];
18
+ }
19
+ /** Whether the plan declares this test as one that expects to fail. Undeclared
20
+ * reads as not failing, exactly as the plan-time `defects` verifier reads it: a
21
+ * test that says nothing about its outcome proves nothing about a bug. */
22
+ function expectsToFail(plannedTest) {
23
+ return plannedTest?.declarations?.expected?.outcome === "fail";
24
+ }
25
+ /** How many characters of an issue's description the objection quotes. */
26
+ const QUOTE_LENGTH = 80;
27
+ function quote(description) {
28
+ const text = String(description ?? "").trim().replace(/\s+/g, " ");
29
+ return text.length > QUOTE_LENGTH ? `${text.slice(0, QUOTE_LENGTH)}…` : text;
30
+ }
31
+ /** The plan-time `defects:untested:<id>` objection, as the plan-time verifier ids it. */
32
+ const UNTESTED_PREFIX = "defects:untested:";
33
+ /** Defect ids whose `defects:untested:<id>` objection the agent closed when it
34
+ * registered the plan, by an answer or by a blocker. Both buckets: a blocker
35
+ * close is stored apart from an answer and is a close all the same. Read
36
+ * defensively: the plan comes off disk. */
37
+ function answeredUntested(plan) {
38
+ const closed = [
39
+ ...(Array.isArray(plan?.answeredObjections) ? plan.answeredObjections : []),
40
+ ...(Array.isArray(plan?.unverifiedCloses) ? plan.unverifiedCloses : []),
41
+ ];
42
+ const ids = new Set();
43
+ for (const entry of closed) {
44
+ const objectionId = idKey(entry?.objection?.objectionId);
45
+ if (!objectionId.startsWith(UNTESTED_PREFIX))
46
+ continue;
47
+ if (idKey(entry?.answer).length === 0)
48
+ continue;
49
+ const id = idKey(objectionId.slice(UNTESTED_PREFIX.length));
50
+ if (id)
51
+ ids.add(id);
52
+ }
53
+ return ids;
54
+ }
55
+ /** Where the defect sits, as the objection quotes it. */
56
+ function locate(defect) {
57
+ const file = String(defect?.file ?? "").trim() || "no file";
58
+ return typeof defect?.line === "number" ? `${file}:${defect.line}` : file;
59
+ }
60
+ /** Report-time. Every `category: bug` issue names the test that proves it, by
61
+ * `plannedTestId` or through a `defectId`, or it draws an objection the agent
62
+ * answers. Run 34283539284 reported six bugs with no test behind any of them and
63
+ * nothing said so; the one test near the headline bug asserted a value that
64
+ * passes against the broken output.
65
+ *
66
+ * A test proves a bug when the run DELIVERED it and the plan declares it as one
67
+ * that expects to fail. Both halves matter. A planned test the run never wrote is
68
+ * a promise, not proof, and the plan-time objections already hold the agent to
69
+ * those — except in the plan-only lane, which delivers nothing by design, so
70
+ * there a planned test is as far as a trace can go. A test the plan declares
71
+ * green is the run-34283539284 case itself: it passes against the broken output,
72
+ * so it says nothing about the bug. The plan is the only place a delivered test's
73
+ * expected outcome is written down, so a delivered test that joins to no planned
74
+ * test carries no such declaration and does not count either.
75
+ *
76
+ * A `defectId` counts the same way, and only for an id the plan's own `defects`
77
+ * list declares: `declarations.defects` is free text until it is joined to that
78
+ * list, so an unjoined id would let a report invent a defect and trace every bug
79
+ * through it.
80
+ *
81
+ * A declared `defectId` also counts when the agent answered `defects:untested:<id>`
82
+ * at plan time (or closed it with a blocker). The plan-time verifier asked why no
83
+ * test proves that defect and the agent said; asking again here is the same
84
+ * question twice, and the answer is already published with the plan. */
85
+ export function checkIssueTraceability(plan, delivered, issues, lane = NORMAL_LANE) {
86
+ const shipped = new Set(delivered.map((test) => idKey(test?.plannedTestId)).filter(Boolean));
87
+ const plannedTests = Array.isArray(plan?.plannedTests) ? plan.plannedTests : [];
88
+ // The join that gives a delivered test its expected outcome. First entry wins:
89
+ // a repeated id is the plan's own duplicate, and the checks that own it are the
90
+ // plan-time ones.
91
+ const plannedById = new Map();
92
+ for (const plannedTest of plannedTests) {
93
+ const id = idKey(plannedTest?.plannedTestId);
94
+ if (id && !plannedById.has(id))
95
+ plannedById.set(id, plannedTest);
96
+ }
97
+ // In the plan-only lane a planned test stands where a delivered one would.
98
+ const inHand = (plannedTestId) => shipped.has(plannedTestId) || (lane.planOnly && plannedById.has(plannedTestId));
99
+ const proves = (plannedTestId) => inHand(plannedTestId) && expectsToFail(plannedById.get(plannedTestId));
100
+ /** Why this id proves nothing, in the words the objection uses. */
101
+ const gap = (plannedTestId) => {
102
+ if (!inHand(plannedTestId)) {
103
+ return plannedById.has(plannedTestId)
104
+ ? `plannedTestId "${plannedTestId}" names a planned test the run did not deliver`
105
+ : `plannedTestId "${plannedTestId}" names no delivered or planned test`;
106
+ }
107
+ const word = lane.planOnly ? "planned" : "delivered";
108
+ return plannedById.has(plannedTestId)
109
+ ? `plannedTestId "${plannedTestId}" names a ${word} test the plan declares as expecting to pass, and a test that passes today proves nothing about a bug`
110
+ : `plannedTestId "${plannedTestId}" names a ${word} test that no planned test declares, so nothing says it expects to fail`;
111
+ };
112
+ const known = new Set(declaredDefects(plan).map((defect) => idKey(defect.id)));
113
+ // Declared defect id -> the tests that prove it: in hand, and red by declaration.
114
+ const provenBy = new Map();
115
+ // Declared defect id -> the tests that cite it and prove nothing. Evidence only.
116
+ const citedWeakly = new Map();
117
+ for (const plannedTest of plannedTests) {
118
+ const id = idKey(plannedTest?.plannedTestId);
119
+ if (!id || !inHand(id))
120
+ continue;
121
+ const bucket = proves(id) ? provenBy : citedWeakly;
122
+ for (const defectId of citedDefects(plannedTest)) {
123
+ if (!known.has(defectId))
124
+ continue;
125
+ bucket.set(defectId, [...(bucket.get(defectId) ?? []), id]);
126
+ }
127
+ }
128
+ const answered = answeredUntested(plan);
129
+ const objections = [];
130
+ issues.forEach((issue, index) => {
131
+ if (String(issue?.category ?? "").trim().toLowerCase() !== "bug")
132
+ return;
133
+ const plannedTestId = idKey(issue?.plannedTestId);
134
+ if (plannedTestId && proves(plannedTestId))
135
+ return;
136
+ const defectId = idKey(issue?.defectId);
137
+ const declaredDefect = defectId.length > 0 && known.has(defectId);
138
+ if (declaredDefect && ((provenBy.get(defectId) ?? []).length > 0 || answered.has(defectId)))
139
+ return;
140
+ const named = [];
141
+ if (plannedTestId)
142
+ named.push(gap(plannedTestId));
143
+ if (defectId) {
144
+ const word = lane.planOnly ? "planned" : "delivered";
145
+ if (!declaredDefect) {
146
+ named.push(`defectId "${defectId}" names no defect this plan declares`);
147
+ }
148
+ else if ((citedWeakly.get(defectId) ?? []).length > 0) {
149
+ named.push(`defectId "${defectId}" is cited by no ${word} test that expects to fail: ${(citedWeakly.get(defectId) ?? []).join(", ")} cite it and the plan declares each as expecting to pass`);
150
+ }
151
+ else {
152
+ named.push(`defectId "${defectId}" is cited by no ${word} test that expects to fail and no plan-time answer closed ${UNTESTED_PREFIX}${defectId}`);
153
+ }
154
+ }
155
+ const where = String(issue?.sourceFile ?? "").trim() || "no sourceFile";
156
+ objections.push({
157
+ // Keyed on the entry's position: an issue has no id of its own. The
158
+ // message NAMES the issue because the report-stage carry matches on the id
159
+ // AND the message: with a fixed message, an answer given for the bug at
160
+ // index 0 would follow index 0 to whatever bug the next call put there.
161
+ objectionId: `issueTraceability:${index}`,
162
+ verifier: "issueTraceability",
163
+ message: `No test proves this bug: "${quote(issue?.description)}" (${where}).`,
164
+ evidence: `issuesFound[${index}]: ${named.length > 0 ? named.join("; ") : "no plannedTestId and no defectId"}.`,
165
+ suggestion: "Set `plannedTestId` to the delivered test that proves this bug — one the plan declares with `expected.outcome: fail`, so it stays red until the bug is fixed — or set `defectId` to a declared plan defect such a test cites. If no test proves it, answer in one line why.",
166
+ });
167
+ });
168
+ return objections;
169
+ }
170
+ /** Report-time. Every defect the plan declared is an `issuesFound` entry that
171
+ * names it by `defectId`. The review found it; the report must show it. */
172
+ export function checkDefectsReported(plan, issues) {
173
+ // Only a `category: bug` entry reports a defect. The schema takes a `defectId`
174
+ // on a lint, type or config entry too, and the report renders those in its own
175
+ // Configuration Errors section — a defect parked there is one the reader never
176
+ // meets under Issues Found, and it would close this check all the same.
177
+ const reported = new Set(issues
178
+ .filter((issue) => String(issue?.category ?? "").trim().toLowerCase() === "bug")
179
+ .map((issue) => idKey(issue?.defectId))
180
+ .filter(Boolean));
181
+ const objections = [];
182
+ const seen = new Set();
183
+ for (const defect of declaredDefects(plan)) {
184
+ const id = idKey(defect.id);
185
+ if (seen.has(id) || reported.has(id))
186
+ continue;
187
+ seen.add(id);
188
+ objections.push({
189
+ objectionId: `defects:unreported:${id}`,
190
+ verifier: "defects",
191
+ message: "The plan declares this defect and no issue in the report names it.",
192
+ evidence: `defect ${id} (${locate(defect)}): "${String(defect.description ?? "").trim()}"; no issuesFound entry carries defectId "${id}".`,
193
+ suggestion: "Add an `issuesFound` entry for it with `category: bug`, `defectId` set to this id, and the `plannedTestId` of the test that proves it. If the review was wrong and there is no defect, answer in one line what you found.",
194
+ });
195
+ }
196
+ return objections;
197
+ }
@@ -0,0 +1,2 @@
1
+ import { Verifier } from "../types.js";
2
+ export declare const requirementSourced: Verifier;
@@ -0,0 +1,168 @@
1
+ import { normalizeCategory } from "../../types/TestRecommendation.js";
2
+ import { REQUIREMENT_SOURCED_CONTRACT, fillPlaceholders } from "../verifierContracts.js";
3
+ import { normalizeCitedPath } from "./citedPath.js";
4
+ import { appearsIn, searchable } from "../pullRequestText.js";
5
+ /** Verifier 10. A change names where the agent read it, and the server reads the
6
+ * name back.
7
+ *
8
+ * `source` is the record of where a change came from, and the server holds the
9
+ * pull request's own words, so it reads the claim back against them: a `spec:` path
10
+ * has to name a file the checkout holds AND a file the pull request names, and a
11
+ * change read from the pull request has to quote words the pull request carries.
12
+ *
13
+ * A conflict is a disagreement between a requirement and the code. A change read
14
+ * off the diff IS the code, so there is no second side for it to disagree with.
15
+ *
16
+ * The plan tool always supplies the pull request: the testbot fetches its prompt
17
+ * from this same server, so the text is in the process. Both fields blank means
18
+ * the run had no title and no description. */
19
+ const { unsourced: UNSOURCED, unreadable: UNREADABLE, fromDiff: FROM_DIFF, noChange: NO_CHANGE, notNamed: NOT_NAMED, emptyPullRequest: EMPTY_PULL_REQUEST, notQuoted: NOT_QUOTED, unquoted: UNQUOTED, } = REQUIREMENT_SOURCED_CONTRACT.objections;
20
+ const CONFLICT_CATEGORY = "requirement_conflict";
21
+ function readSource(raw) {
22
+ const none = (kind) => ({ kind, file: "", citedAs: "" });
23
+ const source = typeof raw === "string" ? raw.trim() : "";
24
+ if (source.length === 0)
25
+ return none("unknown");
26
+ if (/^(pr-title|pr-description)$/i.test(source))
27
+ return none("pullRequest");
28
+ if (/^diff$/i.test(source))
29
+ return none("diff");
30
+ const named = /^spec:(.*)$/i.exec(source);
31
+ if (!named)
32
+ return none("unknown");
33
+ const citedAs = named[1]
34
+ .split(/[#§]/)[0]
35
+ .replace(/(?::\d+)+(?:-\d+)?$/, "")
36
+ .trim();
37
+ const file = normalizeCitedPath(citedAs);
38
+ // `spec:` with nothing after it names no file, so there is nothing to check and
39
+ // nothing to trust either.
40
+ return file ? { kind: "spec", file, citedAs } : none("unknown");
41
+ }
42
+ /** A source that stands for a requirement: the pull request, or a requirements file. */
43
+ const statesARequirement = (source) => source.kind === "pullRequest" || source.kind === "spec";
44
+ const text = (value) => (typeof value === "string" ? value.trim() : "");
45
+ const list = (value) => (Array.isArray(value) ? value : []);
46
+ export const requirementSourced = {
47
+ name: "requirementSourced",
48
+ run(registration, ctx) {
49
+ const changes = list(registration?.changes);
50
+ const plannedTests = list(registration?.plannedTests);
51
+ const objections = [];
52
+ const sourceById = new Map();
53
+ const quoteById = new Map();
54
+ const pullRequestText = searchable(ctx.pullRequest);
55
+ for (const change of changes) {
56
+ const changeId = text(change?.id) || "(unnamed)";
57
+ const written = text(change?.source);
58
+ const source = readSource(written);
59
+ if (text(change?.id)) {
60
+ sourceById.set(text(change.id), source);
61
+ quoteById.set(text(change.id), typeof change?.quote === "string" ? change.quote : undefined);
62
+ }
63
+ if (source.kind === "unknown") {
64
+ objections.push({
65
+ objectionId: `requirementSourced:unsourced:${changeId}`,
66
+ verifier: "requirementSourced",
67
+ message: fillPlaceholders(UNSOURCED.message, { change: changeId }),
68
+ evidence: `change ${changeId} states source \`${written || "(blank)"}\``,
69
+ suggestion: UNSOURCED.suggestion,
70
+ });
71
+ continue;
72
+ }
73
+ if (source.kind === "pullRequest") {
74
+ if (pullRequestText.length > 0)
75
+ continue;
76
+ objections.push({
77
+ objectionId: `requirementSourced:emptyPullRequest:${changeId}`,
78
+ verifier: "requirementSourced",
79
+ message: fillPlaceholders(EMPTY_PULL_REQUEST.message, { change: changeId }),
80
+ evidence: `change ${changeId} states source \`${written}\`; this run rendered its prompt with no title and no description`,
81
+ suggestion: EMPTY_PULL_REQUEST.suggestion,
82
+ });
83
+ continue;
84
+ }
85
+ if (source.kind !== "spec")
86
+ continue;
87
+ if (typeof ctx?.citedFileExists === "function" && !ctx.citedFileExists(source.file)) {
88
+ objections.push({
89
+ objectionId: `requirementSourced:unreadable:${changeId}`,
90
+ verifier: "requirementSourced",
91
+ message: fillPlaceholders(UNREADABLE.message, { change: changeId, source: written }),
92
+ evidence: `change ${changeId} states source \`${written}\`; ${source.file} is not a file in the run's repositories`,
93
+ suggestion: UNREADABLE.suggestion,
94
+ });
95
+ continue;
96
+ }
97
+ // A requirements file the pull request never names is a file the agent found by
98
+ // looking through the repository, which is not reading a requirement.
99
+ if (appearsIn(pullRequestText, source.file) || appearsIn(pullRequestText, source.citedAs))
100
+ continue;
101
+ objections.push({
102
+ objectionId: `requirementSourced:notNamed:${changeId}`,
103
+ verifier: "requirementSourced",
104
+ message: fillPlaceholders(NOT_NAMED.message, { change: changeId, source: written }),
105
+ evidence: `change ${changeId} states source \`${written}\`; neither the title nor the description names ${source.file}`,
106
+ suggestion: NOT_NAMED.suggestion,
107
+ });
108
+ }
109
+ for (const plannedTest of plannedTests) {
110
+ if (normalizeCategory(plannedTest?.scenario?.category) !== CONFLICT_CATEGORY)
111
+ continue;
112
+ const plannedTestId = text(plannedTest?.plannedTestId);
113
+ const named = plannedTestId ? { plannedTestId } : {};
114
+ // An id no change carries names nothing, so it says no more than citing
115
+ // nothing does. Both leave the requirement with no stated origin.
116
+ const cited = list(plannedTest?.declarations?.changes)
117
+ .map((changeId) => text(changeId))
118
+ .filter((changeId) => sourceById.has(changeId));
119
+ if (cited.length === 0) {
120
+ objections.push({
121
+ objectionId: `requirementSourced:noChange:${plannedTestId || "plan"}`,
122
+ verifier: "requirementSourced",
123
+ ...named,
124
+ message: NO_CHANGE.message,
125
+ evidence: `planned test ${plannedTestId || "(unnamed)"} reports a requirement conflict and cites no change this plan declares`,
126
+ suggestion: NO_CHANGE.suggestion,
127
+ });
128
+ continue;
129
+ }
130
+ if (cited.some((changeId) => statesARequirement(sourceById.get(changeId)))) {
131
+ // The quote is what makes the claim checkable. One objection per test, on the
132
+ // first cited change that does not carry one the pull request holds.
133
+ const offending = cited.find((changeId) => {
134
+ if (sourceById.get(changeId)?.kind !== "pullRequest")
135
+ return false;
136
+ const quote = quoteById.get(changeId);
137
+ return quote === undefined || !appearsIn(pullRequestText, quote);
138
+ });
139
+ if (offending === undefined)
140
+ continue;
141
+ const quote = quoteById.get(offending);
142
+ const objection = quote === undefined ? UNQUOTED : NOT_QUOTED;
143
+ objections.push({
144
+ objectionId: `requirementSourced:notQuoted:${plannedTestId || "plan"}`,
145
+ verifier: "requirementSourced",
146
+ ...named,
147
+ message: fillPlaceholders(objection.message, { change: offending }),
148
+ evidence: quote === undefined
149
+ ? `planned test ${plannedTestId || "(unnamed)"} cites ${offending}, which states no \`quote\``
150
+ : `planned test ${plannedTestId || "(unnamed)"} cites ${offending}, whose quote \`${quote}\` is in neither the title nor the description`,
151
+ suggestion: objection.suggestion,
152
+ });
153
+ continue;
154
+ }
155
+ objections.push({
156
+ objectionId: `requirementSourced:fromDiff:${plannedTestId || "plan"}`,
157
+ verifier: "requirementSourced",
158
+ ...named,
159
+ message: FROM_DIFF.message,
160
+ evidence: `planned test ${plannedTestId || "(unnamed)"} cites ${cited
161
+ .map((changeId) => `${changeId} (kind ${sourceById.get(changeId)?.kind})`)
162
+ .join(", ")}`,
163
+ suggestion: FROM_DIFF.suggestion,
164
+ });
165
+ }
166
+ return objections;
167
+ },
168
+ };
@@ -15,6 +15,7 @@ import { isTestbotEnabled, } from "../utils/featureFlags.js";
15
15
  import { answerFor, unknownAnswerObjections } from "../recommendation/answers.js";
16
16
  import { checkDeliveredMatchesPlan } from "../recommendation/verifiers/deliveredMatchesPlan.js";
17
17
  import { checkReportedCategoryMatchesPlan, checkRequirementConflictReported, } from "../recommendation/verifiers/reportedCategory.js";
18
+ import { checkDefectsReported, checkIssueTraceability } from "../recommendation/verifiers/issueTraceability.js";
18
19
  import { checkExpectedOutcomeAfterExecution, } from "../recommendation/verifiers/expectedOutcome.js";
19
20
  import { findInvalidSourceCitations, findUnchangedFileClaims, listChangedFiles, listChangedFilesAcross, listChangedFilesAbs, } from "../utils/reportVerification.js";
20
21
  import { isPlanOnlyMode } from "../utils/planOnlyMode.js";
@@ -322,7 +323,15 @@ const issueFoundSchema = z
322
323
  plannedTestId: z
323
324
  .string()
324
325
  .optional()
325
- .describe("The `plannedTestId` of the planned test this issue is about, when it is about one. Set it on the issue you record for a `requirement_conflict` test so the conflict is tied to the test that asserts it; otherwise leave it out."),
326
+ .describe("The `plannedTestId` of the DELIVERED test that proves this issue. Your plan declares that test with `expected.outcome: fail`, so it stays red until the issue is fixed; a `requirement_conflict` test that asserts the requirement is declared the same way. A planned test you did not write proves nothing, and a test the plan declares green proves nothing either. " +
327
+ "This field is CONDITIONAL, not required on every bug: set it when such a test exists; set `defectId` instead when a plan defect traces the issue; leave both out when no test proves the issue. " +
328
+ "CHECKED on a `bug` entry: one that neither names a delivered red test here nor traces through `defectId` draws an objection you answer in one line."),
329
+ defectId: z
330
+ .string()
331
+ .optional()
332
+ .describe("The `id` of the plan defect this issue reports, from the `defects` list you registered with skyramp_register_test_plan. Spell it as that list spells it: an id the plan does not declare traces nothing. " +
333
+ "Set it on the `category: bug` entry that reports the defect. A `defectId` on a `lint`, `type` or `config` entry does not report the defect — the report renders those in its own Configuration Errors section. " +
334
+ "CHECKED: every plan defect has a `bug` issue that names it, and a `bug` entry with no `plannedTestId` traces through this defect when a delivered test that expects to fail cites it, or when you answered `defects:untested:<id>` at plan time. Leave it out for an issue the plan did not declare."),
326
335
  sourceFile: citationString.describe("Path of the application file whose code is missing or wrong, relative to the repository root (e.g. 'src/crud/products.py'). " +
327
336
  "REQUIRED when category is 'bug'. " +
328
337
  "For code that is MISSING — an unmounted router, an unregistered route, an import never added — name the file where the line should be, not the file that defines what is unmounted. " +
@@ -812,7 +821,10 @@ const PLANNABLE_TEST_TYPES = new Set([
812
821
  TestType.E2E,
813
822
  TestType.UI,
814
823
  ]);
815
- function runPostExecutionChecks(plan, delivered, results, issues) {
824
+ function runPostExecutionChecks(plan, delivered, results, issues,
825
+ /** The plan-only lane delivers nothing, so there a planned test is proof
826
+ * enough for a bug. Passed in, not read here, so the checks stay pure. */
827
+ planOnly) {
816
828
  const shipped = delivered
817
829
  // A smoke, fuzz or load entry is filtered out rather than reported as
818
830
  // unplanned: a plan cannot hold one, so the objection would name a mistake the
@@ -826,6 +838,10 @@ function runPostExecutionChecks(plan, delivered, results, issues) {
826
838
  ...runPostExecutionCheck("expectedOutcome", "expectedOutcome:executed", () => checkExpectedOutcomeAfterExecution(plan, collectExecutionOutcomes(delivered, results), issues)),
827
839
  ...runPostExecutionCheck("reportedCategory", "reportedCategory", () => checkReportedCategoryMatchesPlan(plan, delivered)),
828
840
  ...runPostExecutionCheck("requirementConflictReported", "requirementConflictReported", () => checkRequirementConflictReported(plan, delivered, issues)),
841
+ // Both halves of the review's accountability, and neither refuses the report:
842
+ // a bug with no test behind it, and a plan defect the report never mentions.
843
+ ...runPostExecutionCheck("issueTraceability", "issueTraceability", () => checkIssueTraceability(plan, shipped, issues, { planOnly })),
844
+ ...runPostExecutionCheck("defectsReported", "defects:unreported", () => checkDefectsReported(plan, issues)),
829
845
  ];
830
846
  }
831
847
  export function registerSubmitReportTool(server) {
@@ -1357,7 +1373,7 @@ export function registerSubmitReportTool(server) {
1357
1373
  let objections;
1358
1374
  let changeTable;
1359
1375
  {
1360
- const postExecution = runPostExecutionChecks(stateData.plan ?? { plannedTests: [], registrationNumber: 0, answers: [], openObjections: [], answeredObjections: [] }, dedupedNewTests, params.testResults, params.issuesFound ?? []);
1376
+ const postExecution = runPostExecutionChecks(stateData.plan ?? { plannedTests: [], registrationNumber: 0, answers: [], openObjections: [], answeredObjections: [] }, dedupedNewTests, params.testResults, params.issuesFound ?? [], isPlanOnlyMode());
1361
1377
  // Read defensively, like the crash-contained checks above: the plan comes
1362
1378
  // off disk, so its declared array type holds only on the validated path.
1363
1379
  const stillOpen = Array.isArray(stateData.plan?.openObjections)
@@ -3,13 +3,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StateManager, UnifiedAnalysisState } from "../../utils/AnalysisStateManager.js";
4
4
  import { TestType } from "../../types/TestTypes.js";
5
5
  import { PlanResult } from "../../recommendation/registerPlan.js";
6
- import { ObjectionAnswer, PlanChange, PlanInput, VerifyContext } from "../../recommendation/types.js";
6
+ import { ObjectionAnswer, PlanChange, PlanDefect, PlanInput, VerifyContext } from "../../recommendation/types.js";
7
7
  /** The whole run's state file: the primary repository at the root, every related
8
8
  * repository under `relatedRepos`. */
9
9
  type RunState = Awaited<ReturnType<StateManager<UnifiedAnalysisState>["readFullState"]>>;
10
10
  export declare const declarationFieldsSchema: z.ZodObject<{
11
11
  changedFile: z.ZodOptional<z.ZodString>;
12
12
  changes: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
13
+ defects: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
13
14
  asserts: z.ZodString;
14
15
  stepUnderTest: z.ZodOptional<z.ZodNumber>;
15
16
  expected: z.ZodObject<{
@@ -121,11 +122,12 @@ export declare const declarationFieldsSchema: z.ZodObject<{
121
122
  differsBy: string;
122
123
  }[] | undefined;
123
124
  changedFile?: string | undefined;
124
- changes?: string[] | undefined;
125
+ defects?: string[] | undefined;
125
126
  routes?: {
126
127
  file: string;
127
128
  step: number;
128
129
  }[] | undefined;
130
+ changes?: string[] | undefined;
129
131
  elements?: {
130
132
  items?: {
131
133
  role: string;
@@ -158,11 +160,12 @@ export declare const declarationFieldsSchema: z.ZodObject<{
158
160
  differsBy: string;
159
161
  }[] | undefined;
160
162
  changedFile?: string | undefined;
161
- changes?: string[] | undefined;
163
+ defects?: string[] | undefined;
162
164
  routes?: {
163
165
  file: string;
164
166
  step: number;
165
167
  }[] | undefined;
168
+ changes?: string[] | undefined;
166
169
  elements?: {
167
170
  items?: {
168
171
  role: string;
@@ -250,6 +253,7 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
250
253
  declarations: z.ZodOptional<z.ZodEffects<z.ZodObject<{
251
254
  changedFile: z.ZodOptional<z.ZodString>;
252
255
  changes: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
256
+ defects: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
253
257
  asserts: z.ZodString;
254
258
  stepUnderTest: z.ZodOptional<z.ZodNumber>;
255
259
  expected: z.ZodObject<{
@@ -361,11 +365,12 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
361
365
  differsBy: string;
362
366
  }[] | undefined;
363
367
  changedFile?: string | undefined;
364
- changes?: string[] | undefined;
368
+ defects?: string[] | undefined;
365
369
  routes?: {
366
370
  file: string;
367
371
  step: number;
368
372
  }[] | undefined;
373
+ changes?: string[] | undefined;
369
374
  elements?: {
370
375
  items?: {
371
376
  role: string;
@@ -398,11 +403,12 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
398
403
  differsBy: string;
399
404
  }[] | undefined;
400
405
  changedFile?: string | undefined;
401
- changes?: string[] | undefined;
406
+ defects?: string[] | undefined;
402
407
  routes?: {
403
408
  file: string;
404
409
  step: number;
405
410
  }[] | undefined;
411
+ changes?: string[] | undefined;
406
412
  elements?: {
407
413
  items?: {
408
414
  role: string;
@@ -435,11 +441,12 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
435
441
  differsBy: string;
436
442
  }[] | undefined;
437
443
  changedFile?: string | undefined;
438
- changes?: string[] | undefined;
444
+ defects?: string[] | undefined;
439
445
  routes?: {
440
446
  file: string;
441
447
  step: number;
442
448
  }[] | undefined;
449
+ changes?: string[] | undefined;
443
450
  elements?: {
444
451
  items?: {
445
452
  role: string;
@@ -472,11 +479,12 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
472
479
  differsBy: string;
473
480
  }[] | undefined;
474
481
  changedFile?: string | undefined;
475
- changes?: string[] | undefined;
482
+ defects?: string[] | undefined;
476
483
  routes?: {
477
484
  file: string;
478
485
  step: number;
479
486
  }[] | undefined;
487
+ changes?: string[] | undefined;
480
488
  elements?: {
481
489
  items?: {
482
490
  role: string;
@@ -502,8 +510,8 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
502
510
  }, "strict", z.ZodTypeAny, {
503
511
  description: string;
504
512
  testType: TestType.CONTRACT | TestType.INTEGRATION | TestType.E2E | TestType.UI;
505
- scenarioName: string;
506
513
  category: "new_endpoint" | "bug_caught" | "requirement_conflict" | "business_rule" | "security_boundary" | "data_integrity" | "breaking_change" | "auth" | "error_handling" | "workflow" | "data_validation" | "crud";
514
+ scenarioName: string;
507
515
  steps: {
508
516
  path: string;
509
517
  method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS" | "type" | "TRACE" | "CONNECT" | "assert" | "click" | "drag" | "drag-hold" | "hover" | "inspect" | "navigate" | "press" | "release" | "tap" | "wait" | "OPERATION";
@@ -527,11 +535,12 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
527
535
  differsBy: string;
528
536
  }[] | undefined;
529
537
  changedFile?: string | undefined;
530
- changes?: string[] | undefined;
538
+ defects?: string[] | undefined;
531
539
  routes?: {
532
540
  file: string;
533
541
  step: number;
534
542
  }[] | undefined;
543
+ changes?: string[] | undefined;
535
544
  elements?: {
536
545
  items?: {
537
546
  role: string;
@@ -557,8 +566,8 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
557
566
  }, {
558
567
  description: string;
559
568
  testType: TestType.CONTRACT | TestType.INTEGRATION | TestType.E2E | TestType.UI;
560
- scenarioName: string;
561
569
  category: "new_endpoint" | "bug_caught" | "requirement_conflict" | "business_rule" | "security_boundary" | "data_integrity" | "breaking_change" | "auth" | "error_handling" | "workflow" | "data_validation" | "crud";
570
+ scenarioName: string;
562
571
  steps: {
563
572
  path: string;
564
573
  order: number;
@@ -582,11 +591,12 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
582
591
  differsBy: string;
583
592
  }[] | undefined;
584
593
  changedFile?: string | undefined;
585
- changes?: string[] | undefined;
594
+ defects?: string[] | undefined;
586
595
  routes?: {
587
596
  file: string;
588
597
  step: number;
589
598
  }[] | undefined;
599
+ changes?: string[] | undefined;
590
600
  elements?: {
591
601
  items?: {
592
602
  role: string;
@@ -612,8 +622,8 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
612
622
  }>, {
613
623
  description: string;
614
624
  testType: TestType.CONTRACT | TestType.INTEGRATION | TestType.E2E | TestType.UI;
615
- scenarioName: string;
616
625
  category: "new_endpoint" | "bug_caught" | "requirement_conflict" | "business_rule" | "security_boundary" | "data_integrity" | "breaking_change" | "auth" | "error_handling" | "workflow" | "data_validation" | "crud";
626
+ scenarioName: string;
617
627
  steps: {
618
628
  path: string;
619
629
  method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS" | "type" | "TRACE" | "CONNECT" | "assert" | "click" | "drag" | "drag-hold" | "hover" | "inspect" | "navigate" | "press" | "release" | "tap" | "wait" | "OPERATION";
@@ -637,11 +647,12 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
637
647
  differsBy: string;
638
648
  }[] | undefined;
639
649
  changedFile?: string | undefined;
640
- changes?: string[] | undefined;
650
+ defects?: string[] | undefined;
641
651
  routes?: {
642
652
  file: string;
643
653
  step: number;
644
654
  }[] | undefined;
655
+ changes?: string[] | undefined;
645
656
  elements?: {
646
657
  items?: {
647
658
  role: string;
@@ -667,8 +678,8 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
667
678
  }, {
668
679
  description: string;
669
680
  testType: TestType.CONTRACT | TestType.INTEGRATION | TestType.E2E | TestType.UI;
670
- scenarioName: string;
671
681
  category: "new_endpoint" | "bug_caught" | "requirement_conflict" | "business_rule" | "security_boundary" | "data_integrity" | "breaking_change" | "auth" | "error_handling" | "workflow" | "data_validation" | "crud";
682
+ scenarioName: string;
672
683
  steps: {
673
684
  path: string;
674
685
  order: number;
@@ -692,11 +703,12 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
692
703
  differsBy: string;
693
704
  }[] | undefined;
694
705
  changedFile?: string | undefined;
695
- changes?: string[] | undefined;
706
+ defects?: string[] | undefined;
696
707
  routes?: {
697
708
  file: string;
698
709
  step: number;
699
710
  }[] | undefined;
711
+ changes?: string[] | undefined;
700
712
  elements?: {
701
713
  items?: {
702
714
  role: string;
@@ -722,8 +734,8 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
722
734
  }>, {
723
735
  description: string;
724
736
  testType: TestType.CONTRACT | TestType.INTEGRATION | TestType.E2E | TestType.UI;
725
- scenarioName: string;
726
737
  category: "new_endpoint" | "bug_caught" | "requirement_conflict" | "business_rule" | "security_boundary" | "data_integrity" | "breaking_change" | "auth" | "error_handling" | "workflow" | "data_validation" | "crud";
738
+ scenarioName: string;
727
739
  steps: {
728
740
  path: string;
729
741
  method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS" | "type" | "TRACE" | "CONNECT" | "assert" | "click" | "drag" | "drag-hold" | "hover" | "inspect" | "navigate" | "press" | "release" | "tap" | "wait" | "OPERATION";
@@ -747,11 +759,12 @@ declare const registerPlannedTestSchema: z.ZodEffects<z.ZodEffects<z.ZodObject<{
747
759
  differsBy: string;
748
760
  }[] | undefined;
749
761
  changedFile?: string | undefined;
750
- changes?: string[] | undefined;
762
+ defects?: string[] | undefined;
751
763
  routes?: {
752
764
  file: string;
753
765
  step: number;
754
766
  }[] | undefined;
767
+ changes?: string[] | undefined;
755
768
  elements?: {
756
769
  items?: {
757
770
  role: string;
@@ -779,6 +792,7 @@ type RegisterPlannedTestInput = z.infer<typeof registerPlannedTestSchema>;
779
792
  export interface RegisterTestPlanParams {
780
793
  stateFile?: string;
781
794
  changes?: PlanChange[];
795
+ defects?: PlanDefect[];
782
796
  plannedTests?: RegisterPlannedTestInput[];
783
797
  answers?: ObjectionAnswer[];
784
798
  }