@avi2dg/checks 0.47.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 +18 -0
- package/README.md +19 -0
- package/dist/presets/stylelint.d.ts +4 -1
- package/dist/presets/stylelint.js +92 -4
- package/docs/configs/native-settings.md +2 -2
- package/docs/design.md +2 -2
- package/docs/gates/checks-flake.md +6 -3
- package/docs/gates/checks-frontend-syntax.md +10 -2
- 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 +3 -2
- package/src/quality/presets/stylelint.ts +128 -5
- 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,24 @@
|
|
|
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
|
+
|
|
15
|
+
## 0.48.0
|
|
16
|
+
|
|
17
|
+
Released 2026-10-19.
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
- **quality:** add opt-in BEM, flat selector and page :has stylelint options [#179](https://github.com/avi2d/checks/pull/179)
|
|
22
|
+
|
|
5
23
|
## 0.47.0
|
|
6
24
|
|
|
7
25
|
Released 2026-10-19.
|
package/README.md
CHANGED
|
@@ -223,6 +223,25 @@ To move a repository to a newer release of the kit:
|
|
|
223
223
|
The repository's lockfile pins the kit, so a repository moves only when it runs these steps.
|
|
224
224
|
[CHANGELOG.md](CHANGELOG.md), shipped in the package, lists what each release changed.
|
|
225
225
|
|
|
226
|
+
## Turn on the CSS families a repository needs
|
|
227
|
+
|
|
228
|
+
A repository that declares `frontend-syntax.json` writes `stylelint.config.ts` with the families it needs:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { defineConfig } from "@avi2dg/checks/stylelint";
|
|
232
|
+
|
|
233
|
+
export default defineConfig({
|
|
234
|
+
baseline: "widely",
|
|
235
|
+
tokens: ["src/styles/tokens.css"],
|
|
236
|
+
bem: true,
|
|
237
|
+
flatSelectors: true,
|
|
238
|
+
noPageHas: true,
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
[checks-frontend-syntax](docs/gates/checks-frontend-syntax.md#what-it-reads) says what each key takes and which family it turns on.
|
|
243
|
+
`noPageHas` sees a `:has` nested under a page selector only together with `flatSelectors`, which refuses the nesting.
|
|
244
|
+
|
|
226
245
|
## Where things are
|
|
227
246
|
|
|
228
247
|
Every path is relative to the installed package, `node_modules/@avi2dg/checks/`.
|
|
@@ -4,7 +4,10 @@ export type BaselineTarget = "widely" | "newly" | number;
|
|
|
4
4
|
export type ChecksStylelintConfig = Omit<Config, "extends" | "plugins"> & {
|
|
5
5
|
readonly baseline: BaselineTarget;
|
|
6
6
|
readonly tokens: Globs | false;
|
|
7
|
+
readonly bem?: boolean;
|
|
8
|
+
readonly flatSelectors?: boolean;
|
|
9
|
+
readonly noPageHas?: boolean;
|
|
7
10
|
};
|
|
8
11
|
export declare const MARKUP: readonly ["**/*.html", "**/*.astro", "**/*.vue", "**/*.svelte"];
|
|
9
|
-
export declare function defineConfig({ baseline, tokens, rules, overrides, ...rest }: ChecksStylelintConfig): Config;
|
|
12
|
+
export declare function defineConfig({ baseline, tokens, bem, flatSelectors, noPageHas, rules, overrides, ...rest }: ChecksStylelintConfig): Config;
|
|
10
13
|
export {};
|
|
@@ -20,11 +20,92 @@ var LANGUAGE_FREE_SELECTORS = {
|
|
|
20
20
|
"selector-disallowed-list": ["/[^\\x00-\\x7F]/"]
|
|
21
21
|
};
|
|
22
22
|
var COLOUR_FUNCTIONS = ["rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color"];
|
|
23
|
+
var BEM_NAMES = {
|
|
24
|
+
"selector-class-pattern": "^[a-z][a-z0-9]*(__[a-z0-9]+(-[a-z0-9]+)*)?(--[a-z0-9]+(-[a-z0-9]+)*)?$"
|
|
25
|
+
};
|
|
26
|
+
var POSITIONAL_PSEUDO_CLASSES = [
|
|
27
|
+
"first-child",
|
|
28
|
+
"last-child",
|
|
29
|
+
"only-child",
|
|
30
|
+
"nth-child",
|
|
31
|
+
"nth-last-child",
|
|
32
|
+
"first-of-type",
|
|
33
|
+
"last-of-type",
|
|
34
|
+
"only-of-type",
|
|
35
|
+
"nth-of-type",
|
|
36
|
+
"nth-last-of-type"
|
|
37
|
+
];
|
|
38
|
+
var FLAT_SELECTORS = {
|
|
39
|
+
"selector-max-type": 0,
|
|
40
|
+
"selector-max-id": 0,
|
|
41
|
+
"selector-max-universal": 0,
|
|
42
|
+
"selector-max-compound-selectors": 2,
|
|
43
|
+
"max-nesting-depth": 0,
|
|
44
|
+
"selector-max-attribute": 0,
|
|
45
|
+
"selector-pseudo-class-disallowed-list": POSITIONAL_PSEUDO_CLASSES
|
|
46
|
+
};
|
|
47
|
+
var PAGE_HAS = "/^(?=[\\s\\S]*:has\\()(?=[\\s\\S]*(?:(?:^|[\\s>+~,([])(?:html|body|main)|:root)(?![\\w-]))[\\s\\S]*$/i";
|
|
48
|
+
var PSEUDO_ARGUMENT = "(?:[^()]|\\([^()]*\\))*";
|
|
49
|
+
var PSEUDO_CLASS = `::?[\\w-]+(?:\\(${PSEUDO_ARGUMENT}\\))?`;
|
|
50
|
+
var HAS_IN_PSEUDO_CLASS = `(?::has\\(${PSEUDO_ARGUMENT}\\)|::?[\\w-]+\\(${PSEUDO_ARGUMENT}:has\\([^()]*\\)${PSEUDO_ARGUMENT}\\))`;
|
|
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
|
+
};
|
|
23
100
|
var DESIGN_TOKENS = {
|
|
24
101
|
"color-no-hex": true,
|
|
25
102
|
"color-named": "never",
|
|
26
103
|
"function-disallowed-list": COLOUR_FUNCTIONS.map((name) => `/^${name}$/i`),
|
|
27
|
-
"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
|
|
28
109
|
};
|
|
29
110
|
var REFUSED_BASELINE = '@avi2dg/checks/stylelint: set baseline to "widely", "newly" or a year such as 2024';
|
|
30
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";
|
|
@@ -61,22 +142,29 @@ function designTokens(files) {
|
|
|
61
142
|
const off = Object.fromEntries(Object.keys(DESIGN_TOKENS).map((rule) => [rule, null]));
|
|
62
143
|
return { rules: DESIGN_TOKENS, overrides: [{ files: [...files], rules: off }] };
|
|
63
144
|
}
|
|
64
|
-
function
|
|
145
|
+
function disallowedSelectors(noPageHas) {
|
|
146
|
+
if (noPageHas === true)
|
|
147
|
+
return { "selector-disallowed-list": [[...LANGUAGE_FREE_SELECTORS["selector-disallowed-list"], PAGE_HAS, CLASSLESS_HAS], { splitList: true }] };
|
|
148
|
+
return LANGUAGE_FREE_SELECTORS;
|
|
149
|
+
}
|
|
150
|
+
function defineConfig({ baseline, tokens, bem, flatSelectors, noPageHas, rules, overrides = [], ...rest }) {
|
|
65
151
|
const available = baselineTarget(baseline);
|
|
66
152
|
const tokenRules = designTokens(tokenFiles(tokens));
|
|
67
153
|
const merged = {
|
|
68
154
|
...CONVENTION_CHANGES,
|
|
69
155
|
...SELECTOR_COMPLEXITY,
|
|
70
156
|
...COMMENT_REFUSAL,
|
|
71
|
-
...
|
|
157
|
+
...disallowedSelectors(noPageHas),
|
|
72
158
|
[BASELINE_RULE]: [true, { available }],
|
|
73
159
|
...tokenRules.rules,
|
|
160
|
+
...bem === true ? BEM_NAMES : null,
|
|
161
|
+
...flatSelectors === true ? FLAT_SELECTORS : null,
|
|
74
162
|
...rules
|
|
75
163
|
};
|
|
76
164
|
return {
|
|
77
165
|
...rest,
|
|
78
166
|
extends: [peer("stylelint-config-standard")],
|
|
79
|
-
plugins: [peer("stylelint-plugin-use-baseline")],
|
|
167
|
+
plugins: [peer("stylelint-plugin-use-baseline"), FONT_SIZE_PLUGIN],
|
|
80
168
|
rules: { ...merged, [BASELINE_RULE]: passProperty(merged[BASELINE_RULE]) },
|
|
81
169
|
overrides: [
|
|
82
170
|
{ files: [...MARKUP], customSyntax: peer("postcss-html") },
|
|
@@ -17,7 +17,7 @@ A consuming repository puts each setting in the file its tool reads, and no mani
|
|
|
17
17
|
| `exports-baseline.json` | The existing unused exports and types, per file, kind and name | `checks-exports` |
|
|
18
18
|
| `.jscpd.json` | The `path` and `ignore` globs of the files repetition is measured in | jscpd and `checks-repetition` |
|
|
19
19
|
| `knip.config.ts` | The `entry` globs Knip traces unreferenced files from | Knip, `checks-unused` and `checks-exports` |
|
|
20
|
-
| `stylelint.config.ts` | The Baseline target, the token files, and any stylelint rule the repository sets over the kit's families | `checks-frontend-syntax` |
|
|
20
|
+
| `stylelint.config.ts` | The Baseline target, the token files, the CSS families the repository turns on, and any stylelint rule the repository sets over the kit's families | `checks-frontend-syntax` |
|
|
21
21
|
| `dependency-cruiser.config.ts` | Entry points nothing imports, paths that may import dev dependencies, and import boundaries, when the kit's defaults do not fit | `checks-imports` |
|
|
22
22
|
| `tsconfig.json` | Compiler options, and the Effect language service plugin whose `overrides` and kit severities `checks-effect-scope` writes, keeping every other key | TypeScript and Effect language service |
|
|
23
23
|
| `package.json` | `scripts` with the `checks-vendor` arguments in `prepare`, `author` and `contributors` | Bun, `checks-commit-identity` and `checks-vendor` |
|
|
@@ -41,7 +41,7 @@ The builder throws when the tool loads such a config anyway.
|
|
|
41
41
|
| `@avi2dg/checks/oxlint` | `effect` | `extends`, `plugins`, `jsPlugins` and `categories` |
|
|
42
42
|
| `@avi2dg/checks/knip` | `entry`, where `[]` means the package scripts and tests name every entry | `include` |
|
|
43
43
|
| `@avi2dg/checks/dependency-cruiser` | nothing | `extends` |
|
|
44
|
-
| `@avi2dg/checks/stylelint` | `baseline` and `tokens`, where `false` leaves the design token rules off | `extends` and `plugins` |
|
|
44
|
+
| `@avi2dg/checks/stylelint` | `baseline` and `tokens`, where `false` leaves the design token rules off, with `bem`, `flatSelectors` and `noPageHas` turning on their families | `extends` and `plugins` |
|
|
45
45
|
|
|
46
46
|
A repository whose sources are Effect programs writes `oxlint.config.ts`:
|
|
47
47
|
|
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,16 +37,20 @@ 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 |
|
|
44
|
+
| G8 BEM names | `selector-class-pattern` over one lowercase block with kebab `__element` and `--modifier` parts | off until `bem` is `true` |
|
|
45
|
+
| G9 class-only flat selectors | `selector-max-type` at 0, `selector-max-id` at 0, `selector-max-universal` at 0, `selector-max-compound-selectors` at 2, `max-nesting-depth` at 0, `selector-max-attribute` at 0, and `selector-pseudo-class-disallowed-list` over the positional pseudo-classes, from `:first-child` to `:nth-last-of-type` | off until `flatSelectors` is `true` |
|
|
46
|
+
| G10 page-wide has refused | `selector-disallowed-list` with `splitList` over a pattern that refuses a `:has` beside `html`, `body`, `main` or `:root` in one selector of a list, across line breaks in the selector, and one that refuses a `:has` in a compound of pseudo-classes alone, such as `:has(.hero)`, `:scope:has(.hero)` or `:not(.card):has(.hero)`, whose subject can be the root. A nested `:has` escapes it unless G9 refuses the nesting | off until `noPageHas` is `true` |
|
|
44
47
|
|
|
45
|
-
The builder changes
|
|
48
|
+
The builder changes four of the shared config's rules:
|
|
46
49
|
|
|
47
50
|
- `no-descending-specificity` is off, since it compares only the last compound selector and has no access to the DOM.
|
|
48
51
|
It reports components that never match one element.
|
|
49
52
|
- `property-no-vendor-prefix` keeps reporting without a fix, and passes `-webkit-user-select`, since its fix deletes the prefix Safari still needs.
|
|
53
|
+
- `selector-class-pattern` holds BEM names while `bem` is `true`, since the shared kebab case refuses `__` and `--`.
|
|
50
54
|
- In an `.html` or `.astro` file, `declaration-block-single-line-max-declarations` is off, since a `style` attribute is one line.
|
|
51
55
|
|
|
52
56
|
The comment rule refuses every comment but a sourcemap `/*# */` one, which stylelint passes to keep the map.
|
|
@@ -70,6 +74,7 @@ These are outside what it decides:
|
|
|
70
74
|
|
|
71
75
|
- A `transition` shorthand that names no property, such as `transition: 200ms`, which the browser reads as `all`.
|
|
72
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.
|
|
73
78
|
- `user-select: none` in a `style` attribute on `<body>`, which has no selector to judge.
|
|
74
79
|
- A viewport that disables zoom, since stylelint reads only CSS.
|
|
75
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.
|
|
@@ -108,6 +113,9 @@ The builder throws when the gate loads such a config anyway.
|
|
|
108
113
|
| --- | --- |
|
|
109
114
|
| `baseline` | `"widely"`, `"newly"` or a year such as `2024`, which allows what became Baseline newly available in that year or earlier |
|
|
110
115
|
| `tokens` | the globs of the files that declare the design tokens, which turns G4 on in every other file, or `false` |
|
|
116
|
+
| `bem` | `true` to turn G8 on, left out to leave it off |
|
|
117
|
+
| `flatSelectors` | `true` to turn G9 on, left out to leave it off |
|
|
118
|
+
| `noPageHas` | `true` to turn G10 on, left out to leave it off |
|
|
111
119
|
|
|
112
120
|
`rules`, `overrides`, `ignoreFiles` and the other keys mean what they mean in stylelint's own docs, and land after the kit's.
|
|
113
121
|
An accepted exception lives there rather than in a comment, such as a property the Baseline rule passes:
|
|
@@ -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.
|
|
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,10 +180,11 @@
|
|
|
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",
|
|
187
|
+
"test:fast": "bun scripts/test-fast.ts",
|
|
187
188
|
"test:unit": "bun src/testing/test.ts --tier=unit",
|
|
188
189
|
"test:e2e": "bun src/testing/test.ts --tier=e2e --parallel=4",
|
|
189
190
|
"mutate": "bunx stryker run",
|
|
@@ -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
|
|
|
@@ -8,6 +8,9 @@ export type BaselineTarget = "widely" | "newly" | number;
|
|
|
8
8
|
export type ChecksStylelintConfig = Omit<Config, "extends" | "plugins"> & {
|
|
9
9
|
readonly baseline: BaselineTarget;
|
|
10
10
|
readonly tokens: Globs | false;
|
|
11
|
+
readonly bem?: boolean;
|
|
12
|
+
readonly flatSelectors?: boolean;
|
|
13
|
+
readonly noPageHas?: boolean;
|
|
11
14
|
};
|
|
12
15
|
|
|
13
16
|
type Rules = NonNullable<Config["rules"]>;
|
|
@@ -43,11 +46,124 @@ const LANGUAGE_FREE_SELECTORS = {
|
|
|
43
46
|
|
|
44
47
|
const COLOUR_FUNCTIONS = ["rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color"] as const;
|
|
45
48
|
|
|
49
|
+
// selector-class-pattern builds a RegExp from the string itself, so slashes would match literal slashes.
|
|
50
|
+
const BEM_NAMES = {
|
|
51
|
+
"selector-class-pattern": "^[a-z][a-z0-9]*(__[a-z0-9]+(-[a-z0-9]+)*)?(--[a-z0-9]+(-[a-z0-9]+)*)?$",
|
|
52
|
+
} as const satisfies Rules;
|
|
53
|
+
|
|
54
|
+
const POSITIONAL_PSEUDO_CLASSES = [
|
|
55
|
+
"first-child",
|
|
56
|
+
"last-child",
|
|
57
|
+
"only-child",
|
|
58
|
+
"nth-child",
|
|
59
|
+
"nth-last-child",
|
|
60
|
+
"first-of-type",
|
|
61
|
+
"last-of-type",
|
|
62
|
+
"only-of-type",
|
|
63
|
+
"nth-of-type",
|
|
64
|
+
"nth-last-of-type",
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
const FLAT_SELECTORS = {
|
|
68
|
+
"selector-max-type": 0,
|
|
69
|
+
"selector-max-id": 0,
|
|
70
|
+
"selector-max-universal": 0,
|
|
71
|
+
"selector-max-compound-selectors": 2,
|
|
72
|
+
"max-nesting-depth": 0,
|
|
73
|
+
"selector-max-attribute": 0,
|
|
74
|
+
"selector-pseudo-class-disallowed-list": POSITIONAL_PSEUDO_CLASSES,
|
|
75
|
+
} as const satisfies Rules;
|
|
76
|
+
|
|
77
|
+
// The ban needs both a page element and :has( in one selector, in either order, and a class such as .block-body holds neither.
|
|
78
|
+
// The rule tests the raw selector, newlines included, so [\s\S] stands where . would stop at a line break.
|
|
79
|
+
const PAGE_HAS = "/^(?=[\\s\\S]*:has\\()(?=[\\s\\S]*(?:(?:^|[\\s>+~,([])(?:html|body|main)|:root)(?![\\w-]))[\\s\\S]*$/i";
|
|
80
|
+
|
|
81
|
+
const PSEUDO_ARGUMENT = "(?:[^()]|\\([^()]*\\))*";
|
|
82
|
+
|
|
83
|
+
const PSEUDO_CLASS = `::?[\\w-]+(?:\\(${PSEUDO_ARGUMENT}\\))?`;
|
|
84
|
+
|
|
85
|
+
const HAS_IN_PSEUDO_CLASS = `(?::has\\(${PSEUDO_ARGUMENT}\\)|::?[\\w-]+\\(${PSEUDO_ARGUMENT}:has\\([^()]*\\)${PSEUDO_ARGUMENT}\\))`;
|
|
86
|
+
|
|
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
|
+
const CLASSLESS_HAS = `/(?:^|[\\s>+~,])(?:${PSEUDO_CLASS})*${HAS_IN_PSEUDO_CLASS}(?:${PSEUDO_CLASS})*(?=[\\s>+~,]|$)/i`;
|
|
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
|
+
|
|
46
158
|
const DESIGN_TOKENS = {
|
|
47
159
|
"color-no-hex": true,
|
|
48
160
|
"color-named": "never",
|
|
49
161
|
"function-disallowed-list": COLOUR_FUNCTIONS.map((name) => `/^${name}$/i`),
|
|
50
|
-
"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,
|
|
51
167
|
} as const satisfies Rules;
|
|
52
168
|
|
|
53
169
|
const REFUSED_BASELINE = '@avi2dg/checks/stylelint: set baseline to "widely", "newly" or a year such as 2024';
|
|
@@ -87,23 +203,30 @@ function designTokens(files: readonly string[]): Required<Pick<Config, "rules" |
|
|
|
87
203
|
return { rules: DESIGN_TOKENS, overrides: [{ files: [...files], rules: off }] };
|
|
88
204
|
}
|
|
89
205
|
|
|
206
|
+
function disallowedSelectors(noPageHas: boolean | undefined): Rules {
|
|
207
|
+
if (noPageHas === true) return { "selector-disallowed-list": [[...LANGUAGE_FREE_SELECTORS["selector-disallowed-list"], PAGE_HAS, CLASSLESS_HAS], { splitList: true }] };
|
|
208
|
+
return LANGUAGE_FREE_SELECTORS;
|
|
209
|
+
}
|
|
210
|
+
|
|
90
211
|
// The types refuse a missing or malformed baseline or tokens, and the guards repeat it for a config no typecheck reads.
|
|
91
|
-
export function defineConfig({ baseline, tokens, rules, overrides = [], ...rest }: ChecksStylelintConfig): Config {
|
|
212
|
+
export function defineConfig({ baseline, tokens, bem, flatSelectors, noPageHas, rules, overrides = [], ...rest }: ChecksStylelintConfig): Config {
|
|
92
213
|
const available = baselineTarget(baseline);
|
|
93
214
|
const tokenRules = designTokens(tokenFiles(tokens));
|
|
94
215
|
const merged: Rules = {
|
|
95
216
|
...CONVENTION_CHANGES,
|
|
96
217
|
...SELECTOR_COMPLEXITY,
|
|
97
218
|
...COMMENT_REFUSAL,
|
|
98
|
-
...
|
|
219
|
+
...disallowedSelectors(noPageHas),
|
|
99
220
|
[BASELINE_RULE]: [true, { available }],
|
|
100
221
|
...tokenRules.rules,
|
|
222
|
+
...(bem === true ? BEM_NAMES : null),
|
|
223
|
+
...(flatSelectors === true ? FLAT_SELECTORS : null),
|
|
101
224
|
...rules,
|
|
102
225
|
};
|
|
103
226
|
return {
|
|
104
227
|
...rest,
|
|
105
228
|
extends: [peer("stylelint-config-standard")],
|
|
106
|
-
plugins: [peer("stylelint-plugin-use-baseline")],
|
|
229
|
+
plugins: [peer("stylelint-plugin-use-baseline"), FONT_SIZE_PLUGIN],
|
|
107
230
|
rules: { ...merged, [BASELINE_RULE]: passProperty(merged[BASELINE_RULE]) },
|
|
108
231
|
overrides: [
|
|
109
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",
|