@avi2dg/checks 0.34.0 → 0.35.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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.35.0
6
+
7
+ Released 2026-10-07.
8
+
9
+ ### Features
10
+
11
+ - **quality:** add the opt-in checks-browser runner and checks-frontend-syntax gate [#127](https://github.com/avi2d/checks/pull/127)
12
+
13
+ ### Fixes
14
+
15
+ - **testing:** cap mutation concurrency at half the available cores [#129](https://github.com/avi2d/checks/pull/129)
16
+ - **docs:** check Russian prose in the sentence length and one-sentence-per-line rules [#126](https://github.com/avi2d/checks/pull/126)
17
+
5
18
  ## 0.34.0
6
19
 
7
20
  Released 2026-10-03.
package/README.md CHANGED
@@ -19,6 +19,12 @@ Each repository owns its workflows and native tool configs, as [Native settings]
19
19
  - `oxlint` 1.83.0
20
20
  - `oxlint-tsgolint` 7.0.2002
21
21
  - `typescript` 7.0.2
22
+ - The optional peers, which only an opt-in check loads, at the exact versions the kit pins:
23
+ - `@axe-core/playwright` 4.13.0
24
+ - `html-validate` 11.16.0
25
+ - `playwright-core` 1.63.0
26
+ - `postcss-html` 2.0.0
27
+ - `stylelint` 17.16.0
22
28
 
23
29
  <!-- end generated prerequisites -->
24
30
 
@@ -123,6 +129,7 @@ The table groups the gates by vector, the part of a repository each one judges.
123
129
  | complexity | [`checks-exports`](docs/gates/checks-exports.md) | the range | a repository tracking `*.ts` or `*.tsx` |
124
130
  | quality | [`checks-lint-coverage`](docs/gates/checks-lint-coverage.md) | the working tree | a repository tracking `*.ts` or `*.tsx` or `*.astro` |
125
131
  | quality | [`checks-comment-gate`](docs/gates/checks-comment-gate.md) | the range | every repository |
132
+ | quality | [`checks-frontend-syntax`](docs/gates/checks-frontend-syntax.md) | the working tree | a repository tracking `frontend-syntax.json` |
126
133
  | testing | [`checks-test-layout`](docs/gates/checks-test-layout.md) | the working tree | a repository tracking `*.ts` or `*.tsx` |
127
134
  | testing | [`checks-quarantine-clock`](docs/gates/checks-quarantine-clock.md) | the range | every repository |
128
135
  | docs | [`checks-docs`](docs/gates/checks-docs.md) | the range, and every agent file at the head commit | every repository |
@@ -146,6 +153,7 @@ These bins run on their own:
146
153
  - [`checks-release-pr`](docs/gates/checks-release-pr.md) opens or refreshes the pull request that releases the next version, and dispatches its checks.
147
154
  - [`checks-release-tag`](docs/gates/checks-release-tag.md) tags a landed release commit with its version and dispatches the release workflow on the tag.
148
155
  - [`checks-vendor`](docs/gates/checks-vendor.md) pins each library its `prepare` arguments name to a shared read-only clone and links it under `repos/`.
156
+ - [`checks-browser`](docs/gates/checks-browser.md) opens a product's built pages in Chrome and fails on the layout, keyboard, motion, accessibility, nesting and asset checks its `browser-checks.json` declares.
149
157
 
150
158
  `checks-lint` has [its own page](docs/gates/checks-lint.md), which says which range it resolves.
151
159
  The oxlint base, the dependency-cruiser base and the commitlint config run through their own tools, as the pages under Related topics say.
@@ -155,6 +163,7 @@ The oxlint base, the dependency-cruiser base and the commitlint config run throu
155
163
  To move a repository to a newer release of the kit:
156
164
 
157
165
  1. Run the install line again, which moves the kit to its newest release and the peers to the versions it pins.
166
+ A repository that opted in to `checks-browser` or `checks-frontend-syntax` also reruns the `bun add` line on its page.
158
167
  1. Review the Effect overrides in `.oxlintrc.json` and `tsconfig.json` when a release changes their presets.
159
168
  1. Copy `node_modules/@avi2dg/checks/bunfig.toml` over `bunfig.toml` again, since `checks-test-layout` compares the copy with the installed preset.
160
169
  1. Run `bun run lint`, `bun run typecheck` and `bun run test`.
@@ -47,7 +47,8 @@ export default {
47
47
  comment:
48
48
  "The import resolves to an installed package the nearest package.json does not declare, so it holds only while something else keeps it hoisted. Declare it in dependencies, devDependencies or peerDependencies.",
49
49
  from: {},
50
- to: { dependencyTypes: ["npm-no-pkg", "npm-unknown"] },
50
+ // dependency-cruiser reads every package.json key holding "ependencies" as a list, so peerDependenciesMeta tags a declared peer npm-no-pkg too.
51
+ to: { dependencyTypes: ["npm-no-pkg", "npm-unknown"], dependencyTypesNot: ["npm", "npm-dev", "npm-optional", "npm-peer"] },
51
52
  },
52
53
  {
53
54
  name: "not-to-unresolvable",
@@ -13,7 +13,7 @@ The shared dependency-cruiser base holds a repository's imports to a set of rule
13
13
  - `no-circular`
14
14
  - `no-orphans`
15
15
  - `not-to-dev-dep`, which refuses shipped source importing a dev-only package, and a package listed in `peerDependencies` too is not dev-only
16
- - `no-non-package-json`, which refuses an import of an installed package that the nearest `package.json` does not declare
16
+ - `no-non-package-json`, which refuses an import of an installed package that the nearest `package.json` does not declare, and counts a peer that `peerDependenciesMeta` marks optional as declared
17
17
  - `not-to-unresolvable`, which refuses a specifier nothing installed answers
18
18
  - `no-deep-imports`, which refuses a subpath the package's exports map does not publish
19
19
 
package/docs/design.md CHANGED
@@ -73,8 +73,9 @@ Nothing would then say which gate a helper serves.
73
73
  A mutation runner's default scope covers `src/`, so the kit's own Stryker run mutates its source with no `mutate` list.
74
74
  The testing directory is named `testing` rather than `tests`, because a `src/tests/` beside the root `tests/` would read as a second suite.
75
75
 
76
- Four `exports` keys name a path the file does not sit at, because consumers resolve them by that name.
76
+ Five `exports` keys name a path the file does not sit at, because consumers resolve them by that name.
77
77
  `@avi2dg/checks/scripts/test-skips.ts`, `@avi2dg/checks/scripts/comment-matchers.ts` and `@avi2dg/checks/scripts/prose-matchers.ts` point at their files under `src/`.
78
+ `@avi2dg/checks/browser-hooks.ts` points at `src/quality/browser/hooks.ts`, whose types a product's browser hooks import.
78
79
  `@avi2dg/checks/templates/*` points at `dist/templates/`.
79
80
  A path read without the resolver, such as `node_modules/@avi2dg/checks/scripts/test-skips.ts`, does not exist.
80
81
 
@@ -276,6 +277,27 @@ No finding can be accepted.
276
277
  A secret has no false positive worth keeping in git, because a placeholder carries the same shape without the value.
277
278
  So the gate ignores a repository's `.gitleaks.toml`, `.gitleaksignore` and `gitleaks:allow` comments, which would each let one repository pass what another fails.
278
279
 
280
+ ## The frontend checks are opt-in
281
+
282
+ A browser check needs a built site and a browser, and a backend repository has neither.
283
+ So a product opts in by declaring `browser-checks.json`, `checks-lint` never starts a browser, and the browser libraries are optional peers a backend never installs.
284
+ `checks-frontend-syntax` needs no browser and runs inside `checks-lint`, but only where `frontend-syntax.json` declares its inputs, since a repository with no interface has no CSS to hold to it.
285
+
286
+ Each check wraps a detector that already works rather than a rule of the kit's own.
287
+ The syntax gate runs two of stylelint's built-in rules, `axe` runs axe-core and `nesting` runs one html-validate rule.
288
+ The `layout`, `keyboard` and `motion` probes are the website's own browser tests, generalized after they held on its English and Russian pages.
289
+ A zoom-disabling viewport is judged by axe on the rendered page, because stylelint reads only CSS and html-validate has no rule for the viewport's content.
290
+
291
+ A check that scans nothing would pass without judging anything, as some detectors do on an empty directory.
292
+ So every run names what it scanned, and an empty input, an unserved route or a target that matches no visible element fails.
293
+ A contrast axe cannot measure is listed as unverified, never counted as a pass.
294
+
295
+ Valid choices and the order of async results depend on a product's own schema and requests, which no generic check knows.
296
+ So the runner calls the product's hooks at each visit and holds them to the same inventory, rather than guessing at a product's domain.
297
+
298
+ The runner drives the Chrome a machine already has through `playwright-core`, since Playwright's own browser builds are a separate download of their own.
299
+ GitHub's hosted Ubuntu runners carry Chrome, and `CHROME_PATH` names any other build.
300
+
279
301
  ## Related topics
280
302
 
281
303
  - [checks](../README.md)
@@ -0,0 +1,201 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-browser
6
+
7
+ `checks-browser` is the opt-in runner that opens a product's built pages in Chrome and fails on the measurable quality floor its declaration asks for.
8
+
9
+ ## What it checks
10
+
11
+ It visits each declared route at each declared viewport in each declared state, and runs each declared check on its own fresh page.
12
+ A visit is one route, one viewport and one state.
13
+ Each check judges only the elements of a target that are visible at the visit.
14
+ So a control the page renders once for each breakpoint is judged where it shows.
15
+
16
+ | Check | What fails | Built on |
17
+ | --- | --- | --- |
18
+ | `layout` | a page wider than the viewport, and a target that overflows the viewport, is reachable only by scrolling sideways, is cut off by a container that cannot scroll, clips its own text or hides its text, overlaps another target, or is covered by any element | the DevTools protocol's boxes and hit test |
19
+ | `keyboard` | a `focusable` target that Tab does not reach once for each visible element it matches, and a Tab stop on a target that shows no visible focus | Playwright's keyboard, and a screenshot of the target with and without focus |
20
+ | `motion` | an animation or a transition longer than one frame that starts during load, on hover, on leaving a hovered control or on focus, and smooth scrolling, for a visitor who turns motion off | the page's own `document.getAnimations()`, in each state whose `reducedMotion` is `reduce` |
21
+ | `axe` | each violation of axe's WCAG 2.2 AA rules, which include `color-contrast`, `nested-interactive`, `meta-viewport` and `target-size` | axe-core through `@axe-core/playwright` |
22
+ | `nesting` | an element the HTML content model does not permit where it sits in the rendered page, such as a `<button>` inside an `<a>` | html-validate's `element-permitted-content` rule |
23
+ | `assets` | a request that fails, and a response with a status of 400 or more, while the page loads to network idle | Playwright's request and response events |
24
+
25
+ The `motion` check judges what moves on the rendered page, so a `prefers-reduced-motion` query in the source counts for nothing unless the motion stops.
26
+ It runs only in a state whose `reducedMotion` is `reduce`.
27
+ An animation that ends within one frame never moves, so the common reset that shortens every duration to near zero for reduced motion passes.
28
+ Beyond that, durations, easing and how motion feels are judgement, so no check reads them.
29
+ A control the pointer cannot reach, such as a skip link parked off the screen, is judged on focus alone, and the inventory counts it.
30
+
31
+ `layout` treats text in an SVG `<title>` or `<desc>`, in a `<textarea>`, or that the browser does not render as left out, not hidden.
32
+ Text that is not rendered includes `display: none`, `hidden`, the options of a closed `<select>` and a closed `<details>`.
33
+ An element with `display: contents`, such as a framework island, has no box of its own but its content renders, so its text is judged.
34
+ Transparent, zero-size, `visibility: hidden` and zero-opacity text still fail.
35
+ A `focusable` target parked off the screen or clipped until it is focused, such as a skip link, is left to `keyboard`.
36
+ The `layout` inventory counts it as parked until focused.
37
+
38
+ It does not yet check the rendered page against a product's declared design tokens, which needs a separate decision.
39
+
40
+ `axe` reports a contrast it cannot measure as unverified rather than as a pass or a failure.
41
+ Text over a background image or a gradient is such a case, and the report lists each one under its own heading.
42
+ `nested-interactive` catches a control inside an element whose role makes its children presentational, such as a link inside a `<button>` or inside a `role="button"`.
43
+ `nesting` catches a control inside a link, which axe passes.
44
+ Neither sees a parent click handler that fires with a child control's click, which a product hook tests.
45
+
46
+ ### The inventory
47
+
48
+ The runner prints one line for each check at each visit.
49
+ The line names the route, its locale, the viewport, the state and what the check scanned.
50
+ At every visit, whichever checks are declared, a `targets` run counts the visible elements each target that applies to the route matches.
51
+ For `targets`, `layout`, `keyboard` and a hook, what it scanned is each target by name with the count of its elements.
52
+
53
+ It fails, rather than passes, when a declaration leaves nothing to judge:
54
+
55
+ - a declared route the site does not serve
56
+ - a declared target that matches no visible element on a route it applies to
57
+ - a route that no target applies to
58
+ - a `layout` run or a hook that scans no target
59
+ - a `keyboard` run in which Tab reaches no target
60
+ - a `motion` run that finds no visible control
61
+ - an empty list of routes, viewports, states, targets or checks, which the declaration refuses
62
+
63
+ Each failure names the check, the route, the locale, the viewport and the state, and the target or the element it found.
64
+
65
+ ### Product hooks
66
+
67
+ Two checks need what only the product knows, its domain schema and its requests.
68
+ The runner calls the product's own hooks for them rather than judging them itself:
69
+
70
+ - Valid choices: a hook submits each choice a control offers on the real page, and decodes what reaches the boundary with the product's one schema.
71
+ - Async ordering: a hook answers a second request before the first, then reads the visible and the persisted result.
72
+ It reads them again after a refused request and after a reconnect.
73
+
74
+ A hook is also where a product clicks a child control and checks that the row around it did not activate too.
75
+ The same hook can hover a control and check that no application state changed.
76
+
77
+ The declaration names the module in `hooks`.
78
+ The module exports `checks`, each a name, an optional list of the routes it runs on, and a `run` function.
79
+ The runner hands `run` a fresh page at each visit before it loads anything, so the hook can intercept requests, and the visit with its `url`.
80
+ `run` returns the names of the targets it scanned and what it found, and a hook that throws fails with its message:
81
+
82
+ ```ts
83
+ import { Schema } from "effect";
84
+ import type { ProductCheck } from "@avi2dg/checks/browser-hooks.ts";
85
+ import { Language } from "../../src/domain.ts";
86
+
87
+ export const checks: readonly ProductCheck[] = [
88
+ {
89
+ name: "every offered language decodes at the boundary",
90
+ routes: ["/settings/"],
91
+ run: async (page, visit) => {
92
+ const submitted: string[] = [];
93
+ await page.route("**/api/language", async (route) => {
94
+ submitted.push(route.request().postData() ?? "");
95
+ await route.fulfill({ status: 204 });
96
+ });
97
+ await page.goto(visit.url);
98
+ const offered = await page.locator("#language option").evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value));
99
+ for (const value of offered) {
100
+ await page.selectOption("#language", value);
101
+ await Promise.all([page.waitForRequest("**/api/language"), page.click("#language-form button")]);
102
+ }
103
+ return {
104
+ targets: offered.map(() => "language choice"),
105
+ found: submitted.filter((value) => !Schema.is(Language)(value)).map((value) => `the language control offers ${value}, which the boundary refuses`),
106
+ };
107
+ },
108
+ },
109
+ ];
110
+ ```
111
+
112
+ ## What it reads
113
+
114
+ It reads `browser-checks.json` in the directory it runs in:
115
+
116
+ ```json
117
+ {
118
+ "site": "dist",
119
+ "routes": [
120
+ { "path": "/", "locale": "en" },
121
+ { "path": "/ru/", "locale": "ru" }
122
+ ],
123
+ "viewports": [
124
+ { "name": "phone", "width": 375, "height": 812, "touch": true },
125
+ { "name": "desktop", "width": 1280, "height": 800 }
126
+ ],
127
+ "states": [
128
+ { "name": "default" },
129
+ { "name": "enlarged text", "textPx": 32 },
130
+ { "name": "reduced motion", "reducedMotion": "reduce" }
131
+ ],
132
+ "targets": [
133
+ { "name": "language switch", "selector": "header a[hreflang]", "focusable": true },
134
+ { "name": "email contact", "selector": "main a[href^='mailto:']", "routes": ["/", "/ru/"], "focusable": true },
135
+ { "name": "headline", "selector": "main h1" }
136
+ ],
137
+ "checks": ["layout", "keyboard", "motion", "axe", "nesting", "assets"],
138
+ "hooks": "tests/browser/hooks.ts"
139
+ }
140
+ ```
141
+
142
+ | Field | What it holds |
143
+ | --- | --- |
144
+ | `site` | the directory of the built site, which the runner serves on a local port, an `index.html` answering for its directory |
145
+ | `routes` | each page to visit, as a `path` from the site root and the `locale` it renders |
146
+ | `viewports` | each window to visit in, with a `name`, a `width` and a `height` in pixels, and `touch` for a phone that has touch and a mobile viewport |
147
+ | `states` | each condition to visit in, with a `name`, `textPx` for the browser's default text size, and `reducedMotion` as `reduce` or `no-preference` |
148
+ | `targets` | each named control or text the checks judge, with a CSS `selector`, the `routes` it is on when not every route, and `focusable` when Tab must reach it |
149
+ | `checks` | the built-in checks to run, from `layout`, `keyboard`, `motion`, `axe`, `nesting` and `assets` |
150
+ | `hooks` | the module of product hooks, as a path from the directory the runner runs in |
151
+
152
+ `textPx` sets the default font size, so text the page sizes in `rem` or `em` grows, as a visitor's own setting makes it grow.
153
+ A declaration that names a route, viewport, state, target or check twice is refused.
154
+ So are a `motion` check with no state whose `reducedMotion` is `reduce`, a `keyboard` check with no `focusable` target, and a target or a hook that names an undeclared route.
155
+
156
+ It drives the Chrome the machine has, which `CHROME_PATH` names, or Chrome's standard install when it is unset.
157
+ The product installs the optional peers the runner loads, at the versions the kit pins:
158
+
159
+ ```sh
160
+ bun add -d playwright-core@1.63.0 @axe-core/playwright@4.13.0 html-validate@11.16.0
161
+ ```
162
+
163
+ ## Arguments
164
+
165
+ ```sh
166
+ checks-browser [<directory>]
167
+ ```
168
+
169
+ It reads the declaration in the directory it runs in, or in the directory it is given.
170
+
171
+ ## Exit codes
172
+
173
+ | Code | When |
174
+ | --- | --- |
175
+ | 0 | every check passes at every visit, and every declared route and target is there |
176
+ | 1 | a check fails at a visit, a declared route is not served, a declared target matches no visible element, or a run scans no target |
177
+ | 2 | `browser-checks.json` is missing, does not decode or contradicts itself, the site directory or the hooks module is missing, a peer cannot load, or Chrome cannot start |
178
+
179
+ ## Sample output
180
+
181
+ ```
182
+ browser: targets at /ru/ (ru), phone 375x812 touch, enlarged text scanned language switch 1, email contact 1, headline 1
183
+ browser: layout at /ru/ (ru), phone 375x812 touch, enlarged text scanned language switch 1, email contact 1, headline 1
184
+ browser: keyboard at /ru/ (ru), phone 375x812 touch, enlarged text scanned language switch 1, email contact 1
185
+ browser: axe at /ru/ (ru), phone 375x812 touch, enlarged text scanned 11 rule(s)
186
+ browser: 1 contrast check(s) axe cannot measure, reported as unverified:
187
+ axe at /ru/ (ru), phone 375x812 touch, enlarged text: color-contrast unverified: header > p: Element's background color could not be determined due to a background image
188
+ browser: 2 failure(s) in 4 check run(s) over 1 route(s), 1 viewport(s), 1 state(s) and 3 target(s):
189
+ layout at /ru/ (ru), phone 375x812 touch, enlarged text: email contact "Написать письмо владельцу" clips its own text
190
+ axe at /ru/ (ru), phone 375x812 touch, enlarged text: nested-interactive (serious): button: Interactive controls must not be nested
191
+ ```
192
+
193
+ ## When it runs
194
+
195
+ A product runs it after it builds its site, in a script of its own such as `"test:browser": "astro build && checks-browser"`.
196
+ `checks-lint` never runs it, and a repository without `browser-checks.json` never starts a browser or needs the peers.
197
+
198
+ ## Related topics
199
+
200
+ - [checks-frontend-syntax](checks-frontend-syntax.md)
201
+ - [checks-lint](checks-lint.md)
@@ -0,0 +1,97 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-frontend-syntax
6
+
7
+ `checks-frontend-syntax` is the opt-in gate that refuses two CSS constraints a program can decide: a transition over `all` and a body-wide `user-select`.
8
+
9
+ ## What it checks
10
+
11
+ It runs stylelint with two of stylelint's own rules, and with no other rule and no repository config:
12
+
13
+ | Rule | What it refuses | Why |
14
+ | --- | --- | --- |
15
+ | `declaration-property-value-disallowed-list` | `all` in the value of `transition` or `transition-property`, vendor prefixes included, such as `transition: all 200ms` or `-webkit-transition: opacity 1s, all 1s` | every property that changes then animates, including the ones that make the browser lay out the page again |
16
+ | `rule-selector-property-disallowed-list` | `user-select` or a prefixed `user-select` such as `-webkit-user-select` in a rule whose selector list holds `html`, `body`, `:root` or `*`, or one of them inside `:global()` | a visitor cannot select or copy any text on the page |
17
+
18
+ The second rule refuses any value on those selectors, so `body { user-select: text; }` fails as well.
19
+ It matches each item of the selector list as a whole.
20
+ So it does not follow `:is()`, `:where()` or `:not()`, nor a compound such as `html body` or `body *`.
21
+ A nested rule is judged by its own selector, so `.card { * { user-select: none; } }` fails as `*`.
22
+ A control that needs selection off sets it on its own selector, such as `.drag-handle { user-select: none; }`, which passes.
23
+
24
+ It judges each `.css` file, and each `<style>` block and `style` attribute in an `.html`, `.astro`, `.vue` or `.svelte` file, through `postcss-html`.
25
+ It reports a file that does not parse, since it cannot judge one.
26
+ It fails on each declared input that matches no file, so an input that a renamed directory or a skipped build leaves empty never passes.
27
+
28
+ These are outside what it decides:
29
+
30
+ - A `transition` shorthand that names no property, such as `transition: 200ms`, which the browser reads as `all`.
31
+ - A value it cannot see, such as `transition: var(--motion)` or a style a script sets.
32
+ - `user-select: none` in a `style` attribute on `<body>`, which has no selector to judge.
33
+ - A viewport that disables zoom, since stylelint reads only CSS.
34
+ A repository that runs only this gate gets no viewport check, and [checks-browser](checks-browser.md) refuses one on the rendered page through axe's `meta-viewport` rule.
35
+ - Whether motion respects a visitor who turns it off, which only the rendered page shows, as the `motion` check of [checks-browser](checks-browser.md) says.
36
+ A `prefers-reduced-motion` query in the source proves nothing about what moves.
37
+
38
+ ## What it reads
39
+
40
+ It reads `frontend-syntax.json` in the directory it runs in, which lists the inputs as globs:
41
+
42
+ ```json
43
+ {
44
+ "inputs": ["src/**/*.css", "src/**/*.astro", "dist/**/*.html"]
45
+ }
46
+ ```
47
+
48
+ A glob over built output, such as `dist/**/*.html`, judges the styles a framework or a CSS tool generates, and needs the build to run first.
49
+ stylelint resolves each glob from that directory, skips `node_modules/` and honours `.stylelintignore`.
50
+ It reads the working tree, so an uncommitted file is judged like a committed one.
51
+
52
+ The repository installs the two optional peers the gate loads, at the versions the kit pins:
53
+
54
+ ```sh
55
+ bun add -d stylelint@17.16.0 postcss-html@2.0.0
56
+ ```
57
+
58
+ ## Arguments
59
+
60
+ ```sh
61
+ checks-frontend-syntax [<directory>]
62
+ ```
63
+
64
+ It checks the directory it runs in, or the directory it is given.
65
+
66
+ ## Exit codes
67
+
68
+ | Code | When |
69
+ | --- | --- |
70
+ | 0 | every declared input matches a file, and no file holds a refused declaration |
71
+ | 1 | a file holds a refused declaration or does not parse, or a declared input matches no file |
72
+ | 2 | `frontend-syntax.json` is missing or does not decode, its input list is empty, or stylelint or `postcss-html` cannot load |
73
+
74
+ ## Sample output
75
+
76
+ ```
77
+ frontend-syntax: 3 problem(s) in 2 file(s) from 3 declared input(s):
78
+ dist/**/*.html: the declared input matches no file
79
+ src/layouts/Base.astro:4:15 Disallowed property "user-select" for selector "body". Leave text selectable across the page, and turn selection off only on the control that needs it.
80
+ src/styles/site.css:1:17 Disallowed value "all 200ms" for property "transition". Name the properties the transition animates.
81
+ ```
82
+
83
+ A passing run counts what it judged:
84
+
85
+ ```
86
+ frontend-syntax: no violation in 2 file(s) from 2 declared input(s)
87
+ ```
88
+
89
+ ## When it runs
90
+
91
+ `checks-lint` runs it when the repository tracks `frontend-syntax.json` at its root.
92
+ A repository without that file never runs it and needs neither stylelint nor `postcss-html`.
93
+
94
+ ## Related topics
95
+
96
+ - [checks-browser](checks-browser.md)
97
+ - [checks-lint](checks-lint.md)
@@ -12,6 +12,7 @@ It runs the gates under [What runs](../../README.md#what-runs), each in its own
12
12
  Gates requiring tracked TypeScript files begin running when the repository tracks TypeScript.
13
13
  `checks-lint-coverage` and `checks-unused` also begin running when the repository tracks an `.astro` file.
14
14
  `checks-advisories` begins running when the repository tracks `bun.lock`.
15
+ `checks-frontend-syntax` begins running when the repository tracks `frontend-syntax.json` at its root.
15
16
  All other gates run for every repository.
16
17
 
17
18
  ## What it reads
@@ -46,7 +47,7 @@ With two arguments, the base and head override range discovery.
46
47
 
47
48
  ```
48
49
  checks-lint: range 2504acf098d120e73a8ece3c96f22b934f35c6a8..10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
49
- checks-lint: 1 of 13 gate(s) failed: checks-comment-gate
50
+ checks-lint: 1 of 14 gate(s) failed: checks-comment-gate
50
51
  ```
51
52
 
52
53
  <!-- end generated lint-sample -->
@@ -55,6 +56,7 @@ checks-lint: 1 of 13 gate(s) failed: checks-comment-gate
55
56
 
56
57
  A repository runs it from `bun run lint` in a pull request workflow that fetches the whole git history.
57
58
  It leaves out the TypeScript gates while the repository tracks no TypeScript file, except that `checks-lint-coverage` and `checks-unused` run when it tracks an `.astro` file.
59
+ It never runs `checks-browser`, which a product runs after it builds its site.
58
60
 
59
61
  ## Related topics
60
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,6 +54,8 @@
54
54
  "src/docs/prose-matchers.ts",
55
55
  "src/quality/comments.ts",
56
56
  "src/quality/comment-gate.ts",
57
+ "src/quality/frontend-syntax.ts",
58
+ "src/quality/browser/",
57
59
  "src/complexity/suppressions-ratchet.ts",
58
60
  "src/complexity/repetition.ts",
59
61
  "src/complexity/unused.ts",
@@ -95,7 +97,8 @@
95
97
  "./scripts/prose-matchers.ts": "./src/docs/prose-matchers.ts",
96
98
  "./stryker.preset.js": "./stryker.preset.js",
97
99
  "./templates/*": "./dist/templates/*",
98
- "./tsconfig.effect.json": "./tsconfig.effect.json"
100
+ "./tsconfig.effect.json": "./tsconfig.effect.json",
101
+ "./browser-hooks.ts": "./src/quality/browser/hooks.ts"
99
102
  },
100
103
  "bin": {
101
104
  "checks-lint": "src/core/lint.ts",
@@ -122,7 +125,9 @@
122
125
  "checks-docs": "src/docs/docs.ts",
123
126
  "checks-vendor": "src/dependencies/vendor.ts",
124
127
  "checks-advisories": "src/dependencies/advisories.ts",
125
- "checks-secrets": "src/delivery/secrets.ts"
128
+ "checks-secrets": "src/delivery/secrets.ts",
129
+ "checks-frontend-syntax": "src/quality/frontend-syntax.ts",
130
+ "checks-browser": "src/quality/browser/browser.ts"
126
131
  },
127
132
  "scripts": {
128
133
  "prepare": "bun src/dependencies/vendor.ts --library effect --package effect --repository https://github.com/Effect-TS/effect.git --tag 'effect@{version}' --path packages/effect/package.json",
@@ -134,16 +139,39 @@
134
139
  "mutate:incremental": "bunx stryker run --incremental"
135
140
  },
136
141
  "peerDependencies": {
142
+ "@axe-core/playwright": "4.13.0",
143
+ "@effect/tsgo": "0.45.0",
137
144
  "@swc/core": "1.16.2",
138
145
  "dependency-cruiser": "18.4.0",
139
- "@effect/tsgo": "0.45.0",
140
146
  "effect": "4.0.0",
147
+ "html-validate": "11.16.0",
141
148
  "jscpd": "5.3.2",
142
149
  "oxlint": "1.83.0",
143
150
  "oxlint-tsgolint": "7.0.2002",
151
+ "playwright-core": "1.63.0",
152
+ "postcss-html": "2.0.0",
153
+ "stylelint": "17.16.0",
144
154
  "typescript": "7.0.2"
145
155
  },
156
+ "peerDependenciesMeta": {
157
+ "@axe-core/playwright": {
158
+ "optional": true
159
+ },
160
+ "html-validate": {
161
+ "optional": true
162
+ },
163
+ "playwright-core": {
164
+ "optional": true
165
+ },
166
+ "postcss-html": {
167
+ "optional": true
168
+ },
169
+ "stylelint": {
170
+ "optional": true
171
+ }
172
+ },
146
173
  "devDependencies": {
174
+ "@axe-core/playwright": "4.13.0",
147
175
  "@effect/tsgo": "0.45.0",
148
176
  "@hughescr/stryker-bun-runner": "1.4.0",
149
177
  "@oxlint/plugins": "1.83.0",
@@ -153,9 +181,13 @@
153
181
  "@types/commonmark": "0.27.10",
154
182
  "dependency-cruiser": "18.4.0",
155
183
  "effect": "4.0.0",
184
+ "html-validate": "11.16.0",
156
185
  "jscpd": "5.3.2",
157
186
  "oxlint": "1.83.0",
158
187
  "oxlint-tsgolint": "7.0.2002",
188
+ "playwright-core": "1.63.0",
189
+ "postcss-html": "2.0.0",
190
+ "stylelint": "17.16.0",
159
191
  "typescript": "7.0.2"
160
192
  },
161
193
  "dependencies": {
package/src/core/gates.ts CHANGED
@@ -36,11 +36,14 @@ export const LINTED_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx", "*.a
36
36
 
37
37
  const BUN_LOCKFILE: TrackedContent = { pathspecs: ["bun.lock"], content: "a bun lockfile" };
38
38
 
39
+ const FRONTEND_SYNTAX_DECLARATION: TrackedContent = { pathspecs: ["frontend-syntax.json"], content: "a frontend syntax declaration" };
40
+
39
41
  export const KIT_GATES = [
40
42
  { bin: "checks-lint-coverage", vector: "quality", file: "lint-coverage.sh", reads: "tree", appliesTo: LINTED_SOURCE },
41
43
  { bin: "checks-test-layout", vector: "testing", file: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
42
44
  { bin: "checks-commit-identity", vector: "delivery", file: "commit-identity.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
43
45
  { bin: "checks-comment-gate", vector: "quality", file: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
46
+ { bin: "checks-frontend-syntax", vector: "quality", file: "frontend-syntax.ts", reads: "tree", appliesTo: FRONTEND_SYNTAX_DECLARATION },
44
47
  { bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
45
48
  { bin: "checks-ci-wiring", vector: "delivery", file: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
46
49
  { bin: "checks-secrets", vector: "delivery", file: "secrets.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
@@ -335,9 +335,9 @@ function ruleFindings(line: MarkdownLine, reader: Reader): ProseFinding[] {
335
335
  );
336
336
  }
337
337
 
338
- const SENTENCE_BREAK = /(?<=[^\s.!?])[.!?]["'”’)\]*_]*\s+(?=[A-Z"“*_[\0])/g;
339
- const ABBREVIATION = /\b(?:e\.g|i\.e|etc|vs|cf|approx|Mr|Mrs|Ms|Dr|St|No|Fig)$/i;
340
- const SENTENCE_END = /[.!?:]["'”’)\]*_]*\s*$/;
338
+ const SENTENCE_BREAK = /(?<=[^\s.!?])[.!?]["'”’»)\]*_]*\s+(?=[\p{Lu}"“«*_[\0])/gu;
339
+ const ABBREVIATION = /(?<![\p{L}\p{N}_])(?:e\.g|i\.e|etc|vs|cf|approx|Mr|Mrs|Ms|Dr|St|No|Fig|см|напр|т\.\s*[едп]|др|стр)$/iu;
340
+ const SENTENCE_END = /[.!?:]["'”’»)\]*_]*\s*$/;
341
341
  const LIST_ITEM = /^(?:\s*>)*\s*(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
342
342
  const QUOTE_DEPTH = /^(?:\s*>)*/;
343
343
 
@@ -355,7 +355,7 @@ function secondSentence(line: MarkdownLine): ProseFinding | undefined {
355
355
  return undefined;
356
356
  }
357
357
 
358
- const OPENS_LOWERCASE = /^\s*[a-z]/;
358
+ const OPENS_LOWERCASE = /^\s*\p{Ll}/u;
359
359
 
360
360
  // A sentence may end inside the code or link that closes its line, so only a lowercase next line proves it runs on.
361
361
  function endsMidSentence(line: MarkdownLine, next: MarkdownLine): boolean {
@@ -38,7 +38,7 @@ function orderedAt(raw: string): boolean {
38
38
  }
39
39
 
40
40
  function isWordChar(char: string): boolean {
41
- return (char >= "0" && char <= "9") || (char >= "A" && char <= "Z") || (char >= "a" && char <= "z");
41
+ return /[\p{L}\p{N}]/u.test(char);
42
42
  }
43
43
 
44
44
  function wordsIn(sentence: string): number {
@@ -59,7 +59,7 @@ function wordsIn(sentence: string): number {
59
59
  }
60
60
 
61
61
  function isCloser(char: string): boolean {
62
- return char === '"' || char === "'" || char === ")" || char === "]" || char === "*" || char === "_" || char === "”" || char === "’";
62
+ return char === '"' || char === "'" || char === ")" || char === "]" || char === "*" || char === "_" || char === "”" || char === "’" || char === "»";
63
63
  }
64
64
 
65
65
  function sentencesIn(body: string): readonly string[] {
@@ -0,0 +1,23 @@
1
+ import type { Page } from "playwright-core";
2
+
3
+ const FAILING_STATUS = 400;
4
+
5
+ export type AssetWatch = {
6
+ readonly requested: () => number;
7
+ readonly found: () => readonly string[];
8
+ };
9
+
10
+ export function watchAssets(page: Page): AssetWatch {
11
+ const found: string[] = [];
12
+ let requested = 0;
13
+ page.on("request", () => {
14
+ requested += 1;
15
+ });
16
+ page.on("requestfailed", (request) => {
17
+ found.push(`${request.url()} fails to load: ${request.failure()?.errorText ?? "no reason given"}`);
18
+ });
19
+ page.on("response", (response) => {
20
+ if (response.status() >= FAILING_STATUS) found.push(`${response.url()} answers ${response.status()}`);
21
+ });
22
+ return { requested: () => requested, found: () => [...found] };
23
+ }
@@ -0,0 +1,33 @@
1
+ import { Effect } from "effect";
2
+ import type { Page } from "playwright-core";
3
+ import { attempt } from "./page.ts";
4
+
5
+ const WCAG_AA = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
6
+
7
+ const CONTRAST = "color-contrast";
8
+
9
+ type AxeReport = {
10
+ readonly found: readonly string[];
11
+ readonly unverified: readonly string[];
12
+ readonly rules: number;
13
+ };
14
+
15
+ type Node = { readonly target: readonly unknown[]; readonly any: readonly { readonly message: string }[] };
16
+
17
+ function where(node: Node): string {
18
+ return node.target.map(String).join(" ");
19
+ }
20
+
21
+ export const judgeAxe = Effect.fn("judgeAxe")(function* (page: Page) {
22
+ const { AxeBuilder } = yield* attempt("cannot load @axe-core/playwright, which a product that runs the axe check installs beside the kit", () =>
23
+ import("@axe-core/playwright"),
24
+ );
25
+ const results = yield* attempt("axe cannot analyse the page", () => new AxeBuilder({ page }).withTags(WCAG_AA).analyze());
26
+ const found = results.violations.flatMap((violation) =>
27
+ violation.nodes.map((node) => `${violation.id} (${violation.impact ?? "unknown"}): ${where(node)}: ${violation.help}`),
28
+ );
29
+ const unverified = results.incomplete
30
+ .filter(({ id }) => id === CONTRAST)
31
+ .flatMap(({ nodes }) => nodes.map((node) => `${CONTRAST} unverified: ${where(node)}: ${node.any[0]?.message ?? "axe cannot measure it"}`));
32
+ return { found, unverified, rules: results.passes.length + results.violations.length + results.incomplete.length } satisfies AxeReport;
33
+ });