@erclx/canon 5.60.0 → 5.62.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/CHANGELOG.md +23 -0
- package/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +2 -1
- package/claude/skills/design-identity/references/card-route.md +3 -3
- package/claude/skills/design-record/references/design.md +19 -0
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/SKILL.md +6 -5
- package/claude/skills/document-health/references/axes.md +3 -1
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +3 -2
- package/claude/skills/review-ui/references/post.md +2 -2
- package/claude/skills/target-update/SKILL.md +9 -1
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-checklist/SKILL.md +12 -0
- package/claude/skills/ux-audit/SKILL.md +1 -1
- package/docs/agents/install-and-sync.md +45 -11
- package/docs/agents/records.md +1 -1
- package/docs/index.md +1 -1
- package/docs/target/migrations.md +7 -9
- package/docs/target/sync.md +20 -1
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/index.md +3 -3
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/visual-design-workflow.md +58 -32
- package/governance/detect.toml +8 -0
- package/governance/ledger/ui/a11y.md +8 -6
- package/governance/ledger/ui/keyboard.md +20 -21
- package/governance/ledger/ui/pointer.md +24 -0
- package/governance/ledger/ui/surface-capture.md +12 -12
- package/governance/ledger/ui/ux-completeness.md +11 -23
- package/governance/ledger/ui/ux-controls.md +26 -0
- package/governance/rules/ui/a11y.md +2 -0
- package/governance/rules/ui/keyboard.md +4 -7
- package/governance/rules/ui/pointer.md +23 -0
- package/governance/rules/ui/surface-capture.md +5 -5
- package/governance/rules/ui/ux-completeness.md +2 -10
- package/governance/rules/ui/ux-controls.md +25 -0
- package/governance/splits.toml +15 -0
- package/package.json +2 -1
- package/src/commands/driver.ts +29 -3
- package/src/commands/migrate.ts +7 -0
- package/src/commands/records.ts +3 -2
- package/src/commands/sync.ts +3 -1
- package/src/commands/tooling.ts +329 -10
- package/src/design/audit.ts +25 -1
- package/src/design/budget.ts +2 -1
- package/src/design/document.ts +5 -1
- package/src/design/themes.ts +176 -0
- package/src/design/tokens.ts +3 -0
- package/src/driver/drive.ts +17 -1
- package/src/driver/probes/axe.ts +195 -0
- package/src/driver/steps.ts +20 -1
- package/src/gate/measures.ts +9 -3
- package/src/gov/adapter.ts +53 -4
- package/src/gov/splits.ts +36 -0
- package/src/markdown/structure.ts +4 -1
- package/src/migrate/records.ts +46 -7
- package/src/records/stale-canonical.ts +2 -2
- package/src/sync/check.ts +9 -1
- package/src/sync/stamp.ts +135 -11
- package/src/tooling/disposition.ts +167 -0
- package/src/tooling/inject.ts +17 -2
- package/src/tooling/scan.ts +86 -7
- package/src/tooling/stamp.ts +51 -7
package/src/design/audit.ts
CHANGED
|
@@ -3,6 +3,12 @@ import { join, resolve } from 'node:path'
|
|
|
3
3
|
import { measureDesignProse } from '@/design/budget'
|
|
4
4
|
import { parseDesignDoc } from '@/design/parse'
|
|
5
5
|
import { DESIGN_DOCUMENT } from '@/design/regen'
|
|
6
|
+
import {
|
|
7
|
+
checkThemes,
|
|
8
|
+
readThemes,
|
|
9
|
+
scanDeclarations,
|
|
10
|
+
type ThemeResult,
|
|
11
|
+
} from '@/design/themes'
|
|
6
12
|
|
|
7
13
|
/** The custom property a table row names, such as `--color-accent`. */
|
|
8
14
|
const PROPERTY = /--[A-Za-z0-9_-]+/g
|
|
@@ -19,6 +25,8 @@ export type TokenCheck =
|
|
|
19
25
|
readonly stylesheet: string
|
|
20
26
|
readonly properties: number
|
|
21
27
|
readonly missing: readonly string[]
|
|
28
|
+
/** One entry per theme the record declares, absent where it declares none. */
|
|
29
|
+
readonly themes?: readonly ThemeResult[]
|
|
22
30
|
}
|
|
23
31
|
| { readonly status: 'unchecked'; readonly reason: string }
|
|
24
32
|
| { readonly status: 'not-applicable'; readonly reason: string }
|
|
@@ -53,7 +61,11 @@ export function auditDesign(root: string): DesignAudit {
|
|
|
53
61
|
const clean =
|
|
54
62
|
prose.length === 0 &&
|
|
55
63
|
tokens.status !== 'unchecked' &&
|
|
56
|
-
(tokens.status !== 'checked' ||
|
|
64
|
+
(tokens.status !== 'checked' ||
|
|
65
|
+
(tokens.missing.length === 0 &&
|
|
66
|
+
(tokens.themes ?? []).every(
|
|
67
|
+
(theme) => theme.matched && theme.missing.length === 0,
|
|
68
|
+
)))
|
|
57
69
|
|
|
58
70
|
return { ok: true, record: DESIGN_DOCUMENT, prose, tokens, clean }
|
|
59
71
|
}
|
|
@@ -100,10 +112,22 @@ function checkTokens(root: string, path: string, source: string): TokenCheck {
|
|
|
100
112
|
),
|
|
101
113
|
]
|
|
102
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
|
+
|
|
103
124
|
return {
|
|
104
125
|
status: 'checked',
|
|
105
126
|
stylesheet: named,
|
|
106
127
|
properties: properties.length,
|
|
107
128
|
missing: properties.filter((property) => !declared.has(property)),
|
|
129
|
+
...(themes.length > 0 && {
|
|
130
|
+
themes: checkThemes(themes, scanDeclarations(css), colors),
|
|
131
|
+
}),
|
|
108
132
|
}
|
|
109
133
|
}
|
package/src/design/budget.ts
CHANGED
|
@@ -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/document.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/design/tokens.ts
CHANGED
|
@@ -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.',
|
package/src/driver/drive.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/driver/steps.ts
CHANGED
|
@@ -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 [...
|
|
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[] = []
|
package/src/gate/measures.ts
CHANGED
|
@@ -590,10 +590,16 @@ export const recordIdempotence: Measure = async (ctx) => {
|
|
|
590
590
|
}
|
|
591
591
|
|
|
592
592
|
const record = parseJson(run.stdout) as
|
|
593
|
-
| {
|
|
593
|
+
| {
|
|
594
|
+
moves?: unknown
|
|
595
|
+
rewritten?: unknown
|
|
596
|
+
dropped?: unknown
|
|
597
|
+
paths?: unknown
|
|
598
|
+
}
|
|
594
599
|
| undefined
|
|
595
600
|
const moves = record?.moves
|
|
596
601
|
const rewritten = record?.rewritten
|
|
602
|
+
const dropped = typeof record?.dropped === 'number' ? record.dropped : 0
|
|
597
603
|
|
|
598
604
|
if (!Array.isArray(moves) || typeof rewritten !== 'number') {
|
|
599
605
|
return {
|
|
@@ -613,14 +619,14 @@ export const recordIdempotence: Measure = async (ctx) => {
|
|
|
613
619
|
}
|
|
614
620
|
}
|
|
615
621
|
|
|
616
|
-
if (rewritten > 0) {
|
|
622
|
+
if (rewritten + dropped > 0) {
|
|
617
623
|
return {
|
|
618
624
|
// The payload already names every file, so listing them here is what
|
|
619
625
|
// separates a count a reader has to go and reproduce from a remedy they
|
|
620
626
|
// can act on. A malformed `paths` costs the list and not the finding,
|
|
621
627
|
// since the count above it was read from a field of its own.
|
|
622
628
|
emissions: citedPaths(record).map((path) => warn(path)),
|
|
623
|
-
failure: `The records are at .canon/ and ${rewritten} citation(s) still name the old root, so a second run of canon migrate records would rewrite them. Repoint each one, or mark it canon-keep-record-root where the sentence has to keep the old spelling.`,
|
|
629
|
+
failure: `The records are at .canon/ and ${rewritten + dropped} citation(s) still name the old root, so a second run of canon migrate records would rewrite or drop them. Repoint each one, or mark it canon-keep-record-root where the sentence has to keep the old spelling.`,
|
|
624
630
|
}
|
|
625
631
|
}
|
|
626
632
|
|
package/src/gov/adapter.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { basename, join, relative, resolve } from 'node:path'
|
|
|
3
3
|
import { readCoverage } from '@/gov/coverage'
|
|
4
4
|
import { canonRulesDir, ruleSubdir } from '@/gov/install'
|
|
5
5
|
import { loadRenames, resolveSuccessor } from '@/gov/renames'
|
|
6
|
+
import { loadSplits } from '@/gov/splits'
|
|
6
7
|
import { resolveMissingRules } from '@/gov/stacks'
|
|
7
8
|
import type { InstalledFile, RetiredSurface, SyncAdapter } from '@/sync/engine'
|
|
8
9
|
import { readStamp, stampedChain } from '@/sync/stamp'
|
|
@@ -125,14 +126,15 @@ function heldRulePath(target: string, rule: string): string | undefined {
|
|
|
125
126
|
|
|
126
127
|
/**
|
|
127
128
|
* Rules the target's recorded chain entitles it to and its tree does not
|
|
128
|
-
* hold. Reports as `notice` text
|
|
129
|
-
* already returns, since both are
|
|
130
|
-
* absence to remove, this one an
|
|
129
|
+
* hold, then the partners of a held rule that split. Reports as `notice` text
|
|
130
|
+
* through the same shape `collectRetired` already returns, since both are
|
|
131
|
+
* surfaces the file walk cannot see: one an absence to remove, this one an
|
|
132
|
+
* absence to add.
|
|
131
133
|
*/
|
|
132
134
|
function collectMissingGov(root: string, target: string): RetiredSurface[] {
|
|
133
135
|
const chain = stampedChain(readStamp(target), 'governance')
|
|
134
136
|
|
|
135
|
-
|
|
137
|
+
const fromChain = resolveMissingRules(root, target, chain).map((source) => {
|
|
136
138
|
const dest = join(canonRulesDir(target), source.subdir, `${source.rule}.md`)
|
|
137
139
|
const rel = relative(target, dest)
|
|
138
140
|
return {
|
|
@@ -141,6 +143,53 @@ function collectMissingGov(root: string, target: string): RetiredSurface[] {
|
|
|
141
143
|
notice: `${rel} (listed by ${chain[0]}, not installed. Run canon gov install ${chain[0]} to add it.)`,
|
|
142
144
|
}
|
|
143
145
|
})
|
|
146
|
+
|
|
147
|
+
const listed = new Set(fromChain.map((entry) => entry.path))
|
|
148
|
+
const fromSplits = collectSplitPartners(root, target).filter(
|
|
149
|
+
(entry) => !listed.has(entry.path),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return [...fromChain, ...fromSplits]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Partners the split ledger names for a rule the target holds and whose own
|
|
157
|
+
* file it lacks. The ui rules sit in no stack, so no chain entitles a target
|
|
158
|
+
* to them and the chain-based read stays silent when one splits. Holding the
|
|
159
|
+
* rule is the entitlement, found by basename the way `heldRulePath` finds a
|
|
160
|
+
* predecessor, so a partner held under another folder reads as held.
|
|
161
|
+
*/
|
|
162
|
+
function collectSplitPartners(root: string, target: string): RetiredSurface[] {
|
|
163
|
+
const splits = loadSplits(root)
|
|
164
|
+
if (splits.size === 0) return []
|
|
165
|
+
|
|
166
|
+
const index = indexSourceRules(root)
|
|
167
|
+
const notices: RetiredSurface[] = []
|
|
168
|
+
|
|
169
|
+
for (const [held, partners] of splits) {
|
|
170
|
+
if (heldRulePath(target, held) === undefined) continue
|
|
171
|
+
|
|
172
|
+
for (const partner of partners) {
|
|
173
|
+
const source = index.get(partner)
|
|
174
|
+
if (source === undefined || heldRulePath(target, partner) !== undefined) {
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const dest = join(
|
|
179
|
+
canonRulesDir(target),
|
|
180
|
+
ruleSubdir(source, rulesSourceDir(root)),
|
|
181
|
+
`${partner}.md`,
|
|
182
|
+
)
|
|
183
|
+
const rel = relative(target, dest)
|
|
184
|
+
notices.push({
|
|
185
|
+
path: dest,
|
|
186
|
+
rel,
|
|
187
|
+
notice: `${rel} (${held}.md moved bullets here. Run canon gov install --add ${partner} to add it.)`,
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return notices
|
|
144
193
|
}
|
|
145
194
|
|
|
146
195
|
function collectRetiredGov(target: string): RetiredSurface[] {
|