@avi2dg/checks 0.48.0 → 0.48.1

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,16 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.48.1
6
+
7
+ Released 2026-10-20.
8
+
9
+ ### Fixes
10
+
11
+ - **quality:** hold font shorthand and local size custom properties to the tokens option [#185](https://github.com/avi2d/checks/pull/185)
12
+ - **testing:** compare checks-night-run against the latest night that recorded scores [#184](https://github.com/avi2d/checks/pull/184)
13
+ - **testing:** default checks-test --parallel to the machine's core count [#182](https://github.com/avi2d/checks/pull/182)
14
+
5
15
  ## 0.48.0
6
16
 
7
17
  Released 2026-10-19.
@@ -49,11 +49,63 @@ var PSEUDO_ARGUMENT = "(?:[^()]|\\([^()]*\\))*";
49
49
  var PSEUDO_CLASS = `::?[\\w-]+(?:\\(${PSEUDO_ARGUMENT}\\))?`;
50
50
  var HAS_IN_PSEUDO_CLASS = `(?::has\\(${PSEUDO_ARGUMENT}\\)|::?[\\w-]+\\(${PSEUDO_ARGUMENT}:has\\([^()]*\\)${PSEUDO_ARGUMENT}\\))`;
51
51
  var CLASSLESS_HAS = `/(?:^|[\\s>+~,])(?:${PSEUDO_CLASS})*${HAS_IN_PSEUDO_CLASS}(?:${PSEUDO_CLASS})*(?=[\\s>+~,]|$)/i`;
52
+ var VAR = "var\\(\\s*--[^\\s,()]+\\s*\\)";
53
+ var VAR_NAMES = /var\(\s*(--[^\s,()]+)\s*\)/gi;
54
+ var FONT_SIZE_VALUE = new RegExp(`^${VAR}$`, "i");
55
+ var FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH = "(?:normal|italic|oblique(?:\\s+-?[\\d.]+(?:deg|grad|rad|turn))?|small-caps|bold|bolder|lighter|[1-9]\\d*(?:\\.\\d+)?|(?:ultra-|extra-|semi-)?(?:condensed|expanded))";
56
+ var FONT_FAMILY = `(?:"[^"]*"|'[^']*'|${VAR}|-?[a-z_][\\w-]*(?:\\s+-?[a-z_][\\w-]*)*)`;
57
+ var FONT_FAMILIES = `${FONT_FAMILY}(?:\\s*,\\s*${FONT_FAMILY})*`;
58
+ var FONT_FAMILY_VALUE = new RegExp(`^${FONT_FAMILIES}$`, "i");
59
+ var FONT_VALUE = new RegExp(`^(?:${FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH}\\s+)*(?<size>${VAR})(?:\\s*\\/\\s*(?:${VAR}|[\\w.%-]+))?(?:\\s+(?<families>${FONT_FAMILIES}))?$`, "i");
60
+ var FONT_SIZE_RULE = "checks/font-size-from-tokens";
61
+ function varNames(value) {
62
+ return [...value.matchAll(VAR_NAMES)].map(([, name]) => name).filter((name) => name !== undefined);
63
+ }
64
+ function sizeCarriers(property, value) {
65
+ const name = property.toLowerCase();
66
+ if (name === "font-size" && FONT_SIZE_VALUE.test(value))
67
+ return varNames(value).map((token) => ({ token, setTo: FONT_SIZE_VALUE }));
68
+ const font = name === "font" ? FONT_VALUE.exec(value)?.groups : undefined;
69
+ if (font?.size === undefined)
70
+ return [];
71
+ return [
72
+ ...varNames(font.size).map((token) => ({ token, setTo: FONT_SIZE_VALUE })),
73
+ ...varNames(font.families ?? "").map((token) => ({ token, setTo: FONT_FAMILY_VALUE }))
74
+ ];
75
+ }
76
+ function fromTokens({ token, setTo }, declared, seen) {
77
+ const values = declared.get(token);
78
+ if (values === undefined)
79
+ return true;
80
+ return values.every((value) => setTo.test(value) && varNames(value).every((alias) => !seen.has(alias) && fromTokens({ token: alias, setTo }, declared, new Set([...seen, alias]))));
81
+ }
82
+ var fontSizeFromTokens = () => async (root, result) => {
83
+ const { utils } = (await import("stylelint")).default;
84
+ const declared = new Map;
85
+ result.root.walkDecls(/^--/, ({ prop, value }) => {
86
+ declared.set(prop, [...declared.get(prop) ?? [], value]);
87
+ });
88
+ root.walkDecls((node) => {
89
+ for (const carrier of sizeCarriers(node.prop, node.value)) {
90
+ if (fromTokens(carrier, declared, new Set([carrier.token])))
91
+ continue;
92
+ utils.report({ ruleName: FONT_SIZE_RULE, result, node, word: carrier.token, message: `Expected "${carrier.token}" from the token files rather than set in this file` });
93
+ }
94
+ });
95
+ };
96
+ var FONT_SIZE_PLUGIN = {
97
+ ruleName: FONT_SIZE_RULE,
98
+ rule: Object.assign(fontSizeFromTokens, { ruleName: FONT_SIZE_RULE, messages: {} })
99
+ };
52
100
  var DESIGN_TOKENS = {
53
101
  "color-no-hex": true,
54
102
  "color-named": "never",
55
103
  "function-disallowed-list": COLOUR_FUNCTIONS.map((name) => `/^${name}$/i`),
56
- "declaration-property-value-allowed-list": { "/^font-size$/i": ["/^var\\(\\s*--[^\\s,()]+\\s*\\)$/i", "/^inherit$/i"] }
104
+ "declaration-property-value-allowed-list": {
105
+ "/^font-size$/i": [String(FONT_SIZE_VALUE), "/^inherit$/i"],
106
+ "/^font$/i": [String(FONT_VALUE), "/^inherit$/i"]
107
+ },
108
+ [FONT_SIZE_RULE]: true
57
109
  };
58
110
  var REFUSED_BASELINE = '@avi2dg/checks/stylelint: set baseline to "widely", "newly" or a year such as 2024';
59
111
  var REFUSED_TOKENS = "@avi2dg/checks/stylelint: set tokens to the globs of the token files, or false to leave the design token rules off";
@@ -112,7 +164,7 @@ function defineConfig({ baseline, tokens, bem, flatSelectors, noPageHas, rules,
112
164
  return {
113
165
  ...rest,
114
166
  extends: [peer("stylelint-config-standard")],
115
- plugins: [peer("stylelint-plugin-use-baseline")],
167
+ plugins: [peer("stylelint-plugin-use-baseline"), FONT_SIZE_PLUGIN],
116
168
  rules: { ...merged, [BASELINE_RULE]: passProperty(merged[BASELINE_RULE]) },
117
169
  overrides: [
118
170
  { files: [...MARKUP], customSyntax: peer("postcss-html") },
package/docs/design.md CHANGED
@@ -305,10 +305,10 @@ A browser check needs a built site and a browser, and a backend repository has n
305
305
  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.
306
306
  `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.
307
307
 
308
- Each check wraps a detector that already works rather than a rule of the kit's own.
308
+ Each check wraps a detector that already works, and the kit writes a rule of its own only where none decides the constraint.
309
309
  The syntax gate runs two of stylelint's built-in rules and the stylelint team's shared configs, `axe` runs axe-core and `nesting` runs one html-validate rule.
310
310
  Its Baseline family runs `stylelint-plugin-use-baseline`, the one stylelint rule that judges against the Baseline data rather than a browser list.
311
- Its design token family is stylelint's own colour and value rules, applied outside the files a repository names as its tokens.
311
+ Its design token family is stylelint's own colour and value rules and the kit's `checks/font-size-from-tokens`, applied outside the files a repository names as its tokens.
312
312
  A check of the rendered page against declared tokens would need a token format of the kit's own.
313
313
  The repository's `stylelint.config.ts` sets the Baseline target and the token files, since only the repository knows either.
314
314
  The gate imports that file itself, because stylelint's config loader calls `typescript.findConfigFile`, which TypeScript 7 lacks, and a JavaScript config is what [checks-no-plain-js](gates/checks-no-plain-js.md) refuses.
@@ -15,8 +15,10 @@ A run that fails with no failing test, such as a test file that throws while loa
15
15
 
16
16
  ## Parallel hunts
17
17
 
18
- A repository whose `test` script passes `--parallel=<workers>` to [checks-test](checks-test.md#parallel-runs) is hunted with the same worker count, so each run is `bun test --randomize --seed=<seed> --parallel=<workers>`, the mode CI runs in.
19
- A `test` script without the option keeps the serial hunt.
18
+ A repository whose `test` script runs [checks-test](checks-test.md#parallel-runs) is hunted with the worker count `checks-test` runs on.
19
+ That is the `--parallel=<workers>` the script passes, or else one worker per core of the machine.
20
+ So each run is `bun test --randomize --seed=<seed> --parallel=<workers>`, the mode CI runs in.
21
+ A `test` script that does not run `checks-test` gets the serial hunt.
20
22
 
21
23
  Under workers a seed fixes the order Bun hands out the files.
22
24
  Which worker takes each file is a race between the workers.
@@ -42,7 +44,8 @@ A run whose worker crashed gets a replay of that worker's files up to the crashe
42
44
  ## What it reads
43
45
 
44
46
  It reads bun's JUnit report of each run.
45
- It reads the worker count from the `test` script in `package.json`, as [checks-test-layout](checks-test-layout.md) validates that script, and hunts serially when the file is missing.
47
+ It reads the worker count from the `test` script in `package.json`, as [checks-test-layout](checks-test-layout.md) validates that script.
48
+ It hunts serially when the file is missing or its `test` script does not run `checks-test`.
46
49
  Under workers it reads the record its preload writes of which worker ran each file.
47
50
  For each replay it reads the order Bun ran its placeholder files.
48
51
  Under GitHub Actions it appends its summary to the file `GITHUB_STEP_SUMMARY` names, which is the job summary.
@@ -37,7 +37,7 @@ A control that needs selection off sets it on its own selector, such as `.drag-h
37
37
  | G1 correctness | `stylelint-config-recommended`, the stylelint team's rules for likely mistakes, such as `font-variant-numeric: oldstyle-num` for `oldstyle-nums` | on |
38
38
  | G2 conventions | `stylelint-config-standard`, which extends G1 with the stylelint team's notation and spacing rules | on |
39
39
  | G3 selector complexity | `selector-max-specificity` at `0,3,2` and `selector-max-compound-selectors` at 3 | on |
40
- | G4 design tokens | `color-no-hex`, `color-named: "never"`, `function-disallowed-list` over `rgb`, `rgba`, `hsl`, `hsla`, `hwb`, `lab`, `lch`, `oklab`, `oklch` and `color`, and `font-size` only as one `var(--…)` with no fallback, or `inherit` | off until `tokens` names the token files, and off in those files |
40
+ | G4 design tokens | `color-no-hex`, `color-named: "never"`, `function-disallowed-list` over `rgb`, `rgba`, `hsl`, `hsla`, `hwb`, `lab`, `lch`, `oklab`, `oklch` and `color`, `font-size` only as one `var(--…)` with no fallback, or `inherit`, `font` only as `inherit`, or as such a `var(--…)` for its size after any literal style, variant, weight or width, and before an optional line height and a family list of names, strings and `var(--…)`s, and `checks/font-size-from-tokens`, which refuses a `var(--…)` for a size or a family naming a custom property that any style block or attribute of the same file sets, unless the file sets it only to another such `var(--…)`, or for a family to a family list | off until `tokens` names the token files, and off in those files |
41
41
  | G5 Baseline target | `plugin/use-baseline` from `stylelint-plugin-use-baseline`, which judges each property, value, function, at-rule, selector and unit against the Baseline data, and passes a property inside an `@supports` block. It passes `@property` at every target, since a browser without `@property` treats the declaration as an unregistered custom property | on, at the target `baseline` sets |
42
42
  | G6 comments refused | `comment-word-disallowed-list` over `/^[\s\S]*$/`, which refuses every comment, including an empty `/* */` | on |
43
43
  | G7 language-free selectors | `selector-disallowed-list` over `/[^\x00-\x7F]/`, which refuses any selector holding a non-ASCII character, in attribute values, class names and ids alike | on |
@@ -74,6 +74,7 @@ These are outside what it decides:
74
74
 
75
75
  - A `transition` shorthand that names no property, such as `transition: 200ms`, which the browser reads as `all`.
76
76
  - A value it cannot see, such as `transition: var(--motion)`, a style a script sets, or a colour a build writes into CSS outside the declared inputs.
77
+ - A font size custom property that one stylesheet outside the token files sets and another reads.
77
78
  - `user-select: none` in a `style` attribute on `<body>`, which has no selector to judge.
78
79
  - A viewport that disables zoom, since stylelint reads only CSS.
79
80
  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.
@@ -44,8 +44,10 @@ A run on any ref but `refs/heads/main` files no issue and records no score, and
44
44
  ## What it reads
45
45
 
46
46
  It reads tonight's Stryker `mutation.json` report, when the mutation run left one, and any number of JUnit reports, such as the ones `checks-test --junit=<file>` keeps, as [checks-test](checks-test.md#arguments) says.
47
- It reads the previous night from the `night-run` artifact of the newest successful run of the same workflow on `main`.
48
- That artifact holds `night-run.json`, the scores the run recorded, and a newest run without it counts as no previous night.
47
+ It scans the newest 30 successful runs of the same workflow on `main` from newest to oldest.
48
+ It takes the first run whose `night-run` artifact still exists as the previous night.
49
+ That artifact holds `night-run.json`, the scores the run recorded.
50
+ When no scanned run holds the artifact it compares no score.
49
51
  A run on another branch never becomes the previous night.
50
52
  On `main` with a mutation report it writes tonight's scores to `reports/night-run.json`, and the workflow uploads that file as the `night-run` artifact.
51
53
  It reads the run from `GITHUB_SERVER_URL`, `GITHUB_REPOSITORY`, `GITHUB_RUN_ID`, `GITHUB_WORKFLOW_REF` and `GITHUB_REF`, so it runs only inside a GitHub Actions job.
@@ -26,7 +26,7 @@ It fails unless the repository holds this shape, and names the file and the path
26
26
  It may not import `$`, `spawn`, `spawnSync`, `connect`, `serve` or `listen` from `bun`, may not touch `Bun.$` or `Bun.spawn`, and may not call `fetch`.
27
27
  A test at the `e2e`, `live` or `pixel` level may do all of it, under `tests/quarantine/` as well.
28
28
  Helpers in `tests/lib/**` answer to the same rule, since an in-process test reaches them.
29
- - `scripts.test` is `checks-test`, optionally with `--parallel=<workers>`, which runs `bun test --randomize` as [checks-test](checks-test.md) says.
29
+ - `scripts.test` is `checks-test`, optionally with `--parallel=<workers>`, which runs `bun test --randomize --parallel=<workers>` as [checks-test](checks-test.md#parallel-runs) says.
30
30
  - `scripts.lint` runs this check, itself or through `checks-lint` called by its bare bin name.
31
31
  - `bunfig.toml` carries every `[test]` key of the shipped preset with the same value.
32
32
  `[test].pathIgnorePatterns` is the preset's `["**/tests/quarantine/**", "**/tests/live/**", "**/tests/pixel/**", "repos/**"]`, which the check pins itself, so the kit's own repository, whose bunfig is the preset, cannot drift it either.
@@ -8,7 +8,7 @@ audience: consumers
8
8
 
9
9
  ## What it checks
10
10
 
11
- It runs the default suite with `bun test --randomize` and reads Bun's JUnit report from that run.
11
+ It runs the default suite with `bun test --randomize --parallel=<workers>`, as [Parallel runs](#parallel-runs) says, and reads Bun's JUnit report from that run.
12
12
  It runs Bun with `CI=true`, so `test.only` fails the run in every environment.
13
13
  Bun exits zero when tests skip, so `checks-test` checks every skipped test against its source declaration.
14
14
  A test that `test.skip`, `test.skipIf`, `test.if`, `test.todo` or an enclosing `describe.skip` skips fails unless the test declares its reason.
@@ -76,7 +76,9 @@ Bun ignores both directories during the default run.
76
76
 
77
77
  ## Parallel runs
78
78
 
79
- A repository runs its test files in parallel by passing a worker count in its `test` script:
79
+ `checks-test` runs `bun test --randomize --parallel=<workers>`, so Bun hands each test file to one of that many worker processes.
80
+ Without `--parallel=<workers>`, the count is one worker per core, as `os.availableParallelism()` reports it, so a local run never oversubscribes the machine.
81
+ A repository sets its own count by passing `--parallel=<workers>` in its `test` script:
80
82
 
81
83
  ```json
82
84
  {
@@ -86,13 +88,12 @@ A repository runs its test files in parallel by passing a worker count in its `t
86
88
  }
87
89
  ```
88
90
 
89
- `checks-test --parallel=<workers>` runs `bun test --randomize --parallel=<workers>`, so Bun hands each test file to one of that many worker processes.
90
- Each file then runs in a fresh global, so a file cannot lean on state another file left behind.
91
- The count is a whole number from 1 up, and without the option the suite runs serially in one process.
91
+ The count is a whole number from 1 up.
92
+ Each file runs in a fresh global, so a file cannot lean on state another file left behind.
92
93
  Tests still run in random order, and `test.only` still fails the run.
93
- Bun merges every worker's results into the one JUnit report, so each skipped test and each todo is judged as in a serial run.
94
+ Bun merges every worker's results into the one JUnit report, so each skipped test and each todo is judged across the whole suite.
94
95
  A tier script takes the same option, as in `checks-test --tier=live --parallel=4`.
95
- [checks-flake](checks-flake.md#parallel-hunts) hunts with the worker count the `test` script sets.
96
+ [checks-flake](checks-flake.md#parallel-hunts) hunts with the same worker count.
96
97
 
97
98
  A file whose worker crashes, for example when a test calls `process.exit`, fails the run.
98
99
  Bun reports no result from that file, so its skips are not judged and its declarations are not reported as stale.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.48.0",
3
+ "version": "0.48.1",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -180,7 +180,7 @@
180
180
  },
181
181
  "scripts": {
182
182
  "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",
183
- "build": "bun build src/quality/effect-channel/index.ts --outdir dist/effect-channel --target node --format esm && bun build src/complexity/readability/index.ts --outdir dist/readability --target node --format esm && bun build src/quality/data-shape/index.ts --outdir dist/data-shape --target node --format esm && bun build src/testing/mutation-guard-plugin.ts src/testing/mutation-refusal.ts --outdir dist/mutation --target node --format esm && bun build src/quality/presets/oxlint.ts src/quality/presets/knip.ts src/quality/presets/dependency-cruiser.ts src/quality/presets/stylelint.ts --outdir dist/presets --target node --format esm && tsc -p tsconfig.presets.json && bun scripts/configs-write.ts && bun src/quality/effect-scope.ts && bun scripts/doc-templates-write.ts && bun src/delivery/changelog-write.ts && bun scripts/doc-blocks-write.ts",
183
+ "build": "bun build src/quality/effect-channel/index.ts --outdir dist/effect-channel --target node --format esm && bun build src/complexity/readability/index.ts --outdir dist/readability --target node --format esm && bun build src/quality/data-shape/index.ts --outdir dist/data-shape --target node --format esm && bun build src/testing/mutation-guard-plugin.ts src/testing/mutation-refusal.ts --outdir dist/mutation --target node --format esm && bun build src/quality/presets/oxlint.ts src/quality/presets/knip.ts src/quality/presets/dependency-cruiser.ts src/quality/presets/stylelint.ts --outdir dist/presets --target node --format esm --packages external && tsc -p tsconfig.presets.json && bun scripts/configs-write.ts && bun src/quality/effect-scope.ts && bun scripts/doc-templates-write.ts && bun src/delivery/changelog-write.ts && bun scripts/doc-blocks-write.ts",
184
184
  "lint": "oxlint && bun src/core/lint.ts",
185
185
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
186
186
  "test": "bun src/testing/test.ts",
@@ -1,5 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
- import type { Config } from "stylelint";
2
+ import type { Config, Plugin, RuleBase } from "stylelint";
3
3
 
4
4
  type Globs = readonly [string, ...string[]];
5
5
 
@@ -87,11 +87,83 @@ const HAS_IN_PSEUDO_CLASS = `(?::has\\(${PSEUDO_ARGUMENT}\\)|::?[\\w-]+\\(${PSEU
87
87
  // A compound of pseudo-classes alone, :scope and :not(.card) among them, can match the root element, so its :has() reads page state.
88
88
  const CLASSLESS_HAS = `/(?:^|[\\s>+~,])(?:${PSEUDO_CLASS})*${HAS_IN_PSEUDO_CLASS}(?:${PSEUDO_CLASS})*(?=[\\s>+~,]|$)/i`;
89
89
 
90
+ const VAR = "var\\(\\s*--[^\\s,()]+\\s*\\)";
91
+
92
+ const VAR_NAMES = /var\(\s*(--[^\s,()]+)\s*\)/gi;
93
+
94
+ const FONT_SIZE_VALUE = new RegExp(`^${VAR}$`, "i");
95
+
96
+ const FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH = "(?:normal|italic|oblique(?:\\s+-?[\\d.]+(?:deg|grad|rad|turn))?|small-caps|bold|bolder|lighter|[1-9]\\d*(?:\\.\\d+)?|(?:ultra-|extra-|semi-)?(?:condensed|expanded))";
97
+
98
+ const FONT_FAMILY = `(?:"[^"]*"|'[^']*'|${VAR}|-?[a-z_][\\w-]*(?:\\s+-?[a-z_][\\w-]*)*)`;
99
+
100
+ const FONT_FAMILIES = `${FONT_FAMILY}(?:\\s*,\\s*${FONT_FAMILY})*`;
101
+
102
+ const FONT_FAMILY_VALUE = new RegExp(`^${FONT_FAMILIES}$`, "i");
103
+
104
+ // A family name or a token name can hold a size word, so the token is required in the size slot rather than size words refused anywhere.
105
+ const FONT_VALUE = new RegExp(
106
+ `^(?:${FONT_STYLE_VARIANT_WEIGHT_OR_WIDTH}\\s+)*(?<size>${VAR})(?:\\s*\\/\\s*(?:${VAR}|[\\w.%-]+))?(?:\\s+(?<families>${FONT_FAMILIES}))?$`,
107
+ "i",
108
+ );
109
+
110
+ const FONT_SIZE_RULE = "checks/font-size-from-tokens";
111
+
112
+ function varNames(value: string): string[] {
113
+ return [...value.matchAll(VAR_NAMES)].map(([, name]) => name).filter((name) => name !== undefined);
114
+ }
115
+
116
+ type SizeCarrier = { readonly token: string; readonly setTo: RegExp };
117
+
118
+ // A var() before the size var can be a weight, which leaves a family var() free to hold the size.
119
+ function sizeCarriers(property: string, value: string): SizeCarrier[] {
120
+ const name = property.toLowerCase();
121
+ if (name === "font-size" && FONT_SIZE_VALUE.test(value)) return varNames(value).map((token) => ({ token, setTo: FONT_SIZE_VALUE }));
122
+ const font = name === "font" ? FONT_VALUE.exec(value)?.groups : undefined;
123
+ if (font?.size === undefined) return [];
124
+ return [
125
+ ...varNames(font.size).map((token) => ({ token, setTo: FONT_SIZE_VALUE })),
126
+ ...varNames(font.families ?? "").map((token) => ({ token, setTo: FONT_FAMILY_VALUE })),
127
+ ];
128
+ }
129
+
130
+ function fromTokens({ token, setTo }: SizeCarrier, declared: ReadonlyMap<string, readonly string[]>, seen: ReadonlySet<string>): boolean {
131
+ const values = declared.get(token);
132
+ if (values === undefined) return true;
133
+ return values.every(
134
+ (value) => setTo.test(value) && varNames(value).every((alias) => !seen.has(alias) && fromTokens({ token: alias, setTo }, declared, new Set([...seen, alias]))),
135
+ );
136
+ }
137
+
138
+ // postcss-html hands the rule one root per style block or attribute, so the file's declarations sit on the result's document.
139
+ const fontSizeFromTokens: RuleBase = () => async (root, result) => {
140
+ const { utils } = (await import("stylelint")).default;
141
+ const declared = new Map<string, string[]>();
142
+ result.root.walkDecls(/^--/, ({ prop, value }) => {
143
+ declared.set(prop, [...(declared.get(prop) ?? []), value]);
144
+ });
145
+ root.walkDecls((node) => {
146
+ for (const carrier of sizeCarriers(node.prop, node.value)) {
147
+ if (fromTokens(carrier, declared, new Set([carrier.token]))) continue;
148
+ utils.report({ ruleName: FONT_SIZE_RULE, result, node, word: carrier.token, message: `Expected "${carrier.token}" from the token files rather than set in this file` });
149
+ }
150
+ });
151
+ };
152
+
153
+ const FONT_SIZE_PLUGIN = {
154
+ ruleName: FONT_SIZE_RULE,
155
+ rule: Object.assign(fontSizeFromTokens, { ruleName: FONT_SIZE_RULE, messages: {} }),
156
+ } satisfies Plugin;
157
+
90
158
  const DESIGN_TOKENS = {
91
159
  "color-no-hex": true,
92
160
  "color-named": "never",
93
161
  "function-disallowed-list": COLOUR_FUNCTIONS.map((name) => `/^${name}$/i`),
94
- "declaration-property-value-allowed-list": { "/^font-size$/i": ["/^var\\(\\s*--[^\\s,()]+\\s*\\)$/i", "/^inherit$/i"] },
162
+ "declaration-property-value-allowed-list": {
163
+ "/^font-size$/i": [String(FONT_SIZE_VALUE), "/^inherit$/i"],
164
+ "/^font$/i": [String(FONT_VALUE), "/^inherit$/i"],
165
+ },
166
+ [FONT_SIZE_RULE]: true,
95
167
  } as const satisfies Rules;
96
168
 
97
169
  const REFUSED_BASELINE = '@avi2dg/checks/stylelint: set baseline to "widely", "newly" or a year such as 2024';
@@ -154,7 +226,7 @@ export function defineConfig({ baseline, tokens, bem, flatSelectors, noPageHas,
154
226
  return {
155
227
  ...rest,
156
228
  extends: [peer("stylelint-config-standard")],
157
- plugins: [peer("stylelint-plugin-use-baseline")],
229
+ plugins: [peer("stylelint-plugin-use-baseline"), FONT_SIZE_PLUGIN],
158
230
  rules: { ...merged, [BASELINE_RULE]: passProperty(merged[BASELINE_RULE]) },
159
231
  overrides: [
160
232
  { files: [...MARKUP], customSyntax: peer("postcss-html") },
@@ -3,7 +3,7 @@ import { Config, Console, Effect, FileSystem, Option, Path, Random, Schema } fro
3
3
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
4
  import { runMain, Usage } from "../core/main.ts";
5
5
  import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
6
- import { scriptOptions } from "./test.ts";
6
+ import { scriptOptions, workerCount } from "./test.ts";
7
7
 
8
8
  export type Run = {
9
9
  readonly seed: number;
@@ -90,7 +90,8 @@ const workersOf = Effect.fn("workersOf")(function* (root: string) {
90
90
  Effect.flatMap(decodeManifest),
91
91
  Effect.mapError((cause) => new ManifestError({ message: `cannot read ${file}: ${cause.message}` })),
92
92
  );
93
- return scriptOptions(scripts?.["test"])?.workers;
93
+ const options = scriptOptions(scripts?.["test"]);
94
+ return options === undefined ? undefined : workerCount(options.workers);
94
95
  });
95
96
 
96
97
  const assignmentOf = Effect.fn("assignmentOf")(function* (log: string) {
@@ -175,24 +175,26 @@ const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(NightRecor
175
175
 
176
176
  const unreadable = (source: string) => (cause: { readonly message: string }) => new NightRunFailure({ message: `cannot read ${source}: ${cause.message}` });
177
177
 
178
- // Only the newest successful run on main counts, so a branch dispatch never becomes the night a later run compares with.
178
+ // A skipped night records no artifact, so the newest successful run can carry no baseline.
179
179
  const previousNight = Effect.fn("previousNight")(
180
180
  function* ({ runs, workflow }: Actions) {
181
- const listed = ["run", "list", "--workflow", workflow, "--branch", DEFAULT_BRANCH, "--status", "success", "--limit", "1", "--json", "databaseId,event,createdAt"];
182
- const [newest] = yield* gh(listed).pipe(Effect.flatMap(decodeRuns), Effect.mapError(unreadable(`the runs of ${workflow}`)));
183
- if (newest === undefined) return Option.none<PreviousNight>();
184
- const run = newest.databaseId;
185
- const { artifacts } = yield* gh(["api", `${REPOSITORY}/actions/runs/${run}/artifacts?name=${ARTIFACT}`]).pipe(
186
- Effect.flatMap(decodeArtifacts),
187
- Effect.mapError(unreadable(`the artifacts of run ${run}`)),
188
- );
189
- if (!artifacts.some((artifact) => artifact.name === ARTIFACT && !artifact.expired)) return Option.none<PreviousNight>();
190
- const fs = yield* FileSystem.FileSystem;
191
- const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-night-run-" });
192
- yield* gh(["run", "download", String(run), "--name", ARTIFACT, "--dir", dir]);
193
- const file = (yield* Path.Path).join(dir, RECORD_NAME);
194
- const record = yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRecord), Effect.mapError(unreadable(`the ${ARTIFACT} artifact of run ${run}`)));
195
- return Option.some<PreviousNight>({ url: `${runs}/${run}`, record });
181
+ const listed = ["run", "list", "--workflow", workflow, "--branch", DEFAULT_BRANCH, "--status", "success", "--limit", "30", "--json", "databaseId,event,createdAt"];
182
+ const candidates = yield* gh(listed).pipe(Effect.flatMap(decodeRuns), Effect.mapError(unreadable(`the runs of ${workflow}`)));
183
+ for (const candidate of candidates) {
184
+ const run = candidate.databaseId;
185
+ const { artifacts } = yield* gh(["api", `${REPOSITORY}/actions/runs/${run}/artifacts?name=${ARTIFACT}`]).pipe(
186
+ Effect.flatMap(decodeArtifacts),
187
+ Effect.mapError(unreadable(`the artifacts of run ${run}`)),
188
+ );
189
+ if (!artifacts.some((artifact) => artifact.name === ARTIFACT && !artifact.expired)) continue;
190
+ const fs = yield* FileSystem.FileSystem;
191
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-night-run-" });
192
+ yield* gh(["run", "download", String(run), "--name", ARTIFACT, "--dir", dir]);
193
+ const file = (yield* Path.Path).join(dir, RECORD_NAME);
194
+ const record = yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRecord), Effect.mapError(unreadable(`the ${ARTIFACT} artifact of run ${run}`)));
195
+ return Option.some<PreviousNight>({ url: `${runs}/${run}`, record });
196
+ }
197
+ return Option.none<PreviousNight>();
196
198
  },
197
199
  Effect.scoped,
198
200
  );
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { availableParallelism } from "node:os";
2
3
  import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
3
4
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
5
  import { TEST_ENTRY_POINT } from "../core/gates.ts";
@@ -164,10 +165,13 @@ function tierArgs(tier: TestTier | undefined): readonly string[] {
164
165
  return [`./tests/${tier}`];
165
166
  }
166
167
 
168
+ export function workerCount(workers: number | undefined): number {
169
+ return workers ?? availableParallelism();
170
+ }
171
+
167
172
  const runSuite = Effect.fn("runSuite")(function* (outfile: string, { tier, workers }: TestOptions) {
168
173
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
169
- const parallelArgs = workers === undefined ? [] : [`--parallel=${workers}`];
170
- const args = ["test", "--randomize", ...parallelArgs, ...tierArgs(tier), ...reporterArgs(outfile)];
174
+ const args = ["test", "--randomize", `--parallel=${workerCount(workers)}`, ...tierArgs(tier), ...reporterArgs(outfile)];
171
175
  return yield* spawner.exitCode(
172
176
  ChildProcess.make(process.execPath, args, {
173
177
  stdin: "ignore",