@sentinelqa/playwright-reporter 0.1.47 → 0.1.50

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.
@@ -3,24 +3,213 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.buildQuickDiagnosis = exports.summarizeSignal = exports.buildSimilarityKey = exports.buildDebugSummary = exports.describeFailure = exports.parseFailureFacts = exports.collectFailureFacts = void 0;
6
+ exports.buildQuickDiagnosis = exports.summarizeSignal = exports.buildSimilarityKey = exports.buildDebugSummary = exports.collectFailureFacts = exports.parseFailureFacts = exports.describeFailure = void 0;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const node_child_process_1 = require("node:child_process");
10
+ const REPORTER_HISTORY_DIR = node_path_1.default.join(".sentinel", "reporter-history");
11
+ const stripAnsi = (value) => value.replace(/\u001b\[[0-9;]*m/g, "");
12
+ const normalizeTitleParts = (value) => value
13
+ .split(" > ")
14
+ .map((part) => part.trim())
15
+ .filter(Boolean);
16
+ const cleanTitleParts = (parts) => {
17
+ const withoutUnnamed = parts.filter((part) => part && part !== "Unnamed test");
18
+ return withoutUnnamed.length ? withoutUnnamed : parts.filter(Boolean);
19
+ };
20
+ const pickHumanTitleParts = (value) => cleanTitleParts(normalizeTitleParts(value)).slice(-2);
21
+ const formatTitle = (title) => pickHumanTitleParts(title).join(" > ") || title;
22
+ const shortenTitle = (value) => {
23
+ const parts = pickHumanTitleParts(value);
24
+ return parts[parts.length - 1] || value;
25
+ };
26
+ const normalizePath = (value) => (value || "").replace(/\\/g, "/").toLowerCase().trim();
27
+ const basename = (value) => {
28
+ const normalized = normalizePath(value);
29
+ if (!normalized)
30
+ return "";
31
+ return normalized.split("/").pop() || normalized;
32
+ };
33
+ const dirnameToken = (value) => {
34
+ const normalized = normalizePath(value);
35
+ if (!normalized || !normalized.includes("/"))
36
+ return "";
37
+ const parts = normalized.split("/").filter(Boolean);
38
+ return parts.length > 1 ? parts[parts.length - 2] : "";
39
+ };
40
+ const gitOutput = (args) => {
41
+ try {
42
+ return (0, node_child_process_1.execFileSync)("git", args, {
43
+ cwd: process.cwd(),
44
+ stdio: ["ignore", "pipe", "ignore"],
45
+ encoding: "utf8"
46
+ }).trim();
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ };
52
+ const currentSha = () => (process.env.GITHUB_SHA ||
53
+ process.env.CI_COMMIT_SHA ||
54
+ process.env.VERCEL_GIT_COMMIT_SHA ||
55
+ gitOutput(["rev-parse", "HEAD"]) ||
56
+ "").trim();
57
+ const currentBranch = () => (process.env.GITHUB_REF_NAME ||
58
+ process.env.CI_COMMIT_REF_NAME ||
59
+ process.env.CI_COMMIT_BRANCH ||
60
+ process.env.BRANCH_NAME ||
61
+ gitOutput(["rev-parse", "--abbrev-ref", "HEAD"]) ||
62
+ "main").trim();
8
63
  const normalizeMessageFingerprint = (message) => stripAnsi(message)
9
64
  .split(/\r?\n/)
10
65
  .map((line) => line.trim())
11
66
  .filter(Boolean)
12
- .slice(0, 3)
67
+ .slice(0, 4)
13
68
  .join(" | ")
14
69
  .replace(/\b\d+ms\b/gi, "<ms>")
15
70
  .replace(/:\d+:\d+/g, ":<line>:<col>")
16
71
  .replace(/\s+/g, " ")
17
- .slice(0, 200);
18
- const stripAnsi = (value) => value.replace(/\u001b\[[0-9;]*m/g, "");
72
+ .slice(0, 240);
73
+ const readJson = (filePath) => {
74
+ if (!node_fs_1.default.existsSync(filePath))
75
+ return null;
76
+ try {
77
+ return JSON.parse(node_fs_1.default.readFileSync(filePath, "utf8"));
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ };
83
+ const isMeaningfulHistorySnapshot = (snapshot) => {
84
+ if (!snapshot)
85
+ return false;
86
+ if (typeof snapshot.totalTests === 'number' && snapshot.totalTests <= 0)
87
+ return false;
88
+ if (typeof snapshot.failedCount === 'number' && typeof snapshot.passedCount === 'number') {
89
+ if (snapshot.failedCount === 0 && snapshot.passedCount === 0)
90
+ return false;
91
+ }
92
+ return true;
93
+ };
94
+ const listHistorySnapshots = (branch) => {
95
+ const dir = node_path_1.default.resolve(process.cwd(), REPORTER_HISTORY_DIR);
96
+ if (!node_fs_1.default.existsSync(dir))
97
+ return [];
98
+ return node_fs_1.default
99
+ .readdirSync(dir)
100
+ .filter((file) => file.endsWith(".json"))
101
+ .map((file) => readJson(node_path_1.default.join(dir, file)))
102
+ .filter((value) => Boolean(value))
103
+ .filter((snapshot) => (snapshot.branch || branch) === branch)
104
+ .filter((snapshot) => isMeaningfulHistorySnapshot(snapshot))
105
+ .sort((a, b) => String(b.generatedAt || "").localeCompare(String(a.generatedAt || "")));
106
+ };
107
+ const getLastPassingSnapshot = () => {
108
+ const branch = currentBranch();
109
+ const sha = currentSha();
110
+ const snapshots = listHistorySnapshots(branch);
111
+ const sameCommitFailure = sha
112
+ ? snapshots.find((snapshot) => snapshot.gitSha === sha &&
113
+ Number(snapshot.failedCount || 0) > 0)
114
+ : null;
115
+ if (sameCommitFailure) {
116
+ return {
117
+ sha: sameCommitFailure.gitSha || "unknown",
118
+ reason: "current commit has already failed in local history"
119
+ };
120
+ }
121
+ const sameCommitPass = sha
122
+ ? snapshots.find((snapshot) => snapshot.gitSha === sha &&
123
+ Number(snapshot.failedCount || 0) === 0 &&
124
+ Number(snapshot.passedCount || 0) > 0)
125
+ : null;
126
+ if (sameCommitPass) {
127
+ return {
128
+ sha: sameCommitPass.gitSha || "unknown",
129
+ reason: "current commit already passed locally; no new code change separates that pass from this failure"
130
+ };
131
+ }
132
+ const match = snapshots.find((snapshot) => Number(snapshot.failedCount || 0) === 0 &&
133
+ Number(snapshot.passedCount || 0) > 0 &&
134
+ Number(snapshot.totalTests || 0) > 0 &&
135
+ snapshot.gitSha !== sha &&
136
+ snapshot.gitSha &&
137
+ snapshot.gitSha !== "unknown");
138
+ if (!match?.gitSha)
139
+ return null;
140
+ return { sha: match.gitSha, reason: null };
141
+ };
142
+ const resolveAttachmentPath = (filePath) => {
143
+ if (!filePath)
144
+ return null;
145
+ return node_path_1.default.isAbsolute(filePath) ? filePath : node_path_1.default.resolve(process.cwd(), filePath);
146
+ };
147
+ const readAttachmentJson = (result, name) => {
148
+ const attachment = (result?.attachments || []).find((item) => item?.name === name && item.path);
149
+ const resolved = resolveAttachmentPath(attachment?.path);
150
+ return resolved ? readJson(resolved) : null;
151
+ };
152
+ const extractFocusLineFromSnippet = (snippet) => {
153
+ if (!snippet)
154
+ return null;
155
+ const lines = snippet.split(/\r?\n/);
156
+ const marked = lines.find((line) => /^\s*>\s*\d+\s*\|/.test(line));
157
+ const preferred = marked || lines.find((line) => /\S/.test(line));
158
+ if (!preferred)
159
+ return null;
160
+ return preferred
161
+ .replace(/^\s*>\s*/, "")
162
+ .replace(/^\s*\d+\s*\|\s*/, "")
163
+ .trim() || null;
164
+ };
165
+ const parseCommitLine = (line) => {
166
+ const [sha, author, message] = line.split("\u001f");
167
+ if (!sha)
168
+ return null;
169
+ const changedFilesRaw = gitOutput(["show", "--pretty=", "--name-only", sha]) || "";
170
+ return {
171
+ sha,
172
+ author: author || "Unknown",
173
+ message: message || "Recent commit",
174
+ changedFiles: changedFilesRaw
175
+ .split(/\r?\n/)
176
+ .map((item) => item.trim())
177
+ .filter(Boolean)
178
+ .slice(0, 80)
179
+ };
180
+ };
181
+ const getCommitWindow = (limit = 24) => {
182
+ const sha = currentSha();
183
+ const lastPassing = getLastPassingSnapshot();
184
+ const lastPassingSha = lastPassing?.sha || null;
185
+ const pretty = `--pretty=format:%H%x1f%an%x1f%s`;
186
+ if (!sha || !lastPassingSha || lastPassingSha === sha) {
187
+ return { commits: [], trusted: false, reason: lastPassing?.reason || "no trusted last passing commit available yet" };
188
+ }
189
+ const mergeBase = gitOutput(["merge-base", lastPassingSha, sha]);
190
+ if (!mergeBase || mergeBase !== lastPassingSha) {
191
+ return { commits: [], trusted: false, reason: "local git history does not contain a safe pass-to-fail commit range" };
192
+ }
193
+ const log = gitOutput(["log", "--first-parent", "--ancestry-path", `${lastPassingSha}..${sha}`, pretty]) || "";
194
+ const commits = log
195
+ .split(/\r?\n/)
196
+ .map((line) => parseCommitLine(line))
197
+ .filter((value) => Boolean(value))
198
+ .slice(0, limit);
199
+ if (!commits.length) {
200
+ return { commits: [], trusted: false, reason: "no commits found between the last pass and current failure" };
201
+ }
202
+ return { commits, trusted: true, reason: null };
203
+ };
204
+ const normalizeStatus = (status) => {
205
+ if (status === "failed" || status === "timedOut" || status === "interrupted")
206
+ return "failed";
207
+ if (status === "passed" || status === "flaky")
208
+ return "passed";
209
+ return "skipped";
210
+ };
19
211
  const toMessage = (result) => {
20
- const direct = result.error?.message ||
21
- result.error?.stack ||
22
- result.error?.value ||
23
- null;
212
+ const direct = result.error?.message || result.error?.stack || result.error?.value || null;
24
213
  if (direct)
25
214
  return stripAnsi(String(direct));
26
215
  const first = result.errors?.find(Boolean);
@@ -28,7 +217,10 @@ const toMessage = (result) => {
28
217
  };
29
218
  const classifySignal = (message) => {
30
219
  const lower = message.toLowerCase();
31
- if (/expected substring|expected string|received string|tohavetext|tocontaintext/.test(lower)) {
220
+ if (/browser has been closed|target page, context or browser has been closed|crash|page crashed|browser disconnected/.test(lower)) {
221
+ return "infra";
222
+ }
223
+ if (/expected substring|expected string|received string|tohavetext|tocontaintext|tohavevalue|tobechecked/.test(lower)) {
32
224
  return "assertion_mismatch";
33
225
  }
34
226
  if (/timeout|timed out|waiting for/.test(lower))
@@ -39,10 +231,12 @@ const classifySignal = (message) => {
39
231
  if (/not visible|not enabled|not stable|intercepts pointer events|not actionable/.test(lower)) {
40
232
  return "actionability";
41
233
  }
42
- if (/status\s*[45]\d{2}|net::|failed to fetch|network|request failed/.test(lower)) {
234
+ if (/status\s*[45]\d{2}|net::|failed to fetch|network|request failed|socket hang up|econnreset|503|502|500/.test(lower)) {
43
235
  return "network";
44
236
  }
45
- if (/typeerror|referenceerror|syntaxerror|unhandled/.test(lower))
237
+ if (/simulated flaky retry|retry guard|flaky retry/.test(lower))
238
+ return "runtime";
239
+ if (/^error:|\nerror:|typeerror|referenceerror|syntaxerror|unhandled/.test(lower))
46
240
  return "runtime";
47
241
  return "unknown";
48
242
  };
@@ -77,82 +271,282 @@ const extractLastUrl = (message) => {
77
271
  const match = message.match(/https?:\/\/[^\s)"']+/i);
78
272
  return match?.[0] || null;
79
273
  };
80
- const signalSummary = (signal) => {
81
- switch (signal) {
82
- case "timeout":
83
- return "timeout while waiting for UI or network conditions";
84
- case "assertion_mismatch":
85
- return "assertion mismatch between expected and rendered UI state";
86
- case "locator_not_found":
87
- return "missing or changed locator";
88
- case "actionability":
89
- return "target element was not actionable";
90
- case "network":
91
- return "network or API failure";
92
- case "runtime":
93
- return "frontend runtime error";
94
- default:
95
- return "failure signal could not be classified cleanly";
274
+ const extractApiHint = (message, codeContext) => {
275
+ if (codeContext?.apiCall)
276
+ return codeContext.apiCall;
277
+ const url = extractLastUrl(message);
278
+ if (url)
279
+ return url;
280
+ const apiMatch = message.match(/\/(api|graphql|rest)\/[^\s)"']+/i);
281
+ return apiMatch?.[0] || null;
282
+ };
283
+ const extractStackLocation = (message) => {
284
+ const lines = stripAnsi(message).split(/\r?\n/);
285
+ for (const line of lines) {
286
+ const match = line.match(/((?:[A-Za-z]:)?[^:\n]+\.[cm]?[jt]sx?):(\d+):(\d+)/);
287
+ if (!match)
288
+ continue;
289
+ const file = match[1]?.trim();
290
+ if (!file || file.includes("node_modules"))
291
+ continue;
292
+ const lineNumber = Number(match[2]);
293
+ const columnNumber = Number(match[3]);
294
+ return {
295
+ file,
296
+ line: Number.isFinite(lineNumber) ? lineNumber : null,
297
+ column: Number.isFinite(columnNumber) ? columnNumber : null
298
+ };
96
299
  }
300
+ return null;
301
+ };
302
+ const loadCodeContext = (result) => readAttachmentJson(result, "sentinel-code-context");
303
+ const loadDomCapture = (result) => readAttachmentJson(result, "sentinel-dom-capture");
304
+ const inferLikelyFile = (file, codeContext) => codeContext?.file || file || null;
305
+ const inferLikelyModule = (file, locator, codeContext) => {
306
+ const fileBase = basename(inferLikelyFile(file, codeContext));
307
+ if (fileBase)
308
+ return fileBase.replace(/\.(spec|test|e2e)?\.[cm]?[jt]sx?$/i, "");
309
+ const locatorToken = (locator || "")
310
+ .toLowerCase()
311
+ .replace(/[^a-z0-9]+/g, " ")
312
+ .split(" ")
313
+ .find((token) => token.length >= 4);
314
+ return locatorToken || null;
97
315
  };
316
+ const describeDomState = (failure) => {
317
+ const dom = failure.domCapture;
318
+ if (!dom)
319
+ return null;
320
+ if (dom.targetFound === false || dom.matchedCount === 0) {
321
+ return failure.locator
322
+ ? `${failure.locator} never appeared before the timeout`
323
+ : "the expected target never appeared before the timeout";
324
+ }
325
+ if (dom.visible === false) {
326
+ return failure.locator
327
+ ? `${failure.locator} was found but stayed hidden before the timeout`
328
+ : "the target was found but stayed hidden before the timeout";
329
+ }
330
+ if (dom.enabled === false) {
331
+ return failure.locator
332
+ ? `${failure.locator} was found but stayed disabled before the timeout`
333
+ : "the target was found but stayed disabled before the timeout";
334
+ }
335
+ if (dom.targetFound === true && dom.visible === true && failure.codeContext?.action) {
336
+ return failure.locator
337
+ ? `${failure.codeContext.action} on ${failure.locator} never completed even though the target was present`
338
+ : `${failure.codeContext.action} never completed even though the target was present`;
339
+ }
340
+ return null;
341
+ };
342
+ const buildTouchedFileReason = (label, files) => `${label}: ${files.slice(0, 2).map((file) => basename(file)).join(", ")}`;
343
+ const commitTouchedFailure = (commit, failure) => {
344
+ const reasons = [];
345
+ const touchedFiles = new Set();
346
+ let score = 0;
347
+ const likelyFileBase = basename(failure.likelyFile);
348
+ const likelyDir = dirnameToken(failure.likelyFile);
349
+ const likelyModule = (failure.likelyModule || "").toLowerCase();
350
+ const locatorToken = (failure.locator || failure.domCapture?.testId || "")
351
+ .toLowerCase()
352
+ .replace(/[^a-z0-9]+/g, " ")
353
+ .split(" ")
354
+ .find((token) => token.length >= 4) || null;
355
+ const apiToken = (failure.apiHint || "")
356
+ .toLowerCase()
357
+ .replace(/https?:\/\//g, "")
358
+ .replace(/[^a-z0-9/]+/g, " ")
359
+ .split(/[\s/]+/)
360
+ .find((token) => token.length >= 4) || null;
361
+ const titleToken = cleanTitleParts(failure.titlePath)
362
+ .join(" ")
363
+ .toLowerCase()
364
+ .split(/[^a-z0-9]+/)
365
+ .find((token) => token.length >= 4) || null;
366
+ if (commit.sha === currentSha()) {
367
+ score += 0.08;
368
+ reasons.push("current failing commit");
369
+ }
370
+ const exactFileMatches = commit.changedFiles.filter((item) => basename(item) === likelyFileBase);
371
+ if (exactFileMatches.length) {
372
+ score += 0.42;
373
+ exactFileMatches.forEach((file) => touchedFiles.add(file));
374
+ reasons.push(buildTouchedFileReason("touches likely file", exactFileMatches));
375
+ }
376
+ const dirMatches = likelyDir ? commit.changedFiles.filter((item) => normalizePath(item).includes(`/${likelyDir}/`) || normalizePath(item).endsWith(`/${likelyDir}`)) : [];
377
+ if (dirMatches.length) {
378
+ score += 0.18;
379
+ dirMatches.slice(0, 2).forEach((file) => touchedFiles.add(file));
380
+ reasons.push(buildTouchedFileReason("touches same area", dirMatches));
381
+ }
382
+ const moduleMatches = likelyModule ? commit.changedFiles.filter((item) => normalizePath(item).includes(likelyModule)) : [];
383
+ if (moduleMatches.length) {
384
+ score += 0.16;
385
+ moduleMatches.slice(0, 2).forEach((file) => touchedFiles.add(file));
386
+ reasons.push(buildTouchedFileReason("overlaps likely module", moduleMatches));
387
+ }
388
+ const locatorMatches = locatorToken ? commit.changedFiles.filter((item) => normalizePath(item).includes(locatorToken)) : [];
389
+ if (locatorMatches.length) {
390
+ score += 0.22;
391
+ locatorMatches.slice(0, 2).forEach((file) => touchedFiles.add(file));
392
+ reasons.push(buildTouchedFileReason(`overlaps selector token (${locatorToken})`, locatorMatches));
393
+ }
394
+ const apiMatches = apiToken ? commit.changedFiles.filter((item) => normalizePath(item).includes(apiToken)) : [];
395
+ if (apiMatches.length) {
396
+ score += 0.24;
397
+ apiMatches.slice(0, 2).forEach((file) => touchedFiles.add(file));
398
+ reasons.push(buildTouchedFileReason(`overlaps API token (${apiToken})`, apiMatches));
399
+ }
400
+ if (locatorToken && commit.message.toLowerCase().includes(locatorToken)) {
401
+ score += 0.12;
402
+ reasons.push("commit message overlaps the failing selector");
403
+ }
404
+ if (apiToken && commit.message.toLowerCase().includes(apiToken)) {
405
+ score += 0.12;
406
+ reasons.push("commit message overlaps the failing API");
407
+ }
408
+ if (titleToken && commit.changedFiles.some((item) => normalizePath(item).includes(titleToken))) {
409
+ score += 0.12;
410
+ reasons.push(`changed file overlaps the failing flow (${titleToken})`);
411
+ }
412
+ if (failure.signal === "network" && /api|request|fetch|backend|service|latency|timeout/i.test(commit.message)) {
413
+ score += 0.14;
414
+ reasons.push("commit message points to backend or network changes");
415
+ }
416
+ if (["assertion_mismatch", "locator_not_found", "actionability"].includes(failure.signal) && /ui|button|modal|page|auth|login|checkout|selector|form/i.test(commit.message)) {
417
+ score += 0.14;
418
+ reasons.push("commit message points to the failing UI flow");
419
+ }
420
+ if (failure.signal === "infra" && /browser|playwright|infra|ci|worker|build|docker|image/i.test(commit.message)) {
421
+ score += 0.16;
422
+ reasons.push("commit message points to CI or browser infrastructure changes");
423
+ }
424
+ return {
425
+ commit,
426
+ score,
427
+ reasons: Array.from(new Set(reasons)).slice(0, 3),
428
+ touchedFiles: Array.from(touchedFiles).slice(0, 3)
429
+ };
430
+ };
431
+ const rankCommitsForFailure = (failure, window) => window.commits
432
+ .map((commit) => commitTouchedFailure(commit, failure))
433
+ .filter((entry) => entry.score > 0.2)
434
+ .sort((a, b) => b.score - a.score)
435
+ .slice(0, 2);
98
436
  const flattenFailedCases = (node, titlePath = []) => {
99
437
  const currentTitlePath = node.title ? [...titlePath, node.title] : titlePath;
100
- const failedCases = [];
438
+ const failures = [];
439
+ for (const child of node.suites || [])
440
+ failures.push(...flattenFailedCases(child, currentTitlePath));
441
+ for (const child of node.specs || [])
442
+ failures.push(...flattenFailedCases(child, currentTitlePath));
101
443
  for (const test of node.tests || []) {
102
- const title = [...currentTitlePath, test.title || "Unnamed test"].join(" > ");
103
- for (const result of test.results || []) {
104
- if (!["failed", "timedOut", "interrupted"].includes(result.status || ""))
105
- continue;
106
- const message = toMessage(result);
107
- failedCases.push({
108
- title,
109
- message,
110
- signal: classifySignal(message)
111
- });
444
+ const results = Array.isArray(test.results) ? test.results : [];
445
+ const finalResult = results[results.length - 1];
446
+ if (!finalResult)
447
+ continue;
448
+ const finalStatus = normalizeStatus(finalResult.status);
449
+ if (finalStatus !== "failed")
450
+ continue;
451
+ const message = toMessage(finalResult);
452
+ const titleParts = cleanTitleParts([...currentTitlePath, test.title || "Unnamed test"]);
453
+ failures.push((0, exports.parseFailureFacts)(titleParts.join(" > "), titleParts, message, "failed", node.file || node.location?.file || test.location?.file || null, {
454
+ projectName: test.projectName || null,
455
+ timeoutBudgetMs: typeof test.timeout === "number" ? test.timeout : null,
456
+ codeContext: loadCodeContext(finalResult),
457
+ domCapture: loadDomCapture(finalResult),
458
+ errorLocation: finalResult.error?.location || finalResult.errors?.find(Boolean)?.location || null,
459
+ errorSnippet: finalResult.error?.snippet || null
460
+ }));
461
+ }
462
+ return failures;
463
+ };
464
+ const checkFirst = (failure) => {
465
+ if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
466
+ const file = basename(failure.codeContext?.file || failure.likelyFile);
467
+ const line = failure.codeContext?.line;
468
+ if (file && line) {
469
+ return `inspect the throw at ${file}:${line} before opening the trace`;
112
470
  }
471
+ if (failure.codeContext?.focusLine) {
472
+ return `inspect ${failure.codeContext.focusLine.trim()} before opening the trace`;
473
+ }
474
+ return "inspect the shared retry or flaky helper before opening the trace";
475
+ }
476
+ if (failure.signal === "infra") {
477
+ return "inspect the browser crash or CI worker logs before digging into the test code";
113
478
  }
114
- for (const child of node.specs || []) {
115
- failedCases.push(...flattenFailedCases(child, currentTitlePath));
479
+ if (failure.signal === "network") {
480
+ return failure.apiHint ? `inspect ${failure.apiHint}` : "inspect the failing request and backend response";
116
481
  }
117
- for (const child of node.suites || []) {
118
- failedCases.push(...flattenFailedCases(child, currentTitlePath));
482
+ if (failure.signal === "timeout" && failure.codeContext?.action) {
483
+ if (failure.locator && failure.domCapture?.targetFound === false) {
484
+ return `inspect why ${failure.locator} never appeared before the ${failure.codeContext.action}`;
485
+ }
486
+ if (failure.locator && failure.domCapture?.visible === false) {
487
+ return `inspect why ${failure.locator} stayed hidden before the ${failure.codeContext.action}`;
488
+ }
489
+ if (failure.locator && failure.domCapture?.enabled === false) {
490
+ return `inspect why ${failure.locator} stayed disabled before the ${failure.codeContext.action}`;
491
+ }
492
+ if (failure.locator && failure.domCapture?.targetFound === true && failure.domCapture?.visible === true) {
493
+ return `inspect what blocked ${failure.codeContext.action} on ${failure.locator} (overlay or pointer interception is likely)`;
494
+ }
495
+ return `inspect the blocked ${failure.codeContext.action} step before the timeout`;
119
496
  }
120
- return failedCases;
497
+ if (failure.locator)
498
+ return `verify ${failure.locator}`;
499
+ if (failure.codeContext?.focusLine)
500
+ return `inspect ${failure.codeContext.focusLine.trim()}`;
501
+ if (failure.likelyFile)
502
+ return `inspect ${basename(failure.likelyFile)}`;
503
+ return "open the failing trace step";
121
504
  };
122
- const shortenTitle = (value) => {
123
- const parts = value.split(" > ").filter(Boolean);
124
- return parts[parts.length - 1] || value;
505
+ const confidenceLabel = (score) => {
506
+ if (score >= 0.62)
507
+ return "High";
508
+ if (score >= 0.34)
509
+ return "Medium";
510
+ return "Low";
125
511
  };
126
- const collectFailureFacts = (playwrightJsonPath) => {
127
- if (!node_fs_1.default.existsSync(playwrightJsonPath))
128
- return [];
129
- try {
130
- const raw = node_fs_1.default.readFileSync(playwrightJsonPath, "utf8");
131
- const parsed = JSON.parse(raw);
132
- const failedCases = flattenFailedCases(parsed);
133
- return failedCases.map((failed) => (0, exports.parseFailureFacts)(shortenTitle(failed.title), failed.title.split(" > ").filter(Boolean), failed.message, "failed"));
134
- }
135
- catch {
136
- return [];
512
+ const compactCommitLine = (match) => {
513
+ if (!match)
514
+ return null;
515
+ return `Suspected commit: ${match.commit.sha.slice(0, 7)} "${match.commit.message}"`;
516
+ };
517
+ const compactWhyLine = (match) => {
518
+ if (!match?.reasons.length)
519
+ return null;
520
+ return match.reasons.join("; ");
521
+ };
522
+ const alternateCommitLine = (match) => {
523
+ if (!match)
524
+ return null;
525
+ return `Also changed: ${match.commit.sha.slice(0, 7)} "${match.commit.message}"`;
526
+ };
527
+ const rootCauseLabel = (failure) => {
528
+ switch (failure.signal) {
529
+ case "assertion_mismatch":
530
+ return "UI assertion mismatch";
531
+ case "locator_not_found":
532
+ return "Locator or render regression";
533
+ case "actionability":
534
+ return "Actionability regression";
535
+ case "network":
536
+ return "Backend or network failure";
537
+ case "runtime":
538
+ return /retry|flaky/i.test(failure.message) ? "Same test-side throw before the app flow completed" : "Runtime error during the flow";
539
+ case "timeout":
540
+ return "Timeout waiting for state change";
541
+ case "infra":
542
+ return "Browser or CI infrastructure failure";
543
+ default:
544
+ return "Failure pattern";
137
545
  }
138
546
  };
139
- exports.collectFailureFacts = collectFailureFacts;
140
- const parseFailureFacts = (title, titlePath, message, status) => ({
141
- title,
142
- titlePath,
143
- message,
144
- signal: classifySignal(message),
145
- locator: extractLocator(message),
146
- expected: extractExpected(message),
147
- received: extractReceived(message),
148
- timeoutMs: extractTimeoutMs(message),
149
- lastUrl: extractLastUrl(message),
150
- status
151
- });
152
- exports.parseFailureFacts = parseFailureFacts;
153
547
  const describeFailure = (failure) => {
154
548
  if (failure.signal === "assertion_mismatch" && failure.locator && failure.expected && failure.received) {
155
- return `${failure.locator} showed "${failure.received}" instead of "${failure.expected}" before timeout.`;
549
+ return `${failure.locator} showed "${failure.received}" instead of "${failure.expected}".`;
156
550
  }
157
551
  if (failure.signal === "locator_not_found" && failure.locator) {
158
552
  return `${failure.locator} was not found when the test expected it to be available.`;
@@ -161,68 +555,390 @@ const describeFailure = (failure) => {
161
555
  return `${failure.locator} was found but was not actionable when the interaction ran.`;
162
556
  }
163
557
  if (failure.signal === "network") {
164
- return `The test likely failed because a network or API request did not complete successfully.`;
558
+ return failure.apiHint
559
+ ? `A network or API request around ${failure.apiHint} did not complete successfully.`
560
+ : `A network or API request did not complete successfully.`;
165
561
  }
166
562
  if (failure.signal === "timeout") {
167
- return `The expected UI or network condition did not complete before timeout.`;
563
+ const domState = describeDomState(failure);
564
+ if (domState)
565
+ return `${domState}.`;
566
+ return failure.timeoutMs
567
+ ? `The expected UI or network condition did not complete before the ${failure.timeoutMs}ms timeout.`
568
+ : `The expected UI or network condition did not complete before timeout.`;
569
+ }
570
+ if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
571
+ return `The test code threw a retry or flaky guard error before the app flow completed.`;
168
572
  }
169
573
  if (failure.signal === "runtime") {
170
- return `A frontend runtime error interrupted the test flow.`;
574
+ return `A runtime error interrupted the test flow before the expected state was reached.`;
575
+ }
576
+ if (failure.signal === "infra") {
577
+ return `The test failed because the browser or CI worker became unstable before the flow completed.`;
171
578
  }
172
579
  return `The failure signal could not be classified cleanly from the captured error.`;
173
580
  };
174
581
  exports.describeFailure = describeFailure;
175
- const buildDebugSummary = (failure) => {
176
- const lines = [
177
- `Test: ${failure.title}`,
178
- `Diagnosis: ${(0, exports.describeFailure)(failure)}`
179
- ];
582
+ const describeCluster = (cluster) => {
583
+ const failure = cluster.sample;
584
+ if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
585
+ const location = buildLocationLine(failure);
586
+ const origin = location ? ` The same helper threw at ${location.replace(/^Error location: /, '').replace(/^Likely file: /, '')}.` : "";
587
+ return `${cluster.count} tests hit the same test-side throw before the app flow completed.${origin}`;
588
+ }
589
+ if (cluster.count > 1 && failure.signal === "assertion_mismatch") {
590
+ return `${cluster.count} tests hit the same UI assertion mismatch, which usually means one shared UI regression.`;
591
+ }
592
+ if (cluster.count > 1 && failure.signal === "network") {
593
+ return `${cluster.count} tests failed behind the same network or API signal.`;
594
+ }
595
+ if (cluster.count > 1 && failure.signal === "timeout") {
596
+ return `${cluster.count} tests timed out behind the same blocked state transition.`;
597
+ }
598
+ if (cluster.count > 1 && failure.signal === "infra") {
599
+ return `${cluster.count} tests failed behind the same browser or CI instability signal.`;
600
+ }
601
+ return (0, exports.describeFailure)(failure);
602
+ };
603
+ const clusterCheckFirst = (cluster) => {
604
+ const failure = cluster.sample;
605
+ if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
606
+ const file = basename(failure.codeContext?.file || failure.likelyFile);
607
+ const line = failure.codeContext?.line;
608
+ if (file && line) {
609
+ return `inspect the throw at ${file}:${line} before opening the trace`;
610
+ }
611
+ return "inspect the shared retry/flaky helper or intentional throw in these tests before opening the trace";
612
+ }
613
+ return checkFirst(failure);
614
+ };
615
+ const formatAffectedTests = (titles) => {
616
+ const unique = Array.from(new Set(titles.map((title) => shortenTitle(title)))).slice(0, 3);
617
+ if (!unique.length)
618
+ return null;
619
+ return `Affected tests: ${unique.join("; ")}`;
620
+ };
621
+ const buildLocationLine = (failure) => {
622
+ const file = failure.codeContext?.file || failure.likelyFile;
623
+ const line = failure.codeContext?.line;
624
+ const column = failure.codeContext?.column;
625
+ if (!file)
626
+ return null;
627
+ if (line && column)
628
+ return `Error location: ${basename(file)}:${line}:${column}`;
629
+ if (line)
630
+ return `Error location: ${basename(file)}:${line}`;
631
+ return `Likely file: ${basename(file)}`;
632
+ };
633
+ const compactLocation = (failure) => {
634
+ const file = failure.codeContext?.file || failure.likelyFile;
635
+ const line = failure.codeContext?.line;
636
+ if (!file)
637
+ return null;
638
+ return line ? `${basename(file)}:${line}` : basename(file);
639
+ };
640
+ const buildClusterLocationLine = (failures) => {
641
+ const locations = Array.from(new Set(failures.map((failure) => compactLocation(failure)).filter(Boolean)));
642
+ if (!locations.length)
643
+ return null;
644
+ const label = locations.length > 1 ? 'Error locations' : 'Error location';
645
+ return `${label}: ${locations.slice(0, 3).join('; ')}`;
646
+ };
647
+ const buildEvidenceLines = (failure) => {
648
+ const lines = [];
649
+ const locationLine = buildLocationLine(failure);
650
+ if (locationLine)
651
+ lines.push(locationLine);
652
+ if (failure.codeContext?.focusLine)
653
+ lines.push(`Failing code: ${failure.codeContext.focusLine.trim()}`);
654
+ if (failure.codeContext?.action)
655
+ lines.push(`Failing step: ${failure.codeContext.action}`);
180
656
  if (failure.locator)
181
- lines.push(`Locator: ${failure.locator}`);
657
+ lines.push(`Selector: ${failure.locator}`);
658
+ if (failure.signal === "timeout" && failure.domCapture) {
659
+ if (failure.domCapture.targetFound === false || failure.domCapture.matchedCount === 0) {
660
+ lines.push(`Target state: locator never appeared`);
661
+ }
662
+ else if (failure.domCapture.visible === false) {
663
+ lines.push(`Target state: found but hidden`);
664
+ }
665
+ else if (failure.domCapture.enabled === false) {
666
+ lines.push(`Target state: found but disabled`);
667
+ }
668
+ else if (failure.domCapture.targetFound === true && failure.domCapture.visible === true) {
669
+ lines.push(`Target state: found and visible before timeout`);
670
+ }
671
+ }
672
+ if (failure.apiHint)
673
+ lines.push(`API: ${failure.apiHint}`);
674
+ return lines.slice(0, 4);
675
+ };
676
+ const withoutPrefix = (value, prefix) => value.startsWith(prefix) ? value.slice(prefix.length).trim() : value;
677
+ const buildSecondaryEvidenceLines = (failure) => buildEvidenceLines(failure)
678
+ .filter((line) => !line.startsWith("Error location:") && !line.startsWith("Likely file:"))
679
+ .slice(0, 3);
680
+ const compactRootCauseSummary = (cluster) => {
681
+ const failure = cluster.sample;
682
+ if (failure.signal === "runtime" && /retry|flaky/i.test(failure.message)) {
683
+ return "Same test-side throw before the app flow completed";
684
+ }
685
+ if (failure.signal === "network") {
686
+ return failure.apiHint
687
+ ? `Same network/API failure around ${failure.apiHint}`
688
+ : "Same network/API failure across these tests";
689
+ }
690
+ if (failure.signal === "timeout") {
691
+ return "Same blocked state transition timed out across these tests";
692
+ }
693
+ if (failure.signal === "assertion_mismatch") {
694
+ return "Same UI assertion mismatch across these tests";
695
+ }
696
+ if (failure.signal === "locator_not_found") {
697
+ return "Same missing or changed locator across these tests";
698
+ }
699
+ if (failure.signal === "actionability") {
700
+ return "Same element actionability problem across these tests";
701
+ }
702
+ if (failure.signal === "infra") {
703
+ return "Same browser/CI instability affected these tests";
704
+ }
705
+ return describeCluster(cluster);
706
+ };
707
+ const compactErrorLine = (failure) => {
708
+ if (!failure.firstErrorLine)
709
+ return null;
710
+ return withoutPrefix(failure.firstErrorLine, "Error:");
711
+ };
712
+ const parseFailureFacts = (title, titlePath, message, status, file = null, options) => {
713
+ const signal = classifySignal(message);
714
+ const fallbackLocation = options?.errorLocation || extractStackLocation(message);
715
+ const fallbackCodeContext = fallbackLocation || options?.errorSnippet
716
+ ? {
717
+ file: fallbackLocation?.file || file || null,
718
+ line: typeof fallbackLocation?.line === "number" ? fallbackLocation.line : null,
719
+ column: typeof fallbackLocation?.column === "number" ? fallbackLocation.column : null,
720
+ action: null,
721
+ locator: null,
722
+ expectedText: null,
723
+ timeoutMs: null,
724
+ apiCall: null,
725
+ assertion: null,
726
+ methodName: null,
727
+ focusLine: extractFocusLineFromSnippet(options?.errorSnippet),
728
+ previousActionLine: null,
729
+ found: Boolean(fallbackLocation?.file || options?.errorSnippet)
730
+ }
731
+ : null;
732
+ const codeContext = options?.codeContext
733
+ ? {
734
+ ...fallbackCodeContext,
735
+ ...options.codeContext,
736
+ file: options.codeContext.file || fallbackCodeContext?.file || null,
737
+ line: options.codeContext.line ?? fallbackCodeContext?.line ?? null,
738
+ column: options.codeContext.column ?? fallbackCodeContext?.column ?? null,
739
+ focusLine: options.codeContext.focusLine || fallbackCodeContext?.focusLine || null,
740
+ found: options.codeContext.found ?? fallbackCodeContext?.found ?? false
741
+ }
742
+ : fallbackCodeContext || null;
743
+ const domCapture = options?.domCapture || null;
744
+ const locator = extractLocator(message) || codeContext?.locator || domCapture?.locator || null;
745
+ const expected = extractExpected(message) || codeContext?.expectedText || domCapture?.expectedText || null;
746
+ const received = extractReceived(message) || domCapture?.observedText || domCapture?.textContent || null;
747
+ const likelyFile = inferLikelyFile(file, codeContext);
748
+ const likelyModule = inferLikelyModule(file, locator, codeContext);
749
+ const apiHint = extractApiHint(message, codeContext);
750
+ return {
751
+ title,
752
+ titlePath,
753
+ projectName: options?.projectName || null,
754
+ message,
755
+ firstErrorLine: stripAnsi(message).split(/\r?\n/).map((line) => line.trim()).find(Boolean) || null,
756
+ signal,
757
+ locator,
758
+ expected,
759
+ received,
760
+ timeoutMs: extractTimeoutMs(message) || codeContext?.timeoutMs || options?.timeoutBudgetMs || null,
761
+ timeoutBudgetMs: options?.timeoutBudgetMs || codeContext?.timeoutMs || null,
762
+ lastUrl: extractLastUrl(message),
763
+ status,
764
+ file,
765
+ likelyFile,
766
+ likelyModule,
767
+ apiHint,
768
+ codeContext,
769
+ domCapture
770
+ };
771
+ };
772
+ exports.parseFailureFacts = parseFailureFacts;
773
+ const collectFailureFacts = (playwrightJsonPath) => {
774
+ if (!node_fs_1.default.existsSync(playwrightJsonPath))
775
+ return [];
776
+ try {
777
+ const raw = node_fs_1.default.readFileSync(playwrightJsonPath, "utf8");
778
+ const parsed = JSON.parse(raw);
779
+ return flattenFailedCases(parsed);
780
+ }
781
+ catch {
782
+ return [];
783
+ }
784
+ };
785
+ exports.collectFailureFacts = collectFailureFacts;
786
+ const buildDebugSummary = (failure) => {
787
+ const lines = [`Test: ${failure.title}`, `Diagnosis: ${(0, exports.describeFailure)(failure)}`];
788
+ for (const line of buildEvidenceLines(failure))
789
+ lines.push(line);
182
790
  if (failure.expected)
183
791
  lines.push(`Expected: ${failure.expected}`);
184
792
  if (failure.received)
185
793
  lines.push(`Observed: ${failure.received}`);
186
794
  if (failure.timeoutMs)
187
795
  lines.push(`Timeout: ${failure.timeoutMs}ms`);
188
- if (failure.lastUrl)
189
- lines.push(`URL: ${failure.lastUrl}`);
190
796
  return lines.join("\n");
191
797
  };
192
798
  exports.buildDebugSummary = buildDebugSummary;
193
799
  const buildSimilarityKey = (failure) => {
194
- if (failure.locator || failure.expected || failure.received) {
800
+ const locationKey = failure.codeContext?.line ? `${basename(failure.codeContext.file)}:${failure.codeContext.line}` : basename(failure.likelyFile) || "unknown-file";
801
+ if (failure.signal === 'runtime' || failure.signal === 'unknown') {
195
802
  return [
196
803
  failure.signal,
197
- failure.locator || "unknown-locator",
804
+ normalizeMessageFingerprint(failure.message),
805
+ locationKey
806
+ ].join('|');
807
+ }
808
+ if (failure.locator || failure.expected || failure.received || failure.apiHint) {
809
+ return [
810
+ failure.signal,
811
+ failure.locator || failure.apiHint || "unknown-target",
198
812
  failure.expected || "unknown-expected",
199
- failure.received || "unknown-received"
813
+ failure.received || "unknown-received",
814
+ locationKey
200
815
  ].join("|");
201
816
  }
202
- return `${failure.signal}|${normalizeMessageFingerprint(failure.message)}`;
817
+ return `${failure.signal}|${normalizeMessageFingerprint(failure.message)}|${locationKey}`;
203
818
  };
204
819
  exports.buildSimilarityKey = buildSimilarityKey;
205
- exports.summarizeSignal = signalSummary;
820
+ const summarizeSignal = (signal) => {
821
+ switch (signal) {
822
+ case "timeout":
823
+ return "timeout while waiting for UI or network conditions";
824
+ case "assertion_mismatch":
825
+ return "assertion mismatch between expected and rendered UI state";
826
+ case "locator_not_found":
827
+ return "missing or changed locator";
828
+ case "actionability":
829
+ return "target element was not actionable";
830
+ case "network":
831
+ return "network or API failure";
832
+ case "runtime":
833
+ return "runtime error thrown before the flow completed";
834
+ case "infra":
835
+ return "browser or CI infrastructure failure";
836
+ default:
837
+ return "failure signal could not be classified cleanly";
838
+ }
839
+ };
840
+ exports.summarizeSignal = summarizeSignal;
841
+ const buildClusterSuspects = (clusterFailures, window) => {
842
+ return window.commits
843
+ .map((commit) => {
844
+ const combined = clusterFailures.map((failure) => commitTouchedFailure(commit, failure));
845
+ const score = combined.reduce((sum, item) => sum + item.score, 0);
846
+ const reasons = Array.from(new Set(combined.flatMap((item) => item.reasons))).slice(0, 3);
847
+ const touchedFiles = Array.from(new Set(combined.flatMap((item) => item.touchedFiles))).slice(0, 3);
848
+ return { commit, score, reasons, touchedFiles };
849
+ })
850
+ .filter((entry) => entry.score > 0.2)
851
+ .sort((a, b) => b.score - a.score)
852
+ .slice(0, 2);
853
+ };
206
854
  const buildQuickDiagnosis = (playwrightJsonPath) => {
207
855
  const failures = (0, exports.collectFailureFacts)(playwrightJsonPath);
208
856
  if (!failures.length)
209
857
  return null;
858
+ const commitWindow = getCommitWindow();
210
859
  if (failures.length === 1) {
211
860
  const failed = failures[0];
861
+ const suspects = commitWindow.trusted ? rankCommitsForFailure(failed, commitWindow) : [];
862
+ const top = suspects[0];
863
+ const alt = suspects[1];
864
+ const lines = [`${formatTitle(failed.title)} failed.`, `What broke: ${(0, exports.describeFailure)(failed)}`];
865
+ const primaryLocation = buildLocationLine(failed);
866
+ if (primaryLocation)
867
+ lines.push(`Where: ${withoutPrefix(withoutPrefix(primaryLocation, "Error location:"), "Likely file:")}`);
868
+ if (failed.firstErrorLine)
869
+ lines.push(`Error: ${failed.firstErrorLine}`);
870
+ for (const evidence of buildSecondaryEvidenceLines(failed))
871
+ lines.push(evidence);
872
+ if (top) {
873
+ lines.push(compactCommitLine(top));
874
+ lines.push(`Why Sentinel picked it: ${compactWhyLine(top)}.`);
875
+ if (top.touchedFiles.length) {
876
+ lines.push(`Touched files: ${top.touchedFiles.map((file) => basename(file)).join(", ")}`);
877
+ }
878
+ if (alt)
879
+ lines.push(alternateCommitLine(alt));
880
+ }
881
+ lines.push(`Check first: ${checkFirst(failed)}.`);
212
882
  return {
213
- lines: [`Test "${failed.title}" likely failed due to ${signalSummary(failed.signal)}.`]
883
+ lines,
884
+ footer: [
885
+ ...(top ? [`Confidence: ${confidenceLabel(top.score)}`] : []),
886
+ ...(commitWindow.trusted ? [`Commits analyzed: ${commitWindow.commits.length}`] : [`Commit blame skipped: ${commitWindow.reason}`])
887
+ ]
214
888
  };
215
889
  }
216
- const counts = new Map();
217
- for (const failed of failures) {
218
- counts.set(failed.signal, (counts.get(failed.signal) || 0) + 1);
890
+ const clusterMap = new Map();
891
+ for (const failure of failures) {
892
+ const key = (0, exports.buildSimilarityKey)(failure);
893
+ const bucket = clusterMap.get(key) || [];
894
+ bucket.push(failure);
895
+ clusterMap.set(key, bucket);
896
+ }
897
+ const clusters = Array.from(clusterMap.entries())
898
+ .map(([key, clusterFailures]) => ({
899
+ key,
900
+ count: clusterFailures.length,
901
+ sample: clusterFailures[0],
902
+ titles: clusterFailures.map((item) => item.title),
903
+ suspects: commitWindow.trusted ? buildClusterSuspects(clusterFailures, commitWindow) : []
904
+ }))
905
+ .sort((a, b) => b.count - a.count)
906
+ .slice(0, 2);
907
+ const topCluster = clusters[0];
908
+ const lines = [
909
+ `${failures.length} tests failed.`,
910
+ `Collapsed into ${clusters.length} likely root cause${clusters.length === 1 ? "" : "s"}:`
911
+ ];
912
+ for (const [index, cluster] of clusters.entries()) {
913
+ const clusterFailures = clusterMap.get(cluster.key) || [cluster.sample];
914
+ const clusterLocation = buildClusterLocationLine(clusterFailures);
915
+ const top = cluster.suspects[0];
916
+ const locationValue = clusterLocation
917
+ ? withoutPrefix(clusterLocation, clusterLocation.startsWith("Error locations:") ? "Error locations:" : "Error location:")
918
+ : null;
919
+ const rootCause = compactRootCauseSummary(cluster);
920
+ const errorLine = compactErrorLine(cluster.sample);
921
+ lines.push(`[${index + 1}] ${rootCauseLabel(cluster.sample)} (${cluster.count} tests)`);
922
+ lines.push(` Root cause: ${rootCause}`);
923
+ if (errorLine) {
924
+ lines.push(` Error: ${errorLine}`);
925
+ }
926
+ if (locationValue) {
927
+ lines.push(` Where: ${locationValue}`);
928
+ }
929
+ if (top && top.touchedFiles.length) {
930
+ lines.push(` Changed files: ${top.touchedFiles.map((file) => basename(file)).join(", ")}`);
931
+ }
932
+ lines.push(` Check first: ${clusterCheckFirst(cluster)}.`);
219
933
  }
220
- const topSignal = Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || "unknown";
221
934
  return {
222
- lines: [
223
- `${failures.length} tests failed.`,
224
- `Most common signal: ${signalSummary(topSignal)}.`
225
- ]
935
+ lines,
936
+ footer: topCluster
937
+ ? [
938
+ ...(topCluster.suspects[0] ? [`Top confidence: ${confidenceLabel(topCluster.suspects[0].score)}`] : []),
939
+ ...(commitWindow.trusted ? [`Commits analyzed: ${commitWindow.commits.length}`] : [`Commit blame skipped: ${commitWindow.reason}`])
940
+ ]
941
+ : []
226
942
  };
227
943
  };
228
944
  exports.buildQuickDiagnosis = buildQuickDiagnosis;