@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 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": { "/^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
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 defineConfig({ baseline, tokens, rules, overrides = [], ...rest }) {
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
- ...LANGUAGE_FREE_SELECTORS,
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 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,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`, 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 |
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 three of the shared config's rules:
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 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.47.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": { "/^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,
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
- ...LANGUAGE_FREE_SELECTORS,
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") },
@@ -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",