@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.
- package/build/prompts/enhance-assertions/sharedAssertionRules.js +20 -4
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +4 -5
- package/build/prompts/testbot/testbot-prompts.js +10 -8
- package/build/recommendation/answers.d.ts +11 -7
- package/build/recommendation/answers.js +14 -10
- package/build/recommendation/pullRequestText.d.ts +18 -0
- package/build/recommendation/pullRequestText.js +31 -0
- package/build/recommendation/registerPlan.d.ts +5 -1
- package/build/recommendation/registerPlan.js +3 -1
- package/build/recommendation/runVerifiers.js +6 -0
- package/build/recommendation/types.d.ts +35 -0
- package/build/recommendation/verifierContracts.d.ts +94 -11
- package/build/recommendation/verifierContracts.js +129 -27
- package/build/recommendation/verifiers/defects.d.ts +9 -0
- package/build/recommendation/verifiers/defects.js +117 -0
- package/build/recommendation/verifiers/expectedValueSourced.d.ts +14 -0
- package/build/recommendation/verifiers/expectedValueSourced.js +149 -0
- package/build/recommendation/verifiers/issueTraceability.d.ts +52 -0
- package/build/recommendation/verifiers/issueTraceability.js +197 -0
- package/build/recommendation/verifiers/requirementSourced.d.ts +2 -0
- package/build/recommendation/verifiers/requirementSourced.js +168 -0
- package/build/tools/submitReportTool.js +19 -3
- package/build/tools/test-management/registerTestPlanTool.d.ts +31 -17
- package/build/tools/test-management/registerTestPlanTool.js +79 -5
- package/build/types/TestbotReport.d.ts +7 -3
- package/build/utils/assertion-verify/api-shared-lints.js +70 -0
- package/package.json +1 -1
- package/plugin/prompts/generate-tests/execution-plan.md +2 -2
- package/plugin/prompts/plan-tests.md +18 -9
- package/plugin/prompts/testbot-task1.md +3 -9
- package/build/prompts/testbot/planDeclarations.d.ts +0 -6
- package/build/prompts/testbot/planDeclarations.js +0 -9
- 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,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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|