@erclx/canon 5.59.0 → 5.61.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 (39) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/design-record/references/design.md +19 -0
  4. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  5. package/claude/skills/document-health/SKILL.md +23 -3
  6. package/claude/skills/document-health/references/axes.md +21 -1
  7. package/claude/skills/plan-feature/SKILL.md +1 -1
  8. package/claude/skills/review-pr/SKILL.md +1 -1
  9. package/claude/skills/review-pr/references/scope.md +10 -0
  10. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  11. package/claude/skills/review-ui/SKILL.md +3 -2
  12. package/claude/skills/review-ui/references/post.md +2 -2
  13. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  14. package/claude/skills/ui-checklist/SKILL.md +12 -0
  15. package/docs/agents/records.md +1 -1
  16. package/docs/target/migrations.md +1 -1
  17. package/docs/workflow/parallel-features.md +1 -1
  18. package/package.json +2 -1
  19. package/src/commands/design.ts +57 -1
  20. package/src/commands/driver.ts +29 -3
  21. package/src/commands/migrate.ts +7 -0
  22. package/src/commands/records.ts +3 -2
  23. package/src/commands/tooling.ts +17 -2
  24. package/src/design/audit.ts +133 -0
  25. package/src/design/budget.ts +2 -1
  26. package/src/design/css.ts +1 -5
  27. package/src/design/document.ts +5 -1
  28. package/src/design/themes.ts +176 -0
  29. package/src/design/tokens.ts +3 -0
  30. package/src/driver/drive.ts +17 -1
  31. package/src/driver/probes/axe.ts +195 -0
  32. package/src/driver/steps.ts +20 -1
  33. package/src/gate/measures.ts +9 -3
  34. package/src/markdown/structure.ts +4 -1
  35. package/src/migrate/records.ts +46 -7
  36. package/src/records/stale-canonical.ts +2 -2
  37. package/src/sync/stamp.ts +24 -4
  38. package/src/tooling/inject.ts +6 -3
  39. package/src/tooling/install.ts +55 -0
@@ -0,0 +1,133 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { measureDesignProse } from '@/design/budget'
4
+ import { parseDesignDoc } from '@/design/parse'
5
+ import { DESIGN_DOCUMENT } from '@/design/regen'
6
+ import {
7
+ checkThemes,
8
+ readThemes,
9
+ scanDeclarations,
10
+ type ThemeResult,
11
+ } from '@/design/themes'
12
+
13
+ /** The custom property a table row names, such as `--color-accent`. */
14
+ const PROPERTY = /--[A-Za-z0-9_-]+/g
15
+
16
+ /** A custom property declaration, which a stylesheet and a Tailwind `@theme` block share. */
17
+ const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g
18
+
19
+ /** A backticked path to a stylesheet. */
20
+ const STYLESHEET = /`([^`\s]+\.css)`/
21
+
22
+ export type TokenCheck =
23
+ | {
24
+ readonly status: 'checked'
25
+ readonly stylesheet: string
26
+ readonly properties: number
27
+ readonly missing: readonly string[]
28
+ /** One entry per theme the record declares, absent where it declares none. */
29
+ readonly themes?: readonly ThemeResult[]
30
+ }
31
+ | { readonly status: 'unchecked'; readonly reason: string }
32
+ | { readonly status: 'not-applicable'; readonly reason: string }
33
+
34
+ export interface DesignAudit {
35
+ readonly ok: true
36
+ /** The record read, or null where the project holds none. */
37
+ readonly record: string | null
38
+ readonly prose: readonly string[]
39
+ readonly tokens: TokenCheck | null
40
+ /** True only when the prose is within budget and the token check ran clean. */
41
+ readonly clean: boolean
42
+ }
43
+
44
+ /**
45
+ * Reads a project's design record against the prose budget and checks each
46
+ * custom property its tables name against the stylesheet its lead names. It
47
+ * writes nothing.
48
+ *
49
+ * A token check that cannot run says why under `unchecked` and leaves `clean`
50
+ * false, since a check that did not look is no pass.
51
+ */
52
+ export function auditDesign(root: string): DesignAudit {
53
+ const path = join(root, DESIGN_DOCUMENT)
54
+ if (!existsSync(path)) {
55
+ return { ok: true, record: null, prose: [], tokens: null, clean: true }
56
+ }
57
+
58
+ const source = readFileSync(path, 'utf8')
59
+ const prose = measureDesignProse(source)
60
+ const tokens = checkTokens(root, path, source)
61
+ const clean =
62
+ prose.length === 0 &&
63
+ tokens.status !== 'unchecked' &&
64
+ (tokens.status !== 'checked' ||
65
+ (tokens.missing.length === 0 &&
66
+ (tokens.themes ?? []).every(
67
+ (theme) => theme.matched && theme.missing.length === 0,
68
+ )))
69
+
70
+ return { ok: true, record: DESIGN_DOCUMENT, prose, tokens, clean }
71
+ }
72
+
73
+ function checkTokens(root: string, path: string, source: string): TokenCheck {
74
+ const doc = parseDesignDoc(path)
75
+ const rows = [...doc.color, ...doc.borders]
76
+
77
+ // A values record renders from a token module and carries `Value` where an
78
+ // intent record carries `Property`, so it has no property to compare.
79
+ if (!rows.some((row) => 'Property' in row)) {
80
+ return {
81
+ status: 'not-applicable',
82
+ reason:
83
+ 'The record has no Property column, so it holds no property to compare.',
84
+ }
85
+ }
86
+
87
+ const lead = source.split(/^##\s/m)[0]
88
+ const named = lead.match(STYLESHEET)?.[1]
89
+ if (named === undefined) {
90
+ return {
91
+ status: 'unchecked',
92
+ reason: 'The lead names no stylesheet, so no property could be checked.',
93
+ }
94
+ }
95
+
96
+ const stylesheet = resolve(root, named)
97
+ if (!stylesheet.startsWith(resolve(root)) || !existsSync(stylesheet)) {
98
+ return {
99
+ status: 'unchecked',
100
+ reason: `The lead names ${named}, which does not resolve in the project.`,
101
+ }
102
+ }
103
+
104
+ const css = readFileSync(stylesheet, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
105
+ const declared = new Set([...css.matchAll(DECLARATION)].map((m) => m[1]))
106
+
107
+ const properties = [
108
+ ...new Set(
109
+ rows.flatMap((row) =>
110
+ [...(row['Property']?.value.matchAll(PROPERTY) ?? [])].map((m) => m[0]),
111
+ ),
112
+ ),
113
+ ]
114
+
115
+ const themes = readThemes(source)
116
+ const colors = [
117
+ ...new Set(
118
+ doc.color.flatMap((row) =>
119
+ [...(row['Property']?.value.matchAll(PROPERTY) ?? [])].map((m) => m[0]),
120
+ ),
121
+ ),
122
+ ]
123
+
124
+ return {
125
+ status: 'checked',
126
+ stylesheet: named,
127
+ properties: properties.length,
128
+ missing: properties.filter((property) => !declared.has(property)),
129
+ ...(themes.length > 0 && {
130
+ themes: checkThemes(themes, scanDeclarations(css), colors),
131
+ }),
132
+ }
133
+ }
@@ -1,4 +1,5 @@
1
1
  import { canonicalHeading } from '@/design/parse'
2
+ import { stripFrontmatter } from '@/markdown/frontmatter'
2
3
 
3
4
  /**
4
5
  * The prose budget `claude/skills/design-record/references/design.md` sets for a design record, and the
@@ -91,7 +92,7 @@ function splitSections(source: string): Section[] {
91
92
  const sections: { heading: string | undefined; lines: string[] }[] = [
92
93
  { heading: undefined, lines: [] },
93
94
  ]
94
- for (const line of source.split('\n')) {
95
+ for (const line of stripFrontmatter(source).split('\n')) {
95
96
  const match = line.match(/^##\s+(.+?)\s*$/)
96
97
  if (match) {
97
98
  sections.push({ heading: match[1], lines: [] })
package/src/design/css.ts CHANGED
@@ -154,8 +154,6 @@ export function fontFaceBlock(
154
154
  }
155
155
 
156
156
  export interface CssOptions {
157
- /** Prepended as a comment, naming what wrote the file and from where. */
158
- readonly banner?: string
159
157
  /**
160
158
  * Component rules ride along by default; a token-only consumer opts out
161
159
  * with `false`. Pass an explicit list to emit those instead of the generic default.
@@ -173,8 +171,6 @@ export function buildDesignCss(
173
171
  tokens: DesignTokens = TOKENS,
174
172
  options: CssOptions = {},
175
173
  ): string {
176
- const banner =
177
- options.banner === undefined ? '' : `/* ${options.banner} */\n\n`
178
174
  const root = [':root {', ...tokenProperties(tokens), '}'].join('\n')
179
175
  const faces =
180
176
  options.embedFonts === true
@@ -193,5 +189,5 @@ export function buildDesignCss(
193
189
  : COMPONENTS
194
190
  if (components) parts.push(componentBlock(components))
195
191
 
196
- return `${banner}${parts.join('\n\n')}\n`
192
+ return `${parts.join('\n\n')}\n`
197
193
  }
@@ -5,7 +5,7 @@ import type {
5
5
  SpaceToken,
6
6
  TypeToken,
7
7
  } from '@/design/tokens'
8
- import { TOKENS } from '@/design/tokens'
8
+ import { REVIEWED, TOKENS } from '@/design/tokens'
9
9
 
10
10
  /**
11
11
  * Renders `canon/DESIGN.md` from the token source.
@@ -110,6 +110,10 @@ function section(heading: string, ...blocks: string[]): string {
110
110
 
111
111
  export function renderDesignDocument(tokens: DesignTokens = TOKENS): string {
112
112
  const body = [
113
+ '---',
114
+ `reviewed: ${REVIEWED}`,
115
+ '---',
116
+ '',
113
117
  '# Design',
114
118
  '',
115
119
  LEAD,
@@ -0,0 +1,176 @@
1
+ const RAW_FRONTMATTER = /^---\n([\s\S]*?)\n---/
2
+ const THEMES_BLOCK = /^themes:[ \t]*\n((?:[ \t]+-[^\n]*\n?|[ \t]*\n)+)/m
3
+ const LIST_ITEM = /^[ \t]*-[ \t]+(.*?)[ \t]*$/
4
+ const FLOW_LIST = /^themes:[ \t]*\[(.*)\][ \t]*$/m
5
+ const ENTRY = /\s*(?:'([^']*)'|"([^"]*)"|([^,#]*?))\s*(?:,|(?:#.*)?$)/y
6
+ const DECLARATION = /^\s*(--[A-Za-z0-9_-]+)\s*:/
7
+
8
+ /**
9
+ * One custom property declaration and the preludes enclosing it, outermost
10
+ * first. Each level holds the alternatives of its selector list, normalized so
11
+ * a record and a stylesheet compare equal.
12
+ */
13
+ export interface ScannedDeclaration {
14
+ readonly property: string
15
+ readonly chain: readonly (readonly string[])[]
16
+ }
17
+
18
+ /**
19
+ * Reads the `themes:` list off the raw frontmatter block, for the reason
20
+ * `readReviewed` in `@/records/stale-canonical` gives. Each entry is the
21
+ * stylesheet prelude that opens the theme, such as `:root` or
22
+ * `@media (prefers-color-scheme: dark)`. A record declaring none reads as an
23
+ * empty list.
24
+ */
25
+ export function readThemes(source: string): string[] {
26
+ const block = RAW_FRONTMATTER.exec(source.replaceAll('\r\n', '\n'))?.[1]
27
+ if (!block) return []
28
+
29
+ const flow = FLOW_LIST.exec(block)?.[1]
30
+ const list = THEMES_BLOCK.exec(`${block}\n`)?.[1]
31
+ const entries =
32
+ flow !== undefined
33
+ ? splitEntries(flow)
34
+ : (list ?? '')
35
+ .split('\n')
36
+ .map((line) => LIST_ITEM.exec(line)?.[1])
37
+ .filter((item): item is string => item !== undefined)
38
+ .flatMap((item) => splitEntries(item).slice(0, 1))
39
+
40
+ return entries.filter((entry) => entry !== '')
41
+ }
42
+
43
+ /**
44
+ * Splits a flow list or one block item into entries, honoring quotes and
45
+ * dropping a trailing ` #` comment, since a selector may carry a comma or a
46
+ * hash inside quotes.
47
+ */
48
+ function splitEntries(text: string): string[] {
49
+ const out: string[] = []
50
+ ENTRY.lastIndex = 0
51
+ while (ENTRY.lastIndex < text.length) {
52
+ const match = ENTRY.exec(text)
53
+ if (match === null || match[0] === '') break
54
+ out.push((match[1] ?? match[2] ?? match[3] ?? '').trim())
55
+ }
56
+ return out
57
+ }
58
+
59
+ /**
60
+ * Collapses the differences a hand-written prelude carries: runs of
61
+ * whitespace, the quote style around an attribute value, and spacing inside
62
+ * an at-rule condition.
63
+ */
64
+ export function normalizePrelude(prelude: string): string {
65
+ const flat = prelude.replace(/\s+/g, ' ').trim()
66
+ if (flat.startsWith('@')) return flat.replace(/\s*:\s*/g, ': ')
67
+ return flat
68
+ .replace(
69
+ /\[\s*([^\]=\s]+)\s*=\s*["']?([^"'\]]*?)["']?\s*\]/g,
70
+ (_, name: string, value: string) => `[${name}=${value}]`,
71
+ )
72
+ .replace(/\s*([>+~])\s*/g, ' $1 ')
73
+ .replace(/\s+/g, ' ')
74
+ }
75
+
76
+ /** Splits a selector list at its top-level commas, and leaves an at-rule whole. */
77
+ function members(prelude: string): string[] {
78
+ if (prelude.trimStart().startsWith('@')) return [normalizePrelude(prelude)]
79
+
80
+ const out: string[] = []
81
+ let depth = 0
82
+ let start = 0
83
+ for (let index = 0; index < prelude.length; index += 1) {
84
+ const char = prelude[index]
85
+ if (char === '(' || char === '[') depth += 1
86
+ else if (char === ')' || char === ']') depth -= 1
87
+ else if (char === ',' && depth === 0) {
88
+ out.push(prelude.slice(start, index))
89
+ start = index + 1
90
+ }
91
+ }
92
+ out.push(prelude.slice(start))
93
+ return out.map(normalizePrelude).filter((member) => member !== '')
94
+ }
95
+
96
+ /**
97
+ * Walks a stylesheet and returns every custom property declaration with the
98
+ * block chain around it.
99
+ */
100
+ export function scanDeclarations(css: string): ScannedDeclaration[] {
101
+ const source = css.replace(/\/\*[\s\S]*?\*\//g, '')
102
+ const found: ScannedDeclaration[] = []
103
+ const stack: (readonly string[])[] = []
104
+ let buffer = ''
105
+
106
+ const flush = (): void => {
107
+ const property = DECLARATION.exec(buffer)?.[1]
108
+ if (property !== undefined) found.push({ property, chain: [...stack] })
109
+ buffer = ''
110
+ }
111
+
112
+ for (const char of source) {
113
+ if (char === '{') {
114
+ stack.push(members(buffer))
115
+ buffer = ''
116
+ } else if (char === '}') {
117
+ flush()
118
+ stack.pop()
119
+ } else if (char === ';') {
120
+ flush()
121
+ } else {
122
+ buffer += char
123
+ }
124
+ }
125
+ flush()
126
+
127
+ return found
128
+ }
129
+
130
+ export interface ThemeResult {
131
+ readonly selector: string
132
+ /** False when no block in the stylesheet opens this theme. */
133
+ readonly matched: boolean
134
+ /** Properties the record names that the theme's blocks do not declare. */
135
+ readonly missing: readonly string[]
136
+ }
137
+
138
+ /**
139
+ * Checks each declared theme for the properties given. A declaration counts
140
+ * for the outermost enclosing prelude that is a declared theme, so a `:root`
141
+ * inside a declared media query belongs to the query and never to the light
142
+ * `:root` as well, and `:root` never matches `:root[data-theme='dark']` by
143
+ * containment.
144
+ */
145
+ export function checkThemes(
146
+ themes: readonly string[],
147
+ declarations: readonly ScannedDeclaration[],
148
+ properties: readonly string[],
149
+ ): ThemeResult[] {
150
+ const declared = new Set(themes.map(normalizePrelude))
151
+ const owned = new Map<string, Set<string>>()
152
+
153
+ for (const { property, chain } of declarations) {
154
+ const level = chain.find((alternatives) =>
155
+ alternatives.some((prelude) => declared.has(prelude)),
156
+ )
157
+ for (const prelude of level ?? []) {
158
+ if (!declared.has(prelude)) continue
159
+ const set = owned.get(prelude) ?? new Set<string>()
160
+ set.add(property)
161
+ owned.set(prelude, set)
162
+ }
163
+ }
164
+
165
+ return themes.map((selector) => {
166
+ const theme = owned.get(normalizePrelude(selector))
167
+ return {
168
+ selector,
169
+ matched: theme !== undefined,
170
+ missing:
171
+ theme === undefined
172
+ ? []
173
+ : properties.filter((property) => !theme.has(property)),
174
+ }
175
+ })
176
+ }
@@ -95,6 +95,9 @@ const SANS = 'Geist Variable, DejaVu Sans, sans-serif'
95
95
  const DARK_GROUNDS = ['background', 'surface'] as const
96
96
  const LIGHT_GROUNDS = ['light-background', 'light-surface'] as const
97
97
 
98
+ /** The day the record was last read whole, rendered into its frontmatter so `canon design regen` and the review point agree. */
99
+ export const REVIEWED = '2026-10-24'
100
+
98
101
  export const TOKENS: DesignTokens = {
99
102
  personality:
100
103
  'Warm neutrals under one rust accent, set in Geist. The page reads as prose, and monospace appears only where a surface shows what a shell printed. One accent carries every count, link, and primary action.',
@@ -1,6 +1,7 @@
1
1
  import { chromium } from 'playwright-core'
2
2
  import type { Browser, Page } from 'playwright-core'
3
3
  import { isBrowserMissing, isServerUnreachable } from '@/browser/engine'
4
+ import { AxeEngineMissing, probeAxe } from '@/driver/probes/axe'
4
5
  import { probeDetails } from '@/driver/probes/details'
5
6
  import {
6
7
  probeDiagramGeometry,
@@ -43,6 +44,19 @@ const PROBES: Record<ProbeName, (page: Page) => Promise<Finding[]>> = {
43
44
  details: probeDetails,
44
45
  'diagram-geometry': probeDiagramGeometry,
45
46
  'diagram-strokes': probeDiagramStrokes,
47
+ axe: probeAxe,
48
+ }
49
+
50
+ /**
51
+ * Runs `axe` after every other probe a step names, whatever order the run
52
+ * wrote, so every probe sharing its step reads the page before the engine's
53
+ * script has ever loaded into it.
54
+ */
55
+ function inRunOrder(names: readonly ProbeName[]): ProbeName[] {
56
+ return [
57
+ ...names.filter((name) => name !== 'axe'),
58
+ ...names.filter((name) => name === 'axe'),
59
+ ]
46
60
  }
47
61
 
48
62
  export interface DriveOptions {
@@ -60,6 +74,7 @@ export interface PassReading {
60
74
 
61
75
  export type DriverRefusal =
62
76
  | 'browser-missing'
77
+ | 'axe-missing'
63
78
  | 'server-unreachable'
64
79
  | 'drive-failed'
65
80
 
@@ -107,6 +122,7 @@ export async function drive(options: DriveOptions): Promise<DriverResult> {
107
122
  passes.push(...pass.passes)
108
123
  }
109
124
  } catch (error) {
125
+ if (error instanceof AxeEngineMissing) return failed('axe-missing', error)
110
126
  return failed(
111
127
  isServerUnreachable(error) ? 'server-unreachable' : 'drive-failed',
112
128
  error,
@@ -159,7 +175,7 @@ async function driveViewport(
159
175
 
160
176
  const names = stepProbes(options.plan, step)
161
177
  let count = 0
162
- for (const name of names) {
178
+ for (const name of inRunOrder(names)) {
163
179
  const probe = PROBES[name]
164
180
  for (const finding of await probe(page)) {
165
181
  findings.push({ ...finding, step: step.name, viewport: where })
@@ -0,0 +1,195 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import type { Page } from 'playwright-core'
4
+ import type { Finding } from '@/driver/steps'
5
+
6
+ /**
7
+ * Runs axe-core on the WCAG A and AA tags and reports what it found, marking
8
+ * the findings a UI review holds open on.
9
+ *
10
+ * The engine is canon's own dependency and is read from canon's install at run
11
+ * time, so nothing reaches a target's manifest or production build.
12
+ *
13
+ * The bundle assigns `window.axe`, `doT`, and `_encodeHTML` as it loads, and
14
+ * registers with any AMD loader the page carries. It is evaluated with
15
+ * `define`, `module`, and `exports` shadowed, and every global it added or
16
+ * replaced as it loaded is put back once the run settles, so a page owning a
17
+ * global named `axe` keeps it, a global the page sets during the run survives,
18
+ * and a probe running after this one reads the page it found.
19
+ */
20
+
21
+ /** The tags the groundwork measured on, which together are WCAG 2.2 A and AA. */
22
+ const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']
23
+
24
+ /**
25
+ * The rules a violation of which keeps a UI review open. Each raised no false
26
+ * finding across frameboard's real screens, and a rule moves to report-only on
27
+ * its first false finding on a real pull request.
28
+ *
29
+ * `label-content-name-mismatch` stays off it as a text heuristic, and every
30
+ * incomplete result stays off it as axe saying it could not decide.
31
+ */
32
+ export const AXE_GATE_RULES: readonly string[] = [
33
+ 'color-contrast',
34
+ 'target-size',
35
+ 'button-name',
36
+ 'link-name',
37
+ 'input-button-name',
38
+ 'select-name',
39
+ 'label',
40
+ 'image-alt',
41
+ 'document-title',
42
+ 'html-has-lang',
43
+ 'html-lang-valid',
44
+ 'aria-valid-attr',
45
+ 'aria-required-attr',
46
+ 'aria-roles',
47
+ 'nested-interactive',
48
+ ]
49
+
50
+ const ENGINE = 'axe-core/axe.min.js'
51
+
52
+ /**
53
+ * The engine was not where canon's install should hold it. Thrown rather than
54
+ * returned as an empty list, since no findings and no engine read the same to
55
+ * a caller counting findings.
56
+ */
57
+ export class AxeEngineMissing extends Error {
58
+ override readonly name = 'AxeEngineMissing'
59
+ }
60
+
61
+ /** Reads the engine bundle out of canon's own install. */
62
+ export function readAxeEngine(specifier: string = ENGINE): string {
63
+ try {
64
+ const path = createRequire(import.meta.url).resolve(specifier)
65
+ return readFileSync(path, 'utf8')
66
+ } catch (error) {
67
+ throw new AxeEngineMissing(
68
+ `axe-core did not resolve from canon's install as ${specifier}. ${error instanceof Error ? error.message : String(error)}`,
69
+ { cause: error },
70
+ )
71
+ }
72
+ }
73
+
74
+ let cached: string | undefined
75
+
76
+ /** Read once per process, since a drive calls the probe after every step. */
77
+ function cachedEngine(): string {
78
+ cached ??= readAxeEngine()
79
+ return cached
80
+ }
81
+
82
+ interface Row {
83
+ readonly rule: string
84
+ readonly outcome: 'violation' | 'incomplete'
85
+ readonly help: string
86
+ readonly criteria: string
87
+ readonly selector: string
88
+ readonly summary: string
89
+ }
90
+
91
+ const OUTCOME_ORDER = { violation: 0, incomplete: 1 } as const
92
+
93
+ export async function probeAxe(
94
+ page: Page,
95
+ engine: () => string = cachedEngine,
96
+ ): Promise<Finding[]> {
97
+ const source = engine()
98
+ const rows = (await page.evaluate(runner(source))) as Row[]
99
+ const gating = new Set(AXE_GATE_RULES)
100
+
101
+ return [...rows]
102
+ .sort(
103
+ (a, b) =>
104
+ OUTCOME_ORDER[a.outcome] - OUTCOME_ORDER[b.outcome] ||
105
+ a.rule.localeCompare(b.rule) ||
106
+ a.selector.localeCompare(b.selector),
107
+ )
108
+ .map((row) => ({
109
+ probe: 'axe' as const,
110
+ selector: row.selector,
111
+ detail:
112
+ row.outcome === 'violation'
113
+ ? row.help
114
+ : `${row.help}, which axe could not decide and a person reads`,
115
+ measured: [row.criteria, row.summary].filter(Boolean).join(': '),
116
+ rule: row.rule,
117
+ outcome: row.outcome,
118
+ gate: row.outcome === 'violation' && gating.has(row.rule),
119
+ }))
120
+ }
121
+
122
+ /**
123
+ * Builds the expression the page evaluates. A string rather than a function,
124
+ * so the bundle is evaluated by the debugging protocol, which a page's content
125
+ * security policy does not govern, where a `new Function` inside the page would
126
+ * be refused by any policy without `unsafe-eval`.
127
+ */
128
+ function runner(source: string): string {
129
+ const options = JSON.stringify({
130
+ runOnly: { type: 'tag', values: TAGS },
131
+ resultTypes: ['violations', 'incomplete'],
132
+ iframes: false,
133
+ })
134
+
135
+ return `(async () => {
136
+ const scope = window
137
+ const before = new Map()
138
+ for (const key of Object.getOwnPropertyNames(scope)) {
139
+ before.set(key, Object.getOwnPropertyDescriptor(scope, key))
140
+ }
141
+ const prior = before.get('axe')
142
+
143
+ // The keys the bundle's load added or replaced, read once it has loaded and
144
+ // before the run awaits, so a global the page sets mid-run stays the page's.
145
+ const loaded = () =>
146
+ Object.getOwnPropertyNames(scope).filter((key) => {
147
+ const was = before.get(key)
148
+ if (!was) return true
149
+ const now = Object.getOwnPropertyDescriptor(scope, key)
150
+ return Boolean(now && 'value' in was && now.value !== was.value)
151
+ })
152
+ let touched
153
+
154
+ try {
155
+ ;(function (define, module, exports) {
156
+ ${source}
157
+ }).call(scope)
158
+ touched = loaded()
159
+
160
+ const engine = scope.axe
161
+ if (!engine || engine === prior?.value || typeof engine.run !== 'function') {
162
+ throw new Error('the page holds window.axe in a property the engine could not replace')
163
+ }
164
+
165
+ const result = await engine.run(document, ${options})
166
+ const rows = []
167
+ const read = (outcome, rules) => {
168
+ for (const rule of rules) {
169
+ const criteria = rule.tags.filter((tag) => /^wcag\\d{3,}$/.test(tag)).join(' ')
170
+ for (const node of rule.nodes) {
171
+ rows.push({
172
+ rule: rule.id,
173
+ outcome,
174
+ help: rule.help,
175
+ criteria,
176
+ selector: node.target.map((part) => [].concat(part).join(' >>> ')).join(' '),
177
+ summary: (node.failureSummary || '').replace(/\\s+/g, ' ').trim(),
178
+ })
179
+ }
180
+ }
181
+ }
182
+ read('violation', result.violations)
183
+ read('incomplete', result.incomplete)
184
+ return rows
185
+ } finally {
186
+ // A load that threw partway set nothing the page could have raced, so
187
+ // the keys that differ now are all the bundle's.
188
+ for (const key of touched ?? loaded()) {
189
+ const was = before.get(key)
190
+ if (was) Object.defineProperty(scope, key, was)
191
+ else delete scope[key]
192
+ }
193
+ }
194
+ })()`
195
+ }
@@ -28,10 +28,20 @@ export const PROBE_NAMES = [
28
28
  'details',
29
29
  'diagram-geometry',
30
30
  'diagram-strokes',
31
+ 'axe',
31
32
  ] as const
32
33
 
33
34
  export type ProbeName = (typeof PROBE_NAMES)[number]
34
35
 
36
+ /**
37
+ * What a run that names no list measures. `axe` is left out because it loads a
38
+ * third-party script into the page under test, which a run opts into by name
39
+ * rather than inheriting.
40
+ */
41
+ export const DEFAULT_PROBES: readonly ProbeName[] = PROBE_NAMES.filter(
42
+ (name) => name !== 'axe',
43
+ )
44
+
35
45
  /**
36
46
  * One thing a probe measured and judged wrong. The step and the viewport are
37
47
  * filled in by the orchestrator rather than by the probe, since a probe runs
@@ -44,6 +54,15 @@ export interface Finding {
44
54
  readonly selector: string
45
55
  readonly detail: string
46
56
  readonly measured: string
57
+ /** The engine rule that raised it, set by a probe that delegates to one. */
58
+ readonly rule?: string
59
+ /** Whether the engine judged the element or declined to decide. */
60
+ readonly outcome?: 'violation' | 'incomplete'
61
+ /**
62
+ * True when the finding is one a review holds open on. The probe sets it and
63
+ * still never gates, since a gate belongs to the reader, not to this command.
64
+ */
65
+ readonly gate?: boolean
47
66
  }
48
67
 
49
68
  /** A finding once the run has said where it happened. */
@@ -169,7 +188,7 @@ export function readDriverPlan(source: string): PlanRead {
169
188
 
170
189
  /** Returns the parsed list, or the message naming what failed. */
171
190
  function readProbeList(value: unknown): ProbeName[] | string {
172
- if (value === undefined) return [...PROBE_NAMES]
191
+ if (value === undefined) return [...DEFAULT_PROBES]
173
192
  if (!Array.isArray(value)) return 'probes is not a list'
174
193
 
175
194
  const names: ProbeName[] = []