automatica11y 0.0.0-stage → 0.3.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 (49) hide show
  1. package/AGENTS.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +136 -2
  4. package/bin/automatica11y.js +4 -0
  5. package/package.json +50 -4
  6. package/skills/automatica11y/SKILL.md +23 -0
  7. package/skills/automatica11y-runner/SKILL.md +181 -0
  8. package/skills/automatica11y-runner/references/fixtures.md +59 -0
  9. package/src/cli.js +49 -0
  10. package/src/commands/audit.js +4 -0
  11. package/src/commands/common.js +274 -0
  12. package/src/commands/compare.js +4 -0
  13. package/src/commands/doctor.js +20 -0
  14. package/src/commands/guide.js +45 -0
  15. package/src/env/browser.js +136 -0
  16. package/src/env/versions.js +60 -0
  17. package/src/globals.d.ts +10 -0
  18. package/src/harness/browser.js +20 -0
  19. package/src/harness/bundle.js +49 -0
  20. package/src/harness/npm-install.js +65 -0
  21. package/src/harness/npm-react.js +39 -0
  22. package/src/harness/npm-wc.js +30 -0
  23. package/src/harness/shadow.js +42 -0
  24. package/src/harness/static-serve.js +68 -0
  25. package/src/harness/storybook.js +116 -0
  26. package/src/harness/url.js +41 -0
  27. package/src/plan/build-plan.js +62 -0
  28. package/src/plan/classify.js +185 -0
  29. package/src/plan/mapping.js +101 -0
  30. package/src/plan/resolve-npm.js +87 -0
  31. package/src/report/comparison.js +183 -0
  32. package/src/report/index.js +10 -0
  33. package/src/report/parts.js +334 -0
  34. package/src/report/single.js +16 -0
  35. package/src/run/audit-npm.js +279 -0
  36. package/src/run/fail-check.js +62 -0
  37. package/src/run/pool.js +21 -0
  38. package/src/run/run-plan.js +262 -0
  39. package/src/run/summary.js +49 -0
  40. package/src/schema.js +255 -0
  41. package/src/text.js +9 -0
  42. package/src/tiers/interactions/archetypes.js +417 -0
  43. package/src/tiers/interactions/helpers.js +145 -0
  44. package/src/tiers/interactions/index.js +107 -0
  45. package/src/tiers/rules/axe.js +75 -0
  46. package/src/tiers/rules/canvas.js +34 -0
  47. package/src/tiers/rules/ibm.js +121 -0
  48. package/src/tiers/rules/index.js +81 -0
  49. package/src/tiers/vsr.js +134 -0
@@ -0,0 +1,101 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { ARCHETYPE_PATTERNS } from "../harness/storybook.js";
4
+ import { ARCHETYPES, parseMappingFile } from "../schema.js";
5
+
6
+ /** Names that mean the archetype itself, best first. */
7
+ const BASE_NAMES = {
8
+ button: ["button"],
9
+ link: ["link", "anchor"],
10
+ dialog: ["dialog", "modal"],
11
+ menu: ["menu", "dropdownmenu"],
12
+ tabs: ["tabs", "tablist"],
13
+ combobox: ["combobox", "autocomplete", "select"],
14
+ "form-field": ["input", "textfield", "textinput", "field", "checkbox"],
15
+ accordion: ["accordion", "collapsible", "disclosure"],
16
+ tooltip: ["tooltip", "popover"],
17
+ chart: ["chart", "linechart", "barchart"],
18
+ };
19
+
20
+ /** Archetypes a template can fill in. Everything else needs a fixture someone writes. */
21
+ export const TEMPLATED = new Set(["button", "link"]);
22
+
23
+ /** `DialogTrigger` becomes `Dialog Trigger`. `sl-button` becomes `sl button`. */
24
+ const words = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]/g, " ");
25
+
26
+ /** How well a name fits an archetype. 0 means it doesn't. */
27
+ function score(archetype, name) {
28
+ if (!ARCHETYPE_PATTERNS[archetype].test(words(name))) return 0;
29
+ const compact = name.replace(/[-_\s]/g, "").toLowerCase();
30
+ const last = words(name).toLowerCase().split(" ").pop();
31
+ const bases = BASE_NAMES[archetype];
32
+ if (bases.includes(compact)) return 4;
33
+ if (bases.includes(last)) return 3;
34
+ if (bases.some((base) => compact.startsWith(base))) return 2;
35
+ return 1;
36
+ }
37
+
38
+ /**
39
+ * Guess which exports (React) or tags (web components) stand for each archetype.
40
+ * The result is a starting point. A person or the skill checks it before trusting it.
41
+ * @param {{ flavor: "react" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
42
+ * @returns {Record<string, any>}
43
+ */
44
+ export function candidateMapping({ flavor, exports = [], tags = [] }) {
45
+ const names = flavor === "wc" ? tags : exports.filter((e) => /^[A-Z]/.test(e.name)).map((e) => e.name);
46
+ /** @type {Record<string, any>} */
47
+ const mapping = {};
48
+ for (const archetype of ARCHETYPES) {
49
+ const ranked = names
50
+ .map((name) => ({ name, score: score(archetype, name) }))
51
+ .filter((c) => c.score > 0)
52
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
53
+ if (ranked.length === 0) {
54
+ mapping[archetype] = { flavor, status: "no-match", candidates: [], fixture: null, reason: `No ${flavor === "wc" ? "custom element" : "export"} looks like the ${archetype} archetype.` };
55
+ continue;
56
+ }
57
+ const best = ranked[0].name;
58
+ const info = exports.find((e) => e.name === best);
59
+ const parts = info?.parts ?? [];
60
+ // Flat compound libraries (DialogRoot, DialogTrigger, DialogContent) have sibling exports that share a prefix.
61
+ const siblings = flavor === "react" ? names.filter((n) => n !== best && n.startsWith(best) && n.length > best.length) : [];
62
+ const compound = parts.length > 0 || siblings.length >= 2;
63
+ const templated = TEMPLATED.has(archetype) && !compound;
64
+ mapping[archetype] = {
65
+ flavor,
66
+ ...(flavor === "wc" ? { tag: best } : { export: best }),
67
+ status: templated ? "template" : "needs-fixture",
68
+ candidates: ranked.slice(0, 5).map((c) => c.name),
69
+ ...(parts.length ? { parts } : siblings.length ? { parts: siblings.slice(0, 12) } : {}),
70
+ fixture: null,
71
+ ...(templated ? {} : { reason: compound ? `${best} is built from parts, so a fixture has to assemble them.` : `The ${archetype} archetype needs a fixture someone writes.` }),
72
+ };
73
+ }
74
+ return mapping;
75
+ }
76
+
77
+ /** Read and validate a `--mapping` file. Throws an Error with a plain message. */
78
+ export function loadMappingFile(path, cwd) {
79
+ const file = resolve(cwd, path);
80
+ if (!existsSync(file)) throw new Error(`The mapping file doesn't exist: ${file}`);
81
+ let json;
82
+ try {
83
+ json = JSON.parse(readFileSync(file, "utf8"));
84
+ } catch (error) {
85
+ throw new Error(`The mapping file isn't valid JSON: ${error.message}`);
86
+ }
87
+ return parseMappingFile(json);
88
+ }
89
+
90
+ /** Where an authored fixture can sit when the mapping doesn't name one. */
91
+ export function findAuthoredFixture({ cwd, targetId, archetype, mapped }) {
92
+ if (mapped?.fixture) {
93
+ const file = resolve(cwd, mapped.fixture);
94
+ return existsSync(file) ? file : null;
95
+ }
96
+ for (const ext of ["jsx", "js"]) {
97
+ const file = resolve(cwd, "fixtures", targetId, `${archetype}.${ext}`);
98
+ if (existsSync(file)) return file;
99
+ }
100
+ return null;
101
+ }
@@ -0,0 +1,87 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated"];
4
+ const OTHER_FRAMEWORKS = {
5
+ vue: "Vue",
6
+ "@angular/core": "Angular",
7
+ svelte: "Svelte",
8
+ "solid-js": "Solid",
9
+ preact: "Preact",
10
+ "@builder.io/qwik": "Qwik",
11
+ "ember-source": "Ember",
12
+ };
13
+ const WEB_COMPONENT_BASES = ["lit", "lit-element", "@lit/reactive-element", "@stencil/core", "@microsoft/fast-element", "@polymer/polymer"];
14
+
15
+ /**
16
+ * Read a package's registry metadata without installing it.
17
+ * @param {string} spec `name`, `name@version`, or `name@range`
18
+ * @param {{ timeoutMs?: number }} [options]
19
+ * @returns {Promise<any>} The metadata. Throws an Error with a plain reason when the package can't be read.
20
+ */
21
+ export function npmView(spec, { timeoutMs = 60_000 } = {}) {
22
+ return new Promise((resolve, reject) => {
23
+ execFile(
24
+ process.platform === "win32" ? "npm.cmd" : "npm",
25
+ ["view", spec, ...FIELDS, "--json"],
26
+ { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, shell: process.platform === "win32" },
27
+ (error, stdout, stderr) => {
28
+ if (error) {
29
+ const text = `${stdout}\n${stderr}`;
30
+ if (/E404|404 Not Found|is not in this registry/.test(text)) return reject(new Error(`"${spec}" wasn't found on npm.`));
31
+ if (/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|network/i.test(text)) return reject(new Error("The npm registry couldn't be reached."));
32
+ if ((error).killed) return reject(new Error("The npm registry didn't answer in time."));
33
+ return reject(new Error(`npm couldn't read "${spec}": ${text.split("\n").find((line) => line.trim())?.trim() ?? error.message}`));
34
+ }
35
+ try {
36
+ const parsed = JSON.parse(stdout);
37
+ resolve(Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed);
38
+ } catch {
39
+ reject(new Error(`npm returned something unreadable for "${spec}".`));
40
+ }
41
+ },
42
+ );
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Guess how a package renders from its metadata alone. React wins when both signals appear.
48
+ * `npm` means the metadata can't say, so the run decides after it installs and loads the package.
49
+ * @param {any} meta
50
+ * @returns {{ kind: "npm-react" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
51
+ */
52
+ export function detectFlavor(meta) {
53
+ const peers = meta.peerDependencies ?? {};
54
+ const deps = meta.dependencies ?? {};
55
+ if ("react" in peers || "react-dom" in peers || "react" in deps) {
56
+ return { kind: "npm-react", framework: "React", reason: "The package lists react as a dependency." };
57
+ }
58
+ if (meta.customElements) return { kind: "npm-wc", framework: "Web components", reason: "The package has a customElements manifest." };
59
+ for (const [name, label] of Object.entries(OTHER_FRAMEWORKS)) {
60
+ if (name in peers) return { kind: "npm-unsupported", framework: label, reason: `The package needs ${label}.` };
61
+ }
62
+ const base = WEB_COMPONENT_BASES.find((name) => name in deps || name in peers);
63
+ if (base) return { kind: "npm-wc", framework: "Web components", reason: `The package builds on ${base}.` };
64
+ return { kind: "npm", framework: null, reason: "The metadata doesn't say. The run decides after it loads the package." };
65
+ }
66
+
67
+ /**
68
+ * Fill in a classified npm target: the concrete version and the framework guess.
69
+ * @param {import("./classify.js").ClassifiedTarget} target
70
+ * @param {(spec: string) => Promise<any>} view
71
+ * @returns {Promise<import("./classify.js").ClassifiedTarget>}
72
+ */
73
+ export async function resolveNpmTarget(target, view) {
74
+ if (target.status !== "ok" || target.kind !== "npm" || !target.resolved) return target;
75
+ const { name, requested } = target.resolved;
76
+ try {
77
+ const meta = await view(`${name}@${requested ?? "latest"}`);
78
+ const flavor = detectFlavor(meta);
79
+ return {
80
+ ...target,
81
+ kind: /** @type {any} */ (flavor.kind),
82
+ resolved: { ...target.resolved, version: meta.version ?? null, framework: flavor.framework, detectedBy: flavor.reason },
83
+ };
84
+ } catch (error) {
85
+ return { ...target, status: "failed", reason: error instanceof Error ? error.message : String(error), kind: null, evidenceLevel: null, resolved: null };
86
+ }
87
+ }
@@ -0,0 +1,183 @@
1
+ import { ARCHETYPES } from "../schema.js";
2
+ import { num, plural } from "../text.js";
3
+ import {
4
+ ENGINE_NAMES,
5
+ FINDINGS_NOTE,
6
+ TIER_NAMES,
7
+ cell,
8
+ code,
9
+ configLabel,
10
+ finish,
11
+ notTestableLines,
12
+ reportFooter,
13
+ reportHeader,
14
+ sentence,
15
+ targetSection,
16
+ } from "./parts.js";
17
+
18
+ const TIER_ORDER = ["rules", "interactions", "vsr"];
19
+
20
+ /** The archetype rows: component archetypes in a fixed order, then whole pages, then Storybook stories. */
21
+ function archetypeKeys(results) {
22
+ const keys = new Set();
23
+ for (const target of results.targets) {
24
+ for (const key of Object.keys(target.archetypes)) if (!key.startsWith("story:")) keys.add(key);
25
+ for (const key of Object.keys(target.storybook?.archetypeMatches ?? {})) keys.add(key);
26
+ }
27
+ return [...ARCHETYPES.filter((a) => keys.has(a)), ...[...keys].filter((k) => !ARCHETYPES.includes(k) && k !== "page"), ...(keys.has("page") ? ["page"] : [])];
28
+ }
29
+
30
+ /** Configs for one target and one archetype row, as labeled lines for the findings table. */
31
+ function rowsFor(planTarget, target, key) {
32
+ // A target that failed outright has nothing per archetype. One whose archetypes were all gaps still says what each gap was.
33
+ if (target.status !== "ran" && Object.keys(target.archetypes).length === 0) return [{ label: "-", note: `${target.status}: ${sentence(target.reason ?? "")}` }];
34
+ if (target.storybook) {
35
+ const ids = target.storybook.archetypeMatches[key] ?? [];
36
+ const stories = ids.map((id) => target.archetypes[`story:${id}`]).filter(Boolean);
37
+ if (key === "page") return [{ label: "-", note: "not-applicable: component evidence" }];
38
+ if (stories.length === 0) return [{ label: "-", note: "gap: no story matched this archetype" }];
39
+ const configs = stories.filter((s) => s.status === "ran").map((s) => s.configs[0]);
40
+ if (configs.length === 0) return [{ label: `${plural(stories.length, "story", "stories")}`, note: "gap: none of the matching stories rendered" }];
41
+ return [{ label: `${plural(configs.length, "story", "stories")} of ${num(stories.length)}`, configs, always: true }];
42
+ }
43
+ const archetype = target.archetypes[key];
44
+ if (!archetype) return [{ label: "-", note: key === "page" ? "not-applicable: component evidence" : "not-applicable: page evidence" }];
45
+ if (archetype.status === "gap") return [{ label: "-", note: `gap: ${sentence(archetype.reason ?? "no fixture")}` }];
46
+ return archetype.configs.map((config) => ({ label: configLabel(config).replace(/^ \(|\)$/g, "") || "-", configs: [config] }));
47
+ }
48
+
49
+ /** A coverage cell for one tier: what happened to that tier for the target and archetype. */
50
+ function tierStatus(rows, tier) {
51
+ const row = rows[0];
52
+ if (row.note) return row.note.split(":")[0];
53
+ const statuses = rows.flatMap((r) => r.configs.map((c) => c.tiers[tier]?.status).filter(Boolean));
54
+ if (statuses.length === 0) return "-";
55
+ if (statuses.every((s) => s === statuses[0])) return statuses[0];
56
+ return statuses.includes("ran") ? "partly ran" : statuses[0];
57
+ }
58
+
59
+ function coverageTable(plan, results, tier, keys) {
60
+ const head = ["Archetype", ...results.targets.map((t) => t.id)];
61
+ const lines = [`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`];
62
+ for (const key of keys) {
63
+ const cells = results.targets.map((target) => {
64
+ const planTarget = plan.targets.find((t) => t.id === target.id);
65
+ const rows = rowsFor(planTarget, target, key);
66
+ const status = tierStatus(rows, tier);
67
+ // Name the configuration only when there's more than one to tell apart, or the row stands for several stories.
68
+ const withTier = rows.filter((r) => r.configs?.some((c) => c.tiers[tier]));
69
+ const labels = withTier.length > 1 || withTier[0]?.always ? withTier.map((r) => r.label).filter((l) => l && l !== "-") : [];
70
+ return labels.length ? `${status} (${labels.join("; ")})` : status;
71
+ });
72
+ lines.push(`| ${key} | ${cells.join(" | ")} |`);
73
+ }
74
+ return lines;
75
+ }
76
+
77
+ /** Rule IDs for one engine across configs, with element counts, for a findings cell. */
78
+ function ruleCell(configs, engine) {
79
+ const found = new Map();
80
+ let review = 0;
81
+ let statuses = [];
82
+ for (const config of configs) {
83
+ const result = config.tiers.rules?.engines?.[engine];
84
+ if (!result) continue;
85
+ statuses.push(result.status);
86
+ if (result.status !== "ran") continue;
87
+ for (const finding of result.violations) found.set(finding.ruleId, (found.get(finding.ruleId) ?? 0) + finding.nodeCount);
88
+ review += result.incomplete.length;
89
+ }
90
+ if (statuses.length === 0) return "-";
91
+ if (!statuses.includes("ran")) return statuses[0];
92
+ const list = [...found.entries()].sort(([a], [b]) => a.localeCompare(b));
93
+ const shown = list.slice(0, 4).map(([id, n]) => `${code(id)} (${num(n)})`).join(", ");
94
+ const text = list.length ? `${shown}${list.length > 4 ? `, and ${num(list.length - 4)} more` : ""}` : "none found";
95
+ return `${text}${review ? `; ${num(review)} to review` : ""}`;
96
+ }
97
+
98
+ function interactionsCell(configs) {
99
+ const checks = configs.flatMap((c) => c.tiers.interactions?.checks ?? []);
100
+ if (checks.length === 0) return configs.map((c) => c.tiers.interactions?.status).find(Boolean) ?? "-";
101
+ const failed = checks.filter((c) => c.result === "fail").map((c) => code(c.name));
102
+ const errors = checks.filter((c) => c.result === "error").length;
103
+ const parts = [failed.length ? `failed: ${failed.slice(0, 3).join(", ")}${failed.length > 3 ? `, and ${num(failed.length - 3)} more` : ""}` : "no failures", errors ? `${plural(errors, "error")}` : null];
104
+ return parts.filter(Boolean).join("; ");
105
+ }
106
+
107
+ function vsrCell(configs) {
108
+ const walks = configs.map((c) => c.tiers.vsr).filter((v) => v?.status === "ran");
109
+ if (walks.length === 0) return configs.map((c) => c.tiers.vsr?.status).find(Boolean) ?? "-";
110
+ const flags = walks.flatMap((v) => v.flags.map((f) => f.phrase));
111
+ const unique = [...new Set(flags)];
112
+ return flags.length ? `${plural(flags.length, "phrase")} flagged: ${unique.slice(0, 3).map(code).join(", ")}` : "none flagged";
113
+ }
114
+
115
+ function findingsTable(plan, results, key) {
116
+ const head = ["Target", "Configuration", ENGINE_NAMES.axe, ENGINE_NAMES.ibm, "Interactions", "Virtual screen reader (simulated)"];
117
+ const lines = [`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`];
118
+ for (const target of results.targets) {
119
+ const planTarget = plan.targets.find((t) => t.id === target.id);
120
+ for (const row of rowsFor(planTarget, target, key)) {
121
+ if (row.note) {
122
+ lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(row.note)} | | | |`);
123
+ continue;
124
+ }
125
+ const c = row.configs;
126
+ lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(ruleCell(c, "axe"))} | ${cell(ruleCell(c, "ibm"))} | ${cell(interactionsCell(c))} | ${cell(vsrCell(c))} |`);
127
+ }
128
+ }
129
+ return lines;
130
+ }
131
+
132
+ /** Violations by axe impact and by IBM Toolkit level, in separate tables. Counts are never added across engines. */
133
+ function impactTables(results, engines) {
134
+ const lines = [];
135
+ if (engines.includes("axe")) {
136
+ lines.push("### axe-core, violations by impact.", "", "| Target | Critical | Serious | Moderate | Minor | Needs review |", "| --- | --- | --- | --- | --- | --- |");
137
+ for (const target of results.targets) {
138
+ const s = target.summary.engines.axe;
139
+ lines.push(s?.status === "ran" ? `| ${target.id} | ${["critical", "serious", "moderate", "minor"].map((k) => s.violationsByImpact[k]).join(" | ")} | ${s.needsReview} |` : `| ${target.id} | ${s?.status ?? target.status} | | | | |`);
140
+ }
141
+ lines.push("");
142
+ }
143
+ if (engines.includes("ibm")) {
144
+ lines.push("### IBM Equal Access, violations by Toolkit level.", "", "| Target | Level 1 | Level 2 | Level 3 | Level 4 | Needs review |", "| --- | --- | --- | --- | --- | --- |");
145
+ for (const target of results.targets) {
146
+ const s = target.summary.engines.ibm;
147
+ lines.push(s?.status === "ran" ? `| ${target.id} | ${["1", "2", "3", "4"].map((k) => s.violationsByToolkitLevel[k]).join(" | ")} | ${s.needsReview} |` : `| ${target.id} | ${s?.status ?? target.status} | | | | |`);
148
+ }
149
+ lines.push("");
150
+ }
151
+ return lines;
152
+ }
153
+
154
+ /**
155
+ * Render report.md for a comparison of two or more targets.
156
+ * Every target ran with the same settings, so the columns line up. A gap or a failure shows in its cell, and never reads as a pass.
157
+ * @param {{ plan: any, results: any }} input
158
+ */
159
+ export function renderComparison({ plan, results }) {
160
+ const o = plan.options;
161
+ const lines = reportHeader(plan, results, "Accessibility comparison.");
162
+ lines.push(
163
+ `Every target was checked with the same settings: WCAG ${o.wcag}, level ${o.level}, ${o.engines.map((e) => ENGINE_NAMES[e]).join(" and ")}, and the same archetypes${o.archetypes ? ` (${o.archetypes.join(", ")})` : ""}. A difference below comes from the targets and not from the settings.`,
164
+ "",
165
+ );
166
+ const keys = archetypeKeys(results);
167
+
168
+ lines.push("## Coverage.", "", "Each cell says what happened to that tier for that target and archetype: ran, gap, not-testable, not-applicable, skipped, or failed. A gap, a failure, or a target that wasn't testable is a finding. It never counts as a pass.", "");
169
+ for (const tier of TIER_ORDER.filter((t) => o.tiers.includes(t))) {
170
+ lines.push(`### ${TIER_NAMES[tier]}${tier === "vsr" ? " (simulated)" : ""}.`, "", ...coverageTable(plan, results, tier, keys), "");
171
+ }
172
+ lines.push(...notTestableLines(results));
173
+
174
+ lines.push("## Findings.", "", FINDINGS_NOTE, "", ...(o.tiers.includes("rules") ? impactTables(results, o.engines) : []));
175
+ for (const key of keys) {
176
+ lines.push(`### ${key === "page" ? "Whole pages" : key}.`, "", ...findingsTable(plan, results, key), "");
177
+ }
178
+
179
+ lines.push("## Details by target.", "", "Every finding, with its elements, rule help, and logs, for each target in turn.", "");
180
+ for (const target of results.targets) lines.push(targetSection(plan.targets.find((t) => t.id === target.id), target));
181
+ lines.push(...reportFooter(results));
182
+ return finish(lines);
183
+ }
@@ -0,0 +1,10 @@
1
+ import { renderComparison } from "./comparison.js";
2
+ import { renderSingleReport } from "./single.js";
3
+
4
+ /**
5
+ * Render report.md. A comparison of two or more targets gets the side-by-side report. Anything else gets the single report.
6
+ * @param {{ plan: any, results: any }} input
7
+ */
8
+ export function renderReport(input) {
9
+ return input.plan.command === "compare" && input.results.targets.length > 1 ? renderComparison(input) : renderSingleReport(input);
10
+ }