automatica11y 0.3.3 → 0.4.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/README.md +25 -7
- package/package.json +5 -3
- package/skills/automatica11y-runner/SKILL.md +44 -13
- package/skills/automatica11y-runner/references/fixtures.md +38 -2
- package/src/commands/common.js +5 -2
- package/src/frameworks/index.js +42 -0
- package/src/frameworks/react.js +77 -0
- package/src/frameworks/vue.js +90 -0
- package/src/frameworks/wc.js +55 -0
- package/src/globals.d.ts +1 -0
- package/src/harness/bundle.js +7 -6
- package/src/harness/generate/dialects.js +64 -0
- package/src/harness/generate/index.js +12 -0
- package/src/harness/generate/jsx-recipes.js +224 -0
- package/src/harness/generate/jsx.js +76 -0
- package/src/harness/generate/kit.js +50 -0
- package/src/harness/generate/marking.js +64 -0
- package/src/harness/generate/probe.js +97 -0
- package/src/harness/generate/shared.js +12 -0
- package/src/harness/generate/wc-recipes.js +132 -0
- package/src/harness/npm-install.js +38 -7
- package/src/harness/settle.js +17 -0
- package/src/harness/storybook.js +1 -0
- package/src/harness/url.js +13 -3
- package/src/plan/classify.js +11 -4
- package/src/plan/mapping.js +10 -7
- package/src/plan/resolve-npm.js +15 -9
- package/src/plan/subpath.js +133 -0
- package/src/report/comparison.js +14 -9
- package/src/report/parts.js +36 -12
- package/src/run/audit-npm.js +118 -28
- package/src/run/generate-fixture.js +74 -0
- package/src/run/run-plan.js +19 -4
- package/src/run/summary.js +5 -0
- package/src/schema.js +30 -6
- package/src/tiers/computed/checks.js +44 -5
- package/src/tiers/computed/color.js +8 -0
- package/src/tiers/computed/index.js +2 -2
- package/src/tiers/computed/measure-kit.js +30 -10
- package/src/tiers/conditions/checks.js +283 -0
- package/src/tiers/conditions/index.js +30 -0
- package/src/tiers/conditions/kit.js +133 -0
- package/src/tiers/interactions/archetypes.js +111 -0
- package/src/tiers/interactions/helpers.js +56 -0
- package/src/tiers/interactions/index.js +16 -3
- package/src/harness/npm-react.js +0 -39
- package/src/harness/npm-wc.js +0 -30
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidate web component fixtures, built from what the element says about itself once it's defined:
|
|
3
|
+
* the attributes it observes, the members its class has, and the slots its shadow root offers.
|
|
4
|
+
* A candidate is a guess until the probe has seen it behave (see probe.js). None of this names a library.
|
|
5
|
+
*/
|
|
6
|
+
import { markingSource } from "./marking.js";
|
|
7
|
+
import { ATTEMPT_LIMIT, GENERATABLE } from "./shared.js";
|
|
8
|
+
|
|
9
|
+
/** The module around every candidate: the marking code and a mount function that starts it. */
|
|
10
|
+
function frame(archetype, body) {
|
|
11
|
+
return `${markingSource(archetype)}
|
|
12
|
+
export default function mount(container) {
|
|
13
|
+
${body.split("\n").map((line) => (line ? ` ${line}` : line)).join("\n")}
|
|
14
|
+
startMarking();
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const has = (facts, name) => facts.attributes.includes(name) || facts.members.includes(name);
|
|
20
|
+
const slotFor = (facts, pattern) => facts.slots.find((name) => pattern.test(name));
|
|
21
|
+
|
|
22
|
+
// ---- dialog ----
|
|
23
|
+
|
|
24
|
+
/** The ways a dialog element is commonly opened, from what it has. */
|
|
25
|
+
function openers(facts) {
|
|
26
|
+
const found = [];
|
|
27
|
+
if (facts.attributes.includes("open")) found.push(["attribute", `dialog.setAttribute("open", "");`]);
|
|
28
|
+
if (facts.members.includes("open")) found.push(["property", "dialog.open = true;"]);
|
|
29
|
+
if (facts.members.includes("showModal")) found.push(["showModal()", "dialog.showModal();"]);
|
|
30
|
+
if (facts.members.includes("show")) found.push(["show()", "dialog.show();"]);
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function dialogs(tagName, facts) {
|
|
35
|
+
const titleSlot = slotFor(facts, /title|header|heading/i);
|
|
36
|
+
const heading = titleSlot ? `<h2 slot="${titleSlot}">Edit profile</h2>` : "<h2>Edit profile</h2>";
|
|
37
|
+
return openers(facts).map(([how, code]) => ({
|
|
38
|
+
id: `dialog-${how.replace(/[^a-z]+/gi, "-").replace(/-$/, "")}`,
|
|
39
|
+
summary: `a ${tagName} opened with its ${how}`,
|
|
40
|
+
source: frame("dialog", `container.innerHTML = \`<button type="button" data-a11y-trigger>Open dialog</button><${tagName}>${heading}<p>Update your details.</p><button type="button">Close</button></${tagName}>\`;
|
|
41
|
+
const dialog = container.querySelector(${JSON.stringify(tagName)});
|
|
42
|
+
container.querySelector("[data-a11y-trigger]").addEventListener("click", () => {
|
|
43
|
+
${code}
|
|
44
|
+
});`),
|
|
45
|
+
used: [tagName],
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- tooltip ----
|
|
50
|
+
|
|
51
|
+
function tooltips(tagName, facts) {
|
|
52
|
+
return facts.attributes.filter((name) => /^(tip|tooltip|content|text|label|title|message|description)$/.test(name)).map((name) => ({
|
|
53
|
+
id: `tooltip-${name}`,
|
|
54
|
+
summary: `a ${tagName} that takes its text from ${name}`,
|
|
55
|
+
source: frame("tooltip", `container.innerHTML = \`<${tagName} ${name}="Saves your work."><button type="button" data-a11y-trigger>Save</button></${tagName}>\`;`),
|
|
56
|
+
used: [tagName],
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- live region ----
|
|
61
|
+
|
|
62
|
+
function messages(tagName, facts) {
|
|
63
|
+
const list = [{
|
|
64
|
+
id: "message-appended",
|
|
65
|
+
summary: `a ${tagName} added to the page when the trigger is pressed`,
|
|
66
|
+
source: frame("live-region", `container.innerHTML = \`<button type="button" data-a11y-trigger>Show message</button><div id="slot"></div>\`;
|
|
67
|
+
container.querySelector("[data-a11y-trigger]").addEventListener("click", () => {
|
|
68
|
+
const message = document.createElement(${JSON.stringify(tagName)});
|
|
69
|
+
message.textContent = "Saved.";
|
|
70
|
+
container.querySelector("#slot").append(message);
|
|
71
|
+
});`),
|
|
72
|
+
used: [tagName],
|
|
73
|
+
}];
|
|
74
|
+
if (has(facts, "open")) {
|
|
75
|
+
list.push({
|
|
76
|
+
id: "message-open",
|
|
77
|
+
summary: `a ${tagName} shown by setting open`,
|
|
78
|
+
source: frame("live-region", `container.innerHTML = \`<button type="button" data-a11y-trigger>Show message</button><${tagName}>Saved.</${tagName}>\`;
|
|
79
|
+
const message = container.querySelector(${JSON.stringify(tagName)});
|
|
80
|
+
container.querySelector("[data-a11y-trigger]").addEventListener("click", () => message.setAttribute("open", ""));`),
|
|
81
|
+
used: [tagName],
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return list;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- form field ----
|
|
88
|
+
|
|
89
|
+
/** The element isn't marked as the trigger here. The marking code finds the input inside its shadow root, which is the thing a person types in. */
|
|
90
|
+
function fields(tagName, facts) {
|
|
91
|
+
if (!["value", "name", "label", "placeholder", "checked", "type"].some((name) => has(facts, name))) return [];
|
|
92
|
+
const list = [{
|
|
93
|
+
id: "field-wrapped-label",
|
|
94
|
+
summary: `a ${tagName} inside a wrapping label`,
|
|
95
|
+
source: frame("form-field", `container.innerHTML = \`<label>Name <${tagName}></${tagName}></label>\`;`),
|
|
96
|
+
used: [tagName],
|
|
97
|
+
}];
|
|
98
|
+
if (has(facts, "label")) {
|
|
99
|
+
list.push({
|
|
100
|
+
id: "field-label-attribute",
|
|
101
|
+
summary: `a ${tagName} with a label attribute`,
|
|
102
|
+
source: frame("form-field", `container.innerHTML = \`<${tagName} label="Name"></${tagName}>\`;`),
|
|
103
|
+
used: [tagName],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return list;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Candidates for one element and archetype. Archetypes with no recipe here (menu, tabs, accordion, combobox) return none,
|
|
111
|
+
* because those depend on child elements and slots that an element can't describe well enough to guess.
|
|
112
|
+
* @param {{ archetype: string, tag: string, facts: { attributes: string[], members: string[], slots: string[] } }} input
|
|
113
|
+
*/
|
|
114
|
+
export function wcCandidates({ archetype, tag, facts }) {
|
|
115
|
+
const build = { dialog: dialogs, tooltip: tooltips, "live-region": messages, "form-field": fields }[archetype];
|
|
116
|
+
return build ? build(tag, facts) : [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Candidates for a web component package, from what the element the mapping found says about itself.
|
|
121
|
+
* @param {{ archetype: string, entry: { tag?: string }, facts: Record<string, { attributes: string[], members: string[], slots: string[] }> }} input
|
|
122
|
+
* @returns {{ candidates: Array<{ id: string, summary: string, source: string, used: string[] }>, reason: string | null }}
|
|
123
|
+
*/
|
|
124
|
+
export function generateWc({ archetype, entry, facts }) {
|
|
125
|
+
if (!GENERATABLE.has(archetype)) return { candidates: [], reason: `Nothing is generated for the ${archetype} archetype.` };
|
|
126
|
+
const tag = entry.tag;
|
|
127
|
+
if (!tag || !facts[tag]) return { candidates: [], reason: "No custom element looks like this archetype, so there's nothing to build a fixture around." };
|
|
128
|
+
const candidates = wcCandidates({ archetype, tag, facts: facts[tag] }).slice(0, ATTEMPT_LIMIT);
|
|
129
|
+
return candidates.length
|
|
130
|
+
? { candidates, reason: null }
|
|
131
|
+
: { candidates: [], reason: `${tag} doesn't show a way to wire a ${archetype} (for example, an open attribute or a tip attribute), and no recipe covers a web component ${archetype} without one.` };
|
|
132
|
+
}
|
|
@@ -33,9 +33,9 @@ export function installedVersion(dir, name) {
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Install a package into its own directory, never next to another target's install.
|
|
36
|
-
* npm adds the peer dependencies. A React package also needs react-dom, so add
|
|
36
|
+
* npm adds the peer dependencies. A React package also needs react-dom, and a Vue package needs vue, so add them when the peers left them out.
|
|
37
37
|
* Install scripts stay off, because the code is untrusted until it runs in the browser sandbox.
|
|
38
|
-
* @param {{ dir: string, name: string, version: string, flavor: "react" | "wc" | "unknown", run?: typeof runNpm }} options
|
|
38
|
+
* @param {{ dir: string, name: string, version: string, flavor: "react" | "vue" | "wc" | "unknown", run?: typeof runNpm }} options
|
|
39
39
|
*/
|
|
40
40
|
export async function installPackage({ dir, name, version, flavor, run = runNpm }) {
|
|
41
41
|
mkdirSync(dir, { recursive: true });
|
|
@@ -69,15 +69,46 @@ export async function installPackage({ dir, name, version, flavor, run = runNpm
|
|
|
69
69
|
await npm(["install", "react", "react-dom", ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
70
70
|
warnings.push(`${name} didn't bring in react, so the latest react and react-dom were added.`);
|
|
71
71
|
} else if (!installedVersion(dir, "react-dom")) {
|
|
72
|
-
|
|
72
|
+
// Name react too. A loose install prunes a package that only arrived as a peer, and react did.
|
|
73
|
+
await npm(["install", `react@${react}`, `react-dom@${react}`, ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
|
-
|
|
76
|
+
if (flavor === "vue" && !installedVersion(dir, "vue")) {
|
|
77
|
+
await npm(["install", "vue", ...NPM_FLAGS, "--legacy-peer-deps"]);
|
|
78
|
+
warnings.push(`${name} didn't bring in vue, so the latest vue was added.`);
|
|
79
|
+
}
|
|
80
|
+
return { dir, warnings, react: installedVersion(dir, "react"), reactDom: installedVersion(dir, "react-dom"), vue: installedVersion(dir, "vue"), version: installedVersion(dir, name) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The installed framework runtimes (React, React DOM, Vue), named again so a loose install can't prune them. */
|
|
84
|
+
function pinnedRuntime(dir) {
|
|
85
|
+
return ["react", "react-dom", "vue"].flatMap((name) => (installedVersion(dir, name) ? [`${name}@${installedVersion(dir, name)}`] : []));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const PACKAGE_SPEC = /^(@[a-z0-9~][\w.~-]*\/)?[a-z0-9~][\w.~-]*(@[\w.^~<>=*|-]+)?$/i;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Install packages a mapping names beside the target: a token stylesheet, a theme, or another package the library's own
|
|
92
|
+
* documentation says to load. Only plain package specs are accepted (no flags, paths, or URLs). Install scripts stay off.
|
|
93
|
+
* @param {{ dir: string, specs: string[], run?: typeof runNpm }} options
|
|
94
|
+
* @returns {Promise<{ installed: string[], warnings: string[] }>}
|
|
95
|
+
*/
|
|
96
|
+
export async function installExtraPackages({ dir, specs, run = runNpm }) {
|
|
97
|
+
const wanted = [...new Set(specs)];
|
|
98
|
+
if (wanted.length === 0) return { installed: [], warnings: [] };
|
|
99
|
+
for (const spec of wanted) if (!PACKAGE_SPEC.test(spec)) throw new Error(`The mapping's install list has "${spec}", which isn't a package name with an optional version.`);
|
|
100
|
+
try {
|
|
101
|
+
await run(["install", ...wanted, ...pinnedRuntime(dir), "--legacy-peer-deps", ...NPM_FLAGS], dir);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new Error(`npm couldn't install ${wanted.join(", ")}: ${firstLine(error.message)}`);
|
|
104
|
+
}
|
|
105
|
+
return { installed: wanted, warnings: [`The mapping named ${wanted.join(", ")} to install beside the library, so it was installed.`] };
|
|
76
106
|
}
|
|
77
107
|
|
|
78
108
|
/**
|
|
79
109
|
* Find the optional peer dependencies that installed packages declare, with the range each asks for.
|
|
80
|
-
* npm leaves these out, but a library's default setup can still need one
|
|
110
|
+
* npm leaves these out, but a library's default setup can still need one
|
|
111
|
+
* (e.g., React libraries may need an Emotion package).
|
|
81
112
|
* @param {string} dir The install folder.
|
|
82
113
|
* @returns {Map<string, string>} Package name to range.
|
|
83
114
|
*/
|
|
@@ -113,8 +144,8 @@ export async function installOptionalPeers({ dir, unresolved, run = runNpm }) {
|
|
|
113
144
|
const declared = declaredOptionalPeers(dir);
|
|
114
145
|
const wanted = unresolved.filter((name) => declared.has(name) && !installedVersion(dir, name));
|
|
115
146
|
if (wanted.length === 0) return { installed: [], warnings: [] };
|
|
116
|
-
// A loose install prunes packages that only arrived as peers, so name
|
|
117
|
-
const keep =
|
|
147
|
+
// A loose install prunes packages that only arrived as peers, so name the framework again to keep it.
|
|
148
|
+
const keep = pinnedRuntime(dir);
|
|
118
149
|
const specs = [...wanted.map((name) => `${name}@${declared.get(name)}`), ...keep];
|
|
119
150
|
try {
|
|
120
151
|
await run(["install", ...specs, "--legacy-peer-deps", ...NPM_FLAGS], dir);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wait for fades and slides to finish before a state is scanned, so contrast is measured on the settled colors and not on a
|
|
3
|
+
* half-transparent element mid-transition. Animations that never end (a spinner) are left alone. Waits at most 2 seconds.
|
|
4
|
+
* @param {import("playwright-core").Page} page
|
|
5
|
+
*/
|
|
6
|
+
export async function settleAnimations(page) {
|
|
7
|
+
await page.evaluate(async () => {
|
|
8
|
+
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
9
|
+
await frame();
|
|
10
|
+
const finite = () => document.getAnimations().filter((a) => a.effect && a.effect.getComputedTiming().endTime !== Infinity);
|
|
11
|
+
const deadline = Date.now() + 2000;
|
|
12
|
+
while (finite().length > 0 && Date.now() < deadline) {
|
|
13
|
+
await Promise.race([Promise.allSettled(finite().map((a) => a.finished)), new Promise((done) => setTimeout(done, 200))]);
|
|
14
|
+
await frame();
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
package/src/harness/storybook.js
CHANGED
|
@@ -12,6 +12,7 @@ export const ARCHETYPE_PATTERNS = {
|
|
|
12
12
|
"form-field": /\binputs?\b|\btext ?(field|area|box)\b|\bform\b|\bcheckbox(es)?\b|\bradio\b|\bswitch\b|\bfield\b/i,
|
|
13
13
|
accordion: /\baccordions?\b|\bcollaps(e|ible)\b|\bdisclosure\b/i,
|
|
14
14
|
tooltip: /\btooltips?\b|\bpopovers?\b/i,
|
|
15
|
+
"live-region": /\balerts?\b(?! ?dialogs?)|\bstatus\b|\btoasts?\b|\bsnackbars?\b|\bnotifications?\b|\blive ?regions?\b/i,
|
|
15
16
|
chart: /\bcharts?\b|\bgraphs?\b|\bplots?\b/i,
|
|
16
17
|
};
|
|
17
18
|
|
package/src/harness/url.js
CHANGED
|
@@ -8,12 +8,12 @@ const NETWORK_IDLE_MS = 30_000;
|
|
|
8
8
|
* The returned `warnings` hold anything the report should mention, such as a network that never went idle.
|
|
9
9
|
* @param {import("playwright-core").Browser} browser
|
|
10
10
|
* @param {string} url
|
|
11
|
-
* @param {{ viewport?: { width: number, height: number }, forcedColors?: boolean, beforeGoto?: (page: import("playwright-core").Page) => void | Promise<unknown>, waitUntil?: "load" | "networkidle" }} [options]
|
|
11
|
+
* @param {{ viewport?: { width: number, height: number }, forcedColors?: boolean, colorScheme?: "light" | "dark", reducedMotion?: boolean, contrast?: "more" | "less", reducedTransparency?: boolean, beforeGoto?: (page: import("playwright-core").Page) => void | Promise<unknown>, waitUntil?: "load" | "networkidle" }} [options]
|
|
12
12
|
* `beforeGoto` runs before navigation, so a caller can attach console listeners that see the first messages.
|
|
13
13
|
* `waitUntil` defaults to `networkidle`. Local fixture pages don't need to wait for the network.
|
|
14
14
|
*/
|
|
15
|
-
export async function openPage(browser, url, { viewport = VIEWPORT, forcedColors = false, beforeGoto, waitUntil = "networkidle" } = {}) {
|
|
16
|
-
const context = await browser.newContext({ viewport, deviceScaleFactor: 1, forcedColors: forcedColors ? "active" : "none" });
|
|
15
|
+
export async function openPage(browser, url, { viewport = VIEWPORT, forcedColors = false, colorScheme = "light", reducedMotion = false, contrast, reducedTransparency = false, beforeGoto, waitUntil = "networkidle" } = {}) {
|
|
16
|
+
const context = await browser.newContext({ viewport, deviceScaleFactor: 1, forcedColors: forcedColors ? "active" : "none", colorScheme, reducedMotion: reducedMotion ? "reduce" : "no-preference" });
|
|
17
17
|
await context.addInitScript(recordClosedShadowRoots);
|
|
18
18
|
await context.addInitScript(recordCustomElements);
|
|
19
19
|
/** @type {string[]} */
|
|
@@ -21,6 +21,16 @@ export async function openPage(browser, url, { viewport = VIEWPORT, forcedColors
|
|
|
21
21
|
try {
|
|
22
22
|
const page = await context.newPage();
|
|
23
23
|
page.setDefaultTimeout(30_000);
|
|
24
|
+
// Playwright can't set prefers-reduced-transparency or prefers-contrast: less, so ask the browser directly.
|
|
25
|
+
// A page opened this way sets nothing else, because the call replaces any other emulated media features.
|
|
26
|
+
const features = [
|
|
27
|
+
...(contrast ? [{ name: "prefers-contrast", value: contrast }] : []),
|
|
28
|
+
...(reducedTransparency ? [{ name: "prefers-reduced-transparency", value: "reduce" }] : []),
|
|
29
|
+
];
|
|
30
|
+
if (features.length) {
|
|
31
|
+
const session = await context.newCDPSession(page);
|
|
32
|
+
await session.send("Emulation.setEmulatedMedia", { features });
|
|
33
|
+
}
|
|
24
34
|
await beforeGoto?.(page);
|
|
25
35
|
let response;
|
|
26
36
|
try {
|
package/src/plan/classify.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, extname, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { cleanSubpath } from "./subpath.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* @typedef {"npm" | "storybook" | "url" | "html-file" | "static-dir"} TargetKind
|
|
@@ -22,7 +23,7 @@ import { fileURLToPath } from "node:url";
|
|
|
22
23
|
const LOCAL_PATH = /^(\.{1,2}(\/|$)|\/|~(\/|$)|file:)/;
|
|
23
24
|
const LABEL = /^([A-Za-z0-9][\w.-]*)=(.+)$/;
|
|
24
25
|
const HTTP_URL = /^https?:\/\//i;
|
|
25
|
-
const NPM_NAME = /^(?:@([a-z0-9][a-z0-9._~-]*)\/)?([a-z0-9][a-z0-9._~-]*)(?:@(
|
|
26
|
+
const NPM_NAME = /^(?:@([a-z0-9][a-z0-9._~-]*)\/)?([a-z0-9][a-z0-9._~-]*)(?:@([^/]*))?(?:\/(.+))?$/;
|
|
26
27
|
const COMPONENT_SOURCE = new Set([".tsx", ".jsx", ".ts", ".js", ".mjs", ".cjs", ".vue", ".svelte"]);
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -129,17 +130,23 @@ function classifyNpm(spec, base) {
|
|
|
129
130
|
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
131
|
return failed(`"${spec}" isn't a valid npm package name. Write npm:name, npm:@scope/name, or npm:name@version.`, base);
|
|
131
132
|
}
|
|
132
|
-
const [, scope, name, version] = match;
|
|
133
|
+
const [, scope, name, version, rawSubpath] = match;
|
|
133
134
|
if (version === "") return failed(`"${spec}" ends with @ but has no version.`, base);
|
|
135
|
+
let subpath = null;
|
|
136
|
+
if (rawSubpath !== undefined) {
|
|
137
|
+
const cleaned = cleanSubpath(rawSubpath);
|
|
138
|
+
if ("reason" in cleaned) return failed(`"${spec}" isn't a valid sub-path: ${cleaned.reason}. Write npm:name/sub/path or npm:name@version/sub/path.`, base);
|
|
139
|
+
subpath = /** @type {{ subpath: string }} */ (cleaned).subpath;
|
|
140
|
+
}
|
|
134
141
|
const full = scope ? `@${scope}/${name}` : name;
|
|
135
142
|
return {
|
|
136
143
|
...base,
|
|
137
|
-
name: base.name || full,
|
|
144
|
+
name: base.name || (subpath ? `${full}/${subpath}` : full),
|
|
138
145
|
status: "ok",
|
|
139
146
|
reason: null,
|
|
140
147
|
kind: "npm",
|
|
141
148
|
evidenceLevel: "component",
|
|
142
|
-
resolved: { name: full, requested: version ?? null, version: null },
|
|
149
|
+
resolved: { name: full, requested: version ?? null, version: null, subpath },
|
|
143
150
|
};
|
|
144
151
|
}
|
|
145
152
|
|
package/src/plan/mapping.js
CHANGED
|
@@ -14,6 +14,7 @@ const BASE_NAMES = {
|
|
|
14
14
|
"form-field": ["input", "textfield", "textinput", "field", "checkbox"],
|
|
15
15
|
accordion: ["accordion", "collapsible", "disclosure"],
|
|
16
16
|
tooltip: ["tooltip", "popover"],
|
|
17
|
+
"live-region": ["alert", "status", "toast", "snackbar", "notification", "liveregion"],
|
|
17
18
|
chart: ["chart", "linechart", "barchart"],
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -24,21 +25,23 @@ export const TEMPLATED = new Set(["button", "link"]);
|
|
|
24
25
|
const words = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]/g, " ");
|
|
25
26
|
|
|
26
27
|
/** How well a name fits an archetype. 0 means it doesn't. */
|
|
27
|
-
function score(archetype, name) {
|
|
28
|
+
export function score(archetype, name) {
|
|
28
29
|
if (!ARCHETYPE_PATTERNS[archetype].test(words(name))) return 0;
|
|
29
30
|
const compact = name.replace(/[-_\s]/g, "").toLowerCase();
|
|
30
31
|
const last = words(name).toLowerCase().split(" ").pop();
|
|
31
32
|
const bases = BASE_NAMES[archetype];
|
|
32
|
-
|
|
33
|
-
if (bases.includes(
|
|
34
|
-
if (bases.
|
|
33
|
+
// Names listed first are closer to the archetype itself, so `tooltip` beats `popover` when both are there.
|
|
34
|
+
if (bases.includes(compact)) return 4 - bases.indexOf(compact) * 0.1;
|
|
35
|
+
if (bases.includes(last)) return 3 - bases.indexOf(last) * 0.1;
|
|
36
|
+
const starts = bases.findIndex((base) => compact.startsWith(base));
|
|
37
|
+
if (starts !== -1) return 2 - starts * 0.1;
|
|
35
38
|
return 1;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/**
|
|
39
|
-
* Guess which exports (React) or tags (web components) stand for each archetype.
|
|
42
|
+
* Guess which exports (React or Vue) or tags (web components) stand for each archetype.
|
|
40
43
|
* 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
|
|
44
|
+
* @param {{ flavor: "react" | "vue" | "wc", exports?: Array<{ name: string, type: string, parts: string[] }>, tags?: string[] }} input
|
|
42
45
|
* @returns {Record<string, any>}
|
|
43
46
|
*/
|
|
44
47
|
export function candidateMapping({ flavor, exports = [], tags = [] }) {
|
|
@@ -59,7 +62,7 @@ export function candidateMapping({ flavor, exports = [], tags = [] }) {
|
|
|
59
62
|
const parts = info?.parts ?? [];
|
|
60
63
|
// Flat compound libraries (DialogRoot, DialogTrigger, DialogContent) have sibling exports that share a prefix.
|
|
61
64
|
// A button or link usually sits beside ButtonBase, ButtonGroup, and the like, which aren't its parts, so only real parts (Button.Root) count there.
|
|
62
|
-
const siblings = flavor
|
|
65
|
+
const siblings = flavor !== "wc" && !TEMPLATED.has(archetype) ? names.filter((n) => n !== best && n.startsWith(best) && n.length > best.length) : [];
|
|
63
66
|
const compound = parts.length > 0 || siblings.length >= 2;
|
|
64
67
|
const templated = TEMPLATED.has(archetype) && !compound;
|
|
65
68
|
mapping[archetype] = {
|
package/src/plan/resolve-npm.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { ADAPTERS } from "../frameworks/index.js";
|
|
3
|
+
import { checkExports, notExportedMessage } from "./subpath.js";
|
|
2
4
|
|
|
3
|
-
const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated"];
|
|
5
|
+
const FIELDS = ["name", "version", "peerDependencies", "dependencies", "keywords", "customElements", "deprecated", "exports"];
|
|
4
6
|
const OTHER_FRAMEWORKS = {
|
|
5
|
-
vue: "Vue",
|
|
6
7
|
"@angular/core": "Angular",
|
|
7
8
|
svelte: "Svelte",
|
|
8
9
|
"solid-js": "Solid",
|
|
@@ -44,19 +45,19 @@ export function npmView(spec, { timeoutMs = 60_000 } = {}) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
|
-
* Guess how a package renders from its metadata alone. React wins when both signals appear.
|
|
48
|
+
* Guess how a package renders from its metadata alone. React wins when both signals appear, then a custom elements manifest, then Vue.
|
|
48
49
|
* `npm` means the metadata can't say, so the run decides after it installs and loads the package.
|
|
49
50
|
* @param {any} meta
|
|
50
|
-
* @returns {{ kind: "npm-react" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
|
|
51
|
+
* @returns {{ kind: "npm-react" | "npm-vue" | "npm-wc" | "npm-unsupported" | "npm", framework: string | null, reason: string }}
|
|
51
52
|
*/
|
|
52
53
|
export function detectFlavor(meta) {
|
|
53
54
|
const peers = meta.peerDependencies ?? {};
|
|
54
55
|
const deps = meta.dependencies ?? {};
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return { kind: "npm-react", framework: "React", reason: `The package lists react as a ${how}.` };
|
|
58
|
-
}
|
|
56
|
+
const react = /** @type {any} */ (ADAPTERS.react.detect(meta));
|
|
57
|
+
if (react) return react;
|
|
59
58
|
if (meta.customElements) return { kind: "npm-wc", framework: "Web components", reason: "The package has a customElements manifest." };
|
|
59
|
+
const vue = /** @type {any} */ (ADAPTERS.vue.detect(meta));
|
|
60
|
+
if (vue) return vue;
|
|
60
61
|
for (const [name, label] of Object.entries(OTHER_FRAMEWORKS)) {
|
|
61
62
|
if (name in peers) return { kind: "npm-unsupported", framework: label, reason: `The package needs ${label}.` };
|
|
62
63
|
}
|
|
@@ -73,9 +74,14 @@ export function detectFlavor(meta) {
|
|
|
73
74
|
*/
|
|
74
75
|
export async function resolveNpmTarget(target, view) {
|
|
75
76
|
if (target.status !== "ok" || target.kind !== "npm" || !target.resolved) return target;
|
|
76
|
-
const { name, requested } = target.resolved;
|
|
77
|
+
const { name, requested, subpath } = target.resolved;
|
|
77
78
|
try {
|
|
78
79
|
const meta = await view(`${name}@${requested ?? "latest"}`);
|
|
80
|
+
// The registry lists a package's exports, so a wrong sub-path is caught here, before anything is installed.
|
|
81
|
+
if (subpath) {
|
|
82
|
+
const result = checkExports(meta.exports, subpath);
|
|
83
|
+
if (result.checked && !result.ok) throw new Error(notExportedMessage({ name, version: meta.version ?? null, subpath, exact: result.exact, patterns: result.patterns }));
|
|
84
|
+
}
|
|
79
85
|
const flavor = detectFlavor(meta);
|
|
80
86
|
return {
|
|
81
87
|
...target,
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-paths of an npm package: `@scope/pkg/button/v2` imports `button/v2` from `@scope/pkg`.
|
|
3
|
+
* A package says what it lets people import in its `exports` field. A package without one lets people import any file in it.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Split a sub-path into clean segments, or say what's wrong with it. Rejects empty parts, `.` and `..`, and backslashes,
|
|
10
|
+
* so a sub-path can't point outside the package. A trailing slash is dropped.
|
|
11
|
+
* @param {string} text
|
|
12
|
+
* @returns {{ ok: true, subpath: string } | { ok: false, reason: string }}
|
|
13
|
+
*/
|
|
14
|
+
export function cleanSubpath(text) {
|
|
15
|
+
const trimmed = text.replace(/\/+$/, "");
|
|
16
|
+
if (!trimmed) return { ok: false, reason: "the sub-path is empty" };
|
|
17
|
+
if (trimmed.includes("\\")) return { ok: false, reason: "a sub-path uses forward slashes" };
|
|
18
|
+
const segments = trimmed.split("/");
|
|
19
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) return { ok: false, reason: 'a sub-path can\'t contain empty parts, "." or ".."' };
|
|
20
|
+
return { ok: true, subpath: segments.join("/") };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The export keys that name a sub-path (`./button`), and the patterns (`./es/*`), from an `exports` field.
|
|
25
|
+
* A string, an array, or an object with no `./` keys only exports the package root.
|
|
26
|
+
*/
|
|
27
|
+
function exportKeys(exportsField) {
|
|
28
|
+
if (!exportsField || typeof exportsField !== "object" || Array.isArray(exportsField)) return { exact: [], patterns: [], hasMap: false };
|
|
29
|
+
const keys = Object.keys(exportsField).filter((key) => key === "." || key.startsWith("./"));
|
|
30
|
+
if (keys.length === 0) return { exact: [], patterns: [], hasMap: false };
|
|
31
|
+
const usable = keys.filter((key) => exportsField[key] !== null);
|
|
32
|
+
return { exact: usable.filter((key) => !key.includes("*") && key !== "." && key !== "./package.json"), patterns: usable.filter((key) => key.includes("*")), hasMap: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Does a pattern key like `./es/*` or `./features/*.js` match a sub-path? */
|
|
36
|
+
function matchesPattern(pattern, subpath) {
|
|
37
|
+
const [before, after] = pattern.slice(2).split("*");
|
|
38
|
+
return subpath.length >= before.length + after.length && subpath.startsWith(before) && subpath.endsWith(after);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Does the package's `exports` field let a sub-path be imported? Returns the sub-paths to suggest when it doesn't.
|
|
43
|
+
* `package.json` is always allowed. A package with no exports map isn't judged here, because its files are the answer.
|
|
44
|
+
* @param {unknown} exportsField
|
|
45
|
+
* @param {string} subpath
|
|
46
|
+
* @returns {{ checked: boolean, ok: boolean, exact: string[], patterns: string[] }}
|
|
47
|
+
*/
|
|
48
|
+
export function checkExports(exportsField, subpath) {
|
|
49
|
+
const { exact, patterns, hasMap } = exportKeys(exportsField);
|
|
50
|
+
if (!hasMap) {
|
|
51
|
+
// A string or array exports field means only the root is exported. No field means nothing here can say.
|
|
52
|
+
return { checked: exportsField !== undefined && exportsField !== null, ok: false, exact: [], patterns: [] };
|
|
53
|
+
}
|
|
54
|
+
const key = `./${subpath}`;
|
|
55
|
+
const ok = key === "./package.json" || exact.includes(key) || patterns.some((pattern) => matchesPattern(pattern, subpath));
|
|
56
|
+
return { checked: true, ok, exact, patterns };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What's wrong with a sub-path of an installed package, or null when it can be imported.
|
|
61
|
+
* @param {string} workDir The folder the package was installed into.
|
|
62
|
+
* @param {string} name
|
|
63
|
+
* @param {string} subpath
|
|
64
|
+
* @param {string | null} version
|
|
65
|
+
* @returns {string | null}
|
|
66
|
+
*/
|
|
67
|
+
export function subpathProblem(workDir, name, subpath, version) {
|
|
68
|
+
const packageDir = join(workDir, "node_modules", name);
|
|
69
|
+
let manifest;
|
|
70
|
+
try {
|
|
71
|
+
manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
|
|
72
|
+
} catch {
|
|
73
|
+
return `${name} was installed, but its package.json couldn't be read to check the sub-path "${subpath}".`;
|
|
74
|
+
}
|
|
75
|
+
const result = checkExports(manifest.exports, subpath);
|
|
76
|
+
if (result.checked) return result.ok ? null : notExportedMessage({ name, version, subpath, exact: result.exact, patterns: result.patterns });
|
|
77
|
+
return fileExists(packageDir, subpath) ? null : `"${name}/${subpath}" isn't a file in ${name}${version ? `@${version}` : ""}, and the package has no exports map that lists what it offers.`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The extensions a file can be imported without writing, for a package with no exports map. */
|
|
81
|
+
const EXTENSIONS = ["", ".js", ".mjs", ".cjs", ".json", "/index.js", "/index.mjs", "/index.cjs"];
|
|
82
|
+
|
|
83
|
+
/** Is there a file for this sub-path in an installed package that has no exports map? */
|
|
84
|
+
export function fileExists(packageDir, subpath) {
|
|
85
|
+
return EXTENSIONS.some((extension) => existsSync(join(packageDir, `${subpath}${extension}`)));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How many single-character edits turn one word into another. */
|
|
89
|
+
function distance(a, b) {
|
|
90
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
91
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
92
|
+
const row = [i];
|
|
93
|
+
for (let j = 1; j <= b.length; j += 1) row.push(Math.min(previous[j] + 1, row[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)));
|
|
94
|
+
previous = row;
|
|
95
|
+
}
|
|
96
|
+
return previous[b.length];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The exports closest to what was asked for: a shared start, a shared last part, or a typo of it. */
|
|
100
|
+
function closest(subpath, exact) {
|
|
101
|
+
const wanted = subpath.toLowerCase().split("/");
|
|
102
|
+
const last = wanted[wanted.length - 1];
|
|
103
|
+
return exact
|
|
104
|
+
.map((key) => {
|
|
105
|
+
const parts = key.slice(2).toLowerCase().split("/");
|
|
106
|
+
const shared = parts.findIndex((part, i) => part !== wanted[i]);
|
|
107
|
+
const lead = shared === -1 ? parts.length : shared;
|
|
108
|
+
const tail = parts[parts.length - 1];
|
|
109
|
+
const rank = lead * 2 + (tail.includes(last) || last.includes(tail) ? 1 : 0) + (distance(last, tail) <= 2 ? 2 : 0);
|
|
110
|
+
return { key, rank };
|
|
111
|
+
})
|
|
112
|
+
.filter((entry) => entry.rank > 0)
|
|
113
|
+
.sort((a, b) => b.rank - a.rank || a.key.length - b.key.length)
|
|
114
|
+
.slice(0, 3)
|
|
115
|
+
.map((entry) => entry.key);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The message for a sub-path a package doesn't offer, with the closest matches first.
|
|
120
|
+
* @param {{ name: string, version: string | null, subpath: string, exact: string[], patterns: string[] }} input
|
|
121
|
+
*/
|
|
122
|
+
export function notExportedMessage({ name, version, subpath, exact, patterns }) {
|
|
123
|
+
const spec = (key) => `${name}${key === "." ? "" : key.slice(1)}`;
|
|
124
|
+
const close = closest(subpath, exact);
|
|
125
|
+
const shown = exact.slice(0, 12).map(spec);
|
|
126
|
+
const more = exact.length > shown.length ? `, and ${exact.length - shown.length} more` : "";
|
|
127
|
+
const parts = [`"${name}/${subpath}" isn't something ${name}${version ? `@${version}` : ""} exports.`];
|
|
128
|
+
if (close.length) parts.push(`Did you mean ${close.map(spec).join(" or ")}?`);
|
|
129
|
+
if (exact.length) parts.push(`It exports ${shown.join(", ")}${more}.`);
|
|
130
|
+
if (patterns.length) parts.push(`It also exports files by pattern: ${patterns.slice(0, 4).map(spec).join(", ")}.`);
|
|
131
|
+
if (!exact.length && !patterns.length) parts.push("It exports only its main entry.");
|
|
132
|
+
return parts.join(" ");
|
|
133
|
+
}
|
package/src/report/comparison.js
CHANGED
|
@@ -3,11 +3,13 @@ import { num, plural } from "../text.js";
|
|
|
3
3
|
import {
|
|
4
4
|
ENGINE_NAMES,
|
|
5
5
|
FINDINGS_NOTE,
|
|
6
|
+
GENERATED_NOTE,
|
|
6
7
|
TIER_NAMES,
|
|
7
8
|
cell,
|
|
8
9
|
code,
|
|
9
10
|
configLabel,
|
|
10
11
|
finish,
|
|
12
|
+
hasGenerated,
|
|
11
13
|
notTestableLines,
|
|
12
14
|
reportFooter,
|
|
13
15
|
reportHeader,
|
|
@@ -15,7 +17,7 @@ import {
|
|
|
15
17
|
targetSection,
|
|
16
18
|
} from "./parts.js";
|
|
17
19
|
|
|
18
|
-
const TIER_ORDER = ["rules", "interactions", "computed", "vsr"];
|
|
20
|
+
const TIER_ORDER = ["rules", "interactions", "computed", "conditions", "vsr"];
|
|
19
21
|
|
|
20
22
|
/** The archetype rows: component archetypes in a fixed order, then whole pages, then Storybook stories. */
|
|
21
23
|
function archetypeKeys(results) {
|
|
@@ -67,7 +69,9 @@ function coverageTable(plan, results, tier, keys) {
|
|
|
67
69
|
// Name the configuration only when there's more than one to tell apart, or the row stands for several stories.
|
|
68
70
|
const withTier = rows.filter((r) => r.configs?.some((c) => c.tiers[tier]));
|
|
69
71
|
const labels = withTier.length > 1 || withTier[0]?.always ? withTier.map((r) => r.label).filter((l) => l && l !== "-") : [];
|
|
70
|
-
|
|
72
|
+
const generated = target.archetypes?.[key]?.fixture?.source === "generated" && String(status).startsWith("ran");
|
|
73
|
+
const base = labels.length ? `${status} (${labels.join("; ")})` : status;
|
|
74
|
+
return generated ? `${base} (generated fixture)` : base;
|
|
71
75
|
});
|
|
72
76
|
lines.push(`| ${key} | ${cells.join(" | ")} |`);
|
|
73
77
|
}
|
|
@@ -104,9 +108,9 @@ function interactionsCell(configs) {
|
|
|
104
108
|
return parts.filter(Boolean).join("; ");
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
function
|
|
108
|
-
const checks = configs.flatMap((c) => c.tiers
|
|
109
|
-
if (checks.length === 0) return configs.map((c) => c.tiers
|
|
111
|
+
function measuredCell(configs, tier) {
|
|
112
|
+
const checks = configs.flatMap((c) => c.tiers[tier]?.checks ?? []);
|
|
113
|
+
if (checks.length === 0) return configs.map((c) => c.tiers[tier]?.status).find(Boolean) ?? "-";
|
|
110
114
|
const failed = checks.filter((c) => c.result === "fail").map((c) => code(c.name));
|
|
111
115
|
const unknown = checks.filter((c) => c.result === "undetermined").length;
|
|
112
116
|
const errors = checks.filter((c) => c.result === "error").length;
|
|
@@ -123,17 +127,17 @@ function vsrCell(configs) {
|
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
function findingsTable(plan, results, key) {
|
|
126
|
-
const head = ["Target", "Configuration", ENGINE_NAMES.axe, ENGINE_NAMES.ibm, "Interactions", "Computed checks", "Virtual screen reader (simulated)"];
|
|
130
|
+
const head = ["Target", "Configuration", ENGINE_NAMES.axe, ENGINE_NAMES.ibm, "Interactions", "Computed checks", "Conditions", "Virtual screen reader (simulated)"];
|
|
127
131
|
const lines = [`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`];
|
|
128
132
|
for (const target of results.targets) {
|
|
129
133
|
const planTarget = plan.targets.find((t) => t.id === target.id);
|
|
130
134
|
for (const row of rowsFor(planTarget, target, key)) {
|
|
131
135
|
if (row.note) {
|
|
132
|
-
lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(row.note)} | | | | |`);
|
|
136
|
+
lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(row.note)} | | | | | |`);
|
|
133
137
|
continue;
|
|
134
138
|
}
|
|
135
139
|
const c = row.configs;
|
|
136
|
-
lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(ruleCell(c, "axe"))} | ${cell(ruleCell(c, "ibm"))} | ${cell(interactionsCell(c))} | ${cell(
|
|
140
|
+
lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(ruleCell(c, "axe"))} | ${cell(ruleCell(c, "ibm"))} | ${cell(interactionsCell(c))} | ${cell(measuredCell(c, "computed"))} | ${cell(measuredCell(c, "conditions"))} | ${cell(vsrCell(c))} |`);
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
143
|
return lines;
|
|
@@ -180,6 +184,7 @@ export function renderComparison({ plan, results }) {
|
|
|
180
184
|
lines.push(`### ${TIER_NAMES[tier]}${tier === "vsr" ? " (simulated)" : ""}.`, "", ...coverageTable(plan, results, tier, keys), "");
|
|
181
185
|
}
|
|
182
186
|
lines.push(...notTestableLines(results));
|
|
187
|
+
if (hasGenerated(results)) lines.push(GENERATED_NOTE, "");
|
|
183
188
|
|
|
184
189
|
lines.push("## Findings.", "", FINDINGS_NOTE, "", ...(o.tiers.includes("rules") ? impactTables(results, o.engines) : []));
|
|
185
190
|
for (const key of keys) {
|
|
@@ -187,7 +192,7 @@ export function renderComparison({ plan, results }) {
|
|
|
187
192
|
}
|
|
188
193
|
|
|
189
194
|
lines.push("## Details by target.", "", "Every finding, with its elements, rule help, and logs, for each target in turn.", "");
|
|
190
|
-
for (const target of results.targets) lines.push(targetSection(plan.targets.find((t) => t.id === target.id), target));
|
|
195
|
+
for (const target of results.targets) lines.push(targetSection(plan.targets.find((t) => t.id === target.id), target, { generatedNote: false }));
|
|
191
196
|
lines.push(...reportFooter(results));
|
|
192
197
|
return finish(lines);
|
|
193
198
|
}
|