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,270 @@
1
+ // Contract artifact → view model.
2
+ //
3
+ // Every renderer reads this shape, never the raw artifact. One place therefore owns
4
+ // the questions that would otherwise be answered slightly differently in eight
5
+ // sections: which verdict applies when `executive` is absent, what counts as a
6
+ // "covered" dimension, what the subject line of the report is, and — the one that
7
+ // matters most — what may be *derived* versus what must be *measured*.
8
+ //
9
+ // ## Derivation discipline
10
+ //
11
+ // A report is trusted because its numbers came from somewhere. So:
12
+ //
13
+ // derived — the overall score (a fixed function of severity counts), dimension
14
+ // filter counts, page/finding association, totals, the fallback verdict
15
+ // measured — every performance number, every score the producer supplied,
16
+ // artifact existence, request timings
17
+ //
18
+ // Nothing here invents a measurement. If a run never checked accessibility there is
19
+ // no accessibility score, and the report says the area was not examined rather than
20
+ // printing a plausible number. A fabricated 87 is worse than a blank, because the
21
+ // blank prompts a question and the 87 ends one.
22
+
23
+ import path from 'node:path';
24
+
25
+ import { createRegistry } from '../../artifacts/manager.mjs';
26
+ import { VERDICT, SEVERITY_ORDER } from '../theme/tokens.mjs';
27
+ import { DIMENSION_LABEL } from '../components/findings.mjs';
28
+ import { normalize } from './normalize.mjs';
29
+ import { versionStamp } from '../version.mjs';
30
+
31
+ /**
32
+ * What the report is *about*, short enough to be a heading.
33
+ *
34
+ * The contract has no title field on a 1.0 result and the summary is a paragraph —
35
+ * using it as an `<h1>` produced a five-line heading. The host is the honest short
36
+ * answer, the way Lighthouse titles a report by its URL.
37
+ */
38
+ export function subjectOf(result) {
39
+ if (result.title) return String(result.title);
40
+ const url = String(result.url ?? '').trim();
41
+ if (url) {
42
+ const withoutScheme = url.includes('://') ? url.slice(url.indexOf('://') + 3) : url;
43
+ return withoutScheme.replace(/\/+$/, '') || url;
44
+ }
45
+ return String(result.summary ?? 'QA report').split('.')[0].slice(0, 80);
46
+ }
47
+
48
+ /**
49
+ * The release decision.
50
+ *
51
+ * `executive.verdict` is authoritative when the run supplied one. Otherwise the
52
+ * classification maps onto the same surface, so a 1.0 result still renders a verdict
53
+ * banner instead of an empty strip. The severity counts are the last resort and the
54
+ * safest one: any critical means do-not-ship, whatever the prose says.
55
+ */
56
+ export function verdictOf(result) {
57
+ const executive = result.executive ?? {};
58
+ let key = executive.verdict;
59
+
60
+ if (!key) {
61
+ const counts = result.severityCounts ?? {};
62
+ if (result.classification === 'blocked') key = 'blocked';
63
+ else if (result.classification === 'insufficient-data') key = 'insufficient-data';
64
+ else if ((counts.critical ?? 0) > 0) key = 'do-not-ship';
65
+ else if ((counts.high ?? 0) > 0) key = 'ship-with-risks';
66
+ else key = result.classification === 'pass' ? 'pass' : 'issues-found';
67
+ }
68
+
69
+ const spec = VERDICT[key] ?? VERDICT['insufficient-data'];
70
+ return {
71
+ key,
72
+ label: spec.label,
73
+ tone: spec.tone,
74
+ blurb: executive.headline ?? spec.blurb,
75
+ headline: executive.headline ?? null,
76
+ health: executive.health ?? null,
77
+ risks: executive.risks ?? [],
78
+ recommendedAction: executive.recommendedAction ?? null,
79
+ estimatedFixHours: executive.estimatedFixHours ?? null,
80
+ confidence: Number.isFinite(executive.confidence) ? executive.confidence : null,
81
+ // True when nothing in the artifact stated a verdict and this one was inferred.
82
+ inferred: !executive.verdict,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * An overall score from severity counts.
88
+ *
89
+ * Deliberately a fixed, published function rather than a judgement: a critical costs
90
+ * 35 points, a high 12, a medium 4, a low 1, floored at zero. Two runs of the same
91
+ * application produce the same number, and a reader can check the arithmetic. It is
92
+ * shown as "derived" precisely so nobody mistakes it for a measurement.
93
+ */
94
+ export function deriveOverall(counts) {
95
+ const penalty =
96
+ (counts.critical ?? 0) * 35 + (counts.high ?? 0) * 12 + (counts.medium ?? 0) * 4 + (counts.low ?? 0) * 1;
97
+ return Math.max(0, 100 - penalty);
98
+ }
99
+
100
+ /** Dimension facets for the filter bar, counted from the findings themselves. */
101
+ function dimensionFacets(findings) {
102
+ const counts = new Map();
103
+ for (const finding of findings) {
104
+ const key = finding.dimension;
105
+ if (!key) continue;
106
+ counts.set(key, (counts.get(key) ?? 0) + 1);
107
+ }
108
+ return [...counts.entries()]
109
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
110
+ .map(([key, count]) => ({ key, label: DIMENSION_LABEL[key] ?? key, count }));
111
+ }
112
+
113
+ /**
114
+ * The boundary of the run, assembled from what was declared and what is derivable.
115
+ *
116
+ * An unstated boundary reads as "everything was checked". Blocked cases and unrun
117
+ * dimensions are boundaries the artifact already knows about, so they are added to
118
+ * whatever the run declared rather than left to the producer to remember.
119
+ */
120
+ function notCovered(result) {
121
+ const items = [...(result.scope?.notCovered ?? []).map(String)];
122
+ const ran = new Set(result.dimensionsRun ?? []);
123
+
124
+ if (ran.size > 0) {
125
+ for (const [key, label] of Object.entries(DIMENSION_LABEL)) {
126
+ if (ran.has(key)) continue;
127
+ items.push(`${label} was not examined in this run.`);
128
+ }
129
+ }
130
+
131
+ const db = result.dbValidation ?? {};
132
+ if (db.inScope === false && !db.summary) {
133
+ items.push('Data was not compared against the system of record — no access was provided.');
134
+ }
135
+
136
+ const blocked = (result.testCases?.cases ?? []).filter((testCase) => testCase.status === 'blocked');
137
+ if (blocked.length > 0) {
138
+ items.push(`Could not be run: ${blocked.map((c) => `${c.id} (${c.title})`).join(', ')}`);
139
+ }
140
+
141
+ return items;
142
+ }
143
+
144
+ /**
145
+ * Requests worth showing, ranked by how much trouble they represent.
146
+ *
147
+ * A raw endpoint list sorted by URL buries the 4-second call among ninety static
148
+ * assets. Failures first, then slow, then everything flagged with any issue.
149
+ */
150
+ function rankEndpoints(endpoints) {
151
+ const weight = (endpoint) => {
152
+ if (endpoint.issue === 'failed' || Number(endpoint.status) >= 500) return 0;
153
+ if (Number(endpoint.status) >= 400) return 1;
154
+ if (endpoint.issue === 'slow') return 2;
155
+ if (endpoint.issue) return 3;
156
+ return 4;
157
+ };
158
+ return [...endpoints].sort(
159
+ (a, b) => weight(a) - weight(b) || (Number(b.durationMs) || 0) - (Number(a.durationMs) || 0),
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Build the view model.
165
+ *
166
+ * `resultPath` locates the artifact on disk so evidence paths resolve against the
167
+ * right directory; `outPath` is where the document will be written, which is what
168
+ * every href is made relative to. They are usually the same directory and must never
169
+ * be assumed to be.
170
+ */
171
+ export function buildModel(input, options = {}) {
172
+ // Whatever the producer's contract, it becomes one shape here — and it is refused by
173
+ // name if this renderer has never been tested against it.
174
+ const result = normalize(input);
175
+
176
+ const resultPath = options.resultPath ? path.resolve(options.resultPath) : null;
177
+ const baseDir = options.baseDir
178
+ ? path.resolve(options.baseDir)
179
+ : resultPath
180
+ ? path.dirname(resultPath)
181
+ : process.cwd();
182
+ const outDir = options.outPath ? path.dirname(path.resolve(options.outPath)) : baseDir;
183
+
184
+ const registry = createRegistry(result, {
185
+ baseDir,
186
+ outDir,
187
+ hash: options.hash !== false,
188
+ embed: options.embed === true,
189
+ embedLimit: options.embedLimit,
190
+ // Set by the bundler, which copies evidence into its own tree and needs every link
191
+ // pointed at the copy rather than at wherever the file was found.
192
+ hrefMap: options.hrefMap ?? null,
193
+ });
194
+
195
+ const findings = [...(result.findings ?? [])];
196
+ const counts = result.severityCounts ?? { critical: 0, high: 0, medium: 0, low: 0 };
197
+ const testCases = result.testCases ?? null;
198
+ const suppliedScores = result.scores ?? {};
199
+
200
+ const scores = { ...suppliedScores };
201
+ const overallDerived = !Number.isFinite(suppliedScores.overall);
202
+ if (overallDerived) scores.overall = deriveOverall(counts);
203
+
204
+ const network = result.network ?? null;
205
+ const endpoints = rankEndpoints(network?.endpoints ?? []);
206
+
207
+ return {
208
+ // Identity
209
+ title: result.title ?? null,
210
+ subject: subjectOf(result),
211
+ url: result.url ?? null,
212
+ summary: result.summary ?? '',
213
+ generatedAt: result.generatedAt ?? null,
214
+ environment: result.environment ?? null,
215
+ reportVersion: result.reportVersion ?? null,
216
+ browserAdapter: result.browserAdapter ?? null,
217
+ durationMs: Number.isFinite(result.durationMs) ? result.durationMs : null,
218
+ contract: result.contract ?? null,
219
+ skill: result.skill ?? null,
220
+
221
+ // Decision
222
+ classification: result.classification ?? null,
223
+ verdict: verdictOf(result),
224
+ scores,
225
+ overallDerived,
226
+
227
+ // Findings
228
+ findings,
229
+ severityCounts: counts,
230
+ totalFindings: SEVERITY_ORDER.reduce((sum, key) => sum + (counts[key] ?? 0), 0),
231
+ dimensions: dimensionFacets(findings),
232
+
233
+ // Coverage
234
+ scope: result.scope ?? null,
235
+ dimensionsRun: result.dimensionsRun ?? [],
236
+ notCovered: notCovered(result),
237
+ testCases,
238
+ pages: result.pages ?? [],
239
+ authentication: result.authentication ?? null,
240
+
241
+ // Analysis
242
+ timeline: result.timeline ?? [],
243
+ network: network ? { ...network, endpoints } : null,
244
+ performance: result.performance ?? null,
245
+ accessibility: result.accessibility ?? null,
246
+ security: result.security ?? null,
247
+ console: result.console ?? null,
248
+ dbValidation: result.dbValidation ?? null,
249
+
250
+ // Release rollup only: the per-test failure list and the two written summaries.
251
+ failures: result.failureSummary ?? [],
252
+ summaries: result.summaries ?? null,
253
+
254
+ // Guidance
255
+ whatWorksWell: result.whatWorksWell ?? [],
256
+ fixOrder: result.fixOrder ?? [],
257
+ recommendations: result.recommendations ?? [],
258
+
259
+ // Evidence
260
+ registry,
261
+ evidence: result.evidence ?? [],
262
+ artifactStats: registry.stats(),
263
+
264
+ // Provenance, for the appendix. `producer` is displayed and never branched on:
265
+ // the renderer must behave identically whoever made the report.
266
+ producer: result.producer ?? null,
267
+ versions: versionStamp(),
268
+ paths: { resultPath, baseDir, outDir },
269
+ };
270
+ }
@@ -0,0 +1,226 @@
1
+ // Every input shape the renderer accepts, folded into one internal shape.
2
+ //
3
+ // ## Why this seam exists
4
+ //
5
+ // The promise is that a report looks the same whoever produced it — Claude Code,
6
+ // Cursor, Codex, a future agent nobody has written yet. That promise is only keepable
7
+ // if *no producer ever touches presentation*, which in turn is only enforceable if
8
+ // every producer hands over the same kind of thing: structured data, validated, and
9
+ // then rendered by code none of them can influence.
10
+ //
11
+ // This module is where "the same kind of thing" is defined. It accepts:
12
+ //
13
+ // qa-engineer/qa-report the canonical, producer-neutral report (schema 2.0)
14
+ // qa-explore/explore-result the exploratory contract (schema 1.x)
15
+ // qa-report/report-result the release rollup (schema 1.x)
16
+ //
17
+ // and returns one internal shape. Adding a fourth producer means adding a normalizer
18
+ // here — not a stylesheet, not a template, and certainly not a second renderer.
19
+ //
20
+ // ## Why the older contracts are not simply migrated
21
+ //
22
+ // Reports are archived. A result written a year ago must still render, and an
23
+ // installed skill emitting schema 1.1 must not break when the pack updates. So the
24
+ // older shapes are translated on read rather than deprecated on write, and the
25
+ // canonical shape is what new producers are pointed at.
26
+ //
27
+ // ## What a producer may not do
28
+ //
29
+ // Nothing here reads a colour, a class name, a font, or a layout hint, and the
30
+ // canonical schema has no field to carry one. An agent that wants its report to look
31
+ // different has no mechanism to make it so — which is the point.
32
+
33
+ import { schemaVersionOf, SUPPORTED_SCHEMA_VERSIONS, isSupportedSchema } from '../version.mjs';
34
+
35
+ export class SchemaError extends Error {
36
+ name = 'SchemaError';
37
+ }
38
+
39
+ /**
40
+ * The canonical report, unwrapped into the internal shape.
41
+ *
42
+ * The canonical form groups fields by audience — `summary` for the decision,
43
+ * `coverage` for what was looked at, `issues` for the detail — because that is how a
44
+ * producer thinks about filling it in. The internal shape is flat because that is how
45
+ * fifteen sections read from it. Neither is wrong; this function is the join.
46
+ */
47
+ function fromCanonical(report) {
48
+ const metadata = report.metadata ?? {};
49
+ const summary = report.summary ?? {};
50
+ const coverage = report.coverage ?? {};
51
+ const recommendations = report.recommendations ?? {};
52
+
53
+ return {
54
+ contract: { name: 'qa-engineer/qa-report', version: report.schemaVersion ?? '2.0' },
55
+ skill: metadata.producer
56
+ ? { name: metadata.producer.skill ?? metadata.producer.agent ?? 'unknown', version: metadata.producer.version ?? '' }
57
+ : undefined,
58
+ producer: metadata.producer ?? null,
59
+
60
+ title: metadata.title,
61
+ url: metadata.url,
62
+ environment: metadata.environment,
63
+ generatedAt: metadata.generatedAt,
64
+ durationMs: metadata.durationMs,
65
+ reportVersion: metadata.reportVersion,
66
+ browserAdapter: metadata.browserAdapter,
67
+ authentication: metadata.authentication,
68
+
69
+ summary: summary.text ?? '',
70
+ classification: summary.classification ?? null,
71
+ executive: {
72
+ verdict: summary.verdict,
73
+ headline: summary.headline,
74
+ health: summary.health,
75
+ risks: summary.risks,
76
+ recommendedAction: summary.recommendedAction,
77
+ estimatedFixHours: summary.estimatedFixHours,
78
+ confidence: summary.confidence,
79
+ },
80
+ scores: summary.scores,
81
+ severityCounts: summary.severityCounts ?? countSeverities(report.issues ?? []),
82
+
83
+ scope: {
84
+ objective: coverage.objective,
85
+ covered: coverage.covered,
86
+ notCovered: coverage.notCovered,
87
+ },
88
+ dimensionsRun: coverage.dimensionsRun,
89
+ pages: coverage.pages,
90
+ testCases: coverage.testCases,
91
+ timeline: coverage.timeline,
92
+
93
+ // `issues` is the canonical name; `findings` is what fifteen sections already call
94
+ // them. Renaming the sections to match would be churn with no reader benefit.
95
+ findings: report.issues ?? [],
96
+ artifacts: report.artifacts,
97
+ evidence: report.evidence ?? [],
98
+
99
+ performance: report.performance,
100
+ security: report.security,
101
+ accessibility: report.accessibility,
102
+ console: report.console,
103
+ network: report.network,
104
+ dbValidation: report.dataValidation,
105
+
106
+ fixOrder: recommendations.fixOrder,
107
+ recommendations: recommendations.actions,
108
+ whatWorksWell: recommendations.whatWorksWell,
109
+ };
110
+ }
111
+
112
+ /** Severity totals, when the producer did not count them itself. */
113
+ function countSeverities(issues) {
114
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
115
+ for (const issue of issues) {
116
+ if (counts[issue.severity] !== undefined) counts[issue.severity] += 1;
117
+ }
118
+ return counts;
119
+ }
120
+
121
+ /**
122
+ * Fold `qa-report/report-result` — the release rollup — into the same shape.
123
+ *
124
+ * The rollup describes the same event from further away: one summary of many runs
125
+ * rather than one run of a product. Its readiness verdict becomes the executive
126
+ * verdict and its test summary becomes the test counts. Its failure list stays a list
127
+ * of failures rather than being forced into `findings` with an invented severity for
128
+ * each row — a failing assertion is not a severity-ranked defect, and pretending
129
+ * otherwise would put fabricated numbers in the summary tiles.
130
+ */
131
+ function fromRollup(result) {
132
+ const readiness = result.releaseReadiness ?? {};
133
+ const tests = result.testSummary ?? {};
134
+ const summaries = result.summaries ?? {};
135
+
136
+ return {
137
+ ...result,
138
+ executive: result.executive ?? {
139
+ verdict: readiness.verdict ?? result.classification,
140
+ headline: readiness.rationale ?? result.summary,
141
+ health: summaries.executive ?? null,
142
+ },
143
+ severityCounts: result.severityCounts ?? { critical: 0, high: 0, medium: 0, low: 0 },
144
+ testCases: result.testCases ?? (Object.keys(tests).length > 0
145
+ ? {
146
+ total: tests.total ?? 0,
147
+ passed: tests.passed ?? 0,
148
+ failed: tests.failed ?? 0,
149
+ blocked: tests.blocked ?? 0,
150
+ skipped: tests.skipped ?? 0,
151
+ cases: [],
152
+ }
153
+ : null),
154
+ };
155
+ }
156
+
157
+ /** The exploratory contract is already the internal shape; nothing to translate. */
158
+ function fromExplore(result) {
159
+ return result;
160
+ }
161
+
162
+ const NORMALIZERS = Object.freeze({
163
+ 'qa-engineer/qa-report': fromCanonical,
164
+ 'qa-explore/explore-result': fromExplore,
165
+ 'qa-report/report-result': fromRollup,
166
+ });
167
+
168
+ /** Contract names this renderer accepts, for an error message and for `--help`. */
169
+ export function supportedContracts() {
170
+ return Object.keys(NORMALIZERS).sort();
171
+ }
172
+
173
+ /**
174
+ * Which producer made this report, for the provenance block.
175
+ *
176
+ * Recorded and displayed, never acted on: the renderer's behaviour must not vary by
177
+ * producer, or the guarantee that two agents produce identical output is only true
178
+ * until someone adds a special case.
179
+ */
180
+ export function producerOf(result) {
181
+ const producer = result?.metadata?.producer ?? result?.producer;
182
+ if (producer) {
183
+ return {
184
+ agent: producer.agent ?? null,
185
+ model: producer.model ?? null,
186
+ skill: producer.skill ?? null,
187
+ version: producer.version ?? null,
188
+ };
189
+ }
190
+ if (result?.skill) {
191
+ return { agent: null, model: null, skill: result.skill.name, version: result.skill.version };
192
+ }
193
+ return null;
194
+ }
195
+
196
+ /**
197
+ * Normalize any accepted contract into the internal shape.
198
+ *
199
+ * Throws rather than guessing. A result whose contract is unknown, or whose schema
200
+ * version this renderer has never been tested against, is refused by name — rendering
201
+ * it half-understood would produce a document that looks authoritative and is missing
202
+ * whatever the unknown version added.
203
+ */
204
+ export function normalize(result) {
205
+ const name = result?.contract?.name;
206
+ const normalizer = NORMALIZERS[name];
207
+
208
+ if (!normalizer) {
209
+ throw new SchemaError(
210
+ `no renderer for contract ${name === undefined ? 'None' : `'${name}'`}; supported: ` +
211
+ `${supportedContracts().join(', ')}`,
212
+ );
213
+ }
214
+
215
+ if (!isSupportedSchema(result)) {
216
+ throw new SchemaError(
217
+ `report declares schema version ${schemaVersionOf(result)}, which this renderer ` +
218
+ `does not support; supported: ${SUPPORTED_SCHEMA_VERSIONS.join(', ')}`,
219
+ );
220
+ }
221
+
222
+ const normalized = normalizer(result);
223
+ // Provenance survives normalization so the appendix can state who produced the
224
+ // report even when the contract carried it somewhere else.
225
+ return { ...normalized, producer: producerOf(result) };
226
+ }