@avi2dg/checks 0.57.1 → 0.59.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,30 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.59.0
6
+
7
+ Released 2026-10-31.
8
+
9
+ ### Features
10
+
11
+ - **browser:** add shared screen size presets and starter declaration [#20](https://github.com/avi2d/checks/pull/20)
12
+
13
+ ## 0.58.1
14
+
15
+ Released 2026-10-29.
16
+
17
+ ### Fixes
18
+
19
+ - **browser:** judge where the pointer lands instead of timing the hover [#19](https://github.com/avi2d/checks/pull/19)
20
+
21
+ ## 0.58.0
22
+
23
+ Released 2026-10-29.
24
+
25
+ ### Features
26
+
27
+ - **delivery:** scan commit messages for secrets and pack terms [#17](https://github.com/avi2d/checks/pull/17)
28
+
5
29
  ## 0.57.1
6
30
 
7
31
  Released 2026-10-29.
package/docs/design.md CHANGED
@@ -84,9 +84,10 @@ Nothing would then say which gate a helper serves.
84
84
  A mutation runner's default scope covers `src/`, so the kit's own Stryker run mutates its source with no `mutate` list.
85
85
  The testing directory is named `testing` rather than `tests`, because a `src/tests/` beside the root `tests/` would read as a second suite.
86
86
 
87
- Five `exports` keys name a path the file does not sit at, because consumers resolve them by that name.
87
+ Six `exports` keys name a path the file does not sit at, because consumers resolve them by that name.
88
88
  `@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/`.
89
89
  `@avi2dg/checks/browser-hooks.ts` points at `src/quality/browser/hooks.ts`, whose types a product's browser hooks import.
90
+ `@avi2dg/checks/browser-checks.json` points at `src/quality/browser/browser-checks.json`, the starter a product copies.
90
91
  `@avi2dg/checks/templates/*` points at `dist/templates/`.
91
92
  A path read without the resolver, such as `node_modules/@avi2dg/checks/scripts/test-skips.ts`, does not exist.
92
93
 
@@ -323,6 +324,8 @@ A finding location prints as it stands, such as a file name or a link.
323
324
  So a term inside a file name or a repository name shows there.
324
325
  That location is already public in the scanned commit or the scanned pull request.
325
326
 
327
+ A pushed branch has already sent each commit message to the forge.
328
+ So `checks-secrets` scans each message in the range with the same rules and the same pack.
326
329
  A pull request's title, body and comments reach the forge without a commit, so [checks-pr-text](gates/checks-pr-text.md) scans them with the same rules and the same pack.
327
330
 
328
331
  ## The frontend checks are opt-in
@@ -121,12 +121,12 @@ It reads `browser-checks.json` in the directory it runs in:
121
121
  { "path": "/ru/", "locale": "ru" }
122
122
  ],
123
123
  "viewports": [
124
- { "name": "phone", "width": 375, "height": 812, "touch": true },
125
- { "name": "desktop", "width": 1280, "height": 800 }
124
+ { "preset": "anchor" },
125
+ { "name": "small laptop", "width": 1280, "height": 720, "device": "desktop" }
126
126
  ],
127
127
  "states": [
128
128
  { "name": "default" },
129
- { "name": "enlarged text", "textPx": 32 },
129
+ { "preset": "enlarged-text" },
130
130
  { "name": "reduced motion", "reducedMotion": "reduce" }
131
131
  ],
132
132
  "targets": [
@@ -143,8 +143,8 @@ It reads `browser-checks.json` in the directory it runs in:
143
143
  | --- | --- |
144
144
  | `site` | the directory of the built site, which the runner serves on a local port, an `index.html` answering for its directory |
145
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` |
146
+ | `viewports` | each window to visit in, with a `name`, a `width` and a `height` in pixels, `touch` for a phone that has touch and a mobile viewport, and `device` as `phone`, `tablet` or `desktop`, or a `preset` that stands for a set of screen sizes |
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`, or the `enlarged-text` preset |
148
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
149
  | `checks` | the built-in checks to run, from `layout`, `keyboard`, `motion`, `axe`, `nesting` and `assets` |
150
150
  | `hooks` | the module of product hooks, as a path from the directory the runner runs in |
@@ -153,6 +153,88 @@ It reads `browser-checks.json` in the directory it runs in:
153
153
  A declaration that names a route, viewport, state, target or check twice is refused.
154
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
155
 
156
+ ### Screen sizes
157
+
158
+ A viewport `{ "preset": "anchor" }` stands for the anchor set of screen sizes, and `{ "preset": "full" }` for the full set.
159
+ The full set holds the anchors and eleven more sizes.
160
+ A product runs the anchor set on every pull request and the full set at night.
161
+ Each size in a set is a viewport named for its device and its size, with `device` set, and each phone also sets `touch`.
162
+ A preset sits beside a product's own viewports.
163
+ A name both declare is refused as declared twice, and so is a size that two listed presets both hold.
164
+
165
+ <!-- generated screen-sizes: bun run build writes it from SCREEN_SIZES in src/quality/browser/screen-sizes.ts and scripts/doc-blocks.ts -->
166
+
167
+ | Device | Width | Height | Name | Sets |
168
+ | --- | --- | --- | --- | --- |
169
+ | phone | 360 | 640 | `phone-360x640` | `anchor` and `full` |
170
+ | phone | 375 | 667 | `phone-375x667` | `anchor` and `full` |
171
+ | phone | 393 | 852 | `phone-393x852` | `anchor` and `full` |
172
+ | phone | 412 | 915 | `phone-412x915` | `anchor` and `full` |
173
+ | tablet | 820 | 1180 | `tablet-820x1180` | `anchor` and `full` |
174
+ | desktop | 1366 | 768 | `desktop-1366x768` | `anchor` and `full` |
175
+ | desktop | 1440 | 900 | `desktop-1440x900` | `anchor` and `full` |
176
+ | desktop | 1920 | 1080 | `desktop-1920x1080` | `anchor` and `full` |
177
+ | phone | 320 | 568 | `phone-320x568` | `full` |
178
+ | phone | 360 | 540 | `phone-360x540` | `full` |
179
+ | phone | 375 | 567 | `phone-375x567` | `full` |
180
+ | phone | 375 | 812 | `phone-375x812` | `full` |
181
+ | phone | 390 | 844 | `phone-390x844` | `full` |
182
+ | phone | 430 | 932 | `phone-430x932` | `full` |
183
+ | tablet | 768 | 1024 | `tablet-768x1024` | `full` |
184
+ | tablet | 1024 | 1366 | `tablet-1024x1366` | `full` |
185
+ | desktop | 1280 | 800 | `desktop-1280x800` | `full` |
186
+ | desktop | 1536 | 864 | `desktop-1536x864` | `full` |
187
+ | desktop | 2560 | 1440 | `desktop-2560x1440` | `full` |
188
+
189
+ <!-- end generated screen-sizes -->
190
+
191
+ The state `{ "preset": "enlarged-text" }` stands for a state named `enlarged text` whose `textPx` is 32.
192
+ The sizes were checked on 2026-10-31 against four sources.
193
+ They are StatCounter's screen resolution statistics, the point sizes in Apple's Human Interface Guidelines, Material Design's device metrics and the Chrome UX Report.
194
+ They are due for a recheck against those sources once a year.
195
+
196
+ ### Start from the shipped declaration
197
+
198
+ The kit ships a starter declaration, which a product copies and then edits to its own routes and targets:
199
+
200
+ ```sh
201
+ cp node_modules/@avi2dg/checks/src/quality/browser/browser-checks.json browser-checks.json
202
+ ```
203
+
204
+ A host resolves it as `@avi2dg/checks/browser-checks.json`.
205
+
206
+ <!-- generated starter: bun run build writes it from src/quality/browser/browser-checks.json and scripts/doc-blocks.ts -->
207
+
208
+ ```json
209
+ {
210
+ "site": "dist",
211
+ "routes": [
212
+ { "path": "/", "locale": "en" }
213
+ ],
214
+ "viewports": [
215
+ { "preset": "anchor" }
216
+ ],
217
+ "states": [
218
+ { "name": "default" },
219
+ { "preset": "enlarged-text" },
220
+ { "name": "reduced motion", "reducedMotion": "reduce" }
221
+ ],
222
+ "targets": [
223
+ { "name": "headline", "selector": "main h1" },
224
+ { "name": "link", "selector": "a[href]", "focusable": true }
225
+ ],
226
+ "checks": ["layout", "keyboard", "motion", "axe", "nesting", "assets"]
227
+ }
228
+ ```
229
+
230
+ <!-- end generated starter -->
231
+
232
+ For the night run, a product keeps a second declaration in a directory of its own.
233
+ Its `site` is a path from that directory, such as `../dist`, and its viewports are `{ "preset": "full" }`.
234
+ It runs `checks-browser <directory>` on that directory.
235
+
236
+ ### Chrome and the peers
237
+
156
238
  It drives the Chrome the machine has, which `CHROME_PATH` names, or Chrome's standard install when it is unset.
157
239
  The product installs the optional peers the runner loads, at the versions the kit pins:
158
240
 
@@ -183,15 +265,15 @@ It reads the declaration in the directory it runs in, or in the directory it is
183
265
  ## Sample output
184
266
 
185
267
  ```
186
- browser: targets at /ru/ (ru), phone 375x812 touch, enlarged text scanned language switch 1, email contact 1, headline 1
187
- browser: layout at /ru/ (ru), phone 375x812 touch, enlarged text scanned language switch 1, email contact 1, headline 1
188
- browser: keyboard at /ru/ (ru), phone 375x812 touch, enlarged text scanned language switch 1, email contact 1
189
- browser: axe at /ru/ (ru), phone 375x812 touch, enlarged text scanned 11 rule(s)
268
+ browser: targets at /ru/ (ru), phone-375x667 375x667 touch, enlarged text scanned language switch 1, email contact 1, headline 1
269
+ browser: layout at /ru/ (ru), phone-375x667 375x667 touch, enlarged text scanned language switch 1, email contact 1, headline 1
270
+ browser: keyboard at /ru/ (ru), phone-375x667 375x667 touch, enlarged text scanned language switch 1, email contact 1
271
+ browser: axe at /ru/ (ru), phone-375x667 375x667 touch, enlarged text scanned 11 rule(s)
190
272
  browser: 1 contrast check(s) axe cannot measure, reported as unverified:
191
- 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
273
+ axe at /ru/ (ru), phone-375x667 375x667 touch, enlarged text: color-contrast unverified: header > p: Element's background color could not be determined due to a background image
192
274
  browser: 2 failure(s) in 4 check run(s) over 1 route(s), 1 viewport(s), 1 state(s) and 3 target(s):
193
- layout at /ru/ (ru), phone 375x812 touch, enlarged text: email contact "Написать письмо владельцу" clips its own text
194
- axe at /ru/ (ru), phone 375x812 touch, enlarged text: nested-interactive (serious): button: Interactive controls must not be nested
275
+ layout at /ru/ (ru), phone-375x667 375x667 touch, enlarged text: email contact "Написать письмо владельцу" clips its own text
276
+ axe at /ru/ (ru), phone-375x667 375x667 touch, enlarged text: nested-interactive (serious): button: Interactive controls must not be nested
195
277
  ```
196
278
 
197
279
  ## When it runs
@@ -12,7 +12,10 @@ A private pack adds terms no public file may carry, such as a person's name, an
12
12
  It scans the lines each commit in the range adds, in every file the commit adds or modifies, with gitleaks.
13
13
  In a merge commit it scans the lines the merge's own resolution adds, such as a key written while resolving a conflict, and not what the merge brings in from the other parent.
14
14
  It exits 2 when a commit it scans is an octopus merge, one with three or more parents, because git gives no resolution diff for one.
15
+ It scans the message of each commit in the range with the rules and the private pack [checks-pr-text](checks-pr-text.md) runs on a pull request's text.
16
+ A merge commit's own message is scanned too, though the file scan skips what the merge brings in from the other parent.
15
17
  It fails on each secret the scan finds, and names its file, its line, the rule that matched and the commit that added it.
18
+ A finding in a message names `the message` and its line in place of a file, where the subject is line 1.
16
19
  A secret one commit adds and a later commit in the range removes still fails, because the first commit still holds it.
17
20
  It guards new changes only, and never scans a commit before the range, so a secret already in the history takes no part.
18
21
 
@@ -40,14 +43,15 @@ These rules do not catch:
40
43
  No setting accepts a finding.
41
44
  It ignores a repository's `.gitleaks.toml`, its `.gitleaksignore` and every `gitleaks:allow` comment.
42
45
  It skips the paths gitleaks' default config skips, such as lockfiles, images and `node_modules/`.
43
- It scans what a commit adds to its files, and not its message or its author, which [checks-commit-identity](checks-commit-identity.md) judges.
46
+ It scans what a commit adds to its files and its message, and not its author, which [checks-commit-identity](checks-commit-identity.md) judges.
44
47
 
45
48
  ## The private pack
46
49
 
47
50
  The private pack is a JSON file of terms that lives outside every public repository, and `CHECKS_SECRETS_PACK` names its path.
48
51
  The kit ships no pack and no term, so a public repository can run the pack without carrying what it hides.
49
52
  With `CHECKS_SECRETS_PACK` unset the gate runs the kit's rules alone, exactly as it does without a pack, and its last line says so.
50
- With it set the gate adds a rule for each kind of term the pack lists, and fails a range that adds any of them.
53
+ With it set the gate adds a rule for each kind of term the pack lists.
54
+ It fails a range that adds any of them to a file or a commit message.
51
55
 
52
56
  Each key is optional, and each holds a list of strings:
53
57
 
@@ -78,7 +82,7 @@ An IPv6 range has no key, so a pack lists its prefix under `hosts` as its addres
78
82
  The gitleaks default allowlist skips paths such as lockfiles, go.mod and go.sum, images and documents such as PDFs.
79
83
  It also skips node_modules and some Go and Ruby vendor paths, so the pack terms are not searched there.
80
84
  A lockfile's resolved URLs can carry a private registry host, so keep such a host out of lockfiles, go.mod and go.sum by other means.
81
- Commit messages are not scanned by the pack, and checks-commit-identity covers only the author, the committer and Co-authored-by trailers.
85
+ The pack scans each commit message, trailers included, and checks-commit-identity also judges the author, the committer and Co-authored-by trailers.
82
86
 
83
87
  The gate exits 2, and scans nothing, when `CHECKS_SECRETS_PACK` is set but the pack cannot serve:
84
88
 
@@ -119,11 +123,12 @@ Unlike [checks-pr-text](checks-pr-text.md), this gate has no skip: a job that wr
119
123
 
120
124
  ## What it reads
121
125
 
122
- It reads the commits of the range from git, not the working tree.
126
+ It reads the commits of the range and their messages from git, not the working tree.
123
127
  It needs git 2.36 or newer, which diffs a merge against the merge git would make on its own, and it exits 2 on an older git.
124
128
  It reads its rules from `src/delivery/gitleaks.toml` in the installed package.
125
129
  It reads the path of the private pack from `CHECKS_SECRETS_PACK`.
126
- It writes the kit's rules and the pack's into one config in a temporary directory it deletes when the scan ends.
130
+ It writes the kit's rules and the pack's into one config, and each commit message into a file of its own.
131
+ Each goes in a temporary directory it deletes when the scan ends.
127
132
 
128
133
  It runs the gitleaks release `src/delivery/gitleaks.ts` pins, by the SHA-256 of each platform's archive and of the binary inside it.
129
134
  On first use it downloads the archive from the scanner's GitHub release and unpacks the binary into `~/.cache/avi2dg-checks/gitleaks/<version>/`.
@@ -146,8 +151,8 @@ With one it scans that commit alone.
146
151
 
147
152
  | Code | When |
148
153
  | --- | --- |
149
- | 0 | no commit in the range adds a secret |
150
- | 1 | a commit in the range adds a secret |
154
+ | 0 | no commit in the range adds a secret, in its files or its message |
155
+ | 1 | a commit in the range adds a secret, in its files or its message |
151
156
  | 2 | a ref does not resolve, or a commit it scans is an octopus merge, or no verified scanner is at hand, or git is older than 2.36, or gitleaks fails, or `CHECKS_SECRETS_PACK` is set and the pack cannot serve |
152
157
 
153
158
  ## Sample output
@@ -163,8 +168,9 @@ secrets: CHECKS_SECRETS_PACK is unset, so the kit's rules ran without a private
163
168
  With a pack set, a finding of the pack names the kind of term and never the term:
164
169
 
165
170
  ```
166
- secrets: the range adds 1 secret(s); put a placeholder such as <private-key> in its place in the commit that added it, and rotate any secret that left this machine:
171
+ secrets: the range adds 2 secret(s); put a placeholder such as <private-key> in its place in the commit that added it, and rotate any secret that left this machine:
167
172
  docs/setup.md:12 private-domain in 4f1c09ab: A domain the private pack lists
173
+ the message:3 private-name in 4f1c09ab: A name form the private pack lists
168
174
  secrets: the kit's rules and the private pack CHECKS_SECRETS_PACK names ran
169
175
  ```
170
176
 
@@ -21,7 +21,7 @@ Every path is relative to the installed package, `node_modules/@avi2dg/checks/`.
21
21
  | `commitlint.config.ts` | the shared commitlint config |
22
22
  | `dependency-cruiser.config.js` | the kit's dependency-cruiser rules as a base a `.dependency-cruiser.cjs` extends by path, which the build writes |
23
23
  | `knip-base.json` | the Knip `include` setting, for a configuration that spreads it |
24
- | `src/` | every bin, which a package script calls by its `checks-` name, the modules the bins import, and the Effect language service severities under `src/quality/presets/` |
24
+ | `src/` | every bin, which a package script calls by its `checks-` name, the modules the bins import, the Effect language service severities under `src/quality/presets/`, and the starter `browser-checks.json` under `src/quality/browser/` |
25
25
  | `dist/` | the compiled oxlint plugins, the config builders `@avi2dg/checks/oxlint`, `@avi2dg/checks/knip`, `@avi2dg/checks/dependency-cruiser` and `@avi2dg/checks/stylelint` resolve to, and the doc templates, one template per kind of doc file |
26
26
  | `oxlintrc.json` | the oxlint `base` as a config a `.oxlintrc.json` extends by path, which the build writes |
27
27
  | `stryker.preset.js` | the Stryker mutation-testing preset, which refuses a full run outside CI |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.57.1",
3
+ "version": "0.59.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -141,6 +141,7 @@
141
141
  "./templates/*": "./dist/templates/*",
142
142
  "./tsconfig.effect.json": "./tsconfig.effect.json",
143
143
  "./browser-hooks.ts": "./src/quality/browser/hooks.ts",
144
+ "./browser-checks.json": "./src/quality/browser/browser-checks.json",
144
145
  "./oxlint": {
145
146
  "types": "./dist/presets/oxlint.d.ts",
146
147
  "default": "./dist/presets/oxlint.js"
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Schema } from "effect";
3
- import { commitOf, git, refArgs } from "../core/git.ts";
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { GitFailure, commitOf, git, refArgs } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { cacheRoot } from "../dependencies/cache-root.ts";
6
- import { pinnedGitleaks, scanCommits, type Leak } from "./gitleaks.ts";
6
+ import { pinnedGitleaks, scanCommits, scanDirectory, type DirectoryScan, type Leak } from "./gitleaks.ts";
7
7
  import { scanConfig } from "./secrets-pack.ts";
8
8
 
9
9
  const NAME = "secrets";
@@ -27,8 +27,7 @@ const revisionsOf = Effect.fn("revisionsOf")(function* (args: readonly string[],
27
27
 
28
28
  // Without --remerge-diff git log prints no diff for a merge, and a first-parent diff would rescan what the merge brings in.
29
29
  // git skips --remerge-diff for an octopus merge with only a warning, so the scan would pass a commit it never read.
30
- const logOptionsOf = Effect.fn("logOptionsOf")(function* (args: readonly string[], root: string) {
31
- const revisions = yield* revisionsOf(args, root);
30
+ const logOptionsOf = Effect.fn("logOptionsOf")(function* (revisions: readonly string[], root: string) {
32
31
  const [octopus] = (yield* git(["rev-list", `--min-parents=${OCTOPUS_PARENTS}`, ...revisions], root)).split("\n");
33
32
  if (octopus !== undefined && octopus !== "") {
34
33
  return yield* new OctopusMerge({
@@ -48,10 +47,42 @@ const atRepositoryPath = Effect.fn("atRepositoryPath")(function* (leak: Leak, ro
48
47
  return held ? leak : { ...leak, File: leak.File.slice(NEW_SIDE_PREFIX.length) };
49
48
  });
50
49
 
50
+ type Message = { readonly commit: string; readonly text: string };
51
+
52
+ // git refuses a NUL in a commit message, so the NUL -z writes after each record ends it.
53
+ const messagesOf = Effect.fn("messagesOf")(function* (revisions: readonly string[], root: string) {
54
+ const records = (yield* git(["log", "--no-show-signature", "-z", "--format=%H%n%B", ...revisions], root)).split("\0").filter((record) => record !== "");
55
+ return yield* Effect.forEach(records, (record) => {
56
+ const newline = record.indexOf("\n");
57
+ const commit = newline === -1 ? "" : record.slice(0, newline);
58
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
59
+ return Effect.fail(new GitFailure({ message: "git log: a record does not start with a commit id" }));
60
+ }
61
+ return Effect.succeed({ commit, text: record.slice(newline + 1) } satisfies Message);
62
+ });
63
+ });
64
+
65
+ const messageLeaksOf = Effect.fn("messageLeaksOf")(function* (messages: readonly Message[], scanner: Omit<DirectoryScan, "dir">) {
66
+ const fs = yield* FileSystem.FileSystem;
67
+ const path = yield* Path.Path;
68
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-messages-" });
69
+ yield* Effect.forEach(messages, ({ commit, text }) => fs.writeFileString(path.join(dir, commit), text));
70
+ const leaks = yield* scanDirectory({ ...scanner, dir });
71
+ return leaks.map((leak) => ({ ...leak, Commit: path.basename(leak.File) }));
72
+ });
73
+
51
74
  function leakLine({ File, StartLine, RuleID, Commit, Description }: Leak): string {
52
75
  return ` ${File}:${StartLine} ${RuleID} in ${Commit.slice(0, SHORT_SHA)}: ${Description}`;
53
76
  }
54
77
 
78
+ function messageLine({ StartLine, RuleID, Commit, Description }: Leak): string {
79
+ return ` the message:${StartLine} ${RuleID} in ${Commit.slice(0, SHORT_SHA)}: ${Description}`;
80
+ }
81
+
82
+ function byMessage(left: Leak, right: Leak): number {
83
+ return left.Commit.localeCompare(right.Commit) || left.StartLine - right.StartLine || left.RuleID.localeCompare(right.RuleID);
84
+ }
85
+
55
86
  function byPlace(left: Leak, right: Leak): number {
56
87
  return (
57
88
  left.File.localeCompare(right.File) ||
@@ -61,27 +92,31 @@ function byPlace(left: Leak, right: Leak): number {
61
92
  );
62
93
  }
63
94
 
64
- export function report(leaks: readonly Leak[]): string {
65
- if (leaks.length === 0) return `${NAME}: the range adds no secret`;
95
+ export function report(fileLeaks: readonly Leak[], messageLeaks: readonly Leak[]): string {
96
+ const count = fileLeaks.length + messageLeaks.length;
97
+ if (count === 0) return `${NAME}: the range adds no secret`;
66
98
  return [
67
- `${NAME}: the range adds ${leaks.length} secret(s); put a placeholder such as <private-key> in its place in the commit that added it, and rotate any secret that left this machine:`,
68
- ...leaks.toSorted(byPlace).map(leakLine),
99
+ `${NAME}: the range adds ${count} secret(s); put a placeholder such as <private-key> in its place in the commit that added it, and rotate any secret that left this machine:`,
100
+ ...fileLeaks.toSorted(byPlace).map(leakLine),
101
+ ...messageLeaks.toSorted(byMessage).map(messageLine),
69
102
  ].join("\n");
70
103
  }
71
104
 
72
105
  const secrets = Effect.gen(function* () {
73
106
  const fs = yield* FileSystem.FileSystem;
74
107
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
75
- const logOptions = yield* logOptionsOf(process.argv.slice(2), root);
108
+ const revisions = yield* revisionsOf(process.argv.slice(2), root);
109
+ const logOptions = yield* logOptionsOf(revisions, root);
76
110
  const ignoreDir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-" });
77
111
  const { config, notice } = yield* scanConfig(NAME, yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-pack-" }));
78
112
  const binary = yield* pinnedGitleaks(yield* cacheRoot());
79
113
  const gitDir = (yield* git(["rev-parse", "--absolute-git-dir"], root)).trim();
80
114
  const reported = yield* scanCommits({ binary, gitDir, logOptions, config, ignoreDir });
81
- const leaks = yield* Effect.forEach(reported, (leak) => atRepositoryPath(leak, root));
82
- yield* Console.log(report(leaks));
115
+ const fileLeaks = yield* Effect.forEach(reported, (leak) => atRepositoryPath(leak, root));
116
+ const messageLeaks = yield* messageLeaksOf(yield* messagesOf(revisions, root), { binary, config, ignoreDir });
117
+ yield* Console.log(report(fileLeaks, messageLeaks));
83
118
  yield* Console.log(notice);
84
- return leaks.length === 0;
119
+ return fileLeaks.length + messageLeaks.length === 0;
85
120
  }).pipe(Effect.scoped);
86
121
 
87
122
  if (import.meta.main) runMain(NAME, secrets);
@@ -0,0 +1,19 @@
1
+ {
2
+ "site": "dist",
3
+ "routes": [
4
+ { "path": "/", "locale": "en" }
5
+ ],
6
+ "viewports": [
7
+ { "preset": "anchor" }
8
+ ],
9
+ "states": [
10
+ { "name": "default" },
11
+ { "preset": "enlarged-text" },
12
+ { "name": "reduced motion", "reducedMotion": "reduce" }
13
+ ],
14
+ "targets": [
15
+ { "name": "headline", "selector": "main h1" },
16
+ { "name": "link", "selector": "a[href]", "focusable": true }
17
+ ],
18
+ "checks": ["layout", "keyboard", "motion", "axe", "nesting", "assets"]
19
+ }
@@ -1,5 +1,6 @@
1
- import { Effect, FileSystem, Path, Schema } from "effect";
1
+ import { Array as Arr, Effect, FileSystem, Path, Schema } from "effect";
2
2
  import { Usage } from "../../core/main.ts";
3
+ import { DEVICES, ENLARGED_TEXT_PX, SCREEN_SIZE_SETS, SCREEN_SIZES, screenSizeName, type ScreenSize } from "./screen-sizes.ts";
3
4
 
4
5
  const DECLARATION = "browser-checks.json";
5
6
 
@@ -15,7 +16,15 @@ const Route = Schema.Struct({
15
16
  locale: Name,
16
17
  });
17
18
 
18
- const Viewport = Schema.Struct({ name: Name, width: Pixels, height: Pixels, touch: Schema.optionalKey(Schema.Boolean) });
19
+ const Viewport = Schema.Struct({
20
+ name: Name,
21
+ width: Pixels,
22
+ height: Pixels,
23
+ touch: Schema.optionalKey(Schema.Boolean),
24
+ device: Schema.optionalKey(Schema.Literals(DEVICES)),
25
+ });
26
+
27
+ const ScreenSizeSet = Schema.Struct({ preset: Schema.Literals(SCREEN_SIZE_SETS) });
19
28
 
20
29
  const State = Schema.Struct({
21
30
  name: Name,
@@ -23,6 +32,10 @@ const State = Schema.Struct({
23
32
  reducedMotion: Schema.optionalKey(Schema.Literals(["reduce", "no-preference"])),
24
33
  });
25
34
 
35
+ const STATE_PRESETS = ["enlarged-text"] as const;
36
+
37
+ const StatePreset = Schema.Struct({ preset: Schema.Literals(STATE_PRESETS) });
38
+
26
39
  const Target = Schema.Struct({
27
40
  name: Name,
28
41
  selector: Name,
@@ -40,6 +53,12 @@ const Declaration = Schema.Struct({
40
53
  hooks: Schema.optionalKey(Name),
41
54
  });
42
55
 
56
+ const Listed = Schema.Struct({
57
+ ...Declaration.fields,
58
+ viewports: Schema.NonEmptyArray(Schema.Union([Viewport, ScreenSizeSet])),
59
+ states: Schema.NonEmptyArray(Schema.Union([State, StatePreset])),
60
+ });
61
+
43
62
  export type Route = typeof Route.Type;
44
63
  export type Viewport = typeof Viewport.Type;
45
64
  export type State = typeof State.Type;
@@ -52,7 +71,26 @@ export type Visit = {
52
71
  readonly state: State;
53
72
  };
54
73
 
55
- const decodeDeclaration = Schema.decodeUnknownEffect(Schema.fromJsonString(Declaration));
74
+ export type Listed = typeof Listed.Type;
75
+
76
+ const decodeListed = Schema.decodeUnknownEffect(Schema.fromJsonString(Listed));
77
+
78
+ function viewportOf(size: ScreenSize): Viewport {
79
+ const { device, width, height } = size;
80
+ return { name: screenSizeName(size), width, height, device, ...(device === "phone" ? { touch: true } : {}) };
81
+ }
82
+
83
+ const STATES: Readonly<Record<(typeof STATE_PRESETS)[number], State>> = {
84
+ "enlarged-text": { name: "enlarged text", textPx: ENLARGED_TEXT_PX },
85
+ };
86
+
87
+ export function expanded(listed: Listed): Declaration {
88
+ return {
89
+ ...listed,
90
+ viewports: Arr.flatMap(listed.viewports, (entry) => ("preset" in entry ? Arr.map(SCREEN_SIZES[entry.preset], viewportOf) : [entry])),
91
+ states: Arr.map(listed.states, (entry) => ("preset" in entry ? STATES[entry.preset] : entry)),
92
+ };
93
+ }
56
94
 
57
95
  function repeated(names: readonly string[]): readonly string[] {
58
96
  return [...new Set(names.filter((name, index) => names.indexOf(name) !== index))];
@@ -85,9 +123,10 @@ export const readDeclaration = Effect.fn("readDeclaration")(function* (root: str
85
123
  if (!(yield* fs.exists(file))) {
86
124
  return yield* new Usage({ message: `${DECLARATION} is missing, and a product opts in to the browser checks by declaring its pages there` });
87
125
  }
88
- const declaration = yield* decodeDeclaration(yield* fs.readFileString(file)).pipe(
126
+ const listed = yield* decodeListed(yield* fs.readFileString(file)).pipe(
89
127
  Effect.mapError(({ message }) => new Usage({ message: `${DECLARATION} does not decode: ${message}` })),
90
128
  );
129
+ const declaration = expanded(listed);
91
130
  const found = inconsistencies(declaration);
92
131
  if (found.length > 0) return yield* new Usage({ message: `${DECLARATION}: ${found.join("; ")}` });
93
132
  return declaration;
@@ -1,10 +1,63 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import type { BrowserContext, Page } from "playwright-core";
3
+ import type { Rect } from "./dom.ts";
3
4
  import { attempt, BrowserFailure } from "./page.ts";
4
5
 
5
6
  const CONTROLS = "a[href], button, summary, input, select, textarea";
6
7
 
7
- const HOVER_TIMEOUT_MS = 2_000;
8
+ const Landing = Schema.NullOr(Schema.Struct({ x: Schema.Finite, y: Schema.Finite }));
9
+
10
+ type ScrollAlignment = "nearest" | "center" | "start" | "end";
11
+
12
+ type PointerTarget = {
13
+ readonly ownerDocument: { readonly documentElement: { readonly clientWidth: number; readonly clientHeight: number } };
14
+ readonly scrollIntoView: (options: { readonly behavior: "instant"; readonly block: ScrollAlignment; readonly inline: ScrollAlignment }) => void;
15
+ readonly getClientRects: () => Iterable<Rect>;
16
+ readonly getRootNode: () => { readonly elementFromPoint: (x: number, y: number) => unknown };
17
+ };
18
+
19
+ // Playwright's hover retries until a timeout to give up on a control the pointer cannot reach, and a busy host outlasts any timeout on a control it can reach.
20
+ // The page runs this from its source text, so it must not reach anything outside its own body.
21
+ function pointerLanding(control: PointerTarget): typeof Landing.Type {
22
+ const reachesControl = (hit: unknown): boolean => {
23
+ let node: unknown = hit;
24
+ while (node !== null && node !== undefined) {
25
+ if (node === control) return true;
26
+ const candidate = node as { readonly assignedSlot?: unknown; readonly parentNode?: unknown; readonly host?: unknown };
27
+ if (candidate.assignedSlot !== undefined && candidate.assignedSlot !== null) {
28
+ node = candidate.assignedSlot;
29
+ continue;
30
+ }
31
+ if (candidate.parentNode !== undefined && candidate.parentNode !== null) {
32
+ node = candidate.parentNode;
33
+ continue;
34
+ }
35
+ if (candidate.host !== undefined && candidate.host !== null) {
36
+ node = candidate.host;
37
+ continue;
38
+ }
39
+ return false;
40
+ }
41
+ return false;
42
+ };
43
+ const alignments: readonly ScrollAlignment[] = ["nearest", "center", "start", "end"];
44
+ for (const alignment of alignments) {
45
+ control.scrollIntoView({ behavior: "instant", block: alignment, inline: alignment });
46
+ const { clientWidth, clientHeight } = control.ownerDocument.documentElement;
47
+ for (const rect of control.getClientRects()) {
48
+ const left = Math.max(rect.left, 0);
49
+ const right = Math.min(rect.right, clientWidth);
50
+ const top = Math.max(rect.top, 0);
51
+ const bottom = Math.min(rect.bottom, clientHeight);
52
+ if (right - left < 1 || bottom - top < 1) continue;
53
+ const x = (left + right) / 2;
54
+ const y = (top + bottom) / 2;
55
+ const hit = control.getRootNode().elementFromPoint(x, y);
56
+ if (hit !== null && reachesControl(hit)) return { x, y };
57
+ }
58
+ }
59
+ return null;
60
+ }
8
61
 
9
62
  // A motion reset for reduced motion shortens animations to near zero rather than removing them, so one that ends within a frame never moves.
10
63
  const FRAME_MS = 1000 / 60;
@@ -33,6 +86,8 @@ const MOTION_RECORDER = `(() => {
33
86
 
34
87
  const decodeStarted = Schema.decodeUnknownEffect(Schema.Array(Schema.String));
35
88
 
89
+ const decodeLanding = Schema.decodeUnknownEffect(Landing);
90
+
36
91
  export function recordMotion(context: BrowserContext): Promise<unknown> {
37
92
  return context.addInitScript(MOTION_RECORDER);
38
93
  }
@@ -67,11 +122,12 @@ export const judgeStillness = Effect.fn("judgeStillness")(function* (page: Page)
67
122
  const control = controls.nth(index);
68
123
  if (!(yield* attempt(`cannot see control ${index + 1}`, () => control.isVisible()))) continue;
69
124
  judged += 1;
70
- const hovered = yield* attempt(`cannot hover control ${index + 1}`, () => control.hover({ timeout: HOVER_TIMEOUT_MS })).pipe(
71
- Effect.as(true),
72
- Effect.catchTag("BrowserFailure", () => Effect.succeed(false)),
125
+ const landing: unknown = yield* attempt(`cannot find where the pointer lands on control ${index + 1}`, () => control.evaluate(pointerLanding));
126
+ const point = yield* decodeLanding(landing).pipe(
127
+ Effect.mapError(() => new BrowserFailure({ message: `the pointer landing on control ${index + 1} gave ${JSON.stringify(landing)}` })),
73
128
  );
74
- if (hovered) {
129
+ if (point !== null) {
130
+ yield* attempt(`cannot hover control ${index + 1}`, () => page.mouse.move(point.x, point.y));
75
131
  yield* animatedDuring(`on hovering control ${index + 1}`);
76
132
  yield* attempt("cannot move the pointer away", () => page.mouse.move(0, 0));
77
133
  yield* animatedDuring(`on leaving control ${index + 1}`);
@@ -0,0 +1,48 @@
1
+ import type { NonEmptyReadonlyArray } from "effect/Array";
2
+
3
+ export const DEVICES = ["phone", "tablet", "desktop"] as const;
4
+
5
+ type Device = (typeof DEVICES)[number];
6
+
7
+ export type ScreenSize = { readonly device: Device; readonly width: number; readonly height: number };
8
+
9
+ const phone = (width: number, height: number): ScreenSize => ({ device: "phone", width, height });
10
+ const tablet = (width: number, height: number): ScreenSize => ({ device: "tablet", width, height });
11
+ const desktop = (width: number, height: number): ScreenSize => ({ device: "desktop", width, height });
12
+
13
+ const ANCHOR = [
14
+ phone(360, 640),
15
+ phone(375, 667),
16
+ phone(393, 852),
17
+ phone(412, 915),
18
+ tablet(820, 1180),
19
+ desktop(1366, 768),
20
+ desktop(1440, 900),
21
+ desktop(1920, 1080),
22
+ ] as const;
23
+
24
+ const NIGHTLY = [
25
+ phone(320, 568),
26
+ phone(360, 540),
27
+ phone(375, 567),
28
+ phone(375, 812),
29
+ phone(390, 844),
30
+ phone(430, 932),
31
+ tablet(768, 1024),
32
+ tablet(1024, 1366),
33
+ desktop(1280, 800),
34
+ desktop(1536, 864),
35
+ desktop(2560, 1440),
36
+ ] as const;
37
+
38
+ export const SCREEN_SIZE_SETS = ["anchor", "full"] as const;
39
+
40
+ export type ScreenSizeSet = (typeof SCREEN_SIZE_SETS)[number];
41
+
42
+ export const SCREEN_SIZES: Readonly<Record<ScreenSizeSet, NonEmptyReadonlyArray<ScreenSize>>> = { anchor: ANCHOR, full: [...ANCHOR, ...NIGHTLY] };
43
+
44
+ export function screenSizeName({ device, width, height }: ScreenSize): string {
45
+ return `${device}-${width}x${height}`;
46
+ }
47
+
48
+ export const ENLARGED_TEXT_PX = 32;