@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 +10 -0
- package/dist/presets/stylelint.js +54 -2
- package/docs/design.md +2 -2
- package/docs/gates/checks-flake.md +6 -3
- package/docs/gates/checks-frontend-syntax.md +2 -1
- package/docs/gates/checks-night-run.md +4 -2
- package/docs/gates/checks-test-layout.md +1 -1
- package/docs/gates/checks-test.md +8 -7
- package/package.json +2 -2
- package/src/quality/presets/stylelint.ts +75 -3
- package/src/testing/flake.ts +3 -2
- package/src/testing/night-run.ts +18 -16
- package/src/testing/test.ts +6 -2
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": {
|
|
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
|
|
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
|
|
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
|
|
19
|
-
|
|
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
|
|
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`,
|
|
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
|
|
48
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
Each file
|
|
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
|
|
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
|
|
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.
|
|
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": {
|
|
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") },
|
package/src/testing/flake.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/testing/night-run.ts
CHANGED
|
@@ -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
|
-
//
|
|
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", "
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
);
|
package/src/testing/test.ts
CHANGED
|
@@ -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
|
|
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",
|