scenescout 3.15.0 → 3.17.0
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/CHANGELOG.md +87 -0
- package/README.md +70 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +191 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +107 -47
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +244 -42
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +181 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/status-pane-page.js +441 -0
- package/dist/engine/status-pane.js +128 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +5 -0
- package/dist/installer.js +378 -8
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +660 -65
- package/dist/playbook.js +5 -0
- package/dist/prompts.js +106 -0
- package/package.json +8 -5
- package/skills/scenescout/SKILL.md +49 -16
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
export const CRITERION_VERDICTS = ["pass", "fail", "not-tested"];
|
|
2
|
+
/** Why a criterion was not tested, as the agent states it. */
|
|
3
|
+
export const NOT_TESTED_REASONS = ["no-access", "observe-blocked", "out-of-scope"];
|
|
4
|
+
/** Bounds, so one pasted backlog cannot grow the memory file without limit. */
|
|
5
|
+
export const MAX_TICKET_TEXT = 200_000;
|
|
6
|
+
export const MAX_TICKETS = 50;
|
|
7
|
+
/** Most tickets the project keeps across runs: the newest readings. */
|
|
8
|
+
export const MAX_TICKETS_KEPT = 100;
|
|
9
|
+
export const MAX_CRITERIA_PER_TICKET = 50;
|
|
10
|
+
export const MAX_CRITERION_TEXT = 500;
|
|
11
|
+
export const MAX_REASON = 300;
|
|
12
|
+
export const MAX_CRITERION_FINDINGS = 20;
|
|
13
|
+
export const MAX_CRITERION_VERDICTS = 1000;
|
|
14
|
+
/** A ticket file is read only up to this size. */
|
|
15
|
+
export const MAX_TICKET_FILE_BYTES = 1_000_000;
|
|
16
|
+
/** The file types a ticket is read from: text, never a binary or a document format. */
|
|
17
|
+
export const TICKET_FILE_EXTENSIONS = [".md", ".markdown", ".txt", ".text", ".feature"];
|
|
18
|
+
/** Below this confidence, the plain report says the verdict is unsure. */
|
|
19
|
+
export const UNSURE_BELOW = 0.6;
|
|
20
|
+
/** Whether a file name has one of the extensions a ticket is read from. */
|
|
21
|
+
export function isTicketFileName(name) {
|
|
22
|
+
const lower = name.toLowerCase();
|
|
23
|
+
return TICKET_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
24
|
+
}
|
|
25
|
+
// ── Reading ───────────────────────────────────────────────────────────────────
|
|
26
|
+
/** A heading that opens the criteria. */
|
|
27
|
+
const AC_HEADING = /^(?:(?:acceptance criteria|acceptance criterion|acceptance tests?|definition of done)\b.*|acs?|criteria|dod|scenarios?)$/i;
|
|
28
|
+
/** A heading whose lists are not criteria: how to reproduce a bug, what is excluded. */
|
|
29
|
+
const EXCLUDED_HEADING = /^(steps|steps to reproduce|reproduction steps|repro|repro steps|to reproduce|how to reproduce|expected|expected result|expected behaviou?r|actual|actual result|actual behaviou?r|environment|out of scope|not in scope|non-goals?)$/i;
|
|
30
|
+
/** A heading that belongs to a ticket rather than starting one. */
|
|
31
|
+
const PART_HEADING = /^(description|summary|background|context|notes?|details|user story|story|design|technical notes|implementation notes|dependencies|links?|attachments?|screenshots?|comments?|testing notes|how to test|why|goal|problem)$/i;
|
|
32
|
+
const TICKET_KEY = /\b([A-Z][A-Z0-9]{1,9}-\d{1,7})\b/;
|
|
33
|
+
const ISSUE_NUMBER = /(?:^|[\s([])#(\d{1,7})\b/;
|
|
34
|
+
const STEP = /^(?:[-*+•]\s+)?(?:\*\*|__)?(Given|When|Then|And|But|GIVEN|WHEN|THEN|AND|BUT)(?:\*\*|__)?\s+(.+)$/;
|
|
35
|
+
const SCENARIO = /^(?:[-*+•]\s+)?(?:\*\*|__)?(?:Scenario Outline|Scenario Template|Scenario|Example)(?:\*\*|__)?\s*:\s*(.*)$/i;
|
|
36
|
+
const BACKGROUND = /^(?:\*\*|__)?Background(?:\*\*|__)?\s*:\s*$/i;
|
|
37
|
+
const FEATURE = /^(?:\*\*|__)?Feature(?:\*\*|__)?\s*:\s*(.*)$/i;
|
|
38
|
+
const LABELLED = /^(?:[-*+•]\s+|\d{1,3}[.)]\s+)?(?:\*\*|__)?AC\s*[-#]?\s*(\d{1,3})(?:\*\*|__)?\s*[:.)\-–—]\s*(?:\*\*|__)?\s*(.*)$/i;
|
|
39
|
+
const CHECK = /^(?:[-*+•]\s+)?\[( |x|X)\]\s+(.+)$/;
|
|
40
|
+
const NUMBERED = /^(\d{1,3})[.)]\s+(.+)$/;
|
|
41
|
+
const BULLET = /^[-*+•]\s+(.+)$/;
|
|
42
|
+
const MD_HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
43
|
+
const BOLD_LINE = /^(?:\*\*|__)([^*_]+?)(?:\*\*|__)\s*:?\s*$/;
|
|
44
|
+
const LABEL_LINE = /^([A-Za-z][A-Za-z /&'-]{0,40}):\s*$/;
|
|
45
|
+
const RULE = /^(?:-{3,}|\*{3,}|_{3,}|={3,})\s*$/;
|
|
46
|
+
/** Markdown emphasis and runs of space taken out of a line of a criterion. */
|
|
47
|
+
function clean(text) {
|
|
48
|
+
return text
|
|
49
|
+
.replace(/\*\*|__/g, "")
|
|
50
|
+
.replace(/`/g, "")
|
|
51
|
+
.replace(/\s+/g, " ")
|
|
52
|
+
.trim();
|
|
53
|
+
}
|
|
54
|
+
/** A heading's words, without the ticket key, numbering or a trailing colon. */
|
|
55
|
+
function headingWords(text) {
|
|
56
|
+
return clean(text)
|
|
57
|
+
.replace(/[:.\s]+$/, "")
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
function regionOf(heading) {
|
|
61
|
+
const words = headingWords(heading);
|
|
62
|
+
if (AC_HEADING.test(words))
|
|
63
|
+
return "ac";
|
|
64
|
+
if (EXCLUDED_HEADING.test(words))
|
|
65
|
+
return "excluded";
|
|
66
|
+
if (PART_HEADING.test(words))
|
|
67
|
+
return "part";
|
|
68
|
+
return "ticket";
|
|
69
|
+
}
|
|
70
|
+
/** Where one ticket ends and the next begins. */
|
|
71
|
+
function splitTickets(text, gherkin) {
|
|
72
|
+
const out = [];
|
|
73
|
+
let current = { lines: [] };
|
|
74
|
+
const start = (heading, level) => {
|
|
75
|
+
if (current.heading !== undefined || current.lines.some((l) => l.trim()))
|
|
76
|
+
out.push(current);
|
|
77
|
+
current = { heading, headingLevel: level, lines: [] };
|
|
78
|
+
};
|
|
79
|
+
for (const line of text.split(/\r?\n/)) {
|
|
80
|
+
const trimmed = line.trim();
|
|
81
|
+
if (RULE.test(trimmed)) {
|
|
82
|
+
start();
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (gherkin && trimmed.startsWith("#"))
|
|
86
|
+
continue; // a comment in a .feature file
|
|
87
|
+
const h = gherkin ? null : MD_HEADING.exec(trimmed);
|
|
88
|
+
if (h) {
|
|
89
|
+
const level = h[1].length;
|
|
90
|
+
// A heading below the ticket's own is part of it; one at its level or
|
|
91
|
+
// above, and not a known part's name, is the next ticket. So is one that
|
|
92
|
+
// carries a ticket key, or that follows a heading with nothing under it
|
|
93
|
+
// (stories under an epic).
|
|
94
|
+
const within = current.headingLevel !== undefined && level > current.headingLevel && !ticketKey(h[2]) && current.lines.some((l) => l.trim());
|
|
95
|
+
if (!within && regionOf(h[2]) === "ticket") {
|
|
96
|
+
start(h[2], level);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const feature = FEATURE.exec(trimmed);
|
|
101
|
+
if (feature && line === line.trimStart()) {
|
|
102
|
+
start(feature[1] || "Feature");
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// A line that opens with a ticket key ("PROJ-12: Export is empty") starts a ticket.
|
|
106
|
+
// It needs a separator after the key: "UTF-8 names round-trip" is a sentence, not a ticket.
|
|
107
|
+
const keyed = /^(?:\[[A-Z][A-Z0-9]{1,9}-\d{1,7}\]\s*|[A-Z][A-Z0-9]{1,9}-\d{1,7}(?:\s*:\s*|\s+[-–—]\s+))(.+)$/.exec(trimmed);
|
|
108
|
+
if (keyed && line === line.trimStart()) {
|
|
109
|
+
start(trimmed);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
current.lines.push(line);
|
|
113
|
+
}
|
|
114
|
+
start();
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
const indentOf = (line) => line.length - line.trimStart().length;
|
|
118
|
+
/** The criteria in one ticket's lines, before choosing which regions count. */
|
|
119
|
+
function draftsOf(lines, gherkin) {
|
|
120
|
+
const drafts = [];
|
|
121
|
+
let region = "open";
|
|
122
|
+
let regionLevel = 0;
|
|
123
|
+
let current = null;
|
|
124
|
+
let title;
|
|
125
|
+
const open = (d) => {
|
|
126
|
+
drafts.push(d);
|
|
127
|
+
current = d;
|
|
128
|
+
};
|
|
129
|
+
for (const raw of lines) {
|
|
130
|
+
const line = raw.replace(/\t/g, " ");
|
|
131
|
+
const trimmed = line.trim();
|
|
132
|
+
const indent = indentOf(line);
|
|
133
|
+
if (!trimmed) {
|
|
134
|
+
// A scenario's name may stand a blank line above its steps; anything else ends at a blank line.
|
|
135
|
+
if (current && !(current.shape === "given-when-then" && current.parts.length === 0))
|
|
136
|
+
current = null;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (gherkin && (trimmed.startsWith("#") || trimmed.startsWith("@")))
|
|
140
|
+
continue;
|
|
141
|
+
if (trimmed.startsWith("|")) {
|
|
142
|
+
// An Examples table belongs to its scenario; elsewhere a table is not a criterion.
|
|
143
|
+
if (current?.shape !== "given-when-then")
|
|
144
|
+
current = null;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (/^Examples\s*:/i.test(trimmed) && current?.shape === "given-when-then")
|
|
148
|
+
continue;
|
|
149
|
+
const h = MD_HEADING.exec(trimmed);
|
|
150
|
+
const label = h ? null : (BOLD_LINE.exec(trimmed) ?? LABEL_LINE.exec(trimmed));
|
|
151
|
+
if (h || (label && !STEP.test(trimmed) && !SCENARIO.test(trimmed))) {
|
|
152
|
+
const words = h ? h[2] : label[1];
|
|
153
|
+
const level = h ? h[1].length : 7;
|
|
154
|
+
const kind = regionOf(words);
|
|
155
|
+
current = null;
|
|
156
|
+
if (kind === "ticket") {
|
|
157
|
+
// A sub-heading inside the criteria groups them; elsewhere it is neutral.
|
|
158
|
+
// A label ("Error handling:") inside criteria opened by a label groups them too.
|
|
159
|
+
if (!(region === "ac" && (level > regionLevel || (!h && regionLevel === 7))))
|
|
160
|
+
region = "part";
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
region = kind;
|
|
164
|
+
regionLevel = level;
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
let m;
|
|
169
|
+
if (BACKGROUND.test(trimmed)) {
|
|
170
|
+
// Steps every scenario shares: context, not a criterion. Kept out of the list.
|
|
171
|
+
current = { shape: "given-when-then", parts: [], region, indent, background: true };
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if ((m = SCENARIO.exec(trimmed))) {
|
|
175
|
+
open({ shape: "given-when-then", parts: [], region, indent, title: clean(m[1]) || undefined });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if ((m = STEP.exec(trimmed))) {
|
|
179
|
+
const keyword = m[1][0].toUpperCase() + m[1].slice(1).toLowerCase();
|
|
180
|
+
const step = `${keyword} ${clean(m[2])}`;
|
|
181
|
+
const phase = keyword === "Given" ? "given" : keyword === "When" ? "when" : keyword === "Then" ? "then" : undefined;
|
|
182
|
+
const cur = current;
|
|
183
|
+
if (cur && cur.shape === "given-when-then" && !(phase === "given" && (cur.phase === "when" || cur.phase === "then"))) {
|
|
184
|
+
cur.parts.push(step);
|
|
185
|
+
if (phase)
|
|
186
|
+
cur.phase = phase;
|
|
187
|
+
if (phase)
|
|
188
|
+
(cur.keywords ??= new Set()).add(phase);
|
|
189
|
+
}
|
|
190
|
+
else if (cur && cur.shape !== "given-when-then" && (indent > cur.indent || /:$/.test(cur.parts[cur.parts.length - 1] ?? ""))) {
|
|
191
|
+
// Steps written under a numbered or labelled criterion spell that criterion out.
|
|
192
|
+
cur.parts.push(step);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
open({ shape: "given-when-then", parts: [step], region, indent, phase: phase ?? "given", keywords: new Set(phase ? [phase] : []) });
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if ((m = LABELLED.exec(trimmed)) && m[2].trim()) {
|
|
200
|
+
open({ shape: "labelled", parts: [clean(m[2])], region, indent, label: Number(m[1]) });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if ((m = CHECK.exec(trimmed))) {
|
|
204
|
+
open({ shape: "checklist", parts: [clean(m[2])], region, indent });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const cur = current;
|
|
208
|
+
if ((m = NUMBERED.exec(trimmed))) {
|
|
209
|
+
if (cur && indent > cur.indent)
|
|
210
|
+
cur.parts.push(clean(m[2]));
|
|
211
|
+
else
|
|
212
|
+
open({ shape: "numbered", parts: [clean(m[2])], region, indent });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if ((m = BULLET.exec(trimmed))) {
|
|
216
|
+
if (cur && indent > cur.indent)
|
|
217
|
+
cur.parts.push(clean(m[1]));
|
|
218
|
+
else
|
|
219
|
+
open({ shape: "bullet", parts: [clean(m[1])], region, indent });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (cur) {
|
|
223
|
+
// A wrapped line carries on the criterion above it.
|
|
224
|
+
const last = cur.parts.length - 1;
|
|
225
|
+
if (last >= 0 && cur.shape !== "given-when-then")
|
|
226
|
+
cur.parts[last] = `${cur.parts[last]} ${clean(trimmed)}`;
|
|
227
|
+
else
|
|
228
|
+
cur.parts.push(clean(trimmed));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (!title && region === "open")
|
|
232
|
+
title = clean(trimmed);
|
|
233
|
+
}
|
|
234
|
+
return { drafts, title };
|
|
235
|
+
}
|
|
236
|
+
/** The shapes that are criteria without an "Acceptance criteria" heading, by the region they sit in. */
|
|
237
|
+
function countsWithoutHeading(d) {
|
|
238
|
+
if (d.region === "excluded")
|
|
239
|
+
return false;
|
|
240
|
+
if (d.shape === "bullet")
|
|
241
|
+
return false;
|
|
242
|
+
// A numbered list in the description of a ticket is as often a list of
|
|
243
|
+
// steps as of criteria; only one at the top of the ticket counts.
|
|
244
|
+
if (d.shape === "numbered")
|
|
245
|
+
return d.region === "open";
|
|
246
|
+
// Steps outside the criteria are a scenario only when named as one or when they
|
|
247
|
+
// run from a Given or When to a Then: "When I export, the file is empty." is prose.
|
|
248
|
+
if (d.shape === "given-when-then")
|
|
249
|
+
return d.title !== undefined || (d.keywords?.has("then") === true && (d.keywords.has("given") || d.keywords.has("when")));
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
function textOf(d) {
|
|
253
|
+
const body = d.parts.filter(Boolean).join("; ");
|
|
254
|
+
const text = d.title ? (body ? `${d.title}: ${body}` : d.title) : body;
|
|
255
|
+
return text.length > MAX_CRITERION_TEXT ? `${text.slice(0, MAX_CRITERION_TEXT - 1)}…` : text;
|
|
256
|
+
}
|
|
257
|
+
/** What a ticket with no recognisable criteria had instead, and how to make them readable. */
|
|
258
|
+
function noCriteriaNote(drafts) {
|
|
259
|
+
const how = 'Write them as Given/When/Then, as a checklist ("- [ ] …"), as a numbered list, or as a list under an "Acceptance criteria" heading, or say what to check in words.';
|
|
260
|
+
if (drafts.some((d) => d.shape === "bullet" && d.region !== "excluded"))
|
|
261
|
+
return `No acceptance criteria were recognised: it has a bulleted list but no "Acceptance criteria" heading, so the list was not taken as criteria. ${how}`;
|
|
262
|
+
if (drafts.some((d) => d.region === "excluded"))
|
|
263
|
+
return `No acceptance criteria were recognised: its only list is under a heading such as "Steps to reproduce", which describes a bug rather than what should be true. ${how}`;
|
|
264
|
+
return `No acceptance criteria were recognised: it has no Given/When/Then, checklist, numbered list or "Acceptance criteria" section. ${how}`;
|
|
265
|
+
}
|
|
266
|
+
/** The criteria of one ticket, numbered: a criterion the ticket labelled "AC3" keeps that number, the rest take the next free one in order. */
|
|
267
|
+
function numberCriteria(drafts) {
|
|
268
|
+
const kept = drafts.filter((d) => textOf(d) && !d.background).slice(0, MAX_CRITERIA_PER_TICKET);
|
|
269
|
+
const taken = new Set();
|
|
270
|
+
const labelOf = new Map();
|
|
271
|
+
for (const d of kept) {
|
|
272
|
+
if (d.label !== undefined && !taken.has(d.label)) {
|
|
273
|
+
taken.add(d.label);
|
|
274
|
+
labelOf.set(d, d.label);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
let next = 1;
|
|
278
|
+
return kept.map((d) => {
|
|
279
|
+
let n = labelOf.get(d);
|
|
280
|
+
if (n === undefined) {
|
|
281
|
+
while (taken.has(next))
|
|
282
|
+
next += 1;
|
|
283
|
+
n = next;
|
|
284
|
+
taken.add(n);
|
|
285
|
+
}
|
|
286
|
+
return { id: `AC${n}`, text: textOf(d), shape: d.shape };
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/** The key a ticket's heading carries, if any: "PROJ-12" or "#12". */
|
|
290
|
+
export function ticketKey(heading) {
|
|
291
|
+
const key = TICKET_KEY.exec(heading)?.[1];
|
|
292
|
+
if (key)
|
|
293
|
+
return key;
|
|
294
|
+
const issue = ISSUE_NUMBER.exec(heading)?.[1];
|
|
295
|
+
return issue ? `#${issue}` : undefined;
|
|
296
|
+
}
|
|
297
|
+
function titleOf(heading, key) {
|
|
298
|
+
let t = clean(heading);
|
|
299
|
+
if (key)
|
|
300
|
+
t = t.replace(key.startsWith("#") ? key : new RegExp(`\\[?${key}\\]?`), "");
|
|
301
|
+
return t
|
|
302
|
+
.replace(/\(\s*\)|\[\s*\]/g, "")
|
|
303
|
+
.replace(/^[\s:\-–—|]+|[\s:\-–—|]+$/g, "")
|
|
304
|
+
.trim();
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The tickets in a piece of text, with their criteria. Several tickets may
|
|
308
|
+
* be pasted at once: each starts at a heading, at a line opening with a key
|
|
309
|
+
* such as "PROJ-12", at a Gherkin `Feature:` or after a `---` rule.
|
|
310
|
+
*
|
|
311
|
+
* When a ticket has an "Acceptance criteria" (or "Definition of done",
|
|
312
|
+
* "Scenarios") section, only that section's items are criteria. Without one,
|
|
313
|
+
* Given/When/Then scenarios, checklists and "AC1:" lines count anywhere
|
|
314
|
+
* except under a heading such as "Steps to reproduce", and a numbered list
|
|
315
|
+
* counts at the top of the ticket. Plain bullets and prose never count on
|
|
316
|
+
* their own: the ticket is returned with no criteria and a note saying so.
|
|
317
|
+
*/
|
|
318
|
+
export function parseTickets(text, source = "pasted text") {
|
|
319
|
+
const body = text.slice(0, MAX_TICKET_TEXT);
|
|
320
|
+
// A Gherkin file, where "#" starts a comment rather than a heading: named .feature, or opening with Feature:.
|
|
321
|
+
const firstLine = body.split(/\r?\n/).find((l) => l.trim() && !/^\s*[#@]/.test(l)) ?? "";
|
|
322
|
+
const gherkin = source.toLowerCase().endsWith(".feature") || FEATURE.test(firstLine.trim());
|
|
323
|
+
const raws = splitTickets(body, gherkin);
|
|
324
|
+
const tickets = [];
|
|
325
|
+
const used = new Set();
|
|
326
|
+
let auto = 0;
|
|
327
|
+
for (const [index, raw] of raws.entries()) {
|
|
328
|
+
const { drafts, title: prose } = draftsOf(raw.lines, gherkin);
|
|
329
|
+
if (raws.length > 1 && drafts.length === 0) {
|
|
330
|
+
// A heading with nothing under it (an epic over its stories) is not a ticket to answer,
|
|
331
|
+
// and neither is a line of introduction before the first ticket.
|
|
332
|
+
if (raw.heading !== undefined && !raw.lines.some((l) => l.trim()))
|
|
333
|
+
continue;
|
|
334
|
+
if (raw.heading === undefined && index === 0)
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const inAc = drafts.filter((d) => d.region === "ac");
|
|
338
|
+
const chosen = inAc.length > 0 ? inAc : drafts.filter(countsWithoutHeading);
|
|
339
|
+
const criteria = numberCriteria(chosen);
|
|
340
|
+
const key = raw.heading ? ticketKey(raw.heading) : undefined;
|
|
341
|
+
auto += 1;
|
|
342
|
+
let id = key ?? `T${auto}`;
|
|
343
|
+
for (let n = 2; used.has(id); n += 1)
|
|
344
|
+
id = `${key ?? `T${auto}`}~${n}`;
|
|
345
|
+
used.add(id);
|
|
346
|
+
const title = (raw.heading ? titleOf(raw.heading, key) : "") || (prose ?? "").slice(0, 120) || `Ticket ${auto}`;
|
|
347
|
+
const cut = chosen.filter((d) => textOf(d) && !d.background).length > MAX_CRITERIA_PER_TICKET;
|
|
348
|
+
const note = criteria.length === 0 ? noCriteriaNote(drafts) : cut ? `Only the first ${MAX_CRITERIA_PER_TICKET} criteria were read.` : undefined;
|
|
349
|
+
tickets.push({ id, title: title.slice(0, 200), criteria, source, ...(note ? { note } : {}) });
|
|
350
|
+
if (tickets.length >= MAX_TICKETS)
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
return tickets;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Tickets read in this run so far, with a new reading added. A ticket with the
|
|
357
|
+
* same key replaces the earlier reading; one with no key whose title and
|
|
358
|
+
* criteria are the same as one already read is that ticket again; any other
|
|
359
|
+
* keyless ticket takes the next free "T" number, so "T1" in a second paste is
|
|
360
|
+
* not the first paste's T1.
|
|
361
|
+
*/
|
|
362
|
+
export function addReading(existing, parsed, at) {
|
|
363
|
+
const out = [...existing];
|
|
364
|
+
const added = [];
|
|
365
|
+
const same = (a, b) => a.title === b.title && a.criteria.length === b.criteria.length && a.criteria.every((c, i) => c.text === b.criteria[i].text);
|
|
366
|
+
const isAuto = (id) => /^T\d+(~\d+)?$/.test(id);
|
|
367
|
+
for (const t of parsed) {
|
|
368
|
+
const index = isAuto(t.id) ? out.findIndex((e) => isAuto(e.id) && same(e, t)) : out.findIndex((e) => e.id === t.id);
|
|
369
|
+
let id = t.id;
|
|
370
|
+
if (index >= 0)
|
|
371
|
+
id = out[index].id;
|
|
372
|
+
else if (isAuto(t.id)) {
|
|
373
|
+
let n = 1;
|
|
374
|
+
while (out.some((e) => e.id === `T${n}`))
|
|
375
|
+
n += 1;
|
|
376
|
+
id = `T${n}`;
|
|
377
|
+
}
|
|
378
|
+
const stored = { ...t, id, loadedAt: at };
|
|
379
|
+
if (index >= 0)
|
|
380
|
+
out[index] = stored;
|
|
381
|
+
else
|
|
382
|
+
out.push(stored);
|
|
383
|
+
added.push(stored);
|
|
384
|
+
}
|
|
385
|
+
return { tickets: out.slice(-MAX_TICKETS), added };
|
|
386
|
+
}
|
|
387
|
+
/** A criterion by what the agent called it: "AC2", "ac 2", "AC-2" or "2". */
|
|
388
|
+
export function findCriterion(ticket, name) {
|
|
389
|
+
const n = /^\s*(?:ac\s*[-#]?\s*)?(\d{1,3})\s*$/i.exec(name)?.[1];
|
|
390
|
+
return n ? ticket.criteria.find((c) => c.id === `AC${Number(n)}`) : undefined;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* A verdict made into a record, or the reason it is refused. It checks the
|
|
394
|
+
* record is whole, never what it says: a fail names the findings that show
|
|
395
|
+
* it, and they exist; a criterion not tested says which kind of reason; a
|
|
396
|
+
* claim that observe blocked it comes from a session in observe.
|
|
397
|
+
*/
|
|
398
|
+
export function judgeCriterion(input, ctx) {
|
|
399
|
+
const ticket = ctx.tickets.find((t) => t.id === input.ticket.trim());
|
|
400
|
+
if (!ticket) {
|
|
401
|
+
const known = ctx.tickets.map((t) => t.id);
|
|
402
|
+
return {
|
|
403
|
+
ok: false,
|
|
404
|
+
reason: known.length
|
|
405
|
+
? `no ticket ${JSON.stringify(input.ticket)} was read in this run. The tickets read are: ${known.join(", ")}`
|
|
406
|
+
: `no tickets have been read in this run. Read them with scout_tickets first`,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const criterion = findCriterion(ticket, input.criterion);
|
|
410
|
+
if (!criterion) {
|
|
411
|
+
return {
|
|
412
|
+
ok: false,
|
|
413
|
+
reason: ticket.criteria.length
|
|
414
|
+
? `ticket ${ticket.id} has no criterion ${JSON.stringify(input.criterion)}. Its criteria are ${ticket.criteria.map((c) => c.id).join(", ")}`
|
|
415
|
+
: `ticket ${ticket.id} has no recognised criteria, so there is nothing to judge. Ask the person for its criteria rather than inventing them`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
if (!(input.confidence >= 0 && input.confidence <= 1))
|
|
419
|
+
return { ok: false, reason: "confidence is a number from 0 to 1" };
|
|
420
|
+
const reason = input.reason.trim().replace(/\s+/g, " ");
|
|
421
|
+
if (!reason)
|
|
422
|
+
return { ok: false, reason: "say in a sentence what you saw, or why it could not be tried" };
|
|
423
|
+
const findings = [...new Set((input.findings ?? []).map((f) => f.trim()).filter(Boolean))];
|
|
424
|
+
if (findings.length > MAX_CRITERION_FINDINGS)
|
|
425
|
+
return { ok: false, reason: `at most ${MAX_CRITERION_FINDINGS} findings per criterion` };
|
|
426
|
+
const unknown = findings.filter((id) => !ctx.findings.some((f) => f.id === id));
|
|
427
|
+
if (unknown.length > 0)
|
|
428
|
+
return { ok: false, reason: `no finding with id ${unknown.join(", ")}. File it with scout_finding first and pass the id it returns` };
|
|
429
|
+
if (input.verdict === "fail" && findings.length === 0) {
|
|
430
|
+
return {
|
|
431
|
+
ok: false,
|
|
432
|
+
reason: "a failing criterion links to the findings that show it failing. File each with scout_finding and pass their ids in `findings`",
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
if (input.verdict === "not-tested") {
|
|
436
|
+
if (findings.length > 0)
|
|
437
|
+
return { ok: false, reason: "a criterion that was not tested has no findings to link. If a finding decides it, it passed or failed" };
|
|
438
|
+
if (!input.untestedBecause) {
|
|
439
|
+
return { ok: false, reason: `say why it was not tested in \`untestedBecause\`: ${NOT_TESTED_REASONS.join(", ")}` };
|
|
440
|
+
}
|
|
441
|
+
if (input.untestedBecause === "observe-blocked" && ctx.mode !== "observe") {
|
|
442
|
+
return {
|
|
443
|
+
ok: false,
|
|
444
|
+
reason: `this session runs in ${ctx.mode} mode, so observe blocked nothing. Use "no-access" or "out-of-scope", or test it`,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
else if (input.untestedBecause) {
|
|
449
|
+
return { ok: false, reason: "`untestedBecause` is only for a criterion that was not tested" };
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
ok: true,
|
|
453
|
+
record: {
|
|
454
|
+
ticket: ticket.id,
|
|
455
|
+
criterion: criterion.id,
|
|
456
|
+
text: criterion.text,
|
|
457
|
+
verdict: input.verdict,
|
|
458
|
+
findings,
|
|
459
|
+
confidence: Math.round(input.confidence * 100) / 100,
|
|
460
|
+
reason: reason.slice(0, MAX_REASON),
|
|
461
|
+
...(input.verdict === "not-tested" ? { untestedBecause: input.untestedBecause } : {}),
|
|
462
|
+
session: ctx.session,
|
|
463
|
+
at: ctx.at,
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
const verdictKey = (v) => `${v.ticket}\u0000${v.criterion}\u0000${v.session}`;
|
|
468
|
+
/** Verdicts with one added: a session judging a criterion again replaces its own earlier verdict. */
|
|
469
|
+
export function addVerdict(list, record) {
|
|
470
|
+
return [...list.filter((v) => verdictKey(v) !== verdictKey(record)), record].slice(-MAX_CRITERION_VERDICTS);
|
|
471
|
+
}
|
|
472
|
+
/** Two processes' tickets and verdicts as one: per ticket and per session's verdict, the later wins. Idempotent. */
|
|
473
|
+
export function mergeTicketData(mine, theirs) {
|
|
474
|
+
const byTicket = new Map();
|
|
475
|
+
for (const t of [...(theirs.tickets ?? []), ...(mine.tickets ?? [])]) {
|
|
476
|
+
const other = byTicket.get(t.id);
|
|
477
|
+
if (!other || t.loadedAt >= other.loadedAt)
|
|
478
|
+
byTicket.set(t.id, t);
|
|
479
|
+
}
|
|
480
|
+
const byVerdict = new Map();
|
|
481
|
+
for (const v of [...(theirs.criterionVerdicts ?? []), ...(mine.criterionVerdicts ?? [])]) {
|
|
482
|
+
const other = byVerdict.get(verdictKey(v));
|
|
483
|
+
if (!other || v.at >= other.at)
|
|
484
|
+
byVerdict.set(verdictKey(v), v);
|
|
485
|
+
}
|
|
486
|
+
const tickets = [...byTicket.values()].sort((a, b) => a.loadedAt.localeCompare(b.loadedAt)).slice(-MAX_TICKETS_KEPT);
|
|
487
|
+
const criterionVerdicts = [...byVerdict.values()].sort((a, b) => a.at.localeCompare(b.at)).slice(-MAX_CRITERION_VERDICTS);
|
|
488
|
+
return { ...(tickets.length ? { tickets } : {}), ...(criterionVerdicts.length ? { criterionVerdicts } : {}) };
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* One criterion's answer from every verdict recorded on it. A fail from any
|
|
492
|
+
* session decides it, since one session seeing it fail is the evidence; a
|
|
493
|
+
* pass decides it over not tested; with no verdict it is not tested, and
|
|
494
|
+
* says nobody judged it rather than giving a reason nobody gave.
|
|
495
|
+
*/
|
|
496
|
+
export function answerCriterion(criterion, verdicts) {
|
|
497
|
+
const fails = verdicts.filter((v) => v.verdict === "fail");
|
|
498
|
+
const passes = verdicts.filter((v) => v.verdict === "pass");
|
|
499
|
+
const deciding = fails.length ? fails : passes.length ? passes : verdicts.slice(-1);
|
|
500
|
+
if (deciding.length === 0) {
|
|
501
|
+
return { criterion, verdict: "not-tested", untestedBecause: "not-judged", findings: [], reasons: [], sessions: [], disputed: false };
|
|
502
|
+
}
|
|
503
|
+
const verdict = deciding[0].verdict;
|
|
504
|
+
return {
|
|
505
|
+
criterion,
|
|
506
|
+
verdict,
|
|
507
|
+
...(verdict === "not-tested" ? { untestedBecause: deciding[0].untestedBecause } : {}),
|
|
508
|
+
findings: [...new Set(deciding.flatMap((v) => v.findings))],
|
|
509
|
+
confidence: Math.max(...deciding.map((v) => v.confidence)),
|
|
510
|
+
reasons: [...new Set(deciding.map((v) => v.reason))],
|
|
511
|
+
sessions: [...new Set(deciding.map((v) => v.session))],
|
|
512
|
+
disputed: fails.length > 0 && passes.length > 0,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
/** Every criterion of a ticket, answered. */
|
|
516
|
+
export function answerTicket(ticket, verdicts) {
|
|
517
|
+
return ticket.criteria.map((c) => answerCriterion(c,
|
|
518
|
+
// A verdict on a criterion whose words have changed since was about something else.
|
|
519
|
+
verdicts.filter((v) => v.ticket === ticket.id && v.criterion === c.id && (v.text === undefined || v.text === c.text))));
|
|
520
|
+
}
|
|
521
|
+
// ── Reporting ─────────────────────────────────────────────────────────────────
|
|
522
|
+
/** Why a criterion was not tested, in plain words. */
|
|
523
|
+
export const NOT_TESTED_WORDS = {
|
|
524
|
+
"no-access": "the account the run used could not reach this part of the site",
|
|
525
|
+
"observe-blocked": "the run was only allowed to look, not to send changes, so this could not be tried",
|
|
526
|
+
"out-of-scope": "it is outside what this run could check",
|
|
527
|
+
"not-judged": "no result was recorded for it",
|
|
528
|
+
};
|
|
529
|
+
const count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
530
|
+
/** "2 passed, 1 failed and 1 was not tested", leaving out what is zero. */
|
|
531
|
+
export function tally(answers) {
|
|
532
|
+
const n = (v) => answers.filter((a) => a.verdict === v).length;
|
|
533
|
+
const parts = [
|
|
534
|
+
n("pass") ? `${n("pass")} passed` : "",
|
|
535
|
+
n("fail") ? `${n("fail")} failed` : "",
|
|
536
|
+
n("not-tested") ? `${n("not-tested")} ${n("not-tested") === 1 ? "was" : "were"} not tested` : "",
|
|
537
|
+
].filter(Boolean);
|
|
538
|
+
return parts.length <= 1 ? parts.join("") : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
|
|
539
|
+
}
|
|
540
|
+
/** A table cell: the HTML renderer splits rows on every "|", so none may be inside one. */
|
|
541
|
+
const cell = (text) => text.replace(/\|/g, "/").replace(/\r?\n/g, " ");
|
|
542
|
+
function findingWords(id, input) {
|
|
543
|
+
const f = input.findings.find((x) => x.id === id);
|
|
544
|
+
if (!f)
|
|
545
|
+
return `finding ${id}, which is no longer in the project's memory`;
|
|
546
|
+
const n = input.problemNumber?.get(id);
|
|
547
|
+
return n ? `"${f.title}" (problem ${n} below)` : `"${f.title}" (finding ${id})`;
|
|
548
|
+
}
|
|
549
|
+
/** The plain verdict, with "unsure" when the agent was. */
|
|
550
|
+
function plainResult(a, input) {
|
|
551
|
+
const unsure = a.confidence !== undefined && a.confidence < UNSURE_BELOW ? " (unsure)" : "";
|
|
552
|
+
if (a.verdict === "pass")
|
|
553
|
+
return `Passed${unsure}`;
|
|
554
|
+
if (a.verdict === "fail") {
|
|
555
|
+
const shown = a.findings.map((id) => findingWords(id, input)).join(", ");
|
|
556
|
+
return `Failed${unsure}: ${shown}${a.disputed ? ". Another check saw it pass" : ""}`;
|
|
557
|
+
}
|
|
558
|
+
return `Not tested: ${NOT_TESTED_WORDS[a.untestedBecause ?? "not-judged"]}`;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* The tickets in plain words, for the top of the plain section: for each
|
|
562
|
+
* ticket a line of totals and a table of its criteria, then the pictures of
|
|
563
|
+
* the findings that show a criterion failing. Empty when no ticket was read.
|
|
564
|
+
*/
|
|
565
|
+
export function formatTicketsPlain(input) {
|
|
566
|
+
if (input.tickets.length === 0)
|
|
567
|
+
return [];
|
|
568
|
+
const all = input.tickets.flatMap((t) => answerTicket(t, input.verdicts));
|
|
569
|
+
const lines = [
|
|
570
|
+
`### The tickets`,
|
|
571
|
+
``,
|
|
572
|
+
all.length > 0
|
|
573
|
+
? `This run was given ${count(input.tickets.length, "ticket")} with ${count(all.length, "acceptance criterion", "acceptance criteria")}: ${tally(all)}.`
|
|
574
|
+
: `This run was given ${count(input.tickets.length, "ticket")}, and no acceptance criteria could be found in ${input.tickets.length === 1 ? "it" : "them"}.`,
|
|
575
|
+
``,
|
|
576
|
+
];
|
|
577
|
+
for (const t of input.tickets) {
|
|
578
|
+
const answers = answerTicket(t, input.verdicts);
|
|
579
|
+
lines.push(`#### ${t.id === t.title ? t.id : `${t.id}: ${t.title}`}`, ``);
|
|
580
|
+
if (answers.length === 0) {
|
|
581
|
+
lines.push(`${t.note ?? "No acceptance criteria were recognised."} Nothing was checked against this ticket.`, ``);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
// With one ticket the line above already gave its totals.
|
|
585
|
+
if (input.tickets.length > 1)
|
|
586
|
+
lines.push(`${tally(answers)[0].toUpperCase()}${tally(answers).slice(1)}.`, ``);
|
|
587
|
+
lines.push(`| | Acceptance criterion | Result |`, `|---|---|---|`);
|
|
588
|
+
for (const a of answers)
|
|
589
|
+
lines.push(`| ${a.criterion.id} | ${cell(a.criterion.text)} | ${cell(plainResult(a, input))} |`);
|
|
590
|
+
lines.push(``);
|
|
591
|
+
// The pictures of what failed, under the table that names it.
|
|
592
|
+
for (const a of answers.filter((x) => x.verdict === "fail")) {
|
|
593
|
+
for (const id of a.findings) {
|
|
594
|
+
const f = input.findings.find((x) => x.id === id);
|
|
595
|
+
const picture = f && input.pictureOf?.(f);
|
|
596
|
+
if (picture)
|
|
597
|
+
lines.push(`What the page showed when ${a.criterion.id} failed:`, ``, `![${a.criterion.id} failed: ${f.title.replace(/[[\]]/g, "")}](${picture})`, ``);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return lines;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* The tickets for the technical report: per ticket, each criterion's verdict
|
|
605
|
+
* with its confidence, the finding ids, the reason as recorded and the
|
|
606
|
+
* sessions that judged it. Empty when no ticket was read.
|
|
607
|
+
*/
|
|
608
|
+
export function formatTicketsTechnical(input) {
|
|
609
|
+
if (input.tickets.length === 0)
|
|
610
|
+
return [];
|
|
611
|
+
const lines = [
|
|
612
|
+
`## Acceptance criteria`,
|
|
613
|
+
``,
|
|
614
|
+
`Each verdict is the agent's judgement, recorded with \`scout_criterion\` and its confidence; the link from a criterion to a finding is that judgement, not a match on words. A fail from any session decides a criterion, and a criterion no session judged is listed as not judged.`,
|
|
615
|
+
``,
|
|
616
|
+
];
|
|
617
|
+
for (const t of input.tickets) {
|
|
618
|
+
lines.push(`### ${t.id}: ${t.title}`, ``, `Read from ${t.source}.`, ``);
|
|
619
|
+
if (t.criteria.length === 0) {
|
|
620
|
+
lines.push(t.note ?? "No acceptance criteria were recognised.", ``);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
lines.push(`| Id | Criterion | Verdict | Confidence | Findings | Why | Judged by |`, `|---|---|---|---:|---|---|---|`);
|
|
624
|
+
for (const a of answerTicket(t, input.verdicts)) {
|
|
625
|
+
const verdict = a.verdict === "not-tested" ? `not tested (${a.untestedBecause})` : a.verdict + (a.disputed ? " (disputed)" : "");
|
|
626
|
+
lines.push(`| ${a.criterion.id} | ${cell(a.criterion.text)} | ${verdict} | ${a.confidence === undefined ? "—" : a.confidence.toFixed(2)} | ${a.findings.map((id) => `\`${id}\``).join(" ") || "—"} | ${cell(a.reasons.join(" / ")) || "—"} | ${cell(a.sessions.join(", ")) || "—"} |`);
|
|
627
|
+
}
|
|
628
|
+
lines.push(``);
|
|
629
|
+
}
|
|
630
|
+
return lines;
|
|
631
|
+
}
|
|
632
|
+
/** One line for the report's summary: how the criteria came out. */
|
|
633
|
+
export function ticketSummaryLine(tickets, verdicts) {
|
|
634
|
+
if (tickets.length === 0)
|
|
635
|
+
return null;
|
|
636
|
+
const all = tickets.flatMap((t) => answerTicket(t, verdicts));
|
|
637
|
+
return `TICKETS: ${count(tickets.length, "ticket")}, ${count(all.length, "criterion", "criteria")}${all.length ? ` — ${tally(all)}` : ""}`;
|
|
638
|
+
}
|
|
639
|
+
/** What scout_tickets says after reading: each ticket's criteria with their ids, and what to do next. */
|
|
640
|
+
export function formatReading(added) {
|
|
641
|
+
const lines = [];
|
|
642
|
+
const total = added.reduce((n, t) => n + t.criteria.length, 0);
|
|
643
|
+
lines.push(`Read ${count(added.length, "ticket")} with ${count(total, "acceptance criterion", "acceptance criteria")}.`, ``);
|
|
644
|
+
for (const t of added) {
|
|
645
|
+
lines.push(`${t.id}: ${t.title} (from ${t.source})`);
|
|
646
|
+
if (t.criteria.length === 0)
|
|
647
|
+
lines.push(` ⚠ ${t.note ?? "No acceptance criteria were recognised."} Ask the person for them; do not make them up.`);
|
|
648
|
+
for (const c of t.criteria)
|
|
649
|
+
lines.push(` ${c.id} [${c.shape}] ${c.text}`);
|
|
650
|
+
lines.push(``);
|
|
651
|
+
}
|
|
652
|
+
if (total > 0) {
|
|
653
|
+
lines.push(`Next: plan the run against these criteria. Measure each with a journey whose goal names it (scout_journey {action:"start", goal:"${added.find((t) => t.criteria.length).id} AC1: …"}), and in a parallel run pass them as scout_lane_brief's goal, which then lists them in every lane's brief.`, `When a criterion is decided, record it: scout_criterion {ticket, criterion, verdict:"pass"|"fail"|"not-tested", findings, confidence, reason}. A fail names the findings that show it (file them first). A criterion you could not try says why in untestedBecause: ${NOT_TESTED_REASONS.join(", ")}. The link from a criterion to a finding is your judgement: state your confidence honestly.`);
|
|
654
|
+
}
|
|
655
|
+
return lines.join("\n").trimEnd();
|
|
656
|
+
}
|
|
657
|
+
/** The criteria a lane brief lists when the run was given tickets. */
|
|
658
|
+
export function formatCriteriaForLanes(tickets) {
|
|
659
|
+
const withCriteria = tickets.filter((t) => t.criteria.length > 0);
|
|
660
|
+
if (withCriteria.length === 0)
|
|
661
|
+
return "";
|
|
662
|
+
const lines = [
|
|
663
|
+
`ACCEPTANCE CRITERIA this run answers. A lane whose routes decide one records it with scout_criterion {ticket, criterion, verdict, findings, confidence, reason} before reporting; a lane that cannot reach one leaves it to the lane that owns it.`,
|
|
664
|
+
];
|
|
665
|
+
for (const t of withCriteria) {
|
|
666
|
+
lines.push(` ${t.id}: ${t.title}`);
|
|
667
|
+
for (const c of t.criteria)
|
|
668
|
+
lines.push(` ${c.id} ${c.text}`);
|
|
669
|
+
}
|
|
670
|
+
return lines.join("\n");
|
|
671
|
+
}
|