automatica11y 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bin/automatica11y.js +4 -0
- package/package.json +38 -4
- package/src/cli.js +49 -0
- package/src/commands/audit.js +4 -0
- package/src/commands/common.js +274 -0
- package/src/commands/compare.js +4 -0
- package/src/commands/doctor.js +20 -0
- package/src/commands/init-skill.js +7 -0
- package/src/env/browser.js +136 -0
- package/src/env/versions.js +60 -0
- package/src/globals.d.ts +10 -0
- package/src/harness/browser.js +20 -0
- package/src/harness/bundle.js +49 -0
- package/src/harness/npm-install.js +65 -0
- package/src/harness/npm-react.js +39 -0
- package/src/harness/npm-wc.js +30 -0
- package/src/harness/shadow.js +42 -0
- package/src/harness/static-serve.js +68 -0
- package/src/harness/storybook.js +116 -0
- package/src/harness/url.js +41 -0
- package/src/plan/build-plan.js +62 -0
- package/src/plan/classify.js +173 -0
- package/src/plan/mapping.js +101 -0
- package/src/plan/resolve-npm.js +87 -0
- package/src/report/comparison.js +183 -0
- package/src/report/index.js +10 -0
- package/src/report/parts.js +334 -0
- package/src/report/single.js +16 -0
- package/src/run/audit-npm.js +279 -0
- package/src/run/fail-check.js +62 -0
- package/src/run/pool.js +21 -0
- package/src/run/run-plan.js +262 -0
- package/src/run/summary.js +49 -0
- package/src/schema.js +255 -0
- package/src/text.js +9 -0
- package/src/tiers/interactions/archetypes.js +417 -0
- package/src/tiers/interactions/helpers.js +145 -0
- package/src/tiers/interactions/index.js +107 -0
- package/src/tiers/rules/axe.js +75 -0
- package/src/tiers/rules/canvas.js +34 -0
- package/src/tiers/rules/ibm.js +121 -0
- package/src/tiers/rules/index.js +81 -0
- package/src/tiers/vsr.js +134 -0
- package/README.md +0 -3
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, extname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {"npm" | "storybook" | "url" | "html-file" | "static-dir"} TargetKind
|
|
8
|
+
* @typedef {{
|
|
9
|
+
* input: string,
|
|
10
|
+
* label: string | null,
|
|
11
|
+
* name: string,
|
|
12
|
+
* status: "ok" | "failed",
|
|
13
|
+
* reason: string | null,
|
|
14
|
+
* kind: TargetKind | null,
|
|
15
|
+
* evidenceLevel: "component" | "page" | null,
|
|
16
|
+
* resolved: Record<string, string | null> | null,
|
|
17
|
+
* }} ClassifiedTarget
|
|
18
|
+
* @typedef {(url: string | URL, init?: { signal?: AbortSignal, redirect?: string }) => Promise<{ ok: boolean, status: number, text(): Promise<string> }>} FetchLike
|
|
19
|
+
* @typedef {{ cwd?: string, home?: string, fetch?: FetchLike, timeoutMs?: number, npmView?: (spec: string) => Promise<any> }} ClassifyContext
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const LOCAL_PATH = /^(\.{1,2}(\/|$)|\/|~(\/|$)|file:)/;
|
|
23
|
+
const LABEL = /^([A-Za-z0-9][\w.-]*)=(.+)$/;
|
|
24
|
+
const HTTP_URL = /^https?:\/\//i;
|
|
25
|
+
const NPM_NAME = /^(?:@([a-z0-9][a-z0-9._~-]*)\/)?([a-z0-9][a-z0-9._~-]*)(?:@(.*))?$/;
|
|
26
|
+
const COMPONENT_SOURCE = new Set([".tsx", ".jsx", ".ts", ".js", ".mjs", ".cjs", ".vue", ".svelte"]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Split `[label=]<spec>`. A label is a short word followed by `=`, so URLs with query strings stay whole.
|
|
30
|
+
* @param {string} raw
|
|
31
|
+
*/
|
|
32
|
+
export function parseTargetInput(raw) {
|
|
33
|
+
const match = LABEL.exec(raw);
|
|
34
|
+
if (match) return { label: match[1], spec: match[2] };
|
|
35
|
+
return { label: null, spec: raw };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {string} reason @param {{ input: string, label: string | null, name: string }} base @returns {ClassifiedTarget} */
|
|
39
|
+
function failed(reason, base) {
|
|
40
|
+
return { ...base, status: "failed", reason, kind: null, evidenceLevel: null, resolved: null };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Expand `~` and `file:` into an absolute path. */
|
|
44
|
+
function toAbsolutePath(spec, cwd, home) {
|
|
45
|
+
if (spec.startsWith("file://")) return fileURLToPath(spec);
|
|
46
|
+
if (spec.startsWith("file:")) return resolve(cwd, spec.slice("file:".length));
|
|
47
|
+
if (spec === "~" || spec.startsWith("~/")) return resolve(home, spec.slice(2));
|
|
48
|
+
return resolve(cwd, spec);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A Storybook build has `index.json` (`entries`) or the older `stories.json` (`stories`) at its root. */
|
|
52
|
+
function readStorybookIndex(text) {
|
|
53
|
+
try {
|
|
54
|
+
const json = JSON.parse(text);
|
|
55
|
+
return Boolean(json && typeof json === "object" && (typeof json.entries === "object" || typeof json.stories === "object"));
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {string} abs @param {{ input: string, label: string | null, name: string }} base @returns {ClassifiedTarget} */
|
|
62
|
+
function classifyLocal(abs, base) {
|
|
63
|
+
if (!existsSync(abs)) return failed(`Path not found: ${abs}`, base);
|
|
64
|
+
const stats = statSync(abs);
|
|
65
|
+
if (stats.isDirectory()) {
|
|
66
|
+
for (const file of ["index.json", "stories.json"]) {
|
|
67
|
+
const candidate = resolve(abs, file);
|
|
68
|
+
if (existsSync(candidate) && readStorybookIndex(readFileSync(candidate, "utf8"))) {
|
|
69
|
+
return { ...base, status: "ok", reason: null, kind: "storybook", evidenceLevel: "component", resolved: { path: abs, index: file } };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { ...base, status: "ok", reason: null, kind: "static-dir", evidenceLevel: "page", resolved: { path: abs } };
|
|
73
|
+
}
|
|
74
|
+
const ext = extname(abs).toLowerCase();
|
|
75
|
+
if (ext === ".html" || ext === ".htm") {
|
|
76
|
+
return { ...base, status: "ok", reason: null, kind: "html-file", evidenceLevel: "page", resolved: { path: abs } };
|
|
77
|
+
}
|
|
78
|
+
if (COMPONENT_SOURCE.has(ext)) {
|
|
79
|
+
return failed(`Component source files (${ext}) aren't supported as targets. Use an npm package, a Storybook build, an .html file, or a URL.`, base);
|
|
80
|
+
}
|
|
81
|
+
return failed(`Unsupported file type "${ext || "(none)"}". Use an .html file, a directory, or a URL.`, base);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Directory that would hold a Storybook's `index.json` for this URL. */
|
|
85
|
+
function storybookBase(url) {
|
|
86
|
+
const base = new URL(url);
|
|
87
|
+
base.search = "";
|
|
88
|
+
base.hash = "";
|
|
89
|
+
const last = base.pathname.split("/").pop() ?? "";
|
|
90
|
+
if (base.pathname.endsWith("/")) return base;
|
|
91
|
+
base.pathname = last.includes(".") ? base.pathname.slice(0, base.pathname.length - last.length) : `${base.pathname}/`;
|
|
92
|
+
return base;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @param {string} spec @param {{ input: string, label: string | null, name: string }} base @param {ClassifyContext} ctx @returns {Promise<ClassifiedTarget>} */
|
|
96
|
+
async function classifyUrl(spec, base, ctx) {
|
|
97
|
+
const doFetch = ctx.fetch ?? globalThis.fetch;
|
|
98
|
+
const signal = () => AbortSignal.timeout(ctx.timeoutMs ?? 8000);
|
|
99
|
+
let url;
|
|
100
|
+
try {
|
|
101
|
+
url = new URL(spec);
|
|
102
|
+
} catch {
|
|
103
|
+
return failed(`Not a valid URL: ${spec}`, base);
|
|
104
|
+
}
|
|
105
|
+
const root = storybookBase(spec);
|
|
106
|
+
for (const file of ["index.json", "stories.json"]) {
|
|
107
|
+
try {
|
|
108
|
+
const response = await doFetch(new URL(file, root), { signal: signal(), redirect: "follow" });
|
|
109
|
+
if (response.ok && readStorybookIndex(await response.text())) {
|
|
110
|
+
return { ...base, status: "ok", reason: null, kind: "storybook", evidenceLevel: "component", resolved: { url: root.href, index: file } };
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// A failed probe means "not Storybook here." The page check below reports real network trouble.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const response = await doFetch(url, { signal: signal(), redirect: "follow" });
|
|
118
|
+
if (!response.ok) return failed(`The URL responded with HTTP ${response.status}.`, base);
|
|
119
|
+
return { ...base, status: "ok", reason: null, kind: "url", evidenceLevel: "page", resolved: { url: url.href } };
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return failed(`The URL didn't respond: ${error instanceof Error ? error.message : String(error)}`, base);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @param {string} spec @param {{ input: string, label: string | null, name: string }} base @returns {ClassifiedTarget} */
|
|
126
|
+
function classifyNpm(spec, base) {
|
|
127
|
+
const match = NPM_NAME.exec(spec);
|
|
128
|
+
if (!match) {
|
|
129
|
+
if (/[A-Z]/.test(spec) && NPM_NAME.test(spec.toLowerCase())) return failed(`"${spec}" isn't a valid package name. npm package names are lowercase.`, base);
|
|
130
|
+
return failed(`Can't classify "${spec}". Use a package name, an http(s) URL, or a path that starts with ./, ../, /, ~, or file:.`, base);
|
|
131
|
+
}
|
|
132
|
+
const [, scope, name, version] = match;
|
|
133
|
+
if (version === "") return failed(`"${spec}" ends with @ but has no version.`, base);
|
|
134
|
+
const full = scope ? `@${scope}/${name}` : name;
|
|
135
|
+
return {
|
|
136
|
+
...base,
|
|
137
|
+
name: base.name || full,
|
|
138
|
+
status: "ok",
|
|
139
|
+
reason: null,
|
|
140
|
+
kind: "npm",
|
|
141
|
+
evidenceLevel: "component",
|
|
142
|
+
resolved: { name: full, requested: version ?? null, version: null },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Classify one target. Order, first match wins: local path, Storybook, plain URL, npm package.
|
|
148
|
+
* A failure comes back as a `failed` target with a reason, never as a throw, so one bad target can't stop a comparison.
|
|
149
|
+
* @param {string} raw `[label=]<spec>`
|
|
150
|
+
* @param {ClassifyContext} [ctx]
|
|
151
|
+
* @returns {Promise<ClassifiedTarget>}
|
|
152
|
+
*/
|
|
153
|
+
export async function classifyTarget(raw, ctx = {}) {
|
|
154
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
155
|
+
const home = ctx.home ?? homedir();
|
|
156
|
+
const { label, spec } = parseTargetInput(raw.trim());
|
|
157
|
+
const base = { input: raw, label, name: label ?? "" };
|
|
158
|
+
|
|
159
|
+
if (LOCAL_PATH.test(spec) || spec === "." || spec === "..") {
|
|
160
|
+
const abs = toAbsolutePath(spec, cwd, home);
|
|
161
|
+
return classifyLocal(abs, { ...base, name: label ?? basename(abs) });
|
|
162
|
+
}
|
|
163
|
+
if (HTTP_URL.test(spec)) {
|
|
164
|
+
let host = spec;
|
|
165
|
+
try {
|
|
166
|
+
host = new URL(spec).hostname;
|
|
167
|
+
} catch {
|
|
168
|
+
// classifyUrl reports the invalid URL.
|
|
169
|
+
}
|
|
170
|
+
return classifyUrl(spec, { ...base, name: label ?? host }, ctx);
|
|
171
|
+
}
|
|
172
|
+
return classifyNpm(spec.startsWith("npm:") ? spec.slice("npm:".length) : spec, base);
|
|
173
|
+
}
|
|
@@ -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
|
+
}
|