@erclx/canon 5.60.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.
- package/CHANGELOG.md +14 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- 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/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-checklist/SKILL.md +12 -0
- package/docs/agents/records.md +1 -1
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- 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/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/markdown/structure.ts +4 -1
- package/src/migrate/records.ts +46 -7
- package/src/records/stale-canonical.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.61.0](https://github.com/erclx/canon/compare/v5.60.0...v5.61.0) (2026-10-24)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **design:** check themes and add a review point ([#2673](https://github.com/erclx/canon/issues/2673)) ([8cdaa5c](https://github.com/erclx/canon/commit/8cdaa5c001b2d783108c5ce6d70da0b6cca0719c))
|
|
9
|
+
* **driver:** add an axe probe that gates the UI review ([#2672](https://github.com/erclx/canon/issues/2672)) ([785e474](https://github.com/erclx/canon/commit/785e474b76eae4f41a55fd588df0b3691756742d))
|
|
10
|
+
* **migrate:** drop a covered ignore line from the records sweep ([#2668](https://github.com/erclx/canon/issues/2668)) ([188bee8](https://github.com/erclx/canon/commit/188bee8c0061045e46e660da50422b5d5673cf14))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **markdown:** measure a bullet without its list marker ([#2670](https://github.com/erclx/canon/issues/2670)) ([c7b9d70](https://github.com/erclx/canon/commit/c7b9d70fc522a91dc9b2b058481d1eb984abd3ad))
|
|
16
|
+
|
|
3
17
|
## [5.60.0](https://github.com/erclx/canon/compare/v5.59.0...v5.60.0) (2026-10-24)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -14,6 +14,7 @@ Applies to `canon/DESIGN.md`. Captures visual intent and the decisions behind ho
|
|
|
14
14
|
- [What goes in](#what-goes-in)
|
|
15
15
|
- [What does not go in](#what-does-not-go-in)
|
|
16
16
|
- [Format](#format)
|
|
17
|
+
- [Frontmatter](#frontmatter)
|
|
17
18
|
- [Prose budget](#prose-budget)
|
|
18
19
|
- [Sections](#sections)
|
|
19
20
|
- [The uncertainty tag](#the-uncertainty-tag)
|
|
@@ -56,6 +57,17 @@ Everything below applies to both shapes unless it names one.
|
|
|
56
57
|
- Plain English over technical notation. If a section could be removed and the developer would still build correctly from the stylesheet and code alone, remove it.
|
|
57
58
|
- Keep table headers and role names intact so the parser can read the tables.
|
|
58
59
|
|
|
60
|
+
## Frontmatter
|
|
61
|
+
|
|
62
|
+
Both fields are optional, and a record without them audits as it always did. Frontmatter never counts toward the prose budget.
|
|
63
|
+
|
|
64
|
+
- `themes:` lists each theme as the stylesheet prelude that opens it, such as `:root` or `@media (prefers-color-scheme: dark)`.
|
|
65
|
+
- `canon design audit` checks every color property `## Colors` names against each theme, and reports a theme that omits one or opens no block.
|
|
66
|
+
- `reviewed:` is the day the record was last read whole, as `YYYY-MM-DD`. `canon records stale canonical` returns it with the releases tagged since.
|
|
67
|
+
- No skill step sets `reviewed:`, so whoever reads the record whole updates it.
|
|
68
|
+
|
|
69
|
+
Quote style and spacing may differ between the record and the stylesheet. A declaration inside a declared media query counts for the query alone, and `:root` never matches `:root[data-theme='dark']` by containment. A values record renders `reviewed:` from its token module and declares no `themes:`.
|
|
70
|
+
|
|
59
71
|
## Prose budget
|
|
60
72
|
|
|
61
73
|
The record is a lookup, so its prose stays short enough to scan. Why a rule was chosen belongs in the project's context entry for design, never here.
|
|
@@ -113,6 +125,13 @@ A prose section takes its uncertainty inline instead, in a sentence saying what
|
|
|
113
125
|
The template is the intent record. The column headers are read by exact key, so they stay verbatim. Row names are not, which leaves a project free to rename a role, add one, or drop one it has no use for.
|
|
114
126
|
|
|
115
127
|
```markdown
|
|
128
|
+
---
|
|
129
|
+
themes:
|
|
130
|
+
- ':root'
|
|
131
|
+
- ":root[data-theme='dark']"
|
|
132
|
+
reviewed: YYYY-MM-DD
|
|
133
|
+
---
|
|
134
|
+
|
|
116
135
|
# Design
|
|
117
136
|
|
|
118
137
|
Values live in `<path to the stylesheet>`. This record holds the intent behind them.
|
|
@@ -17,7 +17,7 @@ Length, placement, and staleness answer to different evidence, and one list hand
|
|
|
17
17
|
- Propose a split or an owner as a structure line for each overgrown entry and doubled or unnamed coverage finding, with the headings and the reason
|
|
18
18
|
- Ship the staleness axis as a reading rather than cutting it for lacking a verb
|
|
19
19
|
- Report a refusal, an absent key, or a missing subcommand as an unread axis naming the cause, and say which layer answered the classifier sweep
|
|
20
|
-
- Run standalone, reading the requirements record for drift with its reminder, and the design record through `canon design audit --json
|
|
20
|
+
- Run standalone, reading the requirements record for drift with its reminder, and the design record through `canon design audit --json` and its review reminder, routing findings to `design-record`
|
|
21
21
|
|
|
22
22
|
## Must not
|
|
23
23
|
|
|
@@ -68,7 +68,7 @@ A refusal, an absent key, and a subcommand the project's binary does not carry e
|
|
|
68
68
|
canon design audit --json
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
The record carries `prose`, the lines past the budget
|
|
71
|
+
The record carries `prose`, the lines past the budget, and `tokens`, the custom properties the stylesheet lacks. Where the record declares `themes:`, `tokens.themes` adds one cell per theme. `${CLAUDE_SKILL_DIR}/references/axes.md` holds how each reads and where it routes. Skip the verb when the scope excludes the record or the record is `null`.
|
|
72
72
|
|
|
73
73
|
## Step 3: read the staleness axis
|
|
74
74
|
|
|
@@ -76,15 +76,15 @@ No verb answers whether a document still describes the tree it was written about
|
|
|
76
76
|
|
|
77
77
|
Mark every staleness finding as a reading. It is this session's judgment rather than a measurement, and a report that sets it beside two verb-backed axes without marking it implies evidence the axis does not carry.
|
|
78
78
|
|
|
79
|
-
### The
|
|
79
|
+
### The review points
|
|
80
80
|
|
|
81
|
-
When the scope reaches `canon/REQUIREMENTS.md
|
|
81
|
+
When the scope reaches `canon/REQUIREMENTS.md` or `canon/DESIGN.md`, take the review point for each record the scope reaches from the verb:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
84
|
canon records stale canonical --json
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
Each doc in the record carries `reviewed` and `releasesSince`. Skip the verb when the scope reaches neither record. When the scope reaches the requirements record, also read it for identity drift as well as stale paths, with the claim shapes in `${CLAUDE_SKILL_DIR}/references/axes.md`.
|
|
88
88
|
|
|
89
89
|
Nothing triggers this review but a request. A finished review hands the rewrite to the operator, and its last edit sets `reviewed:` in the doc's frontmatter to the day the operator's answers landed. This run writes neither.
|
|
90
90
|
|
|
@@ -118,6 +118,7 @@ When Step 3 read the canonical records, add one reminder line per doc the verb r
|
|
|
118
118
|
|
|
119
119
|
```markdown
|
|
120
120
|
`canon/REQUIREMENTS.md`: reviewed <date>, <n> releases since.
|
|
121
|
+
`canon/DESIGN.md`: reviewed <date>, <n> releases since.
|
|
121
122
|
```
|
|
122
123
|
|
|
123
124
|
Write `reviewed value is not a date: <value>` for a doc carrying `invalidReviewed`, and `no release tags to count` in place of the count when `tagged` is false. Name the unread cause instead when the verb refused or the binary carries no `canonical` kind.
|
|
@@ -136,7 +137,7 @@ Keep the evidence line under the table on every run. A table read without it fla
|
|
|
136
137
|
|
|
137
138
|
When Step 2 read the design record, add a row for `canon/DESIGN.md`, with both findings `measured`.
|
|
138
139
|
|
|
139
|
-
- Put the `prose` findings in the Length cell and the `tokens` findings in the Placement cell
|
|
140
|
+
- Put the `prose` findings in the Length cell and the `tokens` findings in the Placement cell, naming the selector of each `tokens.themes` cell that omits a property or matched no block
|
|
140
141
|
- Write `unread: <reason>` in the Placement cell when `tokens.status` reads `unchecked`, since a check that could not run is no pass
|
|
141
142
|
- Write `n/a: <reason>` there when it reads `not-applicable`, a values record with no property to compare
|
|
142
143
|
- Route every finding to `design-record`, never to `document-repair`
|
|
@@ -89,6 +89,8 @@ The record is exempt from the length and placement verbs above, so this is the o
|
|
|
89
89
|
- `tokens.status: "not-applicable"`: a values record, which carries `Value` where an intent record carries `Property`. Report it as not applicable with the `reason`.
|
|
90
90
|
- `record: null`: the project holds no design record, so there is nothing to report
|
|
91
91
|
|
|
92
|
+
`tokens.themes` is present only where the record declares `themes:`, with one cell per theme carrying its `selector`, `matched`, and `missing`. A cell with `matched: false` means no block opens that selector, and one with `missing` names the color properties that theme leaves out. Report either under its selector.
|
|
93
|
+
|
|
92
94
|
**Routes to:** `design-record`, which owns the record's shape. `document-repair` rewrites a context folder and does not know the budget.
|
|
93
95
|
|
|
94
96
|
## Staleness
|
|
@@ -120,7 +122,7 @@ This record states what the project is and why, so it goes stale by the project
|
|
|
120
122
|
|
|
121
123
|
### The review point
|
|
122
124
|
|
|
123
|
-
The review point beside them is `measured
|
|
125
|
+
The review point beside them is `measured`, for the requirements record and the design record alike. `canon records stale canonical --json` returns the `reviewed` date from each record's frontmatter and `releasesSince`, the release tags merged into HEAD after that day.
|
|
124
126
|
|
|
125
127
|
- A null `releasesSince` means the doc was never reviewed
|
|
126
128
|
- `tagged: false` means no release tag exists to count, so a zero there measured nothing
|
|
@@ -17,7 +17,7 @@ A model asked to drive a checklist also drifts. It judges a taste box the operat
|
|
|
17
17
|
- The checklist and address come from the evidence record rather than the diff, and only a build of the head the verdict names is driven
|
|
18
18
|
- An unreachable address reports not driven rather than fail, a box no commit can settle leaves the review closed, and a pass that drove no box never reads as closed
|
|
19
19
|
- The review posts as its own pull request review under `## UI review` while anything is owed and `## UI review closed` once nothing is, ending on a marker naming the head it drove
|
|
20
|
-
- The pass drives a pinned browser under its own named session
|
|
20
|
+
- The pass drives a pinned browser under its own named session and sweeps only console errors, overflow, and the focus, details, and axe probes, opening on an axe finding the CLI marks as gating
|
|
21
21
|
|
|
22
22
|
## Must not
|
|
23
23
|
|
|
@@ -128,15 +128,16 @@ Push only a frame of the address this pass drove, since the branch is as public
|
|
|
128
128
|
|
|
129
129
|
## Step 6: sweep
|
|
130
130
|
|
|
131
|
-
Run
|
|
131
|
+
Run four checks over every route the boxes reached, and nothing beyond them:
|
|
132
132
|
|
|
133
133
|
- Console errors, through the wrapper's `console error` on each route.
|
|
134
134
|
- Sideways overflow at each width a box named, as `document.documentElement.scrollWidth` against `clientWidth`.
|
|
135
135
|
- The `focus` and `details` probes, through `canon drive <url> <run> --json` with a run file in the scratch folder.
|
|
136
|
+
- The `axe` probe, named in the same run file and read by each finding's `gate` rather than a copy of the CLI's rule list. On `axe-missing`, drop it and name the refusal as an unread check.
|
|
136
137
|
|
|
137
138
|
The probes launch their own browser at a URL, so a state the boxes reached by clicks reaches them only as steps the run file repeats. Where a state needs a step kind `canon drive` lacks, probe the route on arrival and say so.
|
|
138
139
|
|
|
139
|
-
A console error or an
|
|
140
|
+
A console error, an overflow, or an axe finding whose `gate` is true is a finding. Every other probe result is a reading to confirm, an `incomplete` axe outcome included, since each probe carries a known class of false finding. Close the browser with `${CLAUDE_SKILL_DIR}/scripts/pw.sh -s=<project>-<number> close` once the sweep ends.
|
|
140
141
|
|
|
141
142
|
## Step 7: post
|
|
142
143
|
|
|
@@ -11,7 +11,7 @@ Step 7 of `review-ui`.
|
|
|
11
11
|
|
|
12
12
|
The heading reports whether the branch owes anything, the way the code review's does.
|
|
13
13
|
|
|
14
|
-
- `## UI review` when a box failed, the sweep
|
|
14
|
+
- `## UI review` when a box failed, the sweep raised a finding Step 6 names, the worktree check read `changed`, a box was not driven for a cause on the page, or the pass drove no box at all.
|
|
15
15
|
- `## UI review closed` otherwise.
|
|
16
16
|
|
|
17
17
|
A needs-eyes box leaves the review closed, and so does a box not driven for a cause off the page, such as an address that did not answer or a `(local only)` box on a hosted preview, as long as something else was driven.
|
|
@@ -77,7 +77,7 @@ Driven at `<short-sha>` on <address>, rendered by <renderer>.
|
|
|
77
77
|
|
|
78
78
|
**Sweep**
|
|
79
79
|
|
|
80
|
-
- <route>, <width>: <console error, overflow, or probe reading to confirm>
|
|
80
|
+
- <route>, <width>: <console error, overflow, axe gate finding with its rule, or probe reading to confirm>
|
|
81
81
|
|
|
82
82
|
**Guessed wording**
|
|
83
83
|
|
|
@@ -13,7 +13,7 @@ A file under a gitignored folder is unreadable to a pull request reviewer, and a
|
|
|
13
13
|
|
|
14
14
|
## Must
|
|
15
15
|
|
|
16
|
-
- Every change is split into visual-only and automatable before anything is written
|
|
16
|
+
- Every change is split into visual-only and automatable before anything is written, and a behavior the review's engine cannot read, such as a keyboard trap, gets a box of its own
|
|
17
17
|
- The checklist covers the visual-only half, each item an action and the result a person looks for, with its route, width, and a `(taste)` or `(local only)` marker where one applies
|
|
18
18
|
- Every automatable change shipping with no test is named, with the layer `test-craft` would place it at, in the same file
|
|
19
19
|
- The file is written to the `.canon/tmp/handoff/ui-checklist/<slug>.md` handoff at the main worktree root, overwriting
|
|
@@ -46,6 +46,18 @@ When the plan cites `canon/design/<surface>/` under `**Design references:**`, na
|
|
|
46
46
|
|
|
47
47
|
The checkbox stays `- [ ]` followed by the action and the result, since the evidence comment reads boxes by that shape. Only the UI reviewer ticks a box, stamping it with the commit it passed at, and the comment carries that tick forward only while the head it names is current.
|
|
48
48
|
|
|
49
|
+
## Boxes no engine reads
|
|
50
|
+
|
|
51
|
+
The UI review runs axe over every route it reaches, so a box restating what axe checks, such as contrast, a missing name, or a page title, adds a drive and catches nothing. Derive a box instead for each behavior below that the changed surface carries:
|
|
52
|
+
|
|
53
|
+
- **Hover content.** Content shown on hover or focus stays while the pointer moves onto it, and Escape dismisses it, per WCAG 1.4.13.
|
|
54
|
+
- **Keyboard trap.** Tab and Shift+Tab leave every widget the change added, per WCAG 2.1.2.
|
|
55
|
+
- **Outside press.** A menu, popover, or dialog the change added closes on a press outside it and when focus leaves it.
|
|
56
|
+
- **Change on input.** Choosing an option or typing changes no route and moves no focus until the person submits, per WCAG 3.2.2.
|
|
57
|
+
- **Clipped focus ring.** Tab to each control the change added and judge from the capture whether a parent clips its ring, per WCAG 2.4.7.
|
|
58
|
+
|
|
59
|
+
Write each in the box shape above, naming the control. A surface carrying none of these behaviors takes no derived box.
|
|
60
|
+
|
|
49
61
|
## The missing-test list
|
|
50
62
|
|
|
51
63
|
Name every automatable change this branch ships with no test, one per line, with the layer `test-craft` would place it at. Do not write the test or read the project's test config to write one against.
|
package/docs/agents/records.md
CHANGED
|
@@ -58,4 +58,4 @@ Branch on `reason` in the `--json` record, not the exit code.
|
|
|
58
58
|
|
|
59
59
|
- `canon records ordinal <kind> <slug> --claim`: reserve the next intake or groundwork ordinal
|
|
60
60
|
- `canon records validate <kind> [record] --help`: where a record disagrees with its standard, plus a `structure` report that never sets the exit
|
|
61
|
-
- `canon records stale --help`: when the requirements
|
|
61
|
+
- `canon records stale --help`: when the requirements and design records were last reviewed
|
|
@@ -20,7 +20,7 @@ canon migrate records --json
|
|
|
20
20
|
canon migrate records --write --json
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
The first line takes the `.canon/` ignore entry
|
|
23
|
+
The first line takes the `.canon/` ignore entry. The second reports and the third applies, repointing files that cite a moved folder and deleting a `.gitignore` line `.canon/` covers, as `dropped`. A line naming an old path keeps it via `canon-keep-record-root` on that line or the nearest non-blank one above.
|
|
24
24
|
|
|
25
25
|
Then sweep the citations inside the records, which the verb above never reaches.
|
|
26
26
|
|
|
@@ -34,7 +34,7 @@ Assert the orchestrator role in one warm session with `canon:role-orchestrator`.
|
|
|
34
34
|
|
|
35
35
|
- `canon records validate plans [plan]` reports a plan breaking its standard, plus `structure` misses that never set the exit, and takes `groundwork` and `intake`
|
|
36
36
|
- `canon records push` and `canon records pull` carry `.canon/` to and from a private repository. See [records](../agents/records.md).
|
|
37
|
-
- `canon records size`, `canon records prune-tmp`, and `canon records stale canonical` report folder size, idle scratch, and releases since the requirements
|
|
37
|
+
- `canon records size`, `canon records prune-tmp`, and `canon records stale canonical` report folder size, idle scratch, and releases since the requirements and design records were last read
|
|
38
38
|
- `canon tasks archive` moves a shipped plan to `.canon/plans/archive/` and retargets the task's `Plan:` line
|
|
39
39
|
|
|
40
40
|
## Shipping a fan-out
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.61.0",
|
|
5
5
|
"description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"web:e2e": "cd web && playwright test"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
+
"axe-core": "4.14.0",
|
|
54
55
|
"commander": "^13.1.0",
|
|
55
56
|
"execa": "^9.5.2",
|
|
56
57
|
"playwright-core": "1.64.1"
|
package/src/commands/driver.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
|
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
|
|
5
5
|
import type { DriverRefusal } from '@/driver/drive'
|
|
6
|
+
import { AXE_GATE_RULES } from '@/driver/probes/axe'
|
|
6
7
|
import { describeViewport } from '@/driver/probes/viewport'
|
|
7
8
|
import { PROBE_NAMES, readDriverPlan, type ProbeName } from '@/driver/steps'
|
|
8
9
|
import type { PlanRefusal } from '@/driver/steps'
|
|
@@ -51,6 +52,8 @@ const PLAN_REFUSALS: Record<PlanRefusal, string> = {
|
|
|
51
52
|
|
|
52
53
|
const DRIVE_REFUSALS: Record<DriverRefusal, string> = {
|
|
53
54
|
'browser-missing': 'The browser binary is not installed in this project.',
|
|
55
|
+
'axe-missing':
|
|
56
|
+
'The axe engine did not resolve from canon, so no page was read with it.',
|
|
54
57
|
'server-unreachable': 'Nothing answered at the URL, so no state was reached.',
|
|
55
58
|
'drive-failed': 'The drive failed against a reachable page.',
|
|
56
59
|
}
|
|
@@ -62,6 +65,7 @@ const PROBE_NOTES: Record<ProbeName, string> = {
|
|
|
62
65
|
'diagram-geometry':
|
|
63
66
|
'an SVG label outside its frame, covered by a fill, or on another label',
|
|
64
67
|
'diagram-strokes': 'an SVG label a stroke crosses, in either paint order',
|
|
68
|
+
axe: 'axe-core on WCAG 2.2 A and AA, frames excluded, run only when named',
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
export function register(program: Command): void {
|
|
@@ -89,9 +93,10 @@ export function register(program: Command): void {
|
|
|
89
93
|
'run, since nothing else there measures the page as it first painted.',
|
|
90
94
|
'',
|
|
91
95
|
'The run file carries the viewport, the default probe list, and the',
|
|
92
|
-
'steps. A step names its own probes to override the default
|
|
93
|
-
'
|
|
94
|
-
'
|
|
96
|
+
'steps. A step names its own probes to override the default, and a run',
|
|
97
|
+
'naming no list gets every probe but axe. Viewport heights are never',
|
|
98
|
+
'defaulted: the heights a defect hides at are a property of the layout',
|
|
99
|
+
'rather than of this command.',
|
|
95
100
|
'',
|
|
96
101
|
' {',
|
|
97
102
|
' "viewport": { "width": 1440, "heights": [900, 1200, 1500] },',
|
|
@@ -116,6 +121,11 @@ export function register(program: Command): void {
|
|
|
116
121
|
(name) => ` ${name.padEnd(18)}${PROBE_NOTES[name]}`,
|
|
117
122
|
),
|
|
118
123
|
'',
|
|
124
|
+
'An axe finding carries rule, outcome, and gate. outcome is violation or',
|
|
125
|
+
'incomplete, the second being axe declining to decide. gate is true for',
|
|
126
|
+
'a violation of one of these rules, which a UI review holds open on:',
|
|
127
|
+
...wrapRules(AXE_GATE_RULES),
|
|
128
|
+
'',
|
|
119
129
|
'Reasons:',
|
|
120
130
|
' no-run-file nothing readable at the run path',
|
|
121
131
|
' unreadable-plan the run file is not valid JSON',
|
|
@@ -128,6 +138,7 @@ export function register(program: Command): void {
|
|
|
128
138
|
' no-heights the viewport names no height, which is never defaulted',
|
|
129
139
|
' engine-missing the browser engine is not installed in this project',
|
|
130
140
|
' browser-missing the engine is installed and its browser binary is not',
|
|
141
|
+
' axe-missing the run names axe and its engine did not resolve',
|
|
131
142
|
' server-unreachable nothing answered at the URL, so no state was reached',
|
|
132
143
|
' drive-failed the drive failed against a reachable page',
|
|
133
144
|
'',
|
|
@@ -153,6 +164,21 @@ export function register(program: Command): void {
|
|
|
153
164
|
})
|
|
154
165
|
}
|
|
155
166
|
|
|
167
|
+
/** Lays the gate list out at the help text's width. */
|
|
168
|
+
function wrapRules(rules: readonly string[]): string[] {
|
|
169
|
+
const lines: string[] = []
|
|
170
|
+
let line = ' '
|
|
171
|
+
for (const rule of rules) {
|
|
172
|
+
if (line.length + rule.length + 1 > 74) {
|
|
173
|
+
lines.push(line)
|
|
174
|
+
line = ' '
|
|
175
|
+
}
|
|
176
|
+
line += ` ${rule}`
|
|
177
|
+
}
|
|
178
|
+
lines.push(line)
|
|
179
|
+
return lines
|
|
180
|
+
}
|
|
181
|
+
|
|
156
182
|
async function runDriver(
|
|
157
183
|
url: string,
|
|
158
184
|
runPath: string,
|
package/src/commands/migrate.ts
CHANGED
|
@@ -326,6 +326,11 @@ function reportRecords(plan: RecordsPlan, records: number): void {
|
|
|
326
326
|
logInfo(
|
|
327
327
|
`${plural(plan.entries.length, 'file')} to change, ${plural(plan.rewritten, 'citation')} to rewrite.`,
|
|
328
328
|
)
|
|
329
|
+
if (plan.dropped > 0) {
|
|
330
|
+
logInfo(
|
|
331
|
+
`${plural(plan.dropped, 'ignore line')} dropped, already covered by .canon/.`,
|
|
332
|
+
)
|
|
333
|
+
}
|
|
329
334
|
logInfo(`${plural(plan.kept, 'citation')} marked to keep the old root.`)
|
|
330
335
|
|
|
331
336
|
// A count rather than a list, and its own line rather than a place in
|
|
@@ -401,6 +406,7 @@ function toRecordsRecord(
|
|
|
401
406
|
collisions: plan.collisions,
|
|
402
407
|
files: plan.entries.length,
|
|
403
408
|
rewritten: plan.rewritten,
|
|
409
|
+
dropped: plan.dropped,
|
|
404
410
|
kept: plan.kept,
|
|
405
411
|
excluded: plan.excluded.length,
|
|
406
412
|
coupled: plan.coupled,
|
|
@@ -414,6 +420,7 @@ function toRecordsRecord(
|
|
|
414
420
|
paths: plan.entries.map((entry) => ({
|
|
415
421
|
path: entry.path,
|
|
416
422
|
rewritten: entry.rewritten,
|
|
423
|
+
dropped: entry.dropped,
|
|
417
424
|
})),
|
|
418
425
|
}
|
|
419
426
|
}
|
package/src/commands/records.ts
CHANGED
|
@@ -223,7 +223,7 @@ export function register(program: Command): void {
|
|
|
223
223
|
records
|
|
224
224
|
.command('stale')
|
|
225
225
|
.description(
|
|
226
|
-
'Report when the requirements
|
|
226
|
+
'Report when the requirements and design records were last reviewed and how many releases have shipped since',
|
|
227
227
|
)
|
|
228
228
|
.argument('<kind>', `Record folder: ${STALE_KINDS.join(', ')}`)
|
|
229
229
|
.helpOption('-h, --help', 'Show this help message')
|
|
@@ -233,7 +233,8 @@ export function register(program: Command): void {
|
|
|
233
233
|
'after',
|
|
234
234
|
[
|
|
235
235
|
'',
|
|
236
|
-
'canonical: reads REQUIREMENTS.md at whichever surface root
|
|
236
|
+
'canonical: reads REQUIREMENTS.md and DESIGN.md at whichever surface root',
|
|
237
|
+
'carries each, skipping one the project lacks,',
|
|
237
238
|
'in the current worktree since it is tracked. It reports its reviewed date',
|
|
238
239
|
'and the release tags merged into HEAD after that day, or never when it',
|
|
239
240
|
'carries no date.',
|
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
|
|
|
@@ -422,6 +422,9 @@ export function longestRun(
|
|
|
422
422
|
* continuing a bullet do fold in, so a heavy bullet cannot fall under the
|
|
423
423
|
* checkpoint by being wrapped across two source lines. A fenced line closes the
|
|
424
424
|
* open bullet, which keeps a bullet from absorbing the example below it.
|
|
425
|
+
*
|
|
426
|
+
* A bullet is measured without its list marker, the way the `bullet-length`
|
|
427
|
+
* gate measures it, so the count and the gate agree at the checkpoint.
|
|
425
428
|
*/
|
|
426
429
|
export function heavyBullets(
|
|
427
430
|
lines: readonly BodyLine[],
|
|
@@ -470,7 +473,7 @@ function foldBullets(
|
|
|
470
473
|
close()
|
|
471
474
|
open = {
|
|
472
475
|
line: line.number,
|
|
473
|
-
characters: visibleText(text).length,
|
|
476
|
+
characters: visibleText(text.replace(LIST_ITEM, '')).length,
|
|
474
477
|
text,
|
|
475
478
|
indent: item[1].length,
|
|
476
479
|
}
|
package/src/migrate/records.ts
CHANGED
|
@@ -56,14 +56,21 @@ export function destinationPath(entry: string): string {
|
|
|
56
56
|
* shorter entry can claim its prefix, and each entry is escaped because two of
|
|
57
57
|
* them carry a dot.
|
|
58
58
|
*/
|
|
59
|
+
const ENTRY_ALTERNATION = [...MOVED_ENTRIES]
|
|
60
|
+
.sort((left, right) => right.length - left.length)
|
|
61
|
+
.map(escape)
|
|
62
|
+
.join('|')
|
|
63
|
+
|
|
59
64
|
const CITATION = new RegExp(
|
|
60
|
-
`${escape(FROM_ROOT)}/(${[
|
|
61
|
-
.sort((left, right) => right.length - left.length)
|
|
62
|
-
.map(escape)
|
|
63
|
-
.join('|')})(?![A-Za-z0-9._-])`,
|
|
65
|
+
`${escape(FROM_ROOT)}/(${ENTRY_ALTERNATION})(?![A-Za-z0-9._-])`,
|
|
64
66
|
'g',
|
|
65
67
|
)
|
|
66
68
|
|
|
69
|
+
/** A gitignore line that is wholly an entry for one moved folder or file. */
|
|
70
|
+
const IGNORE_ENTRY = new RegExp(
|
|
71
|
+
`^/?${escape(FROM_ROOT)}/(?:${ENTRY_ALTERNATION})/?$`,
|
|
72
|
+
)
|
|
73
|
+
|
|
67
74
|
function escape(value: string): string {
|
|
68
75
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
69
76
|
}
|
|
@@ -386,6 +393,30 @@ export function scanText(text: string): {
|
|
|
386
393
|
return { rewritten, kept, globs, crossRepo, dated }
|
|
387
394
|
}
|
|
388
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Deletes each live line that is wholly an entry for a moved folder, counting
|
|
398
|
+
* them. Only a root `.gitignore` that already ignores `.canon/` may be handed
|
|
399
|
+
* here, since the dropped line is redundant only under that entry. Splitting on
|
|
400
|
+
* `\n` and matching the trimmed line leaves CRLF endings and a missing final
|
|
401
|
+
* newline on the lines that stay.
|
|
402
|
+
*/
|
|
403
|
+
export function dropCoveredIgnoreLines(text: string): {
|
|
404
|
+
readonly text: string
|
|
405
|
+
readonly dropped: number
|
|
406
|
+
} {
|
|
407
|
+
const lines = text.split('\n')
|
|
408
|
+
const frontmatterEndIndex = frontmatterEnd(lines)
|
|
409
|
+
const kept = lines.filter(
|
|
410
|
+
(line, index) =>
|
|
411
|
+
!(
|
|
412
|
+
IGNORE_ENTRY.test(line.trim()) &&
|
|
413
|
+
classifyLine(lines, index, frontmatterEndIndex) === 'live'
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return { text: kept.join('\n'), dropped: lines.length - kept.length }
|
|
418
|
+
}
|
|
419
|
+
|
|
389
420
|
/** Where a citation sits, so a reader can judge it before `--write` runs. */
|
|
390
421
|
export interface CitationLine {
|
|
391
422
|
readonly line: number
|
|
@@ -503,6 +534,7 @@ export interface CitationEntry {
|
|
|
503
534
|
readonly text: string
|
|
504
535
|
readonly rewritten: number
|
|
505
536
|
readonly kept: number
|
|
537
|
+
readonly dropped: number
|
|
506
538
|
}
|
|
507
539
|
|
|
508
540
|
/** One file whose frontmatter `paths:` glob names a moved root, held rather than rewritten. */
|
|
@@ -533,6 +565,7 @@ export interface RecordsPlan {
|
|
|
533
565
|
readonly crossRepoCitations: readonly CrossRepoCitationEntry[]
|
|
534
566
|
readonly datedCitations: readonly DatedCitationEntry[]
|
|
535
567
|
readonly rewritten: number
|
|
568
|
+
readonly dropped: number
|
|
536
569
|
readonly kept: number
|
|
537
570
|
readonly globs: number
|
|
538
571
|
readonly crossRepo: number
|
|
@@ -578,7 +611,11 @@ export function planRecordsMove(
|
|
|
578
611
|
continue
|
|
579
612
|
}
|
|
580
613
|
|
|
581
|
-
const
|
|
614
|
+
const swept =
|
|
615
|
+
source.path === '.gitignore' && ignoresDestination(source.text)
|
|
616
|
+
? dropCoveredIgnoreLines(source.text)
|
|
617
|
+
: { text: source.text, dropped: 0 }
|
|
618
|
+
const counts = scanText(swept.text)
|
|
582
619
|
kept += counts.kept
|
|
583
620
|
globs += counts.globs
|
|
584
621
|
crossRepo += counts.crossRepo
|
|
@@ -599,15 +636,16 @@ export function planRecordsMove(
|
|
|
599
636
|
if (dates.length > 0)
|
|
600
637
|
datedCitations.push({ path: source.path, lines: dates })
|
|
601
638
|
|
|
602
|
-
if (counts.rewritten === 0) continue
|
|
639
|
+
if (counts.rewritten === 0 && swept.dropped === 0) continue
|
|
603
640
|
|
|
604
641
|
if (referencesExcluded(source.text)) coupled.push(source.path)
|
|
605
642
|
|
|
606
643
|
entries.push({
|
|
607
644
|
path: source.path,
|
|
608
|
-
text: rewriteText(
|
|
645
|
+
text: rewriteText(swept.text),
|
|
609
646
|
rewritten: counts.rewritten,
|
|
610
647
|
kept: counts.kept,
|
|
648
|
+
dropped: swept.dropped,
|
|
611
649
|
})
|
|
612
650
|
}
|
|
613
651
|
|
|
@@ -621,6 +659,7 @@ export function planRecordsMove(
|
|
|
621
659
|
crossRepoCitations,
|
|
622
660
|
datedCitations,
|
|
623
661
|
rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
|
|
662
|
+
dropped: entries.reduce((sum, entry) => sum + entry.dropped, 0),
|
|
624
663
|
kept,
|
|
625
664
|
globs,
|
|
626
665
|
crossRepo,
|
|
@@ -5,8 +5,8 @@ import { relative } from 'node:path'
|
|
|
5
5
|
import { gitEnv } from '@/git/env'
|
|
6
6
|
import { surfaceDir } from '@/roots/surface'
|
|
7
7
|
|
|
8
|
-
/** The
|
|
9
|
-
const CANONICAL_DOCS = ['REQUIREMENTS.md'] as const
|
|
8
|
+
/** The records carrying a `reviewed:` review point, whose drift is how far the project moved since they were read whole. */
|
|
9
|
+
const CANONICAL_DOCS = ['REQUIREMENTS.md', 'DESIGN.md'] as const
|
|
10
10
|
|
|
11
11
|
export interface CanonicalDoc {
|
|
12
12
|
/** Relative to the root, at whichever surface root carries the doc. */
|