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,75 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+
4
+ const require = createRequire(import.meta.url);
5
+ let axeSource = null;
6
+
7
+ /** Hand AxeBuilder our own axe-core, so the version we record is the version that ran. */
8
+ function loadAxe() {
9
+ axeSource ??= readFileSync(require.resolve("axe-core/axe.min.js"), "utf8");
10
+ return axeSource;
11
+ }
12
+
13
+ /** Tag sets that match a WCAG version and level. Best-practice rules aren't WCAG, so they stay out. */
14
+ export function axeTags(wcag, level) {
15
+ const tags = ["wcag2a"];
16
+ if (level !== "A") tags.push("wcag2aa");
17
+ if (level === "AAA") tags.push("wcag2aaa");
18
+ if (wcag === "2.1" || wcag === "2.2") {
19
+ tags.push("wcag21a");
20
+ if (level !== "A") tags.push("wcag21aa");
21
+ if (level === "AAA") tags.push("wcag21aaa");
22
+ }
23
+ if (wcag === "2.2") {
24
+ if (level !== "A") tags.push("wcag22aa");
25
+ if (level === "AAA") tags.push("wcag22aaa");
26
+ }
27
+ return tags;
28
+ }
29
+
30
+ /** `wcag143` becomes `1.4.3`. Returns null for tags that aren't success criteria. */
31
+ function criterionFromTag(tag) {
32
+ const match = /^wcag(\d)(\d)(\d{1,2})$/.exec(tag);
33
+ return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
34
+ }
35
+
36
+ /** An axe target can be nested for iframes and shadow roots. Flatten it to one readable selector. */
37
+ function selectorFor(target) {
38
+ return (Array.isArray(target) ? target.flat(Infinity) : [target]).join(" ");
39
+ }
40
+
41
+ function normalize(items, maxNodes) {
42
+ return items.map((item) => ({
43
+ ruleId: item.id,
44
+ impact: item.impact ?? null,
45
+ wcag: item.tags.map(criterionFromTag).filter(Boolean),
46
+ tags: item.tags.filter((tag) => tag.startsWith("wcag") || tag.startsWith("best-practice")),
47
+ help: item.help,
48
+ helpUrl: item.helpUrl,
49
+ nodeCount: item.nodes.length,
50
+ nodes: item.nodes.slice(0, maxNodes).map((node) => ({ selector: selectorFor(node.target), html: String(node.html).slice(0, 300) })),
51
+ }));
52
+ }
53
+
54
+ /**
55
+ * Run axe-core against the page for one WCAG version and level.
56
+ * @param {import("playwright-core").Page} page
57
+ * @param {{ wcag: string, level: string, maxNodes?: number, scope?: string | string[] | null }} options
58
+ * `scope` is a CSS selector. Component evidence checks only that element, so page-level rules like document title don't fire.
59
+ */
60
+ export async function runAxe(page, { wcag, level, maxNodes = 5, scope = null }) {
61
+ const { AxeBuilder } = await import("@axe-core/playwright");
62
+ const tags = axeTags(wcag, level);
63
+ const builder = new AxeBuilder({ page, axeSource: loadAxe() }).withTags(tags);
64
+ for (const selector of [scope ?? []].flat()) builder.include(selector);
65
+ const result = await builder.analyze();
66
+ return {
67
+ status: /** @type {const} */ ("ran"),
68
+ version: result.testEngine.version,
69
+ config: { tags, ...(scope ? { scope } : {}) },
70
+ violations: normalize(result.violations, maxNodes),
71
+ incomplete: normalize(result.incomplete, maxNodes),
72
+ passesCount: result.passes.length,
73
+ notes: /** @type {string[]} */ ([]),
74
+ };
75
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Decide whether the scope is mainly a canvas with nothing a rule engine can read.
3
+ * A canvas is a bitmap to the accessibility tree. Without a text alternative, a nearby table or SVG, or a label,
4
+ * axe and IBM would find nothing and the result would read as clean. It isn't clean. It's untested.
5
+ * @param {import("playwright-core").Page} page
6
+ * @param {string | string[] | null} scope
7
+ * @returns {Promise<{ canvases: number, canvasOnly: boolean, hasAlternative: boolean }>}
8
+ */
9
+ export async function inspectCanvas(page, scope) {
10
+ return page.evaluate((scopes) => {
11
+ const roots = scopes.length ? scopes.flatMap((selector) => [...document.querySelectorAll(selector)]) : [document.body];
12
+ const canvases = roots.flatMap((root) => [...root.querySelectorAll("canvas")]);
13
+ if (canvases.length === 0) return { canvases: 0, canvasOnly: false, hasAlternative: false };
14
+ const labelled = canvases.some(
15
+ (canvas) =>
16
+ canvas.hasAttribute("aria-label") ||
17
+ canvas.hasAttribute("aria-labelledby") ||
18
+ ["img", "graphics-document"].includes(canvas.getAttribute("role") ?? "") ||
19
+ (canvas.textContent ?? "").trim().length > 0,
20
+ );
21
+ const nearby = roots.some((root) => root.querySelector('svg, table, [role="img"], [role="graphics-document"], [role="figure"], figure'));
22
+ const text = roots
23
+ .map((root) => {
24
+ const copy = /** @type {HTMLElement} */ (root.cloneNode(true));
25
+ copy.querySelectorAll("canvas, script, style").forEach((el) => el.remove());
26
+ return (copy.textContent ?? "").trim();
27
+ })
28
+ .join(" ").length;
29
+ const hasAlternative = labelled || nearby || text >= 20;
30
+ return { canvases: canvases.length, canvasOnly: !hasAlternative, hasAlternative };
31
+ }, [scope ?? []].flat());
32
+ }
33
+
34
+ export const CANVAS_REASON = "Canvas output exposes nothing to rule checks.";
@@ -0,0 +1,121 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { readPackageVersion } from "../../env/versions.js";
4
+
5
+ const require = createRequire(import.meta.url);
6
+ let aceSource = null;
7
+
8
+ /** Read ace.js once. The engine runs inside the page, so we only need its source text. */
9
+ function loadAce() {
10
+ aceSource ??= readFileSync(require.resolve("accessibility-checker-engine/ace.js"), "utf8");
11
+ return aceSource;
12
+ }
13
+
14
+ const LEVEL_ORDER = { A: 1, AA: 2 };
15
+
16
+ export const helpUrl = (ruleId) => `https://able.ibm.com/rules/archives/latest/doc/en-US/${ruleId}.html`;
17
+
18
+ /**
19
+ * Group raw IBM results into findings, one per rule and kind, with the first few nodes.
20
+ * @param {Array<{ ruleId: string, message: string, dom?: string, snippet?: string }>} items
21
+ * @param {Map<string, { wcag: string[], toolkitLevel: number | null }>} ruleInfo
22
+ * @param {(item: any) => string} kindOf
23
+ * @param {number} maxNodes
24
+ */
25
+ function group(items, ruleInfo, kindOf, maxNodes, withKind) {
26
+ const groups = new Map();
27
+ for (const item of items) {
28
+ const kind = kindOf(item);
29
+ const key = withKind ? `${item.ruleId}|${kind}` : item.ruleId;
30
+ let entry = groups.get(key);
31
+ if (!entry) {
32
+ const info = ruleInfo.get(item.ruleId);
33
+ entry = {
34
+ ruleId: item.ruleId,
35
+ impact: null,
36
+ toolkitLevel: info?.toolkitLevel ?? null,
37
+ wcag: info?.wcag ?? [],
38
+ ...(withKind ? { kind } : {}),
39
+ help: item.message,
40
+ helpUrl: helpUrl(item.ruleId),
41
+ nodeCount: 0,
42
+ nodes: [],
43
+ };
44
+ groups.set(key, entry);
45
+ }
46
+ entry.nodeCount += 1;
47
+ if (entry.nodes.length < maxNodes) entry.nodes.push({ selector: item.dom ?? "", html: String(item.snippet ?? "").slice(0, 300) });
48
+ }
49
+ return [...groups.values()].sort((a, b) => a.ruleId.localeCompare(b.ruleId));
50
+ }
51
+
52
+ /**
53
+ * Run the IBM Equal Access engine against the page.
54
+ * The engine's WCAG rulesets cover levels A and AA. Level AAA runs the AA rules and says so.
55
+ * ace.js goes in through page.evaluate, because a strict CSP blocks addScriptTag.
56
+ * @param {import("playwright-core").Page} page
57
+ * @param {{ wcag: string, level: string, maxNodes?: number, scope?: string | string[] | null }} options
58
+ * `scope` is a CSS selector. Component evidence checks only that element.
59
+ */
60
+ export async function runIbm(page, { wcag, level, maxNodes = 5, scope = null }) {
61
+ const ruleset = `WCAG_${wcag.replace(".", "_")}`;
62
+ const maxLevel = LEVEL_ORDER[level] ?? LEVEL_ORDER.AA;
63
+ await page.evaluate(loadAce());
64
+ const scopes = [scope ?? []].flat();
65
+ const raw = await page.evaluate(async ({ id, scopes }) => {
66
+ // @ts-ignore ace.js defines window.ace in the page.
67
+ const checker = new window.ace.Checker();
68
+ const set = checker.rulesets.find((r) => r.id === id);
69
+ if (!set) throw new Error(`The IBM engine has no ruleset ${id}.`);
70
+ const roots = scopes.length ? scopes.flatMap((selector) => [...document.querySelectorAll(selector)]) : [document];
71
+ if (roots.length === 0) throw new Error(`Nothing matches the scope ${scopes.join(", ")}.`);
72
+ const passed = new Set();
73
+ const found = [];
74
+ const seen = new Set();
75
+ for (const root of roots) {
76
+ const report = await checker.check(root, [id]);
77
+ for (const r of report.results) {
78
+ if (r.value[1] === "PASS") passed.add(r.ruleId);
79
+ else {
80
+ const key = `${r.ruleId}|${r.path?.dom}|${r.value.join()}`;
81
+ if (seen.has(key)) continue;
82
+ seen.add(key);
83
+ found.push({ ruleId: r.ruleId, value: r.value, message: r.message, dom: r.path?.dom, snippet: r.snippet });
84
+ }
85
+ }
86
+ }
87
+ return {
88
+ checkpoints: set.checkpoints.map((cp) => ({ num: cp.num, level: cp.wcagLevel, rules: cp.rules.map((x) => [x.id, x.toolkitLevel]) })),
89
+ found,
90
+ passedRules: [...passed],
91
+ };
92
+ }, { id: ruleset, scopes });
93
+
94
+ /** @type {Map<string, { wcag: string[], toolkitLevel: number | null }>} */
95
+ const ruleInfo = new Map();
96
+ for (const checkpoint of raw.checkpoints) {
97
+ if ((LEVEL_ORDER[checkpoint.level] ?? 99) > maxLevel) continue;
98
+ for (const [id, toolkit] of checkpoint.rules) {
99
+ const info = ruleInfo.get(id) ?? { wcag: [], toolkitLevel: null };
100
+ if (!info.wcag.includes(checkpoint.num)) info.wcag.push(checkpoint.num);
101
+ const number = Number(toolkit);
102
+ if (Number.isFinite(number)) info.toolkitLevel = info.toolkitLevel === null ? number : Math.min(info.toolkitLevel, number);
103
+ ruleInfo.set(id, info);
104
+ }
105
+ }
106
+
107
+ const inScope = raw.found.filter((item) => ruleInfo.has(item.ruleId));
108
+ const violations = inScope.filter((item) => item.value[0] === "VIOLATION" && item.value[1] === "FAIL");
109
+ const review = inScope.filter((item) => !(item.value[0] === "VIOLATION" && item.value[1] === "FAIL"));
110
+ const reviewKind = (item) => (item.value[1] === "MANUAL" ? "manual" : item.value[1] === "POTENTIAL" ? "potential" : "recommendation");
111
+ const notes = level === "AAA" ? ["IBM's WCAG rulesets cover levels A and AA. This run used the AA rules."] : [];
112
+ return {
113
+ status: /** @type {const} */ ("ran"),
114
+ version: readPackageVersion("accessibility-checker-engine"),
115
+ config: { ruleset, levels: level === "A" ? ["A"] : ["A", "AA"], ...(scope ? { scope } : {}) },
116
+ violations: group(violations, ruleInfo, () => "violation", maxNodes, false),
117
+ incomplete: group(review, ruleInfo, reviewKind, maxNodes, true),
118
+ passesCount: raw.passedRules.filter((id) => ruleInfo.has(id)).length,
119
+ notes,
120
+ };
121
+ }
@@ -0,0 +1,81 @@
1
+ import { CANVAS_REASON, inspectCanvas } from "./canvas.js";
2
+ import { runAxe } from "./axe.js";
3
+ import { runIbm } from "./ibm.js";
4
+
5
+ const RUNNERS = { axe: runAxe, ibm: runIbm };
6
+
7
+ /**
8
+ * Run each chosen engine against the page. Engines report separately, and one failing engine doesn't stop the other.
9
+ * @param {import("playwright-core").Page} page
10
+ * @param {{ engines: string[], wcag: string, level: string, maxNodes?: number, scope?: string | string[] | null }} options
11
+ */
12
+ export async function runRules(page, { engines, wcag, level, maxNodes = 5, scope = null }) {
13
+ const canvas = await inspectCanvas(page, scope);
14
+ if (canvas.canvasOnly) {
15
+ const reason = `${CANVAS_REASON} The page or component is mainly a canvas with no text alternative, label, table, or SVG, so there was nothing to test.`;
16
+ return {
17
+ status: "not-testable",
18
+ reason,
19
+ engines: Object.fromEntries(engines.map((engine) => [engine, { status: "not-testable", reason }])),
20
+ };
21
+ }
22
+ /** @type {Record<string, any>} */
23
+ const results = {};
24
+ for (const engine of engines) {
25
+ try {
26
+ results[engine] = await RUNNERS[/** @type {keyof typeof RUNNERS} */ (engine)](page, { wcag, level, maxNodes, scope });
27
+ } catch (error) {
28
+ results[engine] = { status: "failed", reason: error instanceof Error ? error.message.split("\n")[0] : String(error) };
29
+ }
30
+ }
31
+ if (canvas.canvases > 0) {
32
+ for (const result of Object.values(results)) {
33
+ if (result.status === "ran") (result.notes ??= []).push("The canvas drawing itself isn't checked. The rules ran on the markup around it.");
34
+ }
35
+ }
36
+ const statuses = Object.values(results).map((r) => r.status);
37
+ return {
38
+ status: statuses.includes("ran") ? "ran" : "failed",
39
+ reason: statuses.includes("ran") ? null : "Every rule engine failed.",
40
+ engines: results,
41
+ };
42
+ }
43
+
44
+ const SELF_TEST_HTML = `<!doctype html><html lang="en"><head><title>Self-test</title></head><body><main><h1>Self-test</h1>
45
+ <img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=">
46
+ <p style="color:#ccc;background:#fff">Low contrast text</p></main></body></html>`;
47
+
48
+ const EXPECTED = {
49
+ axe: ["image-alt", "color-contrast"],
50
+ ibm: ["img_alt_valid", "text_contrast_sufficient"],
51
+ };
52
+
53
+ /**
54
+ * Audit a page with known problems and check that every chosen engine flags them.
55
+ * A run that can't pass this must not report a clean result.
56
+ * @param {import("playwright-core").Browser} browser
57
+ * @param {string[]} engines
58
+ * @param {Function} [run] Swap in a stand-in to test the failure path.
59
+ */
60
+ export async function selfTest(browser, engines, run = runRules) {
61
+ const context = await browser.newContext();
62
+ try {
63
+ const page = await context.newPage();
64
+ await page.setContent(SELF_TEST_HTML);
65
+ const result = await run(page, { engines, wcag: "2.2", level: "AA", maxNodes: 1 });
66
+ const problems = [];
67
+ for (const engine of engines) {
68
+ const engineResult = result.engines[engine];
69
+ if (engineResult.status !== "ran") {
70
+ problems.push(`${engine}: ${engineResult.reason}`);
71
+ continue;
72
+ }
73
+ const found = new Set(engineResult.violations.map((v) => v.ruleId));
74
+ const missing = EXPECTED[/** @type {keyof typeof EXPECTED} */ (engine)].filter((id) => !found.has(id));
75
+ if (missing.length) problems.push(`${engine} missed ${missing.join(", ")}`);
76
+ }
77
+ return { passed: problems.length === 0, problems };
78
+ } finally {
79
+ await context.close();
80
+ }
81
+ }
@@ -0,0 +1,134 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { readPackageVersion } from "../env/versions.js";
4
+
5
+ const require = createRequire(import.meta.url);
6
+ let injected = null;
7
+
8
+ /**
9
+ * The browser build of the virtual screen reader is one ES module. Turn its `export` line into a global,
10
+ * so `page.evaluate` can run it. `evaluate` isn't subject to the page's CSP, and a script tag would be.
11
+ */
12
+ function loadSource() {
13
+ if (injected) return injected;
14
+ const source = readFileSync(require.resolve("@guidepup/virtual-screen-reader/browser.js"), "utf8").replace(/\/\/# sourceMappingURL=.*$/m, "");
15
+ const exported = /export\s*\{([^}]*)\};?/.exec(source);
16
+ if (!exported) throw new Error("The virtual screen reader's browser build has an unexpected shape.");
17
+ const names = exported[1].split(",").map((part) => {
18
+ const [local, alias] = part.trim().split(/\s+as\s+/);
19
+ return `${alias ?? local}:${local}`;
20
+ });
21
+ injected = `(()=>{${source.replace(/export\s*\{[^}]*\};?/, "")}\nwindow.__vsr={${names.join(",")}};})()`;
22
+ return injected;
23
+ }
24
+
25
+ /** Roles that should come with a name. A phrase that is only the role means the control has none. */
26
+ export const UNNAMED_ROLES = new Set([
27
+ "button", "link", "image", "textbox", "searchbox", "checkbox", "radio", "switch", "slider", "spinbutton",
28
+ "combobox", "tab", "menuitem", "menuitemcheckbox", "menuitemradio", "option", "dialog", "alertdialog", "listbox", "menu",
29
+ ]);
30
+ const GENERIC_ROLES = new Set(["generic", "none", "presentation"]);
31
+
32
+ /**
33
+ * Point at phrases a person should look at. This doesn't judge the log. It marks two patterns and nothing else:
34
+ * a control announced as only its role, and a role announced as generic.
35
+ * @param {string[]} phrases
36
+ */
37
+ export function flagPhrases(phrases) {
38
+ /** @type {Array<{ type: string, phrase: string, index: number }>} */
39
+ const flags = [];
40
+ phrases.forEach((phrase, index) => {
41
+ const text = phrase.trim().toLowerCase();
42
+ if (UNNAMED_ROLES.has(text)) flags.push({ type: "unnamed-control", phrase, index });
43
+ else if (GENERIC_ROLES.has(text)) flags.push({ type: "generic-role", phrase, index });
44
+ });
45
+ return flags;
46
+ }
47
+
48
+ /**
49
+ * Walk the page with the virtual screen reader and record what it would announce.
50
+ * Every result is simulated. It's not a real screen reader's output.
51
+ * Open shadow roots inside the scope aren't read, and the result says so.
52
+ * @param {import("playwright-core").Page} page
53
+ * @param {{ scope?: string, state?: string | null, maxSteps?: number }} [options]
54
+ */
55
+ export async function runVsr(page, { scope = "body", state = null, maxSteps = 150 } = {}) {
56
+ await page.evaluate(loadSource());
57
+ const hosts = await page.evaluate(async (scope) => {
58
+ const container = document.querySelector(scope);
59
+ if (!container) throw new Error(`Nothing matches the scope ${scope}.`);
60
+ /** Hosts of open shadow roots inside the container, with counts. */
61
+ const found = {};
62
+ const visit = (root) => {
63
+ for (const el of root.querySelectorAll("*")) {
64
+ if (el.shadowRoot) {
65
+ found[el.localName] = (found[el.localName] ?? 0) + 1;
66
+ visit(el.shadowRoot);
67
+ }
68
+ }
69
+ };
70
+ visit(container);
71
+ await window.__vsr.virtual.start({ container });
72
+ return found;
73
+ }, scope);
74
+ let phrases = [];
75
+ let reachedEnd = false;
76
+ try {
77
+ while (phrases.length < maxSteps + 1 && !reachedEnd) {
78
+ const chunk = await page.evaluate(async (steps) => {
79
+ const virtual = window.__vsr.virtual;
80
+ for (let i = 0; i < steps; i += 1) {
81
+ if ((await virtual.lastSpokenPhrase()) === "end of document") break;
82
+ await virtual.next();
83
+ }
84
+ return { log: await virtual.spokenPhraseLog(), ended: (await virtual.lastSpokenPhrase()) === "end of document" };
85
+ }, 10);
86
+ phrases = chunk.log;
87
+ // Inside a container that isn't the whole document, the reader wraps around instead of ending.
88
+ const period = repeatLength(phrases);
89
+ if (chunk.ended) reachedEnd = true;
90
+ else if (period) {
91
+ phrases = phrases.slice(0, period);
92
+ reachedEnd = true;
93
+ }
94
+ }
95
+ } finally {
96
+ await page.evaluate(() => window.__vsr.virtual.stop());
97
+ }
98
+ phrases = phrases.slice(0, maxSteps + 1);
99
+ const notTestable = Object.entries(hosts).map(([tag, n]) => `open shadow root in <${tag}> (${n}): the virtual screen reader doesn't read inside it`);
100
+ return {
101
+ status: /** @type {const} */ ("ran"),
102
+ simulated: true,
103
+ version: readPackageVersion("@guidepup/virtual-screen-reader"),
104
+ log: [{ state, announcements: phrases, reachedEnd, truncated: !reachedEnd }],
105
+ flags: flagPhrases(phrases).map((flag) => ({ ...flag, state })),
106
+ notTestable,
107
+ notes: reachedEnd ? [] : [`The walk stopped after ${maxSteps} steps, before it came back around to the start.`],
108
+ };
109
+ }
110
+
111
+ /**
112
+ * When a walk wraps around, the log repeats. Returns how much of the log is the first full pass, or 0 if it hasn't repeated yet.
113
+ * The first pass can start with a stray phrase (a container announced twice), so the repeating part may begin a step or two in.
114
+ * A pass counts only after it has run a full time and started again, so a long document isn't cut short.
115
+ * @param {string[]} log
116
+ */
117
+ export function repeatLength(log) {
118
+ for (let offset = 0; offset <= 2; offset += 1) {
119
+ for (let period = 1; offset + period * 2 + 1 <= log.length; period += 1) {
120
+ let same = true;
121
+ for (let i = offset; i + period < log.length; i += 1) {
122
+ if (log[i] !== log[i + period]) {
123
+ same = false;
124
+ break;
125
+ }
126
+ }
127
+ if (same) return offset + period;
128
+ }
129
+ }
130
+ return 0;
131
+ }
132
+
133
+ /** What the tier reports when it can't run. */
134
+ export const failedVsr = (error) => ({ status: /** @type {const} */ ("failed"), simulated: true, reason: (error instanceof Error ? error.message : String(error)).split("\n")[0] });