qa-engineer 0.10.0 → 0.11.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.
Files changed (63) hide show
  1. package/README.md +167 -59
  2. package/package.json +1 -1
  3. package/packages/engine/bin/qa-engine.mjs +232 -8
  4. package/packages/engine/lib/analysis/har.mjs +34 -0
  5. package/packages/engine/lib/analysis/network.mjs +237 -0
  6. package/packages/engine/lib/analysis/report-html.mjs +47 -734
  7. package/packages/engine/lib/artifacts/manager.mjs +453 -0
  8. package/packages/engine/lib/artifacts/mime.mjs +109 -0
  9. package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
  10. package/packages/engine/lib/report/components/charts.mjs +424 -0
  11. package/packages/engine/lib/report/components/evidence.mjs +207 -0
  12. package/packages/engine/lib/report/components/findings.mjs +258 -0
  13. package/packages/engine/lib/report/components/nav.mjs +99 -0
  14. package/packages/engine/lib/report/components/primitives.mjs +246 -0
  15. package/packages/engine/lib/report/components/runtime.mjs +246 -0
  16. package/packages/engine/lib/report/components/timeline.mjs +65 -0
  17. package/packages/engine/lib/report/core/model.mjs +270 -0
  18. package/packages/engine/lib/report/core/normalize.mjs +226 -0
  19. package/packages/engine/lib/report/core/sections.mjs +978 -0
  20. package/packages/engine/lib/report/export/bundle.mjs +293 -0
  21. package/packages/engine/lib/report/export/html.mjs +183 -0
  22. package/packages/engine/lib/report/export/machine.mjs +290 -0
  23. package/packages/engine/lib/report/export/markdown.mjs +323 -0
  24. package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
  25. package/packages/engine/lib/report/theme/css.mjs +529 -0
  26. package/packages/engine/lib/report/theme/tokens.mjs +137 -0
  27. package/packages/engine/lib/report/version.mjs +78 -0
  28. package/packages/engine/package.json +2 -2
  29. package/packages/installer/lib/agents/targets.mjs +90 -0
  30. package/packages/installer/lib/agents/user-level.mjs +80 -0
  31. package/packages/installer/lib/cli/flags.mjs +20 -2
  32. package/packages/installer/lib/commands/doctor.mjs +6 -4
  33. package/packages/installer/lib/commands/install.mjs +134 -93
  34. package/packages/installer/lib/commands/repair.mjs +10 -6
  35. package/packages/installer/lib/commands/self-test.mjs +4 -3
  36. package/packages/installer/lib/commands/uninstall.mjs +14 -8
  37. package/packages/installer/lib/commands/update.mjs +9 -4
  38. package/packages/installer/lib/commands/verify.mjs +13 -12
  39. package/packages/installer/lib/constants.mjs +13 -0
  40. package/packages/installer/lib/core/conflict.mjs +5 -4
  41. package/packages/installer/lib/core/fs-safe.mjs +146 -6
  42. package/packages/installer/lib/core/integrity.mjs +59 -0
  43. package/packages/installer/lib/core/lockfile.mjs +19 -3
  44. package/packages/installer/lib/core/plan.mjs +213 -0
  45. package/packages/installer/lib/core/qa-home.mjs +145 -0
  46. package/packages/installer/lib/core/scope.mjs +274 -0
  47. package/packages/installer/lib/core/validate-install.mjs +37 -12
  48. package/packages/installer/package.json +1 -1
  49. package/packages/installer/schemas/qa-lock.schema.json +119 -21
  50. package/shared/tooling/qa-tool.mjs +41 -5
  51. package/skills/qa-api/scripts/qa-tool.mjs +41 -5
  52. package/skills/qa-audit/scripts/qa-tool.mjs +41 -5
  53. package/skills/qa-debug/scripts/qa-tool.mjs +41 -5
  54. package/skills/qa-explore/SKILL.md +26 -10
  55. package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
  56. package/skills/qa-explore/references/api-replay.md +40 -1
  57. package/skills/qa-explore/references/report-pipeline.md +265 -95
  58. package/skills/qa-explore/scripts/qa-tool.mjs +41 -5
  59. package/skills/qa-fix/scripts/qa-tool.mjs +41 -5
  60. package/skills/qa-flaky/scripts/qa-tool.mjs +41 -5
  61. package/skills/qa-init/scripts/qa-tool.mjs +41 -5
  62. package/skills/qa-report/scripts/qa-tool.mjs +41 -5
  63. package/skills/qa-run/scripts/qa-tool.mjs +41 -5
@@ -0,0 +1,290 @@
1
+ // Machine-readable exports: SARIF, JUnit, and CSV.
2
+ //
3
+ // These exist so a QA run can enter systems that were never going to read an HTML
4
+ // page. SARIF puts findings in GitHub's Security tab and any code-scanning viewer;
5
+ // JUnit puts them on a CI dashboard beside the unit tests; CSV puts them in the
6
+ // spreadsheet a QA lead is already tracking the release from.
7
+ //
8
+ // ## Where the mapping is honest and where it is lossy
9
+ //
10
+ // SARIF and JUnit were designed for static analysis and unit tests respectively, and
11
+ // an exploratory QA finding is neither. Rather than pretend otherwise:
12
+ //
13
+ // - A finding with no `page` has no meaningful file location, so SARIF gets the
14
+ // target URL as its artifact URI rather than an invented source path. A reader
15
+ // seeing `admin.example.com/users` knows it is a runtime finding, not line 12 of a
16
+ // file that does not exist.
17
+ // - JUnit's model has no severity, so severity travels in the failure `type` and the
18
+ // dimension in the classname. Nothing is dropped silently; it is relocated and
19
+ // documented.
20
+ //
21
+ // Anything that genuinely does not fit — evidence images, root-cause chains — stays in
22
+ // the JSON, which every one of these formats can point back to.
23
+
24
+ import { buildModel } from '../core/model.mjs';
25
+ import { SEVERITY } from '../theme/tokens.mjs';
26
+ import { dimensionLabel } from '../components/findings.mjs';
27
+
28
+ /** SARIF severity levels. SARIF has three; the pack has four, so medium and low share. */
29
+ const SARIF_LEVEL = Object.freeze({ critical: 'error', high: 'error', medium: 'warning', low: 'note' });
30
+
31
+ // SARIF's own 0–10 scale, so a viewer that ranks by score ranks the way the report does.
32
+ const SARIF_RANK = Object.freeze({ critical: 10, high: 8, medium: 5, low: 2 });
33
+
34
+ /** SARIF 2.1.0, one run, one rule per distinct dimension+severity pairing. */
35
+ export function renderSarif(result, options = {}) {
36
+ const model = buildModel(result, { ...options, hash: false });
37
+
38
+ const rules = new Map();
39
+ const results = model.findings.map((finding) => {
40
+ const ruleId = `qa-explore/${finding.dimension ?? 'general'}`;
41
+ if (!rules.has(ruleId)) {
42
+ rules.set(ruleId, {
43
+ id: ruleId,
44
+ name: `${dimensionLabel(finding.dimension)} finding`,
45
+ shortDescription: { text: `${dimensionLabel(finding.dimension)} defect found by exploratory QA` },
46
+ fullDescription: {
47
+ text:
48
+ 'Reported by qa-explore, an AI-driven exploratory QA pass against a running application. ' +
49
+ 'Each result carries the observed behaviour, the expected behaviour, and reproduction steps.',
50
+ },
51
+ defaultConfiguration: { level: SARIF_LEVEL[finding.severity] ?? 'note' },
52
+ properties: { tags: ['qa', 'exploratory', finding.dimension ?? 'general'] },
53
+ });
54
+ }
55
+
56
+ const location = finding.page ?? model.url ?? 'unknown';
57
+ const message = [
58
+ finding.actual,
59
+ `Expected: ${finding.expected}`,
60
+ `Fix: ${finding.fixDirection}`,
61
+ ].join('\n\n');
62
+
63
+ return {
64
+ ruleId,
65
+ level: SARIF_LEVEL[finding.severity] ?? 'note',
66
+ rank: SARIF_RANK[finding.severity] ?? 1,
67
+ message: { text: message },
68
+ locations: [
69
+ {
70
+ physicalLocation: {
71
+ artifactLocation: { uri: location },
72
+ region: { startLine: 1 },
73
+ },
74
+ message: { text: finding.title },
75
+ },
76
+ ],
77
+ partialFingerprints: { findingId: String(finding.id) },
78
+ properties: {
79
+ findingId: finding.id,
80
+ severity: finding.severity,
81
+ dimension: finding.dimension,
82
+ status: finding.status,
83
+ businessImpact: finding.businessImpact ?? undefined,
84
+ rootCause: finding.rootCause?.summary ?? undefined,
85
+ regressionRisk: finding.regressionRisk?.level ?? undefined,
86
+ confidence: finding.confidence ?? undefined,
87
+ repro: finding.repro,
88
+ },
89
+ };
90
+ });
91
+
92
+ return {
93
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
94
+ version: '2.1.0',
95
+ runs: [
96
+ {
97
+ tool: {
98
+ driver: {
99
+ name: 'qa-engineer',
100
+ informationUri: 'https://github.com/abisheik88/qa-engineer',
101
+ rules: [...rules.values()],
102
+ version: model.skill?.version ?? undefined,
103
+ },
104
+ },
105
+ invocations: [
106
+ {
107
+ executionSuccessful: model.classification !== 'blocked',
108
+ endTimeUtc: model.generatedAt ?? undefined,
109
+ },
110
+ ],
111
+ results,
112
+ },
113
+ ],
114
+ };
115
+ }
116
+
117
+ /** XML-escape, for both text nodes and attribute values. */
118
+ function x(value) {
119
+ return String(value ?? '')
120
+ .replace(/&/g, '&')
121
+ .replace(/</g, '&lt;')
122
+ .replace(/>/g, '&gt;')
123
+ .replace(/"/g, '&quot;')
124
+ .replace(/'/g, '&apos;')
125
+ // A control character is legal in a JSON string and illegal in XML 1.0, so a
126
+ // console excerpt carrying one would produce a file no parser accepts.
127
+ // eslint-disable-next-line no-control-regex
128
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
129
+ }
130
+
131
+ /**
132
+ * JUnit XML.
133
+ *
134
+ * Two suites: the executed test cases (when the run had any) and the findings. Keeping
135
+ * them apart matters — a CI dashboard that mixes "the login case failed" with "the
136
+ * login page leaks a token into localStorage" cannot tell a regression from a review
137
+ * comment.
138
+ */
139
+ export function renderJUnit(result, options = {}) {
140
+ const model = buildModel(result, { ...options, hash: false });
141
+ const suites = [];
142
+
143
+ const cases = model.testCases?.cases ?? [];
144
+ if (cases.length > 0) {
145
+ const bodies = cases.map((testCase) => {
146
+ const attributes =
147
+ `name="${x(testCase.title)}" classname="${x(`qa-explore.cases.${testCase.id}`)}"`;
148
+ if (testCase.status === 'pass') return ` <testcase ${attributes}/>`;
149
+ if (testCase.status === 'skipped') {
150
+ return ` <testcase ${attributes}><skipped/></testcase>`;
151
+ }
152
+ if (testCase.status === 'blocked') {
153
+ return (
154
+ ` <testcase ${attributes}><skipped message="Blocked: could not be executed"/></testcase>`
155
+ );
156
+ }
157
+ const finding = model.findings.find((entry) => entry.id === testCase.findingId);
158
+ const detail = finding
159
+ ? `${finding.actual}\n\nExpected: ${finding.expected}`
160
+ : (testCase.note ?? 'Case failed.');
161
+ return (
162
+ ` <testcase ${attributes}>` +
163
+ `<failure type="case-failed" message="${x(testCase.title)}">${x(detail)}</failure>` +
164
+ '</testcase>'
165
+ );
166
+ });
167
+ suites.push(
168
+ ` <testsuite name="qa-explore.cases" tests="${cases.length}" ` +
169
+ `failures="${cases.filter((c) => c.status === 'fail').length}" ` +
170
+ `skipped="${cases.filter((c) => c.status === 'skipped' || c.status === 'blocked').length}">\n` +
171
+ `${bodies.join('\n')}\n </testsuite>`,
172
+ );
173
+ }
174
+
175
+ if (model.findings.length > 0) {
176
+ const bodies = model.findings.map((finding) => {
177
+ const detail = [
178
+ finding.actual,
179
+ `Expected: ${finding.expected}`,
180
+ `Reproduction: ${finding.repro}`,
181
+ `Fix: ${finding.fixDirection}`,
182
+ ].join('\n\n');
183
+ return (
184
+ ` <testcase name="${x(`${finding.id} ${finding.title}`)}" ` +
185
+ `classname="${x(`qa-explore.findings.${finding.dimension ?? 'general'}`)}">` +
186
+ `<failure type="${x(finding.severity)}" message="${x(finding.title)}">${x(detail)}</failure>` +
187
+ '</testcase>'
188
+ );
189
+ });
190
+ suites.push(
191
+ ` <testsuite name="qa-explore.findings" tests="${model.findings.length}" ` +
192
+ `failures="${model.findings.length}">\n${bodies.join('\n')}\n </testsuite>`,
193
+ );
194
+ }
195
+
196
+ const totals = suites.length;
197
+ return (
198
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
199
+ `<testsuites name="qa-explore" tests="${cases.length + model.findings.length}" ` +
200
+ `failures="${cases.filter((c) => c.status === 'fail').length + model.findings.length}"` +
201
+ (totals === 0 ? '/>\n' : `>\n${suites.join('\n')}\n</testsuites>\n`)
202
+ );
203
+ }
204
+
205
+ /** RFC 4180 quoting: wrap when needed, double any embedded quote. */
206
+ function csvCell(value) {
207
+ const text = String(value ?? '');
208
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
209
+ }
210
+
211
+ /**
212
+ * Findings as CSV.
213
+ *
214
+ * Column order is triage order, so the sheet is usable unsorted: id, severity, the two
215
+ * behaviours, then the context. A leading `` because Excel opens a UTF-8 CSV
216
+ * without one as mojibake, and a report that renders `â€"` in a stakeholder's
217
+ * spreadsheet has failed regardless of how correct the bytes are.
218
+ */
219
+ export function renderCsv(result, options = {}) {
220
+ const model = buildModel(result, { ...options, hash: false });
221
+ const headers = [
222
+ 'id', 'severity', 'dimension', 'status', 'title', 'page', 'actual', 'expected',
223
+ 'businessImpact', 'repro', 'fixDirection', 'rootCause', 'regressionRisk',
224
+ 'confidence', 'tags', 'affectedApis', 'evidenceCount',
225
+ ];
226
+
227
+ const rows = model.findings.map((finding) =>
228
+ [
229
+ finding.id,
230
+ finding.severity,
231
+ finding.dimension,
232
+ finding.status,
233
+ finding.title,
234
+ finding.page ?? '',
235
+ finding.actual,
236
+ finding.expected,
237
+ finding.businessImpact ?? '',
238
+ finding.repro,
239
+ finding.fixDirection,
240
+ finding.rootCause?.summary ?? '',
241
+ finding.regressionRisk?.level ?? '',
242
+ finding.confidence ?? '',
243
+ (finding.tags ?? []).join(' '),
244
+ (finding.affectedApis ?? []).join(' '),
245
+ (finding.evidence ?? []).length,
246
+ ]
247
+ .map(csvCell)
248
+ .join(','),
249
+ );
250
+
251
+ return `\ufeff${[headers.join(','), ...rows].join('\r\n')}\r\n`;
252
+ }
253
+
254
+ /**
255
+ * A manifest of everything a shareable bundle should contain.
256
+ *
257
+ * The pack writes no archive itself — zipping is the caller's job and every platform
258
+ * already has a tool for it. What the pack owns is knowing *what belongs in the
259
+ * archive*, which is the part that goes wrong: a bundle missing three screenshots is
260
+ * indistinguishable from a complete one until someone opens it.
261
+ */
262
+ export function bundleManifest(result, options = {}) {
263
+ const model = buildModel(result, options);
264
+ const files = model.registry
265
+ .list()
266
+ .filter((record) => record.declaredPath && !record.external)
267
+ .map((record) => ({
268
+ path: record.declaredPath,
269
+ kind: record.kind,
270
+ bytes: record.bytes,
271
+ sha256: record.sha256,
272
+ present: record.exists,
273
+ reason: record.exists ? undefined : record.missingReason,
274
+ }));
275
+
276
+ return {
277
+ subject: model.subject,
278
+ generatedAt: model.generatedAt,
279
+ complete: files.every((file) => file.present),
280
+ counts: {
281
+ total: files.length,
282
+ present: files.filter((file) => file.present).length,
283
+ missing: files.filter((file) => !file.present).length,
284
+ },
285
+ totalBytes: files.reduce((sum, file) => sum + (file.bytes ?? 0), 0),
286
+ files,
287
+ };
288
+ }
289
+
290
+ export { SEVERITY };
@@ -0,0 +1,323 @@
1
+ // The Markdown rendering.
2
+ //
3
+ // Markdown is the format a finding gets pasted into a Jira ticket, a pull request, or
4
+ // a Slack thread from, so it is written for *excerpting* rather than for reading end to
5
+ // end: each finding is a self-contained block with its own heading, and no finding
6
+ // depends on a table three sections above it to make sense.
7
+ //
8
+ // It is rendered from the same model as the HTML, which is the only reason the two can
9
+ // be trusted to agree. Producing the prose separately is exactly how the first live run
10
+ // ended up with a Markdown report that said things the JSON did not.
11
+
12
+ import { footerMarkdown } from '../../analysis/branding.mjs';
13
+ import { buildModel } from '../core/model.mjs';
14
+ import { SEVERITY, SEVERITY_ORDER } from '../theme/tokens.mjs';
15
+ import { dimensionLabel, STATUS_LABEL } from '../components/findings.mjs';
16
+ import { formatDuration, formatBytes } from '../components/primitives.mjs';
17
+
18
+ /** Escape the characters that would restructure a table cell or a heading. */
19
+ function cell(value) {
20
+ return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
21
+ }
22
+
23
+ function table(headers, rows) {
24
+ if (rows.length === 0) return '';
25
+ return [
26
+ `| ${headers.join(' | ')} |`,
27
+ `| ${headers.map(() => '---').join(' | ')} |`,
28
+ ...rows.map((row) => `| ${row.map(cell).join(' | ')} |`),
29
+ '',
30
+ ].join('\n');
31
+ }
32
+
33
+ function findingBlock(finding, model) {
34
+ const severity = SEVERITY[finding.severity]?.label ?? finding.severity;
35
+ const lines = [
36
+ `### ${finding.id} · ${severity} — ${finding.title}`,
37
+ '',
38
+ `**Area** ${dimensionLabel(finding.dimension)} · ` +
39
+ `**Status** ${STATUS_LABEL[finding.status] ?? finding.status}` +
40
+ (finding.page ? ` · **Page** ${finding.page}` : ''),
41
+ '',
42
+ `**Current behaviour** — ${finding.actual}`,
43
+ '',
44
+ `**Expected behaviour** — ${finding.expected}`,
45
+ '',
46
+ ];
47
+
48
+ if (finding.businessImpact) lines.push(`**Business impact** — ${finding.businessImpact}`, '');
49
+
50
+ lines.push('**Reproduction**', '');
51
+ if (Array.isArray(finding.steps) && finding.steps.length > 0) {
52
+ finding.steps.forEach((step, index) => lines.push(`${index + 1}. ${step}`));
53
+ } else {
54
+ lines.push(finding.repro);
55
+ }
56
+ lines.push('');
57
+
58
+ if (finding.rootCause?.summary) {
59
+ lines.push(`**Root cause** — ${finding.rootCause.summary}`, '');
60
+ for (const step of finding.rootCause.chain ?? []) lines.push(`- ${step}`);
61
+ if (finding.rootCause.chain?.length) lines.push('');
62
+ }
63
+
64
+ lines.push(`**Suggested fix** — ${finding.fixDirection}`, '');
65
+
66
+ if (finding.regressionRisk?.level) {
67
+ lines.push(
68
+ `**Regression risk** — ${finding.regressionRisk.level}` +
69
+ (finding.regressionRisk.note ? `: ${finding.regressionRisk.note}` : ''),
70
+ '',
71
+ );
72
+ for (const item of finding.regressionRisk.retest ?? []) lines.push(`- Re-test: ${item}`);
73
+ if (finding.regressionRisk.retest?.length) lines.push('');
74
+ }
75
+
76
+ if (finding.developerNotes) lines.push(`**Developer notes** — ${finding.developerNotes}`, '');
77
+
78
+ if (finding.metrics?.length) {
79
+ lines.push(
80
+ table(
81
+ ['Measurement', 'Observed', 'Budget'],
82
+ finding.metrics.map((metric) => [metric.label, metric.value, metric.budget ?? '—']),
83
+ ),
84
+ );
85
+ }
86
+
87
+ // Evidence goes through the registry so a missing file is *stated* in Markdown too,
88
+ // rather than becoming a dead image reference in a ticket.
89
+ const evidence = (finding.evidence ?? [])
90
+ .map((entry) => ({ entry, record: model.registry.forEvidence(entry) }))
91
+ .filter((pair) => pair.record);
92
+ if (evidence.length > 0) {
93
+ lines.push('**Evidence**', '');
94
+ for (const { entry, record } of evidence) {
95
+ if (!record.exists) {
96
+ lines.push(`- *Artifact missing* — \`${record.declaredPath}\` (${record.missingReason})`);
97
+ continue;
98
+ }
99
+ if (record.renderAs === 'image') {
100
+ lines.push(`![${record.label ?? entry.type}](${record.href})`, '');
101
+ } else {
102
+ lines.push(`- [${record.label ?? entry.type}](${record.href})`);
103
+ }
104
+ if (entry.excerpt) lines.push('', '```', entry.excerpt, '```', '');
105
+ }
106
+ lines.push('');
107
+ }
108
+
109
+ return lines.join('\n');
110
+ }
111
+
112
+ /** Render the whole report as Markdown. */
113
+ export function renderMarkdown(result, options = {}) {
114
+ const model = buildModel(result, options);
115
+ const out = [];
116
+
117
+ out.push(`# ${model.subject} — QA Report`, '');
118
+ out.push(
119
+ table(
120
+ ['Field', 'Value'],
121
+ [
122
+ ['URL', model.url ?? '—'],
123
+ ['Generated', model.generatedAt ?? '—'],
124
+ model.environment ? ['Environment', model.environment] : null,
125
+ ['Verdict', `**${model.verdict.label}** — ${model.verdict.blurb}`],
126
+ ['Findings', String(model.totalFindings)],
127
+ model.durationMs !== null ? ['Duration', formatDuration(model.durationMs)] : null,
128
+ model.reportVersion ? ['Report version', `v${model.reportVersion}`] : null,
129
+ ['Tester', 'AI-assisted QA (qa-explore)'],
130
+ ].filter(Boolean),
131
+ ),
132
+ );
133
+
134
+ out.push('## Summary', '', model.summary, '');
135
+
136
+ if (model.verdict.risks.length > 0) {
137
+ out.push('### Why', '');
138
+ for (const risk of model.verdict.risks) out.push(`- ${risk}`);
139
+ out.push('');
140
+ }
141
+ if (model.verdict.recommendedAction) {
142
+ out.push(`**Recommended action** — ${model.verdict.recommendedAction}`, '');
143
+ }
144
+ if (model.verdict.estimatedFixHours) {
145
+ const { low, high } = model.verdict.estimatedFixHours;
146
+ out.push(`**Estimated fix time** — ${low === high ? low : `${low}–${high}`} hours`, '');
147
+ }
148
+
149
+ out.push(
150
+ '## Findings by severity',
151
+ '',
152
+ table(
153
+ SEVERITY_ORDER.map((key) => SEVERITY[key].label),
154
+ [SEVERITY_ORDER.map((key) => String(model.severityCounts[key] ?? 0))],
155
+ ),
156
+ );
157
+
158
+ if (model.scope?.objective) out.push('## Scope', '', model.scope.objective, '');
159
+ if (model.scope?.covered?.length) {
160
+ out.push('**Exercised**', '');
161
+ for (const item of model.scope.covered) out.push(`- ${item}`);
162
+ out.push('');
163
+ }
164
+ if (model.notCovered.length > 0) {
165
+ out.push('**Not covered in this run**', '');
166
+ for (const item of model.notCovered) out.push(`- ${item}`);
167
+ out.push('');
168
+ }
169
+
170
+ if (model.pages.length > 0) {
171
+ out.push(
172
+ '## Pages',
173
+ '',
174
+ table(
175
+ ['Page', 'State', 'HTTP', 'Load', 'Requests'],
176
+ model.pages.map((page) => [
177
+ page.title || page.url,
178
+ page.status,
179
+ page.httpStatus ?? '—',
180
+ page.loadMs !== undefined ? formatDuration(page.loadMs) : '—',
181
+ page.requests ?? '—',
182
+ ]),
183
+ ),
184
+ );
185
+ }
186
+
187
+ out.push('## Findings', '');
188
+ if (model.findings.length === 0) {
189
+ out.push('_No findings recorded._', '');
190
+ } else {
191
+ const sorted = [...model.findings].sort(
192
+ (a, b) =>
193
+ (SEVERITY[a.severity] ?? SEVERITY.low).rank - (SEVERITY[b.severity] ?? SEVERITY.low).rank ||
194
+ String(a.id).localeCompare(String(b.id), 'en', { numeric: true }),
195
+ );
196
+ for (const finding of sorted) out.push(findingBlock(finding, model), '');
197
+ }
198
+
199
+ if (model.performance) {
200
+ const rows = Object.entries(model.performance)
201
+ .filter(([, value]) => value !== null && value !== undefined && typeof value !== 'string')
202
+ .map(([key, value]) => [key, key.endsWith('Bytes') ? formatBytes(value) : String(value)]);
203
+ if (rows.length > 0) out.push('## Performance', '', table(['Metric', 'Value'], rows));
204
+ }
205
+
206
+ if (model.network?.endpoints?.length) {
207
+ out.push(
208
+ '## API and network',
209
+ '',
210
+ table(
211
+ ['Method', 'Endpoint', 'Status', 'Time', 'Size', 'Count', 'Issue'],
212
+ model.network.endpoints.slice(0, 40).map((endpoint) => [
213
+ endpoint.method,
214
+ endpoint.url,
215
+ endpoint.status ?? '—',
216
+ Number.isFinite(endpoint.durationMs) ? formatDuration(endpoint.durationMs) : '—',
217
+ Number.isFinite(endpoint.bytes) ? formatBytes(endpoint.bytes) : '—',
218
+ endpoint.count ?? 1,
219
+ endpoint.issue ?? '',
220
+ ]),
221
+ ),
222
+ );
223
+ }
224
+
225
+ if (model.security?.checks?.length) {
226
+ out.push(
227
+ '## Security',
228
+ '',
229
+ table(
230
+ ['Check', 'Result', 'Detail'],
231
+ model.security.checks.map((check) => [check.check, check.status, check.detail ?? '']),
232
+ ),
233
+ );
234
+ }
235
+
236
+ if (model.accessibility?.violations?.length) {
237
+ out.push(
238
+ '## Accessibility',
239
+ '',
240
+ table(
241
+ ['Rule', 'Impact', 'Instances', 'Description'],
242
+ model.accessibility.violations.map((violation) => [
243
+ violation.rule, violation.impact, violation.count, violation.description ?? '',
244
+ ]),
245
+ ),
246
+ );
247
+ }
248
+
249
+ if (model.testCases?.cases?.length) {
250
+ out.push(
251
+ '## Test case coverage',
252
+ '',
253
+ table(
254
+ ['ID', 'Case', 'Result', 'Finding'],
255
+ model.testCases.cases.map((testCase) => [
256
+ testCase.id, testCase.title, testCase.status, testCase.findingId ?? '—',
257
+ ]),
258
+ ),
259
+ );
260
+ }
261
+
262
+ if (model.dbValidation?.comparisons?.length) {
263
+ out.push(
264
+ '## Data validation',
265
+ '',
266
+ table(
267
+ ['Metric', 'UI', 'Source of truth', 'Result'],
268
+ model.dbValidation.comparisons.map((comparison) => [
269
+ comparison.metric, comparison.uiValue, comparison.sourceValue,
270
+ comparison.match ? 'Match' : 'Mismatch',
271
+ ]),
272
+ ),
273
+ );
274
+ }
275
+
276
+ if (model.whatWorksWell.length > 0) {
277
+ out.push('## What works well', '');
278
+ for (const item of model.whatWorksWell) out.push(`- ${item}`);
279
+ out.push('');
280
+ }
281
+
282
+ if (model.fixOrder.length > 0) {
283
+ out.push('## Suggested fix order', '');
284
+ model.fixOrder.forEach((item, index) => out.push(`${index + 1}. ${item}`));
285
+ out.push('');
286
+ }
287
+
288
+ if (model.recommendations.length > 0) {
289
+ out.push(
290
+ '## Recommendations',
291
+ '',
292
+ table(
293
+ ['Action', 'Priority', 'Owner', 'Effort'],
294
+ model.recommendations.map((recommendation) => [
295
+ recommendation.action,
296
+ recommendation.priority,
297
+ recommendation.owner ?? '—',
298
+ recommendation.effort ?? '—',
299
+ ]),
300
+ ),
301
+ );
302
+ }
303
+
304
+ const stats = model.artifactStats;
305
+ if (stats.total > 0) {
306
+ out.push(
307
+ '## Artifacts',
308
+ '',
309
+ `${stats.present} of ${stats.total} present (${formatBytes(stats.bytes)}).` +
310
+ (stats.missing > 0 ? ` **${stats.missing} missing.**` : ''),
311
+ '',
312
+ table(
313
+ ['Kind', 'File', 'State'],
314
+ model.registry
315
+ .list()
316
+ .filter((record) => record.declaredPath)
317
+ .map((record) => [record.kind, record.declaredPath, record.exists ? 'Present' : 'Missing']),
318
+ ),
319
+ );
320
+ }
321
+
322
+ return `${out.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n\n${footerMarkdown()}`;
323
+ }